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
277277 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
278278 | `/reasoning` | Set the reasoning level for subsequent model calls. |
279279 | `/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. |
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`. |
280+| `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. |
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. |
282283 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
283284 | `/btw` | Side chat with full context that doesn't add to the main thread. |
284285 | `/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
301302
302303 ### `/sessions`
303304
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.
305306
306307 ### `/session`
307308
@@ -310,9 +311,13 @@ Four ops on the current session. `/session` alone opens a picker; each is also r
310311 - **`/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.
311312
312313 **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 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-- **`/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.
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).
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.
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.
316321
317322 ### `/jump`
318323
@@ -419,7 +424,7 @@ This is a guardrail against accidents, not a hard security boundary. If you need
419424
420425 ## Sessions
421426
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.
423428
424429 ## Providers
425430
@@ -860,7 +865,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
860865 | `enter` | Submit (queued if the agent is busy). |
861866 | `alt+enter` | Newline. |
862867 | `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. |
864869 | `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. |
865870 | `ctrl+d` | Exit on empty input. |
866871 | `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
487487 $ZOT_HOME/sessions/agents/<name>/
488488 ```
489489
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.
491491
492492 ## Commands
493493
M packages/agent/cli.go +393 -54
@@ -198,6 +198,161 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes
198198 return provider.RepairOrphanedToolResults(out)
199199 }
200200
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+
201356 // fanoutAgentEvent translates a core.AgentEvent into the wire-format
202357 // EventFromHost and pushes it through the extension manager. Only
203358 // the events that have a clear extension-facing meaning are
@@ -426,8 +581,19 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
426581
427582 ag := r.NewAgent()
428583 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
429- sess, _ := openOrCreateSession(args, r, ag, version)
430- defer sess.Close()
584+ sess, err := openOrCreateSession(args, r, ag, version)
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+ }
431597
432598 prompt := args.Prompt
433599 if prompt == "" {
@@ -469,8 +635,19 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
469635
470636 ag := r.NewAgent()
471637 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
472- sess, _ := openOrCreateSession(args, r, ag, version)
473- defer sess.Close()
638+ sess, err := openOrCreateSession(args, r, ag, version)
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+ }
474651
475652 prompt := args.Prompt
476653 if prompt == "" {
@@ -551,8 +728,19 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
551728
552729 ag := r.NewAgent()
553730 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
554- sess, _ := openOrCreateSession(args, r, ag, version)
555- defer sess.Close()
731+ sess, err := openOrCreateSession(args, r, ag, version)
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+ }
556744
557745 prompt := args.Prompt
558746 if prompt == "" {
@@ -695,6 +883,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
695883 // rebuilt tool instances must share the same one so the lock sticks.
696884 sharedSandbox := r.Sandbox
697885
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+
698897 // Build the extension manager BEFORE the agent so we can fold
699898 // extension-defined tools into the registry. Attach the interactive
700899 // 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 {
791990 return reg
792991 }
793992 reg["swarm_spawn"] = &tools.SwarmSpawnTool{
794- Swarm: swarmMgr,
795- Enabled: AutoSwarmEnabled,
796- DefaultModel: func() string { return r.Model },
797- DefaultProvider: func() string { return r.Provider },
993+ Swarm: swarmMgr,
994+ Enabled: AutoSwarmEnabled,
995+ DefaultModel: func() string {
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+ },
7981005 DefaultReasoning: func() string { return r.Reasoning },
7991006 ResolveSubagent: resolveSubagent,
8001007 OnSpawned: onSpawnedSwarm,
@@ -957,17 +1164,42 @@ func runInteractive(ctx context.Context, args Args, version string) error {
9571164
9581165 var sess *core.Session
9591166 var sessBaselineMsgs int // messages already on disk when current session opened
960- // persistMu guards sess + sessBaselineMsgs against concurrent access
961- // from the agent loop's per-message persistence hook (runs on the
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
1167+ // persistMu also guards sess + sessBaselineMsgs, so a session swap
1168+ // commits the transcript writer and live selection under the same lock.
9661169 if !args.NoSess && ag != nil {
9671170 sess, _ = openOrCreateSession(args, r, ag, version)
9681171 if ag != nil {
9691172 sessBaselineMsgs = len(ag.Messages())
9701173 }
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+ }
9711203 }
9721204 defer func() {
9731205 persistMu.Lock()
@@ -984,6 +1216,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
9841216 // baseline counter advances in lock-step so the exit-time flush
9851217 // doesn't double-write rows already on disk.
9861218 persistMessage := func(m provider.Message) {
1219+ sessionTransitionMu.RLock()
1220+ defer sessionTransitionMu.RUnlock()
9871221 persistMu.Lock()
9881222 defer persistMu.Unlock()
9891223 if sess == nil {
@@ -994,6 +1228,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
9941228 }
9951229 }
9961230 persistUsage := func(cum provider.Usage) {
1231+ sessionTransitionMu.RLock()
1232+ defer sessionTransitionMu.RUnlock()
9971233 persistMu.Lock()
9981234 defer persistMu.Unlock()
9991235 if sess == nil {
@@ -1002,6 +1238,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10021238 _ = sess.AppendUsage(cum, cum)
10031239 }
10041240 persistCompaction := func(messages []provider.Message) {
1241+ sessionTransitionMu.RLock()
1242+ defer sessionTransitionMu.RUnlock()
10051243 persistMu.Lock()
10061244 defer persistMu.Unlock()
10071245 if sess == nil {
@@ -1025,7 +1263,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10251263 // Re-wrap the build closures so any agent constructed by the TUI
10261264 // (login, /model swap to a different provider) also gets the
10271265 // 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.
10291268 baseBuildAgent := buildAgent
10301269 buildAgent = func() (*core.Agent, string, string, error) {
10311270 a, p, m, err := baseBuildAgent()
@@ -1045,47 +1284,100 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10451284 // loadSession replaces the current session with the one at path and
10461285 // hands its messages to the agent. Used by the /sessions picker.
10471286 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+
10481294 currentAg := liveInteractiveAgent(iv, ag)
10491295 if currentAg == nil {
10501296 return fmt.Errorf("no agent running; log in first")
10511297 }
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)
10531316 if err != nil {
1317+ // prepareSessionResume closes its append handle on failure;
1318+ // no live agent or session has been changed.
10541319 return err
10551320 }
1056- fullMsgCount := len(msgs)
1057- msgs = trimMessagesForResume(msgs, 100)
1321+ committed := false
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+
10581333 persistMu.Lock()
1059- // Flush any unsaved messages to the old session before swapping.
1060- // Per-message persistence keeps sessBaselineMsgs current, so
1061- // this is a defensive no-op in the common case; it still
1062- // matters for the rare race where a turn just finished and
1063- // hadn't fired its hook yet.
1064- if sess != nil {
1065- writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1066- _ = sess.Close()
1334+ // A provider rebuild ran outside persistMu. Refuse to install it if
1335+ // the live agent, session, selection, transcript, or cost changed in
1336+ // the meantime; otherwise a slow resume could discard a newer turn or
1337+ // model swap.
1338+ live := liveInteractiveAgent(iv, ag)
1339+ changed := sess != oldSess || activeProvider != currentProvider || activeModel != currentModel || live != currentAg
1340+ if !changed && live != nil {
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")
10671346 }
1068- sess = newSess
1069- currentAg.SetMessages(msgs)
1070- if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {
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)
1347+
1348+ if oldSess != nil {
1349+ _ = oldSess.Close()
10881350 }
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()
10891381 return nil
10901382 }
10911383
@@ -1105,6 +1397,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11051397 // The /jail state is preserved verbatim: if the sandbox was locked
11061398 // to the old cwd, it stays locked, just re-pointed at the new one.
11071399 changeCWD := func(path string) error {
1400+ sessionTransitionMu.Lock()
1401+ defer sessionTransitionMu.Unlock()
11081402 if path == "" {
11091403 return fmt.Errorf("empty path")
11101404 }
@@ -1135,7 +1429,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11351429 return fmt.Errorf("not a directory: %s", absPath)
11361430 }
11371431
1138- currentAg := ag
1432+ currentAg := liveInteractiveAgent(iv, ag)
11391433 if currentAg == nil {
11401434 return fmt.Errorf("no agent running; log in first")
11411435 }
@@ -1146,6 +1440,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11461440 oldCWD := args.CWD
11471441 oldResolvedCWD := r.CWD
11481442 oldPermissionSet := args.PermissionSet
1443+ persistMu.Lock()
1444+ oldActiveProvider := activeProvider
1445+ oldActiveModel := activeModel
1446+ persistMu.Unlock()
11491447 oldSwarmRoot := ""
11501448 if swarmMgr != nil {
11511449 oldSwarmRoot = swarmMgr.RepoRoot()
@@ -1160,6 +1458,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11601458 args.CWD = oldCWD
11611459 r.CWD = oldResolvedCWD
11621460 args.PermissionSet = oldPermissionSet
1461+ persistMu.Lock()
1462+ activeProvider = oldActiveProvider
1463+ activeModel = oldActiveModel
1464+ persistMu.Unlock()
11631465 if swarmMgr != nil {
11641466 swarmMgr.SetRepoRoot(oldSwarmRoot)
11651467 }
@@ -1224,6 +1526,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12241526 }
12251527 sess = newSess
12261528 sessBaselineMsgs = 0
1529+ activeProvider = newProvider
1530+ activeModel = newModel
12271531 persistMu.Unlock()
12281532 ag = newAg
12291533
@@ -1413,12 +1717,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14131717 LoadSession: loadSession,
14141718 ChangeCWD: changeCWD,
14151719 CurrentSessionPath: func() string {
1720+ persistMu.Lock()
1721+ defer persistMu.Unlock()
14161722 if sess == nil {
14171723 return ""
14181724 }
14191725 return sess.Path
14201726 },
14211727 FlushSession: func() {
1728+ sessionTransitionMu.RLock()
1729+ defer sessionTransitionMu.RUnlock()
14221730 // Append any not-yet-persisted agent messages to the
14231731 // current session file, then advance the baseline so
14241732 // the final WriteNewTranscript at exit doesn't write
@@ -1439,6 +1747,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14391747 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
14401748 sessBaselineMsgs = len(currentAg.Messages())
14411749 },
1750+ SessionTransition: func(fn func()) {
1751+ sessionTransitionMu.Lock()
1752+ defer sessionTransitionMu.Unlock()
1753+ fn()
1754+ },
14421755 Extensions: extMgr,
14431756 Swarm: swarmMgr,
14441757 ResolveSubagent: resolveSubagent,
@@ -1473,14 +1786,40 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14731786 NoYolo: args.NoYolo,
14741787 ConfirmGate: confirmGate,
14751788 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+
14761811 // 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+ }
14781819 cfg.Provider = providerName
14791820 cfg.Model = model
1480- _ = SaveConfig(cfg)
1481- // Update the active session's meta so resume picks this up.
1482- if sess != nil {
1483- _ = sess.UpdateModel(providerName, model)
1821+ if cfgErr = SaveConfig(cfg); cfgErr != nil && iv != nil {
1822+ iv.Notify("session", "error", "model changed for this run, but config could not be saved: "+cfgErr.Error())
14841823 }
14851824 },
14861825 })
M packages/agent/cli_session_test.go +151 -0
@@ -1,6 +1,9 @@
11 package agent
22
33 import (
4+ "context"
5+ "errors"
6+ "os"
47 "testing"
58
69 "github.com/patriceckhart/zot/packages/agent/modes"
@@ -46,3 +49,151 @@ func firstMessageText(messages []provider.Message) string {
4649 text, _ := messages[0].Content[0].(provider.TextBlock)
4750 return text.Text
4851 }
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{
1313 {"enter", "submit the current input"},
1414 {"shift+enter / alt+enter", "insert a newline"},
1515 {"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"},
1717 {"ctrl+c", "exit (while idle) - cancel the current turn (while busy)"},
1818 {"ctrl+w", "delete previous word"},
1919 {"alt+backspace", "delete previous word (same as ctrl+w)"},
M packages/agent/modes/interactive.go +254 -97
@@ -251,6 +251,12 @@ type InteractiveConfig struct {
251251 // file that only has the meta row.
252252 FlushSession func()
253253
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+
254260 // PersistModel is called whenever the user switches model or provider.
255261 // It should update config.json and (if there's an active session)
256262 // write a new meta row so resume picks up the same model.
@@ -424,6 +430,7 @@ type Interactive struct {
424430 busy bool
425431 dirty chan struct{}
426432 modelRefresh chan modelRefreshResult
433+ modelRefreshing bool
427434 startupPreDone chan startupPreResult
428435 cancelTurn context.CancelFunc
429436 scrollOffset int // rows from the bottom; 0 = pinned to latest
@@ -543,6 +550,11 @@ type Interactive struct {
543550 // within ctrlCExitWindow exits. Mirrors the python-repl convention.
544551 lastCtrlC time.Time
545552
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+
546558 // welcomeStart is when the interactive run began. The welcome
547559 // banner shows the binary version for welcomeVersionDuration
548560 // after this point and reverts to plain text after.
@@ -671,6 +683,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
671683 fileSuggest: newFileSuggester(),
672684 spin: newSpinner(cfg.Theme),
673685 inputHistoryIndex: -1,
686+ clock: time.Now,
674687 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
675688 }
676689 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
@@ -915,6 +928,9 @@ func (i *Interactive) Run(ctx context.Context) error {
915928 i.handleAuthEvent(ev)
916929 i.invalidate()
917930 case result := <-i.modelRefresh:
931+ i.mu.Lock()
932+ i.modelRefreshing = false
933+ i.mu.Unlock()
918934 i.openModelPickerAfterRefresh(result.err)
919935 i.invalidate()
920936 case result := <-i.startupPreDone:
@@ -1323,6 +1339,14 @@ func (i *Interactive) redraw() {
13231339 case i.sessionOpsDialog.Active():
13241340 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
13251341 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
13261350 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
13271351 }
13281352 if len(dialog) > 0 {
@@ -2074,6 +2098,13 @@ func (i *Interactive) restoreConfirmFocus() {
20742098 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
20752099 defer i.restoreConfirmFocus()
20762100
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+
20772108 // Dialogs route keys before the main clipboard handler below. Resolve text
20782109 // here when a child interaction owns input so every editor and filter sees
20792110 // 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) {
23032334 }
23042335 act := i.sessionTreeDialog.HandleKey(k)
23052336 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+ }
23072344 }
23082345 i.invalidate()
23092346 return false
@@ -2384,6 +2421,28 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
23842421 return false
23852422 }
23862423
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+
23872446 // Global keys.
23882447 switch k.Kind {
23892448 case tui.KeyCtrlC:
@@ -2690,18 +2749,27 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
26902749 if text == "" && len(images) == 0 {
26912750 return false
26922751 }
2693- i.clipboardImages = nil
2694- i.ed.Clear()
2695- i.inputHistoryIndex = -1
2696- i.suggest.Reset()
2697- i.fileSuggest.Reset()
2752+ clearSubmittedInput := func() {
2753+ i.clipboardImages = nil
2754+ i.ed.Clear()
2755+ i.inputHistoryIndex = -1
2756+ i.suggest.Reset()
2757+ i.fileSuggest.Reset()
2758+ }
26982759
2699- if cmd, ok := shellEscapeCommand(text); ok {
2700- i.startShellEscape(ctx, cmd)
2701- return false
2760+ // Shell escapes and slash commands are text-only. An image-bearing
2761+ // draft remains a normal provider prompt instead of silently dropping
2762+ // 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+ }
27022769 }
27032770
2704- if looksLikeSlashCommand(text) {
2771+ if len(images) == 0 && looksLikeSlashCommand(text) {
2772+ clearSubmittedInput()
27052773 head := text
27062774 rest := ""
27072775 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
@@ -2724,12 +2792,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
27242792 }
27252793 // Slash commands run regardless of busy state. Commands that
27262794 // would mutate the transcript or replace the agent (/clear,
2727- // /compact, /logout, /login, /model) cancel the active turn
2795+ // /compact, /logout, /login, /model, /fork, /session fork)
2796+ // cancel the active turn
27282797 // first and wait for the goroutine to wind down so they don't
27292798 // race with a streaming response. Safe commands (/help,
27302799 // /jump, /sessions, /jail, /unjail, /exit) run immediately
27312800 // without disturbing the active turn.
2732- if slashCancelsTurn(head) {
2801+ if slashCommandCancelsTurn(text) {
27332802 i.cancelAndWaitForIdle()
27342803 }
27352804 return i.runSlash(ctx, text)
@@ -2765,6 +2834,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
27652834 i.invalidate()
27662835 return false
27672836 }
2837+ clearSubmittedInput()
27682838 if ag != nil && !compacting {
27692839 ag.QueueMessage(text)
27702840 } else {
@@ -2775,6 +2845,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
27752845 i.invalidate()
27762846 return false
27772847 }
2848+ clearSubmittedInput()
27782849 i.startTurnWithImages(ctx, text, images)
27792850 }
27802851 return false
@@ -3105,6 +3176,38 @@ func (i *Interactive) applyStartupPreResult(result startupPreResult) {
31053176 }
31063177 }
31073178
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+
31083211 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
31093212 // not provide startup context metadata.
31103213 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
31653268 // agent cancel the active turn first via the same path the editor
31663269 // uses for typed slash commands.
31673270 func (i *Interactive) SubmitSlash(text string) {
3271+ i.doubleEscape.Reset()
31683272 text = strings.TrimSpace(text)
31693273 if !strings.HasPrefix(text, "/") {
31703274 return
@@ -4462,6 +4566,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
44624566 i.applyModelSelection("", parts[1])
44634567 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
44644568 i.mu.Lock()
4569+ i.modelRefreshing = true
44654570 i.statusOK = "refreshing models"
44664571 i.statusErr = ""
44674572 i.mu.Unlock()
@@ -4497,6 +4602,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
44974602 i.openSettingsDialog()
44984603 case "/sessions":
44994604 i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)
4605+ case "/fork":
4606+ i.doSessionFork()
45004607 case "/jump":
45014608 i.openJumpDialog(parts[1:])
45024609 case "/btw":
@@ -5336,6 +5443,16 @@ func (i *Interactive) applyRescueModelSelection(prov, model string) {
53365443 // the supplied builder. rescue=true tags the success message so the
53375444 // user can see that launch-time overrides were ignored.
53385445 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) {
53395456 if model == "" {
53405457 return
53415458 }
@@ -5438,15 +5555,27 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
54385555 i.dialog.ShowResult(false, err.Error())
54395556 return
54405557 }
5441- i.mu.Lock()
5442- i.agent = ag
5443- i.cfg.Provider = prov
5444- i.cfg.Model = model
5445- i.statusErr = ""
5446- i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5447- i.mu.Unlock()
5448- i.applyAutoSwarmTool(i.autoSwarmEnabled())
5449- i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5558+ commitLogin := func() {
5559+ i.mu.Lock()
5560+ i.agent = ag
5561+ i.cfg.Provider = prov
5562+ i.cfg.Model = model
5563+ i.statusErr = ""
5564+ i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5565+ i.mu.Unlock()
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+ }
54505579 i.dialog.ShowResult(true, "")
54515580 }
54525581 }
@@ -6885,14 +7014,7 @@ func (i *Interactive) doSessionOp(action, arg string) {
68857014 // the user's home directory if it doesn't exist). The helper
68867015 // expands a leading `~` and creates any missing parent directories.
68877016 func (i *Interactive) doSessionExport(dst string) {
6888- if i.cfg.CurrentSessionPath == nil {
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()
7017+ src := i.currentSessionPath()
68967018 if src == "" {
68977019 i.mu.Lock()
68987020 i.statusErr = "export: no session is active (running with --no-session?)"
@@ -7047,114 +7169,156 @@ func friendlyPath(p string) string {
70477169 // next selection branches the current session at that user turn
70487170 // instead of scrolling the viewport to it.
70497171 func (i *Interactive) doSessionFork() {
7050- if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
7172+ path := i.currentSessionPath()
7173+ if path == "" {
70517174 i.mu.Lock()
70527175 i.statusErr = "fork: no session is active (running with --no-session?)"
70537176 i.mu.Unlock()
70547177 i.invalidate()
70557178 return
70567179 }
7057- msgs := []provider.Message{}
7058- if i.agent != nil {
7059- msgs = i.agent.Messages()
7060- }
7061- if len(msgs) == 0 {
7180+ i.mu.Lock()
7181+ busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
7182+ queued := len(i.queued) != 0
7183+ ag := i.agent
7184+ i.mu.Unlock()
7185+ if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
70627186 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"
70647188 i.mu.Unlock()
70657189 i.invalidate()
70667190 return
70677191 }
7068- i.pendingFork = true
7069- i.jumpDialog.Open(msgs, "")
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()
7192+ if i.cfg.FlushSession != nil {
7193+ i.cfg.FlushSession()
70807194 }
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()
70827200 i.invalidate()
70837201 return
70847202 }
7085- msgs := []provider.Message{}
7086- if i.agent != nil {
7087- msgs = i.agent.Messages()
7088- }
7203+ msgs := snapshot.Messages
70897204 if len(msgs) == 0 {
70907205 i.mu.Lock()
7091- i.statusErr = "tree: no messages in this session yet"
7206+ i.statusErr = "fork: transcript is empty; nothing to fork from"
70927207 i.mu.Unlock()
70937208 i.invalidate()
70947209 return
70957210 }
7096- if !i.sessionTreeDialog.OpenMessages(msgs) {
7097- i.mu.Lock()
7098- i.statusErr = "tree: no messages to show"
7099- i.mu.Unlock()
7100- }
7211+ i.pendingFork = true
7212+ i.jumpDialog.Open(msgs, "")
71017213 i.invalidate()
71027214 }
71037215
7104-// applySessionTreeMessageSelection checks out the selected message into a
7105-// new branch, then switches the running agent to that branch. Unlike /jump,
7106-// this changes future model context: only messages up to the selected row are
7107-// sent on subsequent turns.
7216+// doSessionTree opens the current session family after the shared
7217+// fail-closed gate succeeds. It intentionally does not fall back to the
7218+// in-memory transcript: a tree that cannot be persisted and reloaded cannot
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.
71087227 func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
7109- if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
7110- i.mu.Lock()
7111- i.statusErr = "tree: session branching is not available in this build"
7112- i.mu.Unlock()
7113- i.invalidate()
7228+ target := sessionTreeTarget{
7229+ SourcePath: src,
7230+ EffectiveIndex: msgIdx,
7231+ SelectionBoundary: msgIdx + 1,
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")
71147253 return
71157254 }
7255+ src := target.SourcePath
71167256 if src == "" {
7117- src = i.cfg.CurrentSessionPath()
7257+ src = i.currentSessionPath()
71187258 }
71197259 if src == "" {
7120- i.mu.Lock()
7121- i.statusErr = "tree: no session is active"
7122- i.mu.Unlock()
7123- i.invalidate()
7260+ i.setSessionTreeError("tree: no session is active")
7261+ return
7262+ }
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())
71247276 return
71257277 }
71267278 if i.cfg.FlushSession != nil {
71277279 i.cfg.FlushSession()
71287280 }
7129- upTo := msgIdx + 1
7130- if role == provider.RoleUser {
7131- upTo = msgIdx
7132- }
7133- newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
7281+ newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
71347282 if err != nil {
7135- i.mu.Lock()
7136- i.statusErr = "tree: " + err.Error()
7137- i.mu.Unlock()
7138- i.invalidate()
7283+ i.setSessionTreeError("tree: " + err.Error())
71397284 return
71407285 }
71417286 if err := i.cfg.LoadSession(newPath); err != nil {
7142- i.mu.Lock()
7143- i.statusErr = "tree: checkout failed: " + err.Error()
7144- i.mu.Unlock()
7145- i.invalidate()
7287+ i.setSessionTreeError("tree: checkout failed: " + err.Error())
71467288 return
71477289 }
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.
71497294 i.mu.Lock()
7150- if role == provider.RoleUser {
7151- i.ed.SetValue(prompt)
7295+ i.lastCtxInput = 0
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
71527310 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
71537311 } else {
7312+ i.ed.Clear()
7313+ i.clipboardImages = nil
71547314 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
71557315 }
71567316 i.statusErr = ""
71577317 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.
71587322 i.invalidate()
71597323 }
71607324
@@ -7164,14 +7328,7 @@ func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnN
71647328 // file. Called from the jump-dialog handler when pendingFork=true.
71657329 func (i *Interactive) applyForkSelection(msgIdx int) {
71667330 i.pendingFork = false
7167- if i.cfg.CurrentSessionPath == nil {
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()
7331+ src := i.currentSessionPath()
71757332 if src == "" {
71767333 i.mu.Lock()
71777334 i.statusErr = "fork: no session is active"
M packages/agent/modes/session_tree_dialog.go +599 -106
@@ -2,13 +2,66 @@ package modes
22
33 import (
44 "fmt"
5+ "reflect"
56 "strings"
7+ "unicode"
8+
9+ "github.com/mattn/go-runewidth"
610
711 "github.com/patriceckhart/zot/packages/core"
812 "github.com/patriceckhart/zot/packages/provider"
913 "github.com/patriceckhart/zot/packages/tui"
1014 )
1115
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+
1265 // sessionTreeDialog renders a compact outline of the current session family.
1366 // Branches are shown inline at the point where they forked, using indentation
1467 // rather than file-level rows. Selecting a node checks out that point into a
@@ -17,54 +70,108 @@ type sessionTreeDialog struct {
1770 active bool
1871 items []sessionTreeItem
1972 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
2080 }
2181
2282 type sessionTreeItem struct {
2383 label string
24- messageIdx int
84+ messageIdx int // compatibility alias for target.EffectiveIndex
2585 turnNo int
2686 role provider.Role
27- prompt string
28- path string
87+ prompt string // compatibility alias for target.UserDraft
88+ path string // compatibility alias for target.SourcePath
2989 depth int
3090 current bool
91+ target sessionTreeTarget
3192 }
3293
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.
3397 type sessionTreeAction struct {
34- Select bool
98+ 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.
35103 MessageIdx int
36104 TurnNo int
37105 Role provider.Role
38106 Prompt string
39107 Path string
108+ Boundary sessionTreeBoundary
40109 Close bool
41110 }
42111
112+type sessionTreeSnapshot struct {
113+ path string
114+ messages []provider.Message
115+}
116+
117+const defaultSessionTreeRows = 12
118+
43119 func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }
44120
121+// OpenMessages opens a snapshot supplied by the running agent. This is the
122+// fallback used before a session has a persisted source path.
45123 func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool {
46- d.items = buildSessionTreeItems("", msgs, 0, false)
47- d.cursor = len(d.items) - 1
48- if d.cursor < 0 {
49- d.cursor = 0
124+ items := buildSessionTreeItems("", msgs, 0, false)
125+ if len(items) == 0 {
126+ return false
50127 }
51- d.active = true
52- return len(d.items) > 0
128+ d.activate(items, len(items)-1)
129+ return true
53130 }
54131
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.
55136 func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool {
56- roots := core.BuildSessionTree(root, cwd)
57- if len(roots) == 0 {
137+ roots, err := core.BuildSessionTreeFamilyStrict(root, cwd, currentPath)
138+ if err != nil || len(roots) == 0 {
58139 return false
59140 }
60- rootNode := findTreeRootForPath(roots, currentPath)
61- if rootNode == nil {
62- rootNode = roots[0]
141+ familyRoot := roots[0]
142+
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
63150 }
64- d.items = flattenSessionFamily(rootNode, currentPath)
65- d.cursor = indexCurrentTreeItem(d.items)
151+ d.activate(items, indexCurrentTreeItem(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
66174 d.active = true
67- return len(d.items) > 0
68175 }
69176
70177 // Close hides the dialog.
@@ -76,30 +183,60 @@ func (d *sessionTreeDialog) CursorPos() (row, col int) { return -1, -1 }
76183 // Active reports whether the dialog consumes input.
77184 func (d *sessionTreeDialog) Active() bool { return d != nil && d.active }
78185
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+
79197 // HandleKey advances the cursor or resolves the selection.
80198 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+ }
81206 switch k.Kind {
82207 case tui.KeyUp:
83208 if d.cursor > 0 {
84209 d.cursor--
85210 }
211+ d.followCursor()
86212 case tui.KeyDown:
87213 if d.cursor < len(d.items)-1 {
88214 d.cursor++
89215 }
216+ d.followCursor()
90217 case tui.KeyPageUp:
91- d.cursor -= 5
218+ d.cursor -= page
92219 if d.cursor < 0 {
93220 d.cursor = 0
94221 }
222+ d.followCursor()
95223 case tui.KeyPageDown:
96- d.cursor += 5
224+ d.cursor += page
97225 if d.cursor >= len(d.items) {
98226 d.cursor = len(d.items) - 1
99227 }
100228 if d.cursor < 0 {
101229 d.cursor = 0
102230 }
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()
103240 case tui.KeyEsc:
104241 d.Close()
105242 return sessionTreeAction{Close: true}
@@ -110,148 +247,395 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
110247 }
111248 it := d.items[d.cursor]
112249 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)
114251 }
115252 return sessionTreeAction{}
116253 }
117254
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.
119279 func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
120280 if !d.Active() {
121281 return nil
122282 }
123- var lines []string
124- lines = append(lines, frameHeader(th, "session tree", width))
283+ renderWidth := 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))}
125290 if len(d.items) == 0 {
126- lines = append(lines, th.FG256(th.Muted, "no messages in this session yet"))
127- lines = append(lines, th.FG256(th.Muted, "press esc to close"))
128- lines = append(lines, frameRule(th, width))
291+ lines = append(lines,
292+ line(th.FG256(th.Muted, "no messages in this session yet")),
293+ line(th.FG256(th.Muted, "press esc to close")),
294+ line(frameRule(th, renderWidth)),
295+ )
129296 return lines
130297 }
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):")))
132300
133- const maxRows = 12
134- start := 0
135- end := len(d.items)
136- if end > maxRows {
137- start = d.cursor - maxRows/2
138- if start < 0 {
139- start = 0
140- }
141- end = start + maxRows
142- if end > len(d.items) {
143- end = len(d.items)
144- start = end - maxRows
145- }
301+ d.followCursor()
302+ start := d.viewTop
303+ end := start + d.pageRows()
304+ if end > len(d.items) {
305+ end = len(d.items)
306+ }
307+ if start > end {
308+ start = end
146309 }
147310 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))))
149312 }
150313 for i := start; i < end; i++ {
151314 it := d.items[i]
152- indent := strings.Repeat(" ", it.depth)
153- plain := " " + indent + fitSessionTreeLabel(it.label, width-2-len([]rune(indent)))
154- if it.current {
155- plain += " [current]"
156- }
315+ plain := fitSessionTreeItemPlain(it, renderWidth)
157316 if i == d.cursor {
158- lines = append(lines, th.PadHighlight(plain, width))
317+ lines = append(lines, line(th.PadHighlight(plain, renderWidth)))
159318 } else {
160- lines = append(lines, colorSessionTreeLine(th, plain))
319+ lines = append(lines, line(colorSessionTreeLine(th, plain)))
161320 }
162321 }
163322 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))))
165324 }
166- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items))))
167- lines = append(lines, frameRule(th, width))
325+ lines = append(lines, line(th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items)))), line(frameRule(th, renderWidth)))
168326 return lines
169327 }
170328
171-func flattenSessionFamily(root *core.TreeNode, currentPath string) []sessionTreeItem {
172- childrenByFork := map[int][]*core.TreeNode{}
173- for _, child := range root.Children {
174- childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child)
329+func clampSessionTreeViewTop(viewTop, cursor, window, total int) int {
330+ if window <= 0 || total <= 0 || window >= total {
331+ return 0
175332 }
176- sess, msgs, err := core.OpenSession(root.Summary.Path)
177- if err != nil {
178- return nil
333+ if cursor < viewTop {
334+ viewTop = cursor
179335 }
180- _ = sess.Close()
181- items := buildSessionTreeItems(root.Summary.Path, msgs, 0, root.Summary.Path == currentPath)
182- var out []sessionTreeItem
183- for idx, item := range items {
184- out = append(out, item)
185- for _, child := range childrenByFork[idx+1] {
186- out = append(out, flattenSessionBranch(child, currentPath, 1)...)
336+ if cursor >= viewTop+window {
337+ viewTop = cursor - window + 1
338+ }
339+ if viewTop < 0 {
340+ viewTop = 0
341+ }
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)
187385 }
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
188404 }
189- for _, child := range childrenByFork[0] {
190- out = append(out, flattenSessionBranch(child, currentPath, 1)...)
405+ if err := walk(root); err != nil {
406+ return nil, err
191407 }
192- return out
408+ return snapshots, nil
193409 }
194410
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.
195428 func flattenSessionBranch(node *core.TreeNode, currentPath string, depth int) []sessionTreeItem {
196- sess, msgs, err := core.OpenSession(node.Summary.Path)
429+ snapshots, err := preflightSessionTreeFamily(node)
197430 if err != nil {
198431 return nil
199432 }
200- _ = sess.Close()
201- start := node.Meta.ForkPoint
202- if start < 0 {
203- start = 0
433+ parentLen := 0
434+ if snap, ok := snapshots[node.Summary.Path]; ok {
435+ parentLen = len(snap.messages)
204436 }
205- if start > len(msgs) {
206- start = len(msgs)
437+ return flattenSessionNode(node, currentPath, snapshots, depth, parentLen, false, false)
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
207447 }
208- items := buildSessionTreeItems(node.Summary.Path, msgs[start:], depth, node.Summary.Path == currentPath)
209- for idx := range items {
210- items[idx].messageIdx += start
448+ snapshot, ok := snapshots[node.Summary.Path]
449+ if !ok {
450+ return nil
211451 }
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
213484 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)
215506 }
507+
216508 var out []sessionTreeItem
217- for relIdx, item := range items {
218- out = append(out, item)
219- absAfter := start + relIdx + 1
220- for _, child := range childrenByFork[absAfter] {
221- out = append(out, flattenSessionBranch(child, currentPath, depth+1)...)
509+ if boundary != sessionTreeMessageBoundary {
510+ turn := treeTurnAtBoundary(msgs, selectionBoundary)
511+ boundaryCurrent := snapshot.path == currentPath && (len(msgs) == 0 || boundary == sessionTreeEmptyBoundary)
512+ out = append(out, makeSessionTreeBoundaryItem(snapshot.path, selectionBoundary, depth, boundaryCurrent, boundary, rawFork, len(msgs), turn))
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)...)
222518 }
223519 }
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)
224532 return out
225533 }
226534
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+
227583 func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem {
228584 out := make([]sessionTreeItem, 0, len(msgs))
229585 turn := 0
230586 lastTurn := 0
231587 for idx, msg := range msgs {
232- label := sessionTreeRoleLabel(msg.Role)
233588 if msg.Role == provider.RoleUser {
234589 turn++
235590 lastTurn = turn
236591 } else if lastTurn == 0 {
237592 lastTurn = 1
238593 }
594+ roleLabel := sessionTreeRoleLabel(msg.Role)
239595 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+ }
240610 out = append(out, sessionTreeItem{
241- label: fmt.Sprintf("%s: %s", label, preview),
611+ label: fmt.Sprintf("%s: %s", roleLabel, preview),
242612 messageIdx: idx,
243613 turnNo: lastTurn,
244614 role: msg.Role,
245- prompt: firstTextFromTreeMessage(msg),
615+ prompt: draft,
246616 path: path,
247617 depth: depth,
248618 current: currentPath && idx == len(msgs)-1,
619+ target: target,
249620 })
250621 }
251622 return out
252623 }
253624
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+
254635 func findTreeRootForPath(roots []*core.TreeNode, path string) *core.TreeNode {
636+ if path == "" {
637+ return nil
638+ }
255639 for _, root := range roots {
256640 if treeContainsPath(root, path) {
257641 return root
@@ -287,15 +671,6 @@ func indexCurrentTreeItem(items []sessionTreeItem) int {
287671 return len(items) - 1
288672 }
289673
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-
299674 func sessionTreeRoleLabel(role provider.Role) string {
300675 switch role {
301676 case provider.RoleUser:
@@ -314,14 +689,13 @@ func sessionTreePreview(msg provider.Message) string {
314689 for _, c := range msg.Content {
315690 switch b := c.(type) {
316691 case provider.TextBlock:
317- text := strings.TrimSpace(strings.ReplaceAll(b.Text, "\n", " "))
318- if text != "" {
692+ if text := sanitizeSessionTreeText(b.Text); text != "" {
319693 parts = append(parts, text)
320694 }
321695 case provider.ImageBlock:
322696 parts = append(parts, "[image]")
323697 case provider.ToolCallBlock:
324- parts = append(parts, "tool "+b.Name)
698+ parts = append(parts, "tool "+sanitizeSessionTreeText(b.Name))
325699 case provider.ToolResultBlock:
326700 if b.IsError {
327701 parts = append(parts, "tool result error")
@@ -340,6 +714,42 @@ func sessionTreePreview(msg provider.Message) string {
340714 return strings.Join(parts, " ")
341715 }
342716
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+
343753 func colorSessionTreeLine(th tui.Theme, line string) string {
344754 plain := strings.TrimSpace(line)
345755 switch {
@@ -354,13 +764,96 @@ func colorSessionTreeLine(th tui.Theme, line string) string {
354764 }
355765 }
356766
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.
357770 func fitSessionTreeLabel(label string, maxWidth int) string {
358- if maxWidth < 4 {
359- maxWidth = 4
771+ if maxWidth <= 0 {
772+ return ""
360773 }
361- runes := []rune(label)
362- if len(runes) <= maxWidth {
774+ if runewidth.StringWidth(label) <= maxWidth {
363775 return label
364776 }
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
366859 }
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 {
2626 // the streaming response without trouble.
2727 func slashCancelsTurn(head string) bool {
2828 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":
3030 return true
3131 }
3232 return false
3333 }
3434
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+
3546 // slashCatalog lists every slash command the interactive mode handles.
3647 // Keep in sync with runSlash().
3748 var slashCatalog = []slashCommand{
@@ -42,7 +53,8 @@ var slashCatalog = []slashCommand{
4253 {Name: "/reasoning", Desc: "set the reasoning level"},
4354 {Name: "/llama", Desc: "manage llama.cpp router models"},
4455 {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"},
4658 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},
4759 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
4860 {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) {
5252 if !isKnownSlashCommand("/Exit") {
5353 t.Fatal("/Exit was not recognized as a built-in command")
5454 }
55+ if !isKnownSlashCommand("/Fork") {
56+ t.Fatal("/Fork was not recognized as a built-in command")
57+ }
5558 if !slashCancelsTurn("/CLEAR") {
5659 t.Fatal("/CLEAR did not retain /clear cancellation semantics")
5760 }
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+ }
5867 }
5968
6069 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 {
6060
6161 ag := r.NewAgent()
6262 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
63- sess, _ := openOrCreateSession(args, r, ag, version)
64- defer sess.Close()
63+ sess, err := openOrCreateSession(args, r, ag, version)
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+ }
6576
6677 // Open the inbox listener BEFORE emitting agent_ready so the
6778 // supervisor can dial through on the very first send. The
M packages/core/session.go +465 -243
@@ -69,18 +69,36 @@ type SessionMeta struct {
6969 // the default unmarshaler cannot reconstruct); it is written with a
7070 // regular provider.Message value.
7171 type sessionLine struct {
72- Type string `json:"type"`
73- Meta *SessionMeta `json:"meta,omitempty"`
74- Message *provider.Message `json:"message,omitempty"`
75- Messages []provider.Message `json:"messages,omitempty"`
76- Usage *provider.Usage `json:"usage,omitempty"`
77- Cumulative *provider.Usage `json:"cumulative,omitempty"`
72+ Type string `json:"type"`
73+ Meta *SessionMeta `json:"meta,omitempty"`
74+ Message *provider.Message `json:"message,omitempty"`
75+ Messages *[]provider.Message `json:"messages,omitempty"`
76+ Usage *provider.Usage `json:"usage,omitempty"`
77+ Cumulative *provider.Usage `json:"cumulative,omitempty"`
7878 }
7979
8080 type sessionLineHead struct {
8181 Type string `json:"type"`
8282 }
8383
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+
84102 // SessionsDir returns the per-cwd sessions directory under root.
85103 func SessionsDir(root, cwd string) string {
86104 sum := sha256.Sum256([]byte(cwd))
@@ -168,69 +186,33 @@ func SessionUsage(path string) (provider.Usage, error) {
168186 }
169187
170188 // SessionUsageDetail returns the latest cumulative usage and the
171-// per-turn usage of the final completed turn. The per-turn row drives
172-// the live "context used" gauge in the status bar (input + cache
173-// approximates the prompt size the model just saw), letting the TUI
174-// rehydrate the gauge on resume instead of starting at 0% until the
175-// next turn lands.
189+// per-turn usage of the final completed turn. It reads through the same
190+// strict snapshot path as OpenSession, so a truncated or malformed session
191+// cannot silently reset the usage display.
176192 func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err error) {
177- f, ferr := os.Open(path)
178- if ferr != nil {
179- return provider.Usage{}, provider.Usage{}, ferr
193+ snapshot, err := ReadSessionSnapshot(path)
194+ if err != nil {
195+ return provider.Usage{}, provider.Usage{}, err
180196 }
181- defer f.Close()
182-
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
197+ if len(snapshot.UsageCheckpoints) == 0 {
198+ return provider.Usage{}, provider.Usage{}, nil
211199 }
212- if haveCum {
213- // input/output are monotonic totals -> per-turn = delta.
214- lastTurn.InputTokens = nonNegDelta(cumulative.InputTokens, prevCum.InputTokens)
215- lastTurn.OutputTokens = nonNegDelta(cumulative.OutputTokens, prevCum.OutputTokens)
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- }
200+ last := snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative
201+ var prev provider.Usage
202+ if len(snapshot.UsageCheckpoints) > 1 {
203+ prev = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-2].Cumulative
232204 }
233- return cumulative, lastTurn, nil
205+ 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
234216 }
235217
236218 func nonNegDelta(cur, prev int) int {
@@ -240,49 +222,322 @@ func nonNegDelta(cur, prev int) int {
240222 return cur - prev
241223 }
242224
243-// OpenSession opens an existing session for appending.
244-func OpenSession(path string) (*Session, []provider.Message, error) {
225+// ReadSessionSnapshot reads a complete session file and reconstructs the
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) {
245230 f, err := os.Open(path)
246231 if err != nil {
247- return nil, nil, err
232+ return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err)
248233 }
249234 defer f.Close()
250235
251- var meta SessionMeta
252- var messages []provider.Message
253- if err := forEachJSONLLine(f, func(line []byte) error {
236+ var snapshot SessionSnapshot
237+ var sawMeta bool
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 {
254247 var head sessionLineHead
255248 if err := json.Unmarshal(line, &head); err != nil {
256- return nil
249+ 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)
257256 }
257+
258258 switch head.Type {
259259 case "meta":
260- var row struct {
261- Meta SessionMeta `json:"meta"`
260+ var row sessionLine
261+ if err := json.Unmarshal(line, &row); err != nil {
262+ return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
262263 }
263- if err := json.Unmarshal(line, &row); err == nil {
264- meta = row.Meta
264+ if row.Meta == nil || row.Meta.ID == "" {
265+ return fmt.Errorf("line %d: meta row has no session id", lineNo)
265266 }
267+ snapshot.Meta = *row.Meta
268+ sawMeta = true
269+
266270 case "message":
267- if msg, err := hydrateMessage(line); err == nil && len(msg.Content) > 0 {
268- messages = append(messages, msg)
271+ msg, err := hydrateMessage(line)
272+ if err != nil {
273+ return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
269274 }
275+ snapshot.Messages = append(snapshot.Messages, msg)
276+
270277 case "compaction":
271- if compacted, err := hydrateCompaction(line); err == nil {
272- messages = compacted
278+ compacted, err := hydrateCompaction(line)
279+ 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)
273311 }
312+
313+ default:
314+ return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
274315 }
275316 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 {
277527 return nil, nil, err
278528 }
279- messages = repairToolUseResultPairs(messages)
280529 out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
281530 if err != nil {
282531 return nil, nil, err
283532 }
284- s := &Session{ID: meta.ID, Path: path, Meta: meta, writer: out, buf: bufio.NewWriter(out)}
285- return s, messages, nil
533+ s := &Session{
534+ 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
286541 }
287542
288543 // repairToolUseResultPairs walks a restored transcript and
@@ -311,68 +566,7 @@ func OpenSession(path string) (*Session, []provider.Message, error) {
311566 //
312567 // Runs once per OpenSession call. No cost on the hot path.
313568 func repairToolUseResultPairs(msgs []provider.Message) []provider.Message {
314- if len(msgs) == 0 {
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
569+ return repairSessionMessages(msgs).messages
376570 }
377571
378572 // LatestSession returns the most recent session file for cwd, or "".
@@ -425,8 +619,8 @@ func DeleteSession(path string) error {
425619 }
426620
427621 // DescribeSessions returns lightweight summaries for every session in
428-// cwd, newest first. Parses only the first few lines and the last usage
429-// line so it's cheap to run on every dialog open.
622+// cwd, newest first. It uses the effective snapshot so message counts,
623+// metadata, and usage agree with resume and branching behavior.
430624 func DescribeSessions(root, cwd string) []SessionSummary {
431625 paths := ListSessions(root, cwd)
432626 summaries := make([]SessionSummary, 0, len(paths))
@@ -470,73 +664,60 @@ func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[
470664
471665 func describeSession(path string) SessionSummary {
472666 s := SessionSummary{Path: path}
473- f, err := os.Open(path)
667+ snapshot, err := ReadSessionSnapshot(path)
474668 if err != nil {
475669 return s
476670 }
477- defer f.Close()
478- isBranch := false
479- forkPoint := 0
671+ s.Started = snapshot.Meta.Started
672+ s.Model = snapshot.Meta.Model
673+ 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.
480689 messageIdx := 0
481- branchPrompt := ""
482- lastUser := ""
483690 renameMessageIdx := -1
484- _ = forEachJSONLLine(f, func(line []byte) error {
485- var head sessionLineHead
486- if err := json.Unmarshal(line, &head); err != nil {
487- return nil
691+ _ = func() error {
692+ f, err := os.Open(path)
693+ if err != nil {
694+ return err
488695 }
489- switch head.Type {
490- case "meta":
491- var row struct {
492- Meta SessionMeta `json:"meta"`
696+ defer f.Close()
697+ return forEachJSONLLine(f, func(line []byte) error {
698+ var head sessionLineHead
699+ if err := json.Unmarshal(line, &head); err != nil {
700+ return nil
493701 }
494- if err := json.Unmarshal(line, &row); err == nil {
495- s.Started = row.Meta.Started
496- s.Model = row.Meta.Model
497- s.Provider = row.Meta.Provider
498- s.Title = row.Meta.Title
499- isBranch = row.Meta.Parent != ""
500- forkPoint = row.Meta.ForkPoint
501- }
502- case "message":
503- if text := firstUserText(line); text != "" {
504- lastUser = text
505- if s.FirstUserText == "" {
506- s.FirstUserText = text
702+ switch head.Type {
703+ case "message":
704+ messageIdx++
705+ case "compaction":
706+ if compacted, err := hydrateCompaction(line); err == nil {
707+ messageIdx = len(compacted)
507708 }
508- if isBranch && messageIdx >= forkPoint && branchPrompt == "" {
509- branchPrompt = text
709+ case "rename":
710+ 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
510716 }
511717 }
512- messageIdx++
513- s.MessageCount++
514- case "compaction":
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- })
718+ return nil
719+ })
720+ }()
540721 if isBranch {
541722 // A rename written after the branch has diverged is user intent
542723 // and must win. Older generated branch titles were written at
@@ -552,29 +733,6 @@ func describeSession(path string) SessionSummary {
552733 return s
553734 }
554735
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-
578736 func firstUserTextFromMessages(msgs []provider.Message) string {
579737 return firstUserTextFromMessagesAfter(msgs, 0)
580738 }
@@ -712,10 +870,14 @@ func (s *Session) AppendCompaction(messages []provider.Message) error {
712870 if s == nil {
713871 return nil
714872 }
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 {
716875 return err
717876 }
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++
719881 return nil
720882 }
721883
@@ -726,9 +888,15 @@ func (s *Session) UpdateModel(providerName, model string) error {
726888 if s == nil {
727889 return nil
728890 }
891+ oldProvider, oldModel := s.Meta.Provider, s.Meta.Model
729892 s.Meta.Provider = providerName
730893 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
732900 }
733901
734902 // AppendUsage writes a usage row to the session.
@@ -784,17 +952,27 @@ func (s *Session) writeLine(row sessionLine) error {
784952
785953 func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {
786954 var row struct {
787- Messages []json.RawMessage `json:"messages"`
955+ Messages json.RawMessage `json:"messages"`
788956 }
789957 if err := json.Unmarshal(lineBytes, &row); err != nil {
790958 return nil, err
791959 }
792- messages := make([]provider.Message, 0, len(row.Messages))
793- for _, raw := range row.Messages {
960+ if len(row.Messages) == 0 || bytes.Equal(bytes.TrimSpace(row.Messages), []byte("null")) {
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 {
794971 msg, err := hydrateMessageObject(raw)
795- if err == nil && len(msg.Content) > 0 {
796- messages = append(messages, msg)
972+ if err != nil {
973+ return nil, fmt.Errorf("message %d: %w", idx, err)
797974 }
975+ messages = append(messages, msg)
798976 }
799977 return messages, nil
800978 }
@@ -806,6 +984,9 @@ func hydrateMessage(lineBytes []byte) (provider.Message, error) {
806984 if err := json.Unmarshal(lineBytes, &row); err != nil {
807985 return provider.Message{}, err
808986 }
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+ }
809990 return hydrateMessageObject(row.Message)
810991 }
811992
@@ -820,8 +1001,17 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
8201001 if err := json.Unmarshal(rawMessage, &row); err != nil {
8211002 return provider.Message{}, err
8221003 }
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+ }
8231010 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+ }
8251015 var head struct {
8261016 Text string `json:"text"`
8271017 MimeType string `json:"mime_type"`
@@ -836,7 +1026,14 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
8361026 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError
8371027 }
8381028 if err := json.Unmarshal(raw, &head); err != nil {
839- continue
1029+ 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)
8401037 }
8411038 // Discriminate by presence of fields.
8421039 switch {
@@ -846,7 +1043,10 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
8461043 Summary: head.Summary,
8471044 Encrypted: head.Encrypted,
8481045 })
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+ }
8501050 var tc struct {
8511051 ID string `json:"id"`
8521052 Name string `json:"name"`
@@ -866,21 +1066,40 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
8661066 Content []json.RawMessage `json:"content"`
8671067 IsError bool `json:"is_error"`
8681068 }
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+ }
8701075 block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError}
871- for _, c := range tr.Content {
1076+ for innerIdx, c := range tr.Content {
8721077 var inner struct {
8731078 Text string `json:"text"`
8741079 MimeType string `json:"mime_type"`
8751080 Data []byte `json:"data"`
8761081 }
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+ }
8781092 if inner.MimeType != "" {
8791093 block.Content = append(block.Content, provider.ImageBlock{MimeType: inner.MimeType, Data: inner.Data})
880- } else {
1094+ } else if _, ok := innerFields["text"]; ok {
8811095 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)
8821098 }
8831099 }
1100+ if len(block.Content) == 0 {
1101+ return provider.Message{}, fmt.Errorf("content block %d tool result has no content", idx)
1102+ }
8841103 msg.Content = append(msg.Content, block)
8851104 case head.MimeType != "":
8861105 msg.Content = append(msg.Content, provider.ImageBlock{
@@ -889,6 +1108,9 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
8891108 ThoughtSignature: head.ThoughtSignature,
8901109 })
8911110 default:
1111+ if _, ok := fields["text"]; !ok {
1112+ return provider.Message{}, fmt.Errorf("content block %d: unknown content", idx)
1113+ }
8921114 msg.Content = append(msg.Content, provider.TextBlock{
8931115 Text: head.Text,
8941116 ThoughtSignature: head.ThoughtSignature,
M packages/core/session_portable.go +293 -119
@@ -40,6 +40,13 @@ func ExportSession(srcPath, dstPath string) (string, error) {
4040 return "", errors.New("export: destination path is empty")
4141 }
4242
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+
4350 // Read the source meta up-front so we can name the output sensibly
4451 // when dstPath is a directory, and so we can validate it's a real
4552 // session before starting to write.
@@ -103,7 +110,7 @@ func ExportSession(srcPath, dstPath string) (string, error) {
103110 // Rewrite the meta row: strip the cwd (the importing user has
104111 // their own) and keep everything else identical. ID stays so the
105112 // export is traceable; the importer will rotate to a fresh ID.
106- exportMeta := *head.Meta
113+ exportMeta := snapshot.Meta
107114 exportMeta.CWD = ""
108115 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &exportMeta})
109116 if err != nil {
@@ -167,6 +174,14 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
167174 }
168175 defer src.Close()
169176
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+
170185 // Validate the file header before committing to a destination.
171186 sc := bufio.NewScanner(src)
172187 sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
@@ -201,8 +216,8 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
201216 importMeta := SessionMeta{
202217 ID: newID,
203218 CWD: cwd,
204- Model: head.Meta.Model,
205- Provider: head.Meta.Provider,
219+ Model: snapshot.Meta.Model,
220+ Provider: snapshot.Meta.Provider,
206221 Started: time.Now().UTC(),
207222 Version: version,
208223 }
@@ -245,14 +260,16 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
245260 // parent's messages 0..upToMessageIdx-1 (i.e. the first N user+
246261 // assistant+tool rows). The new meta records Parent=<parent id> and
247262 // ForkPoint=N so /session tree can rebuild the branch topology
248-// later. All non-message rows (usage) are preserved up to the cut
249-// point so the running cost tracker stays accurate.
263+// later. The effective repaired prefix is materialized, and all eligible
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.
250267 //
251268 // upToMessageIdx is a count over the flat message stream as
252269 // returned by OpenSession. To "branch at user turn 3" the caller
253270 // passes the index of that user message in msgs + 1 (so the
254271 // 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.
256273 //
257274 // Returns the path of the new session file, ready for OpenSession.
258275 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
273290 return "", errors.New("branch: upToMessageIdx must be >= 0")
274291 }
275292
276- src, err := os.Open(parentPath)
293+ snapshot, err := ReadSessionSnapshot(parentPath)
277294 if err != nil {
278- return "", fmt.Errorf("branch: open parent: %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")
295+ return "", fmt.Errorf("branch: read parent snapshot: %w", err)
288296 }
289- var head sessionLine
290- if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
291- return "", fmt.Errorf("branch: parse parent meta: %w", err)
297+ if upToMessageIdx > len(snapshot.Messages) {
298+ return "", fmt.Errorf("branch: upToMessageIdx %d exceeds effective message count %d", upToMessageIdx, len(snapshot.Messages))
292299 }
293- if head.Type != "meta" || head.Meta == nil {
294- return "", errors.New("branch: parent first line is not a meta row")
300+
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++
295308 }
296- parentMeta := *head.Meta
297309
298- // Build the destination file.
299310 dir := SessionsDir(root, cwd)
300311 if err := os.MkdirAll(dir, 0o755); err != nil {
301312 return "", err
@@ -303,23 +314,38 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
303314 newID := uuid.NewString()
304315 name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
305316 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")
307321 if err != nil {
308- return "", fmt.Errorf("branch: create dst: %w", err)
309- }
310- defer dst.Close()
322+ return "", fmt.Errorf("branch: create temp: %w", err)
323+ }
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+ }()
311338 bw := bufio.NewWriter(dst)
312339
313- // Write the branch meta.
314340 branchMeta := SessionMeta{
315341 ID: newID,
316342 CWD: cwd,
317- Model: parentMeta.Model,
318- Provider: parentMeta.Provider,
343+ Model: snapshot.Meta.Model,
344+ Provider: snapshot.Meta.Provider,
319345 Started: time.Now().UTC(),
320346 Version: version,
321- Parent: parentMeta.ID,
322- ForkPoint: upToMessageIdx,
347+ Parent: snapshot.Meta.ID,
348+ ForkPoint: limit,
323349 HideFromSessions: hideFromSessions,
324350 }
325351 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
@@ -333,79 +359,52 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
333359 return "", err
334360 }
335361
336- // Reconstruct the effective transcript the same way OpenSession
337- // does: message rows append, and compaction rows replace everything
338- // before them. The fork index is defined over that effective stream,
339- // not over the raw audit rows kept on disk before a compaction.
340- if _, err := src.Seek(0, io.SeekStart); err != nil {
341- return "", fmt.Errorf("branch: rewind parent: %w", err)
342- }
343- var effective []provider.Message
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
362+ // Always materialize the shared effective prefix. Replaying raw rows here
363+ // would lose compaction replacement or synthetic tool results and make the
364+ // child differ from the transcript that was displayed and selected.
365+ for idx := 0; idx < limit; idx++ {
366+ msg := snapshot.Messages[idx]
367+ line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})
368+ if err != nil {
369+ return "", fmt.Errorf("branch: marshal message %d: %w", idx, err)
351370 }
352- switch h.Type {
353- case "message":
354- if msg, err := hydrateMessage(line); err == nil && len(msg.Content) > 0 {
355- effective = append(effective, msg)
356- if !sawCompaction && effectiveCount < upToMessageIdx {
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- }
371+ if _, err := bw.Write(line); err != nil {
372+ return "", err
373+ }
374+ if err := bw.WriteByte('\n'); err != nil {
375+ return "", err
373376 }
374- return nil
375- }); err != nil && err != io.EOF {
376- return "", fmt.Errorf("branch: read parent: %w", err)
377377 }
378- limit := upToMessageIdx
379- if limit > len(effective) {
380- limit = len(effective)
381- }
382- branchTitle := lastUserText(effective[:limit])
383- if sawCompaction {
384- for i := 0; i < limit; i++ {
385- msg := effective[i]
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- }
378+
379+ // Preserve every cumulative checkpoint that belongs to this prefix. The
380+ // final two rows are needed to reconstruct LastTurnUsage as a delta; a
381+ // branch containing only the latest row would incorrectly report the
382+ // whole cumulative total as its final turn.
383+ for _, checkpoint := range snapshot.UsageCheckpoints {
384+ if checkpoint.MessageCount > limit {
385+ continue
396386 }
397- } else {
398- for _, row := range nonCompactedRows {
399- if _, err := bw.Write(row); err != nil {
400- return "", err
401- }
402- if err := bw.WriteByte('\n'); err != nil {
403- return "", err
404- }
387+ usageLine, err := json.Marshal(sessionLine{
388+ Type: "usage",
389+ Usage: &checkpoint.Cumulative,
390+ Cumulative: &checkpoint.Cumulative,
391+ })
392+ if err != nil {
393+ return "", fmt.Errorf("branch: marshal usage: %w", err)
394+ }
395+ if _, err := bw.Write(usageLine); err != nil {
396+ return "", err
397+ }
398+ if err := bw.WriteByte('\n'); err != nil {
399+ return "", err
405400 }
406401 }
407- if branchTitle != "" {
408- line, _ := json.Marshal(map[string]string{"type": "rename", "title": branchTitle})
402+
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+ }
409408 if _, err := bw.Write(line); err != nil {
410409 return "", err
411410 }
@@ -416,6 +415,16 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
416415 if err := bw.Flush(); err != nil {
417416 return "", err
418417 }
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
419428 return outPath, nil
420429 }
421430
@@ -464,6 +473,183 @@ func BuildSessionTree(root, cwd string) []*TreeNode {
464473 nodes[meta.ID] = &TreeNode{Summary: summary, Meta: meta}
465474 order = append(order, meta.ID)
466475 }
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 {
467653 var roots []*TreeNode
468654 for _, id := range order {
469655 n := nodes[id]
@@ -474,35 +660,23 @@ func BuildSessionTree(root, cwd string) []*TreeNode {
474660 if parent, ok := nodes[n.Meta.Parent]; ok {
475661 parent.Children = append(parent.Children, n)
476662 } else {
477- // Parent file missing (was manually deleted). Treat as
478- // a root so it still shows up in the tree.
663+ // Parent file missing (was manually deleted). Treat as a root so it
664+ // still shows up in the tree.
479665 roots = append(roots, n)
480666 }
481667 }
482668 return roots
483669 }
484670
485-// readSessionMeta opens path, reads the meta row, and returns it.
486-// Empty SessionMeta when the file is missing or not a valid session.
671+// readSessionMeta returns the latest validated metadata for path. It uses
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.
487674 func readSessionMeta(path string) (SessionMeta, error) {
488- f, err := os.Open(path)
675+ snapshot, err := ReadSessionSnapshot(path)
489676 if err != nil {
490677 return SessionMeta{}, err
491678 }
492- defer f.Close()
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
679+ return snapshot.Meta, nil
506680 }
507681
508682 // 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) {
8282 }
8383 }
8484
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+
85129 // TestExportToFilePath writes to an explicit file path (no
86130 // directory guessing) and checks the .zotsession extension is
87131 // 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 (
5656 type Reader struct {
5757 src func() (byte, error)
5858 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
5996 }
6097
6198 // NewReader returns a Reader that pulls bytes from read.
@@ -67,9 +104,65 @@ func NewReaderWithPeek(read func() (byte, error), peek func(time.Duration) (byte
67104 return &Reader{src: read, peek: peek}
68105 }
69106
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+
70163 // Read returns the next parsed Key.
71164 func (r *Reader) Read() (Key, error) {
72- b, err := r.src()
165+ b, err := r.readByte()
73166 if err != nil {
74167 return Key{}, err
75168 }
@@ -113,7 +206,7 @@ func (r *Reader) Read() (Key, error) {
113206 n := utf8Len(b)
114207 buf := []byte{b}
115208 for i := 1; i < n; i++ {
116- bb, err := r.src()
209+ bb, err := r.readByte()
117210 if err != nil {
118211 return Key{}, err
119212 }
@@ -161,12 +254,19 @@ func (r *Reader) readEscape() (Key, error) {
161254 if err != nil || !have {
162255 return Key{Kind: KeyEsc}, nil
163256 }
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+ }
164264 switch b {
165265 case '[':
166266 return r.readCSI()
167267 case 'O':
168268 // SS3 sequences (function keys in some terminals).
169- c, err := r.src()
269+ c, err := r.readByte()
170270 if err != nil {
171271 return Key{}, err
172272 }
@@ -199,24 +299,23 @@ func (r *Reader) readEscape() (Key, error) {
199299 }
200300
201301 // 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 blocking
203-// read, which means bare Esc is only detected after the next keystroke.
302+// we use it. NewReader's source pump supplies the same bounded behavior
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.
204307 func (r *Reader) readEscapeNext(d time.Duration) (byte, bool, error) {
205308 if r.peek != nil {
206309 return r.peek(d)
207310 }
208- b, err := r.src()
209- if err != nil {
210- return 0, false, err
211- }
212- return b, true, nil
311+ return r.readByteTimeout(d)
213312 }
214313
215314 // readCSI parses a CSI sequence after ESC [.
216315 func (r *Reader) readCSI() (Key, error) {
217316 var params []byte
218317 for {
219- c, err := r.src()
318+ c, err := r.readByte()
220319 if err != nil {
221320 return Key{}, err
222321 }
@@ -411,7 +510,7 @@ func (r *Reader) readPaste() Key {
411510 const end = "\x1b[201~"
412511 tail := make([]byte, 0, len(end))
413512 for {
414- b, err := r.src()
513+ b, err := r.readByte()
415514 if err != nil {
416515 break
417516 }
M packages/tui/input_test.go +137 -4
@@ -1,6 +1,10 @@
11 package tui
22
3-import "testing"
3+import (
4+ "io"
5+ "testing"
6+ "time"
7+)
48
59 func TestReaderParsesCSIUShiftEnter(t *testing.T) {
610 k := readKey(t, "\x1b[13;2u")
@@ -91,6 +95,111 @@ func TestReaderParsesCSIUTabAndBackspace(t *testing.T) {
9195 }
9296 }
9397
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+
94203 func TestReaderParsesSGRMouseWheel(t *testing.T) {
95204 cases := []struct {
96205 seq string
@@ -109,15 +218,39 @@ func TestReaderParsesSGRMouseWheel(t *testing.T) {
109218
110219 func readKey(t *testing.T, seq string) Key {
111220 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 {
112229 idx := 0
113- r := NewReader(func() (byte, error) {
230+ read := func() (byte, error) {
231+ if idx >= len(seq) {
232+ return 0, io.EOF
233+ }
114234 b := seq[idx]
115235 idx++
116236 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()
118251 k, err := r.Read()
119252 if err != nil {
120- t.Fatalf("Read(%q): %v", seq, err)
253+ t.Fatalf("Read: %v", err)
121254 }
122255 return k
123256 }
M packages/tui/resize_windows.go +116 -7
@@ -5,6 +5,9 @@ package tui
55 import (
66 "os"
77 "time"
8+ "unsafe"
9+
10+ "golang.org/x/sys/windows"
811 )
912
1013 // Windows doesn't have SIGWINCH; resize events are signaled via input
@@ -14,14 +17,120 @@ func (p *ProcTerm) installResizeHandler() {}
1417
1518 func (p *ProcTerm) SetNonblock(enable bool) error { return nil }
1619
17-// peekStdin on Windows: no non-blocking primitive in v1; fall through
18-// to a blocking read. This means bare Esc on Windows is only detected
19-// after the next keystroke. Good enough for v1.
20-func peekStdin(in *os.File, _ time.Duration) (byte, bool, error) {
21- var b [1]byte
22- _, err := in.Read(b[:])
20+// peekStdin waits for one byte on a supported Windows stdin handle without
21+// consuming anything unless a byte is available. Console input handles can be
22+// waited on directly. Pipes are checked with PeekNamedPipe, which does not
23+// consume data; polling that non-consuming check keeps the wait bounded.
24+//
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)
2333 if err != nil {
2434 return 0, false, err
2535 }
26- return b[0], true, nil
36+
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+ }
27136 }

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