alexhancock/tui-improvements (#8736)
This commit is contained in:
@@ -1208,15 +1208,17 @@ impl GooseAcpAgent {
|
|||||||
.map(|a| serde_json::Value::Object(a.clone()));
|
.map(|a| serde_json::Value::Object(a.clone()));
|
||||||
let fallback_title = summarize_tool_call(&tool_name, args_value.as_ref());
|
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(
|
cx.send_notification(SessionNotification::new(
|
||||||
session_id.clone(),
|
session_id.clone(),
|
||||||
SessionUpdate::ToolCall(
|
SessionUpdate::ToolCall(initial_tool_call),
|
||||||
ToolCall::new(
|
|
||||||
ToolCallId::new(tool_request.id.clone()),
|
|
||||||
fallback_title.clone(),
|
|
||||||
)
|
|
||||||
.status(ToolCallStatus::Pending),
|
|
||||||
),
|
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
if let Ok(tool_call) = &tool_request.tool_call {
|
if let Ok(tool_call) = &tool_request.tool_call {
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ export function renderToolCallItem(
|
|||||||
item: ResponseItem & { itemType: "tool_call" },
|
item: ResponseItem & { itemType: "tool_call" },
|
||||||
index: number,
|
index: number,
|
||||||
width: number,
|
width: number,
|
||||||
toolCallsExpanded: boolean,
|
selected: boolean,
|
||||||
isFirst: boolean,
|
|
||||||
hasToolCalls: boolean
|
|
||||||
): React.ReactElement[] {
|
): React.ReactElement[] {
|
||||||
const info: ToolCallInfo = {
|
const info: ToolCallInfo = {
|
||||||
toolCallId: item.toolCallId,
|
toolCallId: item.toolCallId,
|
||||||
@@ -47,10 +45,10 @@ export function renderToolCallItem(
|
|||||||
content: item.content,
|
content: item.content,
|
||||||
locations: item.locations,
|
locations: item.locations,
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
emptyLine(`tc-gap-${index}`, width),
|
emptyLine(`tc-gap-${index}`, width),
|
||||||
...renderToolCallLines(info, width, toolCallsExpanded, isFirst && hasToolCalls),
|
...renderToolCallLines(info, width, selected),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ interface HeaderProps {
|
|||||||
status: string;
|
status: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
spinIdx: number;
|
spinIdx: number;
|
||||||
hasPendingPermission: boolean;
|
|
||||||
turnInfo?: { current: number; total: number };
|
turnInfo?: { current: number; total: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +18,6 @@ export const Header = React.memo(function Header({
|
|||||||
status,
|
status,
|
||||||
loading,
|
loading,
|
||||||
spinIdx,
|
spinIdx,
|
||||||
hasPendingPermission,
|
|
||||||
turnInfo,
|
turnInfo,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const statusColor =
|
const statusColor =
|
||||||
@@ -38,7 +36,7 @@ export const Header = React.memo(function Header({
|
|||||||
<Box flexShrink={1}>
|
<Box flexShrink={1}>
|
||||||
<Text color={statusColor} wrap="truncate-end">{status}</Text>
|
<Text color={statusColor} wrap="truncate-end">{status}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{loading && !hasPendingPermission && (
|
{loading && (
|
||||||
<Text> <Spinner idx={spinIdx} /></Text>
|
<Text> <Spinner idx={spinIdx} /></Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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 INPUT_MAX_ROWS = 8;
|
||||||
export const SENT_PREVIEW_LEN = 60;
|
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 = [
|
export const GOOSE_FRAMES = [
|
||||||
[
|
[
|
||||||
" ,_",
|
" ,_",
|
||||||
@@ -66,17 +70,3 @@ export const GREETING_MESSAGES = [
|
|||||||
|
|
||||||
export const INITIAL_GREETING =
|
export const INITIAL_GREETING =
|
||||||
GREETING_MESSAGES[Math.floor(Math.random() * GREETING_MESSAGES.length)]!;
|
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 {
|
function truncateLine(line: string, maxWidth: number): string {
|
||||||
const safeMaxWidth = Math.max(maxWidth, 1);
|
const safeMaxWidth = Math.max(maxWidth, 1);
|
||||||
if (line.length <= safeMaxWidth) return line;
|
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[] {
|
export function formatJson(value: unknown): string {
|
||||||
if (value === undefined || value === null) return [];
|
if (value === undefined || value === null) return "";
|
||||||
let raw: string;
|
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
raw = value;
|
// If it looks like JSON, try to parse and re-format; otherwise return as-is.
|
||||||
} else {
|
const trimmed = value.trim();
|
||||||
try {
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||||
raw = JSON.stringify(value, null, 2);
|
try {
|
||||||
} catch {
|
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
||||||
raw = String(value);
|
} catch {
|
||||||
}
|
return 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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} 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(
|
export function renderToolCallLines(
|
||||||
info: ToolCallInfo,
|
info: ToolCallInfo,
|
||||||
width: number,
|
width: number,
|
||||||
expanded: boolean,
|
selected: boolean,
|
||||||
showTabHint: boolean,
|
|
||||||
): React.ReactElement[] {
|
): React.ReactElement[] {
|
||||||
const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "⚙";
|
const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "⚙";
|
||||||
const statusInfo = STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!;
|
const statusInfo =
|
||||||
const borderColor = info.status === "failed" ? CRANBERRY : CEDAR;
|
STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!;
|
||||||
const dimBorder = info.status !== "failed";
|
|
||||||
|
const borderColor = selected
|
||||||
|
? GOLD
|
||||||
|
: info.status === "failed"
|
||||||
|
? CRANBERRY
|
||||||
|
: CEDAR;
|
||||||
|
const dimBorder = !selected && info.status !== "failed";
|
||||||
|
|
||||||
const safeWidth = Math.max(width, 10);
|
const safeWidth = Math.max(width, 10);
|
||||||
const innerWidth = Math.max(safeWidth - 4, 6);
|
const innerWidth = Math.max(safeWidth - 4, 6);
|
||||||
const indentedWidth = Math.max(innerWidth - 2, 4);
|
|
||||||
|
|
||||||
const lines: React.ReactElement[] = [];
|
|
||||||
const k = info.toolCallId;
|
const k = info.toolCallId;
|
||||||
|
const lines: React.ReactElement[] = [];
|
||||||
|
|
||||||
const hRule = "─".repeat(Math.max(safeWidth - 2, 0));
|
const hRule = "─".repeat(Math.max(safeWidth - 2, 0));
|
||||||
lines.push(
|
lines.push(
|
||||||
<Box key={`${k}-t`} width={safeWidth} height={1}>
|
<Box key={`${k}-t`} width={safeWidth} height={1}>
|
||||||
<Text color={borderColor} dimColor={dimBorder}>╭{hRule}╮</Text>
|
<Text color={borderColor} dimColor={dimBorder}>
|
||||||
|
╭{hRule}╮
|
||||||
|
</Text>
|
||||||
</Box>,
|
</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 statusIcon = statusInfo.icon;
|
||||||
const runningText = info.status === "in_progress" ? " running…" : "";
|
const runningText = info.status === "in_progress" ? " running…" : "";
|
||||||
const tabHintText = showTabHint && !expanded ? "tab ↔" : "";
|
const hintText = selected ? "space to expand" : "";
|
||||||
const fixedLen = 4 + runningText.length + tabHintText.length; // icon+space+kind+space + suffix + hint
|
const fixedLen = 4 + runningText.length + hintText.length;
|
||||||
const titleMax = Math.max(innerWidth - fixedLen, 4);
|
const titleMax = Math.max(innerWidth - fixedLen, 4);
|
||||||
const title = truncateLine(info.title, titleMax);
|
const title = truncateLine(info.title, titleMax);
|
||||||
|
|
||||||
row(`${k}-h`, (
|
lines.push(
|
||||||
<>
|
<Box key={`${k}-h`} width={safeWidth} height={1}>
|
||||||
<Text color={statusInfo.color}>{statusIcon}</Text>
|
<Text color={borderColor} dimColor={dimBorder}>
|
||||||
<Text> {kindIcon} </Text>
|
│{" "}
|
||||||
<Text wrap="truncate-end" color={TEXT_SECONDARY} bold>{title}</Text>
|
</Text>
|
||||||
{runningText ? <Text color={TEXT_DIM} italic>{runningText}</Text> : null}
|
<Box width={innerWidth} height={1}>
|
||||||
<Box flexGrow={1} />
|
<Text color={statusInfo.color}>{statusIcon}</Text>
|
||||||
{tabHintText ? <Text color={TEXT_DIM} italic>{tabHintText}</Text> : null}
|
<Text> {kindIcon} </Text>
|
||||||
</>
|
<Text wrap="truncate-end" color={TEXT_SECONDARY} bold>
|
||||||
));
|
{title}
|
||||||
|
</Text>
|
||||||
if (expanded) {
|
{runningText ? (
|
||||||
if (info.locations) {
|
<Text color={TEXT_DIM} italic>
|
||||||
for (let i = 0; i < info.locations.length; i++) {
|
{runningText}
|
||||||
const loc = info.locations[i]!;
|
</Text>
|
||||||
const t = truncateLine(`📁 ${loc.path}${loc.line ? `:${loc.line}` : ""}`, innerWidth);
|
) : null}
|
||||||
row(`${k}-l${i}`, <Text wrap="truncate-end" color={TEXT_DIM}>{t}</Text>);
|
<Box flexGrow={1} />
|
||||||
}
|
{hintText ? (
|
||||||
}
|
<Text color={GOLD} italic>
|
||||||
|
{hintText}
|
||||||
const section = (label: string, sLines: string[]) => {
|
</Text>
|
||||||
if (sLines.length === 0) return;
|
) : null}
|
||||||
row(`${k}-${label}H`, <Text color={TEXT_DIM}>▸ {label}:</Text>);
|
</Box>
|
||||||
for (let i = 0; i < sLines.length; i++) {
|
<Text color={borderColor} dimColor={dimBorder}>
|
||||||
row(`${k}-${label}${i}`, (
|
{" "}
|
||||||
<Text wrap="truncate-end" color={TEXT_DIM}>{" "}{sLines[i]}</Text>
|
│
|
||||||
));
|
</Text>
|
||||||
}
|
</Box>,
|
||||||
};
|
);
|
||||||
|
|
||||||
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(
|
lines.push(
|
||||||
<Box key={`${k}-b`} width={safeWidth} height={1}>
|
<Box key={`${k}-b`} width={safeWidth} height={1}>
|
||||||
<Text color={borderColor} dimColor={dimBorder}>╰{hRule}╯</Text>
|
<Text color={borderColor} dimColor={dimBorder}>
|
||||||
|
╰{hRule}╯
|
||||||
|
</Text>
|
||||||
</Box>,
|
</Box>,
|
||||||
);
|
);
|
||||||
|
|
||||||
return lines;
|
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 { Readable, Writable } from "node:stream";
|
||||||
import type {
|
import type {
|
||||||
SessionNotification,
|
SessionNotification,
|
||||||
RequestPermissionRequest,
|
|
||||||
RequestPermissionResponse,
|
|
||||||
Stream,
|
Stream,
|
||||||
ContentChunk,
|
ContentChunk,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
@@ -20,7 +18,7 @@ import { resolveGooseBinary } from "@aaif/goose-sdk/node";
|
|||||||
import Onboarding from "./onboarding.js";
|
import Onboarding from "./onboarding.js";
|
||||||
import ConfigureScreen, { ConfigureIntent } from "./configure.js";
|
import ConfigureScreen, { ConfigureIntent } from "./configure.js";
|
||||||
import ExtensionsManager from "./extensions.js";
|
import ExtensionsManager from "./extensions.js";
|
||||||
import type { PendingPermission, ResponseItem, Turn } from "./types.js";
|
import type { Turn } from "./types.js";
|
||||||
import {
|
import {
|
||||||
emptyLine,
|
emptyLine,
|
||||||
renderUserPrompt,
|
renderUserPrompt,
|
||||||
@@ -32,13 +30,14 @@ import {
|
|||||||
} from "./components/ContentRenderers.js";
|
} from "./components/ContentRenderers.js";
|
||||||
import { Header } from "./components/Header.js";
|
import { Header } from "./components/Header.js";
|
||||||
import { Rule } from "./components/Rule.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 { isErrorStatus, formatError } from "./utils.js";
|
||||||
import {
|
import {
|
||||||
CRANBERRY,
|
CRANBERRY,
|
||||||
TEAL,
|
TEAL,
|
||||||
GOLD,
|
GOLD,
|
||||||
TEXT_PRIMARY,
|
TEXT_PRIMARY,
|
||||||
TEXT_SECONDARY,
|
|
||||||
TEXT_DIM,
|
TEXT_DIM,
|
||||||
RULE_COLOR,
|
RULE_COLOR,
|
||||||
} from "./colors.js";
|
} from "./colors.js";
|
||||||
@@ -49,8 +48,8 @@ import {
|
|||||||
SENT_PREVIEW_LEN,
|
SENT_PREVIEW_LEN,
|
||||||
GOOSE_FRAMES,
|
GOOSE_FRAMES,
|
||||||
INITIAL_GREETING,
|
INITIAL_GREETING,
|
||||||
PERMISSION_LABELS,
|
SCROLL_STEP,
|
||||||
PERMISSION_KEYS,
|
SCROLL_FAST_MULTIPLIER,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
|
|
||||||
const InputBar = React.memo(function InputBar({
|
const InputBar = React.memo(function InputBar({
|
||||||
@@ -167,7 +166,9 @@ const InputBar = React.memo(function InputBar({
|
|||||||
})()}
|
})()}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{scrollHint && <Text color={TEXT_DIM}>shift+↑↓ history</Text>}
|
{scrollHint && (
|
||||||
|
<Text color={TEXT_DIM}>↑↓ scroll · ⌥↑↓ fast · shift+↑↓ history</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box flexGrow={1} justifyContent="space-between">
|
<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>
|
||||||
)}
|
)}
|
||||||
</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({
|
function buildContentLines({
|
||||||
turn,
|
turn,
|
||||||
turnIndex,
|
turnIndex,
|
||||||
@@ -222,9 +236,7 @@ function buildContentLines({
|
|||||||
loading,
|
loading,
|
||||||
status,
|
status,
|
||||||
spinIdx,
|
spinIdx,
|
||||||
pendingPermission,
|
selectedToolCallIdx,
|
||||||
permissionIdx,
|
|
||||||
toolCallsExpanded,
|
|
||||||
queuedMessages,
|
queuedMessages,
|
||||||
}: {
|
}: {
|
||||||
turn: Turn | undefined;
|
turn: Turn | undefined;
|
||||||
@@ -233,13 +245,12 @@ function buildContentLines({
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
spinIdx: number;
|
spinIdx: number;
|
||||||
pendingPermission: PendingPermission | null;
|
selectedToolCallIdx: number | null;
|
||||||
permissionIdx: number;
|
|
||||||
toolCallsExpanded: boolean;
|
|
||||||
queuedMessages: string[];
|
queuedMessages: string[];
|
||||||
}): React.ReactElement[] {
|
}): ContentLayout {
|
||||||
const lines: React.ReactElement[] = [];
|
const lines: React.ReactElement[] = [];
|
||||||
if (!turn) return lines;
|
const toolCallRanges: ToolCallRange[] = [];
|
||||||
|
if (!turn) return { lines, toolCallRanges };
|
||||||
|
|
||||||
const safeWidth = Math.max(width, 20);
|
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;
|
let tcIdx = 0;
|
||||||
|
|
||||||
for (let i = 0; i < turn.responseItems.length; i++) {
|
for (let i = 0; i < turn.responseItems.length; i++) {
|
||||||
const item = turn.responseItems[i]!;
|
const item = turn.responseItems[i]!;
|
||||||
|
|
||||||
if (item.itemType === "tool_call") {
|
if (item.itemType === "tool_call") {
|
||||||
lines.push(
|
const isSelected = selectedToolCallIdx === tcIdx;
|
||||||
...renderToolCallItem(
|
const rendered = renderToolCallItem(item, i, safeWidth, isSelected);
|
||||||
item,
|
const startLine = lines.length;
|
||||||
i,
|
lines.push(...rendered);
|
||||||
safeWidth,
|
toolCallRanges.push({
|
||||||
toolCallsExpanded,
|
responseItemIndex: i,
|
||||||
tcIdx === 0,
|
startLine,
|
||||||
hasToolCalls,
|
endLine: lines.length - 1,
|
||||||
),
|
});
|
||||||
);
|
|
||||||
tcIdx++;
|
tcIdx++;
|
||||||
} else if (item.itemType === "error") {
|
} else if (item.itemType === "error") {
|
||||||
lines.push(...renderErrorItem(item, i, safeWidth));
|
lines.push(...renderErrorItem(item, i, safeWidth));
|
||||||
@@ -310,96 +316,13 @@ function buildContentLines({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading indicator
|
if (loading) {
|
||||||
if (loading && !pendingPermission) {
|
|
||||||
lines.push(...renderLoadingIndicator(status, spinIdx, safeWidth));
|
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));
|
lines.push(...renderQueuedMessages(queuedMessages, safeWidth));
|
||||||
|
|
||||||
return lines;
|
return { lines, toolCallRanges };
|
||||||
}
|
}
|
||||||
|
|
||||||
const Viewport = React.memo(function Viewport({
|
const Viewport = React.memo(function Viewport({
|
||||||
@@ -542,8 +465,29 @@ function App({
|
|||||||
}) {
|
}) {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const { stdout } = useStdout();
|
const { stdout } = useStdout();
|
||||||
const termWidth = stdout?.columns ?? 80;
|
// `useStdout()` returns the live stream but does not trigger a React
|
||||||
const termHeight = stdout?.rows ?? 24;
|
// 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 [turns, setTurns] = useState<Turn[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
@@ -552,13 +496,14 @@ function App({
|
|||||||
const [spinIdx, setSpinIdx] = useState(0);
|
const [spinIdx, setSpinIdx] = useState(0);
|
||||||
const [gooseFrame, setGooseFrame] = useState(0);
|
const [gooseFrame, setGooseFrame] = useState(0);
|
||||||
const [bannerVisible, setBannerVisible] = useState(true);
|
const [bannerVisible, setBannerVisible] = useState(true);
|
||||||
const [pendingPermission, setPendingPermission] =
|
|
||||||
useState<PendingPermission | null>(null);
|
|
||||||
const [permissionIdx, setPermissionIdx] = useState(0);
|
|
||||||
const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
|
const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
|
||||||
|
|
||||||
const [viewTurnIdx, setViewTurnIdx] = useState(-1);
|
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 [scrollOffset, setScrollOffset] = useState(0);
|
||||||
const [pastedFull, setPastedFull] = useState<string | null>(null);
|
const [pastedFull, setPastedFull] = useState<string | null>(null);
|
||||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
@@ -592,10 +537,18 @@ function App({
|
|||||||
}, [turns]);
|
}, [turns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setToolCallsExpanded(false);
|
setSelectedToolCallIdx(null);
|
||||||
|
setToolCallExpanded(false);
|
||||||
|
setToolCallExpandedScroll(0);
|
||||||
setScrollOffset(0);
|
setScrollOffset(0);
|
||||||
}, [viewTurnIdx, turns.length]);
|
}, [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) => {
|
const appendAgent = useCallback((text: string) => {
|
||||||
setTurns((prev) => {
|
setTurns((prev) => {
|
||||||
if (prev.length === 0) return prev;
|
if (prev.length === 0) return prev;
|
||||||
@@ -688,27 +641,12 @@ function App({
|
|||||||
{ userText: text, responseItems: [], toolCallsById: new Map() },
|
{ userText: text, responseItems: [], toolCallsById: new Map() },
|
||||||
]);
|
]);
|
||||||
setViewTurnIdx(-1);
|
setViewTurnIdx(-1);
|
||||||
setToolCallsExpanded(false);
|
setSelectedToolCallIdx(null);
|
||||||
|
setToolCallExpanded(false);
|
||||||
|
setToolCallExpandedScroll(0);
|
||||||
setScrollOffset(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(
|
const executePrompt = useCallback(
|
||||||
async (text: string) => {
|
async (text: string) => {
|
||||||
const client = clientRef.current;
|
const client = clientRef.current;
|
||||||
@@ -816,22 +754,6 @@ function App({
|
|||||||
handleToolCallUpdate(update);
|
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,
|
serverConnection,
|
||||||
);
|
);
|
||||||
@@ -896,7 +818,9 @@ function App({
|
|||||||
setInput("");
|
setInput("");
|
||||||
setPastedFull(null);
|
setPastedFull(null);
|
||||||
setViewTurnIdx(-1);
|
setViewTurnIdx(-1);
|
||||||
setToolCallsExpanded(false);
|
setSelectedToolCallIdx(null);
|
||||||
|
setToolCallExpanded(false);
|
||||||
|
setToolCallExpandedScroll(0);
|
||||||
setScrollOffset(0);
|
setScrollOffset(0);
|
||||||
|
|
||||||
if (loading || isProcessingRef.current) {
|
if (loading || isProcessingRef.current) {
|
||||||
@@ -909,18 +833,141 @@ function App({
|
|||||||
[loading, sendPrompt],
|
[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(
|
useInput(
|
||||||
(ch, key) => {
|
(ch, key) => {
|
||||||
|
if (toolCallExpanded) return;
|
||||||
|
|
||||||
if (key.escape || (ch === "c" && key.ctrl)) {
|
if (key.escape || (ch === "c" && key.ctrl)) {
|
||||||
if (pendingPermission) {
|
|
||||||
resolvePermission("cancelled");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.escape && pastedFull !== null) return;
|
if (key.escape && pastedFull !== null) return;
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!loading && !pendingPermission && sessionIdRef.current) {
|
if (!loading && sessionIdRef.current) {
|
||||||
if (key.ctrl && (ch === "p" || ch === "P")) {
|
if (key.ctrl && (ch === "p" || ch === "P")) {
|
||||||
setOverlay({ screen: "configure", intent: "provider" });
|
setOverlay({ screen: "configure", intent: "provider" });
|
||||||
return;
|
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 =
|
const viewingHistory =
|
||||||
viewTurnIdx !== -1 && viewTurnIdx < turns.length - 1;
|
viewTurnIdx !== -1 && viewTurnIdx < turns.length - 1;
|
||||||
const multilineOwnsArrows =
|
const multilineOwnsArrows =
|
||||||
!pendingPermission &&
|
|
||||||
!initialPrompt &&
|
!initialPrompt &&
|
||||||
!viewingHistory &&
|
!viewingHistory &&
|
||||||
pastedFull === null;
|
pastedFull === null &&
|
||||||
|
input.includes("\n");
|
||||||
|
|
||||||
if (key.tab) {
|
if (ch === " " && selectedToolCallIdx !== null) {
|
||||||
const idx = viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx;
|
setToolCallExpandedScroll(0);
|
||||||
const t = turns[idx];
|
setToolCallExpanded(true);
|
||||||
if (t && t.responseItems.some((it) => it.itemType === "tool_call")) {
|
return;
|
||||||
setToolCallsExpanded((prev) => !prev);
|
}
|
||||||
|
|
||||||
|
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 (toolCallRanges.length > 0) {
|
||||||
if (!multilineOwnsArrows) setScrollOffset((prev) => prev + 3);
|
const direction: -1 | 1 = key.upArrow ? -1 : 1;
|
||||||
return;
|
if (moveSelection(direction)) return;
|
||||||
}
|
if (selectedToolCallIdx !== null) {
|
||||||
if (key.downArrow && !key.shift) {
|
setSelectedToolCallIdx(null);
|
||||||
if (!multilineOwnsArrows)
|
}
|
||||||
setScrollOffset((prev) => Math.max(prev - 3, 0));
|
}
|
||||||
|
|
||||||
|
const step = SCROLL_STEP;
|
||||||
|
if (key.upArrow) {
|
||||||
|
setScrollOffset((prev) => prev + step);
|
||||||
|
} else {
|
||||||
|
setScrollOffset((prev) => Math.max(prev - step, 0));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1022,64 +1057,6 @@ function App({
|
|||||||
{ isActive: !needsOnboarding && !overlay },
|
{ 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) {
|
if (needsOnboarding && clientRef.current) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" width={safeTermWidth} height={safeTermHeight}>
|
<Box flexDirection="column" width={safeTermWidth} height={safeTermHeight}>
|
||||||
@@ -1158,7 +1135,6 @@ function App({
|
|||||||
status={status}
|
status={status}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
spinIdx={spinIdx}
|
spinIdx={spinIdx}
|
||||||
hasPendingPermission={!!pendingPermission}
|
|
||||||
turnInfo={
|
turnInfo={
|
||||||
turns.length > 1
|
turns.length > 1
|
||||||
? { current: effectiveTurnIdx + 1, total: turns.length }
|
? { current: effectiveTurnIdx + 1, total: turns.length }
|
||||||
@@ -1166,12 +1142,26 @@ function App({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Viewport
|
{toolCallExpanded && selectedToolCallInfo ? (
|
||||||
lines={contentLines}
|
<ToolCallExpanded
|
||||||
height={viewportHeight}
|
info={selectedToolCallInfo}
|
||||||
width={contentWidth}
|
width={contentWidth}
|
||||||
scrollOffset={scrollOffset}
|
height={viewportHeight}
|
||||||
/>
|
scrollOffset={toolCallExpandedScroll}
|
||||||
|
onScroll={setToolCallExpandedScroll}
|
||||||
|
onClose={() => {
|
||||||
|
setToolCallExpanded(false);
|
||||||
|
setToolCallExpandedScroll(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Viewport
|
||||||
|
lines={contentLines}
|
||||||
|
height={viewportHeight}
|
||||||
|
width={contentWidth}
|
||||||
|
scrollOffset={scrollOffset}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isViewingHistory && (
|
{isViewingHistory && (
|
||||||
<Box flexDirection="column" width={contentWidth} flexShrink={0}>
|
<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,
|
serverConnection,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import type { ContentChunk, ToolCall, RequestPermissionResponse } from "@agentclientprotocol/sdk";
|
import type { ContentChunk, ToolCall } from "@agentclientprotocol/sdk";
|
||||||
|
|
||||||
export interface PendingPermission {
|
|
||||||
toolTitle: string;
|
|
||||||
options: Array<{ optionId: string; name: string; kind: string }>;
|
|
||||||
resolve: (response: RequestPermissionResponse) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ResponseItem =
|
export type ResponseItem =
|
||||||
| (ContentChunk & { itemType: "content_chunk" })
|
| (ContentChunk & { itemType: "content_chunk" })
|
||||||
|
|||||||
Reference in New Issue
Block a user