diff --git a/context/knowledge/gotchas/pty-terminal.md b/context/knowledge/gotchas/pty-terminal.md index b8c46cce..e5e8f606 100644 --- a/context/knowledge/gotchas/pty-terminal.md +++ b/context/knowledge/gotchas/pty-terminal.md @@ -7,6 +7,7 @@ - **Single-reader-tee pattern is critical.** Two goroutines reading the same fd causes data loss. - **`AddWriter` must replay before registering.** Register first → live bytes arrive before replay → duplicate data → rendering corruption. - **x/vt `SafeEmulator` hangs on terminal query sequences.** Use `newDrainedEmulator()` which starts `go io.Copy(io.Discard, emu)` to drain the response pipe. Never use bare `xvt.NewSafeEmulator()` in tui. +- **Discarding query responses silently degrades color-aware agent CLIs — the pane's LIVE emulator now answers instead.** `NewDrainedEmulator`'s `io.Discard` drain (above) avoided a hang but also meant an agent process's `OSC 10`/`OSC 11` ("what's your fg/bg color?") queries got no reply. Codex sends this on every startup and, per a captured real session, never draws its composer/placeholder highlight background in any occurrence when the query goes unanswered — it only ever colors the separately-unconditional submitted-prompt history box (bare `\x1b[7m`). Fix (`NewLiveEmulator`, used only by `newTrackedEmulatorWithCallback`, the pane's live-emulator path): set `SetBackgroundColor`/`SetForegroundColor` to an assumed dark-terminal default (Argus itself never assumes a background — chrome uses `tcell.ColorDefault` throughout — so this is a best-effort guess, not a queried truth), and forward the drained response bytes into `TerminalAdapter.WriteInput` (looked up fresh under `tp.mu` per call, since the session can change across attach/detach) instead of `io.Discard`. Replay/preview emulators (`newTrackedReplayEmulatorWithCallback`, `previewvt.go`) deliberately keep the discard-only behavior — they reconstruct historical output for a process that may not be live, so forwarding would be meaningless or cross-wired. - **x/vt can panic on replay from differently-sized terminals.** Use `safeEmuWrite()` which wraps with `recover()`. - **Cursor rendering respects `CursorVisibility` callback.** Tracked via `cursorVisible` field, updated by x/vt callback. Defaults to `false` on emulator creation. - **Plugin terminalpane cursor: force emulator to hidden state in `New()` or the first `\e[?25h` is a silent no-op.** `xvt.SafeEmulator` starts with cursor visible (`Hidden=false`). The `CursorVisibility` callback only fires on state CHANGES (`changed := s.cur.Hidden != hidden`). So if you initialize tracking to `false` (via `atomic.Bool` zero value) but leave the emulator in the visible state, the plugin's first `\e[?25h` hits the emulator already visible → no change → callback doesn't fire → cursor stays hidden in tracking. Fix: write `\x1b[?25l` directly to the emulator in `New()` BEFORE starting the consumer goroutine; this changes the emulator's state from visible to hidden, fires the callback (sets tracking to `false`), and ensures the first `\e[?25h` triggers the callback correctly. The task terminal uses a different fix (calls the callback manually with `false` and relies on agents always sending `\e[?25l` early) — both approaches are valid for their respective contexts. diff --git a/context/knowledge/index.md b/context/knowledge/index.md index 64740eec..7a767d7e 100644 --- a/context/knowledge/index.md +++ b/context/knowledge/index.md @@ -5,7 +5,7 @@ Non-obvious invariants and gotchas, split by topic. Read the relevant file when | File | Topic | Bullets | | --- | --- | --- | | [gotchas/daemon-rpc.md](gotchas/daemon-rpc.md) | Daemon lifecycle, RPC timeouts, reconciliation races, session resume, Claude /clear recapture, binary staleness (SHA-256 content hash, not mtime), self-update, launchd auto-start + PATH, stream Since offset, paste-boundary flush, *.test fork-bomb backstop, singleton flock, PR poller (eligibility, terminal-state skip, batched per-repo graphql w/ alias-safe ids + chunked keep-stale), evidence-based completion (ExitInfo.CleanExit predicate; reconcile→InReview never Complete), hera worker finish policy (BUG-050 RollHeraWorkerToReview), startup hera-binding reconciliation, session-supervisor P1–P4 (dark PTY-owner, daemon-as-client behind cfg.Supervisor.Enabled, re-attach on bounce, default ON + in-process rollback, #707 cache-vs-EOF relay race), callWithTimeout nil-rpc guard, TUI supervisor restart, go-install skew (doctor restart-vs-path-divergence, supervisor-checked-on-auto-start, ProtocolVersion 2→3 old-supervisor-unknown, double-confirm supervisor restart), revive-restores-in_progress (BUG-B ReviveHeraWorkerToInProgress, inverse of RollHeraWorkerToReview), host-suspend watchdog (ARGUS_HOST_SUSPENDED advisory note — wall-clock gap>3m between 30s ticks, unconditional not Hera-gated, sibling of sendBounceSignals, one-shot no-dedup baseline-before-loop, monotonic-strip required, advisory-only no state mutation), Claude Code's own background-session supervisor (orphaned-worker root cause: single-PID SIGTERM can never reach a session Claude Code itself detached to its per-user supervisor; `claude agents`/`claude stop` detection+fix SHIPPED via internal/claudeagents + Runner.Stop fire-and-forget reap, not a signal-scoping bug), doctor Stop-hook registration check (detect-missing-coord-hook: REGISTERED/NOT REGISTERED/UNKNOWN, advisory-only, never gates the binary-coherence exit code), resume-time session-ID recapture (agent.RefreshResumeSessionID mirrors the exit hook because hera workers idle/StreamLost never reach captureSessionIDPostExit; Claude-only; wired at reattachSupervised orphans + TUI startSession + REST resume/restart; idempotent, never blanks/fabricates) | 107 | -| [gotchas/pty-terminal.md](gotchas/pty-terminal.md) | PTY sizing, x/vt emulator, ring buffer, replay cache, paint cache, lazyScreen, test concurrency, ESC-boundary alignment, live rebuild from log tail, monotonic firstByteOffset, scrollOffset clamp, waitLoop close-after-drain order, rerender gates (unchanged-cols, cache invalidation, blocked-on-prompt), OSC 0x9C-in-UTF8 strip filter, persistent preview emulator (PreviewVT reuse-via-RIS), plugin terminalpane cursor sync, alt-screen keyboard-scroll + scroll-mode-entry suppression (BUG-031, not just the wheel) | 60 | +| [gotchas/pty-terminal.md](gotchas/pty-terminal.md) | PTY sizing, x/vt emulator, ring buffer, replay cache, paint cache, lazyScreen, test concurrency, ESC-boundary alignment, live rebuild from log tail, monotonic firstByteOffset, scrollOffset clamp, waitLoop close-after-drain order, rerender gates (unchanged-cols, cache invalidation, blocked-on-prompt), OSC 0x9C-in-UTF8 strip filter, persistent preview emulator (PreviewVT reuse-via-RIS), plugin terminalpane cursor sync, alt-screen keyboard-scroll + scroll-mode-entry suppression (BUG-031, not just the wheel), live-emulator OSC 10/11 query answering (NewLiveEmulator forwards to TerminalAdapter.WriteInput instead of discarding, replay/preview stay discard-only) | 61 | | [gotchas/ui-threading.md](gotchas/ui-threading.md) | tview thread safety, tick-goroutine rules, lazyScreen fill invariant, paste/input batching, tmux UX-tearing post-mortem (no Sync; 3 legit repair callsites), OnBranchChange log-only contract, EventFocus drift recovery, stderr/stdout-after-Init fd 2 guards, status-bar notice auto-expire (15s TTL, lazy revert via 1s tick, no Sync/timer) | 27 | | [gotchas/ci-gates.md](gotchas/ci-gates.md) | `make pre-pr` per-gate failure recipes (fmt-check, test-cover-gate floor, lint-pr new-from-rev, vuln stdlib continue-on-error) | 4 | | [gotchas/sandbox.md](gotchas/sandbox.md) | macOS sandbox-exec SBPL profiles, symlink resolution, allowed paths, Chrome support dir for Playwright, AppleEvent allowlist, Messages.app legacy alias, picker modal, TCC re-prompt fix (stable-signed local binary via workflow-neutral `make install-signed`; first-sign Keychain "Always Allow") | 26 | diff --git a/internal/tui/terminal/terminalpane.go b/internal/tui/terminal/terminalpane.go index 2f6cfccc..5b225f54 100644 --- a/internal/tui/terminal/terminalpane.go +++ b/internal/tui/terminal/terminalpane.go @@ -53,6 +53,53 @@ func NewDrainedEmulator(cols, rows int) *xvt.SafeEmulator { return emu } +// assumedTerminalBG/FG answer OSC 10/11 (foreground/background color) +// queries from a live emulator (see NewLiveEmulator). Argus itself never +// assumes a background — its own chrome uses tcell.ColorDefault everywhere +// — so these are a best-effort dark-terminal guess, not a queried truth: +// they exist only so an agent CLI that conditions its own styling on a color +// query (Codex skips a background-dependent composer highlight when it +// can't determine the terminal's background — see gotchas/pty-terminal.md) +// gets *an* answer instead of the query going unanswered. +var ( + assumedTerminalBG = color.RGBA{A: 0xff} + assumedTerminalFG = color.RGBA{R: 0xe5, G: 0xe5, B: 0xe5, A: 0xff} +) + +// NewLiveEmulator creates an x/vt SafeEmulator for a pane's live agent +// session. Like NewDrainedEmulator, it drains the emulator's internal +// response pipe so terminal query sequences (DA1, DA2, DSR, OSC 10/11, etc.) +// never hang Write(). Unlike NewDrainedEmulator, the emulator reports +// assumedTerminalFG/BG and its generated response bytes are passed to +// forward (writing them to the agent process's stdin) instead of discarded +// — so a color-aware agent CLI sees a normal, answering terminal. forward +// may be called after the pane's session has changed or gone away; it is +// responsible for no-op'ing safely in that case. +// +// Only use this for a pane's live emulator. Replay/preview emulators +// reconstruct historical output for a process that isn't listening (or +// isn't the same live process) — forwarding responses into them would be +// meaningless at best and cross-wired at worst, so they keep using +// NewDrainedEmulator. +func NewLiveEmulator(cols, rows int, forward func([]byte)) *xvt.SafeEmulator { + emu := xvt.NewSafeEmulator(cols, rows) + emu.Emulator.SetBackgroundColor(assumedTerminalBG) + emu.Emulator.SetForegroundColor(assumedTerminalFG) + go func() { + buf := make([]byte, 4096) + for { + n, err := emu.Read(buf) + if n > 0 && forward != nil { + forward(buf[:n]) + } + if err != nil { + return + } + } + }() + return emu +} + // newDrainedReplayEmulator creates an emulator with a large scrollback buffer // for scrollback browsing. This avoids frequent rebuilds when scrolling up // through long session output. @@ -1678,7 +1725,7 @@ func (tp *TerminalPane) newTrackedEmulator(cols, rows int) *xvt.SafeEmulator { } func (tp *TerminalPane) newTrackedEmulatorWithCallback(cols, rows int, onCursorVisible func(bool)) *xvt.SafeEmulator { - emu := NewDrainedEmulator(cols, rows) + emu := NewLiveEmulator(cols, rows, tp.forwardEmulatorResponse) if onCursorVisible != nil { emu.Emulator.SetCallbacks(xvt.Callbacks{ CursorVisibility: onCursorVisible, @@ -1695,6 +1742,20 @@ func (tp *TerminalPane) newTrackedEmulatorWithCallback(cols, rows int, onCursorV return emu } +// forwardEmulatorResponse writes a live emulator's generated query-response +// bytes (OSC 10/11 answers, etc.) into the pane's current session, if any. +// The session is looked up fresh under tp.mu on every call rather than +// captured once at emulator-creation time, because the pane's session can +// change (attach/detach, reconnect) across the emulator's lifetime. +func (tp *TerminalPane) forwardEmulatorResponse(p []byte) { + tp.mu.Lock() + sess := tp.session + tp.mu.Unlock() + if sess != nil { + _, _ = sess.WriteInput(p) + } +} + // newTrackedReplayEmulatorWithCallback creates a replay emulator with a large // scrollback buffer (50K lines) for scrollback browsing in long sessions. func (tp *TerminalPane) newTrackedReplayEmulatorWithCallback(cols, rows int, onCursorVisible func(bool)) *xvt.SafeEmulator { diff --git a/internal/tui/terminal/terminalpane_test.go b/internal/tui/terminal/terminalpane_test.go index 2d486479..801dd638 100644 --- a/internal/tui/terminal/terminalpane_test.go +++ b/internal/tui/terminal/terminalpane_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -147,11 +148,28 @@ type mockAdapter struct { alive bool totalWritten uint64 output []byte + + writeMu sync.Mutex + written []byte +} + +func (m *mockAdapter) WriteInput(p []byte) (int, error) { + m.writeMu.Lock() + m.written = append(m.written, p...) + m.writeMu.Unlock() + return len(p), nil } -func (m *mockAdapter) WriteInput(p []byte) (int, error) { return len(p), nil } -func (m *mockAdapter) Resize(rows, cols uint16) error { return nil } -func (m *mockAdapter) RecentOutput() []byte { return m.output } +// Written returns a snapshot of everything WriteInput has received so far. +// Safe to call concurrently with WriteInput (used to poll for bytes forwarded +// from a background emulator-drain goroutine). +func (m *mockAdapter) Written() []byte { + m.writeMu.Lock() + defer m.writeMu.Unlock() + return append([]byte(nil), m.written...) +} +func (m *mockAdapter) Resize(rows, cols uint16) error { return nil } +func (m *mockAdapter) RecentOutput() []byte { return m.output } func (m *mockAdapter) RecentOutputTail(n int) []byte { if n >= len(m.output) { return m.output @@ -662,6 +680,115 @@ func TestNewTrackedEmulator_DefaultCursorHidden(t *testing.T) { } } +func TestNewLiveEmulator_AnswersBackgroundColorQuery(t *testing.T) { + got := make(chan []byte, 1) + emu := NewLiveEmulator(80, 24, func(p []byte) { + got <- append([]byte(nil), p...) + }) + if _, err := emu.Write([]byte(ansi.RequestBackgroundColor)); err != nil { + t.Fatalf("Write: %v", err) + } + + want := ansi.SetBackgroundColor(ansi.XRGBColor{Color: assumedTerminalBG}.String()) + select { + case p := <-got: + if string(p) != want { + t.Fatalf("forwarded response = %q, want %q", p, want) + } + case <-time.After(2 * time.Second): + t.Fatal("forward callback was never invoked for an OSC 11 query") + } +} + +func TestNewLiveEmulator_AnswersForegroundColorQuery(t *testing.T) { + got := make(chan []byte, 1) + emu := NewLiveEmulator(80, 24, func(p []byte) { + got <- append([]byte(nil), p...) + }) + if _, err := emu.Write([]byte(ansi.RequestForegroundColor)); err != nil { + t.Fatalf("Write: %v", err) + } + + want := ansi.SetForegroundColor(ansi.XRGBColor{Color: assumedTerminalFG}.String()) + select { + case p := <-got: + if string(p) != want { + t.Fatalf("forwarded response = %q, want %q", p, want) + } + case <-time.After(2 * time.Second): + t.Fatal("forward callback was never invoked for an OSC 10 query") + } +} + +func TestNewLiveEmulator_NilForwardDoesNotPanic(t *testing.T) { + emu := NewLiveEmulator(80, 24, nil) + if _, err := emu.Write([]byte(ansi.RequestBackgroundColor)); err != nil { + t.Fatalf("Write: %v", err) + } + // Give the drain goroutine a chance to run; there is nothing to + // assert beyond "this didn't panic". + time.Sleep(10 * time.Millisecond) +} + +func TestTerminalPane_ForwardEmulatorResponse_NoSession(t *testing.T) { + tp := NewTerminalPane() + // No session attached — must no-op rather than panic. + tp.forwardEmulatorResponse([]byte("anything")) +} + +func TestTerminalPane_ForwardEmulatorResponse_ForwardsToSession(t *testing.T) { + tp := NewTerminalPane() + sess := &mockAdapter{alive: true} + tp.SetSession(sess) + + tp.forwardEmulatorResponse([]byte("hello")) + + if got := string(sess.Written()); got != "hello" { + t.Fatalf("session received %q, want %q", got, "hello") + } +} + +func TestTerminalPane_LiveEmulatorForwardsQueryResponseToSession(t *testing.T) { + tp := NewTerminalPane() + sess := &mockAdapter{alive: true} + tp.SetSession(sess) + + emu := tp.newTrackedEmulatorWithCallback(80, 24, func(bool) {}) + if _, err := emu.Write([]byte(ansi.RequestBackgroundColor)); err != nil { + t.Fatalf("Write: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if len(sess.Written()) > 0 { + break + } + time.Sleep(time.Millisecond) + } + if len(sess.Written()) == 0 { + t.Fatal("expected the live emulator's OSC 11 response to be forwarded to the attached session") + } +} + +func TestNewTrackedReplayEmulator_DoesNotForwardResponses(t *testing.T) { + tp := NewTerminalPane() + sess := &mockAdapter{alive: true} + tp.SetSession(sess) + + // The replay/preview emulator constructor must stay discard-only: it + // takes no forward callback and must never write into the attached + // session even though one is present. + emu := tp.newTrackedReplayEmulatorWithCallback(80, 24, func(bool) {}) + if _, err := emu.Write([]byte(ansi.RequestBackgroundColor)); err != nil { + t.Fatalf("Write: %v", err) + } + + time.Sleep(50 * time.Millisecond) + if got := sess.Written(); len(got) != 0 { + t.Fatalf("replay emulator forwarded a response to the live session: %q", got) + } +} + func TestPaintEmu_HiddenCursorNoContentExtension(t *testing.T) { // When cursor is hidden and at (0, lastRow), paintEmu should NOT extend // lastContentRow to include the cursor — otherwise a phantom cursor cell diff --git a/openspec/changes/archive/2026-07-17-fix-codex-terminal-query-response/proposal.md b/openspec/changes/archive/2026-07-17-fix-codex-terminal-query-response/proposal.md new file mode 100644 index 00000000..3d4dcca0 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-fix-codex-terminal-query-response/proposal.md @@ -0,0 +1,68 @@ +# Answer live-emulator terminal capability queries instead of discarding them + +## Why + +Argus's live PTY emulator (`x/vt`) deliberately discards any response it +generates to terminal capability queries — `NewDrainedEmulator` drains the +emulator's response pipe to `io.Discard` specifically to avoid a hang bug +(the emulator's internal `io.Pipe` blocks forever if nobody reads it; see +`gotchas/pty-terminal.md`). That fix was correct for the hang, but it also +means an agent CLI that queries the terminal at startup — e.g. Codex sends +`OSC 11` ("what's your background color?") on every session start — never +gets an answer, because nothing forwards the emulator's generated reply back +into the child process's stdin. + +Confirmed via a real Codex v0.144.5 session log captured from a live Argus +task: Codex sends `OSC 10`/`OSC 11` queries at startup, and across every +occurrence of its idle composer/placeholder text in that session, it draws +zero background/reverse-video styling — it only colors the *submitted +prompt* history box (a separate, unconditional `\x1b[7m`). This is +consistent with Codex conservatively skipping a background-dependent +highlight it can't safely draw without knowing the terminal's background — +and matches the reported symptom exactly: the same composer state shows a +highlighted background band when Codex runs in a real terminal (which +answers OSC 11), but not when nested in Argus (which doesn't). + +## What Changes + +- The pane's **live** `x/vt` emulator now reports an assumed terminal + background/foreground color (`SetBackgroundColor`/`SetForegroundColor`) and + forwards its auto-generated query responses (OSC 10/11, and anything else + `x/vt` answers) into the agent process's stdin via the existing + `TerminalAdapter.WriteInput`, instead of discarding them. The drain-to-avoid-hang + behavior is preserved — responses are still drained asynchronously, just + routed to the real PTY instead of `io.Discard`. +- Replay and preview emulators (scrollback browsing, task-list preview) are + **unchanged** — they reconstruct historical output for a process that may + not be running (or isn't the same live process), so forwarding responses + into them would be meaningless or cross-wired. They keep using the existing + discard-only `NewDrainedEmulator`. + +## Capabilities + +### Modified Capabilities + +- `terminal-rendering`: the live PTY emulator additionally answers terminal + capability queries (OSC 10/11 today) by forwarding a real response into the + agent process, using an assumed background/foreground color rather than + silence. + +## Impact + +- **Modified code:** `internal/tui/terminal/terminalpane.go` — new + `NewLiveEmulator` constructor (used only by the pane's live emulator path, + `newTrackedEmulatorWithCallback`); a `forwardEmulatorResponse` method that + looks up the pane's current session under its existing mutex (session can + change across attach/detach during the emulator's lifetime) and writes to + it if non-nil. +- **No breaking changes.** Additive behavior on the live emulator only; + replay/preview paths and the existing hang-avoidance drain are untouched. +- **Not addressed here:** querying the *real* outer terminal (the one Argus + itself runs inside) for its actual background/foreground color and + forwarding that true value through. Argus's own chrome never assumes a + background (`tview.Styles.PrimitiveBackgroundColor = tcell.ColorDefault` + everywhere), so there's no existing "real" color to relay — doing this + properly would mean Argus querying its own controlling terminal over its + own stdout/stdin, which risks conflicting with tcell's ownership of that + fd's raw-mode state. Out of scope for this fix; the assumed dark-terminal + default is a deliberate, named simplification, not an oversight. diff --git a/openspec/changes/archive/2026-07-17-fix-codex-terminal-query-response/specs/terminal-rendering/spec.md b/openspec/changes/archive/2026-07-17-fix-codex-terminal-query-response/specs/terminal-rendering/spec.md new file mode 100644 index 00000000..b573659b --- /dev/null +++ b/openspec/changes/archive/2026-07-17-fix-codex-terminal-query-response/specs/terminal-rendering/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: Live emulator answers terminal capability queries + +The pane's live PTY emulator SHALL report an assumed terminal +background/foreground color and SHALL forward the emulator's auto-generated +responses to terminal capability queries (including `OSC 10`/`OSC 11` +foreground/background color queries) into the agent process's stdin, so an +agent CLI that conditions its own rendering on a color query receives a real +answer instead of silence. Responses SHALL continue to be drained +asynchronously so a query sequence never blocks the emulator's `Write` (the +pre-existing hang-avoidance behavior is preserved, only the destination of +the drained bytes changes for the live emulator). + +Replay and preview emulators (scrollback browsing, task-list preview) SHALL +NOT forward responses into any process — they continue to drain to +`io.Discard`, since they reconstruct historical output for a process that +may not be running or isn't the same live process. + +#### Scenario: Live emulator answers a background-color query + +- **WHEN** the live emulator processes an `OSC 11 ?` (query background color) + sequence from the agent process +- **THEN** the emulator's generated response is written to the agent + process's stdin via the terminal adapter, reporting the assumed background + color + +#### Scenario: Live emulator answers a foreground-color query + +- **WHEN** the live emulator processes an `OSC 10 ?` (query foreground color) + sequence from the agent process +- **THEN** the emulator's generated response is written to the agent + process's stdin via the terminal adapter, reporting the assumed foreground + color + +#### Scenario: No live session attached drops the response silently + +- **WHEN** the live emulator generates a query response but the pane has no + current session (detached, not yet attached, or exited) +- **THEN** the response is discarded rather than written anywhere, and no + error occurs + +#### Scenario: Replay and preview emulators remain discard-only + +- **WHEN** a replay or preview emulator (not the pane's live emulator) + processes a terminal capability query +- **THEN** its generated response is drained to `io.Discard` as before and is + never forwarded to any process diff --git a/openspec/changes/archive/2026-07-17-fix-codex-terminal-query-response/tasks.md b/openspec/changes/archive/2026-07-17-fix-codex-terminal-query-response/tasks.md new file mode 100644 index 00000000..72c6471b --- /dev/null +++ b/openspec/changes/archive/2026-07-17-fix-codex-terminal-query-response/tasks.md @@ -0,0 +1,12 @@ +# Tasks + +- [x] 1.1 Failing test in `internal/tui/terminal`: a new `NewLiveEmulator(cols, rows, forward)` constructor feeds it an `OSC 11 ?` query and asserts `forward` is called with a response reporting the assumed background color (and likewise for `OSC 10 ?` / foreground). +- [x] 1.2 Implement `NewLiveEmulator` (drains the emulator's response pipe like `NewDrainedEmulator`, but calls `forward` with the drained bytes instead of discarding; sets `SetBackgroundColor`/`SetForegroundColor` to the assumed defaults). +- [x] 2.1 Wire `newTrackedEmulatorWithCallback` (the pane's live-emulator constructor) to use `NewLiveEmulator` with a new `forwardEmulatorResponse` method that looks up `tp.session` under `tp.mu` at call time and calls `WriteInput` if non-nil, no-oping otherwise. +- [x] 2.2 Test: `forwardEmulatorResponse` with no session attached does not panic or error. +- [x] 2.3 Test: `forwardEmulatorResponse` with a `mockAdapter` session forwards bytes via `WriteInput` (extend `mockAdapter` to record written bytes). +- [x] 3.1 Confirm `newTrackedReplayEmulatorWithCallback` (replay/preview path) is untouched and still uses `newDrainedReplayEmulator` (discard-only) — add/keep a test asserting a replay emulator's query response is never forwarded. +- [x] 4.1 Add a gotcha note to `context/knowledge/gotchas/pty-terminal.md` next to the existing `NewDrainedEmulator` bullet, documenting the live-vs-replay split and why the live path now answers queries. +- [x] 5.1 Run the full `make pre-pr` gate and fix any gaps. +- [x] 6.1 Archive: fold the delta into `openspec/specs/terminal-rendering/spec.md`, move the change folder to `openspec/changes/archive/-fix-codex-terminal-query-response/`, in the same branch before merge. +- [x] 6.2 Re-run `make pre-pr` after archiving to confirm no drift. diff --git a/openspec/specs/terminal-rendering/spec.md b/openspec/specs/terminal-rendering/spec.md index 8ab03f19..d61598ad 100644 --- a/openspec/specs/terminal-rendering/spec.md +++ b/openspec/specs/terminal-rendering/spec.md @@ -364,3 +364,50 @@ scroll SHALL browse the pane's own scrollback exactly as before. - **THEN** a subsequent keyboard scroll-up SHALL enter scroll mode and browse the pane's scrollback normally +### Requirement: Live emulator answers terminal capability queries + +The pane's live PTY emulator SHALL report an assumed terminal +background/foreground color and SHALL forward the emulator's auto-generated +responses to terminal capability queries (including `OSC 10`/`OSC 11` +foreground/background color queries) into the agent process's stdin, so an +agent CLI that conditions its own rendering on a color query receives a real +answer instead of silence. Responses SHALL continue to be drained +asynchronously so a query sequence never blocks the emulator's `Write` (the +pre-existing hang-avoidance behavior is preserved, only the destination of +the drained bytes changes for the live emulator). + +Replay and preview emulators (scrollback browsing, task-list preview) SHALL +NOT forward responses into any process — they continue to drain to +`io.Discard`, since they reconstruct historical output for a process that +may not be running or isn't the same live process. + +#### Scenario: Live emulator answers a background-color query + +- **WHEN** the live emulator processes an `OSC 11 ?` (query background color) + sequence from the agent process +- **THEN** the emulator's generated response is written to the agent + process's stdin via the terminal adapter, reporting the assumed background + color + +#### Scenario: Live emulator answers a foreground-color query + +- **WHEN** the live emulator processes an `OSC 10 ?` (query foreground color) + sequence from the agent process +- **THEN** the emulator's generated response is written to the agent + process's stdin via the terminal adapter, reporting the assumed foreground + color + +#### Scenario: No live session attached drops the response silently + +- **WHEN** the live emulator generates a query response but the pane has no + current session (detached, not yet attached, or exited) +- **THEN** the response is discarded rather than written anywhere, and no + error occurs + +#### Scenario: Replay and preview emulators remain discard-only + +- **WHEN** a replay or preview emulator (not the pane's live emulator) + processes a terminal capability query +- **THEN** its generated response is drained to `io.Discard` as before and is + never forwarded to any process +