diff --git a/cmd/harnesscli/main.go b/cmd/harnesscli/main.go index 34882b392..5b9c3dcc8 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/main_tui_test.go b/cmd/harnesscli/main_tui_test.go index 94520e798..a415f7f84 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/askuser.go b/cmd/harnesscli/tui/askuser.go index bff6f0d0a..9a551edc3 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,22 @@ 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 + 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 } diff --git a/cmd/harnesscli/tui/askuser_test.go b/cmd/harnesscli/tui/askuser_test.go index 3910ce7d4..d945c6346 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) + } +} diff --git a/cmd/harnesscli/tui/cmd_parser.go b/cmd/harnesscli/tui/cmd_parser.go index 3d1b0b547..cfd8cd837 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/cmd_parser_test.go b/cmd/harnesscli/tui/cmd_parser_test.go index eb5b96f05..8b6b8994b 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/components/messagebubble/assistant.go b/cmd/harnesscli/tui/components/messagebubble/assistant.go index 4be80f190..3fa13ec43 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 new file mode 100644 index 000000000..9ae1886ed --- /dev/null +++ b/cmd/harnesscli/tui/components/messagebubble/width_1407_test.go @@ -0,0 +1,35 @@ +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) + } + } + // 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) + } + } +} diff --git a/cmd/harnesscli/tui/config.go b/cmd/harnesscli/tui/config.go index 0d669b826..e0eb2718f 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/filecomplete.go b/cmd/harnesscli/tui/filecomplete.go index 6f2fcdc13..cfde4a77e 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 } 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 000000000..a0d5295a5 --- /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/model.go b/cmd/harnesscli/tui/model.go index b3da02220..02f66fe93 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 } @@ -799,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() } @@ -1971,6 +1975,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 +3169,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 +5878,14 @@ 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). + 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 new file mode 100644 index 000000000..9139a3647 --- /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.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.StatusBarModelLabel(), "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 000000000..052a1da84 --- /dev/null +++ b/cmd/harnesscli/tui/scenarios_1407_test.go @@ -0,0 +1,52 @@ +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") + 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 { + t.Fatalf("ctrl+o duplicated the interrupted tool card (bash mentions %d -> %d):\n%s", before, after, m.View()) + } +} 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 000000000..26473b33f --- /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() +} 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 2e073ad29..2437c316f 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 2fca58bae..643ac80a3 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 f85d0f109..e19f77c55 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 diff --git a/docs/logs/engineering-log.md b/docs/logs/engineering-log.md index 9f49fd81a..d8279ad4d 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 4c2ef7d0d..dcd05f13b 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 `/`. :::