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 = 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", "--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 } }