add collapsible code blocks

This commit is contained in:
patriceckhart 2026-04-18 10:30:29 +02:00
parent 6bb3e9e23f
commit dbe6763736
4 changed files with 260 additions and 22 deletions

View file

@ -20,6 +20,7 @@ var helpKeyRows = [][2]string{
{"ctrl+a / ctrl+e", "jump to start / end of line"},
{"alt+← / alt+→", "jump one word back / forward"},
{"ctrl+l", "redraw the screen"},
{"ctrl+o", "expand / collapse long tool results"},
{"pgup / pgdn", "scroll the chat one page up / down"},
{"up / down", "scroll by 3 lines (when input is empty) · prompt history (otherwise)"},
}

View file

@ -90,6 +90,16 @@ type Interactive struct {
cancelTurn context.CancelFunc
scrollOffset int // rows from the bottom; 0 = pinned to latest
// Messages typed while a turn is in flight. Each is delivered as
// its own follow-up turn once the current one finishes. Rendered
// above the status bar as "sliding in: ..." chips.
queued []string
// runCtx is the top-level context passed to Run(). Follow-up turns
// drained from `queued` are started against this context so they
// survive past the ctx of the key event that enqueued them.
runCtx context.Context
dialog *loginDialog
modelDialog *modelDialog
sessionDialog *sessionDialog
@ -124,6 +134,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
// Run blocks until the user quits.
func (i *Interactive) Run(ctx context.Context) error {
i.runCtx = ctx
term := i.cfg.Terminal
restore, err := term.EnterRaw()
if err != nil {
@ -391,10 +402,23 @@ func (i *Interactive) redraw() {
})
edLines, curR, curC := i.ed.Render(cols)
// "Sliding in" chips for messages the user typed while a turn is
// in flight. Shown directly above the status bar so they're close
// to the editor but don't push the chat around.
var queue []string
if len(i.queued) > 0 {
for _, q := range i.queued {
label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
text := truncateLine(q, cols-15)
queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
}
}
// Bottom-sticky sections (always visible, never scroll).
bottom := make([]string, 0, len(dialog)+len(suggest)+len(edLines)+1)
bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
bottom = append(bottom, dialog...)
bottom = append(bottom, suggest...)
bottom = append(bottom, queue...)
bottom = append(bottom, status)
bottom = append(bottom, edLines...)
@ -443,11 +467,30 @@ func (i *Interactive) redraw() {
frame = append(frame, visibleChat...)
frame = append(frame, bottom...)
cursorRow := len(visibleChat) + len(dialog) + len(suggest) + 1 + curR
cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + 1 + curR
cursorCol := curC
i.rend.Draw(frame, cursorRow, cursorCol)
}
// truncateLine shortens s so it fits within n display cells, with an
// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
// novel doesn't blow past the status line.
func truncateLine(s string, n int) string {
if n <= 0 {
return ""
}
// Collapse newlines — chips are single line.
s = strings.ReplaceAll(s, "\n", " ↩ ")
runes := []rune(s)
if len(runes) <= n {
return s
}
if n <= 1 {
return "…"
}
return string(runes[:n-1]) + "…"
}
func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
// Dialogs consume keys while open (except ctrl+c, which always closes them).
if i.dialog.Active() {
@ -513,6 +556,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
i.rend.Clear()
i.invalidate()
return false
case tui.KeyCtrlO:
// Toggle expansion of collapsed tool results. Affects every tool
// call in the transcript — press again to re-collapse.
i.mu.Lock()
i.view.ExpandAll = !i.view.ExpandAll
i.mu.Unlock()
i.invalidate()
return false
case tui.KeyPageUp:
i.scrollBy(+i.chatPage())
return false
@ -535,9 +586,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
}
}
if i.busy {
return false
}
// Note: we intentionally do NOT gate the editor on i.busy here.
// Typing while the agent is working is supported — submitted
// messages are queued and delivered as follow-up turns when the
// current turn ends. See the submit handler below.
if k.Kind == tui.KeyEnter && k.Alt {
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) {
i.mu.Unlock()
return false
}
// Slash commands need a quiet state (they may swap models,
// compact the transcript, open dialogs, etc). Refuse while
// a turn is in flight — esc / ctrl+c cancels first.
i.mu.Lock()
busy := i.busy
i.mu.Unlock()
if busy {
i.mu.Lock()
i.statusErr = "cancel the current turn (esc) before running a slash command"
i.mu.Unlock()
return false
}
return i.runSlash(ctx, text)
}
@ -609,6 +673,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
i.mu.Unlock()
return false
}
// If a turn is already in flight, queue this prompt instead of
// starting a second one. The drain loop at the end of startTurn
// will pick it up when the current turn finishes.
i.mu.Lock()
if i.busy {
i.queued = append(i.queued, text)
i.mu.Unlock()
i.invalidate()
return false
}
i.mu.Unlock()
i.startTurn(ctx, text)
}
return false
@ -958,8 +1033,27 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
if err != nil && ctx.Err() == nil {
i.statusErr = err.Error()
}
// Pop the next queued message, if any, and relaunch.
var next string
var hasNext bool
if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
next, i.queued = i.queued[0], i.queued[1:]
hasNext = true
}
// If the turn was cancelled or errored, drop the queue so the
// user isn't bombarded with stale messages after an interrupt.
if ctx.Err() != nil || err != nil {
i.queued = nil
}
i.mu.Unlock()
i.invalidate()
if hasNext {
parent := i.runCtx
if parent == nil {
parent = context.Background()
}
i.startTurn(parent, next)
}
}()
}

View file

@ -40,6 +40,7 @@ const (
KeyCtrlA
KeyCtrlE
KeyCtrlW
KeyCtrlO
KeyPaste
KeyUnknown
)
@ -83,6 +84,8 @@ func (r *Reader) Read() (Key, error) {
return Key{Kind: KeyCtrlE}, nil
case b == 0x17:
return Key{Kind: KeyCtrlW}, nil
case b == 0x0f:
return Key{Kind: KeyCtrlO}, nil
case b == '\r', b == '\n':
return Key{Kind: KeyEnter}, nil
case b == '\t':

View file

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
"github.com/patriceckhart/zot/internal/provider"
@ -48,8 +49,22 @@ type View struct {
ToolCalls []ToolCallView // tool calls in flight or completed
StatusLine string
Err string
// ExpandAll forces every long tool result to render in full.
// Toggled from the tui by ctrl+o. When false, results longer than
// ToolCollapseLines collapse to ToolCollapsePreview lines plus a
// "... (N more lines, M total, ctrl+o to expand)" footer.
ExpandAll bool
}
// ToolCollapsePreview is the number of lines shown before a long tool
// result is replaced with a "... ctrl+o to expand" footer. Tool
// results shorter than ToolCollapseLines always render in full.
const (
ToolCollapsePreview = 10
ToolCollapseLines = 12
)
// ToolCallView is a pending tool invocation plus optional result.
type ToolCallView struct {
ID string
@ -169,7 +184,15 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
if tc.Error {
color = v.Theme.Error
}
lines = append(lines, toolResultBlock(v.Theme, tc.Result, width, color)...)
block := toolResultBlock(v.Theme, tc.Result, width, color)
// Strip rules, collapse the body, put rules back on.
if len(block) >= 2 {
top, bot := block[0], block[len(block)-1]
body := v.collapseToolBody(block[1:len(block)-1], false)
block = append([]string{top}, body...)
block = append(block, bot)
}
lines = append(lines, block...)
}
return lines
}
@ -184,20 +207,45 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string) []string {
rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
var out []string
out = append(out, rule)
var body []string
hasImage := false
for _, b := range blocks {
switch bb := b.(type) {
case provider.TextBlock:
out = append(out, v.renderToolText(bb.Text, width, color, sourcePath)...)
body = append(body, v.renderToolText(bb.Text, width, color, sourcePath)...)
case provider.ImageBlock:
out = append(out, v.renderImageBlock(bb, width)...)
hasImage = true
body = append(body, v.renderImageBlock(bb, width)...)
}
}
body = v.collapseToolBody(body, hasImage)
out := make([]string, 0, len(body)+2)
out = append(out, rule)
out = append(out, body...)
out = append(out, rule)
return out
}
// collapseToolBody trims lines to the configured preview size when the
// view is not in ExpandAll mode, appending a muted "... ctrl+o to
// expand" footer. Image blocks never collapse — they're short in text
// rows but represent real content the user wants to see.
func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
if v.ExpandAll || hasImage {
return lines
}
if len(lines) <= ToolCollapseLines {
return lines
}
kept := lines[:ToolCollapsePreview]
hidden := len(lines) - ToolCollapsePreview
total := len(lines)
footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
footer = v.Theme.FG256(v.Theme.Muted, footer)
return append(append([]string(nil), kept...), footer)
}
// renderToolText renders a text block inside a tool result. If the
// text contains a unified-diff section (lines starting with "--- " /
// "+++ " / "+" / "-"/" "), those rows are styled with add/remove
@ -217,6 +265,7 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
lines := strings.Split(text, "\n")
inDiff := false
oldLine, newLine := 1, 1
var out []string
for _, l := range lines {
// Detect diff header: "--- name" followed somewhere by "+++ name".
@ -225,16 +274,29 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
continue
}
// Hunk header "@@ -a,b +c,d @@" resets the counters so patches
// that skip around in the file still get correct numbering.
if inDiff && strings.HasPrefix(l, "@@") {
if o, n, ok := parseHunkHeader(l); ok {
oldLine, newLine = o, n
}
out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
continue
}
if inDiff && len(l) > 0 {
switch l[0] {
case '+':
out = append(out, v.renderDiffRow(l, width, v.Theme.Tool))
out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))
newLine++
continue
case '-':
out = append(out, v.renderDiffRow(l, width, v.Theme.Error))
out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))
oldLine++
continue
case ' ':
out = append(out, v.renderDiffRow(l, width, v.Theme.Muted))
out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))
oldLine++
newLine++
continue
}
}
@ -246,16 +308,94 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
return out
}
// renderDiffRow renders a single unified-diff line in fg color only.
// The leading +/-/space stays visible so the user can tell at a glance
// what changed; the rest of the line is colored the same. Long lines
// are wrapped with a 4-cell indent preserved.
func (v *View) renderDiffRow(line string, width, color int) string {
body := line
if len(body) > width-4 {
body = body[:width-7] + "…"
// parseHunkHeader extracts the starting old/new line from a unified
// diff hunk header ("@@ -12,5 +12,7 @@ ..."). Returns ok=false if the
// header is malformed or missing numbers.
func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
// Skip "@@ "
rest := strings.TrimPrefix(l, "@@")
rest = strings.TrimSpace(rest)
if !strings.HasPrefix(rest, "-") {
return 0, 0, false
}
return " " + v.Theme.FG256(color, body)
rest = rest[1:]
space := strings.IndexByte(rest, ' ')
if space < 0 {
return 0, 0, false
}
oldPart := rest[:space]
rest = strings.TrimSpace(rest[space+1:])
if !strings.HasPrefix(rest, "+") {
return 0, 0, false
}
rest = rest[1:]
if sp := strings.IndexAny(rest, " \t"); sp >= 0 {
rest = rest[:sp]
}
parseStart := func(s string) (int, bool) {
if c := strings.IndexByte(s, ','); c >= 0 {
s = s[:c]
}
n, err := strconv.Atoi(s)
if err != nil || n < 1 {
return 0, false
}
return n, true
}
o, ok1 := parseStart(oldPart)
n, ok2 := parseStart(rest)
if !ok1 || !ok2 {
return 0, 0, false
}
return o, n, true
}
// renderDiffRow renders one unified-diff line with a read-style gutter
// (6-cell right-aligned line number, muted) followed by the +/-/space
// marker and the code. Code is syntax-highlighted if sourcePath hints
// at a known language; falls back to the plain diff color otherwise.
func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) string {
if len(line) == 0 {
return ""
}
code := line[1:] // strip the leading marker; we re-emit it in colour
// Syntax-highlight the code half when we know the language. Use
// the same HighlightCode pipeline as renderNumberedFile so the
// palette matches.
lang := LanguageFromPath(sourcePath)
var codeRendered string
if lang != "" {
if h := HighlightCode(code, lang); len(h) == 1 {
codeRendered = h[0]
}
}
if codeRendered == "" {
codeRendered = v.Theme.FG256(color, code)
}
gutter := v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("%6d\t", lineNo))
marker := v.Theme.FG256(color, string(mark)+" ")
row := " " + gutter + marker + codeRendered
// Cheap width clamp: truncate visible text if the raw code is too
// long. We work on the pre-ANSI code string because measuring ansi
// output is unreliable.
maxCode := width - 4 /* indent */ - 7 /* gutter */ - 2 /* marker */
if maxCode > 0 && len(code) > maxCode {
trunc := code[:maxCode-1] + "…"
if lang != "" {
if h := HighlightCode(trunc, lang); len(h) == 1 {
codeRendered = h[0]
} else {
codeRendered = v.Theme.FG256(color, trunc)
}
} else {
codeRendered = v.Theme.FG256(color, trunc)
}
row = " " + gutter + marker + codeRendered
}
return row
}
// renderImageBlock returns the lines for one image, inline if possible.