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
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,6 @@
│ │
│ │
│ │
[Enter] edit [Esc] close
│ [Enter] edit [Esc] close [RO] read-only
╰──────────────────────────────────────────────────────────────────────────────╯

Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,6 @@
│ │
│ │
│ │
[Enter] edit [Esc] close
│ [Enter] edit [Esc] close [RO] read-only
╰──────────────────────────────────────────────────────────────────────────────╯

Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@
│ │
│ │
│ │
[Enter] edit [Esc] close
│ [Enter] edit [Esc] close [RO] read-only
╰────────────────────────────────────────────────────────────────────────────╯

15 changes: 10 additions & 5 deletions cmd/harnesscli/tui/components/configpanel/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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).
Expand Down
8 changes: 5 additions & 3 deletions cmd/harnesscli/tui/components/profilepicker/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
}
Expand Down
8 changes: 7 additions & 1 deletion cmd/harnesscli/tui/context_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand All @@ -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)

Expand Down
16 changes: 14 additions & 2 deletions cmd/harnesscli/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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)
Expand Down
88 changes: 88 additions & 0 deletions cmd/harnesscli/tui/settings_polish_1405_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
}
5 changes: 5 additions & 0 deletions docs/logs/engineering-log.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Engineering Log

## 2026-09-06 — Settings overlays read wrong to a first-time user (#1405)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the new log entry to the folder index

This commit adds the durable #1405 engineering-log entry while leaving docs/logs/INDEX.md unchanged, so the folder index does not expose the new record. Update the logs index in the same change.

AGENTS.md reference: AGENTS.md:L56-L56

Useful? React with 👍 / 👎.


- 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record this task's success criteria

A repository-wide search finds no #1405 entry or corresponding success definition in docs/logs/long-term-thinking-log.md, even though this entry records the completed implementation. Add the command intent, user intent, and success criteria there so the task's completion contract is durable.

AGENTS.md reference: AGENTS.md:L23-L23

Useful? React with 👍 / 👎.


## 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.
Expand Down
Loading