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 | |||
| 293 | 293 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | |
| 294 | 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 | 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 | 297 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | |
| 298 | 298 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | |
| 299 | 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 | 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 | 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 | 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. | |
| @@ -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 { | |||
| 482 | 482 | telegramBridge *telegram.Bridge | |
| 483 | 483 | sessionOpsDialog *sessionOpsDialog | |
| 484 | 484 | sessionTreeDialog *sessionTreeDialog | |
| 485 | + | timeline *timelineView | |
| 485 | 486 | extPanel *extPanelDialog | |
| 486 | 487 | llamaConfigured bool | |
| 487 | 488 | ||
| @@ -643,6 +644,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 643 | 644 | settingsDialog: newSettingsDialog(), | |
| 644 | 645 | sessionOpsDialog: newSessionOpsDialog(), | |
| 645 | 646 | sessionTreeDialog: newSessionTreeDialog(), | |
| 647 | + | timeline: newTimelineView(), | |
| 646 | 648 | extPanel: newExtPanelDialog(), | |
| 647 | 649 | suggest: newSlashSuggester(), | |
| 648 | 650 | fileSuggest: newFileSuggester(), | |
| @@ -972,7 +974,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) { | |||
| 972 | 974 | // Live turns mutate streaming/tool-call state at high frequency; | |
| 973 | 975 | // keep those on the old rebuild path. The cache targets the common | |
| 974 | 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 | 978 | return chatCacheKey{}, false | |
| 977 | 979 | } | |
| 978 | 980 | var rev uint64 | |
| @@ -1004,6 +1006,10 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 1004 | 1006 | } else { | |
| 1005 | 1007 | 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 | 1013 | // Pacer flush: while the streaming pacer is still draining the | |
| 1008 | 1014 | // buffer (i.e. EvAssistantMessage already fired but more runes | |
| 1009 | 1015 | // are queued), the final assistant message is already in | |
| @@ -1163,6 +1169,25 @@ func (i *Interactive) lastCols() int { | |||
| 1163 | 1169 | 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 | 1191 | // chatPage returns the number of chat rows currently visible, used | |
| 1167 | 1192 | // as the page size for PageUp/PageDown. | |
| 1168 | 1193 | func (i *Interactive) chatPage() int { | |
| @@ -2027,6 +2052,7 @@ func (i *Interactive) confirmChildActive() bool { | |||
| 2027 | 2052 | i.settingsDialog.Active() || | |
| 2028 | 2053 | i.sessionOpsDialog.Active() || | |
| 2029 | 2054 | i.sessionTreeDialog.Active() || | |
| 2055 | + | i.timeline.Active() || | |
| 2030 | 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 | 2322 | 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 | 2342 | if i.jumpDialog.Active() { | |
| 2299 | 2343 | if k.Kind == tui.KeyCtrlC { | |
| 2300 | 2344 | i.jumpDialog.Close() | |
| @@ -6670,11 +6714,12 @@ func (h *telegramHost) Notify(level, message string) { | |||
| 6670 | 6714 | } | |
| 6671 | 6715 | ||
| 6672 | 6716 | // 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). | |
| 6676 | 6720 | func (i *Interactive) openSessionOpsDialog() { | |
| 6677 | 6721 | items := []sessionOpsItem{ | |
| 6722 | + | {label: "timeline", action: "timeline", hint: "inspect context, messages, and tool calls"}, | |
| 6678 | 6723 | {label: "export", action: "export", hint: "write the current session to a .zotsession file"}, | |
| 6679 | 6724 | {label: "import", action: "import", hint: "load a .zotsession file into this directory"}, | |
| 6680 | 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 | 6729 | i.invalidate() | |
| 6685 | 6730 | } | |
| 6686 | 6731 | ||
| 6687 | - | // doSessionOp dispatches export, import, fork, or tree. arg is the | |
| 6732 | + | // doSessionOp dispatches timeline, export, import, fork, or tree. arg is the | |
| 6688 | 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 | 6735 | func (i *Interactive) doSessionOp(action, arg string) { | |
| 6691 | 6736 | switch action { | |
| 6737 | + | case "timeline": | |
| 6738 | + | i.openTimeline() | |
| 6692 | 6739 | case "export": | |
| 6693 | 6740 | i.doSessionExport(arg) | |
| 6694 | 6741 | case "import": | |
| @@ -6699,12 +6746,26 @@ func (i *Interactive) doSessionOp(action, arg string) { | |||
| 6699 | 6746 | i.doSessionTree() | |
| 6700 | 6747 | default: | |
| 6701 | 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 | 6750 | i.mu.Unlock() | |
| 6704 | 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 | 6769 | // doSessionExport writes the live session file to destination path | |
| 6709 | 6770 | // dst. When dst is empty we default to ~/Downloads (falling back to | |
| 6710 | 6771 | // the user's home directory if it doesn't exist). The helper | |
| @@ -482,6 +482,7 @@ type Interactive struct { | |||||
| 482 | telegramBridge *telegram.Bridge | 482 | telegramBridge *telegram.Bridge | ||
| 483 | sessionOpsDialog *sessionOpsDialog | 483 | sessionOpsDialog *sessionOpsDialog | ||
| 484 | sessionTreeDialog *sessionTreeDialog | 484 | sessionTreeDialog *sessionTreeDialog | ||
| 485 | + | timeline *timelineView | |||
| 485 | extPanel *extPanelDialog | 486 | extPanel *extPanelDialog | ||
| 486 | llamaConfigured bool | 487 | 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 common | 975 | // 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{}, false | 978 | return chatCacheKey{}, false | ||
| 977 | } | 979 | } | ||
| 978 | var rev uint64 | 980 | var rev uint64 | ||
| @@ -1004,6 +1006,10 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 1004 | } else { | 1006 | } else { | ||
| 1005 | i.view.Messages = nil | 1007 | 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 the | 1013 | // Pacer flush: while the streaming pacer is still draining the | ||
| 1008 | // buffer (i.e. EvAssistantMessage already fired but more runes | 1014 | // buffer (i.e. EvAssistantMessage already fired but more runes | ||
| 1009 | // are queued), the final assistant message is already in | 1015 | // are queued), the final assistant message is already in | ||
| @@ -1163,6 +1169,25 @@ func (i *Interactive) lastCols() int { | |||||
| 1163 | return cols | 1169 | 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, used | 1191 | // 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 false | 2322 | 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 with | 6717 | + | // Always offers timeline, export, import, fork, and tree. The handlers bail |
| 6674 | - | // a clear status message when the precondition isn't met (empty | 6718 | + | // 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 the | 6732 | + | // 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 path | 6769 | // doSessionExport writes the live session file to destination path | ||
| 6709 | // dst. When dst is empty we default to ~/Downloads (falling back to | 6770 | // dst. When dst is empty we default to ~/Downloads (falling back to | ||
| 6710 | // the user's home directory if it doesn't exist). The helper | 6771 | // 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 ( | |||
| 5 | 5 | ) | |
| 6 | 6 | ||
| 7 | 7 | // 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. | |
| 12 | 10 | // | |
| 13 | 11 | // Shape mirrors telegramDialog and logoutDialog: tiny list, arrow | |
| 14 | 12 | // keys to move, enter to pick, esc to cancel. | |
| @@ -20,7 +18,7 @@ type sessionOpsDialog struct { | |||
| 20 | 18 | ||
| 21 | 19 | type sessionOpsItem struct { | |
| 22 | 20 | label string | |
| 23 | - | action string // "export" | "import" | |
| 21 | + | action string // "timeline" | "export" | "import" | "fork" | "tree" | |
| 24 | 22 | hint string | |
| 25 | 23 | } | |
| 26 | 24 | ||
| @@ -32,9 +30,7 @@ type sessionOpsAction struct { | |||
| 32 | 30 | ||
| 33 | 31 | func newSessionOpsDialog() *sessionOpsDialog { return &sessionOpsDialog{} } | |
| 34 | 32 | ||
| 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. | |
| 38 | 34 | func (d *sessionOpsDialog) Open(items []sessionOpsItem) bool { | |
| 39 | 35 | if len(items) == 0 { | |
| 40 | 36 | 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 on | 8 | + | // without an argument. It offers timeline inspection, portable-file |
| 9 | - | // the current conversation: export (write the in-memory transcript | 9 | + | // 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, arrow | 11 | // 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 string | 20 | label string | ||
| 23 | - | action string // "export" | "import" | 21 | + | action string // "timeline" | "export" | "import" | "fork" | "tree" |
| 24 | hint string | 22 | 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" and | 33 | + | // 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 false | 36 | return false | ||
M packages/agent/modes/slash_suggest.go +1 -1
| @@ -42,7 +42,7 @@ var slashCatalog = []slashCommand{ | |||
| 42 | 42 | {Name: "/reasoning", Desc: "set the reasoning level"}, | |
| 43 | 43 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | |
| 44 | 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 | 46 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | |
| 47 | 47 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | |
| 48 | 48 | {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 { | |||
| 212 | 212 | 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 | 226 | // Revision returns a monotonically increasing transcript version. | |
| 216 | 227 | // It is cheap to query and changes whenever Messages() would return | |
| 217 | 228 | // different transcript content because of append/set operations. | |
| @@ -212,6 +212,17 @@ func (a *Agent) Messages() []provider.Message { | |||||
| 212 | return out | 212 | 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 return | 227 | // 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. | ||