import { useNavigate } from "@solidjs/router" import { useCommand, type CommandOption } from "@/context/command" import { useDialog } from "@opencode-ai/ui/context/dialog" import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge" import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePermission } from "@/context/permission" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { showToast } from "@/utils/toast" import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { findLast } from "@opencode-ai/core/util/array" import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" import { Message, Part, UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionArchive } from "@/pages/session/session-archive" import { createSessionOwnership } from "./session-ownership" import { useLocal } from "@/context/local" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void setActiveMessage: (message: UserMessage | undefined) => void focusInput: () => void review?: () => boolean fileBrowser?: () => boolean } const withCategory = (category: string) => { return (option: Omit): CommandOption => ({ ...option, category, }) } export const useSessionCommands = (actions: SessionCommandContext) => { const command = useCommand() const dialog = useDialog() const file = useFile() const language = useLanguage() const permission = usePermission() const prompt = usePrompt() const sdk = useSDK() const settings = useSettings() const sync = useSync() const terminal = useTerminal() const layout = useLayout() const local = useLocal() const navigate = useNavigate() const { params, sessionKey, tabs, view } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) const sessionArchive = useSessionArchive() const openDialog = async (load: () => Promise, show: (value: T) => void) => { const owner = sessionOwnership.capture() const value = await load() owner.run(() => show(value)) } const runCommand = async (input: { owner: ReturnType["capture"]> prompt: T request: () => Promise updatePrompt: (prompt: T) => void updateViewport: () => void }) => { await input.request() input.updatePrompt(input.prompt) input.owner.run(input.updateViewport) } const info = () => { const id = params.id if (!id) return return sync().session.get(id) } const hasReview = () => !!params.id const normalizeTab = (tab: string) => { if (!tab.startsWith("file://")) return tab return file.tab(tab) } const tabState = createSessionTabs({ tabs, pathFromTab: file.pathFromTab, normalizeTab, review: actions.review, hasReview, fileBrowser: actions.fileBrowser, }) const activeFileTab = tabState.activeFileTab const closableTab = tabState.closableTab const shown = settings.visibility.fileTree const messages = () => { const id = params.id if (!id) return [] return sync().data.message[id] ?? [] } const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[] const visibleUserMessages = () => { const revert = info()?.revert?.messageID if (!revert) return userMessages() const boundary = userMessages().findIndex((message) => message.id === revert) return boundary < 0 ? userMessages() : userMessages().slice(0, boundary) } const showAllFiles = () => { if (layout.fileTree.tab() !== "changes") return layout.fileTree.setTab("all") } const selectionPreview = (path: string, selection: FileSelection) => { const content = file.get(path)?.content?.content if (!content) return undefined return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine }) } const addSelectionToContext = (path: string, selection: FileSelection) => { const preview = selectionPreview(path, selection) prompt.context.add({ type: "file", path, selection, preview }) } const canAddSelectionContext = () => { const tab = activeFileTab() if (!tab) return false const path = file.pathFromTab(tab) if (!path) return false return file.selectedLines(path) != null } const navigateMessageByOffset = actions.navigateMessageByOffset const setActiveMessage = actions.setActiveMessage const focusInput = actions.focusInput const sessionCommand = withCategory(language.t("command.category.session")) const fileCommand = withCategory(language.t("command.category.file")) const contextCommand = withCategory(language.t("command.category.context")) const viewCommand = withCategory(language.t("command.category.view")) const terminalCommand = withCategory(language.t("command.category.terminal")) const mcpCommand = withCategory(language.t("command.category.mcp")) const permissionsCommand = withCategory(language.t("command.category.permissions")) const isAutoAcceptActive = () => { const sessionID = params.id if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory) return permission.isAutoAcceptingDirectory(sdk().directory) } const write = async (value: string) => { const body = typeof document === "undefined" ? undefined : document.body if (body) { const textarea = document.createElement("textarea") textarea.value = value textarea.setAttribute("readonly", "") textarea.style.position = "fixed" textarea.style.opacity = "0" textarea.style.pointerEvents = "none" body.appendChild(textarea) textarea.select() const copied = document.execCommand("copy") body.removeChild(textarea) if (copied) return true } const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard if (!clipboard?.writeText) return false return clipboard.writeText(value).then( () => true, () => false, ) } const copyShare = async (url: string, existing: boolean) => { if (!(await write(url))) { showToast({ title: language.t("toast.session.share.copyFailed.title"), variant: "error", }) return } showToast({ title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"), description: language.t("toast.session.share.success.description"), variant: "success", }) } const share = async () => { const sessionID = params.id if (!sessionID) return const existing = info()?.share?.url if (existing) { await copyShare(existing, true) return } const url = await sdk() .client.session.share({ sessionID }) .then((res) => res.data?.share?.url) .catch(() => undefined) if (!url) { showToast({ title: language.t("toast.session.share.failed.title"), description: language.t("toast.session.share.failed.description"), variant: "error", }) return } await copyShare(url, false) } const unshare = async () => { const sessionID = params.id if (!sessionID) return await sdk() .client.session.unshare({ sessionID }) .then(() => showToast({ title: language.t("toast.session.unshare.success.title"), description: language.t("toast.session.unshare.success.description"), variant: "success", }), ) .catch(() => showToast({ title: language.t("toast.session.unshare.failed.title"), description: language.t("toast.session.unshare.failed.description"), variant: "error", }), ) } const exportSession = async () => { const sessionID = params.id if (!sessionID) return try { const data = await fetchSessionExport({ sessionID, client: sdk().client, }) const filename = sessionExportFilename(data.info) downloadSessionExport(filename, data) showToast({ variant: "success", icon: "circle-check", title: language.t("toast.session.export.success.title"), description: language.t("toast.session.export.success.description", { filename }), }) } catch (err) { showToast({ variant: "error", title: language.t("toast.session.export.failed.title"), description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"), }) } } const openFile = () => { void openDialog( () => import("@/components/dialog-select-file"), (x) => dialog.show(() => ), ) } const closeTab = () => { const tab = closableTab() if (!tab) return tabs().close(tab) } const addSelection = () => { const tab = activeFileTab() if (!tab) return const path = file.pathFromTab(tab) if (!path) return const range = file.selectedLines(path) as SelectedLineRange | null | undefined if (!range) { showToast({ title: language.t("toast.context.noLineSelection.title"), description: language.t("toast.context.noLineSelection.description"), }) return } addSelectionToContext(path, selectionFromLines(range)) } const openTerminal = () => { if (terminal.all().length > 0) terminal.new({ focus: true }) if (terminal.all().length === 0) terminal.requestFocus() view().terminal.open() } const closeTerminal = () => { const id = terminal.active() if (!id) return const last = terminal.all().length === 1 void terminal.close(id) if (last) view().terminal.close() } const chooseMcp = () => { void openDialog( () => import("@/components/dialog-select-mcp"), (x) => dialog.show(() => ), ) } const toggleAutoAccept = () => { const sessionID = params.id if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory) else permission.toggleAutoAcceptDirectory(sdk().directory) const active = sessionID ? permission.isAutoAccepting(sessionID, sdk().directory) : permission.isAutoAcceptingDirectory(sdk().directory) showToast({ title: active ? language.t("toast.permissions.autoaccept.on.title") : language.t("toast.permissions.autoaccept.off.title"), description: active ? language.t("toast.permissions.autoaccept.on.description") : language.t("toast.permissions.autoaccept.off.description"), }) } const undo = async () => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() const session = sdk().api.session const directory = sdk().directory const promptSession = prompt.capture() const revert = info()?.revert?.messageID const messages = userMessages() const boundary = revert ? messages.findIndex((message) => message.id === revert) : messages.length if (boundary < 0) return const message = messages[boundary - 1] if (!message) return const parts = sync().data.part[message.id] if (sync().data.session_working(sessionID)) { await session.interrupt({ sessionID }).catch(() => {}) } await runCommand({ owner, prompt: promptSession, request: () => session.revert.stage({ sessionID, messageID: message.id }), updatePrompt: (promptSession) => { if (parts) promptSession.set(extractPromptFromParts(parts, { directory })) }, updateViewport: () => setActiveMessage(messages[boundary - 2]), }) } const redo = async () => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() const session = sdk().api.session const messages = userMessages() const promptSession = prompt.capture() const revertMessageID = info()?.revert?.messageID if (!revertMessageID) return const boundary = messages.findIndex((message) => message.id === revertMessageID) if (boundary < 0) return const next = messages[boundary + 1] if (!next) { await runCommand({ owner, prompt: promptSession, request: () => session.revert.clear({ sessionID }), updatePrompt: (promptSession) => promptSession.reset(), updateViewport: () => setActiveMessage(messages.at(-1)), }) return } await runCommand({ owner, prompt: promptSession, request: () => session.revert.stage({ sessionID, messageID: next.id }), updatePrompt: () => undefined, updateViewport: () => setActiveMessage(messages[boundary]), }) } const compact = async () => { const sessionID = params.id if (!sessionID) return const model = local.model.current() if (!model) { showToast({ title: language.t("toast.model.none.title"), description: language.t("toast.model.none.description"), }) return } await sdk().api.session.compact({ sessionID, model: { providerID: model.provider.id, modelID: model.id }, }) } const fork = () => { void openDialog( () => import("@/components/dialog-fork"), (x) => dialog.show(() => ), ) } const shareCmds = () => { if (sync().data.config.share === "disabled") return [] return [ sessionCommand({ id: "session.share", title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"), description: info()?.share?.url ? language.t("toast.session.share.success.description") : language.t("command.session.share.description"), slash: "share", disabled: !params.id, onSelect: share, }), sessionCommand({ id: "session.unshare", title: language.t("command.session.unshare"), description: language.t("command.session.unshare.description"), slash: "unshare", disabled: !params.id || !info()?.share?.url, onSelect: unshare, }), ] } const sessionCmds = () => [ sessionCommand({ id: "session.new", title: language.t("command.session.new"), keybind: "mod+shift+s", slash: "new", onSelect: (source) => { if (settings.general.newLayoutDesigns()) { command.trigger("tab.new", source) return } navigate(`/${params.dir}/session`) }, }), sessionCommand({ id: "session.undo", title: language.t("command.session.undo"), description: language.t("command.session.undo.description"), slash: "undo", disabled: !params.id || visibleUserMessages().length === 0, onSelect: undo, }), sessionCommand({ id: "session.redo", title: language.t("command.session.redo"), description: language.t("command.session.redo.description"), slash: "redo", disabled: !params.id || !info()?.revert?.messageID, onSelect: redo, }), sessionCommand({ id: "session.compact", title: language.t("command.session.compact"), description: language.t("command.session.compact.description"), slash: "compact", disabled: !params.id || visibleUserMessages().length === 0, onSelect: compact, }), sessionCommand({ id: "session.fork", title: language.t("command.session.fork"), description: language.t("command.session.fork.description"), slash: "fork", disabled: !params.id || visibleUserMessages().length === 0, onSelect: fork, }), sessionCommand({ id: "session.export", title: language.t("command.session.export"), description: language.t("command.session.export.description"), slash: "export", disabled: !params.id, onSelect: exportSession, }), sessionCommand({ id: "session.archive", title: language.t("command.session.archive"), keybind: "mod+shift+backspace", disabled: !params.id, onSelect: () => { const id = params.id if (id) void sessionArchive.archive(id) }, }), ] const fileCmds = () => { const tab = closableTab() return [ fileCommand({ id: "file.open", title: language.t("command.file.open"), description: language.t("palette.search.placeholder"), keybind: "mod+p", slash: "open", onSelect: openFile, }), tab && fileCommand({ id: "tab.close", title: language.t("command.tab.close"), keybind: "mod+w", onSelect: closeTab, }), ].filter((v) => !!v) } const contextCmds = () => [ contextCommand({ id: "context.addSelection", title: language.t("command.context.addSelection"), description: language.t("command.context.addSelection.description"), keybind: "mod+shift+l", disabled: !canAddSelectionContext(), onSelect: addSelection, }), ] const viewCmds = () => [ viewCommand({ id: "terminal.toggle", title: language.t("command.terminal.toggle"), keybind: "ctrl+`", slash: "terminal", onSelect: () => { if (view().terminal.opened()) { terminal.cancelFocus() view().terminal.close() return } terminal.requestFocus(terminal.active()) view().terminal.open() }, }), viewCommand({ id: "review.toggle", title: language.t("command.review.toggle"), keybind: "mod+shift+r", onSelect: () => view().reviewPanel.toggle(), }), ...(shown() ? [ viewCommand({ id: "fileTree.toggle", title: language.t("command.fileTree.toggle"), keybind: "mod+\\", onSelect: () => layout.fileTree.toggle(), }), ] : []), viewCommand({ id: "input.focus", title: language.t("command.input.focus"), keybind: "ctrl+l", onSelect: focusInput, }), ] const terminalCmds = () => [ terminalCommand({ id: "terminal.close", title: language.t("terminal.close"), keybind: "mod+w", hidden: true, when: (event) => event.target instanceof Element && !!event.target.closest('[data-component="terminal"]'), onSelect: closeTerminal, }), terminalCommand({ id: "terminal.new", title: language.t("command.terminal.new"), description: language.t("command.terminal.new.description"), keybind: "ctrl+alt+t", onSelect: openTerminal, }), ] const messageCmds = () => [ sessionCommand({ id: "message.previous", title: language.t("command.message.previous"), description: language.t("command.message.previous.description"), keybind: "mod+alt+[", disabled: !params.id, onSelect: () => navigateMessageByOffset(-1), }), sessionCommand({ id: "message.next", title: language.t("command.message.next"), description: language.t("command.message.next.description"), keybind: "mod+alt+]", disabled: !params.id, onSelect: () => navigateMessageByOffset(1), }), ] const mcpCmds = () => [ mcpCommand({ id: "mcp.toggle", title: language.t("command.mcp.toggle"), description: language.t("command.mcp.toggle.description"), keybind: "mod+;", slash: "mcp", onSelect: chooseMcp, }), ] const permissionsCmds = () => [ permissionsCommand({ id: "permissions.autoaccept", title: isAutoAcceptActive() ? language.t("command.permissions.autoaccept.disable") : language.t("command.permissions.autoaccept.enable"), keybind: "mod+shift+a", disabled: false, onSelect: toggleAutoAccept, }), ] command.register("session", () => [ ...sessionCmds(), ...shareCmds(), ...fileCmds(), ...contextCmds(), ...viewCmds(), ...terminalCmds(), ...messageCmds(), ...mcpCmds(), ...permissionsCmds(), ]) }