feat: onboarding UX for the TUI (#8513)
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { renderMarkdown } from "../markdown.js";
|
||||
import { renderToolCallLines } from "../toolcall.js";
|
||||
import type { ToolCallInfo } from "../toolcall.js";
|
||||
import type { ResponseItem } from "../types.js";
|
||||
import { CRANBERRY, TEXT_DIM, GOLD } from "../colors.js";
|
||||
import { Spinner } from "./Spinner.js";
|
||||
|
||||
export function emptyLine(key: string, width: number): React.ReactElement {
|
||||
return <Box key={key} width={width} height={1}><Text> </Text></Box>;
|
||||
}
|
||||
|
||||
export function renderUserPrompt(
|
||||
userText: string,
|
||||
width: number,
|
||||
turnId: string,
|
||||
collapsedUserPrompt: (text: string, width: number) => React.ReactElement
|
||||
): React.ReactElement[] {
|
||||
const constrainedWidth = Math.max(width - 4, 10);
|
||||
return [
|
||||
emptyLine(`u-gap-${turnId}`, width),
|
||||
<Box key={`u-prompt-${turnId}`} width={width} height={1}>
|
||||
<Text color={CRANBERRY} bold>{"❯ "}</Text>
|
||||
<Box width={constrainedWidth}>
|
||||
{collapsedUserPrompt(userText, constrainedWidth)}
|
||||
</Box>
|
||||
</Box>,
|
||||
];
|
||||
}
|
||||
|
||||
export function renderToolCallItem(
|
||||
item: ResponseItem & { itemType: "tool_call" },
|
||||
index: number,
|
||||
width: number,
|
||||
toolCallsExpanded: boolean,
|
||||
isFirst: boolean,
|
||||
hasToolCalls: boolean
|
||||
): React.ReactElement[] {
|
||||
const info: ToolCallInfo = {
|
||||
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,
|
||||
};
|
||||
|
||||
return [
|
||||
emptyLine(`tc-gap-${index}`, width),
|
||||
...renderToolCallLines(info, width, toolCallsExpanded, isFirst && hasToolCalls),
|
||||
];
|
||||
}
|
||||
|
||||
export function renderErrorItem(
|
||||
item: ResponseItem & { itemType: "error" },
|
||||
index: number,
|
||||
width: number
|
||||
): React.ReactElement[] {
|
||||
const lines: React.ReactElement[] = [
|
||||
emptyLine(`err-gap-${index}`, width),
|
||||
<Box key={`err-box-${index}`} width={width} height={1}>
|
||||
<Text color={CRANBERRY} bold>{"⚠ Error: "}</Text>
|
||||
</Box>,
|
||||
];
|
||||
|
||||
const errorLines = item.message.split("\n");
|
||||
errorLines.forEach((line, j) => {
|
||||
lines.push(
|
||||
<Box key={`err-${index}-${j}`} width={width} height={1}>
|
||||
<Box width={width}>
|
||||
<Text color={CRANBERRY} wrap="truncate">{line}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function renderContentItem(
|
||||
item: ResponseItem & { itemType: "content_chunk" },
|
||||
index: number,
|
||||
width: number
|
||||
): React.ReactElement[] {
|
||||
if (item.content.type !== "text" || !item.content.text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const constrainedWidth = Math.max(width - 2, 10);
|
||||
const mdLines = renderMarkdown(item.content.text, constrainedWidth);
|
||||
const lines: React.ReactElement[] = [emptyLine(`md-gap-${index}`, width)];
|
||||
|
||||
mdLines.forEach((mdLine, j) => {
|
||||
lines.push(
|
||||
<Box key={`md-${index}-${j}`} width={width} height={1}>
|
||||
<Box width={constrainedWidth}>
|
||||
<Text wrap="truncate">{mdLine}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function renderLoadingIndicator(
|
||||
status: string,
|
||||
spinIdx: number,
|
||||
width: number
|
||||
): React.ReactElement[] {
|
||||
return [
|
||||
emptyLine("ld-gap", width),
|
||||
<Box key="ld" width={width} height={1}>
|
||||
<Spinner idx={spinIdx} />
|
||||
<Text color={TEXT_DIM} italic> {status}</Text>
|
||||
</Box>,
|
||||
];
|
||||
}
|
||||
|
||||
export function renderQueuedMessages(
|
||||
queuedMessages: string[],
|
||||
width: number
|
||||
): React.ReactElement[] {
|
||||
const messageWidth = Math.max(width - 20, 10);
|
||||
return queuedMessages.map((message, i) => (
|
||||
<Box key={`q-${i}`} width={width} height={1}>
|
||||
<Text color={TEXT_DIM}>{"❯ "}</Text>
|
||||
<Box width={messageWidth}>
|
||||
<Text wrap="truncate-end" color={TEXT_DIM}>{message}</Text>
|
||||
</Box>
|
||||
<Text color={GOLD} dimColor> (queued)</Text>
|
||||
</Box>
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { Box, Text, useInput, useStdout } from "ink";
|
||||
import { CRANBERRY, TEXT_PRIMARY, TEXT_DIM } from "../colors.js";
|
||||
|
||||
interface ErrorScreenProps {
|
||||
errorMsg: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export const ErrorScreen = React.memo(function ErrorScreen({ errorMsg, onRetry }: ErrorScreenProps) {
|
||||
const { stdout } = useStdout();
|
||||
const columns = stdout?.columns ?? 80;
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.return || key.escape) {
|
||||
onRetry();
|
||||
}
|
||||
});
|
||||
|
||||
const maxWidth = Math.min(columns - 4, 80);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={2} width={maxWidth}>
|
||||
<Text color={CRANBERRY} bold>✗ Setup error</Text>
|
||||
{errorMsg && (
|
||||
<Box width={maxWidth - 4}>
|
||||
<Text color={TEXT_PRIMARY} wrap="wrap">{errorMsg}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={TEXT_DIM}>press enter to retry</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { Spinner } from "./Spinner.js";
|
||||
import { Rule } from "./Rule.js";
|
||||
import { TEAL, CRANBERRY, TEXT_PRIMARY, TEXT_DIM, RULE_COLOR } from "../colors.js";
|
||||
import { isErrorStatus } from "../utils.js";
|
||||
|
||||
interface HeaderProps {
|
||||
width: number;
|
||||
status: string;
|
||||
loading: boolean;
|
||||
spinIdx: number;
|
||||
hasPendingPermission: boolean;
|
||||
turnInfo?: { current: number; total: number };
|
||||
}
|
||||
|
||||
export const Header = React.memo(function Header({
|
||||
width,
|
||||
status,
|
||||
loading,
|
||||
spinIdx,
|
||||
hasPendingPermission,
|
||||
turnInfo,
|
||||
}: HeaderProps) {
|
||||
const statusColor =
|
||||
status === "ready" ? TEAL : isErrorStatus(status) ? CRANBERRY : TEXT_DIM;
|
||||
|
||||
const constrainedWidth = Math.max(width, 20);
|
||||
const leftSideWidth = Math.min(Math.floor(constrainedWidth * 0.7), constrainedWidth - 15);
|
||||
const rightSideWidth = constrainedWidth - leftSideWidth;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={constrainedWidth} flexShrink={0}>
|
||||
<Box justifyContent="space-between" width={constrainedWidth}>
|
||||
<Box width={leftSideWidth}>
|
||||
<Text color={TEXT_PRIMARY} bold>goose</Text>
|
||||
<Text color={RULE_COLOR}> · </Text>
|
||||
<Box width={Math.max(leftSideWidth - 10, 5)}>
|
||||
<Text color={statusColor} wrap="truncate-end">{status}</Text>
|
||||
</Box>
|
||||
{loading && !hasPendingPermission && (
|
||||
<Text> <Spinner idx={spinIdx} /></Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box width={rightSideWidth} justifyContent="flex-end">
|
||||
{turnInfo && turnInfo.total > 1 && (
|
||||
<Text color={TEXT_DIM}>
|
||||
{turnInfo.current}/{turnInfo.total}{" "}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={TEXT_DIM}>^C exit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Rule width={constrainedWidth} />
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import { Text } from "ink";
|
||||
import { RULE_COLOR } from "../colors.js";
|
||||
|
||||
interface RuleProps {
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const Rule = React.memo(function Rule({ width }: RuleProps) {
|
||||
const ruleWidth = Math.max(width, 1);
|
||||
return <Text color={RULE_COLOR}>{"─".repeat(ruleWidth)}</Text>;
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { Text } from "ink";
|
||||
import { CRANBERRY } from "../colors.js";
|
||||
|
||||
const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"];
|
||||
|
||||
interface SpinnerProps {
|
||||
idx: number;
|
||||
}
|
||||
|
||||
export const Spinner = React.memo(function Spinner({ idx }: SpinnerProps) {
|
||||
return (
|
||||
<Text color={CRANBERRY}>
|
||||
{SPINNER_FRAMES[idx % SPINNER_FRAMES.length]}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
export { SPINNER_FRAMES };
|
||||
@@ -0,0 +1,82 @@
|
||||
// UI Layout Constants
|
||||
export const PASTE_THRESHOLD = 80;
|
||||
export const PASTE_PREVIEW_LEN = 40;
|
||||
export const INPUT_MAX_ROWS = 8;
|
||||
export const SENT_PREVIEW_LEN = 60;
|
||||
|
||||
export const GOOSE_FRAMES = [
|
||||
[
|
||||
" ,_",
|
||||
" (o >",
|
||||
" //\\",
|
||||
" \\\\ \\",
|
||||
" \\\\_/",
|
||||
" | |",
|
||||
" ^ ^",
|
||||
],
|
||||
[
|
||||
" ,_",
|
||||
" (o >",
|
||||
" //\\",
|
||||
" \\\\ \\",
|
||||
" \\\\_/",
|
||||
" / |",
|
||||
" ^ ^",
|
||||
],
|
||||
[
|
||||
" ,_",
|
||||
" (o >",
|
||||
" //\\",
|
||||
" \\\\ \\",
|
||||
" \\\\_/",
|
||||
" | |",
|
||||
" ^ ^",
|
||||
],
|
||||
[
|
||||
" ,_",
|
||||
" (o >",
|
||||
" //\\",
|
||||
" \\\\ \\",
|
||||
" \\\\_/",
|
||||
" | \\",
|
||||
" ^ ^",
|
||||
],
|
||||
];
|
||||
|
||||
export const GREETING_MESSAGES = [
|
||||
"What would you like to work on?",
|
||||
"Ready to build something amazing?",
|
||||
"What would you like to explore?",
|
||||
"What's on your mind?",
|
||||
"What shall we create today?",
|
||||
"What project needs attention?",
|
||||
"What would you like to tackle?",
|
||||
"What needs to be done?",
|
||||
"What's the plan for today?",
|
||||
"Ready to create something great?",
|
||||
"What can be built today?",
|
||||
"What's the next challenge?",
|
||||
"What progress can be made?",
|
||||
"What would you like to accomplish?",
|
||||
"What task awaits?",
|
||||
"What's the mission today?",
|
||||
"What can be achieved?",
|
||||
"What project is ready to begin?",
|
||||
];
|
||||
|
||||
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",
|
||||
};
|
||||
@@ -0,0 +1,675 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Box, Text, useInput, useStdout } from "ink";
|
||||
import { TextInput, PasswordInput } from '@inkjs/ui';
|
||||
import type { GooseClient, ProviderDetailEntry } from "@aaif/goose-acp";
|
||||
import {
|
||||
CRANBERRY,
|
||||
TEAL,
|
||||
GOLD,
|
||||
TEXT_PRIMARY,
|
||||
TEXT_SECONDARY,
|
||||
TEXT_DIM,
|
||||
RULE_COLOR,
|
||||
} from "./colors.js";
|
||||
import { Spinner, SPINNER_FRAMES } from "./components/Spinner.js";
|
||||
import { ErrorScreen } from "./components/ErrorScreen.js";
|
||||
|
||||
type Phase =
|
||||
| "loading"
|
||||
| "select_provider"
|
||||
| "configure"
|
||||
| "saving"
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
interface OnboardingProps {
|
||||
client: GooseClient;
|
||||
width: number;
|
||||
height: number;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
interface ProviderSelectorProps {
|
||||
providers: ProviderDetailEntry[];
|
||||
height: number;
|
||||
onSelect: (provider: ProviderDetailEntry) => void;
|
||||
}
|
||||
|
||||
const ProviderSelector = React.memo(function ProviderSelector({ providers, height, onSelect }: ProviderSelectorProps) {
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const { stdout } = useStdout();
|
||||
const columns = stdout?.columns ?? 80;
|
||||
|
||||
const filtered = (() => {
|
||||
if (!searchQuery) return providers;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return providers.filter(
|
||||
(p) =>
|
||||
p.displayName.toLowerCase().includes(q) ||
|
||||
p.name.toLowerCase().includes(q),
|
||||
);
|
||||
})();
|
||||
|
||||
// Calculate grid dimensions based on terminal size
|
||||
const cardWidth = 36; // Width of each provider card
|
||||
const cardHeight = 8; // Height of each provider card
|
||||
const minSpacing = 2; // Minimum spacing between cards
|
||||
|
||||
const availableWidth = columns - 4; // Leave margins
|
||||
// Header: marginTop(1) + title+mb(2) + subtitle+mb(3) + searchbar+mb(5) = 11
|
||||
// Footer: mt(2) + text(1) = 3, plus potential scroll indicators(2)
|
||||
const availableHeight = height - 16;
|
||||
|
||||
const cardsPerRow = Math.max(1, Math.floor(availableWidth / (cardWidth + minSpacing)));
|
||||
// Cap horizontal gap so it doesn't grow unbounded on wide terminals
|
||||
const columnSpacing = Math.min(minSpacing, Math.floor((availableWidth - (cardsPerRow * cardWidth)) / Math.max(1, cardsPerRow - 1)));
|
||||
// Terminal chars are ~2× taller than wide, so 1 row ≈ 2 columns visually
|
||||
const rowSpacing = 1;
|
||||
const rowsVisible = Math.max(1, Math.floor((availableHeight + rowSpacing) / (cardHeight + rowSpacing)));
|
||||
|
||||
const totalRows = Math.ceil(filtered.length / cardsPerRow);
|
||||
const selectedRow = Math.floor(selectedIdx / cardsPerRow);
|
||||
// Calculate scroll offset for rows
|
||||
const [scrollRow, setScrollRow] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRow < scrollRow) {
|
||||
setScrollRow(selectedRow);
|
||||
} else if (selectedRow >= scrollRow + rowsVisible) {
|
||||
setScrollRow(selectedRow - rowsVisible + 1);
|
||||
}
|
||||
}, [selectedRow, rowsVisible, scrollRow]);
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.escape) {
|
||||
if (searchQuery) {
|
||||
setSearchQuery("");
|
||||
setSelectedIdx(0);
|
||||
setScrollRow(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
// Only allow typing/backspace when no results match; skip navigation
|
||||
if (key.backspace || key.delete) {
|
||||
setSearchQuery((q) => q.slice(0, -1));
|
||||
setSelectedIdx(0);
|
||||
setScrollRow(0);
|
||||
return;
|
||||
}
|
||||
if (ch && ch.length === 1 && !key.ctrl && !key.meta) {
|
||||
setSearchQuery((q) => q + ch);
|
||||
setSelectedIdx(0);
|
||||
setScrollRow(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.upArrow) {
|
||||
const newIdx = Math.max(selectedIdx - cardsPerRow, 0);
|
||||
setSelectedIdx(newIdx);
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
const newIdx = Math.min(selectedIdx + cardsPerRow, filtered.length - 1);
|
||||
setSelectedIdx(newIdx);
|
||||
return;
|
||||
}
|
||||
if (key.leftArrow) {
|
||||
const newIdx = Math.max(selectedIdx - 1, 0);
|
||||
setSelectedIdx(newIdx);
|
||||
return;
|
||||
}
|
||||
if (key.rightArrow) {
|
||||
const newIdx = Math.min(selectedIdx + 1, filtered.length - 1);
|
||||
setSelectedIdx(newIdx);
|
||||
return;
|
||||
}
|
||||
if (key.return) {
|
||||
const p = filtered[selectedIdx];
|
||||
if (p) onSelect(p);
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setSearchQuery((q) => q.slice(0, -1));
|
||||
setSelectedIdx(0);
|
||||
setScrollRow(0);
|
||||
return;
|
||||
}
|
||||
if (ch && ch.length === 1 && !key.ctrl && !key.meta) {
|
||||
setSearchQuery((q) => q + ch);
|
||||
setSelectedIdx(0);
|
||||
setScrollRow(0);
|
||||
}
|
||||
});
|
||||
|
||||
// Create grid of provider cards
|
||||
const renderProviderCard = (provider: ProviderDetailEntry, _index: number, isSelected: boolean) => {
|
||||
const cardBorder = isSelected ? "double" : "single";
|
||||
const cardBorderColor = isSelected ? GOLD : RULE_COLOR;
|
||||
const textColor = isSelected ? TEXT_PRIMARY : TEXT_SECONDARY;
|
||||
|
||||
// Calculate actual content width: cardWidth - borders (2) - paddingX (2)
|
||||
const contentWidth = cardWidth - 4;
|
||||
// Width for title (leave space for icons: 2-3 chars)
|
||||
const titleWidth = contentWidth - 3;
|
||||
// Available lines for description: cardHeight - borders (2) - title (1) - margin (1) - name (1) - margin (1)
|
||||
const descriptionMaxLines = Math.max(1, cardHeight - 6);
|
||||
const descriptionMaxChars = descriptionMaxLines * contentWidth;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={provider.name}
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
borderStyle={cardBorder}
|
||||
borderColor={cardBorderColor}
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Box justifyContent="space-between" alignItems="center">
|
||||
<Box width={titleWidth} flexShrink={1}>
|
||||
<Text color={textColor} bold={isSelected} wrap="truncate">
|
||||
{provider.displayName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0}>
|
||||
{provider.providerType === "Preferred" && (
|
||||
<Text color={TEAL}>★</Text>
|
||||
)}
|
||||
{provider.isConfigured && (
|
||||
<Text color={TEAL}>✓</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column" flexGrow={1}>
|
||||
<Box width={contentWidth}>
|
||||
<Text color={TEXT_DIM} wrap="truncate">
|
||||
{provider.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{provider.description && (
|
||||
<Box marginTop={1} width={contentWidth}>
|
||||
<Text color={TEXT_DIM} wrap="truncate" dimColor>
|
||||
{provider.description.length > descriptionMaxChars
|
||||
? provider.description.slice(0, descriptionMaxChars - 1) + "…"
|
||||
: provider.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const visibleRows = [];
|
||||
for (let row = scrollRow; row < Math.min(scrollRow + rowsVisible, totalRows); row++) {
|
||||
const rowProviders = [];
|
||||
for (let col = 0; col < cardsPerRow; col++) {
|
||||
const index = row * cardsPerRow + col;
|
||||
if (index < filtered.length) {
|
||||
const isSelected = index === selectedIdx;
|
||||
rowProviders.push(renderProviderCard(filtered[index], index, isSelected));
|
||||
}
|
||||
}
|
||||
|
||||
if (rowProviders.length > 0) {
|
||||
const isLastVisibleRow = row === Math.min(scrollRow + rowsVisible, totalRows) - 1;
|
||||
visibleRows.push(
|
||||
<Box key={row} gap={columnSpacing} marginBottom={isLastVisibleRow ? 0 : rowSpacing}>
|
||||
{rowProviders}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
|
||||
{/* Header */}
|
||||
<Box marginTop={1} />
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text color={TEXT_PRIMARY} bold>
|
||||
◆ Welcome to goose ◆
|
||||
</Text>
|
||||
</Box>
|
||||
<Box justifyContent="center" marginBottom={2}>
|
||||
<Text color={TEXT_DIM}>
|
||||
Connect an AI model provider to get started
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Search Bar */}
|
||||
<Box justifyContent="center" marginBottom={2}>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={RULE_COLOR}
|
||||
paddingX={2}
|
||||
width={Math.min(60, availableWidth)}
|
||||
>
|
||||
<Text color={CRANBERRY} bold>
|
||||
{"❯ "}
|
||||
</Text>
|
||||
<Text color={searchQuery ? TEXT_PRIMARY : TEXT_DIM} wrap="truncate">
|
||||
{searchQuery || "search providers…"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Provider Grid */}
|
||||
<Box flexDirection="column" flexGrow={1} justifyContent="flex-start">
|
||||
{filtered.length === 0 ? (
|
||||
<Box justifyContent="center" alignItems="center" height={10}>
|
||||
<Text color={TEXT_DIM}>No matching providers found</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{scrollRow > 0 && (
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text color={TEXT_DIM}>▲ {scrollRow * cardsPerRow} more above</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box justifyContent="center">
|
||||
<Box flexDirection="column">
|
||||
{visibleRows}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{scrollRow + rowsVisible < totalRows && (
|
||||
<Box justifyContent="center" marginTop={1}>
|
||||
<Text color={TEXT_DIM}>
|
||||
▼ {filtered.length - (scrollRow + rowsVisible) * cardsPerRow} more below
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box justifyContent="center" marginTop={2}>
|
||||
<Text color={TEXT_DIM}>
|
||||
↑↓←→ navigate · enter select · type to search · esc clear
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
interface ProviderConfiguratorProps {
|
||||
provider: ProviderDetailEntry;
|
||||
height: number;
|
||||
onComplete: (values: Record<string, string>) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const ProviderConfigurator = React.memo(function ProviderConfigurator({ provider, height, onComplete, onBack }: ProviderConfiguratorProps) {
|
||||
const [keyValues, setKeyValues] = useState<Record<string, string>>({});
|
||||
const [activeKeyIdx, setActiveKeyIdx] = useState(0);
|
||||
const [showMasked, setShowMasked] = useState<Record<string, boolean>>({});
|
||||
const [inputKey, setInputKey] = useState(0);
|
||||
const { stdout } = useStdout();
|
||||
const columns = stdout?.columns ?? 80;
|
||||
|
||||
const keys = provider.configKeys.filter(
|
||||
(k) => k.required && !k.oauthFlow && !k.deviceCodeFlow,
|
||||
);
|
||||
const currentKey = keys[activeKeyIdx];
|
||||
|
||||
useInput((_ch, key) => {
|
||||
if (!currentKey) return;
|
||||
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
if (key.tab && currentKey.secret) {
|
||||
setShowMasked((prev) => ({
|
||||
...prev,
|
||||
[currentKey.name]: !prev[currentKey.name],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (value: string) => {
|
||||
if (!currentKey) return;
|
||||
const effective = value.trim() || currentVal.trim();
|
||||
if (!effective) return;
|
||||
const newValues = { ...keyValues, [currentKey.name]: effective };
|
||||
setKeyValues(newValues);
|
||||
if (activeKeyIdx < keys.length - 1) {
|
||||
setActiveKeyIdx(activeKeyIdx + 1);
|
||||
setShowMasked({});
|
||||
setInputKey(prev => prev + 1); // Force new input component
|
||||
} else {
|
||||
onComplete(newValues);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (!currentKey) return;
|
||||
setKeyValues((prev) => ({
|
||||
...prev,
|
||||
[currentKey.name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const currentVal = keyValues[currentKey?.name ?? ""] ?? "";
|
||||
const masked = currentKey?.secret && !showMasked[currentKey?.name ?? ""];
|
||||
const maxWidth = Math.min(columns - 4, 80);
|
||||
|
||||
// Calculate content height for proper centering
|
||||
const headerHeight = 1 + (provider.description ? 2 : 0) + 1; // title + description + spacer
|
||||
const keysHeight = keys.length; // one line per key
|
||||
const inputHeight = currentKey ? 3 : 0; // input + help text + spacing
|
||||
const setupStepsHeight = provider.setupSteps?.length ? provider.setupSteps.length + 1 : 0;
|
||||
const contentHeight = headerHeight + keysHeight + inputHeight + setupStepsHeight;
|
||||
const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height={height} alignItems="center" width={columns}>
|
||||
{topPad > 0 && <Box height={topPad} />}
|
||||
<Box flexDirection="column" width={maxWidth} paddingX={2}>
|
||||
{/* Header */}
|
||||
<Text color={TEXT_PRIMARY} bold>
|
||||
Configure {provider.displayName}
|
||||
</Text>
|
||||
{provider.description && (
|
||||
<Box marginTop={1} width={maxWidth - 4}>
|
||||
<Text color={TEXT_DIM} wrap="wrap">{provider.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1} />
|
||||
|
||||
{/* Configuration Keys */}
|
||||
{keys.map((k, i) => (
|
||||
<Box key={k.name} marginBottom={1}>
|
||||
<Text color={i === activeKeyIdx ? GOLD : TEXT_DIM}>
|
||||
{i < activeKeyIdx ? "✓ " : i === activeKeyIdx ? "▸ " : " "}
|
||||
</Text>
|
||||
<Text
|
||||
color={i === activeKeyIdx ? TEXT_PRIMARY : TEXT_DIM}
|
||||
bold={i === activeKeyIdx}
|
||||
>
|
||||
{k.name}
|
||||
</Text>
|
||||
{i < activeKeyIdx && (
|
||||
<Text color={TEAL}> ••••••</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* Current Input Field */}
|
||||
{currentKey && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box>
|
||||
<Text color={CRANBERRY} bold>
|
||||
{"❯ "}
|
||||
</Text>
|
||||
{masked ? (
|
||||
<PasswordInput
|
||||
key={`password-${currentKey.name}-${inputKey}`}
|
||||
placeholder={currentKey.name}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
key={`text-${currentKey.name}-${inputKey}`}
|
||||
defaultValue={currentVal}
|
||||
placeholder={currentKey.name}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Box width={maxWidth - 4}>
|
||||
<Text color={TEXT_DIM} wrap="wrap">
|
||||
enter to confirm · esc to go back
|
||||
{currentKey.secret && (
|
||||
<>
|
||||
{" · tab to "}
|
||||
{masked ? "reveal" : "hide"}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Setup Steps */}
|
||||
{provider.setupSteps &&
|
||||
provider.setupSteps.length > 0 && (
|
||||
<Box marginTop={2} flexDirection="column">
|
||||
<Text color={TEXT_DIM}>Setup steps:</Text>
|
||||
{provider.setupSteps.map((step, i) => (
|
||||
<Box key={i} width={maxWidth - 4} marginTop={1}>
|
||||
<Text color={TEXT_DIM} wrap="wrap">
|
||||
{i + 1}. {step}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
interface SuccessScreenProps {
|
||||
provider: ProviderDetailEntry | null;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const SuccessScreen = React.memo(function SuccessScreen({ provider, height }: SuccessScreenProps) {
|
||||
const { stdout } = useStdout();
|
||||
const columns = stdout?.columns ?? 80;
|
||||
|
||||
// Calculate content height for proper centering
|
||||
const contentHeight = 1 + (provider ? 1 : 0); // success message + provider text
|
||||
const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
width={columns}
|
||||
height={height}
|
||||
overflow="hidden"
|
||||
>
|
||||
{topPad > 0 && <Box height={topPad} />}
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
<Text color={TEAL} bold>
|
||||
✓ Provider configured
|
||||
</Text>
|
||||
{provider && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={TEXT_SECONDARY}>
|
||||
Connected to {provider.displayName}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
export default function Onboarding({
|
||||
client,
|
||||
width,
|
||||
height,
|
||||
onComplete,
|
||||
}: OnboardingProps) {
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
const [providers, setProviders] = useState<ProviderDetailEntry[]>([]);
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
useState<ProviderDetailEntry | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [spinIdx, setSpinIdx] = useState(0);
|
||||
const [fetchKey, setFetchKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(
|
||||
() => setSpinIdx((i) => (i + 1) % SPINNER_FRAMES.length),
|
||||
300,
|
||||
);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await client.goose.GooseProvidersDetails({});
|
||||
const sorted = [...resp.providers].sort((a, b) => {
|
||||
const aP = a.providerType === "Preferred" ? 0 : 1;
|
||||
const bP = b.providerType === "Preferred" ? 0 : 1;
|
||||
if (aP !== bP) return aP - bP;
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
});
|
||||
setProviders(sorted);
|
||||
setPhase("select_provider");
|
||||
} catch (e: unknown) {
|
||||
setErrorMsg(e instanceof Error ? e.message : JSON.stringify(e));
|
||||
setPhase("error");
|
||||
}
|
||||
})();
|
||||
}, [client, fetchKey]);
|
||||
|
||||
const saveProvider = useCallback(
|
||||
async (provider: ProviderDetailEntry, values: Record<string, string>) => {
|
||||
setPhase("saving");
|
||||
try {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const configKey = provider.configKeys.find((k) => k.name === key);
|
||||
if (configKey?.secret) {
|
||||
await client.goose.GooseSecretUpsert({ key, value });
|
||||
} else {
|
||||
await client.goose.GooseConfigUpsert({ key, value });
|
||||
}
|
||||
}
|
||||
await client.goose.GooseConfigUpsert({
|
||||
key: "GOOSE_PROVIDER",
|
||||
value: provider.name,
|
||||
});
|
||||
await client.goose.GooseConfigUpsert({
|
||||
key: "GOOSE_MODEL",
|
||||
value: provider.defaultModel,
|
||||
});
|
||||
setPhase("success");
|
||||
setTimeout(onComplete, 1000);
|
||||
} catch (e: unknown) {
|
||||
setErrorMsg(e instanceof Error ? e.message : JSON.stringify(e));
|
||||
setPhase("error");
|
||||
}
|
||||
},
|
||||
[client, onComplete],
|
||||
);
|
||||
|
||||
const confirmProvider = useCallback(
|
||||
(provider: ProviderDetailEntry) => {
|
||||
const keys = provider.configKeys.filter(
|
||||
(k) => k.required && !k.oauthFlow && !k.deviceCodeFlow,
|
||||
);
|
||||
if (keys.length === 0) {
|
||||
saveProvider(provider, {});
|
||||
return;
|
||||
}
|
||||
setSelectedProvider(provider);
|
||||
setPhase("configure");
|
||||
},
|
||||
[saveProvider],
|
||||
);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setErrorMsg("");
|
||||
setFetchKey((k) => k + 1);
|
||||
setPhase("loading");
|
||||
}, []);
|
||||
|
||||
if (phase === "loading") {
|
||||
const contentHeight = 3; // spinner + text + spacing
|
||||
const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
width={width}
|
||||
height={height}
|
||||
overflow="hidden"
|
||||
>
|
||||
{topPad > 0 && <Box height={topPad} />}
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
<Spinner idx={spinIdx} />
|
||||
<Box marginTop={1}>
|
||||
<Text color={TEXT_DIM}>loading providers…</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "error") {
|
||||
return (
|
||||
<Box flexDirection="column" height={height} alignItems="center" width={width}>
|
||||
<ErrorScreen errorMsg={errorMsg} onRetry={handleRetry} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "saving") {
|
||||
const contentHeight = 3; // spinner + text + spacing
|
||||
const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
width={width}
|
||||
height={height}
|
||||
overflow="hidden"
|
||||
>
|
||||
{topPad > 0 && <Box height={topPad} />}
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
<Spinner idx={spinIdx} />
|
||||
<Box marginTop={1}>
|
||||
<Text color={TEXT_DIM}>saving configuration…</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "success") {
|
||||
return (
|
||||
<SuccessScreen provider={selectedProvider} height={height} />
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "configure" && selectedProvider) {
|
||||
return (
|
||||
<ProviderConfigurator
|
||||
provider={selectedProvider}
|
||||
height={height}
|
||||
onComplete={(values) => saveProvider(selectedProvider, values)}
|
||||
onBack={() => {
|
||||
setSelectedProvider(null);
|
||||
setPhase("select_provider");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderSelector
|
||||
providers={providers}
|
||||
height={height}
|
||||
onSelect={confirmProvider}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -41,8 +41,9 @@ const STATUS_INDICATORS: Record<string, { icon: string; color: string }> = {
|
||||
};
|
||||
|
||||
function truncateLine(line: string, maxWidth: number): string {
|
||||
if (line.length <= maxWidth) return line;
|
||||
return maxWidth > 1 ? line.slice(0, maxWidth - 1) + "…" : line.slice(0, maxWidth);
|
||||
const safeMaxWidth = Math.max(maxWidth, 1);
|
||||
if (line.length <= safeMaxWidth) return line;
|
||||
return safeMaxWidth > 1 ? line.slice(0, safeMaxWidth - 1) + "…" : line.slice(0, safeMaxWidth);
|
||||
}
|
||||
|
||||
function formatJsonLines(value: unknown, maxWidth: number): string[] {
|
||||
@@ -92,22 +93,23 @@ export function renderToolCallLines(
|
||||
const borderColor = info.status === "failed" ? CRANBERRY : CEDAR;
|
||||
const dimBorder = info.status !== "failed";
|
||||
|
||||
const innerWidth = Math.max(width - 4, 10);
|
||||
const indentedWidth = Math.max(innerWidth - 2, 8);
|
||||
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 hRule = "─".repeat(Math.max(width - 2, 0));
|
||||
const hRule = "─".repeat(Math.max(safeWidth - 2, 0));
|
||||
lines.push(
|
||||
<Box key={`${k}-t`} width={width} height={1}>
|
||||
<Box key={`${k}-t`} width={safeWidth} height={1}>
|
||||
<Text color={borderColor} dimColor={dimBorder}>╭{hRule}╮</Text>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
const row = (key: string, content: React.ReactNode) => {
|
||||
lines.push(
|
||||
<Box key={key} width={width} height={1}>
|
||||
<Box key={key} width={safeWidth} height={1}>
|
||||
<Text color={borderColor} dimColor={dimBorder}>│ </Text>
|
||||
<Box width={innerWidth} height={1}>
|
||||
{content}
|
||||
@@ -166,7 +168,7 @@ export function renderToolCallLines(
|
||||
}
|
||||
|
||||
lines.push(
|
||||
<Box key={`${k}-b`} width={width} height={1}>
|
||||
<Box key={`${k}-b`} width={safeWidth} height={1}>
|
||||
<Text color={borderColor} dimColor={dimBorder}>╰{hRule}╯</Text>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
+257
-360
@@ -19,195 +19,33 @@ import type {
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { ndJsonStream } from "@agentclientprotocol/sdk";
|
||||
import { GooseClient } from "@aaif/goose-acp";
|
||||
import { renderMarkdown } from "./markdown.js";
|
||||
import { renderToolCallLines } from "./toolcall.js";
|
||||
import type { ToolCallInfo } from "./toolcall.js";
|
||||
import Onboarding from "./onboarding.js";
|
||||
import type { PendingPermission, ResponseItem, Turn } from "./types.js";
|
||||
import {
|
||||
emptyLine,
|
||||
renderUserPrompt,
|
||||
renderToolCallItem,
|
||||
renderErrorItem,
|
||||
renderContentItem,
|
||||
renderLoadingIndicator,
|
||||
renderQueuedMessages,
|
||||
} from "./components/ContentRenderers.js";
|
||||
import { Header } from "./components/Header.js";
|
||||
import { Rule } from "./components/Rule.js";
|
||||
import { isErrorStatus, formatError } from "./utils.js";
|
||||
import { CRANBERRY, TEAL, GOLD, TEXT_PRIMARY, TEXT_SECONDARY, TEXT_DIM, RULE_COLOR } from "./colors.js";
|
||||
import { Spinner, SPINNER_FRAMES } from "./components/Spinner.js";
|
||||
import {
|
||||
PASTE_THRESHOLD,
|
||||
INPUT_MAX_ROWS,
|
||||
SENT_PREVIEW_LEN,
|
||||
GOOSE_FRAMES,
|
||||
INITIAL_GREETING,
|
||||
PERMISSION_LABELS,
|
||||
PERMISSION_KEYS,
|
||||
} from "./constants.js";
|
||||
|
||||
interface PendingPermission {
|
||||
toolTitle: string;
|
||||
options: Array<{ optionId: string; name: string; kind: string }>;
|
||||
resolve: (response: RequestPermissionResponse) => void;
|
||||
}
|
||||
|
||||
type ResponseItem =
|
||||
| (ContentChunk & { itemType: "content_chunk" })
|
||||
| (ToolCall & { itemType: "tool_call" });
|
||||
|
||||
interface Turn {
|
||||
userText: string;
|
||||
responseItems: ResponseItem[];
|
||||
toolCallsById: Map<string, number>;
|
||||
}
|
||||
|
||||
function isErrorStatus(status: string): boolean {
|
||||
return status.startsWith("error") || status.startsWith("failed");
|
||||
}
|
||||
|
||||
const GOOSE_FRAMES = [
|
||||
[
|
||||
" ,_",
|
||||
" (o >",
|
||||
" //\\",
|
||||
" \\\\ \\",
|
||||
" \\\\_/",
|
||||
" | |",
|
||||
" ^ ^",
|
||||
],
|
||||
[
|
||||
" ,_",
|
||||
" (o >",
|
||||
" //\\",
|
||||
" \\\\ \\",
|
||||
" \\\\_/",
|
||||
" / |",
|
||||
" ^ ^",
|
||||
],
|
||||
[
|
||||
" ,_",
|
||||
" (o >",
|
||||
" //\\",
|
||||
" \\\\ \\",
|
||||
" \\\\_/",
|
||||
" | |",
|
||||
" ^ ^",
|
||||
],
|
||||
[
|
||||
" ,_",
|
||||
" (o >",
|
||||
" //\\",
|
||||
" \\\\ \\",
|
||||
" \\\\_/",
|
||||
" | \\",
|
||||
" ^ ^",
|
||||
],
|
||||
];
|
||||
|
||||
const GREETING_MESSAGES = [
|
||||
"What would you like to work on?",
|
||||
"Ready to build something amazing?",
|
||||
"What would you like to explore?",
|
||||
"What's on your mind?",
|
||||
"What shall we create today?",
|
||||
"What project needs attention?",
|
||||
"What would you like to tackle?",
|
||||
"What needs to be done?",
|
||||
"What's the plan for today?",
|
||||
"Ready to create something great?",
|
||||
"What can be built today?",
|
||||
"What's the next challenge?",
|
||||
"What progress can be made?",
|
||||
"What would you like to accomplish?",
|
||||
"What task awaits?",
|
||||
"What's the mission today?",
|
||||
"What can be achieved?",
|
||||
"What project is ready to begin?",
|
||||
];
|
||||
|
||||
const INITIAL_GREETING =
|
||||
GREETING_MESSAGES[Math.floor(Math.random() * GREETING_MESSAGES.length)]!;
|
||||
|
||||
const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"];
|
||||
|
||||
const PERMISSION_LABELS: Record<string, string> = {
|
||||
allow_once: "Allow once",
|
||||
allow_always: "Always allow",
|
||||
reject_once: "Reject once",
|
||||
reject_always: "Always reject",
|
||||
};
|
||||
|
||||
const PERMISSION_KEYS: Record<string, string> = {
|
||||
allow_once: "y",
|
||||
allow_always: "a",
|
||||
reject_once: "n",
|
||||
reject_always: "N",
|
||||
};
|
||||
|
||||
function Rule({ width }: { width: number }) {
|
||||
return <Text color={RULE_COLOR}>{"─".repeat(Math.max(width, 1))}</Text>;
|
||||
}
|
||||
|
||||
function Spinner({ idx }: { idx: number }) {
|
||||
return (
|
||||
<Text color={CRANBERRY}>
|
||||
{SPINNER_FRAMES[idx % SPINNER_FRAMES.length]}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({
|
||||
width,
|
||||
status,
|
||||
loading,
|
||||
spinIdx,
|
||||
hasPendingPermission,
|
||||
turnInfo,
|
||||
}: {
|
||||
width: number;
|
||||
status: string;
|
||||
loading: boolean;
|
||||
spinIdx: number;
|
||||
hasPendingPermission: boolean;
|
||||
turnInfo?: { current: number; total: number };
|
||||
}) {
|
||||
const statusColor =
|
||||
status === "ready" ? TEAL : isErrorStatus(status) ? CRANBERRY : TEXT_DIM;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width} flexShrink={0}>
|
||||
<Box justifyContent="space-between" width={width}>
|
||||
<Box>
|
||||
<Text color={TEXT_PRIMARY} bold>goose</Text>
|
||||
<Text color={RULE_COLOR}> · </Text>
|
||||
<Text color={statusColor}>{status}</Text>
|
||||
{loading && !hasPendingPermission && (
|
||||
<Text> <Spinner idx={spinIdx} /></Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
{turnInfo && turnInfo.total > 1 && (
|
||||
<Text color={TEXT_DIM}>
|
||||
{turnInfo.current}/{turnInfo.total}{" "}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={TEXT_DIM}>^C exit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Rule width={width} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const PASTE_THRESHOLD = 80;
|
||||
const PASTE_PREVIEW_LEN = 40;
|
||||
const INPUT_MAX_ROWS = 8;
|
||||
const SENT_PREVIEW_LEN = 60;
|
||||
|
||||
function collapseForDisplay(text: string, availableWidth = PASTE_PREVIEW_LEN): string {
|
||||
const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (flat.length <= availableWidth) return flat;
|
||||
const suffix = ` (${flat.length.toLocaleString()} chars)`;
|
||||
const previewLen = Math.max(availableWidth - suffix.length - 1, 10);
|
||||
return flat.slice(0, previewLen) + "…" + suffix;
|
||||
}
|
||||
|
||||
function collapsedUserPrompt(text: string, width: number): React.ReactElement {
|
||||
const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
|
||||
const maxPreview = Math.max(width - 30, SENT_PREVIEW_LEN);
|
||||
if (flat.length <= maxPreview + 10) {
|
||||
return <Text color={TEXT_PRIMARY} bold>{flat}</Text>;
|
||||
}
|
||||
const preview = flat.slice(0, maxPreview) + "…";
|
||||
const remaining = flat.length - maxPreview;
|
||||
return (
|
||||
<Text>
|
||||
<Text color={TEXT_PRIMARY} bold>{preview}</Text>
|
||||
<Text color={TEXT_DIM}> ({remaining.toLocaleString()} more chars)</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function InputBar({
|
||||
const InputBar = React.memo(function InputBar({
|
||||
width,
|
||||
input,
|
||||
onChange,
|
||||
@@ -284,6 +122,8 @@ function InputBar({
|
||||
);
|
||||
|
||||
const isPasteMode = pastedFull !== null;
|
||||
const constrainedWidth = Math.max(width, 20);
|
||||
const contentWidth = Math.max(constrainedWidth - 6, 10);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -291,16 +131,26 @@ function InputBar({
|
||||
borderStyle="round"
|
||||
borderColor={RULE_COLOR}
|
||||
paddingX={1}
|
||||
width={width}
|
||||
width={constrainedWidth}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Box>
|
||||
<Text color={CRANBERRY} bold>{"❯ "}</Text>
|
||||
{isPasteMode ? (
|
||||
<Box width={width - 4 - 2} justifyContent="space-between">
|
||||
<Text color={TEXT_PRIMARY} wrap="truncate-end">
|
||||
{collapseForDisplay(pastedFull, width - 4 - 2)}
|
||||
</Text>
|
||||
<Box width={contentWidth} justifyContent="space-between">
|
||||
<Box width={Math.max(contentWidth - 20, 10)}>
|
||||
<Text color={TEXT_PRIMARY} wrap="truncate-end">
|
||||
{(() => {
|
||||
const text = pastedFull;
|
||||
const availableWidth = Math.max(contentWidth - 20, 10);
|
||||
const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (flat.length <= availableWidth) return flat;
|
||||
const suffix = ` (${flat.length.toLocaleString()} chars)`;
|
||||
const previewLen = Math.max(availableWidth - suffix.length - 1, 5);
|
||||
return flat.slice(0, previewLen) + "…" + suffix;
|
||||
})()}
|
||||
</Text>
|
||||
</Box>
|
||||
{scrollHint && <Text color={TEXT_DIM}>shift+↑↓ history</Text>}
|
||||
</Box>
|
||||
) : (
|
||||
@@ -344,74 +194,11 @@ function InputBar({
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyLine(key: string, width: number): React.ReactElement {
|
||||
return <Box key={key} width={width} height={1}><Text> </Text></Box>;
|
||||
}
|
||||
|
||||
function buildPermissionLines(
|
||||
perm: PendingPermission,
|
||||
selectedIdx: number,
|
||||
fullWidth: number,
|
||||
): React.ReactElement[] {
|
||||
const dialogWidth = Math.min(fullWidth - 2, 58);
|
||||
const innerWidth = Math.max(dialogWidth - 4, 10);
|
||||
const hRule = "─".repeat(Math.max(dialogWidth - 2, 0));
|
||||
const lines: React.ReactElement[] = [];
|
||||
|
||||
lines.push(emptyLine("pm-gap", fullWidth));
|
||||
|
||||
lines.push(
|
||||
<Box key="pm-t" width={fullWidth} height={1}>
|
||||
<Text color={GOLD}>╭{hRule}╮</Text>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
const row = (key: string, content: React.ReactNode) => {
|
||||
lines.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>);
|
||||
|
||||
lines.push(
|
||||
<Box key="pm-b" width={fullWidth} height={1}>
|
||||
<Text color={GOLD}>╰{hRule}╯</Text>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
return lines;
|
||||
}
|
||||
});
|
||||
|
||||
function buildContentLines({
|
||||
turn,
|
||||
turnIndex,
|
||||
width,
|
||||
loading,
|
||||
status,
|
||||
@@ -422,6 +209,7 @@ function buildContentLines({
|
||||
queuedMessages,
|
||||
}: {
|
||||
turn: Turn | undefined;
|
||||
turnIndex: number;
|
||||
width: number;
|
||||
loading: boolean;
|
||||
status: string;
|
||||
@@ -434,15 +222,31 @@ function buildContentLines({
|
||||
const lines: React.ReactElement[] = [];
|
||||
if (!turn) return lines;
|
||||
|
||||
lines.push(emptyLine("u-gap", width));
|
||||
lines.push(
|
||||
<Box key="u-prompt" width={width} height={1}>
|
||||
<Text color={CRANBERRY} bold>{"❯ "}</Text>
|
||||
{collapsedUserPrompt(turn.userText, width - 4)}
|
||||
</Box>,
|
||||
);
|
||||
const safeWidth = Math.max(width, 20);
|
||||
|
||||
// Response items
|
||||
const turnId = String(turnIndex);
|
||||
lines.push(...renderUserPrompt(turn.userText, safeWidth, turnId, (text: string, availableWidth: number) => {
|
||||
const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
|
||||
const safeWidth = Math.max(availableWidth, 10);
|
||||
const maxPreview = Math.max(safeWidth - 30, Math.min(SENT_PREVIEW_LEN, safeWidth - 10));
|
||||
if (flat.length <= maxPreview + 10) {
|
||||
return (
|
||||
<Box width={safeWidth}>
|
||||
<Text color={TEXT_PRIMARY} bold wrap="wrap">{flat}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const preview = flat.slice(0, maxPreview) + "…";
|
||||
const remaining = flat.length - maxPreview;
|
||||
return (
|
||||
<Box width={safeWidth}>
|
||||
<Text color={TEXT_PRIMARY} bold wrap="wrap">{preview}</Text>
|
||||
<Text color={TEXT_DIM}> ({remaining.toLocaleString()} more chars)</Text>
|
||||
</Box>
|
||||
);
|
||||
}));
|
||||
|
||||
// Process response items
|
||||
const hasToolCalls = turn.responseItems.some((it) => it.itemType === "tool_call");
|
||||
let tcIdx = 0;
|
||||
|
||||
@@ -450,69 +254,87 @@ function buildContentLines({
|
||||
const item = turn.responseItems[i]!;
|
||||
|
||||
if (item.itemType === "tool_call") {
|
||||
const info: ToolCallInfo = {
|
||||
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,
|
||||
};
|
||||
lines.push(emptyLine(`tc-gap-${i}`, width));
|
||||
lines.push(
|
||||
...renderToolCallLines(info, width, toolCallsExpanded, tcIdx === 0 && hasToolCalls),
|
||||
);
|
||||
lines.push(...renderToolCallItem(item, i, safeWidth, toolCallsExpanded, tcIdx === 0, hasToolCalls));
|
||||
tcIdx++;
|
||||
} else if (
|
||||
item.itemType === "content_chunk" &&
|
||||
item.content.type === "text" &&
|
||||
item.content.text
|
||||
) {
|
||||
const mdLines = renderMarkdown(item.content.text, width);
|
||||
lines.push(emptyLine(`md-gap-${i}`, width));
|
||||
for (let j = 0; j < mdLines.length; j++) {
|
||||
lines.push(
|
||||
<Box key={`md-${i}-${j}`} width={width} height={1}>
|
||||
<Text wrap="truncate-end">{mdLines[j]}</Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
} else if (item.itemType === "error") {
|
||||
lines.push(...renderErrorItem(item, i, safeWidth));
|
||||
} else if (item.itemType === "content_chunk") {
|
||||
lines.push(...renderContentItem(item, i, safeWidth));
|
||||
}
|
||||
}
|
||||
|
||||
// Loading indicator
|
||||
if (loading && !pendingPermission) {
|
||||
lines.push(emptyLine("ld-gap", width));
|
||||
lines.push(
|
||||
<Box key="ld" width={width} height={1}>
|
||||
<Spinner idx={spinIdx} />
|
||||
<Text color={TEXT_DIM} italic> {status}</Text>
|
||||
</Box>,
|
||||
);
|
||||
lines.push(...renderLoadingIndicator(status, spinIdx, safeWidth));
|
||||
}
|
||||
|
||||
// Permission dialog
|
||||
if (pendingPermission) {
|
||||
lines.push(...buildPermissionLines(pendingPermission, permissionIdx, width));
|
||||
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
|
||||
for (let i = 0; i < queuedMessages.length; i++) {
|
||||
lines.push(
|
||||
<Box key={`q-${i}`} width={width} height={1}>
|
||||
<Text color={TEXT_DIM}>{"❯ "}</Text>
|
||||
<Text wrap="truncate-end" color={TEXT_DIM}>{queuedMessages[i]}</Text>
|
||||
<Text color={GOLD} dimColor> (queued)</Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
lines.push(...renderQueuedMessages(queuedMessages, safeWidth));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function Viewport({
|
||||
const Viewport = React.memo(function Viewport({
|
||||
lines,
|
||||
height,
|
||||
width,
|
||||
@@ -551,7 +373,7 @@ function Viewport({
|
||||
}
|
||||
|
||||
for (let i = 0; i < padCount; i++) {
|
||||
elements.push(emptyLine(`vp-${i}`, width));
|
||||
elements.push(emptyLine(`vp-pad-${i}`, width));
|
||||
}
|
||||
elements.push(...visible);
|
||||
|
||||
@@ -566,14 +388,17 @@ function Viewport({
|
||||
);
|
||||
}
|
||||
|
||||
const constrainedWidth = Math.max(width, 10);
|
||||
const constrainedHeight = Math.max(height, 1);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height={height} width={width}>
|
||||
<Box flexDirection="column" height={constrainedHeight} width={constrainedWidth}>
|
||||
{elements}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function SplashScreen({
|
||||
const SplashScreen = React.memo(function SplashScreen({
|
||||
animFrame,
|
||||
width,
|
||||
height,
|
||||
@@ -596,12 +421,16 @@ function SplashScreen({
|
||||
|
||||
const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
|
||||
|
||||
// Use original dimensions for outer container to maintain centering
|
||||
const safeWidth = Math.max(width, 20);
|
||||
const safeHeight = Math.max(height, 10);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
width={width}
|
||||
height={height}
|
||||
width={safeWidth}
|
||||
height={safeHeight}
|
||||
overflow="hidden"
|
||||
>
|
||||
{topPad > 0 && <Box height={topPad} />}
|
||||
@@ -613,14 +442,16 @@ function SplashScreen({
|
||||
<Box marginTop={1}>
|
||||
<Text color={TEXT_PRIMARY} bold>goose</Text>
|
||||
</Box>
|
||||
<Text color={TEXT_DIM}>your on-machine AI agent</Text>
|
||||
<Box marginTop={2} gap={1}>
|
||||
<Box alignItems="center">
|
||||
<Text color={TEXT_DIM}>your on-machine AI agent</Text>
|
||||
</Box>
|
||||
<Box marginTop={2} gap={1} alignItems="center">
|
||||
{loading && <Spinner idx={spinIdx} />}
|
||||
<Text color={statusColor}>{status}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function App({
|
||||
serverConnection,
|
||||
@@ -650,6 +481,7 @@ function App({
|
||||
const [toolCallsExpanded, setToolCallsExpanded] = useState(false);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [pastedFull, setPastedFull] = useState<string | null>(null);
|
||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||
|
||||
const clientRef = useRef<GooseClient | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
@@ -704,6 +536,16 @@ function App({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const appendError = useCallback((errorMessage: string) => {
|
||||
setTurns((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const last = { ...prev[prev.length - 1]! };
|
||||
const newItems = [...last.responseItems];
|
||||
newItems.push({ itemType: "error", message: errorMessage });
|
||||
return [...prev.slice(0, -1), { ...last, responseItems: newItems }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToolCall = useCallback((tc: ToolCall) => {
|
||||
setTurns((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
@@ -790,12 +632,14 @@ function App({
|
||||
: `stopped: ${result.stopReason}`,
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
setStatus(`error: ${e instanceof Error ? e.message : String(e)}`);
|
||||
const errorMsg = formatError(e);
|
||||
setStatus(`error`);
|
||||
appendError(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[appendAgent, addUserTurn],
|
||||
[appendAgent, appendError, addUserTurn],
|
||||
);
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
@@ -817,6 +661,36 @@ function App({
|
||||
[executePrompt, processQueue],
|
||||
);
|
||||
|
||||
const createSession = useCallback(async (client: GooseClient) => {
|
||||
setStatus("creating session…");
|
||||
setLoading(true);
|
||||
try {
|
||||
const session = await client.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
sessionIdRef.current = session.sessionId;
|
||||
setLoading(false);
|
||||
setStatus("ready");
|
||||
|
||||
if (initialPrompt && !sentInitialPrompt.current) {
|
||||
sentInitialPrompt.current = true;
|
||||
await sendPrompt(initialPrompt);
|
||||
setTimeout(() => exit(), 100);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const errorMsg = formatError(e);
|
||||
setStatus(`failed: ${errorMsg}`);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [initialPrompt, sendPrompt, exit]);
|
||||
|
||||
const handleOnboardingComplete = useCallback(() => {
|
||||
setNeedsOnboarding(false);
|
||||
const client = clientRef.current;
|
||||
if (client) createSession(client);
|
||||
}, [createSession]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -870,32 +744,35 @@ function App({
|
||||
});
|
||||
if (cancelled) return;
|
||||
|
||||
setStatus("creating session…");
|
||||
const session = await client.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
setStatus("checking provider…");
|
||||
let hasProvider = false;
|
||||
try {
|
||||
const resp = await client.goose.GooseConfigRead({ key: "GOOSE_PROVIDER" });
|
||||
hasProvider = resp.value != null && resp.value !== "" && resp.value !== "null";
|
||||
} catch {
|
||||
hasProvider = false;
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
sessionIdRef.current = session.sessionId;
|
||||
setLoading(false);
|
||||
setStatus("ready");
|
||||
|
||||
if (initialPrompt && !sentInitialPrompt.current) {
|
||||
sentInitialPrompt.current = true;
|
||||
await sendPrompt(initialPrompt);
|
||||
setTimeout(() => exit(), 100);
|
||||
if (!hasProvider && !initialPrompt) {
|
||||
setNeedsOnboarding(true);
|
||||
setLoading(false);
|
||||
setStatus("setup required");
|
||||
return;
|
||||
}
|
||||
|
||||
await createSession(client);
|
||||
} catch (e: unknown) {
|
||||
if (cancelled) return;
|
||||
setStatus(`failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
const errorMsg = formatError(e);
|
||||
setStatus(`failed: ${errorMsg}`);
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [
|
||||
serverConnection, initialPrompt, sendPrompt,
|
||||
serverConnection, initialPrompt, createSession,
|
||||
appendAgent, handleToolCall, handleToolCallUpdate, exit,
|
||||
]);
|
||||
|
||||
@@ -991,11 +868,13 @@ function App({
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
}, { isActive: !needsOnboarding });
|
||||
|
||||
const PAD_X = 2;
|
||||
const PAD_Y = 1;
|
||||
const contentWidth = Math.max(termWidth - PAD_X * 2, 20);
|
||||
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];
|
||||
@@ -1015,12 +894,13 @@ function App({
|
||||
const inputBarH = showInputBar ? 2 + inputContentRows + inputExtraLines : 0;
|
||||
const historyBarH = isViewingHistory ? 2 : 0;
|
||||
const viewportHeight = Math.max(
|
||||
termHeight - PAD_Y * 2 - headerH - inputBarH - historyBarH,
|
||||
safeTermHeight - PAD_Y * 2 - headerH - inputBarH - historyBarH,
|
||||
3,
|
||||
);
|
||||
|
||||
const contentLines = buildContentLines({
|
||||
turn: currentTurn,
|
||||
turnIndex: effectiveTurnIdx,
|
||||
width: contentWidth,
|
||||
loading: isLatest && loading,
|
||||
status,
|
||||
@@ -1031,11 +911,28 @@ function App({
|
||||
queuedMessages: isLatest ? queuedMessages : [],
|
||||
});
|
||||
|
||||
if (needsOnboarding && clientRef.current) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={safeTermWidth}
|
||||
height={safeTermHeight}
|
||||
>
|
||||
<Onboarding
|
||||
client={clientRef.current}
|
||||
width={safeTermWidth}
|
||||
height={safeTermHeight}
|
||||
onComplete={handleOnboardingComplete}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={termWidth}
|
||||
height={termHeight}
|
||||
width={safeTermWidth}
|
||||
height={safeTermHeight}
|
||||
paddingX={PAD_X}
|
||||
paddingY={PAD_Y}
|
||||
>
|
||||
@@ -1043,7 +940,7 @@ function App({
|
||||
<SplashScreen
|
||||
animFrame={gooseFrame}
|
||||
width={contentWidth}
|
||||
height={Math.max(termHeight - PAD_Y * 2 - inputBarH, 0)}
|
||||
height={Math.max(safeTermHeight - PAD_Y * 2 - inputBarH, 0)}
|
||||
status={status}
|
||||
loading={loading}
|
||||
spinIdx={spinIdx}
|
||||
@@ -1119,22 +1016,7 @@ const cli = meow(
|
||||
},
|
||||
);
|
||||
|
||||
function findServerBinary(): string | null {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const candidates = [
|
||||
join(__dirname, "..", "server-binary.json"),
|
||||
join(__dirname, "server-binary.json"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(candidate, "utf-8"));
|
||||
return data.binaryPath ?? null;
|
||||
} catch {
|
||||
// not found here, try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
let serverProcess: ReturnType<typeof spawn> | null = null;
|
||||
|
||||
@@ -1196,7 +1078,22 @@ async function main() {
|
||||
if (cli.flags.server) {
|
||||
serverConnection = cli.flags.server;
|
||||
} else {
|
||||
const binary = findServerBinary();
|
||||
const binary = (() => {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const candidates = [
|
||||
join(__dirname, "..", "server-binary.json"),
|
||||
join(__dirname, "server-binary.json"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(candidate, "utf-8"));
|
||||
return data.binaryPath ?? null;
|
||||
} catch {
|
||||
// not found here, try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
if (!binary) {
|
||||
console.error(
|
||||
"No goose binary found. Use --server <url> or install the native package.",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export type ResponseItem =
|
||||
| (ContentChunk & { itemType: "content_chunk" })
|
||||
| (ToolCall & { itemType: "tool_call" })
|
||||
| { itemType: "error"; message: string };
|
||||
|
||||
export interface Turn {
|
||||
userText: string;
|
||||
responseItems: ResponseItem[];
|
||||
toolCallsById: Map<string, number>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function isErrorStatus(status: string): boolean {
|
||||
return status.startsWith("error") || status.startsWith("failed");
|
||||
}
|
||||
|
||||
export function formatError(e: unknown): string {
|
||||
if (e instanceof Error) {
|
||||
return e.message || e.toString();
|
||||
}
|
||||
if (typeof e === "string") {
|
||||
return e;
|
||||
}
|
||||
if (e && typeof e === "object") {
|
||||
try {
|
||||
return JSON.stringify(e, null, 2);
|
||||
} catch {
|
||||
return String(e);
|
||||
}
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
Reference in New Issue
Block a user