detached 0d7cbb3

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

feat: add session tree and double escape navigation
commit
0d7cbb34150d077e9b98894bb23302ee86326366
author
bnema · · 2026-08-04 11:54 UTC
committer
GitHub · · 2026-08-04 11:54 UTC
merge of
365593f + 48bddb4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
22 files · +4633 -665
M README.md +13 -8
@@ -277,8 +277,9 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
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
@@ -420,7 +425,7 @@ This is a guardrail against accidents, not a hard security boundary. If you need
420425
421426 ## Sessions
422427
423-Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy.
428+Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Session metadata stores the provider and model used by that session; resume honors that pair, rebuilding the active agent when it differs and refusing the switch without discarding the current session if rebuilding fails. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy.
424429
425430 ## Providers
426431
@@ -865,7 +870,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
865870 | `enter` | Submit (queued if the agent is busy). |
866871 | `alt+enter` | Newline. |
867872 | `tab` | Complete the selected slash command. |
868-| `esc` | Cancel the current turn (while busy); clear input (while idle). |
873+| `esc` | Cancel the current turn (while busy); clear input (while idle). Two parsed bare `Esc` presses within 500 ms open `/session tree` only when the editor is idle and empty; dialogs, busy/queued work, and modified keys keep their existing precedence. |
869874 | `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. |
870875 | `ctrl+d` | Exit on empty input. |
871876 | `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 +452 -57
@@ -10,6 +10,7 @@ import (
1010 "os"
1111 "os/signal"
1212 "path/filepath"
13+ "slices"
1314 "strings"
1415 "sync"
1516 "syscall"
@@ -184,6 +185,21 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes
184185 }
185186 var out []provider.Message
186187 start := len(msgs) - keepTail
188+ var carriedToolNames []string
189+ addToolNames := func(names []string) {
190+ for _, name := range names {
191+ if name == "" || slices.Contains(carriedToolNames, name) {
192+ continue
193+ }
194+ carriedToolNames = append(carriedToolNames, name)
195+ }
196+ }
197+ // Activation markers in the discarded prefix still affect the retained
198+ // provider request. Carry them onto its first message rather than dropping
199+ // deferred-tool availability when the resume tail is shortened.
200+ for _, msg := range msgs[:start] {
201+ addToolNames(msg.AddedToolNames)
202+ }
187203 // Preserve the synthetic compaction summary when present so an
188204 // already-compacted session stays compacted after resume.
189205 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 1 {
@@ -195,9 +211,214 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes
195211 start++
196212 }
197213 out = append(out, msgs[start:]...)
214+ if len(carriedToolNames) > 0 && len(out) > 0 {
215+ first := out[0]
216+ for _, name := range first.AddedToolNames {
217+ addToolNames([]string{name})
218+ }
219+ first.AddedToolNames = append([]string(nil), carriedToolNames...)
220+ out[0] = first
221+ }
198222 return provider.RepairOrphanedToolResults(out)
199223 }
200224
225+// sessionResumeCandidate contains all state needed to resume a session.
226+// It is deliberately assembled before the caller changes any live state:
227+// opening the transcript, reading usage, and rebuilding a provider/model
228+// must all succeed before the candidate can replace the current session.
229+type sessionResumeCandidate struct {
230+ session *core.Session
231+ agent *core.Agent
232+ messages []provider.Message
233+ fullMessageCount int
234+ cumulative provider.Usage
235+ lastTurn provider.Usage
236+ provider string
237+ model string
238+ rebuilt bool
239+}
240+
241+// prepareSessionResume opens and validates a selected session without
242+// touching the current agent. Missing provider/model metadata is treated as
243+// legacy metadata: the current agent is reused, preserving compatibility
244+// with sessions written before those fields were persisted.
245+//
246+// The returned candidate owns session until its caller commits it. On any
247+// error this function closes the candidate session and leaves the current
248+// agent untouched.
249+func prepareSessionResume(path string, current *core.Agent, currentProvider, currentModel string, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) {
250+ return prepareSessionResumeWithOptions(path, current, currentProvider, currentModel, false, false, buildAgentFor)
251+}
252+
253+// prepareSessionResumeWithOptions is the provider/model-aware implementation.
254+// An explicit CLI provider or model keeps that field authoritative; an empty
255+// field falls back to the selected session's metadata when it is complete.
256+func prepareSessionResumeWithOptions(path string, current *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (candidate sessionResumeCandidate, err error) {
257+ if current == nil {
258+ return candidate, fmt.Errorf("no agent running; log in first")
259+ }
260+
261+ sess, msgs, err := core.OpenSession(path)
262+ if err != nil {
263+ return candidate, err
264+ }
265+ keepSession := false
266+ defer func() {
267+ if !keepSession {
268+ _ = sess.Close()
269+ }
270+ }()
271+
272+ cumulative, lastTurn, err := core.SessionUsageDetail(path)
273+ if err != nil {
274+ return candidate, fmt.Errorf("read session usage: %w", err)
275+ }
276+
277+ fullMessageCount := len(msgs)
278+ resumedMessages := trimMessagesForResume(msgs, 100)
279+ storedProvider := strings.TrimSpace(sess.Meta.Provider)
280+ storedModel := strings.TrimSpace(sess.Meta.Model)
281+ currentProvider = strings.TrimSpace(currentProvider)
282+ currentModel = strings.TrimSpace(currentModel)
283+
284+ candidate = sessionResumeCandidate{
285+ session: sess,
286+ agent: current,
287+ messages: resumedMessages,
288+ fullMessageCount: fullMessageCount,
289+ cumulative: cumulative,
290+ lastTurn: lastTurn,
291+ provider: currentProvider,
292+ model: currentModel,
293+ }
294+
295+ // Old sessions may have either field absent. Only a complete stored
296+ // selection is actionable; otherwise resume exactly as older zot did.
297+ if storedProvider == "" || storedModel == "" {
298+ keepSession = true
299+ return candidate, nil
300+ }
301+
302+ resumeProvider := storedProvider
303+ if explicitProvider {
304+ resumeProvider = currentProvider
305+ }
306+ resumeModel := storedModel
307+ if explicitModel {
308+ resumeModel = currentModel
309+ }
310+ if resumeProvider == currentProvider && resumeModel == currentModel {
311+ keepSession = true
312+ return candidate, nil
313+ }
314+ if buildAgentFor == nil {
315+ return sessionResumeCandidate{}, fmt.Errorf("cannot resume session with provider %q and model %q: no agent builder configured", resumeProvider, resumeModel)
316+ }
317+
318+ rebuilt, resolvedProvider, resolvedModel, err := buildAgentFor(resumeProvider, resumeModel)
319+ if err != nil {
320+ return sessionResumeCandidate{}, fmt.Errorf("rebuild agent for session provider %q/model %q: %w", resumeProvider, resumeModel, err)
321+ }
322+ if rebuilt == nil {
323+ return sessionResumeCandidate{}, fmt.Errorf("rebuild agent for session provider %q/model %q returned no agent", resumeProvider, resumeModel)
324+ }
325+ resolvedProvider = strings.TrimSpace(resolvedProvider)
326+ resolvedModel = strings.TrimSpace(resolvedModel)
327+ if resolvedProvider == "" {
328+ resolvedProvider = storedProvider
329+ }
330+ if resolvedModel == "" {
331+ resolvedModel = storedModel
332+ }
333+
334+ // Hydrate and seed the replacement while it is still private. The live
335+ // agent is not modified until the caller commits the complete candidate.
336+ rebuilt.SetMessages(resumedMessages)
337+ rebuilt.SeedCost(cumulative)
338+ rebuilt.SeedLastTurnUsage(lastTurn)
339+ candidate.agent = rebuilt
340+ candidate.provider = resolvedProvider
341+ candidate.model = resolvedModel
342+ candidate.rebuilt = true
343+ keepSession = true
344+ return candidate, nil
345+}
346+
347+// buildNonInteractiveSessionAgent constructs a provider/model-specific agent
348+// for startup resume while preserving the cwd sandbox and extension tools that
349+// were attached to the initial resolution.
350+func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string) (*core.Agent, string, string, error) {
351+ next := args
352+ next.Provider = providerName
353+ next.Model = model
354+ resolved, err := Resolve(next, true)
355+ if err != nil {
356+ return nil, "", "", err
357+ }
358+ resolved.UseSandbox(base.Sandbox)
359+ if extMgr != nil {
360+ resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
361+ }
362+ ag := resolved.NewAgent()
363+ wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
364+ return ag, resolved.Provider, resolved.Model, nil
365+}
366+
367+// applySessionResume shares the startup gate, candidate hydration, and
368+// commit order used by interactive and non-interactive session opening. The
369+// old append handle is closed only after the candidate has been prepared and
370+// seeded successfully.
371+func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) {
372+ candidate := sessionResumeCandidate{
373+ session: sess,
374+ agent: ag,
375+ fullMessageCount: 0,
376+ provider: currentProvider,
377+ model: currentModel,
378+ }
379+ if ag != nil {
380+ candidate.fullMessageCount = len(ag.Messages())
381+ }
382+ if sess == nil || ag == nil {
383+ return candidate, nil
384+ }
385+ // A newly created meta-only session is already on the active pair. Avoid
386+ // reopening and closing it through the resume path: Close intentionally
387+ // removes fresh empty sessions, and a second append handle would otherwise
388+ // keep an unlinked file alive.
389+ if len(ag.Messages()) == 0 &&
390+ strings.TrimSpace(sess.Meta.Provider) == strings.TrimSpace(currentProvider) &&
391+ strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(currentModel) {
392+ return candidate, nil
393+ }
394+ candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, buildAgentFor)
395+ if err != nil {
396+ _ = sess.Close()
397+ return sessionResumeCandidate{}, err
398+ }
399+ if !candidate.rebuilt {
400+ candidate.agent.SetMessages(candidate.messages)
401+ candidate.agent.SeedCost(candidate.cumulative)
402+ candidate.agent.SeedLastTurnUsage(candidate.lastTurn)
403+ }
404+ _ = sess.Close()
405+ return candidate, nil
406+}
407+
408+// applyInitialSessionResume applies the same provider/model-aware candidate
409+// contract used by interactive session switching to non-interactive startup
410+// modes. The returned session owns the replacement append handle.
411+func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent) (*core.Session, *core.Agent, string, string, error) {
412+ candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,
413+ strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) {
414+ return buildNonInteractiveSessionAgent(ctx, args, base, extMgr, providerName, model)
415+ })
416+ if err != nil {
417+ return nil, ag, "", "", err
418+ }
419+ return candidate.session, candidate.agent, candidate.provider, candidate.model, nil
420+}
421+
201422 // fanoutAgentEvent translates a core.AgentEvent into the wire-format
202423 // EventFromHost and pushes it through the extension manager. Only
203424 // the events that have a clear extension-facing meaning are
@@ -426,8 +647,19 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
426647
427648 ag := r.NewAgent()
428649 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
429- sess, _ := openOrCreateSession(args, r, ag, version)
430- defer sess.Close()
650+ sess, err := openOrCreateSession(args, r, ag, version)
651+ if err != nil {
652+ return err
653+ }
654+ if sess != nil {
655+ var providerName, model string
656+ sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
657+ if err != nil {
658+ return err
659+ }
660+ r.Provider, r.Model = providerName, model
661+ defer sess.Close()
662+ }
431663
432664 prompt := args.Prompt
433665 if prompt == "" {
@@ -469,8 +701,19 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
469701
470702 ag := r.NewAgent()
471703 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
472- sess, _ := openOrCreateSession(args, r, ag, version)
473- defer sess.Close()
704+ sess, err := openOrCreateSession(args, r, ag, version)
705+ if err != nil {
706+ return err
707+ }
708+ if sess != nil {
709+ var providerName, model string
710+ sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
711+ if err != nil {
712+ return err
713+ }
714+ r.Provider, r.Model = providerName, model
715+ defer sess.Close()
716+ }
474717
475718 prompt := args.Prompt
476719 if prompt == "" {
@@ -551,8 +794,19 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
551794
552795 ag := r.NewAgent()
553796 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
554- sess, _ := openOrCreateSession(args, r, ag, version)
555- defer sess.Close()
797+ sess, err := openOrCreateSession(args, r, ag, version)
798+ if err != nil {
799+ return err
800+ }
801+ if sess != nil {
802+ var providerName, model string
803+ sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
804+ if err != nil {
805+ return err
806+ }
807+ r.Provider, r.Model = providerName, model
808+ defer sess.Close()
809+ }
556810
557811 prompt := args.Prompt
558812 if prompt == "" {
@@ -695,6 +949,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
695949 // rebuilt tool instances must share the same one so the lock sticks.
696950 sharedSandbox := r.Sandbox
697951
952+ // persistMu guards the active session and selection across the agent
953+ // loop, TUI callbacks, and session swaps. Declare it before tool
954+ // construction because swarm defaults must read the live selection too.
955+ var persistMu sync.Mutex
956+ // Serialize session transitions with persistence callbacks so a candidate
957+ // is read only after the old agent is flushed and cannot be overwritten by
958+ // an in-flight append before commit.
959+ var sessionTransitionMu sync.RWMutex
960+ activeProvider := r.Provider
961+ activeModel := r.Model
962+
698963 // Build the extension manager BEFORE the agent so we can fold
699964 // extension-defined tools into the registry. Attach the interactive
700965 // host after constructing it below so startup alerts can be buffered.
@@ -792,10 +1057,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
7921057 return reg
7931058 }
7941059 reg["swarm_spawn"] = &tools.SwarmSpawnTool{
795- Swarm: swarmMgr,
796- Enabled: AutoSwarmEnabled,
797- DefaultModel: func() string { return r.Model },
798- DefaultProvider: func() string { return r.Provider },
1060+ Swarm: swarmMgr,
1061+ Enabled: AutoSwarmEnabled,
1062+ DefaultModel: func() string {
1063+ persistMu.Lock()
1064+ defer persistMu.Unlock()
1065+ return activeModel
1066+ },
1067+ DefaultProvider: func() string {
1068+ persistMu.Lock()
1069+ defer persistMu.Unlock()
1070+ return activeProvider
1071+ },
7991072 DefaultReasoning: func() string { return r.Reasoning },
8001073 ResolveSubagent: resolveSubagent,
8011074 OnSpawned: onSpawnedSwarm,
@@ -958,16 +1231,32 @@ func runInteractive(ctx context.Context, args Args, version string) error {
9581231
9591232 var sess *core.Session
9601233 var sessBaselineMsgs int // messages already on disk when current session opened
961- // persistMu guards sess + sessBaselineMsgs against concurrent access
962- // from the agent loop's per-message persistence hook (runs on the
963- // agent goroutine) and the TUI's session swap / flush callbacks
964- // (run on the TUI goroutine). Without this, a /sessions swap that
965- // races with a finishing turn could double-write or lose messages.
966- var persistMu sync.Mutex
1234+ // persistMu also guards sess + sessBaselineMsgs, so a session swap
1235+ // commits the transcript writer and live selection under the same lock.
9671236 if !args.NoSess && ag != nil {
968- sess, _ = openOrCreateSession(args, r, ag, version)
969- if ag != nil {
970- sessBaselineMsgs = len(ag.Messages())
1237+ var sessErr error
1238+ sess, sessErr = openOrCreateSession(args, r, ag, version)
1239+ if sessErr != nil {
1240+ return sessErr
1241+ }
1242+ sessBaselineMsgs = len(ag.Messages())
1243+ if sess != nil {
1244+ // Startup resume uses the same provider/model-aware preparation as
1245+ // the interactive session picker. This matters for --continue,
1246+ // --resume, and explicit --session paths whose latest meta row was
1247+ // written by another provider/model.
1248+ candidate, resumeErr := applySessionResume(sess, ag, r.Provider, r.Model,
1249+ strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", buildAgentFor)
1250+ if resumeErr != nil {
1251+ return resumeErr
1252+ }
1253+ sess = candidate.session
1254+ ag = candidate.agent
1255+ sessBaselineMsgs = candidate.fullMessageCount
1256+ activeProvider = candidate.provider
1257+ activeModel = candidate.model
1258+ r.Provider = candidate.provider
1259+ r.Model = candidate.model
9711260 }
9721261 }
9731262 defer func() {
@@ -985,6 +1274,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
9851274 // baseline counter advances in lock-step so the exit-time flush
9861275 // doesn't double-write rows already on disk.
9871276 persistMessage := func(m provider.Message) {
1277+ sessionTransitionMu.RLock()
1278+ defer sessionTransitionMu.RUnlock()
9881279 persistMu.Lock()
9891280 defer persistMu.Unlock()
9901281 if sess == nil {
@@ -995,6 +1286,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
9951286 }
9961287 }
9971288 persistUsage := func(cum provider.Usage) {
1289+ sessionTransitionMu.RLock()
1290+ defer sessionTransitionMu.RUnlock()
9981291 persistMu.Lock()
9991292 defer persistMu.Unlock()
10001293 if sess == nil {
@@ -1003,6 +1296,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10031296 _ = sess.AppendUsage(cum, cum)
10041297 }
10051298 persistCompaction := func(messages []provider.Message) {
1299+ sessionTransitionMu.RLock()
1300+ defer sessionTransitionMu.RUnlock()
10061301 persistMu.Lock()
10071302 defer persistMu.Unlock()
10081303 if sess == nil {
@@ -1026,7 +1321,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10261321 // Re-wrap the build closures so any agent constructed by the TUI
10271322 // (login, /model swap to a different provider) also gets the
10281323 // persistence hooks. Without this, switching provider would
1029- // silently revert to the old in-memory-only behaviour.
1324+ // silently revert to the old in-memory-only behaviour. Selection state
1325+ // is published by the successful transition itself, not by preparation.
10301326 baseBuildAgent := buildAgent
10311327 buildAgent = func() (*core.Agent, string, string, error) {
10321328 a, p, m, err := baseBuildAgent()
@@ -1046,47 +1342,100 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10461342 // loadSession replaces the current session with the one at path and
10471343 // hands its messages to the agent. Used by the /sessions picker.
10481344 loadSession := func(path string) error {
1345+ // Hold the transition lock from the pre-flush through the commit.
1346+ // Persistence callbacks take the read side, so an active session
1347+ // cannot be snapshotted before its lazy writes land or overwritten by
1348+ // a callback while the candidate is being installed.
1349+ sessionTransitionMu.Lock()
1350+ defer sessionTransitionMu.Unlock()
1351+
10491352 currentAg := liveInteractiveAgent(iv, ag)
10501353 if currentAg == nil {
10511354 return fmt.Errorf("no agent running; log in first")
10521355 }
1053- newSess, msgs, err := core.OpenSession(path)
1356+
1357+ persistMu.Lock()
1358+ currentProvider := activeProvider
1359+ currentModel := activeModel
1360+ oldSess := sess
1361+ // Flush before reading the candidate. Use the locked form rather
1362+ // than the public callback, which would acquire the read lock held
1363+ // by this transition.
1364+ if oldSess != nil {
1365+ writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)
1366+ }
1367+ currentMessageCount := len(currentAg.Messages())
1368+ currentCost := currentAg.Cost()
1369+ persistMu.Unlock()
1370+
1371+ // A candidate build must not publish its provider/model as the live
1372+ // selection before the candidate commits.
1373+ candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
10541374 if err != nil {
1375+ // prepareSessionResume closes its append handle on failure;
1376+ // no live agent or session has been changed.
10551377 return err
10561378 }
1057- fullMsgCount := len(msgs)
1058- msgs = trimMessagesForResume(msgs, 100)
1379+ committed := false
1380+ defer func() {
1381+ if !committed {
1382+ _ = candidate.session.Close()
1383+ }
1384+ }()
1385+
1386+ // Wire the private candidate before it can become visible. This is
1387+ // also required for legacy same-provider resumes, where the current
1388+ // agent is reused and only its transcript/usage are refreshed.
1389+ wireAgentPersist(candidate.agent)
1390+
10591391 persistMu.Lock()
1060- // Flush any unsaved messages to the old session before swapping.
1061- // Per-message persistence keeps sessBaselineMsgs current, so
1062- // this is a defensive no-op in the common case; it still
1063- // matters for the rare race where a turn just finished and
1064- // hadn't fired its hook yet.
1065- if sess != nil {
1066- writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1067- _ = sess.Close()
1392+ // A provider rebuild ran outside persistMu. Refuse to install it if
1393+ // the live agent, session, selection, transcript, or cost changed in
1394+ // the meantime; otherwise a slow resume could discard a newer turn or
1395+ // model swap.
1396+ live := liveInteractiveAgent(iv, ag)
1397+ changed := sess != oldSess || activeProvider != currentProvider || activeModel != currentModel || live != currentAg
1398+ if !changed && live != nil {
1399+ changed = len(live.Messages()) != currentMessageCount || live.Cost() != currentCost
1400+ }
1401+ if changed {
1402+ persistMu.Unlock()
1403+ return fmt.Errorf("session changed while loading; please try again")
10681404 }
1069- sess = newSess
1070- currentAg.SetMessages(msgs)
1071- if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {
1072- currentAg.SeedCost(cum)
1073- currentAg.SeedLastTurnUsage(last)
1074- }
1075- // The live agent only receives a compact resume window, but
1076- // the session file remains intact. Keep the persistence
1077- // baseline at the original on-disk message count so future
1078- // turns append after the full session instead of duplicating
1079- // the hydrated tail.
1080- sessBaselineMsgs = fullMsgCount
1081- persistMu.Unlock()
1082- // Re-scope the swarm dashboard to the new session so /swarm
1083- // only shows agents this session spawned. swarmMgr may be nil
1084- // here if we haven't reached the construction site yet (it
1085- // shouldn't be, since the interactive loop is what triggers
1086- // loadSession, but the nil check is cheap insurance).
1087- if swarmMgr != nil && newSess != nil {
1088- swarmMgr.SetActiveSession(newSess.ID)
1405+
1406+ if oldSess != nil {
1407+ _ = oldSess.Close()
10891408 }
1409+ sess = candidate.session
1410+ // The live agent only receives a compact resume window, but the
1411+ // session file remains intact. Keep the persistence baseline at
1412+ // the original on-disk message count so future turns append after
1413+ // the full session instead of duplicating the hydrated tail.
1414+ sessBaselineMsgs = candidate.fullMessageCount
1415+
1416+ if candidate.rebuilt {
1417+ // Commit the TUI agent while persistMu still protects the session
1418+ // writer, so no new message can land on the old file between the
1419+ // two state changes.
1420+ if iv != nil {
1421+ iv.ApplySessionAgent(candidate.agent, candidate.provider, candidate.model)
1422+ }
1423+ ag = candidate.agent
1424+ } else {
1425+ currentAg.SetMessages(candidate.messages)
1426+ currentAg.SeedCost(candidate.cumulative)
1427+ currentAg.SeedLastTurnUsage(candidate.lastTurn)
1428+ ag = currentAg
1429+ }
1430+ activeProvider = candidate.provider
1431+ activeModel = candidate.model
1432+ if swarmMgr != nil && candidate.session != nil {
1433+ // Keep the dashboard scope in the same commit as the session,
1434+ // agent, usage, and persistence baseline.
1435+ swarmMgr.SetActiveSession(candidate.session.ID)
1436+ }
1437+ committed = true
1438+ persistMu.Unlock()
10901439 return nil
10911440 }
10921441
@@ -1106,6 +1455,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11061455 // The /jail state is preserved verbatim: if the sandbox was locked
11071456 // to the old cwd, it stays locked, just re-pointed at the new one.
11081457 changeCWD := func(path string) error {
1458+ sessionTransitionMu.Lock()
1459+ defer sessionTransitionMu.Unlock()
11091460 if path == "" {
11101461 return fmt.Errorf("empty path")
11111462 }
@@ -1136,7 +1487,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11361487 return fmt.Errorf("not a directory: %s", absPath)
11371488 }
11381489
1139- currentAg := ag
1490+ currentAg := liveInteractiveAgent(iv, ag)
11401491 if currentAg == nil {
11411492 return fmt.Errorf("no agent running; log in first")
11421493 }
@@ -1147,6 +1498,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11471498 oldCWD := args.CWD
11481499 oldResolvedCWD := r.CWD
11491500 oldPermissionSet := args.PermissionSet
1501+ persistMu.Lock()
1502+ oldActiveProvider := activeProvider
1503+ oldActiveModel := activeModel
1504+ persistMu.Unlock()
11501505 oldSwarmRoot := ""
11511506 if swarmMgr != nil {
11521507 oldSwarmRoot = swarmMgr.RepoRoot()
@@ -1161,6 +1516,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11611516 args.CWD = oldCWD
11621517 r.CWD = oldResolvedCWD
11631518 args.PermissionSet = oldPermissionSet
1519+ persistMu.Lock()
1520+ activeProvider = oldActiveProvider
1521+ activeModel = oldActiveModel
1522+ persistMu.Unlock()
11641523 if swarmMgr != nil {
11651524 swarmMgr.SetRepoRoot(oldSwarmRoot)
11661525 }
@@ -1225,6 +1584,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12251584 }
12261585 sess = newSess
12271586 sessBaselineMsgs = 0
1587+ activeProvider = newProvider
1588+ activeModel = newModel
12281589 persistMu.Unlock()
12291590 ag = newAg
12301591
@@ -1416,12 +1777,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14161777 LoadSession: loadSession,
14171778 ChangeCWD: changeCWD,
14181779 CurrentSessionPath: func() string {
1780+ persistMu.Lock()
1781+ defer persistMu.Unlock()
14191782 if sess == nil {
14201783 return ""
14211784 }
14221785 return sess.Path
14231786 },
14241787 FlushSession: func() {
1788+ sessionTransitionMu.RLock()
1789+ defer sessionTransitionMu.RUnlock()
14251790 // Append any not-yet-persisted agent messages to the
14261791 // current session file, then advance the baseline so
14271792 // the final WriteNewTranscript at exit doesn't write
@@ -1442,6 +1807,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14421807 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
14431808 sessBaselineMsgs = len(currentAg.Messages())
14441809 },
1810+ SessionTransition: func(fn func()) {
1811+ sessionTransitionMu.Lock()
1812+ defer sessionTransitionMu.Unlock()
1813+ fn()
1814+ },
14451815 Extensions: extMgr,
14461816 Swarm: swarmMgr,
14471817 ResolveSubagent: resolveSubagent,
@@ -1476,14 +1846,39 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14761846 NoYolo: args.NoYolo,
14771847 ConfirmGate: confirmGate,
14781848 PersistModel: func(providerName, model string) {
1849+ providerName = strings.TrimSpace(providerName)
1850+ model = strings.TrimSpace(model)
1851+ // Persist the active session metadata before publishing the
1852+ // selection used by future resumes. The live agent has already
1853+ // switched pairs, so keep the in-memory selection aligned even
1854+ // when the append fails and report that persistence error below.
1855+ persistMu.Lock()
1856+ var sessionErr error
1857+ if sess != nil {
1858+ sessionErr = sess.UpdateModel(providerName, model)
1859+ }
1860+ activeProvider = providerName
1861+ activeModel = model
1862+ persistMu.Unlock()
1863+ if sessionErr != nil {
1864+ if iv != nil {
1865+ iv.Notify("session", "error", "model change was not persisted: "+sessionErr.Error())
1866+ }
1867+ return
1868+ }
1869+
14791870 // Update config.json so next launch uses the same pick.
1480- cfg, _ := LoadConfig()
1871+ cfg, cfgErr := LoadConfig()
1872+ if cfgErr != nil {
1873+ if iv != nil {
1874+ iv.Notify("session", "error", "model changed for this run, but config could not be read: "+cfgErr.Error())
1875+ }
1876+ return
1877+ }
14811878 cfg.Provider = providerName
14821879 cfg.Model = model
1483- _ = SaveConfig(cfg)
1484- // Update the active session's meta so resume picks this up.
1485- if sess != nil {
1486- _ = sess.UpdateModel(providerName, model)
1880+ if cfgErr = SaveConfig(cfg); cfgErr != nil && iv != nil {
1881+ iv.Notify("session", "error", "model changed for this run, but config could not be saved: "+cfgErr.Error())
14871882 }
14881883 },
14891884 })
M packages/agent/cli_session_test.go +209 -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"
@@ -8,6 +11,28 @@ import (
811 "github.com/patriceckhart/zot/packages/provider"
912 )
1013
14+func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
15+ msgs := make([]provider.Message, 0, 101)
16+ msgs = append(msgs, provider.Message{
17+ Role: provider.RoleTool,
18+ AddedToolNames: []string{"lookup_weather"},
19+ Content: []provider.Content{provider.ToolResultBlock{CallID: "old-call"}},
20+ })
21+ for idx := 1; idx < 101; idx++ {
22+ msgs = append(msgs, provider.Message{
23+ Role: provider.RoleUser,
24+ Content: []provider.Content{provider.TextBlock{Text: "message"}},
25+ })
26+ }
27+ trimmed := trimMessagesForResume(msgs, 100)
28+ if len(trimmed) != 100 {
29+ t.Fatalf("trimmed message count = %d, want 100", len(trimmed))
30+ }
31+ if len(trimmed[0].AddedToolNames) != 1 || trimmed[0].AddedToolNames[0] != "lookup_weather" {
32+ t.Fatalf("trimmed activation markers = %v, want lookup_weather", trimmed[0].AddedToolNames)
33+ }
34+}
35+
1136 func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) {
1237 startup := core.NewAgent(nil, "startup-model", "", nil)
1338 startup.SetMessages([]provider.Message{{
@@ -46,3 +71,187 @@ func firstMessageText(messages []provider.Message) string {
4671 text, _ := messages[0].Content[0].(provider.TextBlock)
4772 return text.Text
4873 }
74+
75+func syntheticSession(t *testing.T, providerName, model string, usage provider.Usage) string {
76+ t.Helper()
77+ root := t.TempDir()
78+ cwd := t.TempDir()
79+ sess, err := core.NewSession(root, cwd, providerName, model, "test")
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ if err := sess.AppendMessage(provider.Message{
84+ Role: provider.RoleUser,
85+ Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
86+ }); err != nil {
87+ t.Fatal(err)
88+ }
89+ if usage != (provider.Usage{}) {
90+ if err := sess.AppendUsage(usage, usage); err != nil {
91+ t.Fatal(err)
92+ }
93+ }
94+ if err := sess.Close(); err != nil {
95+ t.Fatal(err)
96+ }
97+ return sess.Path
98+}
99+
100+func TestApplyInitialSessionResumeKeepsFreshEmptySessionOwned(t *testing.T) {
101+ root := t.TempDir()
102+ sess, err := core.NewSession(root, "/workspace", "provider", "model", "test")
103+ if err != nil {
104+ t.Fatal(err)
105+ }
106+ ag := core.NewAgent(nil, "model", "", nil)
107+ gotSess, gotAgent, providerName, model, err := applyInitialSessionResume(context.Background(), Args{}, Resolved{Provider: "provider", Model: "model"}, nil, sess, ag)
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ if gotSess != sess || gotAgent != ag || providerName != "provider" || model != "model" {
112+ t.Fatalf("empty startup resume = session %p/%p agent %p/%p provider/model %q/%q", gotSess, sess, gotAgent, ag, providerName, model)
113+ }
114+ if _, err := os.Stat(sess.Path); err != nil {
115+ t.Fatalf("fresh session disappeared before close: %v", err)
116+ }
117+ if err := sess.Close(); err != nil {
118+ t.Fatal(err)
119+ }
120+ if _, err := os.Stat(sess.Path); !errors.Is(err, os.ErrNotExist) {
121+ t.Fatalf("fresh empty session remained after close: %v", err)
122+ }
123+}
124+
125+func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
126+ old := core.NewAgent(nil, "old-model", "", nil)
127+ old.SetMessages([]provider.Message{{
128+ Role: provider.RoleUser,
129+ Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
130+ }})
131+ path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{
132+ InputTokens: 12,
133+ OutputTokens: 7,
134+ })
135+
136+ var gotProvider, gotModel string
137+ candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(providerName, model string) (*core.Agent, string, string, error) {
138+ gotProvider, gotModel = providerName, model
139+ return core.NewAgent(nil, model, "", nil), providerName, model, nil
140+ })
141+ if err != nil {
142+ t.Fatal(err)
143+ }
144+ defer candidate.session.Close()
145+
146+ if gotProvider != "stored-provider" || gotModel != "stored-model" {
147+ t.Fatalf("builder selection = %q/%q, want stored-provider/stored-model", gotProvider, gotModel)
148+ }
149+ if !candidate.rebuilt || candidate.agent == old {
150+ t.Fatalf("candidate rebuilt = %v, agent=%p, old=%p", candidate.rebuilt, candidate.agent, old)
151+ }
152+ if got := firstMessageText(candidate.agent.Messages()); got != "resumed transcript" {
153+ t.Fatalf("candidate transcript = %q, want resumed transcript", got)
154+ }
155+ if got := candidate.agent.Cost(); got.InputTokens != 12 || got.OutputTokens != 7 {
156+ t.Fatalf("candidate usage = %+v, want input=12 output=7", got)
157+ }
158+ if got := old.Messages(); len(got) != 1 || firstMessageText(got) != "current transcript" {
159+ t.Fatalf("current transcript changed during preparation: %+v", got)
160+ }
161+}
162+
163+func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) {
164+ old := core.NewAgent(nil, "old-model", "", nil)
165+ old.SetMessages([]provider.Message{{
166+ Role: provider.RoleUser,
167+ Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
168+ }})
169+ path := syntheticSession(t, "", "", provider.Usage{})
170+
171+ candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
172+ t.Fatal("legacy session unexpectedly requested a rebuild")
173+ return nil, "", "", nil
174+ })
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ defer candidate.session.Close()
179+
180+ if candidate.rebuilt || candidate.agent != old {
181+ t.Fatalf("legacy candidate = rebuilt %v, agent=%p; want current agent without rebuild", candidate.rebuilt, candidate.agent)
182+ }
183+ if got := firstMessageText(old.Messages()); got != "current transcript" {
184+ t.Fatalf("current transcript changed before commit: %q", got)
185+ }
186+ if got := firstMessageText(candidate.messages); got != "resumed transcript" {
187+ t.Fatalf("legacy candidate transcript = %q, want resumed transcript", got)
188+ }
189+}
190+
191+func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) {
192+ old := core.NewAgent(nil, "old-model", "", nil)
193+ old.SetMessages([]provider.Message{{
194+ Role: provider.RoleUser,
195+ Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
196+ }})
197+ path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
198+ wantErr := errors.New("synthetic builder failure")
199+
200+ if _, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
201+ return nil, "", "", wantErr
202+ }); !errors.Is(err, wantErr) {
203+ t.Fatalf("prepare error = %v, want %v", err, wantErr)
204+ }
205+ if got := firstMessageText(old.Messages()); got != "current transcript" {
206+ t.Fatalf("current transcript changed after failed preparation: %q", got)
207+ }
208+
209+ // The failed candidate must have released its append handle, leaving
210+ // the selected file readable and reopenable for a later retry.
211+ reopened, _, err := core.OpenSession(path)
212+ if err != nil {
213+ t.Fatalf("reopen candidate after failed preparation: %v", err)
214+ }
215+ if err := reopened.Close(); err != nil {
216+ t.Fatal(err)
217+ }
218+ if _, err := os.Stat(path); err != nil {
219+ t.Fatalf("candidate session disappeared after failed preparation: %v", err)
220+ }
221+}
222+
223+func TestPrepareSessionResumeHonorsExplicitProviderModelFields(t *testing.T) {
224+ for _, tc := range []struct {
225+ name string
226+ explicitProvider bool
227+ explicitModel bool
228+ wantProvider string
229+ wantModel string
230+ wantBuild bool
231+ }{
232+ {name: "both", explicitProvider: true, explicitModel: true, wantProvider: "current-provider", wantModel: "current-model"},
233+ {name: "provider-only", explicitProvider: true, wantProvider: "current-provider", wantModel: "stored-model", wantBuild: true},
234+ {name: "model-only", explicitModel: true, wantProvider: "stored-provider", wantModel: "current-model", wantBuild: true},
235+ } {
236+ t.Run(tc.name, func(t *testing.T) {
237+ old := core.NewAgent(nil, "old-model", "", nil)
238+ path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
239+ var gotProvider, gotModel string
240+ candidate, err := prepareSessionResumeWithOptions(path, old, "current-provider", "current-model", tc.explicitProvider, tc.explicitModel, func(providerName, model string) (*core.Agent, string, string, error) {
241+ gotProvider, gotModel = providerName, model
242+ return core.NewAgent(nil, model, "", nil), providerName, model, nil
243+ })
244+ if err != nil {
245+ t.Fatal(err)
246+ }
247+ defer candidate.session.Close()
248+ if tc.wantBuild {
249+ if gotProvider != tc.wantProvider || gotModel != tc.wantModel {
250+ t.Fatalf("builder selection = %q/%q, want %q/%q", gotProvider, gotModel, tc.wantProvider, tc.wantModel)
251+ }
252+ } else if candidate.provider != tc.wantProvider || candidate.model != tc.wantModel || candidate.rebuilt {
253+ t.Fatalf("candidate selection = %q/%q rebuilt=%v, want %q/%q without rebuild", candidate.provider, candidate.model, candidate.rebuilt, tc.wantProvider, tc.wantModel)
254+ }
255+ })
256+ }
257+}
A packages/agent/modes/double_escape.go +377 -0
@@ -0,0 +1,377 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+const sessionTreeEscapeWindow = 500 * time.Millisecond
14+
15+type doubleEscapeTracker struct {
16+ last time.Time
17+ armed bool
18+}
19+
20+func (d *doubleEscapeTracker) Arm(now time.Time) {
21+ d.last = now
22+ d.armed = true
23+}
24+
25+// Consume reports whether now is the second tap of a valid gesture. A clock
26+// that moves backwards is treated as a reset rather than as a valid tap.
27+func (d *doubleEscapeTracker) Consume(now time.Time) bool {
28+ last := d.last
29+ armed := d.armed
30+ d.last = time.Time{}
31+ d.armed = false
32+ if !armed {
33+ return false
34+ }
35+ elapsed := now.Sub(last)
36+ return elapsed >= 0 && elapsed <= sessionTreeEscapeWindow
37+}
38+
39+func (d *doubleEscapeTracker) Reset() {
40+ d.last = time.Time{}
41+ d.armed = false
42+}
43+
44+func isUnmodifiedEscape(k tui.Key) bool {
45+ return k.Kind == tui.KeyEsc && !k.Ctrl && !k.Alt && !k.Shift && !k.Super
46+}
47+
48+func (i *Interactive) sessionTreeEscapeNow() time.Time {
49+ if i.clock != nil {
50+ return i.clock()
51+ }
52+ return time.Now()
53+}
54+
55+// currentSessionPath is the nil-safe session callback shared by slash
56+// commands and tree operations. Running without persistence (for example
57+// --no-session) is a supported mode, so callers must treat a missing
58+// callback exactly like an empty path rather than invoking it blindly.
59+func (i *Interactive) currentSessionPath() string {
60+ if i == nil || i.cfg.CurrentSessionPath == nil {
61+ return ""
62+ }
63+ return i.cfg.CurrentSessionPath()
64+}
65+
66+// sessionTreeEscapeBlocked reports interactions that must retain precedence
67+// over the gesture. It is intentionally broader than the overlay switch in
68+// handleKey: notes and suggestions also own Escape even though they are not
69+// all rendered as dialogs.
70+func (i *Interactive) sessionTreeEscapeBlocked() bool {
71+ if i == nil {
72+ return true
73+ }
74+ if i.ed == nil {
75+ return true
76+ }
77+ if i.dialog != nil && i.dialog.Active() ||
78+ i.modelDialog != nil && i.modelDialog.Active() ||
79+ i.llamaDialog != nil && i.llamaDialog.Active() ||
80+ i.rescueDialog != nil && i.rescueDialog.Active() ||
81+ i.sessionDialog != nil && i.sessionDialog.Active() ||
82+ i.swarmDialog != nil && i.swarmDialog.Active() ||
83+ i.jumpDialog != nil && i.jumpDialog.Active() ||
84+ i.btwDialog != nil && i.btwDialog.Active() ||
85+ i.skillsDialog != nil && i.skillsDialog.Active() ||
86+ i.changelogDialog != nil && i.changelogDialog.Active() ||
87+ i.logoutDialog != nil && i.logoutDialog.Active() ||
88+ i.telegramDialog != nil && i.telegramDialog.Active() ||
89+ i.settingsDialog != nil && i.settingsDialog.Active() ||
90+ i.sessionOpsDialog != nil && i.sessionOpsDialog.Active() ||
91+ i.sessionTreeDialog != nil && i.sessionTreeDialog.Active() ||
92+ i.extPanel != nil && i.extPanel.Active() ||
93+ i.confirmDialog != nil && i.confirmDialog.Active() {
94+ return true
95+ }
96+ if i.suggest != nil && i.suggest.Active(i.ed.Value()) ||
97+ i.fileSuggest != nil && i.fileSuggest.Active(i.ed.Value()) {
98+ return true
99+ }
100+ i.mu.Lock()
101+ blocked := len(i.helpBlock) > 0 || len(i.extNotes) > 0
102+ i.mu.Unlock()
103+ return blocked
104+}
105+
106+// canArmSessionTreeEscape checks only the cheap, local eligibility rules for
107+// the first tap. The complete family read belongs to canOpenSessionTree on
108+// the second tap; otherwise a malformed descendant would prevent arming and
109+// the user would never receive the gate's status error.
110+func (i *Interactive) canArmSessionTreeEscape() bool {
111+ if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" ||
112+ i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
113+ return false
114+ }
115+ if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 || i.sessionTreeEscapeBlocked() {
116+ return false
117+ }
118+ i.mu.Lock()
119+ busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
120+ i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
121+ queued := len(i.queued) != 0
122+ ag := i.agent
123+ pendingFork := i.pendingFork
124+ i.mu.Unlock()
125+ if busy || queued || pendingFork || ag == nil || ag.QueuedMessageCount() != 0 {
126+ return false
127+ }
128+ return i.currentSessionPath() != ""
129+}
130+
131+// canOpenSessionTree is the shared, fail-closed precondition for both the
132+// /session tree command and the bare-Escape gesture. It does not flush or
133+// mutate session state. In particular, callers must not flush until this
134+// check has returned true.
135+func (i *Interactive) canOpenSessionTree() bool {
136+ if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" ||
137+ i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
138+ return false
139+ }
140+ if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 || i.sessionTreeEscapeBlocked() {
141+ return false
142+ }
143+
144+ i.mu.Lock()
145+ busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
146+ i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
147+ queued := len(i.queued) != 0
148+ ag := i.agent
149+ pendingFork := i.pendingFork
150+ i.mu.Unlock()
151+ if busy || queued || pendingFork || ag == nil {
152+ return false
153+ }
154+ if ag.QueuedMessageCount() != 0 {
155+ return false
156+ }
157+
158+ current := i.currentSessionPath()
159+ if current == "" {
160+ return false
161+ }
162+ roots, err := core.BuildSessionTreeFamilyStrict(i.sessionsRoot(), i.cfg.CWD, current)
163+ return err == nil && len(roots) != 0
164+}
165+
166+// canCommitSessionTreeSelection is the open-gate subset used after the tree
167+// already owns the keyboard. It deliberately permits the active tree dialog;
168+// reusing canOpenSessionTree here would reject every Enter selection because
169+// the dialog is itself an overlay.
170+func (i *Interactive) canCommitSessionTreeSelection() bool {
171+ if i == nil || i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil || i.sessionsRoot() == "" || i.cfg.CWD == "" {
172+ return false
173+ }
174+ i.mu.Lock()
175+ busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
176+ i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
177+ queued := len(i.queued) != 0
178+ ag := i.agent
179+ i.mu.Unlock()
180+ if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
181+ return false
182+ }
183+ return i.currentSessionPath() != ""
184+}
185+
186+func (i *Interactive) setSessionTreeError(message string) {
187+ if i.sessionTreeDialog != nil {
188+ i.sessionTreeDialog.Close()
189+ }
190+ i.mu.Lock()
191+ i.statusErr = message
192+ i.statusOK = ""
193+ i.mu.Unlock()
194+ i.invalidate()
195+}
196+
197+// openSessionTree is the only path that activates the session-tree overlay.
198+// The gate runs before FlushSession, and a failed family read leaves the
199+// dialog closed instead of falling back to a stale in-memory transcript.
200+func (i *Interactive) openSessionTree() bool {
201+ if !i.canOpenSessionTree() {
202+ i.setSessionTreeError("tree: session tree is unavailable")
203+ return false
204+ }
205+ current := i.currentSessionPath()
206+ if current == "" {
207+ i.setSessionTreeError("tree: no session is active")
208+ return false
209+ }
210+ if i.cfg.FlushSession != nil {
211+ i.cfg.FlushSession()
212+ }
213+ if i.sessionTreeDialog == nil || !i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {
214+ i.setSessionTreeError("tree: no readable session family")
215+ return false
216+ }
217+ i.mu.Lock()
218+ i.statusErr = ""
219+ i.statusOK = ""
220+ i.mu.Unlock()
221+ i.invalidate()
222+ return true
223+}
224+
225+type sessionTreeSelectionResult struct {
226+ upTo int
227+ restoreDraft bool
228+ draftText string
229+ images []clipboardImageAttachment
230+}
231+
232+// sessionTreeSelection turns a structured dialog target into a branch
233+// boundary. User rows are boundaries before that message and restore the
234+// full editable draft; assistant/tool rows include the selected completed
235+// exchange. Empty and detached rows are already safe message-count
236+// boundaries. A tool call and its result rows are never split.
237+func sessionTreeSelection(msgs []provider.Message, target sessionTreeTarget) (sessionTreeSelectionResult, error) {
238+ if target.IsBoundary() {
239+ upTo := target.SelectionBoundary
240+ if upTo < 0 || upTo > len(msgs) || !safeSessionToolBoundary(msgs[:upTo]) {
241+ return sessionTreeSelectionResult{}, fmt.Errorf("selection splits a tool exchange")
242+ }
243+ return sessionTreeSelectionResult{upTo: upTo}, nil
244+ }
245+ msgIdx := target.EffectiveIndex
246+ if msgIdx < 0 || msgIdx >= len(msgs) {
247+ return sessionTreeSelectionResult{}, fmt.Errorf("selection is outside the session")
248+ }
249+ selected := msgs[msgIdx]
250+ if selected.Role == provider.RoleUser {
251+ upTo := target.SelectionBoundary
252+ if upTo < 0 || upTo > len(msgs) || !safeSessionToolBoundary(msgs[:upTo]) {
253+ return sessionTreeSelectionResult{}, fmt.Errorf("selection splits a tool exchange")
254+ }
255+ text, images := sessionTreeDraft(selected, target.UserDraft)
256+ return sessionTreeSelectionResult{
257+ upTo: upTo,
258+ restoreDraft: true,
259+ draftText: text,
260+ images: images,
261+ }, nil
262+ }
263+
264+ upTo := target.SelectionBoundary
265+ if upTo < msgIdx+1 || upTo > len(msgs) {
266+ return sessionTreeSelectionResult{}, fmt.Errorf("selection is outside the session")
267+ }
268+ if selected.Role == provider.RoleAssistant && messageHasToolCall(selected) || selected.Role == provider.RoleTool {
269+ // A provider tool exchange may occupy one assistant row followed by
270+ // one or more tool rows. Extend a selected row to the end of that
271+ // contiguous exchange before checking the boundary.
272+ for upTo < len(msgs) && msgs[upTo].Role == provider.RoleTool {
273+ upTo++
274+ }
275+ }
276+ if !safeSessionToolBoundary(msgs[:upTo]) {
277+ return sessionTreeSelectionResult{}, fmt.Errorf("selection splits a tool exchange")
278+ }
279+ return sessionTreeSelectionResult{upTo: upTo}, nil
280+}
281+
282+func messageHasToolCall(msg provider.Message) bool {
283+ for _, content := range msg.Content {
284+ if _, ok := content.(provider.ToolCallBlock); ok {
285+ return true
286+ }
287+ }
288+ return false
289+}
290+
291+// safeSessionToolBoundary checks the provider's structural invariant at a
292+// message cut: every tool call in the prefix has its matching result in the
293+// immediately following tool row, and no result is orphaned.
294+func safeSessionToolBoundary(msgs []provider.Message) bool {
295+ pending := map[string]bool{}
296+ for _, msg := range msgs {
297+ if len(pending) > 0 && msg.Role != provider.RoleTool {
298+ return false
299+ }
300+ switch msg.Role {
301+ case provider.RoleAssistant:
302+ if len(pending) != 0 {
303+ return false
304+ }
305+ for _, content := range msg.Content {
306+ if call, ok := content.(provider.ToolCallBlock); ok {
307+ if call.ID == "" || pending[call.ID] {
308+ return false
309+ }
310+ pending[call.ID] = true
311+ }
312+ }
313+ case provider.RoleTool:
314+ if len(pending) == 0 {
315+ if len(msg.AddedToolNames) > 0 {
316+ // Deferred-tool activation rows intentionally carry
317+ // provider-local state without a preceding tool call.
318+ continue
319+ }
320+ return false
321+ }
322+ for _, content := range msg.Content {
323+ result, ok := content.(provider.ToolResultBlock)
324+ if !ok || !pending[result.CallID] {
325+ return false
326+ }
327+ delete(pending, result.CallID)
328+ }
329+ }
330+ }
331+ return len(pending) == 0
332+}
333+
334+func sessionTreeDraft(msg provider.Message, fallback string) (string, []clipboardImageAttachment) {
335+ // Agent.Prompt stores the text first and appends image blocks after it,
336+ // even when an older transcript contains several text blocks. Rebuild
337+ // that same editor representation: join every text block, then place
338+ // image markers after the complete text so submitting the restored draft
339+ // recreates the original text/images pair without losing any text.
340+ var textBlocks []string
341+ var imageBlocks []provider.ImageBlock
342+ for _, content := range msg.Content {
343+ switch block := content.(type) {
344+ case provider.TextBlock:
345+ textBlocks = append(textBlocks, block.Text)
346+ case provider.ImageBlock:
347+ imageBlocks = append(imageBlocks, block)
348+ }
349+ }
350+ var text strings.Builder
351+ if len(textBlocks) > 0 {
352+ text.WriteString(strings.Join(textBlocks, "\n"))
353+ } else {
354+ text.WriteString(fallback)
355+ }
356+ images := make([]clipboardImageAttachment, 0, len(imageBlocks))
357+ for _, block := range imageBlocks {
358+ if text.Len() > 0 {
359+ last := text.String()
360+ if len(last) > 0 && last[len(last)-1] != ' ' && last[len(last)-1] != '\n' && last[len(last)-1] != '\t' {
361+ text.WriteByte(' ')
362+ }
363+ }
364+ marker := fmt.Sprintf("[clipboard image #%d]", len(images)+1)
365+ text.WriteString(marker)
366+ text.WriteByte(' ')
367+ images = append(images, clipboardImageAttachment{
368+ Marker: marker,
369+ Image: provider.ImageBlock{
370+ MimeType: block.MimeType,
371+ Data: append([]byte(nil), block.Data...),
372+ ThoughtSignature: block.ThoughtSignature,
373+ },
374+ })
375+ }
376+ return text.String(), images
377+}
A packages/agent/modes/double_escape_test.go +404 -0
@@ -0,0 +1,404 @@
1+package modes
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+func TestDoubleEscapeTrackerWindowAndReset(t *testing.T) {
14+ base := time.Unix(100, 0)
15+ var tracker doubleEscapeTracker
16+
17+ tracker.Arm(base)
18+ if !tracker.Consume(base.Add(sessionTreeEscapeWindow)) {
19+ t.Fatal("tap at the 500ms boundary should count")
20+ }
21+ if tracker.Consume(base.Add(sessionTreeEscapeWindow)) {
22+ t.Fatal("a consumed gesture must not trigger again")
23+ }
24+
25+ tracker.Arm(base)
26+ if tracker.Consume(base.Add(sessionTreeEscapeWindow + time.Nanosecond)) {
27+ t.Fatal("tap after the gesture window should not count")
28+ }
29+ tracker.Arm(base)
30+ if tracker.Consume(base.Add(-time.Nanosecond)) {
31+ t.Fatal("a clock moving backwards must reset the gesture")
32+ }
33+
34+ // Zero is a valid value for a deterministic test clock; it must not be
35+ // confused with the tracker's unarmed sentinel.
36+ tracker.Arm(time.Time{})
37+ if !tracker.Consume(time.Time{}.Add(100 * time.Millisecond)) {
38+ t.Fatal("zero-valued test timestamp should still arm the gesture")
39+ }
40+}
41+
42+func TestUnmodifiedEscapeRequiresBareParsedKey(t *testing.T) {
43+ bare := tui.Key{Kind: tui.KeyEsc}
44+ for _, key := range []tui.Key{
45+ {Kind: tui.KeyEsc, Alt: true},
46+ {Kind: tui.KeyEsc, Ctrl: true},
47+ {Kind: tui.KeyEsc, Shift: true},
48+ {Kind: tui.KeyEsc, Super: true},
49+ } {
50+ if isUnmodifiedEscape(key) {
51+ t.Fatalf("modified key %#v was treated as a bare Escape", key)
52+ }
53+ }
54+ if !isUnmodifiedEscape(bare) {
55+ t.Fatal("bare parsed Escape was not recognized")
56+ }
57+}
58+
59+func TestSessionTreeSelectionRestoresCompleteUserDraft(t *testing.T) {
60+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "sig"}
61+ msgs := []provider.Message{
62+ {
63+ Role: provider.RoleUser,
64+ Content: []provider.Content{
65+ provider.TextBlock{Text: "before"},
66+ image,
67+ provider.TextBlock{Text: "after"},
68+ },
69+ },
70+ }
71+ target := sessionTreeTarget{
72+ EffectiveIndex: 0,
73+ SelectionBoundary: 0,
74+ Role: provider.RoleUser,
75+ UserDraft: "fallback must not replace message text",
76+ }
77+
78+ got, err := sessionTreeSelection(msgs, target)
79+ if err != nil {
80+ t.Fatalf("sessionTreeSelection: %v", err)
81+ }
82+ if got.upTo != 0 || !got.restoreDraft {
83+ t.Fatalf("selection = %#v, want a pre-user draft boundary", got)
84+ }
85+ if got.draftText != "before\nafter [clipboard image #1] " {
86+ t.Fatalf("draft text = %q", got.draftText)
87+ }
88+ if len(got.images) != 1 || got.images[0].Marker != "[clipboard image #1]" {
89+ t.Fatalf("restored images = %#v", got.images)
90+ }
91+ if string(got.images[0].Image.Data) != "png" || got.images[0].Image.MimeType != image.MimeType || got.images[0].Image.ThoughtSignature != image.ThoughtSignature {
92+ t.Fatalf("restored image = %#v", got.images[0].Image)
93+ }
94+}
95+
96+func TestSessionTreeSelectionKeepsToolExchangeAtomic(t *testing.T) {
97+ msgs := []provider.Message{
98+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "ask"}}},
99+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},
100+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1"}}},
101+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}},
102+ }
103+
104+ got, err := sessionTreeSelection(msgs, sessionTreeTarget{
105+ EffectiveIndex: 1,
106+ SelectionBoundary: 2,
107+ Role: provider.RoleAssistant,
108+ })
109+ if err != nil {
110+ t.Fatalf("tool-call selection: %v", err)
111+ }
112+ if got.upTo != 3 {
113+ t.Fatalf("tool-call boundary = %d, want 3", got.upTo)
114+ }
115+
116+ got, err = sessionTreeSelection(msgs, sessionTreeTarget{
117+ EffectiveIndex: 2,
118+ SelectionBoundary: 3,
119+ Role: provider.RoleTool,
120+ })
121+ if err != nil {
122+ t.Fatalf("tool-result selection: %v", err)
123+ }
124+ if got.upTo != 3 {
125+ t.Fatalf("tool-result boundary = %d, want 3", got.upTo)
126+ }
127+}
128+
129+func TestSessionTreeSelectionAllowsDeferredToolActivation(t *testing.T) {
130+ msgs := []provider.Message{
131+ {
132+ Role: provider.RoleTool,
133+ AddedToolNames: []string{"lookup_weather"},
134+ Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1"}},
135+ },
136+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "next"}}},
137+ }
138+ got, err := sessionTreeSelection(msgs, sessionTreeTarget{
139+ EffectiveIndex: 0,
140+ SelectionBoundary: 1,
141+ Role: provider.RoleTool,
142+ })
143+ if err != nil {
144+ t.Fatalf("deferred-tool selection: %v", err)
145+ }
146+ if got.upTo != 1 {
147+ t.Fatalf("deferred-tool boundary = %d, want 1", got.upTo)
148+ }
149+}
150+
151+func TestSessionTreeGateFailureDoesNotFlushOrOpenOverlay(t *testing.T) {
152+ i := NewInteractive(InteractiveConfig{})
153+ flushes := 0
154+ i.cfg.FlushSession = func() { flushes++ }
155+
156+ i.doSessionTree()
157+ if flushes != 0 {
158+ t.Fatalf("FlushSession called %d times on a failed gate", flushes)
159+ }
160+ if i.sessionTreeDialog.Active() {
161+ t.Fatal("session tree overlay opened after a failed gate")
162+ }
163+}
164+
165+func TestSessionTreeGateRejectsModelRefreshInFlight(t *testing.T) {
166+ root := t.TempDir()
167+ cwd := "/workspace/model-refresh-gate"
168+ session, err := core.NewSession(root, cwd, "test", "model", "test")
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "message"}}}
173+ if err := session.AppendMessage(message); err != nil {
174+ t.Fatal(err)
175+ }
176+ path := session.Path
177+ if err := session.Close(); err != nil {
178+ t.Fatal(err)
179+ }
180+ i := NewInteractive(InteractiveConfig{
181+ Agent: core.NewAgent(nil, "model", "", nil),
182+ CWD: cwd,
183+ SessionsRoot: root,
184+ CurrentSessionPath: func() string { return path },
185+ LoadSession: func(string) error { return nil },
186+ })
187+ i.agent.SetMessages([]provider.Message{message})
188+ i.mu.Lock()
189+ i.modelRefreshing = true
190+ i.mu.Unlock()
191+ if i.canOpenSessionTree() {
192+ t.Fatal("tree gate opened while model refresh was in flight")
193+ }
194+}
195+
196+func TestSessionTreeSelectionGateAllowsActiveTree(t *testing.T) {
197+ root := t.TempDir()
198+ cwd := "/workspace/selection-gate"
199+ session, err := core.NewSession(root, cwd, "test", "model", "test")
200+ if err != nil {
201+ t.Fatal(err)
202+ }
203+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "message"}}}
204+ if err := session.AppendMessage(message); err != nil {
205+ t.Fatal(err)
206+ }
207+ path := session.Path
208+ if err := session.Close(); err != nil {
209+ t.Fatal(err)
210+ }
211+ ag := core.NewAgent(nil, "model", "", nil)
212+ ag.SetMessages([]provider.Message{message})
213+ i := NewInteractive(InteractiveConfig{
214+ Agent: ag,
215+ CWD: cwd,
216+ SessionsRoot: root,
217+ CurrentSessionPath: func() string {
218+ return path
219+ },
220+ LoadSession: func(string) error { return nil },
221+ })
222+ i.sessionTreeDialog.active = true
223+ if i.canOpenSessionTree() {
224+ t.Fatal("open gate accepted an already-active tree")
225+ }
226+ if !i.canCommitSessionTreeSelection() {
227+ t.Fatal("selection gate rejected the active tree dialog")
228+ }
229+}
230+
231+func TestDoubleEscapeOpensReadableTreeThroughSharedGate(t *testing.T) {
232+ root := t.TempDir()
233+ cwd := "/workspace/double-escape"
234+ session, err := core.NewSession(root, cwd, "test", "model", "test")
235+ if err != nil {
236+ t.Fatal(err)
237+ }
238+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
239+ if err := session.AppendMessage(message); err != nil {
240+ t.Fatal(err)
241+ }
242+ path := session.Path
243+ if err := session.Close(); err != nil {
244+ t.Fatal(err)
245+ }
246+
247+ ag := core.NewAgent(nil, "model", "", nil)
248+ ag.SetMessages([]provider.Message{message})
249+ now := time.Unix(200, 0)
250+ flushes := 0
251+ i := NewInteractive(InteractiveConfig{
252+ Agent: ag,
253+ CWD: cwd,
254+ SessionsRoot: root,
255+ CurrentSessionPath: func() string {
256+ return path
257+ },
258+ LoadSession: func(string) error { return nil },
259+ FlushSession: func() {
260+ flushes++
261+ },
262+ })
263+ i.clock = func() time.Time { return now }
264+
265+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
266+ t.Fatal("first Escape exited")
267+ }
268+ if i.sessionTreeDialog.Active() || flushes != 0 {
269+ t.Fatalf("first Escape opened/flushed: active=%v flushes=%d", i.sessionTreeDialog.Active(), flushes)
270+ }
271+ now = now.Add(499 * time.Millisecond)
272+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
273+ t.Fatal("second Escape exited")
274+ }
275+ if !i.sessionTreeDialog.Active() {
276+ t.Fatal("readable session tree did not open")
277+ }
278+ if flushes != 1 {
279+ t.Fatalf("FlushSession called %d times, want once after the gate", flushes)
280+ }
281+ if got := len(ag.Messages()); got != 1 {
282+ t.Fatalf("double Escape changed transcript with a provider turn: %d messages", got)
283+ }
284+}
285+
286+func TestSecondEscapeFallsThroughToCancellationWhenTurnBecomesBusy(t *testing.T) {
287+ root := t.TempDir()
288+ cwd := "/workspace/double-escape-busy"
289+ session, err := core.NewSession(root, cwd, "test", "model", "test")
290+ if err != nil {
291+ t.Fatal(err)
292+ }
293+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
294+ if err := session.AppendMessage(message); err != nil {
295+ t.Fatal(err)
296+ }
297+ path := session.Path
298+ if err := session.Close(); err != nil {
299+ t.Fatal(err)
300+ }
301+
302+ ag := core.NewAgent(nil, "model", "", nil)
303+ ag.SetMessages([]provider.Message{message})
304+ now := time.Unix(300, 0)
305+ cancelled := 0
306+ i := NewInteractive(InteractiveConfig{
307+ Agent: ag,
308+ CWD: cwd,
309+ SessionsRoot: root,
310+ CurrentSessionPath: func() string { return path },
311+ LoadSession: func(string) error { return nil },
312+ })
313+ i.clock = func() time.Time { return now }
314+
315+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
316+ t.Fatal("first Escape exited")
317+ }
318+ i.mu.Lock()
319+ i.busy = true
320+ i.cancelTurn = func() { cancelled++ }
321+ i.mu.Unlock()
322+ now = now.Add(100 * time.Millisecond)
323+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
324+ t.Fatal("second Escape exited")
325+ }
326+ if cancelled != 1 {
327+ t.Fatalf("second Escape did not fall through to cancellation: got %d cancels", cancelled)
328+ }
329+ if i.sessionTreeDialog.Active() {
330+ t.Fatal("session tree opened after the turn became busy")
331+ }
332+ if i.doubleEscape.armed {
333+ t.Fatal("stale double-Escape gesture remained armed after falling through")
334+ }
335+}
336+
337+func TestSecondEscapeStillConsumesFailClosedTreeGate(t *testing.T) {
338+ root := t.TempDir()
339+ cwd := "/workspace/double-escape-gate"
340+ session, err := core.NewSession(root, cwd, "test", "model", "test")
341+ if err != nil {
342+ t.Fatal(err)
343+ }
344+ message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
345+ if err := session.AppendMessage(message); err != nil {
346+ t.Fatal(err)
347+ }
348+ path := session.Path
349+ if err := session.Close(); err != nil {
350+ t.Fatal(err)
351+ }
352+
353+ ag := core.NewAgent(nil, "model", "", nil)
354+ ag.SetMessages([]provider.Message{message})
355+ now := time.Unix(400, 0)
356+ pathCalls := 0
357+ flushes := 0
358+ i := NewInteractive(InteractiveConfig{
359+ Agent: ag,
360+ CWD: cwd,
361+ SessionsRoot: root,
362+ CurrentSessionPath: func() string {
363+ pathCalls++
364+ if pathCalls == 1 {
365+ return path
366+ }
367+ // Keep the cheap gesture eligibility true while making the full
368+ // tree-family gate reject the second tap.
369+ return path + ".missing"
370+ },
371+ LoadSession: func(string) error { return nil },
372+ FlushSession: func() {
373+ flushes++
374+ },
375+ })
376+ i.clock = func() time.Time { return now }
377+
378+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
379+ t.Fatal("first Escape exited")
380+ }
381+ now = now.Add(100 * time.Millisecond)
382+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
383+ t.Fatal("second Escape exited")
384+ }
385+ if i.sessionTreeDialog.Active() {
386+ t.Fatal("fail-closed tree gate opened an overlay")
387+ }
388+ if i.statusErr != "tree: session tree is unavailable" {
389+ t.Fatalf("gate status = %q, want the fail-closed tree error", i.statusErr)
390+ }
391+ if flushes != 0 {
392+ t.Fatalf("FlushSession called %d times after the rejected gate", flushes)
393+ }
394+}
395+
396+func TestForkCommandsWithoutSessionPathReportError(t *testing.T) {
397+ for _, command := range []string{"/fork", "/session fork"} {
398+ i := NewInteractive(InteractiveConfig{})
399+ i.runSlash(context.Background(), command)
400+ if i.statusErr != "fork: no session is active (running with --no-session?)" {
401+ t.Fatalf("%s status = %q", command, i.statusErr)
402+ }
403+ }
404+}
M packages/agent/modes/help.go +1 -1
@@ -13,7 +13,7 @@ var helpKeyRows = [][2]string{
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 +266 -102
@@ -256,6 +256,12 @@ type InteractiveConfig struct {
256256 // file that only has the meta row.
257257 FlushSession func()
258258
259+ // SessionTransition serializes a state-changing session/model
260+ // operation with the host's session load. It is optional for embedders;
261+ // the CLI uses it to prevent a slow provider rebuild from being replaced
262+ // by a concurrent resume (or vice versa).
263+ SessionTransition func(func())
264+
259265 // PersistModel is called whenever the user switches model or provider.
260266 // It should update config.json and (if there's an active session)
261267 // write a new meta row so resume picks up the same model.
@@ -433,6 +439,7 @@ type Interactive struct {
433439 busy bool
434440 dirty chan struct{}
435441 modelRefresh chan modelRefreshResult
442+ modelRefreshing bool
436443 startupPreDone chan startupPreResult
437444 cancelTurn context.CancelFunc
438445 scrollOffset int // rows from the bottom; 0 = pinned to latest
@@ -552,6 +559,11 @@ type Interactive struct {
552559 // within ctrlCExitWindow exits. Mirrors the python-repl convention.
553560 lastCtrlC time.Time
554561
562+ // doubleEscape tracks the optional bare-Escape session-tree gesture.
563+ // clock is injectable so the gesture can be tested without sleeping.
564+ doubleEscape doubleEscapeTracker
565+ clock func() time.Time
566+
555567 // welcomeStart is when the interactive run began. The welcome
556568 // banner shows the binary version for welcomeVersionDuration
557569 // after this point and reverts to plain text after.
@@ -680,6 +692,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
680692 fileSuggest: newFileSuggester(),
681693 spin: newSpinner(cfg.Theme),
682694 inputHistoryIndex: -1,
695+ clock: time.Now,
683696 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
684697 }
685698 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
@@ -924,6 +937,9 @@ func (i *Interactive) Run(ctx context.Context) error {
924937 i.handleAuthEvent(ev)
925938 i.invalidate()
926939 case result := <-i.modelRefresh:
940+ i.mu.Lock()
941+ i.modelRefreshing = false
942+ i.mu.Unlock()
927943 i.openModelPickerAfterRefresh(result.err)
928944 i.invalidate()
929945 case result := <-i.startupPreDone:
@@ -1332,6 +1348,14 @@ func (i *Interactive) redraw() {
13321348 case i.sessionOpsDialog.Active():
13331349 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
13341350 case i.sessionTreeDialog.Active():
1351+ // Reserve rows for the editor, status, and tree chrome while using
1352+ // the same budget for rendering and PageUp/PageDown movement.
1353+ _, rows := i.cfg.Terminal.Size()
1354+ avail := rows - 12
1355+ if avail < 3 {
1356+ avail = 3
1357+ }
1358+ i.sessionTreeDialog.MaxRows = avail
13351359 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
13361360 }
13371361 if len(dialog) > 0 {
@@ -2083,6 +2107,15 @@ func (i *Interactive) restoreConfirmFocus() {
20832107 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
20842108 defer i.restoreConfirmFocus()
20852109
2110+ // A bare Escape is eligible for the session-tree gesture only while the
2111+ // main input owns the key. Any other key, modified Escape, or visible
2112+ // child interaction resets the pending first tap before normal routing.
2113+ if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() {
2114+ i.mu.Lock()
2115+ i.doubleEscape.Reset()
2116+ i.mu.Unlock()
2117+ }
2118+
20862119 // Dialogs route keys before the main clipboard handler below. Resolve text
20872120 // here when a child interaction owns input so every editor and filter sees
20882121 // the same KeyPaste event as terminal-native bracketed paste. Main chat is
@@ -2312,7 +2345,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
23122345 }
23132346 act := i.sessionTreeDialog.HandleKey(k)
23142347 if act.Select {
2315- i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
2348+ if act.Target.Role != "" || act.Target.SourcePath != "" || act.Target.IsBoundary() || act.Target.UserDraft != "" {
2349+ i.applySessionTreeTarget(act.Target, act.TurnNo)
2350+ } else {
2351+ // Keep package embedders that still return the scalar action
2352+ // source-compatible while the dialog migrates to Target.
2353+ i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
2354+ }
23162355 }
23172356 i.invalidate()
23182357 return false
@@ -2393,6 +2432,35 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
23932432 return false
23942433 }
23952434
2435+ // The first eligible bare Escape is deliberately left to the existing
2436+ // Escape path (which clears idle input or cancels a turn). A second tap
2437+ // within the gesture window only gets first refusal while the cheap,
2438+ // local eligibility rules still hold. If a turn started, a message was
2439+ // queued, or the input became ineligible between taps, reset the detector
2440+ // and fall through so Escape retains its normal cancellation behavior.
2441+ // Once those rules still hold, the full tree gate owns the second tap:
2442+ // family/read failures are fail-closed but are nevertheless consumed so
2443+ // they cannot fall through into ordinary editor handling.
2444+ if isUnmodifiedEscape(k) {
2445+ now := i.sessionTreeEscapeNow()
2446+ i.mu.Lock()
2447+ consumed := i.doubleEscape.Consume(now)
2448+ i.mu.Unlock()
2449+ if consumed {
2450+ if i.canArmSessionTreeEscape() {
2451+ i.openSessionTree()
2452+ return false
2453+ }
2454+ i.mu.Lock()
2455+ i.doubleEscape.Reset()
2456+ i.mu.Unlock()
2457+ } else if i.canArmSessionTreeEscape() {
2458+ i.mu.Lock()
2459+ i.doubleEscape.Arm(now)
2460+ i.mu.Unlock()
2461+ }
2462+ }
2463+
23962464 // Global keys.
23972465 switch k.Kind {
23982466 case tui.KeyCtrlC:
@@ -2699,18 +2767,27 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
26992767 if text == "" && len(images) == 0 {
27002768 return false
27012769 }
2702- i.clipboardImages = nil
2703- i.ed.Clear()
2704- i.inputHistoryIndex = -1
2705- i.suggest.Reset()
2706- i.fileSuggest.Reset()
2770+ clearSubmittedInput := func() {
2771+ i.clipboardImages = nil
2772+ i.ed.Clear()
2773+ i.inputHistoryIndex = -1
2774+ i.suggest.Reset()
2775+ i.fileSuggest.Reset()
2776+ }
27072777
2708- if cmd, ok := shellEscapeCommand(text); ok {
2709- i.startShellEscape(ctx, cmd)
2710- return false
2778+ // Shell escapes and slash commands are text-only. An image-bearing
2779+ // draft remains a normal provider prompt instead of silently dropping
2780+ // its attachments.
2781+ if len(images) == 0 {
2782+ if cmd, ok := shellEscapeCommand(text); ok {
2783+ clearSubmittedInput()
2784+ i.startShellEscape(ctx, cmd)
2785+ return false
2786+ }
27112787 }
27122788
2713- if looksLikeSlashCommand(text) {
2789+ if len(images) == 0 && looksLikeSlashCommand(text) {
2790+ clearSubmittedInput()
27142791 head := text
27152792 rest := ""
27162793 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
@@ -2733,12 +2810,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
27332810 }
27342811 // Slash commands run regardless of busy state. Commands that
27352812 // would mutate the transcript or replace the agent (/clear,
2736- // /compact, /logout, /login, /model) cancel the active turn
2813+ // /compact, /logout, /login, /model, /fork, /session fork)
2814+ // cancel the active turn
27372815 // first and wait for the goroutine to wind down so they don't
27382816 // race with a streaming response. Safe commands (/help,
27392817 // /jump, /sessions, /jail, /unjail, /exit) run immediately
27402818 // without disturbing the active turn.
2741- if slashCancelsTurn(head) {
2819+ if slashCommandCancelsTurn(text) {
27422820 i.cancelAndWaitForIdle()
27432821 }
27442822 return i.runSlash(ctx, text)
@@ -2774,6 +2852,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
27742852 i.invalidate()
27752853 return false
27762854 }
2855+ clearSubmittedInput()
27772856 if ag != nil && !compacting {
27782857 ag.QueueMessage(text)
27792858 } else {
@@ -2784,6 +2863,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
27842863 i.invalidate()
27852864 return false
27862865 }
2866+ clearSubmittedInput()
27872867 i.startTurnWithImages(ctx, text, images)
27882868 }
27892869 return false
@@ -3107,6 +3187,38 @@ func (i *Interactive) applyStartupPreResult(result startupPreResult) {
31073187 }
31083188 }
31093189
3190+// ApplySessionAgent swaps the live agent after a validated session resume.
3191+// Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup
3192+// context; the session loader is changing the transcript/provider only.
3193+func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) {
3194+ if ag == nil {
3195+ return
3196+ }
3197+ i.mu.Lock()
3198+ i.agent = ag
3199+ i.cfg.Provider = providerName
3200+ i.cfg.Model = model
3201+ i.view.Messages = filterHiddenTranscriptMessages(ag.Messages())
3202+ i.cumUsage = ag.Cost()
3203+ last := ag.LastTurnUsage()
3204+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
3205+ if len(i.view.Messages) > initialResumeTailLimit {
3206+ i.view.TailLimit = initialResumeTailLimit
3207+ } else {
3208+ i.view.TailLimit = 0
3209+ }
3210+ i.view.InvalidateRenderCache()
3211+ i.toolCalls = map[string]*tui.ToolCallView{}
3212+ i.toolOrder = nil
3213+ i.toolGate = map[string]int{}
3214+ i.helpBlock = nil
3215+ i.extNotes = nil
3216+ i.parkedTurn = 0
3217+ i.parkedTotal = 0
3218+ i.mu.Unlock()
3219+ i.invalidate()
3220+}
3221+
31103222 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
31113223 // not provide startup context metadata.
31123224 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
@@ -3167,15 +3279,14 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
31673279 // agent cancel the active turn first via the same path the editor
31683280 // uses for typed slash commands.
31693281 func (i *Interactive) SubmitSlash(text string) {
3282+ i.mu.Lock()
3283+ i.doubleEscape.Reset()
3284+ i.mu.Unlock()
31703285 text = strings.TrimSpace(text)
31713286 if !strings.HasPrefix(text, "/") {
31723287 return
31733288 }
3174- head := text
3175- if idx := strings.IndexAny(text, " \t"); idx >= 0 {
3176- head = text[:idx]
3177- }
3178- if slashCancelsTurn(head) {
3289+ if slashCommandCancelsTurn(text) {
31793290 i.cancelAndWaitForIdle()
31803291 }
31813292 i.runSlash(i.runCtx, text)
@@ -4519,6 +4630,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
45194630 i.applyModelSelection("", parts[1])
45204631 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
45214632 i.mu.Lock()
4633+ i.modelRefreshing = true
45224634 i.statusOK = "refreshing models"
45234635 i.statusErr = ""
45244636 i.mu.Unlock()
@@ -4554,6 +4666,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
45544666 i.openSettingsDialog()
45554667 case "/sessions":
45564668 i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)
4669+ case "/fork":
4670+ i.doSessionFork()
45574671 case "/jump":
45584672 i.openJumpDialog(parts[1:])
45594673 case "/btw":
@@ -5393,6 +5507,16 @@ func (i *Interactive) applyRescueModelSelection(prov, model string) {
53935507 // the supplied builder. rescue=true tags the success message so the
53945508 // user can see that launch-time overrides were ignored.
53955509 func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
5510+ if i.cfg.SessionTransition != nil {
5511+ i.cfg.SessionTransition(func() {
5512+ i.swapModelUnserialized(prov, model, builder, rescue)
5513+ })
5514+ return
5515+ }
5516+ i.swapModelUnserialized(prov, model, builder, rescue)
5517+}
5518+
5519+func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
53965520 if model == "" {
53975521 return
53985522 }
@@ -5495,15 +5619,27 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
54955619 i.dialog.ShowResult(false, err.Error())
54965620 return
54975621 }
5498- i.mu.Lock()
5499- i.agent = ag
5500- i.cfg.Provider = prov
5501- i.cfg.Model = model
5502- i.statusErr = ""
5503- i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5504- i.mu.Unlock()
5505- i.applyAutoSwarmTool(i.autoSwarmEnabled())
5506- i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5622+ commitLogin := func() {
5623+ i.mu.Lock()
5624+ i.agent = ag
5625+ i.cfg.Provider = prov
5626+ i.cfg.Model = model
5627+ i.statusErr = ""
5628+ i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5629+ i.mu.Unlock()
5630+ i.applyAutoSwarmTool(i.autoSwarmEnabled())
5631+ i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5632+ // Authentication can change the provider used by the live
5633+ // agent. Persist it on the active session just like /model.
5634+ if i.cfg.PersistModel != nil {
5635+ i.cfg.PersistModel(prov, model)
5636+ }
5637+ }
5638+ if i.cfg.SessionTransition != nil {
5639+ i.cfg.SessionTransition(commitLogin)
5640+ } else {
5641+ commitLogin()
5642+ }
55075643 i.dialog.ShowResult(true, "")
55085644 }
55095645 }
@@ -6942,14 +7078,7 @@ func (i *Interactive) doSessionOp(action, arg string) {
69427078 // the user's home directory if it doesn't exist). The helper
69437079 // expands a leading `~` and creates any missing parent directories.
69447080 func (i *Interactive) doSessionExport(dst string) {
6945- if i.cfg.CurrentSessionPath == nil {
6946- i.mu.Lock()
6947- i.statusErr = "export: no session is active (running with --no-session?)"
6948- i.mu.Unlock()
6949- i.invalidate()
6950- return
6951- }
6952- src := i.cfg.CurrentSessionPath()
7081+ src := i.currentSessionPath()
69537082 if src == "" {
69547083 i.mu.Lock()
69557084 i.statusErr = "export: no session is active (running with --no-session?)"
@@ -7104,114 +7233,156 @@ func friendlyPath(p string) string {
71047233 // next selection branches the current session at that user turn
71057234 // instead of scrolling the viewport to it.
71067235 func (i *Interactive) doSessionFork() {
7107- if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
7236+ path := i.currentSessionPath()
7237+ if path == "" {
71087238 i.mu.Lock()
71097239 i.statusErr = "fork: no session is active (running with --no-session?)"
71107240 i.mu.Unlock()
71117241 i.invalidate()
71127242 return
71137243 }
7114- msgs := []provider.Message{}
7115- if i.agent != nil {
7116- msgs = i.agent.Messages()
7117- }
7118- if len(msgs) == 0 {
7244+ i.mu.Lock()
7245+ busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
7246+ queued := len(i.queued) != 0
7247+ ag := i.agent
7248+ i.mu.Unlock()
7249+ if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
71197250 i.mu.Lock()
7120- i.statusErr = "fork: transcript is empty; nothing to fork from"
7251+ i.statusErr = "fork: wait until the current turn and queued messages finish"
71217252 i.mu.Unlock()
71227253 i.invalidate()
71237254 return
71247255 }
7125- i.pendingFork = true
7126- i.jumpDialog.Open(msgs, "")
7127- i.invalidate()
7128-}
7129-
7130-// doSessionTree shows the current session family as an inline branch tree.
7131-// Pick an entry to check out that point into a new branch. Subsequent
7132-// prompts use only context up to the selected message.
7133-func (i *Interactive) doSessionTree() {
7134- current := ""
7135- if i.cfg.CurrentSessionPath != nil {
7136- current = i.cfg.CurrentSessionPath()
7256+ if i.cfg.FlushSession != nil {
7257+ i.cfg.FlushSession()
71377258 }
7138- if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {
7259+ snapshot, err := core.ReadSessionSnapshot(path)
7260+ if err != nil {
7261+ i.mu.Lock()
7262+ i.statusErr = "fork: " + err.Error()
7263+ i.mu.Unlock()
71397264 i.invalidate()
71407265 return
71417266 }
7142- msgs := []provider.Message{}
7143- if i.agent != nil {
7144- msgs = i.agent.Messages()
7145- }
7267+ msgs := snapshot.Messages
71467268 if len(msgs) == 0 {
71477269 i.mu.Lock()
7148- i.statusErr = "tree: no messages in this session yet"
7270+ i.statusErr = "fork: transcript is empty; nothing to fork from"
71497271 i.mu.Unlock()
71507272 i.invalidate()
71517273 return
71527274 }
7153- if !i.sessionTreeDialog.OpenMessages(msgs) {
7154- i.mu.Lock()
7155- i.statusErr = "tree: no messages to show"
7156- i.mu.Unlock()
7157- }
7275+ i.pendingFork = true
7276+ i.jumpDialog.Open(msgs, "")
71587277 i.invalidate()
71597278 }
71607279
7161-// applySessionTreeMessageSelection checks out the selected message into a
7162-// new branch, then switches the running agent to that branch. Unlike /jump,
7163-// this changes future model context: only messages up to the selected row are
7164-// sent on subsequent turns.
7280+// doSessionTree opens the current session family after the shared
7281+// fail-closed gate succeeds. It intentionally does not fall back to the
7282+// in-memory transcript: a tree that cannot be persisted and reloaded cannot
7283+// safely create a navigation branch.
7284+func (i *Interactive) doSessionTree() {
7285+ i.openSessionTree()
7286+}
7287+
7288+// applySessionTreeMessageSelection keeps the old scalar integration
7289+// source-compatible for package consumers. New dialog selections use the
7290+// structured sessionTreeTarget path below.
71657291 func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
7166- if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
7167- i.mu.Lock()
7168- i.statusErr = "tree: session branching is not available in this build"
7169- i.mu.Unlock()
7170- i.invalidate()
7292+ target := sessionTreeTarget{
7293+ SourcePath: src,
7294+ EffectiveIndex: msgIdx,
7295+ SelectionBoundary: msgIdx + 1,
7296+ Role: role,
7297+ UserDraft: prompt,
7298+ Boundary: sessionTreeMessageBoundary,
7299+ }
7300+ if role == provider.RoleUser {
7301+ target.SelectionBoundary = msgIdx
7302+ }
7303+ i.applySessionTreeTarget(target, turnNo)
7304+}
7305+
7306+// applySessionTreeTarget checks out a structured dialog target. The target's
7307+// source and effective indices come from the same preflight snapshot the
7308+// dialog rendered, while the live read below preserves compatibility with
7309+// the existing LoadSession(path) error callback.
7310+func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
7311+ // Selection is a state-changing operation too. Use the selection gate so
7312+ // a queued/busy turn cannot race the hidden branch or accidentally submit
7313+ // a provider turn while the session is being swapped. The open gate cannot
7314+ // be reused here because the tree dialog itself owns the keyboard.
7315+ if !i.canCommitSessionTreeSelection() {
7316+ i.setSessionTreeError("tree: session branching is not available in this build")
71717317 return
71727318 }
7319+ src := target.SourcePath
71737320 if src == "" {
7174- src = i.cfg.CurrentSessionPath()
7321+ src = i.currentSessionPath()
71757322 }
71767323 if src == "" {
7177- i.mu.Lock()
7178- i.statusErr = "tree: no session is active"
7179- i.mu.Unlock()
7180- i.invalidate()
7324+ i.setSessionTreeError("tree: no session is active")
7325+ return
7326+ }
7327+
7328+ // Read and validate the selected row before flushing or creating anything.
7329+ // This gives tool-call/result pairs an atomic boundary and prevents a
7330+ // malformed selection from changing the active session.
7331+ sess, msgs, err := core.OpenSession(src)
7332+ if err != nil {
7333+ i.setSessionTreeError("tree: read selection: " + err.Error())
7334+ return
7335+ }
7336+ _ = sess.Close()
7337+ selection, err := sessionTreeSelection(msgs, target)
7338+ if err != nil {
7339+ i.setSessionTreeError("tree: " + err.Error())
71817340 return
71827341 }
71837342 if i.cfg.FlushSession != nil {
71847343 i.cfg.FlushSession()
71857344 }
7186- upTo := msgIdx + 1
7187- if role == provider.RoleUser {
7188- upTo = msgIdx
7189- }
7190- newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
7345+ newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
71917346 if err != nil {
7192- i.mu.Lock()
7193- i.statusErr = "tree: " + err.Error()
7194- i.mu.Unlock()
7195- i.invalidate()
7347+ i.setSessionTreeError("tree: " + err.Error())
71967348 return
71977349 }
71987350 if err := i.cfg.LoadSession(newPath); err != nil {
7199- i.mu.Lock()
7200- i.statusErr = "tree: checkout failed: " + err.Error()
7201- i.mu.Unlock()
7202- i.invalidate()
7351+ i.setSessionTreeError("tree: checkout failed: " + err.Error())
72037352 return
72047353 }
7205- i.scrollToBottom()
7354+
7355+ // LoadSession is intentionally the old func(string) error callback: the
7356+ // CLI and embedders already own agent/session swapping. Refresh the view
7357+ // from the swapped agent here instead of requiring a new callback result.
72067358 i.mu.Lock()
7207- if role == provider.RoleUser {
7208- i.ed.SetValue(prompt)
7359+ i.lastCtxInput = 0
7360+ if i.agent != nil {
7361+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
7362+ i.cumUsage = i.agent.Cost()
7363+ last := i.agent.LastTurnUsage()
7364+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
7365+ i.view.InvalidateRenderCache()
7366+ }
7367+ i.toolCalls = map[string]*tui.ToolCallView{}
7368+ i.toolOrder = nil
7369+ i.toolGate = map[string]int{}
7370+ i.extNotes = nil
7371+ if selection.restoreDraft {
7372+ i.ed.SetValue(selection.draftText)
7373+ i.clipboardImages = selection.images
72097374 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
72107375 } else {
7376+ i.ed.Clear()
7377+ i.clipboardImages = nil
72117378 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
72127379 }
72137380 i.statusErr = ""
72147381 i.mu.Unlock()
7382+ i.scrollToBottom()
7383+ // Selection only changes local/session state. In particular, do not call
7384+ // Submit, startTurn, or queue a message here; the restored user draft is
7385+ // submitted only by a later explicit Enter.
72157386 i.invalidate()
72167387 }
72177388
@@ -7221,14 +7392,7 @@ func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnN
72217392 // file. Called from the jump-dialog handler when pendingFork=true.
72227393 func (i *Interactive) applyForkSelection(msgIdx int) {
72237394 i.pendingFork = false
7224- if i.cfg.CurrentSessionPath == nil {
7225- i.mu.Lock()
7226- i.statusErr = "fork: no session is active"
7227- i.mu.Unlock()
7228- i.invalidate()
7229- return
7230- }
7231- src := i.cfg.CurrentSessionPath()
7395+ src := i.currentSessionPath()
72327396 if src == "" {
72337397 i.mu.Lock()
72347398 i.statusErr = "fork: no session is active"
M packages/agent/modes/session_tree_dialog.go +604 -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
409+}
410+
411+// flattenSessionFamily is retained for package callers/tests that already
412+// provide a TreeNode. The dialog itself uses the snapshot variant so all
413+// reads happen before activation.
414+func flattenSessionFamily(root *core.TreeNode, currentPath string) []sessionTreeItem {
415+ snapshots, err := preflightSessionTreeFamily(root)
416+ if err != nil {
417+ return nil
418+ }
419+ return flattenSessionFamilySnapshot(root, currentPath, snapshots)
420+}
421+
422+func flattenSessionFamilySnapshot(root *core.TreeNode, currentPath string, snapshots map[string]sessionTreeSnapshot) []sessionTreeItem {
423+ return flattenSessionNode(root, currentPath, snapshots, 0, 0, true, false)
193424 }
194425
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+ var selectionBoundary int
455+ boundary := sessionTreeMessageBoundary
456+ rawFork := 0
457+ if !rootNode {
458+ rawFork = node.Meta.ForkPoint
459+ switch {
460+ case detachedFromParent || rawFork < 0 || rawFork > parentLen || rawFork > len(msgs):
461+ // Historical placement no longer maps to either snapshot. Keep the
462+ // branch visible at the parent's tail, but render its complete
463+ // current snapshot rather than slicing it at the stale fork point.
464+ boundary = sessionTreeDetachedBoundary
465+ start = 0
466+ selectionBoundary = len(msgs)
467+ default:
468+ start = rawFork
469+ selectionBoundary = rawFork
470+ if start >= len(msgs) {
471+ boundary = sessionTreeEmptyBoundary
472+ }
473+ }
474+ } else if len(msgs) == 0 {
475+ boundary = sessionTreeEmptyBoundary
476+ selectionBoundary = 0
477+ } else {
478+ selectionBoundary = len(msgs)
479+ }
480+
481+ all := buildSessionTreeItems(snapshot.path, msgs, depth, snapshot.path == currentPath)
482+ childrenAt := make(map[int][]*core.TreeNode)
483+ var stale []*core.TreeNode
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,47 @@ 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' && i+1 < len(runes) && runes[i+1] == ']' {
728+ // OSC sequences end at BEL or ST. Drop the whole sequence when
729+ // present.
730+ i += 2
731+ for i < len(runes) && runes[i] != '\a' {
732+ if runes[i] == '\x1b' && i+1 < len(runes) && runes[i+1] == '\\' {
733+ i += 2
734+ break
735+ }
736+ i++
737+ }
738+ if i < len(runes) && runes[i] == '\a' {
739+ i++
740+ }
741+ continue
742+ }
743+ if runes[i] == '\x1b' {
744+ // An isolated ESC (or a two-byte escape) is dropped by itself.
745+ i++
746+ continue
747+ }
748+ if unicode.IsControl(runes[i]) {
749+ b.WriteByte(' ')
750+ } else {
751+ b.WriteRune(runes[i])
752+ }
753+ i++
754+ }
755+ return strings.Join(strings.Fields(b.String()), " ")
756+}
757+
343758 func colorSessionTreeLine(th tui.Theme, line string) string {
344759 plain := strings.TrimSpace(line)
345760 switch {
@@ -354,13 +769,96 @@ func colorSessionTreeLine(th tui.Theme, line string) string {
354769 }
355770 }
356771
772+// fitSessionTreeLabel truncates by terminal cells, not Go runes. This is
773+// intentionally also used for the indentation and current marker together so
774+// neither can push a row past the terminal edge.
357775 func fitSessionTreeLabel(label string, maxWidth int) string {
358- if maxWidth < 4 {
359- maxWidth = 4
776+ if maxWidth <= 0 {
777+ return ""
360778 }
361- runes := []rune(label)
362- if len(runes) <= maxWidth {
779+ if runewidth.StringWidth(label) <= maxWidth {
363780 return label
364781 }
365- return string(runes[:maxWidth-3]) + "..."
782+ if maxWidth <= 3 {
783+ return strings.Repeat(".", maxWidth)
784+ }
785+ return truncateSessionTreePlain(label, maxWidth-3) + "..."
786+}
787+
788+func truncateSessionTreePlain(s string, maxWidth int) string {
789+ if maxWidth <= 0 {
790+ return ""
791+ }
792+ var b strings.Builder
793+ used := 0
794+ for _, r := range s {
795+ w := runewidth.RuneWidth(r)
796+ if w > 0 && used+w > maxWidth {
797+ break
798+ }
799+ b.WriteRune(r)
800+ used += w
801+ }
802+ return b.String()
803+}
804+
805+// truncateSessionTreeANSI is the dialog-local equivalent of tui's renderer
806+// clipping helper. Keeping it here avoids changing tui for one dialog while
807+// still making headers, hints, colors, and highlighted rows width-safe.
808+func truncateSessionTreeANSI(s string, maxWidth int) string {
809+ if maxWidth <= 0 {
810+ return ""
811+ }
812+ if sessionTreeANSIWidth(s) <= maxWidth {
813+ return s
814+ }
815+ runes := []rune(s)
816+ var b strings.Builder
817+ used := 0
818+ clipped := false
819+ for i := 0; i < len(runes); {
820+ if end, ok := sessionTreeCSIEnd(runes, i); ok {
821+ b.WriteString(string(runes[i:end]))
822+ i = end
823+ continue
824+ }
825+ w := runewidth.RuneWidth(runes[i])
826+ if w > 0 && used+w > maxWidth {
827+ clipped = true
828+ break
829+ }
830+ b.WriteRune(runes[i])
831+ used += w
832+ i++
833+ }
834+ if clipped && strings.Contains(s, "\x1b[") {
835+ b.WriteString("\x1b[0m")
836+ }
837+ return b.String()
838+}
839+
840+func sessionTreeANSIWidth(s string) int {
841+ runes := []rune(s)
842+ width := 0
843+ for i := 0; i < len(runes); {
844+ if end, ok := sessionTreeCSIEnd(runes, i); ok {
845+ i = end
846+ continue
847+ }
848+ width += runewidth.RuneWidth(runes[i])
849+ i++
850+ }
851+ return width
852+}
853+
854+func sessionTreeCSIEnd(runes []rune, start int) (int, bool) {
855+ if start+1 >= len(runes) || runes[start] != '\x1b' || runes[start+1] != '[' {
856+ return start, false
857+ }
858+ for i := start + 2; i < len(runes); i++ {
859+ if runes[i] >= 0x40 && runes[i] <= 0x7e {
860+ return i + 1, true
861+ }
862+ }
863+ return len(runes), true
366864 }
A packages/agent/modes/session_tree_dialog_test.go +290 -0
@@ -0,0 +1,290 @@
1+package modes
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/provider"
11+ "github.com/patriceckhart/zot/packages/tui"
12+)
13+
14+func TestSessionTreeTargetCarriesEffectiveBoundaryAndDraft(t *testing.T) {
15+ msgs := []provider.Message{
16+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "first"}),
17+ treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "answer"}),
18+ treeTestMessage(provider.RoleUser,
19+ provider.TextBlock{Text: "line one"},
20+ provider.ImageBlock{MimeType: "image/png", Data: []byte{1, 2}},
21+ provider.TextBlock{Text: "line two"}),
22+ }
23+
24+ d := newSessionTreeDialog()
25+ if !d.OpenMessages(msgs) {
26+ t.Fatal("OpenMessages returned false")
27+ }
28+ d.cursor = 2
29+ act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
30+ if !act.Select {
31+ t.Fatal("enter did not select a target")
32+ }
33+ got := act.Target
34+ if got.SourcePath != "" || got.EffectiveIndex != 2 || got.SelectionBoundary != 2 {
35+ t.Fatalf("target indices/path = %+v", got)
36+ }
37+ if got.Role != provider.RoleUser || got.Boundary != sessionTreeMessageBoundary {
38+ t.Fatalf("target role/boundary = %+v", got)
39+ }
40+ if got.UserDraft != "line one\nline two" {
41+ t.Fatalf("user draft = %q", got.UserDraft)
42+ }
43+ // The scalar fields remain usable by the current integration layer.
44+ if act.MessageIdx != 2 || act.Role != provider.RoleUser || act.Prompt != got.UserDraft {
45+ t.Fatalf("legacy action fields = %+v", act)
46+ }
47+}
48+
49+func TestSessionTreeUsesCurrentFamilyAndNoRootFallback(t *testing.T) {
50+ root := t.TempDir()
51+ cwd := "/workspace/current-family"
52+ first := newTreeTestSession(t, root, cwd, []provider.Message{
53+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "unrelated root"}),
54+ })
55+ unrelatedFile, err := os.OpenFile(first, os.O_APPEND|os.O_WRONLY, 0o644)
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if _, err := unrelatedFile.WriteString("{unrelated corruption}\n"); err != nil {
60+ _ = unrelatedFile.Close()
61+ t.Fatal(err)
62+ }
63+ if err := unrelatedFile.Close(); err != nil {
64+ t.Fatal(err)
65+ }
66+ current := newTreeTestSession(t, root, cwd, []provider.Message{
67+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "current root"}),
68+ })
69+
70+ d := newSessionTreeDialog()
71+ if !d.OpenSessionFamily(root, cwd, current) {
72+ t.Fatal("OpenSessionFamily returned false")
73+ }
74+ for _, item := range d.items {
75+ if item.path != current {
76+ t.Fatalf("item from unrelated family: %+v (first root %q)", item, first)
77+ }
78+ }
79+ if !d.Active() {
80+ t.Fatal("dialog is not active")
81+ }
82+ oldItems := append([]sessionTreeItem(nil), d.items...)
83+ if d.OpenSessionFamily(root, cwd, t.TempDir()+"/not-a-session.jsonl") {
84+ t.Fatal("missing current path selected a forest root")
85+ }
86+ if !d.Active() || len(d.items) != len(oldItems) || d.items[0].path != oldItems[0].path {
87+ t.Fatal("failed open changed the active dialog")
88+ }
89+}
90+
91+func TestSessionTreeShowsEmptyAndDetachedBoundaries(t *testing.T) {
92+ root := t.TempDir()
93+ cwd := "/workspace/stale-compaction"
94+ parent := newTreeTestSession(t, root, cwd, []provider.Message{
95+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "one"}),
96+ treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "two"}),
97+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "three"}),
98+ })
99+
100+ // Both children are created against the old effective parent. The first
101+ // has no post-fork rows; the second's fork point becomes stale after the
102+ // parent is compacted to one message.
103+ emptyChild, err := core.BranchSessionHidden(parent, root, cwd, "test", 1)
104+ if err != nil {
105+ t.Fatal(err)
106+ }
107+ staleChild, err := core.BranchSessionHidden(parent, root, cwd, "test", 3)
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ parentSession, _, err := core.OpenSession(parent)
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ if err := parentSession.AppendCompaction([]provider.Message{
116+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "compacted"}),
117+ }); err != nil {
118+ t.Fatal(err)
119+ }
120+ if err := parentSession.Close(); err != nil {
121+ t.Fatal(err)
122+ }
123+
124+ d := newSessionTreeDialog()
125+ if !d.OpenSessionFamily(root, cwd, parent) {
126+ t.Fatal("OpenSessionFamily returned false after compaction")
127+ }
128+ var empty, detached *sessionTreeItem
129+ staleMessageRows := 0
130+ for i := range d.items {
131+ item := &d.items[i]
132+ switch {
133+ case item.target.IsEmptyBoundary() && item.path == emptyChild:
134+ empty = item
135+ case item.target.IsDetachedBoundary() && item.path == staleChild:
136+ detached = item
137+ case item.path == staleChild && !item.target.IsBoundary():
138+ staleMessageRows++
139+ }
140+ }
141+ if empty == nil || detached == nil {
142+ t.Fatalf("boundaries missing from tree: empty=%v detached=%v items=%+v", empty != nil, detached != nil, d.items)
143+ }
144+ if staleMessageRows != 3 {
145+ t.Fatalf("detached branch rendered %d message rows, want its complete snapshot (3)", staleMessageRows)
146+ }
147+ if empty.target.SelectionBoundary != 1 || detached.target.SelectionBoundary != 3 {
148+ t.Fatalf("boundary indices = empty %+v detached %+v", empty.target, detached.target)
149+ }
150+ if !strings.Contains(strings.ToLower(empty.label), "empty") || !strings.Contains(strings.ToLower(detached.label), "detached") {
151+ t.Fatalf("boundary labels = %q / %q", empty.label, detached.label)
152+ }
153+}
154+
155+func TestSessionTreePreflightIsAtomicOnMalformedFamilyMember(t *testing.T) {
156+ root := t.TempDir()
157+ cwd := "/workspace/atomic-tree"
158+ parent := newTreeTestSession(t, root, cwd, []provider.Message{
159+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "parent"}),
160+ })
161+ child, err := core.BranchSessionHidden(parent, root, cwd, "test", 1)
162+ if err != nil {
163+ t.Fatal(err)
164+ }
165+ f, err := os.OpenFile(child, os.O_APPEND|os.O_WRONLY, 0o644)
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ if _, err := f.WriteString("{not valid json}\n"); err != nil {
170+ _ = f.Close()
171+ t.Fatal(err)
172+ }
173+ if err := f.Close(); err != nil {
174+ t.Fatal(err)
175+ }
176+
177+ d := newSessionTreeDialog()
178+ if !d.OpenMessages([]provider.Message{treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "keep"})}) {
179+ t.Fatal("failed to seed dialog")
180+ }
181+ before := append([]sessionTreeItem(nil), d.items...)
182+ if d.OpenSessionFamily(root, cwd, parent) {
183+ t.Fatal("malformed family member passed preflight")
184+ }
185+ if !d.Active() || len(d.items) != len(before) || d.items[0].label != before[0].label {
186+ t.Fatalf("preflight failure changed dialog: active=%v items=%+v", d.Active(), d.items)
187+ }
188+}
189+
190+func TestSanitizeSessionTreeTextPreservesTextAfterNonOSCExit(t *testing.T) {
191+ input := "before\x1bM and \x1b(B after \x1b]0;window title\a tail"
192+ if got, want := sanitizeSessionTreeText(input), "beforeM and (B after tail"; got != want {
193+ t.Fatalf("sanitizeSessionTreeText = %q, want %q", got, want)
194+ }
195+}
196+
197+func TestSessionTreeRenderAndPagingAreWidthAndHeightSafe(t *testing.T) {
198+ msgs := make([]provider.Message, 0, 20)
199+ for i := 0; i < 20; i++ {
200+ msgs = append(msgs, treeTestMessage(provider.RoleUser,
201+ provider.TextBlock{Text: strings.Repeat("界", 4) + " long row"}))
202+ }
203+ d := newSessionTreeDialog()
204+ d.MaxRows = 2
205+ if !d.OpenMessages(msgs) {
206+ t.Fatal("OpenMessages returned false")
207+ }
208+ if got := d.HandleKey(tui.Key{Kind: tui.KeyPageUp}); got.Select || d.cursor != 17 {
209+ t.Fatalf("page up cursor = %d, action=%+v", d.cursor, got)
210+ }
211+ for _, width := range []int{0, 1, 7, 17} {
212+ for _, line := range d.Render(tui.Theme{}, width) {
213+ if got := sessionTreeANSIWidth(line); got > width {
214+ t.Fatalf("width %d rendered %d cells: %q", width, got, line)
215+ }
216+ }
217+ }
218+ if d.viewTop < 0 || d.viewTop+d.MaxRows > len(d.items) {
219+ t.Fatalf("invalid viewport top=%d rows=%d items=%d", d.viewTop, d.MaxRows, len(d.items))
220+ }
221+}
222+
223+func TestSessionTreeCheckoutRefreshesLastTurnUsageIncludingZero(t *testing.T) {
224+ root := t.TempDir()
225+ cwd := "/workspace/tree-usage"
226+ path := newTreeTestSession(t, root, cwd, []provider.Message{
227+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "question"}),
228+ treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "answer"}),
229+ })
230+
231+ initial := core.NewAgent(nil, "model", "", nil)
232+ initial.SeedLastTurnUsage(provider.Usage{InputTokens: 99})
233+ selected := core.NewAgent(nil, "model", "", nil)
234+ selected.SetMessages(initial.Messages())
235+ selected.SeedLastTurnUsage(provider.Usage{
236+ InputTokens: 7,
237+ CacheReadTokens: 11,
238+ CacheWriteTokens: 13,
239+ })
240+
241+ i := NewInteractive(InteractiveConfig{
242+ Agent: initial,
243+ CWD: cwd,
244+ SessionsRoot: root,
245+ CurrentSessionPath: func() string { return path },
246+ })
247+ i.cfg.LoadSession = func(string) error {
248+ i.agent = selected
249+ return nil
250+ }
251+
252+ target := sessionTreeTarget{
253+ SourcePath: path,
254+ EffectiveIndex: 1,
255+ SelectionBoundary: 2,
256+ Role: provider.RoleAssistant,
257+ }
258+ i.applySessionTreeTarget(target, 1)
259+ if i.lastCtxInput != 31 {
260+ t.Fatalf("checked-out context usage = %d, want 31", i.lastCtxInput)
261+ }
262+
263+ selected.SeedLastTurnUsage(provider.Usage{})
264+ i.applySessionTreeTarget(target, 1)
265+ if i.lastCtxInput != 0 {
266+ t.Fatalf("checked-out zero usage left stale context value %d", i.lastCtxInput)
267+ }
268+}
269+
270+func treeTestMessage(role provider.Role, content ...provider.Content) provider.Message {
271+ return provider.Message{Role: role, Content: content, Time: time.Unix(1, 0)}
272+}
273+
274+func newTreeTestSession(t *testing.T, root, cwd string, msgs []provider.Message) string {
275+ t.Helper()
276+ sess, err := core.NewSession(root, cwd, "test", "model", "test")
277+ if err != nil {
278+ t.Fatal(err)
279+ }
280+ for _, msg := range msgs {
281+ if err := sess.AppendMessage(msg); err != nil {
282+ _ = sess.Close()
283+ t.Fatal(err)
284+ }
285+ }
286+ if err := sess.Close(); err != nil {
287+ t.Fatal(err)
288+ }
289+ return sess.Path
290+}
M packages/agent/modes/slash_suggest.go +14 -2
@@ -26,12 +26,23 @@ type slashCommand struct {
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/agent.go +3 -0
@@ -322,6 +322,9 @@ func (a *Agent) fireMessageAppended(m provider.Message) {
322322 // stops or an error occurs. Events are delivered via sink in order.
323323 // sink must not block the caller for long; buffer as needed.
324324 func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error {
325+ if text == "" && len(images) == 0 {
326+ return errors.New("prompt is empty")
327+ }
325328 if sink == nil {
326329 sink = func(AgentEvent) {}
327330 }
M packages/core/core_test.go +11 -0
@@ -1,6 +1,7 @@
11 package core
22
33 import (
4+ "context"
45 "os"
56 "testing"
67 "time"
@@ -8,6 +9,16 @@ import (
89 "github.com/patriceckhart/zot/packages/provider"
910 )
1011
12+func TestAgentRejectsEmptyPrompt(t *testing.T) {
13+ ag := NewAgent(nil, "model", "", nil)
14+ if err := ag.Prompt(context.Background(), "", nil, nil); err == nil {
15+ t.Fatal("empty prompt was accepted")
16+ }
17+ if got := ag.Messages(); len(got) != 0 {
18+ t.Fatalf("empty prompt appended %d messages", len(got))
19+ }
20+}
21+
1122 func TestSessionRoundTrip(t *testing.T) {
1223 dir := t.TempDir()
1324 os.Setenv("ZOT_HOME", dir)
M packages/core/session.go +476 -243
@@ -6,6 +6,7 @@ import (
66 "crypto/sha256"
77 "encoding/hex"
88 "encoding/json"
9+ "errors"
910 "fmt"
1011 "io"
1112 "os"
@@ -69,18 +70,36 @@ type SessionMeta struct {
6970 // the default unmarshaler cannot reconstruct); it is written with a
7071 // regular provider.Message value.
7172 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"`
73+ Type string `json:"type"`
74+ Meta *SessionMeta `json:"meta,omitempty"`
75+ Message *provider.Message `json:"message,omitempty"`
76+ Messages *[]provider.Message `json:"messages,omitempty"`
77+ Usage *provider.Usage `json:"usage,omitempty"`
78+ Cumulative *provider.Usage `json:"cumulative,omitempty"`
7879 }
7980
8081 type sessionLineHead struct {
8182 Type string `json:"type"`
8283 }
8384
85+// SessionUsageCheckpoint records the cumulative usage known when a usage
86+// row was written. MessageCount uses the effective transcript (after the
87+// latest compaction and tool-pair repair), not the number of raw JSONL rows.
88+type SessionUsageCheckpoint struct {
89+ MessageCount int
90+ Cumulative provider.Usage
91+}
92+
93+// SessionSnapshot is the effective, in-memory view of a session file.
94+// Messages are the transcript that a resumed agent should use. The raw
95+// JSONL audit history is deliberately not exposed here because message
96+// indices used by tree navigation and branching must agree.
97+type SessionSnapshot struct {
98+ Meta SessionMeta
99+ Messages []provider.Message
100+ UsageCheckpoints []SessionUsageCheckpoint
101+}
102+
84103 // SessionsDir returns the per-cwd sessions directory under root.
85104 func SessionsDir(root, cwd string) string {
86105 sum := sha256.Sum256([]byte(cwd))
@@ -168,69 +187,33 @@ func SessionUsage(path string) (provider.Usage, error) {
168187 }
169188
170189 // 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.
190+// per-turn usage of the final completed turn. It reads through the same
191+// strict snapshot path as OpenSession, so a truncated or malformed session
192+// cannot silently reset the usage display.
176193 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
194+ snapshot, err := ReadSessionSnapshot(path)
195+ if err != nil {
196+ return provider.Usage{}, provider.Usage{}, err
180197 }
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
198+ if len(snapshot.UsageCheckpoints) == 0 {
199+ return provider.Usage{}, provider.Usage{}, nil
211200 }
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- }
201+ last := snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative
202+ var prev provider.Usage
203+ if len(snapshot.UsageCheckpoints) > 1 {
204+ prev = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-2].Cumulative
232205 }
233- return cumulative, lastTurn, nil
206+ lastTurn.InputTokens = nonNegDelta(last.InputTokens, prev.InputTokens)
207+ lastTurn.OutputTokens = nonNegDelta(last.OutputTokens, prev.OutputTokens)
208+ lastTurn.ReasoningTokens = nonNegDelta(last.ReasoningTokens, prev.ReasoningTokens)
209+ lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown
210+ lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens)
211+ lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens)
212+ lastTurn.CostUSD = last.CostUSD - prev.CostUSD
213+ if lastTurn.CostUSD < 0 {
214+ lastTurn.CostUSD = 0
215+ }
216+ return last, lastTurn, nil
234217 }
235218
236219 func nonNegDelta(cur, prev int) int {
@@ -240,49 +223,329 @@ func nonNegDelta(cur, prev int) int {
240223 return cur - prev
241224 }
242225
243-// OpenSession opens an existing session for appending.
244-func OpenSession(path string) (*Session, []provider.Message, error) {
226+// ReadSessionSnapshot reads a complete session file and reconstructs the
227+// effective transcript. It is intentionally stricter than the lightweight
228+// listing helpers: a caller that is about to resume, render, or branch a
229+// session must not receive a plausible-looking partial transcript.
230+func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
245231 f, err := os.Open(path)
246232 if err != nil {
247- return nil, nil, err
233+ return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err)
248234 }
249235 defer f.Close()
250236
251- var meta SessionMeta
252- var messages []provider.Message
253- if err := forEachJSONLLine(f, func(line []byte) error {
237+ var snapshot SessionSnapshot
238+ var sawMeta bool
239+ generation := 0
240+ type rawCheckpoint struct {
241+ messageCount int
242+ cumulative provider.Usage
243+ generation int
244+ }
245+ var rawCheckpoints []rawCheckpoint
246+
247+ err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
254248 var head sessionLineHead
255249 if err := json.Unmarshal(line, &head); err != nil {
256- return nil
250+ return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
251+ }
252+ if head.Type == "" {
253+ return fmt.Errorf("line %d: missing row type", lineNo)
254+ }
255+ if !sawMeta && head.Type != "meta" {
256+ return fmt.Errorf("line %d: first row is not meta", lineNo)
257257 }
258+
258259 switch head.Type {
259260 case "meta":
260- var row struct {
261- Meta SessionMeta `json:"meta"`
261+ var row sessionLine
262+ if err := json.Unmarshal(line, &row); err != nil {
263+ return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
262264 }
263- if err := json.Unmarshal(line, &row); err == nil {
264- meta = row.Meta
265+ if row.Meta == nil || row.Meta.ID == "" {
266+ return fmt.Errorf("line %d: meta row has no session id", lineNo)
265267 }
268+ snapshot.Meta = *row.Meta
269+ sawMeta = true
270+
266271 case "message":
267- if msg, err := hydrateMessage(line); err == nil && len(msg.Content) > 0 {
268- messages = append(messages, msg)
272+ msg, err := hydrateMessage(line)
273+ if err != nil {
274+ return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
269275 }
276+ snapshot.Messages = append(snapshot.Messages, msg)
277+
270278 case "compaction":
271- if compacted, err := hydrateCompaction(line); err == nil {
272- messages = compacted
279+ compacted, err := hydrateCompaction(line)
280+ if err != nil {
281+ return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err)
282+ }
283+ snapshot.Messages = compacted
284+ generation++
285+
286+ case "usage":
287+ var row struct {
288+ Usage *provider.Usage `json:"usage"`
289+ Cumulative *provider.Usage `json:"cumulative"`
290+ }
291+ if err := json.Unmarshal(line, &row); err != nil {
292+ return fmt.Errorf("line %d: invalid usage row: %w", lineNo, err)
293+ }
294+ if row.Cumulative == nil {
295+ return fmt.Errorf("line %d: usage row has no cumulative usage", lineNo)
296+ }
297+ rawCheckpoints = append(rawCheckpoints, rawCheckpoint{
298+ messageCount: len(snapshot.Messages),
299+ cumulative: *row.Cumulative,
300+ generation: generation,
301+ })
302+
303+ case "rename":
304+ var row struct {
305+ Title *string `json:"title"`
306+ }
307+ if err := json.Unmarshal(line, &row); err != nil {
308+ return fmt.Errorf("line %d: invalid rename row: %w", lineNo, err)
309+ }
310+ if row.Title == nil {
311+ return fmt.Errorf("line %d: rename row has no title", lineNo)
273312 }
313+
314+ default:
315+ return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
274316 }
275317 return nil
276- }); err != nil {
318+ })
319+ if err != nil {
320+ return SessionSnapshot{}, fmt.Errorf("session snapshot %q: %w", path, err)
321+ }
322+ if !sawMeta {
323+ return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path)
324+ }
325+
326+ // Repair only after compaction replacement. This makes synthetic tool
327+ // results and the removal of orphaned results part of the same indexed
328+ // stream that OpenSession returns and that BranchSession materializes.
329+ rawMessages := snapshot.Messages
330+ repaired := repairSessionMessages(rawMessages)
331+ snapshot.Messages = repaired.messages
332+ for _, checkpoint := range rawCheckpoints {
333+ count := checkpoint.messageCount
334+ if checkpoint.generation == generation {
335+ count = repaired.prefixCount(count)
336+ } else if count > len(rawMessages) {
337+ // A compaction replaces the effective stream. Preserve the
338+ // cumulative cost represented by an older checkpoint at the
339+ // end of the replacement rather than making every post-summary
340+ // branch appear to have zero usage.
341+ count = len(rawMessages)
342+ }
343+ snapshot.UsageCheckpoints = append(snapshot.UsageCheckpoints, SessionUsageCheckpoint{
344+ MessageCount: count,
345+ Cumulative: checkpoint.cumulative,
346+ })
347+ }
348+ return snapshot, nil
349+}
350+
351+// forEachStrictJSONLLine is the complete-read counterpart to
352+// forEachJSONLLine. It rejects malformed and blank rows and reports the
353+// line number so callers cannot accidentally proceed with a partial file.
354+func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error {
355+ br := bufio.NewReader(r)
356+ lineNo := 0
357+ for {
358+ line, err := br.ReadBytes('\n')
359+ if len(line) > 0 {
360+ lineNo++
361+ line = bytes.TrimRight(line, "\r\n")
362+ if len(bytes.TrimSpace(line)) == 0 {
363+ return fmt.Errorf("line %d: blank row", lineNo)
364+ }
365+ if ferr := fn(line, lineNo); ferr != nil {
366+ return ferr
367+ }
368+ }
369+ if err == io.EOF {
370+ return nil
371+ }
372+ if err != nil {
373+ return err
374+ }
375+ }
376+}
377+
378+// repairedSessionMessage keeps the raw message index that produced an
379+// effective message. Synthetic tool results use the assistant's raw index;
380+// this lets usage checkpoints continue to refer to the same effective
381+// message prefix after repair removes or inserts rows.
382+type repairedSessionMessage struct {
383+ message provider.Message
384+ rawIndex int
385+}
386+
387+type repairedSessionMessages struct {
388+ messages []provider.Message
389+ counts []int
390+}
391+
392+func (r repairedSessionMessages) prefixCount(rawCount int) int {
393+ if rawCount < 0 {
394+ return 0
395+ }
396+ if rawCount >= len(r.counts) {
397+ return r.counts[len(r.counts)-1]
398+ }
399+ return r.counts[rawCount]
400+}
401+
402+// repairSessionMessages repairs tool-call pairs and removes orphaned result
403+// blocks while retaining a mapping from raw effective prefixes to repaired
404+// prefixes. A tool result is valid only in the immediately following tool
405+// message for the assistant tool calls; keeping an unrelated result there
406+// would still make a resumed or branched transcript provider-invalid.
407+func repairSessionMessages(msgs []provider.Message) repairedSessionMessages {
408+ if len(msgs) == 0 {
409+ return repairedSessionMessages{counts: []int{0}}
410+ }
411+
412+ repaired := make([]repairedSessionMessage, 0, len(msgs)+2)
413+ for i := 0; i < len(msgs); i++ {
414+ m := msgs[i]
415+ if m.Role == provider.RoleAssistant && messageHasToolCalls(m) {
416+ ids := make(map[string]bool)
417+ for _, c := range m.Content {
418+ if tc, ok := c.(provider.ToolCallBlock); ok {
419+ ids[tc.ID] = true
420+ }
421+ }
422+ repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
423+
424+ if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
425+ tool := msgs[i+1]
426+ seen := make(map[string]bool)
427+ filtered := make([]provider.Content, 0, len(tool.Content)+len(ids))
428+ for _, c := range tool.Content {
429+ tr, ok := c.(provider.ToolResultBlock)
430+ if !ok {
431+ filtered = append(filtered, c)
432+ continue
433+ }
434+ if !ids[tr.CallID] || seen[tr.CallID] {
435+ continue
436+ }
437+ seen[tr.CallID] = true
438+ filtered = append(filtered, tr)
439+ }
440+ for _, c := range m.Content {
441+ if tc, ok := c.(provider.ToolCallBlock); ok && !seen[tc.ID] {
442+ filtered = append(filtered, provider.ToolResultBlock{
443+ CallID: tc.ID,
444+ Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}},
445+ IsError: true,
446+ })
447+ }
448+ }
449+ if len(filtered) > 0 {
450+ tool.Content = filtered
451+ repaired = append(repaired, repairedSessionMessage{message: tool, rawIndex: i + 1})
452+ }
453+ i++
454+ continue
455+ }
456+
457+ stubs := make([]provider.Content, 0, len(ids))
458+ for _, c := range m.Content {
459+ if tc, ok := c.(provider.ToolCallBlock); ok {
460+ stubs = append(stubs, provider.ToolResultBlock{
461+ CallID: tc.ID,
462+ Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}},
463+ IsError: true,
464+ })
465+ }
466+ }
467+ repaired = append(repaired, repairedSessionMessage{message: provider.Message{
468+ Role: provider.RoleTool,
469+ Content: stubs,
470+ Time: m.Time,
471+ }, rawIndex: i})
472+ continue
473+ }
474+
475+ if m.Role == provider.RoleTool {
476+ // Deferred-tool activation rows intentionally carry a result
477+ // without a preceding assistant call. Preserve those rows and
478+ // their AddedToolNames marker; they are consumed as local
479+ // transcript state rather than sent as a provider tool pair.
480+ if len(m.AddedToolNames) > 0 {
481+ repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
482+ continue
483+ }
484+ // This tool row was not immediately paired with an assistant
485+ // tool-call row. Drop its result blocks; a bare tool row is an
486+ // orphan and is not safe to send to a provider.
487+ filtered := make([]provider.Content, 0, len(m.Content))
488+ for _, c := range m.Content {
489+ if _, ok := c.(provider.ToolResultBlock); !ok {
490+ filtered = append(filtered, c)
491+ }
492+ }
493+ if len(filtered) > 0 {
494+ m.Content = filtered
495+ repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
496+ }
497+ continue
498+ }
499+ repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
500+ }
501+
502+ out := repairedSessionMessages{
503+ messages: make([]provider.Message, 0, len(repaired)),
504+ counts: make([]int, len(msgs)+1),
505+ }
506+ for _, item := range repaired {
507+ out.messages = append(out.messages, item.message)
508+ }
509+ // rawIndex values are produced in source order. Accumulate the number of
510+ // repaired messages before each raw prefix in one forward pass instead of
511+ // revisiting every remaining prefix for every repaired message.
512+ repairedIndex := 0
513+ for rawCount := range out.counts {
514+ for repairedIndex < len(repaired) && repaired[repairedIndex].rawIndex < rawCount {
515+ repairedIndex++
516+ }
517+ out.counts[rawCount] = repairedIndex
518+ }
519+ return out
520+}
521+
522+func messageHasToolCalls(msg provider.Message) bool {
523+ for _, content := range msg.Content {
524+ if _, ok := content.(provider.ToolCallBlock); ok {
525+ return true
526+ }
527+ }
528+ return false
529+}
530+
531+// OpenSession opens an existing session for appending.
532+func OpenSession(path string) (*Session, []provider.Message, error) {
533+ snapshot, err := ReadSessionSnapshot(path)
534+ if err != nil {
277535 return nil, nil, err
278536 }
279- messages = repairToolUseResultPairs(messages)
280537 out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
281538 if err != nil {
282539 return nil, nil, err
283540 }
284- s := &Session{ID: meta.ID, Path: path, Meta: meta, writer: out, buf: bufio.NewWriter(out)}
285- return s, messages, nil
541+ s := &Session{
542+ ID: snapshot.Meta.ID,
543+ Path: path,
544+ Meta: snapshot.Meta,
545+ writer: out,
546+ buf: bufio.NewWriter(out),
547+ }
548+ return s, snapshot.Messages, nil
286549 }
287550
288551 // repairToolUseResultPairs walks a restored transcript and
@@ -311,68 +574,7 @@ func OpenSession(path string) (*Session, []provider.Message, error) {
311574 //
312575 // Runs once per OpenSession call. No cost on the hot path.
313576 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
577+ return repairSessionMessages(msgs).messages
376578 }
377579
378580 // LatestSession returns the most recent session file for cwd, or "".
@@ -425,8 +627,8 @@ func DeleteSession(path string) error {
425627 }
426628
427629 // 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.
630+// cwd, newest first. It uses the effective snapshot so message counts,
631+// metadata, and usage agree with resume and branching behavior.
430632 func DescribeSessions(root, cwd string) []SessionSummary {
431633 paths := ListSessions(root, cwd)
432634 summaries := make([]SessionSummary, 0, len(paths))
@@ -470,73 +672,60 @@ func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[
470672
471673 func describeSession(path string) SessionSummary {
472674 s := SessionSummary{Path: path}
473- f, err := os.Open(path)
675+ snapshot, err := ReadSessionSnapshot(path)
474676 if err != nil {
475677 return s
476678 }
477- defer f.Close()
478- isBranch := false
479- forkPoint := 0
679+ s.Started = snapshot.Meta.Started
680+ s.Model = snapshot.Meta.Model
681+ s.Provider = snapshot.Meta.Provider
682+ s.Title = snapshot.Meta.Title
683+ s.MessageCount = len(snapshot.Messages)
684+ s.FirstUserText = firstUserTextFromMessages(snapshot.Messages)
685+ lastUser := lastUserText(snapshot.Messages)
686+ isBranch := snapshot.Meta.Parent != ""
687+ forkPoint := snapshot.Meta.ForkPoint
688+ branchPrompt := firstUserTextFromMessagesAfter(snapshot.Messages, forkPoint)
689+ if len(snapshot.UsageCheckpoints) > 0 {
690+ s.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
691+ }
692+
693+ // Rename rows are append-only and are intentionally kept out of the
694+ // effective transcript. Scan only their placement so generated branch
695+ // titles can still be replaced by the first post-fork prompt while an
696+ // explicit rename after divergence keeps priority.
480697 messageIdx := 0
481- branchPrompt := ""
482- lastUser := ""
483698 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
699+ _ = func() error {
700+ f, err := os.Open(path)
701+ if err != nil {
702+ return err
488703 }
489- switch head.Type {
490- case "meta":
491- var row struct {
492- Meta SessionMeta `json:"meta"`
493- }
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
704+ defer f.Close()
705+ return forEachJSONLLine(f, func(line []byte) error {
706+ var head sessionLineHead
707+ if err := json.Unmarshal(line, &head); err != nil {
708+ return nil
501709 }
502- case "message":
503- if text := firstUserText(line); text != "" {
504- lastUser = text
505- if s.FirstUserText == "" {
506- s.FirstUserText = text
710+ switch head.Type {
711+ case "message":
712+ messageIdx++
713+ case "compaction":
714+ if compacted, err := hydrateCompaction(line); err == nil {
715+ messageIdx = len(compacted)
507716 }
508- if isBranch && messageIdx >= forkPoint && branchPrompt == "" {
509- branchPrompt = text
717+ case "rename":
718+ var row struct {
719+ Title string `json:"title"`
720+ }
721+ if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
722+ s.Title = row.Title
723+ renameMessageIdx = messageIdx
510724 }
511725 }
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- })
726+ return nil
727+ })
728+ }()
540729 if isBranch {
541730 // A rename written after the branch has diverged is user intent
542731 // and must win. Older generated branch titles were written at
@@ -552,29 +741,6 @@ func describeSession(path string) SessionSummary {
552741 return s
553742 }
554743
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-
578744 func firstUserTextFromMessages(msgs []provider.Message) string {
579745 return firstUserTextFromMessagesAfter(msgs, 0)
580746 }
@@ -697,6 +863,9 @@ func (s *Session) AppendMessage(m provider.Message) error {
697863 if s == nil {
698864 return nil
699865 }
866+ if len(m.Content) == 0 {
867+ return errors.New("message has no content")
868+ }
700869 if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil {
701870 return err
702871 }
@@ -712,10 +881,14 @@ func (s *Session) AppendCompaction(messages []provider.Message) error {
712881 if s == nil {
713882 return nil
714883 }
715- if err := s.writeLine(sessionLine{Type: "compaction", Messages: messages}); err != nil {
884+ compactionMessages := messages
885+ if err := s.writeLine(sessionLine{Type: "compaction", Messages: &compactionMessages}); err != nil {
716886 return err
717887 }
718- s.messagesAppended = len(messages)
888+ // The compaction row itself is meaningful even when it replaces the
889+ // transcript with an empty or nil slice. Count the append operation so a
890+ // fresh session containing an empty checkpoint is not deleted on Close.
891+ s.messagesAppended++
719892 return nil
720893 }
721894
@@ -726,9 +899,15 @@ func (s *Session) UpdateModel(providerName, model string) error {
726899 if s == nil {
727900 return nil
728901 }
902+ oldProvider, oldModel := s.Meta.Provider, s.Meta.Model
729903 s.Meta.Provider = providerName
730904 s.Meta.Model = model
731- return s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta})
905+ if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
906+ s.Meta.Provider = oldProvider
907+ s.Meta.Model = oldModel
908+ return err
909+ }
910+ return nil
732911 }
733912
734913 // AppendUsage writes a usage row to the session.
@@ -784,17 +963,27 @@ func (s *Session) writeLine(row sessionLine) error {
784963
785964 func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {
786965 var row struct {
787- Messages []json.RawMessage `json:"messages"`
966+ Messages json.RawMessage `json:"messages"`
788967 }
789968 if err := json.Unmarshal(lineBytes, &row); err != nil {
790969 return nil, err
791970 }
792- messages := make([]provider.Message, 0, len(row.Messages))
793- for _, raw := range row.Messages {
971+ if len(row.Messages) == 0 || bytes.Equal(bytes.TrimSpace(row.Messages), []byte("null")) {
972+ // Older writers omitted the field for an empty compaction and some
973+ // wrote it as null. Both represent a valid empty replacement.
974+ return []provider.Message{}, nil
975+ }
976+ var rawMessages []json.RawMessage
977+ if err := json.Unmarshal(row.Messages, &rawMessages); err != nil {
978+ return nil, fmt.Errorf("invalid messages: %w", err)
979+ }
980+ messages := make([]provider.Message, 0, len(rawMessages))
981+ for idx, raw := range rawMessages {
794982 msg, err := hydrateMessageObject(raw)
795- if err == nil && len(msg.Content) > 0 {
796- messages = append(messages, msg)
983+ if err != nil {
984+ return nil, fmt.Errorf("message %d: %w", idx, err)
797985 }
986+ messages = append(messages, msg)
798987 }
799988 return messages, nil
800989 }
@@ -806,6 +995,9 @@ func hydrateMessage(lineBytes []byte) (provider.Message, error) {
806995 if err := json.Unmarshal(lineBytes, &row); err != nil {
807996 return provider.Message{}, err
808997 }
998+ if len(row.Message) == 0 || bytes.Equal(bytes.TrimSpace(row.Message), []byte("null")) {
999+ return provider.Message{}, fmt.Errorf("message row has no message")
1000+ }
8091001 return hydrateMessageObject(row.Message)
8101002 }
8111003
@@ -820,8 +1012,17 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
8201012 if err := json.Unmarshal(rawMessage, &row); err != nil {
8211013 return provider.Message{}, err
8221014 }
1015+ if row.Role != provider.RoleUser && row.Role != provider.RoleAssistant && row.Role != provider.RoleTool {
1016+ return provider.Message{}, fmt.Errorf("message has invalid role %q", row.Role)
1017+ }
1018+ if len(row.Content) == 0 {
1019+ return provider.Message{}, fmt.Errorf("message has no content")
1020+ }
8231021 msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta, AddedToolNames: row.AddedToolNames}
824- for _, raw := range row.Content {
1022+ for idx, raw := range row.Content {
1023+ if len(bytes.TrimSpace(raw)) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
1024+ return provider.Message{}, fmt.Errorf("content block %d is empty", idx)
1025+ }
8251026 var head struct {
8261027 Text string `json:"text"`
8271028 MimeType string `json:"mime_type"`
@@ -836,7 +1037,14 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
8361037 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError
8371038 }
8381039 if err := json.Unmarshal(raw, &head); err != nil {
839- continue
1040+ return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
1041+ }
1042+ var fields map[string]json.RawMessage
1043+ if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
1044+ if err == nil {
1045+ err = fmt.Errorf("content block is not an object")
1046+ }
1047+ return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
8401048 }
8411049 // Discriminate by presence of fields.
8421050 switch {
@@ -846,7 +1054,10 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
8461054 Summary: head.Summary,
8471055 Encrypted: head.Encrypted,
8481056 })
849- case head.Name != "" && head.ID != "":
1057+ case head.Name != "" || head.ID != "" || fields["arguments"] != nil:
1058+ if head.Name == "" || head.ID == "" {
1059+ return provider.Message{}, fmt.Errorf("content block %d: incomplete tool call", idx)
1060+ }
8501061 var tc struct {
8511062 ID string `json:"id"`
8521063 Name string `json:"name"`
@@ -866,21 +1077,40 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
8661077 Content []json.RawMessage `json:"content"`
8671078 IsError bool `json:"is_error"`
8681079 }
869- _ = json.Unmarshal(raw, &tr)
1080+ if err := json.Unmarshal(raw, &tr); err != nil || tr.Content == nil {
1081+ if err == nil {
1082+ err = fmt.Errorf("tool result has no content")
1083+ }
1084+ return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
1085+ }
8701086 block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError}
871- for _, c := range tr.Content {
1087+ for innerIdx, c := range tr.Content {
8721088 var inner struct {
8731089 Text string `json:"text"`
8741090 MimeType string `json:"mime_type"`
8751091 Data []byte `json:"data"`
8761092 }
877- _ = json.Unmarshal(c, &inner)
1093+ if err := json.Unmarshal(c, &inner); err != nil {
1094+ return provider.Message{}, fmt.Errorf("content block %d result %d: %w", idx, innerIdx, err)
1095+ }
1096+ var innerFields map[string]json.RawMessage
1097+ if err := json.Unmarshal(c, &innerFields); err != nil || innerFields == nil {
1098+ if err == nil {
1099+ err = fmt.Errorf("result content is not an object")
1100+ }
1101+ return provider.Message{}, fmt.Errorf("content block %d result %d: %w", idx, innerIdx, err)
1102+ }
8781103 if inner.MimeType != "" {
8791104 block.Content = append(block.Content, provider.ImageBlock{MimeType: inner.MimeType, Data: inner.Data})
880- } else {
1105+ } else if _, ok := innerFields["text"]; ok {
8811106 block.Content = append(block.Content, provider.TextBlock{Text: inner.Text})
1107+ } else {
1108+ return provider.Message{}, fmt.Errorf("content block %d result %d: unknown content", idx, innerIdx)
8821109 }
8831110 }
1111+ if len(block.Content) == 0 {
1112+ return provider.Message{}, fmt.Errorf("content block %d tool result has no content", idx)
1113+ }
8841114 msg.Content = append(msg.Content, block)
8851115 case head.MimeType != "":
8861116 msg.Content = append(msg.Content, provider.ImageBlock{
@@ -889,6 +1119,9 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
8891119 ThoughtSignature: head.ThoughtSignature,
8901120 })
8911121 default:
1122+ if _, ok := fields["text"]; !ok {
1123+ return provider.Message{}, fmt.Errorf("content block %d: unknown content", idx)
1124+ }
8921125 msg.Content = append(msg.Content, provider.TextBlock{
8931126 Text: head.Text,
8941127 ThoughtSignature: head.ThoughtSignature,
M packages/core/session_portable.go +339 -117
@@ -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)
295+ return "", fmt.Errorf("branch: read parent snapshot: %w", err)
279296 }
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")
297+ if upToMessageIdx > len(snapshot.Messages) {
298+ return "", fmt.Errorf("branch: upToMessageIdx %d exceeds effective message count %d", upToMessageIdx, len(snapshot.Messages))
288299 }
289- var head sessionLine
290- if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
291- return "", fmt.Errorf("branch: parse parent meta: %w", err)
292- }
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,71 @@ 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.
487-func readSessionMeta(path string) (SessionMeta, error) {
671+// scanSessionMeta reads JSONL rows without hydrating transcript content and
672+// returns the latest valid metadata row. Listing and ID lookup only need this
673+// small projection; full snapshot validation remains the responsibility of
674+// resume, export, branching, and tree preflight callers.
675+func scanSessionMeta(path string) (meta SessionMeta, err error) {
488676 f, err := os.Open(path)
489677 if err != nil {
490678 return SessionMeta{}, err
491679 }
492680 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
681+
682+ var sawMeta bool
683+ err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
684+ var head sessionLineHead
685+ if err := json.Unmarshal(line, &head); err != nil {
686+ return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
687+ }
688+ if head.Type == "" {
689+ return fmt.Errorf("line %d: missing row type", lineNo)
690+ }
691+ if !sawMeta && head.Type != "meta" {
692+ return fmt.Errorf("line %d: first row is not meta", lineNo)
693+ }
694+ switch head.Type {
695+ case "meta":
696+ var row struct {
697+ Meta *SessionMeta `json:"meta"`
698+ }
699+ if err := json.Unmarshal(line, &row); err != nil {
700+ return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
701+ }
702+ if row.Meta == nil || row.Meta.ID == "" {
703+ return fmt.Errorf("line %d: meta row has no session id", lineNo)
704+ }
705+ meta = *row.Meta
706+ sawMeta = true
707+ case "message", "compaction", "usage", "rename":
708+ // These rows are intentionally not hydrated here.
709+ default:
710+ return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
711+ }
712+ return nil
713+ })
714+ if err != nil {
715+ return SessionMeta{}, fmt.Errorf("session metadata %q: %w", path, err)
501716 }
502- if line.Type != "meta" || line.Meta == nil {
503- return SessionMeta{}, errors.New("first line is not meta")
717+ if !sawMeta {
718+ return SessionMeta{}, fmt.Errorf("session metadata %q: file is empty", path)
504719 }
505- return *line.Meta, nil
720+ return meta, nil
721+}
722+
723+// readSessionMeta returns the latest validated metadata for path without
724+// reconstructing its transcript. Full snapshot validation is performed by
725+// callers that need messages rather than listing metadata.
726+func readSessionMeta(path string) (SessionMeta, error) {
727+ return scanSessionMeta(path)
506728 }
507729
508730 // FindSessionByID looks up a session file in root/cwd whose meta id
@@ -519,7 +741,7 @@ func FindSessionByID(root, cwd, id string) string {
519741 continue
520742 }
521743 path := filepath.Join(dir, e.Name())
522- meta, err := readSessionMeta(path)
744+ meta, err := scanSessionMeta(path)
523745 if err != nil {
524746 continue
525747 }
M packages/core/session_portable_test.go +78 -3
@@ -82,9 +82,84 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
8282 }
8383 }
8484
85-// TestExportToFilePath writes to an explicit file path (no
86-// directory guessing) and checks the .zotsession extension is
87-// appended when missing.
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+
129+// TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages verifies that
130+// metadata lookup follows append-only model updates without reconstructing the
131+// transcript.
132+func TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages(t *testing.T) {
133+ root := t.TempDir()
134+ session, err := NewSession(root, "/workspace", "old-provider", "old-model", "test")
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if err := session.AppendMessage(provider.Message{
139+ Role: provider.RoleUser,
140+ Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("large transcript ", 1000)}},
141+ }); err != nil {
142+ t.Fatal(err)
143+ }
144+ if err := session.UpdateModel("new-provider", "new-model"); err != nil {
145+ t.Fatal(err)
146+ }
147+ path := session.Path
148+ if err := session.Close(); err != nil {
149+ t.Fatal(err)
150+ }
151+
152+ meta, err := scanSessionMeta(path)
153+ if err != nil {
154+ t.Fatal(err)
155+ }
156+ if meta.Provider != "new-provider" || meta.Model != "new-model" {
157+ t.Fatalf("scanned metadata = %q/%q, want latest values", meta.Provider, meta.Model)
158+ }
159+}
160+
161+// TestExportToFilePath writes to an explicit file path (no directory
162+// guessing) and checks the .zotsession extension is appended when missing.
88163 func TestExportToFilePath(t *testing.T) {
89164 root := t.TempDir()
90165 sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
A packages/core/session_snapshot_test.go +628 -0
@@ -0,0 +1,628 @@
1+package core
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+func TestAppendMessageRejectsEmptyContent(t *testing.T) {
15+ session, err := NewSession(t.TempDir(), "/workspace", "provider", "model", "test")
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ if err := session.AppendMessage(provider.Message{Role: provider.RoleUser}); err == nil {
20+ t.Fatal("empty message content was accepted")
21+ }
22+ if err := session.Close(); err != nil {
23+ t.Fatal(err)
24+ }
25+}
26+
27+func TestReadSessionSnapshotUsesLatestCompactionAndRepairsToolPairs(t *testing.T) {
28+ root := t.TempDir()
29+ session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := session.AppendMessage(textMessage(provider.RoleUser, "old history")); err != nil {
34+ t.Fatal(err)
35+ }
36+ if err := session.AppendCompaction([]provider.Message{
37+ textMessage(provider.RoleAssistant, "summary"),
38+ {Role: provider.RoleAssistant, Content: []provider.Content{
39+ provider.ToolCallBlock{ID: "call-1", Name: "read", Arguments: []byte(`{"path":"/tmp/file"}`)},
40+ }},
41+ }); err != nil {
42+ t.Fatal(err)
43+ }
44+ if err := session.AppendUsage(provider.Usage{InputTokens: 10}, provider.Usage{InputTokens: 10}); err != nil {
45+ t.Fatal(err)
46+ }
47+ path := session.Path
48+ if err := session.Close(); err != nil {
49+ t.Fatal(err)
50+ }
51+
52+ snapshot, err := ReadSessionSnapshot(path)
53+ if err != nil {
54+ t.Fatalf("ReadSessionSnapshot: %v", err)
55+ }
56+ if snapshot.Meta.ID == "" {
57+ t.Fatal("snapshot lost session metadata")
58+ }
59+ assertMessageTexts(t, snapshot.Messages, []string{"summary", "", ""})
60+ stub := snapshot.Messages[2].Content[0].(provider.ToolResultBlock).Content[0].(provider.TextBlock)
61+ if stub.Text != "tool call was aborted; no result recorded." {
62+ t.Errorf("repaired result text = %q", stub.Text)
63+ }
64+ if got := snapshot.Messages[1].Role; got != provider.RoleAssistant {
65+ t.Fatalf("repaired transcript message 1 role = %q, want assistant", got)
66+ }
67+ if got := snapshot.Messages[2].Role; got != provider.RoleTool {
68+ t.Fatalf("repaired transcript message 2 role = %q, want tool", got)
69+ }
70+ if got := snapshot.Messages[2].Content[0].(provider.ToolResultBlock).CallID; got != "call-1" {
71+ t.Errorf("repaired result call id = %q, want call-1", got)
72+ }
73+ if len(snapshot.UsageCheckpoints) != 1 {
74+ t.Fatalf("usage checkpoints = %d, want 1", len(snapshot.UsageCheckpoints))
75+ }
76+ if got := snapshot.UsageCheckpoints[0].MessageCount; got != 3 {
77+ t.Fatalf("usage checkpoint message count = %d, want repaired count 3", got)
78+ }
79+ if got := snapshot.UsageCheckpoints[0].Cumulative.InputTokens; got != 10 {
80+ t.Errorf("checkpoint cumulative input = %d, want 10", got)
81+ }
82+
83+ opened, messages, err := OpenSession(path)
84+ if err != nil {
85+ t.Fatalf("OpenSession: %v", err)
86+ }
87+ defer opened.Close()
88+ if len(messages) != len(snapshot.Messages) {
89+ t.Fatalf("OpenSession messages = %d, snapshot messages = %d", len(messages), len(snapshot.Messages))
90+ }
91+}
92+
93+func TestReadSessionSnapshotMergesExistingToolResultWithoutDuplicateRepair(t *testing.T) {
94+ root := t.TempDir()
95+ session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ if err := session.AppendMessage(provider.Message{
100+ Role: provider.RoleAssistant,
101+ Content: []provider.Content{
102+ provider.ToolCallBlock{ID: "call-1", Name: "read"},
103+ provider.ToolCallBlock{ID: "call-2", Name: "bash"},
104+ },
105+ }); err != nil {
106+ t.Fatal(err)
107+ }
108+ if err := session.AppendMessage(provider.Message{
109+ Role: provider.RoleTool,
110+ Content: []provider.Content{provider.ToolResultBlock{
111+ CallID: "call-1",
112+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
113+ }},
114+ }); err != nil {
115+ t.Fatal(err)
116+ }
117+ path := session.Path
118+ if err := session.Close(); err != nil {
119+ t.Fatal(err)
120+ }
121+
122+ snapshot, err := ReadSessionSnapshot(path)
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ if len(snapshot.Messages) != 2 {
127+ t.Fatalf("messages = %d, want existing tool row plus assistant row", len(snapshot.Messages))
128+ }
129+ tool := snapshot.Messages[1]
130+ if len(tool.Content) != 2 {
131+ t.Fatalf("tool result blocks = %d, want 2", len(tool.Content))
132+ }
133+ if got := tool.Content[0].(provider.ToolResultBlock).CallID; got != "call-1" {
134+ t.Errorf("existing result call id = %q", got)
135+ }
136+ if got := tool.Content[1].(provider.ToolResultBlock).CallID; got != "call-2" {
137+ t.Errorf("repaired result call id = %q", got)
138+ }
139+}
140+
141+func TestReadSessionSnapshotRecordsCheckpointsAtEffectiveBoundaries(t *testing.T) {
142+ root := t.TempDir()
143+ session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
144+ if err != nil {
145+ t.Fatal(err)
146+ }
147+ _ = session.AppendMessage(textMessage(provider.RoleUser, "one"))
148+ _ = session.AppendUsage(provider.Usage{InputTokens: 3}, provider.Usage{InputTokens: 3})
149+ _ = session.AppendMessage(textMessage(provider.RoleAssistant, "two"))
150+ _ = session.AppendUsage(provider.Usage{InputTokens: 4}, provider.Usage{InputTokens: 7})
151+ _ = session.AppendCompaction([]provider.Message{textMessage(provider.RoleAssistant, "compact")})
152+ _ = session.AppendMessage(textMessage(provider.RoleUser, "tail"))
153+ _ = session.AppendUsage(provider.Usage{InputTokens: 2}, provider.Usage{InputTokens: 9})
154+ path := session.Path
155+ if err := session.Close(); err != nil {
156+ t.Fatal(err)
157+ }
158+
159+ snapshot, err := ReadSessionSnapshot(path)
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ assertMessageTexts(t, snapshot.Messages, []string{"compact", "tail"})
164+ if len(snapshot.UsageCheckpoints) != 3 {
165+ t.Fatalf("checkpoints = %d, want 3", len(snapshot.UsageCheckpoints))
166+ }
167+ wantCounts := []int{1, 2, 2}
168+ wantCosts := []int{3, 7, 9}
169+ for i, checkpoint := range snapshot.UsageCheckpoints {
170+ if checkpoint.MessageCount != wantCounts[i] {
171+ t.Errorf("checkpoint %d count = %d, want %d", i, checkpoint.MessageCount, wantCounts[i])
172+ }
173+ if checkpoint.Cumulative.InputTokens != wantCosts[i] {
174+ t.Errorf("checkpoint %d input = %d, want %d", i, checkpoint.Cumulative.InputTokens, wantCosts[i])
175+ }
176+ }
177+}
178+
179+func TestReadSessionSnapshotRejectsMalformedRows(t *testing.T) {
180+ meta := SessionMeta{ID: "session-1", CWD: "/workspace", Started: time.Now().UTC()}
181+ metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &meta})
182+ if err != nil {
183+ t.Fatal(err)
184+ }
185+ cases := []string{
186+ string(metaLine) + "\nnot-json\n",
187+ string(metaLine) + `
188+{"type":"message","message":{"role":"user"}}
189+`,
190+ string(metaLine) + `
191+{"type":"usage","cumulative":null}
192+`,
193+ string(metaLine) + `
194+{"type":"future-row"}
195+`,
196+ }
197+ for idx, contents := range cases {
198+ path := filepath.Join(t.TempDir(), "session.jsonl")
199+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
200+ t.Fatal(err)
201+ }
202+ if _, err := ReadSessionSnapshot(path); err == nil {
203+ t.Errorf("case %d: ReadSessionSnapshot returned nil error", idx)
204+ }
205+ if _, _, err := OpenSession(path); err == nil {
206+ t.Errorf("case %d: OpenSession returned nil error", idx)
207+ }
208+ }
209+}
210+
211+func TestReadSessionSnapshotKeepsOldOptionalMetadataReadable(t *testing.T) {
212+ path := filepath.Join(t.TempDir(), "old.jsonl")
213+ contents := `{"type":"meta","meta":{"id":"old-id","cwd":"/workspace","model":"claude","provider":"anthropic"}}
214+{"type":"message","message":{"role":"user","content":[{"text":"hello"}]}}
215+`
216+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
217+ t.Fatal(err)
218+ }
219+ snapshot, err := ReadSessionSnapshot(path)
220+ if err != nil {
221+ t.Fatal(err)
222+ }
223+ if snapshot.Meta.Parent != "" || snapshot.Meta.HideFromSessions {
224+ t.Fatalf("old optional metadata unexpectedly populated: %+v", snapshot.Meta)
225+ }
226+ assertMessageTexts(t, snapshot.Messages, []string{"hello"})
227+}
228+
229+func TestBranchAfterCompactionRetainsPreCompactionUsageAtReplacementBoundary(t *testing.T) {
230+ root := t.TempDir()
231+ parent, err := NewSession(root, "/workspace", "test", "model", "test")
232+ if err != nil {
233+ t.Fatal(err)
234+ }
235+ for _, text := range []string{"one", "two", "three"} {
236+ _ = parent.AppendMessage(textMessage(provider.RoleUser, text))
237+ }
238+ _ = parent.AppendUsage(provider.Usage{InputTokens: 10}, provider.Usage{InputTokens: 10})
239+ _ = parent.AppendCompaction([]provider.Message{textMessage(provider.RoleAssistant, "summary")})
240+ path := parent.Path
241+ if err := parent.Close(); err != nil {
242+ t.Fatal(err)
243+ }
244+ branchPath, err := BranchSessionHidden(path, root, "/workspace", "test", 1)
245+ if err != nil {
246+ t.Fatal(err)
247+ }
248+ usage, err := SessionUsage(branchPath)
249+ if err != nil {
250+ t.Fatal(err)
251+ }
252+ if usage.InputTokens != 10 {
253+ t.Fatalf("branch usage input = %d, want pre-compaction cumulative usage", usage.InputTokens)
254+ }
255+}
256+
257+func TestBranchSessionUsesSharedSnapshotAndPreservesUsage(t *testing.T) {
258+ root := t.TempDir()
259+ parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
260+ if err != nil {
261+ t.Fatal(err)
262+ }
263+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "first"))
264+ _ = parent.AppendUsage(provider.Usage{InputTokens: 11}, provider.Usage{InputTokens: 11})
265+ _ = parent.AppendMessage(textMessage(provider.RoleAssistant, "reply"))
266+ _ = parent.AppendUsage(provider.Usage{InputTokens: 12}, provider.Usage{InputTokens: 23})
267+ parentPath := parent.Path
268+ if err := parent.Close(); err != nil {
269+ t.Fatal(err)
270+ }
271+
272+ branchPath, err := BranchSessionHidden(parentPath, root, "/workspace", "test", 1)
273+ if err != nil {
274+ t.Fatalf("BranchSessionHidden: %v", err)
275+ }
276+ branch, messages, err := OpenSession(branchPath)
277+ if err != nil {
278+ t.Fatal(err)
279+ }
280+ branch.Close()
281+ assertMessageTexts(t, messages, []string{"first"})
282+ if branch.Meta.ForkPoint != 1 {
283+ t.Errorf("fork point = %d, want 1", branch.Meta.ForkPoint)
284+ }
285+ if !branch.Meta.HideFromSessions {
286+ t.Error("hidden branch did not set HideFromSessions")
287+ }
288+ usage, err := SessionUsage(branchPath)
289+ if err != nil {
290+ t.Fatal(err)
291+ }
292+ if usage.InputTokens != 11 {
293+ t.Errorf("branch usage input = %d, want 11", usage.InputTokens)
294+ }
295+
296+ if _, err := BranchSession(parentPath, root, "/workspace", "test", 99); err == nil {
297+ t.Error("out-of-range fork returned nil error")
298+ }
299+}
300+
301+func TestBranchSessionRepairsToolBoundaryAndSupportsZeroAndEnd(t *testing.T) {
302+ root := t.TempDir()
303+ parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "before"))
308+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{
309+ provider.ToolCallBlock{ID: "call-1", Name: "read"},
310+ }})
311+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleTool, Content: []provider.Content{
312+ provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "result"}}},
313+ }})
314+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "after"))
315+ path := parent.Path
316+ if err := parent.Close(); err != nil {
317+ t.Fatal(err)
318+ }
319+
320+ toolBranchPath, err := BranchSession(parent.Path, root, "/workspace", "test", 2)
321+ if err != nil {
322+ t.Fatal(err)
323+ }
324+ toolBranch, toolMessages, err := OpenSession(toolBranchPath)
325+ if err != nil {
326+ t.Fatal(err)
327+ }
328+ toolBranch.Close()
329+ if len(toolMessages) != 3 {
330+ t.Fatalf("tool boundary branch messages = %d, want 3", len(toolMessages))
331+ }
332+ if toolBranch.Meta.ForkPoint != 3 {
333+ t.Errorf("tool boundary fork point = %d, want 3", toolBranch.Meta.ForkPoint)
334+ }
335+
336+ zeroPath, err := BranchSessionHidden(path, root, "/workspace", "test", 0)
337+ if err != nil {
338+ t.Fatal(err)
339+ }
340+ zero, zeroMessages, err := OpenSession(zeroPath)
341+ if err != nil {
342+ t.Fatal(err)
343+ }
344+ zero.Close()
345+ if len(zeroMessages) != 0 || zero.Meta.ForkPoint != 0 {
346+ t.Errorf("zero branch = %d messages, fork %d", len(zeroMessages), zero.Meta.ForkPoint)
347+ }
348+
349+ endPath, err := BranchSession(path, root, "/workspace", "test", 4)
350+ if err != nil {
351+ t.Fatal(err)
352+ }
353+ end, endMessages, err := OpenSession(endPath)
354+ if err != nil {
355+ t.Fatal(err)
356+ }
357+ end.Close()
358+ if len(endMessages) != 4 || end.Meta.ForkPoint != 4 {
359+ t.Errorf("end branch = %d messages, fork %d", len(endMessages), end.Meta.ForkPoint)
360+ }
361+}
362+
363+func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) {
364+ root := t.TempDir()
365+ parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
366+ if err != nil {
367+ t.Fatal(err)
368+ }
369+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "root"))
370+ parentPath := parent.Path
371+ parentID := parent.ID
372+ if err := parent.Close(); err != nil {
373+ t.Fatal(err)
374+ }
375+ childPath, err := BranchSessionHidden(parentPath, root, "/workspace", "test", 1)
376+ if err != nil {
377+ t.Fatal(err)
378+ }
379+ if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath {
380+ t.Fatalf("DescribeSessions = %+v, want only parent", got)
381+ }
382+ tree := BuildSessionTree(root, "/workspace")
383+ if len(tree) != 1 || tree[0].Meta.ID != parentID {
384+ t.Fatalf("tree roots = %+v, want parent root", tree)
385+ }
386+ if len(tree[0].Children) != 1 || tree[0].Children[0].Summary.Path != childPath {
387+ t.Fatalf("tree children = %+v, want hidden child", tree[0].Children)
388+ }
389+}
390+
391+func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) {
392+ root := t.TempDir()
393+ cwd := "/workspace/strict-tree"
394+ parent, err := NewSession(root, cwd, "test", "model", "test")
395+ if err != nil {
396+ t.Fatal(err)
397+ }
398+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "parent"))
399+ parentPath := parent.Path
400+ if err := parent.Close(); err != nil {
401+ t.Fatal(err)
402+ }
403+ badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl")
404+ if err := os.WriteFile(badPath, []byte("{not-json}\n"), 0o644); err != nil {
405+ t.Fatal(err)
406+ }
407+ if _, err := BuildSessionTreeStrict(root, cwd); err == nil {
408+ t.Fatal("BuildSessionTreeStrict accepted a malformed session member")
409+ }
410+ if _, err := os.Stat(parentPath); err != nil {
411+ t.Fatalf("valid parent disappeared: %v", err)
412+ }
413+}
414+
415+func TestSessionUsageDetailUsesCurrentCacheAfterCounterReset(t *testing.T) {
416+ root := t.TempDir()
417+ session, err := NewSession(root, "/workspace", "anthropic", "model", "test")
418+ if err != nil {
419+ t.Fatal(err)
420+ }
421+ if err := session.AppendMessage(textMessage(provider.RoleUser, "prompt")); err != nil {
422+ t.Fatal(err)
423+ }
424+ if err := session.AppendUsage(provider.Usage{}, provider.Usage{
425+ InputTokens: 10,
426+ OutputTokens: 4,
427+ ReasoningTokens: 2,
428+ ReasoningTokensKnown: true,
429+ CacheReadTokens: 100,
430+ CacheWriteTokens: 40,
431+ CostUSD: 0.5,
432+ }); err != nil {
433+ t.Fatal(err)
434+ }
435+ if err := session.AppendUsage(provider.Usage{}, provider.Usage{
436+ InputTokens: 13,
437+ OutputTokens: 7,
438+ ReasoningTokens: 3,
439+ ReasoningTokensKnown: true,
440+ CacheReadTokens: 6,
441+ CacheWriteTokens: 2,
442+ CostUSD: 0.7,
443+ }); err != nil {
444+ t.Fatal(err)
445+ }
446+ path := session.Path
447+ if err := session.Close(); err != nil {
448+ t.Fatal(err)
449+ }
450+
451+ cumulative, last, err := SessionUsageDetail(path)
452+ if err != nil {
453+ t.Fatal(err)
454+ }
455+ if cumulative.CacheReadTokens != 6 || cumulative.CacheWriteTokens != 2 {
456+ t.Fatalf("cumulative cache = (%d, %d), want (6, 2)", cumulative.CacheReadTokens, cumulative.CacheWriteTokens)
457+ }
458+ if last.InputTokens != 3 || last.OutputTokens != 3 || last.ReasoningTokens != 1 {
459+ t.Fatalf("last token delta = %+v, want input=3 output=3 reasoning=1", last)
460+ }
461+ if last.CacheReadTokens != 6 || last.CacheWriteTokens != 2 {
462+ t.Fatalf("last cache = (%d, %d), want current checkpoint values (6, 2)", last.CacheReadTokens, last.CacheWriteTokens)
463+ }
464+ if !last.ReasoningTokensKnown {
465+ t.Fatal("last-turn reasoning-token known flag was lost")
466+ }
467+}
468+
469+func TestSnapshotRemovesOrphanedToolResultsAndMapsCheckpointCount(t *testing.T) {
470+ root := t.TempDir()
471+ session, err := NewSession(root, "/workspace", "anthropic", "model", "test")
472+ if err != nil {
473+ t.Fatal(err)
474+ }
475+ _ = session.AppendMessage(textMessage(provider.RoleUser, "prompt"))
476+ _ = session.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{
477+ provider.ToolCallBlock{ID: "call-1", Name: "read"},
478+ }})
479+ _ = session.AppendMessage(provider.Message{Role: provider.RoleTool, Content: []provider.Content{
480+ provider.ToolResultBlock{CallID: "orphan", Content: []provider.Content{provider.TextBlock{Text: "stale"}}},
481+ }})
482+ _ = session.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 9})
483+ path := session.Path
484+ if err := session.Close(); err != nil {
485+ t.Fatal(err)
486+ }
487+
488+ snapshot, err := ReadSessionSnapshot(path)
489+ if err != nil {
490+ t.Fatal(err)
491+ }
492+ if len(snapshot.Messages) != 3 {
493+ t.Fatalf("repaired messages = %d, want 3", len(snapshot.Messages))
494+ }
495+ tool := snapshot.Messages[2]
496+ if len(tool.Content) != 1 {
497+ t.Fatalf("repaired tool blocks = %d, want synthetic result only", len(tool.Content))
498+ }
499+ result, ok := tool.Content[0].(provider.ToolResultBlock)
500+ if !ok || result.CallID != "call-1" {
501+ t.Fatalf("repaired result = %#v, want call-1", tool.Content[0])
502+ }
503+ if snapshot.UsageCheckpoints[0].MessageCount != 3 {
504+ t.Fatalf("checkpoint message count = %d, want repaired count 3", snapshot.UsageCheckpoints[0].MessageCount)
505+ }
506+
507+ branchPath, err := BranchSessionHidden(path, root, "/workspace", "test", len(snapshot.Messages))
508+ if err != nil {
509+ t.Fatal(err)
510+ }
511+ _, branchMessages, err := OpenSession(branchPath)
512+ if err != nil {
513+ t.Fatal(err)
514+ }
515+ if len(branchMessages) != 3 {
516+ t.Fatalf("branch messages = %d, want 3", len(branchMessages))
517+ }
518+ branchResult := branchMessages[2].Content[0].(provider.ToolResultBlock)
519+ if branchResult.CallID != "call-1" {
520+ t.Fatalf("branch kept orphan result call id %q", branchResult.CallID)
521+ }
522+}
523+
524+func TestAppendCompactionEmptyAndHistoricalNullRowsRoundTrip(t *testing.T) {
525+ root := t.TempDir()
526+ session, err := NewSession(root, "/workspace", "anthropic", "model", "test")
527+ if err != nil {
528+ t.Fatal(err)
529+ }
530+ _ = session.AppendMessage(textMessage(provider.RoleUser, "before"))
531+ if err := session.AppendCompaction(nil); err != nil {
532+ t.Fatal(err)
533+ }
534+ if err := session.AppendCompaction([]provider.Message{}); err != nil {
535+ t.Fatal(err)
536+ }
537+ path := session.Path
538+ if err := session.Close(); err != nil {
539+ t.Fatal(err)
540+ }
541+ if _, err := os.Stat(path); err != nil {
542+ t.Fatalf("empty compaction session was removed: %v", err)
543+ }
544+ snapshot, err := ReadSessionSnapshot(path)
545+ if err != nil {
546+ t.Fatal(err)
547+ }
548+ if len(snapshot.Messages) != 0 {
549+ t.Fatalf("empty compaction messages = %d, want 0", len(snapshot.Messages))
550+ }
551+
552+ meta := SessionMeta{ID: "historical-empty", CWD: "/workspace"}
553+ metaLine, _ := json.Marshal(sessionLine{Type: "meta", Meta: &meta})
554+ for _, compaction := range []string{`{"type":"compaction"}`, `{"type":"compaction","messages":null}`} {
555+ historicalPath := filepath.Join(t.TempDir(), "historical.jsonl")
556+ contents := string(metaLine) + "\n" + compaction + "\n"
557+ if err := os.WriteFile(historicalPath, []byte(contents), 0o644); err != nil {
558+ t.Fatal(err)
559+ }
560+ historical, err := ReadSessionSnapshot(historicalPath)
561+ if err != nil {
562+ t.Fatalf("historical %s: %v", compaction, err)
563+ }
564+ if len(historical.Messages) != 0 {
565+ t.Fatalf("historical %s messages = %d, want 0", compaction, len(historical.Messages))
566+ }
567+ }
568+}
569+
570+func TestBranchPreservesCumulativeCheckpointsForLastTurnDelta(t *testing.T) {
571+ root := t.TempDir()
572+ parent, err := NewSession(root, "/workspace", "anthropic", "model", "test")
573+ if err != nil {
574+ t.Fatal(err)
575+ }
576+ _ = parent.AppendMessage(textMessage(provider.RoleUser, "first"))
577+ _ = parent.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 10, CacheReadTokens: 4})
578+ _ = parent.AppendMessage(textMessage(provider.RoleAssistant, "second"))
579+ _ = parent.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 25, CacheReadTokens: 7})
580+ parentPath := parent.Path
581+ if err := parent.Close(); err != nil {
582+ t.Fatal(err)
583+ }
584+
585+ branchPath, err := BranchSession(parentPath, root, "/workspace", "test", 2)
586+ if err != nil {
587+ t.Fatal(err)
588+ }
589+ cumulative, last, err := SessionUsageDetail(branchPath)
590+ if err != nil {
591+ t.Fatal(err)
592+ }
593+ if cumulative.InputTokens != 25 || cumulative.CacheReadTokens != 7 {
594+ t.Fatalf("branch cumulative = %+v, want input=25 cache_read=7", cumulative)
595+ }
596+ if last.InputTokens != 15 || last.CacheReadTokens != 3 {
597+ t.Fatalf("branch last turn = %+v, want input=15 cache_read=3", last)
598+ }
599+}
600+
601+func textMessage(role provider.Role, text string) provider.Message {
602+ return provider.Message{Role: role, Content: []provider.Content{provider.TextBlock{Text: text}}}
603+}
604+
605+func TestSnapshotMessagesDoNotExposeMutableRawCompactionState(t *testing.T) {
606+ // This is a small API contract check: callers can safely materialize a
607+ // branch from the returned slice without changing the source file.
608+ root := t.TempDir()
609+ session, err := NewSession(root, "/workspace", "anthropic", "claude", "test")
610+ if err != nil {
611+ t.Fatal(err)
612+ }
613+ _ = session.AppendMessage(textMessage(provider.RoleUser, "original"))
614+ path := session.Path
615+ _ = session.Close()
616+ snapshot, err := ReadSessionSnapshot(path)
617+ if err != nil {
618+ t.Fatal(err)
619+ }
620+ snapshot.Messages[0].Content[0] = provider.TextBlock{Text: "changed in memory"}
621+ again, err := ReadSessionSnapshot(path)
622+ if err != nil {
623+ t.Fatal(err)
624+ }
625+ if got := extractText(again.Messages[0]); strings.Contains(got, "changed") {
626+ t.Fatal("mutating snapshot changed on-disk transcript")
627+ }
628+}
M packages/tui/input.go +111 -12
@@ -56,6 +56,43 @@ const (
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 +140 -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,42 @@ 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+ if len(seq) == 0 {
223+ return 0, io.EOF
224+ }
225+ b := seq[0]
226+ seq = seq[1:]
227+ return b, nil
228+ }))
229+}
230+
231+func newPeekReader(seq string) *Reader {
112232 idx := 0
113- r := NewReader(func() (byte, error) {
233+ read := func() (byte, error) {
234+ if idx >= len(seq) {
235+ return 0, io.EOF
236+ }
114237 b := seq[idx]
115238 idx++
116239 return b, nil
117- })
240+ }
241+ peek := func(time.Duration) (byte, bool, error) {
242+ if idx >= len(seq) {
243+ return 0, false, nil
244+ }
245+ b := seq[idx]
246+ idx++
247+ return b, true, nil
248+ }
249+ return NewReaderWithPeek(read, peek)
250+}
251+
252+func readReaderKey(t *testing.T, r *Reader) Key {
253+ t.Helper()
118254 k, err := r.Read()
119255 if err != nil {
120- t.Fatalf("Read(%q): %v", seq, err)
256+ t.Fatalf("Read: %v", err)
121257 }
122258 return k
123259 }
M packages/tui/resize_windows.go +194 -7
@@ -3,8 +3,12 @@
33 package tui
44
55 import (
6+ "encoding/binary"
67 "os"
78 "time"
9+ "unsafe"
10+
11+ "golang.org/x/sys/windows"
812 )
913
1014 // Windows doesn't have SIGWINCH; resize events are signaled via input
@@ -14,14 +18,197 @@ func (p *ProcTerm) installResizeHandler() {}
1418
1519 func (p *ProcTerm) SetNonblock(enable bool) error { return nil }
1620
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[:])
21+// peekStdin waits for one byte on a supported Windows stdin handle without
22+// consuming anything unless a byte is available. Console input handles can be
23+// waited on directly. Pipes are checked with PeekNamedPipe, which does not
24+// consume data; polling that non-consuming check keeps the wait bounded.
25+//
26+// Disk files and other character handles are deliberately unsupported. A
27+// speculative os.File.Read on either can block (or consume a byte that cannot
28+// be put back safely), so those handles report no data instead of corrupting
29+// the escape parser. In particular, do not replace this with a blocking Read
30+// fallback: an isolated Esc must remain observable within d.
31+func peekStdin(in *os.File, d time.Duration) (byte, bool, error) {
32+ handle := windows.Handle(in.Fd())
33+ fileType, err := windows.GetFileType(handle)
2334 if err != nil {
2435 return 0, false, err
2536 }
26- return b[0], true, nil
37+
38+ switch fileType {
39+ case windows.FILE_TYPE_CHAR:
40+ // FILE_TYPE_CHAR also includes devices such as NUL. GetConsoleMode
41+ // distinguishes an actual console input handle from those devices.
42+ var mode uint32
43+ if err := windows.GetConsoleMode(handle, &mode); err != nil {
44+ return 0, false, windows.ERROR_INVALID_FUNCTION
45+ }
46+ return peekConsole(in, handle, d)
47+ case windows.FILE_TYPE_PIPE:
48+ return peekPipe(in, handle, d)
49+ case windows.FILE_TYPE_DISK:
50+ return 0, false, nil
51+ default:
52+ return 0, false, nil
53+ }
54+}
55+
56+const consoleKeyEvent = 0x0001
57+
58+type consoleInputRecord struct {
59+ eventType uint16
60+ _ uint16
61+ event [16]byte
62+}
63+
64+var (
65+ peekConsoleInputProc = windows.NewLazySystemDLL("kernel32.dll").NewProc("PeekConsoleInputW")
66+ readConsoleInputProc = windows.NewLazySystemDLL("kernel32.dll").NewProc("ReadConsoleInputW")
67+)
68+
69+func consoleInputRecordCall(proc *windows.LazyProc, handle windows.Handle, record *consoleInputRecord) (uint32, error) {
70+ if err := proc.Find(); err != nil {
71+ return 0, err
72+ }
73+ var read uint32
74+ r1, _, callErr := proc.Call(
75+ uintptr(handle),
76+ uintptr(unsafe.Pointer(record)),
77+ 1,
78+ uintptr(unsafe.Pointer(&read)),
79+ )
80+ if r1 == 0 {
81+ if callErr != nil && callErr != windows.ERROR_SUCCESS {
82+ return 0, callErr
83+ }
84+ return 0, windows.ERROR_INVALID_FUNCTION
85+ }
86+ return read, nil
87+}
88+
89+func peekConsole(in *os.File, handle windows.Handle, d time.Duration) (byte, bool, error) {
90+ deadline := time.Now().Add(d)
91+ for {
92+ remaining := d
93+ if d > 0 {
94+ remaining = time.Until(deadline)
95+ if remaining <= 0 {
96+ return 0, false, nil
97+ }
98+ }
99+ event, err := windows.WaitForSingleObject(handle, waitMilliseconds(remaining))
100+ if err != nil {
101+ return 0, false, err
102+ }
103+ switch event {
104+ case uint32(windows.WAIT_TIMEOUT):
105+ return 0, false, nil
106+ case windows.WAIT_OBJECT_0:
107+ var record consoleInputRecord
108+ count, err := consoleInputRecordCall(peekConsoleInputProc, handle, &record)
109+ if err != nil {
110+ return 0, false, err
111+ }
112+ if count == 0 {
113+ if d <= 0 {
114+ return 0, false, nil
115+ }
116+ continue
117+ }
118+ if record.eventType == consoleKeyEvent {
119+ keyDown := binary.LittleEndian.Uint32(record.event[0:4]) != 0
120+ char := binary.LittleEndian.Uint16(record.event[10:12])
121+ if keyDown && char != 0 {
122+ // The callback returns one byte, so leave non-ASCII
123+ // characters queued for the normal reader rather than
124+ // consuming them and truncating their encoding.
125+ if char > 0xff {
126+ return 0, false, nil
127+ }
128+ count, err := consoleInputRecordCall(readConsoleInputProc, handle, &record)
129+ if err != nil {
130+ return 0, false, err
131+ }
132+ if count != 0 {
133+ return byte(char), true, nil
134+ }
135+ continue
136+ }
137+ }
138+ // Consume non-character, key-up, and non-printing records so
139+ // a ready console handle cannot make the next wait spin.
140+ if _, err := consoleInputRecordCall(readConsoleInputProc, handle, &record); err != nil {
141+ return 0, false, err
142+ }
143+ if d <= 0 || time.Until(deadline) <= 0 {
144+ return 0, false, nil
145+ }
146+ continue
147+ default:
148+ return 0, false, windows.ERROR_INVALID_FUNCTION
149+ }
150+ }
151+}
152+
153+func waitMilliseconds(d time.Duration) uint32 {
154+ if d <= 0 {
155+ return 0
156+ }
157+ milliseconds := uint64(d / time.Millisecond)
158+ if d%time.Millisecond != 0 {
159+ milliseconds++
160+ }
161+ const maxWaitMilliseconds = uint64(^uint32(0) - 1) // reserve INFINITE
162+ if milliseconds > maxWaitMilliseconds {
163+ return uint32(maxWaitMilliseconds)
164+ }
165+ return uint32(milliseconds)
166+}
167+
168+var peekNamedPipe = windows.NewLazySystemDLL("kernel32.dll").NewProc("PeekNamedPipe")
169+
170+func peekPipe(in *os.File, handle windows.Handle, d time.Duration) (byte, bool, error) {
171+ if err := peekNamedPipe.Find(); err != nil {
172+ return 0, false, err
173+ }
174+ deadline := time.Now().Add(d)
175+ for {
176+ var available uint32
177+ ok, _, callErr := peekNamedPipe.Call(
178+ uintptr(handle),
179+ 0, 0, 0,
180+ uintptr(unsafe.Pointer(&available)),
181+ 0,
182+ )
183+ if ok == 0 {
184+ if callErr != nil && callErr != windows.ERROR_SUCCESS {
185+ return 0, false, callErr
186+ }
187+ return 0, false, windows.ERROR_INVALID_FUNCTION
188+ }
189+ if available != 0 {
190+ var b [1]byte
191+ n, err := in.Read(b[:])
192+ if err != nil {
193+ return 0, false, err
194+ }
195+ if n != len(b) {
196+ return 0, false, windows.ERROR_INVALID_FUNCTION
197+ }
198+ return b[0], true, nil
199+ }
200+ if d <= 0 {
201+ return 0, false, nil
202+ }
203+ remaining := time.Until(deadline)
204+ if remaining <= 0 {
205+ return 0, false, nil
206+ }
207+ // PeekNamedPipe is a non-consuming availability check. A short
208+ // sleep avoids a busy loop while the deadline remains authoritative.
209+ if remaining > time.Millisecond {
210+ remaining = time.Millisecond
211+ }
212+ time.Sleep(remaining)
213+ }
27214 }

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