detached dbe6763

add collapsible code blocks

commit
dbe6763736605fe78d63f0fcc4596cd07417e222
author
patriceckhart · · 2026-04-18 08:30 UTC
parent
6bb3e9e
signature
unsigned
4 files · +260 -22
M internal/agent/modes/help.go +1 -0
@@ -20,6 +20,7 @@ var helpKeyRows = [][2]string{
2020 {"ctrl+a / ctrl+e", "jump to start / end of line"},
2121 {"alt+← / alt+→", "jump one word back / forward"},
2222 {"ctrl+l", "redraw the screen"},
23+ {"ctrl+o", "expand / collapse long tool results"},
2324 {"pgup / pgdn", "scroll the chat one page up / down"},
2425 {"up / down", "scroll by 3 lines (when input is empty) · prompt history (otherwise)"},
2526 }
@@ -20,6 +20,7 @@ var helpKeyRows = [][2]string{
20 {"ctrl+a / ctrl+e", "jump to start / end of line"},20 {"ctrl+a / ctrl+e", "jump to start / end of line"},
21 {"alt+← / alt+→", "jump one word back / forward"},21 {"alt+← / alt+→", "jump one word back / forward"},
22 {"ctrl+l", "redraw the screen"},22 {"ctrl+l", "redraw the screen"},
23+ {"ctrl+o", "expand / collapse long tool results"},
23 {"pgup / pgdn", "scroll the chat one page up / down"},24 {"pgup / pgdn", "scroll the chat one page up / down"},
24 {"up / down", "scroll by 3 lines (when input is empty) · prompt history (otherwise)"},25 {"up / down", "scroll by 3 lines (when input is empty) · prompt history (otherwise)"},
25 }26 }
M internal/agent/modes/interactive.go +99 -5
@@ -90,6 +90,16 @@ type Interactive struct {
9090 cancelTurn context.CancelFunc
9191 scrollOffset int // rows from the bottom; 0 = pinned to latest
9292
93+ // Messages typed while a turn is in flight. Each is delivered as
94+ // its own follow-up turn once the current one finishes. Rendered
95+ // above the status bar as "sliding in: ..." chips.
96+ queued []string
97+
98+ // runCtx is the top-level context passed to Run(). Follow-up turns
99+ // drained from `queued` are started against this context so they
100+ // survive past the ctx of the key event that enqueued them.
101+ runCtx context.Context
102+
93103 dialog *loginDialog
94104 modelDialog *modelDialog
95105 sessionDialog *sessionDialog
@@ -124,6 +134,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
124134
125135 // Run blocks until the user quits.
126136 func (i *Interactive) Run(ctx context.Context) error {
137+ i.runCtx = ctx
127138 term := i.cfg.Terminal
128139 restore, err := term.EnterRaw()
129140 if err != nil {
@@ -391,10 +402,23 @@ func (i *Interactive) redraw() {
391402 })
392403 edLines, curR, curC := i.ed.Render(cols)
393404
405+ // "Sliding in" chips for messages the user typed while a turn is
406+ // in flight. Shown directly above the status bar so they're close
407+ // to the editor but don't push the chat around.
408+ var queue []string
409+ if len(i.queued) > 0 {
410+ for _, q := range i.queued {
411+ label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
412+ text := truncateLine(q, cols-15)
413+ queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
414+ }
415+ }
416+
394417 // Bottom-sticky sections (always visible, never scroll).
395- bottom := make([]string, 0, len(dialog)+len(suggest)+len(edLines)+1)
418+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
396419 bottom = append(bottom, dialog...)
397420 bottom = append(bottom, suggest...)
421+ bottom = append(bottom, queue...)
398422 bottom = append(bottom, status)
399423 bottom = append(bottom, edLines...)
400424
@@ -443,11 +467,30 @@ func (i *Interactive) redraw() {
443467 frame = append(frame, visibleChat...)
444468 frame = append(frame, bottom...)
445469
446- cursorRow := len(visibleChat) + len(dialog) + len(suggest) + 1 + curR
470+ cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + 1 + curR
447471 cursorCol := curC
448472 i.rend.Draw(frame, cursorRow, cursorCol)
449473 }
450474
475+// truncateLine shortens s so it fits within n display cells, with an
476+// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
477+// novel doesn't blow past the status line.
478+func truncateLine(s string, n int) string {
479+ if n <= 0 {
480+ return ""
481+ }
482+ // Collapse newlines — chips are single line.
483+ s = strings.ReplaceAll(s, "\n", " ↩ ")
484+ runes := []rune(s)
485+ if len(runes) <= n {
486+ return s
487+ }
488+ if n <= 1 {
489+ return "…"
490+ }
491+ return string(runes[:n-1]) + "…"
492+}
493+
451494 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
452495 // Dialogs consume keys while open (except ctrl+c, which always closes them).
453496 if i.dialog.Active() {
@@ -513,6 +556,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
513556 i.rend.Clear()
514557 i.invalidate()
515558 return false
559+ case tui.KeyCtrlO:
560+ // Toggle expansion of collapsed tool results. Affects every tool
561+ // call in the transcript — press again to re-collapse.
562+ i.mu.Lock()
563+ i.view.ExpandAll = !i.view.ExpandAll
564+ i.mu.Unlock()
565+ i.invalidate()
566+ return false
516567 case tui.KeyPageUp:
517568 i.scrollBy(+i.chatPage())
518569 return false
@@ -535,9 +586,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
535586 }
536587 }
537588
538- if i.busy {
539- return false
540- }
589+ // Note: we intentionally do NOT gate the editor on i.busy here.
590+ // Typing while the agent is working is supported — submitted
591+ // messages are queued and delivered as follow-up turns when the
592+ // current turn ends. See the submit handler below.
541593
542594 if k.Kind == tui.KeyEnter && k.Alt {
543595 i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
@@ -600,6 +652,18 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
600652 i.mu.Unlock()
601653 return false
602654 }
655+ // Slash commands need a quiet state (they may swap models,
656+ // compact the transcript, open dialogs, etc). Refuse while
657+ // a turn is in flight — esc / ctrl+c cancels first.
658+ i.mu.Lock()
659+ busy := i.busy
660+ i.mu.Unlock()
661+ if busy {
662+ i.mu.Lock()
663+ i.statusErr = "cancel the current turn (esc) before running a slash command"
664+ i.mu.Unlock()
665+ return false
666+ }
603667 return i.runSlash(ctx, text)
604668 }
605669
@@ -609,6 +673,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
609673 i.mu.Unlock()
610674 return false
611675 }
676+ // If a turn is already in flight, queue this prompt instead of
677+ // starting a second one. The drain loop at the end of startTurn
678+ // will pick it up when the current turn finishes.
679+ i.mu.Lock()
680+ if i.busy {
681+ i.queued = append(i.queued, text)
682+ i.mu.Unlock()
683+ i.invalidate()
684+ return false
685+ }
686+ i.mu.Unlock()
612687 i.startTurn(ctx, text)
613688 }
614689 return false
@@ -958,8 +1033,27 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
9581033 if err != nil && ctx.Err() == nil {
9591034 i.statusErr = err.Error()
9601035 }
1036+ // Pop the next queued message, if any, and relaunch.
1037+ var next string
1038+ var hasNext bool
1039+ if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
1040+ next, i.queued = i.queued[0], i.queued[1:]
1041+ hasNext = true
1042+ }
1043+ // If the turn was cancelled or errored, drop the queue so the
1044+ // user isn't bombarded with stale messages after an interrupt.
1045+ if ctx.Err() != nil || err != nil {
1046+ i.queued = nil
1047+ }
9611048 i.mu.Unlock()
9621049 i.invalidate()
1050+ if hasNext {
1051+ parent := i.runCtx
1052+ if parent == nil {
1053+ parent = context.Background()
1054+ }
1055+ i.startTurn(parent, next)
1056+ }
9631057 }()
9641058 }
9651059
@@ -90,6 +90,16 @@ type Interactive struct {
90 cancelTurn context.CancelFunc90 cancelTurn context.CancelFunc
91 scrollOffset int // rows from the bottom; 0 = pinned to latest91 scrollOffset int // rows from the bottom; 0 = pinned to latest
92 92
93+ // Messages typed while a turn is in flight. Each is delivered as
94+ // its own follow-up turn once the current one finishes. Rendered
95+ // above the status bar as "sliding in: ..." chips.
96+ queued []string
97+
98+ // runCtx is the top-level context passed to Run(). Follow-up turns
99+ // drained from `queued` are started against this context so they
100+ // survive past the ctx of the key event that enqueued them.
101+ runCtx context.Context
102+
93 dialog *loginDialog103 dialog *loginDialog
94 modelDialog *modelDialog104 modelDialog *modelDialog
95 sessionDialog *sessionDialog105 sessionDialog *sessionDialog
@@ -124,6 +134,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
124 134
125 // Run blocks until the user quits.135 // Run blocks until the user quits.
126 func (i *Interactive) Run(ctx context.Context) error {136 func (i *Interactive) Run(ctx context.Context) error {
137+ i.runCtx = ctx
127 term := i.cfg.Terminal138 term := i.cfg.Terminal
128 restore, err := term.EnterRaw()139 restore, err := term.EnterRaw()
129 if err != nil {140 if err != nil {
@@ -391,10 +402,23 @@ func (i *Interactive) redraw() {
391 })402 })
392 edLines, curR, curC := i.ed.Render(cols)403 edLines, curR, curC := i.ed.Render(cols)
393 404
405+ // "Sliding in" chips for messages the user typed while a turn is
406+ // in flight. Shown directly above the status bar so they're close
407+ // to the editor but don't push the chat around.
408+ var queue []string
409+ if len(i.queued) > 0 {
410+ for _, q := range i.queued {
411+ label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
412+ text := truncateLine(q, cols-15)
413+ queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
414+ }
415+ }
416+
394 // Bottom-sticky sections (always visible, never scroll).417 // Bottom-sticky sections (always visible, never scroll).
395- bottom := make([]string, 0, len(dialog)+len(suggest)+len(edLines)+1)418+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
396 bottom = append(bottom, dialog...)419 bottom = append(bottom, dialog...)
397 bottom = append(bottom, suggest...)420 bottom = append(bottom, suggest...)
421+ bottom = append(bottom, queue...)
398 bottom = append(bottom, status)422 bottom = append(bottom, status)
399 bottom = append(bottom, edLines...)423 bottom = append(bottom, edLines...)
400 424
@@ -443,11 +467,30 @@ func (i *Interactive) redraw() {
443 frame = append(frame, visibleChat...)467 frame = append(frame, visibleChat...)
444 frame = append(frame, bottom...)468 frame = append(frame, bottom...)
445 469
446- cursorRow := len(visibleChat) + len(dialog) + len(suggest) + 1 + curR470+ cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + 1 + curR
447 cursorCol := curC471 cursorCol := curC
448 i.rend.Draw(frame, cursorRow, cursorCol)472 i.rend.Draw(frame, cursorRow, cursorCol)
449 }473 }
450 474
475+// truncateLine shortens s so it fits within n display cells, with an
476+// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
477+// novel doesn't blow past the status line.
478+func truncateLine(s string, n int) string {
479+ if n <= 0 {
480+ return ""
481+ }
482+ // Collapse newlines — chips are single line.
483+ s = strings.ReplaceAll(s, "\n", " ↩ ")
484+ runes := []rune(s)
485+ if len(runes) <= n {
486+ return s
487+ }
488+ if n <= 1 {
489+ return "…"
490+ }
491+ return string(runes[:n-1]) + "…"
492+}
493+
451 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {494 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
452 // Dialogs consume keys while open (except ctrl+c, which always closes them).495 // Dialogs consume keys while open (except ctrl+c, which always closes them).
453 if i.dialog.Active() {496 if i.dialog.Active() {
@@ -513,6 +556,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
513 i.rend.Clear()556 i.rend.Clear()
514 i.invalidate()557 i.invalidate()
515 return false558 return false
559+ case tui.KeyCtrlO:
560+ // Toggle expansion of collapsed tool results. Affects every tool
561+ // call in the transcript — press again to re-collapse.
562+ i.mu.Lock()
563+ i.view.ExpandAll = !i.view.ExpandAll
564+ i.mu.Unlock()
565+ i.invalidate()
566+ return false
516 case tui.KeyPageUp:567 case tui.KeyPageUp:
517 i.scrollBy(+i.chatPage())568 i.scrollBy(+i.chatPage())
518 return false569 return false
@@ -535,9 +586,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
535 }586 }
536 }587 }
537 588
538- if i.busy {589+ // Note: we intentionally do NOT gate the editor on i.busy here.
539- return false590+ // Typing while the agent is working is supported — submitted
540- }591+ // messages are queued and delivered as follow-up turns when the
592+ // current turn ends. See the submit handler below.
541 593
542 if k.Kind == tui.KeyEnter && k.Alt {594 if k.Kind == tui.KeyEnter && k.Alt {
543 i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})595 i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
@@ -600,6 +652,18 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
600 i.mu.Unlock()652 i.mu.Unlock()
601 return false653 return false
602 }654 }
655+ // Slash commands need a quiet state (they may swap models,
656+ // compact the transcript, open dialogs, etc). Refuse while
657+ // a turn is in flight — esc / ctrl+c cancels first.
658+ i.mu.Lock()
659+ busy := i.busy
660+ i.mu.Unlock()
661+ if busy {
662+ i.mu.Lock()
663+ i.statusErr = "cancel the current turn (esc) before running a slash command"
664+ i.mu.Unlock()
665+ return false
666+ }
603 return i.runSlash(ctx, text)667 return i.runSlash(ctx, text)
604 }668 }
605 669
@@ -609,6 +673,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
609 i.mu.Unlock()673 i.mu.Unlock()
610 return false674 return false
611 }675 }
676+ // If a turn is already in flight, queue this prompt instead of
677+ // starting a second one. The drain loop at the end of startTurn
678+ // will pick it up when the current turn finishes.
679+ i.mu.Lock()
680+ if i.busy {
681+ i.queued = append(i.queued, text)
682+ i.mu.Unlock()
683+ i.invalidate()
684+ return false
685+ }
686+ i.mu.Unlock()
612 i.startTurn(ctx, text)687 i.startTurn(ctx, text)
613 }688 }
614 return false689 return false
@@ -958,8 +1033,27 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
958 if err != nil && ctx.Err() == nil {1033 if err != nil && ctx.Err() == nil {
959 i.statusErr = err.Error()1034 i.statusErr = err.Error()
960 }1035 }
1036+ // Pop the next queued message, if any, and relaunch.
1037+ var next string
1038+ var hasNext bool
1039+ if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
1040+ next, i.queued = i.queued[0], i.queued[1:]
1041+ hasNext = true
1042+ }
1043+ // If the turn was cancelled or errored, drop the queue so the
1044+ // user isn't bombarded with stale messages after an interrupt.
1045+ if ctx.Err() != nil || err != nil {
1046+ i.queued = nil
1047+ }
961 i.mu.Unlock()1048 i.mu.Unlock()
962 i.invalidate()1049 i.invalidate()
1050+ if hasNext {
1051+ parent := i.runCtx
1052+ if parent == nil {
1053+ parent = context.Background()
1054+ }
1055+ i.startTurn(parent, next)
1056+ }
963 }()1057 }()
964 }1058 }
965 1059
M internal/tui/input.go +3 -0
@@ -40,6 +40,7 @@ const (
4040 KeyCtrlA
4141 KeyCtrlE
4242 KeyCtrlW
43+ KeyCtrlO
4344 KeyPaste
4445 KeyUnknown
4546 )
@@ -83,6 +84,8 @@ func (r *Reader) Read() (Key, error) {
8384 return Key{Kind: KeyCtrlE}, nil
8485 case b == 0x17:
8586 return Key{Kind: KeyCtrlW}, nil
87+ case b == 0x0f:
88+ return Key{Kind: KeyCtrlO}, nil
8689 case b == '\r', b == '\n':
8790 return Key{Kind: KeyEnter}, nil
8891 case b == '\t':
@@ -40,6 +40,7 @@ const (
40 KeyCtrlA40 KeyCtrlA
41 KeyCtrlE41 KeyCtrlE
42 KeyCtrlW42 KeyCtrlW
43+ KeyCtrlO
43 KeyPaste44 KeyPaste
44 KeyUnknown45 KeyUnknown
45 )46 )
@@ -83,6 +84,8 @@ func (r *Reader) Read() (Key, error) {
83 return Key{Kind: KeyCtrlE}, nil84 return Key{Kind: KeyCtrlE}, nil
84 case b == 0x17:85 case b == 0x17:
85 return Key{Kind: KeyCtrlW}, nil86 return Key{Kind: KeyCtrlW}, nil
87+ case b == 0x0f:
88+ return Key{Kind: KeyCtrlO}, nil
86 case b == '\r', b == '\n':89 case b == '\r', b == '\n':
87 return Key{Kind: KeyEnter}, nil90 return Key{Kind: KeyEnter}, nil
88 case b == '\t':91 case b == '\t':
M internal/tui/view.go +157 -17
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "os"
77 "regexp"
8+ "strconv"
89 "strings"
910
1011 "github.com/patriceckhart/zot/internal/provider"
@@ -48,8 +49,22 @@ type View struct {
4849 ToolCalls []ToolCallView // tool calls in flight or completed
4950 StatusLine string
5051 Err string
52+
53+ // ExpandAll forces every long tool result to render in full.
54+ // Toggled from the tui by ctrl+o. When false, results longer than
55+ // ToolCollapseLines collapse to ToolCollapsePreview lines plus a
56+ // "... (N more lines, M total, ctrl+o to expand)" footer.
57+ ExpandAll bool
5158 }
5259
60+// ToolCollapsePreview is the number of lines shown before a long tool
61+// result is replaced with a "... ctrl+o to expand" footer. Tool
62+// results shorter than ToolCollapseLines always render in full.
63+const (
64+ ToolCollapsePreview = 10
65+ ToolCollapseLines = 12
66+)
67+
5368 // ToolCallView is a pending tool invocation plus optional result.
5469 type ToolCallView struct {
5570 ID string
@@ -169,7 +184,15 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
169184 if tc.Error {
170185 color = v.Theme.Error
171186 }
172- lines = append(lines, toolResultBlock(v.Theme, tc.Result, width, color)...)
187+ block := toolResultBlock(v.Theme, tc.Result, width, color)
188+ // Strip rules, collapse the body, put rules back on.
189+ if len(block) >= 2 {
190+ top, bot := block[0], block[len(block)-1]
191+ body := v.collapseToolBody(block[1:len(block)-1], false)
192+ block = append([]string{top}, body...)
193+ block = append(block, bot)
194+ }
195+ lines = append(lines, block...)
173196 }
174197 return lines
175198 }
@@ -184,20 +207,45 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
184207 func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string) []string {
185208 rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
186209
187- var out []string
188- out = append(out, rule)
210+ var body []string
211+ hasImage := false
189212 for _, b := range blocks {
190213 switch bb := b.(type) {
191214 case provider.TextBlock:
192- out = append(out, v.renderToolText(bb.Text, width, color, sourcePath)...)
215+ body = append(body, v.renderToolText(bb.Text, width, color, sourcePath)...)
193216 case provider.ImageBlock:
194- out = append(out, v.renderImageBlock(bb, width)...)
217+ hasImage = true
218+ body = append(body, v.renderImageBlock(bb, width)...)
195219 }
196220 }
221+ body = v.collapseToolBody(body, hasImage)
222+
223+ out := make([]string, 0, len(body)+2)
224+ out = append(out, rule)
225+ out = append(out, body...)
197226 out = append(out, rule)
198227 return out
199228 }
200229
230+// collapseToolBody trims lines to the configured preview size when the
231+// view is not in ExpandAll mode, appending a muted "... ctrl+o to
232+// expand" footer. Image blocks never collapse — they're short in text
233+// rows but represent real content the user wants to see.
234+func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
235+ if v.ExpandAll || hasImage {
236+ return lines
237+ }
238+ if len(lines) <= ToolCollapseLines {
239+ return lines
240+ }
241+ kept := lines[:ToolCollapsePreview]
242+ hidden := len(lines) - ToolCollapsePreview
243+ total := len(lines)
244+ footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
245+ footer = v.Theme.FG256(v.Theme.Muted, footer)
246+ return append(append([]string(nil), kept...), footer)
247+}
248+
201249 // renderToolText renders a text block inside a tool result. If the
202250 // text contains a unified-diff section (lines starting with "--- " /
203251 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove
@@ -217,6 +265,7 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
217265 lines := strings.Split(text, "\n")
218266
219267 inDiff := false
268+ oldLine, newLine := 1, 1
220269 var out []string
221270 for _, l := range lines {
222271 // Detect diff header: "--- name" followed somewhere by "+++ name".
@@ -225,16 +274,29 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
225274 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
226275 continue
227276 }
277+ // Hunk header "@@ -a,b +c,d @@" resets the counters so patches
278+ // that skip around in the file still get correct numbering.
279+ if inDiff && strings.HasPrefix(l, "@@") {
280+ if o, n, ok := parseHunkHeader(l); ok {
281+ oldLine, newLine = o, n
282+ }
283+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
284+ continue
285+ }
228286 if inDiff && len(l) > 0 {
229287 switch l[0] {
230288 case '+':
231- out = append(out, v.renderDiffRow(l, width, v.Theme.Tool))
289+ out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))
290+ newLine++
232291 continue
233292 case '-':
234- out = append(out, v.renderDiffRow(l, width, v.Theme.Error))
293+ out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))
294+ oldLine++
235295 continue
236296 case ' ':
237- out = append(out, v.renderDiffRow(l, width, v.Theme.Muted))
297+ out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))
298+ oldLine++
299+ newLine++
238300 continue
239301 }
240302 }
@@ -246,16 +308,94 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
246308 return out
247309 }
248310
249-// renderDiffRow renders a single unified-diff line in fg color only.
250-// The leading +/-/space stays visible so the user can tell at a glance
251-// what changed; the rest of the line is colored the same. Long lines
252-// are wrapped with a 4-cell indent preserved.
253-func (v *View) renderDiffRow(line string, width, color int) string {
254- body := line
255- if len(body) > width-4 {
256- body = body[:width-7] + "…"
311+// parseHunkHeader extracts the starting old/new line from a unified
312+// diff hunk header ("@@ -12,5 +12,7 @@ ..."). Returns ok=false if the
313+// header is malformed or missing numbers.
314+func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
315+ // Skip "@@ "
316+ rest := strings.TrimPrefix(l, "@@")
317+ rest = strings.TrimSpace(rest)
318+ if !strings.HasPrefix(rest, "-") {
319+ return 0, 0, false
320+ }
321+ rest = rest[1:]
322+ space := strings.IndexByte(rest, ' ')
323+ if space < 0 {
324+ return 0, 0, false
325+ }
326+ oldPart := rest[:space]
327+ rest = strings.TrimSpace(rest[space+1:])
328+ if !strings.HasPrefix(rest, "+") {
329+ return 0, 0, false
330+ }
331+ rest = rest[1:]
332+ if sp := strings.IndexAny(rest, " \t"); sp >= 0 {
333+ rest = rest[:sp]
334+ }
335+ parseStart := func(s string) (int, bool) {
336+ if c := strings.IndexByte(s, ','); c >= 0 {
337+ s = s[:c]
338+ }
339+ n, err := strconv.Atoi(s)
340+ if err != nil || n < 1 {
341+ return 0, false
342+ }
343+ return n, true
344+ }
345+ o, ok1 := parseStart(oldPart)
346+ n, ok2 := parseStart(rest)
347+ if !ok1 || !ok2 {
348+ return 0, 0, false
349+ }
350+ return o, n, true
351+}
352+
353+// renderDiffRow renders one unified-diff line with a read-style gutter
354+// (6-cell right-aligned line number, muted) followed by the +/-/space
355+// marker and the code. Code is syntax-highlighted if sourcePath hints
356+// at a known language; falls back to the plain diff color otherwise.
357+func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) string {
358+ if len(line) == 0 {
359+ return ""
360+ }
361+ code := line[1:] // strip the leading marker; we re-emit it in colour
362+
363+ // Syntax-highlight the code half when we know the language. Use
364+ // the same HighlightCode pipeline as renderNumberedFile so the
365+ // palette matches.
366+ lang := LanguageFromPath(sourcePath)
367+ var codeRendered string
368+ if lang != "" {
369+ if h := HighlightCode(code, lang); len(h) == 1 {
370+ codeRendered = h[0]
371+ }
372+ }
373+ if codeRendered == "" {
374+ codeRendered = v.Theme.FG256(color, code)
375+ }
376+
377+ gutter := v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("%6d\t", lineNo))
378+ marker := v.Theme.FG256(color, string(mark)+" ")
379+ row := " " + gutter + marker + codeRendered
380+
381+ // Cheap width clamp: truncate visible text if the raw code is too
382+ // long. We work on the pre-ANSI code string because measuring ansi
383+ // output is unreliable.
384+ maxCode := width - 4 /* indent */ - 7 /* gutter */ - 2 /* marker */
385+ if maxCode > 0 && len(code) > maxCode {
386+ trunc := code[:maxCode-1] + "…"
387+ if lang != "" {
388+ if h := HighlightCode(trunc, lang); len(h) == 1 {
389+ codeRendered = h[0]
390+ } else {
391+ codeRendered = v.Theme.FG256(color, trunc)
392+ }
393+ } else {
394+ codeRendered = v.Theme.FG256(color, trunc)
395+ }
396+ row = " " + gutter + marker + codeRendered
257397 }
258- return " " + v.Theme.FG256(color, body)
398+ return row
259399 }
260400
261401 // renderImageBlock returns the lines for one image, inline if possible.
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "regexp"7 "regexp"
8+ "strconv"
8 "strings"9 "strings"
9 10
10 "github.com/patriceckhart/zot/internal/provider"11 "github.com/patriceckhart/zot/internal/provider"
@@ -48,8 +49,22 @@ type View struct {
48 ToolCalls []ToolCallView // tool calls in flight or completed49 ToolCalls []ToolCallView // tool calls in flight or completed
49 StatusLine string50 StatusLine string
50 Err string51 Err string
52+
53+ // ExpandAll forces every long tool result to render in full.
54+ // Toggled from the tui by ctrl+o. When false, results longer than
55+ // ToolCollapseLines collapse to ToolCollapsePreview lines plus a
56+ // "... (N more lines, M total, ctrl+o to expand)" footer.
57+ ExpandAll bool
51 }58 }
52 59
60+// ToolCollapsePreview is the number of lines shown before a long tool
61+// result is replaced with a "... ctrl+o to expand" footer. Tool
62+// results shorter than ToolCollapseLines always render in full.
63+const (
64+ ToolCollapsePreview = 10
65+ ToolCollapseLines = 12
66+)
67+
53 // ToolCallView is a pending tool invocation plus optional result.68 // ToolCallView is a pending tool invocation plus optional result.
54 type ToolCallView struct {69 type ToolCallView struct {
55 ID string70 ID string
@@ -169,7 +184,15 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
169 if tc.Error {184 if tc.Error {
170 color = v.Theme.Error185 color = v.Theme.Error
171 }186 }
172- lines = append(lines, toolResultBlock(v.Theme, tc.Result, width, color)...)187+ block := toolResultBlock(v.Theme, tc.Result, width, color)
188+ // Strip rules, collapse the body, put rules back on.
189+ if len(block) >= 2 {
190+ top, bot := block[0], block[len(block)-1]
191+ body := v.collapseToolBody(block[1:len(block)-1], false)
192+ block = append([]string{top}, body...)
193+ block = append(block, bot)
194+ }
195+ lines = append(lines, block...)
173 }196 }
174 return lines197 return lines
175 }198 }
@@ -184,20 +207,45 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
184 func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string) []string {207 func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string) []string {
185 rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))208 rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
186 209
187- var out []string210+ var body []string
188- out = append(out, rule)211+ hasImage := false
189 for _, b := range blocks {212 for _, b := range blocks {
190 switch bb := b.(type) {213 switch bb := b.(type) {
191 case provider.TextBlock:214 case provider.TextBlock:
192- out = append(out, v.renderToolText(bb.Text, width, color, sourcePath)...)215+ body = append(body, v.renderToolText(bb.Text, width, color, sourcePath)...)
193 case provider.ImageBlock:216 case provider.ImageBlock:
194- out = append(out, v.renderImageBlock(bb, width)...)217+ hasImage = true
218+ body = append(body, v.renderImageBlock(bb, width)...)
195 }219 }
196 }220 }
221+ body = v.collapseToolBody(body, hasImage)
222+
223+ out := make([]string, 0, len(body)+2)
224+ out = append(out, rule)
225+ out = append(out, body...)
197 out = append(out, rule)226 out = append(out, rule)
198 return out227 return out
199 }228 }
200 229
230+// collapseToolBody trims lines to the configured preview size when the
231+// view is not in ExpandAll mode, appending a muted "... ctrl+o to
232+// expand" footer. Image blocks never collapse — they're short in text
233+// rows but represent real content the user wants to see.
234+func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
235+ if v.ExpandAll || hasImage {
236+ return lines
237+ }
238+ if len(lines) <= ToolCollapseLines {
239+ return lines
240+ }
241+ kept := lines[:ToolCollapsePreview]
242+ hidden := len(lines) - ToolCollapsePreview
243+ total := len(lines)
244+ footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
245+ footer = v.Theme.FG256(v.Theme.Muted, footer)
246+ return append(append([]string(nil), kept...), footer)
247+}
248+
201 // renderToolText renders a text block inside a tool result. If the249 // renderToolText renders a text block inside a tool result. If the
202 // text contains a unified-diff section (lines starting with "--- " /250 // text contains a unified-diff section (lines starting with "--- " /
203 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove251 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove
@@ -217,6 +265,7 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
217 lines := strings.Split(text, "\n")265 lines := strings.Split(text, "\n")
218 266
219 inDiff := false267 inDiff := false
268+ oldLine, newLine := 1, 1
220 var out []string269 var out []string
221 for _, l := range lines {270 for _, l := range lines {
222 // Detect diff header: "--- name" followed somewhere by "+++ name".271 // Detect diff header: "--- name" followed somewhere by "+++ name".
@@ -225,16 +274,29 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
225 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))274 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
226 continue275 continue
227 }276 }
277+ // Hunk header "@@ -a,b +c,d @@" resets the counters so patches
278+ // that skip around in the file still get correct numbering.
279+ if inDiff && strings.HasPrefix(l, "@@") {
280+ if o, n, ok := parseHunkHeader(l); ok {
281+ oldLine, newLine = o, n
282+ }
283+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
284+ continue
285+ }
228 if inDiff && len(l) > 0 {286 if inDiff && len(l) > 0 {
229 switch l[0] {287 switch l[0] {
230 case '+':288 case '+':
231- out = append(out, v.renderDiffRow(l, width, v.Theme.Tool))289+ out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))
290+ newLine++
232 continue291 continue
233 case '-':292 case '-':
234- out = append(out, v.renderDiffRow(l, width, v.Theme.Error))293+ out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))
294+ oldLine++
235 continue295 continue
236 case ' ':296 case ' ':
237- out = append(out, v.renderDiffRow(l, width, v.Theme.Muted))297+ out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))
298+ oldLine++
299+ newLine++
238 continue300 continue
239 }301 }
240 }302 }
@@ -246,16 +308,94 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
246 return out308 return out
247 }309 }
248 310
249-// renderDiffRow renders a single unified-diff line in fg color only.311+// parseHunkHeader extracts the starting old/new line from a unified
250-// The leading +/-/space stays visible so the user can tell at a glance312+// diff hunk header ("@@ -12,5 +12,7 @@ ..."). Returns ok=false if the
251-// what changed; the rest of the line is colored the same. Long lines313+// header is malformed or missing numbers.
252-// are wrapped with a 4-cell indent preserved.314+func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
253-func (v *View) renderDiffRow(line string, width, color int) string {315+ // Skip "@@ "
254- body := line316+ rest := strings.TrimPrefix(l, "@@")
255- if len(body) > width-4 {317+ rest = strings.TrimSpace(rest)
256- body = body[:width-7] + "…"318+ if !strings.HasPrefix(rest, "-") {
319+ return 0, 0, false
320+ }
321+ rest = rest[1:]
322+ space := strings.IndexByte(rest, ' ')
323+ if space < 0 {
324+ return 0, 0, false
325+ }
326+ oldPart := rest[:space]
327+ rest = strings.TrimSpace(rest[space+1:])
328+ if !strings.HasPrefix(rest, "+") {
329+ return 0, 0, false
330+ }
331+ rest = rest[1:]
332+ if sp := strings.IndexAny(rest, " \t"); sp >= 0 {
333+ rest = rest[:sp]
334+ }
335+ parseStart := func(s string) (int, bool) {
336+ if c := strings.IndexByte(s, ','); c >= 0 {
337+ s = s[:c]
338+ }
339+ n, err := strconv.Atoi(s)
340+ if err != nil || n < 1 {
341+ return 0, false
342+ }
343+ return n, true
344+ }
345+ o, ok1 := parseStart(oldPart)
346+ n, ok2 := parseStart(rest)
347+ if !ok1 || !ok2 {
348+ return 0, 0, false
349+ }
350+ return o, n, true
351+}
352+
353+// renderDiffRow renders one unified-diff line with a read-style gutter
354+// (6-cell right-aligned line number, muted) followed by the +/-/space
355+// marker and the code. Code is syntax-highlighted if sourcePath hints
356+// at a known language; falls back to the plain diff color otherwise.
357+func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) string {
358+ if len(line) == 0 {
359+ return ""
360+ }
361+ code := line[1:] // strip the leading marker; we re-emit it in colour
362+
363+ // Syntax-highlight the code half when we know the language. Use
364+ // the same HighlightCode pipeline as renderNumberedFile so the
365+ // palette matches.
366+ lang := LanguageFromPath(sourcePath)
367+ var codeRendered string
368+ if lang != "" {
369+ if h := HighlightCode(code, lang); len(h) == 1 {
370+ codeRendered = h[0]
371+ }
372+ }
373+ if codeRendered == "" {
374+ codeRendered = v.Theme.FG256(color, code)
375+ }
376+
377+ gutter := v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("%6d\t", lineNo))
378+ marker := v.Theme.FG256(color, string(mark)+" ")
379+ row := " " + gutter + marker + codeRendered
380+
381+ // Cheap width clamp: truncate visible text if the raw code is too
382+ // long. We work on the pre-ANSI code string because measuring ansi
383+ // output is unreliable.
384+ maxCode := width - 4 /* indent */ - 7 /* gutter */ - 2 /* marker */
385+ if maxCode > 0 && len(code) > maxCode {
386+ trunc := code[:maxCode-1] + "…"
387+ if lang != "" {
388+ if h := HighlightCode(trunc, lang); len(h) == 1 {
389+ codeRendered = h[0]
390+ } else {
391+ codeRendered = v.Theme.FG256(color, trunc)
392+ }
393+ } else {
394+ codeRendered = v.Theme.FG256(color, trunc)
395+ }
396+ row = " " + gutter + marker + codeRendered
257 }397 }
258- return " " + v.Theme.FG256(color, body)398+ return row
259 }399 }
260 400
261 // renderImageBlock returns the lines for one image, inline if possible.401 // renderImageBlock returns the lines for one image, inline if possible.

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