mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat(tui): add slash commands and provider/model selector
This commit is contained in:
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user