// This file binds the full-screen TUI to the real agent run seam and the local // session store (US-009, FR-16/17). It is the TUI counterpart to the REPL's // replDeps + streamRun + cli.PersistTurn plumbing (internal/cli/repl): it // assembles an AgentContext + RunConfig from the model's Options, feeds them to // the event bridge (bridge.go's startRun → runtime.StartRun/DrainStream), and // persists the growing conversation to ~/.pigo/sessions after each turn. // // It deliberately imports the SHARED lower-level packages the REPL also uses // (session, runtime, provider, cli, cli/run, cli/headless, cli/ui) rather than // the repl package itself, so the two entry paths share one store and one // run-config shape without an import cycle (repl and tui are siblings; prompts // imports tui, so tui must not reach back into repl/prompts). package tui import ( "context" "fmt" "os" "time" tea "charm.land/bubbletea/v2" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/agenttool" "github.com/smallnest/pigo/internal/cli" "github.com/smallnest/pigo/internal/cli/headless" "github.com/smallnest/pigo/internal/cli/run" "github.com/smallnest/pigo/internal/cli/ui" "github.com/smallnest/pigo/internal/compaction" "github.com/smallnest/pigo/internal/hooks" "github.com/smallnest/pigo/internal/memory" "github.com/smallnest/pigo/internal/plugin" "github.com/smallnest/pigo/internal/provider" "github.com/smallnest/pigo/internal/runtime" "github.com/smallnest/pigo/internal/session" "github.com/smallnest/pigo/internal/trust" ) // runSession holds the assembled per-session state for a TUI run: the persisted // store + header, the growing conversation context, the live (mutable) run // config, and the tool/credential collaborators. It mirrors the subset of // repl.replDeps the TUI needs, and owns the same session-tree cursor bookkeeping // (curLeaf / persisted) so each turn is persisted as a branch rather than a // flattening rewrite. type runSession struct { store *session.Store header session.SessionHeader agentCtx *agentcore.AgentContext live *cli.LiveConfig reg *agenttool.ToolRegistry reminders *runtime.ReminderRegistry creds *provider.CredentialStore // cwd is the directory pigo was launched in, captured once at session // assembly. It is the trust key and the /status environment display. cwd string // trust persists project-trust decisions (US-018, #134). It is nil when // trust is disabled (store could not be loaded / no cwd); when nil /status // reports "disabled" and the trust-gated hook layer is skipped. trust *trust.Manager // slash is the shared slash-command registry the TUI consults exactly as the // REPL does. It is assembled per-session against the live config (withSession // rebinds the model's registry to this one) so /model switches reach it. slash *runtime.SlashRegistry // telemetry holds the retained per-run telemetry events (US-001, #291) and // the cumulative accumulator that sums metrics across all runs in the // session. The run loop folds each run's TelemetryEvent into it; /status // reads it back through the Host contract. telemetry *cli.TelemetryHolder // memoryRoot is the persistent-memory Store root (empty when memory is // disabled). It routes auto-compaction checkpoints and /rebuild recovery to // /sessions//, the canonical checkpoint location. memoryRoot string // memstore is the live persistent-memory Store (nil when memory is disabled). // It lets /memory inspect entry counts without re-opening the database. memstore *memory.Store // dispatcher is the session's hook dispatcher, nil when no hooks are // configured (FR-18). hookDeps carries the session id / project dir stamped // onto every HookInput and hook process environment. dispatcher *hooks.Dispatcher hookDeps run.HookDeps // onEvent is the observer chain delivered to every run: the plugin notifier // (US-017) with the SessionEnd/PreCompact hook notifier chained after it. onEvent func(agentcore.AgentEvent) // curLeaf is the id of the on-disk entry the next turn descends from; persisted // is the number of agentCtx.Messages already written. persist() appends only // Messages[persisted:] as a branch from curLeaf (see cli.PersistTurn). curLeaf string persisted int // compacted is set when the run loop compacted the context (CompactionEvent): // compaction rewrites Messages into a summary + recent tail, which both shrinks // the slice below persisted (so an incremental Messages[persisted:] would panic) // and invalidates the branch prefix. persist() honors this by re-saving the // flattened context linearly and resetting the branch cursor, then clears it. compacted bool // cancelRun cancels the in-flight run's context; startRun sets it and the // two-stage interrupt (Model.interruptFn → interrupt) calls it. It is nil // before the first run and after a run is cancelled. cancelRun context.CancelFunc // lastBtw is the /btw side thread's context from this process and // lastBtwBase the background-message index it diverged from. Both are // carried on the Host contract for parity with the REPL; the TUI does not // run /btw today, so they stay nil/0. lastBtw *agentcore.AgentContext lastBtwBase int // remote owns the running remote-control server+bridge (remotecontrol.go), // nil when /remote-control is off. buildConfig reads it to install the remote // confirm seam so risky tool calls route to the paired browser while connected. remote *remoteSession } // newRunSession assembles the run session from the resolved Options, opening the // shared ~/.pigo/sessions store. When Options carries a ResumeID it loads that // session's entries and rebuilds the context (the returned history seeds the // replayed transcript); otherwise it starts a fresh session with a new header. // It is the production entry; newRunSessionWithStore holds the store-agnostic // core so tests can drive it against a temp-dir store. func newRunSession(opts Options) (*runSession, []agentcore.Message, error) { store, err := headless.SessionStore() if err != nil { return nil, nil, err } return newRunSessionWithStore(store, opts) } // newRunSessionWithStore is the store-agnostic core of newRunSession: given an // already-opened store it resolves resume-vs-fresh, builds the live config and // collaborators, and returns the session plus the resumed history (nil for a // fresh session). func newRunSessionWithStore(store *session.Store, opts Options) (*runSession, []agentcore.Message, error) { creds := provider.NewCredentialStore(nil) creds.SetOverride(opts.ProviderName, opts.APIKey) // cwd is the launch directory, captured once: it stamps fresh sessions, is // the trust key, and feeds /status's environment section and the hook layer. cwd, _ := os.Getwd() now := time.Now().UTC() var ( agentCtx *agentcore.AgentContext header session.SessionHeader history []agentcore.Message curLeaf string ) if opts.ResumeID != "" { h, entries, err := store.LoadEntries(opts.ResumeID) if err != nil { return nil, nil, err } msgs := make(agentcore.MessageList, len(entries)) for i, e := range entries { msgs[i] = e.Message } if len(entries) > 0 { curLeaf = entries[len(entries)-1].ID } header = h sysPrompt := h.SystemPrompt if sysPrompt == "" { sysPrompt = opts.SysPrompt } agentCtx = &agentcore.AgentContext{SystemPrompt: sysPrompt, Messages: msgs, Tools: opts.Tools} history = msgs } else { agentCtx = &agentcore.AgentContext{SystemPrompt: opts.SysPrompt, Tools: opts.Tools} // Stamp the launch directory onto a fresh session (#526/#524) so the // session is attributed to a project and a later /dream pass can distill it // under the right scope, mirroring headless/REPL. An unresolvable cwd // yields "" (session stays unattributed) rather than aborting. header = session.SessionHeader{ ID: session.NewID(now), CreatedAt: now, UpdatedAt: now, Model: opts.Model, Provider: opts.ProviderName, SystemPrompt: opts.SysPrompt, Cwd: cwd, } } live := &cli.LiveConfig{ Model: opts.Model, ProviderName: opts.ProviderName, Provider: opts.Provider, BaseURL: opts.BaseURL, Protocol: opts.Protocol, ThinkingLevel: opts.ThinkingLevel, ContextWindow: cli.DefaultContextWindow, } // Project trust (US-018, #134): load the persisted trust store for the // launch directory, mirroring the REPL. A load failure (or an unresolvable // cwd) is non-fatal: trust is disabled (mgr stays nil) and the TUI still // runs — the store is surfaced rather than silently overwritten. mgr, mgrErr := trust.NewManager(trust.DefaultPath()) if mgrErr != nil { fmt.Fprintf(os.Stderr, "pigo: trust store unavailable, trust disabled: %v\n", mgrErr) mgr = nil } if cwd == "" && mgr != nil { fmt.Fprintf(os.Stderr, "pigo: cannot resolve working directory, trust disabled\n") mgr = nil } s := &runSession{ store: store, header: header, agentCtx: agentCtx, live: live, reg: run.ToolRegistry(opts.Tools), reminders: run.TodoReminders(opts.Tools), creds: creds, cwd: cwd, trust: mgr, slash: newSlashRegistry(opts, live), telemetry: cli.NewTelemetryHolder(), curLeaf: curLeaf, persisted: len(history), memoryRoot: run.MemoryRootFromTools(opts.Tools), memstore: run.MemoryStoreFromTools(opts.Tools), } // /trust is a per-session command (its closure captures mgr + cwd), so it is // registered here rather than in newSlashRegistry. A nil mgr is a no-op. trust.RegisterCommand(s.slash, mgr, cwd) // Wire hooks uniformly with every other driver (#425): resolve the trust-gated // hook set, build the dispatcher, dispatch SessionStart once, and compose the // SessionEnd/PreCompact observer with the plugin notifier. Trust is granted by // --approve (Options.Approve) or the shared trust store; project-layer hooks // only apply when trusted (FR-14). A malformed hook layer disables hooks with a // warning rather than failing the TUI launch. s.hookDeps = run.HookDeps{SessionID: header.ID, ProjectDir: cwd, WarnLog: os.Stderr} trusted := opts.Approve || (mgr != nil && mgr.IsTrusted(cwd)) var baseOnEvent func(agentcore.AgentEvent) if n := plugin.NewEventNotifier(opts.Plugins, os.Stderr); n != nil { baseOnEvent = n.Handle } if set, err := run.ResolveHookSet(cwd, trusted); err != nil { fmt.Fprintf(os.Stderr, "pigo: hooks disabled: %v\n", err) s.onEvent = baseOnEvent } else if d := run.BuildDispatcher(set, s.hookDeps); d != nil { s.dispatcher = d if s.reminders == nil { s.reminders = runtime.NewReminderRegistry() } ssCfg := runtime.RunConfig{Reminders: s.reminders} run.DispatchSessionStart(context.Background(), d, &ssCfg, s.hookDeps, sessionStartSource(opts)) s.reminders = ssCfg.Reminders n := hooks.NewHookNotifier(d, s.hookDeps.SessionID, s.hookDeps.ProjectDir) s.onEvent = chainTUIEvent(baseOnEvent, n.Handle) } else { s.onEvent = baseOnEvent } return s, history, nil } // sessionStartSource maps the resolved run options to the SessionStart source // tag: "resume" when continuing an existing session, "startup" otherwise. func sessionStartSource(opts Options) string { if opts.ResumeID != "" { return "resume" } return "startup" } // chainTUIEvent composes the plugin notifier with the hook notifier into one // observer; a nil operand is identity. func chainTUIEvent(prev, next func(agentcore.AgentEvent)) func(agentcore.AgentEvent) { if prev == nil { return next } if next == nil { return prev } return func(ev agentcore.AgentEvent) { prev(ev) next(ev) } } // buildConfig assembles the RunConfig for one turn from the live config and // collaborators. It replicates repl.streamRun's assembly (same LoopConfig fields, // tool registry and reminders) minus the interactive trust confirmation hook: the // TUI has no stdin prompt to confirm side-effect tool calls on, so tools run // under the trust granted up front by --approve (Options.Approve) rather than a // per-call BeforeToolCall prompt. The stream fn is derived from the live provider // and the API key resolved through the credential store, exactly as the REPL does. func (s *runSession) buildConfig() runtime.RunConfig { cfg := runtime.RunConfig{ LoopConfig: runtime.LoopConfig{ Model: s.live.Model, Provider: s.live.ProviderName, ThinkingLevel: s.live.ThinkingLevel, Stream: provider.StreamFnFromProvider(s.live.Provider), GetAPIKey: s.creds.GetAPIKey, ContextWindow: s.live.ContextWindow, Compaction: compaction.DefaultCompactionSettings, }, Batch: agenttool.BatchConfig{ ToolExecutorConfig: agenttool.ToolExecutorConfig{ Registry: s.reg, }, }, Reminders: s.reminders, SessionID: s.header.ID, MemoryRoot: s.memoryRoot, } // Per-turn wiring of the tool-execution + Stop seams; nil dispatcher is a // no-op so the hot path pays nothing when no hooks are configured (FR-18). if s.dispatcher != nil { run.InstallSeams(&cfg, s.dispatcher, s.hookDeps) } // When remote control is active, route side-effect tool-call confirmations to // the paired browser (no-op when no client is connected or the cwd is trusted, // so the non-remote path is unchanged). The trust manager is read from the // shared store; a nil manager disables the seam. if s.remote != nil { if mgr, err := trust.NewManager(trust.DefaultPath()); err == nil { cfg.Batch.ToolExecutorConfig.BeforeToolCall = remoteConfirmSeam(s.remote, mgr, s.hookDeps.ProjectDir) } } return cfg } // rebuildDoneMsg reports the outcome of a manual /rebuild to the model: summary // is the status line to show in the transcript, err is set when the rebuild // failed (the context is then left unchanged). type rebuildDoneMsg struct { summary string err error } // rebuildCmd runs a context rebuild off the tea loop (the no-checkpoint fallback // makes a summarization LLM call, so it must not block the UI goroutine) and // yields a rebuildDoneMsg the model folds into the transcript. It mirrors the // REPL's runManualRebuild. func (s *runSession) rebuildCmd() tea.Cmd { return func() tea.Msg { summary, err := s.rebuild() return rebuildDoneMsg{summary: summary, err: err} } } // rebuild reconstructs the shared context from the session's persisted checkpoint // (collapsing the pre-watermark prefix to the checkpoint summary and preserving // the recent tail verbatim), falling back to lossy compaction when no checkpoint // exists. It replaces agentCtx.Messages in place on success and flags compacted // so persist() re-saves the flattened context linearly (as after a /compact). func (s *runSession) rebuild() (string, error) { msgs := s.agentCtx.Messages before := compaction.EstimateContextTokens(msgs).Tokens // Checkpoints live under /sessions//; recover from the same // root the loop writes to. Empty when memory is disabled — RebuildFromCheckpoint // then falls back to lossy compaction. memoryRoot := s.memoryRoot cfg := s.buildConfig() res, err := runtime.RebuildFromCheckpoint(context.Background(), msgs, s.header.ID, memoryRoot, &cfg, nil) if err != nil { return "", err } if res.NoOp { return fmt.Sprintf("nothing to rebuild (%d tokens, %d messages)", before, len(msgs)), nil } s.agentCtx.Messages = res.Messages s.compacted = true source := "checkpoint" if !res.FromCheckpoint { source = "compaction (no checkpoint)" } return fmt.Sprintf("context rebuilt from %s: %d → %d tokens, collapsed %d messages, kept %d", source, res.TokensBefore, res.TokensAfter, res.SummarizedCount, res.KeptCount), nil } // prompt to the growing context as a user message, then hands the context and a // freshly-built config to the event bridge (bridge.startRun → runtime.StartRun + // DrainStream on a goroutine), returning the bridge channel and the first // waitForEvent Cmd so Update can pump the run's events. The context grows in // place (agentCtx is a pointer), so the next turn continues the conversation. func (s *runSession) startRun(prompt string) (chan tea.Msg, tea.Cmd) { content, err := ui.BuildUserContent(prompt) if err != nil { // A malformed image reference must not swallow the turn: fall back to the // raw prompt as plain text so the run still starts. content = agentcore.ContentList{agentcore.NewTextContent(prompt)} } // UserPromptSubmit runs before the prompt is committed to the context: a block // aborts the turn (emitting a runEndMsg carrying the reason) without leaving a // dangling user message; additionalContext is injected into this turn only. if s.dispatcher != nil { pc := runtime.RunConfig{Reminders: s.reminders} if block, reason := run.DispatchUserPromptSubmit(context.Background(), s.dispatcher, &pc, s.hookDeps, prompt); block { ch := newEventChan() go func() { ch <- runEndMsg{err: fmt.Errorf("prompt blocked by hook: %s", reason)} }() return ch, waitForEvent(ch) } s.reminders = pc.Reminders } s.agentCtx.Messages = append(s.agentCtx.Messages, agentcore.UserMessage{ RoleField: agentcore.RoleUser, Content: content, }) // Use a cancellable context so the two-stage interrupt (FR-14) can stop this // run: cancelling propagates through StartRun/DrainStream, which then emits a // runEndMsg and the model returns to idle. ctx, cancel := context.WithCancel(context.Background()) s.cancelRun = cancel return startRun(ctx, s.agentCtx, s.buildConfig(), s.onEvent) } // interrupt cancels the in-flight run, if any. It is bound to Model.interruptFn // by withSession so pressing Esc / Ctrl+C while running stops the current run // instead of quitting the program (FR-14). Safe to call when no run is active. func (s *runSession) interrupt() { if s.cancelRun != nil { s.cancelRun() } } // persist writes the messages produced since the last persist as a new branch // descending from the active leaf, advancing the leaf and the persisted cursor. // It mirrors cli.PersistTurn: growing the on-disk tree with AppendBranch (rather // than a linear rewrite) keeps history intact. A no-op when nothing new was // produced, so an idle turn-end never regenerates entry ids. func (s *runSession) persist() error { // A compaction during the run rewrote Messages into a summary + recent tail, // so the append-a-tail branch model no longer holds: the prefix changed and // the slice may be shorter than persisted. Re-save the flattened context // linearly and reset the branch cursor to the new leaf, mirroring the REPL's // /compact handling. if s.compacted || s.persisted > len(s.agentCtx.Messages) { s.header.UpdatedAt = time.Now().UTC() s.header.Model = s.live.Model s.header.Provider = s.live.ProviderName if err := s.store.Save(s.header, s.agentCtx.Messages); err != nil { return err } s.persisted = len(s.agentCtx.Messages) s.curLeaf = "" if _, entries, err := s.store.LoadEntries(s.header.ID); err == nil && len(entries) > 0 { s.curLeaf = entries[len(entries)-1].ID } s.compacted = false return nil } tail := s.agentCtx.Messages[s.persisted:] if len(tail) == 0 { return nil } s.header.UpdatedAt = time.Now().UTC() s.header.Model = s.live.Model s.header.Provider = s.live.ProviderName leaf, err := s.store.AppendBranch(s.header, s.curLeaf, tail) if err != nil { return err } s.curLeaf = leaf s.persisted = len(s.agentCtx.Messages) return nil } // seedTranscript replays a resumed session's prior messages into the transcript // so the user sees the conversation so far before re-prompting (the TUI analogue // of repl.replayTranscript). User and assistant text become their respective // blocks; assistant tool calls render as system lines (tool cards land in #389). // Tool-result messages are omitted here — their content is echoed live during a // run, and replaying raw results would clutter the resumed view. func seedTranscript(t *transcript, history []agentcore.Message) { for _, m := range history { switch msg := m.(type) { case agentcore.UserMessage: if text := agentcore.ContentToText(msg.Content); text != "" { t.addUser(text) } case agentcore.AssistantMessage: if text := agentcore.ContentToText(msg.Content); text != "" { t.finalizeTurn(msg) } for _, c := range msg.ToolCalls() { t.addSystem("· " + c.Name) } } } }