475 lines
16 KiB
TypeScript
475 lines
16 KiB
TypeScript
import { createSignal, onMount } from "solid-js"
|
|
import type { Accessor } from "solid-js"
|
|
import type { Preferences, ExpansionPreference } from "../../stores/preferences"
|
|
import { createCommandRegistry, type Command } from "../commands"
|
|
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
|
|
import {
|
|
activeParentSessionId,
|
|
activeSessionId as activeSessionMap,
|
|
getSessionFamily,
|
|
getSessions,
|
|
setActiveSession,
|
|
} from "../../stores/sessions"
|
|
import { setSessionCompactionState } from "../../stores/session-compaction"
|
|
import { showAlertDialog } from "../../stores/alerts"
|
|
import type { Instance } from "../../types/instance"
|
|
|
|
export interface UseCommandsOptions {
|
|
preferences: Accessor<Preferences>
|
|
toggleShowThinkingBlocks: () => void
|
|
toggleUsageMetrics: () => void
|
|
setDiffViewMode: (mode: "split" | "unified") => void
|
|
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
|
setDiagnosticsExpansion: (mode: ExpansionPreference) => 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
|
|
}
|
|
|
|
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: "New Instance",
|
|
description: "Open folder picker to create new instance",
|
|
category: "Instance",
|
|
keywords: ["folder", "project", "workspace"],
|
|
shortcut: { key: "N", meta: true },
|
|
action: options.handleNewInstanceRequest,
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "close-instance",
|
|
label: "Close Instance",
|
|
description: "Stop current instance's server",
|
|
category: "Instance",
|
|
keywords: ["stop", "quit", "close"],
|
|
shortcut: { key: "W", meta: true },
|
|
action: async () => {
|
|
const instance = activeInstance()
|
|
if (!instance) return
|
|
await options.handleCloseInstance(instance.id)
|
|
},
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "instance-next",
|
|
label: "Next Instance",
|
|
description: "Cycle to next instance tab",
|
|
category: "Instance",
|
|
keywords: ["switch", "navigate"],
|
|
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: "Previous Instance",
|
|
description: "Cycle to previous instance tab",
|
|
category: "Instance",
|
|
keywords: ["switch", "navigate"],
|
|
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: "New Session",
|
|
description: "Create a new parent session",
|
|
category: "Session",
|
|
keywords: ["create", "start"],
|
|
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: "Close Session",
|
|
description: "Close current parent session",
|
|
category: "Session",
|
|
keywords: ["close", "stop"],
|
|
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: "switch-to-info",
|
|
label: "Instance Info",
|
|
description: "Open the instance overview for logs and status",
|
|
category: "Instance",
|
|
keywords: ["info", "logs", "console", "output"],
|
|
shortcut: { key: "L", meta: true, shift: true },
|
|
action: () => {
|
|
const instance = activeInstance()
|
|
if (instance) setActiveSession(instance.id, "info")
|
|
},
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "session-next",
|
|
label: "Next Session",
|
|
description: "Cycle to next session tab",
|
|
category: "Session",
|
|
keywords: ["switch", "navigate"],
|
|
shortcut: { key: "]", meta: true, shift: true },
|
|
action: () => {
|
|
const instanceId = activeInstanceId()
|
|
if (!instanceId) return
|
|
const parentId = activeParentSessionId().get(instanceId)
|
|
if (!parentId) return
|
|
const familySessions = getSessionFamily(instanceId, parentId)
|
|
const ids = familySessions.map((s) => s.id).concat(["info"])
|
|
if (ids.length <= 1) return
|
|
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
|
|
const next = (current + 1) % ids.length
|
|
if (ids[next]) setActiveSession(instanceId, ids[next])
|
|
},
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "session-prev",
|
|
label: "Previous Session",
|
|
description: "Cycle to previous session tab",
|
|
category: "Session",
|
|
keywords: ["switch", "navigate"],
|
|
shortcut: { key: "[", meta: true, shift: true },
|
|
action: () => {
|
|
const instanceId = activeInstanceId()
|
|
if (!instanceId) return
|
|
const parentId = activeParentSessionId().get(instanceId)
|
|
if (!parentId) return
|
|
const familySessions = getSessionFamily(instanceId, parentId)
|
|
const ids = familySessions.map((s) => s.id).concat(["info"])
|
|
if (ids.length <= 1) return
|
|
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
|
|
const prev = current <= 0 ? ids.length - 1 : current - 1
|
|
if (ids[prev]) setActiveSession(instanceId, ids[prev])
|
|
},
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "compact",
|
|
label: "Compact Session",
|
|
description: "Summarize and compact the current session",
|
|
category: "Session",
|
|
keywords: ["/compact", "summarize", "compress"],
|
|
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 {
|
|
setSessionCompactionState(instance.id, sessionId, true)
|
|
await instance.client.session.summarize({
|
|
path: { id: sessionId },
|
|
body: {
|
|
providerID: session.model.providerId,
|
|
modelID: session.model.modelId,
|
|
},
|
|
})
|
|
} catch (error: unknown) {
|
|
setSessionCompactionState(instance.id, sessionId, false)
|
|
console.error("Failed to compact session:", error)
|
|
const message = error instanceof Error ? error.message : "Failed to compact session"
|
|
showAlertDialog(`Compact failed: ${message}`, {
|
|
title: "Compact failed",
|
|
variant: "error",
|
|
})
|
|
}
|
|
},
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "undo",
|
|
label: "Undo Last Message",
|
|
description: "Revert the last message",
|
|
category: "Session",
|
|
keywords: ["/undo", "revert", "undo"],
|
|
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
|
|
|
|
let after = 0
|
|
const revert = session.revert
|
|
|
|
if (revert?.messageID) {
|
|
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
const msg = session.messages[i]
|
|
const info = session.messagesInfo.get(msg.id)
|
|
if (info?.id === revert.messageID) {
|
|
after = info.time?.created || 0
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
let messageID = ""
|
|
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
const msg = session.messages[i]
|
|
const info = session.messagesInfo.get(msg.id)
|
|
|
|
if (msg.type === "user" && info?.time?.created) {
|
|
if (after > 0 && info.time.created >= after) {
|
|
continue
|
|
}
|
|
messageID = msg.id
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!messageID) {
|
|
showAlertDialog("Nothing to undo", {
|
|
title: "No actions to undo",
|
|
variant: "info",
|
|
})
|
|
return
|
|
}
|
|
|
|
try {
|
|
await instance.client.session.revert({
|
|
path: { id: sessionId },
|
|
body: { messageID },
|
|
})
|
|
|
|
const revertedMessage = session.messages.find((m) => m.id === messageID)
|
|
const revertedInfo = session.messagesInfo.get(messageID)
|
|
|
|
if (revertedMessage && revertedInfo?.role === "user") {
|
|
const textParts = revertedMessage.parts.filter((p) => p.type === "text")
|
|
if (textParts.length > 0) {
|
|
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
|
if (textarea) {
|
|
textarea.value = textParts.map((p: any) => p.text).join("\n")
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
|
textarea.focus()
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to revert message:", error)
|
|
showAlertDialog("Failed to revert message", {
|
|
title: "Undo failed",
|
|
variant: "error",
|
|
})
|
|
}
|
|
},
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "open-model-selector",
|
|
label: "Open Model Selector",
|
|
description: "Choose a different model",
|
|
category: "Agent & Model",
|
|
keywords: ["model", "llm", "ai"],
|
|
shortcut: { key: "M", meta: true, shift: true },
|
|
action: () => {
|
|
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
|
|
if (modelInput) {
|
|
modelInput.focus()
|
|
setTimeout(() => {
|
|
const event = new KeyboardEvent("keydown", {
|
|
key: "ArrowDown",
|
|
code: "ArrowDown",
|
|
keyCode: 40,
|
|
which: 40,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
})
|
|
modelInput.dispatchEvent(event)
|
|
}, 10)
|
|
}
|
|
},
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "open-agent-selector",
|
|
label: "Open Agent Selector",
|
|
description: "Choose a different agent",
|
|
category: "Agent & Model",
|
|
keywords: ["agent", "mode"],
|
|
shortcut: { key: "A", meta: true, shift: true },
|
|
action: () => {
|
|
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
|
|
if (agentTrigger) {
|
|
agentTrigger.focus()
|
|
setTimeout(() => {
|
|
const event = new KeyboardEvent("keydown", {
|
|
key: "Enter",
|
|
code: "Enter",
|
|
keyCode: 13,
|
|
which: 13,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
})
|
|
agentTrigger.dispatchEvent(event)
|
|
}, 50)
|
|
}
|
|
},
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "clear-input",
|
|
label: "Clear Input",
|
|
description: "Clear the prompt textarea",
|
|
category: "Input & Focus",
|
|
keywords: ["clear", "reset"],
|
|
shortcut: { key: "K", meta: true },
|
|
action: () => {
|
|
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
|
if (textarea) textarea.value = ""
|
|
},
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "thinking",
|
|
label: () => `${options.preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`,
|
|
description: "Show/hide AI thinking process",
|
|
category: "System",
|
|
keywords: ["/thinking", "toggle", "show", "hide"],
|
|
action: options.toggleShowThinkingBlocks,
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "diff-view-split",
|
|
label: () => `${(options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`,
|
|
description: "Display tool-call diffs side-by-side",
|
|
category: "System",
|
|
keywords: ["diff", "split", "view"],
|
|
action: () => options.setDiffViewMode("split"),
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "diff-view-unified",
|
|
label: () => `${(options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""}Use Unified Diff View`,
|
|
description: "Display tool-call diffs inline",
|
|
category: "System",
|
|
keywords: ["diff", "unified", "view"],
|
|
action: () => options.setDiffViewMode("unified"),
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "tool-output-default-visibility",
|
|
label: () => {
|
|
const mode = options.preferences().toolOutputExpansion || "expanded"
|
|
return `Tool Outputs Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
|
|
},
|
|
description: "Toggle default expansion for tool outputs",
|
|
category: "System",
|
|
keywords: ["tool", "output", "expand", "collapse"],
|
|
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"
|
|
return `Diagnostics Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
|
|
},
|
|
description: "Toggle default expansion for diagnostics output",
|
|
category: "System",
|
|
keywords: ["diagnostics", "expand", "collapse"],
|
|
action: () => {
|
|
const mode = options.preferences().diagnosticsExpansion || "expanded"
|
|
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
|
options.setDiagnosticsExpansion(next)
|
|
},
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "token-usage-visibility",
|
|
label: () => {
|
|
const visible = options.preferences().showUsageMetrics ?? true
|
|
return `Token Usage Display · ${visible ? "Visible" : "Hidden"}`
|
|
},
|
|
description: "Show or hide token and cost stats for assistant messages",
|
|
category: "System",
|
|
keywords: ["token", "usage", "cost", "stats"],
|
|
action: options.toggleUsageMetrics,
|
|
})
|
|
|
|
commandRegistry.register({
|
|
id: "help",
|
|
label: "Show Help",
|
|
|
|
description: "Display keyboard shortcuts and help",
|
|
category: "System",
|
|
keywords: ["/help", "shortcuts", "help"],
|
|
action: () => {
|
|
console.log("Show help modal (not implemented)")
|
|
},
|
|
})
|
|
}
|
|
|
|
function executeCommand(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)
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
registerCommands()
|
|
refreshCommands()
|
|
})
|
|
|
|
return {
|
|
commands,
|
|
commandRegistry,
|
|
refreshCommands,
|
|
executeCommand,
|
|
}
|
|
}
|