Files
CodeNomad/packages/ui/src/lib/hooks/use-commands.ts
codenomadbot[bot] 9800afb785 feat(ui): toggle tool call input YAML (#182)
* feat(ui): toggle tool call input yaml

* ui: rename tool input toggle and add IO headers

* ui: add input/output accordions in tool calls

* ui: refine tool IO accordion styling

* ui: remove extra padding around IO sections

* ui: remove semibold from IO headers

* feat(ui): add tool input visibility preference

* fix(ui): scope tool input toggle to current tool call

* ui: left-align tool IO header text

* fix(ui): let palette tool input visibility override per-call

* ui: default tool input visibility to collapsed

* fix(ui): expand read tool calls on error

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-02-19 22:08:41 +00:00

641 lines
25 KiB
TypeScript

import { createSignal, onMount } from "solid-js"
import type { Accessor } from "solid-js"
import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
import { createCommandRegistry, type Command } from "../commands"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import type { ClientPart, MessageInfo } from "../../types/message"
import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance"
import type { MessageRecord } from "../../stores/message-v2/types"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { cleanupBlankSessions } from "../../stores/session-state"
import { getLogger } from "../logger"
import { requestData } from "../opencode-api"
import { emitSessionSidebarRequest } from "../session-sidebar-events"
import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env"
const log = getLogger("actions")
function splitKeywords(key: string): string[] {
return tGlobal(key)
.split(",")
.map((value) => value.trim())
.filter(Boolean)
}
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void
toggleKeyboardShortcutHints: () => void
toggleShowTimelineTools: () => void
toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void
togglePromptSubmitOnEnter: () => void
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
handleNewInstanceRequest: () => void
handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void>
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
getActiveInstance: () => Instance | null
getActiveSessionIdForInstance: () => string | null
}
function extractUserTextFromRecord(record?: MessageRecord): string | null {
if (!record) return null
const parts = record.partIds
.map((partId) => record.parts[partId]?.data)
.filter((part): part is ClientPart => Boolean(part))
const textParts = parts.filter((part): part is ClientPart & { type: "text"; text: string } => part.type === "text" && typeof (part as any).text === "string")
if (textParts.length === 0) {
return null
}
return textParts.map((part) => (part as any).text as string).join("\n")
}
export function useCommands(options: UseCommandsOptions) {
const commandRegistry = createCommandRegistry()
const [commands, setCommands] = createSignal<Command[]>([])
function refreshCommands() {
setCommands(commandRegistry.getAll())
}
function registerCommands() {
const activeInstance = options.getActiveInstance
const activeSessionIdForInstance = options.getActiveSessionIdForInstance
commandRegistry.register({
id: "new-instance",
label: () => tGlobal("commands.newInstance.label"),
description: () => tGlobal("commands.newInstance.description"),
category: "Instance",
keywords: () => splitKeywords("commands.newInstance.keywords"),
shortcut: { key: "N", meta: true },
action: options.handleNewInstanceRequest,
})
commandRegistry.register({
id: "close-instance",
label: () => tGlobal("commands.closeInstance.label"),
description: () => tGlobal("commands.closeInstance.description"),
category: "Instance",
keywords: () => splitKeywords("commands.closeInstance.keywords"),
shortcut: { key: "W", meta: true },
action: async () => {
const instance = activeInstance()
if (!instance) return
await options.handleCloseInstance(instance.id)
},
})
commandRegistry.register({
id: "instance-next",
label: () => tGlobal("commands.nextInstance.label"),
description: () => tGlobal("commands.nextInstance.description"),
category: "Instance",
keywords: () => splitKeywords("commands.nextInstance.keywords"),
shortcut: { key: "]", meta: true },
action: () => {
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const next = (current + 1) % ids.length
if (ids[next]) setActiveInstanceId(ids[next])
},
})
commandRegistry.register({
id: "instance-prev",
label: () => tGlobal("commands.previousInstance.label"),
description: () => tGlobal("commands.previousInstance.description"),
category: "Instance",
keywords: () => splitKeywords("commands.previousInstance.keywords"),
shortcut: { key: "[", meta: true },
action: () => {
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const prev = current <= 0 ? ids.length - 1 : current - 1
if (ids[prev]) setActiveInstanceId(ids[prev])
},
})
commandRegistry.register({
id: "new-session",
label: () => tGlobal("commands.newSession.label"),
description: () => tGlobal("commands.newSession.description"),
category: "Session",
keywords: () => splitKeywords("commands.newSession.keywords"),
shortcut: { key: "N", meta: true, shift: true },
action: async () => {
const instance = activeInstance()
if (!instance) return
await options.handleNewSession(instance.id)
},
})
commandRegistry.register({
id: "close-session",
label: () => tGlobal("commands.closeSession.label"),
description: () => tGlobal("commands.closeSession.description"),
category: "Session",
keywords: () => splitKeywords("commands.closeSession.keywords"),
shortcut: { key: "W", meta: true, shift: true },
action: async () => {
const instance = activeInstance()
const sessionId = activeSessionIdForInstance()
if (!instance || !sessionId || sessionId === "info") return
await options.handleCloseSession(instance.id, sessionId)
},
})
commandRegistry.register({
id: "cleanup-blank-sessions",
label: () => tGlobal("commands.scrubSessions.label"),
description: () => tGlobal("commands.scrubSessions.description"),
category: "Session",
keywords: () => splitKeywords("commands.scrubSessions.keywords"),
action: async () => {
const instance = activeInstance()
if (!instance) return
cleanupBlankSessions(instance.id, undefined, true)
},
})
commandRegistry.register({
id: "switch-to-info",
label: () => tGlobal("commands.instanceInfo.label"),
description: () => tGlobal("commands.instanceInfo.description"),
category: "Instance",
keywords: () => splitKeywords("commands.instanceInfo.keywords"),
shortcut: { key: "L", meta: true, shift: true },
action: () => {
const instance = activeInstance()
if (instance) setActiveSession(instance.id, "info")
},
})
commandRegistry.register({
id: "session-next",
label: () => tGlobal("commands.nextSession.label"),
description: () => tGlobal("commands.nextSession.description"),
category: "Session",
keywords: () => splitKeywords("commands.nextSession.keywords"),
shortcut: { key: "]", meta: true, shift: true },
action: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const ids = getVisibleSessionIds(instanceId)
if (ids.length <= 1) return
const currentActiveId = activeSessionIdForInstance() ?? ""
const currentIndex = ids.indexOf(currentActiveId)
const targetIndex = (currentIndex + 1 + ids.length) % ids.length
const targetSessionId = ids[targetIndex]
if (targetSessionId) {
setActiveSessionFromList(instanceId, targetSessionId)
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
}
},
})
commandRegistry.register({
id: "session-prev",
label: () => tGlobal("commands.previousSession.label"),
description: () => tGlobal("commands.previousSession.description"),
category: "Session",
keywords: () => splitKeywords("commands.previousSession.keywords"),
shortcut: { key: "[", meta: true, shift: true },
action: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const ids = getVisibleSessionIds(instanceId)
if (ids.length <= 1) return
const currentActiveId = activeSessionIdForInstance() ?? ""
const currentIndex = ids.indexOf(currentActiveId)
const targetIndex =
currentIndex === -1 ? ids.length - 1 : currentIndex <= 0 ? ids.length - 1 : currentIndex - 1
const targetSessionId = ids[targetIndex]
if (targetSessionId) {
setActiveSessionFromList(instanceId, targetSessionId)
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
}
},
})
commandRegistry.register({
id: "compact",
label: () => tGlobal("commands.compactSession.label"),
description: () => tGlobal("commands.compactSession.description"),
category: "Session",
keywords: () => ["/compact", ...splitKeywords("commands.compactSession.keywords")],
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 {
await requestData(
instance.client.session.summarize({
sessionID: sessionId,
providerID: session.model.providerId,
modelID: session.model.modelId,
}),
"session.summarize",
)
} catch (error) {
log.error("Failed to compact session", error)
const message = error instanceof Error ? error.message : tGlobal("commands.compactSession.errorFallback")
showAlertDialog(tGlobal("commands.compactSession.alert.message", { message }), {
title: tGlobal("commands.compactSession.alert.title"),
variant: "error",
})
}
},
})
function escapeCss(value: string) {
if (typeof CSS !== "undefined" && typeof (CSS as any).escape === "function") {
return (CSS as any).escape(value)
}
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")
}
function findVisiblePromptTextarea(sessionId?: string): HTMLTextAreaElement | null {
if (typeof document === "undefined") return null
const base = ".session-cache-pane[aria-hidden=\"false\"]"
const selector = sessionId
? `${base}[data-session-id=\"${escapeCss(sessionId)}\"] .prompt-input`
: `${base} .prompt-input`
return document.querySelector(selector) as HTMLTextAreaElement | null
}
commandRegistry.register({
id: "undo",
label: () => tGlobal("commands.undoLastMessage.label"),
description: () => tGlobal("commands.undoLastMessage.description"),
category: "Session",
keywords: () => ["/undo", ...splitKeywords("commands.undoLastMessage.keywords")],
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
const store = messageStoreBus.getOrCreate(instance.id)
const messageIds = store.getSessionMessageIds(sessionId)
const infoMap = new Map<string, MessageInfo>()
messageIds.forEach((id) => {
const info = store.getMessageInfo(id)
if (info) infoMap.set(id, info)
})
const revertState = store.getSessionRevert(sessionId) ?? session.revert
let after = 0
if (revertState?.messageID) {
const revertInfo = infoMap.get(revertState.messageID) ?? store.getMessageInfo(revertState.messageID)
after = revertInfo?.time?.created || 0
}
let messageID = ""
let restoredText: string | null = null
for (let i = messageIds.length - 1; i >= 0; i--) {
const id = messageIds[i]
const record = store.getMessage(id)
const info = infoMap.get(id) ?? store.getMessageInfo(id)
if (record?.role === "user" && info?.time?.created) {
if (after > 0 && info.time.created >= after) {
continue
}
messageID = id
restoredText = extractUserTextFromRecord(record)
break
}
}
if (!messageID) {
showAlertDialog(tGlobal("commands.undoLastMessage.none.message"), {
title: tGlobal("commands.undoLastMessage.none.title"),
variant: "info",
})
return
}
try {
await requestData(
instance.client.session.revert({
sessionID: sessionId,
messageID,
}),
"session.revert",
)
if (!restoredText) {
const fallbackRecord = store.getMessage(messageID)
restoredText = extractUserTextFromRecord(fallbackRecord)
}
if (restoredText) {
const textarea = findVisiblePromptTextarea(sessionId)
if (textarea) {
textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
}
} catch (error) {
log.error("Failed to revert message", error)
showAlertDialog(tGlobal("commands.undoLastMessage.failed.message"), {
title: tGlobal("commands.undoLastMessage.failed.title"),
variant: "error",
})
}
},
})
commandRegistry.register({
id: "open-model-selector",
label: () => tGlobal("commands.openModelSelector.label"),
description: () => tGlobal("commands.openModelSelector.description"),
category: "Agent & Model",
keywords: () => splitKeywords("commands.openModelSelector.keywords"),
shortcut: { key: "M", meta: true, shift: true },
action: () => {
const instance = activeInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-model-selector" })
},
})
commandRegistry.register({
id: "open-variant-selector",
label: () => tGlobal("commands.selectModelVariant.label"),
description: () => tGlobal("commands.selectModelVariant.description"),
category: "Agent & Model",
keywords: () => splitKeywords("commands.selectModelVariant.keywords"),
shortcut: { key: "T", meta: true, shift: true },
action: () => {
const instance = activeInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
},
})
commandRegistry.register({
id: "open-agent-selector",
label: () => tGlobal("commands.openAgentSelector.label"),
description: () => tGlobal("commands.openAgentSelector.description"),
category: "Agent & Model",
keywords: () => splitKeywords("commands.openAgentSelector.keywords"),
shortcut: { key: "A", meta: true, shift: true },
action: () => {
const instance = activeInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
},
})
commandRegistry.register({
id: "clear-input",
label: () => tGlobal("commands.clearInput.label"),
description: () => tGlobal("commands.clearInput.description"),
category: "Input & Focus",
keywords: () => splitKeywords("commands.clearInput.keywords"),
shortcut: { key: "K", meta: true },
action: () => {
const textarea = findVisiblePromptTextarea()
if (textarea) textarea.value = ""
},
})
commandRegistry.register({
id: "prompt-submit-shortcut",
label: () =>
options.preferences().promptSubmitOnEnter
? tGlobal("commands.promptSubmitShortcut.label.swapped")
: tGlobal("commands.promptSubmitShortcut.label.default"),
description: () => tGlobal("commands.promptSubmitShortcut.description"),
category: "Input & Focus",
keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
action: options.togglePromptSubmitOnEnter,
})
commandRegistry.register({
id: "thinking",
label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),
description: () => tGlobal("commands.thinkingBlocks.description"),
category: "System",
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
action: options.toggleShowThinkingBlocks,
})
commandRegistry.register({
id: "timeline-tools",
label: () => tGlobal(options.preferences().showTimelineTools ? "commands.timelineToolCalls.label.hide" : "commands.timelineToolCalls.label.show"),
description: () => tGlobal("commands.timelineToolCalls.description"),
category: "System",
keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
action: options.toggleShowTimelineTools,
})
commandRegistry.register({
id: "keyboard-shortcut-hints",
label: () =>
tGlobal(
options.preferences().showKeyboardShortcutHints
? "commands.keyboardShortcutHints.label.hide"
: "commands.keyboardShortcutHints.label.show",
),
description: () =>
tGlobal(
runtimeEnv.host === "web"
? "commands.keyboardShortcutHints.description.disabledWeb"
: "commands.keyboardShortcutHints.description",
),
category: "System",
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
disabled: () => runtimeEnv.host === "web",
action: options.toggleKeyboardShortcutHints,
})
commandRegistry.register({
id: "thinking-default-visibility",
label: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.thinkingBlocksDefault.label", { state })
},
description: () => tGlobal("commands.thinkingBlocksDefault.description"),
category: "System",
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")],
action: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setThinkingBlocksExpansion(next)
},
})
commandRegistry.register({
id: "diff-view-split",
label: () => {
const prefix = (options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
},
description: () => tGlobal("commands.diffViewSplit.description"),
category: "System",
keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
action: () => options.setDiffViewMode("split"),
})
commandRegistry.register({
id: "diff-view-unified",
label: () => {
const prefix = (options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
},
description: () => tGlobal("commands.diffViewUnified.description"),
category: "System",
keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
action: () => options.setDiffViewMode("unified"),
})
commandRegistry.register({
id: "tool-output-default-visibility",
label: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.toolOutputsDefault.label", { state })
},
description: () => tGlobal("commands.toolOutputsDefault.description"),
category: "System",
keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"),
action: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setToolOutputExpansion(next)
},
})
commandRegistry.register({
id: "diagnostics-default-visibility",
label: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.diagnosticsDefault.label", { state })
},
description: () => tGlobal("commands.diagnosticsDefault.description"),
category: "System",
keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"),
action: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setDiagnosticsExpansion(next)
},
})
commandRegistry.register({
id: "tool-inputs-visibility",
label: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const state =
mode === "expanded"
? tGlobal("commands.common.expanded")
: mode === "collapsed"
? tGlobal("commands.common.collapsed")
: tGlobal("commands.common.hidden")
return tGlobal("commands.toolInputsVisibility.label", { state })
},
description: () => tGlobal("commands.toolInputsVisibility.description"),
category: "System",
keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"),
action: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const next: ToolInputsVisibilityPreference =
mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden"
options.setToolInputsVisibility(next)
},
})
commandRegistry.register({
id: "token-usage-visibility",
label: () => {
const visible = options.preferences().showUsageMetrics ?? true
const state = visible ? tGlobal("commands.common.visible") : tGlobal("commands.common.hidden")
return tGlobal("commands.tokenUsageDisplay.label", { state })
},
description: () => tGlobal("commands.tokenUsageDisplay.description"),
category: "System",
keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"),
action: options.toggleUsageMetrics,
})
commandRegistry.register({
id: "auto-cleanup-blank-sessions",
label: () => {
const enabled = options.preferences().autoCleanupBlankSessions
const state = enabled ? tGlobal("commands.common.enabled") : tGlobal("commands.common.disabled")
return tGlobal("commands.autoCleanupBlankSessions.label", { state })
},
description: () => tGlobal("commands.autoCleanupBlankSessions.description"),
category: "System",
keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"),
action: options.toggleAutoCleanupBlankSessions,
})
commandRegistry.register({
id: "help",
label: () => tGlobal("commands.showHelp.label"),
description: () => tGlobal("commands.showHelp.description"),
category: "System",
keywords: () => ["/help", ...splitKeywords("commands.showHelp.keywords")],
action: () => {
log.info("Show help modal (not implemented)")
},
})
}
function executeCommand(command: Command) {
try {
const result = command.action?.()
if (result instanceof Promise) {
void result.catch((error) => {
log.error("Command execution failed", error)
})
}
} catch (error) {
log.error("Command execution failed", error)
}
}
onMount(() => {
registerCommands()
refreshCommands()
})
return {
commands,
commandRegistry,
refreshCommands,
executeCommand,
}
}