scope custom commands

This commit is contained in:
Shantur Rathore
2025-11-14 23:11:52 +00:00
parent efe7af6f77
commit adee1e0383
6 changed files with 293 additions and 157 deletions

View File

@@ -4,7 +4,7 @@ import { Toaster } from "solid-toast"
import type { Session } from "./types/session"
import type { Attachment } from "./types/attachment"
import type { SDKPart, ClientPart } from "./types/message"
import type { Permission } from "@opencode-ai/sdk"
import type { Permission, Command as SDKCommand } from "@opencode-ai/sdk"
import FolderSelectionView from "./components/folder-selection-view"
import InstanceWelcomeView from "./components/instance-welcome-view"
import CommandPalette from "./components/command-palette"
@@ -65,10 +65,12 @@ import {
getSessionInfo,
isSessionMessagesLoading,
fetchSessions,
executeCustomCommand,
} from "./stores/sessions"
import { isSessionBusy } from "./stores/session-status"
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette } from "./stores/command-palette"
import { getCommands as getInstanceCommands } from "./stores/commands"
import { registerNavigationShortcuts } from "./lib/shortcuts/navigation"
import { registerInputShortcuts } from "./lib/shortcuts/input"
import { registerAgentShortcuts } from "./lib/shortcuts/agent"
@@ -845,43 +847,6 @@ const App: Component = () => {
},
})
commandRegistry.register({
id: "init",
label: "Initialize AGENTS.md",
description: "Create or update AGENTS.md file",
category: "Agent & Model",
keywords: ["/init", "agents", "initialize"],
action: async () => {
const instance = activeInstance()
const sessionId = activeSessionIdForInstance()
if (!instance || !instance.client || !sessionId || sessionId === "info") return
const sessions = getSessions(instance.id)
const session = sessions.find((s) => s.id === sessionId)
if (!session) return
try {
// Generate ID similar to server format: timestamp in hex + random chars
const timestamp = Date.now()
const timePart = (timestamp * 0x1000).toString(16).padStart(12, "0")
const randomPart = Math.random().toString(16).substring(2, 16)
const messageID = `msg_${timePart}${randomPart}`
await instance.client.session.init({
path: { id: sessionId },
body: {
messageID,
providerID: session.model.providerId,
modelID: session.model.modelId,
},
})
console.log("Initializing AGENTS.md...")
} catch (error) {
console.error("Failed to initialize AGENTS.md:", error)
}
},
})
commandRegistry.register({
id: "clear-input",
label: "Clear Input",
@@ -936,8 +901,17 @@ const App: Component = () => {
refreshCommandPalette()
}
function handleExecuteCommand(commandId: string) {
commandRegistry.execute(commandId)
function handleExecuteCommand(command: Command) {
try {
const result = command.action?.()
if (result instanceof Promise) {
void result.catch((error) => {
console.error("Command execution failed:", error)
})
}
} catch (error) {
console.error("Command execution failed:", error)
}
}
@@ -952,7 +926,12 @@ const App: Component = () => {
handleCloseInstance,
handleNewSession,
handleCloseSession,
showCommandPalette,
() => {
const instance = activeInstance()
if (instance) {
showCommandPalette(instance.id)
}
},
)
registerNavigationShortcuts()
@@ -1039,7 +1018,7 @@ const App: Component = () => {
const active = document.activeElement as HTMLElement
active?.blur()
},
hideCommandPalette,
() => hideCommandPalette(),
)
const handleKeyDown = (e: KeyboardEvent) => {
@@ -1147,104 +1126,122 @@ const App: Component = () => {
/>
<Show when={activeInstance()} keyed>
{(instance) => (
<>
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={instance} />}>
<div class="flex flex-1 min-h-0">
{/* Session Sidebar */}
<div
class="session-sidebar flex flex-col bg-surface-secondary"
style={{ width: `${sessionSidebarWidth()}px` }}
>
<SessionList
instanceId={instance.id}
sessions={activeSessions()}
activeSessionId={activeSessionIdForInstance()}
onSelect={(id) => setActiveSession(instance.id, id)}
onClose={(id) => handleCloseSession(instance.id, id)}
onNew={() => handleNewSession(instance.id)}
showHeader
showFooter={false}
headerContent={
<div class="session-sidebar-header">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
<div class="session-sidebar-shortcuts">
{(() => {
const shortcuts = [
keyboardRegistry.get("session-prev"),
keyboardRegistry.get("session-next"),
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))
return shortcuts.length ? (
<KeyboardHint shortcuts={shortcuts} separator=" " showDescription={false} />
) : null
})()}
</div>
</div>
}
{(instance) => {
const customCommands = createMemo(() =>
buildCustomCommandEntries(instance.id, getInstanceCommands(instance.id)),
)
const instancePaletteCommands = createMemo(() => [
...paletteCommands(),
...customCommands(),
])
const paletteOpen = createMemo(() => isCommandPaletteOpen(instance.id))
onWidthChange={setSessionSidebarWidth}
/>
<div class="session-sidebar-separator border-t border-base" />
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<>
<ContextUsagePanel instanceId={instance.id} sessionId={activeSession().id} />
<div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3">
<AgentSelector
instanceId={instance.id}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={handleSidebarAgentChange}
/>
<ModelSelector
instanceId={instance.id}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={handleSidebarModelChange}
/>
</div>
</>
)}
</Show>
</div>
{/* Main Content Area */}
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={activeSessionIdForInstance() === "info"}
fallback={
<Show
when={activeSessionIdForInstance()}
keyed
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500 dark:text-gray-400">
<p class="mb-2">No session selected</p>
<p class="text-sm">Select a session to view messages</p>
</div>
</div>
}
>
{(sessionId) => (
<SessionView
sessionId={sessionId}
activeSessions={activeSessions()}
instanceId={instance.id}
instanceFolder={instance.folder}
escapeInDebounce={escapeInDebounce()}
/>
)}
</Show>
}
return (
<>
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={instance} />}>
<div class="flex flex-1 min-h-0">
{/* Session Sidebar */}
<div
class="session-sidebar flex flex-col bg-surface-secondary"
style={{ width: `${sessionSidebarWidth()}px` }}
>
<InfoView instanceId={instance.id} />
</Show>
<SessionList
instanceId={instance.id}
sessions={activeSessions()}
activeSessionId={activeSessionIdForInstance()}
onSelect={(id) => setActiveSession(instance.id, id)}
onClose={(id) => handleCloseSession(instance.id, id)}
onNew={() => handleNewSession(instance.id)}
showHeader
showFooter={false}
headerContent={
<div class="session-sidebar-header">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
<div class="session-sidebar-shortcuts">
{(() => {
const shortcuts = [
keyboardRegistry.get("session-prev"),
keyboardRegistry.get("session-next"),
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))
return shortcuts.length ? (
<KeyboardHint shortcuts={shortcuts} separator=" " showDescription={false} />
) : null
})()}
</div>
</div>
}
onWidthChange={setSessionSidebarWidth}
/>
<div class="session-sidebar-separator border-t border-base" />
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<>
<ContextUsagePanel instanceId={instance.id} sessionId={activeSession().id} />
<div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3">
<AgentSelector
instanceId={instance.id}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={handleSidebarAgentChange}
/>
<ModelSelector
instanceId={instance.id}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={handleSidebarModelChange}
/>
</div>
</>
)}
</Show>
</div>
{/* Main Content Area */}
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={activeSessionIdForInstance() === "info"}
fallback={
<Show
when={activeSessionIdForInstance()}
keyed
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500 dark:text-gray-400">
<p class="mb-2">No session selected</p>
<p class="text-sm">Select a session to view messages</p>
</div>
</div>
}
>
{(sessionId) => (
<SessionView
sessionId={sessionId}
activeSessions={activeSessions()}
instanceId={instance.id}
instanceFolder={instance.folder}
escapeInDebounce={escapeInDebounce()}
/>
)}
</Show>
}
>
<InfoView instanceId={instance.id} />
</Show>
</div>
</div>
</div>
</Show>
</>
)}
</Show>
<CommandPalette
open={paletteOpen()}
onClose={() => hideCommandPalette(instance.id)}
commands={instancePaletteCommands()}
onExecute={handleExecuteCommand}
/>
</>
)
}}
</Show>
</>
}
@@ -1258,12 +1255,6 @@ const App: Component = () => {
/>
</Show>
<CommandPalette
open={isCommandPaletteOpen()}
onClose={hideCommandPalette}
commands={paletteCommands()}
onExecute={handleExecuteCommand}
/>
<Show when={showFolderSelection()}>
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
@@ -1310,4 +1301,52 @@ const App: Component = () => {
)
}
function commandRequiresArguments(template?: string) {
if (!template) return false
return /\$(?:\d+|ARGUMENTS)/.test(template)
}
function promptForCommandArguments(command: SDKCommand.Info) {
if (!commandRequiresArguments(command.template)) {
return ""
}
const input = window.prompt(`Arguments for /${command.name}`, "")
if (input === null) {
return null
}
return input
}
function formatCommandLabel(name: string) {
if (!name) return ""
return name.charAt(0).toUpperCase() + name.slice(1)
}
function buildCustomCommandEntries(instanceId: string, commands: SDKCommand.Info[]): Command[] {
return commands.map((cmd) => ({
id: `custom:${instanceId}:${cmd.name}`,
label: formatCommandLabel(cmd.name),
description: cmd.description ?? "Custom command",
category: "Custom Commands",
keywords: [cmd.name, ...(cmd.description ? cmd.description.split(/\s+/).filter(Boolean) : [])],
action: async () => {
const sessionId = activeSessionId().get(instanceId)
if (!sessionId || sessionId === "info") {
alert("Select a session before running a custom command.")
return
}
const args = promptForCommandArguments(cmd)
if (args === null) {
return
}
try {
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
} catch (error) {
console.error("Failed to run custom command:", error)
alert("Failed to run custom command. Check the console for details.")
}
},
}))
}
export default App

View File

@@ -7,7 +7,7 @@ interface CommandPaletteProps {
open: boolean
onClose: () => void
commands: Command[]
onExecute: (commandId: string) => void
onExecute: (command: Command) => void
}
function buildShortcutString(shortcut: Command["shortcut"]): string {
@@ -30,7 +30,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
let inputRef: HTMLInputElement | undefined
let listRef: HTMLDivElement | undefined
const categoryOrder = ["Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
type CommandGroup = { category: string; commands: Command[]; startIndex: number }
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
@@ -167,13 +167,15 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
e.stopPropagation()
const index = selectedIndex()
if (index < 0 || index >= ordered.length) return
props.onExecute(ordered[index].id)
const command = ordered[index]
if (!command) return
props.onExecute(command)
props.onClose()
}
}
function handleCommandClick(commandId: string) {
props.onExecute(commandId)
function handleCommandClick(command: Command) {
props.onExecute(command)
props.onClose()
}
@@ -241,7 +243,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
<button
type="button"
data-command-index={commandIndex}
onClick={() => handleCommandClick(command.id)}
onClick={() => handleCommandClick(command)}
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
onPointerMove={(event) => {
if (event.movementX === 0 && event.movementY === 0) return

View File

@@ -1,17 +1,36 @@
import { createSignal } from "solid-js"
const [isOpen, setIsOpen] = createSignal(false)
const [openStates, setOpenStates] = createSignal<Map<string, boolean>>(new Map())
export function showCommandPalette() {
setIsOpen(true)
function updateState(instanceId: string, open: boolean) {
setOpenStates((prev) => {
const next = new Map(prev)
next.set(instanceId, open)
return next
})
}
export function hideCommandPalette() {
setIsOpen(false)
export function showCommandPalette(instanceId: string) {
if (!instanceId) return
updateState(instanceId, true)
}
export function toggleCommandPalette() {
setIsOpen(!isOpen())
export function hideCommandPalette(instanceId?: string) {
if (!instanceId) {
setOpenStates(new Map())
return
}
updateState(instanceId, false)
}
export { isOpen }
export function toggleCommandPalette(instanceId: string) {
if (!instanceId) return
const current = openStates().get(instanceId) ?? false
updateState(instanceId, !current)
}
export function isOpen(instanceId: string): boolean {
return openStates().get(instanceId) ?? false
}
export { openStates }

30
src/stores/commands.ts Normal file
View File

@@ -0,0 +1,30 @@
import { createSignal } from "solid-js"
import type { Command as SDKCommand } from "@opencode-ai/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/client"
const [commandMap, setCommandMap] = createSignal<Map<string, SDKCommand.Info[]>>(new Map())
export async function fetchCommands(instanceId: string, client: OpencodeClient): Promise<void> {
const response = await client.command.list()
const commands = response.data ?? []
setCommandMap((prev) => {
const next = new Map(prev)
next.set(instanceId, commands)
return next
})
}
export function getCommands(instanceId: string): SDKCommand.Info[] {
return commandMap().get(instanceId) ?? []
}
export function clearCommands(instanceId: string): void {
setCommandMap((prev) => {
if (!prev.has(instanceId)) return prev
const next = new Map(prev)
next.delete(instanceId)
return next
})
}
export { commandMap as commands }

View File

@@ -10,6 +10,7 @@ import {
removeSessionIndexes,
clearInstanceDraftPrompts,
} from "./sessions"
import { fetchCommands, clearCommands } from "./commands"
import { preferences, updateLastUsedBinary } from "./preferences"
import { setHasInstances } from "./ui"
@@ -139,6 +140,7 @@ function removeInstance(id: string) {
})
removeLogContainer(id)
clearCommands(id)
if (activeInstanceId() === id) {
setActiveInstanceId(nextActiveId)
@@ -194,6 +196,7 @@ async function createInstance(folder: string, binaryPath?: string): Promise<stri
await fetchSessions(id)
await fetchAgents(id)
await fetchProviders(id)
await fetchCommands(id, client)
} catch (error) {
console.error("Failed to fetch initial data:", error)
}

View File

@@ -2029,6 +2029,48 @@ async function sendMessage(
}
}
async function executeCustomCommand(
instanceId: string,
sessionId: string,
commandName: string,
args: string,
): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
const body: {
command: string
arguments: string
messageID: string
agent?: string
model?: string
} = {
command: commandName,
arguments: args,
messageID: createId("msg"),
}
if (session.agent) {
body.agent = session.agent
}
if (session.model.providerId && session.model.modelId) {
body.model = `${session.model.providerId}/${session.model.modelId}`
}
await instance.client.session.command({
path: { id: sessionId },
body,
})
}
async function abortSession(instanceId: string, sessionId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
@@ -2266,4 +2308,5 @@ export {
setSessionDraftPrompt,
clearSessionDraftPrompt,
clearInstanceDraftPrompts,
executeCustomCommand,
}