detached 4f7a1da

feat: add session tree and double escape navigation

commit
4f7a1dafa1e1f4e5c5f1ef1a6e0e356ea5df4252
author
brice · · 2026-08-04 10:54 UTC
parent
86b133e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +4266 -656
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
@@ -419,7 +424,7 @@ This is a guardrail against accidents, not a hard security boundary. If you need
419 424
420 ## Sessions425 ## Sessions
421 426
422-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.427+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.
423 428
424 ## Providers429 ## Providers
425 430
@@ -860,7 +865,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
860 | `enter` | Submit (queued if the agent is busy). |865 | `enter` | Submit (queued if the agent is busy). |
861 | `alt+enter` | Newline. |866 | `alt+enter` | Newline. |
862 | `tab` | Complete the selected slash command. |867 | `tab` | Complete the selected slash command. |
863-| `esc` | Cancel the current turn (while busy); clear input (while idle). |868+| `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. |
864 | `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. |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. |
865 | `ctrl+d` | Exit on empty input. |870 | `ctrl+d` | Exit on empty input. |
866 | `ctrl+l` | Redraw the screen. |871 | `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 +393 -54
@@ -198,6 +198,161 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes
198 return provider.RepairOrphanedToolResults(out)198 return provider.RepairOrphanedToolResults(out)
199 }199 }
200 200
201+// sessionResumeCandidate contains all state needed to resume a session.
202+// It is deliberately assembled before the caller changes any live state:
203+// opening the transcript, reading usage, and rebuilding a provider/model
204+// must all succeed before the candidate can replace the current session.
205+type sessionResumeCandidate struct {
206+ session *core.Session
207+ agent *core.Agent
208+ messages []provider.Message
209+ fullMessageCount int
210+ cumulative provider.Usage
211+ lastTurn provider.Usage
212+ provider string
213+ model string
214+ rebuilt bool
215+}
216+
217+// prepareSessionResume opens and validates a selected session without
218+// touching the current agent. Missing provider/model metadata is treated as
219+// legacy metadata: the current agent is reused, preserving compatibility
220+// with sessions written before those fields were persisted.
221+//
222+// The returned candidate owns session until its caller commits it. On any
223+// error this function closes the candidate session and leaves the current
224+// agent untouched.
225+func prepareSessionResume(path string, current *core.Agent, currentProvider, currentModel string, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (candidate sessionResumeCandidate, err error) {
226+ if current == nil {
227+ return candidate, fmt.Errorf("no agent running; log in first")
228+ }
229+
230+ sess, msgs, err := core.OpenSession(path)
231+ if err != nil {
232+ return candidate, err
233+ }
234+ keepSession := false
235+ defer func() {
236+ if !keepSession {
237+ _ = sess.Close()
238+ }
239+ }()
240+
241+ cumulative, lastTurn, err := core.SessionUsageDetail(path)
242+ if err != nil {
243+ return candidate, fmt.Errorf("read session usage: %w", err)
244+ }
245+
246+ fullMessageCount := len(msgs)
247+ resumedMessages := trimMessagesForResume(msgs, 100)
248+ storedProvider := strings.TrimSpace(sess.Meta.Provider)
249+ storedModel := strings.TrimSpace(sess.Meta.Model)
250+ currentProvider = strings.TrimSpace(currentProvider)
251+ currentModel = strings.TrimSpace(currentModel)
252+
253+ candidate = sessionResumeCandidate{
254+ session: sess,
255+ agent: current,
256+ messages: resumedMessages,
257+ fullMessageCount: fullMessageCount,
258+ cumulative: cumulative,
259+ lastTurn: lastTurn,
260+ provider: currentProvider,
261+ model: currentModel,
262+ }
263+
264+ // Old sessions may have either field absent. Only a complete stored
265+ // selection is actionable; otherwise resume exactly as older zot did.
266+ if storedProvider == "" || storedModel == "" ||
267+ (storedProvider == currentProvider && storedModel == currentModel) {
268+ keepSession = true
269+ return candidate, nil
270+ }
271+ if buildAgentFor == nil {
272+ return sessionResumeCandidate{}, fmt.Errorf("cannot resume session with provider %q and model %q: no agent builder configured", storedProvider, storedModel)
273+ }
274+
275+ rebuilt, resolvedProvider, resolvedModel, err := buildAgentFor(storedProvider, storedModel)
276+ if err != nil {
277+ return sessionResumeCandidate{}, fmt.Errorf("rebuild agent for session provider %q/model %q: %w", storedProvider, storedModel, err)
278+ }
279+ if rebuilt == nil {
280+ return sessionResumeCandidate{}, fmt.Errorf("rebuild agent for session provider %q/model %q returned no agent", storedProvider, storedModel)
281+ }
282+ resolvedProvider = strings.TrimSpace(resolvedProvider)
283+ resolvedModel = strings.TrimSpace(resolvedModel)
284+ if resolvedProvider == "" {
285+ resolvedProvider = storedProvider
286+ }
287+ if resolvedModel == "" {
288+ resolvedModel = storedModel
289+ }
290+
291+ // Hydrate and seed the replacement while it is still private. The live
292+ // agent is not modified until the caller commits the complete candidate.
293+ rebuilt.SetMessages(resumedMessages)
294+ rebuilt.SeedCost(cumulative)
295+ rebuilt.SeedLastTurnUsage(lastTurn)
296+ candidate.agent = rebuilt
297+ candidate.provider = resolvedProvider
298+ candidate.model = resolvedModel
299+ candidate.rebuilt = true
300+ keepSession = true
301+ return candidate, nil
302+}
303+
304+// buildNonInteractiveSessionAgent constructs a provider/model-specific agent
305+// for startup resume while preserving the cwd sandbox and extension tools that
306+// were attached to the initial resolution.
307+func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string) (*core.Agent, string, string, error) {
308+ next := args
309+ next.Provider = providerName
310+ next.Model = model
311+ resolved, err := Resolve(next, true)
312+ if err != nil {
313+ return nil, "", "", err
314+ }
315+ resolved.UseSandbox(base.Sandbox)
316+ if extMgr != nil {
317+ resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
318+ }
319+ ag := resolved.NewAgent()
320+ wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
321+ return ag, resolved.Provider, resolved.Model, nil
322+}
323+
324+// applyInitialSessionResume applies the same provider/model-aware candidate
325+// contract used by interactive session switching to non-interactive startup
326+// modes. The returned session owns the replacement append handle.
327+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) {
328+ if sess == nil || ag == nil {
329+ return sess, ag, base.Provider, base.Model, nil
330+ }
331+ // A newly created meta-only session is already on the active pair. Avoid
332+ // reopening and closing it through the resume path: Close intentionally
333+ // removes fresh empty sessions, and a second append handle would otherwise
334+ // keep an unlinked file alive.
335+ if len(ag.Messages()) == 0 &&
336+ strings.TrimSpace(sess.Meta.Provider) == strings.TrimSpace(base.Provider) &&
337+ strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(base.Model) {
338+ return sess, ag, base.Provider, base.Model, nil
339+ }
340+ candidate, err := prepareSessionResume(sess.Path, ag, base.Provider, base.Model, func(providerName, model string) (*core.Agent, string, string, error) {
341+ return buildNonInteractiveSessionAgent(ctx, args, base, extMgr, providerName, model)
342+ })
343+ if err != nil {
344+ _ = sess.Close()
345+ return nil, ag, "", "", err
346+ }
347+ if !candidate.rebuilt {
348+ candidate.agent.SetMessages(candidate.messages)
349+ candidate.agent.SeedCost(candidate.cumulative)
350+ candidate.agent.SeedLastTurnUsage(candidate.lastTurn)
351+ }
352+ _ = sess.Close()
353+ return candidate.session, candidate.agent, candidate.provider, candidate.model, nil
354+}
355+
201 // fanoutAgentEvent translates a core.AgentEvent into the wire-format356 // fanoutAgentEvent translates a core.AgentEvent into the wire-format
202 // EventFromHost and pushes it through the extension manager. Only357 // EventFromHost and pushes it through the extension manager. Only
203 // the events that have a clear extension-facing meaning are358 // the events that have a clear extension-facing meaning are
@@ -426,8 +581,19 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
426 581
427 ag := r.NewAgent()582 ag := r.NewAgent()
428 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)583 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
429- sess, _ := openOrCreateSession(args, r, ag, version)584+ sess, err := openOrCreateSession(args, r, ag, version)
430- defer sess.Close()585+ if err != nil {
586+ return err
587+ }
588+ if sess != nil {
589+ var providerName, model string
590+ sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
591+ if err != nil {
592+ return err
593+ }
594+ r.Provider, r.Model = providerName, model
595+ defer sess.Close()
596+ }
431 597
432 prompt := args.Prompt598 prompt := args.Prompt
433 if prompt == "" {599 if prompt == "" {
@@ -469,8 +635,19 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
469 635
470 ag := r.NewAgent()636 ag := r.NewAgent()
471 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)637 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
472- sess, _ := openOrCreateSession(args, r, ag, version)638+ sess, err := openOrCreateSession(args, r, ag, version)
473- defer sess.Close()639+ if err != nil {
640+ return err
641+ }
642+ if sess != nil {
643+ var providerName, model string
644+ sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
645+ if err != nil {
646+ return err
647+ }
648+ r.Provider, r.Model = providerName, model
649+ defer sess.Close()
650+ }
474 651
475 prompt := args.Prompt652 prompt := args.Prompt
476 if prompt == "" {653 if prompt == "" {
@@ -551,8 +728,19 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
551 728
552 ag := r.NewAgent()729 ag := r.NewAgent()
553 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)730 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
554- sess, _ := openOrCreateSession(args, r, ag, version)731+ sess, err := openOrCreateSession(args, r, ag, version)
555- defer sess.Close()732+ if err != nil {
733+ return err
734+ }
735+ if sess != nil {
736+ var providerName, model string
737+ sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
738+ if err != nil {
739+ return err
740+ }
741+ r.Provider, r.Model = providerName, model
742+ defer sess.Close()
743+ }
556 744
557 prompt := args.Prompt745 prompt := args.Prompt
558 if prompt == "" {746 if prompt == "" {
@@ -695,6 +883,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
695 // rebuilt tool instances must share the same one so the lock sticks.883 // rebuilt tool instances must share the same one so the lock sticks.
696 sharedSandbox := r.Sandbox884 sharedSandbox := r.Sandbox
697 885
886+ // persistMu guards the active session and selection across the agent
887+ // loop, TUI callbacks, and session swaps. Declare it before tool
888+ // construction because swarm defaults must read the live selection too.
889+ var persistMu sync.Mutex
890+ // Serialize session transitions with persistence callbacks so a candidate
891+ // is read only after the old agent is flushed and cannot be overwritten by
892+ // an in-flight append before commit.
893+ var sessionTransitionMu sync.RWMutex
894+ activeProvider := r.Provider
895+ activeModel := r.Model
896+
698 // Build the extension manager BEFORE the agent so we can fold897 // Build the extension manager BEFORE the agent so we can fold
699 // extension-defined tools into the registry. Attach the interactive898 // extension-defined tools into the registry. Attach the interactive
700 // host after constructing it below so startup alerts can be buffered.899 // host after constructing it below so startup alerts can be buffered.
@@ -791,10 +990,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
791 return reg990 return reg
792 }991 }
793 reg["swarm_spawn"] = &tools.SwarmSpawnTool{992 reg["swarm_spawn"] = &tools.SwarmSpawnTool{
794- Swarm: swarmMgr,993+ Swarm: swarmMgr,
795- Enabled: AutoSwarmEnabled,994+ Enabled: AutoSwarmEnabled,
796- DefaultModel: func() string { return r.Model },995+ DefaultModel: func() string {
797- DefaultProvider: func() string { return r.Provider },996+ persistMu.Lock()
997+ defer persistMu.Unlock()
998+ return activeModel
999+ },
1000+ DefaultProvider: func() string {
1001+ persistMu.Lock()
1002+ defer persistMu.Unlock()
1003+ return activeProvider
1004+ },
798 DefaultReasoning: func() string { return r.Reasoning },1005 DefaultReasoning: func() string { return r.Reasoning },
799 ResolveSubagent: resolveSubagent,1006 ResolveSubagent: resolveSubagent,
800 OnSpawned: onSpawnedSwarm,1007 OnSpawned: onSpawnedSwarm,
@@ -957,17 +1164,42 @@ func runInteractive(ctx context.Context, args Args, version string) error {
957 1164
958 var sess *core.Session1165 var sess *core.Session
959 var sessBaselineMsgs int // messages already on disk when current session opened1166 var sessBaselineMsgs int // messages already on disk when current session opened
960- // persistMu guards sess + sessBaselineMsgs against concurrent access1167+ // persistMu also guards sess + sessBaselineMsgs, so a session swap
961- // from the agent loop's per-message persistence hook (runs on the1168+ // commits the transcript writer and live selection under the same lock.
962- // agent goroutine) and the TUI's session swap / flush callbacks
963- // (run on the TUI goroutine). Without this, a /sessions swap that
964- // races with a finishing turn could double-write or lose messages.
965- var persistMu sync.Mutex
966 if !args.NoSess && ag != nil {1169 if !args.NoSess && ag != nil {
967 sess, _ = openOrCreateSession(args, r, ag, version)1170 sess, _ = openOrCreateSession(args, r, ag, version)
968 if ag != nil {1171 if ag != nil {
969 sessBaselineMsgs = len(ag.Messages())1172 sessBaselineMsgs = len(ag.Messages())
970 }1173 }
1174+ if sess != nil {
1175+ // Startup resume uses the same provider/model-aware preparation as
1176+ // the interactive session picker. This matters for --continue,
1177+ // --resume, and explicit --session paths whose latest meta row was
1178+ // written by another provider/model.
1179+ if !(len(ag.Messages()) == 0 &&
1180+ strings.TrimSpace(sess.Meta.Provider) == strings.TrimSpace(r.Provider) &&
1181+ strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(r.Model)) {
1182+ candidate, resumeErr := prepareSessionResume(sess.Path, ag, r.Provider, r.Model, buildAgentFor)
1183+ if resumeErr != nil {
1184+ _ = sess.Close()
1185+ return resumeErr
1186+ }
1187+ oldSess := sess
1188+ if !candidate.rebuilt {
1189+ candidate.agent.SetMessages(candidate.messages)
1190+ candidate.agent.SeedCost(candidate.cumulative)
1191+ candidate.agent.SeedLastTurnUsage(candidate.lastTurn)
1192+ }
1193+ _ = oldSess.Close()
1194+ sess = candidate.session
1195+ ag = candidate.agent
1196+ sessBaselineMsgs = candidate.fullMessageCount
1197+ activeProvider = candidate.provider
1198+ activeModel = candidate.model
1199+ r.Provider = candidate.provider
1200+ r.Model = candidate.model
1201+ }
1202+ }
971 }1203 }
972 defer func() {1204 defer func() {
973 persistMu.Lock()1205 persistMu.Lock()
@@ -984,6 +1216,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
984 // baseline counter advances in lock-step so the exit-time flush1216 // baseline counter advances in lock-step so the exit-time flush
985 // doesn't double-write rows already on disk.1217 // doesn't double-write rows already on disk.
986 persistMessage := func(m provider.Message) {1218 persistMessage := func(m provider.Message) {
1219+ sessionTransitionMu.RLock()
1220+ defer sessionTransitionMu.RUnlock()
987 persistMu.Lock()1221 persistMu.Lock()
988 defer persistMu.Unlock()1222 defer persistMu.Unlock()
989 if sess == nil {1223 if sess == nil {
@@ -994,6 +1228,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
994 }1228 }
995 }1229 }
996 persistUsage := func(cum provider.Usage) {1230 persistUsage := func(cum provider.Usage) {
1231+ sessionTransitionMu.RLock()
1232+ defer sessionTransitionMu.RUnlock()
997 persistMu.Lock()1233 persistMu.Lock()
998 defer persistMu.Unlock()1234 defer persistMu.Unlock()
999 if sess == nil {1235 if sess == nil {
@@ -1002,6 +1238,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1002 _ = sess.AppendUsage(cum, cum)1238 _ = sess.AppendUsage(cum, cum)
1003 }1239 }
1004 persistCompaction := func(messages []provider.Message) {1240 persistCompaction := func(messages []provider.Message) {
1241+ sessionTransitionMu.RLock()
1242+ defer sessionTransitionMu.RUnlock()
1005 persistMu.Lock()1243 persistMu.Lock()
1006 defer persistMu.Unlock()1244 defer persistMu.Unlock()
1007 if sess == nil {1245 if sess == nil {
@@ -1025,7 +1263,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1025 // Re-wrap the build closures so any agent constructed by the TUI1263 // Re-wrap the build closures so any agent constructed by the TUI
1026 // (login, /model swap to a different provider) also gets the1264 // (login, /model swap to a different provider) also gets the
1027 // persistence hooks. Without this, switching provider would1265 // persistence hooks. Without this, switching provider would
1028- // silently revert to the old in-memory-only behaviour.1266+ // silently revert to the old in-memory-only behaviour. Selection state
1267+ // is published by the successful transition itself, not by preparation.
1029 baseBuildAgent := buildAgent1268 baseBuildAgent := buildAgent
1030 buildAgent = func() (*core.Agent, string, string, error) {1269 buildAgent = func() (*core.Agent, string, string, error) {
1031 a, p, m, err := baseBuildAgent()1270 a, p, m, err := baseBuildAgent()
@@ -1045,47 +1284,100 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1045 // loadSession replaces the current session with the one at path and1284 // loadSession replaces the current session with the one at path and
1046 // hands its messages to the agent. Used by the /sessions picker.1285 // hands its messages to the agent. Used by the /sessions picker.
1047 loadSession := func(path string) error {1286 loadSession := func(path string) error {
1287+ // Hold the transition lock from the pre-flush through the commit.
1288+ // Persistence callbacks take the read side, so an active session
1289+ // cannot be snapshotted before its lazy writes land or overwritten by
1290+ // a callback while the candidate is being installed.
1291+ sessionTransitionMu.Lock()
1292+ defer sessionTransitionMu.Unlock()
1293+
1048 currentAg := liveInteractiveAgent(iv, ag)1294 currentAg := liveInteractiveAgent(iv, ag)
1049 if currentAg == nil {1295 if currentAg == nil {
1050 return fmt.Errorf("no agent running; log in first")1296 return fmt.Errorf("no agent running; log in first")
1051 }1297 }
1052- newSess, msgs, err := core.OpenSession(path)1298+
1299+ persistMu.Lock()
1300+ currentProvider := activeProvider
1301+ currentModel := activeModel
1302+ oldSess := sess
1303+ // Flush before reading the candidate. Use the locked form rather
1304+ // than the public callback, which would acquire the read lock held
1305+ // by this transition.
1306+ if oldSess != nil {
1307+ writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)
1308+ }
1309+ currentMessageCount := len(currentAg.Messages())
1310+ currentCost := currentAg.Cost()
1311+ persistMu.Unlock()
1312+
1313+ // A candidate build must not publish its provider/model as the live
1314+ // selection before the candidate commits.
1315+ candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
1053 if err != nil {1316 if err != nil {
1317+ // prepareSessionResume closes its append handle on failure;
1318+ // no live agent or session has been changed.
1054 return err1319 return err
1055 }1320 }
1056- fullMsgCount := len(msgs)1321+ committed := false
1057- msgs = trimMessagesForResume(msgs, 100)1322+ defer func() {
1323+ if !committed {
1324+ _ = candidate.session.Close()
1325+ }
1326+ }()
1327+
1328+ // Wire the private candidate before it can become visible. This is
1329+ // also required for legacy same-provider resumes, where the current
1330+ // agent is reused and only its transcript/usage are refreshed.
1331+ wireAgentPersist(candidate.agent)
1332+
1058 persistMu.Lock()1333 persistMu.Lock()
1059- // Flush any unsaved messages to the old session before swapping.1334+ // A provider rebuild ran outside persistMu. Refuse to install it if
1060- // Per-message persistence keeps sessBaselineMsgs current, so1335+ // the live agent, session, selection, transcript, or cost changed in
1061- // this is a defensive no-op in the common case; it still1336+ // the meantime; otherwise a slow resume could discard a newer turn or
1062- // matters for the rare race where a turn just finished and1337+ // model swap.
1063- // hadn't fired its hook yet.1338+ live := liveInteractiveAgent(iv, ag)
1064- if sess != nil {1339+ changed := sess != oldSess || activeProvider != currentProvider || activeModel != currentModel || live != currentAg
1065- writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)1340+ if !changed && live != nil {
1066- _ = sess.Close()1341+ changed = len(live.Messages()) != currentMessageCount || live.Cost() != currentCost
1342+ }
1343+ if changed {
1344+ persistMu.Unlock()
1345+ return fmt.Errorf("session changed while loading; please try again")
1067 }1346 }
1068- sess = newSess1347+
1069- currentAg.SetMessages(msgs)1348+ if oldSess != nil {
1070- if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {1349+ _ = oldSess.Close()
1071- currentAg.SeedCost(cum)
1072- currentAg.SeedLastTurnUsage(last)
1073- }
1074- // The live agent only receives a compact resume window, but
1075- // the session file remains intact. Keep the persistence
1076- // baseline at the original on-disk message count so future
1077- // turns append after the full session instead of duplicating
1078- // the hydrated tail.
1079- sessBaselineMsgs = fullMsgCount
1080- persistMu.Unlock()
1081- // Re-scope the swarm dashboard to the new session so /swarm
1082- // only shows agents this session spawned. swarmMgr may be nil
1083- // here if we haven't reached the construction site yet (it
1084- // shouldn't be, since the interactive loop is what triggers
1085- // loadSession, but the nil check is cheap insurance).
1086- if swarmMgr != nil && newSess != nil {
1087- swarmMgr.SetActiveSession(newSess.ID)
1088 }1350 }
1351+ sess = candidate.session
1352+ // The live agent only receives a compact resume window, but the
1353+ // session file remains intact. Keep the persistence baseline at
1354+ // the original on-disk message count so future turns append after
1355+ // the full session instead of duplicating the hydrated tail.
1356+ sessBaselineMsgs = candidate.fullMessageCount
1357+
1358+ if candidate.rebuilt {
1359+ // Commit the TUI agent while persistMu still protects the session
1360+ // writer, so no new message can land on the old file between the
1361+ // two state changes.
1362+ if iv != nil {
1363+ iv.ApplySessionAgent(candidate.agent, candidate.provider, candidate.model)
1364+ }
1365+ ag = candidate.agent
1366+ } else {
1367+ currentAg.SetMessages(candidate.messages)
1368+ currentAg.SeedCost(candidate.cumulative)
1369+ currentAg.SeedLastTurnUsage(candidate.lastTurn)
1370+ ag = currentAg
1371+ }
1372+ activeProvider = candidate.provider
1373+ activeModel = candidate.model
1374+ if swarmMgr != nil && candidate.session != nil {
1375+ // Keep the dashboard scope in the same commit as the session,
1376+ // agent, usage, and persistence baseline.
1377+ swarmMgr.SetActiveSession(candidate.session.ID)
1378+ }
1379+ committed = true
1380+ persistMu.Unlock()
1089 return nil1381 return nil
1090 }1382 }
1091 1383
@@ -1105,6 +1397,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1105 // The /jail state is preserved verbatim: if the sandbox was locked1397 // The /jail state is preserved verbatim: if the sandbox was locked
1106 // to the old cwd, it stays locked, just re-pointed at the new one.1398 // to the old cwd, it stays locked, just re-pointed at the new one.
1107 changeCWD := func(path string) error {1399 changeCWD := func(path string) error {
1400+ sessionTransitionMu.Lock()
1401+ defer sessionTransitionMu.Unlock()
1108 if path == "" {1402 if path == "" {
1109 return fmt.Errorf("empty path")1403 return fmt.Errorf("empty path")
1110 }1404 }
@@ -1135,7 +1429,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1135 return fmt.Errorf("not a directory: %s", absPath)1429 return fmt.Errorf("not a directory: %s", absPath)
1136 }1430 }
1137 1431
1138- currentAg := ag1432+ currentAg := liveInteractiveAgent(iv, ag)
1139 if currentAg == nil {1433 if currentAg == nil {
1140 return fmt.Errorf("no agent running; log in first")1434 return fmt.Errorf("no agent running; log in first")
1141 }1435 }
@@ -1146,6 +1440,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1146 oldCWD := args.CWD1440 oldCWD := args.CWD
1147 oldResolvedCWD := r.CWD1441 oldResolvedCWD := r.CWD
1148 oldPermissionSet := args.PermissionSet1442 oldPermissionSet := args.PermissionSet
1443+ persistMu.Lock()
1444+ oldActiveProvider := activeProvider
1445+ oldActiveModel := activeModel
1446+ persistMu.Unlock()
1149 oldSwarmRoot := ""1447 oldSwarmRoot := ""
1150 if swarmMgr != nil {1448 if swarmMgr != nil {
1151 oldSwarmRoot = swarmMgr.RepoRoot()1449 oldSwarmRoot = swarmMgr.RepoRoot()
@@ -1160,6 +1458,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1160 args.CWD = oldCWD1458 args.CWD = oldCWD
1161 r.CWD = oldResolvedCWD1459 r.CWD = oldResolvedCWD
1162 args.PermissionSet = oldPermissionSet1460 args.PermissionSet = oldPermissionSet
1461+ persistMu.Lock()
1462+ activeProvider = oldActiveProvider
1463+ activeModel = oldActiveModel
1464+ persistMu.Unlock()
1163 if swarmMgr != nil {1465 if swarmMgr != nil {
1164 swarmMgr.SetRepoRoot(oldSwarmRoot)1466 swarmMgr.SetRepoRoot(oldSwarmRoot)
1165 }1467 }
@@ -1224,6 +1526,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1224 }1526 }
1225 sess = newSess1527 sess = newSess
1226 sessBaselineMsgs = 01528 sessBaselineMsgs = 0
1529+ activeProvider = newProvider
1530+ activeModel = newModel
1227 persistMu.Unlock()1531 persistMu.Unlock()
1228 ag = newAg1532 ag = newAg
1229 1533
@@ -1413,12 +1717,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1413 LoadSession: loadSession,1717 LoadSession: loadSession,
1414 ChangeCWD: changeCWD,1718 ChangeCWD: changeCWD,
1415 CurrentSessionPath: func() string {1719 CurrentSessionPath: func() string {
1720+ persistMu.Lock()
1721+ defer persistMu.Unlock()
1416 if sess == nil {1722 if sess == nil {
1417 return ""1723 return ""
1418 }1724 }
1419 return sess.Path1725 return sess.Path
1420 },1726 },
1421 FlushSession: func() {1727 FlushSession: func() {
1728+ sessionTransitionMu.RLock()
1729+ defer sessionTransitionMu.RUnlock()
1422 // Append any not-yet-persisted agent messages to the1730 // Append any not-yet-persisted agent messages to the
1423 // current session file, then advance the baseline so1731 // current session file, then advance the baseline so
1424 // the final WriteNewTranscript at exit doesn't write1732 // the final WriteNewTranscript at exit doesn't write
@@ -1439,6 +1747,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1439 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)1747 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1440 sessBaselineMsgs = len(currentAg.Messages())1748 sessBaselineMsgs = len(currentAg.Messages())
1441 },1749 },
1750+ SessionTransition: func(fn func()) {
1751+ sessionTransitionMu.Lock()
1752+ defer sessionTransitionMu.Unlock()
1753+ fn()
1754+ },
1442 Extensions: extMgr,1755 Extensions: extMgr,
1443 Swarm: swarmMgr,1756 Swarm: swarmMgr,
1444 ResolveSubagent: resolveSubagent,1757 ResolveSubagent: resolveSubagent,
@@ -1473,14 +1786,40 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1473 NoYolo: args.NoYolo,1786 NoYolo: args.NoYolo,
1474 ConfirmGate: confirmGate,1787 ConfirmGate: confirmGate,
1475 PersistModel: func(providerName, model string) {1788 PersistModel: func(providerName, model string) {
1789+ providerName = strings.TrimSpace(providerName)
1790+ model = strings.TrimSpace(model)
1791+ // Persist the active session metadata before publishing the
1792+ // selection used by future resumes. UpdateModel restores its old
1793+ // in-memory metadata when the append fails.
1794+ persistMu.Lock()
1795+ var sessionErr error
1796+ if sess != nil {
1797+ sessionErr = sess.UpdateModel(providerName, model)
1798+ }
1799+ if sessionErr == nil {
1800+ activeProvider = providerName
1801+ activeModel = model
1802+ }
1803+ persistMu.Unlock()
1804+ if sessionErr != nil {
1805+ if iv != nil {
1806+ iv.Notify("session", "error", "model change was not persisted: "+sessionErr.Error())
1807+ }
1808+ return
1809+ }
1810+
1476 // Update config.json so next launch uses the same pick.1811 // Update config.json so next launch uses the same pick.
1477- cfg, _ := LoadConfig()1812+ cfg, cfgErr := LoadConfig()
1813+ if cfgErr != nil {
1814+ if iv != nil {
1815+ iv.Notify("session", "error", "model changed for this run, but config could not be read: "+cfgErr.Error())
1816+ }
1817+ return
1818+ }
1478 cfg.Provider = providerName1819 cfg.Provider = providerName
1479 cfg.Model = model1820 cfg.Model = model
1480- _ = SaveConfig(cfg)1821+ if cfgErr = SaveConfig(cfg); cfgErr != nil && iv != nil {
1481- // Update the active session's meta so resume picks this up.1822+ iv.Notify("session", "error", "model changed for this run, but config could not be saved: "+cfgErr.Error())
1482- if sess != nil {
1483- _ = sess.UpdateModel(providerName, model)
1484 }1823 }
1485 },1824 },
1486 })1825 })
M packages/agent/cli_session_test.go +151 -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"
@@ -46,3 +49,151 @@ func firstMessageText(messages []provider.Message) string {
46 text, _ := messages[0].Content[0].(provider.TextBlock)49 text, _ := messages[0].Content[0].(provider.TextBlock)
47 return text.Text50 return text.Text
48 }51 }
52+
53+func syntheticSession(t *testing.T, providerName, model string, usage provider.Usage) string {
54+ t.Helper()
55+ root := t.TempDir()
56+ cwd := t.TempDir()
57+ sess, err := core.NewSession(root, cwd, providerName, model, "test")
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ if err := sess.AppendMessage(provider.Message{
62+ Role: provider.RoleUser,
63+ Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
64+ }); err != nil {
65+ t.Fatal(err)
66+ }
67+ if usage != (provider.Usage{}) {
68+ if err := sess.AppendUsage(usage, usage); err != nil {
69+ t.Fatal(err)
70+ }
71+ }
72+ if err := sess.Close(); err != nil {
73+ t.Fatal(err)
74+ }
75+ return sess.Path
76+}
77+
78+func TestApplyInitialSessionResumeKeepsFreshEmptySessionOwned(t *testing.T) {
79+ root := t.TempDir()
80+ sess, err := core.NewSession(root, "/workspace", "provider", "model", "test")
81+ if err != nil {
82+ t.Fatal(err)
83+ }
84+ ag := core.NewAgent(nil, "model", "", nil)
85+ gotSess, gotAgent, providerName, model, err := applyInitialSessionResume(context.Background(), Args{}, Resolved{Provider: "provider", Model: "model"}, nil, sess, ag)
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ if gotSess != sess || gotAgent != ag || providerName != "provider" || model != "model" {
90+ t.Fatalf("empty startup resume = session %p/%p agent %p/%p provider/model %q/%q", gotSess, sess, gotAgent, ag, providerName, model)
91+ }
92+ if _, err := os.Stat(sess.Path); err != nil {
93+ t.Fatalf("fresh session disappeared before close: %v", err)
94+ }
95+ if err := sess.Close(); err != nil {
96+ t.Fatal(err)
97+ }
98+ if _, err := os.Stat(sess.Path); !errors.Is(err, os.ErrNotExist) {
99+ t.Fatalf("fresh empty session remained after close: %v", err)
100+ }
101+}
102+
103+func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
104+ old := core.NewAgent(nil, "old-model", "", nil)
105+ old.SetMessages([]provider.Message{{
106+ Role: provider.RoleUser,
107+ Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
108+ }})
109+ path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{
110+ InputTokens: 12,
111+ OutputTokens: 7,
112+ })
113+
114+ var gotProvider, gotModel string
115+ candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(providerName, model string) (*core.Agent, string, string, error) {
116+ gotProvider, gotModel = providerName, model
117+ return core.NewAgent(nil, model, "", nil), providerName, model, nil
118+ })
119+ if err != nil {
120+ t.Fatal(err)
121+ }
122+ defer candidate.session.Close()
123+
124+ if gotProvider != "stored-provider" || gotModel != "stored-model" {
125+ t.Fatalf("builder selection = %q/%q, want stored-provider/stored-model", gotProvider, gotModel)
126+ }
127+ if !candidate.rebuilt || candidate.agent == old {
128+ t.Fatalf("candidate rebuilt = %v, agent=%p, old=%p", candidate.rebuilt, candidate.agent, old)
129+ }
130+ if got := firstMessageText(candidate.agent.Messages()); got != "resumed transcript" {
131+ t.Fatalf("candidate transcript = %q, want resumed transcript", got)
132+ }
133+ if got := candidate.agent.Cost(); got.InputTokens != 12 || got.OutputTokens != 7 {
134+ t.Fatalf("candidate usage = %+v, want input=12 output=7", got)
135+ }
136+ if got := old.Messages(); len(got) != 1 || firstMessageText(got) != "current transcript" {
137+ t.Fatalf("current transcript changed during preparation: %+v", got)
138+ }
139+}
140+
141+func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) {
142+ old := core.NewAgent(nil, "old-model", "", nil)
143+ old.SetMessages([]provider.Message{{
144+ Role: provider.RoleUser,
145+ Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
146+ }})
147+ path := syntheticSession(t, "", "", provider.Usage{})
148+
149+ candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
150+ t.Fatal("legacy session unexpectedly requested a rebuild")
151+ return nil, "", "", nil
152+ })
153+ if err != nil {
154+ t.Fatal(err)
155+ }
156+ defer candidate.session.Close()
157+
158+ if candidate.rebuilt || candidate.agent != old {
159+ t.Fatalf("legacy candidate = rebuilt %v, agent=%p; want current agent without rebuild", candidate.rebuilt, candidate.agent)
160+ }
161+ if got := firstMessageText(old.Messages()); got != "current transcript" {
162+ t.Fatalf("current transcript changed before commit: %q", got)
163+ }
164+ if got := firstMessageText(candidate.messages); got != "resumed transcript" {
165+ t.Fatalf("legacy candidate transcript = %q, want resumed transcript", got)
166+ }
167+}
168+
169+func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) {
170+ old := core.NewAgent(nil, "old-model", "", nil)
171+ old.SetMessages([]provider.Message{{
172+ Role: provider.RoleUser,
173+ Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
174+ }})
175+ path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
176+ wantErr := errors.New("synthetic builder failure")
177+
178+ if _, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
179+ return nil, "", "", wantErr
180+ }); !errors.Is(err, wantErr) {
181+ t.Fatalf("prepare error = %v, want %v", err, wantErr)
182+ }
183+ if got := firstMessageText(old.Messages()); got != "current transcript" {
184+ t.Fatalf("current transcript changed after failed preparation: %q", got)
185+ }
186+
187+ // The failed candidate must have released its append handle, leaving
188+ // the selected file readable and reopenable for a later retry.
189+ reopened, _, err := core.OpenSession(path)
190+ if err != nil {
191+ t.Fatalf("reopen candidate after failed preparation: %v", err)
192+ }
193+ if err := reopened.Close(); err != nil {
194+ t.Fatal(err)
195+ }
196+ if _, err := os.Stat(path); err != nil {
197+ t.Fatalf("candidate session disappeared after failed preparation: %v", err)
198+ }
199+}
A packages/agent/modes/double_escape.go +372 -0
@@ -0,0 +1,372 @@
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+ return false
316+ }
317+ for _, content := range msg.Content {
318+ result, ok := content.(provider.ToolResultBlock)
319+ if !ok || !pending[result.CallID] {
320+ return false
321+ }
322+ delete(pending, result.CallID)
323+ }
324+ }
325+ }
326+ return len(pending) == 0
327+}
328+
329+func sessionTreeDraft(msg provider.Message, fallback string) (string, []clipboardImageAttachment) {
330+ // Agent.Prompt stores the text first and appends image blocks after it,
331+ // even when an older transcript contains several text blocks. Rebuild
332+ // that same editor representation: join every text block, then place
333+ // image markers after the complete text so submitting the restored draft
334+ // recreates the original text/images pair without losing any text.
335+ var textBlocks []string
336+ var imageBlocks []provider.ImageBlock
337+ for _, content := range msg.Content {
338+ switch block := content.(type) {
339+ case provider.TextBlock:
340+ textBlocks = append(textBlocks, block.Text)
341+ case provider.ImageBlock:
342+ imageBlocks = append(imageBlocks, block)
343+ }
344+ }
345+ var text strings.Builder
346+ if len(textBlocks) > 0 {
347+ text.WriteString(strings.Join(textBlocks, "\n"))
348+ } else {
349+ text.WriteString(fallback)
350+ }
351+ images := make([]clipboardImageAttachment, 0, len(imageBlocks))
352+ for _, block := range imageBlocks {
353+ if text.Len() > 0 {
354+ last := text.String()
355+ if len(last) > 0 && last[len(last)-1] != ' ' && last[len(last)-1] != '\n' && last[len(last)-1] != '\t' {
356+ text.WriteByte(' ')
357+ }
358+ }
359+ marker := fmt.Sprintf("[clipboard image #%d]", len(images)+1)
360+ text.WriteString(marker)
361+ text.WriteByte(' ')
362+ images = append(images, clipboardImageAttachment{
363+ Marker: marker,
364+ Image: provider.ImageBlock{
365+ MimeType: block.MimeType,
366+ Data: append([]byte(nil), block.Data...),
367+ ThoughtSignature: block.ThoughtSignature,
368+ },
369+ })
370+ }
371+ return text.String(), images
372+}
A packages/agent/modes/double_escape_test.go +382 -0
@@ -0,0 +1,382 @@
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 TestSessionTreeGateFailureDoesNotFlushOrOpenOverlay(t *testing.T) {
130+ i := NewInteractive(InteractiveConfig{})
131+ flushes := 0
132+ i.cfg.FlushSession = func() { flushes++ }
133+
134+ i.doSessionTree()
135+ if flushes != 0 {
136+ t.Fatalf("FlushSession called %d times on a failed gate", flushes)
137+ }
138+ if i.sessionTreeDialog.Active() {
139+ t.Fatal("session tree overlay opened after a failed gate")
140+ }
141+}
142+
143+func TestSessionTreeGateRejectsModelRefreshInFlight(t *testing.T) {
144+ root := t.TempDir()
145+ cwd := "/workspace/model-refresh-gate"
146+ session, err := core.NewSession(root, cwd, "test", "model", "test")
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "message"}}}
151+ if err := session.AppendMessage(message); err != nil {
152+ t.Fatal(err)
153+ }
154+ path := session.Path
155+ if err := session.Close(); err != nil {
156+ t.Fatal(err)
157+ }
158+ i := NewInteractive(InteractiveConfig{
159+ Agent: core.NewAgent(nil, "model", "", nil),
160+ CWD: cwd,
161+ SessionsRoot: root,
162+ CurrentSessionPath: func() string { return path },
163+ LoadSession: func(string) error { return nil },
164+ })
165+ i.agent.SetMessages([]provider.Message{message})
166+ i.mu.Lock()
167+ i.modelRefreshing = true
168+ i.mu.Unlock()
169+ if i.canOpenSessionTree() {
170+ t.Fatal("tree gate opened while model refresh was in flight")
171+ }
172+}
173+
174+func TestSessionTreeSelectionGateAllowsActiveTree(t *testing.T) {
175+ root := t.TempDir()
176+ cwd := "/workspace/selection-gate"
177+ session, err := core.NewSession(root, cwd, "test", "model", "test")
178+ if err != nil {
179+ t.Fatal(err)
180+ }
181+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "message"}}}
182+ if err := session.AppendMessage(message); err != nil {
183+ t.Fatal(err)
184+ }
185+ path := session.Path
186+ if err := session.Close(); err != nil {
187+ t.Fatal(err)
188+ }
189+ ag := core.NewAgent(nil, "model", "", nil)
190+ ag.SetMessages([]provider.Message{message})
191+ i := NewInteractive(InteractiveConfig{
192+ Agent: ag,
193+ CWD: cwd,
194+ SessionsRoot: root,
195+ CurrentSessionPath: func() string {
196+ return path
197+ },
198+ LoadSession: func(string) error { return nil },
199+ })
200+ i.sessionTreeDialog.active = true
201+ if i.canOpenSessionTree() {
202+ t.Fatal("open gate accepted an already-active tree")
203+ }
204+ if !i.canCommitSessionTreeSelection() {
205+ t.Fatal("selection gate rejected the active tree dialog")
206+ }
207+}
208+
209+func TestDoubleEscapeOpensReadableTreeThroughSharedGate(t *testing.T) {
210+ root := t.TempDir()
211+ cwd := "/workspace/double-escape"
212+ session, err := core.NewSession(root, cwd, "test", "model", "test")
213+ if err != nil {
214+ t.Fatal(err)
215+ }
216+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
217+ if err := session.AppendMessage(message); err != nil {
218+ t.Fatal(err)
219+ }
220+ path := session.Path
221+ if err := session.Close(); err != nil {
222+ t.Fatal(err)
223+ }
224+
225+ ag := core.NewAgent(nil, "model", "", nil)
226+ ag.SetMessages([]provider.Message{message})
227+ now := time.Unix(200, 0)
228+ flushes := 0
229+ i := NewInteractive(InteractiveConfig{
230+ Agent: ag,
231+ CWD: cwd,
232+ SessionsRoot: root,
233+ CurrentSessionPath: func() string {
234+ return path
235+ },
236+ LoadSession: func(string) error { return nil },
237+ FlushSession: func() {
238+ flushes++
239+ },
240+ })
241+ i.clock = func() time.Time { return now }
242+
243+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
244+ t.Fatal("first Escape exited")
245+ }
246+ if i.sessionTreeDialog.Active() || flushes != 0 {
247+ t.Fatalf("first Escape opened/flushed: active=%v flushes=%d", i.sessionTreeDialog.Active(), flushes)
248+ }
249+ now = now.Add(499 * time.Millisecond)
250+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
251+ t.Fatal("second Escape exited")
252+ }
253+ if !i.sessionTreeDialog.Active() {
254+ t.Fatal("readable session tree did not open")
255+ }
256+ if flushes != 1 {
257+ t.Fatalf("FlushSession called %d times, want once after the gate", flushes)
258+ }
259+ if got := len(ag.Messages()); got != 1 {
260+ t.Fatalf("double Escape changed transcript with a provider turn: %d messages", got)
261+ }
262+}
263+
264+func TestSecondEscapeFallsThroughToCancellationWhenTurnBecomesBusy(t *testing.T) {
265+ root := t.TempDir()
266+ cwd := "/workspace/double-escape-busy"
267+ session, err := core.NewSession(root, cwd, "test", "model", "test")
268+ if err != nil {
269+ t.Fatal(err)
270+ }
271+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
272+ if err := session.AppendMessage(message); err != nil {
273+ t.Fatal(err)
274+ }
275+ path := session.Path
276+ if err := session.Close(); err != nil {
277+ t.Fatal(err)
278+ }
279+
280+ ag := core.NewAgent(nil, "model", "", nil)
281+ ag.SetMessages([]provider.Message{message})
282+ now := time.Unix(300, 0)
283+ cancelled := 0
284+ i := NewInteractive(InteractiveConfig{
285+ Agent: ag,
286+ CWD: cwd,
287+ SessionsRoot: root,
288+ CurrentSessionPath: func() string { return path },
289+ LoadSession: func(string) error { return nil },
290+ })
291+ i.clock = func() time.Time { return now }
292+
293+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
294+ t.Fatal("first Escape exited")
295+ }
296+ i.mu.Lock()
297+ i.busy = true
298+ i.cancelTurn = func() { cancelled++ }
299+ i.mu.Unlock()
300+ now = now.Add(100 * time.Millisecond)
301+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
302+ t.Fatal("second Escape exited")
303+ }
304+ if cancelled != 1 {
305+ t.Fatalf("second Escape did not fall through to cancellation: got %d cancels", cancelled)
306+ }
307+ if i.sessionTreeDialog.Active() {
308+ t.Fatal("session tree opened after the turn became busy")
309+ }
310+ if i.doubleEscape.armed {
311+ t.Fatal("stale double-Escape gesture remained armed after falling through")
312+ }
313+}
314+
315+func TestSecondEscapeStillConsumesFailClosedTreeGate(t *testing.T) {
316+ root := t.TempDir()
317+ cwd := "/workspace/double-escape-gate"
318+ session, err := core.NewSession(root, cwd, "test", "model", "test")
319+ if err != nil {
320+ t.Fatal(err)
321+ }
322+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
323+ if err := session.AppendMessage(message); err != nil {
324+ t.Fatal(err)
325+ }
326+ path := session.Path
327+ if err := session.Close(); err != nil {
328+ t.Fatal(err)
329+ }
330+
331+ ag := core.NewAgent(nil, "model", "", nil)
332+ ag.SetMessages([]provider.Message{message})
333+ now := time.Unix(400, 0)
334+ pathCalls := 0
335+ flushes := 0
336+ i := NewInteractive(InteractiveConfig{
337+ Agent: ag,
338+ CWD: cwd,
339+ SessionsRoot: root,
340+ CurrentSessionPath: func() string {
341+ pathCalls++
342+ if pathCalls == 1 {
343+ return path
344+ }
345+ // Keep the cheap gesture eligibility true while making the full
346+ // tree-family gate reject the second tap.
347+ return path + ".missing"
348+ },
349+ LoadSession: func(string) error { return nil },
350+ FlushSession: func() {
351+ flushes++
352+ },
353+ })
354+ i.clock = func() time.Time { return now }
355+
356+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
357+ t.Fatal("first Escape exited")
358+ }
359+ now = now.Add(100 * time.Millisecond)
360+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
361+ t.Fatal("second Escape exited")
362+ }
363+ if i.sessionTreeDialog.Active() {
364+ t.Fatal("fail-closed tree gate opened an overlay")
365+ }
366+ if i.statusErr != "tree: session tree is unavailable" {
367+ t.Fatalf("gate status = %q, want the fail-closed tree error", i.statusErr)
368+ }
369+ if flushes != 0 {
370+ t.Fatalf("FlushSession called %d times after the rejected gate", flushes)
371+ }
372+}
373+
374+func TestForkCommandsWithoutSessionPathReportError(t *testing.T) {
375+ for _, command := range []string{"/fork", "/session fork"} {
376+ i := NewInteractive(InteractiveConfig{})
377+ i.runSlash(context.Background(), command)
378+ if i.statusErr != "fork: no session is active (running with --no-session?)" {
379+ t.Fatalf("%s status = %q", command, i.statusErr)
380+ }
381+ }
382+}
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 +254 -97
@@ -251,6 +251,12 @@ type InteractiveConfig struct {
251 // file that only has the meta row.251 // file that only has the meta row.
252 FlushSession func()252 FlushSession func()
253 253
254+ // SessionTransition serializes a state-changing session/model
255+ // operation with the host's session load. It is optional for embedders;
256+ // the CLI uses it to prevent a slow provider rebuild from being replaced
257+ // by a concurrent resume (or vice versa).
258+ SessionTransition func(func())
259+
254 // PersistModel is called whenever the user switches model or provider.260 // PersistModel is called whenever the user switches model or provider.
255 // It should update config.json and (if there's an active session)261 // It should update config.json and (if there's an active session)
256 // write a new meta row so resume picks up the same model.262 // write a new meta row so resume picks up the same model.
@@ -424,6 +430,7 @@ type Interactive struct {
424 busy bool430 busy bool
425 dirty chan struct{}431 dirty chan struct{}
426 modelRefresh chan modelRefreshResult432 modelRefresh chan modelRefreshResult
433+ modelRefreshing bool
427 startupPreDone chan startupPreResult434 startupPreDone chan startupPreResult
428 cancelTurn context.CancelFunc435 cancelTurn context.CancelFunc
429 scrollOffset int // rows from the bottom; 0 = pinned to latest436 scrollOffset int // rows from the bottom; 0 = pinned to latest
@@ -543,6 +550,11 @@ type Interactive struct {
543 // within ctrlCExitWindow exits. Mirrors the python-repl convention.550 // within ctrlCExitWindow exits. Mirrors the python-repl convention.
544 lastCtrlC time.Time551 lastCtrlC time.Time
545 552
553+ // doubleEscape tracks the optional bare-Escape session-tree gesture.
554+ // clock is injectable so the gesture can be tested without sleeping.
555+ doubleEscape doubleEscapeTracker
556+ clock func() time.Time
557+
546 // welcomeStart is when the interactive run began. The welcome558 // welcomeStart is when the interactive run began. The welcome
547 // banner shows the binary version for welcomeVersionDuration559 // banner shows the binary version for welcomeVersionDuration
548 // after this point and reverts to plain text after.560 // after this point and reverts to plain text after.
@@ -671,6 +683,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
671 fileSuggest: newFileSuggester(),683 fileSuggest: newFileSuggester(),
672 spin: newSpinner(cfg.Theme),684 spin: newSpinner(cfg.Theme),
673 inputHistoryIndex: -1,685 inputHistoryIndex: -1,
686+ clock: time.Now,
674 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),687 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
675 }688 }
676 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)689 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
@@ -915,6 +928,9 @@ func (i *Interactive) Run(ctx context.Context) error {
915 i.handleAuthEvent(ev)928 i.handleAuthEvent(ev)
916 i.invalidate()929 i.invalidate()
917 case result := <-i.modelRefresh:930 case result := <-i.modelRefresh:
931+ i.mu.Lock()
932+ i.modelRefreshing = false
933+ i.mu.Unlock()
918 i.openModelPickerAfterRefresh(result.err)934 i.openModelPickerAfterRefresh(result.err)
919 i.invalidate()935 i.invalidate()
920 case result := <-i.startupPreDone:936 case result := <-i.startupPreDone:
@@ -1323,6 +1339,14 @@ func (i *Interactive) redraw() {
1323 case i.sessionOpsDialog.Active():1339 case i.sessionOpsDialog.Active():
1324 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)1340 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
1325 case i.sessionTreeDialog.Active():1341 case i.sessionTreeDialog.Active():
1342+ // Reserve rows for the editor, status, and tree chrome while using
1343+ // the same budget for rendering and PageUp/PageDown movement.
1344+ _, rows := i.cfg.Terminal.Size()
1345+ avail := rows - 12
1346+ if avail < 3 {
1347+ avail = 3
1348+ }
1349+ i.sessionTreeDialog.MaxRows = avail
1326 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)1350 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
1327 }1351 }
1328 if len(dialog) > 0 {1352 if len(dialog) > 0 {
@@ -2074,6 +2098,13 @@ func (i *Interactive) restoreConfirmFocus() {
2074 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {2098 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2075 defer i.restoreConfirmFocus()2099 defer i.restoreConfirmFocus()
2076 2100
2101+ // A bare Escape is eligible for the session-tree gesture only while the
2102+ // main input owns the key. Any other key, modified Escape, or visible
2103+ // child interaction resets the pending first tap before normal routing.
2104+ if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() {
2105+ i.doubleEscape.Reset()
2106+ }
2107+
2077 // Dialogs route keys before the main clipboard handler below. Resolve text2108 // Dialogs route keys before the main clipboard handler below. Resolve text
2078 // here when a child interaction owns input so every editor and filter sees2109 // here when a child interaction owns input so every editor and filter sees
2079 // the same KeyPaste event as terminal-native bracketed paste. Main chat is2110 // the same KeyPaste event as terminal-native bracketed paste. Main chat is
@@ -2303,7 +2334,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2303 }2334 }
2304 act := i.sessionTreeDialog.HandleKey(k)2335 act := i.sessionTreeDialog.HandleKey(k)
2305 if act.Select {2336 if act.Select {
2306- i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)2337+ if act.Target.Role != "" || act.Target.SourcePath != "" || act.Target.IsBoundary() || act.Target.UserDraft != "" {
2338+ i.applySessionTreeTarget(act.Target, act.TurnNo)
2339+ } else {
2340+ // Keep package embedders that still return the scalar action
2341+ // source-compatible while the dialog migrates to Target.
2342+ i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
2343+ }
2307 }2344 }
2308 i.invalidate()2345 i.invalidate()
2309 return false2346 return false
@@ -2384,6 +2421,28 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2384 return false2421 return false
2385 }2422 }
2386 2423
2424+ // The first eligible bare Escape is deliberately left to the existing
2425+ // Escape path (which clears idle input or cancels a turn). A second tap
2426+ // within the gesture window only gets first refusal while the cheap,
2427+ // local eligibility rules still hold. If a turn started, a message was
2428+ // queued, or the input became ineligible between taps, reset the detector
2429+ // and fall through so Escape retains its normal cancellation behavior.
2430+ // Once those rules still hold, the full tree gate owns the second tap:
2431+ // family/read failures are fail-closed but are nevertheless consumed so
2432+ // they cannot fall through into ordinary editor handling.
2433+ if isUnmodifiedEscape(k) {
2434+ now := i.sessionTreeEscapeNow()
2435+ if i.doubleEscape.Consume(now) {
2436+ if i.canArmSessionTreeEscape() {
2437+ i.openSessionTree()
2438+ return false
2439+ }
2440+ i.doubleEscape.Reset()
2441+ } else if i.canArmSessionTreeEscape() {
2442+ i.doubleEscape.Arm(now)
2443+ }
2444+ }
2445+
2387 // Global keys.2446 // Global keys.
2388 switch k.Kind {2447 switch k.Kind {
2389 case tui.KeyCtrlC:2448 case tui.KeyCtrlC:
@@ -2690,18 +2749,27 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2690 if text == "" && len(images) == 0 {2749 if text == "" && len(images) == 0 {
2691 return false2750 return false
2692 }2751 }
2693- i.clipboardImages = nil2752+ clearSubmittedInput := func() {
2694- i.ed.Clear()2753+ i.clipboardImages = nil
2695- i.inputHistoryIndex = -12754+ i.ed.Clear()
2696- i.suggest.Reset()2755+ i.inputHistoryIndex = -1
2697- i.fileSuggest.Reset()2756+ i.suggest.Reset()
2757+ i.fileSuggest.Reset()
2758+ }
2698 2759
2699- if cmd, ok := shellEscapeCommand(text); ok {2760+ // Shell escapes and slash commands are text-only. An image-bearing
2700- i.startShellEscape(ctx, cmd)2761+ // draft remains a normal provider prompt instead of silently dropping
2701- return false2762+ // its attachments.
2763+ if len(images) == 0 {
2764+ if cmd, ok := shellEscapeCommand(text); ok {
2765+ clearSubmittedInput()
2766+ i.startShellEscape(ctx, cmd)
2767+ return false
2768+ }
2702 }2769 }
2703 2770
2704- if looksLikeSlashCommand(text) {2771+ if len(images) == 0 && looksLikeSlashCommand(text) {
2772+ clearSubmittedInput()
2705 head := text2773 head := text
2706 rest := ""2774 rest := ""
2707 if idx := strings.IndexAny(text, " \t"); idx >= 0 {2775 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
@@ -2724,12 +2792,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2724 }2792 }
2725 // Slash commands run regardless of busy state. Commands that2793 // Slash commands run regardless of busy state. Commands that
2726 // would mutate the transcript or replace the agent (/clear,2794 // would mutate the transcript or replace the agent (/clear,
2727- // /compact, /logout, /login, /model) cancel the active turn2795+ // /compact, /logout, /login, /model, /fork, /session fork)
2796+ // cancel the active turn
2728 // first and wait for the goroutine to wind down so they don't2797 // first and wait for the goroutine to wind down so they don't
2729 // race with a streaming response. Safe commands (/help,2798 // race with a streaming response. Safe commands (/help,
2730 // /jump, /sessions, /jail, /unjail, /exit) run immediately2799 // /jump, /sessions, /jail, /unjail, /exit) run immediately
2731 // without disturbing the active turn.2800 // without disturbing the active turn.
2732- if slashCancelsTurn(head) {2801+ if slashCommandCancelsTurn(text) {
2733 i.cancelAndWaitForIdle()2802 i.cancelAndWaitForIdle()
2734 }2803 }
2735 return i.runSlash(ctx, text)2804 return i.runSlash(ctx, text)
@@ -2765,6 +2834,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2765 i.invalidate()2834 i.invalidate()
2766 return false2835 return false
2767 }2836 }
2837+ clearSubmittedInput()
2768 if ag != nil && !compacting {2838 if ag != nil && !compacting {
2769 ag.QueueMessage(text)2839 ag.QueueMessage(text)
2770 } else {2840 } else {
@@ -2775,6 +2845,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2775 i.invalidate()2845 i.invalidate()
2776 return false2846 return false
2777 }2847 }
2848+ clearSubmittedInput()
2778 i.startTurnWithImages(ctx, text, images)2849 i.startTurnWithImages(ctx, text, images)
2779 }2850 }
2780 return false2851 return false
@@ -3105,6 +3176,38 @@ func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3105 }3176 }
3106 }3177 }
3107 3178
3179+// ApplySessionAgent swaps the live agent after a validated session resume.
3180+// Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup
3181+// context; the session loader is changing the transcript/provider only.
3182+func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) {
3183+ if ag == nil {
3184+ return
3185+ }
3186+ i.mu.Lock()
3187+ i.agent = ag
3188+ i.cfg.Provider = providerName
3189+ i.cfg.Model = model
3190+ i.view.Messages = filterHiddenTranscriptMessages(ag.Messages())
3191+ i.cumUsage = ag.Cost()
3192+ last := ag.LastTurnUsage()
3193+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
3194+ if len(i.view.Messages) > initialResumeTailLimit {
3195+ i.view.TailLimit = initialResumeTailLimit
3196+ } else {
3197+ i.view.TailLimit = 0
3198+ }
3199+ i.view.InvalidateRenderCache()
3200+ i.toolCalls = map[string]*tui.ToolCallView{}
3201+ i.toolOrder = nil
3202+ i.toolGate = map[string]int{}
3203+ i.helpBlock = nil
3204+ i.extNotes = nil
3205+ i.parkedTurn = 0
3206+ i.parkedTotal = 0
3207+ i.mu.Unlock()
3208+ i.invalidate()
3209+}
3210+
3108 // ApplyChangedCWD is called by hosts after a successful /cd hook that do3211 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
3109 // not provide startup context metadata.3212 // not provide startup context metadata.
3110 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {3213 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
@@ -3165,6 +3268,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
3165 // agent cancel the active turn first via the same path the editor3268 // agent cancel the active turn first via the same path the editor
3166 // uses for typed slash commands.3269 // uses for typed slash commands.
3167 func (i *Interactive) SubmitSlash(text string) {3270 func (i *Interactive) SubmitSlash(text string) {
3271+ i.doubleEscape.Reset()
3168 text = strings.TrimSpace(text)3272 text = strings.TrimSpace(text)
3169 if !strings.HasPrefix(text, "/") {3273 if !strings.HasPrefix(text, "/") {
3170 return3274 return
@@ -4462,6 +4566,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4462 i.applyModelSelection("", parts[1])4566 i.applyModelSelection("", parts[1])
4463 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {4567 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
4464 i.mu.Lock()4568 i.mu.Lock()
4569+ i.modelRefreshing = true
4465 i.statusOK = "refreshing models"4570 i.statusOK = "refreshing models"
4466 i.statusErr = ""4571 i.statusErr = ""
4467 i.mu.Unlock()4572 i.mu.Unlock()
@@ -4497,6 +4602,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4497 i.openSettingsDialog()4602 i.openSettingsDialog()
4498 case "/sessions":4603 case "/sessions":
4499 i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)4604 i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)
4605+ case "/fork":
4606+ i.doSessionFork()
4500 case "/jump":4607 case "/jump":
4501 i.openJumpDialog(parts[1:])4608 i.openJumpDialog(parts[1:])
4502 case "/btw":4609 case "/btw":
@@ -5336,6 +5443,16 @@ func (i *Interactive) applyRescueModelSelection(prov, model string) {
5336 // the supplied builder. rescue=true tags the success message so the5443 // the supplied builder. rescue=true tags the success message so the
5337 // user can see that launch-time overrides were ignored.5444 // user can see that launch-time overrides were ignored.
5338 func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {5445 func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
5446+ if i.cfg.SessionTransition != nil {
5447+ i.cfg.SessionTransition(func() {
5448+ i.swapModelUnserialized(prov, model, builder, rescue)
5449+ })
5450+ return
5451+ }
5452+ i.swapModelUnserialized(prov, model, builder, rescue)
5453+}
5454+
5455+func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
5339 if model == "" {5456 if model == "" {
5340 return5457 return
5341 }5458 }
@@ -5438,15 +5555,27 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
5438 i.dialog.ShowResult(false, err.Error())5555 i.dialog.ShowResult(false, err.Error())
5439 return5556 return
5440 }5557 }
5441- i.mu.Lock()5558+ commitLogin := func() {
5442- i.agent = ag5559+ i.mu.Lock()
5443- i.cfg.Provider = prov5560+ i.agent = ag
5444- i.cfg.Model = model5561+ i.cfg.Provider = prov
5445- i.statusErr = ""5562+ i.cfg.Model = model
5446- i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method5563+ i.statusErr = ""
5447- i.mu.Unlock()5564+ i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5448- i.applyAutoSwarmTool(i.autoSwarmEnabled())5565+ i.mu.Unlock()
5449- i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())5566+ i.applyAutoSwarmTool(i.autoSwarmEnabled())
5567+ i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5568+ // Authentication can change the provider used by the live
5569+ // agent. Persist it on the active session just like /model.
5570+ if i.cfg.PersistModel != nil {
5571+ i.cfg.PersistModel(prov, model)
5572+ }
5573+ }
5574+ if i.cfg.SessionTransition != nil {
5575+ i.cfg.SessionTransition(commitLogin)
5576+ } else {
5577+ commitLogin()
5578+ }
5450 i.dialog.ShowResult(true, "")5579 i.dialog.ShowResult(true, "")
5451 }5580 }
5452 }5581 }
@@ -6885,14 +7014,7 @@ func (i *Interactive) doSessionOp(action, arg string) {
6885 // the user's home directory if it doesn't exist). The helper7014 // the user's home directory if it doesn't exist). The helper
6886 // expands a leading `~` and creates any missing parent directories.7015 // expands a leading `~` and creates any missing parent directories.
6887 func (i *Interactive) doSessionExport(dst string) {7016 func (i *Interactive) doSessionExport(dst string) {
6888- if i.cfg.CurrentSessionPath == nil {7017+ src := i.currentSessionPath()
6889- i.mu.Lock()
6890- i.statusErr = "export: no session is active (running with --no-session?)"
6891- i.mu.Unlock()
6892- i.invalidate()
6893- return
6894- }
6895- src := i.cfg.CurrentSessionPath()
6896 if src == "" {7018 if src == "" {
6897 i.mu.Lock()7019 i.mu.Lock()
6898 i.statusErr = "export: no session is active (running with --no-session?)"7020 i.statusErr = "export: no session is active (running with --no-session?)"
@@ -7047,114 +7169,156 @@ func friendlyPath(p string) string {
7047 // next selection branches the current session at that user turn7169 // next selection branches the current session at that user turn
7048 // instead of scrolling the viewport to it.7170 // instead of scrolling the viewport to it.
7049 func (i *Interactive) doSessionFork() {7171 func (i *Interactive) doSessionFork() {
7050- if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {7172+ path := i.currentSessionPath()
7173+ if path == "" {
7051 i.mu.Lock()7174 i.mu.Lock()
7052 i.statusErr = "fork: no session is active (running with --no-session?)"7175 i.statusErr = "fork: no session is active (running with --no-session?)"
7053 i.mu.Unlock()7176 i.mu.Unlock()
7054 i.invalidate()7177 i.invalidate()
7055 return7178 return
7056 }7179 }
7057- msgs := []provider.Message{}7180+ i.mu.Lock()
7058- if i.agent != nil {7181+ busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
7059- msgs = i.agent.Messages()7182+ queued := len(i.queued) != 0
7060- }7183+ ag := i.agent
7061- if len(msgs) == 0 {7184+ i.mu.Unlock()
7185+ if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
7062 i.mu.Lock()7186 i.mu.Lock()
7063- i.statusErr = "fork: transcript is empty; nothing to fork from"7187+ i.statusErr = "fork: wait until the current turn and queued messages finish"
7064 i.mu.Unlock()7188 i.mu.Unlock()
7065 i.invalidate()7189 i.invalidate()
7066 return7190 return
7067 }7191 }
7068- i.pendingFork = true7192+ if i.cfg.FlushSession != nil {
7069- i.jumpDialog.Open(msgs, "")7193+ i.cfg.FlushSession()
7070- i.invalidate()
7071-}
7072-
7073-// doSessionTree shows the current session family as an inline branch tree.
7074-// Pick an entry to check out that point into a new branch. Subsequent
7075-// prompts use only context up to the selected message.
7076-func (i *Interactive) doSessionTree() {
7077- current := ""
7078- if i.cfg.CurrentSessionPath != nil {
7079- current = i.cfg.CurrentSessionPath()
7080 }7194 }
7081- if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {7195+ snapshot, err := core.ReadSessionSnapshot(path)
7196+ if err != nil {
7197+ i.mu.Lock()
7198+ i.statusErr = "fork: " + err.Error()
7199+ i.mu.Unlock()
7082 i.invalidate()7200 i.invalidate()
7083 return7201 return
7084 }7202 }
7085- msgs := []provider.Message{}7203+ msgs := snapshot.Messages
7086- if i.agent != nil {
7087- msgs = i.agent.Messages()
7088- }
7089 if len(msgs) == 0 {7204 if len(msgs) == 0 {
7090 i.mu.Lock()7205 i.mu.Lock()
7091- i.statusErr = "tree: no messages in this session yet"7206+ i.statusErr = "fork: transcript is empty; nothing to fork from"
7092 i.mu.Unlock()7207 i.mu.Unlock()
7093 i.invalidate()7208 i.invalidate()
7094 return7209 return
7095 }7210 }
7096- if !i.sessionTreeDialog.OpenMessages(msgs) {7211+ i.pendingFork = true
7097- i.mu.Lock()7212+ i.jumpDialog.Open(msgs, "")
7098- i.statusErr = "tree: no messages to show"
7099- i.mu.Unlock()
7100- }
7101 i.invalidate()7213 i.invalidate()
7102 }7214 }
7103 7215
7104-// applySessionTreeMessageSelection checks out the selected message into a7216+// doSessionTree opens the current session family after the shared
7105-// new branch, then switches the running agent to that branch. Unlike /jump,7217+// fail-closed gate succeeds. It intentionally does not fall back to the
7106-// this changes future model context: only messages up to the selected row are7218+// in-memory transcript: a tree that cannot be persisted and reloaded cannot
7107-// sent on subsequent turns.7219+// safely create a navigation branch.
7220+func (i *Interactive) doSessionTree() {
7221+ i.openSessionTree()
7222+}
7223+
7224+// applySessionTreeMessageSelection keeps the old scalar integration
7225+// source-compatible for package consumers. New dialog selections use the
7226+// structured sessionTreeTarget path below.
7108 func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {7227 func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
7109- if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {7228+ target := sessionTreeTarget{
7110- i.mu.Lock()7229+ SourcePath: src,
7111- i.statusErr = "tree: session branching is not available in this build"7230+ EffectiveIndex: msgIdx,
7112- i.mu.Unlock()7231+ SelectionBoundary: msgIdx + 1,
7113- i.invalidate()7232+ Role: role,
7233+ UserDraft: prompt,
7234+ Boundary: sessionTreeMessageBoundary,
7235+ }
7236+ if role == provider.RoleUser {
7237+ target.SelectionBoundary = msgIdx
7238+ }
7239+ i.applySessionTreeTarget(target, turnNo)
7240+}
7241+
7242+// applySessionTreeTarget checks out a structured dialog target. The target's
7243+// source and effective indices come from the same preflight snapshot the
7244+// dialog rendered, while the live read below preserves compatibility with
7245+// the existing LoadSession(path) error callback.
7246+func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
7247+ // Selection is a state-changing operation too. Use the selection gate so
7248+ // a queued/busy turn cannot race the hidden branch or accidentally submit
7249+ // a provider turn while the session is being swapped. The open gate cannot
7250+ // be reused here because the tree dialog itself owns the keyboard.
7251+ if !i.canCommitSessionTreeSelection() {
7252+ i.setSessionTreeError("tree: session branching is not available in this build")
7114 return7253 return
7115 }7254 }
7255+ src := target.SourcePath
7116 if src == "" {7256 if src == "" {
7117- src = i.cfg.CurrentSessionPath()7257+ src = i.currentSessionPath()
7118 }7258 }
7119 if src == "" {7259 if src == "" {
7120- i.mu.Lock()7260+ i.setSessionTreeError("tree: no session is active")
7121- i.statusErr = "tree: no session is active"7261+ return
7122- i.mu.Unlock()7262+ }
7123- i.invalidate()7263+
7264+ // Read and validate the selected row before flushing or creating anything.
7265+ // This gives tool-call/result pairs an atomic boundary and prevents a
7266+ // malformed selection from changing the active session.
7267+ sess, msgs, err := core.OpenSession(src)
7268+ if err != nil {
7269+ i.setSessionTreeError("tree: read selection: " + err.Error())
7270+ return
7271+ }
7272+ _ = sess.Close()
7273+ selection, err := sessionTreeSelection(msgs, target)
7274+ if err != nil {
7275+ i.setSessionTreeError("tree: " + err.Error())
7124 return7276 return
7125 }7277 }
7126 if i.cfg.FlushSession != nil {7278 if i.cfg.FlushSession != nil {
7127 i.cfg.FlushSession()7279 i.cfg.FlushSession()
7128 }7280 }
7129- upTo := msgIdx + 17281+ newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
7130- if role == provider.RoleUser {
7131- upTo = msgIdx
7132- }
7133- newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
7134 if err != nil {7282 if err != nil {
7135- i.mu.Lock()7283+ i.setSessionTreeError("tree: " + err.Error())
7136- i.statusErr = "tree: " + err.Error()
7137- i.mu.Unlock()
7138- i.invalidate()
7139 return7284 return
7140 }7285 }
7141 if err := i.cfg.LoadSession(newPath); err != nil {7286 if err := i.cfg.LoadSession(newPath); err != nil {
7142- i.mu.Lock()7287+ i.setSessionTreeError("tree: checkout failed: " + err.Error())
7143- i.statusErr = "tree: checkout failed: " + err.Error()
7144- i.mu.Unlock()
7145- i.invalidate()
7146 return7288 return
7147 }7289 }
7148- i.scrollToBottom()7290+
7291+ // LoadSession is intentionally the old func(string) error callback: the
7292+ // CLI and embedders already own agent/session swapping. Refresh the view
7293+ // from the swapped agent here instead of requiring a new callback result.
7149 i.mu.Lock()7294 i.mu.Lock()
7150- if role == provider.RoleUser {7295+ i.lastCtxInput = 0
7151- i.ed.SetValue(prompt)7296+ if i.agent != nil {
7297+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
7298+ i.cumUsage = i.agent.Cost()
7299+ last := i.agent.LastTurnUsage()
7300+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
7301+ i.view.InvalidateRenderCache()
7302+ }
7303+ i.toolCalls = map[string]*tui.ToolCallView{}
7304+ i.toolOrder = nil
7305+ i.toolGate = map[string]int{}
7306+ i.extNotes = nil
7307+ if selection.restoreDraft {
7308+ i.ed.SetValue(selection.draftText)
7309+ i.clipboardImages = selection.images
7152 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)7310 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
7153 } else {7311 } else {
7312+ i.ed.Clear()
7313+ i.clipboardImages = nil
7154 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)7314 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
7155 }7315 }
7156 i.statusErr = ""7316 i.statusErr = ""
7157 i.mu.Unlock()7317 i.mu.Unlock()
7318+ i.scrollToBottom()
7319+ // Selection only changes local/session state. In particular, do not call
7320+ // Submit, startTurn, or queue a message here; the restored user draft is
7321+ // submitted only by a later explicit Enter.
7158 i.invalidate()7322 i.invalidate()
7159 }7323 }
7160 7324
@@ -7164,14 +7328,7 @@ func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnN
7164 // file. Called from the jump-dialog handler when pendingFork=true.7328 // file. Called from the jump-dialog handler when pendingFork=true.
7165 func (i *Interactive) applyForkSelection(msgIdx int) {7329 func (i *Interactive) applyForkSelection(msgIdx int) {
7166 i.pendingFork = false7330 i.pendingFork = false
7167- if i.cfg.CurrentSessionPath == nil {7331+ src := i.currentSessionPath()
7168- i.mu.Lock()
7169- i.statusErr = "fork: no session is active"
7170- i.mu.Unlock()
7171- i.invalidate()
7172- return
7173- }
7174- src := i.cfg.CurrentSessionPath()
7175 if src == "" {7332 if src == "" {
7176 i.mu.Lock()7333 i.mu.Lock()
7177 i.statusErr = "fork: no session is active"7334 i.statusErr = "fork: no session is active"
M packages/agent/modes/session_tree_dialog.go +599 -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
193 }409 }
194 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)
424+}
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+ selectionBoundary := 0
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,42 @@ 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' {
728+ // OSC sequences end at BEL or ST. Drop the whole sequence when
729+ // present; an isolated ESC is dropped as well.
730+ i++
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 unicode.IsControl(runes[i]) {
744+ b.WriteByte(' ')
745+ } else {
746+ b.WriteRune(runes[i])
747+ }
748+ i++
749+ }
750+ return strings.Join(strings.Fields(b.String()), " ")
751+}
752+
343 func colorSessionTreeLine(th tui.Theme, line string) string {753 func colorSessionTreeLine(th tui.Theme, line string) string {
344 plain := strings.TrimSpace(line)754 plain := strings.TrimSpace(line)
345 switch {755 switch {
@@ -354,13 +764,96 @@ func colorSessionTreeLine(th tui.Theme, line string) string {
354 }764 }
355 }765 }
356 766
767+// fitSessionTreeLabel truncates by terminal cells, not Go runes. This is
768+// intentionally also used for the indentation and current marker together so
769+// neither can push a row past the terminal edge.
357 func fitSessionTreeLabel(label string, maxWidth int) string {770 func fitSessionTreeLabel(label string, maxWidth int) string {
358- if maxWidth < 4 {771+ if maxWidth <= 0 {
359- maxWidth = 4772+ return ""
360 }773 }
361- runes := []rune(label)774+ if runewidth.StringWidth(label) <= maxWidth {
362- if len(runes) <= maxWidth {
363 return label775 return label
364 }776 }
365- return string(runes[:maxWidth-3]) + "..."777+ if maxWidth <= 3 {
778+ return strings.Repeat(".", maxWidth)
779+ }
780+ return truncateSessionTreePlain(label, maxWidth-3) + "..."
781+}
782+
783+func truncateSessionTreePlain(s string, maxWidth int) string {
784+ if maxWidth <= 0 {
785+ return ""
786+ }
787+ var b strings.Builder
788+ used := 0
789+ for _, r := range s {
790+ w := runewidth.RuneWidth(r)
791+ if w > 0 && used+w > maxWidth {
792+ break
793+ }
794+ b.WriteRune(r)
795+ used += w
796+ }
797+ return b.String()
798+}
799+
800+// truncateSessionTreeANSI is the dialog-local equivalent of tui's renderer
801+// clipping helper. Keeping it here avoids changing tui for one dialog while
802+// still making headers, hints, colors, and highlighted rows width-safe.
803+func truncateSessionTreeANSI(s string, maxWidth int) string {
804+ if maxWidth <= 0 {
805+ return ""
806+ }
807+ if sessionTreeANSIWidth(s) <= maxWidth {
808+ return s
809+ }
810+ runes := []rune(s)
811+ var b strings.Builder
812+ used := 0
813+ clipped := false
814+ for i := 0; i < len(runes); {
815+ if end, ok := sessionTreeCSIEnd(runes, i); ok {
816+ b.WriteString(string(runes[i:end]))
817+ i = end
818+ continue
819+ }
820+ w := runewidth.RuneWidth(runes[i])
821+ if w > 0 && used+w > maxWidth {
822+ clipped = true
823+ break
824+ }
825+ b.WriteRune(runes[i])
826+ used += w
827+ i++
828+ }
829+ if clipped && strings.Contains(s, "\x1b[") {
830+ b.WriteString("\x1b[0m")
831+ }
832+ return b.String()
833+}
834+
835+func sessionTreeANSIWidth(s string) int {
836+ runes := []rune(s)
837+ width := 0
838+ for i := 0; i < len(runes); {
839+ if end, ok := sessionTreeCSIEnd(runes, i); ok {
840+ i = end
841+ continue
842+ }
843+ width += runewidth.RuneWidth(runes[i])
844+ i++
845+ }
846+ return width
847+}
848+
849+func sessionTreeCSIEnd(runes []rune, start int) (int, bool) {
850+ if start+1 >= len(runes) || runes[start] != '\x1b' || runes[start+1] != '[' {
851+ return start, false
852+ }
853+ for i := start + 2; i < len(runes); i++ {
854+ if runes[i] >= 0x40 && runes[i] <= 0x7e {
855+ return i + 1, true
856+ }
857+ }
858+ return len(runes), true
366 }859 }
A packages/agent/modes/session_tree_dialog_test.go +283 -0
@@ -0,0 +1,283 @@
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 TestSessionTreeRenderAndPagingAreWidthAndHeightSafe(t *testing.T) {
191+ msgs := make([]provider.Message, 0, 20)
192+ for i := 0; i < 20; i++ {
193+ msgs = append(msgs, treeTestMessage(provider.RoleUser,
194+ provider.TextBlock{Text: strings.Repeat("界", 4) + " long row"}))
195+ }
196+ d := newSessionTreeDialog()
197+ d.MaxRows = 2
198+ if !d.OpenMessages(msgs) {
199+ t.Fatal("OpenMessages returned false")
200+ }
201+ if got := d.HandleKey(tui.Key{Kind: tui.KeyPageUp}); !got.Select && d.cursor != 17 {
202+ t.Fatalf("page up cursor = %d, action=%+v", d.cursor, got)
203+ }
204+ for _, width := range []int{0, 1, 7, 17} {
205+ for _, line := range d.Render(tui.Theme{}, width) {
206+ if got := sessionTreeANSIWidth(line); got > width {
207+ t.Fatalf("width %d rendered %d cells: %q", width, got, line)
208+ }
209+ }
210+ }
211+ if d.viewTop < 0 || d.viewTop+d.MaxRows > len(d.items) {
212+ t.Fatalf("invalid viewport top=%d rows=%d items=%d", d.viewTop, d.MaxRows, len(d.items))
213+ }
214+}
215+
216+func TestSessionTreeCheckoutRefreshesLastTurnUsageIncludingZero(t *testing.T) {
217+ root := t.TempDir()
218+ cwd := "/workspace/tree-usage"
219+ path := newTreeTestSession(t, root, cwd, []provider.Message{
220+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "question"}),
221+ treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "answer"}),
222+ })
223+
224+ initial := core.NewAgent(nil, "model", "", nil)
225+ initial.SeedLastTurnUsage(provider.Usage{InputTokens: 99})
226+ selected := core.NewAgent(nil, "model", "", nil)
227+ selected.SetMessages(initial.Messages())
228+ selected.SeedLastTurnUsage(provider.Usage{
229+ InputTokens: 7,
230+ CacheReadTokens: 11,
231+ CacheWriteTokens: 13,
232+ })
233+
234+ i := NewInteractive(InteractiveConfig{
235+ Agent: initial,
236+ CWD: cwd,
237+ SessionsRoot: root,
238+ CurrentSessionPath: func() string { return path },
239+ })
240+ i.cfg.LoadSession = func(string) error {
241+ i.agent = selected
242+ return nil
243+ }
244+
245+ target := sessionTreeTarget{
246+ SourcePath: path,
247+ EffectiveIndex: 1,
248+ SelectionBoundary: 2,
249+ Role: provider.RoleAssistant,
250+ }
251+ i.applySessionTreeTarget(target, 1)
252+ if i.lastCtxInput != 31 {
253+ t.Fatalf("checked-out context usage = %d, want 31", i.lastCtxInput)
254+ }
255+
256+ selected.SeedLastTurnUsage(provider.Usage{})
257+ i.applySessionTreeTarget(target, 1)
258+ if i.lastCtxInput != 0 {
259+ t.Fatalf("checked-out zero usage left stale context value %d", i.lastCtxInput)
260+ }
261+}
262+
263+func treeTestMessage(role provider.Role, content ...provider.Content) provider.Message {
264+ return provider.Message{Role: role, Content: content, Time: time.Unix(1, 0)}
265+}
266+
267+func newTreeTestSession(t *testing.T, root, cwd string, msgs []provider.Message) string {
268+ t.Helper()
269+ sess, err := core.NewSession(root, cwd, "test", "model", "test")
270+ if err != nil {
271+ t.Fatal(err)
272+ }
273+ for _, msg := range msgs {
274+ if err := sess.AppendMessage(msg); err != nil {
275+ _ = sess.Close()
276+ t.Fatal(err)
277+ }
278+ }
279+ if err := sess.Close(); err != nil {
280+ t.Fatal(err)
281+ }
282+ return sess.Path
283+}
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/session.go +465 -243
@@ -69,18 +69,36 @@ type SessionMeta struct {
69 // the default unmarshaler cannot reconstruct); it is written with a69 // the default unmarshaler cannot reconstruct); it is written with a
70 // regular provider.Message value.70 // regular provider.Message value.
71 type sessionLine struct {71 type sessionLine struct {
72- Type string `json:"type"`72+ Type string `json:"type"`
73- Meta *SessionMeta `json:"meta,omitempty"`73+ Meta *SessionMeta `json:"meta,omitempty"`
74- Message *provider.Message `json:"message,omitempty"`74+ Message *provider.Message `json:"message,omitempty"`
75- Messages []provider.Message `json:"messages,omitempty"`75+ Messages *[]provider.Message `json:"messages,omitempty"`
76- Usage *provider.Usage `json:"usage,omitempty"`76+ Usage *provider.Usage `json:"usage,omitempty"`
77- Cumulative *provider.Usage `json:"cumulative,omitempty"`77+ Cumulative *provider.Usage `json:"cumulative,omitempty"`
78 }78 }
79 79
80 type sessionLineHead struct {80 type sessionLineHead struct {
81 Type string `json:"type"`81 Type string `json:"type"`
82 }82 }
83 83
84+// SessionUsageCheckpoint records the cumulative usage known when a usage
85+// row was written. MessageCount uses the effective transcript (after the
86+// latest compaction and tool-pair repair), not the number of raw JSONL rows.
87+type SessionUsageCheckpoint struct {
88+ MessageCount int
89+ Cumulative provider.Usage
90+}
91+
92+// SessionSnapshot is the effective, in-memory view of a session file.
93+// Messages are the transcript that a resumed agent should use. The raw
94+// JSONL audit history is deliberately not exposed here because message
95+// indices used by tree navigation and branching must agree.
96+type SessionSnapshot struct {
97+ Meta SessionMeta
98+ Messages []provider.Message
99+ UsageCheckpoints []SessionUsageCheckpoint
100+}
101+
84 // SessionsDir returns the per-cwd sessions directory under root.102 // SessionsDir returns the per-cwd sessions directory under root.
85 func SessionsDir(root, cwd string) string {103 func SessionsDir(root, cwd string) string {
86 sum := sha256.Sum256([]byte(cwd))104 sum := sha256.Sum256([]byte(cwd))
@@ -168,69 +186,33 @@ func SessionUsage(path string) (provider.Usage, error) {
168 }186 }
169 187
170 // SessionUsageDetail returns the latest cumulative usage and the188 // SessionUsageDetail returns the latest cumulative usage and the
171-// per-turn usage of the final completed turn. The per-turn row drives189+// per-turn usage of the final completed turn. It reads through the same
172-// the live "context used" gauge in the status bar (input + cache190+// strict snapshot path as OpenSession, so a truncated or malformed session
173-// approximates the prompt size the model just saw), letting the TUI191+// 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) {192 func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err error) {
177- f, ferr := os.Open(path)193+ snapshot, err := ReadSessionSnapshot(path)
178- if ferr != nil {194+ if err != nil {
179- return provider.Usage{}, provider.Usage{}, ferr195+ return provider.Usage{}, provider.Usage{}, err
180 }196 }
181- defer f.Close()197+ if len(snapshot.UsageCheckpoints) == 0 {
182-198+ 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 }199 }
212- if haveCum {200+ last := snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative
213- // input/output are monotonic totals -> per-turn = delta.201+ var prev provider.Usage
214- lastTurn.InputTokens = nonNegDelta(cumulative.InputTokens, prevCum.InputTokens)202+ if len(snapshot.UsageCheckpoints) > 1 {
215- lastTurn.OutputTokens = nonNegDelta(cumulative.OutputTokens, prevCum.OutputTokens)203+ 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 }204 }
233- return cumulative, lastTurn, nil205+ lastTurn.InputTokens = nonNegDelta(last.InputTokens, prev.InputTokens)
206+ lastTurn.OutputTokens = nonNegDelta(last.OutputTokens, prev.OutputTokens)
207+ lastTurn.ReasoningTokens = nonNegDelta(last.ReasoningTokens, prev.ReasoningTokens)
208+ lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown
209+ lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens)
210+ lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens)
211+ lastTurn.CostUSD = last.CostUSD - prev.CostUSD
212+ if lastTurn.CostUSD < 0 {
213+ lastTurn.CostUSD = 0
214+ }
215+ return last, lastTurn, nil
234 }216 }
235 217
236 func nonNegDelta(cur, prev int) int {218 func nonNegDelta(cur, prev int) int {
@@ -240,49 +222,322 @@ func nonNegDelta(cur, prev int) int {
240 return cur - prev222 return cur - prev
241 }223 }
242 224
243-// OpenSession opens an existing session for appending.225+// ReadSessionSnapshot reads a complete session file and reconstructs the
244-func OpenSession(path string) (*Session, []provider.Message, error) {226+// effective transcript. It is intentionally stricter than the lightweight
227+// listing helpers: a caller that is about to resume, render, or branch a
228+// session must not receive a plausible-looking partial transcript.
229+func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
245 f, err := os.Open(path)230 f, err := os.Open(path)
246 if err != nil {231 if err != nil {
247- return nil, nil, err232+ return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err)
248 }233 }
249 defer f.Close()234 defer f.Close()
250 235
251- var meta SessionMeta236+ var snapshot SessionSnapshot
252- var messages []provider.Message237+ var sawMeta bool
253- if err := forEachJSONLLine(f, func(line []byte) error {238+ generation := 0
239+ type rawCheckpoint struct {
240+ messageCount int
241+ cumulative provider.Usage
242+ generation int
243+ }
244+ var rawCheckpoints []rawCheckpoint
245+
246+ err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
254 var head sessionLineHead247 var head sessionLineHead
255 if err := json.Unmarshal(line, &head); err != nil {248 if err := json.Unmarshal(line, &head); err != nil {
256- return nil249+ return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
250+ }
251+ if head.Type == "" {
252+ return fmt.Errorf("line %d: missing row type", lineNo)
253+ }
254+ if !sawMeta && head.Type != "meta" {
255+ return fmt.Errorf("line %d: first row is not meta", lineNo)
257 }256 }
257+
258 switch head.Type {258 switch head.Type {
259 case "meta":259 case "meta":
260- var row struct {260+ var row sessionLine
261- Meta SessionMeta `json:"meta"`261+ if err := json.Unmarshal(line, &row); err != nil {
262+ return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
262 }263 }
263- if err := json.Unmarshal(line, &row); err == nil {264+ if row.Meta == nil || row.Meta.ID == "" {
264- meta = row.Meta265+ return fmt.Errorf("line %d: meta row has no session id", lineNo)
265 }266 }
267+ snapshot.Meta = *row.Meta
268+ sawMeta = true
269+
266 case "message":270 case "message":
267- if msg, err := hydrateMessage(line); err == nil && len(msg.Content) > 0 {271+ msg, err := hydrateMessage(line)
268- messages = append(messages, msg)272+ if err != nil {
273+ return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
269 }274 }
275+ snapshot.Messages = append(snapshot.Messages, msg)
276+
270 case "compaction":277 case "compaction":
271- if compacted, err := hydrateCompaction(line); err == nil {278+ compacted, err := hydrateCompaction(line)
272- messages = compacted279+ if err != nil {
280+ return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err)
281+ }
282+ snapshot.Messages = compacted
283+ generation++
284+
285+ case "usage":
286+ var row struct {
287+ Usage *provider.Usage `json:"usage"`
288+ Cumulative *provider.Usage `json:"cumulative"`
289+ }
290+ if err := json.Unmarshal(line, &row); err != nil {
291+ return fmt.Errorf("line %d: invalid usage row: %w", lineNo, err)
292+ }
293+ if row.Cumulative == nil {
294+ return fmt.Errorf("line %d: usage row has no cumulative usage", lineNo)
295+ }
296+ rawCheckpoints = append(rawCheckpoints, rawCheckpoint{
297+ messageCount: len(snapshot.Messages),
298+ cumulative: *row.Cumulative,
299+ generation: generation,
300+ })
301+
302+ case "rename":
303+ var row struct {
304+ Title *string `json:"title"`
305+ }
306+ if err := json.Unmarshal(line, &row); err != nil {
307+ return fmt.Errorf("line %d: invalid rename row: %w", lineNo, err)
308+ }
309+ if row.Title == nil {
310+ return fmt.Errorf("line %d: rename row has no title", lineNo)
273 }311 }
312+
313+ default:
314+ return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
274 }315 }
275 return nil316 return nil
276- }); err != nil {317+ })
318+ if err != nil {
319+ return SessionSnapshot{}, fmt.Errorf("session snapshot %q: %w", path, err)
320+ }
321+ if !sawMeta {
322+ return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path)
323+ }
324+
325+ // Repair only after compaction replacement. This makes synthetic tool
326+ // results and the removal of orphaned results part of the same indexed
327+ // stream that OpenSession returns and that BranchSession materializes.
328+ rawMessages := snapshot.Messages
329+ repaired := repairSessionMessages(rawMessages)
330+ snapshot.Messages = repaired.messages
331+ for _, checkpoint := range rawCheckpoints {
332+ count := checkpoint.messageCount
333+ if checkpoint.generation == generation {
334+ count = repaired.prefixCount(count)
335+ } else if count > len(rawMessages) {
336+ // A compaction replaces the effective stream. Preserve the
337+ // cumulative cost represented by an older checkpoint at the
338+ // end of the replacement rather than making every post-summary
339+ // branch appear to have zero usage.
340+ count = len(rawMessages)
341+ }
342+ snapshot.UsageCheckpoints = append(snapshot.UsageCheckpoints, SessionUsageCheckpoint{
343+ MessageCount: count,
344+ Cumulative: checkpoint.cumulative,
345+ })
346+ }
347+ return snapshot, nil
348+}
349+
350+// forEachStrictJSONLLine is the complete-read counterpart to
351+// forEachJSONLLine. It rejects malformed and blank rows and reports the
352+// line number so callers cannot accidentally proceed with a partial file.
353+func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error {
354+ br := bufio.NewReader(r)
355+ lineNo := 0
356+ for {
357+ line, err := br.ReadBytes('\n')
358+ if len(line) > 0 {
359+ lineNo++
360+ line = bytes.TrimRight(line, "\r\n")
361+ if len(bytes.TrimSpace(line)) == 0 {
362+ return fmt.Errorf("line %d: blank row", lineNo)
363+ }
364+ if ferr := fn(line, lineNo); ferr != nil {
365+ return ferr
366+ }
367+ }
368+ if err == io.EOF {
369+ return nil
370+ }
371+ if err != nil {
372+ return err
373+ }
374+ }
375+}
376+
377+// repairedSessionMessage keeps the raw message index that produced an
378+// effective message. Synthetic tool results use the assistant's raw index;
379+// this lets usage checkpoints continue to refer to the same effective
380+// message prefix after repair removes or inserts rows.
381+type repairedSessionMessage struct {
382+ message provider.Message
383+ rawIndex int
384+}
385+
386+type repairedSessionMessages struct {
387+ messages []provider.Message
388+ counts []int
389+}
390+
391+func (r repairedSessionMessages) prefixCount(rawCount int) int {
392+ if rawCount < 0 {
393+ return 0
394+ }
395+ if rawCount >= len(r.counts) {
396+ return r.counts[len(r.counts)-1]
397+ }
398+ return r.counts[rawCount]
399+}
400+
401+// repairSessionMessages repairs tool-call pairs and removes orphaned result
402+// blocks while retaining a mapping from raw effective prefixes to repaired
403+// prefixes. A tool result is valid only in the immediately following tool
404+// message for the assistant tool calls; keeping an unrelated result there
405+// would still make a resumed or branched transcript provider-invalid.
406+func repairSessionMessages(msgs []provider.Message) repairedSessionMessages {
407+ if len(msgs) == 0 {
408+ return repairedSessionMessages{counts: []int{0}}
409+ }
410+
411+ repaired := make([]repairedSessionMessage, 0, len(msgs)+2)
412+ for i := 0; i < len(msgs); i++ {
413+ m := msgs[i]
414+ if m.Role == provider.RoleAssistant && messageHasToolCalls(m) {
415+ ids := make(map[string]bool)
416+ for _, c := range m.Content {
417+ if tc, ok := c.(provider.ToolCallBlock); ok {
418+ ids[tc.ID] = true
419+ }
420+ }
421+ repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
422+
423+ if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
424+ tool := msgs[i+1]
425+ seen := make(map[string]bool)
426+ filtered := make([]provider.Content, 0, len(tool.Content)+len(ids))
427+ for _, c := range tool.Content {
428+ tr, ok := c.(provider.ToolResultBlock)
429+ if !ok {
430+ filtered = append(filtered, c)
431+ continue
432+ }
433+ if !ids[tr.CallID] || seen[tr.CallID] {
434+ continue
435+ }
436+ seen[tr.CallID] = true
437+ filtered = append(filtered, tr)
438+ }
439+ for _, c := range m.Content {
440+ if tc, ok := c.(provider.ToolCallBlock); ok && !seen[tc.ID] {
441+ filtered = append(filtered, provider.ToolResultBlock{
442+ CallID: tc.ID,
443+ Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}},
444+ IsError: true,
445+ })
446+ }
447+ }
448+ if len(filtered) > 0 {
449+ tool.Content = filtered
450+ repaired = append(repaired, repairedSessionMessage{message: tool, rawIndex: i + 1})
451+ }
452+ i++
453+ continue
454+ }
455+
456+ stubs := make([]provider.Content, 0, len(ids))
457+ for _, c := range m.Content {
458+ if tc, ok := c.(provider.ToolCallBlock); ok {
459+ stubs = append(stubs, provider.ToolResultBlock{
460+ CallID: tc.ID,
461+ Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}},
462+ IsError: true,
463+ })
464+ }
465+ }
466+ repaired = append(repaired, repairedSessionMessage{message: provider.Message{
467+ Role: provider.RoleTool,
468+ Content: stubs,
469+ Time: m.Time,
470+ }, rawIndex: i})
471+ continue
472+ }
473+
474+ if m.Role == provider.RoleTool {
475+ // Deferred-tool activation rows intentionally carry a result
476+ // without a preceding assistant call. Preserve those rows and
477+ // their AddedToolNames marker; they are consumed as local
478+ // transcript state rather than sent as a provider tool pair.
479+ if len(m.AddedToolNames) > 0 {
480+ repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
481+ continue
482+ }
483+ // This tool row was not immediately paired with an assistant
484+ // tool-call row. Drop its result blocks; a bare tool row is an
485+ // orphan and is not safe to send to a provider.
486+ filtered := make([]provider.Content, 0, len(m.Content))
487+ for _, c := range m.Content {
488+ if _, ok := c.(provider.ToolResultBlock); !ok {
489+ filtered = append(filtered, c)
490+ }
491+ }
492+ if len(filtered) > 0 {
493+ m.Content = filtered
494+ repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
495+ }
496+ continue
497+ }
498+ repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
499+ }
500+
501+ out := repairedSessionMessages{
502+ messages: make([]provider.Message, 0, len(repaired)),
503+ counts: make([]int, len(msgs)+1),
504+ }
505+ for _, item := range repaired {
506+ out.messages = append(out.messages, item.message)
507+ for n := item.rawIndex + 1; n <= len(msgs); n++ {
508+ out.counts[n]++
509+ }
510+ }
511+ return out
512+}
513+
514+func messageHasToolCalls(msg provider.Message) bool {
515+ for _, content := range msg.Content {
516+ if _, ok := content.(provider.ToolCallBlock); ok {
517+ return true
518+ }
519+ }
520+ return false
521+}
522+
523+// OpenSession opens an existing session for appending.
524+func OpenSession(path string) (*Session, []provider.Message, error) {
525+ snapshot, err := ReadSessionSnapshot(path)
526+ if err != nil {
277 return nil, nil, err527 return nil, nil, err
278 }528 }
279- messages = repairToolUseResultPairs(messages)
280 out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)529 out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
281 if err != nil {530 if err != nil {
282 return nil, nil, err531 return nil, nil, err
283 }532 }
284- s := &Session{ID: meta.ID, Path: path, Meta: meta, writer: out, buf: bufio.NewWriter(out)}533+ s := &Session{
285- return s, messages, nil534+ ID: snapshot.Meta.ID,
535+ Path: path,
536+ Meta: snapshot.Meta,
537+ writer: out,
538+ buf: bufio.NewWriter(out),
539+ }
540+ return s, snapshot.Messages, nil
286 }541 }
287 542
288 // repairToolUseResultPairs walks a restored transcript and543 // repairToolUseResultPairs walks a restored transcript and
@@ -311,68 +566,7 @@ func OpenSession(path string) (*Session, []provider.Message, error) {
311 //566 //
312 // Runs once per OpenSession call. No cost on the hot path.567 // Runs once per OpenSession call. No cost on the hot path.
313 func repairToolUseResultPairs(msgs []provider.Message) []provider.Message {568 func repairToolUseResultPairs(msgs []provider.Message) []provider.Message {
314- if len(msgs) == 0 {569+ 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 }570 }
377 571
378 // LatestSession returns the most recent session file for cwd, or "".572 // LatestSession returns the most recent session file for cwd, or "".
@@ -425,8 +619,8 @@ func DeleteSession(path string) error {
425 }619 }
426 620
427 // DescribeSessions returns lightweight summaries for every session in621 // DescribeSessions returns lightweight summaries for every session in
428-// cwd, newest first. Parses only the first few lines and the last usage622+// cwd, newest first. It uses the effective snapshot so message counts,
429-// line so it's cheap to run on every dialog open.623+// metadata, and usage agree with resume and branching behavior.
430 func DescribeSessions(root, cwd string) []SessionSummary {624 func DescribeSessions(root, cwd string) []SessionSummary {
431 paths := ListSessions(root, cwd)625 paths := ListSessions(root, cwd)
432 summaries := make([]SessionSummary, 0, len(paths))626 summaries := make([]SessionSummary, 0, len(paths))
@@ -470,73 +664,60 @@ func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[
470 664
471 func describeSession(path string) SessionSummary {665 func describeSession(path string) SessionSummary {
472 s := SessionSummary{Path: path}666 s := SessionSummary{Path: path}
473- f, err := os.Open(path)667+ snapshot, err := ReadSessionSnapshot(path)
474 if err != nil {668 if err != nil {
475 return s669 return s
476 }670 }
477- defer f.Close()671+ s.Started = snapshot.Meta.Started
478- isBranch := false672+ s.Model = snapshot.Meta.Model
479- forkPoint := 0673+ s.Provider = snapshot.Meta.Provider
674+ s.Title = snapshot.Meta.Title
675+ s.MessageCount = len(snapshot.Messages)
676+ s.FirstUserText = firstUserTextFromMessages(snapshot.Messages)
677+ lastUser := lastUserText(snapshot.Messages)
678+ isBranch := snapshot.Meta.Parent != ""
679+ forkPoint := snapshot.Meta.ForkPoint
680+ branchPrompt := firstUserTextFromMessagesAfter(snapshot.Messages, forkPoint)
681+ if len(snapshot.UsageCheckpoints) > 0 {
682+ s.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
683+ }
684+
685+ // Rename rows are append-only and are intentionally kept out of the
686+ // effective transcript. Scan only their placement so generated branch
687+ // titles can still be replaced by the first post-fork prompt while an
688+ // explicit rename after divergence keeps priority.
480 messageIdx := 0689 messageIdx := 0
481- branchPrompt := ""
482- lastUser := ""
483 renameMessageIdx := -1690 renameMessageIdx := -1
484- _ = forEachJSONLLine(f, func(line []byte) error {691+ _ = func() error {
485- var head sessionLineHead692+ f, err := os.Open(path)
486- if err := json.Unmarshal(line, &head); err != nil {693+ if err != nil {
487- return nil694+ return err
488 }695 }
489- switch head.Type {696+ defer f.Close()
490- case "meta":697+ return forEachJSONLLine(f, func(line []byte) error {
491- var row struct {698+ var head sessionLineHead
492- Meta SessionMeta `json:"meta"`699+ if err := json.Unmarshal(line, &head); err != nil {
700+ return nil
493 }701 }
494- if err := json.Unmarshal(line, &row); err == nil {702+ switch head.Type {
495- s.Started = row.Meta.Started703+ case "message":
496- s.Model = row.Meta.Model704+ messageIdx++
497- s.Provider = row.Meta.Provider705+ case "compaction":
498- s.Title = row.Meta.Title706+ if compacted, err := hydrateCompaction(line); err == nil {
499- isBranch = row.Meta.Parent != ""707+ messageIdx = len(compacted)
500- forkPoint = row.Meta.ForkPoint
501- }
502- case "message":
503- if text := firstUserText(line); text != "" {
504- lastUser = text
505- if s.FirstUserText == "" {
506- s.FirstUserText = text
507 }708 }
508- if isBranch && messageIdx >= forkPoint && branchPrompt == "" {709+ case "rename":
509- branchPrompt = text710+ var row struct {
711+ Title string `json:"title"`
712+ }
713+ if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
714+ s.Title = row.Title
715+ renameMessageIdx = messageIdx
510 }716 }
511 }717 }
512- messageIdx++718+ return nil
513- s.MessageCount++719+ })
514- case "compaction":720+ }()
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 {721 if isBranch {
541 // A rename written after the branch has diverged is user intent722 // A rename written after the branch has diverged is user intent
542 // and must win. Older generated branch titles were written at723 // and must win. Older generated branch titles were written at
@@ -552,29 +733,6 @@ func describeSession(path string) SessionSummary {
552 return s733 return s
553 }734 }
554 735
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 {736 func firstUserTextFromMessages(msgs []provider.Message) string {
579 return firstUserTextFromMessagesAfter(msgs, 0)737 return firstUserTextFromMessagesAfter(msgs, 0)
580 }738 }
@@ -712,10 +870,14 @@ func (s *Session) AppendCompaction(messages []provider.Message) error {
712 if s == nil {870 if s == nil {
713 return nil871 return nil
714 }872 }
715- if err := s.writeLine(sessionLine{Type: "compaction", Messages: messages}); err != nil {873+ compactionMessages := messages
874+ if err := s.writeLine(sessionLine{Type: "compaction", Messages: &compactionMessages}); err != nil {
716 return err875 return err
717 }876 }
718- s.messagesAppended = len(messages)877+ // The compaction row itself is meaningful even when it replaces the
878+ // transcript with an empty or nil slice. Count the append operation so a
879+ // fresh session containing an empty checkpoint is not deleted on Close.
880+ s.messagesAppended++
719 return nil881 return nil
720 }882 }
721 883
@@ -726,9 +888,15 @@ func (s *Session) UpdateModel(providerName, model string) error {
726 if s == nil {888 if s == nil {
727 return nil889 return nil
728 }890 }
891+ oldProvider, oldModel := s.Meta.Provider, s.Meta.Model
729 s.Meta.Provider = providerName892 s.Meta.Provider = providerName
730 s.Meta.Model = model893 s.Meta.Model = model
731- return s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta})894+ if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
895+ s.Meta.Provider = oldProvider
896+ s.Meta.Model = oldModel
897+ return err
898+ }
899+ return nil
732 }900 }
733 901
734 // AppendUsage writes a usage row to the session.902 // AppendUsage writes a usage row to the session.
@@ -784,17 +952,27 @@ func (s *Session) writeLine(row sessionLine) error {
784 952
785 func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {953 func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {
786 var row struct {954 var row struct {
787- Messages []json.RawMessage `json:"messages"`955+ Messages json.RawMessage `json:"messages"`
788 }956 }
789 if err := json.Unmarshal(lineBytes, &row); err != nil {957 if err := json.Unmarshal(lineBytes, &row); err != nil {
790 return nil, err958 return nil, err
791 }959 }
792- messages := make([]provider.Message, 0, len(row.Messages))960+ if len(row.Messages) == 0 || bytes.Equal(bytes.TrimSpace(row.Messages), []byte("null")) {
793- for _, raw := range row.Messages {961+ // Older writers omitted the field for an empty compaction and some
962+ // wrote it as null. Both represent a valid empty replacement.
963+ return []provider.Message{}, nil
964+ }
965+ var rawMessages []json.RawMessage
966+ if err := json.Unmarshal(row.Messages, &rawMessages); err != nil {
967+ return nil, fmt.Errorf("invalid messages: %w", err)
968+ }
969+ messages := make([]provider.Message, 0, len(rawMessages))
970+ for idx, raw := range rawMessages {
794 msg, err := hydrateMessageObject(raw)971 msg, err := hydrateMessageObject(raw)
795- if err == nil && len(msg.Content) > 0 {972+ if err != nil {
796- messages = append(messages, msg)973+ return nil, fmt.Errorf("message %d: %w", idx, err)
797 }974 }
975+ messages = append(messages, msg)
798 }976 }
799 return messages, nil977 return messages, nil
800 }978 }
@@ -806,6 +984,9 @@ func hydrateMessage(lineBytes []byte) (provider.Message, error) {
806 if err := json.Unmarshal(lineBytes, &row); err != nil {984 if err := json.Unmarshal(lineBytes, &row); err != nil {
807 return provider.Message{}, err985 return provider.Message{}, err
808 }986 }
987+ if len(row.Message) == 0 || bytes.Equal(bytes.TrimSpace(row.Message), []byte("null")) {
988+ return provider.Message{}, fmt.Errorf("message row has no message")
989+ }
809 return hydrateMessageObject(row.Message)990 return hydrateMessageObject(row.Message)
810 }991 }
811 992
@@ -820,8 +1001,17 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
820 if err := json.Unmarshal(rawMessage, &row); err != nil {1001 if err := json.Unmarshal(rawMessage, &row); err != nil {
821 return provider.Message{}, err1002 return provider.Message{}, err
822 }1003 }
1004+ if row.Role != provider.RoleUser && row.Role != provider.RoleAssistant && row.Role != provider.RoleTool {
1005+ return provider.Message{}, fmt.Errorf("message has invalid role %q", row.Role)
1006+ }
1007+ if row.Content == nil || len(row.Content) == 0 {
1008+ return provider.Message{}, fmt.Errorf("message has no content")
1009+ }
823 msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta, AddedToolNames: row.AddedToolNames}1010 msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta, AddedToolNames: row.AddedToolNames}
824- for _, raw := range row.Content {1011+ for idx, raw := range row.Content {
1012+ if len(bytes.TrimSpace(raw)) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
1013+ return provider.Message{}, fmt.Errorf("content block %d is empty", idx)
1014+ }
825 var head struct {1015 var head struct {
826 Text string `json:"text"`1016 Text string `json:"text"`
827 MimeType string `json:"mime_type"`1017 MimeType string `json:"mime_type"`
@@ -836,7 +1026,14 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
836 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError1026 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError
837 }1027 }
838 if err := json.Unmarshal(raw, &head); err != nil {1028 if err := json.Unmarshal(raw, &head); err != nil {
839- continue1029+ return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
1030+ }
1031+ var fields map[string]json.RawMessage
1032+ if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
1033+ if err == nil {
1034+ err = fmt.Errorf("content block is not an object")
1035+ }
1036+ return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
840 }1037 }
841 // Discriminate by presence of fields.1038 // Discriminate by presence of fields.
842 switch {1039 switch {
@@ -846,7 +1043,10 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
846 Summary: head.Summary,1043 Summary: head.Summary,
847 Encrypted: head.Encrypted,1044 Encrypted: head.Encrypted,
848 })1045 })
849- case head.Name != "" && head.ID != "":1046+ case head.Name != "" || head.ID != "" || fields["arguments"] != nil:
1047+ if head.Name == "" || head.ID == "" {
1048+ return provider.Message{}, fmt.Errorf("content block %d: incomplete tool call", idx)
1049+ }
850 var tc struct {1050 var tc struct {
851 ID string `json:"id"`1051 ID string `json:"id"`
852 Name string `json:"name"`1052 Name string `json:"name"`
@@ -866,21 +1066,40 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
866 Content []json.RawMessage `json:"content"`1066 Content []json.RawMessage `json:"content"`
867 IsError bool `json:"is_error"`1067 IsError bool `json:"is_error"`
868 }1068 }
869- _ = json.Unmarshal(raw, &tr)1069+ if err := json.Unmarshal(raw, &tr); err != nil || tr.Content == nil {
1070+ if err == nil {
1071+ err = fmt.Errorf("tool result has no content")
1072+ }
1073+ return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
1074+ }
870 block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError}1075 block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError}
871- for _, c := range tr.Content {1076+ for innerIdx, c := range tr.Content {
872 var inner struct {1077 var inner struct {
873 Text string `json:"text"`1078 Text string `json:"text"`
874 MimeType string `json:"mime_type"`1079 MimeType string `json:"mime_type"`
875 Data []byte `json:"data"`1080 Data []byte `json:"data"`
876 }1081 }
877- _ = json.Unmarshal(c, &inner)1082+ if err := json.Unmarshal(c, &inner); err != nil {
1083+ return provider.Message{}, fmt.Errorf("content block %d result %d: %w", idx, innerIdx, err)
1084+ }
1085+ var innerFields map[string]json.RawMessage
1086+ if err := json.Unmarshal(c, &innerFields); err != nil || innerFields == nil {
1087+ if err == nil {
1088+ err = fmt.Errorf("result content is not an object")
1089+ }
1090+ return provider.Message{}, fmt.Errorf("content block %d result %d: %w", idx, innerIdx, err)
1091+ }
878 if inner.MimeType != "" {1092 if inner.MimeType != "" {
879 block.Content = append(block.Content, provider.ImageBlock{MimeType: inner.MimeType, Data: inner.Data})1093 block.Content = append(block.Content, provider.ImageBlock{MimeType: inner.MimeType, Data: inner.Data})
880- } else {1094+ } else if _, ok := innerFields["text"]; ok {
881 block.Content = append(block.Content, provider.TextBlock{Text: inner.Text})1095 block.Content = append(block.Content, provider.TextBlock{Text: inner.Text})
1096+ } else {
1097+ return provider.Message{}, fmt.Errorf("content block %d result %d: unknown content", idx, innerIdx)
882 }1098 }
883 }1099 }
1100+ if len(block.Content) == 0 {
1101+ return provider.Message{}, fmt.Errorf("content block %d tool result has no content", idx)
1102+ }
884 msg.Content = append(msg.Content, block)1103 msg.Content = append(msg.Content, block)
885 case head.MimeType != "":1104 case head.MimeType != "":
886 msg.Content = append(msg.Content, provider.ImageBlock{1105 msg.Content = append(msg.Content, provider.ImageBlock{
@@ -889,6 +1108,9 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
889 ThoughtSignature: head.ThoughtSignature,1108 ThoughtSignature: head.ThoughtSignature,
890 })1109 })
891 default:1110 default:
1111+ if _, ok := fields["text"]; !ok {
1112+ return provider.Message{}, fmt.Errorf("content block %d: unknown content", idx)
1113+ }
892 msg.Content = append(msg.Content, provider.TextBlock{1114 msg.Content = append(msg.Content, provider.TextBlock{
893 Text: head.Text,1115 Text: head.Text,
894 ThoughtSignature: head.ThoughtSignature,1116 ThoughtSignature: head.ThoughtSignature,
M packages/core/session_portable.go +293 -119
@@ -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- }
280- defer src.Close()
281-
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 }296 }
289- var head sessionLine297+ if upToMessageIdx > len(snapshot.Messages) {
290- if err := json.Unmarshal(sc.Bytes(), &head); err != nil {298+ return "", fmt.Errorf("branch: upToMessageIdx %d exceeds effective message count %d", upToMessageIdx, len(snapshot.Messages))
291- return "", fmt.Errorf("branch: parse parent meta: %w", err)
292 }299 }
293- if head.Type != "meta" || head.Meta == nil {300+
294- return "", errors.New("branch: parent first line is not a meta row")301+ // A tool-call row is not a safe provider boundary by itself. Accept the
302+ // convenient row index used by existing callers, but extend it through the
303+ // immediately paired tool result when necessary. Orphan calls already have
304+ // a synthetic result in the shared snapshot.
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,23 @@ 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+// readSessionMeta returns the latest validated metadata for path. It uses
486-// Empty SessionMeta when the file is missing or not a valid session.672+// the shared snapshot reader so tree linking, flat-session filtering, and
673+// resume do not disagree when a session records a later meta row.
487 func readSessionMeta(path string) (SessionMeta, error) {674 func readSessionMeta(path string) (SessionMeta, error) {
488- f, err := os.Open(path)675+ snapshot, err := ReadSessionSnapshot(path)
489 if err != nil {676 if err != nil {
490 return SessionMeta{}, err677 return SessionMeta{}, err
491 }678 }
492- defer f.Close()679+ return snapshot.Meta, nil
493- sc := bufio.NewScanner(f)
494- sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
495- if !sc.Scan() {
496- return SessionMeta{}, errors.New("empty file")
497- }
498- var line sessionLine
499- if err := json.Unmarshal(sc.Bytes(), &line); err != nil {
500- return SessionMeta{}, err
501- }
502- if line.Type != "meta" || line.Meta == nil {
503- return SessionMeta{}, errors.New("first line is not meta")
504- }
505- return *line.Meta, nil
506 }680 }
507 681
508 // FindSessionByID looks up a session file in root/cwd whose meta id682 // FindSessionByID looks up a session file in root/cwd whose meta id
M packages/core/session_portable_test.go +44 -0
@@ -82,6 +82,50 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
82 }82 }
83 }83 }
84 84
85+func TestExportImportUsesLatestProviderModelMetadata(t *testing.T) {
86+ root := t.TempDir()
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+
85 // TestExportToFilePath writes to an explicit file path (no129 // TestExportToFilePath writes to an explicit file path (no
86 // directory guessing) and checks the .zotsession extension is130 // directory guessing) and checks the .zotsession extension is
87 // appended when missing.131 // appended when missing.
A packages/core/session_snapshot_test.go +615 -0
@@ -0,0 +1,615 @@
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 TestReadSessionSnapshotUsesLatestCompactionAndRepairsToolPairs(t *testing.T) {
15+ root := t.TempDir()
16+ session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := session.AppendMessage(textMessage(provider.RoleUser, "old history")); err != nil {
21+ t.Fatal(err)
22+ }
23+ if err := session.AppendCompaction([]provider.Message{
24+ textMessage(provider.RoleAssistant, "summary"),
25+ {Role: provider.RoleAssistant, Content: []provider.Content{
26+ provider.ToolCallBlock{ID: "call-1", Name: "read", Arguments: []byte(`{"path":"/tmp/file"}`)},
27+ }},
28+ }); err != nil {
29+ t.Fatal(err)
30+ }
31+ if err := session.AppendUsage(provider.Usage{InputTokens: 10}, provider.Usage{InputTokens: 10}); err != nil {
32+ t.Fatal(err)
33+ }
34+ path := session.Path
35+ if err := session.Close(); err != nil {
36+ t.Fatal(err)
37+ }
38+
39+ snapshot, err := ReadSessionSnapshot(path)
40+ if err != nil {
41+ t.Fatalf("ReadSessionSnapshot: %v", err)
42+ }
43+ if snapshot.Meta.ID == "" {
44+ t.Fatal("snapshot lost session metadata")
45+ }
46+ assertMessageTexts(t, snapshot.Messages, []string{"summary", "", ""})
47+ stub := snapshot.Messages[2].Content[0].(provider.ToolResultBlock).Content[0].(provider.TextBlock)
48+ if stub.Text != "tool call was aborted; no result recorded." {
49+ t.Errorf("repaired result text = %q", stub.Text)
50+ }
51+ if got := snapshot.Messages[1].Role; got != provider.RoleAssistant {
52+ t.Fatalf("repaired transcript message 1 role = %q, want assistant", got)
53+ }
54+ if got := snapshot.Messages[2].Role; got != provider.RoleTool {
55+ t.Fatalf("repaired transcript message 2 role = %q, want tool", got)
56+ }
57+ if got := snapshot.Messages[2].Content[0].(provider.ToolResultBlock).CallID; got != "call-1" {
58+ t.Errorf("repaired result call id = %q, want call-1", got)
59+ }
60+ if len(snapshot.UsageCheckpoints) != 1 {
61+ t.Fatalf("usage checkpoints = %d, want 1", len(snapshot.UsageCheckpoints))
62+ }
63+ if got := snapshot.UsageCheckpoints[0].MessageCount; got != 3 {
64+ t.Fatalf("usage checkpoint message count = %d, want repaired count 3", got)
65+ }
66+ if got := snapshot.UsageCheckpoints[0].Cumulative.InputTokens; got != 10 {
67+ t.Errorf("checkpoint cumulative input = %d, want 10", got)
68+ }
69+
70+ opened, messages, err := OpenSession(path)
71+ if err != nil {
72+ t.Fatalf("OpenSession: %v", err)
73+ }
74+ defer opened.Close()
75+ if len(messages) != len(snapshot.Messages) {
76+ t.Fatalf("OpenSession messages = %d, snapshot messages = %d", len(messages), len(snapshot.Messages))
77+ }
78+}
79+
80+func TestReadSessionSnapshotMergesExistingToolResultWithoutDuplicateRepair(t *testing.T) {
81+ root := t.TempDir()
82+ session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ if err := session.AppendMessage(provider.Message{
87+ Role: provider.RoleAssistant,
88+ Content: []provider.Content{
89+ provider.ToolCallBlock{ID: "call-1", Name: "read"},
90+ provider.ToolCallBlock{ID: "call-2", Name: "bash"},
91+ },
92+ }); err != nil {
93+ t.Fatal(err)
94+ }
95+ if err := session.AppendMessage(provider.Message{
96+ Role: provider.RoleTool,
97+ Content: []provider.Content{provider.ToolResultBlock{
98+ CallID: "call-1",
99+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
100+ }},
101+ }); err != nil {
102+ t.Fatal(err)
103+ }
104+ path := session.Path
105+ if err := session.Close(); err != nil {
106+ t.Fatal(err)
107+ }
108+
109+ snapshot, err := ReadSessionSnapshot(path)
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+ if len(snapshot.Messages) != 2 {
114+ t.Fatalf("messages = %d, want existing tool row plus assistant row", len(snapshot.Messages))
115+ }
116+ tool := snapshot.Messages[1]
117+ if len(tool.Content) != 2 {
118+ t.Fatalf("tool result blocks = %d, want 2", len(tool.Content))
119+ }
120+ if got := tool.Content[0].(provider.ToolResultBlock).CallID; got != "call-1" {
121+ t.Errorf("existing result call id = %q", got)
122+ }
123+ if got := tool.Content[1].(provider.ToolResultBlock).CallID; got != "call-2" {
124+ t.Errorf("repaired result call id = %q", got)
125+ }
126+}
127+
128+func TestReadSessionSnapshotRecordsCheckpointsAtEffectiveBoundaries(t *testing.T) {
129+ root := t.TempDir()
130+ session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ _ = session.AppendMessage(textMessage(provider.RoleUser, "one"))
135+ _ = session.AppendUsage(provider.Usage{InputTokens: 3}, provider.Usage{InputTokens: 3})
136+ _ = session.AppendMessage(textMessage(provider.RoleAssistant, "two"))
137+ _ = session.AppendUsage(provider.Usage{InputTokens: 4}, provider.Usage{InputTokens: 7})
138+ _ = session.AppendCompaction([]provider.Message{textMessage(provider.RoleAssistant, "compact")})
139+ _ = session.AppendMessage(textMessage(provider.RoleUser, "tail"))
140+ _ = session.AppendUsage(provider.Usage{InputTokens: 2}, provider.Usage{InputTokens: 9})
141+ path := session.Path
142+ if err := session.Close(); err != nil {
143+ t.Fatal(err)
144+ }
145+
146+ snapshot, err := ReadSessionSnapshot(path)
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ assertMessageTexts(t, snapshot.Messages, []string{"compact", "tail"})
151+ if len(snapshot.UsageCheckpoints) != 3 {
152+ t.Fatalf("checkpoints = %d, want 3", len(snapshot.UsageCheckpoints))
153+ }
154+ wantCounts := []int{1, 2, 2}
155+ wantCosts := []int{3, 7, 9}
156+ for i, checkpoint := range snapshot.UsageCheckpoints {
157+ if checkpoint.MessageCount != wantCounts[i] {
158+ t.Errorf("checkpoint %d count = %d, want %d", i, checkpoint.MessageCount, wantCounts[i])
159+ }
160+ if checkpoint.Cumulative.InputTokens != wantCosts[i] {
161+ t.Errorf("checkpoint %d input = %d, want %d", i, checkpoint.Cumulative.InputTokens, wantCosts[i])
162+ }
163+ }
164+}
165+
166+func TestReadSessionSnapshotRejectsMalformedRows(t *testing.T) {
167+ meta := SessionMeta{ID: "session-1", CWD: "/workspace", Started: time.Now().UTC()}
168+ metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &meta})
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ cases := []string{
173+ string(metaLine) + "\nnot-json\n",
174+ string(metaLine) + `
175+{"type":"message","message":{"role":"user"}}
176+`,
177+ string(metaLine) + `
178+{"type":"usage","cumulative":null}
179+`,
180+ string(metaLine) + `
181+{"type":"future-row"}
182+`,
183+ }
184+ for idx, contents := range cases {
185+ path := filepath.Join(t.TempDir(), "session.jsonl")
186+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
187+ t.Fatal(err)
188+ }
189+ if _, err := ReadSessionSnapshot(path); err == nil {
190+ t.Errorf("case %d: ReadSessionSnapshot returned nil error", idx)
191+ }
192+ if _, _, err := OpenSession(path); err == nil {
193+ t.Errorf("case %d: OpenSession returned nil error", idx)
194+ }
195+ }
196+}
197+
198+func TestReadSessionSnapshotKeepsOldOptionalMetadataReadable(t *testing.T) {
199+ path := filepath.Join(t.TempDir(), "old.jsonl")
200+ contents := `{"type":"meta","meta":{"id":"old-id","cwd":"/workspace","model":"claude","provider":"anthropic"}}
201+{"type":"message","message":{"role":"user","content":[{"text":"hello"}]}}
202+`
203+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
204+ t.Fatal(err)
205+ }
206+ snapshot, err := ReadSessionSnapshot(path)
207+ if err != nil {
208+ t.Fatal(err)
209+ }
210+ if snapshot.Meta.Parent != "" || snapshot.Meta.HideFromSessions {
211+ t.Fatalf("old optional metadata unexpectedly populated: %+v", snapshot.Meta)
212+ }
213+ assertMessageTexts(t, snapshot.Messages, []string{"hello"})
214+}
215+
216+func TestBranchAfterCompactionRetainsPreCompactionUsageAtReplacementBoundary(t *testing.T) {
217+ root := t.TempDir()
218+ parent, err := NewSession(root, "/workspace", "test", "model", "test")
219+ if err != nil {
220+ t.Fatal(err)
221+ }
222+ for _, text := range []string{"one", "two", "three"} {
223+ _ = parent.AppendMessage(textMessage(provider.RoleUser, text))
224+ }
225+ _ = parent.AppendUsage(provider.Usage{InputTokens: 10}, provider.Usage{InputTokens: 10})
226+ _ = parent.AppendCompaction([]provider.Message{textMessage(provider.RoleAssistant, "summary")})
227+ path := parent.Path
228+ if err := parent.Close(); err != nil {
229+ t.Fatal(err)
230+ }
231+ branchPath, err := BranchSessionHidden(path, root, "/workspace", "test", 1)
232+ if err != nil {
233+ t.Fatal(err)
234+ }
235+ usage, err := SessionUsage(branchPath)
236+ if err != nil {
237+ t.Fatal(err)
238+ }
239+ if usage.InputTokens != 10 {
240+ t.Fatalf("branch usage input = %d, want pre-compaction cumulative usage", usage.InputTokens)
241+ }
242+}
243+
244+func TestBranchSessionUsesSharedSnapshotAndPreservesUsage(t *testing.T) {
245+ root := t.TempDir()
246+ parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
247+ if err != nil {
248+ t.Fatal(err)
249+ }
250+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "first"))
251+ _ = parent.AppendUsage(provider.Usage{InputTokens: 11}, provider.Usage{InputTokens: 11})
252+ _ = parent.AppendMessage(textMessage(provider.RoleAssistant, "reply"))
253+ _ = parent.AppendUsage(provider.Usage{InputTokens: 12}, provider.Usage{InputTokens: 23})
254+ parentPath := parent.Path
255+ if err := parent.Close(); err != nil {
256+ t.Fatal(err)
257+ }
258+
259+ branchPath, err := BranchSessionHidden(parentPath, root, "/workspace", "test", 1)
260+ if err != nil {
261+ t.Fatalf("BranchSessionHidden: %v", err)
262+ }
263+ branch, messages, err := OpenSession(branchPath)
264+ if err != nil {
265+ t.Fatal(err)
266+ }
267+ branch.Close()
268+ assertMessageTexts(t, messages, []string{"first"})
269+ if branch.Meta.ForkPoint != 1 {
270+ t.Errorf("fork point = %d, want 1", branch.Meta.ForkPoint)
271+ }
272+ if !branch.Meta.HideFromSessions {
273+ t.Error("hidden branch did not set HideFromSessions")
274+ }
275+ usage, err := SessionUsage(branchPath)
276+ if err != nil {
277+ t.Fatal(err)
278+ }
279+ if usage.InputTokens != 11 {
280+ t.Errorf("branch usage input = %d, want 11", usage.InputTokens)
281+ }
282+
283+ if _, err := BranchSession(parentPath, root, "/workspace", "test", 99); err == nil {
284+ t.Error("out-of-range fork returned nil error")
285+ }
286+}
287+
288+func TestBranchSessionRepairsToolBoundaryAndSupportsZeroAndEnd(t *testing.T) {
289+ root := t.TempDir()
290+ parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
291+ if err != nil {
292+ t.Fatal(err)
293+ }
294+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "before"))
295+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{
296+ provider.ToolCallBlock{ID: "call-1", Name: "read"},
297+ }})
298+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleTool, Content: []provider.Content{
299+ provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "result"}}},
300+ }})
301+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "after"))
302+ path := parent.Path
303+ if err := parent.Close(); err != nil {
304+ t.Fatal(err)
305+ }
306+
307+ toolBranchPath, err := BranchSession(parent.Path, root, "/workspace", "test", 2)
308+ if err != nil {
309+ t.Fatal(err)
310+ }
311+ toolBranch, toolMessages, err := OpenSession(toolBranchPath)
312+ if err != nil {
313+ t.Fatal(err)
314+ }
315+ toolBranch.Close()
316+ if len(toolMessages) != 3 {
317+ t.Fatalf("tool boundary branch messages = %d, want 3", len(toolMessages))
318+ }
319+ if toolBranch.Meta.ForkPoint != 3 {
320+ t.Errorf("tool boundary fork point = %d, want 3", toolBranch.Meta.ForkPoint)
321+ }
322+
323+ zeroPath, err := BranchSessionHidden(path, root, "/workspace", "test", 0)
324+ if err != nil {
325+ t.Fatal(err)
326+ }
327+ zero, zeroMessages, err := OpenSession(zeroPath)
328+ if err != nil {
329+ t.Fatal(err)
330+ }
331+ zero.Close()
332+ if len(zeroMessages) != 0 || zero.Meta.ForkPoint != 0 {
333+ t.Errorf("zero branch = %d messages, fork %d", len(zeroMessages), zero.Meta.ForkPoint)
334+ }
335+
336+ endPath, err := BranchSession(path, root, "/workspace", "test", 4)
337+ if err != nil {
338+ t.Fatal(err)
339+ }
340+ end, endMessages, err := OpenSession(endPath)
341+ if err != nil {
342+ t.Fatal(err)
343+ }
344+ end.Close()
345+ if len(endMessages) != 4 || end.Meta.ForkPoint != 4 {
346+ t.Errorf("end branch = %d messages, fork %d", len(endMessages), end.Meta.ForkPoint)
347+ }
348+}
349+
350+func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) {
351+ root := t.TempDir()
352+ parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
353+ if err != nil {
354+ t.Fatal(err)
355+ }
356+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "root"))
357+ parentPath := parent.Path
358+ parentID := parent.ID
359+ if err := parent.Close(); err != nil {
360+ t.Fatal(err)
361+ }
362+ childPath, err := BranchSessionHidden(parentPath, root, "/workspace", "test", 1)
363+ if err != nil {
364+ t.Fatal(err)
365+ }
366+ if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath {
367+ t.Fatalf("DescribeSessions = %+v, want only parent", got)
368+ }
369+ tree := BuildSessionTree(root, "/workspace")
370+ if len(tree) != 1 || tree[0].Meta.ID != parentID {
371+ t.Fatalf("tree roots = %+v, want parent root", tree)
372+ }
373+ if len(tree[0].Children) != 1 || tree[0].Children[0].Summary.Path != childPath {
374+ t.Fatalf("tree children = %+v, want hidden child", tree[0].Children)
375+ }
376+}
377+
378+func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) {
379+ root := t.TempDir()
380+ cwd := "/workspace/strict-tree"
381+ parent, err := NewSession(root, cwd, "test", "model", "test")
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "parent"))
386+ parentPath := parent.Path
387+ if err := parent.Close(); err != nil {
388+ t.Fatal(err)
389+ }
390+ badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl")
391+ if err := os.WriteFile(badPath, []byte("{not-json}\\n"), 0o644); err != nil {
392+ t.Fatal(err)
393+ }
394+ if _, err := BuildSessionTreeStrict(root, cwd); err == nil {
395+ t.Fatal("BuildSessionTreeStrict accepted a malformed session member")
396+ }
397+ if _, err := os.Stat(parentPath); err != nil {
398+ t.Fatalf("valid parent disappeared: %v", err)
399+ }
400+}
401+
402+func TestSessionUsageDetailUsesCurrentCacheAfterCounterReset(t *testing.T) {
403+ root := t.TempDir()
404+ session, err := NewSession(root, "/workspace", "anthropic", "model", "test")
405+ if err != nil {
406+ t.Fatal(err)
407+ }
408+ if err := session.AppendMessage(textMessage(provider.RoleUser, "prompt")); err != nil {
409+ t.Fatal(err)
410+ }
411+ if err := session.AppendUsage(provider.Usage{}, provider.Usage{
412+ InputTokens: 10,
413+ OutputTokens: 4,
414+ ReasoningTokens: 2,
415+ ReasoningTokensKnown: true,
416+ CacheReadTokens: 100,
417+ CacheWriteTokens: 40,
418+ CostUSD: 0.5,
419+ }); err != nil {
420+ t.Fatal(err)
421+ }
422+ if err := session.AppendUsage(provider.Usage{}, provider.Usage{
423+ InputTokens: 13,
424+ OutputTokens: 7,
425+ ReasoningTokens: 3,
426+ ReasoningTokensKnown: true,
427+ CacheReadTokens: 6,
428+ CacheWriteTokens: 2,
429+ CostUSD: 0.7,
430+ }); err != nil {
431+ t.Fatal(err)
432+ }
433+ path := session.Path
434+ if err := session.Close(); err != nil {
435+ t.Fatal(err)
436+ }
437+
438+ cumulative, last, err := SessionUsageDetail(path)
439+ if err != nil {
440+ t.Fatal(err)
441+ }
442+ if cumulative.CacheReadTokens != 6 || cumulative.CacheWriteTokens != 2 {
443+ t.Fatalf("cumulative cache = (%d, %d), want (6, 2)", cumulative.CacheReadTokens, cumulative.CacheWriteTokens)
444+ }
445+ if last.InputTokens != 3 || last.OutputTokens != 3 || last.ReasoningTokens != 1 {
446+ t.Fatalf("last token delta = %+v, want input=3 output=3 reasoning=1", last)
447+ }
448+ if last.CacheReadTokens != 6 || last.CacheWriteTokens != 2 {
449+ t.Fatalf("last cache = (%d, %d), want current checkpoint values (6, 2)", last.CacheReadTokens, last.CacheWriteTokens)
450+ }
451+ if !last.ReasoningTokensKnown {
452+ t.Fatal("last-turn reasoning-token known flag was lost")
453+ }
454+}
455+
456+func TestSnapshotRemovesOrphanedToolResultsAndMapsCheckpointCount(t *testing.T) {
457+ root := t.TempDir()
458+ session, err := NewSession(root, "/workspace", "anthropic", "model", "test")
459+ if err != nil {
460+ t.Fatal(err)
461+ }
462+ _ = session.AppendMessage(textMessage(provider.RoleUser, "prompt"))
463+ _ = session.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{
464+ provider.ToolCallBlock{ID: "call-1", Name: "read"},
465+ }})
466+ _ = session.AppendMessage(provider.Message{Role: provider.RoleTool, Content: []provider.Content{
467+ provider.ToolResultBlock{CallID: "orphan", Content: []provider.Content{provider.TextBlock{Text: "stale"}}},
468+ }})
469+ _ = session.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 9})
470+ path := session.Path
471+ if err := session.Close(); err != nil {
472+ t.Fatal(err)
473+ }
474+
475+ snapshot, err := ReadSessionSnapshot(path)
476+ if err != nil {
477+ t.Fatal(err)
478+ }
479+ if len(snapshot.Messages) != 3 {
480+ t.Fatalf("repaired messages = %d, want 3", len(snapshot.Messages))
481+ }
482+ tool := snapshot.Messages[2]
483+ if len(tool.Content) != 1 {
484+ t.Fatalf("repaired tool blocks = %d, want synthetic result only", len(tool.Content))
485+ }
486+ result, ok := tool.Content[0].(provider.ToolResultBlock)
487+ if !ok || result.CallID != "call-1" {
488+ t.Fatalf("repaired result = %#v, want call-1", tool.Content[0])
489+ }
490+ if snapshot.UsageCheckpoints[0].MessageCount != 3 {
491+ t.Fatalf("checkpoint message count = %d, want repaired count 3", snapshot.UsageCheckpoints[0].MessageCount)
492+ }
493+
494+ branchPath, err := BranchSessionHidden(path, root, "/workspace", "test", len(snapshot.Messages))
495+ if err != nil {
496+ t.Fatal(err)
497+ }
498+ _, branchMessages, err := OpenSession(branchPath)
499+ if err != nil {
500+ t.Fatal(err)
501+ }
502+ if len(branchMessages) != 3 {
503+ t.Fatalf("branch messages = %d, want 3", len(branchMessages))
504+ }
505+ branchResult := branchMessages[2].Content[0].(provider.ToolResultBlock)
506+ if branchResult.CallID != "call-1" {
507+ t.Fatalf("branch kept orphan result call id %q", branchResult.CallID)
508+ }
509+}
510+
511+func TestAppendCompactionEmptyAndHistoricalNullRowsRoundTrip(t *testing.T) {
512+ root := t.TempDir()
513+ session, err := NewSession(root, "/workspace", "anthropic", "model", "test")
514+ if err != nil {
515+ t.Fatal(err)
516+ }
517+ _ = session.AppendMessage(textMessage(provider.RoleUser, "before"))
518+ if err := session.AppendCompaction(nil); err != nil {
519+ t.Fatal(err)
520+ }
521+ if err := session.AppendCompaction([]provider.Message{}); err != nil {
522+ t.Fatal(err)
523+ }
524+ path := session.Path
525+ if err := session.Close(); err != nil {
526+ t.Fatal(err)
527+ }
528+ if _, err := os.Stat(path); err != nil {
529+ t.Fatalf("empty compaction session was removed: %v", err)
530+ }
531+ snapshot, err := ReadSessionSnapshot(path)
532+ if err != nil {
533+ t.Fatal(err)
534+ }
535+ if len(snapshot.Messages) != 0 {
536+ t.Fatalf("empty compaction messages = %d, want 0", len(snapshot.Messages))
537+ }
538+
539+ meta := SessionMeta{ID: "historical-empty", CWD: "/workspace"}
540+ metaLine, _ := json.Marshal(sessionLine{Type: "meta", Meta: &meta})
541+ for _, compaction := range []string{`{"type":"compaction"}`, `{"type":"compaction","messages":null}`} {
542+ historicalPath := filepath.Join(t.TempDir(), "historical.jsonl")
543+ contents := string(metaLine) + "\n" + compaction + "\n"
544+ if err := os.WriteFile(historicalPath, []byte(contents), 0o644); err != nil {
545+ t.Fatal(err)
546+ }
547+ historical, err := ReadSessionSnapshot(historicalPath)
548+ if err != nil {
549+ t.Fatalf("historical %s: %v", compaction, err)
550+ }
551+ if len(historical.Messages) != 0 {
552+ t.Fatalf("historical %s messages = %d, want 0", compaction, len(historical.Messages))
553+ }
554+ }
555+}
556+
557+func TestBranchPreservesCumulativeCheckpointsForLastTurnDelta(t *testing.T) {
558+ root := t.TempDir()
559+ parent, err := NewSession(root, "/workspace", "anthropic", "model", "test")
560+ if err != nil {
561+ t.Fatal(err)
562+ }
563+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "first"))
564+ _ = parent.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 10, CacheReadTokens: 4})
565+ _ = parent.AppendMessage(textMessage(provider.RoleAssistant, "second"))
566+ _ = parent.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 25, CacheReadTokens: 7})
567+ parentPath := parent.Path
568+ if err := parent.Close(); err != nil {
569+ t.Fatal(err)
570+ }
571+
572+ branchPath, err := BranchSession(parentPath, root, "/workspace", "test", 2)
573+ if err != nil {
574+ t.Fatal(err)
575+ }
576+ cumulative, last, err := SessionUsageDetail(branchPath)
577+ if err != nil {
578+ t.Fatal(err)
579+ }
580+ if cumulative.InputTokens != 25 || cumulative.CacheReadTokens != 7 {
581+ t.Fatalf("branch cumulative = %+v, want input=25 cache_read=7", cumulative)
582+ }
583+ if last.InputTokens != 15 || last.CacheReadTokens != 3 {
584+ t.Fatalf("branch last turn = %+v, want input=15 cache_read=3", last)
585+ }
586+}
587+
588+func textMessage(role provider.Role, text string) provider.Message {
589+ return provider.Message{Role: role, Content: []provider.Content{provider.TextBlock{Text: text}}}
590+}
591+
592+func TestSnapshotMessagesDoNotExposeMutableRawCompactionState(t *testing.T) {
593+ // This is a small API contract check: callers can safely materialize a
594+ // branch from the returned slice without changing the source file.
595+ root := t.TempDir()
596+ session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
597+ if err != nil {
598+ t.Fatal(err)
599+ }
600+ _ = session.AppendMessage(textMessage(provider.RoleUser, "original"))
601+ path := session.Path
602+ _ = session.Close()
603+ snapshot, err := ReadSessionSnapshot(path)
604+ if err != nil {
605+ t.Fatal(err)
606+ }
607+ snapshot.Messages[0].Content[0] = provider.TextBlock{Text: "changed in memory"}
608+ again, err := ReadSessionSnapshot(path)
609+ if err != nil {
610+ t.Fatal(err)
611+ }
612+ if got := extractText(again.Messages[0]); strings.Contains(got, "changed") {
613+ t.Fatal("mutating snapshot changed on-disk transcript")
614+ }
615+}
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 +137 -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,39 @@ 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+ b := seq[0]
223+ seq = seq[1:]
224+ return b, nil
225+ }))
226+}
227+
228+func newPeekReader(seq string) *Reader {
112 idx := 0229 idx := 0
113- r := NewReader(func() (byte, error) {230+ read := func() (byte, error) {
231+ if idx >= len(seq) {
232+ return 0, io.EOF
233+ }
114 b := seq[idx]234 b := seq[idx]
115 idx++235 idx++
116 return b, nil236 return b, nil
117- })237+ }
238+ peek := func(time.Duration) (byte, bool, error) {
239+ if idx >= len(seq) {
240+ return 0, false, nil
241+ }
242+ b := seq[idx]
243+ idx++
244+ return b, true, nil
245+ }
246+ return NewReaderWithPeek(read, peek)
247+}
248+
249+func readReaderKey(t *testing.T, r *Reader) Key {
250+ t.Helper()
118 k, err := r.Read()251 k, err := r.Read()
119 if err != nil {252 if err != nil {
120- t.Fatalf("Read(%q): %v", seq, err)253+ t.Fatalf("Read: %v", err)
121 }254 }
122 return k255 return k
123 }256 }
M packages/tui/resize_windows.go +116 -7
@@ -5,6 +5,9 @@ package tui
5 import (5 import (
6 "os"6 "os"
7 "time"7 "time"
8+ "unsafe"
9+
10+ "golang.org/x/sys/windows"
8 )11 )
9 12
10 // Windows doesn't have SIGWINCH; resize events are signaled via input13 // Windows doesn't have SIGWINCH; resize events are signaled via input
@@ -14,14 +17,120 @@ func (p *ProcTerm) installResizeHandler() {}
14 17
15 func (p *ProcTerm) SetNonblock(enable bool) error { return nil }18 func (p *ProcTerm) SetNonblock(enable bool) error { return nil }
16 19
17-// peekStdin on Windows: no non-blocking primitive in v1; fall through20+// 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 detected21+// consuming anything unless a byte is available. Console input handles can be
19-// after the next keystroke. Good enough for v1.22+// waited on directly. Pipes are checked with PeekNamedPipe, which does not
20-func peekStdin(in *os.File, _ time.Duration) (byte, bool, error) {23+// consume data; polling that non-consuming check keeps the wait bounded.
21- var b [1]byte24+//
22- _, err := in.Read(b[:])25+// Disk files and other character handles are deliberately unsupported. A
26+// speculative os.File.Read on either can block (or consume a byte that cannot
27+// be put back safely), so those handles fail closed instead of corrupting the
28+// escape parser. In particular, do not replace this with a blocking Read
29+// fallback: an isolated Esc must remain observable within d.
30+func peekStdin(in *os.File, d time.Duration) (byte, bool, error) {
31+ handle := windows.Handle(in.Fd())
32+ fileType, err := windows.GetFileType(handle)
23 if err != nil {33 if err != nil {
24 return 0, false, err34 return 0, false, err
25 }35 }
26- return b[0], true, nil36+
37+ switch fileType {
38+ case windows.FILE_TYPE_CHAR:
39+ // FILE_TYPE_CHAR also includes devices such as NUL. GetConsoleMode
40+ // distinguishes an actual console input handle from those devices.
41+ var mode uint32
42+ if err := windows.GetConsoleMode(handle, &mode); err != nil {
43+ return 0, false, windows.ERROR_INVALID_FUNCTION
44+ }
45+ return peekConsole(in, handle, d)
46+ case windows.FILE_TYPE_PIPE:
47+ return peekPipe(in, handle, d)
48+ case windows.FILE_TYPE_DISK:
49+ return 0, false, windows.ERROR_INVALID_FUNCTION
50+ default:
51+ return 0, false, windows.ERROR_INVALID_FUNCTION
52+ }
53+}
54+
55+func peekConsole(in *os.File, handle windows.Handle, d time.Duration) (byte, bool, error) {
56+ event, err := windows.WaitForSingleObject(handle, waitMilliseconds(d))
57+ if err != nil {
58+ return 0, false, err
59+ }
60+ switch event {
61+ case uint32(windows.WAIT_TIMEOUT):
62+ return 0, false, nil
63+ case windows.WAIT_OBJECT_0:
64+ var b [1]byte
65+ n, err := in.Read(b[:])
66+ if err != nil {
67+ return 0, false, err
68+ }
69+ if n != len(b) {
70+ return 0, false, windows.ERROR_INVALID_FUNCTION
71+ }
72+ return b[0], true, nil
73+ default:
74+ return 0, false, windows.ERROR_INVALID_FUNCTION
75+ }
76+}
77+
78+func waitMilliseconds(d time.Duration) uint32 {
79+ if d <= 0 {
80+ return 0
81+ }
82+ milliseconds := uint64(d / time.Millisecond)
83+ if d%time.Millisecond != 0 {
84+ milliseconds++
85+ }
86+ const maxWaitMilliseconds = uint64(^uint32(0) - 1) // reserve INFINITE
87+ if milliseconds > maxWaitMilliseconds {
88+ return uint32(maxWaitMilliseconds)
89+ }
90+ return uint32(milliseconds)
91+}
92+
93+var peekNamedPipe = windows.NewLazySystemDLL("kernel32.dll").NewProc("PeekNamedPipe")
94+
95+func peekPipe(in *os.File, handle windows.Handle, d time.Duration) (byte, bool, error) {
96+ deadline := time.Now().Add(d)
97+ for {
98+ var available uint32
99+ ok, _, callErr := peekNamedPipe.Call(
100+ uintptr(handle),
101+ 0, 0, 0,
102+ uintptr(unsafe.Pointer(&available)),
103+ 0,
104+ )
105+ if ok == 0 {
106+ if callErr != nil {
107+ return 0, false, callErr
108+ }
109+ return 0, false, windows.ERROR_INVALID_FUNCTION
110+ }
111+ if available != 0 {
112+ var b [1]byte
113+ n, err := in.Read(b[:])
114+ if err != nil {
115+ return 0, false, err
116+ }
117+ if n != len(b) {
118+ return 0, false, windows.ERROR_INVALID_FUNCTION
119+ }
120+ return b[0], true, nil
121+ }
122+ if d <= 0 {
123+ return 0, false, nil
124+ }
125+ remaining := time.Until(deadline)
126+ if remaining <= 0 {
127+ return 0, false, nil
128+ }
129+ // PeekNamedPipe is a non-consuming availability check. A short
130+ // sleep avoids a busy loop while the deadline remains authoritative.
131+ if remaining > time.Millisecond {
132+ remaining = time.Millisecond
133+ }
134+ time.Sleep(remaining)
135+ }
27 }136 }

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