feat(tui): UI improvements for messages, tool calls, text entry, etc (#8156)
This commit is contained in:
+87
-145
@@ -120,8 +120,6 @@ function summarizeContent(info: ToolCallInfo): string {
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
const MAX_PREVIEW_LINES = 8;
|
||||
|
||||
export function findFeaturedToolCallId(
|
||||
toolCallOrder: string[],
|
||||
toolCalls: Map<string, ToolCallInfo>,
|
||||
@@ -135,16 +133,21 @@ export function findFeaturedToolCallId(
|
||||
return toolCallOrder[toolCallOrder.length - 1];
|
||||
}
|
||||
|
||||
export function buildToolCallCardLines(
|
||||
info: ToolCallInfo,
|
||||
indent: number,
|
||||
totalWidth: number,
|
||||
expanded: boolean,
|
||||
keyPrefix: string = "card",
|
||||
): React.ReactNode[] {
|
||||
const cardWidth = Math.min(totalWidth - indent - 2, 72);
|
||||
const innerWidth = cardWidth - 2;
|
||||
const contentWidth = innerWidth - 2;
|
||||
interface ToolCallProps {
|
||||
info: ToolCallInfo;
|
||||
width: number;
|
||||
expanded: boolean;
|
||||
showTabHint: boolean;
|
||||
keyPrefix: string;
|
||||
}
|
||||
|
||||
export function ToolCallCard({
|
||||
info,
|
||||
width,
|
||||
expanded,
|
||||
showTabHint,
|
||||
keyPrefix,
|
||||
}: ToolCallProps): React.ReactNode[] {
|
||||
const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "⚙";
|
||||
const statusInfo = STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!;
|
||||
const borderColor = info.status === "failed" ? CRANBERRY : CEDAR;
|
||||
@@ -155,150 +158,89 @@ export function buildToolCallCardLines(
|
||||
const hasContent = info.content && info.content.length > 0;
|
||||
const hasLocations = info.locations && info.locations.length > 0;
|
||||
|
||||
const inputLines = hasInput ? formatJsonCompact(info.rawInput, contentWidth - 6) : [];
|
||||
const outputLines = hasOutput ? formatJsonCompact(info.rawOutput, contentWidth - 6) : [];
|
||||
const contentLines = hasContent ? extractTextFromContent(info.content!) : [];
|
||||
const contentWidth = width - 4;
|
||||
|
||||
const shownInput = expanded ? inputLines : inputLines.slice(0, MAX_PREVIEW_LINES);
|
||||
const shownOutput = expanded ? outputLines : outputLines.slice(0, MAX_PREVIEW_LINES);
|
||||
const shownContent = expanded ? contentLines : contentLines.slice(0, MAX_PREVIEW_LINES);
|
||||
|
||||
const hasTruncated =
|
||||
inputLines.length > MAX_PREVIEW_LINES ||
|
||||
outputLines.length > MAX_PREVIEW_LINES ||
|
||||
contentLines.length > MAX_PREVIEW_LINES;
|
||||
|
||||
const bodyRows: Array<{ text: string; color?: string; italic?: boolean }> = [];
|
||||
const lines: React.ReactNode[] = [];
|
||||
const content: React.ReactNode[] = [];
|
||||
|
||||
// Header
|
||||
const runningText = info.status === "in_progress" ? " running…" : "";
|
||||
bodyRows.push({ text: "__HEADER__" });
|
||||
|
||||
if (hasLocations) {
|
||||
for (const loc of info.locations!) {
|
||||
bodyRows.push({ text: ` 📁 ${loc.path}${loc.line ? `:${loc.line}` : ""}`, color: TEXT_DIM });
|
||||
}
|
||||
}
|
||||
|
||||
function addSection(label: string, lines: string[], totalCount: number) {
|
||||
if (lines.length === 0) return;
|
||||
bodyRows.push({ text: ` ▸ ${label}:`, color: TEXT_DIM });
|
||||
for (const line of lines) {
|
||||
bodyRows.push({ text: ` ${line}`, color: TEXT_DIM });
|
||||
}
|
||||
if (!expanded && totalCount > MAX_PREVIEW_LINES) {
|
||||
const remaining = totalCount - MAX_PREVIEW_LINES;
|
||||
bodyRows.push({ text: ` ▸ ${remaining} more lines (tab to expand)`, color: GOLD, italic: true });
|
||||
}
|
||||
}
|
||||
|
||||
addSection("input", shownInput, inputLines.length);
|
||||
addSection("output", shownOutput, outputLines.length);
|
||||
addSection("content", shownContent, contentLines.length);
|
||||
|
||||
const result: React.ReactNode[] = [];
|
||||
const topBorder = "╭" + "─".repeat(innerWidth) + "╮";
|
||||
const botBorder = "╰" + "─".repeat(innerWidth) + "╯";
|
||||
|
||||
result.push(
|
||||
<Box key={`${keyPrefix}-top`} marginLeft={indent} height={1}>
|
||||
<Text color={borderColor} dimColor={dimBorder}>{topBorder}</Text>
|
||||
</Box>,
|
||||
content.push(
|
||||
<Box key="header" flexDirection="row">
|
||||
<Text color={statusInfo.color}>{statusInfo.icon}</Text>
|
||||
<Text> </Text>
|
||||
<Text>{kindIcon}</Text>
|
||||
<Text> </Text>
|
||||
<Text color={TEXT_SECONDARY} bold>{info.title}</Text>
|
||||
{runningText && <Text color={TEXT_DIM} italic>{runningText}</Text>}
|
||||
<Box flexGrow={1} />
|
||||
{showTabHint && !expanded && <Text color={TEXT_DIM} italic>tab ↔</Text>}
|
||||
</Box>
|
||||
);
|
||||
|
||||
for (let i = 0; i < bodyRows.length; i++) {
|
||||
const row = bodyRows[i]!;
|
||||
|
||||
if (row.text === "__HEADER__") {
|
||||
result.push(
|
||||
<Box key={`${keyPrefix}-row-${i}`} marginLeft={indent} width={cardWidth} height={1}>
|
||||
<Text color={borderColor} dimColor={dimBorder}>│ </Text>
|
||||
<Box flexGrow={1} justifyContent="space-between">
|
||||
<Box>
|
||||
<Text color={statusInfo.color}>{statusInfo.icon} </Text>
|
||||
<Text>{kindIcon} </Text>
|
||||
<Text color={TEXT_SECONDARY} bold>{info.title}</Text>
|
||||
{runningText ? <Text color={TEXT_DIM} italic>{runningText}</Text> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<Text color={borderColor} dimColor={dimBorder}> │</Text>
|
||||
</Box>,
|
||||
// Compact view - show summary
|
||||
if (!expanded) {
|
||||
const summary = summarizeContent(info);
|
||||
if (summary) {
|
||||
content.push(
|
||||
<Box key="summary">
|
||||
<Text color={TEXT_DIM}>{summary}</Text>
|
||||
</Box>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Expanded view - show all details
|
||||
const inputLines = hasInput ? formatJsonCompact(info.rawInput, contentWidth - 6) : [];
|
||||
const outputLines = hasOutput ? formatJsonCompact(info.rawOutput, contentWidth - 6) : [];
|
||||
const contentLines = hasContent ? extractTextFromContent(info.content!) : [];
|
||||
|
||||
if (hasLocations) {
|
||||
for (let i = 0; i < info.locations!.length; i++) {
|
||||
const loc = info.locations![i]!;
|
||||
content.push(
|
||||
<Box key={`loc-${i}`}>
|
||||
<Text color={TEXT_DIM}>📁 {loc.path}{loc.line ? `:${loc.line}` : ""}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result.push(
|
||||
<Box key={`${keyPrefix}-row-${i}`} marginLeft={indent} width={cardWidth} height={1}>
|
||||
<Text color={borderColor} dimColor={dimBorder}>│</Text>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={row.color} italic={row.italic}> {row.text}</Text>
|
||||
const addSection = (label: string, sectionLines: string[]) => {
|
||||
if (sectionLines.length === 0) return;
|
||||
|
||||
content.push(
|
||||
<Box key={`${label}-header`}>
|
||||
<Text color={TEXT_DIM}>▸ {label}:</Text>
|
||||
</Box>
|
||||
<Text color={borderColor} dimColor={dimBorder}>│</Text>
|
||||
</Box>,
|
||||
);
|
||||
);
|
||||
|
||||
for (let i = 0; i < sectionLines.length; i++) {
|
||||
content.push(
|
||||
<Box key={`${label}-${i}`} paddingLeft={2}>
|
||||
<Text color={TEXT_DIM}>{sectionLines[i]}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
addSection("input", inputLines);
|
||||
addSection("output", outputLines);
|
||||
addSection("content", contentLines);
|
||||
}
|
||||
|
||||
result.push(
|
||||
<Box key={`${keyPrefix}-bot`} marginLeft={indent} height={1}>
|
||||
<Text color={borderColor} dimColor={dimBorder}>{botBorder}</Text>
|
||||
</Box>,
|
||||
lines.push(
|
||||
<Box
|
||||
key={keyPrefix}
|
||||
width={width}
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
borderDimColor={dimBorder}
|
||||
paddingX={1}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ToolCallCompact({
|
||||
info,
|
||||
indent,
|
||||
width,
|
||||
keyPrefix,
|
||||
showTabHint,
|
||||
}: {
|
||||
info: ToolCallInfo;
|
||||
indent: number;
|
||||
width: number;
|
||||
keyPrefix: string;
|
||||
showTabHint: boolean;
|
||||
}): React.ReactNode[] {
|
||||
const statusInfo = STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!;
|
||||
const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "⚙";
|
||||
const summary = summarizeContent(info);
|
||||
const borderColor = info.status === "failed" ? CRANBERRY : CEDAR;
|
||||
const dimBorder = info.status !== "failed";
|
||||
|
||||
const cardWidth = Math.min(width - indent - 2, 72);
|
||||
const innerWidth = cardWidth - 2;
|
||||
|
||||
const tabHintText = "tab ↔";
|
||||
const maxSummaryWidth = innerWidth - info.title.length - 8 - (showTabHint ? tabHintText.length + 2 : 0);
|
||||
const trimmedSummary =
|
||||
summary.length > maxSummaryWidth && maxSummaryWidth > 3
|
||||
? summary.slice(0, maxSummaryWidth - 1) + "…"
|
||||
: summary;
|
||||
|
||||
const topBorder = "╭" + "─".repeat(innerWidth) + "╮";
|
||||
const botBorder = "╰" + "─".repeat(innerWidth) + "╯";
|
||||
|
||||
return [
|
||||
<Box key={`${keyPrefix}-top`} marginLeft={indent} height={1}>
|
||||
<Text color={borderColor} dimColor={dimBorder}>{topBorder}</Text>
|
||||
</Box>,
|
||||
<Box key={`${keyPrefix}-content`} marginLeft={indent} width={cardWidth} height={1}>
|
||||
<Text color={borderColor} dimColor={dimBorder}>│ </Text>
|
||||
<Box flexGrow={1} justifyContent="space-between">
|
||||
<Box>
|
||||
<Text color={statusInfo.color}>{statusInfo.icon} </Text>
|
||||
<Text>{kindIcon} </Text>
|
||||
<Text color={TEXT_SECONDARY} bold>{info.title}</Text>
|
||||
{trimmedSummary ? (
|
||||
<Text color={TEXT_DIM}> — {trimmedSummary}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
{showTabHint && <Text color={TEXT_DIM} italic>{tabHintText}</Text>}
|
||||
</Box>
|
||||
<Text color={borderColor} dimColor={dimBorder}> │</Text>
|
||||
</Box>,
|
||||
<Box key={`${keyPrefix}-bot`} marginLeft={indent} height={1}>
|
||||
<Text color={borderColor} dimColor={dimBorder}>{botBorder}</Text>
|
||||
</Box>,
|
||||
];
|
||||
return lines;
|
||||
}
|
||||
|
||||
+123
-170
@@ -25,7 +25,7 @@ import type {
|
||||
import { ndJsonStream } from "@agentclientprotocol/sdk";
|
||||
import { GooseClient } from "@aaif/goose-acp";
|
||||
import { renderMarkdown } from "./markdown.js";
|
||||
import { buildToolCallCardLines, ToolCallCompact, findFeaturedToolCallId } from "./toolcall.js";
|
||||
import { ToolCallCard } from "./toolcall.js";
|
||||
import type { ToolCallInfo } from "./toolcall.js";
|
||||
import { CRANBERRY, TEAL, GOLD, TEXT_PRIMARY, TEXT_SECONDARY, TEXT_DIM, RULE_COLOR } from "./colors.js";
|
||||
|
||||
@@ -128,9 +128,6 @@ const PERMISSION_KEYS: Record<string, string> = {
|
||||
reject_always: "N",
|
||||
};
|
||||
|
||||
const INDENT = 3;
|
||||
const CONTENT_INDENT = 5;
|
||||
|
||||
function Rule({ width }: { width: number }) {
|
||||
return <Text color={RULE_COLOR}>{"─".repeat(Math.max(width, 1))}</Text>;
|
||||
}
|
||||
@@ -193,7 +190,7 @@ function Header({
|
||||
|
||||
function UserPrompt({ text }: { text: string }) {
|
||||
return (
|
||||
<Box paddingLeft={INDENT} paddingTop={1}>
|
||||
<Box>
|
||||
<Text color={CRANBERRY} bold>
|
||||
{"❯ "}
|
||||
</Text>
|
||||
@@ -215,11 +212,10 @@ function PermissionDialog({
|
||||
selectedIdx: number;
|
||||
width: number;
|
||||
}) {
|
||||
const dialogWidth = Math.min(width - CONTENT_INDENT - 2, 58);
|
||||
const dialogWidth = Math.min(width - 2, 58);
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginLeft={CONTENT_INDENT}
|
||||
marginTop={1}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
@@ -262,7 +258,7 @@ function PermissionDialog({
|
||||
|
||||
function QueuedMessage({ text }: { text: string }) {
|
||||
return (
|
||||
<Box paddingLeft={INDENT}>
|
||||
<Box>
|
||||
<Text color={TEXT_DIM}>❯ </Text>
|
||||
<Text color={TEXT_DIM}>{text}</Text>
|
||||
<Text color={GOLD} dimColor>
|
||||
@@ -280,6 +276,7 @@ function InputBar({
|
||||
onSubmit,
|
||||
queued,
|
||||
scrollHint,
|
||||
placeholder,
|
||||
}: {
|
||||
width: number;
|
||||
input: string;
|
||||
@@ -287,33 +284,37 @@ function InputBar({
|
||||
onSubmit: (v: string) => void;
|
||||
queued: boolean;
|
||||
scrollHint: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<Box flexDirection="column" width={width} marginBottom={1}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={RULE_COLOR}
|
||||
paddingX={1}
|
||||
width={width}
|
||||
>
|
||||
<Box justifyContent="space-between">
|
||||
<Box flexGrow={1}>
|
||||
<Text color={CRANBERRY} bold>
|
||||
{"❯ "}
|
||||
</Text>
|
||||
<TextInput value={input} onChange={onChange} onSubmit={onSubmit} />
|
||||
</Box>
|
||||
{scrollHint && <Text color={TEXT_DIM}>shift+↑↓ history</Text>}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={RULE_COLOR}
|
||||
paddingX={1}
|
||||
width={width}
|
||||
>
|
||||
<Box justifyContent="space-between">
|
||||
<Box flexGrow={1}>
|
||||
<Text color={CRANBERRY} bold>
|
||||
{"❯ "}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</Box>
|
||||
{queued && (
|
||||
<Box>
|
||||
<Text color={GOLD} dimColor italic>
|
||||
message queued — will send when goose finishes
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{scrollHint && <Text color={TEXT_DIM}>shift+↑↓ history</Text>}
|
||||
</Box>
|
||||
{queued && (
|
||||
<Box>
|
||||
<Text color={GOLD} dimColor italic>
|
||||
message queued — will send when goose finishes
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -338,18 +339,17 @@ function buildTurnBodyLines({
|
||||
toolCallsExpanded: boolean;
|
||||
}): React.ReactNode[] {
|
||||
const lines: React.ReactNode[] = [];
|
||||
|
||||
lines.push(<Box key="gap-top" height={1} />);
|
||||
const hasToolCalls = turn.responseItems.some(item => item.itemType === "tool_call");
|
||||
|
||||
let toolCallIndex = 0;
|
||||
let textChunkIndex = 0;
|
||||
|
||||
// Render items in the order they arrived
|
||||
for (let i = 0; i < turn.responseItems.length; i++) {
|
||||
const item = turn.responseItems[i]!;
|
||||
|
||||
lines.push(<Text key={`gap-${i}`}> </Text>);
|
||||
|
||||
if (item.itemType === "tool_call") {
|
||||
const tcId = item.toolCallId;
|
||||
const info: ToolCallInfo = {
|
||||
toolCallId: item.toolCallId,
|
||||
title: item.title,
|
||||
@@ -361,42 +361,36 @@ function buildTurnBodyLines({
|
||||
locations: item.locations,
|
||||
};
|
||||
|
||||
if (toolCallsExpanded) {
|
||||
const cardLines = buildToolCallCardLines(info, CONTENT_INDENT, width, true, `tc-${tcId}`);
|
||||
lines.push(...cardLines);
|
||||
} else {
|
||||
const compactLines = ToolCallCompact({
|
||||
info,
|
||||
indent: CONTENT_INDENT,
|
||||
width,
|
||||
keyPrefix: `tc-${tcId}`,
|
||||
showTabHint: toolCallIndex === 0,
|
||||
});
|
||||
lines.push(...compactLines);
|
||||
}
|
||||
const toolCallLines = ToolCallCard({
|
||||
info,
|
||||
width,
|
||||
expanded: toolCallsExpanded,
|
||||
showTabHint: toolCallIndex === 0 && hasToolCalls,
|
||||
keyPrefix: `tc-${item.toolCallId}`,
|
||||
});
|
||||
lines.push(...toolCallLines);
|
||||
toolCallIndex++;
|
||||
} else if (item.itemType === "content_chunk") {
|
||||
if (item.content.type === "text") {
|
||||
const text = item.content.text;
|
||||
if (text) {
|
||||
const rendered = renderMarkdown(text);
|
||||
const mdLines = rendered.split("\n");
|
||||
for (let j = 0; j < mdLines.length; j++) {
|
||||
lines.push(
|
||||
<Box key={`text-${textChunkIndex}-${j}`} paddingLeft={CONTENT_INDENT}>
|
||||
<Text>{mdLines[j]}</Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
textChunkIndex++;
|
||||
} else if (item.itemType === "content_chunk" && item.content.type === "text") {
|
||||
const text = item.content.text;
|
||||
if (text) {
|
||||
const rendered = renderMarkdown(text);
|
||||
const mdLines = rendered.split("\n");
|
||||
for (let j = 0; j < mdLines.length; j++) {
|
||||
lines.push(
|
||||
<Box key={`text-${textChunkIndex}-${j}`}>
|
||||
<Text>{mdLines[j]}</Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
textChunkIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !pendingPermission) {
|
||||
lines.push(<Text key="gap-loading"> </Text>);
|
||||
lines.push(
|
||||
<Box key="loading" paddingLeft={CONTENT_INDENT}>
|
||||
<Box key="loading">
|
||||
<Spinner idx={spinIdx} />
|
||||
<Text color={TEXT_DIM} italic>
|
||||
{" "}
|
||||
@@ -407,6 +401,7 @@ function buildTurnBodyLines({
|
||||
}
|
||||
|
||||
if (pendingPermission) {
|
||||
lines.push(<Text key="gap-permission"> </Text>);
|
||||
lines.push(
|
||||
<PermissionDialog
|
||||
key="permission"
|
||||
@@ -485,37 +480,26 @@ function ScrollableBody({
|
||||
function SplashScreen({
|
||||
animFrame,
|
||||
width,
|
||||
height,
|
||||
status,
|
||||
loading,
|
||||
spinIdx,
|
||||
showInput,
|
||||
input,
|
||||
onInputChange,
|
||||
onInputSubmit,
|
||||
}: {
|
||||
animFrame: number;
|
||||
width: number;
|
||||
height: number;
|
||||
status: string;
|
||||
loading: boolean;
|
||||
spinIdx: number;
|
||||
showInput: boolean;
|
||||
input: string;
|
||||
onInputChange: (v: string) => void;
|
||||
onInputSubmit: (v: string) => void;
|
||||
}) {
|
||||
const frame = GOOSE_FRAMES[animFrame % GOOSE_FRAMES.length]!;
|
||||
const statusColor = status === "ready" ? TEAL : isErrorStatus(status) ? CRANBERRY : TEXT_DIM;
|
||||
const inputWidth = Math.min(56, width - 8);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
flexGrow={1}
|
||||
width={width}
|
||||
height={height}
|
||||
>
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
{frame.map((line, i) => (
|
||||
@@ -532,33 +516,10 @@ function SplashScreen({
|
||||
</Box>
|
||||
<Text color={TEXT_DIM}>your on-machine AI agent</Text>
|
||||
|
||||
{showInput ? (
|
||||
<Box flexDirection="column" alignItems="center" marginTop={2}>
|
||||
<Box width={inputWidth}>
|
||||
<Rule width={inputWidth} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={CRANBERRY} bold>
|
||||
{"❯ "}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
placeholder={INITIAL_GREETING}
|
||||
onChange={onInputChange}
|
||||
onSubmit={onInputSubmit}
|
||||
showCursor
|
||||
/>
|
||||
</Box>
|
||||
<Box width={inputWidth}>
|
||||
<Rule width={inputWidth} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box marginTop={2} gap={1}>
|
||||
{loading && <Spinner idx={spinIdx} />}
|
||||
<Text color={statusColor}>{status}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={2} gap={1}>
|
||||
{loading && <Spinner idx={spinIdx} />}
|
||||
<Text color={statusColor}>{status}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -988,27 +949,9 @@ function App({
|
||||
}
|
||||
});
|
||||
|
||||
const GUTTER = 2;
|
||||
const innerWidth = Math.max(termWidth - GUTTER * 2, 20);
|
||||
|
||||
if (bannerVisible) {
|
||||
return (
|
||||
<Box flexDirection="column" width={termWidth} height={termHeight}>
|
||||
<SplashScreen
|
||||
animFrame={gooseFrame}
|
||||
width={termWidth}
|
||||
height={termHeight}
|
||||
status={status}
|
||||
loading={loading}
|
||||
spinIdx={spinIdx}
|
||||
showInput={!loading && !initialPrompt}
|
||||
input={input}
|
||||
onInputChange={setInput}
|
||||
onInputSubmit={handleSubmit}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const PAD_X = 2;
|
||||
const PAD_Y = 1;
|
||||
const contentWidth = Math.max(termWidth - PAD_X * 2, 20);
|
||||
|
||||
const effectiveTurnIdx =
|
||||
viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx;
|
||||
@@ -1023,9 +966,9 @@ function App({
|
||||
toolCallsById: new Map(),
|
||||
};
|
||||
|
||||
const bodyLines = buildTurnBodyLines({
|
||||
const responseLines = buildTurnBodyLines({
|
||||
turn: currentTurn ?? emptyTurn,
|
||||
width: innerWidth,
|
||||
width: contentWidth,
|
||||
loading: isLatest && loading,
|
||||
status,
|
||||
spinIdx,
|
||||
@@ -1034,69 +977,79 @@ function App({
|
||||
toolCallsExpanded,
|
||||
});
|
||||
|
||||
const allBodyLines = isLatest
|
||||
? [
|
||||
...bodyLines,
|
||||
...queuedMessages.map((text, i) => (
|
||||
<QueuedMessage key={`q-${i}`} text={text} />
|
||||
)),
|
||||
]
|
||||
: bodyLines;
|
||||
const scrollLines: React.ReactNode[] = [];
|
||||
if (currentTurn) {
|
||||
scrollLines.push(<Text key="prompt-gap"> </Text>);
|
||||
scrollLines.push(<UserPrompt key="prompt" text={currentTurn.userText} />);
|
||||
scrollLines.push(...responseLines);
|
||||
}
|
||||
if (isLatest) {
|
||||
scrollLines.push(
|
||||
...queuedMessages.map((text, i) => (
|
||||
<QueuedMessage key={`q-${i}`} text={text} />
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
const showInputBar = !pendingPermission && !initialPrompt && !isViewingHistory;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={termWidth}
|
||||
height={termHeight}
|
||||
paddingX={GUTTER}
|
||||
paddingX={PAD_X}
|
||||
paddingY={PAD_Y}
|
||||
>
|
||||
<Header
|
||||
width={innerWidth}
|
||||
status={status}
|
||||
loading={loading}
|
||||
spinIdx={spinIdx}
|
||||
hasPendingPermission={!!pendingPermission}
|
||||
turnInfo={
|
||||
turns.length > 1
|
||||
? { current: effectiveTurnIdx + 1, total: turns.length }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{currentTurn ? (
|
||||
{bannerVisible ? (
|
||||
<SplashScreen
|
||||
animFrame={gooseFrame}
|
||||
width={contentWidth}
|
||||
status={status}
|
||||
loading={loading}
|
||||
spinIdx={spinIdx}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<UserPrompt text={currentTurn.userText} />
|
||||
|
||||
<Header
|
||||
width={contentWidth}
|
||||
status={status}
|
||||
loading={loading}
|
||||
spinIdx={spinIdx}
|
||||
hasPendingPermission={!!pendingPermission}
|
||||
turnInfo={
|
||||
turns.length > 1
|
||||
? { current: effectiveTurnIdx + 1, total: turns.length }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ScrollableBody
|
||||
lines={allBodyLines}
|
||||
width={innerWidth}
|
||||
lines={scrollLines}
|
||||
width={contentWidth}
|
||||
scrollOffset={scrollOffset}
|
||||
/>
|
||||
{isViewingHistory && (
|
||||
<Box flexDirection="column" width={contentWidth}>
|
||||
<Rule width={contentWidth} />
|
||||
<Box justifyContent="center" width={contentWidth}>
|
||||
<Text color={GOLD}>
|
||||
turn {effectiveTurnIdx + 1}/{turns.length}
|
||||
</Text>
|
||||
<Text color={TEXT_DIM}> — shift+↓ to return</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box flexDirection="column" flexGrow={1} />
|
||||
)}
|
||||
|
||||
{isViewingHistory && (
|
||||
<Box flexDirection="column" width={innerWidth}>
|
||||
<Rule width={innerWidth} />
|
||||
<Box justifyContent="center" width={innerWidth}>
|
||||
<Text color={GOLD}>
|
||||
turn {effectiveTurnIdx + 1}/{turns.length}
|
||||
</Text>
|
||||
<Text color={TEXT_DIM}> — shift+↓ to return</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isViewingHistory && !pendingPermission && !initialPrompt && (
|
||||
{showInputBar && (
|
||||
<InputBar
|
||||
width={innerWidth}
|
||||
width={contentWidth}
|
||||
input={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleSubmit}
|
||||
queued={queuedMessages.length > 0}
|
||||
scrollHint={turns.length > 1}
|
||||
scrollHint={!bannerVisible && turns.length > 1}
|
||||
placeholder={bannerVisible ? INITIAL_GREETING : undefined}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user