diff --git a/internal/app/application_overrides.go b/internal/app/application_overrides.go index d4bfc86a..b5f05b1d 100644 --- a/internal/app/application_overrides.go +++ b/internal/app/application_overrides.go @@ -259,9 +259,11 @@ func (a *application) observe_override(next screen.Snapshot) (applicationoverrid } frame := selected.adapter.Observe(next) freshShell := selected.shell.Observe(next) - if !frame.Recognized { + // A fresh shell prompt ends the override even if an adapter still recognizes + // stale application content or its bridge has not reported closure yet. + selected.exitPending = selected.exitPending || freshShell + if !frame.Recognized || selected.exitPending { selected.recognized = false - selected.exitPending = selected.exitPending || freshShell if a.overrideCurrent == selected { if !a.restore_override(func() {}) { return applicationoverrides.Frame{}, false diff --git a/internal/app/application_overrides_test.go b/internal/app/application_overrides_test.go index ad04c5a1..eb96135c 100644 --- a/internal/app/application_overrides_test.go +++ b/internal/app/application_overrides_test.go @@ -5,6 +5,7 @@ package app import ( "errors" + "fmt" "os" "path/filepath" "reflect" @@ -149,6 +150,45 @@ func TestExitWaitsForPendingSpeechChangeAndRestoresBeforeShellOutput(t *testing. } } +func TestCodexShellExitWithoutBridgeClosure(t *testing.T) { + for _, pendingSpeech := range []bool{false, true} { + t.Run(fmt.Sprint(pendingSpeech), func(t *testing.T) { + a, voice, _ := selection_application(t, true) + a.prepareSpeech = func(config.Speech) (speech.Transport, error) { return &preparedVoice{}, nil } + composer := override_screen(1, "› hello") + composer.X = 7 + override_read(t, a, composer) + a.select_override("codex") + override_read(t, a, composer) + baseline := a.overrideCurrent.underlying.settings.live + if pendingSpeech { + if response := request_selection(t, a, "SET speech#voice=Temporary;speech#module=fixture"); response != "" { + t.Fatal(response) + } + } + shell := override_screen(1, "[Username@host directory] $") + copy(shell.Line(20), []rune("› previous composer")) + shell.X = len([]rune(shell.Text(23))) + 1 + override_read(t, a, shell) + if pendingSpeech { + finish_selection(t, a) + override_read(t, a, shell) + } + if a.overrideCurrent != nil || a.overrides[1] != nil || !reflect.DeepEqual(a.settings.live, baseline) { + t.Fatal("shell retained Codex settings without a bridge close event") + } + next := shell + next.X-- + start := len(voice.messages) + a.engine.Note_input(true, false, false) + override_read(t, a, next) + if len(voice.messages) == start { + t.Fatal("shell arrow navigation remained silent") + } + }) + } +} + func override_read(t *testing.T, a *application, snapshot screen.Snapshot) { t.Helper() a.source.(*fakeSource).snapshot = snapshot diff --git a/tests/native_codex.py b/tests/native_codex.py index 72525ab5..30e5ce79 100644 --- a/tests/native_codex.py +++ b/tests/native_codex.py @@ -21,6 +21,9 @@ def fixture_shell(): data = os.read(0, 4096) if not data: return + if data == b"\x04": + os.write(1, b"\x1b[24;1H\x1b[2K[Username@host directory] $ ") + continue os.write(1, data) @@ -128,13 +131,17 @@ def main(): bridge.write_status(statusPath, "done") time.sleep(0.4) action("Insert+0", "Codex finished") - action("Insert+a", "Application Codex") - action("d", "Default") - action("Return", "Default settings restored") + # Leave old Codex/composer text visible and keep the bridge alive: + # the fresh shell prompt itself must restore ordinary behavior. + action("ctrl+d", "Default settings restored") + keyboard.key("h", "e", "l", "l", "o") + action("Left", "o") + action("Left", "l") + action("Right", "o") keyboard.key("Insert+q") wait_for(lambda: (root / "finished").exists(), "reader exit") assert not server.errors, server.errors - print("PASS: real private PTY/override/socket bridge, composer Left/Right feedback, baseline history, completed messages only, one announcement, long replies, replay and status") + print("PASS: real private PTY/override/socket bridge, composer Left/Right feedback, baseline history, completed messages only, one announcement, long replies, replay, status, shell exit without bridge closure and restored shell arrows") except BaseException: print("Recent private speech:", server.events[-15:], file=sys.stderr) if (root / "status.json").exists():