Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 3 additions & 56 deletions src/components/ConfirmAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { Spinner } from "./ui/spinner";
import { Confirm } from "./ui/confirm";
import { TaskList, type Task } from "./ui/task-list";
import { KeyValueTable } from "./KeyValueTable";
import { SuccessBody } from "./SuccessBody";
import { darkTheme, glyphs } from "./ui/_core.js";
import { driveProgress, type ProgressEvent } from "../tui/progress";
import { driveProgress, isProgressGenerator, type ProgressResult } from "../tui/progress";

const theme = darkTheme;

Expand Down Expand Up @@ -41,7 +42,7 @@ export interface ConfirmActionProps {
// optionally with a title overriding successTitle for an outcome only known
// afterwards. A progress generator (what runWithProgress drives) may be
// returned instead; its steps render as a live TaskList while it runs.
action: () => Promise<ActionResult> | AsyncGenerator<ProgressEvent, ActionResult>;
action: () => ProgressResult<ActionResult>;
// successTitle heads the success panel (e.g. "Harness deleted") unless the
// action's result carries its own.
successTitle: string;
Expand Down Expand Up @@ -201,60 +202,6 @@ export function ConfirmAction({
);
}

// A promise has no Symbol.asyncIterator, so this is a safe discriminator.
function isProgressGenerator(
result: Promise<ActionResult> | AsyncGenerator<ProgressEvent, ActionResult>,
): result is AsyncGenerator<ProgressEvent, ActionResult> {
return (
typeof (result as AsyncGenerator<ProgressEvent, ActionResult>)[Symbol.asyncIterator] ===
"function"
);
}

function SuccessBody({
title,
rows,
nextSteps,
onDone,
doneLabel,
}: {
title: string;
rows: SummaryRows;
nextSteps?: string[];
onDone: () => void;
doneLabel: string;
}) {
useInput((_input, key) => {
if (key.return || key.escape) onDone();
});

return (
<Box flexDirection="column">
<Text color={theme.colors.success} bold>
{glyphs.check} {title}
</Text>
{Object.keys(rows).length > 0 && (
<Box flexDirection="column" marginTop={1} marginLeft={2}>
<KeyValueTable items={rows} />
</Box>
)}
{nextSteps !== undefined && nextSteps.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.colors.text}>next steps</Text>
{nextSteps.map((step) => (
<Text key={step} color={theme.colors.primary}>{` ${step}`}</Text>
))}
</Box>
)}
<Box marginTop={1}>
<Text color={theme.colors.muted}>
press <Text color={theme.colors.focus}>enter</Text> to {doneLabel}
</Text>
</Box>
</Box>
);
}

function ErrorBody({ message, onBack }: { message: string; onBack: () => void }) {
useInput((_input, key) => {
if (key.escape || key.return) onBack();
Expand Down
14 changes: 9 additions & 5 deletions src/components/FormRadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface FormRadioGroupProps {
// FormRadioGroup renders a column of radio rows. It is fully controlled: the
// parent owns the focused index and the key handling that moves it.
export function FormRadioGroup({
name,
name = "",
helpText,
options,
focusedIndex,
Expand All @@ -31,10 +31,14 @@ export function FormRadioGroup({

return (
<Box flexDirection="column">
<Box flexDirection="column">
{name && <Text color={theme.colors.text}>{name}</Text>}
<Text color={theme.colors.muted}>{helpText}</Text>
</Box>
{/* Either row is omitted when empty, so a caller whose surrounding
context already asks the question renders just the options. */}
{(name !== "" || helpText !== "") && (
<Box flexDirection="column">
{name !== "" && <Text color={theme.colors.text}>{name}</Text>}
{helpText !== "" && <Text color={theme.colors.muted}>{helpText}</Text>}
</Box>
)}
<Box
flexDirection="column"
paddingX={1}
Expand Down
12 changes: 8 additions & 4 deletions src/components/FormTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ export function FormTextInput({
}: FormTextInputProps) {
return (
<Box flexDirection="column">
<Box flexDirection="column">
<Text color={theme.colors.text}>{name}</Text>
<Text color={theme.colors.muted}>{helpText}</Text>
</Box>
{/* Either row is omitted when empty, so a caller whose surrounding
context already asks the question renders just the input. */}
{(name !== "" || helpText !== "") && (
<Box flexDirection="column">
{name !== "" && <Text color={theme.colors.text}>{name}</Text>}
{helpText !== "" && <Text color={theme.colors.muted}>{helpText}</Text>}
</Box>
)}
<Box borderStyle="round" borderColor={focused ? theme.colors.focus : theme.colors.border}>
<TextInput
value={value}
Expand Down
1 change: 1 addition & 0 deletions src/components/HarnessWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,7 @@ function SuccessPanel({
: " the new version is deploying · enter opens the harness"}
</Text>
<Box
flexDirection="column"
borderStyle="single"
borderColor={theme.colors.border}
borderLeft={false}
Expand Down
5 changes: 5 additions & 0 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ import { BuildProjectScreen } from "../handlers/project/build/screen.tsx";
import { DeployProjectScreen } from "../handlers/project/deploy/screen.tsx";
import { ProjectCreateScreen } from "../handlers/project/create/screen.tsx";
import { ProjectInvokePickerScreen } from "../handlers/project/invoke/screen.tsx";
import { AddRuntimeScreen } from "../handlers/project/add/runtime/screen.tsx";
import { ProjectStatusScreen } from "../handlers/project/status/screen.tsx";
import { HelpScreen, RootScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";
Expand Down Expand Up @@ -811,6 +812,10 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/project/status"
element={<ProjectStatusScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/project/add/runtime"
element={<AddRuntimeScreen ctx={ctx} core={core} />}
/>
{/* Every known command without a screen of its own: a group opens its
menu and a leaf its interactive help. Unknown routes retain the
help-and-exit fallback. */}
Expand Down
57 changes: 57 additions & 0 deletions src/components/SuccessBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Box, Text, useInput } from "ink";
import { KeyValueTable } from "./KeyValueTable";
import { darkTheme, glyphs } from "./ui/_core.js";

const theme = darkTheme;

export interface SuccessBodyProps {
title: string;
rows?: Record<string, string>;
nextSteps?: string[];
hint?: string;
onDone: () => void;
doneLabel?: string;
}

export function SuccessBody({
title,
rows = {},
nextSteps,
hint,
onDone,
doneLabel = "continue",
}: SuccessBodyProps) {
useInput((_input, key) => {
if (key.return || key.escape) onDone();
});

return (
<Box flexDirection="column">
<Text color={theme.colors.success} bold>
{glyphs.check} {title}
</Text>
{Object.keys(rows).length > 0 && (
<Box flexDirection="column" marginTop={1} marginLeft={2}>
<KeyValueTable items={rows} />
</Box>
)}
{nextSteps !== undefined && nextSteps.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.colors.text}>next steps</Text>
{nextSteps.map((step) => (
<Text key={step} color={theme.colors.primary}>{` ${step}`}</Text>
))}
</Box>
)}
<Box marginTop={1}>
<Text color={theme.colors.muted}>
{hint ?? (
<>
press <Text color={theme.colors.focus}>enter</Text> to {doneLabel}
</>
)}
</Text>
</Box>
</Box>
);
}
29 changes: 29 additions & 0 deletions src/components/wizard/Step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Box, Text } from "ink";
import { darkTheme } from "../ui/_core.js";

const theme = darkTheme;

export interface StepProps {
// React reserves `key`, so this cannot use that prop name.
stepKey: string;
title?: string;
prompt?: string;
children: ReactNode;
}

// One field per step. Every field registers its own useInput and answers enter,
// esc and the arrows itself; two fields mounted at once would both react to the
// same keystroke. A step needing related inputs should use one compound field.
export function Step({ prompt, children }: StepProps) {
return (
<Box flexDirection="column" paddingX={1}>
{prompt !== undefined && <Text color={theme.colors.muted}>{prompt}</Text>}
{children}
</Box>
);
}

export function isStepElement(child: ReactNode): child is ReactElement<StepProps> {
return isValidElement(child) && child.type === Step;
}
Loading
Loading