diff --git a/AGENTS.md b/AGENTS.md index 04a3eee..aa8c6ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,9 +228,11 @@ feat(tui): compact tool steps with Ctrl+E details toggle cards are not copy targets. Local clipboard helpers (`pbcopy` / `wl-copy` / `clip`) must run as a plain `tea.Cmd` — `tea.ExecProcess` releases the alt-screen and flickers on every copy. OSC 52 (SSH / - helper-less) still uses `tea.Exec`. `tea.Exec` (attention, OSC 52) must return `afterExec` + helper-less) still uses `tea.Exec`, which must return `afterExec` so `restoreAfterExec` re-arms mouse and Shift+Enter — Bubble Tea's - RestoreTerminal does not. `--plain` skips mouse reporting. + RestoreTerminal does not. Attention (title / bell / OSC 9 on done, + approval, jobs) is a plain `tea.Cmd` raw write — `tea.Exec` there + flickered the screen at every turn end. `--plain` skips mouse reporting. - The management drawer is a bottom sheet: keep ~8 transcript rows above it (`sheetTranscriptMin`); full-bleed only when the terminal cannot fit transcript + sheet. Layout-only — tab grammar (`]`/`[`/`⏎`/`esc`) diff --git a/internal/tui/attention.go b/internal/tui/attention.go index 8ae7acb..ee8cd56 100644 --- a/internal/tui/attention.go +++ b/internal/tui/attention.go @@ -84,11 +84,21 @@ func (m *Model) attentionFor(kind attentionKind) attention { return a } -// attentionCmd materializes the plan as a single raw write via tea.Exec — -// the same escape hatch as the OSC 52 clipboard write. Nil when all-muted. +// attentionWrite lands an attention sequence on the terminal; tests swap it. +var attentionWrite = writeEnhancedKeys + +// attentionCmd materializes the plan as a single raw write. Not tea.Exec: +// Exec releases and restores the terminal — it leaves the alt-screen and +// repaints — which flickered the whole screen on every turn end. One small +// Write on stdout is serialized with the renderer's frame writes by the +// file's write lock, so frames cannot interleave. Nil when all-muted. func (m *Model) attentionCmd(a attention) tea.Cmd { if a.empty() { return nil } - return tea.Exec(&rawSeq{seq: a.sequence()}, afterExec) + seq := a.sequence() + return func() tea.Msg { + attentionWrite(seq) + return nil + } } diff --git a/internal/tui/attention_test.go b/internal/tui/attention_test.go index 76d240e..8dbea19 100644 --- a/internal/tui/attention_test.go +++ b/internal/tui/attention_test.go @@ -15,9 +15,9 @@ import ( // completes or an approval starts waiting, bodek sets the terminal window // title (silent, always on), rings the terminal bell (default on, // --bel=false mutes it), and optionally raises a desktop notification via -// OSC 9 (--notify). All effects ride a single raw write through tea.Exec -// so the sequence lands verbatim and frames can't interleave — the same -// escape hatch the OSC 52 clipboard write uses. +// OSC 9 (--notify). All effects ride a single raw write straight to the +// terminal — never tea.Exec, whose release/restore of the alt-screen +// flickered the screen on every turn end. func TestAttentionPlanApproval(t *testing.T) { m := newTestModel() @@ -137,18 +137,34 @@ func TestAttentionEmptyPlanIsSilent(t *testing.T) { } func TestAttentionCmdWrapsTheRawWrite(t *testing.T) { + var got []string + prev := attentionWrite + attentionWrite = func(s string) { got = append(got, s) } + defer func() { attentionWrite = prev }() + m := newTestModel() - if cmd := m.attentionCmd(attention{title: "T", bell: true}); cmd == nil { + a := attention{title: "T", bell: true} + cmd := m.attentionCmd(a) + if cmd == nil { t.Fatal("enabled plan returned a nil cmd") } + // Regression: tea.Exec released and restored the alt-screen on every + // done, flashing the screen as the turn stats painted. The write must + // happen inside the cmd and yield no message (no exec, no re-arm). + if msg := cmd(); msg != nil { + t.Errorf("attention cmd returned %T, want nil — no terminal release", msg) + } + if len(got) != 1 || got[0] != a.sequence() { + t.Errorf("attention wrote %q, want one write of %q", got, a.sequence()) + } if cmd := m.attentionCmd(attention{bell: true}); cmd == nil { t.Error("bell-only plan returned a nil cmd") } } func TestRawSeqRunWritesVerbatim(t *testing.T) { - // The attention write shares the clipboard's raw-seq escape hatch; the - // writer must land the sequence verbatim and stay silent headless. + // The OSC 52 clipboard's raw-seq escape hatch must land the sequence + // verbatim and stay silent headless. var buf bytes.Buffer w := &rawSeq{seq: "SEQ"} w.SetStdin(nil) diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go index 0f390bb..316af5e 100644 --- a/internal/tui/clipboard.go +++ b/internal/tui/clipboard.go @@ -22,8 +22,7 @@ const osc52Cap = 100_000 // the terminal. tea.Println can't carry it: bodek runs on the alt-screen, // where the renderer drops printed lines entirely. Exec briefly pauses the // renderer and hands over the real terminal writer, so the sequence lands -// verbatim and frames can't interleave. Shared by the OSC 52 clipboard -// write and the attention layer (bell / window title / OSC 9 notify). +// verbatim and frames can't interleave. Used by the OSC 52 clipboard write. type rawSeq struct { seq string w io.Writer diff --git a/internal/tui/model.go b/internal/tui/model.go index fca0528..6939a39 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -718,8 +718,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case restoreAfterExecMsg: // tea.Exec's ReleaseTerminal disables mouse and drops the // Shift+Enter keyboard protocol; RestoreTerminal does not put - // either back. Re-arm so the wheel keeps working after a turn - // done bell / title write or a clipboard exec. + // either back. Re-arm so the wheel keeps working after a + // clipboard exec. return m, m.restoreAfterExec() case copyResultMsg: