diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 1b804870..8d30708e 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -1208,15 +1208,17 @@ impl GooseAcpAgent { .map(|a| serde_json::Value::Object(a.clone())); let fallback_title = summarize_tool_call(&tool_name, args_value.as_ref()); + let mut initial_tool_call = ToolCall::new( + ToolCallId::new(tool_request.id.clone()), + fallback_title.clone(), + ) + .status(ToolCallStatus::Pending); + if let Some(args) = args_value.clone() { + initial_tool_call = initial_tool_call.raw_input(args); + } cx.send_notification(SessionNotification::new( session_id.clone(), - SessionUpdate::ToolCall( - ToolCall::new( - ToolCallId::new(tool_request.id.clone()), - fallback_title.clone(), - ) - .status(ToolCallStatus::Pending), - ), + SessionUpdate::ToolCall(initial_tool_call), ))?; if let Ok(tool_call) = &tool_request.tool_call { diff --git a/ui/text/src/components/ContentRenderers.tsx b/ui/text/src/components/ContentRenderers.tsx index 3a300257..dbae9e7c 100644 --- a/ui/text/src/components/ContentRenderers.tsx +++ b/ui/text/src/components/ContentRenderers.tsx @@ -33,9 +33,7 @@ export function renderToolCallItem( item: ResponseItem & { itemType: "tool_call" }, index: number, width: number, - toolCallsExpanded: boolean, - isFirst: boolean, - hasToolCalls: boolean + selected: boolean, ): React.ReactElement[] { const info: ToolCallInfo = { toolCallId: item.toolCallId, @@ -47,10 +45,10 @@ export function renderToolCallItem( content: item.content, locations: item.locations, }; - + return [ emptyLine(`tc-gap-${index}`, width), - ...renderToolCallLines(info, width, toolCallsExpanded, isFirst && hasToolCalls), + ...renderToolCallLines(info, width, selected), ]; } diff --git a/ui/text/src/components/Header.tsx b/ui/text/src/components/Header.tsx index f74f6a6a..70b80e9e 100644 --- a/ui/text/src/components/Header.tsx +++ b/ui/text/src/components/Header.tsx @@ -10,7 +10,6 @@ interface HeaderProps { status: string; loading: boolean; spinIdx: number; - hasPendingPermission: boolean; turnInfo?: { current: number; total: number }; } @@ -19,7 +18,6 @@ export const Header = React.memo(function Header({ status, loading, spinIdx, - hasPendingPermission, turnInfo, }: HeaderProps) { const statusColor = @@ -38,7 +36,7 @@ export const Header = React.memo(function Header({ {status} - {loading && !hasPendingPermission && ( + {loading && ( )} diff --git a/ui/text/src/components/ToolCallExpanded.tsx b/ui/text/src/components/ToolCallExpanded.tsx new file mode 100644 index 00000000..abf655c8 --- /dev/null +++ b/ui/text/src/components/ToolCallExpanded.tsx @@ -0,0 +1,273 @@ +import React, { useMemo } from "react"; +import { Box, Text, useInput } from "ink"; +import type { ToolCallContent } from "@agentclientprotocol/sdk"; +import { + formatJson, + type ToolCallInfo, +} from "../toolcall.js"; +import { + CRANBERRY, + TEAL, + GOLD, + TEXT_PRIMARY, + TEXT_SECONDARY, + TEXT_DIM, +} from "../colors.js"; +import { SCROLL_STEP, SCROLL_FAST_MULTIPLIER } from "../constants.js"; + +interface Props { + info: ToolCallInfo; + width: number; + height: number; + scrollOffset: number; + onScroll: (updater: (prev: number) => number) => void; + onClose: () => void; +} + +const STATUS_COLORS: Record = { + pending: TEXT_DIM, + in_progress: GOLD, + completed: TEAL, + failed: CRANBERRY, +}; + +function wrapOrTruncate(text: string, width: number): string[] { + const safeWidth = Math.max(width, 10); + const out: string[] = []; + for (const rawLine of text.split("\n")) { + if (rawLine.length <= safeWidth) { + out.push(rawLine); + continue; + } + let remaining = rawLine; + while (remaining.length > safeWidth) { + out.push(remaining.slice(0, safeWidth)); + remaining = remaining.slice(safeWidth); + } + if (remaining.length > 0) out.push(remaining); + } + return out; +} + +function extractContentText(content: ToolCallContent[] | undefined): string { + if (!content || content.length === 0) return ""; + const parts: string[] = []; + for (const item of content) { + if (item.type === "content") { + const block = item.content; + if (block.type === "text" && block.text) { + parts.push(block.text); + } else if (block.type === "resource_link") { + parts.push(`๐Ÿ”— ${block.uri}`); + } else if (block.type === "image") { + parts.push(`๐Ÿ–ผ image (${block.mimeType ?? "unknown"})`); + } else if (block.type === "audio") { + parts.push(`๐ŸŽต audio (${block.mimeType ?? "unknown"})`); + } else if (block.type === "resource") { + const res = block.resource as { uri?: string; text?: string }; + if (res.text) { + parts.push(res.text); + } else if (res.uri) { + parts.push(`๐Ÿ“Ž ${res.uri}`); + } + } + } else if (item.type === "diff") { + const header = `๐Ÿ“ diff: ${item.path}`; + const old = item.oldText ?? ""; + parts.push( + [ + header, + ...(old ? old.split("\n").map((l) => `- ${l}`) : []), + ...item.newText.split("\n").map((l) => `+ ${l}`), + ].join("\n"), + ); + } else if (item.type === "terminal") { + parts.push(`โ–ถ terminal: ${item.terminalId}`); + } + } + return parts.join("\n\n"); +} + +function buildBody( + info: ToolCallInfo, + contentWidth: number, +): React.ReactElement[] { + const body: React.ReactElement[] = []; + + const pushLabel = (label: string, keyPrefix: string, withTopGap: boolean) => { + if (withTopGap) { + body.push( + + + , + ); + } + body.push( + + + {label} + + , + ); + }; + + const pushText = ( + text: string, + keyPrefix: string, + emptyHint: string, + ) => { + if (!text) { + body.push( + + + {emptyHint} + + , + ); + return; + } + const lines = wrapOrTruncate(text, contentWidth); + lines.forEach((l, i) => { + body.push( + + {l || " "} + , + ); + }); + }; + + pushLabel(info.title, "tool", false); + + pushLabel("arguments", "in", true); + const argsText = formatJson(info.rawInput); + pushText(argsText, "in", "(no arguments)"); + + pushLabel("result", "out", true); + let resultText = formatJson(info.rawOutput); + if (!resultText) { + resultText = extractContentText(info.content); + } + const resultEmptyHint = + info.status === "in_progress" + ? "(runningโ€ฆ)" + : info.status === "pending" + ? "(pending)" + : info.status === "failed" + ? "(failed โ€” no output)" + : "(no output)"; + pushText(resultText, "out", resultEmptyHint); + + return body; +} + +export function ToolCallExpanded({ + info, + width, + height, + scrollOffset, + onScroll, + onClose, +}: Props) { + const safeWidth = Math.max(width, 20); + const safeHeight = Math.max(height, 5); + const contentWidth = Math.max(safeWidth - 4, 10); + + const allLines = useMemo( + () => buildBody(info, contentWidth), + [info, contentWidth], + ); + + useInput((ch, key) => { + if (key.escape || ch === " ") { + onClose(); + return; + } + if (key.upArrow || key.downArrow) { + const step = key.meta + ? SCROLL_STEP * SCROLL_FAST_MULTIPLIER + : SCROLL_STEP; + if (key.upArrow) { + onScroll((prev) => prev + step); + } else { + onScroll((prev) => Math.max(prev - step, 0)); + } + } + }); + + const headerH = 2; + const footerH = 2; + const bodyHeight = Math.max(safeHeight - headerH - footerH, 1); + + const total = allLines.length; + const overflows = total > bodyHeight; + const contentHeight = overflows ? Math.max(bodyHeight - 2, 1) : bodyHeight; + + const maxEnd = total; + const minEnd = Math.min(contentHeight, total); + const endIdx = Math.max(minEnd, Math.min(maxEnd - scrollOffset, maxEnd)); + const startIdx = Math.max(0, endIdx - contentHeight); + const visible = allLines.slice(startIdx, endIdx); + const padCount = contentHeight - visible.length; + + const elements: React.ReactElement[] = []; + if (overflows) { + const above = startIdx; + elements.push( + + {above > 0 ? ( + โ–ฒ {above} more (โ†‘) + ) : ( + + )} + , + ); + } + for (let i = 0; i < padCount; i++) { + elements.push( + + + , + ); + } + elements.push(...visible); + if (overflows) { + const below = total - endIdx; + elements.push( + + {below > 0 ? ( + โ–ผ {below} more (โ†“) + ) : ( + + )} + , + ); + } + + const statusColor = STATUS_COLORS[info.status] ?? TEXT_DIM; + + return ( + + + โ— + {info.status} + + + space/esc to close + + + + {elements} + + + โ†‘โ†“ scroll ยท โŒฅโ†‘โ†“ fast + + + ); +} diff --git a/ui/text/src/constants.tsx b/ui/text/src/constants.tsx index 406705a0..3d329027 100644 --- a/ui/text/src/constants.tsx +++ b/ui/text/src/constants.tsx @@ -4,6 +4,10 @@ export const PASTE_PREVIEW_LEN = 40; export const INPUT_MAX_ROWS = 8; export const SENT_PREVIEW_LEN = 60; +// Viewport scroll step (lines per arrow press). Option/Alt applies the multiplier. +export const SCROLL_STEP = 3; +export const SCROLL_FAST_MULTIPLIER = 10; + export const GOOSE_FRAMES = [ [ " ,_", @@ -66,17 +70,3 @@ export const GREETING_MESSAGES = [ export const INITIAL_GREETING = GREETING_MESSAGES[Math.floor(Math.random() * GREETING_MESSAGES.length)]!; - -export const PERMISSION_LABELS: Record = { - allow_once: "Allow once", - allow_always: "Always allow", - reject_once: "Reject once", - reject_always: "Always reject", -}; - -export const PERMISSION_KEYS: Record = { - allow_once: "y", - allow_always: "a", - reject_once: "n", - reject_always: "r", -}; diff --git a/ui/text/src/toolcall.tsx b/ui/text/src/toolcall.tsx index ac1edbe4..a24f23a5 100644 --- a/ui/text/src/toolcall.tsx +++ b/ui/text/src/toolcall.tsx @@ -43,135 +43,119 @@ const STATUS_INDICATORS: Record = { function truncateLine(line: string, maxWidth: number): string { const safeMaxWidth = Math.max(maxWidth, 1); if (line.length <= safeMaxWidth) return line; - return safeMaxWidth > 1 ? line.slice(0, safeMaxWidth - 1) + "โ€ฆ" : line.slice(0, safeMaxWidth); + return safeMaxWidth > 1 + ? line.slice(0, safeMaxWidth - 1) + "โ€ฆ" + : line.slice(0, safeMaxWidth); } -function formatJsonLines(value: unknown, maxWidth: number): string[] { - if (value === undefined || value === null) return []; - let raw: string; +export function formatJson(value: unknown): string { + if (value === undefined || value === null) return ""; if (typeof value === "string") { - raw = value; - } else { - try { - raw = JSON.stringify(value, null, 2); - } catch { - raw = String(value); - } - } - return raw.split("\n").map((line) => truncateLine(line, maxWidth)); -} - -function extractTextLines(content: ToolCallContent[], maxWidth: number): string[] { - const lines: string[] = []; - for (const item of content) { - if (item.type === "content" && item.content) { - const block = item.content as any; - if (block.type === "text" && block.text) { - for (const line of block.text.split("\n")) { - lines.push(truncateLine(line, maxWidth)); - } + // If it looks like JSON, try to parse and re-format; otherwise return as-is. + const trimmed = value.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return JSON.stringify(JSON.parse(trimmed), null, 2); + } catch { + return value; } - } else if (item.type === "diff") { - const diff = item as any; - lines.push(truncateLine(`diff: ${diff.path || "unknown"}`, maxWidth)); - } else if (item.type === "terminal") { - const term = item as any; - lines.push(truncateLine(`terminal: ${term.terminalId || "unknown"}`, maxWidth)); } + return value; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); } - return lines; } +/** + * Render a tool call as a single-line boxed summary. + * + * The box always has the same content and height as before; when `selected` + * is true we swap the border color and show a hint that space will expand it. + */ export function renderToolCallLines( info: ToolCallInfo, width: number, - expanded: boolean, - showTabHint: boolean, + selected: boolean, ): React.ReactElement[] { const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "โš™"; - const statusInfo = STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!; - const borderColor = info.status === "failed" ? CRANBERRY : CEDAR; - const dimBorder = info.status !== "failed"; + const statusInfo = + STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!; + + const borderColor = selected + ? GOLD + : info.status === "failed" + ? CRANBERRY + : CEDAR; + const dimBorder = !selected && info.status !== "failed"; const safeWidth = Math.max(width, 10); const innerWidth = Math.max(safeWidth - 4, 6); - const indentedWidth = Math.max(innerWidth - 2, 4); - const lines: React.ReactElement[] = []; const k = info.toolCallId; + const lines: React.ReactElement[] = []; const hRule = "โ”€".repeat(Math.max(safeWidth - 2, 0)); lines.push( - โ•ญ{hRule}โ•ฎ + + โ•ญ{hRule}โ•ฎ + , ); - const row = (key: string, content: React.ReactNode) => { - lines.push( - - โ”‚ - - {content} - - โ”‚ - , - ); - }; - const statusIcon = statusInfo.icon; const runningText = info.status === "in_progress" ? " runningโ€ฆ" : ""; - const tabHintText = showTabHint && !expanded ? "tab โ†”" : ""; - const fixedLen = 4 + runningText.length + tabHintText.length; // icon+space+kind+space + suffix + hint + const hintText = selected ? "space to expand" : ""; + const fixedLen = 4 + runningText.length + hintText.length; const titleMax = Math.max(innerWidth - fixedLen, 4); const title = truncateLine(info.title, titleMax); - row(`${k}-h`, ( - <> - {statusIcon} - {kindIcon} - {title} - {runningText ? {runningText} : null} - - {tabHintText ? {tabHintText} : null} - - )); - - if (expanded) { - if (info.locations) { - for (let i = 0; i < info.locations.length; i++) { - const loc = info.locations[i]!; - const t = truncateLine(`๐Ÿ“ ${loc.path}${loc.line ? `:${loc.line}` : ""}`, innerWidth); - row(`${k}-l${i}`, {t}); - } - } - - const section = (label: string, sLines: string[]) => { - if (sLines.length === 0) return; - row(`${k}-${label}H`, โ–ธ {label}:); - for (let i = 0; i < sLines.length; i++) { - row(`${k}-${label}${i}`, ( - {" "}{sLines[i]} - )); - } - }; - - if (info.rawInput !== undefined && info.rawInput !== null) { - section("in", formatJsonLines(info.rawInput, indentedWidth)); - } - if (info.rawOutput !== undefined && info.rawOutput !== null) { - section("out", formatJsonLines(info.rawOutput, indentedWidth)); - } - if (info.content && info.content.length > 0) { - section("ct", extractTextLines(info.content, indentedWidth)); - } - } + lines.push( + + + โ”‚{" "} + + + {statusIcon} + {kindIcon} + + {title} + + {runningText ? ( + + {runningText} + + ) : null} + + {hintText ? ( + + {hintText} + + ) : null} + + + {" "} + โ”‚ + + , + ); lines.push( - โ•ฐ{hRule}โ•ฏ + + โ•ฐ{hRule}โ•ฏ + , ); return lines; } + +/** + * Height in lines of the rendered single-line tool-call box. + * Kept in sync with `renderToolCallLines`. + */ +export const TOOL_CALL_BOX_HEIGHT = 3; diff --git a/ui/text/src/tui.tsx b/ui/text/src/tui.tsx index b82cf4c5..b487b894 100644 --- a/ui/text/src/tui.tsx +++ b/ui/text/src/tui.tsx @@ -7,8 +7,6 @@ import { spawn } from "node:child_process"; import { Readable, Writable } from "node:stream"; import type { SessionNotification, - RequestPermissionRequest, - RequestPermissionResponse, Stream, ContentChunk, ToolCall, @@ -20,7 +18,7 @@ import { resolveGooseBinary } from "@aaif/goose-sdk/node"; import Onboarding from "./onboarding.js"; import ConfigureScreen, { ConfigureIntent } from "./configure.js"; import ExtensionsManager from "./extensions.js"; -import type { PendingPermission, ResponseItem, Turn } from "./types.js"; +import type { Turn } from "./types.js"; import { emptyLine, renderUserPrompt, @@ -32,13 +30,14 @@ import { } from "./components/ContentRenderers.js"; import { Header } from "./components/Header.js"; import { Rule } from "./components/Rule.js"; +import { ToolCallExpanded } from "./components/ToolCallExpanded.js"; +import type { ToolCallInfo } from "./toolcall.js"; import { isErrorStatus, formatError } from "./utils.js"; import { CRANBERRY, TEAL, GOLD, TEXT_PRIMARY, - TEXT_SECONDARY, TEXT_DIM, RULE_COLOR, } from "./colors.js"; @@ -49,8 +48,8 @@ import { SENT_PREVIEW_LEN, GOOSE_FRAMES, INITIAL_GREETING, - PERMISSION_LABELS, - PERMISSION_KEYS, + SCROLL_STEP, + SCROLL_FAST_MULTIPLIER, } from "./constants.js"; const InputBar = React.memo(function InputBar({ @@ -167,7 +166,9 @@ const InputBar = React.memo(function InputBar({ })()} - {scrollHint && shift+โ†‘โ†“ history} + {scrollHint && ( + โ†‘โ†“ scroll ยท โŒฅโ†‘โ†“ fast ยท shift+โ†‘โ†“ history + )} ) : ( @@ -193,7 +194,9 @@ const InputBar = React.memo(function InputBar({ ); }} /> - {scrollHint && shift+โ†‘โ†“ history} + {scrollHint && ( + โ†‘โ†“ scroll ยท โŒฅโ†‘โ†“ fast ยท shift+โ†‘โ†“ history + )} )} @@ -215,6 +218,17 @@ const InputBar = React.memo(function InputBar({ ); }); +export interface ToolCallRange { + responseItemIndex: number; + startLine: number; + endLine: number; +} + +export interface ContentLayout { + lines: React.ReactElement[]; + toolCallRanges: ToolCallRange[]; +} + function buildContentLines({ turn, turnIndex, @@ -222,9 +236,7 @@ function buildContentLines({ loading, status, spinIdx, - pendingPermission, - permissionIdx, - toolCallsExpanded, + selectedToolCallIdx, queuedMessages, }: { turn: Turn | undefined; @@ -233,13 +245,12 @@ function buildContentLines({ loading: boolean; status: string; spinIdx: number; - pendingPermission: PendingPermission | null; - permissionIdx: number; - toolCallsExpanded: boolean; + selectedToolCallIdx: number | null; queuedMessages: string[]; -}): React.ReactElement[] { +}): ContentLayout { const lines: React.ReactElement[] = []; - if (!turn) return lines; + const toolCallRanges: ToolCallRange[] = []; + if (!turn) return { lines, toolCallRanges }; const safeWidth = Math.max(width, 20); @@ -282,26 +293,21 @@ function buildContentLines({ ), ); - // Process response items - const hasToolCalls = turn.responseItems.some( - (it) => it.itemType === "tool_call", - ); let tcIdx = 0; for (let i = 0; i < turn.responseItems.length; i++) { const item = turn.responseItems[i]!; if (item.itemType === "tool_call") { - lines.push( - ...renderToolCallItem( - item, - i, - safeWidth, - toolCallsExpanded, - tcIdx === 0, - hasToolCalls, - ), - ); + const isSelected = selectedToolCallIdx === tcIdx; + const rendered = renderToolCallItem(item, i, safeWidth, isSelected); + const startLine = lines.length; + lines.push(...rendered); + toolCallRanges.push({ + responseItemIndex: i, + startLine, + endLine: lines.length - 1, + }); tcIdx++; } else if (item.itemType === "error") { lines.push(...renderErrorItem(item, i, safeWidth)); @@ -310,96 +316,13 @@ function buildContentLines({ } } - // Loading indicator - if (loading && !pendingPermission) { + if (loading) { lines.push(...renderLoadingIndicator(status, spinIdx, safeWidth)); } - // Permission dialog - if (pendingPermission) { - const perm = pendingPermission; - const selectedIdx = permissionIdx; - const fullWidth = safeWidth; - const dialogWidth = Math.min(fullWidth - 2, 58); - const innerWidth = Math.max(dialogWidth - 4, 10); - const hRule = "โ”€".repeat(Math.max(dialogWidth - 2, 0)); - const permissionLines: React.ReactElement[] = []; - - permissionLines.push( - emptyLine( - `pm-gap-${perm.toolTitle.slice(0, 10).replace(/[^a-zA-Z0-9]/g, "")}`, - fullWidth, - ), - ); - - permissionLines.push( - - โ•ญ{hRule}โ•ฎ - , - ); - - const row = (key: string, content: React.ReactNode) => { - permissionLines.push( - - โ”‚ - - {content} - - โ”‚ - , - ); - }; - - row( - "pm-title", - - ๐Ÿ”’ Permission required - , - ); - row("pm-g1", ); - row( - "pm-tool", - - {perm.toolTitle} - , - ); - row("pm-g2", ); - - for (let i = 0; i < perm.options.length; i++) { - const opt = perm.options[i]!; - const k = PERMISSION_KEYS[opt.kind] ?? String(i + 1); - const label = PERMISSION_LABELS[opt.kind] ?? opt.name; - const active = i === selectedIdx; - row( - `pm-o${i}`, - <> - {active ? "โ–ธ " : " "} - - [{k}] {label} - - , - ); - } - - row("pm-g3", ); - row( - "pm-help", - โ†‘โ†“ select ยท enter confirm ยท esc cancel, - ); - - permissionLines.push( - - โ•ฐ{hRule}โ•ฏ - , - ); - - lines.push(...permissionLines); - } - - // Queued messages lines.push(...renderQueuedMessages(queuedMessages, safeWidth)); - return lines; + return { lines, toolCallRanges }; } const Viewport = React.memo(function Viewport({ @@ -542,8 +465,29 @@ function App({ }) { const { exit } = useApp(); const { stdout } = useStdout(); - const termWidth = stdout?.columns ?? 80; - const termHeight = stdout?.rows ?? 24; + // `useStdout()` returns the live stream but does not trigger a React + // re-render when the terminal is resized. Without this subscription the + // outer Box keeps its old width/height after SIGWINCH, producing a + // misaligned frame until some other state change forces a render. + const [termSize, setTermSize] = useState(() => ({ + width: stdout?.columns ?? 80, + height: stdout?.rows ?? 24, + })); + useEffect(() => { + if (!stdout) return; + const onResize = () => { + setTermSize({ + width: stdout.columns ?? 80, + height: stdout.rows ?? 24, + }); + }; + stdout.on("resize", onResize); + return () => { + stdout.off("resize", onResize); + }; + }, [stdout]); + const termWidth = termSize.width; + const termHeight = termSize.height; const [turns, setTurns] = useState([]); const [input, setInput] = useState(""); @@ -552,13 +496,14 @@ function App({ const [spinIdx, setSpinIdx] = useState(0); const [gooseFrame, setGooseFrame] = useState(0); const [bannerVisible, setBannerVisible] = useState(true); - const [pendingPermission, setPendingPermission] = - useState(null); - const [permissionIdx, setPermissionIdx] = useState(0); const [queuedMessages, setQueuedMessages] = useState([]); const [viewTurnIdx, setViewTurnIdx] = useState(-1); - const [toolCallsExpanded, setToolCallsExpanded] = useState(false); + const [selectedToolCallIdx, setSelectedToolCallIdx] = useState( + null, + ); + const [toolCallExpanded, setToolCallExpanded] = useState(false); + const [toolCallExpandedScroll, setToolCallExpandedScroll] = useState(0); const [scrollOffset, setScrollOffset] = useState(0); const [pastedFull, setPastedFull] = useState(null); const [needsOnboarding, setNeedsOnboarding] = useState(false); @@ -592,10 +537,18 @@ function App({ }, [turns]); useEffect(() => { - setToolCallsExpanded(false); + setSelectedToolCallIdx(null); + setToolCallExpanded(false); + setToolCallExpandedScroll(0); setScrollOffset(0); }, [viewTurnIdx, turns.length]); + // Re-layout invalidates any scroll offset we were holding (line counts + // change with width), so snap back to the latest content on resize. + useEffect(() => { + setScrollOffset(0); + }, [termWidth, termHeight]); + const appendAgent = useCallback((text: string) => { setTurns((prev) => { if (prev.length === 0) return prev; @@ -688,27 +641,12 @@ function App({ { userText: text, responseItems: [], toolCallsById: new Map() }, ]); setViewTurnIdx(-1); - setToolCallsExpanded(false); + setSelectedToolCallIdx(null); + setToolCallExpanded(false); + setToolCallExpandedScroll(0); setScrollOffset(0); }, []); - const resolvePermission = useCallback( - (option: { optionId: string } | "cancelled") => { - if (!pendingPermission) return; - const { resolve } = pendingPermission; - if (option === "cancelled") { - resolve({ outcome: { outcome: "cancelled" } }); - } else { - resolve({ - outcome: { outcome: "selected", optionId: option.optionId }, - }); - } - setPendingPermission(null); - setPermissionIdx(0); - }, - [pendingPermission], - ); - const executePrompt = useCallback( async (text: string) => { const client = clientRef.current; @@ -816,22 +754,6 @@ function App({ handleToolCallUpdate(update); } }, - requestPermission: async ( - params: RequestPermissionRequest, - ): Promise => { - return new Promise((resolve) => { - setPendingPermission({ - toolTitle: params.toolCall.title ?? "unknown tool", - options: params.options.map((o) => ({ - optionId: o.optionId, - name: o.name, - kind: o.kind, - })), - resolve, - }); - setPermissionIdx(0); - }); - }, }), serverConnection, ); @@ -896,7 +818,9 @@ function App({ setInput(""); setPastedFull(null); setViewTurnIdx(-1); - setToolCallsExpanded(false); + setSelectedToolCallIdx(null); + setToolCallExpanded(false); + setToolCallExpandedScroll(0); setScrollOffset(0); if (loading || isProcessingRef.current) { @@ -909,18 +833,141 @@ function App({ [loading, sendPrompt], ); + const PAD_X = 2; + const PAD_Y = 1; + const safeTermWidth = Math.max(termWidth, 40); + const safeTermHeight = Math.max(termHeight, 10); + const contentWidth = Math.max(safeTermWidth - PAD_X * 2, 20); + + const effectiveTurnIdx = viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx; + const currentTurn = turns[effectiveTurnIdx]; + const isViewingHistory = viewTurnIdx !== -1 && viewTurnIdx < turns.length - 1; + const isLatest = !isViewingHistory; + const showInputBar = !initialPrompt && !isViewingHistory; + + const headerH = 2; + const isPasteMode = pastedFull !== null; + const inputContentRows = showInputBar + ? isPasteMode + ? 1 + : Math.min(Math.max(input.split("\n").length, 1), INPUT_MAX_ROWS) + : 0; + const inputExtraLines = + (isPasteMode ? 1 : 0) + (queuedMessages.length > 0 ? 1 : 0); + const inputBarH = showInputBar ? 2 + inputContentRows + inputExtraLines : 0; + const historyBarH = isViewingHistory ? 2 : 0; + const viewportHeight = Math.max( + safeTermHeight - PAD_Y * 2 - headerH - inputBarH - historyBarH, + 3, + ); + + const contentLayout = useMemo( + () => + buildContentLines({ + turn: currentTurn, + turnIndex: effectiveTurnIdx, + width: contentWidth, + loading: isLatest && loading, + status, + spinIdx, + selectedToolCallIdx, + queuedMessages: isLatest ? queuedMessages : [], + }), + [ + currentTurn, + effectiveTurnIdx, + contentWidth, + isLatest, + loading, + status, + spinIdx, + selectedToolCallIdx, + queuedMessages, + ], + ); + const contentLines = contentLayout.lines; + const toolCallRanges = contentLayout.toolCallRanges; + + useEffect(() => { + if ( + selectedToolCallIdx !== null && + selectedToolCallIdx >= toolCallRanges.length + ) { + setSelectedToolCallIdx( + toolCallRanges.length === 0 ? null : toolCallRanges.length - 1, + ); + } + }, [toolCallRanges.length, selectedToolCallIdx]); + + const selectedToolCallInfo = useMemo(() => { + if (selectedToolCallIdx === null || !currentTurn) return null; + const range = toolCallRanges[selectedToolCallIdx]; + if (!range) return null; + const item = currentTurn.responseItems[range.responseItemIndex]; + if (!item || item.itemType !== "tool_call") return null; + return { + toolCallId: item.toolCallId, + title: item.title, + status: item.status ?? "pending", + kind: item.kind, + rawInput: item.rawInput, + rawOutput: item.rawOutput, + content: item.content, + locations: item.locations, + }; + }, [selectedToolCallIdx, toolCallRanges, currentTurn]); + + // Compute a scroll offset that keeps the given tool-call range fully + // visible, moving just enough from the current offset. scrollOffset is + // measured in lines-from-bottom, matching Viewport's math. + const scrollOffsetForRange = useCallback( + (range: ToolCallRange, current: number): number => { + const total = contentLines.length; + const overflows = total > viewportHeight; + const contentHeight = overflows + ? Math.max(viewportHeight - 2, 1) + : viewportHeight; + if (!overflows) return 0; + const maxOffset = total - contentHeight; + const minForTop = total - range.startLine - contentHeight; + const maxForBottom = total - range.endLine - 1; + const lo = Math.max(0, minForTop); + const hi = Math.max(lo, Math.min(maxOffset, maxForBottom)); + if (current < lo) return lo; + if (current > hi) return hi; + return current; + }, + [contentLines.length, viewportHeight], + ); + + const moveSelection = useCallback( + (direction: -1 | 1) => { + if (toolCallRanges.length === 0) return false; + let nextIdx: number; + if (selectedToolCallIdx === null) { + nextIdx = direction === -1 ? toolCallRanges.length - 1 : 0; + } else { + nextIdx = selectedToolCallIdx + direction; + if (nextIdx < 0 || nextIdx >= toolCallRanges.length) return false; + } + setSelectedToolCallIdx(nextIdx); + const range = toolCallRanges[nextIdx]!; + setScrollOffset((prev) => scrollOffsetForRange(range, prev)); + return true; + }, + [toolCallRanges, selectedToolCallIdx, scrollOffsetForRange], + ); + useInput( (ch, key) => { + if (toolCallExpanded) return; + if (key.escape || (ch === "c" && key.ctrl)) { - if (pendingPermission) { - resolvePermission("cancelled"); - return; - } if (key.escape && pastedFull !== null) return; exit(); } - if (!loading && !pendingPermission && sessionIdRef.current) { + if (!loading && sessionIdRef.current) { if (key.ctrl && (ch === "p" || ch === "P")) { setOverlay({ screen: "configure", intent: "provider" }); return; @@ -939,59 +986,47 @@ function App({ } } - if (pendingPermission) { - const opts = pendingPermission.options; - if (key.upArrow) { - setPermissionIdx((i) => (i - 1 + opts.length) % opts.length); - return; - } - if (key.downArrow) { - setPermissionIdx((i) => (i + 1) % opts.length); - return; - } - if (key.return) { - const sel = opts[permissionIdx]; - if (sel) resolvePermission({ optionId: sel.optionId }); - return; - } - const keyMap: Record = { - y: "allow_once", - a: "allow_always", - n: "reject_once", - N: "reject_always", - }; - const kind = keyMap[ch]; - if (kind) { - const m = opts.find((o) => o.kind === kind); - if (m) resolvePermission({ optionId: m.optionId }); - } - return; - } - const viewingHistory = viewTurnIdx !== -1 && viewTurnIdx < turns.length - 1; const multilineOwnsArrows = - !pendingPermission && !initialPrompt && !viewingHistory && - pastedFull === null; + pastedFull === null && + input.includes("\n"); - if (key.tab) { - const idx = viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx; - const t = turns[idx]; - if (t && t.responseItems.some((it) => it.itemType === "tool_call")) { - setToolCallsExpanded((prev) => !prev); + if (ch === " " && selectedToolCallIdx !== null) { + setToolCallExpandedScroll(0); + setToolCallExpanded(true); + return; + } + + if ((key.upArrow || key.downArrow) && !key.shift) { + if (multilineOwnsArrows) return; + + if (key.meta) { + const step = SCROLL_STEP * SCROLL_FAST_MULTIPLIER; + if (key.upArrow) { + setScrollOffset((prev) => prev + step); + } else { + setScrollOffset((prev) => Math.max(prev - step, 0)); + } + return; } - return; - } - if (key.upArrow && !key.shift) { - if (!multilineOwnsArrows) setScrollOffset((prev) => prev + 3); - return; - } - if (key.downArrow && !key.shift) { - if (!multilineOwnsArrows) - setScrollOffset((prev) => Math.max(prev - 3, 0)); + if (toolCallRanges.length > 0) { + const direction: -1 | 1 = key.upArrow ? -1 : 1; + if (moveSelection(direction)) return; + if (selectedToolCallIdx !== null) { + setSelectedToolCallIdx(null); + } + } + + const step = SCROLL_STEP; + if (key.upArrow) { + setScrollOffset((prev) => prev + step); + } else { + setScrollOffset((prev) => Math.max(prev - step, 0)); + } return; } @@ -1022,64 +1057,6 @@ function App({ { isActive: !needsOnboarding && !overlay }, ); - const PAD_X = 2; - const PAD_Y = 1; - const safeTermWidth = Math.max(termWidth, 40); - const safeTermHeight = Math.max(termHeight, 10); - const contentWidth = Math.max(safeTermWidth - PAD_X * 2, 20); - - const effectiveTurnIdx = viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx; - const currentTurn = turns[effectiveTurnIdx]; - const isViewingHistory = viewTurnIdx !== -1 && viewTurnIdx < turns.length - 1; - const isLatest = !isViewingHistory; - const showInputBar = - !pendingPermission && !initialPrompt && !isViewingHistory; - - const headerH = 2; - const isPasteMode = pastedFull !== null; - const inputContentRows = showInputBar - ? isPasteMode - ? 1 - : Math.min(Math.max(input.split("\n").length, 1), INPUT_MAX_ROWS) - : 0; - const inputExtraLines = - (isPasteMode ? 1 : 0) + (queuedMessages.length > 0 ? 1 : 0); - const inputBarH = showInputBar ? 2 + inputContentRows + inputExtraLines : 0; - const historyBarH = isViewingHistory ? 2 : 0; - const viewportHeight = Math.max( - safeTermHeight - PAD_Y * 2 - headerH - inputBarH - historyBarH, - 3, - ); - - const contentLines = useMemo( - () => - buildContentLines({ - turn: currentTurn, - turnIndex: effectiveTurnIdx, - width: contentWidth, - loading: isLatest && loading, - status, - spinIdx, - pendingPermission: isLatest ? pendingPermission : null, - permissionIdx, - toolCallsExpanded, - queuedMessages: isLatest ? queuedMessages : [], - }), - [ - currentTurn, - effectiveTurnIdx, - contentWidth, - isLatest, - loading, - status, - spinIdx, - pendingPermission, - permissionIdx, - toolCallsExpanded, - queuedMessages, - ], - ); - if (needsOnboarding && clientRef.current) { return ( @@ -1158,7 +1135,6 @@ function App({ status={status} loading={loading} spinIdx={spinIdx} - hasPendingPermission={!!pendingPermission} turnInfo={ turns.length > 1 ? { current: effectiveTurnIdx + 1, total: turns.length } @@ -1166,12 +1142,26 @@ function App({ } /> - + {toolCallExpanded && selectedToolCallInfo ? ( + { + setToolCallExpanded(false); + setToolCallExpandedScroll(0); + }} + /> + ) : ( + + )} {isViewingHistory && ( @@ -1236,20 +1226,6 @@ async function runTextMode(serverConnection: Stream | string, prompt: string) { } } }, - requestPermission: async ( - params: RequestPermissionRequest, - ): Promise => { - // Auto-reject in text mode - const rejectOption = params.options.find( - (o) => o.kind === "reject_once", - ); - if (rejectOption) { - return { - outcome: { outcome: "selected", optionId: rejectOption.optionId }, - }; - } - return { outcome: { outcome: "cancelled" } }; - }, }), serverConnection, ); diff --git a/ui/text/src/types.tsx b/ui/text/src/types.tsx index ce15d50b..cbac0622 100644 --- a/ui/text/src/types.tsx +++ b/ui/text/src/types.tsx @@ -1,10 +1,4 @@ -import type { ContentChunk, ToolCall, RequestPermissionResponse } from "@agentclientprotocol/sdk"; - -export interface PendingPermission { - toolTitle: string; - options: Array<{ optionId: string; name: string; kind: string }>; - resolve: (response: RequestPermissionResponse) => void; -} +import type { ContentChunk, ToolCall } from "@agentclientprotocol/sdk"; export type ResponseItem = | (ContentChunk & { itemType: "content_chunk" })