detached e5506c1

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
382382
383383 Five ops on the current session. `/session` alone opens a picker; each is also runnable directly.
384384
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.
386386 - **`/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.
387387
388388 **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 {
4444 active bool
4545 cursor int
4646 tab int
47+ detailTop int
4748 searching bool
4849 filter string
4950 notice string
@@ -64,6 +65,7 @@ func (v *timelineView) Open(data timelineData) {
6465 v.filter = ""
6566 v.notice = ""
6667 v.tab = 0
68+ v.detailTop = 0
6769 v.cursor = len(buildTimelineEntries(data)) - 1
6870 if v.cursor < 0 {
6971 v.cursor = 0
@@ -75,6 +77,7 @@ func (v *timelineView) Close() {
7577 v.searching = false
7678 v.filter = ""
7779 v.notice = ""
80+ v.detailTop = 0
7881 }
7982
8083 func (v *timelineView) SetNotice(s string) { v.notice = s }
@@ -83,7 +86,18 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
8386 entries := filterTimelineEntries(buildTimelineEntries(data), v.filter)
8487 clampTimelineCursor(v, len(entries))
8588
89+ previousCursor, previousTab := v.cursor, v.tab
8690 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)
87101 case tui.KeyUp:
88102 if v.cursor > 0 {
89103 v.cursor--
@@ -111,11 +125,13 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
111125 r := []rune(v.filter)
112126 v.filter = string(r[:len(r)-1])
113127 v.cursor = 0
128+ v.detailTop = 0
114129 }
115130 case tui.KeyPaste:
116131 if v.searching {
117132 v.filter += singleLinePaste(k.Paste)
118133 v.cursor = 0
134+ v.detailTop = 0
119135 }
120136 case tui.KeyRune:
121137 if !v.searching && k.Rune == '/' {
@@ -124,12 +140,14 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
124140 } else if v.searching && !k.Ctrl && !k.Alt && !k.Super {
125141 v.filter += string(k.Rune)
126142 v.cursor = 0
143+ v.detailTop = 0
127144 }
128145 case tui.KeyEsc, tui.KeyCtrlC:
129146 if v.searching {
130147 if v.filter != "" {
131148 v.filter = ""
132149 v.cursor = 0
150+ v.detailTop = 0
133151 } else {
134152 v.searching = false
135153 }
@@ -138,6 +156,9 @@ func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
138156 v.Close()
139157 return timelineAction{Close: true}
140158 }
159+ if v.cursor != previousCursor || v.tab != previousTab {
160+ v.detailTop = 0
161+ }
141162 return timelineAction{}
142163 }
143164
@@ -167,7 +188,7 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData
167188 lines := []string{frameHeader(th, "timeline", width)}
168189 lines = append(lines, renderTimelineContext(th, width, data)...)
169190
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"
171192 if v.searching {
172193 hint = "search: " + v.filter + "_ (esc clears)"
173194 }
@@ -211,7 +232,15 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData
211232 if detailRows < 3 {
212233 detailRows = 3
213234 }
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))]...)
215244 }
216245 if v.notice != "" {
217246 lines = append(lines, th.FG256(th.Tool, fitTimelineLine(v.notice, width)))
@@ -249,6 +278,14 @@ func buildTimelineEntries(data timelineData) []timelineEntry {
249278 if message.Role == provider.RoleUser {
250279 turn++
251280 }
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+ }
252289 textParts := timelineMessageText(message)
253290 if len(textParts) > 0 {
254291 entry := timelineEntry{
@@ -294,10 +331,6 @@ func timelineMessageText(message provider.Message) []string {
294331 if strings.TrimSpace(block.Text) != "" {
295332 out = append(out, block.Text)
296333 }
297- case provider.ReasoningBlock:
298- if strings.TrimSpace(block.Summary) != "" {
299- out = append(out, "reasoning: "+block.Summary)
300- }
301334 case provider.ImageBlock:
302335 out = append(out, fmt.Sprintf("[image %s, %d bytes]", block.MimeType, len(block.Data)))
303336 }
@@ -383,7 +416,7 @@ func timelineColor(th tui.Theme, entry timelineEntry, line string) string {
383416 switch entry.Kind {
384417 case "user":
385418 color = th.User
386- case "assistant":
419+ case "assistant", "reasoning":
387420 color = th.Assistant
388421 case "tool":
389422 color = th.Tool
@@ -436,7 +469,7 @@ func timelineTabsWidth(start, end int) int {
436469 return width
437470 }
438471
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 {
440473 var detail string
441474 switch tab {
442475 case 0:
@@ -461,13 +494,13 @@ func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, max
461494 if strings.TrimSpace(detail) == "" {
462495 detail = "not available for this event"
463496 }
497+ if entry.Kind == "reasoning" && (tab == 0 || tab == 1) {
498+ return renderDialogMarkdownRows(entry.Payload, th, width)
499+ }
464500 var rows []string
465501 for _, line := range strings.Split(detail, "\n") {
466502 for _, wrapped := range tui.WrapANSILine(line, width-2) {
467503 rows = append(rows, " "+th.FG256(th.ToolOut, wrapped))
468- if len(rows) >= maxRows {
469- return rows
470- }
471504 }
472505 }
473506 return rows
@@ -44,6 +44,7 @@ type timelineView struct {
44 active bool44 active bool
45 cursor int45 cursor int
46 tab int46 tab int
47+ detailTop int
47 searching bool48 searching bool
48 filter string49 filter string
49 notice string50 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 = 067 v.tab = 0
68+ v.detailTop = 0
67 v.cursor = len(buildTimelineEntries(data)) - 169 v.cursor = len(buildTimelineEntries(data)) - 1
68 if v.cursor < 0 {70 if v.cursor < 0 {
69 v.cursor = 071 v.cursor = 0
@@ -75,6 +77,7 @@ func (v *timelineView) Close() {
75 v.searching = false77 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 = 0127 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 = 0133 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 = 0142 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 = 0149 v.cursor = 0
150+ v.detailTop = 0
133 } else {151 } else {
134 v.searching = false152 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 = 3233 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.User418 color = th.User
386- case "assistant":419+ case "assistant", "reasoning":
387 color = th.Assistant420 color = th.Assistant
388 case "tool":421 case "tool":
389 color = th.Tool422 color = th.Tool
@@ -436,7 +469,7 @@ func timelineTabsWidth(start, end int) int {
436 return width469 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 string473 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 []string500 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 rows506 return rows
M packages/agent/modes/timeline_view_test.go +40 -0
@@ -74,6 +74,46 @@ func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) {
7474 }
7575 }
7676
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+
77117 func TestTimelineNavigationKeysMoveSelection(t *testing.T) {
78118 data := timelineTestData()
79119 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()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close