diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aa0876..d62c583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- Auto-summarization settings now save as soon as a changed row is + applied, including cadence/provider/toggle changes and model edits, + without requiring a separate save step. +- Auto-summarization setting rows now visually separate grey labels + from regular-colour values. +- The active-thread summary in the tab bar is now constrained to the + active tab's width instead of spanning the whole top row. +- Sidebar summary text now wraps from the full summary text instead of + using an ellipsized single-line value. + +### Fixed +- Removed the redundant "Back to Settings" row from the + Agents / Auto-summarization settings screen. + ## [0.0.6] - 2026-05-15 ### Changed diff --git a/fucked-up-terminal-3.txt b/fucked-up-terminal-3.txt deleted file mode 100644 index a7987bc..0000000 --- a/fucked-up-terminal-3.txt +++ /dev/null @@ -1,61 +0,0 @@ - claude + new │ Processes -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────│ ───────────────────────── - - abc1234 if no tag exists yet - - 4. Wire version into the release workflow - - Update .gitea/workflows/release.yml lines 31-35 to inject the pushed tag: - - go build -trimpath \ - -ldflags="-s -w -X main.version=${{ github.ref_name }}" \ - -o dist/patterm-${{ github.ref_name }}-linux-amd64 \ - ./cmd/patterm - - github.ref_name is the tag name (e.g. v0.0.1) because the workflow only - triggers on tags: ['v*']. - - 5. Update inline doc comment - - cmd/patterm/main.go header comment (lines 5-11) — add the --version form - to the usage block. SPEC.md/CLAUDE.md already use --, no change needed there. - - Out of scope - - - Surfacing version in MCP whoami (the hardcoded "version": "0.1.0" in - internal/mcp/protocol.go:27 is the MCP protocol version, not the patterm - binary version — leave it). - - Renaming any existing flags. - - Adding short forms like -p for --project. - - Critical files - - - cmd/patterm/main.go — import swap, --version wiring, version var, header comment - - cmd/patterm/debug_harness.go — import swap - - Makefile lines 38-39 — VERSION var + ldflags - - .gitea/workflows/release.yml lines 31-35 — ldflags - - go.mod / go.sum — add github.com/spf13/pflag - - Verification - - 1. go build -o ./bin/patterm ./cmd/patterm (without Makefile) → still builds, version reports dev. - 2. make patterm → ./bin/patterm --version prints patterm v0.0.1 (commit , built ). - 3. ./bin/patterm -h → help text shows --project string and --version lines. - 4. ./bin/patterm -project /tmp → pflag rejects with usage error (confirms -- is enforced). - 5. ./bin/patterm --project /tmp → starts normally. - 6. ./bin/patterm mcp-stdio --socket /tmp/s --identity x → existing path still works (will fail to connect, but should parse flags fine). - 7. ./bin/patterm debug-harness --scenario internal/harness/scenarios/spawn_process_via_palette.json → harness still runs. - 8. go test ./... and go test ./internal/harness/... — both green. - 9. Push a temporary tag locally and inspect git describe output; confirm release workflow's ${{ github.ref_name }} substitution matches the tag. -╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - - Claude has written up a plan and is ready to execute. Would you like to proceed? - - ❯ 1. Yes, and use auto mode - 2. Yes, manually approve edits - 3. No, refine with Ultraplan on Claude Code on the web - 4. Tell Claude what to change - shift+tab to approve with this feedback - - ctrl-g to edit in VS Code · ~/.claude/plans/flags-in-this-project-vectorized-gosling.md - -claude · you have control Ctrl-A/D · tabs · Ctrl-W/S · tree · Ctrl-K · palette diff --git a/internal/app/app.go b/internal/app/app.go index 86e122f..baf21a6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -505,7 +505,18 @@ func (st *uiState) dbgf(format string, args ...any) { } func (st *uiState) activeSummaryText(width int) string { - if width <= 0 || st.summaries == nil { + text := st.activeSummaryRaw() + if text == "" || width <= 0 { + return "" + } + if visibleLen(text) > width { + text = clipRunes(text, width-1) + "…" + } + return text +} + +func (st *uiState) activeSummaryRaw() string { + if st.summaries == nil { return "" } st.settingsMu.Lock() @@ -525,9 +536,6 @@ func (st *uiState) activeSummaryText(width int) string { if text == "" { return "" } - if visibleLen(text) > width { - text = clipRunes(text, width-1) + "…" - } return text } @@ -1626,6 +1634,11 @@ func (st *uiState) processStdin(chunk []byte) { adv = 1 } i += adv + if action.kind == "settings-save" { + st.applySettingsAction(action) + st.renderPaletteLocked() + continue + } if done { a := action pendingAction = &a diff --git a/internal/app/palette.go b/internal/app/palette.go index 5eef1ae..fbaa237 100644 --- a/internal/app/palette.go +++ b/internal/app/palette.go @@ -1277,8 +1277,11 @@ func (p *paletteState) handleSettingsTextInput(chunk []byte, i int) (paletteActi } switch b { case '\r', '\n': - p.applySettingsInput() + changed := p.applySettingsInput() p.mode = paletteModeAutoSummary + if changed { + return p.settingsAction("settings-save"), false, 1 + } case 0x7f, 0x08: if len(p.settingsInput.value) > 0 { p.settingsInput.value = p.settingsInput.value[:len(p.settingsInput.value)-1] @@ -1310,7 +1313,6 @@ func autoSummaryRows() []autoSummaryRow { {key: "run_now", label: "Summarize current top-level agent now"}, {key: "save", label: "Save settings"}, {key: "cancel", label: "Cancel"}, - {key: "back", label: "Back to Settings"}, } } @@ -1322,6 +1324,8 @@ func (p *paletteState) activateAutoSummaryRow() (paletteAction, bool, int) { switch rows[p.cursor].key { case "enabled": p.settings.AutoSummary.Enabled = !p.settings.AutoSummary.Enabled + p.settings.normalize() + return p.settingsAction("settings-save"), false, 1 case "provider": switch p.settings.AutoSummary.Provider { case "codex": @@ -1331,6 +1335,8 @@ func (p *paletteState) activateAutoSummaryRow() (paletteAction, bool, int) { default: p.settings.AutoSummary.Provider = "codex" } + p.settings.normalize() + return p.settingsAction("settings-save"), false, 1 case "codex_model", "opencode_model", "claude_model": provider := strings.TrimSuffix(rows[p.cursor].key, "_model") p.settingsInput = &settingsInputForm{ @@ -1349,6 +1355,8 @@ func (p *paletteState) activateAutoSummaryRow() (paletteAction, bool, int) { default: p.settings.AutoSummary.Cadence = "15s" } + p.settings.normalize() + return p.settingsAction("settings-save"), false, 1 case "test": return p.settingsAction("settings-test"), true, 1 case "run_now": @@ -1357,36 +1365,36 @@ func (p *paletteState) activateAutoSummaryRow() (paletteAction, bool, int) { return p.settingsAction("settings-close"), true, 1 case "cancel": return paletteAction{kind: "cancel"}, true, 1 - case "back": - p.mode = paletteModeSettings - p.cursor = 0 - p.query = nil - p.rebuildSettings() } p.settings.normalize() return paletteAction{}, false, 1 } -func (p *paletteState) applySettingsInput() { +func (p *paletteState) applySettingsInput() bool { if p.settingsInput == nil { - return + return false } val := strings.TrimSpace(string(p.settingsInput.value)) if val == "" { - return + return false } if p.settings.AutoSummary.Models == nil { p.settings.AutoSummary.Models = defaultSummaryModels() } + changed := false switch p.settingsInput.field { case "codex_model": + changed = p.settings.AutoSummary.Models["codex"] != val p.settings.AutoSummary.Models["codex"] = val case "opencode_model": + changed = p.settings.AutoSummary.Models["opencode"] != val p.settings.AutoSummary.Models["opencode"] = val case "claude_model": + changed = p.settings.AutoSummary.Models["claude"] != val p.settings.AutoSummary.Models["claude"] = val } p.settings.normalize() + return changed } func (p *paletteState) settingsCloseAction() paletteAction { @@ -1473,7 +1481,7 @@ func (p *paletteState) renderAutoSummary(out writeFlusher, cols, rows int) { moveTo(&b, row, leftPad) b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset) row++ - footer := styleHint + "↵ edit/toggle · cadence 15s/30s/1m · save row commits · esc cancel" + styleReset + footer := styleHint + "↵ edit/toggle · changes save when applied · esc cancel" + styleReset if visibleLen(footer) > content { footer = clipRunes(footer, content-1) + "…" } @@ -1503,7 +1511,7 @@ func (p *paletteState) autoSummaryDisplayRows() []string { var out []string for _, row := range autoSummaryRows() { if v, ok := values[row.key]; ok { - out = append(out, row.label+": "+v) + out = append(out, styleHint+row.label+":"+styleReset+" "+v) } else { out = append(out, row.label) } diff --git a/internal/app/palette_ux_test.go b/internal/app/palette_ux_test.go index fe1719a..d56ecc9 100644 --- a/internal/app/palette_ux_test.go +++ b/internal/app/palette_ux_test.go @@ -356,20 +356,71 @@ func TestAutoSummaryCadenceCyclesSoloValues(t *testing.T) { if p.settings.AutoSummary.Cadence != "1m" { t.Fatalf("initial cadence = %q", p.settings.AutoSummary.Cadence) } - p.activateAutoSummaryRow() + action, done, _ := p.activateAutoSummaryRow() + if done || action.kind != "settings-save" { + t.Fatalf("first cycle action = %+v done=%v, want settings-save without close", action, done) + } if p.settings.AutoSummary.Cadence != "15s" { t.Fatalf("first cycle cadence = %q", p.settings.AutoSummary.Cadence) } - p.activateAutoSummaryRow() + action, done, _ = p.activateAutoSummaryRow() + if done || action.kind != "settings-save" { + t.Fatalf("second cycle action = %+v done=%v, want settings-save without close", action, done) + } if p.settings.AutoSummary.Cadence != "30s" { t.Fatalf("second cycle cadence = %q", p.settings.AutoSummary.Cadence) } - p.activateAutoSummaryRow() + action, done, _ = p.activateAutoSummaryRow() + if done || action.kind != "settings-save" { + t.Fatalf("third cycle action = %+v done=%v, want settings-save without close", action, done) + } if p.settings.AutoSummary.Cadence != "1m" { t.Fatalf("third cycle cadence = %q", p.settings.AutoSummary.Cadence) } } +func TestAutoSummaryScreenOmitsBackRow(t *testing.T) { + for _, row := range autoSummaryRows() { + if row.label == "Back to Settings" { + t.Fatal("auto-summary settings should not show Back to Settings") + } + } +} + +func TestAutoSummaryValueRowsStyleLabelAndValueSeparately(t *testing.T) { + p := newPalette(nil, "", "", preset.Set{}, defaultSettings()) + rows := p.autoSummaryDisplayRows() + for _, row := range rows { + if strings.Contains(row, "Cadence:") { + if !strings.HasPrefix(row, styleHint+"Cadence:"+styleReset+" ") { + t.Fatalf("cadence row styling = %q", row) + } + if strings.Contains(strings.TrimPrefix(row, styleHint+"Cadence:"+styleReset+" "), styleHint) { + t.Fatalf("cadence value should use regular text styling: %q", row) + } + return + } + } + t.Fatal("missing cadence display row") +} + +func TestAutoSummaryTextInputSavesWhenSubmitted(t *testing.T) { + p := newPalette(nil, "", "", preset.Set{}, defaultSettings()) + p.mode = paletteModeSettingsInput + p.settingsInput = &settingsInputForm{ + title: "codex model", + field: "codex_model", + value: []rune("custom-model"), + } + action, done, _ := p.handleSettingsTextInput([]byte{'\r'}, 0) + if done || action.kind != "settings-save" { + t.Fatalf("submit action = %+v done=%v, want settings-save without close", action, done) + } + if got := p.settings.AutoSummary.modelFor("codex"); got != "custom-model" { + t.Fatalf("codex model = %q", got) + } +} + func TestPaletteFormCtrlRTogglesRelaunchFromCommandField(t *testing.T) { p := newPalette(nil, "", "", preset.Set{}) p.mode = paletteModeSpawnForm diff --git a/internal/app/sidebar.go b/internal/app/sidebar.go index 1184020..eb3ca00 100644 --- a/internal/app/sidebar.go +++ b/internal/app/sidebar.go @@ -331,7 +331,7 @@ func (st *uiState) drawSidebar() { write(prefix + openStyle + nameCell + styleReset + suffix) } - if summary := st.activeSummaryText(width - 4); summary != "" && row+2 <= maxRow { + if summary := st.activeSummaryRaw(); summary != "" && row+2 <= maxRow { write("") for _, line := range wrapSidebarSummary(summary, width-4) { if row > maxRow { @@ -417,7 +417,13 @@ func wrapSidebarSummary(s string, width int) []string { out = append(out, cur) cur = "" } - out = append(out, clipRunes(word, width-1)+"…") + for visibleLen(word) > width { + out = append(out, clipRunes(word, width)) + word = string([]rune(word)[width:]) + } + if word != "" { + cur = word + } continue } if cur == "" { diff --git a/internal/app/summarizer_test.go b/internal/app/summarizer_test.go index dba7599..a38fc7f 100644 --- a/internal/app/summarizer_test.go +++ b/internal/app/summarizer_test.go @@ -42,8 +42,13 @@ func TestWrapSidebarSummaryKeepsWordBoundaries(t *testing.T) { } } long := wrapSidebarSummary("supercalifragilistic short", 8) - if len(long) == 0 || !strings.HasSuffix(long[0], "…") { - t.Fatalf("long word should clip with ellipsis: %#v", long) + if len(long) == 0 || strings.Contains(strings.Join(long, ""), "…") { + t.Fatalf("long word should wrap without ellipsis: %#v", long) + } + for _, line := range long { + if visibleLen(line) > 8 { + t.Fatalf("line %q exceeds width", line) + } } } diff --git a/internal/app/tabbar.go b/internal/app/tabbar.go index 538962e..e884ffb 100644 --- a/internal/app/tabbar.go +++ b/internal/app/tabbar.go @@ -64,6 +64,7 @@ func (st *uiState) drawTabBar() { label string active bool } + activeTab := -1 // Reserve space at the right edge for "+ new". If there are too // many tabs to fit even at minTabWidth, drop tabs from the right @@ -134,6 +135,9 @@ func (st *uiState) drawTabBar() { label: label, active: c.ID == focus, }) + if tabs[len(tabs)-1].active { + activeTab = len(tabs) - 1 + } col += w } } @@ -195,8 +199,12 @@ func (st *uiState) drawTabBar() { hintCol, styleBorder, strings.Repeat("─", newHintW), styleReset) } - if summary := st.activeSummaryText(width - 2); summary != "" { - fmt.Fprintf(&b, "\x1b[2;1H %s%s%s", styleDim, summary, styleReset) + if activeTab >= 0 { + tab := tabs[activeTab] + summaryWidth := tab.width - 2 + if summary := st.activeSummaryText(summaryWidth); summary != "" { + fmt.Fprintf(&b, "\x1b[2;%dH %s%s%s", tab.startCol, styleDim, summary, styleReset) + } } frame := b.String()