diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 13595010a..7ac2ac39d 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -9,7 +9,7 @@ import { onMount, } from "solid-js"; -import type { Provider } from "@opencode-ai/sdk/v2/client"; +import type { Agent, Provider } from "@opencode-ai/sdk/v2/client"; import { getVersion } from "@tauri-apps/api/app"; import { parse } from "jsonc-parser"; @@ -98,6 +98,8 @@ import { } from "./lib/tauri"; export default function App() { + type ProviderAuthMethod = { type: "oauth" | "api"; label: string }; + const initialView: View = (() => { if (typeof window === "undefined") return "onboarding"; try { @@ -157,6 +159,11 @@ export default function App() { const [sessionModelById, setSessionModelById] = createSignal< Record >({}); + const [sessionAgentById, setSessionAgentById] = createSignal>({}); + const [providerAuthModalOpen, setProviderAuthModalOpen] = createSignal(false); + const [providerAuthBusy, setProviderAuthBusy] = createSignal(false); + const [providerAuthError, setProviderAuthError] = createSignal(null); + const [providerAuthMethods, setProviderAuthMethods] = createSignal>({}); const sessionStore = createSessionStore({ client, @@ -195,6 +202,7 @@ export default function App() { loadSessions, refreshPendingPermissions, selectSession, + renameSession, respondPermission, setSessions, setSessionStatusById, @@ -227,6 +235,7 @@ export default function App() { activeArtifacts, activeWorkingFiles, selectDemoSession, + renameDemoSession, } = demoState; const [prompt, setPrompt] = createSignal(""); @@ -256,10 +265,12 @@ export default function App() { setPrompt(""); const model = selectedSessionModel(); + const agent = selectedSessionAgent(); await c.session.promptAsync({ sessionID, model, + agent: agent ?? undefined, variant: modelVariant() ?? undefined, parts: [{ type: "text", text: content }], }); @@ -289,6 +300,169 @@ export default function App() { } } + async function renameSessionTitle(sessionID: string, title: string) { + const trimmed = title.trim(); + if (!trimmed) { + throw new Error("Session name is required"); + } + + if (isDemoMode()) { + renameDemoSession(sessionID, trimmed); + return; + } + + await renameSession(sessionID, trimmed); + } + + async function openConnectFlow() { + setView("onboarding"); + setMode("client"); + setOnboardingStep("client"); + } + + async function listAgents(): Promise { + const c = client(); + if (!c) return []; + const list = unwrap(await c.app.agents()); + return list.filter((agent) => !agent.hidden && agent.mode !== "subagent"); + } + + function setSessionAgent(sessionID: string, agent: string | null) { + const trimmed = agent?.trim() ?? ""; + setSessionAgentById((current) => { + const next = { ...current }; + if (!trimmed) { + delete next[sessionID]; + return next; + } + next[sessionID] = trimmed; + return next; + }); + } + + async function startProviderAuth(providerId?: string) { + const c = client(); + if (!c) { + throw new Error("Not connected to a server"); + } + + const authMethods = unwrap(await c.provider.auth()); + const providerIds = Object.keys(authMethods).sort(); + if (!providerIds.length) { + throw new Error("No providers available"); + } + + const resolved = providerId?.trim() ?? ""; + if (!resolved) { + throw new Error("Provider ID is required"); + } + if (!authMethods[resolved]) { + throw new Error(`Unknown provider: ${resolved}`); + } + + const methods = authMethods[resolved]; + if (!methods || !methods.length) { + throw new Error(`No auth methods for ${resolved}`); + } + + const oauthIndex = methods.findIndex((method) => method.type === "oauth"); + if (oauthIndex === -1) { + return `Configure ${resolved} API keys in opencode.json`; + } + + const auth = unwrap(await c.provider.oauth.authorize({ providerID: resolved, method: oauthIndex })); + if (isTauriRuntime()) { + const { openUrl } = await import("@tauri-apps/plugin-opener"); + await openUrl(auth.url); + } else { + window.open(auth.url, "_blank", "noopener,noreferrer"); + } + + return auth.instructions || `Opened ${resolved} auth in browser`; + } + + async function openProviderAuthModal() { + const c = client(); + if (!c) { + throw new Error("Not connected to a server"); + } + + setProviderAuthBusy(true); + setProviderAuthError(null); + try { + const methods = unwrap(await c.provider.auth()); + setProviderAuthMethods(methods as Record); + setProviderAuthModalOpen(true); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load providers"; + setProviderAuthError(message); + throw error; + } finally { + setProviderAuthBusy(false); + } + } + + function closeProviderAuthModal() { + setProviderAuthModalOpen(false); + setProviderAuthError(null); + } + + async function saveSessionExport(sessionID: string) { + if (isDemoMode()) { + const payload = { + sessionID, + messages: activeMessages(), + todos: activeTodos(), + exportedAt: new Date().toISOString(), + source: "openwork", + }; + return downloadSessionExport(payload, `session-${sessionID}.json`); + } + + const c = client(); + if (!c) { + throw new Error("Not connected to a server"); + } + + const session = unwrap(await c.session.get({ sessionID })); + const messages = unwrap(await c.session.messages({ sessionID })); + let todos: TodoItem[] = []; + try { + todos = unwrap(await c.session.todo({ sessionID })); + } catch { + // ignore + } + + const payload = { + session, + messages, + todos, + exportedAt: new Date().toISOString(), + source: "openwork", + }; + + const baseName = session.title || session.slug || session.id; + const safeName = baseName + .toLowerCase() + .replace(/[^a-z0-9\-_.]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + const fileName = `session-${safeName || session.id}.json`; + return downloadSessionExport(payload, fileName); + } + + function downloadSessionExport(payload: unknown, fileName: string) { + const json = JSON.stringify(payload, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); + return fileName; + } + async function respondPermissionAndRemember( @@ -629,6 +803,12 @@ export default function App() { return defaultModel(); }); + const selectedSessionAgent = createMemo(() => { + const id = selectedSessionId(); + if (!id) return null; + return sessionAgentById()[id] ?? null; + }); + const selectedSessionModelLabel = createMemo(() => formatModelLabel(selectedSessionModel(), providers()) ); @@ -1779,6 +1959,19 @@ export default function App() { respondPermissionAndRemember={respondPermissionAndRemember} safeStringify={safeStringify} showTryNotionPrompt={tryNotionPromptVisible() && notionIsActive()} + openConnect={openConnectFlow} + startProviderAuth={startProviderAuth} + openProviderAuthModal={openProviderAuthModal} + closeProviderAuthModal={closeProviderAuthModal} + providerAuthModalOpen={providerAuthModalOpen()} + providerAuthBusy={providerAuthBusy()} + providerAuthError={providerAuthError()} + providerAuthMethods={providerAuthMethods()} + providers={providers()} + providerConnectedIds={providerConnectedIds()} + listAgents={listAgents} + setSessionAgent={setSessionAgent} + saveSession={saveSessionExport} onTryNotionPrompt={() => { setPrompt("setup my crm"); setTryNotionPromptVisible(false); @@ -1790,6 +1983,7 @@ export default function App() { } }} sessionStatus={selectedSessionStatus()} + renameSession={renameSessionTitle} error={error()} /> diff --git a/packages/app/src/app/components/model-picker-modal.tsx b/packages/app/src/app/components/model-picker-modal.tsx index f738af27d..e5b1b6cbe 100644 --- a/packages/app/src/app/components/model-picker-modal.tsx +++ b/packages/app/src/app/components/model-picker-modal.tsx @@ -67,9 +67,8 @@ export default function ModelPickerModal(props: ModelPickerModalProps) { }); createEffect(() => { - if (!props.open) return; - const onKeyDown = (event: KeyboardEvent) => { + if (!props.open) return; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); diff --git a/packages/app/src/app/components/provider-auth-modal.tsx b/packages/app/src/app/components/provider-auth-modal.tsx new file mode 100644 index 000000000..e29a1847e --- /dev/null +++ b/packages/app/src/app/components/provider-auth-modal.tsx @@ -0,0 +1,147 @@ +import { CheckCircle2, X } from "lucide-solid"; +import type { Provider } from "@opencode-ai/sdk/v2/client"; +import { createMemo, For, Show } from "solid-js"; + +import Button from "./button"; + +type ProviderAuthMethod = { type: "oauth" | "api"; label: string }; +type ProviderAuthEntry = { + id: string; + name: string; + methods: ProviderAuthMethod[]; + connected: boolean; +}; + +export type ProviderAuthModalProps = { + open: boolean; + loading: boolean; + submitting: boolean; + error: string | null; + providers: Provider[]; + connectedProviderIds: string[]; + authMethods: Record; + onSelect: (providerId: string) => void; + onClose: () => void; +}; + +export default function ProviderAuthModal(props: ProviderAuthModalProps) { + const entries = createMemo(() => { + const methods = props.authMethods ?? {}; + const connected = new Set(props.connectedProviderIds ?? []); + const providers = props.providers ?? []; + + return Object.keys(methods) + .map((id): ProviderAuthEntry => { + const provider = providers.find((item) => item.id === id); + return { + id, + name: provider?.name ?? id, + methods: methods[id] ?? [], + connected: connected.has(id), + }; + }) + .sort((a, b) => { + const aIsOpencode = a.id === "opencode"; + const bIsOpencode = b.id === "opencode"; + if (aIsOpencode !== bIsOpencode) return aIsOpencode ? -1 : 1; + return a.name.localeCompare(b.name); + }); + }); + + const methodLabel = (method: ProviderAuthMethod) => + method.label || (method.type === "oauth" ? "OAuth" : "API key"); + + const actionDisabled = () => props.loading || props.submitting; + + return ( + +
+
+
+
+
+

Connect provider

+

Choose a provider to authenticate.

+
+ +
+ + +
+ {props.error} +
+
+ + +
+ Loading providers... +
+
+ + +
+ No providers available.
} + > + + {(entry) => ( + + )} + +
+
+ + + +
Opening authentication...
+
+ +
+ OAuth providers open in your browser. API key providers require editing your `opencode.json`. +
+ + +
+
+ +
+ ); +} diff --git a/packages/app/src/app/context/session.ts b/packages/app/src/app/context/session.ts index 01494d441..9c281790c 100644 --- a/packages/app/src/app/context/session.ts +++ b/packages/app/src/app/context/session.ts @@ -42,6 +42,18 @@ type StoreState = { const sortById = (list: T[]) => list.slice().sort((a, b) => a.id.localeCompare(b.id)); +const sessionActivity = (session: Session) => + session.time?.updated ?? session.time?.created ?? 0; + +const sortSessionsByActivity = (list: Session[]) => + list + .slice() + .sort((a, b) => { + const delta = sessionActivity(b) - sessionActivity(a); + if (delta !== 0) return delta; + return a.id.localeCompare(b.id); + }); + const createPlaceholderMessage = (part: Part): PlaceholderAssistantMessage => ({ id: part.messageID, sessionID: part.sessionID, @@ -59,10 +71,10 @@ const createPlaceholderMessage = (part: Part): PlaceholderAssistantMessage => ({ const upsertSession = (list: Session[], next: Session) => { const index = list.findIndex((session) => session.id === next.id); - if (index === -1) return sortById([...list, next]); + if (index === -1) return sortSessionsByActivity([...list, next]); const copy = list.slice(); copy[index] = next; - return copy; + return sortSessionsByActivity(copy); }; const removeSession = (list: Session[], sessionID: string) => list.filter((session) => session.id !== sessionID); @@ -168,7 +180,18 @@ export function createSessionStore(options: { const filtered = root ? list.filter((session) => normalizeDirectoryPath(session.directory) === root) : list; - setStore("sessions", reconcile(sortById(filtered), { key: "id" })); + setStore("sessions", reconcile(sortSessionsByActivity(filtered), { key: "id" })); + } + + async function renameSession(sessionID: string, title: string) { + const c = options.client(); + if (!c) return; + const trimmed = title.trim(); + if (!trimmed) { + throw new Error("Session name is required"); + } + const next = unwrap(await c.session.update({ sessionID, title: trimmed })); + setStore("sessions", (current) => upsertSession(current, next)); } async function refreshPendingPermissions() { @@ -299,7 +322,7 @@ export function createSessionStore(options: { } const setSessions = (next: Session[]) => { - setStore("sessions", reconcile(sortById(next), { key: "id" })); + setStore("sessions", reconcile(sortSessionsByActivity(next), { key: "id" })); }; const setSessionStatusById = (next: Record) => { @@ -597,6 +620,7 @@ export function createSessionStore(options: { loadSessions, refreshPendingPermissions, selectSession, + renameSession, respondPermission, setSessions, setSessionStatusById, diff --git a/packages/app/src/app/demo-state.ts b/packages/app/src/app/demo-state.ts index 83c5b1670..dce8f0ec0 100644 --- a/packages/app/src/app/demo-state.ts +++ b/packages/app/src/app/demo-state.ts @@ -237,7 +237,12 @@ export function createDemoState(options: { const workingFiles = createMemo(() => deriveWorkingFiles(artifacts())); const activeSessionId = createMemo(() => (isDemoMode() ? demoSelectedSessionId() : options.selectedSessionId())); - const activeSessions = createMemo(() => (isDemoMode() ? demoSessions() : options.sessions())); + const activeSessions = createMemo(() => { + if (!isDemoMode()) return options.sessions(); + return demoSessions() + .slice() + .sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0) || a.id.localeCompare(b.id)); + }); const activeSessionStatusById = createMemo(() => isDemoMode() ? demoSessionStatusById() : options.sessionStatusById(), ); @@ -250,6 +255,22 @@ export function createDemoState(options: { setDemoSelectedSessionId(sessionId); }; + const renameDemoSession = (sessionId: string, title: string) => { + const trimmed = title.trim(); + if (!trimmed) return; + setDemoSessions((current) => + current.map((session) => + session.id === sessionId + ? { + ...session, + title: trimmed, + time: { ...session.time, updated: Date.now() }, + } + : session, + ), + ); + }; + createEffect(() => { if (!isDemoMode()) return; setDemoSequenceState(demoSequence()); @@ -271,6 +292,7 @@ export function createDemoState(options: { activeArtifacts, activeWorkingFiles, selectDemoSession, + renameDemoSession, setDemoSelectedSessionId, demoSelectedSessionId, }; diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index 262c5eab5..6105cd8bc 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -1,5 +1,5 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; -import type { Part } from "@opencode-ai/sdk/v2/client"; +import type { Agent, Part, Provider } from "@opencode-ai/sdk/v2/client"; import type { ArtifactItem, DashboardTab, @@ -28,6 +28,7 @@ import { import Button from "../components/button"; import PartView from "../components/part-view"; import WorkspaceChip from "../components/workspace-chip"; +import ProviderAuthModal from "../components/provider-auth-modal"; import { isTauriRuntime, isWindowsPlatform } from "../utils"; export type SessionViewProps = { @@ -81,6 +82,20 @@ export type SessionViewProps = { safeStringify: (value: unknown) => string; error: string | null; sessionStatus: string; + renameSession: (sessionId: string, title: string) => Promise | void; + openConnect: () => void; + startProviderAuth: (providerId?: string) => Promise; + openProviderAuthModal: () => Promise; + closeProviderAuthModal: () => void; + providerAuthModalOpen: boolean; + providerAuthBusy: boolean; + providerAuthError: string | null; + providerAuthMethods: Record; + providers: Provider[]; + providerConnectedIds: string[]; + listAgents: () => Promise; + setSessionAgent: (sessionId: string, agent: string | null) => void; + saveSession: (sessionId: string) => Promise; }; export default function SessionView(props: SessionViewProps) { @@ -106,21 +121,26 @@ export default function SessionView(props: SessionViewProps) { const [artifactToast, setArtifactToast] = createSignal(null); const [commandToast, setCommandToast] = createSignal(null); const [commandIndex, setCommandIndex] = createSignal(0); + const [providerAuthActionBusy, setProviderAuthActionBusy] = createSignal(false); let promptInputEl: HTMLTextAreaElement | undefined; + const focusPromptInput = () => { + if (props.busy) return; + const el = promptInputEl; + if (!el) return; + el.focus(); + try { + const len = el.value.length; + el.setSelectionRange(len, len); + } catch { + // ignore + } + }; + createEffect(() => { const handler = () => { - if (props.busy) return; - const el = promptInputEl; - if (!el) return; - el.focus(); - try { - const len = el.value.length; - el.setSelectionRange(len, len); - } catch { - // ignore - } + focusPromptInput(); }; window.addEventListener("openwork:focusPrompt", handler); @@ -300,7 +320,85 @@ export default function SessionView(props: SessionViewProps) { queueMicrotask(syncPromptHeight); }; + const extractCommandArgs = (raw: string) => { + const trimmed = raw.trim(); + if (!trimmed.startsWith("/")) return ""; + const body = trimmed.slice(1).trim(); + const spaceIndex = body.indexOf(" "); + if (spaceIndex === -1) return ""; + return body.slice(spaceIndex + 1).trim(); + }; + + const extractCommandRemainder = (raw: string) => { + if (!raw.startsWith("/")) return ""; + const body = raw.slice(1); + const spaceIndex = body.search(/\s/); + if (spaceIndex === -1) return ""; + return body.slice(spaceIndex); + }; + + const applyCommandCompletion = (commandId: string) => { + if (!commandId) return; + const remainder = extractCommandRemainder(props.prompt); + const next = `/${commandId}${remainder}`; + if (next === props.prompt) return; + props.setPrompt(next); + queueMicrotask(() => { + syncPromptHeight(); + if (!promptInputEl) return; + const length = promptInputEl.value.length; + promptInputEl.focus(); + promptInputEl.setSelectionRange(length, length); + }); + }; + + const requireSessionId = () => { + const sessionId = props.selectedSessionId; + if (!sessionId) { + setCommandToast("No session selected"); + return null; + } + return sessionId; + }; + + const formatListHint = (items: string[]) => { + if (!items.length) return ""; + const preview = items.slice(0, 4).join(", "); + return items.length > 4 ? `${preview}, ...` : preview; + }; + + const activeSessionTitle = createMemo(() => { + const selectedId = props.selectedSessionId; + if (!selectedId) return ""; + const entry = props.sessions.find((session) => session.id === selectedId); + return entry?.title ?? ""; + }); + + const handleProviderAuthSelect = async (providerId: string) => { + if (providerAuthActionBusy()) return; + setProviderAuthActionBusy(true); + try { + const message = await props.startProviderAuth(providerId); + setCommandToast(message || "Auth flow started"); + props.closeProviderAuthModal(); + } catch (error) { + const message = error instanceof Error ? error.message : "Auth failed"; + setCommandToast(message); + } finally { + setProviderAuthActionBusy(false); + } + }; + const commandList = createMemo(() => [ + { + id: "model", + label: "Model", + description: "Choose a model", + run: () => { + props.openSessionModelPicker(); + clearPrompt(); + }, + }, { id: "models", label: "Models", @@ -310,12 +408,155 @@ export default function SessionView(props: SessionViewProps) { clearPrompt(); }, }, + { + id: "connect", + label: "Connect", + description: "Connect to a server", + run: () => { + props.openConnect(); + setCommandToast("Opening connection settings"); + clearPrompt(); + }, + }, + { + id: "auth", + label: "Auth", + description: "Authenticate a provider", + run: async () => { + try { + const providerId = extractCommandArgs(props.prompt); + if (providerId) { + const message = await props.startProviderAuth(providerId); + setCommandToast(message || "Auth flow started"); + clearPrompt(); + return; + } + + await props.openProviderAuthModal(); + setCommandToast("Select a provider to authenticate"); + clearPrompt(); + } catch (error) { + const message = error instanceof Error ? error.message : "Auth failed"; + setCommandToast(message); + } + }, + }, + { + id: "agent", + label: "Agent", + description: "Choose an agent", + run: async () => { + const sessionId = requireSessionId(); + if (!sessionId) return; + + try { + const rawArg = extractCommandArgs(props.prompt); + if (/^(none|clear|default)$/i.test(rawArg)) { + props.setSessionAgent(sessionId, null); + setCommandToast("Agent cleared"); + clearPrompt(); + return; + } + + const agents = await props.listAgents(); + if (!agents.length) { + setCommandToast("No agents available"); + clearPrompt(); + return; + } + + const agentNames = agents.map((agent) => agent.name); + let candidate = rawArg; + if (!candidate) { + const hint = formatListHint(agentNames); + const promptLabel = hint ? `Agent name (e.g. ${hint})` : "Agent name"; + const prompted = window.prompt(promptLabel, agentNames[0] ?? ""); + if (prompted == null) return; + candidate = prompted.trim(); + } + + if (!candidate) { + setCommandToast("Agent name is required"); + clearPrompt(); + return; + } + + const match = agents.find( + (agent) => agent.name.toLowerCase() === candidate.toLowerCase(), + ); + if (!match) { + setCommandToast(`Unknown agent. Available: ${formatListHint(agentNames)}`); + clearPrompt(); + return; + } + + props.setSessionAgent(sessionId, match.name); + setCommandToast(`Agent set to ${match.name}`); + clearPrompt(); + } catch (error) { + const message = error instanceof Error ? error.message : "Agent selection failed"; + setCommandToast(message); + } + }, + }, + { + id: "save", + label: "Save", + description: "Export session JSON", + run: async () => { + const sessionId = requireSessionId(); + if (!sessionId) return; + + try { + const fileName = await props.saveSession(sessionId); + setCommandToast(`Saved ${fileName}`); + clearPrompt(); + } catch (error) { + const message = error instanceof Error ? error.message : "Save failed"; + setCommandToast(message); + } + }, + }, + { + id: "rename", + label: "Rename", + description: "Rename this session", + run: async () => { + const sessionId = requireSessionId(); + if (!sessionId) return; + + let nextTitle = extractCommandArgs(props.prompt); + if (!nextTitle) { + const fallback = activeSessionTitle(); + const prompted = window.prompt("Rename session", fallback); + if (prompted == null) { + return; + } + nextTitle = prompted.trim(); + } + + if (!nextTitle) { + setCommandToast("Session name is required"); + clearPrompt(); + return; + } + + try { + await props.renameSession(sessionId, nextTitle); + setCommandToast("Session renamed"); + clearPrompt(); + } catch (error) { + const message = error instanceof Error ? error.message : "Rename failed"; + setCommandToast(message); + } + }, + }, { id: "help", label: "Help", description: "Show available commands", run: () => { - setCommandToast("Commands: /models, /help, /clear"); + setCommandToast("Commands: /model, /models, /connect, /auth, /agent, /save, /rename, /help, /clear"); clearPrompt(); }, }, @@ -350,6 +591,23 @@ export default function SessionView(props: SessionViewProps) { const commandMenuOpen = createMemo(() => commandInput() !== null && !props.busy); + createEffect(() => { + if (!commandMenuOpen()) return; + queueMicrotask(focusPromptInput); + }); + + const moveCommandIndex = (delta: number) => { + const matches = commandMatches(); + if (!matches.length) return; + setCommandIndex((current) => { + const next = current + delta; + if (next < 0) return 0; + if (next >= matches.length) return matches.length - 1; + return next; + }); + }; + + createEffect(() => { const matches = commandMatches(); if (!matches.length) { @@ -367,21 +625,34 @@ export default function SessionView(props: SessionViewProps) { const runCommand = (commandId?: string) => { if (!commandId) { setCommandToast("Unknown command"); - return; + return null; } const command = commandList().find((entry) => entry.id === commandId); if (!command) { + setCommandToast("Unknown command"); + return null; + } + return command.run(); + }; + + const selectCommand = (commandId?: string) => { + if (!commandId) { setCommandToast("Unknown command"); return; } - command.run(); + applyCommandCompletion(commandId); + const result = runCommand(commandId); + if (result === null) return; + Promise.resolve(result).finally(() => { + clearPrompt(); + }); }; const handlePrimaryAction = () => { if (commandMenuOpen()) { const matches = commandMatches(); - const active = matches[commandIndex()]; - runCommand(active?.id); + const active = matches[commandIndex()] ?? matches[0]; + selectCommand(active?.id); return; } props.sendPromptAsync().catch(() => undefined); @@ -392,26 +663,33 @@ export default function SessionView(props: SessionViewProps) { if (event.isComposing && event.key !== "Enter") return; const menuOpen = commandMenuOpen(); - const matches = commandMatches(); - if (menuOpen) { + const matches = commandMatches(); + if (event.key === "Enter") { + event.preventDefault(); + handlePrimaryAction(); + } if (event.key === "ArrowDown") { event.preventDefault(); if (!matches.length) return; - setCommandIndex((current) => Math.min(current + 1, matches.length - 1)); + moveCommandIndex(1); return; } if (event.key === "ArrowUp") { event.preventDefault(); if (!matches.length) return; - setCommandIndex((current) => Math.max(current - 1, 0)); + moveCommandIndex(-1); + return; + } + if (event.key === "Tab") { + event.preventDefault(); return; } if (event.key === "Escape") { event.preventDefault(); clearPrompt(); - return; } + return; } if (event.key === "Enter") { @@ -915,9 +1193,12 @@ export default function SessionView(props: SessionViewProps) { }`} onMouseDown={(event) => { event.preventDefault(); - runCommand(command.id); + selectCommand(command.id); + }} + onMouseEnter={() => { + setCommandIndex(idx()); + promptInputEl?.focus(); }} - onMouseEnter={() => setCommandIndex(idx())} >
/{command.id}
{command.description}
@@ -966,6 +1247,18 @@ export default function SessionView(props: SessionViewProps) { + +