detached 89513e6

Add session timeline inspector

commit
89513e6ea5760fbb5036ad5602deed223657aecf
author
patriceckhart · · 2026-08-17 06:58 UTC
parent
27cf8b3
signature
unsigned
7 files · +810 -18
M README.md +3 -2
@@ -293,7 +293,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
293293 | `/reasoning` | Set the reasoning level for subsequent model calls. |
294294 | `/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. |
295295 | `/sessions` | Resume a previous session for this directory. |
296-| `/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`. |
296+| `/session` | Five ops on the current session: inspect its `timeline`, `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message, or view its branch `tree`. Opens a picker without an argument; direct forms include `/session timeline`, `/session export [path]`, `/session import <path>`, `/session fork`, and `/session tree`. Default export destination is `~/Downloads`. |
297297 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
298298 | `/btw` | Side chat with full context that doesn't add to the main thread. |
299299 | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. |
@@ -320,8 +320,9 @@ Shows previous sessions for the current working directory, newest first, with ti
320320
321321 ### `/session`
322322
323-Four ops on the current session. `/session` alone opens a picker; each is also runnable directly.
323+Five ops on the current session. `/session` alone opens a picker; each is also runnable directly.
324324
325+- **`/session timeline`**. Replaces the chat area with a read-only timeline of the current system prompt, user and assistant messages, and tool calls. The header shows provider-reported context usage and an estimated split between system prompt, tool definitions, and messages. The split uses a simple byte estimate, so it is directional rather than tokenizer-exact. Use `up` / `down` or `pgup` / `pgdn` to select an event, `tab` / `shift+tab` to inspect its summary, payload, result, schema, and transcript-derived timing, and `/` to search. `ctrl+e` writes a private-permission JSON export to `~/Downloads` (falling back to the home directory). Images are represented by MIME type and byte size instead of embedding their data. Press `esc` to return to chat.
325326 - **`/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.
326327
327328 **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.
@@ -293,7 +293,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
293 | `/reasoning` | Set the reasoning level for subsequent model calls. |293 | `/reasoning` | Set the reasoning level for subsequent model calls. |
294 | `/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. |294 | `/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. |
295 | `/sessions` | Resume a previous session for this directory. |295 | `/sessions` | Resume a previous session for this directory. |
296-| `/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`. |296+| `/session` | Five ops on the current session: inspect its `timeline`, `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message, or view its branch `tree`. Opens a picker without an argument; direct forms include `/session timeline`, `/session export [path]`, `/session import <path>`, `/session fork`, and `/session tree`. Default export destination is `~/Downloads`. |
297 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |297 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
298 | `/btw` | Side chat with full context that doesn't add to the main thread. |298 | `/btw` | Side chat with full context that doesn't add to the main thread. |
299 | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. |299 | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. |
@@ -320,8 +320,9 @@ Shows previous sessions for the current working directory, newest first, with ti
320 320
321 ### `/session`321 ### `/session`
322 322
323-Four ops on the current session. `/session` alone opens a picker; each is also runnable directly.323+Five ops on the current session. `/session` alone opens a picker; each is also runnable directly.
324 324
325+- **`/session timeline`**. Replaces the chat area with a read-only timeline of the current system prompt, user and assistant messages, and tool calls. The header shows provider-reported context usage and an estimated split between system prompt, tool definitions, and messages. The split uses a simple byte estimate, so it is directional rather than tokenizer-exact. Use `up` / `down` or `pgup` / `pgdn` to select an event, `tab` / `shift+tab` to inspect its summary, payload, result, schema, and transcript-derived timing, and `/` to search. `ctrl+e` writes a private-permission JSON export to `~/Downloads` (falling back to the home directory). Images are represented by MIME type and byte size instead of embedding their data. Press `esc` to return to chat.
325 - **`/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.326 - **`/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.
326 327
327 **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.328 **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.
M packages/agent/modes/interactive.go +68 -7
@@ -482,6 +482,7 @@ type Interactive struct {
482482 telegramBridge *telegram.Bridge
483483 sessionOpsDialog *sessionOpsDialog
484484 sessionTreeDialog *sessionTreeDialog
485+ timeline *timelineView
485486 extPanel *extPanelDialog
486487 llamaConfigured bool
487488
@@ -643,6 +644,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
643644 settingsDialog: newSettingsDialog(),
644645 sessionOpsDialog: newSessionOpsDialog(),
645646 sessionTreeDialog: newSessionTreeDialog(),
647+ timeline: newTimelineView(),
646648 extPanel: newExtPanelDialog(),
647649 suggest: newSlashSuggester(),
648650 fileSuggest: newFileSuggester(),
@@ -972,7 +974,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
972974 // Live turns mutate streaming/tool-call state at high frequency;
973975 // keep those on the old rebuild path. The cache targets the common
974976 // idle case where only the editor contents changed between redraws.
975- if i.busy || i.streamOn || i.streamFlushPending {
977+ if i.busy || i.streamOn || i.streamFlushPending || i.timeline.Active() {
976978 return chatCacheKey{}, false
977979 }
978980 var rev uint64
@@ -1004,6 +1006,10 @@ func (i *Interactive) buildChatLocked(cols int) []string {
10041006 } else {
10051007 i.view.Messages = nil
10061008 }
1009+ if i.timeline.Active() {
1010+ _, rows := i.cfg.Terminal.Size()
1011+ return i.timeline.Render(i.cfg.Theme, cols, rows, i.timelineDataLocked())
1012+ }
10071013 // Pacer flush: while the streaming pacer is still draining the
10081014 // buffer (i.e. EvAssistantMessage already fired but more runes
10091015 // are queued), the final assistant message is already in
@@ -1163,6 +1169,25 @@ func (i *Interactive) lastCols() int {
11631169 return cols
11641170 }
11651171
1172+func (i *Interactive) timelineData() timelineData {
1173+ i.mu.Lock()
1174+ defer i.mu.Unlock()
1175+ return i.timelineDataLocked()
1176+}
1177+
1178+// timelineDataLocked snapshots the current provider-neutral context for the
1179+// read-only timeline. The caller must hold i.mu.
1180+func (i *Interactive) timelineDataLocked() timelineData {
1181+ data := timelineData{ContextUsed: i.lastCtxInput}
1182+ if model, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
1183+ data.ContextMax = model.ContextWindow
1184+ }
1185+ if i.agent != nil {
1186+ data.System, data.Tools, data.Messages = i.agent.ContextSnapshot()
1187+ }
1188+ return data
1189+}
1190+
11661191 // chatPage returns the number of chat rows currently visible, used
11671192 // as the page size for PageUp/PageDown.
11681193 func (i *Interactive) chatPage() int {
@@ -2027,6 +2052,7 @@ func (i *Interactive) confirmChildActive() bool {
20272052 i.settingsDialog.Active() ||
20282053 i.sessionOpsDialog.Active() ||
20292054 i.sessionTreeDialog.Active() ||
2055+ i.timeline.Active() ||
20302056 i.extPanel.Active()
20312057 }
20322058
@@ -2295,6 +2321,24 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
22952321 }
22962322 return false
22972323 }
2324+ if i.timeline.Active() {
2325+ data := i.timelineData()
2326+ act := i.timeline.HandleKey(k, data)
2327+ if act.Export {
2328+ path, err := i.timeline.Export("", data)
2329+ if err != nil {
2330+ i.timeline.SetNotice("export failed: " + err.Error())
2331+ } else {
2332+ i.timeline.SetNotice("exported to " + friendlyPath(path))
2333+ }
2334+ }
2335+ i.mu.Lock()
2336+ i.chatCacheValid = false
2337+ i.scrollOffset = 0
2338+ i.mu.Unlock()
2339+ i.invalidate()
2340+ return false
2341+ }
22982342 if i.jumpDialog.Active() {
22992343 if k.Kind == tui.KeyCtrlC {
23002344 i.jumpDialog.Close()
@@ -6670,11 +6714,12 @@ func (h *telegramHost) Notify(level, message string) {
66706714 }
66716715
66726716 // openSessionOpsDialog shows the picker for `/session` with no arg.
6673-// Always offers export, import, fork, tree; the handlers bail with
6674-// a clear status message when the precondition isn't met (empty
6675-// transcript on fork; no parent/siblings on tree).
6717+// Always offers timeline, export, import, fork, and tree. The handlers bail
6718+// with a clear status message when a precondition is not met (empty transcript
6719+// on fork; no parent/siblings on tree).
66766720 func (i *Interactive) openSessionOpsDialog() {
66776721 items := []sessionOpsItem{
6722+ {label: "timeline", action: "timeline", hint: "inspect context, messages, and tool calls"},
66786723 {label: "export", action: "export", hint: "write the current session to a .zotsession file"},
66796724 {label: "import", action: "import", hint: "load a .zotsession file into this directory"},
66806725 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
@@ -6684,11 +6729,13 @@ func (i *Interactive) openSessionOpsDialog() {
66846729 i.invalidate()
66856730 }
66866731
6687-// doSessionOp dispatches export, import, fork, or tree. arg is the
6732+// doSessionOp dispatches timeline, export, import, fork, or tree. arg is the
66886733 // optional positional argument from e.g. /session export <path>
6689-// or /session import <path>; fork and tree ignore it.
6734+// or /session import <path>; timeline, fork, and tree ignore it.
66906735 func (i *Interactive) doSessionOp(action, arg string) {
66916736 switch action {
6737+ case "timeline":
6738+ i.openTimeline()
66926739 case "export":
66936740 i.doSessionExport(arg)
66946741 case "import":
@@ -6699,12 +6746,26 @@ func (i *Interactive) doSessionOp(action, arg string) {
66996746 i.doSessionTree()
67006747 default:
67016748 i.mu.Lock()
6702- i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
6749+ i.statusErr = "unknown /session action: " + action + " (use timeline, export, import, fork, or tree)"
67036750 i.mu.Unlock()
67046751 i.invalidate()
67056752 }
67066753 }
67076754
6755+func (i *Interactive) openTimeline() {
6756+ i.mu.Lock()
6757+ if i.timeline == nil {
6758+ i.timeline = newTimelineView()
6759+ }
6760+ i.timeline.Open(i.timelineDataLocked())
6761+ i.chatCacheValid = false
6762+ i.scrollOffset = 0
6763+ i.statusErr = ""
6764+ i.statusOK = ""
6765+ i.mu.Unlock()
6766+ i.invalidate()
6767+}
6768+
67086769 // doSessionExport writes the live session file to destination path
67096770 // dst. When dst is empty we default to ~/Downloads (falling back to
67106771 // the user's home directory if it doesn't exist). The helper
@@ -482,6 +482,7 @@ type Interactive struct {
482 telegramBridge *telegram.Bridge482 telegramBridge *telegram.Bridge
483 sessionOpsDialog *sessionOpsDialog483 sessionOpsDialog *sessionOpsDialog
484 sessionTreeDialog *sessionTreeDialog484 sessionTreeDialog *sessionTreeDialog
485+ timeline *timelineView
485 extPanel *extPanelDialog486 extPanel *extPanelDialog
486 llamaConfigured bool487 llamaConfigured bool
487 488
@@ -643,6 +644,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
643 settingsDialog: newSettingsDialog(),644 settingsDialog: newSettingsDialog(),
644 sessionOpsDialog: newSessionOpsDialog(),645 sessionOpsDialog: newSessionOpsDialog(),
645 sessionTreeDialog: newSessionTreeDialog(),646 sessionTreeDialog: newSessionTreeDialog(),
647+ timeline: newTimelineView(),
646 extPanel: newExtPanelDialog(),648 extPanel: newExtPanelDialog(),
647 suggest: newSlashSuggester(),649 suggest: newSlashSuggester(),
648 fileSuggest: newFileSuggester(),650 fileSuggest: newFileSuggester(),
@@ -972,7 +974,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
972 // Live turns mutate streaming/tool-call state at high frequency;974 // Live turns mutate streaming/tool-call state at high frequency;
973 // keep those on the old rebuild path. The cache targets the common975 // keep those on the old rebuild path. The cache targets the common
974 // idle case where only the editor contents changed between redraws.976 // idle case where only the editor contents changed between redraws.
975- if i.busy || i.streamOn || i.streamFlushPending {977+ if i.busy || i.streamOn || i.streamFlushPending || i.timeline.Active() {
976 return chatCacheKey{}, false978 return chatCacheKey{}, false
977 }979 }
978 var rev uint64980 var rev uint64
@@ -1004,6 +1006,10 @@ func (i *Interactive) buildChatLocked(cols int) []string {
1004 } else {1006 } else {
1005 i.view.Messages = nil1007 i.view.Messages = nil
1006 }1008 }
1009+ if i.timeline.Active() {
1010+ _, rows := i.cfg.Terminal.Size()
1011+ return i.timeline.Render(i.cfg.Theme, cols, rows, i.timelineDataLocked())
1012+ }
1007 // Pacer flush: while the streaming pacer is still draining the1013 // Pacer flush: while the streaming pacer is still draining the
1008 // buffer (i.e. EvAssistantMessage already fired but more runes1014 // buffer (i.e. EvAssistantMessage already fired but more runes
1009 // are queued), the final assistant message is already in1015 // are queued), the final assistant message is already in
@@ -1163,6 +1169,25 @@ func (i *Interactive) lastCols() int {
1163 return cols1169 return cols
1164 }1170 }
1165 1171
1172+func (i *Interactive) timelineData() timelineData {
1173+ i.mu.Lock()
1174+ defer i.mu.Unlock()
1175+ return i.timelineDataLocked()
1176+}
1177+
1178+// timelineDataLocked snapshots the current provider-neutral context for the
1179+// read-only timeline. The caller must hold i.mu.
1180+func (i *Interactive) timelineDataLocked() timelineData {
1181+ data := timelineData{ContextUsed: i.lastCtxInput}
1182+ if model, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
1183+ data.ContextMax = model.ContextWindow
1184+ }
1185+ if i.agent != nil {
1186+ data.System, data.Tools, data.Messages = i.agent.ContextSnapshot()
1187+ }
1188+ return data
1189+}
1190+
1166 // chatPage returns the number of chat rows currently visible, used1191 // chatPage returns the number of chat rows currently visible, used
1167 // as the page size for PageUp/PageDown.1192 // as the page size for PageUp/PageDown.
1168 func (i *Interactive) chatPage() int {1193 func (i *Interactive) chatPage() int {
@@ -2027,6 +2052,7 @@ func (i *Interactive) confirmChildActive() bool {
2027 i.settingsDialog.Active() ||2052 i.settingsDialog.Active() ||
2028 i.sessionOpsDialog.Active() ||2053 i.sessionOpsDialog.Active() ||
2029 i.sessionTreeDialog.Active() ||2054 i.sessionTreeDialog.Active() ||
2055+ i.timeline.Active() ||
2030 i.extPanel.Active()2056 i.extPanel.Active()
2031 }2057 }
2032 2058
@@ -2295,6 +2321,24 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2295 }2321 }
2296 return false2322 return false
2297 }2323 }
2324+ if i.timeline.Active() {
2325+ data := i.timelineData()
2326+ act := i.timeline.HandleKey(k, data)
2327+ if act.Export {
2328+ path, err := i.timeline.Export("", data)
2329+ if err != nil {
2330+ i.timeline.SetNotice("export failed: " + err.Error())
2331+ } else {
2332+ i.timeline.SetNotice("exported to " + friendlyPath(path))
2333+ }
2334+ }
2335+ i.mu.Lock()
2336+ i.chatCacheValid = false
2337+ i.scrollOffset = 0
2338+ i.mu.Unlock()
2339+ i.invalidate()
2340+ return false
2341+ }
2298 if i.jumpDialog.Active() {2342 if i.jumpDialog.Active() {
2299 if k.Kind == tui.KeyCtrlC {2343 if k.Kind == tui.KeyCtrlC {
2300 i.jumpDialog.Close()2344 i.jumpDialog.Close()
@@ -6670,11 +6714,12 @@ func (h *telegramHost) Notify(level, message string) {
6670 }6714 }
6671 6715
6672 // openSessionOpsDialog shows the picker for `/session` with no arg.6716 // openSessionOpsDialog shows the picker for `/session` with no arg.
6673-// Always offers export, import, fork, tree; the handlers bail with6717+// Always offers timeline, export, import, fork, and tree. The handlers bail
6674-// a clear status message when the precondition isn't met (empty6718+// with a clear status message when a precondition is not met (empty transcript
6675-// transcript on fork; no parent/siblings on tree).6719+// on fork; no parent/siblings on tree).
6676 func (i *Interactive) openSessionOpsDialog() {6720 func (i *Interactive) openSessionOpsDialog() {
6677 items := []sessionOpsItem{6721 items := []sessionOpsItem{
6722+ {label: "timeline", action: "timeline", hint: "inspect context, messages, and tool calls"},
6678 {label: "export", action: "export", hint: "write the current session to a .zotsession file"},6723 {label: "export", action: "export", hint: "write the current session to a .zotsession file"},
6679 {label: "import", action: "import", hint: "load a .zotsession file into this directory"},6724 {label: "import", action: "import", hint: "load a .zotsession file into this directory"},
6680 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},6725 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
@@ -6684,11 +6729,13 @@ func (i *Interactive) openSessionOpsDialog() {
6684 i.invalidate()6729 i.invalidate()
6685 }6730 }
6686 6731
6687-// doSessionOp dispatches export, import, fork, or tree. arg is the6732+// doSessionOp dispatches timeline, export, import, fork, or tree. arg is the
6688 // optional positional argument from e.g. /session export <path>6733 // optional positional argument from e.g. /session export <path>
6689-// or /session import <path>; fork and tree ignore it.6734+// or /session import <path>; timeline, fork, and tree ignore it.
6690 func (i *Interactive) doSessionOp(action, arg string) {6735 func (i *Interactive) doSessionOp(action, arg string) {
6691 switch action {6736 switch action {
6737+ case "timeline":
6738+ i.openTimeline()
6692 case "export":6739 case "export":
6693 i.doSessionExport(arg)6740 i.doSessionExport(arg)
6694 case "import":6741 case "import":
@@ -6699,12 +6746,26 @@ func (i *Interactive) doSessionOp(action, arg string) {
6699 i.doSessionTree()6746 i.doSessionTree()
6700 default:6747 default:
6701 i.mu.Lock()6748 i.mu.Lock()
6702- i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"6749+ i.statusErr = "unknown /session action: " + action + " (use timeline, export, import, fork, or tree)"
6703 i.mu.Unlock()6750 i.mu.Unlock()
6704 i.invalidate()6751 i.invalidate()
6705 }6752 }
6706 }6753 }
6707 6754
6755+func (i *Interactive) openTimeline() {
6756+ i.mu.Lock()
6757+ if i.timeline == nil {
6758+ i.timeline = newTimelineView()
6759+ }
6760+ i.timeline.Open(i.timelineDataLocked())
6761+ i.chatCacheValid = false
6762+ i.scrollOffset = 0
6763+ i.statusErr = ""
6764+ i.statusOK = ""
6765+ i.mu.Unlock()
6766+ i.invalidate()
6767+}
6768+
6708 // doSessionExport writes the live session file to destination path6769 // doSessionExport writes the live session file to destination path
6709 // dst. When dst is empty we default to ~/Downloads (falling back to6770 // dst. When dst is empty we default to ~/Downloads (falling back to
6710 // the user's home directory if it doesn't exist). The helper6771 // the user's home directory if it doesn't exist). The helper
M packages/agent/modes/session_ops_dialog.go +4 -8
@@ -5,10 +5,8 @@ import (
55 )
66
77 // sessionOpsDialog is the picker shown when the user runs `/session`
8-// without an argument. Offers the two portable-file operations on
9-// the current conversation: export (write the in-memory transcript
10-// plus meta to a .zotsession file) and import (load a .zotsession
11-// from another machine and swap it in as the active session).
8+// without an argument. It offers timeline inspection, portable-file
9+// operations, and branch navigation for the current conversation.
1210 //
1311 // Shape mirrors telegramDialog and logoutDialog: tiny list, arrow
1412 // keys to move, enter to pick, esc to cancel.
@@ -20,7 +18,7 @@ type sessionOpsDialog struct {
2018
2119 type sessionOpsItem struct {
2220 label string
23- action string // "export" | "import"
21+ action string // "timeline" | "export" | "import" | "fork" | "tree"
2422 hint string
2523 }
2624
@@ -32,9 +30,7 @@ type sessionOpsAction struct {
3230
3331 func newSessionOpsDialog() *sessionOpsDialog { return &sessionOpsDialog{} }
3432
35-// Open shows the picker. Items are usually both "export" and
36-// "import" but the caller can suppress either (e.g. hide export
37-// when the session is empty).
33+// Open shows the picker with the actions supplied by the caller.
3834 func (d *sessionOpsDialog) Open(items []sessionOpsItem) bool {
3935 if len(items) == 0 {
4036 return false
@@ -5,10 +5,8 @@ import (
5 )5 )
6 6
7 // sessionOpsDialog is the picker shown when the user runs `/session`7 // sessionOpsDialog is the picker shown when the user runs `/session`
8-// without an argument. Offers the two portable-file operations on8+// without an argument. It offers timeline inspection, portable-file
9-// the current conversation: export (write the in-memory transcript9+// operations, and branch navigation for the current conversation.
10-// plus meta to a .zotsession file) and import (load a .zotsession
11-// from another machine and swap it in as the active session).
12 //10 //
13 // Shape mirrors telegramDialog and logoutDialog: tiny list, arrow11 // Shape mirrors telegramDialog and logoutDialog: tiny list, arrow
14 // keys to move, enter to pick, esc to cancel.12 // keys to move, enter to pick, esc to cancel.
@@ -20,7 +18,7 @@ type sessionOpsDialog struct {
20 18
21 type sessionOpsItem struct {19 type sessionOpsItem struct {
22 label string20 label string
23- action string // "export" | "import"21+ action string // "timeline" | "export" | "import" | "fork" | "tree"
24 hint string22 hint string
25 }23 }
26 24
@@ -32,9 +30,7 @@ type sessionOpsAction struct {
32 30
33 func newSessionOpsDialog() *sessionOpsDialog { return &sessionOpsDialog{} }31 func newSessionOpsDialog() *sessionOpsDialog { return &sessionOpsDialog{} }
34 32
35-// Open shows the picker. Items are usually both "export" and33+// Open shows the picker with the actions supplied by the caller.
36-// "import" but the caller can suppress either (e.g. hide export
37-// when the session is empty).
38 func (d *sessionOpsDialog) Open(items []sessionOpsItem) bool {34 func (d *sessionOpsDialog) Open(items []sessionOpsItem) bool {
39 if len(items) == 0 {35 if len(items) == 0 {
40 return false36 return false
M packages/agent/modes/slash_suggest.go +1 -1
@@ -42,7 +42,7 @@ var slashCatalog = []slashCommand{
4242 {Name: "/reasoning", Desc: "set the reasoning level"},
4343 {Name: "/llama", Desc: "manage llama.cpp router models"},
4444 {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"},
45+ {Name: "/session", Desc: "inspect, export, import, fork, or navigate the current session"},
4646 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},
4747 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
4848 {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"},
@@ -42,7 +42,7 @@ var slashCatalog = []slashCommand{
42 {Name: "/reasoning", Desc: "set the reasoning level"},42 {Name: "/reasoning", Desc: "set the reasoning level"},
43 {Name: "/llama", Desc: "manage llama.cpp router models"},43 {Name: "/llama", Desc: "manage llama.cpp router models"},
44 {Name: "/sessions", Desc: "resume a previous session for this directory"},44 {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"},45+ {Name: "/session", Desc: "inspect, export, import, fork, or navigate the current session"},
46 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},46 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},
47 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},47 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
48 {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"},48 {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"},
A packages/agent/modes/timeline_view.go +584 -0
@@ -0,0 +1,584 @@
1+package modes
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "time"
10+
11+ "github.com/mattn/go-runewidth"
12+
13+ "github.com/patriceckhart/zot/packages/provider"
14+ "github.com/patriceckhart/zot/packages/tui"
15+)
16+
17+const timelineTabCount = 5
18+
19+var timelineTabNames = [timelineTabCount]string{"summary", "payload", "result", "schema", "timing"}
20+
21+type timelineEntry struct {
22+ Kind string `json:"kind"`
23+ Turn int `json:"turn,omitempty"`
24+ Label string `json:"label"`
25+ Summary string `json:"summary"`
26+ Time time.Time `json:"time,omitempty"`
27+ Duration string `json:"duration,omitempty"`
28+ Payload string `json:"payload,omitempty"`
29+ Result string `json:"result,omitempty"`
30+ Schema string `json:"schema,omitempty"`
31+ IsError bool `json:"is_error,omitempty"`
32+ ToolCall string `json:"tool_call_id,omitempty"`
33+}
34+
35+type timelineData struct {
36+ System string
37+ Tools []provider.Tool
38+ Messages []provider.Message
39+ ContextUsed int
40+ ContextMax int
41+}
42+
43+type timelineView struct {
44+ active bool
45+ cursor int
46+ tab int
47+ searching bool
48+ filter string
49+ notice string
50+}
51+
52+type timelineAction struct {
53+ Export bool
54+ Close bool
55+}
56+
57+func newTimelineView() *timelineView { return &timelineView{} }
58+
59+func (v *timelineView) Active() bool { return v != nil && v.active }
60+
61+func (v *timelineView) Open(data timelineData) {
62+ v.active = true
63+ v.searching = false
64+ v.filter = ""
65+ v.notice = ""
66+ v.tab = 0
67+ v.cursor = len(buildTimelineEntries(data)) - 1
68+ if v.cursor < 0 {
69+ v.cursor = 0
70+ }
71+}
72+
73+func (v *timelineView) Close() {
74+ v.active = false
75+ v.searching = false
76+ v.filter = ""
77+ v.notice = ""
78+}
79+
80+func (v *timelineView) SetNotice(s string) { v.notice = s }
81+
82+func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
83+ entries := filterTimelineEntries(buildTimelineEntries(data), v.filter)
84+ clampTimelineCursor(v, len(entries))
85+
86+ switch k.Kind {
87+ case tui.KeyUp:
88+ if v.cursor > 0 {
89+ v.cursor--
90+ }
91+ case tui.KeyDown:
92+ if v.cursor < len(entries)-1 {
93+ v.cursor++
94+ }
95+ case tui.KeyPageUp:
96+ v.cursor -= 8
97+ if v.cursor < 0 {
98+ v.cursor = 0
99+ }
100+ case tui.KeyPageDown:
101+ v.cursor += 8
102+ clampTimelineCursor(v, len(entries))
103+ case tui.KeyTab:
104+ v.tab = (v.tab + 1) % timelineTabCount
105+ case tui.KeyShiftTab:
106+ v.tab = (v.tab + timelineTabCount - 1) % timelineTabCount
107+ case tui.KeyCtrlE:
108+ return timelineAction{Export: true}
109+ case tui.KeyBackspace:
110+ if v.searching && v.filter != "" {
111+ r := []rune(v.filter)
112+ v.filter = string(r[:len(r)-1])
113+ v.cursor = 0
114+ }
115+ case tui.KeyPaste:
116+ if v.searching {
117+ v.filter += singleLinePaste(k.Paste)
118+ v.cursor = 0
119+ }
120+ case tui.KeyRune:
121+ if !v.searching && k.Rune == '/' {
122+ v.searching = true
123+ v.notice = ""
124+ } else if v.searching && !k.Ctrl && !k.Alt && !k.Super {
125+ v.filter += string(k.Rune)
126+ v.cursor = 0
127+ }
128+ case tui.KeyEsc, tui.KeyCtrlC:
129+ if v.searching {
130+ if v.filter != "" {
131+ v.filter = ""
132+ v.cursor = 0
133+ } else {
134+ v.searching = false
135+ }
136+ break
137+ }
138+ v.Close()
139+ return timelineAction{Close: true}
140+ }
141+ return timelineAction{}
142+}
143+
144+func clampTimelineCursor(v *timelineView, n int) {
145+ if n == 0 {
146+ v.cursor = 0
147+ return
148+ }
149+ if v.cursor >= n {
150+ v.cursor = n - 1
151+ }
152+ if v.cursor < 0 {
153+ v.cursor = 0
154+ }
155+}
156+
157+func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData) []string {
158+ if !v.Active() {
159+ return nil
160+ }
161+ if width < 30 {
162+ width = 30
163+ }
164+ entries := filterTimelineEntries(buildTimelineEntries(data), v.filter)
165+ clampTimelineCursor(v, len(entries))
166+
167+ lines := []string{frameHeader(th, "timeline", width)}
168+ lines = append(lines, renderTimelineContext(th, width, data)...)
169+
170+ hint := "up/down select pgup/pgdn move tab details / search ctrl+e export esc chat"
171+ if v.searching {
172+ hint = "search: " + v.filter + "_ (esc clears)"
173+ }
174+ lines = append(lines, th.FG256(th.Muted, fitTimelineLine(hint, width)))
175+
176+ listRows := (height - 14) / 2
177+ if listRows < 3 {
178+ listRows = 3
179+ }
180+ if listRows > 12 {
181+ listRows = 12
182+ }
183+ if len(entries) == 0 {
184+ lines = append(lines, th.FG256(th.Muted, " no matching events"))
185+ } else {
186+ start := v.cursor - listRows/2
187+ if start < 0 {
188+ start = 0
189+ }
190+ end := start + listRows
191+ if end > len(entries) {
192+ end = len(entries)
193+ start = end - listRows
194+ if start < 0 {
195+ start = 0
196+ }
197+ }
198+ for idx := start; idx < end; idx++ {
199+ line := formatTimelineRow(entries[idx], width)
200+ if idx == v.cursor {
201+ lines = append(lines, th.PadHighlight(line, width))
202+ } else {
203+ lines = append(lines, timelineColor(th, entries[idx], line))
204+ }
205+ }
206+ }
207+
208+ lines = append(lines, renderTimelineTabs(th, width, v.tab))
209+ if len(entries) > 0 {
210+ detailRows := height - len(lines) - 7
211+ if detailRows < 3 {
212+ detailRows = 3
213+ }
214+ lines = append(lines, renderTimelineDetail(th, width, entries[v.cursor], v.tab, detailRows)...)
215+ }
216+ if v.notice != "" {
217+ lines = append(lines, th.FG256(th.Tool, fitTimelineLine(v.notice, width)))
218+ }
219+ lines = append(lines, frameRule(th, width))
220+ return lines
221+}
222+
223+func buildTimelineEntries(data timelineData) []timelineEntry {
224+ toolSchemas := make(map[string]string, len(data.Tools))
225+ for _, tool := range data.Tools {
226+ toolSchemas[tool.Name] = prettyTimelineJSON(tool.Schema)
227+ }
228+
229+ results := map[string]provider.ToolResultBlock{}
230+ resultTimes := map[string]time.Time{}
231+ for _, message := range data.Messages {
232+ for _, content := range message.Content {
233+ if result, ok := content.(provider.ToolResultBlock); ok {
234+ results[result.CallID] = result
235+ resultTimes[result.CallID] = message.Time
236+ }
237+ }
238+ }
239+
240+ entries := make([]timelineEntry, 0, len(data.Messages)+1)
241+ if strings.TrimSpace(data.System) != "" {
242+ entries = append(entries, timelineEntry{
243+ Kind: "system", Label: "SYSTEM", Summary: firstTimelineLine(data.System), Payload: data.System,
244+ })
245+ }
246+ turn := 0
247+ var previousTime time.Time
248+ for _, message := range data.Messages {
249+ if message.Role == provider.RoleUser {
250+ turn++
251+ }
252+ textParts := timelineMessageText(message)
253+ if len(textParts) > 0 {
254+ entry := timelineEntry{
255+ Kind: string(message.Role), Turn: turn, Label: strings.ToUpper(string(message.Role)),
256+ Summary: firstTimelineLine(strings.Join(textParts, "\n")), Time: message.Time,
257+ Payload: prettyTimelineValue(timelineMessagePayload(message)),
258+ }
259+ if !previousTime.IsZero() && !message.Time.IsZero() && message.Time.After(previousTime) {
260+ entry.Duration = message.Time.Sub(previousTime).Round(time.Millisecond).String()
261+ }
262+ entries = append(entries, entry)
263+ if !message.Time.IsZero() {
264+ previousTime = message.Time
265+ }
266+ }
267+ for _, content := range message.Content {
268+ call, ok := content.(provider.ToolCallBlock)
269+ if !ok {
270+ continue
271+ }
272+ entry := timelineEntry{
273+ Kind: "tool", Turn: turn, Label: "TOOL " + call.Name, Summary: summarizeToolArguments(call.Arguments),
274+ Time: message.Time, Payload: prettyTimelineJSON(call.Arguments), Schema: toolSchemas[call.Name], ToolCall: call.ID,
275+ }
276+ if result, found := results[call.ID]; found {
277+ entry.Result = prettyTimelineValue(timelineResultPayload(result))
278+ entry.IsError = result.IsError
279+ if ended := resultTimes[call.ID]; !message.Time.IsZero() && ended.After(message.Time) {
280+ entry.Duration = ended.Sub(message.Time).Round(time.Millisecond).String()
281+ }
282+ }
283+ entries = append(entries, entry)
284+ }
285+ }
286+ return entries
287+}
288+
289+func timelineMessageText(message provider.Message) []string {
290+ var out []string
291+ for _, content := range message.Content {
292+ switch block := content.(type) {
293+ case provider.TextBlock:
294+ if strings.TrimSpace(block.Text) != "" {
295+ out = append(out, block.Text)
296+ }
297+ case provider.ReasoningBlock:
298+ if strings.TrimSpace(block.Summary) != "" {
299+ out = append(out, "reasoning: "+block.Summary)
300+ }
301+ case provider.ImageBlock:
302+ out = append(out, fmt.Sprintf("[image %s, %d bytes]", block.MimeType, len(block.Data)))
303+ }
304+ }
305+ return out
306+}
307+
308+func timelineMessagePayload(message provider.Message) any {
309+ blocks := make([]any, 0, len(message.Content))
310+ for _, content := range message.Content {
311+ switch block := content.(type) {
312+ case provider.TextBlock:
313+ blocks = append(blocks, map[string]any{"type": "text", "text": block.Text})
314+ case provider.ReasoningBlock:
315+ blocks = append(blocks, map[string]any{"type": "reasoning", "summary": block.Summary})
316+ case provider.ImageBlock:
317+ blocks = append(blocks, map[string]any{"type": "image", "mime_type": block.MimeType, "bytes": len(block.Data)})
318+ }
319+ }
320+ return map[string]any{"role": message.Role, "content": blocks, "meta": message.Meta}
321+}
322+
323+func timelineResultPayload(result provider.ToolResultBlock) any {
324+ parts := make([]any, 0, len(result.Content))
325+ for _, content := range result.Content {
326+ switch block := content.(type) {
327+ case provider.TextBlock:
328+ parts = append(parts, map[string]any{"type": "text", "text": block.Text})
329+ case provider.ImageBlock:
330+ parts = append(parts, map[string]any{"type": "image", "mime_type": block.MimeType, "bytes": len(block.Data)})
331+ default:
332+ parts = append(parts, fmt.Sprintf("%T", content))
333+ }
334+ }
335+ return map[string]any{"content": parts, "is_error": result.IsError}
336+}
337+
338+func filterTimelineEntries(entries []timelineEntry, filter string) []timelineEntry {
339+ filter = strings.ToLower(strings.TrimSpace(filter))
340+ if filter == "" {
341+ return entries
342+ }
343+ out := make([]timelineEntry, 0, len(entries))
344+ for _, entry := range entries {
345+ haystack := strings.ToLower(strings.Join([]string{entry.Label, entry.Summary, entry.Payload, entry.Result}, "\n"))
346+ if strings.Contains(haystack, filter) {
347+ out = append(out, entry)
348+ }
349+ }
350+ return out
351+}
352+
353+func renderTimelineContext(th tui.Theme, width int, data timelineData) []string {
354+ system := estimateTimelineTokens(data.System)
355+ toolBytes, _ := json.Marshal(data.Tools)
356+ tools := estimateTimelineTokens(string(toolBytes))
357+ messages := estimateTimelineMessageTokens(data.Messages)
358+
359+ usage := fmt.Sprintf("context %s", compactTimelineNumber(data.ContextUsed))
360+ if data.ContextMax > 0 {
361+ pct := float64(data.ContextUsed) * 100 / float64(data.ContextMax)
362+ usage = fmt.Sprintf("context %s / %s (%.1f%%)", compactTimelineNumber(data.ContextUsed), compactTimelineNumber(data.ContextMax), pct)
363+ }
364+ composition := fmt.Sprintf("estimated composition: system %s tools %s messages %s", compactTimelineNumber(system), compactTimelineNumber(tools), compactTimelineNumber(messages))
365+ return []string{tui.Bold(usage), th.FG256(th.Muted, fitTimelineLine(composition, width))}
366+}
367+
368+func formatTimelineRow(entry timelineEntry, width int) string {
369+ stamp := " "
370+ if !entry.Time.IsZero() {
371+ stamp = entry.Time.Format("15:04:05")
372+ }
373+ turn := " "
374+ if entry.Turn > 0 {
375+ turn = fmt.Sprintf("%3d", entry.Turn)
376+ }
377+ line := fmt.Sprintf("%s %s %-16s %s", stamp, turn, entry.Label, entry.Summary)
378+ return fitTimelineLine(line, width)
379+}
380+
381+func timelineColor(th tui.Theme, entry timelineEntry, line string) string {
382+ color := th.Muted
383+ switch entry.Kind {
384+ case "user":
385+ color = th.User
386+ case "assistant":
387+ color = th.Assistant
388+ case "tool":
389+ color = th.Tool
390+ case "system":
391+ color = th.Warning
392+ }
393+ if entry.IsError {
394+ color = th.Error
395+ }
396+ return th.FG256(color, line)
397+}
398+
399+func renderTimelineTabs(th tui.Theme, width, selected int) string {
400+ var tabs []string
401+ for idx, name := range timelineTabNames {
402+ if idx == selected {
403+ tabs = append(tabs, tui.Bold(th.FG256(th.Accent, "["+name+"]")))
404+ } else {
405+ tabs = append(tabs, th.FG256(th.Muted, " "+name+" "))
406+ }
407+ }
408+ return fitTimelineLine(strings.Join(tabs, " "), width)
409+}
410+
411+func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, maxRows int) []string {
412+ var detail string
413+ switch tab {
414+ case 0:
415+ detail = fmt.Sprintf("type: %s\nturn: %d\nstatus: %s\nsummary: %s", entry.Kind, entry.Turn, timelineStatus(entry), entry.Summary)
416+ case 1:
417+ detail = entry.Payload
418+ case 2:
419+ detail = entry.Result
420+ case 3:
421+ detail = entry.Schema
422+ case 4:
423+ started := "not recorded"
424+ if !entry.Time.IsZero() {
425+ started = entry.Time.Format(time.RFC3339Nano)
426+ }
427+ duration := entry.Duration
428+ if duration == "" {
429+ duration = "not recorded"
430+ }
431+ detail = "started: " + started + "\nduration: " + duration + "\nsource: transcript timestamps"
432+ }
433+ if strings.TrimSpace(detail) == "" {
434+ detail = "not available for this event"
435+ }
436+ var rows []string
437+ for _, line := range strings.Split(detail, "\n") {
438+ for _, wrapped := range tui.WrapANSILine(line, width-2) {
439+ rows = append(rows, " "+th.FG256(th.ToolOut, wrapped))
440+ if len(rows) >= maxRows {
441+ return rows
442+ }
443+ }
444+ }
445+ return rows
446+}
447+
448+func timelineStatus(entry timelineEntry) string {
449+ if entry.IsError {
450+ return "error"
451+ }
452+ if entry.Kind == "tool" && entry.Result == "" {
453+ return "result unavailable"
454+ }
455+ return "completed"
456+}
457+
458+func (v *timelineView) Export(dir string, data timelineData) (string, error) {
459+ if dir == "" {
460+ dir = defaultExportDir()
461+ }
462+ if err := os.MkdirAll(dir, 0o755); err != nil {
463+ return "", fmt.Errorf("create export directory: %w", err)
464+ }
465+ payload := struct {
466+ ExportedAt time.Time `json:"exported_at"`
467+ ContextUsed int `json:"context_used"`
468+ ContextMax int `json:"context_max"`
469+ Events []timelineEntry `json:"events"`
470+ }{time.Now(), data.ContextUsed, data.ContextMax, buildTimelineEntries(data)}
471+ encoded, err := json.MarshalIndent(payload, "", " ")
472+ if err != nil {
473+ return "", fmt.Errorf("encode timeline: %w", err)
474+ }
475+ name := "zot-timeline-" + time.Now().Format("20060102-150405.000000000") + ".json"
476+ path := filepath.Join(dir, name)
477+ if err := os.WriteFile(path, append(encoded, '\n'), 0o600); err != nil {
478+ return "", fmt.Errorf("write timeline: %w", err)
479+ }
480+ return path, nil
481+}
482+
483+func firstTimelineLine(s string) string {
484+ for _, line := range strings.Split(s, "\n") {
485+ if line = strings.TrimSpace(line); line != "" {
486+ return fitTimelineLine(line, 120)
487+ }
488+ }
489+ return "(empty)"
490+}
491+
492+func summarizeToolArguments(raw json.RawMessage) string {
493+ var object map[string]any
494+ if json.Unmarshal(raw, &object) == nil {
495+ for _, key := range []string{"command", "path", "query", "pattern"} {
496+ if value, ok := object[key]; ok {
497+ return firstTimelineLine(fmt.Sprint(value))
498+ }
499+ }
500+ }
501+ return firstTimelineLine(string(raw))
502+}
503+
504+func prettyTimelineJSON(raw json.RawMessage) string {
505+ if len(raw) == 0 {
506+ return ""
507+ }
508+ var value any
509+ if json.Unmarshal(raw, &value) != nil {
510+ return string(raw)
511+ }
512+ return prettyTimelineValue(value)
513+}
514+
515+func prettyTimelineValue(value any) string {
516+ encoded, err := json.MarshalIndent(value, "", " ")
517+ if err != nil {
518+ return fmt.Sprint(value)
519+ }
520+ return string(encoded)
521+}
522+
523+func estimateTimelineTokens(s string) int {
524+ if s == "" {
525+ return 0
526+ }
527+ return (len([]byte(s)) + 3) / 4
528+}
529+
530+func estimateTimelineMessageTokens(messages []provider.Message) int {
531+ bytes := 0
532+ for _, message := range messages {
533+ bytes += len(message.Role) + 12
534+ for _, content := range message.Content {
535+ switch block := content.(type) {
536+ case provider.TextBlock:
537+ bytes += len(block.Text) + len(block.ThoughtSignature)
538+ case provider.ReasoningBlock:
539+ bytes += len(block.Summary) + len(block.Encrypted)
540+ case provider.ImageBlock:
541+ // JSON transports image bytes as base64, which expands by roughly 4/3.
542+ bytes += (len(block.Data)*4 + 2) / 3
543+ case provider.ToolCallBlock:
544+ bytes += len(block.ID) + len(block.Name) + len(block.Arguments)
545+ case provider.ToolResultBlock:
546+ bytes += len(block.CallID) + estimateTimelineResultBytes(block.Content)
547+ }
548+ }
549+ }
550+ return (bytes + 3) / 4
551+}
552+
553+func estimateTimelineResultBytes(contents []provider.Content) int {
554+ bytes := 0
555+ for _, content := range contents {
556+ switch block := content.(type) {
557+ case provider.TextBlock:
558+ bytes += len(block.Text)
559+ case provider.ImageBlock:
560+ bytes += (len(block.Data)*4 + 2) / 3
561+ }
562+ }
563+ return bytes
564+}
565+
566+func compactTimelineNumber(n int) string {
567+ if n >= 1_000_000 {
568+ return fmt.Sprintf("%.1fm", float64(n)/1_000_000)
569+ }
570+ if n >= 1_000 {
571+ return fmt.Sprintf("%.1fk", float64(n)/1_000)
572+ }
573+ return fmt.Sprintf("%d", n)
574+}
575+
576+func fitTimelineLine(s string, width int) string {
577+ if width < 1 {
578+ return ""
579+ }
580+ if runewidth.StringWidth(s) <= width {
581+ return s
582+ }
583+ return runewidth.Truncate(s, width, "...")
584+}
@@ -0,0 +1,584 @@
1+package modes
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "time"
10+
11+ "github.com/mattn/go-runewidth"
12+
13+ "github.com/patriceckhart/zot/packages/provider"
14+ "github.com/patriceckhart/zot/packages/tui"
15+)
16+
17+const timelineTabCount = 5
18+
19+var timelineTabNames = [timelineTabCount]string{"summary", "payload", "result", "schema", "timing"}
20+
21+type timelineEntry struct {
22+ Kind string `json:"kind"`
23+ Turn int `json:"turn,omitempty"`
24+ Label string `json:"label"`
25+ Summary string `json:"summary"`
26+ Time time.Time `json:"time,omitempty"`
27+ Duration string `json:"duration,omitempty"`
28+ Payload string `json:"payload,omitempty"`
29+ Result string `json:"result,omitempty"`
30+ Schema string `json:"schema,omitempty"`
31+ IsError bool `json:"is_error,omitempty"`
32+ ToolCall string `json:"tool_call_id,omitempty"`
33+}
34+
35+type timelineData struct {
36+ System string
37+ Tools []provider.Tool
38+ Messages []provider.Message
39+ ContextUsed int
40+ ContextMax int
41+}
42+
43+type timelineView struct {
44+ active bool
45+ cursor int
46+ tab int
47+ searching bool
48+ filter string
49+ notice string
50+}
51+
52+type timelineAction struct {
53+ Export bool
54+ Close bool
55+}
56+
57+func newTimelineView() *timelineView { return &timelineView{} }
58+
59+func (v *timelineView) Active() bool { return v != nil && v.active }
60+
61+func (v *timelineView) Open(data timelineData) {
62+ v.active = true
63+ v.searching = false
64+ v.filter = ""
65+ v.notice = ""
66+ v.tab = 0
67+ v.cursor = len(buildTimelineEntries(data)) - 1
68+ if v.cursor < 0 {
69+ v.cursor = 0
70+ }
71+}
72+
73+func (v *timelineView) Close() {
74+ v.active = false
75+ v.searching = false
76+ v.filter = ""
77+ v.notice = ""
78+}
79+
80+func (v *timelineView) SetNotice(s string) { v.notice = s }
81+
82+func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
83+ entries := filterTimelineEntries(buildTimelineEntries(data), v.filter)
84+ clampTimelineCursor(v, len(entries))
85+
86+ switch k.Kind {
87+ case tui.KeyUp:
88+ if v.cursor > 0 {
89+ v.cursor--
90+ }
91+ case tui.KeyDown:
92+ if v.cursor < len(entries)-1 {
93+ v.cursor++
94+ }
95+ case tui.KeyPageUp:
96+ v.cursor -= 8
97+ if v.cursor < 0 {
98+ v.cursor = 0
99+ }
100+ case tui.KeyPageDown:
101+ v.cursor += 8
102+ clampTimelineCursor(v, len(entries))
103+ case tui.KeyTab:
104+ v.tab = (v.tab + 1) % timelineTabCount
105+ case tui.KeyShiftTab:
106+ v.tab = (v.tab + timelineTabCount - 1) % timelineTabCount
107+ case tui.KeyCtrlE:
108+ return timelineAction{Export: true}
109+ case tui.KeyBackspace:
110+ if v.searching && v.filter != "" {
111+ r := []rune(v.filter)
112+ v.filter = string(r[:len(r)-1])
113+ v.cursor = 0
114+ }
115+ case tui.KeyPaste:
116+ if v.searching {
117+ v.filter += singleLinePaste(k.Paste)
118+ v.cursor = 0
119+ }
120+ case tui.KeyRune:
121+ if !v.searching && k.Rune == '/' {
122+ v.searching = true
123+ v.notice = ""
124+ } else if v.searching && !k.Ctrl && !k.Alt && !k.Super {
125+ v.filter += string(k.Rune)
126+ v.cursor = 0
127+ }
128+ case tui.KeyEsc, tui.KeyCtrlC:
129+ if v.searching {
130+ if v.filter != "" {
131+ v.filter = ""
132+ v.cursor = 0
133+ } else {
134+ v.searching = false
135+ }
136+ break
137+ }
138+ v.Close()
139+ return timelineAction{Close: true}
140+ }
141+ return timelineAction{}
142+}
143+
144+func clampTimelineCursor(v *timelineView, n int) {
145+ if n == 0 {
146+ v.cursor = 0
147+ return
148+ }
149+ if v.cursor >= n {
150+ v.cursor = n - 1
151+ }
152+ if v.cursor < 0 {
153+ v.cursor = 0
154+ }
155+}
156+
157+func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData) []string {
158+ if !v.Active() {
159+ return nil
160+ }
161+ if width < 30 {
162+ width = 30
163+ }
164+ entries := filterTimelineEntries(buildTimelineEntries(data), v.filter)
165+ clampTimelineCursor(v, len(entries))
166+
167+ lines := []string{frameHeader(th, "timeline", width)}
168+ lines = append(lines, renderTimelineContext(th, width, data)...)
169+
170+ hint := "up/down select pgup/pgdn move tab details / search ctrl+e export esc chat"
171+ if v.searching {
172+ hint = "search: " + v.filter + "_ (esc clears)"
173+ }
174+ lines = append(lines, th.FG256(th.Muted, fitTimelineLine(hint, width)))
175+
176+ listRows := (height - 14) / 2
177+ if listRows < 3 {
178+ listRows = 3
179+ }
180+ if listRows > 12 {
181+ listRows = 12
182+ }
183+ if len(entries) == 0 {
184+ lines = append(lines, th.FG256(th.Muted, " no matching events"))
185+ } else {
186+ start := v.cursor - listRows/2
187+ if start < 0 {
188+ start = 0
189+ }
190+ end := start + listRows
191+ if end > len(entries) {
192+ end = len(entries)
193+ start = end - listRows
194+ if start < 0 {
195+ start = 0
196+ }
197+ }
198+ for idx := start; idx < end; idx++ {
199+ line := formatTimelineRow(entries[idx], width)
200+ if idx == v.cursor {
201+ lines = append(lines, th.PadHighlight(line, width))
202+ } else {
203+ lines = append(lines, timelineColor(th, entries[idx], line))
204+ }
205+ }
206+ }
207+
208+ lines = append(lines, renderTimelineTabs(th, width, v.tab))
209+ if len(entries) > 0 {
210+ detailRows := height - len(lines) - 7
211+ if detailRows < 3 {
212+ detailRows = 3
213+ }
214+ lines = append(lines, renderTimelineDetail(th, width, entries[v.cursor], v.tab, detailRows)...)
215+ }
216+ if v.notice != "" {
217+ lines = append(lines, th.FG256(th.Tool, fitTimelineLine(v.notice, width)))
218+ }
219+ lines = append(lines, frameRule(th, width))
220+ return lines
221+}
222+
223+func buildTimelineEntries(data timelineData) []timelineEntry {
224+ toolSchemas := make(map[string]string, len(data.Tools))
225+ for _, tool := range data.Tools {
226+ toolSchemas[tool.Name] = prettyTimelineJSON(tool.Schema)
227+ }
228+
229+ results := map[string]provider.ToolResultBlock{}
230+ resultTimes := map[string]time.Time{}
231+ for _, message := range data.Messages {
232+ for _, content := range message.Content {
233+ if result, ok := content.(provider.ToolResultBlock); ok {
234+ results[result.CallID] = result
235+ resultTimes[result.CallID] = message.Time
236+ }
237+ }
238+ }
239+
240+ entries := make([]timelineEntry, 0, len(data.Messages)+1)
241+ if strings.TrimSpace(data.System) != "" {
242+ entries = append(entries, timelineEntry{
243+ Kind: "system", Label: "SYSTEM", Summary: firstTimelineLine(data.System), Payload: data.System,
244+ })
245+ }
246+ turn := 0
247+ var previousTime time.Time
248+ for _, message := range data.Messages {
249+ if message.Role == provider.RoleUser {
250+ turn++
251+ }
252+ textParts := timelineMessageText(message)
253+ if len(textParts) > 0 {
254+ entry := timelineEntry{
255+ Kind: string(message.Role), Turn: turn, Label: strings.ToUpper(string(message.Role)),
256+ Summary: firstTimelineLine(strings.Join(textParts, "\n")), Time: message.Time,
257+ Payload: prettyTimelineValue(timelineMessagePayload(message)),
258+ }
259+ if !previousTime.IsZero() && !message.Time.IsZero() && message.Time.After(previousTime) {
260+ entry.Duration = message.Time.Sub(previousTime).Round(time.Millisecond).String()
261+ }
262+ entries = append(entries, entry)
263+ if !message.Time.IsZero() {
264+ previousTime = message.Time
265+ }
266+ }
267+ for _, content := range message.Content {
268+ call, ok := content.(provider.ToolCallBlock)
269+ if !ok {
270+ continue
271+ }
272+ entry := timelineEntry{
273+ Kind: "tool", Turn: turn, Label: "TOOL " + call.Name, Summary: summarizeToolArguments(call.Arguments),
274+ Time: message.Time, Payload: prettyTimelineJSON(call.Arguments), Schema: toolSchemas[call.Name], ToolCall: call.ID,
275+ }
276+ if result, found := results[call.ID]; found {
277+ entry.Result = prettyTimelineValue(timelineResultPayload(result))
278+ entry.IsError = result.IsError
279+ if ended := resultTimes[call.ID]; !message.Time.IsZero() && ended.After(message.Time) {
280+ entry.Duration = ended.Sub(message.Time).Round(time.Millisecond).String()
281+ }
282+ }
283+ entries = append(entries, entry)
284+ }
285+ }
286+ return entries
287+}
288+
289+func timelineMessageText(message provider.Message) []string {
290+ var out []string
291+ for _, content := range message.Content {
292+ switch block := content.(type) {
293+ case provider.TextBlock:
294+ if strings.TrimSpace(block.Text) != "" {
295+ out = append(out, block.Text)
296+ }
297+ case provider.ReasoningBlock:
298+ if strings.TrimSpace(block.Summary) != "" {
299+ out = append(out, "reasoning: "+block.Summary)
300+ }
301+ case provider.ImageBlock:
302+ out = append(out, fmt.Sprintf("[image %s, %d bytes]", block.MimeType, len(block.Data)))
303+ }
304+ }
305+ return out
306+}
307+
308+func timelineMessagePayload(message provider.Message) any {
309+ blocks := make([]any, 0, len(message.Content))
310+ for _, content := range message.Content {
311+ switch block := content.(type) {
312+ case provider.TextBlock:
313+ blocks = append(blocks, map[string]any{"type": "text", "text": block.Text})
314+ case provider.ReasoningBlock:
315+ blocks = append(blocks, map[string]any{"type": "reasoning", "summary": block.Summary})
316+ case provider.ImageBlock:
317+ blocks = append(blocks, map[string]any{"type": "image", "mime_type": block.MimeType, "bytes": len(block.Data)})
318+ }
319+ }
320+ return map[string]any{"role": message.Role, "content": blocks, "meta": message.Meta}
321+}
322+
323+func timelineResultPayload(result provider.ToolResultBlock) any {
324+ parts := make([]any, 0, len(result.Content))
325+ for _, content := range result.Content {
326+ switch block := content.(type) {
327+ case provider.TextBlock:
328+ parts = append(parts, map[string]any{"type": "text", "text": block.Text})
329+ case provider.ImageBlock:
330+ parts = append(parts, map[string]any{"type": "image", "mime_type": block.MimeType, "bytes": len(block.Data)})
331+ default:
332+ parts = append(parts, fmt.Sprintf("%T", content))
333+ }
334+ }
335+ return map[string]any{"content": parts, "is_error": result.IsError}
336+}
337+
338+func filterTimelineEntries(entries []timelineEntry, filter string) []timelineEntry {
339+ filter = strings.ToLower(strings.TrimSpace(filter))
340+ if filter == "" {
341+ return entries
342+ }
343+ out := make([]timelineEntry, 0, len(entries))
344+ for _, entry := range entries {
345+ haystack := strings.ToLower(strings.Join([]string{entry.Label, entry.Summary, entry.Payload, entry.Result}, "\n"))
346+ if strings.Contains(haystack, filter) {
347+ out = append(out, entry)
348+ }
349+ }
350+ return out
351+}
352+
353+func renderTimelineContext(th tui.Theme, width int, data timelineData) []string {
354+ system := estimateTimelineTokens(data.System)
355+ toolBytes, _ := json.Marshal(data.Tools)
356+ tools := estimateTimelineTokens(string(toolBytes))
357+ messages := estimateTimelineMessageTokens(data.Messages)
358+
359+ usage := fmt.Sprintf("context %s", compactTimelineNumber(data.ContextUsed))
360+ if data.ContextMax > 0 {
361+ pct := float64(data.ContextUsed) * 100 / float64(data.ContextMax)
362+ usage = fmt.Sprintf("context %s / %s (%.1f%%)", compactTimelineNumber(data.ContextUsed), compactTimelineNumber(data.ContextMax), pct)
363+ }
364+ composition := fmt.Sprintf("estimated composition: system %s tools %s messages %s", compactTimelineNumber(system), compactTimelineNumber(tools), compactTimelineNumber(messages))
365+ return []string{tui.Bold(usage), th.FG256(th.Muted, fitTimelineLine(composition, width))}
366+}
367+
368+func formatTimelineRow(entry timelineEntry, width int) string {
369+ stamp := " "
370+ if !entry.Time.IsZero() {
371+ stamp = entry.Time.Format("15:04:05")
372+ }
373+ turn := " "
374+ if entry.Turn > 0 {
375+ turn = fmt.Sprintf("%3d", entry.Turn)
376+ }
377+ line := fmt.Sprintf("%s %s %-16s %s", stamp, turn, entry.Label, entry.Summary)
378+ return fitTimelineLine(line, width)
379+}
380+
381+func timelineColor(th tui.Theme, entry timelineEntry, line string) string {
382+ color := th.Muted
383+ switch entry.Kind {
384+ case "user":
385+ color = th.User
386+ case "assistant":
387+ color = th.Assistant
388+ case "tool":
389+ color = th.Tool
390+ case "system":
391+ color = th.Warning
392+ }
393+ if entry.IsError {
394+ color = th.Error
395+ }
396+ return th.FG256(color, line)
397+}
398+
399+func renderTimelineTabs(th tui.Theme, width, selected int) string {
400+ var tabs []string
401+ for idx, name := range timelineTabNames {
402+ if idx == selected {
403+ tabs = append(tabs, tui.Bold(th.FG256(th.Accent, "["+name+"]")))
404+ } else {
405+ tabs = append(tabs, th.FG256(th.Muted, " "+name+" "))
406+ }
407+ }
408+ return fitTimelineLine(strings.Join(tabs, " "), width)
409+}
410+
411+func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, maxRows int) []string {
412+ var detail string
413+ switch tab {
414+ case 0:
415+ detail = fmt.Sprintf("type: %s\nturn: %d\nstatus: %s\nsummary: %s", entry.Kind, entry.Turn, timelineStatus(entry), entry.Summary)
416+ case 1:
417+ detail = entry.Payload
418+ case 2:
419+ detail = entry.Result
420+ case 3:
421+ detail = entry.Schema
422+ case 4:
423+ started := "not recorded"
424+ if !entry.Time.IsZero() {
425+ started = entry.Time.Format(time.RFC3339Nano)
426+ }
427+ duration := entry.Duration
428+ if duration == "" {
429+ duration = "not recorded"
430+ }
431+ detail = "started: " + started + "\nduration: " + duration + "\nsource: transcript timestamps"
432+ }
433+ if strings.TrimSpace(detail) == "" {
434+ detail = "not available for this event"
435+ }
436+ var rows []string
437+ for _, line := range strings.Split(detail, "\n") {
438+ for _, wrapped := range tui.WrapANSILine(line, width-2) {
439+ rows = append(rows, " "+th.FG256(th.ToolOut, wrapped))
440+ if len(rows) >= maxRows {
441+ return rows
442+ }
443+ }
444+ }
445+ return rows
446+}
447+
448+func timelineStatus(entry timelineEntry) string {
449+ if entry.IsError {
450+ return "error"
451+ }
452+ if entry.Kind == "tool" && entry.Result == "" {
453+ return "result unavailable"
454+ }
455+ return "completed"
456+}
457+
458+func (v *timelineView) Export(dir string, data timelineData) (string, error) {
459+ if dir == "" {
460+ dir = defaultExportDir()
461+ }
462+ if err := os.MkdirAll(dir, 0o755); err != nil {
463+ return "", fmt.Errorf("create export directory: %w", err)
464+ }
465+ payload := struct {
466+ ExportedAt time.Time `json:"exported_at"`
467+ ContextUsed int `json:"context_used"`
468+ ContextMax int `json:"context_max"`
469+ Events []timelineEntry `json:"events"`
470+ }{time.Now(), data.ContextUsed, data.ContextMax, buildTimelineEntries(data)}
471+ encoded, err := json.MarshalIndent(payload, "", " ")
472+ if err != nil {
473+ return "", fmt.Errorf("encode timeline: %w", err)
474+ }
475+ name := "zot-timeline-" + time.Now().Format("20060102-150405.000000000") + ".json"
476+ path := filepath.Join(dir, name)
477+ if err := os.WriteFile(path, append(encoded, '\n'), 0o600); err != nil {
478+ return "", fmt.Errorf("write timeline: %w", err)
479+ }
480+ return path, nil
481+}
482+
483+func firstTimelineLine(s string) string {
484+ for _, line := range strings.Split(s, "\n") {
485+ if line = strings.TrimSpace(line); line != "" {
486+ return fitTimelineLine(line, 120)
487+ }
488+ }
489+ return "(empty)"
490+}
491+
492+func summarizeToolArguments(raw json.RawMessage) string {
493+ var object map[string]any
494+ if json.Unmarshal(raw, &object) == nil {
495+ for _, key := range []string{"command", "path", "query", "pattern"} {
496+ if value, ok := object[key]; ok {
497+ return firstTimelineLine(fmt.Sprint(value))
498+ }
499+ }
500+ }
501+ return firstTimelineLine(string(raw))
502+}
503+
504+func prettyTimelineJSON(raw json.RawMessage) string {
505+ if len(raw) == 0 {
506+ return ""
507+ }
508+ var value any
509+ if json.Unmarshal(raw, &value) != nil {
510+ return string(raw)
511+ }
512+ return prettyTimelineValue(value)
513+}
514+
515+func prettyTimelineValue(value any) string {
516+ encoded, err := json.MarshalIndent(value, "", " ")
517+ if err != nil {
518+ return fmt.Sprint(value)
519+ }
520+ return string(encoded)
521+}
522+
523+func estimateTimelineTokens(s string) int {
524+ if s == "" {
525+ return 0
526+ }
527+ return (len([]byte(s)) + 3) / 4
528+}
529+
530+func estimateTimelineMessageTokens(messages []provider.Message) int {
531+ bytes := 0
532+ for _, message := range messages {
533+ bytes += len(message.Role) + 12
534+ for _, content := range message.Content {
535+ switch block := content.(type) {
536+ case provider.TextBlock:
537+ bytes += len(block.Text) + len(block.ThoughtSignature)
538+ case provider.ReasoningBlock:
539+ bytes += len(block.Summary) + len(block.Encrypted)
540+ case provider.ImageBlock:
541+ // JSON transports image bytes as base64, which expands by roughly 4/3.
542+ bytes += (len(block.Data)*4 + 2) / 3
543+ case provider.ToolCallBlock:
544+ bytes += len(block.ID) + len(block.Name) + len(block.Arguments)
545+ case provider.ToolResultBlock:
546+ bytes += len(block.CallID) + estimateTimelineResultBytes(block.Content)
547+ }
548+ }
549+ }
550+ return (bytes + 3) / 4
551+}
552+
553+func estimateTimelineResultBytes(contents []provider.Content) int {
554+ bytes := 0
555+ for _, content := range contents {
556+ switch block := content.(type) {
557+ case provider.TextBlock:
558+ bytes += len(block.Text)
559+ case provider.ImageBlock:
560+ bytes += (len(block.Data)*4 + 2) / 3
561+ }
562+ }
563+ return bytes
564+}
565+
566+func compactTimelineNumber(n int) string {
567+ if n >= 1_000_000 {
568+ return fmt.Sprintf("%.1fm", float64(n)/1_000_000)
569+ }
570+ if n >= 1_000 {
571+ return fmt.Sprintf("%.1fk", float64(n)/1_000)
572+ }
573+ return fmt.Sprintf("%d", n)
574+}
575+
576+func fitTimelineLine(s string, width int) string {
577+ if width < 1 {
578+ return ""
579+ }
580+ if runewidth.StringWidth(s) <= width {
581+ return s
582+ }
583+ return runewidth.Truncate(s, width, "...")
584+}
A packages/agent/modes/timeline_view_test.go +139 -0
@@ -0,0 +1,139 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/provider"
12+ "github.com/patriceckhart/zot/packages/tui"
13+)
14+
15+func timelineTestData() timelineData {
16+ started := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
17+ finished := started.Add(1250 * time.Millisecond)
18+ return timelineData{
19+ System: "You are zot.",
20+ Tools: []provider.Tool{{
21+ Name: "bash", Schema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}}}`),
22+ }},
23+ Messages: []provider.Message{
24+ {Role: provider.RoleUser, Time: started.Add(-time.Second), Content: []provider.Content{provider.TextBlock{Text: "list files"}}},
25+ {Role: provider.RoleAssistant, Time: started, Content: []provider.Content{
26+ provider.TextBlock{Text: "I will inspect them."},
27+ provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls -la"}`)},
28+ }},
29+ {Role: provider.RoleTool, Time: finished, Content: []provider.Content{
30+ provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "README.md"}}},
31+ }},
32+ },
33+ ContextUsed: 1200,
34+ ContextMax: 200000,
35+ }
36+}
37+
38+func TestSessionTimelineOpensAlternateView(t *testing.T) {
39+ interactive := &Interactive{timeline: newTimelineView()}
40+ interactive.runSlash(context.Background(), "/session timeline")
41+ if !interactive.timeline.Active() {
42+ t.Fatal("/session timeline did not open the timeline view")
43+ }
44+}
45+
46+func TestSessionPickerIncludesTimeline(t *testing.T) {
47+ interactive := &Interactive{sessionOpsDialog: newSessionOpsDialog()}
48+ interactive.openSessionOpsDialog()
49+ if len(interactive.sessionOpsDialog.items) == 0 || interactive.sessionOpsDialog.items[0].action != "timeline" {
50+ t.Fatalf("session actions = %+v, want timeline first", interactive.sessionOpsDialog.items)
51+ }
52+}
53+
54+func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) {
55+ entries := buildTimelineEntries(timelineTestData())
56+ if len(entries) != 4 {
57+ t.Fatalf("entries = %d, want system, user, assistant, and tool", len(entries))
58+ }
59+ tool := entries[3]
60+ if tool.Kind != "tool" || tool.ToolCall != "call-1" {
61+ t.Fatalf("tool entry = %+v", tool)
62+ }
63+ if !strings.Contains(tool.Payload, "ls -la") || !strings.Contains(tool.Result, "README.md") {
64+ t.Fatalf("tool details missing: %+v", tool)
65+ }
66+ if !strings.Contains(tool.Schema, "command") {
67+ t.Fatalf("tool schema missing: %q", tool.Schema)
68+ }
69+ if tool.Duration != "1.25s" {
70+ t.Fatalf("duration = %q, want 1.25s", tool.Duration)
71+ }
72+}
73+
74+func TestTimelineSearchAndTabs(t *testing.T) {
75+ data := timelineTestData()
76+ view := newTimelineView()
77+ view.Open(data)
78+ view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}, data)
79+ for _, r := range "readme" {
80+ view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}, data)
81+ }
82+ matches := filterTimelineEntries(buildTimelineEntries(data), view.filter)
83+ if len(matches) != 1 || matches[0].Kind != "tool" {
84+ t.Fatalf("matches = %+v, want tool result", matches)
85+ }
86+ view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
87+ if view.tab != 1 {
88+ t.Fatalf("tab = %d, want payload tab", view.tab)
89+ }
90+ view.HandleKey(tui.Key{Kind: tui.KeyEsc}, data)
91+ if view.filter != "" || !view.Active() {
92+ t.Fatal("first esc should clear search without closing timeline")
93+ }
94+}
95+
96+func TestTimelineRenderShowsContextAndControls(t *testing.T) {
97+ data := timelineTestData()
98+ if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 {
99+ t.Fatalf("context rows = %d, want usage and composition only", len(contextLines))
100+ }
101+ view := newTimelineView()
102+ view.Open(data)
103+ rendered := strings.Join(view.Render(tui.Theme{}, 100, 40, data), "\n")
104+ for _, want := range []string{"timeline", "context 1.2k / 200.0k", "estimated composition", "ctrl+e export", "TOOL bash"} {
105+ if !strings.Contains(rendered, want) {
106+ t.Fatalf("render missing %q:\n%s", want, rendered)
107+ }
108+ }
109+}
110+
111+func TestTimelineExportOmitsImageDataAndUsesPrivatePermissions(t *testing.T) {
112+ data := timelineTestData()
113+ data.Messages = append(data.Messages, provider.Message{
114+ Role: provider.RoleUser,
115+ Content: []provider.Content{provider.ImageBlock{MimeType: "image/png", Data: []byte("secret-image-bytes")}},
116+ })
117+ view := newTimelineView()
118+ path, err := view.Export(t.TempDir(), data)
119+ if err != nil {
120+ t.Fatal(err)
121+ }
122+ contents, err := os.ReadFile(path)
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ if strings.Contains(string(contents), "secret-image-bytes") {
127+ t.Fatal("export embedded image data")
128+ }
129+ if !strings.Contains(string(contents), "image/png") {
130+ t.Fatal("export omitted image metadata")
131+ }
132+ info, err := os.Stat(path)
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ if info.Mode().Perm() != 0o600 {
137+ t.Fatalf("permissions = %o, want 600", info.Mode().Perm())
138+ }
139+}
@@ -0,0 +1,139 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/provider"
12+ "github.com/patriceckhart/zot/packages/tui"
13+)
14+
15+func timelineTestData() timelineData {
16+ started := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
17+ finished := started.Add(1250 * time.Millisecond)
18+ return timelineData{
19+ System: "You are zot.",
20+ Tools: []provider.Tool{{
21+ Name: "bash", Schema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}}}`),
22+ }},
23+ Messages: []provider.Message{
24+ {Role: provider.RoleUser, Time: started.Add(-time.Second), Content: []provider.Content{provider.TextBlock{Text: "list files"}}},
25+ {Role: provider.RoleAssistant, Time: started, Content: []provider.Content{
26+ provider.TextBlock{Text: "I will inspect them."},
27+ provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls -la"}`)},
28+ }},
29+ {Role: provider.RoleTool, Time: finished, Content: []provider.Content{
30+ provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "README.md"}}},
31+ }},
32+ },
33+ ContextUsed: 1200,
34+ ContextMax: 200000,
35+ }
36+}
37+
38+func TestSessionTimelineOpensAlternateView(t *testing.T) {
39+ interactive := &Interactive{timeline: newTimelineView()}
40+ interactive.runSlash(context.Background(), "/session timeline")
41+ if !interactive.timeline.Active() {
42+ t.Fatal("/session timeline did not open the timeline view")
43+ }
44+}
45+
46+func TestSessionPickerIncludesTimeline(t *testing.T) {
47+ interactive := &Interactive{sessionOpsDialog: newSessionOpsDialog()}
48+ interactive.openSessionOpsDialog()
49+ if len(interactive.sessionOpsDialog.items) == 0 || interactive.sessionOpsDialog.items[0].action != "timeline" {
50+ t.Fatalf("session actions = %+v, want timeline first", interactive.sessionOpsDialog.items)
51+ }
52+}
53+
54+func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) {
55+ entries := buildTimelineEntries(timelineTestData())
56+ if len(entries) != 4 {
57+ t.Fatalf("entries = %d, want system, user, assistant, and tool", len(entries))
58+ }
59+ tool := entries[3]
60+ if tool.Kind != "tool" || tool.ToolCall != "call-1" {
61+ t.Fatalf("tool entry = %+v", tool)
62+ }
63+ if !strings.Contains(tool.Payload, "ls -la") || !strings.Contains(tool.Result, "README.md") {
64+ t.Fatalf("tool details missing: %+v", tool)
65+ }
66+ if !strings.Contains(tool.Schema, "command") {
67+ t.Fatalf("tool schema missing: %q", tool.Schema)
68+ }
69+ if tool.Duration != "1.25s" {
70+ t.Fatalf("duration = %q, want 1.25s", tool.Duration)
71+ }
72+}
73+
74+func TestTimelineSearchAndTabs(t *testing.T) {
75+ data := timelineTestData()
76+ view := newTimelineView()
77+ view.Open(data)
78+ view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}, data)
79+ for _, r := range "readme" {
80+ view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}, data)
81+ }
82+ matches := filterTimelineEntries(buildTimelineEntries(data), view.filter)
83+ if len(matches) != 1 || matches[0].Kind != "tool" {
84+ t.Fatalf("matches = %+v, want tool result", matches)
85+ }
86+ view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
87+ if view.tab != 1 {
88+ t.Fatalf("tab = %d, want payload tab", view.tab)
89+ }
90+ view.HandleKey(tui.Key{Kind: tui.KeyEsc}, data)
91+ if view.filter != "" || !view.Active() {
92+ t.Fatal("first esc should clear search without closing timeline")
93+ }
94+}
95+
96+func TestTimelineRenderShowsContextAndControls(t *testing.T) {
97+ data := timelineTestData()
98+ if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 {
99+ t.Fatalf("context rows = %d, want usage and composition only", len(contextLines))
100+ }
101+ view := newTimelineView()
102+ view.Open(data)
103+ rendered := strings.Join(view.Render(tui.Theme{}, 100, 40, data), "\n")
104+ for _, want := range []string{"timeline", "context 1.2k / 200.0k", "estimated composition", "ctrl+e export", "TOOL bash"} {
105+ if !strings.Contains(rendered, want) {
106+ t.Fatalf("render missing %q:\n%s", want, rendered)
107+ }
108+ }
109+}
110+
111+func TestTimelineExportOmitsImageDataAndUsesPrivatePermissions(t *testing.T) {
112+ data := timelineTestData()
113+ data.Messages = append(data.Messages, provider.Message{
114+ Role: provider.RoleUser,
115+ Content: []provider.Content{provider.ImageBlock{MimeType: "image/png", Data: []byte("secret-image-bytes")}},
116+ })
117+ view := newTimelineView()
118+ path, err := view.Export(t.TempDir(), data)
119+ if err != nil {
120+ t.Fatal(err)
121+ }
122+ contents, err := os.ReadFile(path)
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ if strings.Contains(string(contents), "secret-image-bytes") {
127+ t.Fatal("export embedded image data")
128+ }
129+ if !strings.Contains(string(contents), "image/png") {
130+ t.Fatal("export omitted image metadata")
131+ }
132+ info, err := os.Stat(path)
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ if info.Mode().Perm() != 0o600 {
137+ t.Fatalf("permissions = %o, want 600", info.Mode().Perm())
138+ }
139+}
M packages/core/agent.go +11 -0
@@ -212,6 +212,17 @@ func (a *Agent) Messages() []provider.Message {
212212 return out
213213 }
214214
215+// ContextSnapshot returns the provider-neutral inputs that make up the
216+// current model context. Hosts use the snapshot for read-only inspection
217+// without racing a tool-registry replacement or transcript append.
218+func (a *Agent) ContextSnapshot() (system string, tools []provider.Tool, messages []provider.Message) {
219+ a.mu.Lock()
220+ defer a.mu.Unlock()
221+ messages = make([]provider.Message, len(a.messages))
222+ copy(messages, a.messages)
223+ return a.System, a.Tools.Specs(), messages
224+}
225+
215226 // Revision returns a monotonically increasing transcript version.
216227 // It is cheap to query and changes whenever Messages() would return
217228 // different transcript content because of append/set operations.
@@ -212,6 +212,17 @@ func (a *Agent) Messages() []provider.Message {
212 return out212 return out
213 }213 }
214 214
215+// ContextSnapshot returns the provider-neutral inputs that make up the
216+// current model context. Hosts use the snapshot for read-only inspection
217+// without racing a tool-registry replacement or transcript append.
218+func (a *Agent) ContextSnapshot() (system string, tools []provider.Tool, messages []provider.Message) {
219+ a.mu.Lock()
220+ defer a.mu.Unlock()
221+ messages = make([]provider.Message, len(a.messages))
222+ copy(messages, a.messages)
223+ return a.System, a.Tools.Specs(), messages
224+}
225+
215 // Revision returns a monotonically increasing transcript version.226 // Revision returns a monotonically increasing transcript version.
216 // It is cheap to query and changes whenever Messages() would return227 // It is cheap to query and changes whenever Messages() would return
217 // different transcript content because of append/set operations.228 // different transcript content because of append/set operations.

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