feat: onboarding UX for the TUI (#8513)

This commit is contained in:
Alex Hancock
2026-04-14 10:17:01 -04:00
committed by GitHub
parent 2f018285a4
commit 17a404d4f9
26 changed files with 2325 additions and 423 deletions
+137
View File
@@ -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>
));
}
+35
View File
@@ -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>
);
});
+57
View File
@@ -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>
);
});
+12
View File
@@ -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>;
});
+19
View File
@@ -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 };
+82
View File
@@ -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",
};
+675
View File
@@ -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}
/>
);
}
+10 -8
View File
@@ -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
View File
@@ -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.",
+18
View File
@@ -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>;
}
+20
View File
@@ -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);
}