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
5 changes: 3 additions & 2 deletions cmd/harnesscli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/harnesscli/main_tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
20 changes: 18 additions & 2 deletions cmd/harnesscli/tui/askuser.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/charmbracelet/lipgloss"
"net/http"
"net/url"
"strings"
Expand Down Expand Up @@ -232,7 +233,7 @@ func (m Model) renderAskUserOverlay() []string {
q := m.askUser.questions[m.askUser.qIdx]
lines := []string{
"",
"┌─ " + q.Header + " ─────────────────────────────────",
"┌─ " + q.Header + " ",
"│ " + q.Question,
"│",
}
Expand All @@ -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
}
33 changes: 33 additions & 0 deletions cmd/harnesscli/tui/askuser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package tui_test
import (
"encoding/json"
"fmt"
"github.com/charmbracelet/lipgloss"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -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)
}
}
8 changes: 8 additions & 0 deletions cmd/harnesscli/tui/cmd_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions cmd/harnesscli/tui/cmd_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 25 additions & 2 deletions cmd/harnesscli/tui/components/messagebubble/assistant.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package messagebubble

import (
"github.com/charmbracelet/lipgloss"
"strings"

"go-agent-harness/cmd/harnesscli/tui/components/streamrenderer"
Expand Down Expand Up @@ -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(" ")
Expand Down Expand Up @@ -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
}
35 changes: 35 additions & 0 deletions cmd/harnesscli/tui/components/messagebubble/width_1407_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 2 additions & 0 deletions cmd/harnesscli/tui/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion cmd/harnesscli/tui/filecomplete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
33 changes: 33 additions & 0 deletions cmd/harnesscli/tui/filecomplete_bare_1407_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
30 changes: 29 additions & 1 deletion cmd/harnesscli/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"))
}
Expand Down Expand Up @@ -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 + ")"
}
Expand Down
50 changes: 50 additions & 0 deletions cmd/harnesscli/tui/plan_command_1407_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading