Improve reasoning readability in session timeline
- commit
- e5506c188cda27aad7d6ea91021920114c432a7e
- author
- patriceckhart · · 2026-09-28 19:15 UTC
- parent
- 2a19df1
- signature
- unsigned
- refs
- tag: v0.4.4
3 files · +85 -12
M README.md +1 -1
| @@ -382,7 +382,7 @@ Shows previous sessions for the current working directory, newest first, with ti | |||
| 382 | 382 | ||
| 383 | 383 | Five ops on the current session. `/session` alone opens a picker; each is also runnable directly. | |
| 384 | 384 | ||
| 385 | - | - **`/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 JSON export to `~/Downloads` (falling back to the home directory) with `0600` permissions on platforms that support Unix file modes. Images are represented by MIME type and byte size instead of embedding their data. Press `esc` to return to chat. | |
| 385 | + | - **`/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, `left` / `right` to scroll detail one line, `home` / `end` to jump within detail, and `/` to search. Available reasoning summaries appear as separate events with Markdown rendering, but the timeline shows saved context only, not a live reasoning stream. Providers may not expose readable reasoning. `ctrl+e` writes a JSON export to `~/Downloads` (falling back to the home directory) with `0600` permissions on platforms that support Unix file modes. Images are represented by MIME type and byte size instead of embedding their data. Press `esc` to return to chat. | |
| 386 | 386 | - **`/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. | |
| 387 | 387 | ||
| 388 | 388 | **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. | |
| @@ -382,7 +382,7 @@ Shows previous sessions for the current working directory, newest first, with ti | |||||
| 382 | 382 | ||||
| 383 | Five ops on the current session. `/session` alone opens a picker; each is also runnable directly. | 383 | Five ops on the current session. `/session` alone opens a picker; each is also runnable directly. | ||
| 384 | 384 | ||||
| 385 | - | - **`/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 JSON export to `~/Downloads` (falling back to the home directory) with `0600` permissions on platforms that support Unix file modes. Images are represented by MIME type and byte size instead of embedding their data. Press `esc` to return to chat. | 385 | + | - **`/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, `left` / `right` to scroll detail one line, `home` / `end` to jump within detail, and `/` to search. Available reasoning summaries appear as separate events with Markdown rendering, but the timeline shows saved context only, not a live reasoning stream. Providers may not expose readable reasoning. `ctrl+e` writes a JSON export to `~/Downloads` (falling back to the home directory) with `0600` permissions on platforms that support Unix file modes. Images are represented by MIME type and byte size instead of embedding their data. Press `esc` to return to chat. |
| 386 | - **`/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. | 386 | - **`/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. | ||
| 387 | 387 | ||||
| 388 | **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. | 388 | **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/timeline_view.go +44 -11
| @@ -44,6 +44,7 @@ type timelineView struct { | |||
| 44 | 44 | active bool | |
| 45 | 45 | cursor int | |
| 46 | 46 | tab int | |
| 47 | + | detailTop int | |
| 47 | 48 | searching bool | |
| 48 | 49 | filter string | |
| 49 | 50 | notice string | |
| @@ -64,6 +65,7 @@ func (v *timelineView) Open(data timelineData) { | |||
| 64 | 65 | v.filter = "" | |
| 65 | 66 | v.notice = "" | |
| 66 | 67 | v.tab = 0 | |
| 68 | + | v.detailTop = 0 | |
| 67 | 69 | v.cursor = len(buildTimelineEntries(data)) - 1 | |
| 68 | 70 | if v.cursor < 0 { | |
| 69 | 71 | v.cursor = 0 | |
| @@ -75,6 +77,7 @@ func (v *timelineView) Close() { | |||
| 75 | 77 | v.searching = false | |
| 76 | 78 | v.filter = "" | |
| 77 | 79 | v.notice = "" | |
| 80 | + | v.detailTop = 0 | |
| 78 | 81 | } | |
| 79 | 82 | ||
| 80 | 83 | func (v *timelineView) SetNotice(s string) { v.notice = s } | |
| @@ -83,7 +86,18 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction { | |||
| 83 | 86 | entries := filterTimelineEntries(buildTimelineEntries(data), v.filter) | |
| 84 | 87 | clampTimelineCursor(v, len(entries)) | |
| 85 | 88 | ||
| 89 | + | previousCursor, previousTab := v.cursor, v.tab | |
| 86 | 90 | switch k.Kind { | |
| 91 | + | case tui.KeyLeft: | |
| 92 | + | if v.detailTop > 0 { | |
| 93 | + | v.detailTop-- | |
| 94 | + | } | |
| 95 | + | case tui.KeyRight: | |
| 96 | + | v.detailTop++ | |
| 97 | + | case tui.KeyHome: | |
| 98 | + | v.detailTop = 0 | |
| 99 | + | case tui.KeyEnd: | |
| 100 | + | v.detailTop = int(^uint(0) >> 1) | |
| 87 | 101 | case tui.KeyUp: | |
| 88 | 102 | if v.cursor > 0 { | |
| 89 | 103 | v.cursor-- | |
| @@ -111,11 +125,13 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction { | |||
| 111 | 125 | r := []rune(v.filter) | |
| 112 | 126 | v.filter = string(r[:len(r)-1]) | |
| 113 | 127 | v.cursor = 0 | |
| 128 | + | v.detailTop = 0 | |
| 114 | 129 | } | |
| 115 | 130 | case tui.KeyPaste: | |
| 116 | 131 | if v.searching { | |
| 117 | 132 | v.filter += singleLinePaste(k.Paste) | |
| 118 | 133 | v.cursor = 0 | |
| 134 | + | v.detailTop = 0 | |
| 119 | 135 | } | |
| 120 | 136 | case tui.KeyRune: | |
| 121 | 137 | if !v.searching && k.Rune == '/' { | |
| @@ -124,12 +140,14 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction { | |||
| 124 | 140 | } else if v.searching && !k.Ctrl && !k.Alt && !k.Super { | |
| 125 | 141 | v.filter += string(k.Rune) | |
| 126 | 142 | v.cursor = 0 | |
| 143 | + | v.detailTop = 0 | |
| 127 | 144 | } | |
| 128 | 145 | case tui.KeyEsc, tui.KeyCtrlC: | |
| 129 | 146 | if v.searching { | |
| 130 | 147 | if v.filter != "" { | |
| 131 | 148 | v.filter = "" | |
| 132 | 149 | v.cursor = 0 | |
| 150 | + | v.detailTop = 0 | |
| 133 | 151 | } else { | |
| 134 | 152 | v.searching = false | |
| 135 | 153 | } | |
| @@ -138,6 +156,9 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction { | |||
| 138 | 156 | v.Close() | |
| 139 | 157 | return timelineAction{Close: true} | |
| 140 | 158 | } | |
| 159 | + | if v.cursor != previousCursor || v.tab != previousTab { | |
| 160 | + | v.detailTop = 0 | |
| 161 | + | } | |
| 141 | 162 | return timelineAction{} | |
| 142 | 163 | } | |
| 143 | 164 | ||
| @@ -167,7 +188,7 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData | |||
| 167 | 188 | lines := []string{frameHeader(th, "timeline", width)} | |
| 168 | 189 | lines = append(lines, renderTimelineContext(th, width, data)...) | |
| 169 | 190 | ||
| 170 | - | hint := "↑/↓ select pgup/pgdn move tab details / search ctrl+e export esc chat" | |
| 191 | + | hint := "↑/↓ select tab details ←/→ scroll detail home/end / search ctrl+e export esc chat" | |
| 171 | 192 | if v.searching { | |
| 172 | 193 | hint = "search: " + v.filter + "_ (esc clears)" | |
| 173 | 194 | } | |
| @@ -211,7 +232,15 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData | |||
| 211 | 232 | if detailRows < 3 { | |
| 212 | 233 | detailRows = 3 | |
| 213 | 234 | } | |
| 214 | - | lines = append(lines, renderTimelineDetail(th, width, entries[v.cursor], v.tab, detailRows)...) | |
| 235 | + | detail := timelineDetailRows(th, width, entries[v.cursor], v.tab) | |
| 236 | + | maxTop := len(detail) - detailRows | |
| 237 | + | if maxTop < 0 { | |
| 238 | + | maxTop = 0 | |
| 239 | + | } | |
| 240 | + | if v.detailTop > maxTop { | |
| 241 | + | v.detailTop = maxTop | |
| 242 | + | } | |
| 243 | + | lines = append(lines, detail[v.detailTop:min(v.detailTop+detailRows, len(detail))]...) | |
| 215 | 244 | } | |
| 216 | 245 | if v.notice != "" { | |
| 217 | 246 | lines = append(lines, th.FG256(th.Tool, fitTimelineLine(v.notice, width))) | |
| @@ -249,6 +278,14 @@ func buildTimelineEntries(data timelineData) []timelineEntry { | |||
| 249 | 278 | if message.Role == provider.RoleUser { | |
| 250 | 279 | turn++ | |
| 251 | 280 | } | |
| 281 | + | for _, content := range message.Content { | |
| 282 | + | if block, ok := content.(provider.ReasoningBlock); ok && strings.TrimSpace(block.Summary) != "" { | |
| 283 | + | entries = append(entries, timelineEntry{ | |
| 284 | + | Kind: "reasoning", Turn: turn, Label: "REASONING", Summary: firstTimelineLine(block.Summary), | |
| 285 | + | Time: message.Time, Payload: block.Summary, | |
| 286 | + | }) | |
| 287 | + | } | |
| 288 | + | } | |
| 252 | 289 | textParts := timelineMessageText(message) | |
| 253 | 290 | if len(textParts) > 0 { | |
| 254 | 291 | entry := timelineEntry{ | |
| @@ -294,10 +331,6 @@ func timelineMessageText(message provider.Message) []string { | |||
| 294 | 331 | if strings.TrimSpace(block.Text) != "" { | |
| 295 | 332 | out = append(out, block.Text) | |
| 296 | 333 | } | |
| 297 | - | case provider.ReasoningBlock: | |
| 298 | - | if strings.TrimSpace(block.Summary) != "" { | |
| 299 | - | out = append(out, "reasoning: "+block.Summary) | |
| 300 | - | } | |
| 301 | 334 | case provider.ImageBlock: | |
| 302 | 335 | out = append(out, fmt.Sprintf("[image %s, %d bytes]", block.MimeType, len(block.Data))) | |
| 303 | 336 | } | |
| @@ -383,7 +416,7 @@ func timelineColor(th tui.Theme, entry timelineEntry, line string) string { | |||
| 383 | 416 | switch entry.Kind { | |
| 384 | 417 | case "user": | |
| 385 | 418 | color = th.User | |
| 386 | - | case "assistant": | |
| 419 | + | case "assistant", "reasoning": | |
| 387 | 420 | color = th.Assistant | |
| 388 | 421 | case "tool": | |
| 389 | 422 | color = th.Tool | |
| @@ -436,7 +469,7 @@ func timelineTabsWidth(start, end int) int { | |||
| 436 | 469 | return width | |
| 437 | 470 | } | |
| 438 | 471 | ||
| 439 | - | func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, maxRows int) []string { | |
| 472 | + | func timelineDetailRows(th tui.Theme, width int, entry timelineEntry, tab int) []string { | |
| 440 | 473 | var detail string | |
| 441 | 474 | switch tab { | |
| 442 | 475 | case 0: | |
| @@ -461,13 +494,13 @@ func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, max | |||
| 461 | 494 | if strings.TrimSpace(detail) == "" { | |
| 462 | 495 | detail = "not available for this event" | |
| 463 | 496 | } | |
| 497 | + | if entry.Kind == "reasoning" && (tab == 0 || tab == 1) { | |
| 498 | + | return renderDialogMarkdownRows(entry.Payload, th, width) | |
| 499 | + | } | |
| 464 | 500 | var rows []string | |
| 465 | 501 | for _, line := range strings.Split(detail, "\n") { | |
| 466 | 502 | for _, wrapped := range tui.WrapANSILine(line, width-2) { | |
| 467 | 503 | rows = append(rows, " "+th.FG256(th.ToolOut, wrapped)) | |
| 468 | - | if len(rows) >= maxRows { | |
| 469 | - | return rows | |
| 470 | - | } | |
| 471 | 504 | } | |
| 472 | 505 | } | |
| 473 | 506 | return rows | |
| @@ -44,6 +44,7 @@ type timelineView struct { | |||||
| 44 | active bool | 44 | active bool | ||
| 45 | cursor int | 45 | cursor int | ||
| 46 | tab int | 46 | tab int | ||
| 47 | + | detailTop int | |||
| 47 | searching bool | 48 | searching bool | ||
| 48 | filter string | 49 | filter string | ||
| 49 | notice string | 50 | notice string | ||
| @@ -64,6 +65,7 @@ func (v *timelineView) Open(data timelineData) { | |||||
| 64 | v.filter = "" | 65 | v.filter = "" | ||
| 65 | v.notice = "" | 66 | v.notice = "" | ||
| 66 | v.tab = 0 | 67 | v.tab = 0 | ||
| 68 | + | v.detailTop = 0 | |||
| 67 | v.cursor = len(buildTimelineEntries(data)) - 1 | 69 | v.cursor = len(buildTimelineEntries(data)) - 1 | ||
| 68 | if v.cursor < 0 { | 70 | if v.cursor < 0 { | ||
| 69 | v.cursor = 0 | 71 | v.cursor = 0 | ||
| @@ -75,6 +77,7 @@ func (v *timelineView) Close() { | |||||
| 75 | v.searching = false | 77 | v.searching = false | ||
| 76 | v.filter = "" | 78 | v.filter = "" | ||
| 77 | v.notice = "" | 79 | v.notice = "" | ||
| 80 | + | v.detailTop = 0 | |||
| 78 | } | 81 | } | ||
| 79 | 82 | ||||
| 80 | func (v *timelineView) SetNotice(s string) { v.notice = s } | 83 | func (v *timelineView) SetNotice(s string) { v.notice = s } | ||
| @@ -83,7 +86,18 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction { | |||||
| 83 | entries := filterTimelineEntries(buildTimelineEntries(data), v.filter) | 86 | entries := filterTimelineEntries(buildTimelineEntries(data), v.filter) | ||
| 84 | clampTimelineCursor(v, len(entries)) | 87 | clampTimelineCursor(v, len(entries)) | ||
| 85 | 88 | ||||
| 89 | + | previousCursor, previousTab := v.cursor, v.tab | |||
| 86 | switch k.Kind { | 90 | switch k.Kind { | ||
| 91 | + | case tui.KeyLeft: | |||
| 92 | + | if v.detailTop > 0 { | |||
| 93 | + | v.detailTop-- | |||
| 94 | + | } | |||
| 95 | + | case tui.KeyRight: | |||
| 96 | + | v.detailTop++ | |||
| 97 | + | case tui.KeyHome: | |||
| 98 | + | v.detailTop = 0 | |||
| 99 | + | case tui.KeyEnd: | |||
| 100 | + | v.detailTop = int(^uint(0) >> 1) | |||
| 87 | case tui.KeyUp: | 101 | case tui.KeyUp: | ||
| 88 | if v.cursor > 0 { | 102 | if v.cursor > 0 { | ||
| 89 | v.cursor-- | 103 | v.cursor-- | ||
| @@ -111,11 +125,13 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction { | |||||
| 111 | r := []rune(v.filter) | 125 | r := []rune(v.filter) | ||
| 112 | v.filter = string(r[:len(r)-1]) | 126 | v.filter = string(r[:len(r)-1]) | ||
| 113 | v.cursor = 0 | 127 | v.cursor = 0 | ||
| 128 | + | v.detailTop = 0 | |||
| 114 | } | 129 | } | ||
| 115 | case tui.KeyPaste: | 130 | case tui.KeyPaste: | ||
| 116 | if v.searching { | 131 | if v.searching { | ||
| 117 | v.filter += singleLinePaste(k.Paste) | 132 | v.filter += singleLinePaste(k.Paste) | ||
| 118 | v.cursor = 0 | 133 | v.cursor = 0 | ||
| 134 | + | v.detailTop = 0 | |||
| 119 | } | 135 | } | ||
| 120 | case tui.KeyRune: | 136 | case tui.KeyRune: | ||
| 121 | if !v.searching && k.Rune == '/' { | 137 | if !v.searching && k.Rune == '/' { | ||
| @@ -124,12 +140,14 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction { | |||||
| 124 | } else if v.searching && !k.Ctrl && !k.Alt && !k.Super { | 140 | } else if v.searching && !k.Ctrl && !k.Alt && !k.Super { | ||
| 125 | v.filter += string(k.Rune) | 141 | v.filter += string(k.Rune) | ||
| 126 | v.cursor = 0 | 142 | v.cursor = 0 | ||
| 143 | + | v.detailTop = 0 | |||
| 127 | } | 144 | } | ||
| 128 | case tui.KeyEsc, tui.KeyCtrlC: | 145 | case tui.KeyEsc, tui.KeyCtrlC: | ||
| 129 | if v.searching { | 146 | if v.searching { | ||
| 130 | if v.filter != "" { | 147 | if v.filter != "" { | ||
| 131 | v.filter = "" | 148 | v.filter = "" | ||
| 132 | v.cursor = 0 | 149 | v.cursor = 0 | ||
| 150 | + | v.detailTop = 0 | |||
| 133 | } else { | 151 | } else { | ||
| 134 | v.searching = false | 152 | v.searching = false | ||
| 135 | } | 153 | } | ||
| @@ -138,6 +156,9 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction { | |||||
| 138 | v.Close() | 156 | v.Close() | ||
| 139 | return timelineAction{Close: true} | 157 | return timelineAction{Close: true} | ||
| 140 | } | 158 | } | ||
| 159 | + | if v.cursor != previousCursor || v.tab != previousTab { | |||
| 160 | + | v.detailTop = 0 | |||
| 161 | + | } | |||
| 141 | return timelineAction{} | 162 | return timelineAction{} | ||
| 142 | } | 163 | } | ||
| 143 | 164 | ||||
| @@ -167,7 +188,7 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData | |||||
| 167 | lines := []string{frameHeader(th, "timeline", width)} | 188 | lines := []string{frameHeader(th, "timeline", width)} | ||
| 168 | lines = append(lines, renderTimelineContext(th, width, data)...) | 189 | lines = append(lines, renderTimelineContext(th, width, data)...) | ||
| 169 | 190 | ||||
| 170 | - | hint := "↑/↓ select pgup/pgdn move tab details / search ctrl+e export esc chat" | 191 | + | hint := "↑/↓ select tab details ←/→ scroll detail home/end / search ctrl+e export esc chat" |
| 171 | if v.searching { | 192 | if v.searching { | ||
| 172 | hint = "search: " + v.filter + "_ (esc clears)" | 193 | hint = "search: " + v.filter + "_ (esc clears)" | ||
| 173 | } | 194 | } | ||
| @@ -211,7 +232,15 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData | |||||
| 211 | if detailRows < 3 { | 232 | if detailRows < 3 { | ||
| 212 | detailRows = 3 | 233 | detailRows = 3 | ||
| 213 | } | 234 | } | ||
| 214 | - | lines = append(lines, renderTimelineDetail(th, width, entries[v.cursor], v.tab, detailRows)...) | 235 | + | detail := timelineDetailRows(th, width, entries[v.cursor], v.tab) |
| 236 | + | maxTop := len(detail) - detailRows | |||
| 237 | + | if maxTop < 0 { | |||
| 238 | + | maxTop = 0 | |||
| 239 | + | } | |||
| 240 | + | if v.detailTop > maxTop { | |||
| 241 | + | v.detailTop = maxTop | |||
| 242 | + | } | |||
| 243 | + | lines = append(lines, detail[v.detailTop:min(v.detailTop+detailRows, len(detail))]...) | |||
| 215 | } | 244 | } | ||
| 216 | if v.notice != "" { | 245 | if v.notice != "" { | ||
| 217 | lines = append(lines, th.FG256(th.Tool, fitTimelineLine(v.notice, width))) | 246 | lines = append(lines, th.FG256(th.Tool, fitTimelineLine(v.notice, width))) | ||
| @@ -249,6 +278,14 @@ func buildTimelineEntries(data timelineData) []timelineEntry { | |||||
| 249 | if message.Role == provider.RoleUser { | 278 | if message.Role == provider.RoleUser { | ||
| 250 | turn++ | 279 | turn++ | ||
| 251 | } | 280 | } | ||
| 281 | + | for _, content := range message.Content { | |||
| 282 | + | if block, ok := content.(provider.ReasoningBlock); ok && strings.TrimSpace(block.Summary) != "" { | |||
| 283 | + | entries = append(entries, timelineEntry{ | |||
| 284 | + | Kind: "reasoning", Turn: turn, Label: "REASONING", Summary: firstTimelineLine(block.Summary), | |||
| 285 | + | Time: message.Time, Payload: block.Summary, | |||
| 286 | + | }) | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 252 | textParts := timelineMessageText(message) | 289 | textParts := timelineMessageText(message) | ||
| 253 | if len(textParts) > 0 { | 290 | if len(textParts) > 0 { | ||
| 254 | entry := timelineEntry{ | 291 | entry := timelineEntry{ | ||
| @@ -294,10 +331,6 @@ func timelineMessageText(message provider.Message) []string { | |||||
| 294 | if strings.TrimSpace(block.Text) != "" { | 331 | if strings.TrimSpace(block.Text) != "" { | ||
| 295 | out = append(out, block.Text) | 332 | out = append(out, block.Text) | ||
| 296 | } | 333 | } | ||
| 297 | - | case provider.ReasoningBlock: | |||
| 298 | - | if strings.TrimSpace(block.Summary) != "" { | |||
| 299 | - | out = append(out, "reasoning: "+block.Summary) | |||
| 300 | - | } | |||
| 301 | case provider.ImageBlock: | 334 | case provider.ImageBlock: | ||
| 302 | out = append(out, fmt.Sprintf("[image %s, %d bytes]", block.MimeType, len(block.Data))) | 335 | out = append(out, fmt.Sprintf("[image %s, %d bytes]", block.MimeType, len(block.Data))) | ||
| 303 | } | 336 | } | ||
| @@ -383,7 +416,7 @@ func timelineColor(th tui.Theme, entry timelineEntry, line string) string { | |||||
| 383 | switch entry.Kind { | 416 | switch entry.Kind { | ||
| 384 | case "user": | 417 | case "user": | ||
| 385 | color = th.User | 418 | color = th.User | ||
| 386 | - | case "assistant": | 419 | + | case "assistant", "reasoning": |
| 387 | color = th.Assistant | 420 | color = th.Assistant | ||
| 388 | case "tool": | 421 | case "tool": | ||
| 389 | color = th.Tool | 422 | color = th.Tool | ||
| @@ -436,7 +469,7 @@ func timelineTabsWidth(start, end int) int { | |||||
| 436 | return width | 469 | return width | ||
| 437 | } | 470 | } | ||
| 438 | 471 | ||||
| 439 | - | func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, maxRows int) []string { | 472 | + | func timelineDetailRows(th tui.Theme, width int, entry timelineEntry, tab int) []string { |
| 440 | var detail string | 473 | var detail string | ||
| 441 | switch tab { | 474 | switch tab { | ||
| 442 | case 0: | 475 | case 0: | ||
| @@ -461,13 +494,13 @@ func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, max | |||||
| 461 | if strings.TrimSpace(detail) == "" { | 494 | if strings.TrimSpace(detail) == "" { | ||
| 462 | detail = "not available for this event" | 495 | detail = "not available for this event" | ||
| 463 | } | 496 | } | ||
| 497 | + | if entry.Kind == "reasoning" && (tab == 0 || tab == 1) { | |||
| 498 | + | return renderDialogMarkdownRows(entry.Payload, th, width) | |||
| 499 | + | } | |||
| 464 | var rows []string | 500 | var rows []string | ||
| 465 | for _, line := range strings.Split(detail, "\n") { | 501 | for _, line := range strings.Split(detail, "\n") { | ||
| 466 | for _, wrapped := range tui.WrapANSILine(line, width-2) { | 502 | for _, wrapped := range tui.WrapANSILine(line, width-2) { | ||
| 467 | rows = append(rows, " "+th.FG256(th.ToolOut, wrapped)) | 503 | rows = append(rows, " "+th.FG256(th.ToolOut, wrapped)) | ||
| 468 | - | if len(rows) >= maxRows { | |||
| 469 | - | return rows | |||
| 470 | - | } | |||
| 471 | } | 504 | } | ||
| 472 | } | 505 | } | ||
| 473 | return rows | 506 | return rows | ||
M packages/agent/modes/timeline_view_test.go +40 -0
| @@ -74,6 +74,46 @@ func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) { | |||
| 74 | 74 | } | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | + | func TestTimelineReasoningIsReadableAndScrollable(t *testing.T) { | |
| 78 | + | data := timelineTestData() | |
| 79 | + | data.Messages[1].Content = append([]provider.Content{provider.ReasoningBlock{ | |
| 80 | + | Summary: "**First** step\n" + strings.Repeat("A long line of thought. ", 25) + "\nFinal insight", | |
| 81 | + | }}, data.Messages[1].Content...) | |
| 82 | + | entries := buildTimelineEntries(data) | |
| 83 | + | if len(entries) != 5 || entries[2].Kind != "reasoning" || entries[3].Kind != "assistant" { | |
| 84 | + | t.Fatalf("reasoning entries = %+v", entries) | |
| 85 | + | } | |
| 86 | + | if strings.Contains(entries[3].Summary, "First") { | |
| 87 | + | t.Fatalf("reasoning duplicated in assistant: %+v", entries[3]) | |
| 88 | + | } | |
| 89 | + | view := newTimelineView() | |
| 90 | + | view.Open(data) | |
| 91 | + | view.cursor = 2 | |
| 92 | + | rows := timelineDetailRows(tui.Dark, 40, entries[2], 0) | |
| 93 | + | if len(rows) < 4 || !strings.Contains(stripANSIBytes(strings.Join(rows, "\n")), "Final insight") { | |
| 94 | + | t.Fatalf("reasoning detail not complete: %q", rows) | |
| 95 | + | } | |
| 96 | + | for _, row := range rows { | |
| 97 | + | if runewidth.StringWidth(stripANSIBytes(row)) > 40 { | |
| 98 | + | t.Fatalf("row exceeds width: %q", row) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | first := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n") | |
| 102 | + | view.HandleKey(tui.Key{Kind: tui.KeyEnd}, data) | |
| 103 | + | last := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n") | |
| 104 | + | if view.detailTop == 0 || first == last || !strings.Contains(stripANSIBytes(last), "Final insight") { | |
| 105 | + | t.Fatalf("detail did not scroll to end: top=%d, output=%q", view.detailTop, last) | |
| 106 | + | } | |
| 107 | + | view.HandleKey(tui.Key{Kind: tui.KeyLeft}, data) | |
| 108 | + | if view.detailTop == 0 { | |
| 109 | + | t.Fatal("left did not scroll detail up") | |
| 110 | + | } | |
| 111 | + | view.HandleKey(tui.Key{Kind: tui.KeyTab}, data) | |
| 112 | + | if view.detailTop != 0 { | |
| 113 | + | t.Fatal("changing tab did not reset detail scroll") | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 77 | 117 | func TestTimelineNavigationKeysMoveSelection(t *testing.T) { | |
| 78 | 118 | data := timelineTestData() | |
| 79 | 119 | view := newTimelineView() | |
| @@ -74,6 +74,46 @@ func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) { | |||||
| 74 | } | 74 | } | ||
| 75 | } | 75 | } | ||
| 76 | 76 | ||||
| 77 | + | func TestTimelineReasoningIsReadableAndScrollable(t *testing.T) { | |||
| 78 | + | data := timelineTestData() | |||
| 79 | + | data.Messages[1].Content = append([]provider.Content{provider.ReasoningBlock{ | |||
| 80 | + | Summary: "**First** step\n" + strings.Repeat("A long line of thought. ", 25) + "\nFinal insight", | |||
| 81 | + | }}, data.Messages[1].Content...) | |||
| 82 | + | entries := buildTimelineEntries(data) | |||
| 83 | + | if len(entries) != 5 || entries[2].Kind != "reasoning" || entries[3].Kind != "assistant" { | |||
| 84 | + | t.Fatalf("reasoning entries = %+v", entries) | |||
| 85 | + | } | |||
| 86 | + | if strings.Contains(entries[3].Summary, "First") { | |||
| 87 | + | t.Fatalf("reasoning duplicated in assistant: %+v", entries[3]) | |||
| 88 | + | } | |||
| 89 | + | view := newTimelineView() | |||
| 90 | + | view.Open(data) | |||
| 91 | + | view.cursor = 2 | |||
| 92 | + | rows := timelineDetailRows(tui.Dark, 40, entries[2], 0) | |||
| 93 | + | if len(rows) < 4 || !strings.Contains(stripANSIBytes(strings.Join(rows, "\n")), "Final insight") { | |||
| 94 | + | t.Fatalf("reasoning detail not complete: %q", rows) | |||
| 95 | + | } | |||
| 96 | + | for _, row := range rows { | |||
| 97 | + | if runewidth.StringWidth(stripANSIBytes(row)) > 40 { | |||
| 98 | + | t.Fatalf("row exceeds width: %q", row) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | first := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n") | |||
| 102 | + | view.HandleKey(tui.Key{Kind: tui.KeyEnd}, data) | |||
| 103 | + | last := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n") | |||
| 104 | + | if view.detailTop == 0 || first == last || !strings.Contains(stripANSIBytes(last), "Final insight") { | |||
| 105 | + | t.Fatalf("detail did not scroll to end: top=%d, output=%q", view.detailTop, last) | |||
| 106 | + | } | |||
| 107 | + | view.HandleKey(tui.Key{Kind: tui.KeyLeft}, data) | |||
| 108 | + | if view.detailTop == 0 { | |||
| 109 | + | t.Fatal("left did not scroll detail up") | |||
| 110 | + | } | |||
| 111 | + | view.HandleKey(tui.Key{Kind: tui.KeyTab}, data) | |||
| 112 | + | if view.detailTop != 0 { | |||
| 113 | + | t.Fatal("changing tab did not reset detail scroll") | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 77 | func TestTimelineNavigationKeysMoveSelection(t *testing.T) { | 117 | func TestTimelineNavigationKeysMoveSelection(t *testing.T) { | ||
| 78 | data := timelineTestData() | 118 | data := timelineTestData() | ||
| 79 | view := newTimelineView() | 119 | view := newTimelineView() | ||