alexhancock/tui-improvements (#8736)

This commit is contained in:
Alex Hancock
2026-04-21 20:44:22 -04:00
committed by GitHub
parent ab0c40d26a
commit 23b3b3dcac
8 changed files with 631 additions and 416 deletions
+3 -5
View File
@@ -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),
];
}
+1 -3
View File
@@ -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({
<Box flexShrink={1}>
<Text color={statusColor} wrap="truncate-end">{status}</Text>
</Box>
{loading && !hasPendingPermission && (
{loading && (
<Text> <Spinner idx={spinIdx} /></Text>
)}
</Box>
+273
View File
@@ -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<string, string> = {
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(
<Box key={`${keyPrefix}-gap`} height={1}>
<Text> </Text>
</Box>,
);
}
body.push(
<Box key={`${keyPrefix}-hdr`} height={1}>
<Text color={TEXT_SECONDARY} bold>
{label}
</Text>
</Box>,
);
};
const pushText = (
text: string,
keyPrefix: string,
emptyHint: string,
) => {
if (!text) {
body.push(
<Box key={`${keyPrefix}-empty`} height={1}>
<Text color={TEXT_DIM} italic>
{emptyHint}
</Text>
</Box>,
);
return;
}
const lines = wrapOrTruncate(text, contentWidth);
lines.forEach((l, i) => {
body.push(
<Box key={`${keyPrefix}-${i}`} height={1}>
<Text color={TEXT_PRIMARY}>{l || " "}</Text>
</Box>,
);
});
};
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(
<Box key="exp-up" width={safeWidth} height={1} justifyContent="center">
{above > 0 ? (
<Text color={TEXT_DIM}> {above} more ()</Text>
) : (
<Text> </Text>
)}
</Box>,
);
}
for (let i = 0; i < padCount; i++) {
elements.push(
<Box key={`exp-pad-${i}`} width={safeWidth} height={1}>
<Text> </Text>
</Box>,
);
}
elements.push(...visible);
if (overflows) {
const below = total - endIdx;
elements.push(
<Box key="exp-dn" width={safeWidth} height={1} justifyContent="center">
{below > 0 ? (
<Text color={TEXT_DIM}> {below} more ()</Text>
) : (
<Text> </Text>
)}
</Box>,
);
}
const statusColor = STATUS_COLORS[info.status] ?? TEXT_DIM;
return (
<Box
flexDirection="column"
width={safeWidth}
height={safeHeight}
borderStyle="round"
borderColor={GOLD}
paddingX={1}
>
<Box width={contentWidth} height={1}>
<Text color={statusColor}></Text>
<Text color={TEXT_DIM}> {info.status}</Text>
<Box flexGrow={1} />
<Text color={TEXT_DIM} italic>
space/esc to close
</Text>
</Box>
<Box flexDirection="column" width={contentWidth} height={bodyHeight}>
{elements}
</Box>
<Box width={contentWidth} height={1}>
<Text color={TEXT_DIM}> scroll · fast</Text>
</Box>
</Box>
);
}
+4 -14
View File
@@ -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<string, string> = {
allow_once: "Allow once",
allow_always: "Always allow",
reject_once: "Reject once",
reject_always: "Always reject",
};
export const PERMISSION_KEYS: Record<string, string> = {
allow_once: "y",
allow_always: "a",
reject_once: "n",
reject_always: "r",
};
+78 -94
View File
@@ -43,135 +43,119 @@ const STATUS_INDICATORS: Record<string, { icon: string; color: string }> = {
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(
<Box key={`${k}-t`} width={safeWidth} height={1}>
<Text color={borderColor} dimColor={dimBorder}>{hRule}</Text>
<Text color={borderColor} dimColor={dimBorder}>
{hRule}
</Text>
</Box>,
);
const row = (key: string, content: React.ReactNode) => {
lines.push(
<Box key={key} width={safeWidth} height={1}>
<Text color={borderColor} dimColor={dimBorder}> </Text>
<Box width={innerWidth} height={1}>
{content}
</Box>
<Text color={borderColor} dimColor={dimBorder}> </Text>
</Box>,
);
};
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`, (
<>
<Text color={statusInfo.color}>{statusIcon}</Text>
<Text> {kindIcon} </Text>
<Text wrap="truncate-end" color={TEXT_SECONDARY} bold>{title}</Text>
{runningText ? <Text color={TEXT_DIM} italic>{runningText}</Text> : null}
<Box flexGrow={1} />
{tabHintText ? <Text color={TEXT_DIM} italic>{tabHintText}</Text> : 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}`, <Text wrap="truncate-end" color={TEXT_DIM}>{t}</Text>);
}
}
const section = (label: string, sLines: string[]) => {
if (sLines.length === 0) return;
row(`${k}-${label}H`, <Text color={TEXT_DIM}> {label}:</Text>);
for (let i = 0; i < sLines.length; i++) {
row(`${k}-${label}${i}`, (
<Text wrap="truncate-end" color={TEXT_DIM}>{" "}{sLines[i]}</Text>
));
}
};
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(
<Box key={`${k}-h`} width={safeWidth} height={1}>
<Text color={borderColor} dimColor={dimBorder}>
{" "}
</Text>
<Box width={innerWidth} height={1}>
<Text color={statusInfo.color}>{statusIcon}</Text>
<Text> {kindIcon} </Text>
<Text wrap="truncate-end" color={TEXT_SECONDARY} bold>
{title}
</Text>
{runningText ? (
<Text color={TEXT_DIM} italic>
{runningText}
</Text>
) : null}
<Box flexGrow={1} />
{hintText ? (
<Text color={GOLD} italic>
{hintText}
</Text>
) : null}
</Box>
<Text color={borderColor} dimColor={dimBorder}>
{" "}
</Text>
</Box>,
);
lines.push(
<Box key={`${k}-b`} width={safeWidth} height={1}>
<Text color={borderColor} dimColor={dimBorder}>{hRule}</Text>
<Text color={borderColor} dimColor={dimBorder}>
{hRule}
</Text>
</Box>,
);
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;
+262 -286
View File
@@ -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({
})()}
</Text>
</Box>
{scrollHint && <Text color={TEXT_DIM}>shift+ history</Text>}
{scrollHint && (
<Text color={TEXT_DIM}> scroll · fast · shift+ history</Text>
)}
</Box>
) : (
<Box flexGrow={1} justifyContent="space-between">
@@ -193,7 +194,9 @@ const InputBar = React.memo(function InputBar({
);
}}
/>
{scrollHint && <Text color={TEXT_DIM}>shift+ history</Text>}
{scrollHint && (
<Text color={TEXT_DIM}> scroll · fast · shift+ history</Text>
)}
</Box>
)}
</Box>
@@ -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(
<Box key="pm-t" width={fullWidth} height={1}>
<Text color={GOLD}>{hRule}</Text>
</Box>,
);
const row = (key: string, content: React.ReactNode) => {
permissionLines.push(
<Box key={key} width={fullWidth} height={1}>
<Text color={GOLD}> </Text>
<Box width={innerWidth} height={1}>
{content}
</Box>
<Text color={GOLD}> </Text>
</Box>,
);
};
row(
"pm-title",
<Text color={GOLD} bold>
🔒 Permission required
</Text>,
);
row("pm-g1", <Text> </Text>);
row(
"pm-tool",
<Text wrap="truncate-end" color={TEXT_PRIMARY}>
{perm.toolTitle}
</Text>,
);
row("pm-g2", <Text> </Text>);
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}`,
<>
<Text color={active ? GOLD : RULE_COLOR}>{active ? "▸ " : " "}</Text>
<Text color={active ? TEXT_PRIMARY : TEXT_SECONDARY} bold={active}>
[{k}] {label}
</Text>
</>,
);
}
row("pm-g3", <Text> </Text>);
row(
"pm-help",
<Text color={TEXT_DIM}> select · enter confirm · esc cancel</Text>,
);
permissionLines.push(
<Box key="pm-b" width={fullWidth} height={1}>
<Text color={GOLD}>{hRule}</Text>
</Box>,
);
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<Turn[]>([]);
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<PendingPermission | null>(null);
const [permissionIdx, setPermissionIdx] = useState(0);
const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
const [viewTurnIdx, setViewTurnIdx] = useState(-1);
const [toolCallsExpanded, setToolCallsExpanded] = useState(false);
const [selectedToolCallIdx, setSelectedToolCallIdx] = useState<number | null>(
null,
);
const [toolCallExpanded, setToolCallExpanded] = useState(false);
const [toolCallExpandedScroll, setToolCallExpandedScroll] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
const [pastedFull, setPastedFull] = useState<string | null>(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<RequestPermissionResponse> => {
return new Promise<RequestPermissionResponse>((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<ToolCallInfo | null>(() => {
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<string, string> = {
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 (
<Box flexDirection="column" width={safeTermWidth} height={safeTermHeight}>
@@ -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({
}
/>
<Viewport
lines={contentLines}
height={viewportHeight}
width={contentWidth}
scrollOffset={scrollOffset}
/>
{toolCallExpanded && selectedToolCallInfo ? (
<ToolCallExpanded
info={selectedToolCallInfo}
width={contentWidth}
height={viewportHeight}
scrollOffset={toolCallExpandedScroll}
onScroll={setToolCallExpandedScroll}
onClose={() => {
setToolCallExpanded(false);
setToolCallExpandedScroll(0);
}}
/>
) : (
<Viewport
lines={contentLines}
height={viewportHeight}
width={contentWidth}
scrollOffset={scrollOffset}
/>
)}
{isViewingHistory && (
<Box flexDirection="column" width={contentWidth} flexShrink={0}>
@@ -1236,20 +1226,6 @@ async function runTextMode(serverConnection: Stream | string, prompt: string) {
}
}
},
requestPermission: async (
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> => {
// 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,
);
+1 -7
View File
@@ -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" })