mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* feat(tui): actionable hints on provider/model errors Classify a failed turn's provider error into auth / rate-limit / connectivity / model-not-found / context-overflow and render a one-line next step below the red error row, instead of dumping an identical raw blob for every failure mode. The classifier lives in a new internal/errhint package so both the TUI error row and the CLI exec path can share it (TUIHint references slash commands, CLIHint references zero subcommands). Classification is a conservative string heuristic keyed off providerio.ClassifiedError's prefixes plus lower-level DNS/TLS/timeout/context-length signatures, since the numeric HTTP status is gone by the time the error reaches a UI surface. * feat(tools): budget bash output with head+tail truncation bash was the one tool that returned command output with no byte cap — 'cat large.log' or a verbose test run could dump megabytes into context and force compaction, while every other read/search tool already applies a budget. Cap stdout and stderr at 96KB each, keeping the head and tail of an oversized stream (build/test failures usually land at the tail) and dropping the middle behind a marker that points at redirect-to-file + read_file. Record raw_bytes/emitted_bytes/estimated_tokens/truncated in Meta like the other tools. Sandbox-denial and shell-issue detection still run on the full raw output before budgeting. * fix(gemini): retry 401 with refreshed OAuth token The Gemini provider used providerio.SendWithRetry (429/503/529 only), so an expired OAuth token surfaced as a terminal 'API key not valid' auth error while OpenAI and Anthropic silently recovered via SendWithAuthRetry's force-refresh-and-replay on 401. Plumb OAuthResolver through gemini.Options/Provider and the factory (exactly as the other two providers already do) and switch the stream send to SendWithAuthRetry. With a nil resolver (API-key users) behavior is unchanged. * fix(errhint): gate classification on provider-origin marker Address CodeRabbit review on #1: agentResponseMsg.err can also carry local failures (a tool's 'permission denied', a 'file does not exist', a config error), and broad substrings would have attached a bogus /provider or /model hint to those. Classify now returns Unknown unless the message carries a provider marker the provider layer always attaches (auth error: / rate limit error: / provider error: / provider request error: / provider stream error:). Local errors never draw a provider hint; provider errors sub-classify as before. Added local-failure test cases proving they stay Unknown. * feat(cli): actionable hint on text-mode exec provider errors zero exec printed provider failures as a bare '[zero] <raw error>' with no next step. Append a one-line hint (reusing the shared errhint classifier) for recognized provider failures — 'run `zero auth`', 'run `zero doctor`', etc. The provider-origin gate means non-provider codes (sandbox_error, mcp_error) and JSON/stream-json output are untouched. * fix(tools): set Result.Truncated for budgeted bash output Address CodeRabbit review on #3: budgetBashOutput recorded truncation in meta["truncated"] but never set the Result.Truncated struct field, so consumers reading the struct (like glob/web_fetch/read_minified do) would miss truncated bash results. Return the bool from budgetBashOutput and set Result.Truncated on every return path. * perf(tui): reuse one LSP manager across prompts runAgentWithOptions built a fresh lsp.NewManager per run and shut it down when the run returned, so gopls (and every other language server) cold-started on the first edit of every turn — 200ms-2s of latency the manager's own design (long-lived, reused servers) exists to avoid. Build one manager per session in newModel (cheap: servers start lazily on first Check) and reuse it across runs; the SelfCorrector still gets a fresh checker wrapping the shared manager each run. Torn down with a short deadline in quit(). Runs fall back to a per-run manager when the session manager is absent (cwd unknown, or a directly-built test model). * perf(tui): accumulate streamingText as []byte (O(1) append) m.streamingText += msg.delta was O(len) per delta -> O(n²) across a long generation (a 10k-token code gen allocates hundreds of MB of intermediate strings and the UI gets progressively laggier). Accumulate into a []byte with append (O(1) amortized) and read via streamingTextString(). A []byte rather than strings.Builder because the TUI model is copied by value on every Update, which would trip strings.Builder's copy check; []byte is nil-safe and copies cleanly like the other slice fields. * perf(tui): coalesce streamed text deltas to one frame Every OnText delta was its own tea.Msg, so a fast provider (100+ tok/s) drove 100+ full Update->View cycles per second, each re-parsing the growing markdown — visible stutter over SSH and wasted CPU. Batch agentTextMsg deltas at the runtime sink over a ~16ms frame and forward them as a single message, decoupling render rate from token rate. Any non-text message flushes pending text first so ordering with tool-call / reasoning / row messages is preserved; the final agentResponseMsg (a tea.Cmd return, not a sink message) is safe because the model already drops deltas for an inactive runID. * fix(agent): raise tool-failure stop threshold 4->6 guardrails.go halted a run after 4 consecutive same-error tool failures. A corrective hint fires at 2, and a model iterating on a genuinely tricky edit can legitimately fail a couple more times after the hint while converging — stopping at 4 cut those runs short. Raise to 6; the streak still resets the instant the tool succeeds or hits a different error, so only true same-error loops are affected. * fix(agent): more reconnect retries with jitter; narrow to transport errors #19: bump maxStreamReconnects 2->4 and add up-to-50% jitter on the exponential backoff (capped at 8s), so a multi-second network blip is ridden out instead of killing the run on a 2s hiccup, and concurrent runs (swarms, cron fleets) don't reconnect in lockstep. #34: drop the '502'/'503' substring matches from shouldReconnect. 503 already exhausted providerio.SendWithRetry (retrying here is a redundant double-retry) and 502 is non-idempotent by providerio's rule (the POST may have been processed). Only genuine transport failures — where no response was received — reconnect now. * fix(agent): reduce stall retries 2->1 to bound stuck-session time A no-output stream stall is detected only after the full stream idle timeout (~5min) elapses, and each retry can idle again — so 2 retries left an interactive session frozen for ~15min. Drop to 1 retry: keeps the common single-hiccup recovery while bounding the worst case to ~2x the idle timeout. * fix(tui): serialize coalescer forwarding to preserve message order Address CodeRabbit review on #7: flush() and the run-switch path drained the text buffer under c.mu but called forward() after releasing it, so a timer-fired text flush could race a concurrent non-text send() and land after the tool-call/reasoning message it should precede. Hold c.mu across drain AND forward (drainAndForwardLocked), so whoever holds the lock delivers atomically and the other caller blocks until it is done — text can never overtake a following non-text message. Added a concurrency stress test (timer racing an inline send) that passes under -race. * feat(tui): accept /compact now Bare /compact already triggers compaction, but /compact now — what users reach for when the context gauge climbs — hit the usage-error branch. Accept the 'now' keyword and advertise it in the command usage. * feat(tui): add /retry, /edit, /copy, /export commands /retry resends the last prompt; /edit recalls it into the composer to tweak and resend — both read a new lastPrompt field captured verbatim (pre-expansion) in launchPrompt. /copy puts the last answer on the clipboard via the existing copy machinery; /export writes a plain role-prefixed transcript to a file (timestamped default, or a given path). Real gaps for SSH users with no mouse select. * feat(tools): separator-insensitive tool_search matching tool_search ranked deferred tools by exact substring, so a model that typed 'webfetch' (dropping the underscore) matched nothing and looped. Add a separator-squashing fallback ('web_fetch' -> 'webfetch') scored below an exact substring match, so precise queries still rank first. * feat(tools): signal ask_user dismissal distinctly from a blank field Empty answers all rendered as '(no answer provided)', so the model couldn't tell a wholesale dismissal (user closed the prompt without answering anything) from one field left blank amid real answers — and might invent a default. FormatAskUserAnswers now flags a full dismissal up front as a skip and marks individual empties '(left blank)' vs '(skipped)'. * feat(tui): fuzzy ranking in model/session pickers The pickers filtered by flat strings.Contains with no ranking, so finding 'sonnet 4.5' among 50+ models meant an exact prefix or scrolling. Rank matches (exact < prefix < contains < subsequence, reusing fuzzySubsequenceGap) so the closest match lands on top, and 'snt45' now matches 'Sonnet 4.5'. Groups stay contiguous — ordered by their best match, never split — so the grouped model picker still renders one header per provider. Covers both /model and /resume (shared commandPicker). * perf(agent): cache per-tool schema render across turns partitionTools re-ran the recursive schema->map conversion (schemaToRuntimeMap) for every tool on every turn — redundant, since a tool's advertised name/description/schema is stable for the run. Add a per-run definition cache keyed by tool name (nil-safe; the plain partitionTools entrypoint and tests pass nil for fresh renders). The partitioning itself (visibility, deferral, ordering) still recomputes every turn — it must, because a tool's deferred state can flip mid-run (swarm tools un-defer once a swarm is active). Only the expensive schema render is memoized. tool_search is excluded by its callers (dynamic description) so it never poisons the cache. * perf(agent): calibrate compaction estimate against real prompt tokens The byte/4 heuristic (ApproxTextTokens) over-counts code-heavy content ~15-20%, so with triggerRatio=0.7 compaction fired at ~60% of true capacity — premature summarizer calls that degrade quality. Each turn now folds (rawEstimate, provider InputTokens) into an EMA calibration ratio, clamped per-sample to a sane band; maybeCompact and the reactive path scale their estimates by it. Turn 1 uses the raw estimate (no data yet); later turns compact near real capacity. * style: gofmt the new agent test files The CI gofmt check flagged the #22/#25 test files (struct-field and switch alignment). No behavior change. * feat(agent): turn-based plan staleness reminder The stale-plan nudge only fired after 10 tool calls since the last update_plan, so a plan that drifts stale across many low-tool-call turns (one call per turn) took many turns to catch — the 'plan set turn 1 stays pending forever' case. Add a turn-count complement: after stalePlanTurnThreshold (8) turns without an update AND with items still pending, fire the same one-shot reminder. Gated on pending items so a fully-completed plan is never nagged; the existing tool-call trigger is unchanged. (The roadmap's fuzzy 'tool calls unrelated to plan items' relevance match is deliberately omitted — too false-positive-prone.) * feat(agent): preserve recent-edits summary across compaction Compaction preserved the plan, loaded skills, tool schemas, and project instructions, but not file edits — so after the editing turns were elided the model knew it had changed a file only vaguely, and would re-read to rediscover its own footprint. Extract write_file/edit_file targets from the elided middle with a one-line note from each tool result, merge them across repeated compactions (newer note per path wins), and carry them in the preserved-state JSON (capped at 20 paths / 160 bytes per note). * feat(tui): workspace-scoped /resume + fewer event reads /resume listed sessions from every project globally, and read the full event file for each of them to test for resumable content. Filter the picker (and /resume latest) by the current workspace Cwd, checked BEFORE the per-session read so a large global history no longer pays N full file reads to build one workspace's list; also skip zero-event sessions via metadata without any read. Sessions with no recorded Cwd (older runs) and an unknown current workspace stay visible so nothing is hidden that can't be confidently placed. Explicit /resume <id> still resolves any session. * feat(tui): warn on model switch that drops staged images Staged images were silently dropped at submit when the active model had no vision support — the user only learned after sending. Chips already render above the composer (renderAttachmentChips); add an immediate amber warning line to the /model switch status (both same-provider and cross-provider paths) when images are staged and the newly selected model can't accept them, so the drop is surfaced at switch time. * feat(tui): inline recovery affordance on setup errors A first-run setup error rendered as a bare red 'error: <text>' with no pointer to the fix. Append a faint, stage-tailored recovery line (edit the endpoint/name/key above then Enter, or pick another model) that names only keys the stage's footer confirms, so the guidance is always accurate. Empty for stages whose footer already makes recovery obvious. * fix: address CodeRabbit review (5 findings) - loop.go: recompute the compaction calibration estimate at calibration time, not request-build time, so a reactive-compaction reissue that shrinks the request feeds the EMA the sample that actually completed. - errhint: match bare HTTP status codes (401/403/429/529) only at digit boundaries, so an incidental number ('4290ms', 'id 14015') isn't mis-bucketed as auth/rate-limit. - bash: drop the stray unmatched ']' in the output-truncation marker. - tui /retry: apply the same exiting/compactInFlight guards commandPrompt has, so a retry can't race compactResultMsg's rewrite of session state. - tui /export: write the transcript 0o600 (it may contain echoed secrets), not world/group-readable 0o644. * test(tui): skip export file-mode assertion on Windows os.WriteFile ignores Unix permission bits on Windows and Stat reports 0666, so the 0o600 assertion failed there. The production 0o600 is honored on Linux/macOS (where it matters); guard the check with runtime.GOOS so Windows smoke passes. * fix(tui): align /resume latest filters + case-insensitive workspace match on Windows Address CodeRabbit review on #21: - latestResumableInWorkspace now applies the SAME filters as the picker (skip zero-event metadata and empty/failed runs), so /resume latest can't land on a session the picker intentionally hides. - sessionMatchesWorkspace compares paths case-insensitively on Windows (its filesystem is), so the same workspace spelled with different casing no longer hides resumable sessions; other platforms stay case-sensitive. * fix(tui): /retry resends the last prompt's image/PDF attachments /retry relaunched only lastPrompt's text, but launchPrompt clears the pending image/document queues once a turn is sent. A vision- or PDF-backed prompt that failed would silently retry as text-only and answer a different task. Snapshot the consumed attachments at launch and re-stage them on /retry so launchPrompt rebuilds an identical request (document preamble, images, and the submit-time vision re-check). startNewSession drops the snapshot so a post-/new /retry cannot leak the previous session's attachments. Also correct the OAuthResolver factory comment, which omitted the Gemini provider it is now passed to. * fix: address jatmn review (reconnect 5xx, /edit attachments, edit-cap, coalescer test) - reconnect: exclude HTTP 5xx (500/502/503/504) before the transport substring match so "504 Gateway Timeout" no longer slips through on the generic "timeout" needle. A gateway timeout is non-idempotent — the completion POST may already have reached the model — so replaying the connect risked duplicate billable work. Reuses a digit-boundary status matcher exported from errhint (HasStatusCode). Test asserts 504/500 stay non-reconnectable. - tui /edit: re-stage the remembered image/PDF snapshot alongside the recalled prompt text, mirroring the /retry fix — editing a vision- or document-backed prompt no longer silently resends a text-only version. - compaction recent-edits: order edits by LAST edit, not first, and move a re-touched path to the newest position on merge, so the maxRecentEdits tail cap keeps the file the model most recently edited instead of dropping it. Adds mergeRecentEdits (edit-specific) and a capped-list regression. - coalescer test: inject the frame timer via afterFunc so TestCoalescerBatchesDeltas proves buffering deterministically instead of racing the real 16ms wall-clock timer. * fix(tools): bound bash output capture in memory, not just model-visible text Previously stdout/stderr streamed into unbounded bytes.Buffers and were only truncated to the 96 KiB budget AFTER the command finished, so a runaway command (cat huge.log, yes) could grow Zero's memory until it stalled or OOMed before budgetBashOutput ever ran. Replace the capture with boundedBuffer, an io.Writer that keeps only the head+tail each stream will surface and discards the middle as it arrives, while counting the true total for the truncation marker. Memory is now bounded to ~head+2×tail per stream regardless of output size. truncateHeadTail is refactored onto a total-aware core so the string path (and its tests) are unchanged, and budgetBashCapture reports the true raw_bytes even though only a bounded slice was held. Adds boundedBuffer/capture unit tests and an end-to-end test that streams ~5× the budget and asserts bounded emission with an accurate raw_bytes.
2500 lines
87 KiB
Go
2500 lines
87 KiB
Go
package tui
|
||
|
||
import (
|
||
"fmt"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"charm.land/lipgloss/v2"
|
||
|
||
"github.com/Gitlawb/zero/internal/agent"
|
||
"github.com/Gitlawb/zero/internal/tools"
|
||
)
|
||
|
||
func displayValue(value string, fallback string) string {
|
||
if value == "" {
|
||
return fallback
|
||
}
|
||
return value
|
||
}
|
||
|
||
// pickerBusyText explains that a settings picker (/model, /effort)
|
||
// can't be opened while a run is in flight. Opening it then would silently refuse
|
||
// the selection once the run lands, so the no-arg command no-ops into this notice.
|
||
func pickerBusyText(name string) string {
|
||
label := strings.TrimPrefix(name, "/")
|
||
return renderCommandOutput(commandOutput{
|
||
Title: label,
|
||
Status: commandStatusWarning,
|
||
Sections: []commandSection{{
|
||
Title: "Busy",
|
||
Lines: []string{"Can't change " + label + " while a run is in progress."},
|
||
}},
|
||
Hints: []string{"press Esc twice to cancel the run, then try again"},
|
||
})
|
||
}
|
||
|
||
func helpText() string {
|
||
// Render /help as a styled command card (accent group headers, two-tone
|
||
// command rows) rather than a flat grey system note.
|
||
return commandCardTranscriptPrefix + formatGroupedCommandHelp()
|
||
}
|
||
|
||
// rowContext carries the cross-row knowledge renderRow needs: which tool
|
||
// calls already have results (their call rows collapse into the result card),
|
||
// each call's argument hints for the card head, and which calls were
|
||
// auto-approved (by permission mode or a stored grant). All maps are keyed by
|
||
// run-scoped ids (rcKey): some providers synthesize ToolCallIDs that repeat
|
||
// across turns (e.g. Gemini's gemini_tool_N), so a bare id could attribute a
|
||
// decision or a result to a different run's call.
|
||
type rowContext struct {
|
||
resolved map[string]bool
|
||
hints map[string]string
|
||
args map[string]string
|
||
auto map[string]bool
|
||
decided map[string]bool
|
||
}
|
||
|
||
func rcKey(runID int, id string) string {
|
||
return strconv.Itoa(runID) + ":" + id
|
||
}
|
||
|
||
func buildRowContext(rows []transcriptRow) rowContext {
|
||
rc := rowContext{
|
||
resolved: map[string]bool{},
|
||
hints: map[string]string{},
|
||
args: map[string]string{},
|
||
auto: map[string]bool{},
|
||
decided: map[string]bool{},
|
||
}
|
||
prompted := map[string]bool{}
|
||
for _, row := range rows {
|
||
switch row.kind {
|
||
case rowToolCall:
|
||
if row.id != "" {
|
||
rc.hints[rcKey(row.runID, row.id)] = strings.TrimSpace(row.detail)
|
||
rc.args[rcKey(row.runID, row.id)] = strings.TrimSpace(row.arg)
|
||
}
|
||
case rowToolResult:
|
||
if row.id != "" {
|
||
rc.resolved[rcKey(row.runID, row.id)] = true
|
||
}
|
||
case rowPermission:
|
||
event := row.permission
|
||
if event == nil || event.ToolCallID == "" {
|
||
continue
|
||
}
|
||
key := rcKey(row.runID, event.ToolCallID)
|
||
switch event.Action {
|
||
case agent.PermissionActionPrompt:
|
||
prompted[key] = true
|
||
delete(rc.auto, key)
|
||
case agent.PermissionActionAllow:
|
||
rc.decided[key] = true
|
||
// "auto" means approved without asking: a mode/policy allow or a
|
||
// stored grant match. Any allow that followed a prompt — including a
|
||
// first-time "always" — was a manual decision, not auto.
|
||
if !prompted[key] {
|
||
rc.auto[key] = true
|
||
}
|
||
case agent.PermissionActionDeny:
|
||
rc.decided[key] = true
|
||
case agent.PermissionActionCancel:
|
||
rc.decided[key] = true
|
||
}
|
||
}
|
||
}
|
||
return rc
|
||
}
|
||
|
||
// isHiddenPlumbingTool reports whether a tool is internal mechanism the user
|
||
// never needs to see in the transcript: update_plan (the plan is surfaced live
|
||
// in the context sidebar and the clickable step detail) and tool_search (the
|
||
// on-demand loading of tool schemas — the "select:…" noise). Their cards are
|
||
// suppressed so the chat reads as a clean narrative of real work.
|
||
func isHiddenPlumbingTool(name string) bool {
|
||
switch name {
|
||
case "update_plan", "tool_search":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// skip reports whether a row renders nothing itself: a tool call whose result
|
||
// arrived collapses into the result's card; a permission prompt that has been
|
||
// decided collapses away; silent auto-approvals collapse, while manual approval
|
||
// decisions remain as audit rows.
|
||
func (rc rowContext) skip(row transcriptRow) bool {
|
||
switch row.kind {
|
||
case rowToolCall:
|
||
// Pure-plumbing tools (the plan lives in the sidebar; tool_search just
|
||
// loads tool schemas) are mechanism the user never needs — drop their
|
||
// call and result cards so the chat stays a readable narrative of work.
|
||
if isHiddenPlumbingTool(row.tool) {
|
||
return true
|
||
}
|
||
return row.id != "" && rc.resolved[rcKey(row.runID, row.id)]
|
||
case rowToolResult:
|
||
// Hide only SUCCESSFUL plumbing results; a failed update_plan/tool_search
|
||
// must still surface its error.
|
||
return isHiddenPlumbingTool(row.tool) && row.status != tools.StatusError
|
||
case rowPermission:
|
||
event := row.permission
|
||
if event == nil || event.ToolCallID == "" {
|
||
return false
|
||
}
|
||
key := rcKey(row.runID, event.ToolCallID)
|
||
switch event.Action {
|
||
case agent.PermissionActionPrompt:
|
||
return rc.decided[key]
|
||
case agent.PermissionActionAllow:
|
||
return rc.auto[key]
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// cardRenderOptions carries per-render knobs for tool cards: the body-line cap
|
||
// (small for the live region, generous for the permanent scrollback flush) and
|
||
// the workspace root used to absolutize paths for OSC 8 file hyperlinks.
|
||
type cardRenderOptions struct {
|
||
bodyCap int
|
||
cwd string
|
||
expanded bool
|
||
// fileSelected marks a tool-result card whose mutation touched the file
|
||
// selected in the FILES sidebar; the card border tints accent so the
|
||
// selection reads in the transcript.
|
||
fileSelected bool
|
||
}
|
||
|
||
// flushCardBodyMaxLines is the body cap for cards flushed to scrollback. The
|
||
// small live cap exists only to keep the managed region tidy; history can hold
|
||
// full output — most importantly the complete diffs of edited code, which the
|
||
// user reviews by scrolling up.
|
||
const flushCardBodyMaxLines = 400
|
||
|
||
func (m model) renderRow(row transcriptRow, width int, rc rowContext) string {
|
||
return m.renderRowMode(row, width, rc, false)
|
||
}
|
||
|
||
func (m model) renderRowDetailed(row transcriptRow, width int, rc rowContext) string {
|
||
opts := cardRenderOptions{bodyCap: 0, cwd: m.cwd}
|
||
if defaultRenderCache != nil {
|
||
if key, stable := m.renderRowCacheKey(row, width, rc, opts, false); key != "" {
|
||
return defaultRenderCache.render(key, stable, func() string {
|
||
return m.renderRowModeUncached(row, width, rc, opts)
|
||
})
|
||
}
|
||
}
|
||
return m.renderRowModeUncached(row, width, rc, opts)
|
||
}
|
||
|
||
// renderRowMode renders a transcript row either for the live region (flush ==
|
||
// false: tight body caps, spinner-capable) or for its one-time scrollback
|
||
// flush (flush == true: deep body caps so edited code stays reviewable).
|
||
func (m model) renderRowMode(row transcriptRow, width int, rc rowContext, flush bool) string {
|
||
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
|
||
if flush {
|
||
opts.bodyCap = flushCardBodyMaxLines
|
||
}
|
||
if defaultRenderCache != nil {
|
||
if key, stable := m.renderRowCacheKey(row, width, rc, opts, flush); key != "" {
|
||
return defaultRenderCache.render(key, stable, func() string {
|
||
return m.renderRowModeUncached(row, width, rc, opts)
|
||
})
|
||
}
|
||
}
|
||
return m.renderRowModeUncached(row, width, rc, opts)
|
||
}
|
||
|
||
func (m model) renderRowModeUncached(row transcriptRow, width int, rc rowContext, opts cardRenderOptions) string {
|
||
// Resolved per-row (not at the opts construction sites) so every render
|
||
// path — live, flush, detailed — carries the FILES selection tint; the
|
||
// cache key includes the same predicate (renderRowCacheKey).
|
||
opts.fileSelected = m.rowTouchesSelectedFile(row)
|
||
switch row.kind {
|
||
case rowUser:
|
||
return renderUserRow(row, width)
|
||
case rowAssistant:
|
||
return renderAssistantRow(row, width)
|
||
case rowReasoning:
|
||
return renderReasoningRow(row, width)
|
||
case rowSystem:
|
||
if payload, ok := planCardTranscriptPayload(row.text); ok {
|
||
return renderPlanCardRow(payload, width)
|
||
}
|
||
if payload, ok := commandCardTranscriptPayload(row.text); ok {
|
||
return renderCommandCardRow(payload, width)
|
||
}
|
||
if row.tool == "sessions" {
|
||
return renderSessionsCards(row.text, width)
|
||
}
|
||
if row.tool == "mcp" {
|
||
return renderMCPManagerCard(row.text, width)
|
||
}
|
||
if row.id == compactStatusRowID && strings.HasPrefix(strings.TrimSpace(row.text), "Compressing session") {
|
||
return renderCompactRunningCard(row.text, width)
|
||
}
|
||
if row.id == compactStatusRowID && strings.HasPrefix(strings.TrimSpace(row.text), "Compression complete") {
|
||
return renderCompactCompleteCard(row.text, width)
|
||
}
|
||
if row.id == doctorStatusRowID && strings.HasPrefix(strings.TrimSpace(row.text), "Checking provider") {
|
||
return renderDoctorRunningCard(row.text, width)
|
||
}
|
||
if row.id == doctorStatusRowID {
|
||
return renderDoctorResultCard(row.text, width)
|
||
}
|
||
return renderSystemNote(row.text, width)
|
||
case rowError:
|
||
return renderErrorRow(row, width)
|
||
case rowToolCall:
|
||
return m.renderRunningToolCard(row, width, rc, opts)
|
||
case rowToolResult:
|
||
if isInternalToolArgumentError(row) {
|
||
return ""
|
||
}
|
||
return renderToolResultCard(row, width, rc, opts)
|
||
case rowPermission:
|
||
return renderPermissionRow(row, width)
|
||
case rowAskUser:
|
||
return renderAskUserRow(row, width)
|
||
case rowSpecialist:
|
||
if row.specialistInfo != nil {
|
||
return m.renderSpecialistCard(*row.specialistInfo, width)
|
||
}
|
||
return ""
|
||
case rowRecap:
|
||
return renderRecapRow(row, width)
|
||
default:
|
||
return row.text
|
||
}
|
||
}
|
||
|
||
// renderRecapRow renders the post-turn "※ recap: …" footnote — a faint one-line
|
||
// recap that lands below the answer's done-line (it is a separate transcript row
|
||
// appended after the final answer). Uses the same faint metadata style as the
|
||
// "worked for …" done-line.
|
||
func renderRecapRow(row transcriptRow, width int) string {
|
||
return fitStyledLine(zeroTheme.faint.Render("※ recap: "+strings.TrimSpace(row.text)), width)
|
||
}
|
||
|
||
func isInternalToolArgumentError(row transcriptRow) bool {
|
||
if row.status != tools.StatusError {
|
||
return false
|
||
}
|
||
detail := strings.TrimSpace(row.detail)
|
||
return strings.HasPrefix(detail, "Error: Failed to parse arguments for ") ||
|
||
row.tool == "ask_user" && strings.HasPrefix(detail, "Error: Invalid arguments for ask_user:")
|
||
}
|
||
|
||
// hyperlink wraps already-styled text in an OSC 8 terminal hyperlink so
|
||
// supporting terminals (iTerm2, WezTerm, kitty, Ghostty, …) make it clickable
|
||
// — cmd/ctrl+click on an edited file opens it. The sequences are zero-width
|
||
// for lipgloss/x-ansi width math, and truncateStyledLine skips and re-closes
|
||
// them via ansiSequenceEnd.
|
||
func hyperlink(url string, text string) string {
|
||
if url == "" || text == "" {
|
||
return text
|
||
}
|
||
return "\x1b]8;;" + url + "\x1b\\" + text + "\x1b]8;;\x1b\\"
|
||
}
|
||
|
||
// fileURL builds the file:// link target for a workspace path. The path is
|
||
// percent-encoded (spaces especially) — a raw space inside an OSC 8 URI makes
|
||
// some terminals terminate the sequence early and print the remainder.
|
||
func fileURL(cwd string, path string) string {
|
||
path = strings.TrimSpace(path)
|
||
if path == "" {
|
||
return ""
|
||
}
|
||
if !filepath.IsAbs(path) {
|
||
path = filepath.Join(cwd, path)
|
||
}
|
||
link := url.URL{Scheme: "file", Path: path}
|
||
return link.String()
|
||
}
|
||
|
||
// looksLikePath reports whether a tool-card target plausibly names a file —
|
||
// the only targets worth turning into hyperlinks (bash commands and grep
|
||
// patterns also flow through the target column).
|
||
func looksLikePath(value string) bool {
|
||
if value == "" || strings.ContainsAny(value, " \t") {
|
||
return false
|
||
}
|
||
return strings.Contains(value, "/") || filepath.Ext(value) != ""
|
||
}
|
||
|
||
// userHomeDir is overridable in tests; os.UserHomeDir in production.
|
||
var userHomeDir = os.UserHomeDir
|
||
|
||
// displayPath shortens an absolute path for the transcript so a tool card shows
|
||
// `examples/calc/calc.go` instead of `D:\…\examples\calc\calc.go`. Built-in
|
||
// tools already emit workspace-relative paths; this mainly tames MCP tools and
|
||
// any tool that surfaces an absolute path. Display-only: never mutate the path
|
||
// sent to a tool or stored in the session. The ladder mirrors the reference
|
||
// agents — relative under the workspace, `~`-relative under home, else the
|
||
// trailing segments with a `…/` prefix:
|
||
//
|
||
// under cwd → examples/calc/calc.go
|
||
// under $HOME → ~/projects/zero/main.go
|
||
// elsewhere → …/other/calc.go (last displayPathTailSegments segments)
|
||
// already short → returned unchanged (relative input, no separators, etc.)
|
||
//
|
||
// Output always uses forward slashes so it reads the same on every platform.
|
||
const displayPathTailSegments = 3
|
||
|
||
func displayPath(cwd string, p string) string {
|
||
p = strings.TrimSpace(p)
|
||
if p == "" || !filepath.IsAbs(p) {
|
||
// Relative inputs (the built-in-tool common case) are already short and
|
||
// workspace-anchored; just normalize separators.
|
||
return filepath.ToSlash(p)
|
||
}
|
||
if cwd = strings.TrimSpace(cwd); cwd != "" {
|
||
if rel, err := filepath.Rel(cwd, p); err == nil && !strings.HasPrefix(rel, "..") && rel != "." {
|
||
return filepath.ToSlash(rel)
|
||
}
|
||
}
|
||
if home, err := userHomeDir(); err == nil && home != "" {
|
||
if rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, "..") && rel != "." {
|
||
return "~/" + filepath.ToSlash(rel)
|
||
}
|
||
}
|
||
slashed := filepath.ToSlash(p)
|
||
segments := strings.Split(strings.Trim(slashed, "/"), "/")
|
||
if len(segments) <= displayPathTailSegments {
|
||
return slashed
|
||
}
|
||
return "…/" + strings.Join(segments[len(segments)-displayPathTailSegments:], "/")
|
||
}
|
||
|
||
// sayMeasure is the narrow prose wrap width for compact secondary text.
|
||
func sayMeasure(width int) int {
|
||
measure := width - 4
|
||
if measure > 74 {
|
||
measure = 74
|
||
}
|
||
if measure < 16 {
|
||
measure = 16
|
||
}
|
||
return measure
|
||
}
|
||
|
||
// assistantMeasureCap bounds assistant PROSE to a readable line length on wide
|
||
// terminals — long measures hurt readability past the ~90-100 col sweet spot.
|
||
// Looser than sayMeasure's 74 (this is the main answer); tables and code blocks
|
||
// still use the full chat width (the separate tableMeasure arg).
|
||
const assistantMeasureCap = 96
|
||
|
||
// assistantMeasure is the main answer prose wrap width: the chat width, capped at
|
||
// assistantMeasureCap, with a 16-col floor. Left-aligned (the cap just shortens
|
||
// lines; it does not center).
|
||
func assistantMeasure(width int) int {
|
||
measure := width
|
||
if measure > assistantMeasureCap {
|
||
measure = assistantMeasureCap
|
||
}
|
||
if measure < 16 {
|
||
measure = 16
|
||
}
|
||
return measure
|
||
}
|
||
|
||
// wrapPlainText word-wraps unstyled text to the measure, preserving explicit
|
||
// newlines AND each line's leading indentation (wrapped continuations keep the
|
||
// same indent), so code blocks and aligned lists in assistant answers survive.
|
||
// Words longer than the available measure are hard-split so no emitted line
|
||
// can exceed it.
|
||
func wrapPlainText(text string, measure int) []string {
|
||
if measure < 1 {
|
||
measure = 1
|
||
}
|
||
out := []string{}
|
||
for _, paragraph := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
|
||
if strings.TrimSpace(paragraph) == "" {
|
||
out = append(out, "")
|
||
continue
|
||
}
|
||
body := strings.TrimLeft(paragraph, " \t")
|
||
// Tabs render unpredictably across terminals; a fixed 4-cell indent
|
||
// keeps the width math exact (same policy as the tool cards).
|
||
indent := strings.ReplaceAll(paragraph[:len(paragraph)-len(body)], "\t", " ")
|
||
if len(indent) >= measure {
|
||
indent = strings.Repeat(" ", measure/2)
|
||
}
|
||
available := measure - len(indent)
|
||
// Preformatted: a body with an internal run of >=2 spaces is aligned
|
||
// content (columns, a table, indented code) where word-wrapping via
|
||
// strings.Fields would collapse the runs and destroy the alignment. Split it
|
||
// verbatim by display width instead, preserving every space. A line that
|
||
// already fits returns unchanged. Leading indent (handled above) and
|
||
// explicit newlines (the outer split) are unaffected.
|
||
if strings.Contains(body, " ") {
|
||
for _, segment := range splitPreservingWidth(body, available) {
|
||
out = append(out, indent+segment)
|
||
}
|
||
continue
|
||
}
|
||
line := ""
|
||
for _, word := range strings.Fields(body) {
|
||
for lipgloss.Width(word) > available {
|
||
if line != "" {
|
||
out = append(out, indent+line)
|
||
line = ""
|
||
}
|
||
head, tail := splitAtWidth(word, available)
|
||
out = append(out, indent+head)
|
||
word = tail
|
||
}
|
||
switch {
|
||
case line == "":
|
||
line = word
|
||
case lipgloss.Width(line)+1+lipgloss.Width(word) <= available:
|
||
line += " " + word
|
||
default:
|
||
out = append(out, indent+line)
|
||
line = word
|
||
}
|
||
}
|
||
if line != "" {
|
||
out = append(out, indent+line)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// splitAtWidth cuts text at the largest rune boundary whose display width
|
||
// fits the measure. CJK and emoji runes are double-width, so slicing by rune
|
||
// count would either panic or emit lines up to twice the measure.
|
||
func splitAtWidth(text string, measure int) (string, string) {
|
||
used := 0
|
||
for index, glyph := range text {
|
||
glyphWidth := lipgloss.Width(string(glyph))
|
||
if used+glyphWidth > measure {
|
||
if index == 0 {
|
||
// A single glyph wider than the measure still has to go somewhere.
|
||
_, size := utf8.DecodeRuneInString(text)
|
||
return text[:size], text[size:]
|
||
}
|
||
return text[:index], text[index:]
|
||
}
|
||
used += glyphWidth
|
||
}
|
||
return text, ""
|
||
}
|
||
|
||
// splitPreservingWidth breaks text into segments that each fit the measure in
|
||
// display width, preserving ALL characters (whitespace included) — the verbatim
|
||
// counterpart to the word-wrapper, used for aligned/columnar lines so their
|
||
// column spacing survives. A line that already fits returns a single segment.
|
||
func splitPreservingWidth(text string, measure int) []string {
|
||
if measure < 1 {
|
||
measure = 1
|
||
}
|
||
var segments []string
|
||
for lipgloss.Width(text) > measure {
|
||
head, tail := splitAtWidth(text, measure)
|
||
if head == "" {
|
||
// splitAtWidth always advances by >=1 rune for measure>=1; the guard
|
||
// just keeps a degenerate input from spinning.
|
||
break
|
||
}
|
||
segments = append(segments, head)
|
||
text = tail
|
||
}
|
||
return append(segments, text)
|
||
}
|
||
|
||
func renderUserRow(row transcriptRow, width int) string {
|
||
contentWidth := userPromptContentWidth(width)
|
||
wrapped := wrapPlainText(row.text, maxInt(1, contentWidth))
|
||
lines := make([]string, 0, len(wrapped)+1)
|
||
// A single plain blank line delimits the turn — no full-width painted band.
|
||
// The ▌ accent gutter alone marks it as the user's, matching the clean
|
||
// reference agents instead of a heavy chat bubble.
|
||
lines = append(lines, "")
|
||
for _, line := range wrapped {
|
||
lines = append(lines, renderUserPromptStyledLine(zeroTheme.ink.Bold(true).Render(line), contentWidth))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
const userPromptPrefix = "▌ "
|
||
|
||
func userPromptContentWidth(width int) int {
|
||
if width <= 0 {
|
||
return 0
|
||
}
|
||
prefixWidth := lipgloss.Width(userPromptPrefix)
|
||
return maxInt(0, width-prefixWidth)
|
||
}
|
||
|
||
func renderUserPromptStyledLine(styledText string, contentWidth int) string {
|
||
if contentWidth <= 0 {
|
||
return zeroTheme.userPrompt.Render("▌")
|
||
}
|
||
fitted := fitStyledLine(styledText, contentWidth)
|
||
return zeroTheme.userPrompt.Render("▌") + " " + fitted
|
||
}
|
||
|
||
// renderAssistantRow draws final answers as plain response text plus completion
|
||
// metadata; a non-final assistant row (e.g. a rehydrated inline notice) renders
|
||
// as interim-style prose.
|
||
func renderAssistantRow(row transcriptRow, width int) string {
|
||
tableMeasure := width
|
||
if !row.final {
|
||
// Interim prose is the agent's connective narration ("Now the stylesheet.").
|
||
// Render it as plain prose so the transcript doesn't grow a separate
|
||
// activity rail beside the tool rows.
|
||
narr := renderAssistantMarkdownText(row.text, assistantMeasure(width), tableMeasure, true)
|
||
out := make([]string, 0, len(narr))
|
||
for _, line := range narr {
|
||
styled := styleAssistantMarkdownLine(line, zeroTheme.ink)
|
||
out = append(out, fitStyledLine(styled, width))
|
||
}
|
||
return strings.Join(out, "\n")
|
||
}
|
||
// Committed final answer: highlighting runs here (once, behind the render cache).
|
||
lines := renderAssistantMarkdownText(row.text, assistantMeasure(width), tableMeasure, true)
|
||
for index := range lines {
|
||
lines[index] = styleAssistantMarkdownLine(lines[index], zeroTheme.ink)
|
||
}
|
||
if row.turnElapsed >= longTurnBookend {
|
||
lines = append(lines, doneLine(row))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func renderReasoningRow(row transcriptRow, width int) string {
|
||
// Committed rows live in the scrollable history, so show the whole body (no cap).
|
||
return renderReasoningBlock(row.text, row.expanded, width, false, row.turnElapsed, 0)
|
||
}
|
||
|
||
// renderReasoningBlock renders a reasoning ("Thinking…") block. When expanded and
|
||
// maxBodyLines > 0, the body is capped to the LATEST maxBodyLines (with a faint
|
||
// "… N earlier" marker), so a live thought stays ~half-screen and its clickable
|
||
// toggle header stays on-screen instead of filling the terminal. maxBodyLines = 0
|
||
// shows the whole body.
|
||
func renderReasoningBlock(text string, expanded bool, width int, running bool, elapsed time.Duration, maxBodyLines int) string {
|
||
text = strings.TrimSpace(text)
|
||
if strings.TrimSpace(text) == "" {
|
||
return ""
|
||
}
|
||
header := reasoningHeaderLine(text, expanded, running, elapsed)
|
||
if !expanded {
|
||
return fitStyledLine(header, width)
|
||
}
|
||
lines := []string{fitStyledLine(header, width)}
|
||
body := renderReasoningBodyLines(text, width)
|
||
if maxBodyLines > 0 && len(body) > maxBodyLines {
|
||
hidden := len(body) - maxBodyLines
|
||
lines = append(lines, fitStyledLine(" "+reasoningHiddenMarker(hidden), width))
|
||
body = body[hidden:]
|
||
}
|
||
for _, line := range body {
|
||
lines = append(lines, fitStyledLine(" "+styleAssistantMarkdownLine(line, zeroTheme.sayText), width))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
// reasoningHiddenMarker is the faint line shown in place of capped reasoning body
|
||
// lines; the display and selectable paths share it so they stay line-aligned.
|
||
func reasoningHiddenMarker(hidden int) string {
|
||
return zeroTheme.faint.Render(fmt.Sprintf("… %d earlier lines · Ctrl+O for all", hidden))
|
||
}
|
||
|
||
func renderReasoningBodyLines(text string, width int) []string {
|
||
measure := maxInt(16, sayMeasure(width)-2)
|
||
// Reasoning bodies can stream and rarely carry code: keep them plain.
|
||
return renderAssistantMarkdownText(strings.TrimSpace(text), measure, measure, false)
|
||
}
|
||
|
||
func reasoningHeaderLine(text string, expanded bool, running bool, elapsed time.Duration) string {
|
||
return zeroTheme.faint.Render(reasoningHeaderText(text, expanded, running, elapsed))
|
||
}
|
||
|
||
func reasoningHeaderText(text string, expanded bool, running bool, elapsed time.Duration) string {
|
||
icon, label := reasoningHeaderParts(text, expanded, running, elapsed)
|
||
if icon == "" {
|
||
return label
|
||
}
|
||
return icon + " " + label
|
||
}
|
||
|
||
func reasoningHeaderParts(_ string, expanded bool, running bool, elapsed time.Duration) (string, string) {
|
||
if running {
|
||
return "", "Thinking"
|
||
}
|
||
icon := "▸"
|
||
if expanded {
|
||
icon = "▾"
|
||
}
|
||
label := "Thought"
|
||
if elapsed > 0 {
|
||
label = fmt.Sprintf("Thought for %s", formatElapsedSeconds(elapsed))
|
||
}
|
||
return icon, label
|
||
}
|
||
|
||
func formatElapsedSeconds(elapsed time.Duration) string {
|
||
return fmt.Sprintf("%.1fs", elapsed.Seconds())
|
||
}
|
||
|
||
// longTurnBookend is the floor a turn must cross to earn a "worked for …"
|
||
// terminator. Short turns get none — the next user prompt is the separator,
|
||
// matching the clean reference agents — so trivial replies stay uncluttered.
|
||
const longTurnBookend = 60 * time.Second
|
||
|
||
// doneLine is the faint bookend for a long successful turn ("worked for 1m 5s").
|
||
// It carries no tool count (the tool cards above already show that) and never
|
||
// marks errors (the bordered error note already signals failure).
|
||
func doneLine(row transcriptRow) string {
|
||
return zeroTheme.faint.Render("worked for " + formatElapsedSeconds(row.turnElapsed))
|
||
}
|
||
|
||
// renderSystemNote draws a system notice as plain, lightly-marked lines — not a
|
||
// heavy box. A run cancellation reads amber ("stopped"); every other notice is
|
||
// calm muted info. Multi-line notices keep the marker on the first line and
|
||
// indent the continuation so the block still reads as one note.
|
||
func renderSystemNote(text string, width int) string {
|
||
trimmed := strings.TrimSpace(text)
|
||
marker, style := zeroTheme.faint.Render("·"), zeroTheme.muted
|
||
if isCancellationNotice(trimmed) {
|
||
marker, style = zeroTheme.amber.Render("⊘"), zeroTheme.amber
|
||
}
|
||
srcLines := strings.Split(trimmed, "\n")
|
||
out := make([]string, 0, len(srcLines))
|
||
for i, line := range srcLines {
|
||
prefix := marker + " "
|
||
if i > 0 {
|
||
prefix = " " // continuation lines align under the first word
|
||
}
|
||
out = append(out, fitStyledLine(prefix+style.Render(line), width))
|
||
}
|
||
return strings.Join(out, "\n")
|
||
}
|
||
|
||
// isCancellationNotice reports whether a system notice is the run-cancelled
|
||
// marker (single line, written by the cancel path), so it renders amber.
|
||
func isCancellationNotice(text string) bool {
|
||
t := strings.ToLower(strings.TrimSpace(text))
|
||
return !strings.Contains(t, "\n") && strings.HasPrefix(t, "run cancelled")
|
||
}
|
||
|
||
func renderCommandCardRow(text string, width int) string {
|
||
raw := strings.Split(strings.TrimRight(strings.ReplaceAll(text, "\r\n", "\n"), "\n"), "\n")
|
||
if len(raw) == 0 {
|
||
return renderSystemNote(text, width)
|
||
}
|
||
|
||
title := strings.TrimSpace(raw[0])
|
||
lines := make([]string, 0, len(raw)-1)
|
||
for _, line := range raw[1:] {
|
||
trimmed := strings.TrimSpace(line)
|
||
switch {
|
||
case trimmed == "":
|
||
lines = append(lines, "")
|
||
case isCommandCardStatusLine(trimmed):
|
||
// "status: info" and the like are structural noise — the card border
|
||
// and title already convey state; only surface a non-ok status.
|
||
if styled := styledCommandCardStatus(trimmed); styled != "" {
|
||
lines = append(lines, styled)
|
||
}
|
||
case isCommandCardActionsLine(trimmed):
|
||
lines = append(lines, zeroTheme.accent.Render("actions: ")+zeroTheme.ink.Render(strings.TrimSpace(strings.TrimPrefix(trimmed, "actions:"))))
|
||
case isCommandCardHintLine(trimmed):
|
||
lines = append(lines, zeroTheme.faint.Render(line))
|
||
case isIndentedCommandCardRow(line):
|
||
// A content row (indented): a "/cmd … - description" gets two-tone
|
||
// styling (bright name, muted description); a "key value" field or a
|
||
// "- bullet" keeps the value readable rather than dim grey.
|
||
lines = append(lines, styleCommandCardContentRow(line))
|
||
default:
|
||
// A non-indented, non-empty line is a group header (Model, Session…).
|
||
lines = append(lines, zeroTheme.accent.Bold(true).Render(line))
|
||
}
|
||
}
|
||
return styledBlockFillTitle(width, title, lines, zeroTheme.accent, lipgloss.NewStyle())
|
||
}
|
||
|
||
// renderPlanCardRow renders the plan-step detail card with a deliberately
|
||
// minimal look: a dim grey border (not the loud lime command-card border), a
|
||
// calm status-tinted title, and soft group headers instead of bright accent
|
||
// ones. The payload structure is identical to a command card, so it reuses the
|
||
// same line classification — only the colours differ.
|
||
func renderPlanCardRow(text string, width int) string {
|
||
raw := strings.Split(strings.TrimRight(strings.ReplaceAll(text, "\r\n", "\n"), "\n"), "\n")
|
||
if len(raw) == 0 {
|
||
return renderSystemNote(text, width)
|
||
}
|
||
|
||
title := strings.TrimSpace(raw[0])
|
||
lines := make([]string, 0, len(raw)-1)
|
||
for _, line := range raw[1:] {
|
||
trimmed := strings.TrimSpace(line)
|
||
switch {
|
||
case trimmed == "":
|
||
lines = append(lines, "")
|
||
case isCommandCardStatusLine(trimmed):
|
||
// The border + title already convey state; drop the structural
|
||
// "status: …" line entirely for the minimal card.
|
||
case isCommandCardHintLine(trimmed):
|
||
lines = append(lines, zeroTheme.faint.Render(line))
|
||
case isIndentedCommandCardRow(line):
|
||
lines = append(lines, styleCommandCardContentRow(line))
|
||
default:
|
||
// A section group header (What we did / Files changed …): soft grey,
|
||
// not the loud lime accent the command cards use.
|
||
lines = append(lines, zeroTheme.muted.Bold(true).Render(line))
|
||
}
|
||
}
|
||
return styledBlockFillTitleStyled(width, title, lines, zeroTheme.faintest, lipgloss.NewStyle(), planCardTitleStyle(title))
|
||
}
|
||
|
||
// planCardTitleStyle tints the plan card's title by the step state encoded in its
|
||
// suffix (· done / · failed / · in progress / · up next), staying calm: success
|
||
// green, failure red, otherwise plain ink/faint — no loud lime.
|
||
func planCardTitleStyle(title string) lipgloss.Style {
|
||
switch {
|
||
case strings.HasSuffix(title, "· done"):
|
||
return zeroTheme.green
|
||
case strings.HasSuffix(title, "· failed"):
|
||
return zeroTheme.red
|
||
case strings.HasSuffix(title, "· in progress"):
|
||
return zeroTheme.ink
|
||
default: // · up next (pending)
|
||
return zeroTheme.faint
|
||
}
|
||
}
|
||
|
||
// isIndentedCommandCardRow reports whether a line is an indented content row
|
||
// (a command, field, or bullet) rather than a group header.
|
||
func isIndentedCommandCardRow(line string) bool {
|
||
return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
|
||
}
|
||
|
||
// styleCommandCardContentRow two-tones an indented content row. A command row
|
||
// (" /name [args] - description") renders the "/name [args]" half in bright ink
|
||
// and the description in muted; a plain field/bullet row keeps the leading
|
||
// marker bright and the rest readable. Indentation is preserved.
|
||
func styleCommandCardContentRow(line string) string {
|
||
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
|
||
body := strings.TrimLeft(line, " \t")
|
||
|
||
// "/cmd … - description": split on the FIRST " - " so the command (and its
|
||
// arg/alias syntax) stays bright and the prose dims.
|
||
if strings.HasPrefix(body, "/") {
|
||
if name, desc, ok := strings.Cut(body, " - "); ok {
|
||
return indent + zeroTheme.ink.Bold(true).Render(name) + zeroTheme.muted.Render(" — "+desc)
|
||
}
|
||
return indent + zeroTheme.ink.Bold(true).Render(body)
|
||
}
|
||
// "- bullet" list item: keep it readable (not dim grey).
|
||
if strings.HasPrefix(body, "- ") {
|
||
return indent + zeroTheme.ink.Render(body)
|
||
}
|
||
// "key value" field row: the value carries the information, so keep the
|
||
// whole row in readable ink rather than the old faint grey.
|
||
return indent + zeroTheme.ink.Render(body)
|
||
}
|
||
|
||
// isCommandCardStatusLine reports whether trimmed is a "status: <state>" line.
|
||
func isCommandCardStatusLine(trimmed string) bool {
|
||
return strings.HasPrefix(trimmed, "status: ")
|
||
}
|
||
|
||
// styledCommandCardStatus returns a styled status line for a non-ok/non-info
|
||
// state (warning/blocked surface in their tint), or "" to drop a neutral
|
||
// "status: ok"/"status: info" entirely.
|
||
func styledCommandCardStatus(trimmed string) string {
|
||
state := strings.TrimSpace(strings.TrimPrefix(trimmed, "status:"))
|
||
switch state {
|
||
case "warning":
|
||
return zeroTheme.amber.Render(trimmed)
|
||
case "blocked":
|
||
return zeroTheme.red.Render(trimmed)
|
||
default: // ok, info — no signal worth the line
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func isCommandCardActionsLine(trimmed string) bool {
|
||
return strings.HasPrefix(trimmed, "actions:")
|
||
}
|
||
|
||
func isCommandCardHintLine(trimmed string) bool {
|
||
return strings.HasPrefix(trimmed, "hint:")
|
||
}
|
||
|
||
func renderMCPManagerCard(text string, width int) string {
|
||
raw := strings.Split(strings.TrimRight(strings.ReplaceAll(text, "\r\n", "\n"), "\n"), "\n")
|
||
lines := make([]string, 0, len(raw))
|
||
for index, line := range raw {
|
||
trimmed := strings.TrimSpace(line)
|
||
switch {
|
||
case trimmed == "":
|
||
lines = append(lines, "")
|
||
case index == 0:
|
||
lines = append(lines, zeroTheme.accent.Bold(true).Render(line))
|
||
case index == 1:
|
||
lines = append(lines, zeroTheme.ink.Bold(true).Render(line))
|
||
case isMCPManagerHeading(trimmed):
|
||
lines = append(lines, zeroTheme.accent.Bold(true).Render(line))
|
||
case strings.Contains(trimmed, "zero mcp "):
|
||
lines = append(lines, zeroTheme.ink.Render(line))
|
||
case strings.HasPrefix(trimmed, "›") || strings.HasPrefix(trimmed, "- "):
|
||
lines = append(lines, zeroTheme.ink.Render(line))
|
||
default:
|
||
lines = append(lines, zeroTheme.muted.Render(line))
|
||
}
|
||
}
|
||
return styledBlock(width, lines, zeroTheme.accent)
|
||
}
|
||
|
||
func isMCPManagerHeading(value string) bool {
|
||
switch value {
|
||
case "User MCPs", "Built-in MCPs", "Tools", "Permissions", "OAuth", "Actions":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func renderCompactRunningCard(text string, width int) string {
|
||
raw := strings.Split(strings.TrimRight(strings.ReplaceAll(text, "\r\n", "\n"), "\n"), "\n")
|
||
lines := make([]string, 0, len(raw)+1)
|
||
for index, line := range raw {
|
||
switch index {
|
||
case 0:
|
||
lines = append(lines, zeroTheme.amber.Bold(true).Render(line))
|
||
case 1:
|
||
lines = append(lines, zeroTheme.muted.Render(line))
|
||
case 2:
|
||
lines = append(lines, zeroTheme.amber.Bold(true).Render(line))
|
||
default:
|
||
lines = append(lines, zeroTheme.faint.Render(line))
|
||
}
|
||
if index == 0 {
|
||
lines = append(lines, "")
|
||
}
|
||
}
|
||
return styledBlock(width, lines, zeroTheme.amber)
|
||
}
|
||
|
||
func renderCompactCompleteCard(text string, width int) string {
|
||
raw := strings.Split(strings.TrimRight(strings.ReplaceAll(text, "\r\n", "\n"), "\n"), "\n")
|
||
lines := make([]string, 0, len(raw)+1)
|
||
for index, line := range raw {
|
||
switch index {
|
||
case 0:
|
||
lines = append(lines, zeroTheme.green.Bold(true).Render(line))
|
||
case 1:
|
||
lines = append(lines, zeroTheme.ink.Render(line))
|
||
default:
|
||
lines = append(lines, zeroTheme.muted.Render(line))
|
||
}
|
||
if index == 0 {
|
||
lines = append(lines, "")
|
||
}
|
||
}
|
||
return styledBlock(width, lines, zeroTheme.green)
|
||
}
|
||
|
||
func renderDoctorRunningCard(text string, width int) string {
|
||
raw := strings.Split(strings.TrimRight(strings.ReplaceAll(text, "\r\n", "\n"), "\n"), "\n")
|
||
lines := make([]string, 0, len(raw)+1)
|
||
for index, line := range raw {
|
||
switch index {
|
||
case 0:
|
||
lines = append(lines, zeroTheme.accent.Bold(true).Render(line))
|
||
case 1:
|
||
lines = append(lines, zeroTheme.muted.Render(line))
|
||
case 2:
|
||
lines = append(lines, zeroTheme.accent.Bold(true).Render(line))
|
||
default:
|
||
lines = append(lines, zeroTheme.faint.Render(line))
|
||
}
|
||
if index == 0 {
|
||
lines = append(lines, "")
|
||
}
|
||
}
|
||
return styledBlock(width, lines, zeroTheme.accent)
|
||
}
|
||
|
||
func renderDoctorResultCard(text string, width int) string {
|
||
raw := strings.Split(strings.TrimRight(strings.ReplaceAll(text, "\r\n", "\n"), "\n"), "\n")
|
||
border := doctorResultBorderStyle(text)
|
||
lines := make([]string, 0, len(raw))
|
||
for index, line := range raw {
|
||
trimmed := strings.TrimSpace(line)
|
||
switch {
|
||
case index == 0:
|
||
lines = append(lines, border.Bold(true).Render(line))
|
||
case strings.HasPrefix(trimmed, "status:"):
|
||
lines = append(lines, border.Render(line))
|
||
case isDoctorResultHeading(trimmed):
|
||
lines = append(lines, zeroTheme.accent.Bold(true).Render(line))
|
||
case strings.HasPrefix(trimmed, "- [pass]"):
|
||
lines = append(lines, zeroTheme.green.Render(line))
|
||
case strings.HasPrefix(trimmed, "- [warn]"):
|
||
lines = append(lines, zeroTheme.amber.Render(line))
|
||
case strings.HasPrefix(trimmed, "- [fail]"):
|
||
lines = append(lines, zeroTheme.red.Render(line))
|
||
case strings.HasPrefix(trimmed, "hint:"):
|
||
lines = append(lines, zeroTheme.faint.Render(line))
|
||
case strings.HasPrefix(trimmed, "/") ||
|
||
strings.HasPrefix(trimmed, "WSL2") ||
|
||
strings.HasPrefix(trimmed, "install "):
|
||
lines = append(lines, zeroTheme.ink.Render(line))
|
||
default:
|
||
lines = append(lines, zeroTheme.muted.Render(line))
|
||
}
|
||
if index == 0 {
|
||
lines = append(lines, "")
|
||
}
|
||
}
|
||
return styledBlock(width, lines, border)
|
||
}
|
||
|
||
func doctorResultBorderStyle(text string) lipgloss.Style {
|
||
for _, line := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
|
||
switch strings.TrimSpace(line) {
|
||
case "status: ok":
|
||
return zeroTheme.green
|
||
case "status: blocked":
|
||
return zeroTheme.red
|
||
case "status: warning":
|
||
return zeroTheme.amber
|
||
}
|
||
}
|
||
return zeroTheme.accent
|
||
}
|
||
|
||
func isDoctorResultHeading(value string) bool {
|
||
switch value {
|
||
case "Summary", "Provider", "Platform", "Other", "Backend", "Actions":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func renderErrorRow(row transcriptRow, width int) string {
|
||
note := noteBox(row.text, width, zeroTheme.cardErr, zeroTheme.red)
|
||
// A recognized failure carries a one-line next step. Render it just below the
|
||
// red box in the faint metadata style so it reads as guidance, not more error
|
||
// text (and to avoid nesting ANSI styles inside noteBox's per-line red wrap).
|
||
if hint := strings.TrimSpace(row.hint); hint != "" {
|
||
note += "\n" + fitStyledLine(zeroTheme.faint.Render("→ "+hint), width)
|
||
}
|
||
return note
|
||
}
|
||
|
||
// noteBox is the bordered one-note container behind system and error rows.
|
||
func noteBox(text string, width int, borderStyle lipgloss.Style, textStyle lipgloss.Style) string {
|
||
raw := strings.Split(strings.TrimRight(strings.ReplaceAll(text, "\r\n", "\n"), "\n"), "\n")
|
||
lines := make([]string, 0, len(raw))
|
||
for _, line := range raw {
|
||
lines = append(lines, textStyle.Render(line))
|
||
}
|
||
return styledBlock(width, lines, borderStyle)
|
||
}
|
||
|
||
func renderAskUserRow(row transcriptRow, width int) string {
|
||
line := fitStyledLine(zeroTheme.accent.Render("ask zero")+" "+zeroTheme.ink.Render(strings.TrimPrefix(row.text, "ask_user: ")), width)
|
||
if detail := strings.TrimSpace(row.detail); detail != "" {
|
||
line += "\n" + wrapDetailBlock(detail, width)
|
||
}
|
||
return line
|
||
}
|
||
|
||
// renderPermissionRow draws the transcript record of a permission event. A
|
||
// decided prompt and an auto-approved allow are skipped upstream, so this
|
||
// sees: undecided prompts (one amber line + detail), manual decisions (the
|
||
// spec's collapsed one-liner), and denials.
|
||
func renderPermissionRow(row transcriptRow, width int) string {
|
||
event := row.permission
|
||
if event == nil {
|
||
return zeroTheme.amber.Render("permission") + " " + zeroTheme.ink.Render(row.text)
|
||
}
|
||
|
||
name := event.ToolName
|
||
if name == "" {
|
||
name = row.tool
|
||
}
|
||
displayName := permissionToolDisplayName(name)
|
||
dot := zeroTheme.faintest.Render(" · ")
|
||
|
||
switch event.Action {
|
||
case agent.PermissionActionAllow:
|
||
label := "allowed once"
|
||
if event.DecisionAction == agent.PermissionDecisionAlwaysAllowPrefix {
|
||
label = "always prefix"
|
||
} else if event.DecisionAction == agent.PermissionDecisionAllowPrefix {
|
||
label = "allowed prefix"
|
||
} else if event.DecisionAction == agent.PermissionDecisionAllowForSession ||
|
||
(event.Grant != nil && event.Grant.Session) {
|
||
label = "allowed for session"
|
||
} else if event.DecisionAction == agent.PermissionDecisionAlwaysAllow ||
|
||
event.Grant != nil || event.GrantMatched {
|
||
label = "always"
|
||
}
|
||
line := zeroTheme.green.Render(label) + dot + zeroTheme.green.Render(displayName)
|
||
if scope := strings.TrimSpace(event.Scope); scope != "" {
|
||
line += dot + zeroTheme.muted.Render(permissionEventScopeLabel(event)+":"+scope)
|
||
}
|
||
return fitStyledLine(line, width)
|
||
case agent.PermissionActionDeny:
|
||
line := zeroTheme.red.Render("denied") + dot + zeroTheme.red.Render(displayName)
|
||
if scope := strings.TrimSpace(event.Scope); scope != "" {
|
||
line += dot + zeroTheme.muted.Render(permissionEventScopeLabel(event)+":"+scope)
|
||
}
|
||
if reason := permissionDisplayReason(event.Reason); reason != "" {
|
||
line += zeroTheme.faint.Render(" — " + truncateRunes(reason, maxInt(16, width-lipgloss.Width(displayName)-16)))
|
||
}
|
||
out := fitStyledLine(line, width)
|
||
if detail := strings.TrimSpace(row.detail); detail != "" {
|
||
out += "\n" + wrapDetailBlock(detail, width)
|
||
}
|
||
return out
|
||
case agent.PermissionActionCancel:
|
||
line := zeroTheme.red.Render("cancelled") + dot + zeroTheme.red.Render(displayName)
|
||
if scope := strings.TrimSpace(event.Scope); scope != "" {
|
||
line += dot + zeroTheme.muted.Render(permissionEventScopeLabel(event)+":"+scope)
|
||
}
|
||
if reason := permissionDisplayReason(event.Reason); reason != "" {
|
||
line += zeroTheme.faint.Render(" — " + truncateRunes(reason, maxInt(16, width-lipgloss.Width(displayName)-16)))
|
||
}
|
||
out := fitStyledLine(line, width)
|
||
if detail := strings.TrimSpace(row.detail); detail != "" {
|
||
out += "\n" + wrapDetailBlock(detail, width)
|
||
}
|
||
return out
|
||
default:
|
||
line := zeroTheme.amber.Render("permission") + " " + zeroTheme.ink.Render(displayName) + " " + zeroTheme.amber.Render("prompt")
|
||
if scope := strings.TrimSpace(event.Scope); scope != "" {
|
||
line += " " + zeroTheme.muted.Render(permissionEventScopeLabel(event)+":"+scope)
|
||
}
|
||
out := fitStyledLine(line, width)
|
||
if detail := strings.TrimSpace(row.detail); detail != "" {
|
||
out += "\n" + wrapDetailBlock(detail, width)
|
||
}
|
||
return out
|
||
}
|
||
}
|
||
|
||
// wrapDetailBlock wraps a metadata detail blob to the terminal and indents it
|
||
// two cells, so no permission/ask row can emit a line wider than the frame.
|
||
func wrapDetailBlock(detail string, width int) string {
|
||
lines := wrapPlainText(detail, maxInt(16, width-2))
|
||
for index := range lines {
|
||
lines[index] = " " + zeroTheme.muted.Render(lines[index])
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
// renderFocusedPermissionPrompt draws the modal permission card and reports the
|
||
// card-relative Y offset of each option line (in permissionOptions order) so the
|
||
// caller can register those lines as clickable.
|
||
func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int, width int) (string, []int) {
|
||
name := strings.TrimSpace(request.ToolName)
|
||
if name == "" {
|
||
name = "tool"
|
||
}
|
||
fill := zeroTheme.onPerm
|
||
|
||
top := zeroTheme.permBadge.Render(" PERMISSION ")
|
||
|
||
body := fill(zeroTheme.amber).Bold(true).Render(name)
|
||
if request.ToolName == tools.RequestPermissionsToolName {
|
||
body = fill(zeroTheme.amber).Bold(true).Render("Grant requested permissions?")
|
||
} else if request.SideEffect != "" {
|
||
body += fill(zeroTheme.ink).Render(" " + request.SideEffect)
|
||
}
|
||
lines := []string{top, body}
|
||
if reason := permissionDisplayReason(request.Reason); reason != "" {
|
||
lines = append(lines, fill(zeroTheme.muted).Render(reason))
|
||
}
|
||
// Surface exactly what the grant covers (file/dir/host) so "always" is a
|
||
// clear, bounded choice rather than a blind tool-wide yes.
|
||
if scope := strings.TrimSpace(request.Scope); scope != "" {
|
||
lines = append(lines, fill(zeroTheme.muted).Render(permissionScopeLine(request, scope)))
|
||
}
|
||
|
||
lines = append(lines, "")
|
||
|
||
// Each option is its own line so a click anywhere on that row selects it (no
|
||
// per-column hit-testing). The highlighted row gets a ▸ marker and a reverse
|
||
// label; the rest stay quiet. styledBlockFill prepends exactly one top-border
|
||
// line, so an option at content index i renders at card line i+1 — the offset
|
||
// returned for click registration.
|
||
options := permissionOptions(request)
|
||
cursor = clampPermissionCursor(cursor, request)
|
||
offsets := make([]int, len(options))
|
||
for index, option := range options {
|
||
offsets[index] = 1 + len(lines)
|
||
hotkey := fill(zeroTheme.faint).Render(" [" + option.hotkey + "]")
|
||
optionLabel := permissionOptionLabel(option, request)
|
||
if index == cursor {
|
||
marker := fill(zeroTheme.accent).Render("▸ ")
|
||
label := zeroTheme.badge.Render(" " + optionLabel + " ")
|
||
lines = append(lines, marker+label+hotkey)
|
||
} else {
|
||
label := fill(zeroTheme.ink).Render(optionLabel)
|
||
lines = append(lines, " "+label+hotkey)
|
||
}
|
||
}
|
||
|
||
lines = append(lines, "")
|
||
footer := "↑↓ move · enter or click to confirm · [esc] cancel run"
|
||
if request.ToolName == tools.RequestPermissionsToolName {
|
||
footer = "↑↓ move · enter or click to confirm · [esc] continue without permissions"
|
||
}
|
||
lines = append(lines, fill(zeroTheme.faint).Render(footer))
|
||
|
||
return styledBlockFill(width, lines, zeroTheme.permBorder, zeroTheme.permBg), offsets
|
||
}
|
||
|
||
func permissionScopeLine(request agent.PermissionRequest, scope string) string {
|
||
if request.ToolName == tools.RequestPermissionsToolName {
|
||
return "permissions: " + scope
|
||
}
|
||
if request.SideEffect == string(tools.SideEffectNetwork) {
|
||
return "target: " + scope
|
||
}
|
||
return "scope: " + scope
|
||
}
|
||
|
||
func permissionOptionLabel(option permissionOption, request agent.PermissionRequest) string {
|
||
if request.ToolName == tools.RequestPermissionsToolName {
|
||
switch option.choice {
|
||
case permissionDecisionAllow:
|
||
return "Grant for this task"
|
||
case permissionDecisionAllowStrict:
|
||
return "Grant for this task and ask model to review commands"
|
||
case permissionDecisionAllowForSession:
|
||
return "Grant for this session"
|
||
case permissionDecisionDeny, permissionDecisionCancel:
|
||
return "Continue without permissions"
|
||
default:
|
||
return option.label
|
||
}
|
||
}
|
||
switch option.choice {
|
||
case permissionDecisionAllow:
|
||
if request.SideEffect == string(tools.SideEffectNetwork) {
|
||
return "Yes, just this once"
|
||
}
|
||
return "Yes, proceed"
|
||
case permissionDecisionAllowForSession:
|
||
if request.SideEffect == string(tools.SideEffectNetwork) {
|
||
return "Yes, and allow this host for this conversation"
|
||
}
|
||
if request.SideEffect == string(tools.SideEffectWrite) && strings.TrimSpace(request.Scope) != "" {
|
||
return "Yes, and don't ask again for these files in this session"
|
||
}
|
||
return "Yes, and don't ask again for this command in this session"
|
||
case permissionDecisionAllowPrefix:
|
||
if len(request.CommandPrefix) > 0 {
|
||
return "Yes, and allow `" + strings.Join(request.CommandPrefix, " ") + "` in this session"
|
||
}
|
||
return "Yes, and allow this command prefix in this session"
|
||
case permissionDecisionAlwaysAllowPrefix:
|
||
if len(request.CommandPrefix) > 0 {
|
||
return "Yes, and always allow `" + strings.Join(request.CommandPrefix, " ") + "`"
|
||
}
|
||
return "Yes, and always allow this command prefix"
|
||
case permissionDecisionAlwaysAllow:
|
||
if request.SideEffect == string(tools.SideEffectNetwork) {
|
||
return "Yes, and allow this host in the future"
|
||
}
|
||
if strings.TrimSpace(request.Scope) != "" {
|
||
return "Yes, and don't ask again for this scope"
|
||
}
|
||
return option.label
|
||
case permissionDecisionDeny:
|
||
return "No, continue without running it"
|
||
case permissionDecisionCancel:
|
||
return "No, and tell Zero what to do differently"
|
||
default:
|
||
return option.label
|
||
}
|
||
}
|
||
|
||
func permissionEventScopeLabel(event *agent.PermissionEvent) string {
|
||
if event != nil && event.SideEffect == string(tools.SideEffectNetwork) {
|
||
return "target"
|
||
}
|
||
return "scope"
|
||
}
|
||
|
||
// renderAskUserQuestionnaire draws the ask-user prompt that replaces the composer
|
||
// box: a tab row (one per question + a trailing Confirm tab for multi-question
|
||
// prompts), the active question's picker or free-text field, and a key-hint footer.
|
||
// `input` is the live composer value, echoed in free-text mode.
|
||
func renderAskUserQuestionnaire(prompt pendingAskUserPrompt, input string, width int) string {
|
||
questions := prompt.request.Questions
|
||
if len(questions) == 0 {
|
||
return styledBlockFill(width, []string{zeroTheme.badge.Render(" ASK ")}, zeroTheme.lineStrong, lipgloss.NewStyle())
|
||
}
|
||
// The questionnaire replaces the composer, so it paints on the terminal canvas
|
||
// (black) like the composer box — not a gray card. fill is an identity wrapper
|
||
// so existing fill(style) call sites render bare foregrounds on that canvas.
|
||
fill := func(style lipgloss.Style) lipgloss.Style { return style }
|
||
confirm := len(questions)
|
||
active := prompt.active
|
||
if active < 0 {
|
||
active = 0
|
||
}
|
||
if active > confirm {
|
||
active = confirm
|
||
}
|
||
multi := len(questions) > 1
|
||
|
||
var lines []string
|
||
if header := strings.TrimSpace(prompt.request.Header); header != "" {
|
||
lines = append(lines, fill(zeroTheme.ink).Render(header))
|
||
}
|
||
|
||
// Tab row (only for multi-question prompts): each question's short title + a
|
||
// trailing Confirm tab; the active tab is a lime badge, answered ones get a ✓.
|
||
if multi {
|
||
tabs := make([]string, 0, len(questions)+1)
|
||
for index, question := range questions {
|
||
title := askUserTabTitle(question, index)
|
||
switch {
|
||
case index == active:
|
||
tabs = append(tabs, zeroTheme.badge.Render(" "+title+" "))
|
||
case prompt.states[index].answered:
|
||
tabs = append(tabs, fill(zeroTheme.muted).Render(" ✓ "+title+" "))
|
||
default:
|
||
tabs = append(tabs, fill(zeroTheme.faint).Render(" "+title+" "))
|
||
}
|
||
}
|
||
if active == confirm {
|
||
tabs = append(tabs, zeroTheme.badge.Render(" Confirm "))
|
||
} else {
|
||
tabs = append(tabs, fill(zeroTheme.faint).Render(" Confirm "))
|
||
}
|
||
lines = append(lines, strings.Join(tabs, " "))
|
||
}
|
||
|
||
// Confirm tab: a review of the collected answers.
|
||
if active == confirm {
|
||
lines = append(lines, "")
|
||
lines = append(lines, fill(zeroTheme.ink).Render("Review and submit:"))
|
||
for index, question := range questions {
|
||
answer := strings.TrimSpace(prompt.states[index].answer)
|
||
rendered := fill(zeroTheme.ink).Render(answer)
|
||
if answer == "" {
|
||
rendered = fill(zeroTheme.faint).Render("(no answer)")
|
||
}
|
||
lines = append(lines, " "+fill(zeroTheme.muted).Render(askUserTabTitle(question, index)+": ")+rendered)
|
||
}
|
||
lines = append(lines, "")
|
||
lines = append(lines, fill(zeroTheme.faint).Render("⇆ tab · enter submit · esc dismiss"))
|
||
return styledBlockFill(width, lines, zeroTheme.lineStrong, lipgloss.NewStyle())
|
||
}
|
||
|
||
question := questions[active]
|
||
state := prompt.states[active]
|
||
lines = append(lines, "")
|
||
lines = append(lines, fill(zeroTheme.ink).Render(question.Question))
|
||
|
||
if len(question.Options) > 0 && !state.typing {
|
||
// Picker: numbered options (with optional descriptions) + a trailing "type
|
||
// your own" entry; the highlighted row gets a lime ▸ marker and a badge label,
|
||
// the recommended option is tagged inline.
|
||
selectable := askUserSelectableCount(question)
|
||
cursor := clampAskUserCursor(state.cursor, selectable)
|
||
for index, option := range question.Options {
|
||
label := fmt.Sprintf("%d. %s", index+1, option)
|
||
if option == question.Recommended {
|
||
label += " (recommended)"
|
||
}
|
||
if index == cursor {
|
||
lines = append(lines, fill(zeroTheme.accent).Render("▸ ")+zeroTheme.badge.Render(" "+label+" "))
|
||
} else {
|
||
lines = append(lines, " "+fill(zeroTheme.ink).Render(label))
|
||
}
|
||
if index < len(question.OptionDescriptions) && strings.TrimSpace(question.OptionDescriptions[index]) != "" {
|
||
lines = append(lines, " "+fill(zeroTheme.faint).Render(question.OptionDescriptions[index]))
|
||
}
|
||
}
|
||
typeOwn := fmt.Sprintf("%d. %s", len(question.Options)+1, askUserTypeMyOwnLabel)
|
||
if cursor >= len(question.Options) {
|
||
lines = append(lines, fill(zeroTheme.accent).Render("▸ ")+zeroTheme.badge.Render(" "+typeOwn+" "))
|
||
} else {
|
||
lines = append(lines, " "+fill(zeroTheme.muted).Render(typeOwn))
|
||
}
|
||
lines = append(lines, "")
|
||
footer := "↑↓ select · enter confirm · esc dismiss"
|
||
if multi {
|
||
footer = "⇆ tab · ↑↓ select · enter confirm · esc dismiss"
|
||
}
|
||
lines = append(lines, fill(zeroTheme.faint).Render(footer))
|
||
return styledBlockFill(width, lines, zeroTheme.lineStrong, lipgloss.NewStyle())
|
||
}
|
||
|
||
// Free-text mode: the typed answer is echoed here (this region IS the input now).
|
||
if question.MultiSelect && len(question.Options) > 0 {
|
||
lines = append(lines, fill(zeroTheme.muted).Render("suggested: "+strings.Join(question.Options, ", ")))
|
||
}
|
||
lines = append(lines, zeroTheme.userPrompt.Render("❯ ")+fill(zeroTheme.ink).Render(input)+fill(zeroTheme.accent).Render("▌"))
|
||
footer := "enter submit · esc dismiss"
|
||
switch {
|
||
case !question.MultiSelect && len(question.Options) > 0:
|
||
footer = "enter submit · esc back to options"
|
||
case multi:
|
||
footer = "⇆ tab · enter submit · esc dismiss"
|
||
}
|
||
lines = append(lines, fill(zeroTheme.faint).Render(footer))
|
||
return styledBlockFill(width, lines, zeroTheme.lineStrong, lipgloss.NewStyle())
|
||
}
|
||
|
||
// --- Tool cards -------------------------------------------------------------
|
||
|
||
// cardBodyMaxLines caps every card body; hidden lines collapse into a
|
||
// "… N more lines" trailer.
|
||
const cardBodyMaxLines = 16
|
||
|
||
// cardBody is what a result-shape renderer hands back: body lines, an
|
||
// optional footer embedded in the bottom border, and optional extra head
|
||
// metadata (e.g. a read's line range).
|
||
type cardBody struct {
|
||
lines []string
|
||
footer string
|
||
headTag string
|
||
}
|
||
|
||
// renderRunningToolCard draws the head-only card for a tool call that has no
|
||
// result yet: spinner glyph while ITS run is live, a static placeholder for
|
||
// orphans (cancelled/errored turns, rehydrated history) — keying off the
|
||
// global pending flag alone would re-animate dead cards on every later run.
|
||
func (m model) renderRunningToolCard(row transcriptRow, width int, rc rowContext, opts cardRenderOptions) string {
|
||
glyph := zeroTheme.faintest.Render("…")
|
||
if m.pending && row.runID != 0 && row.runID == m.activeRunID {
|
||
glyph = zeroTheme.accent.Render(m.spinnerGlyph())
|
||
}
|
||
// The call row carries its own argHints; rc.hints/args only matter for
|
||
// result rows, whose detail is the tool output.
|
||
hint := strings.TrimSpace(row.detail)
|
||
if hint == "" {
|
||
hint = rc.hints[rcKey(row.runID, row.id)]
|
||
}
|
||
arg := strings.TrimSpace(row.arg)
|
||
if arg == "" {
|
||
arg = rc.args[rcKey(row.runID, row.id)]
|
||
}
|
||
// Running cards keep the normal name color; the accent spinner glyph at the
|
||
// front already marks them live (and orphaned dead cards must not look active).
|
||
head := toolCardHead(toolRowName(row), hint, arg, "", "", "", true, zeroTheme.ink, rc.auto[rcKey(row.runID, row.id)], width, opts)
|
||
return toolCard(head, glyph, nil, "", zeroTheme.cardRun, width)
|
||
}
|
||
|
||
func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts cardRenderOptions) string {
|
||
name := toolRowName(row)
|
||
failed := row.status == tools.StatusError
|
||
glyph := zeroTheme.green.Render("•")
|
||
nameStyle := zeroTheme.green
|
||
borderStyle := zeroTheme.line
|
||
if opts.fileSelected {
|
||
// The selected FILES row's edit card: accent border, same as the
|
||
// sidebar's ▸ marker, so click → highlight reads as one gesture.
|
||
borderStyle = zeroTheme.accent
|
||
}
|
||
if failed {
|
||
glyph = zeroTheme.red.Render("•")
|
||
nameStyle = zeroTheme.red
|
||
borderStyle = zeroTheme.cardErr
|
||
}
|
||
key := rcKey(row.runID, row.id)
|
||
headTarget := rc.hints[key]
|
||
headArg := rc.args[key]
|
||
if !failed && isExploreTool(name) {
|
||
headTarget = ""
|
||
headArg = ""
|
||
}
|
||
// A successful call whose only output is a one-line confirmation ("Created
|
||
// examples/calc.go (45 bytes).", "Successfully created directory …") restates
|
||
// what the head already shows (action + target + status dot), so drop the body and let
|
||
// the card collapse to a single line — matching the reference agents' density.
|
||
// Only for clean OK results: errors and anything multi-line keep their body.
|
||
if !failed && opts.bodyCap > 0 && !toolCardAlwaysExpands(name) && looksLikeRedundantConfirmation(row.detail) {
|
||
head := toolCardHead(name, headTarget, headArg, "", row.detail, row.text, false, nameStyle, rc.auto[key], width, opts)
|
||
return toolCard(head, glyph, nil, "", borderStyle, width)
|
||
}
|
||
// Collapse long, noisy output (web-search/MCP/read dumps) by default so the
|
||
// transcript stays scannable; the model still received the full output. Click
|
||
// the card to expand (▸ → ▾) while it is live; collapsed rows flush to
|
||
// scrollback clean. Skipped for: the uncapped detailed view (opts.bodyCap==0),
|
||
// diff tools whose body must stay reviewable, and short output.
|
||
collapsedFooter := ""
|
||
if opts.bodyCap > 0 && !toolCardAlwaysExpands(name) && !(!failed && (isExploreTool(name) || isLocalControlTool(name))) {
|
||
collapsedFooter = collapsedToolFooter(row.detail)
|
||
}
|
||
if collapsedFooter != "" && !row.expanded {
|
||
head := toolCardHead(name, headTarget, headArg, "", row.detail, row.text, false, nameStyle, rc.auto[key], width, opts)
|
||
return toolCard(head, glyph, nil, collapsedFooter, borderStyle, width)
|
||
}
|
||
bodyOpts := opts
|
||
bodyOpts.expanded = row.expanded
|
||
body := toolCardBody(name, rc.hints[key], rc.args[key], row.detail, width, bodyOpts, failed)
|
||
head := toolCardHead(name, headTarget, headArg, body.headTag, row.detail, row.text, false, nameStyle, rc.auto[key], width, opts)
|
||
footer := body.footer
|
||
if collapsedFooter != "" && row.expanded && footer == "" {
|
||
footer = "▾ collapse"
|
||
}
|
||
return toolCard(head, glyph, body.lines, footer, borderStyle, width)
|
||
}
|
||
|
||
// confirmationVerbPattern matches a single-line success confirmation that only
|
||
// restates the action + target: a leading verb ("Created", "Overwrote",
|
||
// "Successfully created directory", "Wrote", "Deleted", …) optionally followed
|
||
// by a path/detail. Kept deliberately narrow — anything it doesn't recognize
|
||
// keeps its body, so the worst case is the status quo, never lost output.
|
||
var confirmationVerbPattern = regexp.MustCompile(`(?i)^(successfully\s+\w+|created|overwrote|wrote|updated|edited|deleted|removed|renamed|moved|copied|appended)\b`)
|
||
|
||
// looksLikeRedundantConfirmation reports whether a tool's output is a single
|
||
// short line that merely confirms a mutation (so the card body would just echo
|
||
// the head). Multi-line output, or anything not starting with a known
|
||
// confirmation verb, is NOT redundant and keeps its body.
|
||
func looksLikeRedundantConfirmation(detail string) bool {
|
||
trimmed := strings.TrimSpace(detail)
|
||
if trimmed == "" || strings.Contains(trimmed, "\n") {
|
||
return false
|
||
}
|
||
return confirmationVerbPattern.MatchString(trimmed)
|
||
}
|
||
|
||
// toolCardAlwaysExpands reports tools whose body is a code diff that must stay
|
||
// visible for review (mirrors the deeper flush cap intent) rather than collapse.
|
||
func toolCardAlwaysExpands(name string) bool {
|
||
switch name {
|
||
case "edit_file", "apply_patch", "write_file":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// collapsedToolFooter summarizes the hidden output for a collapsed tool card, or
|
||
// "" when the output is short enough to render inline. Only output longer than
|
||
// the live body cap (the noisy "… N more lines" case, e.g. a web-search dump)
|
||
// collapses by default.
|
||
func collapsedToolFooter(detail string) string {
|
||
trimmed := strings.TrimRight(detail, "\n")
|
||
if strings.TrimSpace(trimmed) == "" {
|
||
return ""
|
||
}
|
||
n := strings.Count(trimmed, "\n") + 1
|
||
if n <= cardBodyMaxLines {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("▸ %d lines — click to expand", n)
|
||
}
|
||
|
||
func toolRowName(row transcriptRow) string {
|
||
if row.tool != "" {
|
||
return row.tool
|
||
}
|
||
name := strings.TrimPrefix(row.text, "tool call: ")
|
||
return strings.TrimPrefix(name, "tool result: ")
|
||
}
|
||
|
||
func isExploreTool(name string) bool {
|
||
switch name {
|
||
case "read_file", "read_minified_file", "list_directory", "grep", "glob":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isLocalControlTool(name string) bool {
|
||
switch name {
|
||
case "browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot",
|
||
"browser_click", "browser_type", "browser_press", "browser_action",
|
||
"desktop_windows", "desktop_snapshot", "desktop_action",
|
||
"terminal_session", "capture_artifact":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// toolDisplayName is the human-facing label for a tool card head. Core tools use
|
||
// action-oriented labels, and MCP tools are cleaned from "mcp_<server>_<tool>"
|
||
// to "<tool>" (e.g. mcp_exa_web_search_exa → "web search").
|
||
func toolDisplayName(name string) string {
|
||
switch name {
|
||
case "write_file":
|
||
return "Create"
|
||
case "edit_file":
|
||
return "Edit"
|
||
case "apply_patch":
|
||
return "Patch"
|
||
case "read_file", "read_minified_file":
|
||
return "Read"
|
||
case "list_directory":
|
||
return "List"
|
||
case "grep":
|
||
return "Search"
|
||
case "glob":
|
||
return "Find"
|
||
case "bash", "exec_command":
|
||
return "Run"
|
||
case "write_stdin":
|
||
return "Send input"
|
||
case "browser_install":
|
||
return "Install browser"
|
||
case "browser_launch":
|
||
return "Launch browser"
|
||
case "browser_connect":
|
||
return "Connect browser"
|
||
case "browser_open":
|
||
return "Open browser"
|
||
case "browser_snapshot":
|
||
return "Browser snapshot"
|
||
case "browser_click":
|
||
return "Click"
|
||
case "browser_type":
|
||
return "Type"
|
||
case "browser_press":
|
||
return "Press key"
|
||
case "browser_action":
|
||
return "Browser action"
|
||
case "desktop_windows":
|
||
return "Desktop windows"
|
||
case "desktop_snapshot":
|
||
return "Desktop snapshot"
|
||
case "desktop_action":
|
||
return "Desktop action"
|
||
case "terminal_session":
|
||
return "Terminal session"
|
||
case "capture_artifact":
|
||
return "Capture"
|
||
}
|
||
if !strings.HasPrefix(name, "mcp_") {
|
||
return name
|
||
}
|
||
rest := strings.TrimPrefix(name, "mcp_")
|
||
server := rest
|
||
if i := strings.Index(rest, "_"); i >= 0 {
|
||
server = rest[:i]
|
||
rest = rest[i+1:]
|
||
} else {
|
||
rest = ""
|
||
}
|
||
rest = strings.TrimSuffix(rest, "_"+server) // exa: web_search_exa → web_search
|
||
if rest == "" {
|
||
rest = server
|
||
}
|
||
return strings.ReplaceAll(rest, "_", " ")
|
||
}
|
||
|
||
func toolCardActionLabel(name string, detail string, running bool) string {
|
||
if running {
|
||
switch name {
|
||
case "write_file":
|
||
return "Adding"
|
||
case "edit_file":
|
||
return "Editing"
|
||
case "apply_patch":
|
||
return "Patching"
|
||
case "read_file", "read_minified_file":
|
||
return "Reading"
|
||
case "list_directory":
|
||
return "Listing"
|
||
case "grep":
|
||
return "Searching"
|
||
case "glob":
|
||
return "Finding"
|
||
case "bash", "exec_command":
|
||
return "Running"
|
||
case "write_stdin":
|
||
return "Sending input"
|
||
case "browser_install":
|
||
return "Installing"
|
||
case "browser_launch":
|
||
return "Launching"
|
||
case "browser_connect":
|
||
return "Connecting"
|
||
case "browser_open":
|
||
return "Opening"
|
||
case "browser_snapshot", "desktop_snapshot", "capture_artifact":
|
||
return "Capturing"
|
||
case "browser_click":
|
||
return "Clicking"
|
||
case "browser_type":
|
||
return "Typing"
|
||
case "browser_press":
|
||
return "Pressing"
|
||
case "browser_action", "desktop_action":
|
||
return "Interacting"
|
||
case "desktop_windows":
|
||
return "Listing"
|
||
case "terminal_session":
|
||
return "Running"
|
||
default:
|
||
return toolDisplayName(name)
|
||
}
|
||
}
|
||
switch name {
|
||
case "read_file", "read_minified_file", "list_directory", "grep", "glob":
|
||
return "Explored"
|
||
case "write_file":
|
||
meta := diffCardMetadata(detail)
|
||
trimmed := strings.TrimSpace(detail)
|
||
switch {
|
||
case strings.Contains(trimmed, "Overwrote "):
|
||
return "Updated"
|
||
case strings.Contains(trimmed, "Created "):
|
||
return "Added"
|
||
case meta.adds > 0 && meta.dels == 0:
|
||
return "Added"
|
||
case meta.adds > 0 || meta.dels > 0:
|
||
return "Updated"
|
||
default:
|
||
return "Create"
|
||
}
|
||
case "edit_file":
|
||
if strings.TrimSpace(detail) != "" {
|
||
return "Edited"
|
||
}
|
||
return "Edit"
|
||
case "apply_patch":
|
||
if strings.TrimSpace(detail) != "" {
|
||
return "Patched"
|
||
}
|
||
return "Patch"
|
||
case "bash", "exec_command":
|
||
return "Ran"
|
||
case "write_stdin":
|
||
return "Sent input"
|
||
case "browser_install":
|
||
return "Installed"
|
||
case "browser_launch":
|
||
return "Launched"
|
||
case "browser_connect":
|
||
return "Connected"
|
||
case "browser_open":
|
||
return "Opened"
|
||
case "browser_snapshot":
|
||
return "Captured"
|
||
case "browser_click":
|
||
return "Clicked"
|
||
case "browser_type":
|
||
return "Typed"
|
||
case "browser_press":
|
||
return "Pressed"
|
||
case "browser_action":
|
||
return "Ran action"
|
||
case "desktop_windows":
|
||
return "Listed"
|
||
case "desktop_snapshot":
|
||
return "Captured"
|
||
case "desktop_action":
|
||
return "Ran desktop action"
|
||
case "terminal_session":
|
||
return "Ran"
|
||
case "capture_artifact":
|
||
return "Captured"
|
||
default:
|
||
return toolDisplayName(name)
|
||
}
|
||
}
|
||
|
||
func toolCardHeadTarget(name string, target string, detail string) string {
|
||
target = strings.TrimSpace(target)
|
||
if target != "" {
|
||
return target
|
||
}
|
||
if name == "write_file" || name == "edit_file" || name == "apply_patch" || looksLikeDiff(detail) {
|
||
if meta := diffCardMetadata(detail); meta.path != "" {
|
||
return meta.path
|
||
}
|
||
}
|
||
if target := localControlHeadTarget(name, detail); target != "" {
|
||
return target
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func localControlHeadTarget(name string, detail string) string {
|
||
switch name {
|
||
case "browser_snapshot":
|
||
return "snapshot"
|
||
case "desktop_windows":
|
||
return "windows"
|
||
case "desktop_snapshot":
|
||
return "desktop"
|
||
case "browser_install":
|
||
return "runtime"
|
||
case "capture_artifact":
|
||
return artifactCapturedPath(detail)
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func permissionToolDisplayName(name string) string {
|
||
if isLocalControlTool(name) {
|
||
return toolDisplayName(name)
|
||
}
|
||
return name
|
||
}
|
||
|
||
// toolCardHead composes the card head: user-facing action label,
|
||
// middle-truncated target (hyperlinked when it names a file), the faintest arg
|
||
// column, optional extra tag, and the auto marker. The status glyph is added by
|
||
// toolCard so it leads the head text.
|
||
func toolCardHead(name string, target string, arg string, headTag string, detail string, actionDetail string, running bool, nameStyle lipgloss.Style, auto bool, width int, opts cardRenderOptions) string {
|
||
// Color the action label by state (accent running / green done / red failed)
|
||
// so the head row reads at a glance, reinforcing the leading status glyph.
|
||
if actionDetail == "" {
|
||
actionDetail = detail
|
||
}
|
||
head := nameStyle.Bold(true).Render(toolCardActionLabel(name, actionDetail, running))
|
||
if target = singleLineToolHeadText(toolCardHeadTarget(name, target, detail)); target != "" {
|
||
// Show a shortened, workspace-relative path, but keep the hyperlink
|
||
// pointing at the original absolute path so the file still opens.
|
||
shown := target
|
||
if looksLikePath(target) {
|
||
shown = displayPath(opts.cwd, target)
|
||
}
|
||
styled := zeroTheme.toolTarget.Render(middleTruncate(shown, maxInt(16, width/2)))
|
||
if looksLikePath(target) {
|
||
styled = hyperlink(fileURL(opts.cwd, target), styled)
|
||
}
|
||
head += " " + styled
|
||
}
|
||
// The arg column is the first thing the width tiers drop (below 100 cols).
|
||
if arg = singleLineToolHeadText(arg); arg != "" && widthTier(width) == tierFull {
|
||
head += " " + zeroTheme.toolArg.Render(truncateRunes(arg, maxInt(12, width/3)))
|
||
}
|
||
if headTag != "" {
|
||
if strings.Contains(headTag, "\x1b") {
|
||
head += " " + headTag
|
||
} else {
|
||
head += " " + zeroTheme.faint.Render(headTag)
|
||
}
|
||
}
|
||
_ = auto // the permission mode is shown in the composer divider; a per-card [auto] badge is redundant noise
|
||
return head
|
||
}
|
||
|
||
func singleLineToolHeadText(text string) string {
|
||
text = strings.TrimSpace(strings.ReplaceAll(text, "\r\n", "\n"))
|
||
if text == "" {
|
||
return ""
|
||
}
|
||
parts := []string{}
|
||
for _, line := range strings.Split(strings.ReplaceAll(text, "\r", "\n"), "\n") {
|
||
if trimmed := strings.TrimSpace(line); trimmed != "" {
|
||
parts = append(parts, trimmed)
|
||
}
|
||
}
|
||
return strings.Join(parts, " ")
|
||
}
|
||
|
||
// toolCard draws a compact tool block: the status glyph leads the head row,
|
||
// body lines follow directly below, and an optional footer closes the block. No
|
||
// rail or box border is drawn, so the transcript does not carry a distracting
|
||
// vertical activity line.
|
||
func toolCard(head string, glyph string, body []string, footer string, _ lipgloss.Style, width int) string {
|
||
if width < 24 {
|
||
width = 24
|
||
}
|
||
innerWidth := width
|
||
|
||
// Head line: LEADING status glyph + head. Leading the row with the
|
||
// glyph (status dot / spinner running) puts state in the first cell the
|
||
// eye lands on, instead of right-aligning it to the far card edge. The glyph
|
||
// (and the space after it) is reserved out of the head's width budget so a long
|
||
// head truncates cleanly; the row is right-padded to the full card width.
|
||
glyphWidth := lipgloss.Width(glyph)
|
||
leading := ""
|
||
leadingWidth := 0
|
||
if glyphWidth > 0 {
|
||
leading = glyph + " "
|
||
leadingWidth = glyphWidth + 1
|
||
}
|
||
headBudget := maxInt(1, width-leadingWidth)
|
||
head = fitStyledLine(head, headBudget)
|
||
headPad := maxInt(0, width-leadingWidth-lipgloss.Width(head))
|
||
headLine := leading + head + strings.Repeat(" ", headPad)
|
||
|
||
lines := make([]string, 0, len(body)+2)
|
||
lines = append(lines, headLine)
|
||
for _, line := range body {
|
||
fitted := fitStyledLine(line, innerWidth)
|
||
pad := strings.Repeat(" ", maxInt(0, width-lipgloss.Width(fitted)))
|
||
lines = append(lines, fitted+pad)
|
||
}
|
||
|
||
if strings.TrimSpace(footer) != "" {
|
||
fittedFooter := fitStyledLine(footer, width)
|
||
pad := strings.Repeat(" ", maxInt(0, width-lipgloss.Width(fittedFooter)))
|
||
lines = append(lines, fittedFooter+pad)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
// toolCardBody delegates result-shape selection to the tool body registry.
|
||
func toolCardBody(name string, hint string, arg string, detail string, width int, opts cardRenderOptions, failed bool) cardBody {
|
||
return defaultToolBodyRegistry.render(toolBodyRequest{
|
||
name: name,
|
||
hint: hint,
|
||
arg: arg,
|
||
detail: detail,
|
||
width: width,
|
||
opts: opts,
|
||
failed: failed,
|
||
})
|
||
}
|
||
|
||
// capCardLines applies the body cap, appending the hidden-count trailer when
|
||
// lines were dropped.
|
||
func capCardLines(lines []string, cap int) []string {
|
||
if cap <= 0 || len(lines) <= cap {
|
||
return lines
|
||
}
|
||
hidden := len(lines) - cap
|
||
lines = lines[:cap]
|
||
return append(lines, zeroTheme.faint.Render(fmt.Sprintf("… %d more lines", hidden)))
|
||
}
|
||
|
||
func genericCardBody(detail string, opts cardRenderOptions) cardBody {
|
||
raw := strings.Split(detail, "\n")
|
||
lines := make([]string, 0, len(raw))
|
||
for _, line := range raw {
|
||
lines = append(lines, zeroTheme.muted.Render(line))
|
||
}
|
||
return cardBody{lines: capCardLines(lines, opts.bodyCap)}
|
||
}
|
||
|
||
// hunkHeaderPattern extracts the old/new start lines from a unified-diff hunk
|
||
// header so the gutter can show real line numbers.
|
||
var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
|
||
|
||
type diffMetadata struct {
|
||
path string
|
||
newFile bool
|
||
adds int
|
||
dels int
|
||
}
|
||
|
||
func diffCardMetadata(detail string) diffMetadata {
|
||
meta := diffMetadata{}
|
||
for _, line := range strings.Split(detail, "\n") {
|
||
switch {
|
||
case strings.HasPrefix(line, "+++ "):
|
||
meta.path = strings.TrimPrefix(strings.TrimSpace(strings.TrimPrefix(line, "+++ ")), "b/")
|
||
case strings.HasPrefix(line, "--- "):
|
||
if strings.TrimSpace(strings.TrimPrefix(line, "--- ")) == "/dev/null" {
|
||
meta.newFile = true
|
||
}
|
||
case strings.HasPrefix(line, "+"):
|
||
meta.adds++
|
||
case strings.HasPrefix(line, "-"):
|
||
meta.dels++
|
||
}
|
||
}
|
||
return meta
|
||
}
|
||
|
||
func diffHeadTag(meta diffMetadata) string {
|
||
return diffCountTag(meta.adds, meta.dels)
|
||
}
|
||
|
||
func diffCountTag(adds int, dels int) string {
|
||
if adds == 0 && dels == 0 {
|
||
return ""
|
||
}
|
||
return zeroTheme.faint.Render("(") +
|
||
zeroTheme.diffAdd.Render(fmt.Sprintf("+%d", adds)) +
|
||
zeroTheme.faint.Render(" ") +
|
||
zeroTheme.diffDel.Render(fmt.Sprintf("-%d", dels)) +
|
||
zeroTheme.faint.Render(")")
|
||
}
|
||
|
||
func (meta diffMetadata) addOnly() bool {
|
||
return meta.adds > 0 && meta.dels == 0
|
||
}
|
||
|
||
func diffCardBody(detail string, width int, opts cardRenderOptions) cardBody {
|
||
rawLines := strings.Split(detail, "\n")
|
||
meta := diffCardMetadata(detail)
|
||
innerWidth := width
|
||
lines := []string{}
|
||
|
||
// The line-number gutter drops below 80 cols (the 60–79 tier). With it,
|
||
// gutter(4) + sign(3) + textBudget == innerWidth; without, sign(3) + text.
|
||
gutter := widthTier(width) >= tierMedium
|
||
gutterWidth := 0
|
||
if gutter {
|
||
gutterWidth = 4
|
||
}
|
||
textBudget := maxInt(8, innerWidth-3-gutterWidth)
|
||
highlightedAdds := highlightedAddedDiffLines(rawLines, meta, textBudget)
|
||
highlightAddIndex := 0
|
||
oldLine, newLine := 0, 0
|
||
inHunk := false
|
||
for i := 0; i < len(rawLines); i++ {
|
||
line := rawLines[i]
|
||
switch {
|
||
case strings.HasPrefix(line, "+++ "), strings.HasPrefix(line, "--- "):
|
||
// Path and counts live in the tool head row.
|
||
case strings.HasPrefix(line, "@@"):
|
||
if match := hunkHeaderPattern.FindStringSubmatch(line); match != nil {
|
||
oldLine, _ = strconv.Atoi(match[1])
|
||
newLine, _ = strconv.Atoi(match[2])
|
||
inHunk = true
|
||
}
|
||
// The raw hunk header is implementation metadata. Use it for line
|
||
// numbers, but keep the visible diff focused on file content.
|
||
continue
|
||
case !inHunk, strings.HasPrefix(line, `\`):
|
||
// Preamble ("diff --git", "index …", a stray "stdout:") and the
|
||
// "\ No newline at end of file" marker are not content lines: no
|
||
// gutter number, and the hunk counters must not advance.
|
||
lines = append(lines, zeroTheme.diffMeta.Render(truncateRunes(line, innerWidth)))
|
||
case strings.HasPrefix(line, "+"):
|
||
text := truncateRunes(strings.TrimPrefix(line, "+"), textBudget)
|
||
if len(highlightedAdds) == meta.adds && highlightAddIndex < len(highlightedAdds) {
|
||
lines = append(lines, diffBodyStyledLine(newLine, "+", highlightedAdds[highlightAddIndex], true, textBudget, gutter))
|
||
highlightAddIndex++
|
||
} else {
|
||
lines = append(lines, diffBodyLine(newLine, "+", text, true, textBudget, gutter))
|
||
}
|
||
newLine++
|
||
case strings.HasPrefix(line, "-"):
|
||
// Isolated 1:1 replacement (one "-" immediately followed by one "+"):
|
||
// highlight only the changed span on each side so a one-token edit reads
|
||
// instantly. Block changes and near-rewrites fall back to whole-line tint.
|
||
if isIsolatedReplacement(rawLines, i) {
|
||
delText := truncateRunes(strings.TrimPrefix(line, "-"), textBudget)
|
||
addText := truncateRunes(strings.TrimPrefix(rawLines[i+1], "+"), textBudget)
|
||
if delRow, addRow, ok := renderWordDiffPair(oldLine, newLine, delText, addText, textBudget, gutter); ok {
|
||
lines = append(lines, delRow, addRow)
|
||
oldLine++
|
||
newLine++
|
||
i++ // consume the paired "+"
|
||
continue
|
||
}
|
||
}
|
||
text := truncateRunes(strings.TrimPrefix(line, "-"), textBudget)
|
||
lines = append(lines, diffBodyLine(oldLine, "−", text, false, textBudget, gutter))
|
||
oldLine++
|
||
default:
|
||
text := truncateRunes(strings.TrimPrefix(line, " "), textBudget)
|
||
row := " " + zeroTheme.muted.Render(text)
|
||
if gutter {
|
||
row = zeroTheme.faintest.Render(fmt.Sprintf("%4d", newLine)) + row
|
||
}
|
||
lines = append(lines, row)
|
||
oldLine++
|
||
newLine++
|
||
}
|
||
}
|
||
return cardBody{lines: capCardLines(lines, opts.bodyCap), headTag: diffHeadTag(meta)}
|
||
}
|
||
|
||
func highlightedAddedDiffLines(rawLines []string, meta diffMetadata, textBudget int) []string {
|
||
if !meta.addOnly() || meta.path == "" {
|
||
return nil
|
||
}
|
||
content := make([]string, 0, meta.adds)
|
||
for _, line := range rawLines {
|
||
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++ ") {
|
||
content = append(content, strings.TrimPrefix(line, "+"))
|
||
}
|
||
}
|
||
if len(content) == 0 {
|
||
return nil
|
||
}
|
||
highlighted, ok := highlightCodeForPath(content, meta.path, textBudget, zeroTheme.addLine.GetBackground())
|
||
if !ok || len(highlighted) != len(content) {
|
||
return nil
|
||
}
|
||
return highlighted
|
||
}
|
||
|
||
// diffBodyLine paints one changed row: optional gutter number, sign column,
|
||
// and text padded to the full budget, all on the add/del tint so the row
|
||
// reads as one solid band edge to edge.
|
||
func diffBodyLine(number int, sign string, text string, added bool, textBudget int, gutter bool) string {
|
||
if pad := textBudget - lipgloss.Width(text); pad > 0 {
|
||
text += strings.Repeat(" ", pad)
|
||
}
|
||
numCol := ""
|
||
if gutter {
|
||
num := fmt.Sprintf("%4d", number)
|
||
if added {
|
||
numCol = zeroTheme.addLineNum.Render(num)
|
||
} else {
|
||
numCol = zeroTheme.delLineNum.Render(num)
|
||
}
|
||
}
|
||
if added {
|
||
return numCol + zeroTheme.addSign.Render(" "+sign+" ") + zeroTheme.addLine.Render(text)
|
||
}
|
||
return numCol + zeroTheme.delSign.Render(" "+sign+" ") + zeroTheme.delLine.Render(text)
|
||
}
|
||
|
||
func diffBodyStyledLine(number int, sign string, styledText string, added bool, textBudget int, gutter bool) string {
|
||
lineStyle, signStyle, numStyle := zeroTheme.delLine, zeroTheme.delSign, zeroTheme.delLineNum
|
||
if added {
|
||
lineStyle, signStyle, numStyle = zeroTheme.addLine, zeroTheme.addSign, zeroTheme.addLineNum
|
||
}
|
||
if pad := textBudget - lipgloss.Width(styledText); pad > 0 {
|
||
styledText += lineStyle.Render(strings.Repeat(" ", pad))
|
||
}
|
||
numCol := ""
|
||
if gutter {
|
||
numCol = numStyle.Render(fmt.Sprintf("%4d", number))
|
||
}
|
||
return numCol + signStyle.Render(" "+sign+" ") + styledText
|
||
}
|
||
|
||
func isDiffAddContent(s string) bool {
|
||
return strings.HasPrefix(s, "+") && !strings.HasPrefix(s, "+++ ")
|
||
}
|
||
func isDiffDelContent(s string) bool {
|
||
return strings.HasPrefix(s, "-") && !strings.HasPrefix(s, "--- ")
|
||
}
|
||
|
||
// isIsolatedReplacement reports whether rawLines[i] (already a deleted content
|
||
// line) is a single delete immediately followed by a single add — a 1:1 swap
|
||
// where an intra-line word diff is meaningful. It rejects multi-line delete or
|
||
// add blocks (where pairing would be wrong).
|
||
func isIsolatedReplacement(rawLines []string, i int) bool {
|
||
if i+1 >= len(rawLines) || !isDiffAddContent(rawLines[i+1]) {
|
||
return false
|
||
}
|
||
if i > 0 && isDiffDelContent(rawLines[i-1]) {
|
||
return false // part of a multi-line delete block
|
||
}
|
||
if i+2 < len(rawLines) && isDiffAddContent(rawLines[i+2]) {
|
||
return false // part of a multi-line add block
|
||
}
|
||
return true
|
||
}
|
||
|
||
// changedSpan returns the rune index p where a and b first diverge, plus the
|
||
// exclusive ends in a and b after the common suffix. The changed middle is
|
||
// a[p:aEnd] / b[p:bEnd]; equal strings yield p==aEnd==bEnd.
|
||
func changedSpan(a, b []rune) (p, aEnd, bEnd int) {
|
||
n := minInt(len(a), len(b))
|
||
for p < n && a[p] == b[p] {
|
||
p++
|
||
}
|
||
s := 0
|
||
for s < n-p && a[len(a)-1-s] == b[len(b)-1-s] {
|
||
s++
|
||
}
|
||
return p, len(a) - s, len(b) - s
|
||
}
|
||
|
||
// renderWordDiffPair renders a deleted/added pair with only the changed span
|
||
// highlighted. ok is false (caller falls back to whole-line tinting) when the
|
||
// change covers more than 60% of the longer line — a near-rewrite, where
|
||
// per-word highlighting is just noise.
|
||
func renderWordDiffPair(oldLine, newLine int, delText, addText string, textBudget int, gutter bool) (string, string, bool) {
|
||
del := []rune(delText)
|
||
add := []rune(addText)
|
||
p, delEnd, addEnd := changedSpan(del, add)
|
||
longer := maxInt(len(del), len(add))
|
||
changed := maxInt(delEnd-p, addEnd-p)
|
||
if longer == 0 || changed <= 0 || float64(changed)/float64(longer) > 0.6 {
|
||
return "", "", false
|
||
}
|
||
delRow := diffBodyLineSpanned(oldLine, "−", del, false, p, delEnd, textBudget, gutter)
|
||
addRow := diffBodyLineSpanned(newLine, "+", add, true, p, addEnd, textBudget, gutter)
|
||
return delRow, addRow, true
|
||
}
|
||
|
||
// diffBodyLineSpanned is diffBodyLine with the [spanStart,spanEnd) rune range of
|
||
// text painted on the brighter changed-span bg.
|
||
func diffBodyLineSpanned(number int, sign string, text []rune, added bool, spanStart, spanEnd, textBudget int, gutter bool) string {
|
||
if spanStart < 0 {
|
||
spanStart = 0
|
||
}
|
||
if spanEnd > len(text) {
|
||
spanEnd = len(text)
|
||
}
|
||
if spanEnd < spanStart {
|
||
spanEnd = spanStart
|
||
}
|
||
lineStyle, wordStyle, signStyle, numStyle := zeroTheme.delLine, zeroTheme.delLineWord, zeroTheme.delSign, zeroTheme.delLineNum
|
||
if added {
|
||
lineStyle, wordStyle, signStyle, numStyle = zeroTheme.addLine, zeroTheme.addLineWord, zeroTheme.addSign, zeroTheme.addLineNum
|
||
}
|
||
pre := string(text[:spanStart])
|
||
mid := string(text[spanStart:spanEnd])
|
||
post := string(text[spanEnd:])
|
||
if pad := textBudget - lipgloss.Width(string(text)); pad > 0 {
|
||
post += strings.Repeat(" ", pad)
|
||
}
|
||
body := lineStyle.Render(pre) + wordStyle.Render(mid) + lineStyle.Render(post)
|
||
numCol := ""
|
||
if gutter {
|
||
numCol = numStyle.Render(fmt.Sprintf("%4d", number))
|
||
}
|
||
return numCol + signStyle.Render(" "+sign+" ") + body
|
||
}
|
||
|
||
func exploreCardBody(name string, hint string, arg string, detail string, width int, opts cardRenderOptions, failed bool) cardBody {
|
||
if failed {
|
||
return genericCardBody(detail, opts)
|
||
}
|
||
summary := exploreCardLine(name, hint, arg, detail, width, opts, "└")
|
||
if !opts.expanded && opts.bodyCap > 0 {
|
||
footer := ""
|
||
if strings.TrimSpace(detail) != "" {
|
||
footer = zeroTheme.faint.Render("▸ details")
|
||
}
|
||
return cardBody{lines: []string{summary}, footer: footer}
|
||
}
|
||
body := exploreDetailCardBody(name, detail, width, opts)
|
||
lines := append([]string{summary}, body.lines...)
|
||
footer := body.footer
|
||
if opts.expanded && opts.bodyCap > 0 && footer == "" {
|
||
footer = zeroTheme.faint.Render("▾ collapse")
|
||
}
|
||
return cardBody{lines: lines, footer: footer}
|
||
}
|
||
|
||
func exploreDetailCardBody(name string, detail string, width int, opts cardRenderOptions) cardBody {
|
||
switch name {
|
||
case "grep":
|
||
return grepCardBody(detail, width, opts)
|
||
default:
|
||
return genericCardBody(detail, opts)
|
||
}
|
||
}
|
||
|
||
func exploreCardLine(name string, hint string, arg string, detail string, width int, opts cardRenderOptions, marker string) string {
|
||
if marker == "" {
|
||
marker = "└"
|
||
}
|
||
action := exploreChildAction(name)
|
||
target := exploreTarget(name, hint, arg, detail)
|
||
line := zeroTheme.faint.Render(" "+marker+" ") + zeroTheme.green.Render(action)
|
||
if target != "" {
|
||
shown := target
|
||
isPath := exploreTargetLooksLikePath(name, target)
|
||
if isPath {
|
||
shown = displayPath(opts.cwd, target)
|
||
}
|
||
styled := zeroTheme.toolTarget.Render(middleTruncate(shown, maxInt(8, width-lipgloss.Width(action)-6)))
|
||
if isPath {
|
||
styled = hyperlink(fileURL(opts.cwd, target), styled)
|
||
}
|
||
line += " " + styled
|
||
}
|
||
return line
|
||
}
|
||
|
||
func exploreChildAction(name string) string {
|
||
switch name {
|
||
case "read_file", "read_minified_file":
|
||
return "Read"
|
||
case "list_directory":
|
||
return "List"
|
||
case "grep":
|
||
return "Search"
|
||
case "glob":
|
||
return "Find"
|
||
default:
|
||
return toolDisplayName(name)
|
||
}
|
||
}
|
||
|
||
func exploreTargetLooksLikePath(name string, target string) bool {
|
||
switch name {
|
||
case "read_file", "read_minified_file", "list_directory":
|
||
return looksLikePath(target)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func exploreTarget(name string, hint string, arg string, detail string) string {
|
||
switch name {
|
||
case "read_file", "read_minified_file":
|
||
if target := strings.TrimSpace(hint); target != "" {
|
||
return target
|
||
}
|
||
return readDetailPath(detail)
|
||
case "grep":
|
||
if target := strings.TrimSpace(arg); target != "" {
|
||
return target
|
||
}
|
||
return strings.TrimSpace(hint)
|
||
case "glob":
|
||
if target := strings.TrimSpace(arg); target != "" {
|
||
return target
|
||
}
|
||
return strings.TrimSpace(hint)
|
||
case "list_directory":
|
||
return strings.TrimSpace(hint)
|
||
default:
|
||
return strings.TrimSpace(hint)
|
||
}
|
||
}
|
||
|
||
func readDetailPath(detail string) string {
|
||
for _, line := range strings.Split(detail, "\n") {
|
||
if strings.HasPrefix(line, "File: ") {
|
||
return strings.TrimSpace(strings.TrimPrefix(line, "File: "))
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func localControlCardBody(name string, hint string, detail string, width int, opts cardRenderOptions, failed bool) cardBody {
|
||
if failed {
|
||
return genericCardBody(detail, opts)
|
||
}
|
||
switch name {
|
||
case "browser_snapshot", "desktop_windows", "desktop_snapshot", "capture_artifact":
|
||
return cardBody{}
|
||
case "browser_open":
|
||
if summary := browserOpenSummary(detail, hint); summary != "" {
|
||
return cardBody{lines: []string{localControlChildLine(summary, width)}}
|
||
}
|
||
return cardBody{}
|
||
default:
|
||
if summary := firstLocalControlSummaryLine(detail); summary != "" {
|
||
return cardBody{lines: []string{localControlChildLine(summary, width)}}
|
||
}
|
||
return cardBody{}
|
||
}
|
||
}
|
||
|
||
func localControlChildLine(text string, width int) string {
|
||
prefix := " └ "
|
||
budget := maxInt(8, width-lipgloss.Width(prefix))
|
||
return zeroTheme.faint.Render(prefix) + zeroTheme.muted.Render(truncateDisplayWidth(text, budget))
|
||
}
|
||
|
||
func browserOpenSummary(detail string, target string) string {
|
||
target = strings.TrimRight(strings.TrimSpace(target), "/")
|
||
for _, line := range strings.Split(detail, "\n") {
|
||
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "✓"))
|
||
if line == "" {
|
||
continue
|
||
}
|
||
normalized := strings.TrimRight(line, "/")
|
||
if target != "" && normalized == target {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(line, "http://") || strings.HasPrefix(line, "https://") {
|
||
continue
|
||
}
|
||
return line
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func firstLocalControlSummaryLine(detail string) string {
|
||
for _, line := range strings.Split(detail, "\n") {
|
||
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "✓"))
|
||
switch {
|
||
case line == "":
|
||
continue
|
||
case strings.HasPrefix(line, "Artifact captured:"):
|
||
continue
|
||
default:
|
||
return line
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func artifactCapturedPath(detail string) string {
|
||
for _, line := range strings.Split(detail, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if strings.HasPrefix(line, "Artifact captured:") {
|
||
return strings.TrimSpace(strings.TrimPrefix(line, "Artifact captured:"))
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func bashCardBody(command string, detail string, width int, opts cardRenderOptions) cardBody {
|
||
footer := ""
|
||
output := []commandOutputLine{}
|
||
section := "stdout"
|
||
for _, line := range strings.Split(detail, "\n") {
|
||
switch {
|
||
case line == "stdout:":
|
||
section = "stdout"
|
||
case line == "stderr:":
|
||
section = "stderr"
|
||
case strings.HasPrefix(line, "exit_code: "):
|
||
code := strings.TrimPrefix(line, "exit_code: ")
|
||
if code != "0" {
|
||
footer = zeroTheme.red.Render("exit " + code)
|
||
}
|
||
default:
|
||
style := zeroTheme.muted
|
||
if section == "stderr" {
|
||
style = zeroTheme.delText
|
||
}
|
||
output = append(output, commandOutputLine{text: line, style: style})
|
||
}
|
||
}
|
||
lines := renderCommandOutputLines(output, width, opts)
|
||
return cardBody{lines: capCardLines(lines, opts.bodyCap), footer: footer}
|
||
}
|
||
|
||
func execCommandCardBody(command string, detail string, width int, opts cardRenderOptions) cardBody {
|
||
footer := ""
|
||
output := []commandOutputLine{}
|
||
section := ""
|
||
interrupted := false
|
||
for _, line := range strings.Split(detail, "\n") {
|
||
switch {
|
||
case line == "output:":
|
||
section = "output"
|
||
case line == "interrupted: true":
|
||
interrupted = true
|
||
footer = zeroTheme.green.Render("interrupted")
|
||
case strings.HasPrefix(line, "exit_code: "):
|
||
code := strings.TrimPrefix(line, "exit_code: ")
|
||
if code == "0" {
|
||
// Successful completion is already visible from the green status dot.
|
||
} else if interrupted {
|
||
footer = zeroTheme.green.Render("interrupted")
|
||
} else {
|
||
footer = zeroTheme.red.Render("exit " + code)
|
||
}
|
||
case strings.HasPrefix(line, "session_id: "):
|
||
footer = zeroTheme.faint.Render("session " + strings.TrimSpace(strings.TrimPrefix(line, "session_id: ")))
|
||
case strings.HasPrefix(line, "Use write_stdin "):
|
||
continue
|
||
default:
|
||
style := zeroTheme.muted
|
||
if section == "" && strings.HasPrefix(line, "Command is still running.") {
|
||
style = zeroTheme.faint
|
||
}
|
||
output = append(output, commandOutputLine{text: line, style: style})
|
||
}
|
||
}
|
||
lines := renderCommandOutputLines(output, width, opts)
|
||
return cardBody{lines: capCardLines(lines, opts.bodyCap), footer: footer}
|
||
}
|
||
|
||
type commandOutputLine struct {
|
||
text string
|
||
style lipgloss.Style
|
||
}
|
||
|
||
func renderCommandOutputLines(output []commandOutputLine, width int, opts cardRenderOptions) []string {
|
||
output = trimTrailingEmptyCommandOutput(output)
|
||
if len(output) == 0 {
|
||
return nil
|
||
}
|
||
|
||
if len(output) == 1 {
|
||
prefix := " └ "
|
||
budget := maxInt(8, width-lipgloss.Width(prefix))
|
||
text := truncateDisplayWidth(output[0].text, budget)
|
||
return capCardLines([]string{zeroTheme.faint.Render(prefix) + output[0].style.Render(text)}, opts.bodyCap)
|
||
}
|
||
|
||
lines := make([]string, 0, len(output))
|
||
prefix := " │ "
|
||
budget := maxInt(8, width-lipgloss.Width(prefix))
|
||
for _, item := range output {
|
||
text := truncateDisplayWidth(item.text, budget)
|
||
lines = append(lines, zeroTheme.faint.Render(prefix)+item.style.Render(text))
|
||
}
|
||
return capCardLines(lines, opts.bodyCap)
|
||
}
|
||
|
||
func trimTrailingEmptyCommandOutput(output []commandOutputLine) []commandOutputLine {
|
||
for len(output) > 0 && strings.TrimSpace(output[len(output)-1].text) == "" {
|
||
output = output[:len(output)-1]
|
||
}
|
||
return output
|
||
}
|
||
|
||
func truncateDisplayWidth(text string, budget int) string {
|
||
if budget <= 0 {
|
||
return ""
|
||
}
|
||
if lipgloss.Width(text) <= budget {
|
||
return text
|
||
}
|
||
if budget <= 1 {
|
||
return "…"
|
||
}
|
||
head, _ := splitAtWidth(text, budget-1)
|
||
return head + "…"
|
||
}
|
||
|
||
// renderSessionsCards draws the /resume list as stacked cards: id (accent) +
|
||
// age (faint) on the top row, title (ink), then the meta line (faint with
|
||
// faintest dots). Records arrive as sessionsCardFieldSep-joined fields; a
|
||
// record without separators is a plain trailing hint.
|
||
func renderSessionsCards(payload string, width int) string {
|
||
blocks := []string{}
|
||
for _, record := range strings.Split(payload, "\n") {
|
||
fields := strings.Split(record, sessionsCardFieldSep)
|
||
if len(fields) < 4 {
|
||
blocks = append(blocks, fitStyledLine(zeroTheme.faint.Render(record), width))
|
||
continue
|
||
}
|
||
id, age, title, meta := fields[0], fields[1], fields[2], fields[3]
|
||
innerWidth := width - 4
|
||
top := joinHeaderLine(zeroTheme.onPanel(zeroTheme.accent).Render(id), zeroTheme.onPanel(zeroTheme.faint).Render(age), innerWidth)
|
||
metaParts := strings.Split(meta, " · ")
|
||
for index := range metaParts {
|
||
metaParts[index] = zeroTheme.onPanel(zeroTheme.faint).Render(metaParts[index])
|
||
}
|
||
lines := []string{
|
||
top,
|
||
zeroTheme.onPanel(zeroTheme.ink).Render(title),
|
||
strings.Join(metaParts, zeroTheme.onPanel(zeroTheme.faintest).Render(" · ")),
|
||
}
|
||
blocks = append(blocks, styledBlockFill(width, lines, zeroTheme.line, zeroTheme.panel))
|
||
}
|
||
return strings.Join(blocks, "\n")
|
||
}
|
||
|
||
// grepMatchPattern matches the grep tool's "path:line: text" content rows.
|
||
var grepMatchPattern = regexp.MustCompile(`^(.+?:\d+):\s?(.*)$`)
|
||
|
||
func grepCardBody(detail string, width int, opts cardRenderOptions) cardBody {
|
||
innerWidth := width
|
||
raw := strings.Split(detail, "\n")
|
||
lines := make([]string, 0, len(raw))
|
||
matches := 0
|
||
for _, line := range raw {
|
||
if match := grepMatchPattern.FindStringSubmatch(line); match != nil {
|
||
matches++
|
||
location := zeroTheme.grepLoc.Render(match[1])
|
||
// match[1] is "path:line" — link the file so a hit is one click away.
|
||
if path, _, ok := strings.Cut(match[1], ":"); ok && path != "" {
|
||
location = hyperlink(fileURL(opts.cwd, path), location)
|
||
}
|
||
budget := maxInt(8, innerWidth-lipgloss.Width(match[1])-2)
|
||
lines = append(lines, location+" "+zeroTheme.muted.Render(truncateDisplayWidth(match[2], budget)))
|
||
continue
|
||
}
|
||
lines = append(lines, zeroTheme.muted.Render(line))
|
||
}
|
||
footer := ""
|
||
if matches > 0 {
|
||
footer = zeroTheme.faint.Render(fmt.Sprintf("%d matches", matches))
|
||
}
|
||
return cardBody{lines: capCardLines(lines, opts.bodyCap), footer: footer}
|
||
}
|