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..b3da0222 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, } @@ -2106,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}, @@ -5601,7 +5610,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 new file mode 100644 index 00000000..789ca9be --- /dev/null +++ b/cmd/harnesscli/tui/settings_polish_1405_test.go @@ -0,0 +1,88 @@ +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) + } +} + +// /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) + 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()) + } + } +} 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.