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);
+ }}
+ />
+
+ )}
);
}