import { findNode, isVisible, stackOf, stacks, type OpenRequest, type Stack } from "./layout.ts"; import type { Workbench } from "./workbench.ts"; /** * A named shell action (D12). The same table backs keybindings, a command palette, and the * `nib shell ` command an agent would call. Keys use `Mod` for Cmd on macOS and Ctrl elsewhere. */ export interface ShellCommand { readonly id: string; readonly title: string; readonly keys?: string; readonly run: (workbench: Workbench) => void; } /** The stack that has focus, which commands without an explicit target act on. */ export function focusedStack(workbench: Workbench): Stack { const node = findNode(workbench.layout.root, workbench.layout.focus); if (node?.type !== "stack") throw new Error("focus does not point at a stack"); return node; } function activePanel(workbench: Workbench) { return focusedStack(workbench).active; } /** The visible stack `step` places from the focused one, in layout order, wrapping around. */ function stackFrom(workbench: Workbench, step: number): Stack | undefined { const { layout } = workbench; const shown = stacks(layout.root).filter((stack) => isVisible(layout, stack)); const at = shown.findIndex((stack) => stack.id === layout.focus); if (shown.length < 2 || at === -1) return undefined; return shown[(at + step + shown.length) % shown.length]; } /** The views the activity bar shows or hides, with how to open each one when none exists. */ export const views = { explorer: { title: "Explorer", open: (): OpenRequest => ({ kind: "explorer", input: { root: "/" } }) }, terminal: { title: "Terminal", open: (wb: Workbench): OpenRequest => ({ kind: "terminal", input: { cwd: "/", session: wb.env.id() } }) }, processes: { title: "Processes", open: (): OpenRequest => ({ kind: "processes", input: { sandbox: "default" } }) }, agent: { title: "Agent", open: (wb: Workbench): OpenRequest => ({ kind: "agent", input: { conversation: wb.env.id() } }) }, } as const; export type ToggledView = keyof typeof views; /** Whether a view of `kind` is on screen: it exists, is its stack's active panel, and its part isn't hidden. */ export function isViewShown(workbench: Workbench, kind: ToggledView): boolean { const { layout } = workbench; return stacks(layout.root).some( (stack) => isVisible(layout, stack) && stack.active !== null && layout.panels[stack.active]?.kind === kind, ); } /** * Shows or hides a view, as an activity bar button does: opens it when none exists, reveals it when * its part is hidden or another tab covers it, and hides its part when it's already showing there. * A closed view always has a way back. */ export function showOrHideView(workbench: Workbench, kind: ToggledView): void { const { layout } = workbench; const panel = Object.values(layout.panels).find((p) => p.kind === kind); if (!panel) return void workbench.open(views[kind].open(workbench)); const stack = stackOf(layout.root, panel.id)!; if (stack.slot && stack.slot !== "main" && layout.hidden.includes(stack.slot)) { workbench.dispatch({ type: "toggle", slot: stack.slot, visible: true }); workbench.dispatch({ type: "activate", panel: panel.id }); return workbench.dispatch({ type: "focus", stack: stack.id }); } if (stack.active === panel.id && stack.slot && stack.slot !== "main") { return workbench.dispatch({ type: "toggle", slot: stack.slot, visible: false }); } workbench.dispatch({ type: "activate", panel: panel.id }); workbench.dispatch({ type: "focus", stack: stack.id }); } /** Moves the focused stack's active panel to the neighbouring group: the keyboard way to drag a tab. */ function moveToGroup(workbench: Workbench, step: number) { const panel = activePanel(workbench); const target = stackFrom(workbench, step); if (panel && target) workbench.dispatch({ type: "move", panel, to: { stack: target.id } }); } export const commands: readonly ShellCommand[] = [ { id: "view.explorer", title: "Show Explorer", keys: "Mod+Shift+E", run: (wb) => void wb.open({ kind: "explorer", input: { root: "/" } }) }, { id: "view.terminal.new", title: "New Terminal", keys: "Ctrl+Shift+`", run: (wb) => void wb.open({ kind: "terminal", input: { cwd: "/", session: wb.env.id() } }), }, { id: "view.processes", title: "Show Processes", run: (wb) => void wb.open(views.processes.open()) }, { id: "view.agent.new", title: "New Agent Conversation", keys: "Mod+Shift+I", run: (wb) => void wb.open({ kind: "agent", input: { conversation: wb.env.id() } }), }, ...(Object.keys(views) as ToggledView[]).map((kind) => ({ id: `view.${kind}.toggle`, title: `Show or Hide ${views[kind].title}`, run: (wb: Workbench) => showOrHideView(wb, kind), })), { id: "focus.part.next", title: "Focus Next Part", keys: "F6", run: (wb) => { const next = stackFrom(wb, 1); if (next) wb.dispatch({ type: "focus", stack: next.id }); }, }, { id: "focus.part.previous", title: "Focus Previous Part", keys: "Shift+F6", run: (wb) => { const previous = stackFrom(wb, -1); if (previous) wb.dispatch({ type: "focus", stack: previous.id }); }, }, { id: "panel.move.next", title: "Move Panel to Next Group", keys: "Alt+Shift+ArrowRight", run: (wb) => moveToGroup(wb, 1) }, { id: "panel.move.previous", title: "Move Panel to Previous Group", keys: "Alt+Shift+ArrowLeft", run: (wb) => moveToGroup(wb, -1) }, { id: "layout.toggle.primary", title: "Toggle Primary Side Bar", keys: "Mod+B", run: (wb) => wb.dispatch({ type: "toggle", slot: "primary" }) }, { id: "layout.toggle.secondary", title: "Toggle Secondary Side Bar", keys: "Mod+Alt+B", run: (wb) => wb.dispatch({ type: "toggle", slot: "secondary" }) }, { id: "layout.toggle.panel", title: "Toggle Panel", keys: "Mod+J", run: (wb) => wb.dispatch({ type: "toggle", slot: "panel" }) }, { id: "panel.close", title: "Close Panel", // Browsers reserve Mod+W, so the web shell cannot claim it. keys: "Alt+W", run: (wb) => { const panel = activePanel(wb); if (panel) wb.dispatch({ type: "close", panel }); }, }, { id: "panel.float", title: "Move Panel to New Window", // A key press counts as the gesture a popup needs, so this works where a drag's end doesn't (Safari). keys: "Alt+Shift+W", run: (wb) => { const panel = activePanel(wb); if (panel) wb.dispatch({ type: "float", panel }); }, }, { id: "panel.split.right", title: "Move Panel to a New Group on the Right", keys: "Mod+\\", run: (wb) => { const stack = focusedStack(wb); if (stack.active && stack.panels.length > 1) { wb.dispatch({ type: "move", panel: stack.active, to: { beside: stack.id, edge: "right" } }); } }, }, { id: "panel.split.down", title: "Move Panel to a New Group Below", run: (wb) => { const stack = focusedStack(wb); if (stack.active && stack.panels.length > 1) { wb.dispatch({ type: "move", panel: stack.active, to: { beside: stack.id, edge: "bottom" } }); } }, }, ]; export function command(id: string): ShellCommand | undefined { return commands.find((c) => c.id === id); }