From e2ab45360b646491f119d6aad85f9480fb86b115 Mon Sep 17 00:00:00 2001 From: Dennison Date: Sun, 6 Sep 2026 14:16:07 -0400 Subject: [PATCH 01/10] test(red): TASK-1407 /plan command, PLAN status badge, --plan-mode honored by the TUI Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5 --- cmd/harnesscli/tui/plan_command_1407_test.go | 50 ++++++++++++++++++++ cmd/harnesscli/tui/scenarios_1407_test.go | 49 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 cmd/harnesscli/tui/plan_command_1407_test.go create mode 100644 cmd/harnesscli/tui/scenarios_1407_test.go diff --git a/cmd/harnesscli/tui/plan_command_1407_test.go b/cmd/harnesscli/tui/plan_command_1407_test.go new file mode 100644 index 00000000..8537b0fc --- /dev/null +++ b/cmd/harnesscli/tui/plan_command_1407_test.go @@ -0,0 +1,50 @@ +package tui_test + +import ( + "strings" + "testing" + + "go-agent-harness/cmd/harnesscli/tui" +) + +// Issue #1407: plan mode must be reachable and visible. ctrl+o is overloaded +// (it expands tool calls whenever any tool has run), so a /plan command +// toggles it explicitly and the status bar shows the mode. +func TestPlanCommand_TogglesAndShowsInStatusBar(t *testing.T) { + m := initModel(t, 120, 40) + if m.PlanMode() { + t.Fatal("plan mode must start off") + } + m = sendSlashCommand(m, "/plan") + if !m.PlanMode() { + t.Fatal("/plan must turn plan mode on") + } + if !strings.Contains(m.View(), "PLAN") { + t.Fatalf("status bar must show the PLAN badge while plan mode is on:\n%s", m.View()) + } + if !strings.Contains(m.StatusMsg(), "Plan mode") { + t.Fatalf("status must confirm the toggle, got %q", m.StatusMsg()) + } + m = sendSlashCommand(m, "/plan") + if m.PlanMode() || strings.Contains(m.View(), "PLAN") { + t.Fatal("/plan again must turn plan mode off and drop the badge") + } +} + +func TestPlanCommand_InSlashMenu(t *testing.T) { + m := initModel(t, 120, 40) + m = typeIntoModel(m, "/pla") + if !strings.Contains(m.View(), "/plan ") && !strings.Contains(m.View(), "/plan\t") && !strings.Contains(m.View(), "/plan ") { + t.Fatalf("/plan must appear in the slash menu:\n%s", m.View()) + } +} + +// harnesscli --tui --plan-mode must start the TUI in plan mode. +func TestTUIConfig_PlanModeFlag(t *testing.T) { + cfg := tui.DefaultTUIConfig() + cfg.PlanMode = true + m := tui.New(cfg) + if !m.PlanMode() { + t.Fatal("TUIConfig.PlanMode must start the TUI in plan mode") + } +} diff --git a/cmd/harnesscli/tui/scenarios_1407_test.go b/cmd/harnesscli/tui/scenarios_1407_test.go new file mode 100644 index 00000000..8dfc667a --- /dev/null +++ b/cmd/harnesscli/tui/scenarios_1407_test.go @@ -0,0 +1,49 @@ +package tui_test + +import ( + "encoding/json" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "go-agent-harness/cmd/harnesscli/tui" +) + +func sse(m tui.Model, typ, runID, payload string) tui.Model { + m2, _ := m.Update(tui.SSEEventMsg{EventType: typ, RunID: runID, ID: runID + ":" + typ, Raw: json.RawMessage(payload)}) + return m2.(tui.Model) +} + +// After a run is interrupted mid tool call, ctrl+o must not duplicate the +// interrupted tool's card in the transcript. +func TestInterruptedTool_CtrlOKeepsOneCard(t *testing.T) { + m := initModel(t, 120, 40) + // Prior history: a finished turn with a two-call tool group and an answer, + // then a question turn, as in the live session where the bug showed. + m = typeIntoModel(m, "Create hello.go and run it") + m = sendKey(m, tea.KeyEnter) + m0, _ := m.Update(tui.RunStartedMsg{RunID: "run-0"}) + m = m0.(tui.Model) + m = sse(m, "run.started", "run-0", `{"prompt":"Create hello.go and run it","step":0}`) + m = sse(m, "tool.call.started", "run-0", `{"call_id":"c1","tool":"write","arguments":"{\"path\":\"hello.go\"}","step":1}`) + m = sse(m, "tool.call.completed", "run-0", `{"call_id":"c1","tool":"write","output":"ok","duration_ms":1,"step":1}`) + m = sse(m, "tool.call.started", "run-0", `{"call_id":"c2","tool":"bash","arguments":"{\"command\":\"go run hello.go\"}","step":2}`) + m = sse(m, "tool.call.completed", "run-0", `{"call_id":"c2","tool":"bash","output":"hi","duration_ms":1,"step":2}`) + m = sse(m, "assistant.message", "run-0", `{"content":"Created hello.go and ran it.","step":3}`) + m = sse(m, "run.completed", "run-0", `{"output":"Created hello.go and ran it.","step":3}`) + m = typeIntoModel(m, "Run a long command") + m = sendKey(m, tea.KeyEnter) + m2, _ := m.Update(tui.RunStartedMsg{RunID: "run-1"}) + m = m2.(tui.Model) + m = sse(m, "run.started", "run-1", `{"prompt":"Run a long command","step":0}`) + m = sse(m, "tool.call.started", "run-1", `{"call_id":"s1","tool":"bash","arguments":"{\"command\":\"sleep 60\"}","step":1}`) + m = sendKey(m, tea.KeyEsc) // interrupt + m = sse(m, "run.cancelled", "run-1", `{"step":1}`) + before := strings.Count(m.View(), "bash") + m = sendKey(m, tea.KeyCtrlO) + after := strings.Count(m.View(), "bash") + if after > before { + t.Fatalf("ctrl+o duplicated the interrupted tool card (bash mentions %d -> %d):\n%s", before, after, m.View()) + } +} From 97b2891e62e182a6b4cb31786ac93b737e5f221a Mon Sep 17 00:00:00 2001 From: Dennison Date: Sun, 6 Sep 2026 14:18:28 -0400 Subject: [PATCH 02/10] feat(tui): TASK-1407 /plan command toggles plan mode, PLAN badge in the status bar, --tui --plan-mode honored Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5 --- cmd/harnesscli/main.go | 5 ++-- cmd/harnesscli/tui/cmd_parser.go | 8 +++++++ cmd/harnesscli/tui/config.go | 2 ++ cmd/harnesscli/tui/model.go | 23 ++++++++++++++++++- .../snapshots/TUI-041-parser-120x40.txt | 1 + .../snapshots/TUI-041-parser-200x50.txt | 1 + .../snapshots/TUI-041-parser-80x24.txt | 1 + 7 files changed, 38 insertions(+), 3 deletions(-) diff --git a/cmd/harnesscli/main.go b/cmd/harnesscli/main.go index 34882b39..5b9c3dcc 100644 --- a/cmd/harnesscli/main.go +++ b/cmd/harnesscli/main.go @@ -176,7 +176,7 @@ func run(args []string) int { workspacePath := resolveWorkspacePath(*workspace) if *enableTUI { - if err := runTUI(*baseURL, workspacePath, *resume); err != nil { + if err := runTUI(*baseURL, workspacePath, *resume, *planMode); err != nil { fmt.Fprintf(stderr, "harnesscli: tui: %v\n", err) return exitClientError } @@ -571,11 +571,12 @@ func newTUIConfig(baseURL, workspace, resumeConversationID string) tui.TUIConfig } } -func runTUI(baseURL, workspace, resumeConversationID string) error { +func runTUI(baseURL, workspace, resumeConversationID string, planMode bool) error { if !term.IsTerminal(int(os.Stdout.Fd())) { return fmt.Errorf("--tui requires a terminal; pipe output or use without --tui for streaming mode") } tuiCfg := newTUIConfig(baseURL, workspace, resumeConversationID) + tuiCfg.PlanMode = planMode // Resolve and apply the color profile to the renderer before building the // model, and store the effective profile back for accurate display. tuiCfg.ColorProfile = tui.ApplyColorProfile(tuiCfg.ColorProfile) diff --git a/cmd/harnesscli/tui/cmd_parser.go b/cmd/harnesscli/tui/cmd_parser.go index 3d1b0b54..cfd8cd83 100644 --- a/cmd/harnesscli/tui/cmd_parser.go +++ b/cmd/harnesscli/tui/cmd_parser.go @@ -81,6 +81,14 @@ func newEmptyCommandRegistry() *CommandRegistry { func builtinCommandEntries() []CommandEntry { entries := []CommandEntry{ {Name: "plugins", Description: "Browse installed plugin bundles", Handler: func(Command) CommandResult { return CommandResult{Status: CmdOK} }, Execute: executePluginsCommand}, + { + Name: "plan", + Description: "Toggle plan mode: the agent plans in .harness/plan.md and waits for your approval before editing", + Handler: func(cmd Command) CommandResult { + return CommandResult{Status: CmdOK} + }, + Execute: executePlanCommand, + }, { Name: "clear", Description: "Clear conversation history", diff --git a/cmd/harnesscli/tui/config.go b/cmd/harnesscli/tui/config.go index 0d669b82..e0eb2718 100644 --- a/cmd/harnesscli/tui/config.go +++ b/cmd/harnesscli/tui/config.go @@ -26,6 +26,8 @@ type TUIConfig struct { // startup so the run history is loaded and new prompts continue the // existing conversation instead of starting a new one. ResumeConversationID string + // PlanMode starts the TUI in enforced plan mode (harnesscli --tui --plan-mode). + PlanMode bool // SpinnerSeed seeds the thinking-spinner's verb selection. Zero (the default) // uses a time-based seed for whimsical variety in real use; tests set a fixed // non-zero seed so rendered snapshots are deterministic. diff --git a/cmd/harnesscli/tui/model.go b/cmd/harnesscli/tui/model.go index b3da0222..4dc89578 100644 --- a/cmd/harnesscli/tui/model.go +++ b/cmd/harnesscli/tui/model.go @@ -588,6 +588,7 @@ func New(cfg TUIConfig) Model { m = m.WithAutocompleteProvider(buildCombinedProvider(m.commandRegistry)) // Wire slash-complete dropdown. m.slashComplete = buildSlashComplete(m.commandRegistry, m.skillRegistry) + m.planMode = cfg.PlanMode if cfg.ResumeConversationID != "" { m.conversationID = cfg.ResumeConversationID } @@ -1971,6 +1972,21 @@ func (m *Model) resetTranscriptView() { m.clearCompactionBlocks() } +// executePlanCommand toggles enforced plan mode explicitly. ctrl+o only +// reaches plan mode when no tool call has ever run in the session, so a +// first-time user needs a discoverable command (#1407). +func executePlanCommand(m *Model, _ Command) ([]tea.Cmd, bool) { + if m.runActive { + return []tea.Cmd{m.setStatusMsg("Plan mode can't change while a run is active — wait for it to finish or press Esc to cancel")}, false + } + m.planMode = !m.planMode + m.statusBar.SetModel(m.statusBarModelLabel()) + if m.planMode { + return []tea.Cmd{m.setStatusMsg("Plan mode: ON — the agent only edits .harness/plan.md until you approve its plan (/plan to turn off)")}, false + } + return []tea.Cmd{m.setStatusMsg("Plan mode: OFF")}, false +} + func executeClearCommand(m *Model, _ Command) ([]tea.Cmd, bool) { m.resetTranscriptView() return []tea.Cmd{m.setStatusMsg("Conversation cleared")}, false @@ -3150,8 +3166,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if !m.runActive { // Idle (no run active, no active tool): toggle plan mode. m.planMode = !m.planMode + m.statusBar.SetModel(m.statusBarModelLabel()) if m.planMode { - cmds = append(cmds, m.setStatusMsg("Plan mode: ON")) + cmds = append(cmds, m.setStatusMsg("Plan mode: ON — the agent only edits .harness/plan.md until you approve its plan (/plan to turn off)")) } else { cmds = append(cmds, m.setStatusMsg("Plan mode: OFF")) } @@ -5858,6 +5875,10 @@ func (m Model) effectiveModelAndProvider() (model, provider string) { // including reasoning effort suffix and gateway indicator if applicable. func (m Model) statusBarModelLabel() string { label := displayModelName(m.selectedModel) + if m.planMode { + // Make the mode visible; ctrl+o is overloaded and /plan toggles it (#1407). + label = "PLAN · " + label + } if m.selectedReasoningEffort != "" { label += " (" + m.selectedReasoningEffort + ")" } diff --git a/cmd/harnesscli/tui/testdata/snapshots/TUI-041-parser-120x40.txt b/cmd/harnesscli/tui/testdata/snapshots/TUI-041-parser-120x40.txt index 2e073ad2..2437c316 100644 --- a/cmd/harnesscli/tui/testdata/snapshots/TUI-041-parser-120x40.txt +++ b/cmd/harnesscli/tui/testdata/snapshots/TUI-041-parser-120x40.txt @@ -21,6 +21,7 @@ Command Registry - 120x40 /model — Select AI model /new — Start a new session (resets conversation) /permissions — View session tool permissions +/plan — Toggle plan mode: the agent plans in .harness/plan.md and waits for your approval before editing /plugins — Browse installed plugin bundles /profiles — View and select a profile for next run /quit — Quit the TUI diff --git a/cmd/harnesscli/tui/testdata/snapshots/TUI-041-parser-200x50.txt b/cmd/harnesscli/tui/testdata/snapshots/TUI-041-parser-200x50.txt index 2fca58ba..643ac80a 100644 --- a/cmd/harnesscli/tui/testdata/snapshots/TUI-041-parser-200x50.txt +++ b/cmd/harnesscli/tui/testdata/snapshots/TUI-041-parser-200x50.txt @@ -21,6 +21,7 @@ Command Registry - 200x50 /model — Select AI model /new — Start a new session (resets conversation) /permissions — View session tool permissions +/plan — Toggle plan mode: the agent plans in .harness/plan.md and waits for your approval before editing /plugins — Browse installed plugin bundles /profiles — View and select a profile for next run /quit — Quit the TUI diff --git a/cmd/harnesscli/tui/testdata/snapshots/TUI-041-parser-80x24.txt b/cmd/harnesscli/tui/testdata/snapshots/TUI-041-parser-80x24.txt index f85d0f10..e19f77c5 100644 --- a/cmd/harnesscli/tui/testdata/snapshots/TUI-041-parser-80x24.txt +++ b/cmd/harnesscli/tui/testdata/snapshots/TUI-041-parser-80x24.txt @@ -21,6 +21,7 @@ Command Registry - 80x24 /model — Select AI model /new — Start a new session (resets conversation) /permissions — View session tool permissions +/plan — Toggle plan mode: the agent plans in .harness/plan.md and waits for your approval before editing /plugins — Browse installed plugin bundles /profiles — View and select a profile for next run /quit — Quit the TUI From 247d0066bf314309ae1cde9d7152a52f9e69e681 Mon Sep 17 00:00:00 2001 From: Dennison Date: Sun, 6 Sep 2026 14:20:53 -0400 Subject: [PATCH 03/10] fix(tui): TASK-1407 PLAN badge without a model name, registry list and runTUI test updated Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5 --- cmd/harnesscli/main_tui_test.go | 2 +- cmd/harnesscli/tui/cmd_parser_test.go | 1 + cmd/harnesscli/tui/model.go | 9 ++++++++- cmd/harnesscli/tui/plan_command_1407_test.go | 6 +++--- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/cmd/harnesscli/main_tui_test.go b/cmd/harnesscli/main_tui_test.go index 94520e79..a415f7f8 100644 --- a/cmd/harnesscli/main_tui_test.go +++ b/cmd/harnesscli/main_tui_test.go @@ -13,7 +13,7 @@ func TestRunTUIRequiresTerminal(t *testing.T) { t.Skip("stdout is a terminal in this environment") } - err := runTUI("http://localhost:8080", "/tmp/project", "") + err := runTUI("http://localhost:8080", "/tmp/project", "", false) if err == nil { t.Fatal("expected non-terminal runTUI call to fail") } diff --git a/cmd/harnesscli/tui/cmd_parser_test.go b/cmd/harnesscli/tui/cmd_parser_test.go index eb5b96f0..8b6b8994 100644 --- a/cmd/harnesscli/tui/cmd_parser_test.go +++ b/cmd/harnesscli/tui/cmd_parser_test.go @@ -396,6 +396,7 @@ func TestTUI041_BuiltinCommandsRegistered(t *testing.T) { func TestTUI364_RegistryCompleteness(t *testing.T) { // These are the exact built-in slash commands the TUI exposes. knownCommands := []string{ + "plan", "add-dir", "attach", "cancel", "clear", "compact", "config", "context", "cost", "dashboard", "doctor", "export", "feedback", "fork", "help", "history", "hooks", "init", "keys", "model", "new", "permissions", "plugins", "profiles", "quit", "replay", "resume", "runs", "search", "sessions", "stats", "subagents", "tasks", "rewind", "theme", "title", "undo", "workflow", diff --git a/cmd/harnesscli/tui/model.go b/cmd/harnesscli/tui/model.go index 4dc89578..02f66fe9 100644 --- a/cmd/harnesscli/tui/model.go +++ b/cmd/harnesscli/tui/model.go @@ -800,6 +800,9 @@ func (m Model) ConversationID() string { } // SelectedModel returns the currently active model ID (for testing). +// StatusBarModelLabel exposes the status bar's model segment (for testing). +func (m Model) StatusBarModelLabel() string { return m.statusBarModelLabel() } + // EffectiveModelAndProvider exposes the model id and provider the next run // will be sent with (for testing). func (m Model) EffectiveModelAndProvider() (string, string) { return m.effectiveModelAndProvider() } @@ -5877,7 +5880,11 @@ func (m Model) statusBarModelLabel() string { label := displayModelName(m.selectedModel) if m.planMode { // Make the mode visible; ctrl+o is overloaded and /plan toggles it (#1407). - label = "PLAN · " + label + if label == "" { + label = "PLAN" + } else { + label = "PLAN · " + label + } } if m.selectedReasoningEffort != "" { label += " (" + m.selectedReasoningEffort + ")" diff --git a/cmd/harnesscli/tui/plan_command_1407_test.go b/cmd/harnesscli/tui/plan_command_1407_test.go index 8537b0fc..9139a364 100644 --- a/cmd/harnesscli/tui/plan_command_1407_test.go +++ b/cmd/harnesscli/tui/plan_command_1407_test.go @@ -19,14 +19,14 @@ func TestPlanCommand_TogglesAndShowsInStatusBar(t *testing.T) { if !m.PlanMode() { t.Fatal("/plan must turn plan mode on") } - if !strings.Contains(m.View(), "PLAN") { - t.Fatalf("status bar must show the PLAN badge while plan mode is on:\n%s", m.View()) + if !strings.Contains(m.StatusBarModelLabel(), "PLAN") { + t.Fatalf("status bar must show the PLAN badge while plan mode is on, got %q", m.StatusBarModelLabel()) } if !strings.Contains(m.StatusMsg(), "Plan mode") { t.Fatalf("status must confirm the toggle, got %q", m.StatusMsg()) } m = sendSlashCommand(m, "/plan") - if m.PlanMode() || strings.Contains(m.View(), "PLAN") { + if m.PlanMode() || strings.Contains(m.StatusBarModelLabel(), "PLAN") { t.Fatal("/plan again must turn plan mode off and drop the badge") } } From c5ee48c7a5a7f5b8f59529fe05716c80ab3b31cd Mon Sep 17 00:00:00 2001 From: Dennison Date: Sun, 6 Sep 2026 14:27:29 -0400 Subject: [PATCH 04/10] test(red): TASK-1407 assistant bubble must fit the terminal width and contain no tabs; streamed-transcript and interrupted-card guards Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5 --- .../messagebubble/width_1407_test.go | 33 ++++++++++ .../tui/streaming_transcript_1407_test.go | 63 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 cmd/harnesscli/tui/components/messagebubble/width_1407_test.go create mode 100644 cmd/harnesscli/tui/streaming_transcript_1407_test.go diff --git a/cmd/harnesscli/tui/components/messagebubble/width_1407_test.go b/cmd/harnesscli/tui/components/messagebubble/width_1407_test.go new file mode 100644 index 00000000..bb0f1c4a --- /dev/null +++ b/cmd/harnesscli/tui/components/messagebubble/width_1407_test.go @@ -0,0 +1,33 @@ +package messagebubble_test + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + + "go-agent-harness/cmd/harnesscli/tui/components/messagebubble" +) + +// Issue #1407: a rendered assistant bubble must never be wider than the +// terminal and must not contain tab characters. Glamour pads lines to its +// wrap width, and the bubble then adds a 4-column indent; the overflowing +// rows wrapped in the terminal and the renderer's bookkeeping cut and merged +// lines ("• Created calc.go w", "ok PASS: calcAdd/0.123s"). +func TestAssistantBubble_FitsWidthAndHasNoTabs(t *testing.T) { + md := "Done. Summary:\n\n- Created `calc.go` with an `Add` function that returns the sum of two integers.\n- Created `calc_test.go` with table-driven tests covering positive, negative, mixed signs, and zeros.\n\n```\nPASS\nok \tcalc\t0.123s\n```\n" + for _, width := range []int{120, 80, 60} { + out := messagebubble.AssistantBubble{Content: md, Width: width}.View() + for i, line := range strings.Split(out, "\n") { + if strings.Contains(line, "\t") { + t.Errorf("width %d line %d contains a tab: %q", width, i, line) + } + if w := lipgloss.Width(line); w > width { + t.Errorf("width %d line %d is %d columns wide: %q", width, i, w, line) + } + } + if !strings.Contains(out, "sum of two integers") || !strings.Contains(out, "mixed signs, and zeros") { + t.Errorf("width %d: content lost:\n%s", width, out) + } + } +} diff --git a/cmd/harnesscli/tui/streaming_transcript_1407_test.go b/cmd/harnesscli/tui/streaming_transcript_1407_test.go new file mode 100644 index 00000000..26473b33 --- /dev/null +++ b/cmd/harnesscli/tui/streaming_transcript_1407_test.go @@ -0,0 +1,63 @@ +package tui_test + +import ( + "encoding/json" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "go-agent-harness/cmd/harnesscli/tui" +) + +// Issue #1407: a streamed markdown answer must end up complete in the +// transcript. Live, "Created calc_test.go with table-driven tests covering +// positive, negative, mixed signs, and zeros." rendered as two fragments with +// the middle missing. +func TestStreamedMarkdown_NothingLost(t *testing.T) { + m := initModel(t, 120, 40) + m = typeIntoModel(m, "make calc") + m = sendKey(m, tea.KeyEnter) + m2, _ := m.Update(tui.RunStartedMsg{RunID: "run-1"}) + m = m2.(tui.Model) + m = sse(m, "run.started", "run-1", `{"prompt":"make calc","step":0}`) + full := "Done. Summary:\n\n- Created `calc.go` with an `Add` function that returns the sum of two integers.\n- Created `calc_test.go` with table-driven tests covering positive, negative, mixed signs, and zeros.\n- `go test -v ./...` — all four subtests pass.\n\n```\n=== RUN TestAdd\n--- PASS: TestAdd (0.00s)\nPASS\nok \tcalc\t0.123s\n```\n" + // Stream in small chunks so the rendered line count changes many times. + acc := "" + for i := 0; i < len(full); i += 7 { + end := i + 7 + if end > len(full) { + end = len(full) + } + acc += full[i:end] + payload, _ := json.Marshal(map[string]any{"delta": full[i:end], "content": full[i:end], "step": 1}) + m = sse(m, "assistant.message.delta", "run-1", string(payload)) + } + payload, _ := json.Marshal(map[string]any{"content": acc, "step": 1}) + m = sse(m, "assistant.message", "run-1", string(payload)) + m = sse(m, "run.completed", "run-1", `{"output":"x","step":1}`) + view := m.View() + for _, want := range []string{"returns the sum of two integers", "mixed signs, and zeros", "all four subtests pass", "--- PASS: TestAdd"} { + if !strings.Contains(stripANSI(view), want) { + t.Errorf("transcript lost %q\n%s", want, view) + } + } +} + +func stripANSI(s string) string { + var b strings.Builder + inEsc := false + for _, r := range s { + switch { + case inEsc: + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { + inEsc = false + } + case r == '\x1b': + inEsc = true + default: + b.WriteRune(r) + } + } + return b.String() +} From d35db02b3b9c8a9193ee2614f06b632908444bb5 Mon Sep 17 00:00:00 2001 From: Dennison Date: Sun, 6 Sep 2026 14:28:38 -0400 Subject: [PATCH 05/10] fix(tui): TASK-1407 render markdown at the indented width, expand tabs, trim padding so assistant bubbles never exceed the terminal width Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5 --- .../tui/components/messagebubble/assistant.go | 27 +++++++++++++++++-- .../messagebubble/width_1407_test.go | 4 ++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/cmd/harnesscli/tui/components/messagebubble/assistant.go b/cmd/harnesscli/tui/components/messagebubble/assistant.go index 4be80f19..3fa13ec4 100644 --- a/cmd/harnesscli/tui/components/messagebubble/assistant.go +++ b/cmd/harnesscli/tui/components/messagebubble/assistant.go @@ -1,6 +1,7 @@ package messagebubble import ( + "github.com/charmbracelet/lipgloss" "strings" "go-agent-harness/cmd/harnesscli/tui/components/streamrenderer" @@ -70,12 +71,19 @@ func (b AssistantBubble) View() string { if b.Content != "" { if looksLikeMarkdown(b.Content) { - // Render via glamour; strip trailing newlines so we control spacing. - rendered := RenderMarkdown(b.Content, width) + // Render via glamour at the width left after the indent, and strip + // trailing newlines so we control spacing. Glamour pads every line + // to its wrap width; rendering at the full width and then indenting + // pushed rows past the terminal edge, where the terminal wrapped + // them and the renderer's row bookkeeping cut and merged lines + // (#1407). Tabs are expanded for the same reason: a terminal skips + // cells over a tab without clearing them. + rendered := RenderMarkdown(b.Content, contentWidth) rendered = strings.TrimRight(rendered, "\n") // Split rendered output into lines for prefix/indent handling. mdLines := strings.Split(rendered, "\n") for i, line := range mdLines { + line = fitLine(line, contentWidth) if b.Title == "" && i == 0 { sb.WriteString(dotRendered) sb.WriteString(" ") @@ -115,3 +123,18 @@ func (b AssistantBubble) View() string { return sb.String() } + +// fitLine expands tabs, drops trailing padding, and guarantees the line is at +// most width columns wide so the terminal never has to wrap it (#1407). +func fitLine(line string, width int) string { + line = strings.ReplaceAll(line, "\t", " ") + line = strings.TrimRight(line, " ") + if width > 0 && lipgloss.Width(line) > width { + runes := []rune(line) + for len(runes) > 0 && lipgloss.Width(string(runes)) > width { + runes = runes[:len(runes)-1] + } + line = string(runes) + } + return line +} diff --git a/cmd/harnesscli/tui/components/messagebubble/width_1407_test.go b/cmd/harnesscli/tui/components/messagebubble/width_1407_test.go index bb0f1c4a..9ae1886e 100644 --- a/cmd/harnesscli/tui/components/messagebubble/width_1407_test.go +++ b/cmd/harnesscli/tui/components/messagebubble/width_1407_test.go @@ -26,7 +26,9 @@ func TestAssistantBubble_FitsWidthAndHasNoTabs(t *testing.T) { t.Errorf("width %d line %d is %d columns wide: %q", width, i, w, line) } } - if !strings.Contains(out, "sum of two integers") || !strings.Contains(out, "mixed signs, and zeros") { + // Narrow widths wrap mid-phrase; compare with whitespace collapsed. + flat := strings.Join(strings.Fields(out), " ") + if !strings.Contains(flat, "sum of two integers") || !strings.Contains(flat, "mixed signs, and zeros") { t.Errorf("width %d: content lost:\n%s", width, out) } } From 318efb586c93976a7e84ef9453fa0c5bf3a69d3e Mon Sep 17 00:00:00 2001 From: Dennison Date: Sun, 6 Sep 2026 14:31:39 -0400 Subject: [PATCH 06/10] test(red): TASK-1407 @name Tab completion must complete bare relative file names Red: want the two calc files, got [] Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5 --- .../tui/filecomplete_bare_1407_test.go | 33 +++++++++++++++++++ cmd/harnesscli/tui/scenarios_1407_test.go | 3 ++ 2 files changed, 36 insertions(+) create mode 100644 cmd/harnesscli/tui/filecomplete_bare_1407_test.go diff --git a/cmd/harnesscli/tui/filecomplete_bare_1407_test.go b/cmd/harnesscli/tui/filecomplete_bare_1407_test.go new file mode 100644 index 00000000..a0d5295a --- /dev/null +++ b/cmd/harnesscli/tui/filecomplete_bare_1407_test.go @@ -0,0 +1,33 @@ +package tui_test + +import ( + "os" + "path/filepath" + "testing" + + "go-agent-harness/cmd/harnesscli/tui" +) + +// Issue #1407: "@cal" + Tab must complete bare relative names, not only +// paths that start with ./, / or ~. A first-time user types the file name. +func TestFilePathCompleter_BareRelativeName(t *testing.T) { + dir := t.TempDir() + for _, f := range []string{"calc.go", "calc_test.go", "go.mod"} { + if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + t.Chdir(dir) + got := tui.FilePathCompleter("Explain what @cal") + if len(got) != 2 { + t.Fatalf("want the two calc files, got %v", got) + } + for _, c := range got { + if c != "Explain what @calc.go" && c != "Explain what @calc_test.go" { + t.Errorf("unexpected completion %q", c) + } + } + if got := tui.FilePathCompleter("say @go.m"); len(got) != 1 || got[0] != "say @go.mod" { + t.Errorf("single bare match must complete fully, got %v", got) + } +} diff --git a/cmd/harnesscli/tui/scenarios_1407_test.go b/cmd/harnesscli/tui/scenarios_1407_test.go index 8dfc667a..052a1da8 100644 --- a/cmd/harnesscli/tui/scenarios_1407_test.go +++ b/cmd/harnesscli/tui/scenarios_1407_test.go @@ -41,6 +41,9 @@ func TestInterruptedTool_CtrlOKeepsOneCard(t *testing.T) { m = sendKey(m, tea.KeyEsc) // interrupt m = sse(m, "run.cancelled", "run-1", `{"step":1}`) before := strings.Count(m.View(), "bash") + if before == 0 { + t.Fatalf("fixture did not render the tool card at all:\n%s", m.View()) + } m = sendKey(m, tea.KeyCtrlO) after := strings.Count(m.View(), "bash") if after > before { From b587078d58ba6216372551d5da3d8eb62ba4792b Mon Sep 17 00:00:00 2001 From: Dennison Date: Sun, 6 Sep 2026 14:31:49 -0400 Subject: [PATCH 07/10] fix(tui): TASK-1407 @name Tab completion works for bare relative file names Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5 --- cmd/harnesscli/tui/filecomplete.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/harnesscli/tui/filecomplete.go b/cmd/harnesscli/tui/filecomplete.go index 6f2fcdc1..cfde4a77 100644 --- a/cmd/harnesscli/tui/filecomplete.go +++ b/cmd/harnesscli/tui/filecomplete.go @@ -53,7 +53,9 @@ func FilePathCompleter(input string) []string { break } } - if !isPathLike { + // A bare name such as "@cal" is a path relative to the working directory: + // that is what a first-time user types (#1407). + if !isPathLike && strings.ContainsAny(partial, " \t") { return nil } From 54455db4089b6e56fda092ab92b63c4b9d7120d6 Mon Sep 17 00:00:00 2001 From: Dennison Date: Sun, 6 Sep 2026 14:34:16 -0400 Subject: [PATCH 08/10] fix(tui): TASK-1407 question box borders sized to the content Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5 --- cmd/harnesscli/tui/askuser.go | 15 ++++++++++++-- cmd/harnesscli/tui/askuser_test.go | 33 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/cmd/harnesscli/tui/askuser.go b/cmd/harnesscli/tui/askuser.go index bff6f0d0..52a7d59a 100644 --- a/cmd/harnesscli/tui/askuser.go +++ b/cmd/harnesscli/tui/askuser.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/charmbracelet/lipgloss" "net/http" "net/url" "strings" @@ -232,7 +233,7 @@ func (m Model) renderAskUserOverlay() []string { q := m.askUser.questions[m.askUser.qIdx] lines := []string{ "", - "┌─ " + q.Header + " ─────────────────────────────────", + "┌─ " + q.Header + " ", "│ " + q.Question, "│", } @@ -257,7 +258,17 @@ func (m Model) renderAskUserOverlay() []string { lines = append(lines, "│ Deadline: expired") } } - lines = append(lines, "└────────────────────────────────────────") + // Size the top and bottom borders to the widest line so the box reads as + // one shape instead of two mismatched rules (#1407). + width := 0 + for _, l := range lines { + if w := lipgloss.Width(l); w > width { + width = w + } + } + width += 2 + lines[0] = lines[0] + strings.Repeat("─", width-lipgloss.Width(lines[0])) + lines = append(lines, "└"+strings.Repeat("─", width-1)) lines = append(lines, "") return lines } diff --git a/cmd/harnesscli/tui/askuser_test.go b/cmd/harnesscli/tui/askuser_test.go index 3910ce7d..d945c634 100644 --- a/cmd/harnesscli/tui/askuser_test.go +++ b/cmd/harnesscli/tui/askuser_test.go @@ -8,6 +8,7 @@ package tui_test import ( "encoding/json" "fmt" + "github.com/charmbracelet/lipgloss" "net/http" "net/http/httptest" "strings" @@ -712,3 +713,35 @@ func activateAskUserPending( next, _ = model.Update(pending) return next.(tui.Model) } + +// Issue #1407: the question box's top and bottom borders must be the same +// width so it reads as one box. +func TestAskUser_Overlay_BordersMatch(t *testing.T) { + m := initModel(t, 80, 24) + m = m.WithCancelRun(func() {}) + m2, _ := m.Update(tui.RunStartedMsg{RunID: "run-border-1"}) + model := m2.(tui.Model) + pending := tui.AskUserPendingMsg{ + RunID: "run-border-1", CallID: "call-b1", + Questions: []tui.AskUserQuestion{{Question: "Which framework should I use?", Header: "Framework", + Options: []tui.AskUserOption{{Label: "net/http", Description: "standard library"}, {Label: "gin", Description: "gin-gonic router"}}}}, + DeadlineAt: time.Now().Add(5 * time.Minute), + } + model = activateAskUserPending(t, model, pending, 1) + var top, bottom string + for _, line := range strings.Split(model.View(), "\n") { + trimmed := strings.TrimRight(line, " ") + if strings.HasPrefix(trimmed, "┌") { + top = trimmed + } + if strings.HasPrefix(trimmed, "└") { + bottom = trimmed + } + } + if top == "" || bottom == "" { + t.Fatalf("question box borders not found:\n%s", model.View()) + } + if lipgloss.Width(top) != lipgloss.Width(bottom) { + t.Fatalf("border widths differ: top %d vs bottom %d\n%s\n%s", lipgloss.Width(top), lipgloss.Width(bottom), top, bottom) + } +} From dafe74ccca7c899e2e5df8c73c801f4029765f98 Mon Sep 17 00:00:00 2001 From: Dennison Date: Sun, 6 Sep 2026 14:35:25 -0400 Subject: [PATCH 09/10] fix(tui): TASK-1407 extend the question box top border on the actual border line Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5 --- cmd/harnesscli/tui/askuser.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/harnesscli/tui/askuser.go b/cmd/harnesscli/tui/askuser.go index 52a7d59a..9a551edc 100644 --- a/cmd/harnesscli/tui/askuser.go +++ b/cmd/harnesscli/tui/askuser.go @@ -267,7 +267,12 @@ func (m Model) renderAskUserOverlay() []string { } } width += 2 - lines[0] = lines[0] + strings.Repeat("─", width-lipgloss.Width(lines[0])) + for i, l := range lines { + if strings.HasPrefix(l, "┌") { + lines[i] = l + strings.Repeat("─", width-lipgloss.Width(l)) + break + } + } lines = append(lines, "└"+strings.Repeat("─", width-1)) lines = append(lines, "") return lines From f45ebb43af88984163a84ee6fe3e25016023f622 Mon Sep 17 00:00:00 2001 From: Dennison Date: Sun, 6 Sep 2026 14:42:55 -0400 Subject: [PATCH 10/10] docs: TASK-1407 plan mode, @ completion, question box, bubble width; engineering-log entry Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5 --- docs/logs/engineering-log.md | 5 +++++ website/docs/cli/tui.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/docs/logs/engineering-log.md b/docs/logs/engineering-log.md index 9f49fd81..d8279ad4 100644 --- a/docs/logs/engineering-log.md +++ b/docs/logs/engineering-log.md @@ -1,5 +1,10 @@ # Engineering Log +## 2026-09-06 — TUI scenario walk: plan mode, @ completion, question box, bubble width (#1407) + +- Twelve multi-step TUI scenarios driven live in tmux (fake provider with scripted streaming/tool turns, and OpenRouter DeepSeek). Fixes: `/plan` command toggles enforced plan mode with a `PLAN` status badge and `harnesscli --tui --plan-mode` now honored (`runTUI` passes the flag into `TUIConfig`); `@name` Tab completion completes bare relative file names, not only `./`, `/`, `~/` paths; the AskUserQuestion and Plan-Approval boxes size their top/bottom borders to the content instead of a fixed 40-col rule; assistant markdown bubbles render at the indented width, expand tabs, and trim padding so a bubble never exceeds the terminal width. Guards added: streamed-transcript integrity and no duplicate tool card on ctrl+o after interrupt. +- Filed separately: the streamed-markdown truncation under a real color profile is a streaming re-render accounting defect (`ReplaceTailLines` vs. glamour reflow as `looksLikeMarkdown` flips), not the bubble width; not fixed here. + ## 2026-09-06 — Settings overlays read wrong to a first-time user (#1405) - Symptom: `/cost` showed `↑ 0 in ↓ 15,760 out` after a run (the TUI only tracked a single total and passed it as output); `/profiles` wrapped its highlighted row mid-word; `/config` cut values at 20 characters with no ellipsis (the model id read as `deepseek/deepseek-v4`) and never explained `[RO]`, and showed an empty model cell before a model was chosen; `/permissions` drew a stray `──` line because its separator was as wide as the terminal inside a narrower box. diff --git a/website/docs/cli/tui.md b/website/docs/cli/tui.md index 4c2ef7d0..dcd05f13 100644 --- a/website/docs/cli/tui.md +++ b/website/docs/cli/tui.md @@ -145,6 +145,11 @@ While a run is in flight, type corrective input and press `Ctrl+G` to inject it Type `/` to open the command menu. `↑`/`↓` move the highlight, `Enter` runs the highlighted command, `Tab` completes it into the input without running it, and `Esc` closes the menu (a second `Esc` clears the input). A bare `/` plus `Enter` does not run anything; type part of a name or move the highlight first. When nothing matches, the menu says so instead of disappearing; `Enter` then shows the unknown-command hint. Descriptions are shortened with `…` on narrow terminals, and the menu takes its rows from the top of the transcript so the input and status bar never move. Commands are case-insensitive. +:::note Plan mode +`/plan` toggles plan mode: the agent may only edit `.harness/plan.md` and must present a plan for your approval before touching other files. The status bar shows a `PLAN` badge while it is on. `harnesscli --tui --plan-mode` starts in plan mode. (`ctrl+o` also toggles plan mode, but only when no tool call has run yet in the session — otherwise it expands the tool-call group.) +::: + + :::note What the model picker shows Provider rows end with `(n)` (number of models) and `●` (ready: an API key is configured) or `○` (needs an API key); `[R]` marks reasoning models. Selecting a model whose provider is not set up opens the API Keys panel with an explanation of what is missing. While any panel is open, typed text is not sent to the chat input (press `Esc` first). The key form rejects values that cannot be keys, such as text with spaces or anything starting with `/`. :::