protocol cleanup (#9147)

This commit is contained in:
Jack Amadeo
2026-05-22 17:04:46 -04:00
committed by GitHub
parent 48d20e72d3
commit 0a958213db
19 changed files with 2378 additions and 1788 deletions
+66 -28
View File
@@ -60,7 +60,9 @@ const ModelSelector = React.memo(function ModelSelector({
useEffect(() => {
const availableModels = provider.models.map((model) => model.id);
setModels(availableModels);
const defaultIdx = availableModels.findIndex((model) => model === provider.defaultModel);
const defaultIdx = availableModels.findIndex(
(model) => model === provider.defaultModel,
);
setSelectedIdx(defaultIdx >= 0 ? defaultIdx : 0);
setLoading(false);
}, [provider.models, provider.defaultModel]);
@@ -154,10 +156,14 @@ const ModelSelector = React.memo(function ModelSelector({
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold> Select model </Text>
<Text color={TEXT_PRIMARY} bold>
Select model
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>Loading models for {provider.providerName}</Text>
<Text color={TEXT_DIM}>
Loading models for {provider.providerName}
</Text>
</Box>
<Box justifyContent="center" flexGrow={1} alignItems="center">
<Spinner idx={0} />
@@ -171,7 +177,9 @@ const ModelSelector = React.memo(function ModelSelector({
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold> Select model </Text>
<Text color={TEXT_PRIMARY} bold>
Select model
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={GOLD}> No models available</Text>
@@ -193,18 +201,23 @@ const ModelSelector = React.memo(function ModelSelector({
if (manualEntry) {
const inputWidth = Math.min(60, maxWidth - 4);
const displayText = searchQuery || "type model name…";
const truncatedText = displayText.length > inputWidth - 6
? displayText.slice(0, inputWidth - 9) + "…"
: displayText;
const truncatedText =
displayText.length > inputWidth - 6
? displayText.slice(0, inputWidth - 9) + "…"
: displayText;
return (
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold> Enter model name </Text>
<Text color={TEXT_PRIMARY} bold>
Enter model name
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>Type a model identifier for {provider.providerName}</Text>
<Text color={TEXT_DIM}>
Type a model identifier for {provider.providerName}
</Text>
</Box>
<Box justifyContent="center">
@@ -214,7 +227,9 @@ const ModelSelector = React.memo(function ModelSelector({
paddingX={2}
width={inputWidth}
>
<Text color={GOLD} bold>{" "}</Text>
<Text color={GOLD} bold>
{" "}
</Text>
<Text color={searchQuery ? TEXT_PRIMARY : TEXT_DIM}>
{truncatedText}
</Text>
@@ -236,7 +251,9 @@ const ModelSelector = React.memo(function ModelSelector({
{/* Header */}
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold> Select model </Text>
<Text color={TEXT_PRIMARY} bold>
Select model
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>Choose a model for {provider.providerName}</Text>
@@ -250,7 +267,9 @@ const ModelSelector = React.memo(function ModelSelector({
paddingX={2}
width={searchBoxWidth}
>
<Text color={CRANBERRY} bold>{" "}</Text>
<Text color={CRANBERRY} bold>
{" "}
</Text>
<Box width={searchBoxWidth - 8}>
<Text color={searchQuery ? TEXT_PRIMARY : TEXT_DIM} wrap="truncate">
{searchQuery || "search models…"}
@@ -262,7 +281,11 @@ const ModelSelector = React.memo(function ModelSelector({
{/* Model List */}
<Box flexDirection="column" flexGrow={1} justifyContent="flex-start">
{filtered.length === 0 ? (
<Box justifyContent="center" alignItems="center" height={Math.max(listHeight, 1)}>
<Box
justifyContent="center"
alignItems="center"
height={Math.max(listHeight, 1)}
>
<Text color={TEXT_DIM}>No matching models</Text>
</Box>
) : (
@@ -279,16 +302,20 @@ const ModelSelector = React.memo(function ModelSelector({
const active = idx === selectedIdx;
const isDefault = model === provider.defaultModel;
const modelWidth = maxWidth - 8;
const truncatedModel = model.length > modelWidth
? model.slice(0, modelWidth - 1) + "…"
: model;
const truncatedModel =
model.length > modelWidth
? model.slice(0, modelWidth - 1) + "…"
: model;
return (
<Box key={model}>
<Text color={active ? GOLD : TEXT_DIM}>
{active ? "▸ " : " "}
</Text>
<Text color={active ? TEXT_PRIMARY : TEXT_DIM} bold={active}>
<Text
color={active ? TEXT_PRIMARY : TEXT_DIM}
bold={active}
>
{truncatedModel}
</Text>
{isDefault && <Text color={TEAL}> (default)</Text>}
@@ -329,7 +356,8 @@ export default function ConfigureScreen({
}: ConfigureProps) {
const [phase, setPhase] = useState<Phase>("loading");
const [providers, setProviders] = useState<ProviderInventoryEntryDto[]>([]);
const [selectedProvider, setSelectedProvider] = useState<ProviderInventoryEntryDto | null>(null);
const [selectedProvider, setSelectedProvider] =
useState<ProviderInventoryEntryDto | null>(null);
const [errorMsg, setErrorMsg] = useState("");
const [spinIdx, setSpinIdx] = useState(0);
const [fetchKey, setFetchKey] = useState(0);
@@ -347,7 +375,9 @@ export default function ConfigureScreen({
(async () => {
try {
const resp = await client.goose.GooseProvidersList({ providerIds: [] });
const resp = await client.goose.providersList_unstable({
providerIds: [],
});
if (cancelled) return;
const sorted = [...resp.entries].sort((a, b) => {
const aP = a.providerType === "Preferred" ? 0 : 1;
@@ -359,7 +389,7 @@ export default function ConfigureScreen({
if (initialIntent === "model") {
try {
const cfg = await client.goose.GooseDefaultsRead({});
const cfg = await client.goose.defaultsRead_unstable({});
if (cancelled) return;
const current = sorted.find((p) => p.providerId === cfg.providerId);
if (current) {
@@ -395,7 +425,7 @@ export default function ConfigureScreen({
) => {
setPhase("saving");
try {
await client.goose.GooseProvidersConfigSave({
await client.goose.providersConfigSave_unstable({
providerId: provider.providerId,
fields: Object.entries(configValues).map(([key, value]) => ({
key,
@@ -421,7 +451,9 @@ export default function ConfigureScreen({
[client, sessionId, onComplete],
);
const [pendingConfigValues, setPendingConfigValues] = useState<Record<string, string>>({});
const [pendingConfigValues, setPendingConfigValues] = useState<
Record<string, string>
>({});
const handleProviderSelected = useCallback(
(provider: ProviderInventoryEntryDto) => {
@@ -463,15 +495,19 @@ export default function ConfigureScreen({
}, []);
if (phase === "loading" || phase === "loading_models" || phase === "saving") {
const label =
phase === "loading" ? "Loading providers…" :
phase === "loading_models" ? "Loading models…" :
"Applying changes…";
const label =
phase === "loading"
? "Loading providers…"
: phase === "loading_models"
? "Loading models…"
: "Applying changes…";
return (
<Box flexDirection="column" height={height} width={width} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold> Configure provider </Text>
<Text color={TEXT_PRIMARY} bold>
Configure provider
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>{label}</Text>
@@ -488,7 +524,9 @@ export default function ConfigureScreen({
<Box flexDirection="column" height={height} width={width} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold> Configure provider </Text>
<Text color={TEXT_PRIMARY} bold>
Configure provider
</Text>
</Box>
<ErrorScreen errorMsg={errorMsg} onRetry={handleRetry} />
</Box>
+296 -95
View File
@@ -1,10 +1,17 @@
import React, {useCallback, useEffect, useState} from "react";
import {Box, Text, useInput, useStdout} from "ink";
import {TextInput} from "@inkjs/ui";
import type {GooseClient} from "@aaif/goose-sdk";
import {CRANBERRY, GOLD, RULE_COLOR, TEAL, TEXT_DIM, TEXT_PRIMARY} from "./colors.js";
import {Spinner, SPINNER_FRAMES} from "./components/Spinner.js";
import {ErrorScreen} from "./components/ErrorScreen.js";
import React, { useCallback, useEffect, useState } from "react";
import { Box, Text, useInput, useStdout } from "ink";
import { TextInput } from "@inkjs/ui";
import type { GooseClient } from "@aaif/goose-sdk";
import {
CRANBERRY,
GOLD,
RULE_COLOR,
TEAL,
TEXT_DIM,
TEXT_PRIMARY,
} from "./colors.js";
import { Spinner, SPINNER_FRAMES } from "./components/Spinner.js";
import { ErrorScreen } from "./components/ErrorScreen.js";
type ExtEntry = {
enabled: boolean;
@@ -15,29 +22,65 @@ type ExtEntry = {
};
function isExtEntry(v: unknown): v is ExtEntry {
return !!v && typeof v === "object" && "enabled" in v && "type" in v && "name" in v
&& typeof (v as ExtEntry).enabled === "boolean"
&& typeof (v as ExtEntry).type === "string"
&& typeof (v as ExtEntry).name === "string";
return (
!!v &&
typeof v === "object" &&
"enabled" in v &&
"type" in v &&
"name" in v &&
typeof (v as ExtEntry).enabled === "boolean" &&
typeof (v as ExtEntry).type === "string" &&
typeof (v as ExtEntry).name === "string"
);
}
type AddType = "stdio" | "streamable_http";
type Phase = "loading" | "list" | "add_type" | "add_value" | "add_name" | "add_desc" | "saving" | "error";
type Phase =
| "loading"
| "list"
| "add_type"
| "add_value"
| "add_name"
| "add_desc"
| "saving"
| "error";
function deriveNameFromValue(addType: AddType, value: string): string {
if (addType === "stdio") {
const cmd = value.trim().split(/\s+/)[0] ?? "";
return cmd.split("/").pop() ?? cmd;
}
try { return new URL(value.trim()).hostname; } catch { return value.trim(); }
try {
return new URL(value.trim()).hostname;
} catch {
return value.trim();
}
}
function buildConfig(addType: AddType, value: string, name: string, description: string): ExtEntry {
function buildConfig(
addType: AddType,
value: string,
name: string,
description: string,
): ExtEntry {
if (addType === "stdio") {
const parts = value.trim().split(/\s+/);
return {type: "stdio", enabled: true, name, description, cmd: parts[0] ?? "", args: parts.slice(1)};
return {
type: "stdio",
enabled: true,
name,
description,
cmd: parts[0] ?? "",
args: parts.slice(1),
};
}
return {type: "streamable_http", enabled: true, name, description, uri: value.trim()};
return {
type: "streamable_http",
enabled: true,
name,
description,
uri: value.trim(),
};
}
export default function ExtensionsManager({
@@ -51,7 +94,7 @@ export default function ExtensionsManager({
height: number;
onClose: () => void;
}) {
const {stdout} = useStdout();
const { stdout } = useStdout();
const columns = stdout?.columns ?? 80;
const [phase, setPhase] = useState<Phase>("loading");
@@ -68,7 +111,10 @@ export default function ExtensionsManager({
const [inputKey, setInputKey] = useState(0);
useEffect(() => {
const t = setInterval(() => setSpinIdx(i => (i + 1) % SPINNER_FRAMES.length), 300);
const t = setInterval(
() => setSpinIdx((i) => (i + 1) % SPINNER_FRAMES.length),
300,
);
return () => clearInterval(t);
}, []);
@@ -76,16 +122,23 @@ export default function ExtensionsManager({
setPhase("loading");
try {
const [configResp, sessionResp] = await Promise.all([
client.goose.GooseConfigExtensions({}),
client.goose.GooseSessionExtensions({sessionId}),
client.goose.configExtensionsList_unstable({}),
client.goose.sessionExtensionsList_unstable({ sessionId }),
]);
const allExtensions = (configResp.extensions as unknown[]).filter(isExtEntry);
const allExtensions = (configResp.extensions as unknown[]).filter(
isExtEntry,
);
const activeNames = new Set(
(sessionResp.extensions as Array<{name?: string}>).map(e => e.name),
(sessionResp.extensions as Array<{ name?: string }>).map((e) => e.name),
);
setEntries(allExtensions.map(ext => ({...ext, enabled: activeNames.has(ext.name)})));
setEntries(
allExtensions.map((ext) => ({
...ext,
enabled: activeNames.has(ext.name),
})),
);
setWarnings(configResp.warnings ?? []);
setPhase("list");
} catch (e: unknown) {
@@ -94,60 +147,115 @@ export default function ExtensionsManager({
}
}, [client, sessionId]);
useEffect(() => { reload(); }, [reload]);
const withSaving = useCallback(async (fn: () => Promise<void>) => {
setPhase("saving");
try {
await fn();
await reload();
} catch (e: unknown) {
setErrorMsg(e instanceof Error ? e.message : String(e));
setPhase("error");
}
useEffect(() => {
reload();
}, [reload]);
const withSaving = useCallback(
async (fn: () => Promise<void>) => {
setPhase("saving");
try {
await fn();
await reload();
} catch (e: unknown) {
setErrorMsg(e instanceof Error ? e.message : String(e));
setPhase("error");
}
},
[reload],
);
const toggleSelected = useCallback(() => {
const sel = entries[selectedIdx];
if (!sel) return;
withSaving(async () => {
if (sel.enabled) {
await client.goose.GooseExtensionsRemove({sessionId, name: sel.name});
await client.goose.sessionExtensionsRemove_unstable({
sessionId,
name: sel.name,
});
} else {
await client.goose.GooseExtensionsAdd({sessionId, config: sel as any});
await client.goose.sessionExtensionsAdd_unstable({
sessionId,
config: sel as any,
});
}
});
}, [entries, selectedIdx, client, sessionId, withSaving]);
const saveNewExtension = useCallback((description: string) => {
const config = buildConfig(addType, addValue, addName, description);
withSaving(async () => {
await client.goose.GooseConfigExtensionsAdd({
name: config.name,
extensionConfig: config as any,
enabled: true,
const saveNewExtension = useCallback(
(description: string) => {
const config = buildConfig(addType, addValue, addName, description);
withSaving(async () => {
await client.goose.configExtensionsAdd_unstable({
name: config.name,
extensionConfig: config as any,
enabled: true,
});
await client.goose.sessionExtensionsAdd_unstable({
sessionId,
config: config as any,
});
});
await client.goose.GooseExtensionsAdd({sessionId, config: config as any});
});
}, [addType, addValue, addName, client, sessionId, withSaving]);
},
[addType, addValue, addName, client, sessionId, withSaving],
);
useInput((ch, key) => {
if (phase === "list") {
if (key.escape) { onClose(); return; }
if (key.upArrow) { setSelectedIdx(i => Math.max(i - 1, 0)); return; }
if (key.downArrow) { setSelectedIdx(i => Math.min(i + 1, entries.length - 1)); return; }
if (ch === " " || key.return) { toggleSelected(); return; }
if (ch === "a") { setAddType("stdio"); setPhase("add_type"); return; }
if (key.escape) {
onClose();
return;
}
if (key.upArrow) {
setSelectedIdx((i) => Math.max(i - 1, 0));
return;
}
if (key.downArrow) {
setSelectedIdx((i) => Math.min(i + 1, entries.length - 1));
return;
}
if (ch === " " || key.return) {
toggleSelected();
return;
}
if (ch === "a") {
setAddType("stdio");
setPhase("add_type");
return;
}
}
if (phase === "add_type") {
if (key.escape) { setPhase("list"); return; }
if (key.upArrow || key.downArrow) { setAddType(t => t === "stdio" ? "streamable_http" : "stdio"); return; }
if (key.return) { setAddValue(""); setInputKey(k => k + 1); setPhase("add_value"); return; }
if (key.escape) {
setPhase("list");
return;
}
if (key.upArrow || key.downArrow) {
setAddType((t) => (t === "stdio" ? "streamable_http" : "stdio"));
return;
}
if (key.return) {
setAddValue("");
setInputKey((k) => k + 1);
setPhase("add_value");
return;
}
}
if (key.escape) {
if (phase === "add_value") { setPhase("add_type"); return; }
if (phase === "add_name") { setInputKey(k => k + 1); setPhase("add_value"); return; }
if (phase === "add_desc") { setInputKey(k => k + 1); setPhase("add_name"); return; }
if (phase === "add_value") {
setPhase("add_type");
return;
}
if (phase === "add_name") {
setInputKey((k) => k + 1);
setPhase("add_value");
return;
}
if (phase === "add_desc") {
setInputKey((k) => k + 1);
setPhase("add_name");
return;
}
}
});
@@ -156,10 +264,14 @@ export default function ExtensionsManager({
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold> Manage extensions </Text>
<Text color={TEXT_PRIMARY} bold>
Manage extensions
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>{phase === "loading" ? "Loading extensions…" : "Saving…"}</Text>
<Text color={TEXT_DIM}>
{phase === "loading" ? "Loading extensions…" : "Saving…"}
</Text>
</Box>
<Box justifyContent="center" flexGrow={1} alignItems="center">
<Spinner idx={spinIdx} />
@@ -173,7 +285,9 @@ export default function ExtensionsManager({
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold> Manage extensions </Text>
<Text color={TEXT_PRIMARY} bold>
Manage extensions
</Text>
</Box>
<ErrorScreen errorMsg={errorMsg} onRetry={() => reload()} />
</Box>
@@ -184,28 +298,38 @@ export default function ExtensionsManager({
const inputW = Math.min(maxW - 10, 70);
if (phase === "add_type") {
const types: {value: AddType; label: string; hint: string}[] = [
{value: "stdio", label: "Command (stdio)", hint: "run a local command"},
{value: "streamable_http", label: "Endpoint (HTTP)", hint: "connect to a remote server"},
const types: { value: AddType; label: string; hint: string }[] = [
{ value: "stdio", label: "Command (stdio)", hint: "run a local command" },
{
value: "streamable_http",
label: "Endpoint (HTTP)",
hint: "connect to a remote server",
},
];
return (
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold> Add extension </Text>
<Text color={TEXT_PRIMARY} bold>
Add extension
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>Choose a connection type</Text>
</Box>
<Box justifyContent="center">
<Box flexDirection="column">
{types.map(t => {
{types.map((t) => {
const active = addType === t.value;
return (
<Box key={t.value}>
<Text color={active ? GOLD : TEXT_DIM}>{active ? "▸ " : " "}</Text>
<Text color={active ? TEXT_PRIMARY : TEXT_DIM} bold={active}>{t.label}</Text>
<Text color={TEXT_DIM}> {t.hint}</Text>
<Text color={active ? GOLD : TEXT_DIM}>
{active ? "▸ " : " "}
</Text>
<Text color={active ? TEXT_PRIMARY : TEXT_DIM} bold={active}>
{t.label}
</Text>
<Text color={TEXT_DIM}> {t.hint}</Text>
</Box>
);
})}
@@ -227,14 +351,27 @@ export default function ExtensionsManager({
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold> {isStdio ? "Enter command" : "Enter endpoint URL"} </Text>
<Text color={TEXT_PRIMARY} bold>
{isStdio ? "Enter command" : "Enter endpoint URL"}
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>{isStdio ? "The command to launch the extension" : "URL of the remote MCP server"}</Text>
<Text color={TEXT_DIM}>
{isStdio
? "The command to launch the extension"
: "URL of the remote MCP server"}
</Text>
</Box>
<Box justifyContent="center">
<Box borderStyle="round" borderColor={RULE_COLOR} paddingX={2} width={inputW}>
<Text color={CRANBERRY} bold>{" "}</Text>
<Box
borderStyle="round"
borderColor={RULE_COLOR}
paddingX={2}
width={inputW}
>
<Text color={CRANBERRY} bold>
{" "}
</Text>
<TextInput
key={`value-${inputKey}`}
placeholder={placeholder}
@@ -243,7 +380,7 @@ export default function ExtensionsManager({
if (!v.trim()) return;
setAddValue(v);
setAddName(deriveNameFromValue(addType, v));
setInputKey(k => k + 1);
setInputKey((k) => k + 1);
setPhase("add_name");
}}
/>
@@ -261,14 +398,23 @@ export default function ExtensionsManager({
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold> Name this extension </Text>
<Text color={TEXT_PRIMARY} bold>
Name this extension
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>A short name to identify this extension</Text>
</Box>
<Box justifyContent="center">
<Box borderStyle="round" borderColor={RULE_COLOR} paddingX={2} width={inputW}>
<Text color={CRANBERRY} bold>{" "}</Text>
<Box
borderStyle="round"
borderColor={RULE_COLOR}
paddingX={2}
width={inputW}
>
<Text color={CRANBERRY} bold>
{" "}
</Text>
<TextInput
key={`name-${inputKey}`}
defaultValue={addName}
@@ -278,7 +424,7 @@ export default function ExtensionsManager({
if (!v.trim()) return;
setAddName(v.trim());
setAddDesc("");
setInputKey(k => k + 1);
setInputKey((k) => k + 1);
setPhase("add_desc");
}}
/>
@@ -296,14 +442,23 @@ export default function ExtensionsManager({
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold> Description </Text>
<Text color={TEXT_PRIMARY} bold>
Description
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>What does this extension do? (optional)</Text>
</Box>
<Box justifyContent="center">
<Box borderStyle="round" borderColor={RULE_COLOR} paddingX={2} width={inputW}>
<Text color={CRANBERRY} bold>{" "}</Text>
<Box
borderStyle="round"
borderColor={RULE_COLOR}
paddingX={2}
width={inputW}
>
<Text color={CRANBERRY} bold>
{" "}
</Text>
<TextInput
key={`desc-${inputKey}`}
placeholder="what does this extension do?"
@@ -313,7 +468,9 @@ export default function ExtensionsManager({
</Box>
</Box>
<Box justifyContent="center" marginTop={2}>
<Text color={TEXT_DIM}>enter save (leave empty to skip) · esc back</Text>
<Text color={TEXT_DIM}>
enter save (leave empty to skip) · esc back
</Text>
</Box>
</Box>
);
@@ -322,12 +479,15 @@ export default function ExtensionsManager({
const layoutW = maxW;
const GUTTER = 2;
const STATUS_W = 10;
const nameW = Math.max(16, Math.floor(layoutW * 0.30));
const nameW = Math.max(16, Math.floor(layoutW * 0.3));
const descW = Math.max(8, layoutW - 2 - STATUS_W - nameW - 2 * GUTTER);
const rows = Math.max(height - 9, 4);
const maxStart = Math.max(0, entries.length - rows);
const start = Math.min(maxStart, Math.max(0, selectedIdx - Math.floor(rows / 2)));
const start = Math.min(
maxStart,
Math.max(0, selectedIdx - Math.floor(rows / 2)),
);
const end = Math.min(entries.length, start + rows);
const windowed = entries.slice(start, end);
@@ -336,17 +496,27 @@ export default function ExtensionsManager({
{/* Header */}
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold> Manage extensions </Text>
<Text color={TEXT_PRIMARY} bold>
Manage extensions
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>Toggle, add, or remove extensions for this session</Text>
<Text color={TEXT_DIM}>
Toggle, add, or remove extensions for this session
</Text>
</Box>
{/* Extension List */}
<Box flexDirection="column" flexGrow={1} justifyContent="flex-start">
{entries.length === 0 ? (
<Box justifyContent="center" alignItems="center" height={Math.max(rows - 1, 1)}>
<Text color={TEXT_DIM}>No extensions configured press a to add one</Text>
<Box
justifyContent="center"
alignItems="center"
height={Math.max(rows - 1, 1)}
>
<Text color={TEXT_DIM}>
No extensions configured press a to add one
</Text>
</Box>
) : (
<>
@@ -362,12 +532,37 @@ export default function ExtensionsManager({
const active = globalIdx === selectedIdx;
return (
<Box key={`${ext.type}:${ext.name}`} width={layoutW}>
<Text color={active ? GOLD : TEXT_DIM}>{active ? "▸ " : " "}</Text>
<Box width={nameW}><Text color={active ? TEXT_PRIMARY : TEXT_DIM} bold={active} wrap="truncate">{ext.name}</Text></Box>
<Box width={GUTTER}><Text>{" ".repeat(GUTTER)}</Text></Box>
<Box width={descW}><Text color={TEXT_DIM} wrap="truncate">{ext.description || ""}</Text></Box>
<Box width={GUTTER}><Text>{" ".repeat(GUTTER)}</Text></Box>
<Box width={STATUS_W}><Text color={ext.enabled ? TEAL : TEXT_DIM} wrap="truncate">{ext.enabled ? "enabled" : "disabled"}</Text></Box>
<Text color={active ? GOLD : TEXT_DIM}>
{active ? "▸ " : " "}
</Text>
<Box width={nameW}>
<Text
color={active ? TEXT_PRIMARY : TEXT_DIM}
bold={active}
wrap="truncate"
>
{ext.name}
</Text>
</Box>
<Box width={GUTTER}>
<Text>{" ".repeat(GUTTER)}</Text>
</Box>
<Box width={descW}>
<Text color={TEXT_DIM} wrap="truncate">
{ext.description || ""}
</Text>
</Box>
<Box width={GUTTER}>
<Text>{" ".repeat(GUTTER)}</Text>
</Box>
<Box width={STATUS_W}>
<Text
color={ext.enabled ? TEAL : TEXT_DIM}
wrap="truncate"
>
{ext.enabled ? "enabled" : "disabled"}
</Text>
</Box>
</Box>
);
})}
@@ -375,7 +570,9 @@ export default function ExtensionsManager({
</Box>
{end < entries.length && (
<Box justifyContent="center" marginTop={1}>
<Text color={TEXT_DIM}> {entries.length - end} more below</Text>
<Text color={TEXT_DIM}>
{entries.length - end} more below
</Text>
</Box>
)}
</>
@@ -387,7 +584,11 @@ export default function ExtensionsManager({
<Box width={layoutW} flexDirection="column">
<Text color={GOLD}>Warnings</Text>
{warnings.map((w, i) => (
<Box key={i} width={layoutW}><Text color={TEXT_DIM} wrap="truncate"> {w}</Text></Box>
<Box key={i} width={layoutW}>
<Text color={TEXT_DIM} wrap="truncate">
{w}
</Text>
</Box>
))}
</Box>
</Box>
+117 -65
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from "react";
import { Box, Text, useInput, useStdout } from "ink";
import { TextInput, PasswordInput } from '@inkjs/ui';
import { TextInput, PasswordInput } from "@inkjs/ui";
import type { GooseClient, ProviderInventoryEntryDto } from "@aaif/goose-sdk";
import {
CRANBERRY,
@@ -38,7 +38,14 @@ export interface ProviderSelectorProps {
onBack?: () => void;
}
export const ProviderSelector = React.memo(function ProviderSelector({ providers, height, onSelect, title, subtitle, onBack }: ProviderSelectorProps) {
export const ProviderSelector = React.memo(function ProviderSelector({
providers,
height,
onSelect,
title,
subtitle,
onBack,
}: ProviderSelectorProps) {
const [selectedIdx, setSelectedIdx] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const { stdout } = useStdout();
@@ -55,27 +62,38 @@ export const ProviderSelector = React.memo(function ProviderSelector({ providers
})();
// 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 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)));
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)));
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 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);
@@ -159,7 +177,7 @@ export const ProviderSelector = React.memo(function ProviderSelector({ providers
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)
@@ -167,7 +185,7 @@ export const ProviderSelector = React.memo(function ProviderSelector({ providers
// 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.providerId}
@@ -189,12 +207,10 @@ export const ProviderSelector = React.memo(function ProviderSelector({ providers
{provider.providerType === "Preferred" && (
<Text color={TEAL}></Text>
)}
{provider.configured && (
<Text color={TEAL}></Text>
)}
{provider.configured && <Text color={TEAL}></Text>}
</Box>
</Box>
<Box marginTop={1} flexDirection="column" flexGrow={1}>
<Box width={contentWidth}>
<Text color={TEXT_DIM} wrap="truncate">
@@ -216,22 +232,33 @@ export const ProviderSelector = React.memo(function ProviderSelector({ providers
};
const visibleRows = [];
for (let row = scrollRow; row < Math.min(scrollRow + rowsVisible, totalRows); row++) {
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));
rowProviders.push(
renderProviderCard(filtered[index], index, isSelected),
);
}
}
if (rowProviders.length > 0) {
const isLastVisibleRow = row === Math.min(scrollRow + rowsVisible, totalRows) - 1;
const isLastVisibleRow =
row === Math.min(scrollRow + rowsVisible, totalRows) - 1;
visibleRows.push(
<Box key={row} gap={columnSpacing} marginBottom={isLastVisibleRow ? 0 : rowSpacing}>
<Box
key={row}
gap={columnSpacing}
marginBottom={isLastVisibleRow ? 0 : rowSpacing}
>
{rowProviders}
</Box>
</Box>,
);
}
}
@@ -250,7 +277,7 @@ export const ProviderSelector = React.memo(function ProviderSelector({ providers
{subtitle ?? "Connect an AI model provider to get started"}
</Text>
</Box>
{/* Search Bar */}
<Box justifyContent="center" marginBottom={2}>
<Box
@@ -278,20 +305,21 @@ export const ProviderSelector = React.memo(function ProviderSelector({ providers
<>
{scrollRow > 0 && (
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_DIM}> {scrollRow * cardsPerRow} more above</Text>
<Text color={TEXT_DIM}>
{scrollRow * cardsPerRow} more above
</Text>
</Box>
)}
<Box justifyContent="center">
<Box flexDirection="column">
{visibleRows}
</Box>
<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
{filtered.length - (scrollRow + rowsVisible) * cardsPerRow}{" "}
more below
</Text>
</Box>
)}
@@ -302,7 +330,8 @@ export const ProviderSelector = React.memo(function ProviderSelector({ providers
{/* Footer */}
<Box justifyContent="center" marginTop={2}>
<Text color={TEXT_DIM}>
navigate · enter select · type to search{onBack ? " · esc back" : " · esc clear"}
navigate · enter select · type to search
{onBack ? " · esc back" : " · esc clear"}
</Text>
</Box>
</Box>
@@ -316,7 +345,12 @@ export interface ProviderConfiguratorProps {
onBack: () => void;
}
export const ProviderConfigurator = React.memo(function ProviderConfigurator({ provider, height, onComplete, onBack }: ProviderConfiguratorProps) {
export 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>>({});
@@ -354,7 +388,7 @@ export const ProviderConfigurator = React.memo(function ProviderConfigurator({ p
if (activeKeyIdx < keys.length - 1) {
setActiveKeyIdx(activeKeyIdx + 1);
setShowMasked({});
setInputKey(prev => prev + 1); // Force new input component
setInputKey((prev) => prev + 1); // Force new input component
} else {
onComplete(newValues);
}
@@ -376,12 +410,20 @@ export const ProviderConfigurator = React.memo(function ProviderConfigurator({ p
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 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}>
<Box
flexDirection="column"
height={height}
alignItems="center"
width={columns}
>
{topPad > 0 && <Box height={topPad} />}
<Box flexDirection="column" width={maxWidth} paddingX={2}>
{/* Header */}
@@ -393,7 +435,9 @@ export const ProviderConfigurator = React.memo(function ProviderConfigurator({ p
{provider.description && (
<Box justifyContent="center" marginBottom={1}>
<Box width={maxWidth - 4}>
<Text color={TEXT_DIM} wrap="wrap">{provider.description}</Text>
<Text color={TEXT_DIM} wrap="wrap">
{provider.description}
</Text>
</Box>
</Box>
)}
@@ -411,9 +455,7 @@ export const ProviderConfigurator = React.memo(function ProviderConfigurator({ p
>
{k.name}
</Text>
{i < activeKeyIdx && (
<Text color={TEAL}> </Text>
)}
{i < activeKeyIdx && <Text color={TEAL}> </Text>}
</Box>
))}
@@ -458,19 +500,18 @@ export const ProviderConfigurator = React.memo(function ProviderConfigurator({ p
)}
{/* 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>
)}
{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>
);
@@ -481,7 +522,10 @@ interface SuccessScreenProps {
height: number;
}
const SuccessScreen = React.memo(function SuccessScreen({ provider, height }: SuccessScreenProps) {
const SuccessScreen = React.memo(function SuccessScreen({
provider,
height,
}: SuccessScreenProps) {
const { stdout } = useStdout();
const columns = stdout?.columns ?? 80;
@@ -539,7 +583,9 @@ export default function Onboarding({
useEffect(() => {
(async () => {
try {
const resp = await client.goose.GooseProvidersList({ providerIds: [] });
const resp = await client.goose.providersList_unstable({
providerIds: [],
});
const sorted = [...resp.entries].sort((a, b) => {
const aP = a.providerType === "Preferred" ? 0 : 1;
const bP = b.providerType === "Preferred" ? 0 : 1;
@@ -556,10 +602,13 @@ export default function Onboarding({
}, [client, fetchKey]);
const saveProvider = useCallback(
async (provider: ProviderInventoryEntryDto, values: Record<string, string>) => {
async (
provider: ProviderInventoryEntryDto,
values: Record<string, string>,
) => {
setPhase("saving");
try {
await client.goose.GooseProvidersConfigSave({
await client.goose.providersConfigSave_unstable({
providerId: provider.providerId,
fields: Object.entries(values).map(([key, value]) => ({
key,
@@ -600,7 +649,7 @@ export default function Onboarding({
if (phase === "loading") {
const contentHeight = 3; // spinner + text + spacing
const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
return (
<Box
flexDirection="column"
@@ -622,7 +671,12 @@ export default function Onboarding({
if (phase === "error") {
return (
<Box flexDirection="column" height={height} alignItems="center" width={width}>
<Box
flexDirection="column"
height={height}
alignItems="center"
width={width}
>
<ErrorScreen errorMsg={errorMsg} onRetry={handleRetry} />
</Box>
);
@@ -631,7 +685,7 @@ export default function Onboarding({
if (phase === "saving") {
const contentHeight = 3; // spinner + text + spacing
const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
return (
<Box
flexDirection="column"
@@ -652,9 +706,7 @@ export default function Onboarding({
}
if (phase === "success") {
return (
<SuccessScreen provider={selectedProvider} height={height} />
);
return <SuccessScreen provider={selectedProvider} height={height} />;
}
if (phase === "configure" && selectedProvider) {
+36 -29
View File
@@ -1,5 +1,11 @@
#!/usr/bin/env node
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import React, {
useState,
useEffect,
useCallback,
useMemo,
useRef,
} from "react";
import { Box, Text, render, useApp, useInput, useStdout } from "ink";
import { MultilineInput } from "ink-multiline-input";
import meow from "meow";
@@ -172,7 +178,9 @@ const InputBar = React.memo(function InputBar({
</Text>
</Box>
{scrollHint && (
<Text color={TEXT_DIM}> scroll · fast · shift+ history</Text>
<Text color={TEXT_DIM}>
scroll · fast · shift+ history
</Text>
)}
</Box>
) : (
@@ -200,7 +208,9 @@ const InputBar = React.memo(function InputBar({
}}
/>
{scrollHint && (
<Text color={TEXT_DIM}> scroll · fast · shift+ history</Text>
<Text color={TEXT_DIM}>
scroll · fast · shift+ history
</Text>
)}
</Box>
)}
@@ -792,7 +802,7 @@ function App({
setStatus("checking provider…");
let hasProvider = false;
try {
const resp = await client.goose.GooseDefaultsRead({});
const resp = await client.goose.defaultsRead_unstable({});
hasProvider =
resp.providerId != null &&
resp.providerId !== "" &&
@@ -831,31 +841,28 @@ function App({
exit,
]);
const addLocalTurn = useCallback(
(userText: string, message?: string) => {
setTurns((prev) => [
...prev,
{
userText,
responseItems: message
? [
{
itemType: "content_chunk",
content: { type: "text", text: message },
},
]
: [],
toolCallsById: new Map(),
},
]);
setViewTurnIdx(-1);
setSelectedToolCallIdx(null);
setToolCallExpanded(false);
setToolCallExpandedScroll(0);
setScrollOffset(0);
},
[],
);
const addLocalTurn = useCallback((userText: string, message?: string) => {
setTurns((prev) => [
...prev,
{
userText,
responseItems: message
? [
{
itemType: "content_chunk",
content: { type: "text", text: message },
},
]
: [],
toolCallsById: new Map(),
},
]);
setViewTurnIdx(-1);
setSelectedToolCallIdx(null);
setToolCallExpanded(false);
setToolCallExpandedScroll(0);
setScrollOffset(0);
}, []);
const runSlashCommand = useCallback(
(raw: string): boolean => {