alexhancock/tui-improvements (#8736)
This commit is contained in:
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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,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" })
|
||||
|
||||
Reference in New Issue
Block a user