mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* feat(tui): /loop foundation — command parsing, loop state, safety bounds First slice of the /loop feature (#501): the self-contained core with no TUI wiring yet. parseLoopCommand handles the leading-interval, trailing-"every", bare-self-paced, and list/stop forms; loopState models one session-scoped loop with doom-loop + iteration-cap fields; interval/self-pace clamps and duration formatting round it out, with unit tests. Because Zero drives loops off a Go timer rather than a cron backend, arbitrary intervals (90s, 7m) are supported with no whitelist limitation. * feat(tui): wire /loop into the turn lifecycle — dispatch, idle tick, continuation Second slice of #501. Adds the controller and wires it in: - /loop command registered (commands.go) and dispatched to handleLoopCommand. - Loop state on the model (loops, activeLoopID, loopSeq/loopTicking poll guards, loopNextWake/loopDone self-pace channel). - An idle poll tick (loopTickMsg, ~1s) fires the earliest due loop only when the session is idle — mirrors the launchQueuedMessageIfReady guard so loops and a user's queued prompt never collide; a self-perpetuating tea.Tick that stops when no loops remain. - The agentResponseMsg completion seam advances a loop iteration (schedule next wake for fixed, model delay for self-paced) with doom-loop, iteration-cap, and done stop conditions, restarting the ticker if other loops remain. - /loop list|stop|stop all, footer summary + list rendering, and a guard that only a prompt or custom /command may loop (not a built-in). Controller tests cover start/advance/self-paced-delay/done/doom/cap/stop/busy. * feat(tui): /loop self-paced adaptive cadence + footer summary + /clear note Third slice of #501. - Self-paced loops now use an adaptive cadence (2m early -> 30m heartbeat as iterations accrue), with the loopNextWake channel kept as an override for a future explicit model signal. - Persistent footer segment "↻ N loops · next 3:05pm" while loops are active. - /clear notes that loops keep running (it wipes the transcript, not the session) so a loop never silently fires into a cleared screen. - Tests for adaptive cadence + footer summary. * test(tui): drive /loop end-to-end through Update (submit + stale tick) * docs(readme): add /loop to the slash-command table * fix(tui): stop looping on repeated failure + clear loop tag on cancel Addresses CodeRabbit review on #502: - advanceLoop now counts consecutive failed iterations (loopMaxFailures=3) and stops the loop, so a loop whose turns keep erroring no longer churns to the iteration cap (the doom guard only sees identical non-empty answers). - cancelRun clears activeLoopID and re-arms the interrupted loop for its next cadence, so a cancelled iteration is not left running forever and the next unrelated turn is not misattributed as that loop's completion. * fix(tui): finish /loop v1 — real self-paced completion, session scope, leave gate Response to the review. The self-paced path was the real gap: it advertised model-chosen completion but nothing ever wrote the done/next-wake fields, so it was just adaptive polling dressed up. Replaced that with an actual control channel — each self-paced iteration runs a short maintenance playbook and ends its reply with a control line (LOOP: DONE / LOOP: CONTINUE [interval]) that advanceLoop parses to stop the loop or pick the next wake. The two dead model fields are gone; done and next-wake now come from the model's output. Rest of the review: - loops stop on a session switch (/new, /resume) instead of firing the previous conversation's prompt into the new one - validateLoopTarget resolves custom /commands up front and rejects unknowns, so a mistyped `/loop 5m /nope` isn't scheduled and then re-sent as literal text - a one-shot confirm gates /clear and /quit while loops are active - age-based auto-expire (24h) next to the iteration cap - the two CodeRabbit test nits (HasPrefix footer check, bare `/loop stop` on a single loop) Hardening from a self-review pass over the above: - normalize CRLF and allow a trailing note on the control line, so `LOOP: DONE — all clean` and \r\n replies still stop the loop - disarm the leave-confirm before the queue/exit early returns, so an interposed prompt can't leave it falsely armed - a loop no longer fires behind an open picker/modal; a run launched behind the /resume picker would otherwise complete into whatever session you switch to * fix(tui): address /loop review round 2 — ticker re-arm, self-paced command, resume guard - stopLoop re-arms the poll ticker after removing one loop, so stopping one of several no longer strands the rest with no tick scheduled (jatmn). - a bare self-paced /command is rejected with a pointer to the interval form: self-paced drives a free-form goal via the playbook + LOOP: control line, which a custom command's expanded template never carries, so it would silently degrade to adaptive polling (jatmn). - /resume only tears loops down on an actual session-id change; /resume latest or /resume <currentID> can resolve to the already-active session, whose loops are its own, not a "previous" session's (CodeRabbit). - tests: skipped-behind-modal loop stays scheduled, footer carries the next-wake time, the confirmed /clear actually wipes the transcript, plus multi-loop stop keeping the ticker alive and self-paced-/command rejection. * docs(readme): clarify /loop runs a prompt or custom command, not built-ins
850 lines
29 KiB
Go
850 lines
29 KiB
Go
package tui
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/agent"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/sessions"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/usage"
|
|
)
|
|
|
|
const tuiSessionTitleLimit = 80
|
|
|
|
type pendingSessionEvent struct {
|
|
Type sessions.EventType
|
|
Payload any
|
|
}
|
|
|
|
func (m model) ensureActiveSession(prompt string) (model, error) {
|
|
if m.activeSession.SessionID != "" {
|
|
return m, nil
|
|
}
|
|
|
|
session, err := m.sessionStore.Create(sessions.CreateInput{
|
|
Title: tuiSessionTitle(prompt),
|
|
Cwd: m.cwd,
|
|
ModelID: m.modelName,
|
|
Provider: m.providerName,
|
|
})
|
|
if err != nil {
|
|
return m, err
|
|
}
|
|
m.activeSession = session
|
|
m.sessionEvents = []sessions.Event{}
|
|
return m, nil
|
|
}
|
|
|
|
// startNewSession abandons the visible conversation and the agent's in-context
|
|
// history and begins a fresh session in place. The current session already lives
|
|
// on disk (its events are persisted as they happen), so it stays resumable via
|
|
// /resume; here we only clear the in-memory conversation and the per-session
|
|
// usage/compaction counters, then let the next prompt lazily create a new session
|
|
// through ensureActiveSession — the same seam a cold start uses. Model, provider,
|
|
// permission mode, and response style are intentionally preserved: /new starts a
|
|
// clean conversation, not a clean configuration.
|
|
func (m model) startNewSession() model {
|
|
previousID := m.activeSession.SessionID
|
|
|
|
m.activeSession = sessions.Metadata{}
|
|
m.sessionEvents = nil
|
|
|
|
// Reset the per-session usage + compaction display so the new session starts
|
|
// from zero instead of inheriting the previous conversation's token/cost totals.
|
|
if m.usageTracker != nil {
|
|
m.usageTracker.Reset()
|
|
}
|
|
m.lastUsage = usage.Normalized{}
|
|
m.lastUsageSeen = false
|
|
m.unpricedRequests = 0
|
|
m.unpricedTokens = 0
|
|
m.compactRequests = 0
|
|
m.compactFrame = 0
|
|
m.lastCompactResult = nil
|
|
m.lastCompactError = ""
|
|
m.turnLatencySum = 0
|
|
m.turnLatencyCount = 0
|
|
m.turnTTFTSum = 0
|
|
m.turnTTFTCount = 0
|
|
|
|
// Staged input belongs to the previous conversation. Attachments and a queued
|
|
// message are only consumed at prompt-submit, so without clearing them here the
|
|
// fresh session's first prompt would silently inherit the old session's images,
|
|
// documents, or queued text.
|
|
m.pendingImages = nil
|
|
m.pendingImageLabels = nil
|
|
m.pendingDocuments = nil
|
|
m.queuedMessage = ""
|
|
// The remembered /retry attachment snapshot belongs to the previous session
|
|
// too — dropping it keeps a post-/new /retry from re-staging old images or
|
|
// documents. lastPrompt is composer history (like inputHistory) and stays.
|
|
m.lastImages = nil
|
|
m.lastImageLabels = nil
|
|
m.lastDocuments = nil
|
|
|
|
note := "Started a new session."
|
|
if previousID != "" {
|
|
note = "New session started. Previous session saved as " + previousID +
|
|
" — resume it anytime with /resume " + previousID + "."
|
|
}
|
|
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
|
|
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: note})
|
|
// Scrollback above can't be un-printed; a faint divider marks the boundary and
|
|
// the flush frontier restarts for the fresh transcript (mirrors /clear, /resume).
|
|
m.resetFlushFrontier("· new session ·")
|
|
// Loops belong to the previous session; stop them so they don't fire the old
|
|
// conversation's prompt into the fresh one.
|
|
if updated, cleared := m.clearLoopsForSessionSwitch(); cleared > 0 {
|
|
m = updated
|
|
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", cleared)})
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m model) appendSessionEvent(eventType sessions.EventType, payload any) (model, error) {
|
|
if m.activeSession.SessionID == "" {
|
|
return m, nil
|
|
}
|
|
|
|
event, err := m.sessionStore.AppendEvent(m.activeSession.SessionID, sessions.AppendEventInput{
|
|
Type: eventType,
|
|
Payload: payload,
|
|
})
|
|
if err != nil {
|
|
return m, err
|
|
}
|
|
m.activeSession.UpdatedAt = event.CreatedAt
|
|
m.activeSession.EventCount = event.Sequence
|
|
m.activeSession.LastEventType = event.Type
|
|
m.sessionEvents = append(m.sessionEvents, event)
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) appendSessionEvents(events []pendingSessionEvent) (model, []transcriptRow) {
|
|
rows := []transcriptRow{}
|
|
if m.activeSession.SessionID == "" || len(events) == 0 {
|
|
return m, rows
|
|
}
|
|
inputs := make([]sessions.AppendEventInput, 0, len(events))
|
|
for _, event := range events {
|
|
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
|
|
}
|
|
appended, err := m.sessionStore.AppendEvents(m.activeSession.SessionID, inputs)
|
|
if err != nil {
|
|
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
|
|
return m, rows
|
|
}
|
|
if len(appended) > 0 {
|
|
last := appended[len(appended)-1]
|
|
m.activeSession.UpdatedAt = last.CreatedAt
|
|
m.activeSession.EventCount = last.Sequence
|
|
m.activeSession.LastEventType = last.Type
|
|
m.sessionEvents = append(m.sessionEvents, appended...)
|
|
}
|
|
return m, rows
|
|
}
|
|
|
|
// appendSessionEventsTo persists events into a specific (non-active) session —
|
|
// the late flush of a run cancelled before a /resume switched sessions. The
|
|
// active session's in-memory metadata is deliberately untouched.
|
|
func (m model) appendSessionEventsTo(sessionID string, events []pendingSessionEvent) []transcriptRow {
|
|
rows := []transcriptRow{}
|
|
if m.sessionStore == nil || sessionID == "" {
|
|
return rows
|
|
}
|
|
inputs := make([]sessions.AppendEventInput, 0, len(events))
|
|
for _, event := range events {
|
|
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
|
|
}
|
|
if _, err := m.sessionStore.AppendEvents(sessionID, inputs); err != nil {
|
|
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// flushableSessionEvents selects the events worth persisting from a run that was
|
|
// cancelled mid-flight. The cancel path already records a single "Run cancelled."
|
|
// error, so the goroutine's trailing EventError (the ctx-cancellation error) is
|
|
// dropped to avoid a duplicate; everything else it accumulated before the cancel
|
|
// — tool calls/results, permission events, usage, and the EventSessionCheckpoint
|
|
// blobs that /rewind depends on — is kept.
|
|
func flushableSessionEvents(events []pendingSessionEvent) []pendingSessionEvent {
|
|
flushable := make([]pendingSessionEvent, 0, len(events))
|
|
for _, event := range events {
|
|
if event.Type == sessions.EventError {
|
|
continue
|
|
}
|
|
flushable = append(flushable, event)
|
|
}
|
|
return flushable
|
|
}
|
|
|
|
func tuiSessionTitle(prompt string) string {
|
|
// cutRunes keeps the cut on a rune boundary — a bare byte slice could split
|
|
// a multi-byte rune and persist invalid UTF-8 into the session metadata.
|
|
title := cutRunes(strings.Join(strings.Fields(prompt), " "), tuiSessionTitleLimit)
|
|
if title == "" {
|
|
return "Zero TUI session"
|
|
}
|
|
return title
|
|
}
|
|
|
|
func (m model) handleResumeCommand(args string) (model, string) {
|
|
args = strings.TrimSpace(args)
|
|
if args == "" {
|
|
return m, m.resumeText()
|
|
}
|
|
|
|
session, err := m.resolveResumeSession(args)
|
|
if err != nil {
|
|
return m, "Sessions\n" + err.Error()
|
|
}
|
|
events, err := m.resumeEvents(session.SessionID)
|
|
if err != nil {
|
|
return m, "Sessions\nerror: " + err.Error()
|
|
}
|
|
|
|
// Capture the current session id before switching so loops are only torn down
|
|
// on a real change — `/resume latest` or `/resume <currentID>` can resolve to
|
|
// the already-active session, whose loops belong to it, not a "previous" one.
|
|
previousID := m.activeSession.SessionID
|
|
m.activeSession = *session
|
|
m.sessionEvents = append([]sessions.Event{}, events...)
|
|
if m.providerName == "" {
|
|
m.providerName = session.Provider
|
|
}
|
|
if m.modelName == "" {
|
|
m.modelName = session.ModelID
|
|
}
|
|
loopsCleared := 0
|
|
if session.SessionID != previousID {
|
|
m, loopsCleared = m.clearLoopsForSessionSwitch()
|
|
}
|
|
|
|
rows := initialTranscript()
|
|
rows = appendRow(rows, rowSystem, m.formatResumeSummary(*session, len(events)))
|
|
if loopsCleared > 0 {
|
|
rows = appendRow(rows, rowSystem, fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", loopsCleared))
|
|
}
|
|
rows = appendTranscriptRowsDedup(rows, transcriptRowsFromSessionEvents(events))
|
|
m.transcript = rows
|
|
// Every rehydrated row is settled by construction, so resetting the flush
|
|
// frontier sends the whole resumed history to native scrollback in one
|
|
// batch — scrollable, selectable, and O(1) for every later frame.
|
|
m.resetFlushFrontier("· resumed ·")
|
|
return m, ""
|
|
}
|
|
|
|
func (m model) sessionPrompt(prompt string) string {
|
|
if m.activeSession.SessionID == "" || len(m.sessionEvents) == 0 {
|
|
return prompt
|
|
}
|
|
return sessions.FormatExecPrompt(prompt, sessions.PreparedExec{
|
|
Mode: sessions.ModeResume,
|
|
Session: m.activeSession,
|
|
ContextEvents: append([]sessions.Event{}, m.sessionEvents...),
|
|
})
|
|
}
|
|
|
|
func (m model) resolveResumeSession(args string) (*sessions.Metadata, error) {
|
|
if strings.EqualFold(args, "latest") {
|
|
// Latest *resumable* conversation IN THIS WORKSPACE, so "latest" never lands
|
|
// on a child, a spec sub-run, or a session from another project (matching the
|
|
// workspace-scoped picker). An explicit `/resume <id>` below still resolves
|
|
// any session regardless of workspace.
|
|
latest, err := m.latestResumableInWorkspace()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if latest == nil {
|
|
return nil, errors.New("no zero sessions available to resume")
|
|
}
|
|
return latest, nil
|
|
}
|
|
|
|
session, err := m.sessionStore.Get(args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if session == nil {
|
|
return nil, fmt.Errorf("zero session not found: %s", args)
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
// resumeEvents reads a session's events for resume, preferring the rehydrated
|
|
// (compaction-aware) view so a resumed session honors a prior /compact — matching
|
|
// the CLI's `zero exec --resume` (readExecContextEvents) and the in-TUI /compact
|
|
// reload. Falls back to the raw log if rehydration fails.
|
|
func (m model) resumeEvents(sessionID string) ([]sessions.Event, error) {
|
|
events, err := m.sessionStore.ReadRehydratedEvents(sessionID)
|
|
if err == nil {
|
|
return events, nil
|
|
}
|
|
raw, rawErr := m.sessionStore.ReadEvents(sessionID)
|
|
if rawErr != nil {
|
|
// Surface the raw-read failure (the actual fallback error), not the earlier
|
|
// rehydration error, so the caller sees why the fallback itself failed.
|
|
return nil, rawErr
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// formatResumeSummary reports what the resumed conversation will actually continue
|
|
// with (the active model/provider), noting the session's recorded model/provider
|
|
// when it differs — resume keeps the current model rather than switching.
|
|
func (m model) formatResumeSummary(session sessions.Metadata, eventCount int) string {
|
|
modelLine := "model: " + displayValue(m.modelName, "none")
|
|
if recorded := strings.TrimSpace(session.ModelID); recorded != "" && !strings.EqualFold(recorded, m.modelName) {
|
|
modelLine += " (recorded: " + recorded + ")"
|
|
}
|
|
providerLine := "provider: " + displayValue(m.providerName, "none")
|
|
if recorded := strings.TrimSpace(session.Provider); recorded != "" && !strings.EqualFold(recorded, m.providerName) {
|
|
providerLine += " (recorded: " + recorded + ")"
|
|
}
|
|
return renderCommandOutput(commandOutput{
|
|
Title: "Resumed Zero session",
|
|
Status: commandStatusOK,
|
|
Sections: []commandSection{{
|
|
Title: "Session",
|
|
Lines: []string{
|
|
"id: " + session.SessionID,
|
|
"title: " + displayValue(session.Title, "untitled"),
|
|
modelLine,
|
|
providerLine,
|
|
fmt.Sprintf("events: %d", eventCount),
|
|
},
|
|
}},
|
|
})
|
|
}
|
|
|
|
// sessionWhen formats a session's RFC3339 timestamp for the picker: a precise
|
|
// clock time (with seconds) for today so same-minute sessions stay distinct, the
|
|
// month/day and time earlier this year, else the date. Empty on a parse error.
|
|
func sessionWhen(timestamp string, now time.Time) string {
|
|
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(timestamp))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
parsed, now = parsed.Local(), now.Local()
|
|
switch {
|
|
case parsed.Year() == now.Year() && parsed.YearDay() == now.YearDay():
|
|
return parsed.Format("15:04:05")
|
|
case parsed.Year() == now.Year():
|
|
return parsed.Format("Jan _2 15:04")
|
|
default:
|
|
return parsed.Format("2006-01-02")
|
|
}
|
|
}
|
|
|
|
// newSessionPicker builds the interactive /resume picker (mirrors /model & /provider):
|
|
// one row per resumable session — title (Label) + id and relative age (Meta). Returns
|
|
// nil when there are no resumable sessions so the caller falls back to the text path.
|
|
func (m model) newSessionPicker() *commandPicker {
|
|
if m.sessionStore == nil {
|
|
return nil
|
|
}
|
|
metas, err := m.sessionStore.ListResumable()
|
|
if err != nil || len(metas) == 0 {
|
|
return nil
|
|
}
|
|
now := m.now()
|
|
items := make([]pickerItem, 0, len(metas))
|
|
for _, meta := range metas {
|
|
// Workspace-scoped: hide sessions from other project directories so /resume
|
|
// lists this workspace's history, not every project's. Checked BEFORE the
|
|
// per-session event read below, so a large global history doesn't pay 50
|
|
// full file reads to build one workspace's list. Sessions with no recorded
|
|
// Cwd (older runs) stay visible rather than vanishing.
|
|
if !sessionMatchesWorkspace(meta.Cwd, m.cwd) {
|
|
continue
|
|
}
|
|
// A zero-event session has nothing to resume — skip it without a file read.
|
|
if meta.EventCount == 0 {
|
|
continue
|
|
}
|
|
// Skip empty/failed runs (no assistant output, no tool calls) — e.g. the
|
|
// same prompt retried while the model wasn't responding. They have nothing
|
|
// to resume and otherwise flood the list with identical rows. Still on disk.
|
|
if !m.sessionHasResumableContent(meta.SessionID) {
|
|
continue
|
|
}
|
|
// Lead with the timestamp so same-titled sessions (e.g. the same first
|
|
// prompt run several times) are visually distinct; the id (right, faint)
|
|
// stays for reference and is what selection actually resolves.
|
|
label := displayValue(meta.Title, "untitled")
|
|
if when := sessionWhen(meta.UpdatedAt, now); when != "" {
|
|
label = when + " " + label
|
|
}
|
|
items = append(items, pickerItem{
|
|
Label: label,
|
|
Value: meta.SessionID,
|
|
Meta: meta.SessionID,
|
|
})
|
|
}
|
|
if len(items) == 0 {
|
|
return nil // every resumable session was an empty/failed run
|
|
}
|
|
return &commandPicker{
|
|
kind: pickerSession,
|
|
title: "Resume a session",
|
|
items: items,
|
|
allItems: append([]pickerItem{}, items...),
|
|
selected: 0,
|
|
}
|
|
}
|
|
|
|
// sessionHasResumableContent reports whether a session has anything worth
|
|
// resuming: a tool call/result, or a non-user message with real content (not the
|
|
// no-output guardrail stop). Empty/failed runs return false and are hidden from
|
|
// the picker (they stay on disk). Errors fail open (the session is kept).
|
|
// latestResumableInWorkspace returns the most-recently-updated resumable session
|
|
// belonging to the current workspace, or nil when none exist. ListResumable is
|
|
// ordered latest-first, so the first qualifying match is the latest. It applies
|
|
// the SAME filters as newSessionPicker — workspace membership, non-empty
|
|
// metadata, and real resumable content — so `/resume latest` never lands on a
|
|
// zero-event or empty/failed run the picker would have hidden.
|
|
func (m model) latestResumableInWorkspace() (*sessions.Metadata, error) {
|
|
metas, err := m.sessionStore.ListResumable()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range metas {
|
|
if !sessionMatchesWorkspace(metas[i].Cwd, m.cwd) {
|
|
continue
|
|
}
|
|
if metas[i].EventCount == 0 {
|
|
continue
|
|
}
|
|
if !m.sessionHasResumableContent(metas[i].SessionID) {
|
|
continue
|
|
}
|
|
return &metas[i], nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// sessionMatchesWorkspace reports whether a session recorded in sessionCwd
|
|
// belongs to the current workspaceCwd. A session with no recorded Cwd (older
|
|
// runs) or an unknown current workspace is kept visible rather than filtered out,
|
|
// so the scoping never hides history it can't confidently place elsewhere. On
|
|
// Windows the comparison is case-insensitive, since the filesystem is and the
|
|
// same workspace can be spelled with different casing (C:\Proj vs c:\proj).
|
|
func sessionMatchesWorkspace(sessionCwd, workspaceCwd string) bool {
|
|
sessionCwd = strings.TrimSpace(sessionCwd)
|
|
workspaceCwd = strings.TrimSpace(workspaceCwd)
|
|
if sessionCwd == "" || workspaceCwd == "" {
|
|
return true
|
|
}
|
|
a := filepath.Clean(sessionCwd)
|
|
b := filepath.Clean(workspaceCwd)
|
|
if runtime.GOOS == "windows" {
|
|
return strings.EqualFold(a, b)
|
|
}
|
|
return a == b
|
|
}
|
|
|
|
func (m model) sessionHasResumableContent(sessionID string) bool {
|
|
events, err := m.sessionStore.ReadEvents(sessionID)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return eventsHaveResumableContent(events)
|
|
}
|
|
|
|
// eventsHaveResumableContent reports whether already-loaded events contain
|
|
// anything worth resuming: a tool call/result, or a non-user message with real
|
|
// content (not the no-output guardrail stop). It is the pure core of
|
|
// sessionHasResumableContent so callers that already hold the events (e.g. the
|
|
// /retitle scan) don't re-read them.
|
|
func eventsHaveResumableContent(events []sessions.Event) bool {
|
|
for _, event := range events {
|
|
switch event.Type {
|
|
case sessions.EventToolCall, sessions.EventToolResult:
|
|
return true
|
|
case sessions.EventMessage:
|
|
payload := sessionPayload(event)
|
|
if strings.EqualFold(payloadString(payload, "role"), "user") {
|
|
continue
|
|
}
|
|
content := strings.TrimSpace(payloadString(payload, "content"))
|
|
if content != "" && !agent.IsNoProgressStop(content) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// openSessionPicker opens the /resume picker; ok is false when there is nothing to
|
|
// resume (the caller then falls back to the text list / "none" message).
|
|
func (m model) openSessionPicker() (model, bool) {
|
|
picker := m.newSessionPicker()
|
|
if picker == nil {
|
|
return m, false
|
|
}
|
|
m.picker = picker
|
|
return m, true
|
|
}
|
|
|
|
func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow {
|
|
rows := []transcriptRow{}
|
|
// Rehydrated rows all carry runID 0, so repeated provider tool-call ids
|
|
// (e.g. Gemini's per-turn gemini_tool_N) get the same per-occurrence
|
|
// disambiguation the live runner applies — without it, dedup would drop
|
|
// every tool card after the first occurrence of an id.
|
|
callSeq := map[string]int{}
|
|
// Pre-pass: collect the tool-call ids of Task delegations that actually started
|
|
// a specialist (each renders as a card below). Only those Task tool-call/result
|
|
// rows are redundant and skipped; a Task that failed before a specialist started
|
|
// has no card, so its rows are kept — otherwise the failed delegation vanishes
|
|
// on resume (M10).
|
|
specialistToolCalls := map[string]bool{}
|
|
for _, event := range events {
|
|
if event.Type == sessions.EventSpecialistStart {
|
|
if id := payloadString(sessionPayload(event), "toolCallId"); id != "" {
|
|
specialistToolCalls[id] = true
|
|
}
|
|
}
|
|
}
|
|
for _, event := range events {
|
|
payload := sessionPayload(event)
|
|
switch event.Type {
|
|
case sessions.EventMessage:
|
|
role := strings.ToLower(payloadString(payload, "role"))
|
|
switch role {
|
|
case "ask_user":
|
|
rows = append(rows, askUserTranscriptRow(askUserRequestFromPayload(payload)))
|
|
continue
|
|
case "ask_user_answers":
|
|
if text := askUserAnswersText(payload); text != "" {
|
|
rows = append(rows, transcriptRow{kind: rowSystem, text: text})
|
|
}
|
|
continue
|
|
}
|
|
content := payloadString(payload, "content")
|
|
if content == "" {
|
|
continue
|
|
}
|
|
switch role {
|
|
case "user":
|
|
rows = append(rows, transcriptRow{kind: rowUser, text: content})
|
|
case "assistant":
|
|
// A persisted assistant message was a turn's final answer. Tool/timing
|
|
// counters were not recorded; the completion line omits those segments.
|
|
rows = append(rows, transcriptRow{kind: rowAssistant, text: content, final: true})
|
|
default:
|
|
rows = append(rows, transcriptRow{kind: rowSystem, text: content})
|
|
}
|
|
case sessions.EventToolCall:
|
|
name := payloadString(payload, "name")
|
|
if name == "" {
|
|
name = "unknown"
|
|
}
|
|
// Extract the id exactly as the tool-result branch and the specialist
|
|
// pre-pass do (toolCallId first, then id) so call and result rows key
|
|
// callSeq/effectiveToolRowID on the same string and the specialist-skip
|
|
// lookup matches — otherwise a payload that carries toolCallId (not id)
|
|
// desyncs call→result dedup and the M10 skip (L20).
|
|
id := firstNonEmptyString(payloadString(payload, "toolCallId"), payloadString(payload, "id"))
|
|
if name == "Task" && specialistToolCalls[id] {
|
|
// A specialist card renders this delegation; skip the redundant
|
|
// "tool call: Task" row. A Task with no specialist (it failed before
|
|
// one started) keeps its row so the failure stays visible (M10).
|
|
continue
|
|
}
|
|
callSeq[id]++
|
|
rows = append(rows, transcriptRow{
|
|
kind: rowToolCall,
|
|
id: effectiveToolRowID(id, callSeq[id]),
|
|
text: "tool call: " + name,
|
|
tool: name,
|
|
detail: argHint(payloadString(payload, "arguments")),
|
|
arg: argHintSecondary(payloadString(payload, "arguments")),
|
|
})
|
|
case sessions.EventPermission, sessions.EventPermissionRequest, sessions.EventPermissionDecision:
|
|
// Mirror the live path: a silently auto-approved call recorded its
|
|
// audit event but rendered no row, so don't resurrect one on resume.
|
|
event := permissionEventFromPayload(payload)
|
|
if permissionEventIsNoteworthy(event) {
|
|
rows = append(rows, permissionTranscriptRow(event))
|
|
}
|
|
case sessions.EventToolResult:
|
|
name := payloadString(payload, "name")
|
|
if name == "" {
|
|
name = "unknown"
|
|
}
|
|
id := firstNonEmptyString(payloadString(payload, "toolCallId"), payloadString(payload, "id"))
|
|
if name == "Task" && specialistToolCalls[id] {
|
|
// The specialist card carries this Task's result; skip the redundant
|
|
// "tool result: Task" row. A Task with no specialist keeps its result
|
|
// row so a failed delegation's error stays visible (M10).
|
|
continue
|
|
}
|
|
status := tools.Status(payloadString(payload, "status"))
|
|
if status == "" {
|
|
status = tools.StatusOK
|
|
}
|
|
output := payloadString(payload, "output")
|
|
rows = append(rows, transcriptRow{
|
|
kind: rowToolResult,
|
|
id: effectiveToolRowID(id, callSeq[id]),
|
|
text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)),
|
|
tool: name,
|
|
status: status,
|
|
detail: output,
|
|
changedFiles: payloadStringSlice(payload, "changedFiles"),
|
|
})
|
|
case sessions.EventError:
|
|
if message := payloadString(payload, "message"); message != "" {
|
|
rows = append(rows, transcriptRow{kind: rowError, text: message})
|
|
}
|
|
case sessions.EventCompaction:
|
|
if summary := payloadString(payload, "summary"); summary != "" {
|
|
rows = append(rows, transcriptRow{kind: rowSystem, text: summary})
|
|
}
|
|
case sessions.EventSessionFork:
|
|
parentID := payloadString(payload, "parentSessionId")
|
|
if parentID != "" {
|
|
rows = append(rows, transcriptRow{kind: rowSystem, text: "forked from session: " + parentID})
|
|
}
|
|
case sessions.EventSpecialistStart:
|
|
info := specialistInfoFromPayload(payload)
|
|
if info != nil {
|
|
rows = append(rows, transcriptRow{kind: rowSpecialist, specialistInfo: info})
|
|
}
|
|
case sessions.EventSpecialistStop:
|
|
info := specialistInfoFromPayload(payload)
|
|
if info != nil {
|
|
// Reconcile: update the existing Start row with the same
|
|
// childSessionID instead of appending a duplicate. On resume
|
|
// this prevents two cards per specialist (running + completed).
|
|
found := false
|
|
for i := range rows {
|
|
if rows[i].kind == rowSpecialist && rows[i].specialistInfo != nil &&
|
|
rows[i].specialistInfo.childSessionID == info.childSessionID {
|
|
rows[i].specialistInfo = info
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
rows = append(rows, transcriptRow{kind: rowSpecialist, specialistInfo: info})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func sessionPayload(event sessions.Event) map[string]any {
|
|
payload := map[string]any{}
|
|
if len(event.Payload) == 0 {
|
|
return payload
|
|
}
|
|
_ = json.Unmarshal(event.Payload, &payload)
|
|
return payload
|
|
}
|
|
|
|
func permissionEventFromPayload(payload map[string]any) agent.PermissionEvent {
|
|
name := payloadString(payload, "name")
|
|
if name == "" {
|
|
name = payloadString(payload, "toolName")
|
|
}
|
|
event := agent.PermissionEvent{
|
|
ToolCallID: firstNonEmptyString(payloadString(payload, "toolCallId"), payloadString(payload, "id")),
|
|
ToolName: name,
|
|
Action: agent.PermissionAction(payloadString(payload, "action")),
|
|
DecisionAction: agent.PermissionDecisionAction(payloadString(payload, "decisionAction")),
|
|
Permission: payloadString(payload, "permission"),
|
|
PermissionGranted: payloadBool(payload, "permissionGranted"),
|
|
PermissionMode: agent.PermissionMode(payloadString(payload, "permissionMode")),
|
|
Autonomy: payloadString(payload, "autonomy"),
|
|
SideEffect: payloadString(payload, "sideEffect"),
|
|
Reason: payloadString(payload, "reason"),
|
|
Scope: payloadString(payload, "scope"),
|
|
DecisionReason: payloadString(payload, "decisionReason"),
|
|
GrantMatched: payloadBool(payload, "grantMatched"),
|
|
}
|
|
if risk, ok := payloadMap(payload, "risk"); ok {
|
|
event.Risk = sandbox.Risk{
|
|
Level: sandbox.RiskLevel(payloadString(risk, "level")),
|
|
Reason: payloadString(risk, "reason"),
|
|
}
|
|
}
|
|
if block, ok := payloadMap(payload, "block"); ok {
|
|
event.Block = &sandbox.Block{
|
|
Code: sandbox.BlockCode(payloadString(block, "code")),
|
|
ToolName: payloadString(block, "toolName"),
|
|
Action: sandbox.Action(payloadString(block, "action")),
|
|
Risk: event.Risk,
|
|
Path: payloadString(block, "path"),
|
|
Reason: payloadString(block, "reason"),
|
|
Recoverable: payloadBool(block, "recoverable"),
|
|
}
|
|
if nestedRisk, ok := payloadMap(block, "risk"); ok {
|
|
event.Block.Risk = sandbox.Risk{
|
|
Level: sandbox.RiskLevel(payloadString(nestedRisk, "level")),
|
|
Reason: payloadString(nestedRisk, "reason"),
|
|
}
|
|
}
|
|
}
|
|
return event
|
|
}
|
|
|
|
// askUserRequestFromPayload rebuilds the request persisted by
|
|
// askUserSessionPayload, so ask_user exchanges survive /resume instead of
|
|
// silently vanishing from rehydrated history.
|
|
func askUserRequestFromPayload(payload map[string]any) agent.AskUserRequest {
|
|
request := agent.AskUserRequest{
|
|
ToolCallID: payloadString(payload, "toolCallId"),
|
|
Header: payloadString(payload, "header"),
|
|
}
|
|
raw, ok := payload["questions"].([]any)
|
|
if !ok {
|
|
return request
|
|
}
|
|
for _, entry := range raw {
|
|
fields, ok := entry.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
question := agent.AskUserQuestion{
|
|
Question: payloadString(fields, "question"),
|
|
MultiSelect: payloadBool(fields, "multiSelect"),
|
|
}
|
|
if options, ok := fields["options"].([]any); ok {
|
|
for _, option := range options {
|
|
if text, ok := option.(string); ok {
|
|
question.Options = append(question.Options, text)
|
|
}
|
|
}
|
|
}
|
|
request.Questions = append(request.Questions, question)
|
|
}
|
|
return request
|
|
}
|
|
|
|
// askUserAnswersText renders persisted ask_user answers for rehydration.
|
|
func askUserAnswersText(payload map[string]any) string {
|
|
raw, ok := payload["answers"].([]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
lines := make([]string, 0, len(raw))
|
|
for index, entry := range raw {
|
|
text, _ := entry.(string)
|
|
if strings.TrimSpace(text) == "" {
|
|
text = "(skipped)"
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%d. %s", index+1, text))
|
|
}
|
|
if len(lines) == 0 {
|
|
return ""
|
|
}
|
|
return "Answers\n" + strings.Join(lines, "\n")
|
|
}
|
|
|
|
func payloadString(payload map[string]any, key string) string {
|
|
value := payload[key]
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return typed
|
|
case float64, bool:
|
|
return fmt.Sprint(typed)
|
|
case nil:
|
|
return ""
|
|
default:
|
|
data, err := json.Marshal(typed)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(data)
|
|
}
|
|
}
|
|
|
|
// payloadStringSlice reads a []string persisted into a session payload (JSON
|
|
// round-trips it as []any), skipping non-string entries. Nil when absent.
|
|
func payloadStringSlice(payload map[string]any, key string) []string {
|
|
switch typed := payload[key].(type) {
|
|
case []string:
|
|
return typed
|
|
case []any:
|
|
out := make([]string, 0, len(typed))
|
|
for _, v := range typed {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func payloadBool(payload map[string]any, key string) bool {
|
|
value := payload[key]
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed
|
|
case string:
|
|
return strings.EqualFold(strings.TrimSpace(typed), "true")
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func payloadMap(payload map[string]any, key string) (map[string]any, bool) {
|
|
value, ok := payload[key].(map[string]any)
|
|
return value, ok
|
|
}
|
|
|
|
func firstNonEmptyString(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// specialistInfoFromPayload builds a specialistInfo from a specialist_start or
|
|
// specialist_stop session event payload. Returns nil if the payload lacks a
|
|
// childSessionId (the minimum required field).
|
|
func specialistInfoFromPayload(payload map[string]any) *specialistInfo {
|
|
childSessionID := payloadString(payload, "childSessionId")
|
|
if childSessionID == "" {
|
|
return nil
|
|
}
|
|
info := &specialistInfo{
|
|
name: payloadString(payload, "specialist"),
|
|
description: payloadString(payload, "description"),
|
|
childSessionID: childSessionID,
|
|
}
|
|
statusStr := payloadString(payload, "status")
|
|
switch statusStr {
|
|
case "running":
|
|
info.status = specialistRunning
|
|
case "success":
|
|
info.status = specialistCompleted
|
|
case "completed":
|
|
info.status = specialistCompleted
|
|
default:
|
|
info.status = parseSpecialistStatus(statusStr)
|
|
}
|
|
if errMsg := payloadString(payload, "error"); errMsg != "" {
|
|
info.errorMsg = errMsg
|
|
}
|
|
return info
|
|
}
|