Split workspace into electron and ui packages

This commit is contained in:
Shantur Rathore
2025-11-17 12:06:58 +00:00
parent aa77ca2931
commit 89bd32814f
137 changed files with 407 additions and 1371 deletions

View File

@@ -0,0 +1,178 @@
import { onMount, onCleanup, type Accessor } from "solid-js"
import { setupTabKeyboardShortcuts } from "../keyboard"
import { registerNavigationShortcuts } from "../shortcuts/navigation"
import { registerInputShortcuts } from "../shortcuts/input"
import { registerAgentShortcuts } from "../shortcuts/agent"
import { registerEscapeShortcut, setEscapeStateChangeHandler } from "../shortcuts/escape"
import { keyboardRegistry } from "../keyboard-registry"
import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions"
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
import { addLog, updateInstance } from "../../stores/instances"
import type { Instance } from "../../types/instance"
interface UseAppLifecycleOptions {
setEscapeInDebounce: (value: boolean) => void
handleNewInstanceRequest: () => void
handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void>
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
showFolderSelection: Accessor<boolean>
setShowFolderSelection: (value: boolean) => void
getActiveInstance: () => Instance | null
getActiveSessionIdForInstance: () => string | null
}
export function useAppLifecycle(options: UseAppLifecycleOptions) {
onMount(() => {
setEscapeStateChangeHandler(options.setEscapeInDebounce)
setupTabKeyboardShortcuts(
options.handleNewInstanceRequest,
options.handleCloseInstance,
options.handleNewSession,
options.handleCloseSession,
() => {
const instance = options.getActiveInstance()
if (instance) {
showCommandPalette(instance.id)
}
},
)
registerNavigationShortcuts()
registerInputShortcuts(
() => {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textarea) textarea.value = ""
},
() => {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
textarea?.focus()
},
)
registerAgentShortcuts(
() => {
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)
}
},
() => {
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)
}
},
)
registerEscapeShortcut(
() => {
if (options.showFolderSelection()) return true
const instance = options.getActiveInstance()
if (!instance) return false
const sessionId = options.getActiveSessionIdForInstance()
if (!sessionId || sessionId === "info") return false
const sessions = getSessions(instance.id)
const session = sessions.find((s) => s.id === sessionId)
if (!session) return false
return isSessionBusy(instance.id, sessionId)
},
async () => {
if (options.showFolderSelection()) {
options.setShowFolderSelection(false)
return
}
const instance = options.getActiveInstance()
const sessionId = options.getActiveSessionIdForInstance()
if (!instance || !sessionId || sessionId === "info") return
try {
await abortSession(instance.id, sessionId)
console.log("Session aborted successfully")
} catch (error) {
console.error("Failed to abort session:", error)
}
},
() => {
const active = document.activeElement as HTMLElement
active?.blur()
},
() => hideCommandPalette(),
)
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
const isInCombobox = target.closest('[role="combobox"]') !== null
const isInListbox = target.closest('[role="listbox"]') !== null
const isInAgentSelect = target.closest('[role="button"][data-agent-selector]') !== null
if (isInCombobox || isInListbox || isInAgentSelect) {
return
}
const shortcut = keyboardRegistry.findMatch(e)
if (shortcut) {
e.preventDefault()
shortcut.handler()
}
}
window.addEventListener("keydown", handleKeyDown)
window.electronAPI.onNewInstance(() => {
options.handleNewInstanceRequest()
})
window.electronAPI.onInstanceStarted(({ id, port, pid, binaryPath }) => {
console.log("Instance started:", { id, port, pid, binaryPath })
updateInstance(id, { port, pid, status: "ready", binaryPath })
})
window.electronAPI.onInstanceError(({ id, error }) => {
console.error("Instance error:", { id, error })
updateInstance(id, { status: "error", error })
})
window.electronAPI.onInstanceStopped(({ id }) => {
console.log("Instance stopped:", id)
updateInstance(id, { status: "stopped" })
})
window.electronAPI.onInstanceLog(({ id, entry }) => {
addLog(id, entry)
})
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
})
}

View File

@@ -0,0 +1,450 @@
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 type { Instance } from "../../types/instance"
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => 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"
alert(`Compact failed: ${message}`)
}
},
})
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) {
alert("Nothing to undo")
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)
alert("Failed to revert message")
}
},
})
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: "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,
}
}