From 41bd2713f5542795b2c2a4440ec3af95cb0f76e2 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Mon, 31 Aug 2026 09:02:00 +0200 Subject: [PATCH] fix(tui): silence trim signals and tool execution times Engine housekeeping is not user-actionable and no longer surfaces: agent_signal/trim never reaches the notice strip (other subtypes keep flowing), and the per-step execution time is dropped from the compact tool-step right rail, which now carries only the typed chip (diffstat / test verdict). Durations are still recorded on the step struct. Doc sync: README engine-notices bullet, INTEGRATIONS event table, REDESIGN step-line spec. --- README.md | 9 +++++---- docs/INTEGRATIONS.md | 2 +- docs/REDESIGN.md | 2 +- internal/tui/events.go | 5 +++++ internal/tui/notices_test.go | 20 ++++++++++++++++++++ internal/tui/steps_test.go | 11 +++++++---- internal/tui/view.go | 14 ++++---------- 7 files changed, 43 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 7b464bc..8875c13 100644 --- a/README.md +++ b/README.md @@ -353,10 +353,11 @@ collect an approval for a prompt the engine already abandoned. - **Fluent by default** — gradient wordmark and hairline, smooth braille spinner, smart autoscroll that never yanks you while you read history, and a scroll-position indicator. -- **Engine notices** — skill loads, memory merges, and agent signals appear as - quiet status lines. Nothing lingers: info traces fade after 3s, and - errors, warnings, and disconnect notes autoclose after 10s (connection - state stays visible in the header badge). +- **Engine notices** — skill loads, memory merges, and actionable agent + signals appear as quiet status lines; internal housekeeping (context + trims, tool execution times) stays silent. Nothing lingers: info traces + fade after 3s, and errors, warnings, and disconnect notes autoclose + after 10s (connection state stays visible in the header badge). - **Attention when backgrounded** — turn completion and pending approvals set the terminal window title (`✓ done — ` / `⚠ approval needed — `) and ring the bell (`--bel=false` mutes); `--notify` adds OSC 9 diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 748c8a5..52fbe47 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -76,4 +76,4 @@ These workflows have no server endpoint; bodek cannot offer them: | `error` | ← | — | error bubble / cancel markers | | `cancelled` | ← | — | clean cancel close-out | | `approval_request` / `approval_ack` | ← | — | approval queue | -| `skill_event` / `memory_event` / `agent_signal` | ← | — | transient notes (+ suggestion card) | +| `skill_event` / `memory_event` / `agent_signal` | ← | — | transient notes (+ suggestion card; `agent_signal:trim` stays silent) | diff --git a/docs/REDESIGN.md b/docs/REDESIGN.md index b76c66a..62d03c7 100644 --- a/docs/REDESIGN.md +++ b/docs/REDESIGN.md @@ -114,7 +114,7 @@ Typography of the cockpit: values bright, labels muted, glyphs amber. Numbers ne **Reasoning accordions** adopt the WebUI's proven rule: auto-expand while its turn is live (with auto-follow), auto-collapse when the next turn starts; manually-opened history stays open; resumed transcripts start collapsed. (Today: always-capped excerpt — close, but the live auto-expand is what makes thinking models feel fast.) -**Typed tool renderers.** The step line stays one-line (glyph · name · arg · duration · status). What changes is *inspect depth*: expanding picks a renderer by tool/shape — +**Typed tool renderers.** The step line stays one-line (glyph · name · arg · status chip). What changes is *inspect depth*: expanding picks a renderer by tool/shape — | Renderer | Trigger | Inspect view | |----------|---------|--------------| diff --git a/internal/tui/events.go b/internal/tui/events.go index e0b5ea5..4d7150a 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -279,6 +279,11 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { case "memory_event": m.addTransientNote("memory · " + strings.TrimSpace(ev.SubType+" "+ev.Target) + eventTail(ev)) case "agent_signal": + if ev.SubType == "trim" { + // Context trimming is engine housekeeping — nothing the user + // can act on, so it never reaches the notice strip. + break + } m.addTransientNote("signal · " + strings.TrimSpace(ev.SubType+" "+ev.Detail) + eventTail(ev)) case "subagent_log": line := strings.TrimSpace(ev.SubType + " " + ev.Name) diff --git a/internal/tui/notices_test.go b/internal/tui/notices_test.go index aa3fc17..f1eadb1 100644 --- a/internal/tui/notices_test.go +++ b/internal/tui/notices_test.go @@ -58,6 +58,26 @@ func assertAlertDwell(t *testing.T, m *Model, cmdArmed bool, substr string) { } } +// TestTrimSignalSilenced pins the actionable-only contract for agent_signal: +// the "trim" subtype is engine housekeeping (context-window trimming) — +// nothing the user can act on — so it must never surface as a notice. +// Every other subtype keeps flowing into the strip. +func TestTrimSignalSilenced(t *testing.T) { + m := newTestModel() + m.handleEvent(client.Event{Type: "agent_signal", SubType: "trim", Detail: "ctx"}) + for _, n := range m.notices { + if strings.Contains(n, "signal · trim") { + t.Fatalf("trim signal surfaced as a notice: %v", m.notices) + } + } + + // Silence is per-subtype, not per event class. + m.handleEvent(client.Event{Type: "agent_signal", SubType: "fallback", Detail: "glm-x"}) + if note, _ := lastNoteMatching(m, "signal · fallback"); note == "" { + t.Errorf("non-trim agent_signal was silenced too: %v", m.notices) + } +} + // TestNoticesAutoclose is the regression for the never-disappearing // "error: iteration 22: llm: stream idle…" notice: every addNote path — // errors with and without an open turn, disconnects — posts into the strip diff --git a/internal/tui/steps_test.go b/internal/tui/steps_test.go index aae1a54..4675f6c 100644 --- a/internal/tui/steps_test.go +++ b/internal/tui/steps_test.go @@ -281,7 +281,9 @@ func TestFormatStepDur(t *testing.T) { } // TestStepDuration drives a tool call through handleEvent and checks the step -// head: the response time appears once done, and no result excerpt shows. +// head: the duration is recorded internally once done, but the compact head +// never renders it — execution time is internal telemetry, not actionable +// output. No result excerpt shows either. func TestStepDuration(t *testing.T) { m := newTestModel() m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) @@ -295,14 +297,15 @@ func TestStepDuration(t *testing.T) { t.Fatalf("tool_result should stamp the step duration: %+v", st) } - // A done step head shows the duration (fixture-set, for exact rendering). + // A done step head shows no duration even when one was recorded — + // the right rail is reserved for the typed chip (diffstat / verdict). msg := message{role: roleAsst, steps: []step{ {name: "shell", arg: "go test", done: true, result: "exit status 1", dur: 320 * time.Millisecond}, }} out, _ := renderStepsForTest(m, msg, 0, 0) plainOut := plain(out) - if !strings.Contains(plainOut, "320ms") { - t.Errorf("done head missing duration: %q", plainOut) + if strings.Contains(plainOut, "320ms") { + t.Errorf("done head must not render a duration: %q", plainOut) } if strings.Contains(plainOut, "→") || strings.Contains(plainOut, "exit status 1") { t.Errorf("compact head should not show a result excerpt: %q", plainOut) diff --git a/internal/tui/view.go b/internal/tui/view.go index 4c36b08..7512baa 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -776,19 +776,13 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in left += th.stepArg.Render(" · " + r) } } - // Right rail: response time once the call lands, plus the typed chip - // (diffstat / test verdict) — right-aligned so durations read as a - // column down the step list instead of floating mid-line. + // Right rail: the typed chip (diffstat / test verdict), right-aligned. + // Tool execution time is internal telemetry — recorded on the step but + // deliberately never rendered. right := "" - if s.done && s.dur > 0 { - right = th.stepArg.Render(formatStepDur(s.dur)) - } if s.done { if chip := stepHeadSuffix(s.name, s.result, th); chip != "" { - if right != "" { - right += th.stepArg.Render(" ") - } - right += chip + right = chip } } // The left side yields to the right rail, then the pair pads to the