feat(tui): add slash commands and provider/model selector

This commit is contained in:
Alex Hancock
2026-03-26 12:21:32 -04:00
parent 5f1d314ec3
commit df615edcd0
4 changed files with 551 additions and 1 deletions
+106
View File
@@ -0,0 +1,106 @@
import React from "react";
import { Box, Text } from "ink";
import { TEXT_PRIMARY, TEXT_SECONDARY, TEXT_DIM, GOLD, RULE_COLOR } from "./colors.js";
export interface SelectOption {
id: string;
name: string;
description?: string;
}
interface SelectModalProps {
title: string;
options: SelectOption[];
selectedIdx: number;
filter: string;
width: number;
onClose?: () => void;
}
export function SelectModal({
title,
options,
selectedIdx,
filter,
width,
}: SelectModalProps) {
const filtered = filterOptions(options, filter);
const modalWidth = Math.min(width - 4, 60);
return (
<Box
flexDirection="column"
marginLeft={2}
marginTop={1}
paddingX={2}
paddingY={1}
borderStyle="round"
borderColor={GOLD}
width={modalWidth}
>
<Text color={GOLD} bold>
{title}
</Text>
{filter && (
<Box marginTop={1}>
<Text color={TEXT_DIM}>Filter: </Text>
<Text color={TEXT_PRIMARY}>{filter}</Text>
</Box>
)}
<Box marginTop={1} flexDirection="column">
{filtered.length === 0 ? (
<Text color={TEXT_DIM} italic>
No matches found
</Text>
) : (
filtered.map((opt, i) => {
const active = i === selectedIdx;
return (
<Box key={opt.id} flexDirection="column">
<Box>
<Text color={active ? GOLD : RULE_COLOR}>
{active ? " ▸ " : " "}
</Text>
<Text
color={active ? TEXT_PRIMARY : TEXT_SECONDARY}
bold={active}
>
{opt.name}
</Text>
</Box>
{opt.description && active && (
<Box paddingLeft={4}>
<Text color={TEXT_DIM}>{opt.description}</Text>
</Box>
)}
</Box>
);
})
)}
</Box>
<Box marginTop={1}>
<Text color={TEXT_DIM}>
↑↓ select · enter confirm · esc cancel · type to filter
</Text>
</Box>
</Box>
);
}
export function filterOptions(
options: SelectOption[],
filter: string,
): SelectOption[] {
if (!filter) return options;
const searchTerm = filter.toLowerCase();
return options.filter(
(opt) =>
opt.name.toLowerCase().includes(searchTerm) ||
opt.id.toLowerCase().includes(searchTerm) ||
(opt.description && opt.description.toLowerCase().includes(searchTerm)),
);
}
+234
View File
@@ -0,0 +1,234 @@
import React, { useState, useEffect } from "react";
import { useInput } from "ink";
import type { GooseClient } from "@aaif/goose-acp";
import { SelectModal, filterOptions } from "./modal.js";
import type { SelectOption } from "./modal.js";
interface ModelInfo {
modelId: string;
name: string;
description?: string | null;
}
interface ProviderModelSwitcherProps {
client: GooseClient;
sessionId: string;
width: number;
onComplete: () => void;
onCancel: () => void;
}
type SwitcherMode = "selecting-provider" | "selecting-model";
export function ProviderModelSwitcher({
client,
sessionId,
width,
onComplete,
onCancel,
}: ProviderModelSwitcherProps) {
const [mode, setMode] = useState<SwitcherMode>("selecting-provider");
const [providers, setProviders] = useState<SelectOption[]>([]);
const [models, setModels] = useState<SelectOption[]>([]);
const [selectedIdx, setSelectedIdx] = useState(0);
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
useEffect(() => {
loadAvailableModels();
}, [client, sessionId]);
const loadAvailableModels = async () => {
try {
setLoading(true);
setError(null);
const sessionInfo = await client.goose.sessionGet({ sessionId });
const session = sessionInfo.session as any;
if (session?.models?.availableModels) {
const modelInfos: ModelInfo[] = session.models.availableModels;
// Extract unique providers from model IDs
const providerSet = new Set<string>();
const modelsByProvider = new Map<string, ModelInfo[]>();
for (const model of modelInfos) {
// Model IDs are typically in format "provider/model-name"
const parts = model.modelId.split("/");
const provider = parts.length > 1 ? parts[0]! : "default";
providerSet.add(provider);
if (!modelsByProvider.has(provider)) {
modelsByProvider.set(provider, []);
}
modelsByProvider.get(provider)!.push(model);
}
const providerOptions: SelectOption[] = Array.from(providerSet).map(
(p) => ({
id: p,
name: p,
description: `${modelsByProvider.get(p)?.length || 0} models available`,
}),
);
setProviders(providerOptions);
// If only one provider, skip to model selection
if (providerOptions.length === 1) {
const singleProvider = providerOptions[0]!.id;
setSelectedProvider(singleProvider);
setModels(
modelsByProvider.get(singleProvider)!.map((m) => ({
id: m.modelId,
name: m.name,
description: m.description || undefined,
})),
);
setMode("selecting-model");
}
} else {
setError("No models available in session");
}
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
setError(`Failed to load models: ${errMsg}`);
} finally {
setLoading(false);
}
};
const handleProviderSelect = (providerId: string) => {
setSelectedProvider(providerId);
// Load models for this provider
const sessionInfo = client.goose.sessionGet({ sessionId });
sessionInfo.then((info) => {
const session = info.session as any;
if (session?.models?.availableModels) {
const modelInfos: ModelInfo[] = session.models.availableModels;
const providerModels = modelInfos.filter((m) =>
m.modelId.startsWith(`${providerId}/`),
);
setModels(
providerModels.map((m) => ({
id: m.modelId,
name: m.name,
description: m.description || undefined,
})),
);
setMode("selecting-model");
setSelectedIdx(0);
setFilter("");
}
});
};
const handleModelSelect = async (modelId: string) => {
try {
await client.unstable_setSessionModel({ sessionId, modelId });
onComplete();
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
setError(`Failed to set model: ${errMsg}`);
}
};
useInput((ch, key) => {
if (key.escape) {
if (mode === "selecting-model" && providers.length > 1) {
// Go back to provider selection
setMode("selecting-provider");
setSelectedIdx(0);
setFilter("");
return;
}
onCancel();
return;
}
const currentOptions =
mode === "selecting-provider" ? providers : models;
const filtered = filterOptions(currentOptions, filter);
if (key.upArrow) {
setSelectedIdx((i) => (i - 1 + filtered.length) % filtered.length);
return;
}
if (key.downArrow) {
setSelectedIdx((i) => (i + 1) % filtered.length);
return;
}
if (key.return) {
const selected = filtered[selectedIdx];
if (selected) {
if (mode === "selecting-provider") {
handleProviderSelect(selected.id);
} else {
handleModelSelect(selected.id);
}
}
return;
}
if (key.backspace || key.delete) {
setFilter((f) => f.slice(0, -1));
setSelectedIdx(0);
return;
}
if (ch && ch.length === 1 && !key.ctrl && !key.meta) {
setFilter((f) => f + ch);
setSelectedIdx(0);
}
});
if (loading) {
return (
<SelectModal
title="Loading models..."
options={[]}
selectedIdx={0}
filter=""
width={width}
/>
);
}
if (error) {
return (
<SelectModal
title={`Error: ${error}`}
options={[]}
selectedIdx={0}
filter=""
width={width}
/>
);
}
const title =
mode === "selecting-provider"
? "Select Provider"
: `Select Model (${selectedProvider})`;
const options = mode === "selecting-provider" ? providers : models;
return (
<SelectModal
title={title}
options={options}
selectedIdx={selectedIdx}
filter={filter}
width={width}
/>
);
}
export { type SwitcherMode };
+84
View File
@@ -0,0 +1,84 @@
import React from "react";
import { Text, Box } from "ink";
import { TEXT_PRIMARY, TEXT_SECONDARY, TEXT_DIM, GOLD, RULE_COLOR } from "./colors.js";
export interface SlashCommand {
name: string;
description: string;
handler: () => void | Promise<void>;
}
interface SlashCommandMenuProps {
commands: SlashCommand[];
filter: string;
selectedIdx: number;
width: number;
}
export function SlashCommandMenu({
commands,
filter,
selectedIdx,
width,
}: SlashCommandMenuProps) {
const filtered = filterCommands(commands, filter);
if (filtered.length === 0) {
return null;
}
const menuWidth = Math.min(width - 6, 50);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={GOLD}
width={menuWidth}
paddingX={1}
>
{filtered.map((cmd, i) => {
const active = i === selectedIdx;
return (
<Box key={cmd.name}>
<Text color={active ? GOLD : RULE_COLOR}>
{active ? "▸ " : " "}
</Text>
<Text color={active ? TEXT_PRIMARY : TEXT_SECONDARY} bold={active}>
/{cmd.name}
</Text>
<Text color={TEXT_DIM}> — {cmd.description}</Text>
</Box>
);
})}
</Box>
);
}
export function filterCommands(
commands: SlashCommand[],
filter: string,
): SlashCommand[] {
const searchTerm = filter.toLowerCase();
return commands.filter(
(cmd) =>
cmd.name.toLowerCase().includes(searchTerm) ||
cmd.description.toLowerCase().includes(searchTerm),
);
}
export function detectSlashCommand(input: string): {
isSlashCommand: boolean;
commandText: string;
} {
if (input.startsWith("/")) {
return {
isSlashCommand: true,
commandText: input.slice(1),
};
}
return {
isSlashCommand: false,
commandText: "",
};
}
+127 -1
View File
@@ -24,6 +24,9 @@ import { renderMarkdown } from "./markdown.js";
import { buildToolCallCardLines, ToolCallCompact, findFeaturedToolCallId } from "./toolcall.js";
import type { ToolCallInfo } from "./toolcall.js";
import { CRANBERRY, TEAL, GOLD, TEXT_PRIMARY, TEXT_SECONDARY, TEXT_DIM, RULE_COLOR } from "./colors.js";
import { SlashCommandMenu, detectSlashCommand, filterCommands } from "./slash-commands.js";
import type { SlashCommand } from "./slash-commands.js";
import { ProviderModelSwitcher } from "./provider-model-switcher.js";
interface PendingPermission {
toolTitle: string;
@@ -273,6 +276,9 @@ function InputBar({
onSubmit,
queued,
scrollHint,
slashCommands,
slashCommandIdx,
showSlashMenu,
}: {
width: number;
input: string;
@@ -280,9 +286,22 @@ function InputBar({
onSubmit: (v: string) => void;
queued: boolean;
scrollHint: boolean;
slashCommands?: SlashCommand[];
slashCommandIdx?: number;
showSlashMenu?: boolean;
}) {
return (
<Box flexDirection="column" width={width} marginBottom={1}>
{showSlashMenu && slashCommands && slashCommands.length > 0 && (
<Box marginBottom={1}>
<SlashCommandMenu
commands={slashCommands}
filter={detectSlashCommand(input).commandText}
selectedIdx={slashCommandIdx || 0}
width={width}
/>
</Box>
)}
<Box
flexDirection="column"
borderStyle="round"
@@ -569,6 +588,10 @@ function App({
const [toolCallsExpanded, setToolCallsExpanded] = useState(false);
const [scrollOffset, setScrollOffset] = useState(0);
const [showSlashMenu, setShowSlashMenu] = useState(false);
const [slashCommandIdx, setSlashCommandIdx] = useState(0);
const [showProviderModelSwitcher, setShowProviderModelSwitcher] = useState(false);
const clientRef = useRef<GooseClient | null>(null);
const sessionIdRef = useRef<string | null>(null);
const streamBuf = useRef("");
@@ -868,14 +891,52 @@ function App({
exit,
]);
const slashCommands: SlashCommand[] = [
{
name: "model",
description: "Switch provider or model",
handler: () => {
setShowProviderModelSwitcher(true);
setShowSlashMenu(false);
},
},
{
name: "provider",
description: "Switch provider or model",
handler: () => {
setShowProviderModelSwitcher(true);
setShowSlashMenu(false);
},
},
];
const handleSubmit = useCallback(
(value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
// Check if this is a slash command
const { isSlashCommand, commandText } = detectSlashCommand(trimmed);
if (isSlashCommand) {
const filtered = filterCommands(slashCommands, commandText);
const exactMatch = filtered.find(
(cmd) => cmd.name.toLowerCase() === commandText.toLowerCase(),
);
const commandToExecute = exactMatch || filtered[slashCommandIdx];
if (commandToExecute) {
setInput("");
setShowSlashMenu(false);
commandToExecute.handler();
return;
}
}
setInput("");
setViewTurnIdx(-1);
setToolCallsExpanded(false);
setScrollOffset(0);
setShowSlashMenu(false);
if (loading || isProcessingRef.current) {
queueRef.current.push(trimmed);
@@ -884,18 +945,40 @@ function App({
sendPrompt(trimmed);
}
},
[loading, sendPrompt],
[loading, sendPrompt, slashCommandIdx, slashCommands],
);
useEffect(() => {
const { isSlashCommand } = detectSlashCommand(input);
setShowSlashMenu(isSlashCommand);
if (isSlashCommand) {
setSlashCommandIdx(0);
}
}, [input]);
useInput((ch, key) => {
if (key.escape || (ch === "c" && key.ctrl)) {
if (showProviderModelSwitcher) {
setShowProviderModelSwitcher(false);
return;
}
if (pendingPermission) {
resolvePermission("cancelled");
return;
}
if (showSlashMenu) {
setInput("");
setShowSlashMenu(false);
return;
}
exit();
}
if (showProviderModelSwitcher) {
// Provider/model switcher handles its own input
return;
}
if (pendingPermission) {
const opts = pendingPermission.options;
@@ -927,6 +1010,22 @@ function App({
return;
}
if (showSlashMenu) {
const { commandText } = detectSlashCommand(input);
const filtered = filterCommands(slashCommands, commandText);
if (key.upArrow) {
setSlashCommandIdx((i) => (i - 1 + filtered.length) % filtered.length);
return;
}
if (key.downArrow) {
setSlashCommandIdx((i) => (i + 1) % filtered.length);
return;
}
// Return key is handled by handleSubmit
return;
}
if (key.tab) {
const effectiveIdx =
viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx;
@@ -1082,8 +1181,35 @@ function App({
onSubmit={handleSubmit}
queued={queuedMessages.length > 0}
scrollHint={turns.length > 1}
slashCommands={slashCommands}
slashCommandIdx={slashCommandIdx}
showSlashMenu={showSlashMenu}
/>
)}
{showProviderModelSwitcher && clientRef.current && sessionIdRef.current && (
<Box
position="absolute"
flexDirection="column"
width={termWidth}
height={termHeight}
justifyContent="center"
alignItems="center"
>
<ProviderModelSwitcher
client={clientRef.current}
sessionId={sessionIdRef.current}
width={innerWidth}
onComplete={() => {
setShowProviderModelSwitcher(false);
setStatus("model updated");
}}
onCancel={() => {
setShowProviderModelSwitcher(false);
}}
/>
</Box>
)}
</Box>
);
}