From 0d782b88dedcc9c4bf2ea7dc42286b25a872005b Mon Sep 17 00:00:00 2001 From: Dennison Date: Sun, 6 Sep 2026 13:57:15 -0400 Subject: [PATCH 1/3] =?UTF-8?q?test(red):=20TASK-1405=20failing=20tests=20?= =?UTF-8?q?=E2=80=94=20/cost=20in/out=20tokens,=20/config=20ellipsis=20and?= =?UTF-8?q?=20[RO]=20legend,=20/permissions=20stray=20separator;=20profile?= =?UTF-8?q?s=20width=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red output: /cost must show 15,000 in and 700 out; /config must not cut the model id silently; stray separator line in /permissions: "│──". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5 --- .../tui/settings_polish_1405_test.go | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 cmd/harnesscli/tui/settings_polish_1405_test.go diff --git a/cmd/harnesscli/tui/settings_polish_1405_test.go b/cmd/harnesscli/tui/settings_polish_1405_test.go new file mode 100644 index 00000000..88986de4 --- /dev/null +++ b/cmd/harnesscli/tui/settings_polish_1405_test.go @@ -0,0 +1,79 @@ +package tui_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + + "go-agent-harness/cmd/harnesscli/tui" +) + +// Issue #1405: settings overlays must read correctly to a first-time user. + +// /cost must show prompt tokens as "in" and completion tokens as "out". +func TestCost_ShowsPromptAndCompletionTokens(t *testing.T) { + m := initModel(t, 120, 40) + raw := `{"turn_usage":{"prompt_tokens":15000,"completion_tokens":700,"total_tokens":15700},` + + `"cumulative_usage":{"prompt_tokens":15000,"completion_tokens":700,"total_tokens":15700},"cumulative_cost_usd":0.0069}` + m2, _ := m.Update(tui.SSEEventMsg{EventType: "usage.delta", Raw: json.RawMessage(raw), RunID: "run-1"}) + m = m2.(tui.Model) + m = sendSlashCommand(m, "/cost") + view := m.View() + if !strings.Contains(view, "15,000 in") || !strings.Contains(view, "700 out") { + t.Fatalf("/cost must show 15,000 in and 700 out, got:\n%s", view) + } + if strings.Contains(view, "0 in") { + t.Fatalf("/cost must not report 0 input tokens after a run with prompt tokens:\n%s", view) + } +} + +// /profiles must never wrap its highlighted row. +func TestProfilePicker_SelectedRowFitsWidth(t *testing.T) { + m := initModel(t, 120, 40) + m = sendSlashCommand(m, "/profiles") + m2, _ := m.Update(tui.ProfilesLoadedMsg{Entries: []tui.ProfileEntry{ + {Name: "bash-runner", Model: "gpt-4.1-mini", SourceTier: "built-in", Description: "Script execution, pipeline tasks"}, + {Name: "full", Model: "gpt-4.1-mini", SourceTier: "built-in", Description: "Default — all tools available"}, + }}) + m = m2.(tui.Model) + for _, line := range strings.Split(m.View(), "\n") { + if w := lipgloss.Width(line); w > 120 { + t.Fatalf("profiles row wider than the terminal (%d): %q", w, line) + } + } + if !strings.Contains(m.View(), "built-in") || strings.Contains(m.View(), "built-\n") { + t.Fatalf("highlighted profile row must not wrap mid-word:\n%s", m.View()) + } +} + +// /config must not cut values silently and must explain [RO]. +func TestConfigPanel_ValuesEllipsisAndROLegend(t *testing.T) { + m := initModel(t, 120, 40) + m2, _ := m.Update(tui.ModelSelectedMsg{ModelID: "deepseek/deepseek-v4-pro-with-a-long-suffix-x", Provider: "openrouter"}) + m = m2.(tui.Model) + m = sendSlashCommand(m, "/config") + view := m.View() + if strings.Contains(view, "deepseek/deepseek-v4 ") && !strings.Contains(view, "…") { + t.Fatalf("/config must not cut the model id silently:\n%s", view) + } + if !strings.Contains(view, "deepseek/deepseek-v4-pro") { + t.Fatalf("/config should have room for the model id at 120 columns:\n%s", view) + } + if !strings.Contains(view, "read-only") { + t.Fatalf("/config must explain the [RO] badge:\n%s", view) + } +} + +// /permissions must not draw a stray separator. +func TestPermissionsPanel_NoStraySeparator(t *testing.T) { + m := initModel(t, 120, 40) + m = sendSlashCommand(m, "/permissions") + for _, line := range strings.Split(m.View(), "\n") { + trimmed := strings.TrimSpace(strings.Trim(strings.TrimSpace(line), "│")) + if trimmed == "──" || trimmed == "─" { + t.Fatalf("stray separator line in /permissions: %q\n%s", line, m.View()) + } + } +} From ee6e8ae87125eab546cc9775fa4de08b08f8ec78 Mon Sep 17 00:00:00 2001 From: Dennison Date: Sun, 6 Sep 2026 14:02:16 -0400 Subject: [PATCH 2/3] fix(tui): TASK-1405 /cost shows prompt and completion tokens, profile rows fit the box, /config values get an ellipsis and a wider column with a [RO] legend, /permissions separator fits its box Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5 --- .../testdata/snapshots/TUI-051-config-120x40.txt | 2 +- .../testdata/snapshots/TUI-051-config-200x50.txt | 2 +- .../testdata/snapshots/TUI-051-config-80x24.txt | 2 +- cmd/harnesscli/tui/components/configpanel/view.go | 15 ++++++++++----- .../tui/components/profilepicker/view.go | 8 +++++--- cmd/harnesscli/tui/context_usage.go | 8 +++++++- cmd/harnesscli/tui/model.go | 11 +++++++++-- cmd/harnesscli/tui/settings_polish_1405_test.go | 2 +- 8 files changed, 35 insertions(+), 15 deletions(-) diff --git a/cmd/harnesscli/tui/components/configpanel/testdata/snapshots/TUI-051-config-120x40.txt b/cmd/harnesscli/tui/components/configpanel/testdata/snapshots/TUI-051-config-120x40.txt index 9b6899b4..cbbf2cad 100644 --- a/cmd/harnesscli/tui/components/configpanel/testdata/snapshots/TUI-051-config-120x40.txt +++ b/cmd/harnesscli/tui/components/configpanel/testdata/snapshots/TUI-051-config-120x40.txt @@ -35,6 +35,6 @@ │ │ │ │ │ │ - │ [Enter] edit [Esc] close │ + │ [Enter] edit [Esc] close [RO] read-only │ ╰──────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/cmd/harnesscli/tui/components/configpanel/testdata/snapshots/TUI-051-config-200x50.txt b/cmd/harnesscli/tui/components/configpanel/testdata/snapshots/TUI-051-config-200x50.txt index a3ffe55a..0460e467 100644 --- a/cmd/harnesscli/tui/components/configpanel/testdata/snapshots/TUI-051-config-200x50.txt +++ b/cmd/harnesscli/tui/components/configpanel/testdata/snapshots/TUI-051-config-200x50.txt @@ -45,6 +45,6 @@ │ │ │ │ │ │ - │ [Enter] edit [Esc] close │ + │ [Enter] edit [Esc] close [RO] read-only │ ╰──────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/cmd/harnesscli/tui/components/configpanel/testdata/snapshots/TUI-051-config-80x24.txt b/cmd/harnesscli/tui/components/configpanel/testdata/snapshots/TUI-051-config-80x24.txt index 2879966b..2277d9d0 100644 --- a/cmd/harnesscli/tui/components/configpanel/testdata/snapshots/TUI-051-config-80x24.txt +++ b/cmd/harnesscli/tui/components/configpanel/testdata/snapshots/TUI-051-config-80x24.txt @@ -19,6 +19,6 @@ │ │ │ │ │ │ - │ [Enter] edit [Esc] close │ + │ [Enter] edit [Esc] close [RO] read-only │ ╰────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/cmd/harnesscli/tui/components/configpanel/view.go b/cmd/harnesscli/tui/components/configpanel/view.go index c580826f..5efd307f 100644 --- a/cmd/harnesscli/tui/components/configpanel/view.go +++ b/cmd/harnesscli/tui/components/configpanel/view.go @@ -131,8 +131,13 @@ func renderContent(m Model, width, maxLines int) string { maxValLen = valLen } } - // Cap value column to avoid overflow. - maxValDisplay := 20 + // Cap the value column to what the dialog can show: prefix (2), key, + // gaps (2+2), dirty marker (1), badge "[RO]" (4) and a margin. Anything + // longer is shortened with an ellipsis rather than cut silently (#1405). + maxValDisplay := width - maxKeyLen - 14 + if maxValDisplay < 20 { + maxValDisplay = 20 + } if maxValLen > maxValDisplay { maxValLen = maxValDisplay } @@ -182,8 +187,8 @@ func renderRow(e ConfigEntry, selected, editing bool, editBuf string, maxKeyLen, valStr = fmt.Sprintf("%-*s", maxValLen, editBuf+"_") } else { v := e.Value - if len(v) > maxValLen { - v = v[:maxValLen] + if r := []rune(v); len(r) > maxValLen { + v = string(r[:maxValLen-1]) + "…" } valStr = fmt.Sprintf("%-*s", maxValLen, v) } @@ -233,7 +238,7 @@ func renderFooter(m Model, width int) string { if m.editing { hint = "[Enter] commit [Esc] cancel" } else { - hint = "[Enter] edit [Esc] close" + hint = "[Enter] edit [Esc] close [RO] read-only" } return lipgloss.NewStyle(). Width(width). diff --git a/cmd/harnesscli/tui/components/profilepicker/view.go b/cmd/harnesscli/tui/components/profilepicker/view.go index ceded83c..43b1ef2c 100644 --- a/cmd/harnesscli/tui/components/profilepicker/view.go +++ b/cmd/harnesscli/tui/components/profilepicker/view.go @@ -28,8 +28,10 @@ func (m Model) View() string { width = 80 } - // Inner content width: rounded border uses 2 cols (border+space) on each side. - const padding = 4 + // Inner content width: the box takes 1 border column and 1 padding + // column on each side (4 total), and lipgloss counts the padding inside + // Width, so rows must be two columns narrower than the box (#1405). + const padding = 6 innerWidth := width - padding if innerWidth < 20 { innerWidth = 20 @@ -139,7 +141,7 @@ func (m Model) View() string { boxStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). Padding(0, 1). - Width(innerWidth) + Width(innerWidth + 2) return boxStyle.Render(sb.String()) } diff --git a/cmd/harnesscli/tui/context_usage.go b/cmd/harnesscli/tui/context_usage.go index 3eb29e88..daea1fa7 100644 --- a/cmd/harnesscli/tui/context_usage.go +++ b/cmd/harnesscli/tui/context_usage.go @@ -29,7 +29,9 @@ func (m *Model) applyUsageDelta(raw []byte) { CompletionTokens int `json:"completion_tokens"` } `json:"turn_usage"` CumulativeUsage struct { - TotalTokens int `json:"total_tokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` } `json:"cumulative_usage"` CumulativeCostUSD float64 `json:"cumulative_cost_usd"` } @@ -41,6 +43,10 @@ func (m *Model) applyUsageDelta(raw []byte) { m.statusBar.SetCost(m.cumulativeCostUSD) // totalTokens stays cumulative: it feeds cost and accounting surfaces. m.totalTokens = p.CumulativeUsage.TotalTokens + // Keep the in/out split for /cost (#1405): before this, the total was + // shown as "out" and "in" was always 0. + m.promptTokens = p.CumulativeUsage.PromptTokens + m.completionTokens = p.CumulativeUsage.CompletionTokens m.usageDataPoints = upsertTodayDataPoint(m.usageDataPoints, 1, p.CumulativeCostUSD) m.statsPanel = statspanel.New(m.usageDataPoints) diff --git a/cmd/harnesscli/tui/model.go b/cmd/harnesscli/tui/model.go index b7bfb801..5f862908 100644 --- a/cmd/harnesscli/tui/model.go +++ b/cmd/harnesscli/tui/model.go @@ -269,6 +269,9 @@ type Model struct { // accounting surfaces. It is NOT context occupancy — see // contextOccupancyTokens. totalTokens int + // promptTokens/completionTokens split totalTokens for /cost (#1405). + promptTokens int + completionTokens int // contextOccupancyTokens is the latest turn's prompt plus completion: what // the next request will actually carry in the context window (issue #1307). contextOccupancyTokens int @@ -2070,7 +2073,8 @@ func executeStatsCommand(m *Model, _ Command) ([]tea.Cmd, bool) { // surfaced as OutputTokens rather than fabricating a breakdown. func costSnapshotFromModel(m *Model) costdisplay.CostSnapshot { return costdisplay.CostSnapshot{ - OutputTokens: m.totalTokens, + InputTokens: m.promptTokens, + OutputTokens: m.completionTokens, TotalCostUSD: m.cumulativeCostUSD, Model: m.selectedModel, } @@ -5601,7 +5605,10 @@ func (m Model) View() string { case "search": mainContent = m.viewSearchOverlay() case "permissions": - m.permissionsPanel.Width = m.width + // boxOverlay draws a border around the panel, so the panel's own + // separator must be narrower than the terminal or it wraps into a + // stray "──" line (#1405). + m.permissionsPanel.Width = m.width - 4 m.permissionsPanel.Height = m.layout.ViewportHeight raw := m.permissionsPanel.View() mainContent = boxOverlay(raw, m.width) diff --git a/cmd/harnesscli/tui/settings_polish_1405_test.go b/cmd/harnesscli/tui/settings_polish_1405_test.go index 88986de4..09aad153 100644 --- a/cmd/harnesscli/tui/settings_polish_1405_test.go +++ b/cmd/harnesscli/tui/settings_polish_1405_test.go @@ -24,7 +24,7 @@ func TestCost_ShowsPromptAndCompletionTokens(t *testing.T) { if !strings.Contains(view, "15,000 in") || !strings.Contains(view, "700 out") { t.Fatalf("/cost must show 15,000 in and 700 out, got:\n%s", view) } - if strings.Contains(view, "0 in") { + if strings.Contains(view, "↑ 0 in") { t.Fatalf("/cost must not report 0 input tokens after a run with prompt tokens:\n%s", view) } } From 65420f19a2645a82805c7a1e31fee4144651a3e4 Mon Sep 17 00:00:00 2001 From: Dennison Date: Sun, 6 Sep 2026 14:03:26 -0400 Subject: [PATCH 3/3] fix(tui): TASK-1405 /config shows a server-default placeholder for the model; engineering-log entry Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5 --- cmd/harnesscli/tui/model.go | 5 +++++ cmd/harnesscli/tui/settings_polish_1405_test.go | 9 +++++++++ docs/logs/engineering-log.md | 5 +++++ 3 files changed, 19 insertions(+) diff --git a/cmd/harnesscli/tui/model.go b/cmd/harnesscli/tui/model.go index 5f862908..b3da0222 100644 --- a/cmd/harnesscli/tui/model.go +++ b/cmd/harnesscli/tui/model.go @@ -2110,6 +2110,11 @@ func configEntriesFromModel(m *Model) []configpanel.ConfigEntry { if model == "" { model = m.config.Model } + if model == "" { + // Nothing chosen in this session and no --model flag: the daemon's + // default applies. Say so instead of showing an empty cell (#1405). + model = "(server default — use /model to choose)" + } return []configpanel.ConfigEntry{ {Key: "base_url", Value: m.config.BaseURL, Description: "harnessd server URL", ReadOnly: true}, {Key: "model", Value: model, Description: "Active LLM model", ReadOnly: true}, diff --git a/cmd/harnesscli/tui/settings_polish_1405_test.go b/cmd/harnesscli/tui/settings_polish_1405_test.go index 09aad153..789ca9be 100644 --- a/cmd/harnesscli/tui/settings_polish_1405_test.go +++ b/cmd/harnesscli/tui/settings_polish_1405_test.go @@ -66,6 +66,15 @@ func TestConfigPanel_ValuesEllipsisAndROLegend(t *testing.T) { } } +// /config must not show an empty model cell before a model is chosen. +func TestConfigPanel_ModelPlaceholderWhenUnset(t *testing.T) { + m := initModel(t, 120, 40) + m = sendSlashCommand(m, "/config") + if !strings.Contains(m.View(), "server default") { + t.Fatalf("/config must say the server default applies when no model is chosen:\n%s", m.View()) + } +} + // /permissions must not draw a stray separator. func TestPermissionsPanel_NoStraySeparator(t *testing.T) { m := initModel(t, 120, 40) diff --git a/docs/logs/engineering-log.md b/docs/logs/engineering-log.md index 8b5c0053..9f49fd81 100644 --- a/docs/logs/engineering-log.md +++ b/docs/logs/engineering-log.md @@ -1,5 +1,10 @@ # Engineering Log +## 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. +- Fix: `applyUsageDelta` keeps cumulative prompt/completion tokens for the cost snapshot; the profile picker sizes rows to the box content area; the config panel widens the value column to the dialog, ends cut values with `…`, adds `[RO] read-only` to the footer and a "(server default — use /model to choose)" placeholder; the permissions panel is sized to the overlay box. Config snapshot goldens regenerated. Live tmux captures in PR. + ## 2026-09-06 — A chat message could be saved as an API key (#1403) - Symptom: in the TUI, selecting a model whose provider had no key jumped to the API Keys panel with no explanation; letters typed while the panel was open fell through into the chat input; Enter then opened the key form, and the next text plus Enter (`/model`) was stored as the DeepSeek key both client-side (`~/.config/harnesscli/config.json`) and on the daemon. Keys rows also wrapped inside the box and `kimi-subscription` was labelled "ChatGPT subscription"; the picker had no legend for `●/○/(n)` and sorted providers case-sensitively.