diff --git a/ui/text/src/modal.tsx b/ui/text/src/modal.tsx new file mode 100644 index 0000000000..5b515b0903 --- /dev/null +++ b/ui/text/src/modal.tsx @@ -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 ( + + + {title} + + + {filter && ( + + Filter: + {filter} + + )} + + + {filtered.length === 0 ? ( + + No matches found + + ) : ( + filtered.map((opt, i) => { + const active = i === selectedIdx; + return ( + + + + {active ? " ▸ " : " "} + + + {opt.name} + + + {opt.description && active && ( + + {opt.description} + + )} + + ); + }) + )} + + + + + ↑↓ select · enter confirm · esc cancel · type to filter + + + + ); +} + +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)), + ); +} diff --git a/ui/text/src/provider-model-switcher.tsx b/ui/text/src/provider-model-switcher.tsx new file mode 100644 index 0000000000..e93f7136da --- /dev/null +++ b/ui/text/src/provider-model-switcher.tsx @@ -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("selecting-provider"); + const [providers, setProviders] = useState([]); + const [models, setModels] = useState([]); + const [selectedIdx, setSelectedIdx] = useState(0); + const [filter, setFilter] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedProvider, setSelectedProvider] = useState(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(); + const modelsByProvider = new Map(); + + 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 ( + + ); + } + + if (error) { + return ( + + ); + } + + const title = + mode === "selecting-provider" + ? "Select Provider" + : `Select Model (${selectedProvider})`; + const options = mode === "selecting-provider" ? providers : models; + + return ( + + ); +} + +export { type SwitcherMode }; diff --git a/ui/text/src/slash-commands.tsx b/ui/text/src/slash-commands.tsx new file mode 100644 index 0000000000..bd7569b6ec --- /dev/null +++ b/ui/text/src/slash-commands.tsx @@ -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; +} + +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 ( + + {filtered.map((cmd, i) => { + const active = i === selectedIdx; + return ( + + + {active ? "▸ " : " "} + + + /{cmd.name} + + — {cmd.description} + + ); + })} + + ); +} + +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: "", + }; +} diff --git a/ui/text/src/tui.tsx b/ui/text/src/tui.tsx index 57fc4d17d7..36e5743303 100644 --- a/ui/text/src/tui.tsx +++ b/ui/text/src/tui.tsx @@ -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 ( + {showSlashMenu && slashCommands && slashCommands.length > 0 && ( + + + + )} (null); const sessionIdRef = useRef(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 && ( + + { + setShowProviderModelSwitcher(false); + setStatus("model updated"); + }} + onCancel={() => { + setShowProviderModelSwitcher(false); + }} + /> + + )} ); }