mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
add collapsible code blocks
This commit is contained in:
parent
6bb3e9e23f
commit
dbe6763736
4 changed files with 260 additions and 22 deletions
|
|
@ -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)"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue