diff --git a/internal/agent/modes/help.go b/internal/agent/modes/help.go index 336d12d..8624af0 100644 --- a/internal/agent/modes/help.go +++ b/internal/agent/modes/help.go @@ -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)"}, } diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 1a8a40f..4ad4d73 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -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) + } }() } diff --git a/internal/tui/input.go b/internal/tui/input.go index c221a32..326ae71 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -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': diff --git a/internal/tui/view.go b/internal/tui/view.go index 117c617..b4131ba 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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.