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 };
|
||||
Reference in New Issue
Block a user