detached 0d7cbb3

Merge pull request #7 from bnema/feat/session-tree-escape

feat: add session tree and double escape navigation
commit
0d7cbb34150d077e9b98894bb23302ee86326366
author
bnema · · 2026-08-04 11:54 UTC
committer
GitHub · · 2026-08-04 11:54 UTC
merge of
365593f + 48bddb4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
22 files · +4633 -665
M README.md +13 -8
@@ -277,8 +277,9 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
277 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |277 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
278 | `/reasoning` | Set the reasoning level for subsequent model calls. |278 | `/reasoning` | Set the reasoning level for subsequent model calls. |
279 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |279 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |
280-| `/sessions` | Resume a previous session for this directory. |280+| `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. |
281-| `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. |281+| `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. |
282+| `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. |
282 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |283 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
283 | `/btw` | Side chat with full context that doesn't add to the main thread. |284 | `/btw` | Side chat with full context that doesn't add to the main thread. |
284 | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). |285 | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). |
@@ -301,7 +302,7 @@ Type `!` followed by a command to run it directly without going through the mode
301 302
302 ### `/sessions`303 ### `/sessions`
303 304
304-Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember the model they ended on, so resuming picks up on that exact model even if your global default changed.305+Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker.
305 306
306 ### `/session`307 ### `/session`
307 308
@@ -310,9 +311,13 @@ Four ops on the current session. `/session` alone opens a picker; each is also r
310 - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout.311 - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout.
311 312
312 **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.313 **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.
313-- **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions`, `/jump`, and the tree. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically).314+- **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions` and `/jump`, and in the current-family tree when they are part of that family. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically).
314-- **`/session fork`**. Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies every message up to and including that turn into a new session, records `parent` + `fork_point` in the new meta, and switches onto the branch. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path.315+- **`/fork`** (also available as **`/session fork`**). Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies the transcript through that turn into a new branch and switches to it without starting a provider request. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. Use a user-message row in `/session tree` when you want the prompt restored for editing, including image attachments.
315-- **`/session tree`**. Shows every session in the current cwd arranged by parent/child relationships, depth-first with indent per level. The current session is tagged `[current]`. Pick any entry to switch into it. Parentless sessions are roots; branches created via `/session fork` nest under whichever session they were forked from. Orphaned children (whose parent file was deleted) still show as roots so they stay discoverable.316+- **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. Use `up`/`down` and `enter` to choose a row; `esc` closes the tree. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots.
317+
318+ Selecting a user-message row creates a navigation branch before that message and restores the complete editable draft, including text blocks and image attachments; no provider request is made until you submit it. Selecting an assistant/tool row checks out a safe message boundary, and selecting an empty or detached boundary checks out that branch point with an empty editor. Navigation branches are hidden from `/sessions` but remain in the tree.
319+
320+ `/session tree` and the double-Escape shortcut use the same checks: a complete current session family must be readable, and no turn, shell escape, compaction, startup/session load, dialog, picker, suggestion, or queued message may be active. The shortcut is two parsed, unmodified bare `Esc` events within 500 ms from an idle editor with no visible text or pending image. Dialogs, confirmations, panels, pickers, and suggestions keep their existing Escape behavior; busy Escape still cancels, modified Escape is not part of the gesture, and any intervening key or timeout resets it. The shortcut depends on the terminal input path producing those parsed events; `/session tree` is the explicit equivalent when it is unavailable.
316 321
317 ### `/jump`322 ### `/jump`
318 323
@@ -420,7 +425,7 @@ This is a guardrail against accidents, not a hard security boundary. If you need
420 425
421 ## Sessions426 ## Sessions
422 427
423-Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy.428+Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Session metadata stores the provider and model used by that session; resume honors that pair, rebuilding the active agent when it differs and refusing the switch without discarding the current session if rebuilding fails. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy.
424 429
425 ## Providers430 ## Providers
426 431
@@ -865,7 +870,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
865 | `enter` | Submit (queued if the agent is busy). |870 | `enter` | Submit (queued if the agent is busy). |
866 | `alt+enter` | Newline. |871 | `alt+enter` | Newline. |
867 | `tab` | Complete the selected slash command. |872 | `tab` | Complete the selected slash command. |
868-| `esc` | Cancel the current turn (while busy); clear input (while idle). |873+| `esc` | Cancel the current turn (while busy); clear input (while idle). Two parsed bare `Esc` presses within 500 ms open `/session tree` only when the editor is idle and empty; dialogs, busy/queued work, and modified keys keep their existing precedence. |
869 | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. |874 | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. |
870 | `ctrl+d` | Exit on empty input. |875 | `ctrl+d` | Exit on empty input. |
871 | `ctrl+l` | Redraw the screen. |876 | `ctrl+l` | Redraw the screen. |
M docs/zotfiles.md +1 -1
@@ -487,7 +487,7 @@ Sessions created by a zotfile are isolated from ordinary zot sessions and from o
487 $ZOT_HOME/sessions/agents/<name>/487 $ZOT_HOME/sessions/agents/<name>/
488 ```488 ```
489 489
490-Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. The interactive `/sessions` picker and `/session` import, fork, and tree commands use that same agent-scoped root.490+Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. The interactive `/sessions` picker and `/session` import, fork, and tree commands use that same agent-scoped root; `/fork` is the direct alias for selecting a user message and branching after that turn. `/session tree` shows the current session's family within that root (its ancestor and descendants), while tree-navigation branches stay hidden from the flat `/sessions` picker. Resuming honors the selected session's stored provider/model pair; a failed provider/model rebuild leaves the current session unchanged.
491 491
492 ## Commands492 ## Commands
493 493
M packages/agent/cli.go +452 -57
@@ -10,6 +10,7 @@ import (
10 "os"10 "os"
11 "os/signal"11 "os/signal"
12 "path/filepath"12 "path/filepath"
13+ "slices"
13 "strings"14 "strings"
14 "sync"15 "sync"
15 "syscall"16 "syscall"
@@ -184,6 +185,21 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes
184 }185 }
185 var out []provider.Message186 var out []provider.Message
186 start := len(msgs) - keepTail187 start := len(msgs) - keepTail
188+ var carriedToolNames []string
189+ addToolNames := func(names []string) {
190+ for _, name := range names {
191+ if name == "" || slices.Contains(carriedToolNames, name) {
192+ continue
193+ }
194+ carriedToolNames = append(carriedToolNames, name)
195+ }
196+ }
197+ // Activation markers in the discarded prefix still affect the retained
198+ // provider request. Carry them onto its first message rather than dropping
199+ // deferred-tool availability when the resume tail is shortened.
200+ for _, msg := range msgs[:start] {
201+ addToolNames(msg.AddedToolNames)
202+ }
187 // Preserve the synthetic compaction summary when present so an203 // Preserve the synthetic compaction summary when present so an
188 // already-compacted session stays compacted after resume.204 // already-compacted session stays compacted after resume.
189 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 1 {205 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 1 {
@@ -195,9 +211,214 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes
195 start++211 start++
196 }212 }
197 out = append(out, msgs[start:]...)213 out = append(out, msgs[start:]...)
214+ if len(carriedToolNames) > 0 && len(out) > 0 {
215+ first := out[0]
216+ for _, name := range first.AddedToolNames {
217+ addToolNames([]string{name})
218+ }
219+ first.AddedToolNames = append([]string(nil), carriedToolNames...)
220+ out[0] = first
221+ }
198 return provider.RepairOrphanedToolResults(out)222 return provider.RepairOrphanedToolResults(out)
199 }223 }
200 224
225+// sessionResumeCandidate contains all state needed to resume a session.
226+// It is deliberately assembled before the caller changes any live state:
227+// opening the transcript, reading usage, and rebuilding a provider/model
228+// must all succeed before the candidate can replace the current session.
229+type sessionResumeCandidate struct {
230+ session *core.Session
231+ agent *core.Agent
232+ messages []provider.Message
233+ fullMessageCount int
234+ cumulative provider.Usage
235+ lastTurn provider.Usage
236+ provider string
237+ model string
238+ rebuilt bool
239+}
240+
241+// prepareSessionResume opens and validates a selected session without
242+// touching the current agent. Missing provider/model metadata is treated as
243+// legacy metadata: the current agent is reused, preserving compatibility
244+// with sessions written before those fields were persisted.
245+//
246+// The returned candidate owns session until its caller commits it. On any
247+// error this function closes the candidate session and leaves the current
248+// agent untouched.
249+func prepareSessionResume(path string, current *core.Agent, currentProvider, currentModel string, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) {
250+ return prepareSessionResumeWithOptions(path, current, currentProvider, currentModel, false, false, buildAgentFor)
251+}
252+
253+// prepareSessionResumeWithOptions is the provider/model-aware implementation.
254+// An explicit CLI provider or model keeps that field authoritative; an empty
255+// field falls back to the selected session's metadata when it is complete.
256+func prepareSessionResumeWithOptions(path string, current *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (candidate sessionResumeCandidate, err error) {
257+ if current == nil {
258+ return candidate, fmt.Errorf("no agent running; log in first")
259+ }
260+
261+ sess, msgs, err := core.OpenSession(path)
262+ if err != nil {
263+ return candidate, err
264+ }
265+ keepSession := false
266+ defer func() {
267+ if !keepSession {
268+ _ = sess.Close()
269+ }
270+ }()
271+
272+ cumulative, lastTurn, err := core.SessionUsageDetail(path)
273+ if err != nil {
274+ return candidate, fmt.Errorf("read session usage: %w", err)
275+ }
276+
277+ fullMessageCount := len(msgs)
278+ resumedMessages := trimMessagesForResume(msgs, 100)
279+ storedProvider := strings.TrimSpace(sess.Meta.Provider)
280+ storedModel := strings.TrimSpace(sess.Meta.Model)
281+ currentProvider = strings.TrimSpace(currentProvider)
282+ currentModel = strings.TrimSpace(currentModel)
283+
284+ candidate = sessionResumeCandidate{
285+ session: sess,
286+ agent: current,
287+ messages: resumedMessages,
288+ fullMessageCount: fullMessageCount,
289+ cumulative: cumulative,
290+ lastTurn: lastTurn,
291+ provider: currentProvider,
292+ model: currentModel,
293+ }
294+
295+ // Old sessions may have either field absent. Only a complete stored
296+ // selection is actionable; otherwise resume exactly as older zot did.
297+ if storedProvider == "" || storedModel == "" {
298+ keepSession = true
299+ return candidate, nil
300+ }
301+
302+ resumeProvider := storedProvider
303+ if explicitProvider {
304+ resumeProvider = currentProvider
305+ }
306+ resumeModel := storedModel
307+ if explicitModel {
308+ resumeModel = currentModel
309+ }
310+ if resumeProvider == currentProvider && resumeModel == currentModel {
311+ keepSession = true
312+ return candidate, nil
313+ }
314+ if buildAgentFor == nil {
315+ return sessionResumeCandidate{}, fmt.Errorf("cannot resume session with provider %q and model %q: no agent builder configured", resumeProvider, resumeModel)
316+ }
317+
318+ rebuilt, resolvedProvider, resolvedModel, err := buildAgentFor(resumeProvider, resumeModel)
319+ if err != nil {
320+ return sessionResumeCandidate{}, fmt.Errorf("rebuild agent for session provider %q/model %q: %w", resumeProvider, resumeModel, err)
321+ }
322+ if rebuilt == nil {
323+ return sessionResumeCandidate{}, fmt.Errorf("rebuild agent for session provider %q/model %q returned no agent", resumeProvider, resumeModel)
324+ }
325+ resolvedProvider = strings.TrimSpace(resolvedProvider)
326+ resolvedModel = strings.TrimSpace(resolvedModel)
327+ if resolvedProvider == "" {
328+ resolvedProvider = storedProvider
329+ }
330+ if resolvedModel == "" {
331+ resolvedModel = storedModel
332+ }
333+
334+ // Hydrate and seed the replacement while it is still private. The live
335+ // agent is not modified until the caller commits the complete candidate.
336+ rebuilt.SetMessages(resumedMessages)
337+ rebuilt.SeedCost(cumulative)
338+ rebuilt.SeedLastTurnUsage(lastTurn)
339+ candidate.agent = rebuilt
340+ candidate.provider = resolvedProvider
341+ candidate.model = resolvedModel
342+ candidate.rebuilt = true
343+ keepSession = true
344+ return candidate, nil
345+}
346+
347+// buildNonInteractiveSessionAgent constructs a provider/model-specific agent
348+// for startup resume while preserving the cwd sandbox and extension tools that
349+// were attached to the initial resolution.
350+func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string) (*core.Agent, string, string, error) {
351+ next := args
352+ next.Provider = providerName
353+ next.Model = model
354+ resolved, err := Resolve(next, true)
355+ if err != nil {
356+ return nil, "", "", err
357+ }
358+ resolved.UseSandbox(base.Sandbox)
359+ if extMgr != nil {
360+ resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
361+ }
362+ ag := resolved.NewAgent()
363+ wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
364+ return ag, resolved.Provider, resolved.Model, nil
365+}
366+
367+// applySessionResume shares the startup gate, candidate hydration, and
368+// commit order used by interactive and non-interactive session opening. The
369+// old append handle is closed only after the candidate has been prepared and
370+// seeded successfully.
371+func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) {
372+ candidate := sessionResumeCandidate{
373+ session: sess,
374+ agent: ag,
375+ fullMessageCount: 0,
376+ provider: currentProvider,
377+ model: currentModel,
378+ }
379+ if ag != nil {
380+ candidate.fullMessageCount = len(ag.Messages())
381+ }
382+ if sess == nil || ag == nil {
383+ return candidate, nil
384+ }
385+ // A newly created meta-only session is already on the active pair. Avoid
386+ // reopening and closing it through the resume path: Close intentionally
387+ // removes fresh empty sessions, and a second append handle would otherwise
388+ // keep an unlinked file alive.
389+ if len(ag.Messages()) == 0 &&
390+ strings.TrimSpace(sess.Meta.Provider) == strings.TrimSpace(currentProvider) &&
391+ strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(currentModel) {
392+ return candidate, nil
393+ }
394+ candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, buildAgentFor)
395+ if err != nil {
396+ _ = sess.Close()
397+ return sessionResumeCandidate{}, err
398+ }
399+ if !candidate.rebuilt {
400+ candidate.agent.SetMessages(candidate.messages)
401+ candidate.agent.SeedCost(candidate.cumulative)
402+ candidate.agent.SeedLastTurnUsage(candidate.lastTurn)
403+ }
404+ _ = sess.Close()
405+ return candidate, nil
406+}
407+
408+// applyInitialSessionResume applies the same provider/model-aware candidate
409+// contract used by interactive session switching to non-interactive startup
410+// modes. The returned session owns the replacement append handle.
411+func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent) (*core.Session, *core.Agent, string, string, error) {
412+ candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,
413+ strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) {
414+ return buildNonInteractiveSessionAgent(ctx, args, base, extMgr, providerName, model)
415+ })
416+ if err != nil {
417+ return nil, ag, "", "", err
418+ }
419+ return candidate.session, candidate.agent, candidate.provider, candidate.model, nil
420+}
421+
201 // fanoutAgentEvent translates a core.AgentEvent into the wire-format422 // fanoutAgentEvent translates a core.AgentEvent into the wire-format
202 // EventFromHost and pushes it through the extension manager. Only423 // EventFromHost and pushes it through the extension manager. Only
203 // the events that have a clear extension-facing meaning are424 // the events that have a clear extension-facing meaning are
@@ -426,8 +647,19 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
426 647
427 ag := r.NewAgent()648 ag := r.NewAgent()
428 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)649 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
429- sess, _ := openOrCreateSession(args, r, ag, version)650+ sess, err := openOrCreateSession(args, r, ag, version)
430- defer sess.Close()651+ if err != nil {
652+ return err
653+ }
654+ if sess != nil {
655+ var providerName, model string
656+ sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
657+ if err != nil {
658+ return err
659+ }
660+ r.Provider, r.Model = providerName, model
661+ defer sess.Close()
662+ }
431 663
432 prompt := args.Prompt664 prompt := args.Prompt
433 if prompt == "" {665 if prompt == "" {
@@ -469,8 +701,19 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
469 701
470 ag := r.NewAgent()702 ag := r.NewAgent()
471 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)703 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
472- sess, _ := openOrCreateSession(args, r, ag, version)704+ sess, err := openOrCreateSession(args, r, ag, version)
473- defer sess.Close()705+ if err != nil {
706+ return err
707+ }
708+ if sess != nil {
709+ var providerName, model string
710+ sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
711+ if err != nil {
712+ return err
713+ }
714+ r.Provider, r.Model = providerName, model
715+ defer sess.Close()
716+ }
474 717
475 prompt := args.Prompt718 prompt := args.Prompt
476 if prompt == "" {719 if prompt == "" {
@@ -551,8 +794,19 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
551 794
552 ag := r.NewAgent()795 ag := r.NewAgent()
553 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)796 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
554- sess, _ := openOrCreateSession(args, r, ag, version)797+ sess, err := openOrCreateSession(args, r, ag, version)
555- defer sess.Close()798+ if err != nil {
799+ return err
800+ }
801+ if sess != nil {
802+ var providerName, model string
803+ sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
804+ if err != nil {
805+ return err
806+ }
807+ r.Provider, r.Model = providerName, model
808+ defer sess.Close()
809+ }
556 810
557 prompt := args.Prompt811 prompt := args.Prompt
558 if prompt == "" {812 if prompt == "" {
@@ -695,6 +949,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
695 // rebuilt tool instances must share the same one so the lock sticks.949 // rebuilt tool instances must share the same one so the lock sticks.
696 sharedSandbox := r.Sandbox950 sharedSandbox := r.Sandbox
697 951
952+ // persistMu guards the active session and selection across the agent
953+ // loop, TUI callbacks, and session swaps. Declare it before tool
954+ // construction because swarm defaults must read the live selection too.
955+ var persistMu sync.Mutex
956+ // Serialize session transitions with persistence callbacks so a candidate
957+ // is read only after the old agent is flushed and cannot be overwritten by
958+ // an in-flight append before commit.
959+ var sessionTransitionMu sync.RWMutex
960+ activeProvider := r.Provider
961+ activeModel := r.Model
962+
698 // Build the extension manager BEFORE the agent so we can fold963 // Build the extension manager BEFORE the agent so we can fold
699 // extension-defined tools into the registry. Attach the interactive964 // extension-defined tools into the registry. Attach the interactive
700 // host after constructing it below so startup alerts can be buffered.965 // host after constructing it below so startup alerts can be buffered.
@@ -792,10 +1057,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
792 return reg1057 return reg
793 }1058 }
794 reg["swarm_spawn"] = &tools.SwarmSpawnTool{1059 reg["swarm_spawn"] = &tools.SwarmSpawnTool{
795- Swarm: swarmMgr,1060+ Swarm: swarmMgr,
796- Enabled: AutoSwarmEnabled,1061+ Enabled: AutoSwarmEnabled,
797- DefaultModel: func() string { return r.Model },1062+ DefaultModel: func() string {
798- DefaultProvider: func() string { return r.Provider },1063+ persistMu.Lock()
1064+ defer persistMu.Unlock()
1065+ return activeModel
1066+ },
1067+ DefaultProvider: func() string {
1068+ persistMu.Lock()
1069+ defer persistMu.Unlock()
1070+ return activeProvider
1071+ },
799 DefaultReasoning: func() string { return r.Reasoning },1072 DefaultReasoning: func() string { return r.Reasoning },
800 ResolveSubagent: resolveSubagent,1073 ResolveSubagent: resolveSubagent,
801 OnSpawned: onSpawnedSwarm,1074 OnSpawned: onSpawnedSwarm,
@@ -958,16 +1231,32 @@ func runInteractive(ctx context.Context, args Args, version string) error {
958 1231
959 var sess *core.Session1232 var sess *core.Session
960 var sessBaselineMsgs int // messages already on disk when current session opened1233 var sessBaselineMsgs int // messages already on disk when current session opened
961- // persistMu guards sess + sessBaselineMsgs against concurrent access1234+ // persistMu also guards sess + sessBaselineMsgs, so a session swap
962- // from the agent loop's per-message persistence hook (runs on the1235+ // commits the transcript writer and live selection under the same lock.
963- // agent goroutine) and the TUI's session swap / flush callbacks
964- // (run on the TUI goroutine). Without this, a /sessions swap that
965- // races with a finishing turn could double-write or lose messages.
966- var persistMu sync.Mutex
967 if !args.NoSess && ag != nil {1236 if !args.NoSess && ag != nil {
968- sess, _ = openOrCreateSession(args, r, ag, version)1237+ var sessErr error
969- if ag != nil {1238+ sess, sessErr = openOrCreateSession(args, r, ag, version)
970- sessBaselineMsgs = len(ag.Messages())1239+ if sessErr != nil {
1240+ return sessErr
1241+ }
1242+ sessBaselineMsgs = len(ag.Messages())
1243+ if sess != nil {
1244+ // Startup resume uses the same provider/model-aware preparation as
1245+ // the interactive session picker. This matters for --continue,
1246+ // --resume, and explicit --session paths whose latest meta row was
1247+ // written by another provider/model.
1248+ candidate, resumeErr := applySessionResume(sess, ag, r.Provider, r.Model,
1249+ strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", buildAgentFor)
1250+ if resumeErr != nil {
1251+ return resumeErr
1252+ }
1253+ sess = candidate.session
1254+ ag = candidate.agent
1255+ sessBaselineMsgs = candidate.fullMessageCount
1256+ activeProvider = candidate.provider
1257+ activeModel = candidate.model
1258+ r.Provider = candidate.provider
1259+ r.Model = candidate.model
971 }1260 }
972 }1261 }
973 defer func() {1262 defer func() {
@@ -985,6 +1274,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
985 // baseline counter advances in lock-step so the exit-time flush1274 // baseline counter advances in lock-step so the exit-time flush
986 // doesn't double-write rows already on disk.1275 // doesn't double-write rows already on disk.
987 persistMessage := func(m provider.Message) {1276 persistMessage := func(m provider.Message) {
1277+ sessionTransitionMu.RLock()
1278+ defer sessionTransitionMu.RUnlock()
988 persistMu.Lock()1279 persistMu.Lock()
989 defer persistMu.Unlock()1280 defer persistMu.Unlock()
990 if sess == nil {1281 if sess == nil {
@@ -995,6 +1286,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
995 }1286 }
996 }1287 }
997 persistUsage := func(cum provider.Usage) {1288 persistUsage := func(cum provider.Usage) {
1289+ sessionTransitionMu.RLock()
1290+ defer sessionTransitionMu.RUnlock()
998 persistMu.Lock()1291 persistMu.Lock()
999 defer persistMu.Unlock()1292 defer persistMu.Unlock()
1000 if sess == nil {1293 if sess == nil {
@@ -1003,6 +1296,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1003 _ = sess.AppendUsage(cum, cum)1296 _ = sess.AppendUsage(cum, cum)
1004 }1297 }
1005 persistCompaction := func(messages []provider.Message) {1298 persistCompaction := func(messages []provider.Message) {
1299+ sessionTransitionMu.RLock()
1300+ defer sessionTransitionMu.RUnlock()
1006 persistMu.Lock()1301 persistMu.Lock()
1007 defer persistMu.Unlock()1302 defer persistMu.Unlock()
1008 if sess == nil {1303 if sess == nil {
@@ -1026,7 +1321,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1026 // Re-wrap the build closures so any agent constructed by the TUI1321 // Re-wrap the build closures so any agent constructed by the TUI
1027 // (login, /model swap to a different provider) also gets the1322 // (login, /model swap to a different provider) also gets the
1028 // persistence hooks. Without this, switching provider would1323 // persistence hooks. Without this, switching provider would
1029- // silently revert to the old in-memory-only behaviour.1324+ // silently revert to the old in-memory-only behaviour. Selection state
1325+ // is published by the successful transition itself, not by preparation.
1030 baseBuildAgent := buildAgent1326 baseBuildAgent := buildAgent
1031 buildAgent = func() (*core.Agent, string, string, error) {1327 buildAgent = func() (*core.Agent, string, string, error) {
1032 a, p, m, err := baseBuildAgent()1328 a, p, m, err := baseBuildAgent()
@@ -1046,47 +1342,100 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1046 // loadSession replaces the current session with the one at path and1342 // loadSession replaces the current session with the one at path and
1047 // hands its messages to the agent. Used by the /sessions picker.1343 // hands its messages to the agent. Used by the /sessions picker.
1048 loadSession := func(path string) error {1344 loadSession := func(path string) error {
1345+ // Hold the transition lock from the pre-flush through the commit.
1346+ // Persistence callbacks take the read side, so an active session
1347+ // cannot be snapshotted before its lazy writes land or overwritten by
1348+ // a callback while the candidate is being installed.
1349+ sessionTransitionMu.Lock()
1350+ defer sessionTransitionMu.Unlock()
1351+
1049 currentAg := liveInteractiveAgent(iv, ag)1352 currentAg := liveInteractiveAgent(iv, ag)
1050 if currentAg == nil {1353 if currentAg == nil {
1051 return fmt.Errorf("no agent running; log in first")1354 return fmt.Errorf("no agent running; log in first")
1052 }1355 }
1053- newSess, msgs, err := core.OpenSession(path)1356+
1357+ persistMu.Lock()
1358+ currentProvider := activeProvider
1359+ currentModel := activeModel
1360+ oldSess := sess
1361+ // Flush before reading the candidate. Use the locked form rather
1362+ // than the public callback, which would acquire the read lock held
1363+ // by this transition.
1364+ if oldSess != nil {
1365+ writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)
1366+ }
1367+ currentMessageCount := len(currentAg.Messages())
1368+ currentCost := currentAg.Cost()
1369+ persistMu.Unlock()
1370+
1371+ // A candidate build must not publish its provider/model as the live
1372+ // selection before the candidate commits.
1373+ candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
1054 if err != nil {1374 if err != nil {
1375+ // prepareSessionResume closes its append handle on failure;
1376+ // no live agent or session has been changed.
1055 return err1377 return err
1056 }1378 }
1057- fullMsgCount := len(msgs)1379+ committed := false
1058- msgs = trimMessagesForResume(msgs, 100)1380+ defer func() {
1381+ if !committed {
1382+ _ = candidate.session.Close()
1383+ }
1384+ }()
1385+
1386+ // Wire the private candidate before it can become visible. This is
1387+ // also required for legacy same-provider resumes, where the current
1388+ // agent is reused and only its transcript/usage are refreshed.
1389+ wireAgentPersist(candidate.agent)
1390+
1059 persistMu.Lock()1391 persistMu.Lock()
1060- // Flush any unsaved messages to the old session before swapping.1392+ // A provider rebuild ran outside persistMu. Refuse to install it if
1061- // Per-message persistence keeps sessBaselineMsgs current, so1393+ // the live agent, session, selection, transcript, or cost changed in
1062- // this is a defensive no-op in the common case; it still1394+ // the meantime; otherwise a slow resume could discard a newer turn or
1063- // matters for the rare race where a turn just finished and1395+ // model swap.
1064- // hadn't fired its hook yet.1396+ live := liveInteractiveAgent(iv, ag)
1065- if sess != nil {1397+ changed := sess != oldSess || activeProvider != currentProvider || activeModel != currentModel || live != currentAg
1066- writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)1398+ if !changed && live != nil {
1067- _ = sess.Close()1399+ changed = len(live.Messages()) != currentMessageCount || live.Cost() != currentCost
1400+ }
1401+ if changed {
1402+ persistMu.Unlock()
1403+ return fmt.Errorf("session changed while loading; please try again")
1068 }1404 }
1069- sess = newSess1405+
1070- currentAg.SetMessages(msgs)1406+ if oldSess != nil {
1071- if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {1407+ _ = oldSess.Close()
1072- currentAg.SeedCost(cum)
1073- currentAg.SeedLastTurnUsage(last)
1074- }
1075- // The live agent only receives a compact resume window, but
1076- // the session file remains intact. Keep the persistence
1077- // baseline at the original on-disk message count so future
1078- // turns append after the full session instead of duplicating
1079- // the hydrated tail.
1080- sessBaselineMsgs = fullMsgCount
1081- persistMu.Unlock()
1082- // Re-scope the swarm dashboard to the new session so /swarm
1083- // only shows agents this session spawned. swarmMgr may be nil
1084- // here if we haven't reached the construction site yet (it
1085- // shouldn't be, since the interactive loop is what triggers
1086- // loadSession, but the nil check is cheap insurance).
1087- if swarmMgr != nil && newSess != nil {
1088- swarmMgr.SetActiveSession(newSess.ID)
1089 }1408 }
1409+ sess = candidate.session
1410+ // The live agent only receives a compact resume window, but the
1411+ // session file remains intact. Keep the persistence baseline at
1412+ // the original on-disk message count so future turns append after
1413+ // the full session instead of duplicating the hydrated tail.
1414+ sessBaselineMsgs = candidate.fullMessageCount
1415+
1416+ if candidate.rebuilt {
1417+ // Commit the TUI agent while persistMu still protects the session
1418+ // writer, so no new message can land on the old file between the
1419+ // two state changes.
1420+ if iv != nil {
1421+ iv.ApplySessionAgent(candidate.agent, candidate.provider, candidate.model)
1422+ }
1423+ ag = candidate.agent
1424+ } else {
1425+ currentAg.SetMessages(candidate.messages)
1426+ currentAg.SeedCost(candidate.cumulative)
1427+ currentAg.SeedLastTurnUsage(candidate.lastTurn)
1428+ ag = currentAg
1429+ }
1430+ activeProvider = candidate.provider
1431+ activeModel = candidate.model
1432+ if swarmMgr != nil && candidate.session != nil {
1433+ // Keep the dashboard scope in the same commit as the session,
1434+ // agent, usage, and persistence baseline.
1435+ swarmMgr.SetActiveSession(candidate.session.ID)
1436+ }
1437+ committed = true
1438+ persistMu.Unlock()
1090 return nil1439 return nil
1091 }1440 }
1092 1441
@@ -1106,6 +1455,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1106 // The /jail state is preserved verbatim: if the sandbox was locked1455 // The /jail state is preserved verbatim: if the sandbox was locked
1107 // to the old cwd, it stays locked, just re-pointed at the new one.1456 // to the old cwd, it stays locked, just re-pointed at the new one.
1108 changeCWD := func(path string) error {1457 changeCWD := func(path string) error {
1458+ sessionTransitionMu.Lock()
1459+ defer sessionTransitionMu.Unlock()
1109 if path == "" {1460 if path == "" {
1110 return fmt.Errorf("empty path")1461 return fmt.Errorf("empty path")
1111 }1462 }
@@ -1136,7 +1487,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1136 return fmt.Errorf("not a directory: %s", absPath)1487 return fmt.Errorf("not a directory: %s", absPath)
1137 }1488 }
1138 1489
1139- currentAg := ag1490+ currentAg := liveInteractiveAgent(iv, ag)
1140 if currentAg == nil {1491 if currentAg == nil {
1141 return fmt.Errorf("no agent running; log in first")1492 return fmt.Errorf("no agent running; log in first")
1142 }1493 }
@@ -1147,6 +1498,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1147 oldCWD := args.CWD1498 oldCWD := args.CWD
1148 oldResolvedCWD := r.CWD1499 oldResolvedCWD := r.CWD
1149 oldPermissionSet := args.PermissionSet1500 oldPermissionSet := args.PermissionSet
1501+ persistMu.Lock()
1502+ oldActiveProvider := activeProvider
1503+ oldActiveModel := activeModel
1504+ persistMu.Unlock()
1150 oldSwarmRoot := ""1505 oldSwarmRoot := ""
1151 if swarmMgr != nil {1506 if swarmMgr != nil {
1152 oldSwarmRoot = swarmMgr.RepoRoot()1507 oldSwarmRoot = swarmMgr.RepoRoot()
@@ -1161,6 +1516,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1161 args.CWD = oldCWD1516 args.CWD = oldCWD
1162 r.CWD = oldResolvedCWD1517 r.CWD = oldResolvedCWD
1163 args.PermissionSet = oldPermissionSet1518 args.PermissionSet = oldPermissionSet
1519+ persistMu.Lock()
1520+ activeProvider = oldActiveProvider
1521+ activeModel = oldActiveModel
1522+ persistMu.Unlock()
1164 if swarmMgr != nil {1523 if swarmMgr != nil {
1165 swarmMgr.SetRepoRoot(oldSwarmRoot)1524 swarmMgr.SetRepoRoot(oldSwarmRoot)
1166 }1525 }
@@ -1225,6 +1584,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1225 }1584 }
1226 sess = newSess1585 sess = newSess
1227 sessBaselineMsgs = 01586 sessBaselineMsgs = 0
1587+ activeProvider = newProvider
1588+ activeModel = newModel
1228 persistMu.Unlock()1589 persistMu.Unlock()
1229 ag = newAg1590 ag = newAg
1230 1591
@@ -1416,12 +1777,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1416 LoadSession: loadSession,1777 LoadSession: loadSession,
1417 ChangeCWD: changeCWD,1778 ChangeCWD: changeCWD,
1418 CurrentSessionPath: func() string {1779 CurrentSessionPath: func() string {
1780+ persistMu.Lock()
1781+ defer persistMu.Unlock()
1419 if sess == nil {1782 if sess == nil {
1420 return ""1783 return ""
1421 }1784 }
1422 return sess.Path1785 return sess.Path
1423 },1786 },
1424 FlushSession: func() {1787 FlushSession: func() {
1788+ sessionTransitionMu.RLock()
1789+ defer sessionTransitionMu.RUnlock()
1425 // Append any not-yet-persisted agent messages to the1790 // Append any not-yet-persisted agent messages to the
1426 // current session file, then advance the baseline so1791 // current session file, then advance the baseline so
1427 // the final WriteNewTranscript at exit doesn't write1792 // the final WriteNewTranscript at exit doesn't write
@@ -1442,6 +1807,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1442 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)1807 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1443 sessBaselineMsgs = len(currentAg.Messages())1808 sessBaselineMsgs = len(currentAg.Messages())
1444 },1809 },
1810+ SessionTransition: func(fn func()) {
1811+ sessionTransitionMu.Lock()
1812+ defer sessionTransitionMu.Unlock()
1813+ fn()
1814+ },
1445 Extensions: extMgr,1815 Extensions: extMgr,
1446 Swarm: swarmMgr,1816 Swarm: swarmMgr,
1447 ResolveSubagent: resolveSubagent,1817 ResolveSubagent: resolveSubagent,
@@ -1476,14 +1846,39 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1476 NoYolo: args.NoYolo,1846 NoYolo: args.NoYolo,
1477 ConfirmGate: confirmGate,1847 ConfirmGate: confirmGate,
1478 PersistModel: func(providerName, model string) {1848 PersistModel: func(providerName, model string) {
1849+ providerName = strings.TrimSpace(providerName)
1850+ model = strings.TrimSpace(model)
1851+ // Persist the active session metadata before publishing the
1852+ // selection used by future resumes. The live agent has already
1853+ // switched pairs, so keep the in-memory selection aligned even
1854+ // when the append fails and report that persistence error below.
1855+ persistMu.Lock()
1856+ var sessionErr error
1857+ if sess != nil {
1858+ sessionErr = sess.UpdateModel(providerName, model)
1859+ }
1860+ activeProvider = providerName
1861+ activeModel = model
1862+ persistMu.Unlock()
1863+ if sessionErr != nil {
1864+ if iv != nil {
1865+ iv.Notify("session", "error", "model change was not persisted: "+sessionErr.Error())
1866+ }
1867+ return
1868+ }
1869+
1479 // Update config.json so next launch uses the same pick.1870 // Update config.json so next launch uses the same pick.
1480- cfg, _ := LoadConfig()1871+ cfg, cfgErr := LoadConfig()
1872+ if cfgErr != nil {
1873+ if iv != nil {
1874+ iv.Notify("session", "error", "model changed for this run, but config could not be read: "+cfgErr.Error())
1875+ }
1876+ return
1877+ }
1481 cfg.Provider = providerName1878 cfg.Provider = providerName
1482 cfg.Model = model1879 cfg.Model = model
1483- _ = SaveConfig(cfg)1880+ if cfgErr = SaveConfig(cfg); cfgErr != nil && iv != nil {
1484- // Update the active session's meta so resume picks this up.1881+ iv.Notify("session", "error", "model changed for this run, but config could not be saved: "+cfgErr.Error())
1485- if sess != nil {
1486- _ = sess.UpdateModel(providerName, model)
1487 }1882 }
1488 },1883 },
1489 })1884 })
M packages/agent/cli_session_test.go +209 -0
@@ -1,6 +1,9 @@
1 package agent1 package agent
2 2
3 import (3 import (
4+ "context"
5+ "errors"
6+ "os"
4 "testing"7 "testing"
5 8
6 "github.com/patriceckhart/zot/packages/agent/modes"9 "github.com/patriceckhart/zot/packages/agent/modes"
@@ -8,6 +11,28 @@ import (
8 "github.com/patriceckhart/zot/packages/provider"11 "github.com/patriceckhart/zot/packages/provider"
9 )12 )
10 13
14+func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
15+ msgs := make([]provider.Message, 0, 101)
16+ msgs = append(msgs, provider.Message{
17+ Role: provider.RoleTool,
18+ AddedToolNames: []string{"lookup_weather"},
19+ Content: []provider.Content{provider.ToolResultBlock{CallID: "old-call"}},
20+ })
21+ for idx := 1; idx < 101; idx++ {
22+ msgs = append(msgs, provider.Message{
23+ Role: provider.RoleUser,
24+ Content: []provider.Content{provider.TextBlock{Text: "message"}},
25+ })
26+ }
27+ trimmed := trimMessagesForResume(msgs, 100)
28+ if len(trimmed) != 100 {
29+ t.Fatalf("trimmed message count = %d, want 100", len(trimmed))
30+ }
31+ if len(trimmed[0].AddedToolNames) != 1 || trimmed[0].AddedToolNames[0] != "lookup_weather" {
32+ t.Fatalf("trimmed activation markers = %v, want lookup_weather", trimmed[0].AddedToolNames)
33+ }
34+}
35+
11 func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) {36 func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) {
12 startup := core.NewAgent(nil, "startup-model", "", nil)37 startup := core.NewAgent(nil, "startup-model", "", nil)
13 startup.SetMessages([]provider.Message{{38 startup.SetMessages([]provider.Message{{
@@ -46,3 +71,187 @@ func firstMessageText(messages []provider.Message) string {
46 text, _ := messages[0].Content[0].(provider.TextBlock)71 text, _ := messages[0].Content[0].(provider.TextBlock)
47 return text.Text72 return text.Text
48 }73 }
74+
75+func syntheticSession(t *testing.T, providerName, model string, usage provider.Usage) string {
76+ t.Helper()
77+ root := t.TempDir()
78+ cwd := t.TempDir()
79+ sess, err := core.NewSession(root, cwd, providerName, model, "test")
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ if err := sess.AppendMessage(provider.Message{
84+ Role: provider.RoleUser,
85+ Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
86+ }); err != nil {
87+ t.Fatal(err)
88+ }
89+ if usage != (provider.Usage{}) {
90+ if err := sess.AppendUsage(usage, usage); err != nil {
91+ t.Fatal(err)
92+ }
93+ }
94+ if err := sess.Close(); err != nil {
95+ t.Fatal(err)
96+ }
97+ return sess.Path
98+}
99+
100+func TestApplyInitialSessionResumeKeepsFreshEmptySessionOwned(t *testing.T) {
101+ root := t.TempDir()
102+ sess, err := core.NewSession(root, "/workspace", "provider", "model", "test")
103+ if err != nil {
104+ t.Fatal(err)
105+ }
106+ ag := core.NewAgent(nil, "model", "", nil)
107+ gotSess, gotAgent, providerName, model, err := applyInitialSessionResume(context.Background(), Args{}, Resolved{Provider: "provider", Model: "model"}, nil, sess, ag)
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ if gotSess != sess || gotAgent != ag || providerName != "provider" || model != "model" {
112+ t.Fatalf("empty startup resume = session %p/%p agent %p/%p provider/model %q/%q", gotSess, sess, gotAgent, ag, providerName, model)
113+ }
114+ if _, err := os.Stat(sess.Path); err != nil {
115+ t.Fatalf("fresh session disappeared before close: %v", err)
116+ }
117+ if err := sess.Close(); err != nil {
118+ t.Fatal(err)
119+ }
120+ if _, err := os.Stat(sess.Path); !errors.Is(err, os.ErrNotExist) {
121+ t.Fatalf("fresh empty session remained after close: %v", err)
122+ }
123+}
124+
125+func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
126+ old := core.NewAgent(nil, "old-model", "", nil)
127+ old.SetMessages([]provider.Message{{
128+ Role: provider.RoleUser,
129+ Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
130+ }})
131+ path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{
132+ InputTokens: 12,
133+ OutputTokens: 7,
134+ })
135+
136+ var gotProvider, gotModel string
137+ candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(providerName, model string) (*core.Agent, string, string, error) {
138+ gotProvider, gotModel = providerName, model
139+ return core.NewAgent(nil, model, "", nil), providerName, model, nil
140+ })
141+ if err != nil {
142+ t.Fatal(err)
143+ }
144+ defer candidate.session.Close()
145+
146+ if gotProvider != "stored-provider" || gotModel != "stored-model" {
147+ t.Fatalf("builder selection = %q/%q, want stored-provider/stored-model", gotProvider, gotModel)
148+ }
149+ if !candidate.rebuilt || candidate.agent == old {
150+ t.Fatalf("candidate rebuilt = %v, agent=%p, old=%p", candidate.rebuilt, candidate.agent, old)
151+ }
152+ if got := firstMessageText(candidate.agent.Messages()); got != "resumed transcript" {
153+ t.Fatalf("candidate transcript = %q, want resumed transcript", got)
154+ }
155+ if got := candidate.agent.Cost(); got.InputTokens != 12 || got.OutputTokens != 7 {
156+ t.Fatalf("candidate usage = %+v, want input=12 output=7", got)
157+ }
158+ if got := old.Messages(); len(got) != 1 || firstMessageText(got) != "current transcript" {
159+ t.Fatalf("current transcript changed during preparation: %+v", got)
160+ }
161+}
162+
163+func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) {
164+ old := core.NewAgent(nil, "old-model", "", nil)
165+ old.SetMessages([]provider.Message{{
166+ Role: provider.RoleUser,
167+ Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
168+ }})
169+ path := syntheticSession(t, "", "", provider.Usage{})
170+
171+ candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
172+ t.Fatal("legacy session unexpectedly requested a rebuild")
173+ return nil, "", "", nil
174+ })
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ defer candidate.session.Close()
179+
180+ if candidate.rebuilt || candidate.agent != old {
181+ t.Fatalf("legacy candidate = rebuilt %v, agent=%p; want current agent without rebuild", candidate.rebuilt, candidate.agent)
182+ }
183+ if got := firstMessageText(old.Messages()); got != "current transcript" {
184+ t.Fatalf("current transcript changed before commit: %q", got)
185+ }
186+ if got := firstMessageText(candidate.messages); got != "resumed transcript" {
187+ t.Fatalf("legacy candidate transcript = %q, want resumed transcript", got)
188+ }
189+}
190+
191+func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) {
192+ old := core.NewAgent(nil, "old-model", "", nil)
193+ old.SetMessages([]provider.Message{{
194+ Role: provider.RoleUser,
195+ Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
196+ }})
197+ path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
198+ wantErr := errors.New("synthetic builder failure")
199+
200+ if _, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
201+ return nil, "", "", wantErr
202+ }); !errors.Is(err, wantErr) {
203+ t.Fatalf("prepare error = %v, want %v", err, wantErr)
204+ }
205+ if got := firstMessageText(old.Messages()); got != "current transcript" {
206+ t.Fatalf("current transcript changed after failed preparation: %q", got)
207+ }
208+
209+ // The failed candidate must have released its append handle, leaving
210+ // the selected file readable and reopenable for a later retry.
211+ reopened, _, err := core.OpenSession(path)
212+ if err != nil {
213+ t.Fatalf("reopen candidate after failed preparation: %v", err)
214+ }
215+ if err := reopened.Close(); err != nil {
216+ t.Fatal(err)
217+ }
218+ if _, err := os.Stat(path); err != nil {
219+ t.Fatalf("candidate session disappeared after failed preparation: %v", err)
220+ }
221+}
222+
223+func TestPrepareSessionResumeHonorsExplicitProviderModelFields(t *testing.T) {
224+ for _, tc := range []struct {
225+ name string
226+ explicitProvider bool
227+ explicitModel bool
228+ wantProvider string
229+ wantModel string
230+ wantBuild bool
231+ }{
232+ {name: "both", explicitProvider: true, explicitModel: true, wantProvider: "current-provider", wantModel: "current-model"},
233+ {name: "provider-only", explicitProvider: true, wantProvider: "current-provider", wantModel: "stored-model", wantBuild: true},
234+ {name: "model-only", explicitModel: true, wantProvider: "stored-provider", wantModel: "current-model", wantBuild: true},
235+ } {
236+ t.Run(tc.name, func(t *testing.T) {
237+ old := core.NewAgent(nil, "old-model", "", nil)
238+ path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
239+ var gotProvider, gotModel string
240+ candidate, err := prepareSessionResumeWithOptions(path, old, "current-provider", "current-model", tc.explicitProvider, tc.explicitModel, func(providerName, model string) (*core.Agent, string, string, error) {
241+ gotProvider, gotModel = providerName, model
242+ return core.NewAgent(nil, model, "", nil), providerName, model, nil
243+ })
244+ if err != nil {
245+ t.Fatal(err)
246+ }
247+ defer candidate.session.Close()
248+ if tc.wantBuild {
249+ if gotProvider != tc.wantProvider || gotModel != tc.wantModel {
250+ t.Fatalf("builder selection = %q/%q, want %q/%q", gotProvider, gotModel, tc.wantProvider, tc.wantModel)
251+ }
252+ } else if candidate.provider != tc.wantProvider || candidate.model != tc.wantModel || candidate.rebuilt {
253+ t.Fatalf("candidate selection = %q/%q rebuilt=%v, want %q/%q without rebuild", candidate.provider, candidate.model, candidate.rebuilt, tc.wantProvider, tc.wantModel)
254+ }
255+ })
256+ }
257+}
A packages/agent/modes/double_escape.go +377 -0
@@ -0,0 +1,377 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+const sessionTreeEscapeWindow = 500 * time.Millisecond
14+
15+type doubleEscapeTracker struct {
16+ last time.Time
17+ armed bool
18+}
19+
20+func (d *doubleEscapeTracker) Arm(now time.Time) {
21+ d.last = now
22+ d.armed = true
23+}
24+
25+// Consume reports whether now is the second tap of a valid gesture. A clock
26+// that moves backwards is treated as a reset rather than as a valid tap.
27+func (d *doubleEscapeTracker) Consume(now time.Time) bool {
28+ last := d.last
29+ armed := d.armed
30+ d.last = time.Time{}
31+ d.armed = false
32+ if !armed {
33+ return false
34+ }
35+ elapsed := now.Sub(last)
36+ return elapsed >= 0 && elapsed <= sessionTreeEscapeWindow
37+}
38+
39+func (d *doubleEscapeTracker) Reset() {
40+ d.last = time.Time{}
41+ d.armed = false
42+}
43+
44+func isUnmodifiedEscape(k tui.Key) bool {
45+ return k.Kind == tui.KeyEsc && !k.Ctrl && !k.Alt && !k.Shift && !k.Super
46+}
47+
48+func (i *Interactive) sessionTreeEscapeNow() time.Time {
49+ if i.clock != nil {
50+ return i.clock()
51+ }
52+ return time.Now()
53+}
54+
55+// currentSessionPath is the nil-safe session callback shared by slash
56+// commands and tree operations. Running without persistence (for example
57+// --no-session) is a supported mode, so callers must treat a missing
58+// callback exactly like an empty path rather than invoking it blindly.
59+func (i *Interactive) currentSessionPath() string {
60+ if i == nil || i.cfg.CurrentSessionPath == nil {
61+ return ""
62+ }
63+ return i.cfg.CurrentSessionPath()
64+}
65+
66+// sessionTreeEscapeBlocked reports interactions that must retain precedence
67+// over the gesture. It is intentionally broader than the overlay switch in
68+// handleKey: notes and suggestions also own Escape even though they are not
69+// all rendered as dialogs.
70+func (i *Interactive) sessionTreeEscapeBlocked() bool {
71+ if i == nil {
72+ return true
73+ }
74+ if i.ed == nil {
75+ return true
76+ }
77+ if i.dialog != nil && i.dialog.Active() ||
78+ i.modelDialog != nil && i.modelDialog.Active() ||
79+ i.llamaDialog != nil && i.llamaDialog.Active() ||
80+ i.rescueDialog != nil && i.rescueDialog.Active() ||
81+ i.sessionDialog != nil && i.sessionDialog.Active() ||
82+ i.swarmDialog != nil && i.swarmDialog.Active() ||
83+ i.jumpDialog != nil && i.jumpDialog.Active() ||
84+ i.btwDialog != nil && i.btwDialog.Active() ||
85+ i.skillsDialog != nil && i.skillsDialog.Active() ||
86+ i.changelogDialog != nil && i.changelogDialog.Active() ||
87+ i.logoutDialog != nil && i.logoutDialog.Active() ||
88+ i.telegramDialog != nil && i.telegramDialog.Active() ||
89+ i.settingsDialog != nil && i.settingsDialog.Active() ||
90+ i.sessionOpsDialog != nil && i.sessionOpsDialog.Active() ||
91+ i.sessionTreeDialog != nil && i.sessionTreeDialog.Active() ||
92+ i.extPanel != nil && i.extPanel.Active() ||
93+ i.confirmDialog != nil && i.confirmDialog.Active() {
94+ return true
95+ }
96+ if i.suggest != nil && i.suggest.Active(i.ed.Value()) ||
97+ i.fileSuggest != nil && i.fileSuggest.Active(i.ed.Value()) {
98+ return true
99+ }
100+ i.mu.Lock()
101+ blocked := len(i.helpBlock) > 0 || len(i.extNotes) > 0
102+ i.mu.Unlock()
103+ return blocked
104+}
105+
106+// canArmSessionTreeEscape checks only the cheap, local eligibility rules for
107+// the first tap. The complete family read belongs to canOpenSessionTree on
108+// the second tap; otherwise a malformed descendant would prevent arming and
109+// the user would never receive the gate's status error.
110+func (i *Interactive) canArmSessionTreeEscape() bool {
111+ if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" ||
112+ i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
113+ return false
114+ }
115+ if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 || i.sessionTreeEscapeBlocked() {
116+ return false
117+ }
118+ i.mu.Lock()
119+ busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
120+ i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
121+ queued := len(i.queued) != 0
122+ ag := i.agent
123+ pendingFork := i.pendingFork
124+ i.mu.Unlock()
125+ if busy || queued || pendingFork || ag == nil || ag.QueuedMessageCount() != 0 {
126+ return false
127+ }
128+ return i.currentSessionPath() != ""
129+}
130+
131+// canOpenSessionTree is the shared, fail-closed precondition for both the
132+// /session tree command and the bare-Escape gesture. It does not flush or
133+// mutate session state. In particular, callers must not flush until this
134+// check has returned true.
135+func (i *Interactive) canOpenSessionTree() bool {
136+ if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" ||
137+ i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
138+ return false
139+ }
140+ if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 || i.sessionTreeEscapeBlocked() {
141+ return false
142+ }
143+
144+ i.mu.Lock()
145+ busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
146+ i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
147+ queued := len(i.queued) != 0
148+ ag := i.agent
149+ pendingFork := i.pendingFork
150+ i.mu.Unlock()
151+ if busy || queued || pendingFork || ag == nil {
152+ return false
153+ }
154+ if ag.QueuedMessageCount() != 0 {
155+ return false
156+ }
157+
158+ current := i.currentSessionPath()
159+ if current == "" {
160+ return false
161+ }
162+ roots, err := core.BuildSessionTreeFamilyStrict(i.sessionsRoot(), i.cfg.CWD, current)
163+ return err == nil && len(roots) != 0
164+}
165+
166+// canCommitSessionTreeSelection is the open-gate subset used after the tree
167+// already owns the keyboard. It deliberately permits the active tree dialog;
168+// reusing canOpenSessionTree here would reject every Enter selection because
169+// the dialog is itself an overlay.
170+func (i *Interactive) canCommitSessionTreeSelection() bool {
171+ if i == nil || i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil || i.sessionsRoot() == "" || i.cfg.CWD == "" {
172+ return false
173+ }
174+ i.mu.Lock()
175+ busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
176+ i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
177+ queued := len(i.queued) != 0
178+ ag := i.agent
179+ i.mu.Unlock()
180+ if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
181+ return false
182+ }
183+ return i.currentSessionPath() != ""
184+}
185+
186+func (i *Interactive) setSessionTreeError(message string) {
187+ if i.sessionTreeDialog != nil {
188+ i.sessionTreeDialog.Close()
189+ }
190+ i.mu.Lock()
191+ i.statusErr = message
192+ i.statusOK = ""
193+ i.mu.Unlock()
194+ i.invalidate()
195+}
196+
197+// openSessionTree is the only path that activates the session-tree overlay.
198+// The gate runs before FlushSession, and a failed family read leaves the
199+// dialog closed instead of falling back to a stale in-memory transcript.
200+func (i *Interactive) openSessionTree() bool {
201+ if !i.canOpenSessionTree() {
202+ i.setSessionTreeError("tree: session tree is unavailable")
203+ return false
204+ }
205+ current := i.currentSessionPath()
206+ if current == "" {
207+ i.setSessionTreeError("tree: no session is active")
208+ return false
209+ }
210+ if i.cfg.FlushSession != nil {
211+ i.cfg.FlushSession()
212+ }
213+ if i.sessionTreeDialog == nil || !i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {
214+ i.setSessionTreeError("tree: no readable session family")
215+ return false
216+ }
217+ i.mu.Lock()
218+ i.statusErr = ""
219+ i.statusOK = ""
220+ i.mu.Unlock()
221+ i.invalidate()
222+ return true
223+}
224+
225+type sessionTreeSelectionResult struct {
226+ upTo int
227+ restoreDraft bool
228+ draftText string
229+ images []clipboardImageAttachment
230+}
231+
232+// sessionTreeSelection turns a structured dialog target into a branch
233+// boundary. User rows are boundaries before that message and restore the
234+// full editable draft; assistant/tool rows include the selected completed
235+// exchange. Empty and detached rows are already safe message-count
236+// boundaries. A tool call and its result rows are never split.
237+func sessionTreeSelection(msgs []provider.Message, target sessionTreeTarget) (sessionTreeSelectionResult, error) {
238+ if target.IsBoundary() {
239+ upTo := target.SelectionBoundary
240+ if upTo < 0 || upTo > len(msgs) || !safeSessionToolBoundary(msgs[:upTo]) {
241+ return sessionTreeSelectionResult{}, fmt.Errorf("selection splits a tool exchange")
242+ }
243+ return sessionTreeSelectionResult{upTo: upTo}, nil
244+ }
245+ msgIdx := target.EffectiveIndex
246+ if msgIdx < 0 || msgIdx >= len(msgs) {
247+ return sessionTreeSelectionResult{}, fmt.Errorf("selection is outside the session")
248+ }
249+ selected := msgs[msgIdx]
250+ if selected.Role == provider.RoleUser {
251+ upTo := target.SelectionBoundary
252+ if upTo < 0 || upTo > len(msgs) || !safeSessionToolBoundary(msgs[:upTo]) {
253+ return sessionTreeSelectionResult{}, fmt.Errorf("selection splits a tool exchange")
254+ }
255+ text, images := sessionTreeDraft(selected, target.UserDraft)
256+ return sessionTreeSelectionResult{
257+ upTo: upTo,
258+ restoreDraft: true,
259+ draftText: text,
260+ images: images,
261+ }, nil
262+ }
263+
264+ upTo := target.SelectionBoundary
265+ if upTo < msgIdx+1 || upTo > len(msgs) {
266+ return sessionTreeSelectionResult{}, fmt.Errorf("selection is outside the session")
267+ }
268+ if selected.Role == provider.RoleAssistant && messageHasToolCall(selected) || selected.Role == provider.RoleTool {
269+ // A provider tool exchange may occupy one assistant row followed by
270+ // one or more tool rows. Extend a selected row to the end of that
271+ // contiguous exchange before checking the boundary.
272+ for upTo < len(msgs) && msgs[upTo].Role == provider.RoleTool {
273+ upTo++
274+ }
275+ }
276+ if !safeSessionToolBoundary(msgs[:upTo]) {
277+ return sessionTreeSelectionResult{}, fmt.Errorf("selection splits a tool exchange")
278+ }
279+ return sessionTreeSelectionResult{upTo: upTo}, nil
280+}
281+
282+func messageHasToolCall(msg provider.Message) bool {
283+ for _, content := range msg.Content {
284+ if _, ok := content.(provider.ToolCallBlock); ok {
285+ return true
286+ }
287+ }
288+ return false
289+}
290+
291+// safeSessionToolBoundary checks the provider's structural invariant at a
292+// message cut: every tool call in the prefix has its matching result in the
293+// immediately following tool row, and no result is orphaned.
294+func safeSessionToolBoundary(msgs []provider.Message) bool {
295+ pending := map[string]bool{}
296+ for _, msg := range msgs {
297+ if len(pending) > 0 && msg.Role != provider.RoleTool {
298+ return false
299+ }
300+ switch msg.Role {
301+ case provider.RoleAssistant:
302+ if len(pending) != 0 {
303+ return false
304+ }
305+ for _, content := range msg.Content {
306+ if call, ok := content.(provider.ToolCallBlock); ok {
307+ if call.ID == "" || pending[call.ID] {
308+ return false
309+ }
310+ pending[call.ID] = true
311+ }
312+ }
313+ case provider.RoleTool:
314+ if len(pending) == 0 {
315+ if len(msg.AddedToolNames) > 0 {
316+ // Deferred-tool activation rows intentionally carry
317+ // provider-local state without a preceding tool call.
318+ continue
319+ }
320+ return false
321+ }
322+ for _, content := range msg.Content {
323+ result, ok := content.(provider.ToolResultBlock)
324+ if !ok || !pending[result.CallID] {
325+ return false
326+ }
327+ delete(pending, result.CallID)
328+ }
329+ }
330+ }
331+ return len(pending) == 0
332+}
333+
334+func sessionTreeDraft(msg provider.Message, fallback string) (string, []clipboardImageAttachment) {
335+ // Agent.Prompt stores the text first and appends image blocks after it,
336+ // even when an older transcript contains several text blocks. Rebuild
337+ // that same editor representation: join every text block, then place
338+ // image markers after the complete text so submitting the restored draft
339+ // recreates the original text/images pair without losing any text.
340+ var textBlocks []string
341+ var imageBlocks []provider.ImageBlock
342+ for _, content := range msg.Content {
343+ switch block := content.(type) {
344+ case provider.TextBlock:
345+ textBlocks = append(textBlocks, block.Text)
346+ case provider.ImageBlock:
347+ imageBlocks = append(imageBlocks, block)
348+ }
349+ }
350+ var text strings.Builder
351+ if len(textBlocks) > 0 {
352+ text.WriteString(strings.Join(textBlocks, "\n"))
353+ } else {
354+ text.WriteString(fallback)
355+ }
356+ images := make([]clipboardImageAttachment, 0, len(imageBlocks))
357+ for _, block := range imageBlocks {
358+ if text.Len() > 0 {
359+ last := text.String()
360+ if len(last) > 0 && last[len(last)-1] != ' ' && last[len(last)-1] != '\n' && last[len(last)-1] != '\t' {
361+ text.WriteByte(' ')
362+ }
363+ }
364+ marker := fmt.Sprintf("[clipboard image #%d]", len(images)+1)
365+ text.WriteString(marker)
366+ text.WriteByte(' ')
367+ images = append(images, clipboardImageAttachment{
368+ Marker: marker,
369+ Image: provider.ImageBlock{
370+ MimeType: block.MimeType,
371+ Data: append([]byte(nil), block.Data...),
372+ ThoughtSignature: block.ThoughtSignature,
373+ },
374+ })
375+ }
376+ return text.String(), images
377+}
A packages/agent/modes/double_escape_test.go +404 -0
@@ -0,0 +1,404 @@
1+package modes
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+func TestDoubleEscapeTrackerWindowAndReset(t *testing.T) {
14+ base := time.Unix(100, 0)
15+ var tracker doubleEscapeTracker
16+
17+ tracker.Arm(base)
18+ if !tracker.Consume(base.Add(sessionTreeEscapeWindow)) {
19+ t.Fatal("tap at the 500ms boundary should count")
20+ }
21+ if tracker.Consume(base.Add(sessionTreeEscapeWindow)) {
22+ t.Fatal("a consumed gesture must not trigger again")
23+ }
24+
25+ tracker.Arm(base)
26+ if tracker.Consume(base.Add(sessionTreeEscapeWindow + time.Nanosecond)) {
27+ t.Fatal("tap after the gesture window should not count")
28+ }
29+ tracker.Arm(base)
30+ if tracker.Consume(base.Add(-time.Nanosecond)) {
31+ t.Fatal("a clock moving backwards must reset the gesture")
32+ }
33+
34+ // Zero is a valid value for a deterministic test clock; it must not be
35+ // confused with the tracker's unarmed sentinel.
36+ tracker.Arm(time.Time{})
37+ if !tracker.Consume(time.Time{}.Add(100 * time.Millisecond)) {
38+ t.Fatal("zero-valued test timestamp should still arm the gesture")
39+ }
40+}
41+
42+func TestUnmodifiedEscapeRequiresBareParsedKey(t *testing.T) {
43+ bare := tui.Key{Kind: tui.KeyEsc}
44+ for _, key := range []tui.Key{
45+ {Kind: tui.KeyEsc, Alt: true},
46+ {Kind: tui.KeyEsc, Ctrl: true},
47+ {Kind: tui.KeyEsc, Shift: true},
48+ {Kind: tui.KeyEsc, Super: true},
49+ } {
50+ if isUnmodifiedEscape(key) {
51+ t.Fatalf("modified key %#v was treated as a bare Escape", key)
52+ }
53+ }
54+ if !isUnmodifiedEscape(bare) {
55+ t.Fatal("bare parsed Escape was not recognized")
56+ }
57+}
58+
59+func TestSessionTreeSelectionRestoresCompleteUserDraft(t *testing.T) {
60+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "sig"}
61+ msgs := []provider.Message{
62+ {
63+ Role: provider.RoleUser,
64+ Content: []provider.Content{
65+ provider.TextBlock{Text: "before"},
66+ image,
67+ provider.TextBlock{Text: "after"},
68+ },
69+ },
70+ }
71+ target := sessionTreeTarget{
72+ EffectiveIndex: 0,
73+ SelectionBoundary: 0,
74+ Role: provider.RoleUser,
75+ UserDraft: "fallback must not replace message text",
76+ }
77+
78+ got, err := sessionTreeSelection(msgs, target)
79+ if err != nil {
80+ t.Fatalf("sessionTreeSelection: %v", err)
81+ }
82+ if got.upTo != 0 || !got.restoreDraft {
83+ t.Fatalf("selection = %#v, want a pre-user draft boundary", got)
84+ }
85+ if got.draftText != "before\nafter [clipboard image #1] " {
86+ t.Fatalf("draft text = %q", got.draftText)
87+ }
88+ if len(got.images) != 1 || got.images[0].Marker != "[clipboard image #1]" {
89+ t.Fatalf("restored images = %#v", got.images)
90+ }
91+ if string(got.images[0].Image.Data) != "png" || got.images[0].Image.MimeType != image.MimeType || got.images[0].Image.ThoughtSignature != image.ThoughtSignature {
92+ t.Fatalf("restored image = %#v", got.images[0].Image)
93+ }
94+}
95+
96+func TestSessionTreeSelectionKeepsToolExchangeAtomic(t *testing.T) {
97+ msgs := []provider.Message{
98+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "ask"}}},
99+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},
100+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1"}}},
101+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}},
102+ }
103+
104+ got, err := sessionTreeSelection(msgs, sessionTreeTarget{
105+ EffectiveIndex: 1,
106+ SelectionBoundary: 2,
107+ Role: provider.RoleAssistant,
108+ })
109+ if err != nil {
110+ t.Fatalf("tool-call selection: %v", err)
111+ }
112+ if got.upTo != 3 {
113+ t.Fatalf("tool-call boundary = %d, want 3", got.upTo)
114+ }
115+
116+ got, err = sessionTreeSelection(msgs, sessionTreeTarget{
117+ EffectiveIndex: 2,
118+ SelectionBoundary: 3,
119+ Role: provider.RoleTool,
120+ })
121+ if err != nil {
122+ t.Fatalf("tool-result selection: %v", err)
123+ }
124+ if got.upTo != 3 {
125+ t.Fatalf("tool-result boundary = %d, want 3", got.upTo)
126+ }
127+}
128+
129+func TestSessionTreeSelectionAllowsDeferredToolActivation(t *testing.T) {
130+ msgs := []provider.Message{
131+ {
132+ Role: provider.RoleTool,
133+ AddedToolNames: []string{"lookup_weather"},
134+ Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1"}},
135+ },
136+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "next"}}},
137+ }
138+ got, err := sessionTreeSelection(msgs, sessionTreeTarget{
139+ EffectiveIndex: 0,
140+ SelectionBoundary: 1,
141+ Role: provider.RoleTool,
142+ })
143+ if err != nil {
144+ t.Fatalf("deferred-tool selection: %v", err)
145+ }
146+ if got.upTo != 1 {
147+ t.Fatalf("deferred-tool boundary = %d, want 1", got.upTo)
148+ }
149+}
150+
151+func TestSessionTreeGateFailureDoesNotFlushOrOpenOverlay(t *testing.T) {
152+ i := NewInteractive(InteractiveConfig{})
153+ flushes := 0
154+ i.cfg.FlushSession = func() { flushes++ }
155+
156+ i.doSessionTree()
157+ if flushes != 0 {
158+ t.Fatalf("FlushSession called %d times on a failed gate", flushes)
159+ }
160+ if i.sessionTreeDialog.Active() {
161+ t.Fatal("session tree overlay opened after a failed gate")
162+ }
163+}
164+
165+func TestSessionTreeGateRejectsModelRefreshInFlight(t *testing.T) {
166+ root := t.TempDir()
167+ cwd := "/workspace/model-refresh-gate"
168+ session, err := core.NewSession(root, cwd, "test", "model", "test")
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "message"}}}
173+ if err := session.AppendMessage(message); err != nil {
174+ t.Fatal(err)
175+ }
176+ path := session.Path
177+ if err := session.Close(); err != nil {
178+ t.Fatal(err)
179+ }
180+ i := NewInteractive(InteractiveConfig{
181+ Agent: core.NewAgent(nil, "model", "", nil),
182+ CWD: cwd,
183+ SessionsRoot: root,
184+ CurrentSessionPath: func() string { return path },
185+ LoadSession: func(string) error { return nil },
186+ })
187+ i.agent.SetMessages([]provider.Message{message})
188+ i.mu.Lock()
189+ i.modelRefreshing = true
190+ i.mu.Unlock()
191+ if i.canOpenSessionTree() {
192+ t.Fatal("tree gate opened while model refresh was in flight")
193+ }
194+}
195+
196+func TestSessionTreeSelectionGateAllowsActiveTree(t *testing.T) {
197+ root := t.TempDir()
198+ cwd := "/workspace/selection-gate"
199+ session, err := core.NewSession(root, cwd, "test", "model", "test")
200+ if err != nil {
201+ t.Fatal(err)
202+ }
203+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "message"}}}
204+ if err := session.AppendMessage(message); err != nil {
205+ t.Fatal(err)
206+ }
207+ path := session.Path
208+ if err := session.Close(); err != nil {
209+ t.Fatal(err)
210+ }
211+ ag := core.NewAgent(nil, "model", "", nil)
212+ ag.SetMessages([]provider.Message{message})
213+ i := NewInteractive(InteractiveConfig{
214+ Agent: ag,
215+ CWD: cwd,
216+ SessionsRoot: root,
217+ CurrentSessionPath: func() string {
218+ return path
219+ },
220+ LoadSession: func(string) error { return nil },
221+ })
222+ i.sessionTreeDialog.active = true
223+ if i.canOpenSessionTree() {
224+ t.Fatal("open gate accepted an already-active tree")
225+ }
226+ if !i.canCommitSessionTreeSelection() {
227+ t.Fatal("selection gate rejected the active tree dialog")
228+ }
229+}
230+
231+func TestDoubleEscapeOpensReadableTreeThroughSharedGate(t *testing.T) {
232+ root := t.TempDir()
233+ cwd := "/workspace/double-escape"
234+ session, err := core.NewSession(root, cwd, "test", "model", "test")
235+ if err != nil {
236+ t.Fatal(err)
237+ }
238+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
239+ if err := session.AppendMessage(message); err != nil {
240+ t.Fatal(err)
241+ }
242+ path := session.Path
243+ if err := session.Close(); err != nil {
244+ t.Fatal(err)
245+ }
246+
247+ ag := core.NewAgent(nil, "model", "", nil)
248+ ag.SetMessages([]provider.Message{message})
249+ now := time.Unix(200, 0)
250+ flushes := 0
251+ i := NewInteractive(InteractiveConfig{
252+ Agent: ag,
253+ CWD: cwd,
254+ SessionsRoot: root,
255+ CurrentSessionPath: func() string {
256+ return path
257+ },
258+ LoadSession: func(string) error { return nil },
259+ FlushSession: func() {
260+ flushes++
261+ },
262+ })
263+ i.clock = func() time.Time { return now }
264+
265+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
266+ t.Fatal("first Escape exited")
267+ }
268+ if i.sessionTreeDialog.Active() || flushes != 0 {
269+ t.Fatalf("first Escape opened/flushed: active=%v flushes=%d", i.sessionTreeDialog.Active(), flushes)
270+ }
271+ now = now.Add(499 * time.Millisecond)
272+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
273+ t.Fatal("second Escape exited")
274+ }
275+ if !i.sessionTreeDialog.Active() {
276+ t.Fatal("readable session tree did not open")
277+ }
278+ if flushes != 1 {
279+ t.Fatalf("FlushSession called %d times, want once after the gate", flushes)
280+ }
281+ if got := len(ag.Messages()); got != 1 {
282+ t.Fatalf("double Escape changed transcript with a provider turn: %d messages", got)
283+ }
284+}
285+
286+func TestSecondEscapeFallsThroughToCancellationWhenTurnBecomesBusy(t *testing.T) {
287+ root := t.TempDir()
288+ cwd := "/workspace/double-escape-busy"
289+ session, err := core.NewSession(root, cwd, "test", "model", "test")
290+ if err != nil {
291+ t.Fatal(err)
292+ }
293+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
294+ if err := session.AppendMessage(message); err != nil {
295+ t.Fatal(err)
296+ }
297+ path := session.Path
298+ if err := session.Close(); err != nil {
299+ t.Fatal(err)
300+ }
301+
302+ ag := core.NewAgent(nil, "model", "", nil)
303+ ag.SetMessages([]provider.Message{message})
304+ now := time.Unix(300, 0)
305+ cancelled := 0
306+ i := NewInteractive(InteractiveConfig{
307+ Agent: ag,
308+ CWD: cwd,
309+ SessionsRoot: root,
310+ CurrentSessionPath: func() string { return path },
311+ LoadSession: func(string) error { return nil },
312+ })
313+ i.clock = func() time.Time { return now }
314+
315+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
316+ t.Fatal("first Escape exited")
317+ }
318+ i.mu.Lock()
319+ i.busy = true
320+ i.cancelTurn = func() { cancelled++ }
321+ i.mu.Unlock()
322+ now = now.Add(100 * time.Millisecond)
323+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
324+ t.Fatal("second Escape exited")
325+ }
326+ if cancelled != 1 {
327+ t.Fatalf("second Escape did not fall through to cancellation: got %d cancels", cancelled)
328+ }
329+ if i.sessionTreeDialog.Active() {
330+ t.Fatal("session tree opened after the turn became busy")
331+ }
332+ if i.doubleEscape.armed {
333+ t.Fatal("stale double-Escape gesture remained armed after falling through")
334+ }
335+}
336+
337+func TestSecondEscapeStillConsumesFailClosedTreeGate(t *testing.T) {
338+ root := t.TempDir()
339+ cwd := "/workspace/double-escape-gate"
340+ session, err := core.NewSession(root, cwd, "test", "model", "test")
341+ if err != nil {
342+ t.Fatal(err)
343+ }
344+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
345+ if err := session.AppendMessage(message); err != nil {
346+ t.Fatal(err)
347+ }
348+ path := session.Path
349+ if err := session.Close(); err != nil {
350+ t.Fatal(err)
351+ }
352+
353+ ag := core.NewAgent(nil, "model", "", nil)
354+ ag.SetMessages([]provider.Message{message})
355+ now := time.Unix(400, 0)
356+ pathCalls := 0
357+ flushes := 0
358+ i := NewInteractive(InteractiveConfig{
359+ Agent: ag,
360+ CWD: cwd,
361+ SessionsRoot: root,
362+ CurrentSessionPath: func() string {
363+ pathCalls++
364+ if pathCalls == 1 {
365+ return path
366+ }
367+ // Keep the cheap gesture eligibility true while making the full
368+ // tree-family gate reject the second tap.
369+ return path + ".missing"
370+ },
371+ LoadSession: func(string) error { return nil },
372+ FlushSession: func() {
373+ flushes++
374+ },
375+ })
376+ i.clock = func() time.Time { return now }
377+
378+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
379+ t.Fatal("first Escape exited")
380+ }
381+ now = now.Add(100 * time.Millisecond)
382+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
383+ t.Fatal("second Escape exited")
384+ }
385+ if i.sessionTreeDialog.Active() {
386+ t.Fatal("fail-closed tree gate opened an overlay")
387+ }
388+ if i.statusErr != "tree: session tree is unavailable" {
389+ t.Fatalf("gate status = %q, want the fail-closed tree error", i.statusErr)
390+ }
391+ if flushes != 0 {
392+ t.Fatalf("FlushSession called %d times after the rejected gate", flushes)
393+ }
394+}
395+
396+func TestForkCommandsWithoutSessionPathReportError(t *testing.T) {
397+ for _, command := range []string{"/fork", "/session fork"} {
398+ i := NewInteractive(InteractiveConfig{})
399+ i.runSlash(context.Background(), command)
400+ if i.statusErr != "fork: no session is active (running with --no-session?)" {
401+ t.Fatalf("%s status = %q", command, i.statusErr)
402+ }
403+ }
404+}
M packages/agent/modes/help.go +1 -1
@@ -13,7 +13,7 @@ var helpKeyRows = [][2]string{
13 {"enter", "submit the current input"},13 {"enter", "submit the current input"},
14 {"shift+enter / alt+enter", "insert a newline"},14 {"shift+enter / alt+enter", "insert a newline"},
15 {"tab", "complete the highlighted slash command"},15 {"tab", "complete the highlighted slash command"},
16- {"esc", "cancel the current turn (while busy) - clear the input (while idle)"},16+ {"esc", "cancel the current turn (while busy) - clear idle input; two bare Esc presses within 500ms open /session tree"},
17 {"ctrl+c", "exit (while idle) - cancel the current turn (while busy)"},17 {"ctrl+c", "exit (while idle) - cancel the current turn (while busy)"},
18 {"ctrl+w", "delete previous word"},18 {"ctrl+w", "delete previous word"},
19 {"alt+backspace", "delete previous word (same as ctrl+w)"},19 {"alt+backspace", "delete previous word (same as ctrl+w)"},
M packages/agent/modes/interactive.go +266 -102
@@ -256,6 +256,12 @@ type InteractiveConfig struct {
256 // file that only has the meta row.256 // file that only has the meta row.
257 FlushSession func()257 FlushSession func()
258 258
259+ // SessionTransition serializes a state-changing session/model
260+ // operation with the host's session load. It is optional for embedders;
261+ // the CLI uses it to prevent a slow provider rebuild from being replaced
262+ // by a concurrent resume (or vice versa).
263+ SessionTransition func(func())
264+
259 // PersistModel is called whenever the user switches model or provider.265 // PersistModel is called whenever the user switches model or provider.
260 // It should update config.json and (if there's an active session)266 // It should update config.json and (if there's an active session)
261 // write a new meta row so resume picks up the same model.267 // write a new meta row so resume picks up the same model.
@@ -433,6 +439,7 @@ type Interactive struct {
433 busy bool439 busy bool
434 dirty chan struct{}440 dirty chan struct{}
435 modelRefresh chan modelRefreshResult441 modelRefresh chan modelRefreshResult
442+ modelRefreshing bool
436 startupPreDone chan startupPreResult443 startupPreDone chan startupPreResult
437 cancelTurn context.CancelFunc444 cancelTurn context.CancelFunc
438 scrollOffset int // rows from the bottom; 0 = pinned to latest445 scrollOffset int // rows from the bottom; 0 = pinned to latest
@@ -552,6 +559,11 @@ type Interactive struct {
552 // within ctrlCExitWindow exits. Mirrors the python-repl convention.559 // within ctrlCExitWindow exits. Mirrors the python-repl convention.
553 lastCtrlC time.Time560 lastCtrlC time.Time
554 561
562+ // doubleEscape tracks the optional bare-Escape session-tree gesture.
563+ // clock is injectable so the gesture can be tested without sleeping.
564+ doubleEscape doubleEscapeTracker
565+ clock func() time.Time
566+
555 // welcomeStart is when the interactive run began. The welcome567 // welcomeStart is when the interactive run began. The welcome
556 // banner shows the binary version for welcomeVersionDuration568 // banner shows the binary version for welcomeVersionDuration
557 // after this point and reverts to plain text after.569 // after this point and reverts to plain text after.
@@ -680,6 +692,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
680 fileSuggest: newFileSuggester(),692 fileSuggest: newFileSuggester(),
681 spin: newSpinner(cfg.Theme),693 spin: newSpinner(cfg.Theme),
682 inputHistoryIndex: -1,694 inputHistoryIndex: -1,
695+ clock: time.Now,
683 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),696 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
684 }697 }
685 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)698 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
@@ -924,6 +937,9 @@ func (i *Interactive) Run(ctx context.Context) error {
924 i.handleAuthEvent(ev)937 i.handleAuthEvent(ev)
925 i.invalidate()938 i.invalidate()
926 case result := <-i.modelRefresh:939 case result := <-i.modelRefresh:
940+ i.mu.Lock()
941+ i.modelRefreshing = false
942+ i.mu.Unlock()
927 i.openModelPickerAfterRefresh(result.err)943 i.openModelPickerAfterRefresh(result.err)
928 i.invalidate()944 i.invalidate()
929 case result := <-i.startupPreDone:945 case result := <-i.startupPreDone:
@@ -1332,6 +1348,14 @@ func (i *Interactive) redraw() {
1332 case i.sessionOpsDialog.Active():1348 case i.sessionOpsDialog.Active():
1333 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)1349 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
1334 case i.sessionTreeDialog.Active():1350 case i.sessionTreeDialog.Active():
1351+ // Reserve rows for the editor, status, and tree chrome while using
1352+ // the same budget for rendering and PageUp/PageDown movement.
1353+ _, rows := i.cfg.Terminal.Size()
1354+ avail := rows - 12
1355+ if avail < 3 {
1356+ avail = 3
1357+ }
1358+ i.sessionTreeDialog.MaxRows = avail
1335 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)1359 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
1336 }1360 }
1337 if len(dialog) > 0 {1361 if len(dialog) > 0 {
@@ -2083,6 +2107,15 @@ func (i *Interactive) restoreConfirmFocus() {
2083 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {2107 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2084 defer i.restoreConfirmFocus()2108 defer i.restoreConfirmFocus()
2085 2109
2110+ // A bare Escape is eligible for the session-tree gesture only while the
2111+ // main input owns the key. Any other key, modified Escape, or visible
2112+ // child interaction resets the pending first tap before normal routing.
2113+ if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() {
2114+ i.mu.Lock()
2115+ i.doubleEscape.Reset()
2116+ i.mu.Unlock()
2117+ }
2118+
2086 // Dialogs route keys before the main clipboard handler below. Resolve text2119 // Dialogs route keys before the main clipboard handler below. Resolve text
2087 // here when a child interaction owns input so every editor and filter sees2120 // here when a child interaction owns input so every editor and filter sees
2088 // the same KeyPaste event as terminal-native bracketed paste. Main chat is2121 // the same KeyPaste event as terminal-native bracketed paste. Main chat is
@@ -2312,7 +2345,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2312 }2345 }
2313 act := i.sessionTreeDialog.HandleKey(k)2346 act := i.sessionTreeDialog.HandleKey(k)
2314 if act.Select {2347 if act.Select {
2315- i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)2348+ if act.Target.Role != "" || act.Target.SourcePath != "" || act.Target.IsBoundary() || act.Target.UserDraft != "" {
2349+ i.applySessionTreeTarget(act.Target, act.TurnNo)
2350+ } else {
2351+ // Keep package embedders that still return the scalar action
2352+ // source-compatible while the dialog migrates to Target.
2353+ i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
2354+ }
2316 }2355 }
2317 i.invalidate()2356 i.invalidate()
2318 return false2357 return false
@@ -2393,6 +2432,35 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2393 return false2432 return false
2394 }2433 }
2395 2434
2435+ // The first eligible bare Escape is deliberately left to the existing
2436+ // Escape path (which clears idle input or cancels a turn). A second tap
2437+ // within the gesture window only gets first refusal while the cheap,
2438+ // local eligibility rules still hold. If a turn started, a message was
2439+ // queued, or the input became ineligible between taps, reset the detector
2440+ // and fall through so Escape retains its normal cancellation behavior.
2441+ // Once those rules still hold, the full tree gate owns the second tap:
2442+ // family/read failures are fail-closed but are nevertheless consumed so
2443+ // they cannot fall through into ordinary editor handling.
2444+ if isUnmodifiedEscape(k) {
2445+ now := i.sessionTreeEscapeNow()
2446+ i.mu.Lock()
2447+ consumed := i.doubleEscape.Consume(now)
2448+ i.mu.Unlock()
2449+ if consumed {
2450+ if i.canArmSessionTreeEscape() {
2451+ i.openSessionTree()
2452+ return false
2453+ }
2454+ i.mu.Lock()
2455+ i.doubleEscape.Reset()
2456+ i.mu.Unlock()
2457+ } else if i.canArmSessionTreeEscape() {
2458+ i.mu.Lock()
2459+ i.doubleEscape.Arm(now)
2460+ i.mu.Unlock()
2461+ }
2462+ }
2463+
2396 // Global keys.2464 // Global keys.
2397 switch k.Kind {2465 switch k.Kind {
2398 case tui.KeyCtrlC:2466 case tui.KeyCtrlC:
@@ -2699,18 +2767,27 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2699 if text == "" && len(images) == 0 {2767 if text == "" && len(images) == 0 {
2700 return false2768 return false
2701 }2769 }
2702- i.clipboardImages = nil2770+ clearSubmittedInput := func() {
2703- i.ed.Clear()2771+ i.clipboardImages = nil
2704- i.inputHistoryIndex = -12772+ i.ed.Clear()
2705- i.suggest.Reset()2773+ i.inputHistoryIndex = -1
2706- i.fileSuggest.Reset()2774+ i.suggest.Reset()
2775+ i.fileSuggest.Reset()
2776+ }
2707 2777
2708- if cmd, ok := shellEscapeCommand(text); ok {2778+ // Shell escapes and slash commands are text-only. An image-bearing
2709- i.startShellEscape(ctx, cmd)2779+ // draft remains a normal provider prompt instead of silently dropping
2710- return false2780+ // its attachments.
2781+ if len(images) == 0 {
2782+ if cmd, ok := shellEscapeCommand(text); ok {
2783+ clearSubmittedInput()
2784+ i.startShellEscape(ctx, cmd)
2785+ return false
2786+ }
2711 }2787 }
2712 2788
2713- if looksLikeSlashCommand(text) {2789+ if len(images) == 0 && looksLikeSlashCommand(text) {
2790+ clearSubmittedInput()
2714 head := text2791 head := text
2715 rest := ""2792 rest := ""
2716 if idx := strings.IndexAny(text, " \t"); idx >= 0 {2793 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
@@ -2733,12 +2810,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2733 }2810 }
2734 // Slash commands run regardless of busy state. Commands that2811 // Slash commands run regardless of busy state. Commands that
2735 // would mutate the transcript or replace the agent (/clear,2812 // would mutate the transcript or replace the agent (/clear,
2736- // /compact, /logout, /login, /model) cancel the active turn2813+ // /compact, /logout, /login, /model, /fork, /session fork)
2814+ // cancel the active turn
2737 // first and wait for the goroutine to wind down so they don't2815 // first and wait for the goroutine to wind down so they don't
2738 // race with a streaming response. Safe commands (/help,2816 // race with a streaming response. Safe commands (/help,
2739 // /jump, /sessions, /jail, /unjail, /exit) run immediately2817 // /jump, /sessions, /jail, /unjail, /exit) run immediately
2740 // without disturbing the active turn.2818 // without disturbing the active turn.
2741- if slashCancelsTurn(head) {2819+ if slashCommandCancelsTurn(text) {
2742 i.cancelAndWaitForIdle()2820 i.cancelAndWaitForIdle()
2743 }2821 }
2744 return i.runSlash(ctx, text)2822 return i.runSlash(ctx, text)
@@ -2774,6 +2852,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2774 i.invalidate()2852 i.invalidate()
2775 return false2853 return false
2776 }2854 }
2855+ clearSubmittedInput()
2777 if ag != nil && !compacting {2856 if ag != nil && !compacting {
2778 ag.QueueMessage(text)2857 ag.QueueMessage(text)
2779 } else {2858 } else {
@@ -2784,6 +2863,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2784 i.invalidate()2863 i.invalidate()
2785 return false2864 return false
2786 }2865 }
2866+ clearSubmittedInput()
2787 i.startTurnWithImages(ctx, text, images)2867 i.startTurnWithImages(ctx, text, images)
2788 }2868 }
2789 return false2869 return false
@@ -3107,6 +3187,38 @@ func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3107 }3187 }
3108 }3188 }
3109 3189
3190+// ApplySessionAgent swaps the live agent after a validated session resume.
3191+// Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup
3192+// context; the session loader is changing the transcript/provider only.
3193+func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) {
3194+ if ag == nil {
3195+ return
3196+ }
3197+ i.mu.Lock()
3198+ i.agent = ag
3199+ i.cfg.Provider = providerName
3200+ i.cfg.Model = model
3201+ i.view.Messages = filterHiddenTranscriptMessages(ag.Messages())
3202+ i.cumUsage = ag.Cost()
3203+ last := ag.LastTurnUsage()
3204+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
3205+ if len(i.view.Messages) > initialResumeTailLimit {
3206+ i.view.TailLimit = initialResumeTailLimit
3207+ } else {
3208+ i.view.TailLimit = 0
3209+ }
3210+ i.view.InvalidateRenderCache()
3211+ i.toolCalls = map[string]*tui.ToolCallView{}
3212+ i.toolOrder = nil
3213+ i.toolGate = map[string]int{}
3214+ i.helpBlock = nil
3215+ i.extNotes = nil
3216+ i.parkedTurn = 0
3217+ i.parkedTotal = 0
3218+ i.mu.Unlock()
3219+ i.invalidate()
3220+}
3221+
3110 // ApplyChangedCWD is called by hosts after a successful /cd hook that do3222 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
3111 // not provide startup context metadata.3223 // not provide startup context metadata.
3112 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {3224 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
@@ -3167,15 +3279,14 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
3167 // agent cancel the active turn first via the same path the editor3279 // agent cancel the active turn first via the same path the editor
3168 // uses for typed slash commands.3280 // uses for typed slash commands.
3169 func (i *Interactive) SubmitSlash(text string) {3281 func (i *Interactive) SubmitSlash(text string) {
3282+ i.mu.Lock()
3283+ i.doubleEscape.Reset()
3284+ i.mu.Unlock()
3170 text = strings.TrimSpace(text)3285 text = strings.TrimSpace(text)
3171 if !strings.HasPrefix(text, "/") {3286 if !strings.HasPrefix(text, "/") {
3172 return3287 return
3173 }3288 }
3174- head := text3289+ if slashCommandCancelsTurn(text) {
3175- if idx := strings.IndexAny(text, " \t"); idx >= 0 {
3176- head = text[:idx]
3177- }
3178- if slashCancelsTurn(head) {
3179 i.cancelAndWaitForIdle()3290 i.cancelAndWaitForIdle()
3180 }3291 }
3181 i.runSlash(i.runCtx, text)3292 i.runSlash(i.runCtx, text)
@@ -4519,6 +4630,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4519 i.applyModelSelection("", parts[1])4630 i.applyModelSelection("", parts[1])
4520 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {4631 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
4521 i.mu.Lock()4632 i.mu.Lock()
4633+ i.modelRefreshing = true
4522 i.statusOK = "refreshing models"4634 i.statusOK = "refreshing models"
4523 i.statusErr = ""4635 i.statusErr = ""
4524 i.mu.Unlock()4636 i.mu.Unlock()
@@ -4554,6 +4666,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4554 i.openSettingsDialog()4666 i.openSettingsDialog()
4555 case "/sessions":4667 case "/sessions":
4556 i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)4668 i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)
4669+ case "/fork":
4670+ i.doSessionFork()
4557 case "/jump":4671 case "/jump":
4558 i.openJumpDialog(parts[1:])4672 i.openJumpDialog(parts[1:])
4559 case "/btw":4673 case "/btw":
@@ -5393,6 +5507,16 @@ func (i *Interactive) applyRescueModelSelection(prov, model string) {
5393 // the supplied builder. rescue=true tags the success message so the5507 // the supplied builder. rescue=true tags the success message so the
5394 // user can see that launch-time overrides were ignored.5508 // user can see that launch-time overrides were ignored.
5395 func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {5509 func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
5510+ if i.cfg.SessionTransition != nil {
5511+ i.cfg.SessionTransition(func() {
5512+ i.swapModelUnserialized(prov, model, builder, rescue)
5513+ })
5514+ return
5515+ }
5516+ i.swapModelUnserialized(prov, model, builder, rescue)
5517+}
5518+
5519+func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
5396 if model == "" {5520 if model == "" {
5397 return5521 return
5398 }5522 }
@@ -5495,15 +5619,27 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
5495 i.dialog.ShowResult(false, err.Error())5619 i.dialog.ShowResult(false, err.Error())
5496 return5620 return
5497 }5621 }
5498- i.mu.Lock()5622+ commitLogin := func() {
5499- i.agent = ag5623+ i.mu.Lock()
5500- i.cfg.Provider = prov5624+ i.agent = ag
5501- i.cfg.Model = model5625+ i.cfg.Provider = prov
5502- i.statusErr = ""5626+ i.cfg.Model = model
5503- i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method5627+ i.statusErr = ""
5504- i.mu.Unlock()5628+ i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5505- i.applyAutoSwarmTool(i.autoSwarmEnabled())5629+ i.mu.Unlock()
5506- i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())5630+ i.applyAutoSwarmTool(i.autoSwarmEnabled())
5631+ i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5632+ // Authentication can change the provider used by the live
5633+ // agent. Persist it on the active session just like /model.
5634+ if i.cfg.PersistModel != nil {
5635+ i.cfg.PersistModel(prov, model)
5636+ }
5637+ }
5638+ if i.cfg.SessionTransition != nil {
5639+ i.cfg.SessionTransition(commitLogin)
5640+ } else {
5641+ commitLogin()
5642+ }
5507 i.dialog.ShowResult(true, "")5643 i.dialog.ShowResult(true, "")
5508 }5644 }
5509 }5645 }
@@ -6942,14 +7078,7 @@ func (i *Interactive) doSessionOp(action, arg string) {
6942 // the user's home directory if it doesn't exist). The helper7078 // the user's home directory if it doesn't exist). The helper
6943 // expands a leading `~` and creates any missing parent directories.7079 // expands a leading `~` and creates any missing parent directories.
6944 func (i *Interactive) doSessionExport(dst string) {7080 func (i *Interactive) doSessionExport(dst string) {
6945- if i.cfg.CurrentSessionPath == nil {7081+ src := i.currentSessionPath()
6946- i.mu.Lock()
6947- i.statusErr = "export: no session is active (running with --no-session?)"
6948- i.mu.Unlock()
6949- i.invalidate()
6950- return
6951- }
6952- src := i.cfg.CurrentSessionPath()
6953 if src == "" {7082 if src == "" {
6954 i.mu.Lock()7083 i.mu.Lock()
6955 i.statusErr = "export: no session is active (running with --no-session?)"7084 i.statusErr = "export: no session is active (running with --no-session?)"
@@ -7104,114 +7233,156 @@ func friendlyPath(p string) string {
7104 // next selection branches the current session at that user turn7233 // next selection branches the current session at that user turn
7105 // instead of scrolling the viewport to it.7234 // instead of scrolling the viewport to it.
7106 func (i *Interactive) doSessionFork() {7235 func (i *Interactive) doSessionFork() {
7107- if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {7236+ path := i.currentSessionPath()
7237+ if path == "" {
7108 i.mu.Lock()7238 i.mu.Lock()
7109 i.statusErr = "fork: no session is active (running with --no-session?)"7239 i.statusErr = "fork: no session is active (running with --no-session?)"
7110 i.mu.Unlock()7240 i.mu.Unlock()
7111 i.invalidate()7241 i.invalidate()
7112 return7242 return
7113 }7243 }
7114- msgs := []provider.Message{}7244+ i.mu.Lock()
7115- if i.agent != nil {7245+ busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
7116- msgs = i.agent.Messages()7246+ queued := len(i.queued) != 0
7117- }7247+ ag := i.agent
7118- if len(msgs) == 0 {7248+ i.mu.Unlock()
7249+ if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
7119 i.mu.Lock()7250 i.mu.Lock()
7120- i.statusErr = "fork: transcript is empty; nothing to fork from"7251+ i.statusErr = "fork: wait until the current turn and queued messages finish"
7121 i.mu.Unlock()7252 i.mu.Unlock()
7122 i.invalidate()7253 i.invalidate()
7123 return7254 return
7124 }7255 }
7125- i.pendingFork = true7256+ if i.cfg.FlushSession != nil {
7126- i.jumpDialog.Open(msgs, "")7257+ i.cfg.FlushSession()
7127- i.invalidate()
7128-}
7129-
7130-// doSessionTree shows the current session family as an inline branch tree.
7131-// Pick an entry to check out that point into a new branch. Subsequent
7132-// prompts use only context up to the selected message.
7133-func (i *Interactive) doSessionTree() {
7134- current := ""
7135- if i.cfg.CurrentSessionPath != nil {
7136- current = i.cfg.CurrentSessionPath()
7137 }7258 }
7138- if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {7259+ snapshot, err := core.ReadSessionSnapshot(path)
7260+ if err != nil {
7261+ i.mu.Lock()
7262+ i.statusErr = "fork: " + err.Error()
7263+ i.mu.Unlock()
7139 i.invalidate()7264 i.invalidate()
7140 return7265 return
7141 }7266 }
7142- msgs := []provider.Message{}7267+ msgs := snapshot.Messages
7143- if i.agent != nil {
7144- msgs = i.agent.Messages()
7145- }
7146 if len(msgs) == 0 {7268 if len(msgs) == 0 {
7147 i.mu.Lock()7269 i.mu.Lock()
7148- i.statusErr = "tree: no messages in this session yet"7270+ i.statusErr = "fork: transcript is empty; nothing to fork from"
7149 i.mu.Unlock()7271 i.mu.Unlock()
7150 i.invalidate()7272 i.invalidate()
7151 return7273 return
7152 }7274 }
7153- if !i.sessionTreeDialog.OpenMessages(msgs) {7275+ i.pendingFork = true
7154- i.mu.Lock()7276+ i.jumpDialog.Open(msgs, "")
7155- i.statusErr = "tree: no messages to show"
7156- i.mu.Unlock()
7157- }
7158 i.invalidate()7277 i.invalidate()
7159 }7278 }
7160 7279
7161-// applySessionTreeMessageSelection checks out the selected message into a7280+// doSessionTree opens the current session family after the shared
7162-// new branch, then switches the running agent to that branch. Unlike /jump,7281+// fail-closed gate succeeds. It intentionally does not fall back to the
7163-// this changes future model context: only messages up to the selected row are7282+// in-memory transcript: a tree that cannot be persisted and reloaded cannot
7164-// sent on subsequent turns.7283+// safely create a navigation branch.
7284+func (i *Interactive) doSessionTree() {
7285+ i.openSessionTree()
7286+}
7287+
7288+// applySessionTreeMessageSelection keeps the old scalar integration
7289+// source-compatible for package consumers. New dialog selections use the
7290+// structured sessionTreeTarget path below.
7165 func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {7291 func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
7166- if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {7292+ target := sessionTreeTarget{
7167- i.mu.Lock()7293+ SourcePath: src,
7168- i.statusErr = "tree: session branching is not available in this build"7294+ EffectiveIndex: msgIdx,
7169- i.mu.Unlock()7295+ SelectionBoundary: msgIdx + 1,
7170- i.invalidate()7296+ Role: role,
7297+ UserDraft: prompt,
7298+ Boundary: sessionTreeMessageBoundary,
7299+ }
7300+ if role == provider.RoleUser {
7301+ target.SelectionBoundary = msgIdx
7302+ }
7303+ i.applySessionTreeTarget(target, turnNo)
7304+}
7305+
7306+// applySessionTreeTarget checks out a structured dialog target. The target's
7307+// source and effective indices come from the same preflight snapshot the
7308+// dialog rendered, while the live read below preserves compatibility with
7309+// the existing LoadSession(path) error callback.
7310+func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
7311+ // Selection is a state-changing operation too. Use the selection gate so
7312+ // a queued/busy turn cannot race the hidden branch or accidentally submit
7313+ // a provider turn while the session is being swapped. The open gate cannot
7314+ // be reused here because the tree dialog itself owns the keyboard.
7315+ if !i.canCommitSessionTreeSelection() {
7316+ i.setSessionTreeError("tree: session branching is not available in this build")
7171 return7317 return
7172 }7318 }
7319+ src := target.SourcePath
7173 if src == "" {7320 if src == "" {
7174- src = i.cfg.CurrentSessionPath()7321+ src = i.currentSessionPath()
7175 }7322 }
7176 if src == "" {7323 if src == "" {
7177- i.mu.Lock()7324+ i.setSessionTreeError("tree: no session is active")
7178- i.statusErr = "tree: no session is active"7325+ return
7179- i.mu.Unlock()7326+ }
7180- i.invalidate()7327+
7328+ // Read and validate the selected row before flushing or creating anything.
7329+ // This gives tool-call/result pairs an atomic boundary and prevents a
7330+ // malformed selection from changing the active session.
7331+ sess, msgs, err := core.OpenSession(src)
7332+ if err != nil {
7333+ i.setSessionTreeError("tree: read selection: " + err.Error())
7334+ return
7335+ }
7336+ _ = sess.Close()
7337+ selection, err := sessionTreeSelection(msgs, target)
7338+ if err != nil {
7339+ i.setSessionTreeError("tree: " + err.Error())
7181 return7340 return
7182 }7341 }
7183 if i.cfg.FlushSession != nil {7342 if i.cfg.FlushSession != nil {
7184 i.cfg.FlushSession()7343 i.cfg.FlushSession()
7185 }7344 }
7186- upTo := msgIdx + 17345+ newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
7187- if role == provider.RoleUser {
7188- upTo = msgIdx
7189- }
7190- newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
7191 if err != nil {7346 if err != nil {
7192- i.mu.Lock()7347+ i.setSessionTreeError("tree: " + err.Error())
7193- i.statusErr = "tree: " + err.Error()
7194- i.mu.Unlock()
7195- i.invalidate()
7196 return7348 return
7197 }7349 }
7198 if err := i.cfg.LoadSession(newPath); err != nil {7350 if err := i.cfg.LoadSession(newPath); err != nil {
7199- i.mu.Lock()7351+ i.setSessionTreeError("tree: checkout failed: " + err.Error())
7200- i.statusErr = "tree: checkout failed: " + err.Error()
7201- i.mu.Unlock()
7202- i.invalidate()
7203 return7352 return
7204 }7353 }
7205- i.scrollToBottom()7354+
7355+ // LoadSession is intentionally the old func(string) error callback: the
7356+ // CLI and embedders already own agent/session swapping. Refresh the view
7357+ // from the swapped agent here instead of requiring a new callback result.
7206 i.mu.Lock()7358 i.mu.Lock()
7207- if role == provider.RoleUser {7359+ i.lastCtxInput = 0
7208- i.ed.SetValue(prompt)7360+ if i.agent != nil {
7361+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
7362+ i.cumUsage = i.agent.Cost()
7363+ last := i.agent.LastTurnUsage()
7364+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
7365+ i.view.InvalidateRenderCache()
7366+ }
7367+ i.toolCalls = map[string]*tui.ToolCallView{}
7368+ i.toolOrder = nil
7369+ i.toolGate = map[string]int{}
7370+ i.extNotes = nil
7371+ if selection.restoreDraft {
7372+ i.ed.SetValue(selection.draftText)
7373+ i.clipboardImages = selection.images
7209 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)7374 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
7210 } else {7375 } else {
7376+ i.ed.Clear()
7377+ i.clipboardImages = nil
7211 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)7378 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
7212 }7379 }
7213 i.statusErr = ""7380 i.statusErr = ""
7214 i.mu.Unlock()7381 i.mu.Unlock()
7382+ i.scrollToBottom()
7383+ // Selection only changes local/session state. In particular, do not call
7384+ // Submit, startTurn, or queue a message here; the restored user draft is
7385+ // submitted only by a later explicit Enter.
7215 i.invalidate()7386 i.invalidate()
7216 }7387 }
7217 7388
@@ -7221,14 +7392,7 @@ func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnN
7221 // file. Called from the jump-dialog handler when pendingFork=true.7392 // file. Called from the jump-dialog handler when pendingFork=true.
7222 func (i *Interactive) applyForkSelection(msgIdx int) {7393 func (i *Interactive) applyForkSelection(msgIdx int) {
7223 i.pendingFork = false7394 i.pendingFork = false
7224- if i.cfg.CurrentSessionPath == nil {7395+ src := i.currentSessionPath()
7225- i.mu.Lock()
7226- i.statusErr = "fork: no session is active"
7227- i.mu.Unlock()
7228- i.invalidate()
7229- return
7230- }
7231- src := i.cfg.CurrentSessionPath()
7232 if src == "" {7396 if src == "" {
7233 i.mu.Lock()7397 i.mu.Lock()
7234 i.statusErr = "fork: no session is active"7398 i.statusErr = "fork: no session is active"
M packages/agent/modes/session_tree_dialog.go +604 -106
@@ -2,13 +2,66 @@ package modes
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "reflect"
5 "strings"6 "strings"
7+ "unicode"
8+
9+ "github.com/mattn/go-runewidth"
6 10
7 "github.com/patriceckhart/zot/packages/core"11 "github.com/patriceckhart/zot/packages/core"
8 "github.com/patriceckhart/zot/packages/provider"12 "github.com/patriceckhart/zot/packages/provider"
9 "github.com/patriceckhart/zot/packages/tui"13 "github.com/patriceckhart/zot/packages/tui"
10 )14 )
11 15
16+// sessionTreeBoundary describes what a tree row points at. Message rows are
17+// normal transcript rows. Empty and detached rows are real, selectable
18+// boundaries even though they do not correspond to a message.
19+type sessionTreeBoundary uint8
20+
21+const (
22+ sessionTreeMessageBoundary sessionTreeBoundary = iota
23+ sessionTreeEmptyBoundary
24+ sessionTreeDetachedBoundary
25+)
26+
27+func (b sessionTreeBoundary) String() string {
28+ switch b {
29+ case sessionTreeEmptyBoundary:
30+ return "empty"
31+ case sessionTreeDetachedBoundary:
32+ return "detached"
33+ default:
34+ return "message"
35+ }
36+}
37+
38+// sessionTreeTarget is the stable description of a row in the dialog.
39+// EffectiveIndex is an index into SourcePath's effective transcript (the
40+// snapshot returned by core.OpenSession), while SelectionBoundary is the
41+// message count to pass to core.BranchSessionHidden. Keeping both values is
42+// important for a user row: selecting it branches before that row so the
43+// complete draft can be put back in the editor.
44+type sessionTreeTarget struct {
45+ SourcePath string
46+ EffectiveIndex int
47+ SelectionBoundary int
48+ Role provider.Role
49+ UserDraft string
50+ Boundary sessionTreeBoundary
51+}
52+
53+func (t sessionTreeTarget) IsBoundary() bool {
54+ return t.Boundary != sessionTreeMessageBoundary
55+}
56+
57+func (t sessionTreeTarget) IsEmptyBoundary() bool {
58+ return t.Boundary == sessionTreeEmptyBoundary
59+}
60+
61+func (t sessionTreeTarget) IsDetachedBoundary() bool {
62+ return t.Boundary == sessionTreeDetachedBoundary
63+}
64+
12 // sessionTreeDialog renders a compact outline of the current session family.65 // sessionTreeDialog renders a compact outline of the current session family.
13 // Branches are shown inline at the point where they forked, using indentation66 // Branches are shown inline at the point where they forked, using indentation
14 // rather than file-level rows. Selecting a node checks out that point into a67 // rather than file-level rows. Selecting a node checks out that point into a
@@ -17,54 +70,108 @@ type sessionTreeDialog struct {
17 active bool70 active bool
18 items []sessionTreeItem71 items []sessionTreeItem
19 cursor int72 cursor int
73+
74+ // MaxRows is the maximum number of transcript/boundary rows rendered in
75+ // one viewport. The interactive host may set it from terminal height. A
76+ // bounded default is deliberately used when it is left at zero so a large
77+ // family cannot consume the whole chat pane.
78+ MaxRows int
79+ viewTop int
20 }80 }
21 81
22 type sessionTreeItem struct {82 type sessionTreeItem struct {
23 label string83 label string
24- messageIdx int84+ messageIdx int // compatibility alias for target.EffectiveIndex
25 turnNo int85 turnNo int
26 role provider.Role86 role provider.Role
27- prompt string87+ prompt string // compatibility alias for target.UserDraft
28- path string88+ path string // compatibility alias for target.SourcePath
29 depth int89 depth int
30 current bool90 current bool
91+ target sessionTreeTarget
31 }92 }
32 93
94+// sessionTreeAction is returned by HandleKey. Target is the structured API
95+// for the integration layer. The scalar fields are retained for the existing
96+// interactive.go integration and mirror a message target where possible.
33 type sessionTreeAction struct {97 type sessionTreeAction struct {
34- Select bool98+ Select bool
99+ Target sessionTreeTarget
100+
101+ // Legacy/package integration fields. New callers should use Target,
102+ // especially Target.SelectionBoundary for empty or detached rows.
35 MessageIdx int103 MessageIdx int
36 TurnNo int104 TurnNo int
37 Role provider.Role105 Role provider.Role
38 Prompt string106 Prompt string
39 Path string107 Path string
108+ Boundary sessionTreeBoundary
40 Close bool109 Close bool
41 }110 }
42 111
112+type sessionTreeSnapshot struct {
113+ path string
114+ messages []provider.Message
115+}
116+
117+const defaultSessionTreeRows = 12
118+
43 func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }119 func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }
44 120
121+// OpenMessages opens a snapshot supplied by the running agent. This is the
122+// fallback used before a session has a persisted source path.
45 func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool {123 func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool {
46- d.items = buildSessionTreeItems("", msgs, 0, false)124+ items := buildSessionTreeItems("", msgs, 0, false)
47- d.cursor = len(d.items) - 1125+ if len(items) == 0 {
48- if d.cursor < 0 {126+ return false
49- d.cursor = 0
50 }127 }
51- d.active = true128+ d.activate(items, len(items)-1)
52- return len(d.items) > 0129+ return true
53 }130 }
54 131
132+// OpenSessionFamily preflights the complete family containing currentPath,
133+// then activates the dialog in one step. A missing current path is not
134+// recoverable by choosing an arbitrary forest root. Failed reads leave the
135+// prior dialog state untouched.
55 func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool {136 func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool {
56- roots := core.BuildSessionTree(root, cwd)137+ roots, err := core.BuildSessionTreeFamilyStrict(root, cwd, currentPath)
57- if len(roots) == 0 {138+ if err != nil || len(roots) == 0 {
58 return false139 return false
59 }140 }
60- rootNode := findTreeRootForPath(roots, currentPath)141+ familyRoot := roots[0]
61- if rootNode == nil {142+
62- rootNode = roots[0]143+ snapshots, err := preflightSessionTreeFamily(familyRoot)
144+ if err != nil {
145+ return false
146+ }
147+ items := flattenSessionFamilySnapshot(familyRoot, currentPath, snapshots)
148+ if len(items) == 0 || !sessionTreeItemsMeaningful(items, familyRoot.Summary.Path) {
149+ return false
63 }150 }
64- d.items = flattenSessionFamily(rootNode, currentPath)151+ d.activate(items, indexCurrentTreeItem(items))
65- d.cursor = indexCurrentTreeItem(d.items)152+ return true
153+}
154+
155+func sessionTreeItemsMeaningful(items []sessionTreeItem, rootPath string) bool {
156+ for _, item := range items {
157+ if !item.target.IsBoundary() || item.path != rootPath {
158+ return true
159+ }
160+ }
161+ return false
162+}
163+
164+func (d *sessionTreeDialog) activate(items []sessionTreeItem, cursor int) {
165+ if cursor < 0 {
166+ cursor = 0
167+ }
168+ if cursor >= len(items) {
169+ cursor = len(items) - 1
170+ }
171+ d.items = items
172+ d.cursor = cursor
173+ d.viewTop = 0
66 d.active = true174 d.active = true
67- return len(d.items) > 0
68 }175 }
69 176
70 // Close hides the dialog.177 // Close hides the dialog.
@@ -76,30 +183,60 @@ func (d *sessionTreeDialog) CursorPos() (row, col int) { return -1, -1 }
76 // Active reports whether the dialog consumes input.183 // Active reports whether the dialog consumes input.
77 func (d *sessionTreeDialog) Active() bool { return d != nil && d.active }184 func (d *sessionTreeDialog) Active() bool { return d != nil && d.active }
78 185
186+func (d *sessionTreeDialog) pageRows() int {
187+ if d.MaxRows > 0 {
188+ return d.MaxRows
189+ }
190+ return defaultSessionTreeRows
191+}
192+
193+func (d *sessionTreeDialog) followCursor() {
194+ d.viewTop = clampSessionTreeViewTop(d.viewTop, d.cursor, d.pageRows(), len(d.items))
195+}
196+
79 // HandleKey advances the cursor or resolves the selection.197 // HandleKey advances the cursor or resolves the selection.
80 func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {198 func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
199+ if d == nil {
200+ return sessionTreeAction{}
201+ }
202+ page := d.pageRows()
203+ if page < 1 {
204+ page = 1
205+ }
81 switch k.Kind {206 switch k.Kind {
82 case tui.KeyUp:207 case tui.KeyUp:
83 if d.cursor > 0 {208 if d.cursor > 0 {
84 d.cursor--209 d.cursor--
85 }210 }
211+ d.followCursor()
86 case tui.KeyDown:212 case tui.KeyDown:
87 if d.cursor < len(d.items)-1 {213 if d.cursor < len(d.items)-1 {
88 d.cursor++214 d.cursor++
89 }215 }
216+ d.followCursor()
90 case tui.KeyPageUp:217 case tui.KeyPageUp:
91- d.cursor -= 5218+ d.cursor -= page
92 if d.cursor < 0 {219 if d.cursor < 0 {
93 d.cursor = 0220 d.cursor = 0
94 }221 }
222+ d.followCursor()
95 case tui.KeyPageDown:223 case tui.KeyPageDown:
96- d.cursor += 5224+ d.cursor += page
97 if d.cursor >= len(d.items) {225 if d.cursor >= len(d.items) {
98 d.cursor = len(d.items) - 1226 d.cursor = len(d.items) - 1
99 }227 }
100 if d.cursor < 0 {228 if d.cursor < 0 {
101 d.cursor = 0229 d.cursor = 0
102 }230 }
231+ d.followCursor()
232+ case tui.KeyHome:
233+ d.cursor = 0
234+ d.followCursor()
235+ case tui.KeyEnd:
236+ if len(d.items) > 0 {
237+ d.cursor = len(d.items) - 1
238+ }
239+ d.followCursor()
103 case tui.KeyEsc:240 case tui.KeyEsc:
104 d.Close()241 d.Close()
105 return sessionTreeAction{Close: true}242 return sessionTreeAction{Close: true}
@@ -110,148 +247,395 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
110 }247 }
111 it := d.items[d.cursor]248 it := d.items[d.cursor]
112 d.Close()249 d.Close()
113- return sessionTreeAction{Select: true, MessageIdx: it.messageIdx, TurnNo: it.turnNo, Role: it.role, Prompt: it.prompt, Path: it.path}250+ return sessionTreeActionForTarget(it.target, it.turnNo)
114 }251 }
115 return sessionTreeAction{}252 return sessionTreeAction{}
116 }253 }
117 254
118-// Render returns the dialog lines.255+func sessionTreeActionForTarget(target sessionTreeTarget, turnNo int) sessionTreeAction {
256+ act := sessionTreeAction{
257+ Select: true,
258+ Target: target,
259+ TurnNo: turnNo,
260+ Boundary: target.Boundary,
261+ Path: target.SourcePath,
262+ Role: target.Role,
263+ Prompt: target.UserDraft,
264+ // MessageIdx is the effective index for ordinary rows. For a
265+ // boundary, use the old integration's "last included row" form
266+ // so msgIdx+1 still equals the safe selection boundary.
267+ MessageIdx: target.EffectiveIndex,
268+ }
269+ if target.IsBoundary() {
270+ act.MessageIdx = target.SelectionBoundary - 1
271+ act.Role = provider.RoleAssistant
272+ act.Prompt = ""
273+ }
274+ return act
275+}
276+
277+// Render returns the dialog lines. MaxRows controls the number of selectable
278+// rows, not the chrome; every returned row is still hard-clipped to width.
119 func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {279 func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
120 if !d.Active() {280 if !d.Active() {
121 return nil281 return nil
122 }282 }
123- var lines []string283+ renderWidth := width
124- lines = append(lines, frameHeader(th, "session tree", width))284+ if renderWidth < 0 {
285+ renderWidth = 0
286+ }
287+ line := func(s string) string { return truncateSessionTreeANSI(s, renderWidth) }
288+
289+ lines := []string{line(frameHeader(th, "session tree", renderWidth))}
125 if len(d.items) == 0 {290 if len(d.items) == 0 {
126- lines = append(lines, th.FG256(th.Muted, "no messages in this session yet"))291+ lines = append(lines,
127- lines = append(lines, th.FG256(th.Muted, "press esc to close"))292+ line(th.FG256(th.Muted, "no messages in this session yet")),
128- lines = append(lines, frameRule(th, width))293+ line(th.FG256(th.Muted, "press esc to close")),
294+ line(frameRule(th, renderWidth)),
295+ )
129 return lines296 return lines
130 }297 }
131- lines = append(lines, th.FG256(th.Muted, "session branches (↑/↓, pgup/pgdn, enter checkout, esc cancel):"))298+ lines = append(lines, line(th.FG256(th.Muted,
299+ "session branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):")))
132 300
133- const maxRows = 12301+ d.followCursor()
134- start := 0302+ start := d.viewTop
135- end := len(d.items)303+ end := start + d.pageRows()
136- if end > maxRows {304+ if end > len(d.items) {
137- start = d.cursor - maxRows/2305+ end = len(d.items)
138- if start < 0 {306+ }
139- start = 0307+ if start > end {
140- }308+ start = end
141- end = start + maxRows
142- if end > len(d.items) {
143- end = len(d.items)
144- start = end - maxRows
145- }
146 }309 }
147 if start > 0 {310 if start > 0 {
148- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start)))311+ lines = append(lines, line(th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start))))
149 }312 }
150 for i := start; i < end; i++ {313 for i := start; i < end; i++ {
151 it := d.items[i]314 it := d.items[i]
152- indent := strings.Repeat(" ", it.depth)315+ plain := fitSessionTreeItemPlain(it, renderWidth)
153- plain := " " + indent + fitSessionTreeLabel(it.label, width-2-len([]rune(indent)))
154- if it.current {
155- plain += " [current]"
156- }
157 if i == d.cursor {316 if i == d.cursor {
158- lines = append(lines, th.PadHighlight(plain, width))317+ lines = append(lines, line(th.PadHighlight(plain, renderWidth)))
159 } else {318 } else {
160- lines = append(lines, colorSessionTreeLine(th, plain))319+ lines = append(lines, line(colorSessionTreeLine(th, plain)))
161 }320 }
162 }321 }
163 if end < len(d.items) {322 if end < len(d.items) {
164- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end)))323+ lines = append(lines, line(th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end))))
165 }324 }
166- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items))))325+ lines = append(lines, line(th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items)))), line(frameRule(th, renderWidth)))
167- lines = append(lines, frameRule(th, width))
168 return lines326 return lines
169 }327 }
170 328
171-func flattenSessionFamily(root *core.TreeNode, currentPath string) []sessionTreeItem {329+func clampSessionTreeViewTop(viewTop, cursor, window, total int) int {
172- childrenByFork := map[int][]*core.TreeNode{}330+ if window <= 0 || total <= 0 || window >= total {
173- for _, child := range root.Children {331+ return 0
174- childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child)
175 }332 }
176- sess, msgs, err := core.OpenSession(root.Summary.Path)333+ if cursor < viewTop {
177- if err != nil {334+ viewTop = cursor
178- return nil
179 }335 }
180- _ = sess.Close()336+ if cursor >= viewTop+window {
181- items := buildSessionTreeItems(root.Summary.Path, msgs, 0, root.Summary.Path == currentPath)337+ viewTop = cursor - window + 1
182- var out []sessionTreeItem338+ }
183- for idx, item := range items {339+ if viewTop < 0 {
184- out = append(out, item)340+ viewTop = 0
185- for _, child := range childrenByFork[idx+1] {341+ }
186- out = append(out, flattenSessionBranch(child, currentPath, 1)...)342+ if viewTop+window > total {
343+ viewTop = total - window
344+ }
345+ return viewTop
346+}
347+
348+func fitSessionTreeItemPlain(it sessionTreeItem, maxWidth int) string {
349+ if maxWidth <= 0 {
350+ return ""
351+ }
352+ prefix := " " + strings.Repeat(" ", it.depth)
353+ suffix := ""
354+ if it.current {
355+ suffix = " [current]"
356+ }
357+ prefixWidth := runewidth.StringWidth(prefix)
358+ suffixWidth := runewidth.StringWidth(suffix)
359+ if prefixWidth+suffixWidth >= maxWidth {
360+ // A terminal narrower than the structural chrome cannot show the
361+ // complete row; retain the current marker as the highest-value suffix.
362+ return fitSessionTreeLabel(suffix, maxWidth)
363+ }
364+ labelWidth := maxWidth - prefixWidth - suffixWidth
365+ return prefix + fitSessionTreeLabel(it.label, labelWidth) + suffix
366+}
367+
368+// preflightSessionTreeFamily reads every node before any dialog state is
369+// changed. The returned messages are the shared effective snapshots used by
370+// all flattening and target construction for this open operation.
371+func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnapshot, error) {
372+ if root == nil {
373+ return nil, fmt.Errorf("session tree: nil family root")
374+ }
375+ snapshots := make(map[string]sessionTreeSnapshot)
376+ visiting := make(map[string]bool)
377+ var walk func(*core.TreeNode) error
378+ walk = func(node *core.TreeNode) error {
379+ if node == nil {
380+ return nil
381+ }
382+ path := node.Summary.Path
383+ if visiting[path] {
384+ return fmt.Errorf("session tree: cycle at %q", path)
187 }385 }
386+ if _, ok := snapshots[path]; ok {
387+ return nil
388+ }
389+ visiting[path] = true
390+ snapshot, err := core.ReadSessionSnapshot(path)
391+ if err != nil {
392+ delete(visiting, path)
393+ return fmt.Errorf("session tree: read %q: %w", path, err)
394+ }
395+ snapshots[path] = sessionTreeSnapshot{path: path, messages: snapshot.Messages}
396+ for _, child := range node.Children {
397+ if err := walk(child); err != nil {
398+ delete(visiting, path)
399+ return err
400+ }
401+ }
402+ delete(visiting, path)
403+ return nil
188 }404 }
189- for _, child := range childrenByFork[0] {405+ if err := walk(root); err != nil {
190- out = append(out, flattenSessionBranch(child, currentPath, 1)...)406+ return nil, err
191 }407 }
192- return out408+ return snapshots, nil
409+}
410+
411+// flattenSessionFamily is retained for package callers/tests that already
412+// provide a TreeNode. The dialog itself uses the snapshot variant so all
413+// reads happen before activation.
414+func flattenSessionFamily(root *core.TreeNode, currentPath string) []sessionTreeItem {
415+ snapshots, err := preflightSessionTreeFamily(root)
416+ if err != nil {
417+ return nil
418+ }
419+ return flattenSessionFamilySnapshot(root, currentPath, snapshots)
420+}
421+
422+func flattenSessionFamilySnapshot(root *core.TreeNode, currentPath string, snapshots map[string]sessionTreeSnapshot) []sessionTreeItem {
423+ return flattenSessionNode(root, currentPath, snapshots, 0, 0, true, false)
193 }424 }
194 425
426+// flattenSessionBranch remains a small compatibility helper. It is useful in
427+// focused package tests and keeps the old helper available to nearby callers.
195 func flattenSessionBranch(node *core.TreeNode, currentPath string, depth int) []sessionTreeItem {428 func flattenSessionBranch(node *core.TreeNode, currentPath string, depth int) []sessionTreeItem {
196- sess, msgs, err := core.OpenSession(node.Summary.Path)429+ snapshots, err := preflightSessionTreeFamily(node)
197 if err != nil {430 if err != nil {
198 return nil431 return nil
199 }432 }
200- _ = sess.Close()433+ parentLen := 0
201- start := node.Meta.ForkPoint434+ if snap, ok := snapshots[node.Summary.Path]; ok {
202- if start < 0 {435+ parentLen = len(snap.messages)
203- start = 0
204 }436 }
205- if start > len(msgs) {437+ return flattenSessionNode(node, currentPath, snapshots, depth, parentLen, false, false)
206- start = len(msgs)438+}
439+
440+// flattenSessionNode emits the visible suffix for a node and inserts children
441+// at their effective fork boundary. Fork points are counts, so fork N appears
442+// after message N-1. A fork beyond the parent's effective snapshot is kept at
443+// the end and gets a detached boundary instead of silently disappearing.
444+func flattenSessionNode(node *core.TreeNode, currentPath string, snapshots map[string]sessionTreeSnapshot, depth, parentLen int, rootNode, detachedFromParent bool) []sessionTreeItem {
445+ if node == nil {
446+ return nil
207 }447 }
208- items := buildSessionTreeItems(node.Summary.Path, msgs[start:], depth, node.Summary.Path == currentPath)448+ snapshot, ok := snapshots[node.Summary.Path]
209- for idx := range items {449+ if !ok {
210- items[idx].messageIdx += start450+ return nil
211 }451 }
212- childrenByFork := map[int][]*core.TreeNode{}452+ msgs := snapshot.messages
453+ start := 0
454+ var selectionBoundary int
455+ boundary := sessionTreeMessageBoundary
456+ rawFork := 0
457+ if !rootNode {
458+ rawFork = node.Meta.ForkPoint
459+ switch {
460+ case detachedFromParent || rawFork < 0 || rawFork > parentLen || rawFork > len(msgs):
461+ // Historical placement no longer maps to either snapshot. Keep the
462+ // branch visible at the parent's tail, but render its complete
463+ // current snapshot rather than slicing it at the stale fork point.
464+ boundary = sessionTreeDetachedBoundary
465+ start = 0
466+ selectionBoundary = len(msgs)
467+ default:
468+ start = rawFork
469+ selectionBoundary = rawFork
470+ if start >= len(msgs) {
471+ boundary = sessionTreeEmptyBoundary
472+ }
473+ }
474+ } else if len(msgs) == 0 {
475+ boundary = sessionTreeEmptyBoundary
476+ selectionBoundary = 0
477+ } else {
478+ selectionBoundary = len(msgs)
479+ }
480+
481+ all := buildSessionTreeItems(snapshot.path, msgs, depth, snapshot.path == currentPath)
482+ childrenAt := make(map[int][]*core.TreeNode)
483+ var stale []*core.TreeNode
213 for _, child := range node.Children {484 for _, child := range node.Children {
214- childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child)485+ fork := child.Meta.ForkPoint
486+ attach := fork
487+ if attach < start {
488+ attach = start
489+ }
490+ childSnapshot, childLoaded := snapshots[child.Summary.Path]
491+ // A branch with no post-fork messages still needs its explicit empty
492+ // boundary even when the copied historical prefix is no longer
493+ // comparable after compaction; there is no suffix whose placement could
494+ // be wrong. Non-empty suffixes require the prefix identity check.
495+ emptySuffix := childLoaded && fork >= 0 && fork == len(childSnapshot.messages)
496+ if fork < 0 || fork > len(msgs) || !childLoaded ||
497+ (!emptySuffix && !sessionTreePrefixMatches(msgs, childSnapshot.messages, fork)) {
498+ // Numeric fork points are only placement hints. A parent may have
499+ // been compacted and later grown back to the old length, so bounds
500+ // alone are insufficient: verify that the child's copied prefix is
501+ // still the parent's current prefix before placing the edge.
502+ stale = append(stale, child)
503+ continue
504+ }
505+ childrenAt[attach] = append(childrenAt[attach], child)
215 }506 }
507+
216 var out []sessionTreeItem508 var out []sessionTreeItem
217- for relIdx, item := range items {509+ if boundary != sessionTreeMessageBoundary {
218- out = append(out, item)510+ turn := treeTurnAtBoundary(msgs, selectionBoundary)
219- absAfter := start + relIdx + 1511+ boundaryCurrent := snapshot.path == currentPath && (len(msgs) == 0 || boundary == sessionTreeEmptyBoundary)
220- for _, child := range childrenByFork[absAfter] {512+ out = append(out, makeSessionTreeBoundaryItem(snapshot.path, selectionBoundary, depth, boundaryCurrent, boundary, rawFork, len(msgs), turn))
221- out = append(out, flattenSessionBranch(child, currentPath, depth+1)...)513+ }
514+
515+ emitChildren := func(children []*core.TreeNode, childDetached bool) {
516+ for _, child := range children {
517+ out = append(out, flattenSessionNode(child, currentPath, snapshots, depth+1, len(msgs), false, childDetached)...)
222 }518 }
223 }519 }
520+
521+ // A fork at zero is shown before the first visible message. For a branch,
522+ // children that forked in the hidden copied prefix are normalized to its
523+ // first visible index above, so they remain discoverable too.
524+ emitChildren(childrenAt[start], false)
525+ for idx := start; idx < len(all); idx++ {
526+ out = append(out, all[idx])
527+ emitChildren(childrenAt[idx+1], false)
528+ }
529+ // Stale children are attached after the current effective snapshot. Their
530+ // own row makes the missing historical boundary explicit.
531+ emitChildren(stale, true)
224 return out532 return out
225 }533 }
226 534
535+func makeSessionTreeBoundaryItem(path string, effectiveIndex, depth int, current bool, boundary sessionTreeBoundary, forkPoint, snapshotLen, turn int) sessionTreeItem {
536+ label := fmt.Sprintf("%s branch boundary", boundary.String())
537+ if boundary == sessionTreeDetachedBoundary {
538+ label = fmt.Sprintf("detached branch boundary (fork %d; snapshot %d)", forkPoint, snapshotLen)
539+ }
540+ target := sessionTreeTarget{
541+ SourcePath: path,
542+ EffectiveIndex: effectiveIndex,
543+ SelectionBoundary: effectiveIndex,
544+ Boundary: boundary,
545+ }
546+ return sessionTreeItem{
547+ label: label,
548+ messageIdx: effectiveIndex,
549+ turnNo: turn,
550+ path: path,
551+ depth: depth,
552+ current: current,
553+ target: target,
554+ }
555+}
556+
557+func sessionTreePrefixMatches(parent, child []provider.Message, fork int) bool {
558+ if fork < 0 || fork > len(parent) || fork > len(child) {
559+ return false
560+ }
561+ return reflect.DeepEqual(parent[:fork], child[:fork])
562+}
563+
564+func treeTurnAtBoundary(msgs []provider.Message, boundary int) int {
565+ if boundary < 0 {
566+ boundary = 0
567+ }
568+ if boundary > len(msgs) {
569+ boundary = len(msgs)
570+ }
571+ turn := 0
572+ for _, msg := range msgs[:boundary] {
573+ if msg.Role == provider.RoleUser {
574+ turn++
575+ }
576+ }
577+ if turn == 0 {
578+ return 1
579+ }
580+ return turn
581+}
582+
227 func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem {583 func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem {
228 out := make([]sessionTreeItem, 0, len(msgs))584 out := make([]sessionTreeItem, 0, len(msgs))
229 turn := 0585 turn := 0
230 lastTurn := 0586 lastTurn := 0
231 for idx, msg := range msgs {587 for idx, msg := range msgs {
232- label := sessionTreeRoleLabel(msg.Role)
233 if msg.Role == provider.RoleUser {588 if msg.Role == provider.RoleUser {
234 turn++589 turn++
235 lastTurn = turn590 lastTurn = turn
236 } else if lastTurn == 0 {591 } else if lastTurn == 0 {
237 lastTurn = 1592 lastTurn = 1
238 }593 }
594+ roleLabel := sessionTreeRoleLabel(msg.Role)
239 preview := sessionTreePreview(msg)595 preview := sessionTreePreview(msg)
596+ boundary := idx + 1
597+ draft := ""
598+ if msg.Role == provider.RoleUser {
599+ boundary = idx
600+ draft = completeUserDraft(msg)
601+ }
602+ target := sessionTreeTarget{
603+ SourcePath: path,
604+ EffectiveIndex: idx,
605+ SelectionBoundary: boundary,
606+ Role: msg.Role,
607+ UserDraft: draft,
608+ Boundary: sessionTreeMessageBoundary,
609+ }
240 out = append(out, sessionTreeItem{610 out = append(out, sessionTreeItem{
241- label: fmt.Sprintf("%s: %s", label, preview),611+ label: fmt.Sprintf("%s: %s", roleLabel, preview),
242 messageIdx: idx,612 messageIdx: idx,
243 turnNo: lastTurn,613 turnNo: lastTurn,
244 role: msg.Role,614 role: msg.Role,
245- prompt: firstTextFromTreeMessage(msg),615+ prompt: draft,
246 path: path,616 path: path,
247 depth: depth,617 depth: depth,
248 current: currentPath && idx == len(msgs)-1,618 current: currentPath && idx == len(msgs)-1,
619+ target: target,
249 })620 })
250 }621 }
251 return out622 return out
252 }623 }
253 624
625+func completeUserDraft(msg provider.Message) string {
626+ var text []string
627+ for _, c := range msg.Content {
628+ if tb, ok := c.(provider.TextBlock); ok {
629+ text = append(text, tb.Text)
630+ }
631+ }
632+ return strings.Join(text, "\n")
633+}
634+
254 func findTreeRootForPath(roots []*core.TreeNode, path string) *core.TreeNode {635 func findTreeRootForPath(roots []*core.TreeNode, path string) *core.TreeNode {
636+ if path == "" {
637+ return nil
638+ }
255 for _, root := range roots {639 for _, root := range roots {
256 if treeContainsPath(root, path) {640 if treeContainsPath(root, path) {
257 return root641 return root
@@ -287,15 +671,6 @@ func indexCurrentTreeItem(items []sessionTreeItem) int {
287 return len(items) - 1671 return len(items) - 1
288 }672 }
289 673
290-func firstTextFromTreeMessage(msg provider.Message) string {
291- for _, c := range msg.Content {
292- if tb, ok := c.(provider.TextBlock); ok {
293- return tb.Text
294- }
295- }
296- return ""
297-}
298-
299 func sessionTreeRoleLabel(role provider.Role) string {674 func sessionTreeRoleLabel(role provider.Role) string {
300 switch role {675 switch role {
301 case provider.RoleUser:676 case provider.RoleUser:
@@ -314,14 +689,13 @@ func sessionTreePreview(msg provider.Message) string {
314 for _, c := range msg.Content {689 for _, c := range msg.Content {
315 switch b := c.(type) {690 switch b := c.(type) {
316 case provider.TextBlock:691 case provider.TextBlock:
317- text := strings.TrimSpace(strings.ReplaceAll(b.Text, "\n", " "))692+ if text := sanitizeSessionTreeText(b.Text); text != "" {
318- if text != "" {
319 parts = append(parts, text)693 parts = append(parts, text)
320 }694 }
321 case provider.ImageBlock:695 case provider.ImageBlock:
322 parts = append(parts, "[image]")696 parts = append(parts, "[image]")
323 case provider.ToolCallBlock:697 case provider.ToolCallBlock:
324- parts = append(parts, "tool "+b.Name)698+ parts = append(parts, "tool "+sanitizeSessionTreeText(b.Name))
325 case provider.ToolResultBlock:699 case provider.ToolResultBlock:
326 if b.IsError {700 if b.IsError {
327 parts = append(parts, "tool result error")701 parts = append(parts, "tool result error")
@@ -340,6 +714,47 @@ func sessionTreePreview(msg provider.Message) string {
340 return strings.Join(parts, " ")714 return strings.Join(parts, " ")
341 }715 }
342 716
717+// sanitizeSessionTreeText keeps previews single-line and prevents transcript
718+// content from injecting terminal control sequences into the dialog frame.
719+func sanitizeSessionTreeText(s string) string {
720+ runes := []rune(s)
721+ var b strings.Builder
722+ for i := 0; i < len(runes); {
723+ if end, ok := sessionTreeCSIEnd(runes, i); ok {
724+ i = end
725+ continue
726+ }
727+ if runes[i] == '\x1b' && i+1 < len(runes) && runes[i+1] == ']' {
728+ // OSC sequences end at BEL or ST. Drop the whole sequence when
729+ // present.
730+ i += 2
731+ for i < len(runes) && runes[i] != '\a' {
732+ if runes[i] == '\x1b' && i+1 < len(runes) && runes[i+1] == '\\' {
733+ i += 2
734+ break
735+ }
736+ i++
737+ }
738+ if i < len(runes) && runes[i] == '\a' {
739+ i++
740+ }
741+ continue
742+ }
743+ if runes[i] == '\x1b' {
744+ // An isolated ESC (or a two-byte escape) is dropped by itself.
745+ i++
746+ continue
747+ }
748+ if unicode.IsControl(runes[i]) {
749+ b.WriteByte(' ')
750+ } else {
751+ b.WriteRune(runes[i])
752+ }
753+ i++
754+ }
755+ return strings.Join(strings.Fields(b.String()), " ")
756+}
757+
343 func colorSessionTreeLine(th tui.Theme, line string) string {758 func colorSessionTreeLine(th tui.Theme, line string) string {
344 plain := strings.TrimSpace(line)759 plain := strings.TrimSpace(line)
345 switch {760 switch {
@@ -354,13 +769,96 @@ func colorSessionTreeLine(th tui.Theme, line string) string {
354 }769 }
355 }770 }
356 771
772+// fitSessionTreeLabel truncates by terminal cells, not Go runes. This is
773+// intentionally also used for the indentation and current marker together so
774+// neither can push a row past the terminal edge.
357 func fitSessionTreeLabel(label string, maxWidth int) string {775 func fitSessionTreeLabel(label string, maxWidth int) string {
358- if maxWidth < 4 {776+ if maxWidth <= 0 {
359- maxWidth = 4777+ return ""
360 }778 }
361- runes := []rune(label)779+ if runewidth.StringWidth(label) <= maxWidth {
362- if len(runes) <= maxWidth {
363 return label780 return label
364 }781 }
365- return string(runes[:maxWidth-3]) + "..."782+ if maxWidth <= 3 {
783+ return strings.Repeat(".", maxWidth)
784+ }
785+ return truncateSessionTreePlain(label, maxWidth-3) + "..."
786+}
787+
788+func truncateSessionTreePlain(s string, maxWidth int) string {
789+ if maxWidth <= 0 {
790+ return ""
791+ }
792+ var b strings.Builder
793+ used := 0
794+ for _, r := range s {
795+ w := runewidth.RuneWidth(r)
796+ if w > 0 && used+w > maxWidth {
797+ break
798+ }
799+ b.WriteRune(r)
800+ used += w
801+ }
802+ return b.String()
803+}
804+
805+// truncateSessionTreeANSI is the dialog-local equivalent of tui's renderer
806+// clipping helper. Keeping it here avoids changing tui for one dialog while
807+// still making headers, hints, colors, and highlighted rows width-safe.
808+func truncateSessionTreeANSI(s string, maxWidth int) string {
809+ if maxWidth <= 0 {
810+ return ""
811+ }
812+ if sessionTreeANSIWidth(s) <= maxWidth {
813+ return s
814+ }
815+ runes := []rune(s)
816+ var b strings.Builder
817+ used := 0
818+ clipped := false
819+ for i := 0; i < len(runes); {
820+ if end, ok := sessionTreeCSIEnd(runes, i); ok {
821+ b.WriteString(string(runes[i:end]))
822+ i = end
823+ continue
824+ }
825+ w := runewidth.RuneWidth(runes[i])
826+ if w > 0 && used+w > maxWidth {
827+ clipped = true
828+ break
829+ }
830+ b.WriteRune(runes[i])
831+ used += w
832+ i++
833+ }
834+ if clipped && strings.Contains(s, "\x1b[") {
835+ b.WriteString("\x1b[0m")
836+ }
837+ return b.String()
838+}
839+
840+func sessionTreeANSIWidth(s string) int {
841+ runes := []rune(s)
842+ width := 0
843+ for i := 0; i < len(runes); {
844+ if end, ok := sessionTreeCSIEnd(runes, i); ok {
845+ i = end
846+ continue
847+ }
848+ width += runewidth.RuneWidth(runes[i])
849+ i++
850+ }
851+ return width
852+}
853+
854+func sessionTreeCSIEnd(runes []rune, start int) (int, bool) {
855+ if start+1 >= len(runes) || runes[start] != '\x1b' || runes[start+1] != '[' {
856+ return start, false
857+ }
858+ for i := start + 2; i < len(runes); i++ {
859+ if runes[i] >= 0x40 && runes[i] <= 0x7e {
860+ return i + 1, true
861+ }
862+ }
863+ return len(runes), true
366 }864 }
A packages/agent/modes/session_tree_dialog_test.go +290 -0
@@ -0,0 +1,290 @@
1+package modes
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/provider"
11+ "github.com/patriceckhart/zot/packages/tui"
12+)
13+
14+func TestSessionTreeTargetCarriesEffectiveBoundaryAndDraft(t *testing.T) {
15+ msgs := []provider.Message{
16+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "first"}),
17+ treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "answer"}),
18+ treeTestMessage(provider.RoleUser,
19+ provider.TextBlock{Text: "line one"},
20+ provider.ImageBlock{MimeType: "image/png", Data: []byte{1, 2}},
21+ provider.TextBlock{Text: "line two"}),
22+ }
23+
24+ d := newSessionTreeDialog()
25+ if !d.OpenMessages(msgs) {
26+ t.Fatal("OpenMessages returned false")
27+ }
28+ d.cursor = 2
29+ act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
30+ if !act.Select {
31+ t.Fatal("enter did not select a target")
32+ }
33+ got := act.Target
34+ if got.SourcePath != "" || got.EffectiveIndex != 2 || got.SelectionBoundary != 2 {
35+ t.Fatalf("target indices/path = %+v", got)
36+ }
37+ if got.Role != provider.RoleUser || got.Boundary != sessionTreeMessageBoundary {
38+ t.Fatalf("target role/boundary = %+v", got)
39+ }
40+ if got.UserDraft != "line one\nline two" {
41+ t.Fatalf("user draft = %q", got.UserDraft)
42+ }
43+ // The scalar fields remain usable by the current integration layer.
44+ if act.MessageIdx != 2 || act.Role != provider.RoleUser || act.Prompt != got.UserDraft {
45+ t.Fatalf("legacy action fields = %+v", act)
46+ }
47+}
48+
49+func TestSessionTreeUsesCurrentFamilyAndNoRootFallback(t *testing.T) {
50+ root := t.TempDir()
51+ cwd := "/workspace/current-family"
52+ first := newTreeTestSession(t, root, cwd, []provider.Message{
53+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "unrelated root"}),
54+ })
55+ unrelatedFile, err := os.OpenFile(first, os.O_APPEND|os.O_WRONLY, 0o644)
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if _, err := unrelatedFile.WriteString("{unrelated corruption}\n"); err != nil {
60+ _ = unrelatedFile.Close()
61+ t.Fatal(err)
62+ }
63+ if err := unrelatedFile.Close(); err != nil {
64+ t.Fatal(err)
65+ }
66+ current := newTreeTestSession(t, root, cwd, []provider.Message{
67+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "current root"}),
68+ })
69+
70+ d := newSessionTreeDialog()
71+ if !d.OpenSessionFamily(root, cwd, current) {
72+ t.Fatal("OpenSessionFamily returned false")
73+ }
74+ for _, item := range d.items {
75+ if item.path != current {
76+ t.Fatalf("item from unrelated family: %+v (first root %q)", item, first)
77+ }
78+ }
79+ if !d.Active() {
80+ t.Fatal("dialog is not active")
81+ }
82+ oldItems := append([]sessionTreeItem(nil), d.items...)
83+ if d.OpenSessionFamily(root, cwd, t.TempDir()+"/not-a-session.jsonl") {
84+ t.Fatal("missing current path selected a forest root")
85+ }
86+ if !d.Active() || len(d.items) != len(oldItems) || d.items[0].path != oldItems[0].path {
87+ t.Fatal("failed open changed the active dialog")
88+ }
89+}
90+
91+func TestSessionTreeShowsEmptyAndDetachedBoundaries(t *testing.T) {
92+ root := t.TempDir()
93+ cwd := "/workspace/stale-compaction"
94+ parent := newTreeTestSession(t, root, cwd, []provider.Message{
95+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "one"}),
96+ treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "two"}),
97+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "three"}),
98+ })
99+
100+ // Both children are created against the old effective parent. The first
101+ // has no post-fork rows; the second's fork point becomes stale after the
102+ // parent is compacted to one message.
103+ emptyChild, err := core.BranchSessionHidden(parent, root, cwd, "test", 1)
104+ if err != nil {
105+ t.Fatal(err)
106+ }
107+ staleChild, err := core.BranchSessionHidden(parent, root, cwd, "test", 3)
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ parentSession, _, err := core.OpenSession(parent)
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ if err := parentSession.AppendCompaction([]provider.Message{
116+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "compacted"}),
117+ }); err != nil {
118+ t.Fatal(err)
119+ }
120+ if err := parentSession.Close(); err != nil {
121+ t.Fatal(err)
122+ }
123+
124+ d := newSessionTreeDialog()
125+ if !d.OpenSessionFamily(root, cwd, parent) {
126+ t.Fatal("OpenSessionFamily returned false after compaction")
127+ }
128+ var empty, detached *sessionTreeItem
129+ staleMessageRows := 0
130+ for i := range d.items {
131+ item := &d.items[i]
132+ switch {
133+ case item.target.IsEmptyBoundary() && item.path == emptyChild:
134+ empty = item
135+ case item.target.IsDetachedBoundary() && item.path == staleChild:
136+ detached = item
137+ case item.path == staleChild && !item.target.IsBoundary():
138+ staleMessageRows++
139+ }
140+ }
141+ if empty == nil || detached == nil {
142+ t.Fatalf("boundaries missing from tree: empty=%v detached=%v items=%+v", empty != nil, detached != nil, d.items)
143+ }
144+ if staleMessageRows != 3 {
145+ t.Fatalf("detached branch rendered %d message rows, want its complete snapshot (3)", staleMessageRows)
146+ }
147+ if empty.target.SelectionBoundary != 1 || detached.target.SelectionBoundary != 3 {
148+ t.Fatalf("boundary indices = empty %+v detached %+v", empty.target, detached.target)
149+ }
150+ if !strings.Contains(strings.ToLower(empty.label), "empty") || !strings.Contains(strings.ToLower(detached.label), "detached") {
151+ t.Fatalf("boundary labels = %q / %q", empty.label, detached.label)
152+ }
153+}
154+
155+func TestSessionTreePreflightIsAtomicOnMalformedFamilyMember(t *testing.T) {
156+ root := t.TempDir()
157+ cwd := "/workspace/atomic-tree"
158+ parent := newTreeTestSession(t, root, cwd, []provider.Message{
159+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "parent"}),
160+ })
161+ child, err := core.BranchSessionHidden(parent, root, cwd, "test", 1)
162+ if err != nil {
163+ t.Fatal(err)
164+ }
165+ f, err := os.OpenFile(child, os.O_APPEND|os.O_WRONLY, 0o644)
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ if _, err := f.WriteString("{not valid json}\n"); err != nil {
170+ _ = f.Close()
171+ t.Fatal(err)
172+ }
173+ if err := f.Close(); err != nil {
174+ t.Fatal(err)
175+ }
176+
177+ d := newSessionTreeDialog()
178+ if !d.OpenMessages([]provider.Message{treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "keep"})}) {
179+ t.Fatal("failed to seed dialog")
180+ }
181+ before := append([]sessionTreeItem(nil), d.items...)
182+ if d.OpenSessionFamily(root, cwd, parent) {
183+ t.Fatal("malformed family member passed preflight")
184+ }
185+ if !d.Active() || len(d.items) != len(before) || d.items[0].label != before[0].label {
186+ t.Fatalf("preflight failure changed dialog: active=%v items=%+v", d.Active(), d.items)
187+ }
188+}
189+
190+func TestSanitizeSessionTreeTextPreservesTextAfterNonOSCExit(t *testing.T) {
191+ input := "before\x1bM and \x1b(B after \x1b]0;window title\a tail"
192+ if got, want := sanitizeSessionTreeText(input), "beforeM and (B after tail"; got != want {
193+ t.Fatalf("sanitizeSessionTreeText = %q, want %q", got, want)
194+ }
195+}
196+
197+func TestSessionTreeRenderAndPagingAreWidthAndHeightSafe(t *testing.T) {
198+ msgs := make([]provider.Message, 0, 20)
199+ for i := 0; i < 20; i++ {
200+ msgs = append(msgs, treeTestMessage(provider.RoleUser,
201+ provider.TextBlock{Text: strings.Repeat("界", 4) + " long row"}))
202+ }
203+ d := newSessionTreeDialog()
204+ d.MaxRows = 2
205+ if !d.OpenMessages(msgs) {
206+ t.Fatal("OpenMessages returned false")
207+ }
208+ if got := d.HandleKey(tui.Key{Kind: tui.KeyPageUp}); got.Select || d.cursor != 17 {
209+ t.Fatalf("page up cursor = %d, action=%+v", d.cursor, got)
210+ }
211+ for _, width := range []int{0, 1, 7, 17} {
212+ for _, line := range d.Render(tui.Theme{}, width) {
213+ if got := sessionTreeANSIWidth(line); got > width {
214+ t.Fatalf("width %d rendered %d cells: %q", width, got, line)
215+ }
216+ }
217+ }
218+ if d.viewTop < 0 || d.viewTop+d.MaxRows > len(d.items) {
219+ t.Fatalf("invalid viewport top=%d rows=%d items=%d", d.viewTop, d.MaxRows, len(d.items))
220+ }
221+}
222+
223+func TestSessionTreeCheckoutRefreshesLastTurnUsageIncludingZero(t *testing.T) {
224+ root := t.TempDir()
225+ cwd := "/workspace/tree-usage"
226+ path := newTreeTestSession(t, root, cwd, []provider.Message{
227+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "question"}),
228+ treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "answer"}),
229+ })
230+
231+ initial := core.NewAgent(nil, "model", "", nil)
232+ initial.SeedLastTurnUsage(provider.Usage{InputTokens: 99})
233+ selected := core.NewAgent(nil, "model", "", nil)
234+ selected.SetMessages(initial.Messages())
235+ selected.SeedLastTurnUsage(provider.Usage{
236+ InputTokens: 7,
237+ CacheReadTokens: 11,
238+ CacheWriteTokens: 13,
239+ })
240+
241+ i := NewInteractive(InteractiveConfig{
242+ Agent: initial,
243+ CWD: cwd,
244+ SessionsRoot: root,
245+ CurrentSessionPath: func() string { return path },
246+ })
247+ i.cfg.LoadSession = func(string) error {
248+ i.agent = selected
249+ return nil
250+ }
251+
252+ target := sessionTreeTarget{
253+ SourcePath: path,
254+ EffectiveIndex: 1,
255+ SelectionBoundary: 2,
256+ Role: provider.RoleAssistant,
257+ }
258+ i.applySessionTreeTarget(target, 1)
259+ if i.lastCtxInput != 31 {
260+ t.Fatalf("checked-out context usage = %d, want 31", i.lastCtxInput)
261+ }
262+
263+ selected.SeedLastTurnUsage(provider.Usage{})
264+ i.applySessionTreeTarget(target, 1)
265+ if i.lastCtxInput != 0 {
266+ t.Fatalf("checked-out zero usage left stale context value %d", i.lastCtxInput)
267+ }
268+}
269+
270+func treeTestMessage(role provider.Role, content ...provider.Content) provider.Message {
271+ return provider.Message{Role: role, Content: content, Time: time.Unix(1, 0)}
272+}
273+
274+func newTreeTestSession(t *testing.T, root, cwd string, msgs []provider.Message) string {
275+ t.Helper()
276+ sess, err := core.NewSession(root, cwd, "test", "model", "test")
277+ if err != nil {
278+ t.Fatal(err)
279+ }
280+ for _, msg := range msgs {
281+ if err := sess.AppendMessage(msg); err != nil {
282+ _ = sess.Close()
283+ t.Fatal(err)
284+ }
285+ }
286+ if err := sess.Close(); err != nil {
287+ t.Fatal(err)
288+ }
289+ return sess.Path
290+}
M packages/agent/modes/slash_suggest.go +14 -2
@@ -26,12 +26,23 @@ type slashCommand struct {
26 // the streaming response without trouble.26 // the streaming response without trouble.
27 func slashCancelsTurn(head string) bool {27 func slashCancelsTurn(head string) bool {
28 switch strings.ToLower(head) {28 switch strings.ToLower(head) {
29- case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd":29+ case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd", "/fork":
30 return true30 return true
31 }31 }
32 return false32 return false
33 }33 }
34 34
35+func slashCommandCancelsTurn(cmd string) bool {
36+ parts := strings.Fields(cmd)
37+ if len(parts) == 0 {
38+ return false
39+ }
40+ if slashCancelsTurn(parts[0]) {
41+ return true
42+ }
43+ return strings.EqualFold(parts[0], "/session") && len(parts) > 1 && strings.EqualFold(parts[1], "fork")
44+}
45+
35 // slashCatalog lists every slash command the interactive mode handles.46 // slashCatalog lists every slash command the interactive mode handles.
36 // Keep in sync with runSlash().47 // Keep in sync with runSlash().
37 var slashCatalog = []slashCommand{48 var slashCatalog = []slashCommand{
@@ -42,7 +53,8 @@ var slashCatalog = []slashCommand{
42 {Name: "/reasoning", Desc: "set the reasoning level"},53 {Name: "/reasoning", Desc: "set the reasoning level"},
43 {Name: "/llama", Desc: "manage llama.cpp router models"},54 {Name: "/llama", Desc: "manage llama.cpp router models"},
44 {Name: "/sessions", Desc: "resume a previous session for this directory"},55 {Name: "/sessions", Desc: "resume a previous session for this directory"},
45- {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"},56+ {Name: "/fork", Desc: "pick a user message and fork the current session"},
57+ {Name: "/session", Desc: "export/import, fork, or browse the current session tree"},
46 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},58 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},
47 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},59 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
48 {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"},60 {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"},
M packages/agent/modes/slash_suggest_test.go +9 -0
@@ -52,9 +52,18 @@ func TestSlashCommandsAreCaseInsensitive(t *testing.T) {
52 if !isKnownSlashCommand("/Exit") {52 if !isKnownSlashCommand("/Exit") {
53 t.Fatal("/Exit was not recognized as a built-in command")53 t.Fatal("/Exit was not recognized as a built-in command")
54 }54 }
55+ if !isKnownSlashCommand("/Fork") {
56+ t.Fatal("/Fork was not recognized as a built-in command")
57+ }
55 if !slashCancelsTurn("/CLEAR") {58 if !slashCancelsTurn("/CLEAR") {
56 t.Fatal("/CLEAR did not retain /clear cancellation semantics")59 t.Fatal("/CLEAR did not retain /clear cancellation semantics")
57 }60 }
61+ if !slashCommandCancelsTurn("/fork") || !slashCommandCancelsTurn("/session fork") {
62+ t.Fatal("fork commands did not retain idle-transition cancellation semantics")
63+ }
64+ if slashCommandCancelsTurn("/session tree") {
65+ t.Fatal("/session tree should use its fail-closed gate instead of cancelling a turn")
66+ }
58 }67 }
59 68
60 func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) {69 func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) {
M packages/agent/swarm_agent.go +13 -2
@@ -60,8 +60,19 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
60 60
61 ag := r.NewAgent()61 ag := r.NewAgent()
62 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)62 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
63- sess, _ := openOrCreateSession(args, r, ag, version)63+ sess, err := openOrCreateSession(args, r, ag, version)
64- defer sess.Close()64+ if err != nil {
65+ return err
66+ }
67+ if sess != nil {
68+ var providerName, model string
69+ sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
70+ if err != nil {
71+ return err
72+ }
73+ r.Provider, r.Model = providerName, model
74+ defer sess.Close()
75+ }
65 76
66 // Open the inbox listener BEFORE emitting agent_ready so the77 // Open the inbox listener BEFORE emitting agent_ready so the
67 // supervisor can dial through on the very first send. The78 // supervisor can dial through on the very first send. The
M packages/core/agent.go +3 -0
@@ -322,6 +322,9 @@ func (a *Agent) fireMessageAppended(m provider.Message) {
322 // stops or an error occurs. Events are delivered via sink in order.322 // stops or an error occurs. Events are delivered via sink in order.
323 // sink must not block the caller for long; buffer as needed.323 // sink must not block the caller for long; buffer as needed.
324 func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error {324 func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error {
325+ if text == "" && len(images) == 0 {
326+ return errors.New("prompt is empty")
327+ }
325 if sink == nil {328 if sink == nil {
326 sink = func(AgentEvent) {}329 sink = func(AgentEvent) {}
327 }330 }
M packages/core/core_test.go +11 -0
@@ -1,6 +1,7 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "context"
4 "os"5 "os"
5 "testing"6 "testing"
6 "time"7 "time"
@@ -8,6 +9,16 @@ import (
8 "github.com/patriceckhart/zot/packages/provider"9 "github.com/patriceckhart/zot/packages/provider"
9 )10 )
10 11
12+func TestAgentRejectsEmptyPrompt(t *testing.T) {
13+ ag := NewAgent(nil, "model", "", nil)
14+ if err := ag.Prompt(context.Background(), "", nil, nil); err == nil {
15+ t.Fatal("empty prompt was accepted")
16+ }
17+ if got := ag.Messages(); len(got) != 0 {
18+ t.Fatalf("empty prompt appended %d messages", len(got))
19+ }
20+}
21+
11 func TestSessionRoundTrip(t *testing.T) {22 func TestSessionRoundTrip(t *testing.T) {
12 dir := t.TempDir()23 dir := t.TempDir()
13 os.Setenv("ZOT_HOME", dir)24 os.Setenv("ZOT_HOME", dir)
M packages/core/session.go +476 -243
@@ -6,6 +6,7 @@ import (
6 "crypto/sha256"6 "crypto/sha256"
7 "encoding/hex"7 "encoding/hex"
8 "encoding/json"8 "encoding/json"
9+ "errors"
9 "fmt"10 "fmt"
10 "io"11 "io"
11 "os"12 "os"
@@ -69,18 +70,36 @@ type SessionMeta struct {
69 // the default unmarshaler cannot reconstruct); it is written with a70 // the default unmarshaler cannot reconstruct); it is written with a
70 // regular provider.Message value.71 // regular provider.Message value.
71 type sessionLine struct {72 type sessionLine struct {
72- Type string `json:"type"`73+ Type string `json:"type"`
73- Meta *SessionMeta `json:"meta,omitempty"`74+ Meta *SessionMeta `json:"meta,omitempty"`
74- Message *provider.Message `json:"message,omitempty"`75+ Message *provider.Message `json:"message,omitempty"`
75- Messages []provider.Message `json:"messages,omitempty"`76+ Messages *[]provider.Message `json:"messages,omitempty"`
76- Usage *provider.Usage `json:"usage,omitempty"`77+ Usage *provider.Usage `json:"usage,omitempty"`
77- Cumulative *provider.Usage `json:"cumulative,omitempty"`78+ Cumulative *provider.Usage `json:"cumulative,omitempty"`
78 }79 }
79 80
80 type sessionLineHead struct {81 type sessionLineHead struct {
81 Type string `json:"type"`82 Type string `json:"type"`
82 }83 }
83 84
85+// SessionUsageCheckpoint records the cumulative usage known when a usage
86+// row was written. MessageCount uses the effective transcript (after the
87+// latest compaction and tool-pair repair), not the number of raw JSONL rows.
88+type SessionUsageCheckpoint struct {
89+ MessageCount int
90+ Cumulative provider.Usage
91+}
92+
93+// SessionSnapshot is the effective, in-memory view of a session file.
94+// Messages are the transcript that a resumed agent should use. The raw
95+// JSONL audit history is deliberately not exposed here because message
96+// indices used by tree navigation and branching must agree.
97+type SessionSnapshot struct {
98+ Meta SessionMeta
99+ Messages []provider.Message
100+ UsageCheckpoints []SessionUsageCheckpoint
101+}
102+
84 // SessionsDir returns the per-cwd sessions directory under root.103 // SessionsDir returns the per-cwd sessions directory under root.
85 func SessionsDir(root, cwd string) string {104 func SessionsDir(root, cwd string) string {
86 sum := sha256.Sum256([]byte(cwd))105 sum := sha256.Sum256([]byte(cwd))
@@ -168,69 +187,33 @@ func SessionUsage(path string) (provider.Usage, error) {
168 }187 }
169 188
170 // SessionUsageDetail returns the latest cumulative usage and the189 // SessionUsageDetail returns the latest cumulative usage and the
171-// per-turn usage of the final completed turn. The per-turn row drives190+// per-turn usage of the final completed turn. It reads through the same
172-// the live "context used" gauge in the status bar (input + cache191+// strict snapshot path as OpenSession, so a truncated or malformed session
173-// approximates the prompt size the model just saw), letting the TUI192+// cannot silently reset the usage display.
174-// rehydrate the gauge on resume instead of starting at 0% until the
175-// next turn lands.
176 func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err error) {193 func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err error) {
177- f, ferr := os.Open(path)194+ snapshot, err := ReadSessionSnapshot(path)
178- if ferr != nil {195+ if err != nil {
179- return provider.Usage{}, provider.Usage{}, ferr196+ return provider.Usage{}, provider.Usage{}, err
180 }197 }
181- defer f.Close()198+ if len(snapshot.UsageCheckpoints) == 0 {
182-199+ return provider.Usage{}, provider.Usage{}, nil
183- // Some historical sessions logged the per-turn `usage` field as a copy
184- // of `cumulative` instead of the true delta. To recover an accurate
185- // last-turn snapshot (used by the status-bar context gauge on resume),
186- // we always derive lastTurn from the delta between the final two
187- // cumulative rows. For prompt-size purposes, cache_read/cache_write
188- // reflect the most recent prompt directly, so we take those from the
189- // final cumulative row as-is rather than as a delta.
190- var prevCum provider.Usage
191- var haveCum bool
192- if ierr := forEachJSONLLine(f, func(line []byte) error {
193- var head sessionLineHead
194- if err := json.Unmarshal(line, &head); err != nil || head.Type != "usage" {
195- return nil
196- }
197- var row struct {
198- Cumulative provider.Usage `json:"cumulative"`
199- }
200- if err := json.Unmarshal(line, &row); err != nil {
201- return nil
202- }
203- if haveCum {
204- prevCum = cumulative
205- }
206- cumulative = row.Cumulative
207- haveCum = true
208- return nil
209- }); ierr != nil {
210- return provider.Usage{}, provider.Usage{}, ierr
211 }200 }
212- if haveCum {201+ last := snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative
213- // input/output are monotonic totals -> per-turn = delta.202+ var prev provider.Usage
214- lastTurn.InputTokens = nonNegDelta(cumulative.InputTokens, prevCum.InputTokens)203+ if len(snapshot.UsageCheckpoints) > 1 {
215- lastTurn.OutputTokens = nonNegDelta(cumulative.OutputTokens, prevCum.OutputTokens)204+ prev = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-2].Cumulative
216- lastTurn.ReasoningTokens = nonNegDelta(cumulative.ReasoningTokens, prevCum.ReasoningTokens)
217- lastTurn.ReasoningTokensKnown = cumulative.ReasoningTokensKnown
218- // cache_read/write on the final row already represent the last prompt's
219- // cache hit/creation, not a running total of bytes; use directly.
220- lastTurn.CacheReadTokens = cumulative.CacheReadTokens - prevCum.CacheReadTokens
221- if lastTurn.CacheReadTokens < 0 {
222- lastTurn.CacheReadTokens = cumulative.CacheReadTokens
223- }
224- lastTurn.CacheWriteTokens = cumulative.CacheWriteTokens - prevCum.CacheWriteTokens
225- if lastTurn.CacheWriteTokens < 0 {
226- lastTurn.CacheWriteTokens = cumulative.CacheWriteTokens
227- }
228- lastTurn.CostUSD = cumulative.CostUSD - prevCum.CostUSD
229- if lastTurn.CostUSD < 0 {
230- lastTurn.CostUSD = 0
231- }
232 }205 }
233- return cumulative, lastTurn, nil206+ lastTurn.InputTokens = nonNegDelta(last.InputTokens, prev.InputTokens)
207+ lastTurn.OutputTokens = nonNegDelta(last.OutputTokens, prev.OutputTokens)
208+ lastTurn.ReasoningTokens = nonNegDelta(last.ReasoningTokens, prev.ReasoningTokens)
209+ lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown
210+ lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens)
211+ lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens)
212+ lastTurn.CostUSD = last.CostUSD - prev.CostUSD
213+ if lastTurn.CostUSD < 0 {
214+ lastTurn.CostUSD = 0
215+ }
216+ return last, lastTurn, nil
234 }217 }
235 218
236 func nonNegDelta(cur, prev int) int {219 func nonNegDelta(cur, prev int) int {
@@ -240,49 +223,329 @@ func nonNegDelta(cur, prev int) int {
240 return cur - prev223 return cur - prev
241 }224 }
242 225
243-// OpenSession opens an existing session for appending.226+// ReadSessionSnapshot reads a complete session file and reconstructs the
244-func OpenSession(path string) (*Session, []provider.Message, error) {227+// effective transcript. It is intentionally stricter than the lightweight
228+// listing helpers: a caller that is about to resume, render, or branch a
229+// session must not receive a plausible-looking partial transcript.
230+func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
245 f, err := os.Open(path)231 f, err := os.Open(path)
246 if err != nil {232 if err != nil {
247- return nil, nil, err233+ return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err)
248 }234 }
249 defer f.Close()235 defer f.Close()
250 236
251- var meta SessionMeta237+ var snapshot SessionSnapshot
252- var messages []provider.Message238+ var sawMeta bool
253- if err := forEachJSONLLine(f, func(line []byte) error {239+ generation := 0
240+ type rawCheckpoint struct {
241+ messageCount int
242+ cumulative provider.Usage
243+ generation int
244+ }
245+ var rawCheckpoints []rawCheckpoint
246+
247+ err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
254 var head sessionLineHead248 var head sessionLineHead
255 if err := json.Unmarshal(line, &head); err != nil {249 if err := json.Unmarshal(line, &head); err != nil {
256- return nil250+ return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
251+ }
252+ if head.Type == "" {
253+ return fmt.Errorf("line %d: missing row type", lineNo)
254+ }
255+ if !sawMeta && head.Type != "meta" {
256+ return fmt.Errorf("line %d: first row is not meta", lineNo)
257 }257 }
258+
258 switch head.Type {259 switch head.Type {
259 case "meta":260 case "meta":
260- var row struct {261+ var row sessionLine
261- Meta SessionMeta `json:"meta"`262+ if err := json.Unmarshal(line, &row); err != nil {
263+ return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
262 }264 }
263- if err := json.Unmarshal(line, &row); err == nil {265+ if row.Meta == nil || row.Meta.ID == "" {
264- meta = row.Meta266+ return fmt.Errorf("line %d: meta row has no session id", lineNo)
265 }267 }
268+ snapshot.Meta = *row.Meta
269+ sawMeta = true
270+
266 case "message":271 case "message":
267- if msg, err := hydrateMessage(line); err == nil && len(msg.Content) > 0 {272+ msg, err := hydrateMessage(line)
268- messages = append(messages, msg)273+ if err != nil {
274+ return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
269 }275 }
276+ snapshot.Messages = append(snapshot.Messages, msg)
277+
270 case "compaction":278 case "compaction":
271- if compacted, err := hydrateCompaction(line); err == nil {279+ compacted, err := hydrateCompaction(line)
272- messages = compacted280+ if err != nil {
281+ return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err)
282+ }
283+ snapshot.Messages = compacted
284+ generation++
285+
286+ case "usage":
287+ var row struct {
288+ Usage *provider.Usage `json:"usage"`
289+ Cumulative *provider.Usage `json:"cumulative"`
290+ }
291+ if err := json.Unmarshal(line, &row); err != nil {
292+ return fmt.Errorf("line %d: invalid usage row: %w", lineNo, err)
293+ }
294+ if row.Cumulative == nil {
295+ return fmt.Errorf("line %d: usage row has no cumulative usage", lineNo)
296+ }
297+ rawCheckpoints = append(rawCheckpoints, rawCheckpoint{
298+ messageCount: len(snapshot.Messages),
299+ cumulative: *row.Cumulative,
300+ generation: generation,
301+ })
302+
303+ case "rename":
304+ var row struct {
305+ Title *string `json:"title"`
306+ }
307+ if err := json.Unmarshal(line, &row); err != nil {
308+ return fmt.Errorf("line %d: invalid rename row: %w", lineNo, err)
309+ }
310+ if row.Title == nil {
311+ return fmt.Errorf("line %d: rename row has no title", lineNo)
273 }312 }
313+
314+ default:
315+ return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
274 }316 }
275 return nil317 return nil
276- }); err != nil {318+ })
319+ if err != nil {
320+ return SessionSnapshot{}, fmt.Errorf("session snapshot %q: %w", path, err)
321+ }
322+ if !sawMeta {
323+ return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path)
324+ }
325+
326+ // Repair only after compaction replacement. This makes synthetic tool
327+ // results and the removal of orphaned results part of the same indexed
328+ // stream that OpenSession returns and that BranchSession materializes.
329+ rawMessages := snapshot.Messages
330+ repaired := repairSessionMessages(rawMessages)
331+ snapshot.Messages = repaired.messages
332+ for _, checkpoint := range rawCheckpoints {
333+ count := checkpoint.messageCount
334+ if checkpoint.generation == generation {
335+ count = repaired.prefixCount(count)
336+ } else if count > len(rawMessages) {
337+ // A compaction replaces the effective stream. Preserve the
338+ // cumulative cost represented by an older checkpoint at the
339+ // end of the replacement rather than making every post-summary
340+ // branch appear to have zero usage.
341+ count = len(rawMessages)
342+ }
343+ snapshot.UsageCheckpoints = append(snapshot.UsageCheckpoints, SessionUsageCheckpoint{
344+ MessageCount: count,
345+ Cumulative: checkpoint.cumulative,
346+ })
347+ }
348+ return snapshot, nil
349+}
350+
351+// forEachStrictJSONLLine is the complete-read counterpart to
352+// forEachJSONLLine. It rejects malformed and blank rows and reports the
353+// line number so callers cannot accidentally proceed with a partial file.
354+func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error {
355+ br := bufio.NewReader(r)
356+ lineNo := 0
357+ for {
358+ line, err := br.ReadBytes('\n')
359+ if len(line) > 0 {
360+ lineNo++
361+ line = bytes.TrimRight(line, "\r\n")
362+ if len(bytes.TrimSpace(line)) == 0 {
363+ return fmt.Errorf("line %d: blank row", lineNo)
364+ }
365+ if ferr := fn(line, lineNo); ferr != nil {
366+ return ferr
367+ }
368+ }
369+ if err == io.EOF {
370+ return nil
371+ }
372+ if err != nil {
373+ return err
374+ }
375+ }
376+}
377+
378+// repairedSessionMessage keeps the raw message index that produced an
379+// effective message. Synthetic tool results use the assistant's raw index;
380+// this lets usage checkpoints continue to refer to the same effective
381+// message prefix after repair removes or inserts rows.
382+type repairedSessionMessage struct {
383+ message provider.Message
384+ rawIndex int
385+}
386+
387+type repairedSessionMessages struct {
388+ messages []provider.Message
389+ counts []int
390+}
391+
392+func (r repairedSessionMessages) prefixCount(rawCount int) int {
393+ if rawCount < 0 {
394+ return 0
395+ }
396+ if rawCount >= len(r.counts) {
397+ return r.counts[len(r.counts)-1]
398+ }
399+ return r.counts[rawCount]
400+}
401+
402+// repairSessionMessages repairs tool-call pairs and removes orphaned result
403+// blocks while retaining a mapping from raw effective prefixes to repaired
404+// prefixes. A tool result is valid only in the immediately following tool
405+// message for the assistant tool calls; keeping an unrelated result there
406+// would still make a resumed or branched transcript provider-invalid.
407+func repairSessionMessages(msgs []provider.Message) repairedSessionMessages {
408+ if len(msgs) == 0 {
409+ return repairedSessionMessages{counts: []int{0}}
410+ }
411+
412+ repaired := make([]repairedSessionMessage, 0, len(msgs)+2)
413+ for i := 0; i < len(msgs); i++ {
414+ m := msgs[i]
415+ if m.Role == provider.RoleAssistant && messageHasToolCalls(m) {
416+ ids := make(map[string]bool)
417+ for _, c := range m.Content {
418+ if tc, ok := c.(provider.ToolCallBlock); ok {
419+ ids[tc.ID] = true
420+ }
421+ }
422+ repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
423+
424+ if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
425+ tool := msgs[i+1]
426+ seen := make(map[string]bool)
427+ filtered := make([]provider.Content, 0, len(tool.Content)+len(ids))
428+ for _, c := range tool.Content {
429+ tr, ok := c.(provider.ToolResultBlock)
430+ if !ok {
431+ filtered = append(filtered, c)
432+ continue
433+ }
434+ if !ids[tr.CallID] || seen[tr.CallID] {
435+ continue
436+ }
437+ seen[tr.CallID] = true
438+ filtered = append(filtered, tr)
439+ }
440+ for _, c := range m.Content {
441+ if tc, ok := c.(provider.ToolCallBlock); ok && !seen[tc.ID] {
442+ filtered = append(filtered, provider.ToolResultBlock{
443+ CallID: tc.ID,
444+ Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}},
445+ IsError: true,
446+ })
447+ }
448+ }
449+ if len(filtered) > 0 {
450+ tool.Content = filtered
451+ repaired = append(repaired, repairedSessionMessage{message: tool, rawIndex: i + 1})
452+ }
453+ i++
454+ continue
455+ }
456+
457+ stubs := make([]provider.Content, 0, len(ids))
458+ for _, c := range m.Content {
459+ if tc, ok := c.(provider.ToolCallBlock); ok {
460+ stubs = append(stubs, provider.ToolResultBlock{
461+ CallID: tc.ID,
462+ Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}},
463+ IsError: true,
464+ })
465+ }
466+ }
467+ repaired = append(repaired, repairedSessionMessage{message: provider.Message{
468+ Role: provider.RoleTool,
469+ Content: stubs,
470+ Time: m.Time,
471+ }, rawIndex: i})
472+ continue
473+ }
474+
475+ if m.Role == provider.RoleTool {
476+ // Deferred-tool activation rows intentionally carry a result
477+ // without a preceding assistant call. Preserve those rows and
478+ // their AddedToolNames marker; they are consumed as local
479+ // transcript state rather than sent as a provider tool pair.
480+ if len(m.AddedToolNames) > 0 {
481+ repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
482+ continue
483+ }
484+ // This tool row was not immediately paired with an assistant
485+ // tool-call row. Drop its result blocks; a bare tool row is an
486+ // orphan and is not safe to send to a provider.
487+ filtered := make([]provider.Content, 0, len(m.Content))
488+ for _, c := range m.Content {
489+ if _, ok := c.(provider.ToolResultBlock); !ok {
490+ filtered = append(filtered, c)
491+ }
492+ }
493+ if len(filtered) > 0 {
494+ m.Content = filtered
495+ repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
496+ }
497+ continue
498+ }
499+ repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
500+ }
501+
502+ out := repairedSessionMessages{
503+ messages: make([]provider.Message, 0, len(repaired)),
504+ counts: make([]int, len(msgs)+1),
505+ }
506+ for _, item := range repaired {
507+ out.messages = append(out.messages, item.message)
508+ }
509+ // rawIndex values are produced in source order. Accumulate the number of
510+ // repaired messages before each raw prefix in one forward pass instead of
511+ // revisiting every remaining prefix for every repaired message.
512+ repairedIndex := 0
513+ for rawCount := range out.counts {
514+ for repairedIndex < len(repaired) && repaired[repairedIndex].rawIndex < rawCount {
515+ repairedIndex++
516+ }
517+ out.counts[rawCount] = repairedIndex
518+ }
519+ return out
520+}
521+
522+func messageHasToolCalls(msg provider.Message) bool {
523+ for _, content := range msg.Content {
524+ if _, ok := content.(provider.ToolCallBlock); ok {
525+ return true
526+ }
527+ }
528+ return false
529+}
530+
531+// OpenSession opens an existing session for appending.
532+func OpenSession(path string) (*Session, []provider.Message, error) {
533+ snapshot, err := ReadSessionSnapshot(path)
534+ if err != nil {
277 return nil, nil, err535 return nil, nil, err
278 }536 }
279- messages = repairToolUseResultPairs(messages)
280 out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)537 out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
281 if err != nil {538 if err != nil {
282 return nil, nil, err539 return nil, nil, err
283 }540 }
284- s := &Session{ID: meta.ID, Path: path, Meta: meta, writer: out, buf: bufio.NewWriter(out)}541+ s := &Session{
285- return s, messages, nil542+ ID: snapshot.Meta.ID,
543+ Path: path,
544+ Meta: snapshot.Meta,
545+ writer: out,
546+ buf: bufio.NewWriter(out),
547+ }
548+ return s, snapshot.Messages, nil
286 }549 }
287 550
288 // repairToolUseResultPairs walks a restored transcript and551 // repairToolUseResultPairs walks a restored transcript and
@@ -311,68 +574,7 @@ func OpenSession(path string) (*Session, []provider.Message, error) {
311 //574 //
312 // Runs once per OpenSession call. No cost on the hot path.575 // Runs once per OpenSession call. No cost on the hot path.
313 func repairToolUseResultPairs(msgs []provider.Message) []provider.Message {576 func repairToolUseResultPairs(msgs []provider.Message) []provider.Message {
314- if len(msgs) == 0 {577+ return repairSessionMessages(msgs).messages
315- return msgs
316- }
317- out := make([]provider.Message, 0, len(msgs)+2)
318- for i, m := range msgs {
319- out = append(out, m)
320- if m.Role != provider.RoleAssistant {
321- continue
322- }
323- // Collect tool_use ids in this assistant message.
324- var ids []string
325- for _, c := range m.Content {
326- if tc, ok := c.(provider.ToolCallBlock); ok {
327- ids = append(ids, tc.ID)
328- }
329- }
330- if len(ids) == 0 {
331- continue
332- }
333- // Look at the next message (if any) and collect tool_result
334- // CallIDs it covers.
335- have := map[string]bool{}
336- if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
337- for _, c := range msgs[i+1].Content {
338- if tr, ok := c.(provider.ToolResultBlock); ok {
339- have[tr.CallID] = true
340- }
341- }
342- }
343- // Build stubs for any missing id.
344- var stubs []provider.Content
345- for _, id := range ids {
346- if have[id] {
347- continue
348- }
349- stubs = append(stubs, provider.ToolResultBlock{
350- CallID: id,
351- Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}},
352- IsError: true,
353- })
354- }
355- if len(stubs) == 0 {
356- continue
357- }
358- // Merge into the next tool-role message if present,
359- // otherwise insert a synthetic one right after the
360- // assistant message. Merging keeps the tool-role row
361- // count stable; inserting handles the common case where
362- // no tool message was persisted at all.
363- if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
364- msgs[i+1].Content = append(msgs[i+1].Content, stubs...)
365- // We already appended m to out; the modified next
366- // message will be appended on the following iteration.
367- continue
368- }
369- out = append(out, provider.Message{
370- Role: provider.RoleTool,
371- Content: stubs,
372- Time: m.Time,
373- })
374- }
375- return out
376 }578 }
377 579
378 // LatestSession returns the most recent session file for cwd, or "".580 // LatestSession returns the most recent session file for cwd, or "".
@@ -425,8 +627,8 @@ func DeleteSession(path string) error {
425 }627 }
426 628
427 // DescribeSessions returns lightweight summaries for every session in629 // DescribeSessions returns lightweight summaries for every session in
428-// cwd, newest first. Parses only the first few lines and the last usage630+// cwd, newest first. It uses the effective snapshot so message counts,
429-// line so it's cheap to run on every dialog open.631+// metadata, and usage agree with resume and branching behavior.
430 func DescribeSessions(root, cwd string) []SessionSummary {632 func DescribeSessions(root, cwd string) []SessionSummary {
431 paths := ListSessions(root, cwd)633 paths := ListSessions(root, cwd)
432 summaries := make([]SessionSummary, 0, len(paths))634 summaries := make([]SessionSummary, 0, len(paths))
@@ -470,73 +672,60 @@ func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[
470 672
471 func describeSession(path string) SessionSummary {673 func describeSession(path string) SessionSummary {
472 s := SessionSummary{Path: path}674 s := SessionSummary{Path: path}
473- f, err := os.Open(path)675+ snapshot, err := ReadSessionSnapshot(path)
474 if err != nil {676 if err != nil {
475 return s677 return s
476 }678 }
477- defer f.Close()679+ s.Started = snapshot.Meta.Started
478- isBranch := false680+ s.Model = snapshot.Meta.Model
479- forkPoint := 0681+ s.Provider = snapshot.Meta.Provider
682+ s.Title = snapshot.Meta.Title
683+ s.MessageCount = len(snapshot.Messages)
684+ s.FirstUserText = firstUserTextFromMessages(snapshot.Messages)
685+ lastUser := lastUserText(snapshot.Messages)
686+ isBranch := snapshot.Meta.Parent != ""
687+ forkPoint := snapshot.Meta.ForkPoint
688+ branchPrompt := firstUserTextFromMessagesAfter(snapshot.Messages, forkPoint)
689+ if len(snapshot.UsageCheckpoints) > 0 {
690+ s.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
691+ }
692+
693+ // Rename rows are append-only and are intentionally kept out of the
694+ // effective transcript. Scan only their placement so generated branch
695+ // titles can still be replaced by the first post-fork prompt while an
696+ // explicit rename after divergence keeps priority.
480 messageIdx := 0697 messageIdx := 0
481- branchPrompt := ""
482- lastUser := ""
483 renameMessageIdx := -1698 renameMessageIdx := -1
484- _ = forEachJSONLLine(f, func(line []byte) error {699+ _ = func() error {
485- var head sessionLineHead700+ f, err := os.Open(path)
486- if err := json.Unmarshal(line, &head); err != nil {701+ if err != nil {
487- return nil702+ return err
488 }703 }
489- switch head.Type {704+ defer f.Close()
490- case "meta":705+ return forEachJSONLLine(f, func(line []byte) error {
491- var row struct {706+ var head sessionLineHead
492- Meta SessionMeta `json:"meta"`707+ if err := json.Unmarshal(line, &head); err != nil {
493- }708+ return nil
494- if err := json.Unmarshal(line, &row); err == nil {
495- s.Started = row.Meta.Started
496- s.Model = row.Meta.Model
497- s.Provider = row.Meta.Provider
498- s.Title = row.Meta.Title
499- isBranch = row.Meta.Parent != ""
500- forkPoint = row.Meta.ForkPoint
501 }709 }
502- case "message":710+ switch head.Type {
503- if text := firstUserText(line); text != "" {711+ case "message":
504- lastUser = text712+ messageIdx++
505- if s.FirstUserText == "" {713+ case "compaction":
506- s.FirstUserText = text714+ if compacted, err := hydrateCompaction(line); err == nil {
715+ messageIdx = len(compacted)
507 }716 }
508- if isBranch && messageIdx >= forkPoint && branchPrompt == "" {717+ case "rename":
509- branchPrompt = text718+ var row struct {
719+ Title string `json:"title"`
720+ }
721+ if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
722+ s.Title = row.Title
723+ renameMessageIdx = messageIdx
510 }724 }
511 }725 }
512- messageIdx++726+ return nil
513- s.MessageCount++727+ })
514- case "compaction":728+ }()
515- if compacted, err := hydrateCompaction(line); err == nil {
516- s.MessageCount = len(compacted)
517- messageIdx = len(compacted)
518- s.FirstUserText = firstUserTextFromMessages(compacted)
519- lastUser = lastUserText(compacted)
520- branchPrompt = firstUserTextFromMessagesAfter(compacted, forkPoint)
521- }
522- case "rename":
523- var row struct {
524- Title string `json:"title"`
525- }
526- if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
527- s.Title = row.Title
528- renameMessageIdx = messageIdx
529- }
530- case "usage":
531- var row struct {
532- Cumulative provider.Usage `json:"cumulative"`
533- }
534- if err := json.Unmarshal(line, &row); err == nil {
535- s.TotalCost = row.Cumulative.CostUSD
536- }
537- }
538- return nil
539- })
540 if isBranch {729 if isBranch {
541 // A rename written after the branch has diverged is user intent730 // A rename written after the branch has diverged is user intent
542 // and must win. Older generated branch titles were written at731 // and must win. Older generated branch titles were written at
@@ -552,29 +741,6 @@ func describeSession(path string) SessionSummary {
552 return s741 return s
553 }742 }
554 743
555-func firstUserText(line []byte) string {
556- var row struct {
557- Message struct {
558- Role string `json:"role"`
559- Content []struct {
560- Text string `json:"text"`
561- } `json:"content"`
562- } `json:"message"`
563- }
564- if err := json.Unmarshal(line, &row); err != nil {
565- return ""
566- }
567- if row.Message.Role != "user" {
568- return ""
569- }
570- for _, c := range row.Message.Content {
571- if c.Text != "" {
572- return c.Text
573- }
574- }
575- return ""
576-}
577-
578 func firstUserTextFromMessages(msgs []provider.Message) string {744 func firstUserTextFromMessages(msgs []provider.Message) string {
579 return firstUserTextFromMessagesAfter(msgs, 0)745 return firstUserTextFromMessagesAfter(msgs, 0)
580 }746 }
@@ -697,6 +863,9 @@ func (s *Session) AppendMessage(m provider.Message) error {
697 if s == nil {863 if s == nil {
698 return nil864 return nil
699 }865 }
866+ if len(m.Content) == 0 {
867+ return errors.New("message has no content")
868+ }
700 if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil {869 if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil {
701 return err870 return err
702 }871 }
@@ -712,10 +881,14 @@ func (s *Session) AppendCompaction(messages []provider.Message) error {
712 if s == nil {881 if s == nil {
713 return nil882 return nil
714 }883 }
715- if err := s.writeLine(sessionLine{Type: "compaction", Messages: messages}); err != nil {884+ compactionMessages := messages
885+ if err := s.writeLine(sessionLine{Type: "compaction", Messages: &compactionMessages}); err != nil {
716 return err886 return err
717 }887 }
718- s.messagesAppended = len(messages)888+ // The compaction row itself is meaningful even when it replaces the
889+ // transcript with an empty or nil slice. Count the append operation so a
890+ // fresh session containing an empty checkpoint is not deleted on Close.
891+ s.messagesAppended++
719 return nil892 return nil
720 }893 }
721 894
@@ -726,9 +899,15 @@ func (s *Session) UpdateModel(providerName, model string) error {
726 if s == nil {899 if s == nil {
727 return nil900 return nil
728 }901 }
902+ oldProvider, oldModel := s.Meta.Provider, s.Meta.Model
729 s.Meta.Provider = providerName903 s.Meta.Provider = providerName
730 s.Meta.Model = model904 s.Meta.Model = model
731- return s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta})905+ if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
906+ s.Meta.Provider = oldProvider
907+ s.Meta.Model = oldModel
908+ return err
909+ }
910+ return nil
732 }911 }
733 912
734 // AppendUsage writes a usage row to the session.913 // AppendUsage writes a usage row to the session.
@@ -784,17 +963,27 @@ func (s *Session) writeLine(row sessionLine) error {
784 963
785 func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {964 func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {
786 var row struct {965 var row struct {
787- Messages []json.RawMessage `json:"messages"`966+ Messages json.RawMessage `json:"messages"`
788 }967 }
789 if err := json.Unmarshal(lineBytes, &row); err != nil {968 if err := json.Unmarshal(lineBytes, &row); err != nil {
790 return nil, err969 return nil, err
791 }970 }
792- messages := make([]provider.Message, 0, len(row.Messages))971+ if len(row.Messages) == 0 || bytes.Equal(bytes.TrimSpace(row.Messages), []byte("null")) {
793- for _, raw := range row.Messages {972+ // Older writers omitted the field for an empty compaction and some
973+ // wrote it as null. Both represent a valid empty replacement.
974+ return []provider.Message{}, nil
975+ }
976+ var rawMessages []json.RawMessage
977+ if err := json.Unmarshal(row.Messages, &rawMessages); err != nil {
978+ return nil, fmt.Errorf("invalid messages: %w", err)
979+ }
980+ messages := make([]provider.Message, 0, len(rawMessages))
981+ for idx, raw := range rawMessages {
794 msg, err := hydrateMessageObject(raw)982 msg, err := hydrateMessageObject(raw)
795- if err == nil && len(msg.Content) > 0 {983+ if err != nil {
796- messages = append(messages, msg)984+ return nil, fmt.Errorf("message %d: %w", idx, err)
797 }985 }
986+ messages = append(messages, msg)
798 }987 }
799 return messages, nil988 return messages, nil
800 }989 }
@@ -806,6 +995,9 @@ func hydrateMessage(lineBytes []byte) (provider.Message, error) {
806 if err := json.Unmarshal(lineBytes, &row); err != nil {995 if err := json.Unmarshal(lineBytes, &row); err != nil {
807 return provider.Message{}, err996 return provider.Message{}, err
808 }997 }
998+ if len(row.Message) == 0 || bytes.Equal(bytes.TrimSpace(row.Message), []byte("null")) {
999+ return provider.Message{}, fmt.Errorf("message row has no message")
1000+ }
809 return hydrateMessageObject(row.Message)1001 return hydrateMessageObject(row.Message)
810 }1002 }
811 1003
@@ -820,8 +1012,17 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
820 if err := json.Unmarshal(rawMessage, &row); err != nil {1012 if err := json.Unmarshal(rawMessage, &row); err != nil {
821 return provider.Message{}, err1013 return provider.Message{}, err
822 }1014 }
1015+ if row.Role != provider.RoleUser && row.Role != provider.RoleAssistant && row.Role != provider.RoleTool {
1016+ return provider.Message{}, fmt.Errorf("message has invalid role %q", row.Role)
1017+ }
1018+ if len(row.Content) == 0 {
1019+ return provider.Message{}, fmt.Errorf("message has no content")
1020+ }
823 msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta, AddedToolNames: row.AddedToolNames}1021 msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta, AddedToolNames: row.AddedToolNames}
824- for _, raw := range row.Content {1022+ for idx, raw := range row.Content {
1023+ if len(bytes.TrimSpace(raw)) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
1024+ return provider.Message{}, fmt.Errorf("content block %d is empty", idx)
1025+ }
825 var head struct {1026 var head struct {
826 Text string `json:"text"`1027 Text string `json:"text"`
827 MimeType string `json:"mime_type"`1028 MimeType string `json:"mime_type"`
@@ -836,7 +1037,14 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
836 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError1037 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError
837 }1038 }
838 if err := json.Unmarshal(raw, &head); err != nil {1039 if err := json.Unmarshal(raw, &head); err != nil {
839- continue1040+ return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
1041+ }
1042+ var fields map[string]json.RawMessage
1043+ if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
1044+ if err == nil {
1045+ err = fmt.Errorf("content block is not an object")
1046+ }
1047+ return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
840 }1048 }
841 // Discriminate by presence of fields.1049 // Discriminate by presence of fields.
842 switch {1050 switch {
@@ -846,7 +1054,10 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
846 Summary: head.Summary,1054 Summary: head.Summary,
847 Encrypted: head.Encrypted,1055 Encrypted: head.Encrypted,
848 })1056 })
849- case head.Name != "" && head.ID != "":1057+ case head.Name != "" || head.ID != "" || fields["arguments"] != nil:
1058+ if head.Name == "" || head.ID == "" {
1059+ return provider.Message{}, fmt.Errorf("content block %d: incomplete tool call", idx)
1060+ }
850 var tc struct {1061 var tc struct {
851 ID string `json:"id"`1062 ID string `json:"id"`
852 Name string `json:"name"`1063 Name string `json:"name"`
@@ -866,21 +1077,40 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
866 Content []json.RawMessage `json:"content"`1077 Content []json.RawMessage `json:"content"`
867 IsError bool `json:"is_error"`1078 IsError bool `json:"is_error"`
868 }1079 }
869- _ = json.Unmarshal(raw, &tr)1080+ if err := json.Unmarshal(raw, &tr); err != nil || tr.Content == nil {
1081+ if err == nil {
1082+ err = fmt.Errorf("tool result has no content")
1083+ }
1084+ return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
1085+ }
870 block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError}1086 block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError}
871- for _, c := range tr.Content {1087+ for innerIdx, c := range tr.Content {
872 var inner struct {1088 var inner struct {
873 Text string `json:"text"`1089 Text string `json:"text"`
874 MimeType string `json:"mime_type"`1090 MimeType string `json:"mime_type"`
875 Data []byte `json:"data"`1091 Data []byte `json:"data"`
876 }1092 }
877- _ = json.Unmarshal(c, &inner)1093+ if err := json.Unmarshal(c, &inner); err != nil {
1094+ return provider.Message{}, fmt.Errorf("content block %d result %d: %w", idx, innerIdx, err)
1095+ }
1096+ var innerFields map[string]json.RawMessage
1097+ if err := json.Unmarshal(c, &innerFields); err != nil || innerFields == nil {
1098+ if err == nil {
1099+ err = fmt.Errorf("result content is not an object")
1100+ }
1101+ return provider.Message{}, fmt.Errorf("content block %d result %d: %w", idx, innerIdx, err)
1102+ }
878 if inner.MimeType != "" {1103 if inner.MimeType != "" {
879 block.Content = append(block.Content, provider.ImageBlock{MimeType: inner.MimeType, Data: inner.Data})1104 block.Content = append(block.Content, provider.ImageBlock{MimeType: inner.MimeType, Data: inner.Data})
880- } else {1105+ } else if _, ok := innerFields["text"]; ok {
881 block.Content = append(block.Content, provider.TextBlock{Text: inner.Text})1106 block.Content = append(block.Content, provider.TextBlock{Text: inner.Text})
1107+ } else {
1108+ return provider.Message{}, fmt.Errorf("content block %d result %d: unknown content", idx, innerIdx)
882 }1109 }
883 }1110 }
1111+ if len(block.Content) == 0 {
1112+ return provider.Message{}, fmt.Errorf("content block %d tool result has no content", idx)
1113+ }
884 msg.Content = append(msg.Content, block)1114 msg.Content = append(msg.Content, block)
885 case head.MimeType != "":1115 case head.MimeType != "":
886 msg.Content = append(msg.Content, provider.ImageBlock{1116 msg.Content = append(msg.Content, provider.ImageBlock{
@@ -889,6 +1119,9 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
889 ThoughtSignature: head.ThoughtSignature,1119 ThoughtSignature: head.ThoughtSignature,
890 })1120 })
891 default:1121 default:
1122+ if _, ok := fields["text"]; !ok {
1123+ return provider.Message{}, fmt.Errorf("content block %d: unknown content", idx)
1124+ }
892 msg.Content = append(msg.Content, provider.TextBlock{1125 msg.Content = append(msg.Content, provider.TextBlock{
893 Text: head.Text,1126 Text: head.Text,
894 ThoughtSignature: head.ThoughtSignature,1127 ThoughtSignature: head.ThoughtSignature,
M packages/core/session_portable.go +339 -117
@@ -40,6 +40,13 @@ func ExportSession(srcPath, dstPath string) (string, error) {
40 return "", errors.New("export: destination path is empty")40 return "", errors.New("export: destination path is empty")
41 }41 }
42 42
43+ // Read the effective snapshot so a later model/provider update is not
44+ // replaced by the stale metadata in the first row.
45+ snapshot, err := ReadSessionSnapshot(srcPath)
46+ if err != nil {
47+ return "", fmt.Errorf("export: read session snapshot: %w", err)
48+ }
49+
43 // Read the source meta up-front so we can name the output sensibly50 // Read the source meta up-front so we can name the output sensibly
44 // when dstPath is a directory, and so we can validate it's a real51 // when dstPath is a directory, and so we can validate it's a real
45 // session before starting to write.52 // session before starting to write.
@@ -103,7 +110,7 @@ func ExportSession(srcPath, dstPath string) (string, error) {
103 // Rewrite the meta row: strip the cwd (the importing user has110 // Rewrite the meta row: strip the cwd (the importing user has
104 // their own) and keep everything else identical. ID stays so the111 // their own) and keep everything else identical. ID stays so the
105 // export is traceable; the importer will rotate to a fresh ID.112 // export is traceable; the importer will rotate to a fresh ID.
106- exportMeta := *head.Meta113+ exportMeta := snapshot.Meta
107 exportMeta.CWD = ""114 exportMeta.CWD = ""
108 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &exportMeta})115 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &exportMeta})
109 if err != nil {116 if err != nil {
@@ -167,6 +174,14 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
167 }174 }
168 defer src.Close()175 defer src.Close()
169 176
177+ // Read the effective metadata before committing to a destination. Meta
178+ // rows are append-only, so the first row may describe an older provider or
179+ // model after a runtime switch.
180+ snapshot, err := ReadSessionSnapshot(srcPath)
181+ if err != nil {
182+ return "", fmt.Errorf("import: read session snapshot: %w", err)
183+ }
184+
170 // Validate the file header before committing to a destination.185 // Validate the file header before committing to a destination.
171 sc := bufio.NewScanner(src)186 sc := bufio.NewScanner(src)
172 sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)187 sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
@@ -201,8 +216,8 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
201 importMeta := SessionMeta{216 importMeta := SessionMeta{
202 ID: newID,217 ID: newID,
203 CWD: cwd,218 CWD: cwd,
204- Model: head.Meta.Model,219+ Model: snapshot.Meta.Model,
205- Provider: head.Meta.Provider,220+ Provider: snapshot.Meta.Provider,
206 Started: time.Now().UTC(),221 Started: time.Now().UTC(),
207 Version: version,222 Version: version,
208 }223 }
@@ -245,14 +260,16 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
245 // parent's messages 0..upToMessageIdx-1 (i.e. the first N user+260 // parent's messages 0..upToMessageIdx-1 (i.e. the first N user+
246 // assistant+tool rows). The new meta records Parent=<parent id> and261 // assistant+tool rows). The new meta records Parent=<parent id> and
247 // ForkPoint=N so /session tree can rebuild the branch topology262 // ForkPoint=N so /session tree can rebuild the branch topology
248-// later. All non-message rows (usage) are preserved up to the cut263+// later. The effective repaired prefix is materialized, and all eligible
249-// point so the running cost tracker stays accurate.264+// usage checkpoints at or before the cut are preserved so the running cost
265+// tracker can reconstruct the final-turn delta. A boundary that ends on a
266+// tool call is extended through its paired tool result.
250 //267 //
251 // upToMessageIdx is a count over the flat message stream as268 // upToMessageIdx is a count over the flat message stream as
252 // returned by OpenSession. To "branch at user turn 3" the caller269 // returned by OpenSession. To "branch at user turn 3" the caller
253 // passes the index of that user message in msgs + 1 (so the270 // passes the index of that user message in msgs + 1 (so the
254 // message itself is included). The caller figures that out; this271 // message itself is included). The caller figures that out; this
255-// helper just copies the first N message rows.272+// helper rejects a count outside the current effective snapshot.
256 //273 //
257 // Returns the path of the new session file, ready for OpenSession.274 // Returns the path of the new session file, ready for OpenSession.
258 func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {275 func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
@@ -273,29 +290,23 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
273 return "", errors.New("branch: upToMessageIdx must be >= 0")290 return "", errors.New("branch: upToMessageIdx must be >= 0")
274 }291 }
275 292
276- src, err := os.Open(parentPath)293+ snapshot, err := ReadSessionSnapshot(parentPath)
277 if err != nil {294 if err != nil {
278- return "", fmt.Errorf("branch: open parent: %w", err)295+ return "", fmt.Errorf("branch: read parent snapshot: %w", err)
279 }296 }
280- defer src.Close()297+ if upToMessageIdx > len(snapshot.Messages) {
281-298+ return "", fmt.Errorf("branch: upToMessageIdx %d exceeds effective message count %d", upToMessageIdx, len(snapshot.Messages))
282- // Read the parent meta so we can copy model/provider and record
283- // the parent id on the child.
284- sc := bufio.NewScanner(src)
285- sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
286- if !sc.Scan() {
287- return "", errors.New("branch: parent session is empty")
288 }299 }
289- var head sessionLine300+
290- if err := json.Unmarshal(sc.Bytes(), &head); err != nil {301+ // A tool-call row is not a safe provider boundary by itself. Accept the
291- return "", fmt.Errorf("branch: parse parent meta: %w", err)302+ // convenient row index used by existing callers, but extend it through the
292- }303+ // immediately paired tool result when necessary. Orphan calls already have
293- if head.Type != "meta" || head.Meta == nil {304+ // a synthetic result in the shared snapshot.
294- return "", errors.New("branch: parent first line is not a meta row")305+ limit := upToMessageIdx
306+ if limit > 0 && limit < len(snapshot.Messages) && messageHasToolCalls(snapshot.Messages[limit-1]) && snapshot.Messages[limit].Role == provider.RoleTool {
307+ limit++
295 }308 }
296- parentMeta := *head.Meta
297 309
298- // Build the destination file.
299 dir := SessionsDir(root, cwd)310 dir := SessionsDir(root, cwd)
300 if err := os.MkdirAll(dir, 0o755); err != nil {311 if err := os.MkdirAll(dir, 0o755); err != nil {
301 return "", err312 return "", err
@@ -303,23 +314,38 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
303 newID := uuid.NewString()314 newID := uuid.NewString()
304 name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])315 name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
305 outPath := filepath.Join(dir, name)316 outPath := filepath.Join(dir, name)
306- dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)317+ // Build in a same-directory temporary file. Rename is atomic only when
318+ // the temporary and final paths share a directory; closing the file first
319+ // also ensures no buffered writer or descriptor can expose a partial branch.
320+ dst, err := os.CreateTemp(dir, ".zot-branch-*.tmp")
307 if err != nil {321 if err != nil {
308- return "", fmt.Errorf("branch: create dst: %w", err)322+ return "", fmt.Errorf("branch: create temp: %w", err)
309- }323+ }
310- defer dst.Close()324+ tmpPath := dst.Name()
325+ if err := dst.Chmod(0o644); err != nil {
326+ _ = dst.Close()
327+ _ = os.Remove(tmpPath)
328+ return "", fmt.Errorf("branch: chmod temp: %w", err)
329+ }
330+ committed := false
331+ defer func() {
332+ _ = dst.Close()
333+ if !committed {
334+ _ = os.Remove(tmpPath)
335+ _ = os.Remove(outPath)
336+ }
337+ }()
311 bw := bufio.NewWriter(dst)338 bw := bufio.NewWriter(dst)
312 339
313- // Write the branch meta.
314 branchMeta := SessionMeta{340 branchMeta := SessionMeta{
315 ID: newID,341 ID: newID,
316 CWD: cwd,342 CWD: cwd,
317- Model: parentMeta.Model,343+ Model: snapshot.Meta.Model,
318- Provider: parentMeta.Provider,344+ Provider: snapshot.Meta.Provider,
319 Started: time.Now().UTC(),345 Started: time.Now().UTC(),
320 Version: version,346 Version: version,
321- Parent: parentMeta.ID,347+ Parent: snapshot.Meta.ID,
322- ForkPoint: upToMessageIdx,348+ ForkPoint: limit,
323 HideFromSessions: hideFromSessions,349 HideFromSessions: hideFromSessions,
324 }350 }
325 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})351 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
@@ -333,79 +359,52 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
333 return "", err359 return "", err
334 }360 }
335 361
336- // Reconstruct the effective transcript the same way OpenSession362+ // Always materialize the shared effective prefix. Replaying raw rows here
337- // does: message rows append, and compaction rows replace everything363+ // would lose compaction replacement or synthetic tool results and make the
338- // before them. The fork index is defined over that effective stream,364+ // child differ from the transcript that was displayed and selected.
339- // not over the raw audit rows kept on disk before a compaction.365+ for idx := 0; idx < limit; idx++ {
340- if _, err := src.Seek(0, io.SeekStart); err != nil {366+ msg := snapshot.Messages[idx]
341- return "", fmt.Errorf("branch: rewind parent: %w", err)367+ line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})
342- }368+ if err != nil {
343- var effective []provider.Message369+ return "", fmt.Errorf("branch: marshal message %d: %w", idx, err)
344- var nonCompactedRows [][]byte
345- effectiveCount := 0
346- sawCompaction := false
347- if err := forEachJSONLLine(src, func(line []byte) error {
348- var h sessionLineHead
349- if err := json.Unmarshal(line, &h); err != nil {
350- return nil
351 }370 }
352- switch h.Type {371+ if _, err := bw.Write(line); err != nil {
353- case "message":372+ return "", err
354- if msg, err := hydrateMessage(line); err == nil && len(msg.Content) > 0 {373+ }
355- effective = append(effective, msg)374+ if err := bw.WriteByte('\n'); err != nil {
356- if !sawCompaction && effectiveCount < upToMessageIdx {375+ return "", err
357- raw := append([]byte(nil), line...)
358- nonCompactedRows = append(nonCompactedRows, raw)
359- }
360- effectiveCount++
361- }
362- case "compaction":
363- if compacted, err := hydrateCompaction(line); err == nil {
364- effective = compacted
365- effectiveCount = len(effective)
366- sawCompaction = true
367- }
368- case "usage":
369- if !sawCompaction && effectiveCount < upToMessageIdx {
370- raw := append([]byte(nil), line...)
371- nonCompactedRows = append(nonCompactedRows, raw)
372- }
373 }376 }
374- return nil
375- }); err != nil && err != io.EOF {
376- return "", fmt.Errorf("branch: read parent: %w", err)
377 }377 }
378- limit := upToMessageIdx378+
379- if limit > len(effective) {379+ // Preserve every cumulative checkpoint that belongs to this prefix. The
380- limit = len(effective)380+ // final two rows are needed to reconstruct LastTurnUsage as a delta; a
381- }381+ // branch containing only the latest row would incorrectly report the
382- branchTitle := lastUserText(effective[:limit])382+ // whole cumulative total as its final turn.
383- if sawCompaction {383+ for _, checkpoint := range snapshot.UsageCheckpoints {
384- for i := 0; i < limit; i++ {384+ if checkpoint.MessageCount > limit {
385- msg := effective[i]385+ continue
386- line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})
387- if err != nil {
388- return "", fmt.Errorf("branch: marshal message: %w", err)
389- }
390- if _, err := bw.Write(line); err != nil {
391- return "", err
392- }
393- if err := bw.WriteByte('\n'); err != nil {
394- return "", err
395- }
396 }386 }
397- } else {387+ usageLine, err := json.Marshal(sessionLine{
398- for _, row := range nonCompactedRows {388+ Type: "usage",
399- if _, err := bw.Write(row); err != nil {389+ Usage: &checkpoint.Cumulative,
400- return "", err390+ Cumulative: &checkpoint.Cumulative,
401- }391+ })
402- if err := bw.WriteByte('\n'); err != nil {392+ if err != nil {
403- return "", err393+ return "", fmt.Errorf("branch: marshal usage: %w", err)
404- }394+ }
395+ if _, err := bw.Write(usageLine); err != nil {
396+ return "", err
397+ }
398+ if err := bw.WriteByte('\n'); err != nil {
399+ return "", err
405 }400 }
406 }401 }
407- if branchTitle != "" {402+
408- line, _ := json.Marshal(map[string]string{"type": "rename", "title": branchTitle})403+ if branchTitle := lastUserText(snapshot.Messages[:limit]); branchTitle != "" {
404+ line, err := json.Marshal(map[string]string{"type": "rename", "title": branchTitle})
405+ if err != nil {
406+ return "", fmt.Errorf("branch: marshal title: %w", err)
407+ }
409 if _, err := bw.Write(line); err != nil {408 if _, err := bw.Write(line); err != nil {
410 return "", err409 return "", err
411 }410 }
@@ -416,6 +415,16 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
416 if err := bw.Flush(); err != nil {415 if err := bw.Flush(); err != nil {
417 return "", err416 return "", err
418 }417 }
418+ if err := dst.Sync(); err != nil {
419+ return "", fmt.Errorf("branch: sync temp: %w", err)
420+ }
421+ if err := dst.Close(); err != nil {
422+ return "", fmt.Errorf("branch: close temp: %w", err)
423+ }
424+ if err := os.Rename(tmpPath, outPath); err != nil {
425+ return "", fmt.Errorf("branch: commit: %w", err)
426+ }
427+ committed = true
419 return outPath, nil428 return outPath, nil
420 }429 }
421 430
@@ -464,6 +473,183 @@ func BuildSessionTree(root, cwd string) []*TreeNode {
464 nodes[meta.ID] = &TreeNode{Summary: summary, Meta: meta}473 nodes[meta.ID] = &TreeNode{Summary: summary, Meta: meta}
465 order = append(order, meta.ID)474 order = append(order, meta.ID)
466 }475 }
476+ return linkSessionTreeNodes(nodes, order)
477+}
478+
479+// BuildSessionTreeStrict is the complete-read counterpart to the permissive
480+// forest builder. It validates every session file before linking any node, so
481+// callers that need a complete forest cannot silently omit malformed members.
482+func BuildSessionTreeStrict(root, cwd string) ([]*TreeNode, error) {
483+ dir := SessionsDir(root, cwd)
484+ entries, err := os.ReadDir(dir)
485+ if err != nil {
486+ if errors.Is(err, os.ErrNotExist) {
487+ return nil, nil
488+ }
489+ return nil, fmt.Errorf("build session tree: read directory: %w", err)
490+ }
491+ nodes := make(map[string]*TreeNode)
492+ order := make([]string, 0, len(entries))
493+ for _, e := range entries {
494+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
495+ continue
496+ }
497+ path := filepath.Join(dir, e.Name())
498+ snapshot, err := ReadSessionSnapshot(path)
499+ if err != nil {
500+ return nil, fmt.Errorf("build session tree: read %q: %w", path, err)
501+ }
502+ if snapshot.Meta.ID == "" {
503+ return nil, fmt.Errorf("build session tree: %q has no session id", path)
504+ }
505+ summary := SessionSummary{
506+ Path: path,
507+ Started: snapshot.Meta.Started,
508+ Model: snapshot.Meta.Model,
509+ Provider: snapshot.Meta.Provider,
510+ MessageCount: len(snapshot.Messages),
511+ FirstUserText: firstUserTextFromMessages(snapshot.Messages),
512+ Title: snapshot.Meta.Title,
513+ }
514+ if len(snapshot.UsageCheckpoints) > 0 {
515+ summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
516+ }
517+ if _, exists := nodes[snapshot.Meta.ID]; exists {
518+ return nil, fmt.Errorf("build session tree: duplicate session id %q", snapshot.Meta.ID)
519+ }
520+ nodes[snapshot.Meta.ID] = &TreeNode{Summary: summary, Meta: snapshot.Meta}
521+ order = append(order, snapshot.Meta.ID)
522+ }
523+ return linkSessionTreeNodes(nodes, order), nil
524+}
525+
526+// BuildSessionTreeFamilyStrict locates the current session using lightweight
527+// metadata headers, then strictly validates only that root and its descendants.
528+// A corrupt unrelated root in the same cwd bucket must not hide an otherwise
529+// readable current family, while a corrupt member of the selected family still
530+// fails the whole preflight.
531+func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, error) {
532+ if currentPath == "" {
533+ return nil, errors.New("build session family: current path is empty")
534+ }
535+ dir := SessionsDir(root, cwd)
536+ entries, err := os.ReadDir(dir)
537+ if err != nil {
538+ if errors.Is(err, os.ErrNotExist) {
539+ return nil, nil
540+ }
541+ return nil, fmt.Errorf("build session family: read directory: %w", err)
542+ }
543+ cleanCurrent := filepath.Clean(currentPath)
544+ nodes := make(map[string]*TreeNode)
545+ order := make([]string, 0, len(entries))
546+ for _, entry := range entries {
547+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
548+ continue
549+ }
550+ path := filepath.Join(dir, entry.Name())
551+ meta, err := readSessionMetaHeader(path)
552+ if err != nil {
553+ if filepath.Clean(path) == cleanCurrent {
554+ return nil, fmt.Errorf("build session family: read current metadata %q: %w", path, err)
555+ }
556+ // A file whose header is unreadable cannot be associated with the
557+ // selected family. Valid descendants are linked below and fully
558+ // preflighted; unrelated corrupt roots stay out of this family.
559+ continue
560+ }
561+ if _, exists := nodes[meta.ID]; exists {
562+ return nil, fmt.Errorf("build session family: duplicate session id %q", meta.ID)
563+ }
564+ nodes[meta.ID] = &TreeNode{
565+ Summary: SessionSummary{Path: path, Started: meta.Started, Model: meta.Model, Provider: meta.Provider},
566+ Meta: meta,
567+ }
568+ order = append(order, meta.ID)
569+ }
570+ roots := linkSessionTreeNodes(nodes, order)
571+ var familyRoot *TreeNode
572+ for _, candidate := range roots {
573+ if treeNodeContainsPath(candidate, cleanCurrent) {
574+ familyRoot = candidate
575+ break
576+ }
577+ }
578+ if familyRoot == nil {
579+ return nil, fmt.Errorf("build session family: current path %q is not in the session forest", currentPath)
580+ }
581+ var validate func(*TreeNode) error
582+ validate = func(node *TreeNode) error {
583+ snapshot, err := ReadSessionSnapshot(node.Summary.Path)
584+ if err != nil {
585+ return fmt.Errorf("build session family: read %q: %w", node.Summary.Path, err)
586+ }
587+ if snapshot.Meta.ID != node.Meta.ID {
588+ return fmt.Errorf("build session family: session id changed in %q", node.Summary.Path)
589+ }
590+ node.Meta = snapshot.Meta
591+ node.Summary = SessionSummary{
592+ Path: node.Summary.Path,
593+ Started: snapshot.Meta.Started,
594+ Model: snapshot.Meta.Model,
595+ Provider: snapshot.Meta.Provider,
596+ MessageCount: len(snapshot.Messages),
597+ FirstUserText: firstUserTextFromMessages(snapshot.Messages),
598+ Title: snapshot.Meta.Title,
599+ }
600+ if len(snapshot.UsageCheckpoints) > 0 {
601+ node.Summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
602+ }
603+ for _, child := range node.Children {
604+ if err := validate(child); err != nil {
605+ return err
606+ }
607+ }
608+ return nil
609+ }
610+ if err := validate(familyRoot); err != nil {
611+ return nil, err
612+ }
613+ return []*TreeNode{familyRoot}, nil
614+}
615+
616+func readSessionMetaHeader(path string) (SessionMeta, error) {
617+ f, err := os.Open(path)
618+ if err != nil {
619+ return SessionMeta{}, err
620+ }
621+ defer f.Close()
622+ line, err := bufio.NewReader(f).ReadBytes('\n')
623+ if err != nil && len(line) == 0 {
624+ return SessionMeta{}, err
625+ }
626+ line = bytes.TrimSpace(line)
627+ var row sessionLine
628+ if err := json.Unmarshal(line, &row); err != nil {
629+ return SessionMeta{}, err
630+ }
631+ if row.Type != "meta" || row.Meta == nil || row.Meta.ID == "" {
632+ return SessionMeta{}, errors.New("first row is not a valid meta row")
633+ }
634+ return *row.Meta, nil
635+}
636+
637+func treeNodeContainsPath(node *TreeNode, path string) bool {
638+ if node == nil {
639+ return false
640+ }
641+ if filepath.Clean(node.Summary.Path) == filepath.Clean(path) {
642+ return true
643+ }
644+ for _, child := range node.Children {
645+ if treeNodeContainsPath(child, path) {
646+ return true
647+ }
648+ }
649+ return false
650+}
651+
652+func linkSessionTreeNodes(nodes map[string]*TreeNode, order []string) []*TreeNode {
467 var roots []*TreeNode653 var roots []*TreeNode
468 for _, id := range order {654 for _, id := range order {
469 n := nodes[id]655 n := nodes[id]
@@ -474,35 +660,71 @@ func BuildSessionTree(root, cwd string) []*TreeNode {
474 if parent, ok := nodes[n.Meta.Parent]; ok {660 if parent, ok := nodes[n.Meta.Parent]; ok {
475 parent.Children = append(parent.Children, n)661 parent.Children = append(parent.Children, n)
476 } else {662 } else {
477- // Parent file missing (was manually deleted). Treat as663+ // Parent file missing (was manually deleted). Treat as a root so it
478- // a root so it still shows up in the tree.664+ // still shows up in the tree.
479 roots = append(roots, n)665 roots = append(roots, n)
480 }666 }
481 }667 }
482 return roots668 return roots
483 }669 }
484 670
485-// readSessionMeta opens path, reads the meta row, and returns it.671+// scanSessionMeta reads JSONL rows without hydrating transcript content and
486-// Empty SessionMeta when the file is missing or not a valid session.672+// returns the latest valid metadata row. Listing and ID lookup only need this
487-func readSessionMeta(path string) (SessionMeta, error) {673+// small projection; full snapshot validation remains the responsibility of
674+// resume, export, branching, and tree preflight callers.
675+func scanSessionMeta(path string) (meta SessionMeta, err error) {
488 f, err := os.Open(path)676 f, err := os.Open(path)
489 if err != nil {677 if err != nil {
490 return SessionMeta{}, err678 return SessionMeta{}, err
491 }679 }
492 defer f.Close()680 defer f.Close()
493- sc := bufio.NewScanner(f)681+
494- sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)682+ var sawMeta bool
495- if !sc.Scan() {683+ err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
496- return SessionMeta{}, errors.New("empty file")684+ var head sessionLineHead
497- }685+ if err := json.Unmarshal(line, &head); err != nil {
498- var line sessionLine686+ return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
499- if err := json.Unmarshal(sc.Bytes(), &line); err != nil {687+ }
500- return SessionMeta{}, err688+ if head.Type == "" {
689+ return fmt.Errorf("line %d: missing row type", lineNo)
690+ }
691+ if !sawMeta && head.Type != "meta" {
692+ return fmt.Errorf("line %d: first row is not meta", lineNo)
693+ }
694+ switch head.Type {
695+ case "meta":
696+ var row struct {
697+ Meta *SessionMeta `json:"meta"`
698+ }
699+ if err := json.Unmarshal(line, &row); err != nil {
700+ return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
701+ }
702+ if row.Meta == nil || row.Meta.ID == "" {
703+ return fmt.Errorf("line %d: meta row has no session id", lineNo)
704+ }
705+ meta = *row.Meta
706+ sawMeta = true
707+ case "message", "compaction", "usage", "rename":
708+ // These rows are intentionally not hydrated here.
709+ default:
710+ return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
711+ }
712+ return nil
713+ })
714+ if err != nil {
715+ return SessionMeta{}, fmt.Errorf("session metadata %q: %w", path, err)
501 }716 }
502- if line.Type != "meta" || line.Meta == nil {717+ if !sawMeta {
503- return SessionMeta{}, errors.New("first line is not meta")718+ return SessionMeta{}, fmt.Errorf("session metadata %q: file is empty", path)
504 }719 }
505- return *line.Meta, nil720+ return meta, nil
721+}
722+
723+// readSessionMeta returns the latest validated metadata for path without
724+// reconstructing its transcript. Full snapshot validation is performed by
725+// callers that need messages rather than listing metadata.
726+func readSessionMeta(path string) (SessionMeta, error) {
727+ return scanSessionMeta(path)
506 }728 }
507 729
508 // FindSessionByID looks up a session file in root/cwd whose meta id730 // FindSessionByID looks up a session file in root/cwd whose meta id
@@ -519,7 +741,7 @@ func FindSessionByID(root, cwd, id string) string {
519 continue741 continue
520 }742 }
521 path := filepath.Join(dir, e.Name())743 path := filepath.Join(dir, e.Name())
522- meta, err := readSessionMeta(path)744+ meta, err := scanSessionMeta(path)
523 if err != nil {745 if err != nil {
524 continue746 continue
525 }747 }
M packages/core/session_portable_test.go +78 -3
@@ -82,9 +82,84 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
82 }82 }
83 }83 }
84 84
85-// TestExportToFilePath writes to an explicit file path (no85+func TestExportImportUsesLatestProviderModelMetadata(t *testing.T) {
86-// directory guessing) and checks the .zotsession extension is86+ root := t.TempDir()
87-// appended when missing.87+ session, err := NewSession(root, "/source", "old-provider", "old-model", "test")
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ _ = session.AppendMessage(provider.Message{
92+ Role: provider.RoleUser,
93+ Content: []provider.Content{provider.TextBlock{Text: "metadata prompt"}},
94+ })
95+ if err := session.UpdateModel("new-provider", "new-model"); err != nil {
96+ t.Fatal(err)
97+ }
98+ path := session.Path
99+ if err := session.Close(); err != nil {
100+ t.Fatal(err)
101+ }
102+
103+ exportPath, err := ExportSession(path, filepath.Join(t.TempDir(), "latest"+PortableExt))
104+ if err != nil {
105+ t.Fatalf("ExportSession: %v", err)
106+ }
107+ exported, err := ReadSessionSnapshot(exportPath)
108+ if err != nil {
109+ t.Fatalf("Read exported snapshot: %v", err)
110+ }
111+ if exported.Meta.Provider != "new-provider" || exported.Meta.Model != "new-model" {
112+ t.Fatalf("export metadata = provider %q model %q, want latest values", exported.Meta.Provider, exported.Meta.Model)
113+ }
114+
115+ importedPath, err := ImportSession(exportPath, t.TempDir(), "/destination", "test")
116+ if err != nil {
117+ t.Fatalf("ImportSession: %v", err)
118+ }
119+ imported, _, err := OpenSession(importedPath)
120+ if err != nil {
121+ t.Fatalf("Open imported session: %v", err)
122+ }
123+ defer imported.Close()
124+ if imported.Meta.Provider != "new-provider" || imported.Meta.Model != "new-model" {
125+ t.Fatalf("import metadata = provider %q model %q, want latest values", imported.Meta.Provider, imported.Meta.Model)
126+ }
127+}
128+
129+// TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages verifies that
130+// metadata lookup follows append-only model updates without reconstructing the
131+// transcript.
132+func TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages(t *testing.T) {
133+ root := t.TempDir()
134+ session, err := NewSession(root, "/workspace", "old-provider", "old-model", "test")
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if err := session.AppendMessage(provider.Message{
139+ Role: provider.RoleUser,
140+ Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("large transcript ", 1000)}},
141+ }); err != nil {
142+ t.Fatal(err)
143+ }
144+ if err := session.UpdateModel("new-provider", "new-model"); err != nil {
145+ t.Fatal(err)
146+ }
147+ path := session.Path
148+ if err := session.Close(); err != nil {
149+ t.Fatal(err)
150+ }
151+
152+ meta, err := scanSessionMeta(path)
153+ if err != nil {
154+ t.Fatal(err)
155+ }
156+ if meta.Provider != "new-provider" || meta.Model != "new-model" {
157+ t.Fatalf("scanned metadata = %q/%q, want latest values", meta.Provider, meta.Model)
158+ }
159+}
160+
161+// TestExportToFilePath writes to an explicit file path (no directory
162+// guessing) and checks the .zotsession extension is appended when missing.
88 func TestExportToFilePath(t *testing.T) {163 func TestExportToFilePath(t *testing.T) {
89 root := t.TempDir()164 root := t.TempDir()
90 sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")165 sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
A packages/core/session_snapshot_test.go +628 -0
@@ -0,0 +1,628 @@
1+package core
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+func TestAppendMessageRejectsEmptyContent(t *testing.T) {
15+ session, err := NewSession(t.TempDir(), "/workspace", "provider", "model", "test")
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ if err := session.AppendMessage(provider.Message{Role: provider.RoleUser}); err == nil {
20+ t.Fatal("empty message content was accepted")
21+ }
22+ if err := session.Close(); err != nil {
23+ t.Fatal(err)
24+ }
25+}
26+
27+func TestReadSessionSnapshotUsesLatestCompactionAndRepairsToolPairs(t *testing.T) {
28+ root := t.TempDir()
29+ session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := session.AppendMessage(textMessage(provider.RoleUser, "old history")); err != nil {
34+ t.Fatal(err)
35+ }
36+ if err := session.AppendCompaction([]provider.Message{
37+ textMessage(provider.RoleAssistant, "summary"),
38+ {Role: provider.RoleAssistant, Content: []provider.Content{
39+ provider.ToolCallBlock{ID: "call-1", Name: "read", Arguments: []byte(`{"path":"/tmp/file"}`)},
40+ }},
41+ }); err != nil {
42+ t.Fatal(err)
43+ }
44+ if err := session.AppendUsage(provider.Usage{InputTokens: 10}, provider.Usage{InputTokens: 10}); err != nil {
45+ t.Fatal(err)
46+ }
47+ path := session.Path
48+ if err := session.Close(); err != nil {
49+ t.Fatal(err)
50+ }
51+
52+ snapshot, err := ReadSessionSnapshot(path)
53+ if err != nil {
54+ t.Fatalf("ReadSessionSnapshot: %v", err)
55+ }
56+ if snapshot.Meta.ID == "" {
57+ t.Fatal("snapshot lost session metadata")
58+ }
59+ assertMessageTexts(t, snapshot.Messages, []string{"summary", "", ""})
60+ stub := snapshot.Messages[2].Content[0].(provider.ToolResultBlock).Content[0].(provider.TextBlock)
61+ if stub.Text != "tool call was aborted; no result recorded." {
62+ t.Errorf("repaired result text = %q", stub.Text)
63+ }
64+ if got := snapshot.Messages[1].Role; got != provider.RoleAssistant {
65+ t.Fatalf("repaired transcript message 1 role = %q, want assistant", got)
66+ }
67+ if got := snapshot.Messages[2].Role; got != provider.RoleTool {
68+ t.Fatalf("repaired transcript message 2 role = %q, want tool", got)
69+ }
70+ if got := snapshot.Messages[2].Content[0].(provider.ToolResultBlock).CallID; got != "call-1" {
71+ t.Errorf("repaired result call id = %q, want call-1", got)
72+ }
73+ if len(snapshot.UsageCheckpoints) != 1 {
74+ t.Fatalf("usage checkpoints = %d, want 1", len(snapshot.UsageCheckpoints))
75+ }
76+ if got := snapshot.UsageCheckpoints[0].MessageCount; got != 3 {
77+ t.Fatalf("usage checkpoint message count = %d, want repaired count 3", got)
78+ }
79+ if got := snapshot.UsageCheckpoints[0].Cumulative.InputTokens; got != 10 {
80+ t.Errorf("checkpoint cumulative input = %d, want 10", got)
81+ }
82+
83+ opened, messages, err := OpenSession(path)
84+ if err != nil {
85+ t.Fatalf("OpenSession: %v", err)
86+ }
87+ defer opened.Close()
88+ if len(messages) != len(snapshot.Messages) {
89+ t.Fatalf("OpenSession messages = %d, snapshot messages = %d", len(messages), len(snapshot.Messages))
90+ }
91+}
92+
93+func TestReadSessionSnapshotMergesExistingToolResultWithoutDuplicateRepair(t *testing.T) {
94+ root := t.TempDir()
95+ session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ if err := session.AppendMessage(provider.Message{
100+ Role: provider.RoleAssistant,
101+ Content: []provider.Content{
102+ provider.ToolCallBlock{ID: "call-1", Name: "read"},
103+ provider.ToolCallBlock{ID: "call-2", Name: "bash"},
104+ },
105+ }); err != nil {
106+ t.Fatal(err)
107+ }
108+ if err := session.AppendMessage(provider.Message{
109+ Role: provider.RoleTool,
110+ Content: []provider.Content{provider.ToolResultBlock{
111+ CallID: "call-1",
112+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
113+ }},
114+ }); err != nil {
115+ t.Fatal(err)
116+ }
117+ path := session.Path
118+ if err := session.Close(); err != nil {
119+ t.Fatal(err)
120+ }
121+
122+ snapshot, err := ReadSessionSnapshot(path)
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ if len(snapshot.Messages) != 2 {
127+ t.Fatalf("messages = %d, want existing tool row plus assistant row", len(snapshot.Messages))
128+ }
129+ tool := snapshot.Messages[1]
130+ if len(tool.Content) != 2 {
131+ t.Fatalf("tool result blocks = %d, want 2", len(tool.Content))
132+ }
133+ if got := tool.Content[0].(provider.ToolResultBlock).CallID; got != "call-1" {
134+ t.Errorf("existing result call id = %q", got)
135+ }
136+ if got := tool.Content[1].(provider.ToolResultBlock).CallID; got != "call-2" {
137+ t.Errorf("repaired result call id = %q", got)
138+ }
139+}
140+
141+func TestReadSessionSnapshotRecordsCheckpointsAtEffectiveBoundaries(t *testing.T) {
142+ root := t.TempDir()
143+ session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
144+ if err != nil {
145+ t.Fatal(err)
146+ }
147+ _ = session.AppendMessage(textMessage(provider.RoleUser, "one"))
148+ _ = session.AppendUsage(provider.Usage{InputTokens: 3}, provider.Usage{InputTokens: 3})
149+ _ = session.AppendMessage(textMessage(provider.RoleAssistant, "two"))
150+ _ = session.AppendUsage(provider.Usage{InputTokens: 4}, provider.Usage{InputTokens: 7})
151+ _ = session.AppendCompaction([]provider.Message{textMessage(provider.RoleAssistant, "compact")})
152+ _ = session.AppendMessage(textMessage(provider.RoleUser, "tail"))
153+ _ = session.AppendUsage(provider.Usage{InputTokens: 2}, provider.Usage{InputTokens: 9})
154+ path := session.Path
155+ if err := session.Close(); err != nil {
156+ t.Fatal(err)
157+ }
158+
159+ snapshot, err := ReadSessionSnapshot(path)
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ assertMessageTexts(t, snapshot.Messages, []string{"compact", "tail"})
164+ if len(snapshot.UsageCheckpoints) != 3 {
165+ t.Fatalf("checkpoints = %d, want 3", len(snapshot.UsageCheckpoints))
166+ }
167+ wantCounts := []int{1, 2, 2}
168+ wantCosts := []int{3, 7, 9}
169+ for i, checkpoint := range snapshot.UsageCheckpoints {
170+ if checkpoint.MessageCount != wantCounts[i] {
171+ t.Errorf("checkpoint %d count = %d, want %d", i, checkpoint.MessageCount, wantCounts[i])
172+ }
173+ if checkpoint.Cumulative.InputTokens != wantCosts[i] {
174+ t.Errorf("checkpoint %d input = %d, want %d", i, checkpoint.Cumulative.InputTokens, wantCosts[i])
175+ }
176+ }
177+}
178+
179+func TestReadSessionSnapshotRejectsMalformedRows(t *testing.T) {
180+ meta := SessionMeta{ID: "session-1", CWD: "/workspace", Started: time.Now().UTC()}
181+ metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &meta})
182+ if err != nil {
183+ t.Fatal(err)
184+ }
185+ cases := []string{
186+ string(metaLine) + "\nnot-json\n",
187+ string(metaLine) + `
188+{"type":"message","message":{"role":"user"}}
189+`,
190+ string(metaLine) + `
191+{"type":"usage","cumulative":null}
192+`,
193+ string(metaLine) + `
194+{"type":"future-row"}
195+`,
196+ }
197+ for idx, contents := range cases {
198+ path := filepath.Join(t.TempDir(), "session.jsonl")
199+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
200+ t.Fatal(err)
201+ }
202+ if _, err := ReadSessionSnapshot(path); err == nil {
203+ t.Errorf("case %d: ReadSessionSnapshot returned nil error", idx)
204+ }
205+ if _, _, err := OpenSession(path); err == nil {
206+ t.Errorf("case %d: OpenSession returned nil error", idx)
207+ }
208+ }
209+}
210+
211+func TestReadSessionSnapshotKeepsOldOptionalMetadataReadable(t *testing.T) {
212+ path := filepath.Join(t.TempDir(), "old.jsonl")
213+ contents := `{"type":"meta","meta":{"id":"old-id","cwd":"/workspace","model":"claude","provider":"anthropic"}}
214+{"type":"message","message":{"role":"user","content":[{"text":"hello"}]}}
215+`
216+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
217+ t.Fatal(err)
218+ }
219+ snapshot, err := ReadSessionSnapshot(path)
220+ if err != nil {
221+ t.Fatal(err)
222+ }
223+ if snapshot.Meta.Parent != "" || snapshot.Meta.HideFromSessions {
224+ t.Fatalf("old optional metadata unexpectedly populated: %+v", snapshot.Meta)
225+ }
226+ assertMessageTexts(t, snapshot.Messages, []string{"hello"})
227+}
228+
229+func TestBranchAfterCompactionRetainsPreCompactionUsageAtReplacementBoundary(t *testing.T) {
230+ root := t.TempDir()
231+ parent, err := NewSession(root, "/workspace", "test", "model", "test")
232+ if err != nil {
233+ t.Fatal(err)
234+ }
235+ for _, text := range []string{"one", "two", "three"} {
236+ _ = parent.AppendMessage(textMessage(provider.RoleUser, text))
237+ }
238+ _ = parent.AppendUsage(provider.Usage{InputTokens: 10}, provider.Usage{InputTokens: 10})
239+ _ = parent.AppendCompaction([]provider.Message{textMessage(provider.RoleAssistant, "summary")})
240+ path := parent.Path
241+ if err := parent.Close(); err != nil {
242+ t.Fatal(err)
243+ }
244+ branchPath, err := BranchSessionHidden(path, root, "/workspace", "test", 1)
245+ if err != nil {
246+ t.Fatal(err)
247+ }
248+ usage, err := SessionUsage(branchPath)
249+ if err != nil {
250+ t.Fatal(err)
251+ }
252+ if usage.InputTokens != 10 {
253+ t.Fatalf("branch usage input = %d, want pre-compaction cumulative usage", usage.InputTokens)
254+ }
255+}
256+
257+func TestBranchSessionUsesSharedSnapshotAndPreservesUsage(t *testing.T) {
258+ root := t.TempDir()
259+ parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
260+ if err != nil {
261+ t.Fatal(err)
262+ }
263+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "first"))
264+ _ = parent.AppendUsage(provider.Usage{InputTokens: 11}, provider.Usage{InputTokens: 11})
265+ _ = parent.AppendMessage(textMessage(provider.RoleAssistant, "reply"))
266+ _ = parent.AppendUsage(provider.Usage{InputTokens: 12}, provider.Usage{InputTokens: 23})
267+ parentPath := parent.Path
268+ if err := parent.Close(); err != nil {
269+ t.Fatal(err)
270+ }
271+
272+ branchPath, err := BranchSessionHidden(parentPath, root, "/workspace", "test", 1)
273+ if err != nil {
274+ t.Fatalf("BranchSessionHidden: %v", err)
275+ }
276+ branch, messages, err := OpenSession(branchPath)
277+ if err != nil {
278+ t.Fatal(err)
279+ }
280+ branch.Close()
281+ assertMessageTexts(t, messages, []string{"first"})
282+ if branch.Meta.ForkPoint != 1 {
283+ t.Errorf("fork point = %d, want 1", branch.Meta.ForkPoint)
284+ }
285+ if !branch.Meta.HideFromSessions {
286+ t.Error("hidden branch did not set HideFromSessions")
287+ }
288+ usage, err := SessionUsage(branchPath)
289+ if err != nil {
290+ t.Fatal(err)
291+ }
292+ if usage.InputTokens != 11 {
293+ t.Errorf("branch usage input = %d, want 11", usage.InputTokens)
294+ }
295+
296+ if _, err := BranchSession(parentPath, root, "/workspace", "test", 99); err == nil {
297+ t.Error("out-of-range fork returned nil error")
298+ }
299+}
300+
301+func TestBranchSessionRepairsToolBoundaryAndSupportsZeroAndEnd(t *testing.T) {
302+ root := t.TempDir()
303+ parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "before"))
308+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{
309+ provider.ToolCallBlock{ID: "call-1", Name: "read"},
310+ }})
311+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleTool, Content: []provider.Content{
312+ provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "result"}}},
313+ }})
314+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "after"))
315+ path := parent.Path
316+ if err := parent.Close(); err != nil {
317+ t.Fatal(err)
318+ }
319+
320+ toolBranchPath, err := BranchSession(parent.Path, root, "/workspace", "test", 2)
321+ if err != nil {
322+ t.Fatal(err)
323+ }
324+ toolBranch, toolMessages, err := OpenSession(toolBranchPath)
325+ if err != nil {
326+ t.Fatal(err)
327+ }
328+ toolBranch.Close()
329+ if len(toolMessages) != 3 {
330+ t.Fatalf("tool boundary branch messages = %d, want 3", len(toolMessages))
331+ }
332+ if toolBranch.Meta.ForkPoint != 3 {
333+ t.Errorf("tool boundary fork point = %d, want 3", toolBranch.Meta.ForkPoint)
334+ }
335+
336+ zeroPath, err := BranchSessionHidden(path, root, "/workspace", "test", 0)
337+ if err != nil {
338+ t.Fatal(err)
339+ }
340+ zero, zeroMessages, err := OpenSession(zeroPath)
341+ if err != nil {
342+ t.Fatal(err)
343+ }
344+ zero.Close()
345+ if len(zeroMessages) != 0 || zero.Meta.ForkPoint != 0 {
346+ t.Errorf("zero branch = %d messages, fork %d", len(zeroMessages), zero.Meta.ForkPoint)
347+ }
348+
349+ endPath, err := BranchSession(path, root, "/workspace", "test", 4)
350+ if err != nil {
351+ t.Fatal(err)
352+ }
353+ end, endMessages, err := OpenSession(endPath)
354+ if err != nil {
355+ t.Fatal(err)
356+ }
357+ end.Close()
358+ if len(endMessages) != 4 || end.Meta.ForkPoint != 4 {
359+ t.Errorf("end branch = %d messages, fork %d", len(endMessages), end.Meta.ForkPoint)
360+ }
361+}
362+
363+func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) {
364+ root := t.TempDir()
365+ parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
366+ if err != nil {
367+ t.Fatal(err)
368+ }
369+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "root"))
370+ parentPath := parent.Path
371+ parentID := parent.ID
372+ if err := parent.Close(); err != nil {
373+ t.Fatal(err)
374+ }
375+ childPath, err := BranchSessionHidden(parentPath, root, "/workspace", "test", 1)
376+ if err != nil {
377+ t.Fatal(err)
378+ }
379+ if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath {
380+ t.Fatalf("DescribeSessions = %+v, want only parent", got)
381+ }
382+ tree := BuildSessionTree(root, "/workspace")
383+ if len(tree) != 1 || tree[0].Meta.ID != parentID {
384+ t.Fatalf("tree roots = %+v, want parent root", tree)
385+ }
386+ if len(tree[0].Children) != 1 || tree[0].Children[0].Summary.Path != childPath {
387+ t.Fatalf("tree children = %+v, want hidden child", tree[0].Children)
388+ }
389+}
390+
391+func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) {
392+ root := t.TempDir()
393+ cwd := "/workspace/strict-tree"
394+ parent, err := NewSession(root, cwd, "test", "model", "test")
395+ if err != nil {
396+ t.Fatal(err)
397+ }
398+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "parent"))
399+ parentPath := parent.Path
400+ if err := parent.Close(); err != nil {
401+ t.Fatal(err)
402+ }
403+ badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl")
404+ if err := os.WriteFile(badPath, []byte("{not-json}\n"), 0o644); err != nil {
405+ t.Fatal(err)
406+ }
407+ if _, err := BuildSessionTreeStrict(root, cwd); err == nil {
408+ t.Fatal("BuildSessionTreeStrict accepted a malformed session member")
409+ }
410+ if _, err := os.Stat(parentPath); err != nil {
411+ t.Fatalf("valid parent disappeared: %v", err)
412+ }
413+}
414+
415+func TestSessionUsageDetailUsesCurrentCacheAfterCounterReset(t *testing.T) {
416+ root := t.TempDir()
417+ session, err := NewSession(root, "/workspace", "anthropic", "model", "test")
418+ if err != nil {
419+ t.Fatal(err)
420+ }
421+ if err := session.AppendMessage(textMessage(provider.RoleUser, "prompt")); err != nil {
422+ t.Fatal(err)
423+ }
424+ if err := session.AppendUsage(provider.Usage{}, provider.Usage{
425+ InputTokens: 10,
426+ OutputTokens: 4,
427+ ReasoningTokens: 2,
428+ ReasoningTokensKnown: true,
429+ CacheReadTokens: 100,
430+ CacheWriteTokens: 40,
431+ CostUSD: 0.5,
432+ }); err != nil {
433+ t.Fatal(err)
434+ }
435+ if err := session.AppendUsage(provider.Usage{}, provider.Usage{
436+ InputTokens: 13,
437+ OutputTokens: 7,
438+ ReasoningTokens: 3,
439+ ReasoningTokensKnown: true,
440+ CacheReadTokens: 6,
441+ CacheWriteTokens: 2,
442+ CostUSD: 0.7,
443+ }); err != nil {
444+ t.Fatal(err)
445+ }
446+ path := session.Path
447+ if err := session.Close(); err != nil {
448+ t.Fatal(err)
449+ }
450+
451+ cumulative, last, err := SessionUsageDetail(path)
452+ if err != nil {
453+ t.Fatal(err)
454+ }
455+ if cumulative.CacheReadTokens != 6 || cumulative.CacheWriteTokens != 2 {
456+ t.Fatalf("cumulative cache = (%d, %d), want (6, 2)", cumulative.CacheReadTokens, cumulative.CacheWriteTokens)
457+ }
458+ if last.InputTokens != 3 || last.OutputTokens != 3 || last.ReasoningTokens != 1 {
459+ t.Fatalf("last token delta = %+v, want input=3 output=3 reasoning=1", last)
460+ }
461+ if last.CacheReadTokens != 6 || last.CacheWriteTokens != 2 {
462+ t.Fatalf("last cache = (%d, %d), want current checkpoint values (6, 2)", last.CacheReadTokens, last.CacheWriteTokens)
463+ }
464+ if !last.ReasoningTokensKnown {
465+ t.Fatal("last-turn reasoning-token known flag was lost")
466+ }
467+}
468+
469+func TestSnapshotRemovesOrphanedToolResultsAndMapsCheckpointCount(t *testing.T) {
470+ root := t.TempDir()
471+ session, err := NewSession(root, "/workspace", "anthropic", "model", "test")
472+ if err != nil {
473+ t.Fatal(err)
474+ }
475+ _ = session.AppendMessage(textMessage(provider.RoleUser, "prompt"))
476+ _ = session.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{
477+ provider.ToolCallBlock{ID: "call-1", Name: "read"},
478+ }})
479+ _ = session.AppendMessage(provider.Message{Role: provider.RoleTool, Content: []provider.Content{
480+ provider.ToolResultBlock{CallID: "orphan", Content: []provider.Content{provider.TextBlock{Text: "stale"}}},
481+ }})
482+ _ = session.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 9})
483+ path := session.Path
484+ if err := session.Close(); err != nil {
485+ t.Fatal(err)
486+ }
487+
488+ snapshot, err := ReadSessionSnapshot(path)
489+ if err != nil {
490+ t.Fatal(err)
491+ }
492+ if len(snapshot.Messages) != 3 {
493+ t.Fatalf("repaired messages = %d, want 3", len(snapshot.Messages))
494+ }
495+ tool := snapshot.Messages[2]
496+ if len(tool.Content) != 1 {
497+ t.Fatalf("repaired tool blocks = %d, want synthetic result only", len(tool.Content))
498+ }
499+ result, ok := tool.Content[0].(provider.ToolResultBlock)
500+ if !ok || result.CallID != "call-1" {
501+ t.Fatalf("repaired result = %#v, want call-1", tool.Content[0])
502+ }
503+ if snapshot.UsageCheckpoints[0].MessageCount != 3 {
504+ t.Fatalf("checkpoint message count = %d, want repaired count 3", snapshot.UsageCheckpoints[0].MessageCount)
505+ }
506+
507+ branchPath, err := BranchSessionHidden(path, root, "/workspace", "test", len(snapshot.Messages))
508+ if err != nil {
509+ t.Fatal(err)
510+ }
511+ _, branchMessages, err := OpenSession(branchPath)
512+ if err != nil {
513+ t.Fatal(err)
514+ }
515+ if len(branchMessages) != 3 {
516+ t.Fatalf("branch messages = %d, want 3", len(branchMessages))
517+ }
518+ branchResult := branchMessages[2].Content[0].(provider.ToolResultBlock)
519+ if branchResult.CallID != "call-1" {
520+ t.Fatalf("branch kept orphan result call id %q", branchResult.CallID)
521+ }
522+}
523+
524+func TestAppendCompactionEmptyAndHistoricalNullRowsRoundTrip(t *testing.T) {
525+ root := t.TempDir()
526+ session, err := NewSession(root, "/workspace", "anthropic", "model", "test")
527+ if err != nil {
528+ t.Fatal(err)
529+ }
530+ _ = session.AppendMessage(textMessage(provider.RoleUser, "before"))
531+ if err := session.AppendCompaction(nil); err != nil {
532+ t.Fatal(err)
533+ }
534+ if err := session.AppendCompaction([]provider.Message{}); err != nil {
535+ t.Fatal(err)
536+ }
537+ path := session.Path
538+ if err := session.Close(); err != nil {
539+ t.Fatal(err)
540+ }
541+ if _, err := os.Stat(path); err != nil {
542+ t.Fatalf("empty compaction session was removed: %v", err)
543+ }
544+ snapshot, err := ReadSessionSnapshot(path)
545+ if err != nil {
546+ t.Fatal(err)
547+ }
548+ if len(snapshot.Messages) != 0 {
549+ t.Fatalf("empty compaction messages = %d, want 0", len(snapshot.Messages))
550+ }
551+
552+ meta := SessionMeta{ID: "historical-empty", CWD: "/workspace"}
553+ metaLine, _ := json.Marshal(sessionLine{Type: "meta", Meta: &meta})
554+ for _, compaction := range []string{`{"type":"compaction"}`, `{"type":"compaction","messages":null}`} {
555+ historicalPath := filepath.Join(t.TempDir(), "historical.jsonl")
556+ contents := string(metaLine) + "\n" + compaction + "\n"
557+ if err := os.WriteFile(historicalPath, []byte(contents), 0o644); err != nil {
558+ t.Fatal(err)
559+ }
560+ historical, err := ReadSessionSnapshot(historicalPath)
561+ if err != nil {
562+ t.Fatalf("historical %s: %v", compaction, err)
563+ }
564+ if len(historical.Messages) != 0 {
565+ t.Fatalf("historical %s messages = %d, want 0", compaction, len(historical.Messages))
566+ }
567+ }
568+}
569+
570+func TestBranchPreservesCumulativeCheckpointsForLastTurnDelta(t *testing.T) {
571+ root := t.TempDir()
572+ parent, err := NewSession(root, "/workspace", "anthropic", "model", "test")
573+ if err != nil {
574+ t.Fatal(err)
575+ }
576+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "first"))
577+ _ = parent.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 10, CacheReadTokens: 4})
578+ _ = parent.AppendMessage(textMessage(provider.RoleAssistant, "second"))
579+ _ = parent.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 25, CacheReadTokens: 7})
580+ parentPath := parent.Path
581+ if err := parent.Close(); err != nil {
582+ t.Fatal(err)
583+ }
584+
585+ branchPath, err := BranchSession(parentPath, root, "/workspace", "test", 2)
586+ if err != nil {
587+ t.Fatal(err)
588+ }
589+ cumulative, last, err := SessionUsageDetail(branchPath)
590+ if err != nil {
591+ t.Fatal(err)
592+ }
593+ if cumulative.InputTokens != 25 || cumulative.CacheReadTokens != 7 {
594+ t.Fatalf("branch cumulative = %+v, want input=25 cache_read=7", cumulative)
595+ }
596+ if last.InputTokens != 15 || last.CacheReadTokens != 3 {
597+ t.Fatalf("branch last turn = %+v, want input=15 cache_read=3", last)
598+ }
599+}
600+
601+func textMessage(role provider.Role, text string) provider.Message {
602+ return provider.Message{Role: role, Content: []provider.Content{provider.TextBlock{Text: text}}}
603+}
604+
605+func TestSnapshotMessagesDoNotExposeMutableRawCompactionState(t *testing.T) {
606+ // This is a small API contract check: callers can safely materialize a
607+ // branch from the returned slice without changing the source file.
608+ root := t.TempDir()
609+ session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
610+ if err != nil {
611+ t.Fatal(err)
612+ }
613+ _ = session.AppendMessage(textMessage(provider.RoleUser, "original"))
614+ path := session.Path
615+ _ = session.Close()
616+ snapshot, err := ReadSessionSnapshot(path)
617+ if err != nil {
618+ t.Fatal(err)
619+ }
620+ snapshot.Messages[0].Content[0] = provider.TextBlock{Text: "changed in memory"}
621+ again, err := ReadSessionSnapshot(path)
622+ if err != nil {
623+ t.Fatal(err)
624+ }
625+ if got := extractText(again.Messages[0]); strings.Contains(got, "changed") {
626+ t.Fatal("mutating snapshot changed on-disk transcript")
627+ }
628+}
M packages/tui/input.go +111 -12
@@ -56,6 +56,43 @@ const (
56 type Reader struct {56 type Reader struct {
57 src func() (byte, error)57 src func() (byte, error)
58 peek func(time.Duration) (byte, bool, error) // optional; may be nil58 peek func(time.Duration) (byte, bool, error) // optional; may be nil
59+
60+ // pending contains bytes consumed while deciding whether an Esc was
61+ // bare. In particular, a second Esc belongs to the next Read, not to
62+ // the Alt+<char> case handled by readEscape.
63+ pending []byte
64+
65+ // A Reader without a peek callback still needs to preserve the old
66+ // NewReader API and parse Alt/CSI input. The pump lets its bounded
67+ // lookahead wait without calling the blocking source on the Read caller's
68+ // goroutine. There is never more than one outstanding source call: after a
69+ // timeout that call is reused by the next Read rather than replaced.
70+ //
71+ // The source callback has no cancellation contract, so an implementation
72+ // that blocks forever can keep this one goroutine alive forever. That
73+ // limitation is intentional; starting another goroutine for each isolated
74+ // Esc would leak an unbounded number of blocked goroutines and could also
75+ // reorder bytes from an uncancellable source.
76+ pump *readerPump
77+ pumpErr error
78+}
79+
80+type readerResult struct {
81+ b byte
82+ err error
83+}
84+
85+type readerPump struct {
86+ result chan readerResult
87+}
88+
89+func newReaderPump(read func() (byte, error)) *readerPump {
90+ p := &readerPump{result: make(chan readerResult, 1)}
91+ go func() {
92+ b, err := read()
93+ p.result <- readerResult{b: b, err: err}
94+ }()
95+ return p
59 }96 }
60 97
61 // NewReader returns a Reader that pulls bytes from read.98 // NewReader returns a Reader that pulls bytes from read.
@@ -67,9 +104,65 @@ func NewReaderWithPeek(read func() (byte, error), peek func(time.Duration) (byte
67 return &Reader{src: read, peek: peek}104 return &Reader{src: read, peek: peek}
68 }105 }
69 106
107+// readByte returns the next source byte, including any byte held back after
108+// an escape lookahead.
109+func (r *Reader) readByte() (byte, error) {
110+ if len(r.pending) != 0 {
111+ b := r.pending[0]
112+ r.pending = r.pending[1:]
113+ return b, nil
114+ }
115+ if r.pumpErr != nil {
116+ return 0, r.pumpErr
117+ }
118+ if r.pump == nil {
119+ return r.src()
120+ }
121+ result := <-r.pump.result
122+ r.pump = nil
123+ if result.err != nil {
124+ r.pumpErr = result.err
125+ }
126+ return result.b, result.err
127+}
128+
129+// readByteTimeout returns one source byte without waiting longer than d. A
130+// source call left outstanding after a timeout is consumed by the next Read.
131+func (r *Reader) readByteTimeout(d time.Duration) (byte, bool, error) {
132+ if r.pumpErr != nil {
133+ return 0, false, r.pumpErr
134+ }
135+ if r.pump == nil {
136+ r.pump = newReaderPump(r.src)
137+ }
138+
139+ var result readerResult
140+ if d <= 0 {
141+ select {
142+ case result = <-r.pump.result:
143+ default:
144+ return 0, false, nil
145+ }
146+ } else {
147+ timer := time.NewTimer(d)
148+ defer timer.Stop()
149+ select {
150+ case result = <-r.pump.result:
151+ case <-timer.C:
152+ return 0, false, nil
153+ }
154+ }
155+ r.pump = nil
156+ if result.err != nil {
157+ r.pumpErr = result.err
158+ return 0, false, result.err
159+ }
160+ return result.b, true, nil
161+}
162+
70 // Read returns the next parsed Key.163 // Read returns the next parsed Key.
71 func (r *Reader) Read() (Key, error) {164 func (r *Reader) Read() (Key, error) {
72- b, err := r.src()165+ b, err := r.readByte()
73 if err != nil {166 if err != nil {
74 return Key{}, err167 return Key{}, err
75 }168 }
@@ -113,7 +206,7 @@ func (r *Reader) Read() (Key, error) {
113 n := utf8Len(b)206 n := utf8Len(b)
114 buf := []byte{b}207 buf := []byte{b}
115 for i := 1; i < n; i++ {208 for i := 1; i < n; i++ {
116- bb, err := r.src()209+ bb, err := r.readByte()
117 if err != nil {210 if err != nil {
118 return Key{}, err211 return Key{}, err
119 }212 }
@@ -161,12 +254,19 @@ func (r *Reader) readEscape() (Key, error) {
161 if err != nil || !have {254 if err != nil || !have {
162 return Key{Kind: KeyEsc}, nil255 return Key{Kind: KeyEsc}, nil
163 }256 }
257+ if b == 0x1b {
258+ // A second raw ESC is a separate key, not Alt+ESC. Keep it for the
259+ // next Read so Esc Esc produces two bare KeyEsc events. This is the
260+ // one lookahead case that is not intentionally treated as Alt input.
261+ r.pending = append(r.pending, b)
262+ return Key{Kind: KeyEsc}, nil
263+ }
164 switch b {264 switch b {
165 case '[':265 case '[':
166 return r.readCSI()266 return r.readCSI()
167 case 'O':267 case 'O':
168 // SS3 sequences (function keys in some terminals).268 // SS3 sequences (function keys in some terminals).
169- c, err := r.src()269+ c, err := r.readByte()
170 if err != nil {270 if err != nil {
171 return Key{}, err271 return Key{}, err
172 }272 }
@@ -199,24 +299,23 @@ func (r *Reader) readEscape() (Key, error) {
199 }299 }
200 300
201 // readEscapeNext tries to read one byte within d. If peek is available301 // readEscapeNext tries to read one byte within d. If peek is available
202-// we use it (true non-blocking). Otherwise we fall back to a blocking302+// we use it. NewReader's source pump supplies the same bounded behavior
203-// read, which means bare Esc is only detected after the next keystroke.303+// when no peek callback is provided, so a bare Esc never waits forever for
304+// a second byte. A follow-on printable byte, DEL, b, or f remains
305+// intentionally ambiguous with Alt input and keeps the historical mapping
306+// in readEscape.
204 func (r *Reader) readEscapeNext(d time.Duration) (byte, bool, error) {307 func (r *Reader) readEscapeNext(d time.Duration) (byte, bool, error) {
205 if r.peek != nil {308 if r.peek != nil {
206 return r.peek(d)309 return r.peek(d)
207 }310 }
208- b, err := r.src()311+ return r.readByteTimeout(d)
209- if err != nil {
210- return 0, false, err
211- }
212- return b, true, nil
213 }312 }
214 313
215 // readCSI parses a CSI sequence after ESC [.314 // readCSI parses a CSI sequence after ESC [.
216 func (r *Reader) readCSI() (Key, error) {315 func (r *Reader) readCSI() (Key, error) {
217 var params []byte316 var params []byte
218 for {317 for {
219- c, err := r.src()318+ c, err := r.readByte()
220 if err != nil {319 if err != nil {
221 return Key{}, err320 return Key{}, err
222 }321 }
@@ -411,7 +510,7 @@ func (r *Reader) readPaste() Key {
411 const end = "\x1b[201~"510 const end = "\x1b[201~"
412 tail := make([]byte, 0, len(end))511 tail := make([]byte, 0, len(end))
413 for {512 for {
414- b, err := r.src()513+ b, err := r.readByte()
415 if err != nil {514 if err != nil {
416 break515 break
417 }516 }
M packages/tui/input_test.go +140 -4
@@ -1,6 +1,10 @@
1 package tui1 package tui
2 2
3-import "testing"3+import (
4+ "io"
5+ "testing"
6+ "time"
7+)
4 8
5 func TestReaderParsesCSIUShiftEnter(t *testing.T) {9 func TestReaderParsesCSIUShiftEnter(t *testing.T) {
6 k := readKey(t, "\x1b[13;2u")10 k := readKey(t, "\x1b[13;2u")
@@ -91,6 +95,111 @@ func TestReaderParsesCSIUTabAndBackspace(t *testing.T) {
91 }95 }
92 }96 }
93 97
98+func TestReaderParsesRawEscapeEscapeAsTwoEscapes(t *testing.T) {
99+ r := newPeekReader("\x1b\x1b")
100+ if k := readReaderKey(t, r); k.Kind != KeyEsc {
101+ t.Fatalf("first Read kind=%v, want esc", k.Kind)
102+ }
103+ if k := readReaderKey(t, r); k.Kind != KeyEsc {
104+ t.Fatalf("second Read kind=%v, want esc", k.Kind)
105+ }
106+}
107+
108+func TestReaderParsesRawEscapeEscapeCSIAsEscapeThenUp(t *testing.T) {
109+ r := newPeekReader("\x1b\x1b[A")
110+ if k := readReaderKey(t, r); k.Kind != KeyEsc {
111+ t.Fatalf("first Read kind=%v, want esc", k.Kind)
112+ }
113+ if k := readReaderKey(t, r); k.Kind != KeyUp {
114+ t.Fatalf("second Read kind=%v, want up", k.Kind)
115+ }
116+}
117+
118+func TestReaderPreservesDoubleEscapePrintableAmbiguity(t *testing.T) {
119+ r := newPeekReader("\x1b\x1bx")
120+ if got := readReaderKey(t, r); got.Kind != KeyEsc {
121+ t.Fatalf("first Read = %+v, want bare escape", got)
122+ }
123+ if got := readReaderKey(t, r); got.Kind != KeyRune || got.Rune != 'x' || !got.Alt {
124+ t.Fatalf("second Read = %+v, want Alt+x after the documented ambiguity", got)
125+ }
126+}
127+
128+func TestReaderPreservesAmbiguousAltEscapeMappings(t *testing.T) {
129+ cases := []struct {
130+ name string
131+ seq string
132+ want Key
133+ }{
134+ {name: "printable", seq: "\x1bx", want: Key{Kind: KeyRune, Rune: 'x', Alt: true}},
135+ {name: "delete", seq: "\x1b\x7f", want: Key{Kind: KeyBackspace, Alt: true}},
136+ {name: "word-left", seq: "\x1bb", want: Key{Kind: KeyLeft, Alt: true}},
137+ {name: "word-right", seq: "\x1bf", want: Key{Kind: KeyRight, Alt: true}},
138+ }
139+ for _, tc := range cases {
140+ t.Run(tc.name, func(t *testing.T) {
141+ got := readReaderKey(t, newPeekReader(tc.seq))
142+ if got != tc.want {
143+ t.Fatalf("Read(%q) = %+v, want %+v", tc.seq, got, tc.want)
144+ }
145+ })
146+ }
147+}
148+
149+func TestReaderWithoutPeekDoesNotBlockBareEscapeOrStartAnotherPump(t *testing.T) {
150+ release := make(chan struct{})
151+ lookaheadStarted := make(chan struct{})
152+ calls := 0
153+ r := NewReader(func() (byte, error) {
154+ calls++
155+ if calls == 1 {
156+ return 0x1b, nil
157+ }
158+ if calls == 2 {
159+ close(lookaheadStarted)
160+ <-release
161+ return 'x', nil
162+ }
163+ return 0, io.EOF
164+ })
165+
166+ readDone := make(chan struct{})
167+ var got Key
168+ var err error
169+ go func() {
170+ got, err = r.Read()
171+ close(readDone)
172+ }()
173+ select {
174+ case <-lookaheadStarted:
175+ case <-time.After(500 * time.Millisecond):
176+ t.Fatal("lookahead source was not started")
177+ }
178+ select {
179+ case <-readDone:
180+ case <-time.After(500 * time.Millisecond):
181+ t.Fatal("Read blocked while disambiguating bare escape")
182+ }
183+ if err != nil {
184+ t.Fatalf("Read: %v", err)
185+ }
186+ if got.Kind != KeyEsc {
187+ t.Fatalf("Read kind=%v, want esc", got.Kind)
188+ }
189+
190+ // The timed-out source call is still the sole owner of the next byte.
191+ // Releasing it must make that byte available to the next Read without
192+ // starting a second blocked callback.
193+ close(release)
194+ got, err = r.Read()
195+ if err != nil {
196+ t.Fatalf("Read after lookahead: %v", err)
197+ }
198+ if got.Kind != KeyRune || got.Rune != 'x' || calls != 2 {
199+ t.Fatalf("Read after lookahead = %+v with %d source calls, want x with 2 calls", got, calls)
200+ }
201+}
202+
94 func TestReaderParsesSGRMouseWheel(t *testing.T) {203 func TestReaderParsesSGRMouseWheel(t *testing.T) {
95 cases := []struct {204 cases := []struct {
96 seq string205 seq string
@@ -109,15 +218,42 @@ func TestReaderParsesSGRMouseWheel(t *testing.T) {
109 218
110 func readKey(t *testing.T, seq string) Key {219 func readKey(t *testing.T, seq string) Key {
111 t.Helper()220 t.Helper()
221+ return readReaderKey(t, NewReader(func() (byte, error) {
222+ if len(seq) == 0 {
223+ return 0, io.EOF
224+ }
225+ b := seq[0]
226+ seq = seq[1:]
227+ return b, nil
228+ }))
229+}
230+
231+func newPeekReader(seq string) *Reader {
112 idx := 0232 idx := 0
113- r := NewReader(func() (byte, error) {233+ read := func() (byte, error) {
234+ if idx >= len(seq) {
235+ return 0, io.EOF
236+ }
114 b := seq[idx]237 b := seq[idx]
115 idx++238 idx++
116 return b, nil239 return b, nil
117- })240+ }
241+ peek := func(time.Duration) (byte, bool, error) {
242+ if idx >= len(seq) {
243+ return 0, false, nil
244+ }
245+ b := seq[idx]
246+ idx++
247+ return b, true, nil
248+ }
249+ return NewReaderWithPeek(read, peek)
250+}
251+
252+func readReaderKey(t *testing.T, r *Reader) Key {
253+ t.Helper()
118 k, err := r.Read()254 k, err := r.Read()
119 if err != nil {255 if err != nil {
120- t.Fatalf("Read(%q): %v", seq, err)256+ t.Fatalf("Read: %v", err)
121 }257 }
122 return k258 return k
123 }259 }
M packages/tui/resize_windows.go +194 -7
@@ -3,8 +3,12 @@
3 package tui3 package tui
4 4
5 import (5 import (
6+ "encoding/binary"
6 "os"7 "os"
7 "time"8 "time"
9+ "unsafe"
10+
11+ "golang.org/x/sys/windows"
8 )12 )
9 13
10 // Windows doesn't have SIGWINCH; resize events are signaled via input14 // Windows doesn't have SIGWINCH; resize events are signaled via input
@@ -14,14 +18,197 @@ func (p *ProcTerm) installResizeHandler() {}
14 18
15 func (p *ProcTerm) SetNonblock(enable bool) error { return nil }19 func (p *ProcTerm) SetNonblock(enable bool) error { return nil }
16 20
17-// peekStdin on Windows: no non-blocking primitive in v1; fall through21+// peekStdin waits for one byte on a supported Windows stdin handle without
18-// to a blocking read. This means bare Esc on Windows is only detected22+// consuming anything unless a byte is available. Console input handles can be
19-// after the next keystroke. Good enough for v1.23+// waited on directly. Pipes are checked with PeekNamedPipe, which does not
20-func peekStdin(in *os.File, _ time.Duration) (byte, bool, error) {24+// consume data; polling that non-consuming check keeps the wait bounded.
21- var b [1]byte25+//
22- _, err := in.Read(b[:])26+// Disk files and other character handles are deliberately unsupported. A
27+// speculative os.File.Read on either can block (or consume a byte that cannot
28+// be put back safely), so those handles report no data instead of corrupting
29+// the escape parser. In particular, do not replace this with a blocking Read
30+// fallback: an isolated Esc must remain observable within d.
31+func peekStdin(in *os.File, d time.Duration) (byte, bool, error) {
32+ handle := windows.Handle(in.Fd())
33+ fileType, err := windows.GetFileType(handle)
23 if err != nil {34 if err != nil {
24 return 0, false, err35 return 0, false, err
25 }36 }
26- return b[0], true, nil37+
38+ switch fileType {
39+ case windows.FILE_TYPE_CHAR:
40+ // FILE_TYPE_CHAR also includes devices such as NUL. GetConsoleMode
41+ // distinguishes an actual console input handle from those devices.
42+ var mode uint32
43+ if err := windows.GetConsoleMode(handle, &mode); err != nil {
44+ return 0, false, windows.ERROR_INVALID_FUNCTION
45+ }
46+ return peekConsole(in, handle, d)
47+ case windows.FILE_TYPE_PIPE:
48+ return peekPipe(in, handle, d)
49+ case windows.FILE_TYPE_DISK:
50+ return 0, false, nil
51+ default:
52+ return 0, false, nil
53+ }
54+}
55+
56+const consoleKeyEvent = 0x0001
57+
58+type consoleInputRecord struct {
59+ eventType uint16
60+ _ uint16
61+ event [16]byte
62+}
63+
64+var (
65+ peekConsoleInputProc = windows.NewLazySystemDLL("kernel32.dll").NewProc("PeekConsoleInputW")
66+ readConsoleInputProc = windows.NewLazySystemDLL("kernel32.dll").NewProc("ReadConsoleInputW")
67+)
68+
69+func consoleInputRecordCall(proc *windows.LazyProc, handle windows.Handle, record *consoleInputRecord) (uint32, error) {
70+ if err := proc.Find(); err != nil {
71+ return 0, err
72+ }
73+ var read uint32
74+ r1, _, callErr := proc.Call(
75+ uintptr(handle),
76+ uintptr(unsafe.Pointer(record)),
77+ 1,
78+ uintptr(unsafe.Pointer(&read)),
79+ )
80+ if r1 == 0 {
81+ if callErr != nil && callErr != windows.ERROR_SUCCESS {
82+ return 0, callErr
83+ }
84+ return 0, windows.ERROR_INVALID_FUNCTION
85+ }
86+ return read, nil
87+}
88+
89+func peekConsole(in *os.File, handle windows.Handle, d time.Duration) (byte, bool, error) {
90+ deadline := time.Now().Add(d)
91+ for {
92+ remaining := d
93+ if d > 0 {
94+ remaining = time.Until(deadline)
95+ if remaining <= 0 {
96+ return 0, false, nil
97+ }
98+ }
99+ event, err := windows.WaitForSingleObject(handle, waitMilliseconds(remaining))
100+ if err != nil {
101+ return 0, false, err
102+ }
103+ switch event {
104+ case uint32(windows.WAIT_TIMEOUT):
105+ return 0, false, nil
106+ case windows.WAIT_OBJECT_0:
107+ var record consoleInputRecord
108+ count, err := consoleInputRecordCall(peekConsoleInputProc, handle, &record)
109+ if err != nil {
110+ return 0, false, err
111+ }
112+ if count == 0 {
113+ if d <= 0 {
114+ return 0, false, nil
115+ }
116+ continue
117+ }
118+ if record.eventType == consoleKeyEvent {
119+ keyDown := binary.LittleEndian.Uint32(record.event[0:4]) != 0
120+ char := binary.LittleEndian.Uint16(record.event[10:12])
121+ if keyDown && char != 0 {
122+ // The callback returns one byte, so leave non-ASCII
123+ // characters queued for the normal reader rather than
124+ // consuming them and truncating their encoding.
125+ if char > 0xff {
126+ return 0, false, nil
127+ }
128+ count, err := consoleInputRecordCall(readConsoleInputProc, handle, &record)
129+ if err != nil {
130+ return 0, false, err
131+ }
132+ if count != 0 {
133+ return byte(char), true, nil
134+ }
135+ continue
136+ }
137+ }
138+ // Consume non-character, key-up, and non-printing records so
139+ // a ready console handle cannot make the next wait spin.
140+ if _, err := consoleInputRecordCall(readConsoleInputProc, handle, &record); err != nil {
141+ return 0, false, err
142+ }
143+ if d <= 0 || time.Until(deadline) <= 0 {
144+ return 0, false, nil
145+ }
146+ continue
147+ default:
148+ return 0, false, windows.ERROR_INVALID_FUNCTION
149+ }
150+ }
151+}
152+
153+func waitMilliseconds(d time.Duration) uint32 {
154+ if d <= 0 {
155+ return 0
156+ }
157+ milliseconds := uint64(d / time.Millisecond)
158+ if d%time.Millisecond != 0 {
159+ milliseconds++
160+ }
161+ const maxWaitMilliseconds = uint64(^uint32(0) - 1) // reserve INFINITE
162+ if milliseconds > maxWaitMilliseconds {
163+ return uint32(maxWaitMilliseconds)
164+ }
165+ return uint32(milliseconds)
166+}
167+
168+var peekNamedPipe = windows.NewLazySystemDLL("kernel32.dll").NewProc("PeekNamedPipe")
169+
170+func peekPipe(in *os.File, handle windows.Handle, d time.Duration) (byte, bool, error) {
171+ if err := peekNamedPipe.Find(); err != nil {
172+ return 0, false, err
173+ }
174+ deadline := time.Now().Add(d)
175+ for {
176+ var available uint32
177+ ok, _, callErr := peekNamedPipe.Call(
178+ uintptr(handle),
179+ 0, 0, 0,
180+ uintptr(unsafe.Pointer(&available)),
181+ 0,
182+ )
183+ if ok == 0 {
184+ if callErr != nil && callErr != windows.ERROR_SUCCESS {
185+ return 0, false, callErr
186+ }
187+ return 0, false, windows.ERROR_INVALID_FUNCTION
188+ }
189+ if available != 0 {
190+ var b [1]byte
191+ n, err := in.Read(b[:])
192+ if err != nil {
193+ return 0, false, err
194+ }
195+ if n != len(b) {
196+ return 0, false, windows.ERROR_INVALID_FUNCTION
197+ }
198+ return b[0], true, nil
199+ }
200+ if d <= 0 {
201+ return 0, false, nil
202+ }
203+ remaining := time.Until(deadline)
204+ if remaining <= 0 {
205+ return 0, false, nil
206+ }
207+ // PeekNamedPipe is a non-consuming availability check. A short
208+ // sleep avoids a busy loop while the deadline remains authoritative.
209+ if remaining > time.Millisecond {
210+ remaining = time.Millisecond
211+ }
212+ time.Sleep(remaining)
213+ }
27 }214 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close