Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
16 changes: 13 additions & 3 deletions internal/tui/attention.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
28 changes: 22 additions & 6 deletions internal/tui/attention_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions internal/tui/clipboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading