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 };