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" })