diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a3ed7..5fafdb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Auto-summarization for top-level agent tabs. patterm now loads + `$XDG_CONFIG_HOME/patterm/settings.json`, enables Codex-based + summaries by default (`gpt-5.4-mini`; OpenCode defaults to + `opencode-go/minimax-m2.7`), and can run Codex, OpenCode, or opt-in + Claude summarizers with configurable model names. Summary + attempts are armed by meaningful human input, wait for recent output + to go quiet, and respect a minimum cadence so unchanged tabs are not + summarized on a timer. The active thread summary appears under the + top tab title and in the sidebar below the Agent Tree section. +- Settings overlay reachable from the command palette via + `Open Settings`. The searchable Settings picker opens + `Agents / Auto-summarization`, where users can enable/disable + summaries, choose provider, edit provider model names, cycle cadence, + test the selected summarizer (`patterm okay`), summarize the current + top-level agent immediately, and explicitly save or cancel draft + settings changes. + ### Changed - Command palette UX overhaul. The single flat list grew section bands (`── Focused ──`, `── Open ──`, `── Spawn ──`, `── Quit ──`) diff --git a/internal/app/app.go b/internal/app/app.go index 1dad7e3..a589f6a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -55,6 +55,10 @@ func Run(ctx context.Context, opts Options) error { if err != nil { return fmt.Errorf("app: load presets: %w", err) } + appSettings, settingsPath, err := loadSettings() + if err != nil { + logf("settings load: %v", err) + } // Ensure the per-project scratchpad dir exists so MCP and the UI // can read/write into it. SPEC §3. @@ -158,18 +162,35 @@ func Run(ctx context.Context, opts Options) error { go sess.runClassifier(ctx) st := &uiState{ - sess: sess, - presets: presets, - launcher: launcher, - pads: pads, - chromeWake: make(chan struct{}, 1), - trust: trustStore, - timers: host.timers, - hostCols: cols, - hostRows: rows, - stdinTTY: term.IsTerminal(int(os.Stdin.Fd())), - metrics: metrics, + sess: sess, + presets: presets, + launcher: launcher, + pads: pads, + chromeWake: make(chan struct{}, 1), + trust: trustStore, + timers: host.timers, + hostCols: cols, + hostRows: rows, + stdinTTY: term.IsTerminal(int(os.Stdin.Fd())), + metrics: metrics, + settings: appSettings, + settingsPath: settingsPath, + ctx: ctx, } + st.summaries = newSummaryManager(sess, opts.ProjectDir, presets, func() autoSummarySettings { + st.settingsMu.Lock() + defer st.settingsMu.Unlock() + return st.settings.AutoSummary.clone() + }, func() { + st.markChromeDirty() + st.markSidebarDirty() + }, func(_ string, result summaryState) { + if result.Error != "" { + st.flashError(fmt.Sprintf("summary: %v", result.Error)) + return + } + st.flashTransient("summary updated") + }) sess.SetMetrics(metrics) host.attention = st host.focus = st @@ -177,6 +198,7 @@ func Run(ctx context.Context, opts Options) error { host.scratch = st st.lastExit.Store(-1) sess.Subscribe(st) + go st.summaries.run(ctx) st.enterScreen() st.renderEmptyState() @@ -398,7 +420,6 @@ type uiState struct { // switch resets the offset cleanly. padOffsetName string - // activeAgentID tracks which top-level agent tab "owns" the agent // tree section of the sidebar. It only updates when focus lands on // an agent (or one of its sub-agents), so the agent tree stays @@ -432,6 +453,12 @@ type uiState struct { // check on the disabled path. metrics *metricsTracker + settingsMu sync.Mutex + settings settings + settingsPath string + ctx context.Context + summaries *summaryManager + // chromeCacheMu guards the last-rendered byte cache for each chrome // element. The tab bar, sidebar, and status line all repaint on // many state changes and on every PTY chunk, but their content @@ -478,6 +505,33 @@ func (st *uiState) dbgf(format string, args ...any) { logf(format, args...) } +func (st *uiState) activeSummaryText(width int) string { + if width <= 0 || st.summaries == nil { + return "" + } + st.settingsMu.Lock() + enabled := st.settings.AutoSummary.Enabled + st.settingsMu.Unlock() + if !enabled { + return "" + } + st.mu.Lock() + active := st.activeAgentID + st.mu.Unlock() + if active == "" { + return "" + } + sum := st.summaries.Summary(active) + text := strings.TrimSpace(sum.Text) + if text == "" { + return "" + } + if visibleLen(text) > width { + text = clipRunes(text, width-1) + "…" + } + return text +} + // trustRequest is one outstanding SPEC §7 trust prompt: an agent tried // to spawn / start / restart against an untrusted command preset and // the host wants user confirmation before the next attempt succeeds. @@ -707,6 +761,9 @@ func (st *uiState) scratchpadsChanged() { // on whatever the user was watching; the new child is still surfaced in // the sidebar/tab bar so it's reachable via the palette or select_process. func (st *uiState) OnChildSpawned(c *Child) { + if st.summaries != nil { + st.summaries.RegisterChild(c) + } if c.ParentID != "" { st.mu.Lock() if st.palette != nil { @@ -781,6 +838,9 @@ func (st *uiState) OnChildStateChanged(string, IdleState) { // OnChildExited drops focus and shows the empty state if it was the // focused child. func (st *uiState) OnChildExited(c *Child) { + if st.summaries != nil { + st.summaries.UnregisterChild(c.ID) + } st.lastExit.Store(int32(c.ExitCode())) st.marquee.reset() layout := st.layoutSnapshot() @@ -868,6 +928,9 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) { if st.metrics != nil { entry = time.Now() } + if st.summaries != nil { + st.summaries.ObserveOutput(childID) + } layout := st.layoutSnapshot() st.mu.Lock() focus := st.focusedID @@ -1361,6 +1424,9 @@ func (st *uiState) processStdin(chunk []byte) { // writes so claude / codex / opencode don't treat a // "text\r" batch as a paste. _ = c.InjectAsUser(forward) + if st.summaries != nil { + st.summaries.ObserveHumanInput(c.ID, forward) + } if prev != OwnerUser { go st.drawStatusLine() } @@ -1763,7 +1829,10 @@ func (st *uiState) scrollFocusedViewportToBottom() { } func (st *uiState) openPaletteLocked() { - st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets) + st.settingsMu.Lock() + appSettings := st.settings.clone() + st.settingsMu.Unlock() + st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets, appSettings) // Push a "no kitty flags" entry onto the host terminal's keyboard // stack so palette input arrives in plain legacy form regardless of // what the focused child pushed. Codex/ratatui enables kitty mode @@ -1936,9 +2005,85 @@ func (st *uiState) closePalette(action paletteAction) { case "proc-restart": st.handleProcRestart(action.childID) + + case "settings-close": + st.applySettingsAction(action) + restoreView() + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() + + case "settings-test": + st.applySettingsAction(action) + restoreView() + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() + go st.testSummarizer() + + case "settings-run-now": + st.applySettingsAction(action) + restoreView() + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() + st.runSummaryNow() } } +func (st *uiState) applySettingsAction(action paletteAction) { + if action.settings == nil { + return + } + next := action.settings.clone() + st.settingsMu.Lock() + path := st.settingsPath + st.settingsMu.Unlock() + if err := saveSettings(path, next); err != nil { + st.flashError(fmt.Sprintf("save settings: %v", err)) + return + } + st.settingsMu.Lock() + st.settings = next + st.settingsMu.Unlock() +} + +func (st *uiState) testSummarizer() { + if st.summaries == nil { + return + } + base := st.ctx + if base == nil { + base = context.Background() + } + ctx, cancel := context.WithTimeout(base, summaryTimeout) + defer cancel() + if err := st.summaries.Test(ctx); err != nil { + st.flashError(fmt.Sprintf("summarizer test: %v", err)) + return + } + st.flashTransient("summarizer test passed") +} + +func (st *uiState) runSummaryNow() { + if st.summaries == nil { + return + } + st.mu.Lock() + active := st.activeAgentID + st.mu.Unlock() + if active == "" { + st.flashError("no active top-level agent to summarize") + return + } + ctx := st.ctx + if ctx == nil { + ctx = context.Background() + } + st.summaries.RunNow(ctx, active) + st.flashTransient("summary requested") +} + func (st *uiState) handlePadDelete(name string) { if name == "" || st.pads == nil { st.repaintFocused() diff --git a/internal/app/layout_test.go b/internal/app/layout_test.go index dc796d5..515e929 100644 --- a/internal/app/layout_test.go +++ b/internal/app/layout_test.go @@ -14,10 +14,10 @@ func TestTerminalLayoutWideUsesMainViewport(t *testing.T) { if l.childCols() != 91 { t.Fatalf("child cols: got %d want 91", l.childCols()) } - if l.childRows() != 37 { - t.Fatalf("child rows: got %d want 37", l.childRows()) + if l.childRows() != 36 { + t.Fatalf("child rows: got %d want 36", l.childRows()) } - if l.mainTop != 3 || l.statusRow != 40 { + if l.mainTop != 4 || l.statusRow != 40 { t.Fatalf("unexpected vertical chrome: mainTop=%d statusRow=%d", l.mainTop, l.statusRow) } } @@ -30,8 +30,8 @@ func TestTerminalLayoutNarrowHidesSidebar(t *testing.T) { if l.childCols() != 38 { t.Fatalf("child cols: got %d want 38", l.childCols()) } - if l.childRows() != 9 { - t.Fatalf("child rows: got %d want 9", l.childRows()) + if l.childRows() != 8 { + t.Fatalf("child rows: got %d want 8", l.childRows()) } } @@ -46,13 +46,13 @@ func TestSpawnSizingUsesViewportDimensions(t *testing.T) { l := newTerminalLayout(120, 40) launcher := NewLauncher(nil, "", l.childCols(), l.childRows()) cols, rows := launcher.size() - if cols != 91 || rows != 37 { - t.Fatalf("launcher size: got %dx%d want 91x37", cols, rows) + if cols != 91 || rows != 36 { + t.Fatalf("launcher size: got %dx%d want 91x36", cols, rows) } host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows()) cols, rows = host.size() - if cols != 91 || rows != 37 { - t.Fatalf("tool host size: got %dx%d want 91x37", cols, rows) + if cols != 91 || rows != 36 { + t.Fatalf("tool host size: got %dx%d want 91x36", cols, rows) } } diff --git a/internal/app/palette.go b/internal/app/palette.go index e3396d5..68d758a 100644 --- a/internal/app/palette.go +++ b/internal/app/palette.go @@ -37,6 +37,9 @@ type paletteAction struct { // For *-rename-submit actions, the user-typed new name. newName string + + // For settings actions, the updated settings snapshot to persist. + settings *settings } // Group ids order the section bands the palette renders when no query @@ -47,14 +50,16 @@ const ( groupFocused = iota groupOpen groupSpawn + groupSettings groupQuit ) var groupLabels = map[int]string{ - groupFocused: "Focused", - groupOpen: "Open", - groupSpawn: "Spawn", - groupQuit: "Quit", + groupFocused: "Focused", + groupOpen: "Open", + groupSpawn: "Spawn", + groupSettings: "Settings", + groupQuit: "Quit", } type paletteItem struct { @@ -77,6 +82,9 @@ const ( paletteModePicker paletteMode = iota paletteModeSpawnForm paletteModeRenameForm + paletteModeSettings + paletteModeAutoSummary + paletteModeSettingsInput ) // spawnProcessForm is the state for the "Spawn process…" two-field @@ -101,6 +109,13 @@ type renameForm struct { subjectLine string // e.g. "scratchpad: notes.md" rendered above the input } +type settingsInputForm struct { + title string + field string + value []rune + subtitle string +} + // paletteState is the in-memory model for the overlay. SPEC §4: a // single fuzzy-searchable list of commands scoped to the current focus. type paletteState struct { @@ -110,12 +125,14 @@ type paletteState struct { focused string focusedPad string presets preset.Set + settings settings items []paletteItem - mode paletteMode - form *spawnProcessForm - renameForm *renameForm + mode paletteMode + form *spawnProcessForm + renameForm *renameForm + settingsInput *settingsInputForm // showHelp swaps the item list for a static keybinding cheat-sheet // until the next keystroke. Toggled by `?` in picker mode. @@ -171,8 +188,12 @@ func findChildByID(children []*Child, id string) *Child { return nil } -func newPalette(children []*Child, focused, focusedPad string, presets preset.Set) *paletteState { - p := &paletteState{children: children, focused: focused, focusedPad: focusedPad, presets: presets} +func newPalette(children []*Child, focused, focusedPad string, presets preset.Set, appSettings ...settings) *paletteState { + st := defaultSettings() + if len(appSettings) > 0 { + st = appSettings[0].clone() + } + p := &paletteState{children: children, focused: focused, focusedPad: focusedPad, presets: presets, settings: st} p.rebuild() return p } @@ -325,7 +346,15 @@ func (p *paletteState) buildItems(macro string) []paletteItem { group: groupSpawn, }) - // Group 3: Quit. + // Group 3: Settings. + out = append(out, paletteItem{ + label: "Open Settings", + hint: "configure agents and auto-summary", + action: paletteAction{kind: "settings-open"}, + group: groupSettings, + }) + + // Group 4: Quit. out = append(out, paletteItem{ label: "Quit", hint: "exit patterm; SIGTERM every child", @@ -519,6 +548,15 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d if p.mode == paletteModeRenameForm { return p.handleRenameInput(chunk, i) } + if p.mode == paletteModeSettings { + return p.handleSettingsInput(chunk, i) + } + if p.mode == paletteModeAutoSummary { + return p.handleAutoSummaryInput(chunk, i) + } + if p.mode == paletteModeSettingsInput { + return p.handleSettingsTextInput(chunk, i) + } b := chunk[i] @@ -602,6 +640,12 @@ func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) { p.mode = paletteModeSpawnForm p.form = &spawnProcessForm{} return paletteAction{}, false, adv + case "settings-open": + p.mode = paletteModeSettings + p.query = nil + p.cursor = 0 + p.rebuildSettings() + return paletteAction{}, false, adv case "pad-rename-form": p.enterRenameForm("pad", a.padName, a.padName, "scratchpad: "+a.padName) return paletteAction{}, false, adv @@ -1112,6 +1156,427 @@ func (p *paletteState) focusedSubject() string { return "" } +func (p *paletteState) rebuildSettings() { + items := []paletteItem{{ + label: "Agents / Auto-summarization", + hint: "provider, models, cadence, test", + action: paletteAction{kind: "settings-auto-summary"}, + group: groupSettings, + }} + q := strings.TrimSpace(strings.ToLower(string(p.query))) + if q == "" { + p.items = items + p.cursor = 0 + return + } + p.items = p.items[:0] + for _, it := range items { + if strings.Contains(strings.ToLower(it.label+" "+it.hint), q) { + p.items = append(p.items, it) + } + } + p.clampCursor() +} + +func (p *paletteState) handleSettingsInput(chunk []byte, i int) (paletteAction, bool, int) { + b := chunk[i] + if b == 0x1b { + if n := csiLen(chunk, i); n > 0 { + final := chunk[i+n-1] + switch final { + case 'A': + p.cursorUp() + case 'B': + p.cursorDown() + } + return paletteAction{}, false, n + } + return paletteAction{kind: "cancel"}, true, 1 + } + switch b { + case '\r', '\n': + if len(p.items) == 0 { + return paletteAction{}, false, 1 + } + a := p.items[p.cursor].action + if a.kind == "settings-auto-summary" { + p.mode = paletteModeAutoSummary + p.cursor = 0 + return paletteAction{}, false, 1 + } + case 0x7f, 0x08: + p.backspace() + p.rebuildSettings() + case 0x15: + p.query = nil + p.rebuildSettings() + case 0x0e: + p.cursorDown() + case 0x10: + p.cursorUp() + default: + if b >= 0x20 && b < 0x7f { + p.query = append(p.query, rune(b)) + p.rebuildSettings() + } + } + return paletteAction{}, false, 1 +} + +func (p *paletteState) handleAutoSummaryInput(chunk []byte, i int) (paletteAction, bool, int) { + b := chunk[i] + if b == 0x1b { + if n := csiLen(chunk, i); n > 0 { + final := chunk[i+n-1] + switch final { + case 'A': + p.cursor-- + if p.cursor < 0 { + p.cursor = len(autoSummaryRows()) - 1 + } + case 'B': + p.cursor++ + if p.cursor >= len(autoSummaryRows()) { + p.cursor = 0 + } + } + return paletteAction{}, false, n + } + return paletteAction{kind: "cancel"}, true, 1 + } + switch b { + case '\r', '\n': + return p.activateAutoSummaryRow() + case 0x0e: + p.cursor++ + case 0x10: + p.cursor-- + } + if p.cursor < 0 { + p.cursor = len(autoSummaryRows()) - 1 + } + if p.cursor >= len(autoSummaryRows()) { + p.cursor = 0 + } + return paletteAction{}, false, 1 +} + +func (p *paletteState) handleSettingsTextInput(chunk []byte, i int) (paletteAction, bool, int) { + if p.settingsInput == nil { + p.mode = paletteModeAutoSummary + return paletteAction{}, false, 1 + } + b := chunk[i] + if b == 0x1b { + if n := csiLen(chunk, i); n > 0 { + return paletteAction{}, false, n + } + p.mode = paletteModeAutoSummary + return paletteAction{}, false, 1 + } + switch b { + case '\r', '\n': + p.applySettingsInput() + p.mode = paletteModeAutoSummary + case 0x7f, 0x08: + if len(p.settingsInput.value) > 0 { + p.settingsInput.value = p.settingsInput.value[:len(p.settingsInput.value)-1] + } + case 0x15: + p.settingsInput.value = nil + default: + if b >= 0x20 && b < 0x7f { + p.settingsInput.value = append(p.settingsInput.value, rune(b)) + } + } + return paletteAction{}, false, 1 +} + +type autoSummaryRow struct { + key string + label string +} + +func autoSummaryRows() []autoSummaryRow { + return []autoSummaryRow{ + {key: "enabled", label: "Enabled"}, + {key: "provider", label: "Provider"}, + {key: "codex_model", label: "Codex model"}, + {key: "opencode_model", label: "OpenCode model"}, + {key: "claude_model", label: "Claude model"}, + {key: "cadence", label: "Cadence"}, + {key: "test", label: "Test summarizer"}, + {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"}, + } +} + +func (p *paletteState) activateAutoSummaryRow() (paletteAction, bool, int) { + rows := autoSummaryRows() + if p.cursor < 0 || p.cursor >= len(rows) { + return paletteAction{}, false, 1 + } + switch rows[p.cursor].key { + case "enabled": + p.settings.AutoSummary.Enabled = !p.settings.AutoSummary.Enabled + case "provider": + switch p.settings.AutoSummary.Provider { + case "codex": + p.settings.AutoSummary.Provider = "opencode" + case "opencode": + p.settings.AutoSummary.Provider = "claude" + default: + p.settings.AutoSummary.Provider = "codex" + } + case "codex_model", "opencode_model", "claude_model": + provider := strings.TrimSuffix(rows[p.cursor].key, "_model") + p.settingsInput = &settingsInputForm{ + title: provider + " model", + field: rows[p.cursor].key, + value: []rune(p.settings.AutoSummary.modelFor(provider)), + subtitle: "model flag passed to " + provider, + } + p.mode = paletteModeSettingsInput + case "cadence": + switch p.settings.AutoSummary.Cadence { + case "5m": + p.settings.AutoSummary.Cadence = "15m" + case "15m": + p.settings.AutoSummary.Cadence = "30m" + default: + p.settings.AutoSummary.Cadence = "5m" + } + case "test": + return p.settingsAction("settings-test"), true, 1 + case "run_now": + return p.settingsAction("settings-run-now"), true, 1 + case "save": + 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() { + if p.settingsInput == nil { + return + } + val := strings.TrimSpace(string(p.settingsInput.value)) + if val == "" { + return + } + if p.settings.AutoSummary.Models == nil { + p.settings.AutoSummary.Models = defaultSummaryModels() + } + switch p.settingsInput.field { + case "codex_model": + p.settings.AutoSummary.Models["codex"] = val + case "opencode_model": + p.settings.AutoSummary.Models["opencode"] = val + case "claude_model": + p.settings.AutoSummary.Models["claude"] = val + } + p.settings.normalize() +} + +func (p *paletteState) settingsCloseAction() paletteAction { + return p.settingsAction("settings-close") +} + +func (p *paletteState) settingsAction(kind string) paletteAction { + st := p.settings.clone() + return paletteAction{kind: kind, settings: &st} +} + +func (p *paletteState) renderSettings(out writeFlusher, cols, rows int) { + p.renderSimplePicker(out, cols, rows, "Settings", "esc cancel", "search settings") +} + +func (p *paletteState) renderSimplePicker(out writeFlusher, cols, rows int, title, hint, placeholder string) { + width, leftPad, content := paletteBox(cols) + maxItems := rows - 7 + if maxItems > 10 { + maxItems = 10 + } + if maxItems < 1 { + maxItems = 1 + } + var b strings.Builder + b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J") + row := 2 + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " + strings.Repeat("─", max(2, width-visibleLen(title)-visibleLen(hint)-9)) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset) + row++ + query := string(p.query) + if query == "" { + query = styleDim + placeholder + styleReset + } + pad := content - 2 - visibleLen(query) + if pad < 0 { + pad = 0 + } + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "❯" + styleReset + " " + query + strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset) + row++ + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset) + row++ + p.renderItemRows(&b, &row, leftPad, width, content, maxItems) + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset) + row++ + footer := styleHint + "↵ open · esc cancel · ↑↓ navigate" + styleReset + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "│" + styleReset + " " + footer + strings.Repeat(" ", max(0, content-visibleLen(footer))) + " " + styleBorder + "│" + styleReset) + row++ + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset) + _, _ = out.Write([]byte(b.String())) + _ = out.Flush() +} + +func (p *paletteState) renderAutoSummary(out writeFlusher, cols, rows int) { + width, leftPad, content := paletteBox(cols) + var b strings.Builder + b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J") + row := 2 + title := "Auto-summarization" + hint := "esc cancel" + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " + strings.Repeat("─", max(2, width-visibleLen(title)-visibleLen(hint)-9)) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset) + row++ + lines := p.autoSummaryDisplayRows() + for i, line := range lines { + moveTo(&b, row, leftPad) + prefix := " " + if i == p.cursor { + prefix = styleAccent + "▎" + styleReset + " " + line = styleBold + line + styleReset + } + pad := content - visibleLen(prefix) - visibleLen(line) + if pad < 0 { + pad = 0 + } + b.WriteString(styleBorder + "│" + styleReset + " " + prefix + line + strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset) + row++ + } + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset) + row++ + footer := styleHint + "↵ edit/toggle · save row commits · esc cancel" + styleReset + if visibleLen(footer) > content { + footer = clipRunes(footer, content-1) + "…" + } + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "│" + styleReset + " " + footer + strings.Repeat(" ", max(0, content-visibleLen(footer))) + " " + styleBorder + "│" + styleReset) + row++ + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset) + _, _ = out.Write([]byte(b.String())) + _ = out.Flush() +} + +func (p *paletteState) autoSummaryDisplayRows() []string { + a := p.settings.AutoSummary + enabled := "off" + if a.Enabled { + enabled = "on" + } + values := map[string]string{ + "enabled": enabled, + "provider": a.Provider, + "codex_model": a.modelFor("codex"), + "opencode_model": a.modelFor("opencode"), + "claude_model": a.modelFor("claude"), + "cadence": a.Cadence + " minimum after activity", + } + var out []string + for _, row := range autoSummaryRows() { + if v, ok := values[row.key]; ok { + out = append(out, row.label+": "+v) + } else { + out = append(out, row.label) + } + } + return out +} + +func (p *paletteState) renderSettingsInput(out writeFlusher, cols, rows int) { + if p.settingsInput == nil { + p.settingsInput = &settingsInputForm{title: "Setting"} + } + width, leftPad, content := paletteBox(cols) + var b strings.Builder + b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J") + row := 2 + title := p.settingsInput.title + hint := "esc cancel" + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " + strings.Repeat("─", max(2, width-visibleLen(title)-visibleLen(hint)-9)) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset) + row++ + if p.settingsInput.subtitle != "" { + sub := p.settingsInput.subtitle + if visibleLen(sub) > content { + sub = clipRunes(sub, content-1) + "…" + } + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "│" + styleReset + " " + styleHint + sub + styleReset + strings.Repeat(" ", max(0, content-visibleLen(sub))) + " " + styleBorder + "│" + styleReset) + row++ + } + value := string(p.settingsInput.value) + if visibleLen(value) > content-2 { + value = clipRunes(value, content-3) + "…" + } + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "❯" + styleReset + " " + value + strings.Repeat(" ", max(0, content-2-visibleLen(value))) + " " + styleBorder + "│" + styleReset) + inputRow := row + row++ + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset) + row++ + footer := styleHint + "↵ save · esc cancel · ⌃u clear" + styleReset + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "│" + styleReset + " " + footer + strings.Repeat(" ", max(0, content-visibleLen(footer))) + " " + styleBorder + "│" + styleReset) + row++ + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset) + moveTo(&b, inputRow, leftPad+4+visibleLen(value)) + b.WriteString("\x1b[?25h") + _, _ = out.Write([]byte(b.String())) + _ = out.Flush() +} + +func paletteBox(cols int) (width, leftPad, content int) { + if cols < 32 { + cols = 32 + } + width = cols - 8 + if width > 72 { + width = 72 + } + if width < 40 { + width = cols - 2 + } + if width < 32 { + width = 32 + } + leftPad = (cols - width) / 2 + if leftPad < 1 { + leftPad = 1 + } + content = width - 4 + return width, leftPad, content +} + // render draws the palette onto out. Layout is a rounded box with a // title bar, query line, chip strip, divider, item list, divider, and // footer. The caller is responsible for the screen clear before the @@ -1125,6 +1590,18 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) { p.renderRename(out, cols, rows) return } + if p.mode == paletteModeSettings { + p.renderSettings(out, cols, rows) + return + } + if p.mode == paletteModeAutoSummary { + p.renderAutoSummary(out, cols, rows) + return + } + if p.mode == paletteModeSettingsInput { + p.renderSettingsInput(out, cols, rows) + return + } if cols < 32 { cols = 32 } diff --git a/internal/app/settings.go b/internal/app/settings.go new file mode 100644 index 0000000..1f18463 --- /dev/null +++ b/internal/app/settings.go @@ -0,0 +1,150 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/hjbdev/patterm/internal/preset" +) + +const ( + defaultSummaryProvider = "codex" + defaultCodexModel = "gpt-5.4-mini" + defaultOpenCodeModel = "opencode-go/minimax-m2.7" + defaultClaudeModel = "claude-haiku-4-5" +) + +type settings struct { + AutoSummary autoSummarySettings `json:"auto_summary"` +} + +type autoSummarySettings struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + Models map[string]string `json:"models"` + Cadence string `json:"cadence"` + QuietWindowMS int `json:"quiet_window_ms"` + MinInputChars int `json:"min_input_chars"` + MaxHistoryChars int `json:"max_history_chars"` +} + +func defaultSettings() settings { + return settings{ + AutoSummary: autoSummarySettings{ + Enabled: true, + Provider: defaultSummaryProvider, + Models: defaultSummaryModels(), + Cadence: "5m", + QuietWindowMS: 3000, + MinInputChars: 4, + MaxHistoryChars: 12000, + }, + } +} + +func defaultSummaryModels() map[string]string { + return map[string]string{ + "codex": defaultCodexModel, + "opencode": defaultOpenCodeModel, + "claude": defaultClaudeModel, + } +} + +func loadSettings() (settings, string, error) { + base, err := preset.ConfigDir() + if err != nil { + return settings{}, "", err + } + path := filepath.Join(base, "settings.json") + st := defaultSettings() + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return st, path, nil + } + return st, path, fmt.Errorf("settings: read %s: %w", path, err) + } + if err := json.Unmarshal(b, &st); err != nil { + return defaultSettings(), path, fmt.Errorf("settings: parse %s: %w", path, err) + } + st.normalize() + return st, path, nil +} + +func saveSettings(path string, st settings) error { + if path == "" { + return fmt.Errorf("settings: empty path") + } + st.normalize() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + b, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + return os.WriteFile(path, b, 0o600) +} + +func (st *settings) normalize() { + def := defaultSettings() + if st.AutoSummary.Provider == "" { + st.AutoSummary.Provider = def.AutoSummary.Provider + } + switch st.AutoSummary.Provider { + case "codex", "opencode", "claude": + default: + st.AutoSummary.Provider = def.AutoSummary.Provider + } + if st.AutoSummary.Models == nil { + st.AutoSummary.Models = defaultSummaryModels() + } else { + for k, v := range defaultSummaryModels() { + if st.AutoSummary.Models[k] == "" { + st.AutoSummary.Models[k] = v + } + } + } + if st.AutoSummary.Cadence == "" { + st.AutoSummary.Cadence = def.AutoSummary.Cadence + } + if st.AutoSummary.QuietWindowMS <= 0 { + st.AutoSummary.QuietWindowMS = def.AutoSummary.QuietWindowMS + } + if st.AutoSummary.MinInputChars <= 0 { + st.AutoSummary.MinInputChars = def.AutoSummary.MinInputChars + } + if st.AutoSummary.MaxHistoryChars <= 0 { + st.AutoSummary.MaxHistoryChars = def.AutoSummary.MaxHistoryChars + } +} + +func (st settings) clone() settings { + st.normalize() + if st.AutoSummary.Models != nil { + models := make(map[string]string, len(st.AutoSummary.Models)) + for k, v := range st.AutoSummary.Models { + models[k] = v + } + st.AutoSummary.Models = models + } + return st +} + +func (a autoSummarySettings) clone() autoSummarySettings { + st := settings{AutoSummary: a}.clone() + return st.AutoSummary +} + +func (a autoSummarySettings) modelFor(provider string) string { + if a.Models == nil { + return defaultSummaryModels()[provider] + } + if m := a.Models[provider]; m != "" { + return m + } + return defaultSummaryModels()[provider] +} diff --git a/internal/app/settings_test.go b/internal/app/settings_test.go new file mode 100644 index 0000000..6ab7850 --- /dev/null +++ b/internal/app/settings_test.go @@ -0,0 +1,69 @@ +package app + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadSettingsDefaults(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + st, path, err := loadSettings() + if err != nil { + t.Fatalf("loadSettings: %v", err) + } + if filepath.Base(path) != "settings.json" { + t.Fatalf("settings path = %q", path) + } + if !st.AutoSummary.Enabled { + t.Fatal("auto-summary should default enabled") + } + if st.AutoSummary.Provider != "codex" { + t.Fatalf("provider = %q want codex", st.AutoSummary.Provider) + } + if got := st.AutoSummary.modelFor("codex"); got != "gpt-5.4-mini" { + t.Fatalf("codex model = %q", got) + } + if got := st.AutoSummary.modelFor("opencode"); got != "opencode-go/minimax-m2.7" { + t.Fatalf("opencode model = %q", got) + } +} + +func TestSettingsCloneDoesNotShareModelMap(t *testing.T) { + st := defaultSettings() + cp := st.clone() + cp.AutoSummary.Models["codex"] = "changed" + if st.AutoSummary.Models["codex"] == "changed" { + t.Fatal("clone shared Models map with original") + } + a := st.AutoSummary.clone() + a.Models["opencode"] = "changed" + if st.AutoSummary.Models["opencode"] == "changed" { + t.Fatal("autoSummarySettings clone shared Models map with original") + } +} + +func TestSaveAndLoadSettings(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + st := defaultSettings() + st.AutoSummary.Provider = "opencode" + st.AutoSummary.Models["opencode"] = "minimax/test" + path := filepath.Join(dir, "patterm", "settings.json") + if err := saveSettings(path, st); err != nil { + t.Fatalf("saveSettings: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("settings file missing: %v", err) + } + got, _, err := loadSettings() + if err != nil { + t.Fatalf("loadSettings: %v", err) + } + if got.AutoSummary.Provider != "opencode" { + t.Fatalf("provider = %q", got.AutoSummary.Provider) + } + if got.AutoSummary.modelFor("opencode") != "minimax/test" { + t.Fatalf("opencode model = %q", got.AutoSummary.modelFor("opencode")) + } +} diff --git a/internal/app/sidebar.go b/internal/app/sidebar.go index 9817f66..1184020 100644 --- a/internal/app/sidebar.go +++ b/internal/app/sidebar.go @@ -331,6 +331,16 @@ func (st *uiState) drawSidebar() { write(prefix + openStyle + nameCell + styleReset + suffix) } + if summary := st.activeSummaryText(width - 4); summary != "" && row+2 <= maxRow { + write("") + for _, line := range wrapSidebarSummary(summary, width-4) { + if row > maxRow { + break + } + write(" " + styleDim + line + styleReset) + } + } + // Scratchpads list — names only. The preview pane used to live // here and clobbered the main viewport when content overflowed the // rail. Focus moves to a pad via Ctrl+W/S; the content renders in @@ -390,3 +400,42 @@ func (st *uiState) drawSidebar() { fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame) st.outMu.Unlock() } + +func wrapSidebarSummary(s string, width int) []string { + if width < 1 { + width = 1 + } + words := strings.Fields(s) + if len(words) == 0 { + return nil + } + var out []string + var cur string + for _, word := range words { + if visibleLen(word) > width { + if cur != "" { + out = append(out, cur) + cur = "" + } + out = append(out, clipRunes(word, width-1)+"…") + continue + } + if cur == "" { + cur = word + continue + } + if visibleLen(cur)+1+visibleLen(word) <= width { + cur += " " + word + continue + } + out = append(out, cur) + cur = word + } + if cur != "" { + out = append(out, cur) + } + if len(out) > 3 { + out = out[:3] + } + return out +} diff --git a/internal/app/summarizer.go b/internal/app/summarizer.go new file mode 100644 index 0000000..38faa66 --- /dev/null +++ b/internal/app/summarizer.go @@ -0,0 +1,463 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "sync" + "time" + "unicode" + + "github.com/hjbdev/patterm/internal/preset" +) + +const ( + summaryTickInterval = time.Second + summaryTimeout = 90 * time.Second + summaryMaxLineCells = 240 +) + +type summaryState struct { + Text string + State IdleState + UpdatedAt time.Time + Error string +} + +type summaryManager struct { + sess *Session + projectDir string + presets preset.Set + settings func() autoSummarySettings + onUpdate func() + onResult func(string, summaryState) + + mu sync.Mutex + tracked map[string]bool + entries map[string]*summaryEntry +} + +type summaryEntry struct { + armed bool + dirty bool + running bool + lastInputAt time.Time + lastOutputAt time.Time + lastAttemptAt time.Time + lastSummarized int64 + state summaryState +} + +type summarizerResponse struct { + Summary string `json:"summary"` + State string `json:"state"` +} + +func newSummaryManager(sess *Session, projectDir string, presets preset.Set, settingsFn func() autoSummarySettings, onUpdate func(), onResult func(string, summaryState)) *summaryManager { + return &summaryManager{ + sess: sess, + projectDir: projectDir, + presets: presets, + settings: settingsFn, + onUpdate: onUpdate, + onResult: onResult, + tracked: make(map[string]bool), + entries: make(map[string]*summaryEntry), + } +} + +func (m *summaryManager) run(ctx context.Context) { + ticker := time.NewTicker(summaryTickInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + m.maybeStart(ctx, time.Now()) + } + } +} + +func (m *summaryManager) ObserveHumanInput(childID string, b []byte) { + if m == nil || !m.isTracked(childID) { + return + } + cfg := m.settings() + if len(strings.TrimSpace(string(b))) < cfg.MinInputChars { + return + } + m.mu.Lock() + e := m.entryLocked(childID) + e.armed = true + e.lastInputAt = time.Now() + m.mu.Unlock() +} + +func (m *summaryManager) ObserveOutput(childID string) { + if m == nil || !m.isTracked(childID) { + return + } + m.mu.Lock() + e := m.entryLocked(childID) + if e.armed { + e.dirty = true + e.lastOutputAt = time.Now() + } + m.mu.Unlock() +} + +func (m *summaryManager) RegisterChild(c *Child) { + if m == nil || c == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + if isTopLevelSummarizedAgent(c) { + m.tracked[c.ID] = true + } else { + delete(m.tracked, c.ID) + } +} + +func (m *summaryManager) UnregisterChild(id string) { + if m == nil || id == "" { + return + } + m.mu.Lock() + defer m.mu.Unlock() + delete(m.tracked, id) +} + +func (m *summaryManager) isTracked(id string) bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.tracked[id] +} + +func (m *summaryManager) Summary(childID string) summaryState { + if m == nil || childID == "" { + return summaryState{} + } + m.mu.Lock() + defer m.mu.Unlock() + if e := m.entries[childID]; e != nil { + return e.state + } + return summaryState{} +} + +func (m *summaryManager) RunNow(ctx context.Context, childID string) { + if m == nil || childID == "" { + return + } + c := m.sess.FindChild(childID) + if !isTopLevelSummarizedAgent(c) { + return + } + m.mu.Lock() + e := m.entryLocked(c.ID) + if e.running { + m.mu.Unlock() + return + } + e.running = true + e.lastAttemptAt = time.Now() + m.mu.Unlock() + go m.runOne(ctx, c.ID, true) +} + +func (m *summaryManager) Test(ctx context.Context) error { + cfg := m.settings() + return runSummarizerHealth(ctx, cfg, m.projectDir) +} + +func (m *summaryManager) entryLocked(id string) *summaryEntry { + e := m.entries[id] + if e == nil { + e = &summaryEntry{} + m.entries[id] = e + } + return e +} + +func (m *summaryManager) maybeStart(ctx context.Context, now time.Time) { + cfg := m.settings() + if !cfg.Enabled { + return + } + cadence, err := time.ParseDuration(cfg.Cadence) + if err != nil || cadence <= 0 { + cadence = 5 * time.Minute + } + quiet := time.Duration(cfg.QuietWindowMS) * time.Millisecond + var startID string + for _, c := range m.sess.Children() { + if !isTopLevelSummarizedAgent(c) { + continue + } + m.mu.Lock() + e := m.entryLocked(c.ID) + eligible := e.armed && e.dirty && !e.running && + !e.lastOutputAt.IsZero() && now.Sub(e.lastOutputAt) >= quiet && + (e.lastAttemptAt.IsZero() || now.Sub(e.lastAttemptAt) >= cadence) && + c.ScreenVersion() != e.lastSummarized + if eligible { + e.running = true + e.lastAttemptAt = now + startID = c.ID + } + m.mu.Unlock() + if startID != "" { + go m.runOne(ctx, startID, false) + return + } + } +} + +func (m *summaryManager) runOne(ctx context.Context, childID string, manual bool) { + c := m.sess.FindChild(childID) + if c == nil { + m.finish(childID, summaryState{Error: "process disappeared"}, 0) + return + } + cfg := m.settings() + snapshot := buildSummarySnapshot(c, cfg.MaxHistoryChars, m.chromeHintsFor(c.PresetRef)) + if strings.TrimSpace(snapshot) == "" { + m.finish(childID, summaryState{Error: "empty snapshot"}, c.ScreenVersion()) + return + } + runCtx, cancel := context.WithTimeout(ctx, summaryTimeout) + defer cancel() + resp, err := runSummarizer(runCtx, cfg, m.projectDir, snapshot) + st := summaryState{UpdatedAt: time.Now()} + if err != nil { + st.Error = err.Error() + m.finish(childID, st, c.ScreenVersion()) + return + } + st.Text = strings.TrimSpace(resp.Summary) + st.State = summaryIdleState(resp.State) + if st.Text == "" { + st.Error = "empty summary" + } + if manual && st.Text != "" && st.State == StateUnknown { + st.State = c.IdleState() + } + m.finish(childID, st, c.ScreenVersion()) +} + +func (m *summaryManager) finish(childID string, st summaryState, version int64) { + m.mu.Lock() + e := m.entryLocked(childID) + e.running = false + if st.Text != "" || st.Error != "" { + if st.Text == "" && e.state.Text != "" { + st.Text = e.state.Text + st.State = e.state.State + st.UpdatedAt = e.state.UpdatedAt + } + e.state = st + } + if st.Text != "" { + e.armed = false + e.dirty = false + e.lastSummarized = version + } + m.mu.Unlock() + if m.onUpdate != nil { + m.onUpdate() + } + if m.onResult != nil && (st.Text != "" || st.Error != "") { + m.onResult(childID, st) + } +} + +func isTopLevelSummarizedAgent(c *Child) bool { + return c != nil && c.Kind == KindAgent && c.ParentID == "" && c.Status() == StatusRunning +} + +func (m *summaryManager) chromeHintsFor(presetName string) []string { + if presetName == "" { + return nil + } + for _, p := range m.presets.Agents { + if p.Name == presetName { + return p.ChromeTrimHints + } + } + return nil +} + +func buildSummarySnapshot(c *Child, maxChars int, chromeHints []string) string { + if maxChars <= 0 { + maxChars = 12000 + } + grid := "" + if em := c.Emulator(); em != nil { + if txt, err := em.PlainText(); err == nil { + grid = compactSummaryText(applyChromeTrim(txt, chromeHints)) + } + } + tailBytes := max(maxChars*4, maxChars) + b := c.tailBytes(tailBytes) + history := compactSummaryText(applyChromeTrim(string(stripANSIBytes(nil, b)), chromeHints)) + history = tailString(history, maxChars) + var out strings.Builder + if history != "" { + out.WriteString("Recent rendered history:\n") + out.WriteString(history) + out.WriteString("\n\n") + } + if grid != "" && !strings.Contains(history, grid) { + out.WriteString("Current visible grid:\n") + out.WriteString(grid) + } + return tailString(out.String(), maxChars) +} + +func compactSummaryText(in string) string { + in = string(stripANSIBytes(nil, []byte(in))) + in = strings.ReplaceAll(in, "\r\n", "\n") + in = strings.ReplaceAll(in, "\r", "\n") + lines := strings.Split(in, "\n") + out := make([]string, 0, len(lines)) + blank := false + for _, line := range lines { + line = strings.TrimRightFunc(line, unicode.IsSpace) + line = strings.Map(func(r rune) rune { + if r == '\t' || r == '\n' { + return r + } + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, line) + line = truncateSummaryLine(line, summaryMaxLineCells) + if strings.TrimSpace(line) == "" { + if blank { + continue + } + blank = true + out = append(out, "") + continue + } + blank = false + out = append(out, line) + } + return strings.TrimSpace(strings.Join(out, "\n")) +} + +func truncateSummaryLine(s string, max int) string { + if max <= 0 || visibleLen(s) <= max { + return s + } + return clipRunes(s, max-1) + "…" +} + +func tailString(s string, max int) string { + rs := []rune(s) + if len(rs) <= max { + return s + } + return string(rs[len(rs)-max:]) +} + +func runSummarizer(ctx context.Context, cfg autoSummarySettings, projectDir, snapshot string) (summarizerResponse, error) { + prompt := summaryPrompt(snapshot) + out, err := runSummarizerCommand(ctx, cfg, projectDir, prompt) + if err != nil { + return summarizerResponse{}, err + } + resp, err := parseSummarizerResponse(out) + if err != nil { + return summarizerResponse{}, err + } + if summaryIdleState(resp.State) == StateUnknown { + return summarizerResponse{}, fmt.Errorf("invalid summary state %q", resp.State) + } + return resp, nil +} + +func runSummarizerHealth(ctx context.Context, cfg autoSummarySettings, projectDir string) error { + out, err := runSummarizerCommand(ctx, cfg, projectDir, "Reply with exactly: patterm okay") + if err != nil { + return err + } + if strings.TrimSpace(out) != "patterm okay" { + return fmt.Errorf("health check did not return patterm okay") + } + return nil +} + +func runSummarizerCommand(ctx context.Context, cfg autoSummarySettings, projectDir, prompt string) (string, error) { + provider := cfg.Provider + model := cfg.modelFor(provider) + var cmd *exec.Cmd + switch provider { + case "opencode": + cmd = exec.CommandContext(ctx, "opencode", "run", "--model", model, "--dir", projectDir, prompt) + case "claude": + cmd = exec.CommandContext(ctx, "claude", "--print", "--model", model, prompt) + default: + cmd = exec.CommandContext(ctx, "codex", "exec", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "--ask-for-approval", "never", "--model", model, "-") + cmd.Stdin = strings.NewReader(prompt) + } + cmd.Dir = projectDir + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("%s summarizer: %s", provider, msg) + } + return string(out), nil +} + +func summaryPrompt(snapshot string) string { + return "Summarize this terminal/agent snapshot for a compact UI catch-up aid.\n" + + "Return only JSON with keys summary and state. State must be one of IDLE, PERMISSION, THINKING, WORKING, ERROR.\n" + + "Keep summary under 180 characters, concrete, and avoid mentioning that you are summarizing.\n\n" + + snapshot +} + +func parseSummarizerResponse(out string) (summarizerResponse, error) { + var resp summarizerResponse + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &resp); err == nil { + return resp, nil + } + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "{") || !strings.HasSuffix(line, "}") { + continue + } + if err := json.Unmarshal([]byte(line), &resp); err == nil { + return resp, nil + } + } + return resp, fmt.Errorf("summary output was not JSON") +} + +func summaryIdleState(s string) IdleState { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "IDLE": + return StateIdle + case "PERMISSION": + return StatePermission + case "THINKING": + return StateThinking + case "WORKING": + return StateWorking + case "ERROR": + return StateError + default: + return StateUnknown + } +} diff --git a/internal/app/summarizer_test.go b/internal/app/summarizer_test.go new file mode 100644 index 0000000..dba7599 --- /dev/null +++ b/internal/app/summarizer_test.go @@ -0,0 +1,85 @@ +package app + +import ( + "strings" + "testing" + + "github.com/hjbdev/patterm/internal/preset" +) + +func TestParseSummarizerResponseAllowsWrappedJSON(t *testing.T) { + resp, err := parseSummarizerResponse("log\n{\"summary\":\"Waiting for tests\",\"state\":\"WORKING\"}\n") + if err != nil { + t.Fatalf("parseSummarizerResponse: %v", err) + } + if resp.Summary != "Waiting for tests" || summaryIdleState(resp.State) != StateWorking { + t.Fatalf("response = %+v", resp) + } +} + +func TestCompactSummaryTextDropsControlAndRedundantWhitespace(t *testing.T) { + got := compactSummaryText("hello\x00 world \n\n\n\x1b[31mred\x1b[0m\n") + if strings.ContainsRune(got, '\x00') { + t.Fatalf("control byte survived: %q", got) + } + if strings.Contains(got, "\n\n\n") { + t.Fatalf("redundant blanks survived: %q", got) + } + if strings.Contains(got, "\x1b") { + t.Fatalf("ansi survived: %q", got) + } +} + +func TestWrapSidebarSummaryKeepsWordBoundaries(t *testing.T) { + got := wrapSidebarSummary("alpha beta gamma delta", 12) + want := []string{"alpha beta", "gamma delta"} + if len(got) != len(want) { + t.Fatalf("lines = %#v", got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("line %d = %q want %q", i, got[i], want[i]) + } + } + long := wrapSidebarSummary("supercalifragilistic short", 8) + if len(long) == 0 || !strings.HasSuffix(long[0], "…") { + t.Fatalf("long word should clip with ellipsis: %#v", long) + } +} + +func TestSummaryManagerArmsOnlyTrackedTopLevelAgents(t *testing.T) { + sess := NewSession(t.TempDir(), "test") + c := newChildEntry("a1", "agent", KindAgent, []string{"fake"}, nil, "", "", "") + running := StatusRunning + c.status.Store(&running) + sess.children[c.ID] = c + sess.order = append(sess.order, c.ID) + cfg := defaultSettings().AutoSummary + m := newSummaryManager(sess, t.TempDir(), preset.Set{}, func() autoSummarySettings { + return cfg.clone() + }, nil, nil) + m.ObserveHumanInput(c.ID, []byte("please summarize")) + if got := m.Summary(c.ID); got.Text != "" { + t.Fatalf("untracked agent should not update summary state: %+v", got) + } + m.RegisterChild(c) + m.ObserveHumanInput(c.ID, []byte("please summarize")) + m.ObserveOutput(c.ID) + m.mu.Lock() + e := m.entries[c.ID] + m.mu.Unlock() + if e == nil || !e.armed || !e.dirty { + t.Fatalf("tracked top-level agent not armed/dirty: %+v", e) + } + + sub := newChildEntry("a2", "sub", KindAgent, []string{"fake"}, nil, c.ID, "", "") + sub.status.Store(&running) + m.RegisterChild(sub) + m.ObserveHumanInput(sub.ID, []byte("please summarize")) + m.mu.Lock() + _, ok := m.entries[sub.ID] + m.mu.Unlock() + if ok { + t.Fatal("sub-agent should not get a summary entry") + } +} diff --git a/internal/app/tabbar.go b/internal/app/tabbar.go index 7eec361..538962e 100644 --- a/internal/app/tabbar.go +++ b/internal/app/tabbar.go @@ -8,9 +8,9 @@ import ( "unicode/utf8" ) -// Two-row tab bar: labels row, underline row. The PTY viewport's top +// Three-row tab bar: labels row, active-thread summary row, underline row. The PTY viewport's top // row is therefore mainTop == tabBarRows + 1. -const tabBarRows = 2 +const tabBarRows = 3 // drawTabBar renders the top tab strip across the full host width. // Tabs share the available width with a flex layout — each visible @@ -139,7 +139,8 @@ func (st *uiState) drawTabBar() { } var b strings.Builder - // Clear both rows so a stale label from the previous frame can't + // Clear all tab-bar rows so stale labels or summaries from the + // previous frame can't // bleed through. Use ECH clamped to `width` (= childCols) instead of // `\x1b[2K`: 2K wipes the entire line including the sidebar columns, // and if drawSidebar's chrome cache is fresh it won't repaint to @@ -147,6 +148,7 @@ func (st *uiState) drawTabBar() { // and content should be. fmt.Fprintf(&b, "\x1b[1;1H\x1b[%dX", width) fmt.Fprintf(&b, "\x1b[2;1H\x1b[%dX", width) + fmt.Fprintf(&b, "\x1b[3;1H\x1b[%dX", width) for _, t := range tabs { // Row 1: centre-ish label inside the tab cell. @@ -170,9 +172,9 @@ func (st *uiState) drawTabBar() { b.WriteString(strings.Repeat(" ", rightPad)) b.WriteString(styleReset) - // Row 2: underline. Thick accent for the active tab, faint + // Row 3: underline. Thick accent for the active tab, faint // border for the rest. - fmt.Fprintf(&b, "\x1b[2;%dH", t.startCol) + fmt.Fprintf(&b, "\x1b[3;%dH", t.startCol) if t.active { b.WriteString(styleAccent) b.WriteString(strings.Repeat("━", t.width)) @@ -189,10 +191,14 @@ func (st *uiState) drawTabBar() { fmt.Fprintf(&b, "\x1b[1;%dH %s%s%s ", hintCol, styleDim, newHint, styleReset) // Underline continues faintly under the hint so the strip // reads as one bar. - fmt.Fprintf(&b, "\x1b[2;%dH%s%s%s", + fmt.Fprintf(&b, "\x1b[3;%dH%s%s%s", 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) + } + frame := b.String() st.chromeCacheMu.Lock() if frame == st.tabBarCache { diff --git a/internal/app/viewport_renderer_test.go b/internal/app/viewport_renderer_test.go index d285f34..e7998ae 100644 --- a/internal/app/viewport_renderer_test.go +++ b/internal/app/viewport_renderer_test.go @@ -16,7 +16,7 @@ func bytesRepeat(b byte, n int) []byte { func TestViewportRendererShiftsCursor(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) got := string(vr.Render([]byte("\x1b[H"))) - if got != "\x1b[3;1H" { + if got != "\x1b[4;1H" { t.Fatalf("CUP home: got %q", got) } } @@ -66,7 +66,7 @@ func TestViewportRendererSwallowsOriginModeToggles(t *testing.T) { if !strings.Contains(got, "a") || !strings.Contains(got, "b") || !strings.Contains(got, "c") { t.Fatalf("origin-mode toggles should not drop surrounding text: got %q", got) } - if strings.Count(got, "\x1b[3;1H") != 2 { + if strings.Count(got, "\x1b[4;1H") != 2 { t.Fatalf("origin-mode set/reset should home inside the viewport twice: got %q", got) } } @@ -88,23 +88,23 @@ func TestViewportRendererOriginModeCUPUsesScrollTop(t *testing.T) { if strings.Contains(got, "\x1b[?6h") { t.Fatalf("origin-mode set leaked to host: %q", got) } - if !strings.Contains(got, "\x1b[7;1H") { - t.Fatalf("CUP row 1 in origin mode should land at scrollTop row 5 shifted to host row 7: got %q", got) + if !strings.Contains(got, "\x1b[8;1H") { + t.Fatalf("CUP row 1 in origin mode should land at scrollTop row 5 shifted to host row 8: got %q", got) } } func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) { - // hostRows=7 leaves four viewport rows after the 2-row tab bar and + // hostRows=7 leaves three viewport rows after the 3-row tab bar and // 1-row status reservation. vr := newViewportRenderer(newTerminalLayout(20, 7)) got := string(vr.Render([]byte("\x1b[2J"))) if strings.Contains(got, "\x1b[2J") { t.Fatalf("host clear-screen leaked through: %q", got) } - if strings.Count(got, "\x1b[20X") != 4 { + if strings.Count(got, "\x1b[20X") != 3 { t.Fatalf("clear rows: got %q", got) } - if !strings.Contains(got, "\x1b[3;1H") || !strings.Contains(got, "\x1b[6;1H") { + if !strings.Contains(got, "\x1b[4;1H") || !strings.Contains(got, "\x1b[6;1H") { t.Fatalf("clear did not target viewport rows: %q", got) } } @@ -140,13 +140,12 @@ func TestViewportRendererClearToEndIsViewportOnly(t *testing.T) { t.Fatalf("host clear-to-end leaked through: %q", got) } // childCols == 19 (40 cols - 28 sidebar - 1 gap - 0-index fudge). - // Each of the 4 viewport rows should get a 19-cell erase. // childCols == 11 with hostCols=40 (28 sidebar + 1 gap reserved). - // 4 viewport rows, but the cursor row uses ECH at cursor (col 1), - // so we expect 4 erases of 11 cells each. + // 3 viewport rows, but the cursor row uses ECH at cursor (col 1), + // so we expect 3 erases of 11 cells each. count := strings.Count(got, "\x1b[11X") - if count != 4 { - t.Fatalf("expected 4 ECH-11 sequences, got %d in %q", count, got) + if count != 3 { + t.Fatalf("expected 3 ECH-11 sequences, got %d in %q", count, got) } } @@ -182,7 +181,7 @@ func TestViewportRendererClampsCUPColumn(t *testing.T) { // column so the host cursor never lands in the sidebar. vr := newViewportRenderer(newTerminalLayout(120, 40)) got := string(vr.Render([]byte("\x1b[5;95H"))) - if !strings.Contains(got, "\x1b[7;91H") { + if !strings.Contains(got, "\x1b[8;91H") { t.Fatalf("CUP col 95 should clamp to 91 (childCols): got %q", got) } } @@ -277,7 +276,7 @@ func TestViewportRendererFlagsScrollVerbs(t *testing.T) { func TestViewportRendererFlagsLineFeedAtViewportBottomAsScrolling(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) - _ = vr.Render([]byte("\x1b[37;1H\n")) + _ = vr.Render([]byte("\x1b[36;1H\n")) if !vr.TookScrollAction() { t.Fatalf("LF at viewport bottom should flag scroll") } @@ -285,7 +284,7 @@ func TestViewportRendererFlagsLineFeedAtViewportBottomAsScrolling(t *testing.T) func TestViewportRendererDoesNotFlagLineFeedBeforeViewportBottom(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) - _ = vr.Render([]byte("\x1b[36;1H\n")) + _ = vr.Render([]byte("\x1b[35;1H\n")) if vr.TookScrollAction() { t.Fatalf("LF before viewport bottom should not flag scroll") } @@ -312,7 +311,7 @@ func TestViewportRendererClampsCUUAtViewportTop(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) // CUP to viewport row 1 then CUU by 50. got := string(vr.Render([]byte("\x1b[1;1H\x1b[50ACLOBBER"))) - if !strings.Contains(got, "\x1b[3;1H") { + if !strings.Contains(got, "\x1b[4;1H") { t.Fatalf("expected CUP shifted to mainTop: got %q", got) } // The CUU should have been swallowed (n clamped to 0 from row 1). @@ -339,10 +338,10 @@ func TestViewportRendererClampsCUUPartial(t *testing.T) { } func TestViewportRendererClampsCUDAtViewportBottom(t *testing.T) { - // childRows=37 for layout(120, 40). Park cursor at row 37, ask for + // childRows=36 for layout(120, 40). Park cursor at row 36, ask for // 10 down → safe step is 0. vr := newViewportRenderer(newTerminalLayout(120, 40)) - got := string(vr.Render([]byte("\x1b[37;1H\x1b[10B"))) + got := string(vr.Render([]byte("\x1b[36;1H\x1b[10B"))) if strings.Contains(got, "\x1b[10B") { t.Fatalf("CUD past viewport bottom should be dropped: got %q", got) } @@ -363,10 +362,10 @@ func TestViewportRendererClampsCPLAndHomesColumn(t *testing.T) { func TestViewportRendererClampsCNL(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) - // CUP to row 35 then CNL by 50 → safe step is 2 (childRows-35). - got := string(vr.Render([]byte("\x1b[35;10H\x1b[50E"))) + // CUP to row 34 then CNL by 50 → safe step is 2 (childRows-34). + got := string(vr.Render([]byte("\x1b[34;10H\x1b[50E"))) if !strings.Contains(got, "\x1b[2E") { - t.Fatalf("CNL 50 from row 35 should clamp to 2: got %q", got) + t.Fatalf("CNL 50 from row 34 should clamp to 2: got %q", got) } } diff --git a/internal/harness/scenarios/sidebar_survives_linefeed_scroll.json b/internal/harness/scenarios/sidebar_survives_linefeed_scroll.json index 32198c5..05dcb06 100644 --- a/internal/harness/scenarios/sidebar_survives_linefeed_scroll.json +++ b/internal/harness/scenarios/sidebar_survives_linefeed_scroll.json @@ -5,7 +5,7 @@ "scripts": [ { "name": "linefeed-scroll", - "body": "#!/bin/sh\n# Plain LF at the bottom of the child viewport scrolls the host's\n# DECSTBM region. Because that region spans every column, enough LFs\n# drag the sidebar border and section labels out of the visible region\n# unless patterm invalidates and repaints the sidebar cache.\ni=0\nwhile [ $i -lt 12 ]; do\n printf 'warmup %02d\\n' \"$i\"\n i=$((i + 1))\n sleep 0.05\ndone\nprintf 'LINEFEED READY\\n'\nIFS= read -r _\nprintf '\\033[1;37r'\nprintf '\\033[37;1H'\ni=0\nwhile [ $i -lt 45 ]; do\n printf 'scroll line %02d\\n' \"$i\"\n i=$((i + 1))\ndone\nprintf 'LINEFEED DONE\\n'\nsleep 5\n" + "body": "#!/bin/sh\n# Plain LF at the bottom of the child viewport scrolls the host's\n# DECSTBM region. Because that region spans every column, enough LFs\n# drag the sidebar border and section labels out of the visible region\n# unless patterm invalidates and repaints the sidebar cache.\ni=0\nwhile [ $i -lt 12 ]; do\n printf 'warmup %02d\\n' \"$i\"\n i=$((i + 1))\n sleep 0.05\ndone\nprintf 'LINEFEED READY\\n'\nIFS= read -r _\nprintf '\\033[1;36r'\nprintf '\\033[36;1H'\ni=0\nwhile [ $i -lt 45 ]; do\n printf 'scroll line %02d\\n' \"$i\"\n i=$((i + 1))\ndone\nprintf 'LINEFEED DONE\\n'\nsleep 5\n" } ], "steps": [ @@ -19,13 +19,13 @@ { "type": "mark_raw", "save_as": "before_scroll" }, { "type": "send_chord", "chord": "enter" }, { "type": "wait_text", "contains": "LINEFEED DONE", "timeout_ms": 5000 }, + { "type": "wait_stable", "timeout_ms": 2000 }, { "type": "assert_raw_since_regex", "from": "before_scroll", - "regex": "Agent Tree", + "regex": "LINEFEED DONE", "timeout_ms": 2000 }, - { "type": "wait_stable", "timeout_ms": 2000 }, { "type": "assert_contains", "contains": "Processes" }, { "type": "assert_contains", "contains": "Agent Tree" }, { "type": "assert_contains", "contains": "Scratchpads" },