diff --git a/src/app/App.tsx b/src/app/App.tsx index a3edf78b6..867357aad 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -73,10 +73,12 @@ import { hasLeaf, leafIds, navigateFocusedBlocks, + submitToLeaf, type TerminalPaneHandle, useTerminalFileDrop, writeToSession, } from "@/modules/terminal"; +import { useTerminalComposerStore } from "@/modules/terminal/composer/terminalComposerStore"; import { SpaceSwitcher, useSpaces, @@ -273,6 +275,7 @@ export default function App() { const [newEditorOpen, setNewEditorOpen] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [terminalComposerOpen, setTerminalComposerOpen] = useState(false); const [paletteInitialMode, setPaletteInitialMode] = useState< "commands" | "content" >("commands"); @@ -300,6 +303,42 @@ export default function App() { const isEditorTab = activeTab?.kind === "editor"; const isGitHistoryTab = activeTab?.kind === "git-history"; + const closeTerminalComposer = useCallback( + () => setTerminalComposerOpen(false), + [], + ); + const toggleTerminalComposer = useCallback(() => { + if (!activeTerminalTab || activeLeafId === null) return; + setTerminalComposerOpen((open) => !open); + }, [activeTerminalTab, activeLeafId]); + const sendTerminalComposerText = useCallback( + (text: string) => { + if (activeLeafId === null) return; + submitToLeaf(activeLeafId, text); + setTerminalComposerOpen(false); + terminalRefs.current.get(activeLeafId)?.focus(); + }, + [activeLeafId], + ); + const sendTerminalQueuedPromptText = useCallback( + (text: string): boolean => { + if (activeLeafId === null) return false; + if (!submitToLeaf(activeLeafId, text)) return false; + terminalRefs.current.get(activeLeafId)?.focus(); + return true; + }, + [activeLeafId], + ); + const sendNextQueuedTerminalPrompt = useCallback(() => { + if (activeLeafId === null) return; + const store = useTerminalComposerStore.getState(); + const item = store.queuedFor(activeLeafId)[0]; + if (!item) return; + if (sendTerminalQueuedPromptText(item.text)) { + store.dequeueById(activeLeafId, item.id); + } + }, [activeLeafId, sendTerminalQueuedPromptText]); + useEditorFileSync({ tabs, tabsRef, editorRefs }); useThemeFileEditing({ tabsRef, openFileTab }); @@ -673,6 +712,8 @@ export default function App() { }, "terminal.toggleInput": () => window.dispatchEvent(new CustomEvent(TOGGLE_BLOCK_INPUT_EVENT)), + "terminalComposer.toggle": toggleTerminalComposer, + "terminalComposer.sendQueued": sendNextQueuedTerminalPrompt, "blocks.prev": () => navigateFocusedBlocks(-1), "blocks.next": () => navigateFocusedBlocks(1), "search.focus": () => searchInlineRef.current?.focus(), @@ -707,6 +748,8 @@ export default function App() { splitActivePaneInActiveTab, focusNextPaneInTab, toggleSourceControl, + toggleTerminalComposer, + sendNextQueuedTerminalPrompt, togglePanelAndFocus, askFromSelection, toggleSidebar, @@ -742,10 +785,19 @@ export default function App() { } if ( id === "terminal.toggleInput" || + id === "terminalComposer.toggle" || + id === "terminalComposer.sendQueued" || id === "blocks.prev" || id === "blocks.next" ) { - return !(activeTab?.kind === "terminal" && activeTab.blocks === true); + if ( + id === "terminal.toggleInput" || + id === "blocks.prev" || + id === "blocks.next" + ) { + return !(activeTab?.kind === "terminal" && activeTab.blocks === true); + } + return activeTab?.kind !== "terminal"; } if (id === "sidebar.toggle") { // Ctrl+B is also Claude Code's "run in background" key. While a terminal @@ -989,6 +1041,7 @@ export default function App() { closeActiveTabOrPane: handleCloseTabOrPane, splitPaneRight: () => splitActivePaneInActiveTab("row"), splitPaneDown: () => splitActivePaneInActiveTab("col"), + toggleTerminalComposer, focusSearch: () => searchInlineRef.current?.focus(), focusExplorerSearch: () => explorerRef.current?.focusSearch(), toggleSidebar, @@ -1018,6 +1071,7 @@ export default function App() { toggleSourceControl, handleCloseTabOrPane, splitActivePaneInActiveTab, + toggleTerminalComposer, toggleSidebar, togglePanelAndFocus, askFromSelection, @@ -1181,6 +1235,10 @@ export default function App() { hasComposer={hasComposer} panelOpen={panelOpen} keysLoaded={keysLoaded} + terminalComposerOpen={terminalComposerOpen} + onTerminalComposerClose={closeTerminalComposer} + onTerminalComposerSend={sendTerminalComposerText} + onTerminalQueuedPromptSend={sendTerminalQueuedPromptText} onConnect={() => void openSettingsWindow("models")} /> diff --git a/src/app/components/WorkspaceInputBar.tsx b/src/app/components/WorkspaceInputBar.tsx index 9a80ab9a2..1e4855d35 100644 --- a/src/app/components/WorkspaceInputBar.tsx +++ b/src/app/components/WorkspaceInputBar.tsx @@ -3,6 +3,8 @@ import { AiInputBarConnect } from "@/modules/ai"; import { Chip } from "@/modules/ai/components/Chip"; import { ChipsRow } from "@/modules/ai/components/ChipsRow"; import { useComposer } from "@/modules/ai/lib/composer"; +import { TerminalPromptQueue } from "@/modules/terminal/composer/TerminalPromptQueue"; +import { useTerminalComposerStore } from "@/modules/terminal/composer/terminalComposerStore"; import { useBlockController } from "@/modules/terminal/lib/blockController"; import { focusLeafInput } from "@/modules/terminal/lib/useTerminalSession"; import { useTheme } from "@/modules/theme"; @@ -25,6 +27,11 @@ const AiComposerInput = lazy(() => default: m.AiComposerInput, })), ); +const TerminalComposer = lazy(() => + import("@/modules/terminal/composer/TerminalComposer").then((m) => ({ + default: m.TerminalComposer, + })), +); export const TOGGLE_BLOCK_INPUT_EVENT = "terax:toggle-block-input"; @@ -37,6 +44,10 @@ type Props = { hasComposer: boolean; panelOpen: boolean; keysLoaded: boolean; + terminalComposerOpen: boolean; + onTerminalComposerClose: () => void; + onTerminalComposerSend: (text: string) => void; + onTerminalQueuedPromptSend: (text: string) => boolean; onConnect: () => void; }; @@ -49,6 +60,10 @@ export function WorkspaceInputBar({ hasComposer, panelOpen, keysLoaded, + terminalComposerOpen, + onTerminalComposerClose, + onTerminalComposerSend, + onTerminalQueuedPromptSend, onConnect, }: Props) { const c = useComposer(); @@ -73,9 +88,21 @@ export function WorkspaceInputBar({ const showToggle = isBlockTab && hasComposer; const [mode, setMode] = useState<"shell" | "ai">("shell"); const effectiveMode = !isBlockTab ? "ai" : hasComposer ? mode : "shell"; + const terminalComposerVisible = + isTerminalTab && activeLeafId !== null && terminalComposerOpen; + const terminalQueueCount = useTerminalComposerStore((state) => + activeLeafId === null ? 0 : (state.queues[activeLeafId]?.length ?? 0), + ); + const terminalQueueVisible = + isTerminalTab && activeLeafId !== null && terminalQueueCount > 0; - const mounted = keysLoaded || isBlockTab; - const open = isBlockTab || (keysLoaded && panelOpen); + const mounted = + terminalComposerVisible || terminalQueueVisible || keysLoaded || isBlockTab; + const open = + terminalComposerVisible || + terminalQueueVisible || + isBlockTab || + (keysLoaded && panelOpen); const [aiLoaded, setAiLoaded] = useState(false); useEffect(() => { @@ -190,7 +217,26 @@ export function WorkspaceInputBar({ className="terax-reveal" aria-hidden={!open} > -
{content}
+ {terminalQueueVisible && activeLeafId !== null && ( + + )} +
+ {terminalComposerVisible && activeLeafId !== null ? ( + + + + ) : ( + content + )} +
); } diff --git a/src/modules/command-palette/commands.test.ts b/src/modules/command-palette/commands.test.ts new file mode 100644 index 000000000..01c4c6004 --- /dev/null +++ b/src/modules/command-palette/commands.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; + +import { DEFAULT_SPACE_ID, type Tab } from "@/modules/tabs"; + +import { + createCommandItems, + type CommandPaletteActionContext, +} from "./commands"; + +function terminalTab(id: number, leafId: number): Tab { + return { + id, + kind: "terminal", + title: "Terminal", + spaceId: DEFAULT_SPACE_ID, + paneTree: { kind: "leaf", id: leafId }, + activeLeafId: leafId, + }; +} + +function editorTab(id: number): Tab { + return { + id, + kind: "editor", + title: "Editor", + spaceId: DEFAULT_SPACE_ID, + path: "/tmp/file.ts", + dirty: false, + preview: false, + }; +} + +function context( + tab: Tab, + overrides: Partial & { + toggleTerminalComposer?: () => void; + } = {}, +): CommandPaletteActionContext { + return { + tabs: [tab], + activeId: tab.id, + searchTarget: null, + explorerRoot: "/tmp", + home: "/tmp", + openNewTab: vi.fn(), + openNewBlock: vi.fn(), + openNewPrivate: vi.fn(), + openNewEditor: vi.fn(), + openNewPreview: vi.fn(), + openGitGraph: vi.fn(), + toggleSourceControl: vi.fn(), + closeActiveTabOrPane: vi.fn(), + splitPaneRight: vi.fn(), + splitPaneDown: vi.fn(), + focusSearch: vi.fn(), + focusExplorerSearch: vi.fn(), + toggleSidebar: vi.fn(), + toggleAi: vi.fn(), + askAiSelection: vi.fn(), + openSettings: vi.fn(), + openKeyboardShortcuts: vi.fn(), + spaces: [], + activeSpaceId: DEFAULT_SPACE_ID, + openSpacesOverview: vi.fn(), + newSpace: vi.fn(), + switchSpace: vi.fn(), + ...overrides, + } as CommandPaletteActionContext; +} + +describe("createCommandItems", () => { + it("includes a terminal composer action for terminal tabs", () => { + const toggleTerminalComposer = vi.fn(); + const items = createCommandItems( + context(terminalTab(1, 101), { toggleTerminalComposer }), + ); + + const item = items.find((entry) => entry.id === "terminalComposer.toggle"); + + expect(item).toMatchObject({ + title: "Open terminal composer", + group: "Terminal", + shortcutId: "terminalComposer.toggle", + disabledReason: undefined, + }); + item?.run(); + expect(toggleTerminalComposer).toHaveBeenCalledOnce(); + }); + + it("disables the terminal composer action outside terminal tabs", () => { + const items = createCommandItems(context(editorTab(1))); + + expect( + items.find((entry) => entry.id === "terminalComposer.toggle"), + ).toMatchObject({ + disabledReason: "No terminal tab", + }); + }); +}); diff --git a/src/modules/command-palette/commands.ts b/src/modules/command-palette/commands.ts index 9f5e1c952..e97874b81 100644 --- a/src/modules/command-palette/commands.ts +++ b/src/modules/command-palette/commands.ts @@ -26,6 +26,7 @@ export const COMMAND_GROUPS = [ "Spaces", "Tabs", "Panes", + "Terminal", "Git", "Search", "View", @@ -48,6 +49,7 @@ export type CommandPaletteActionContext = { closeActiveTabOrPane: () => void; splitPaneRight: () => void; splitPaneDown: () => void; + toggleTerminalComposer: () => void; focusSearch: () => void; focusExplorerSearch: () => void; toggleSidebar: () => void; @@ -148,9 +150,19 @@ export function createCommandItems( title: "New block terminal", group: "Tabs", keywords: ["blocks", "warp", "command blocks", "terminal"], - icon: DashboardSquare01Icon, - run: ctx.openNewBlock, - }, + icon: DashboardSquare01Icon, + run: ctx.openNewBlock, + }, + { + id: "terminalComposer.toggle", + title: "Open terminal composer", + group: "Terminal", + keywords: ["composer", "draft", "prompt", "queue", "terminal"], + icon: TerminalIcon, + shortcutId: "terminalComposer.toggle", + disabledReason: activeTerminalTab ? undefined : "No terminal tab", + run: ctx.toggleTerminalComposer, + }, { id: "tab.newPrivate", title: "New private terminal", diff --git a/src/modules/settings/store.ts b/src/modules/settings/store.ts index a300c6f7d..22c7efb16 100644 --- a/src/modules/settings/store.ts +++ b/src/modules/settings/store.ts @@ -15,6 +15,14 @@ import { WHISPERCPP_DEFAULT_BASE_URL, } from "@/modules/ai/config"; import type { KeyBinding, ShortcutId } from "@/modules/shortcuts/shortcuts"; +import { + DEFAULT_COMPOSER_SYNTAX_RULES, + DEFAULT_COMPOSER_SYNTAX_MODE, + normalizeComposerSyntaxRules, + resolveComposerSyntaxMode, + type ComposerSyntaxRule, + type ComposerSyntaxMode, +} from "@/modules/terminal/composer/composerLanguage"; import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event"; import { LazyStore } from "@tauri-apps/plugin-store"; @@ -154,6 +162,8 @@ export type Preferences = { terminalLetterSpacing: number; terminalFontSize: number; terminalScrollback: number; + terminalComposerSyntaxMode: ComposerSyntaxMode; + terminalComposerSyntaxRules: ComposerSyntaxRule[]; lastWslDistro: string | null; zoomLevel: number; agentNotifications: boolean; @@ -221,6 +231,8 @@ const KEY_TERMINAL_SHELL = "terminalShell"; const KEY_TERMINAL_LETTER_SPACING = "terminalLetterSpacing"; const KEY_TERMINAL_FONT_SIZE = "terminalFontSize"; const KEY_TERMINAL_SCROLLBACK = "terminalScrollback"; +const KEY_TERMINAL_COMPOSER_SYNTAX_MODE = "terminalComposerSyntaxMode"; +const KEY_TERMINAL_COMPOSER_SYNTAX_RULES = "terminalComposerSyntaxRules"; const KEY_LAST_WSL_DISTRO = "lastWslDistro"; const KEY_ZOOM_LEVEL = "zoomLevel"; const KEY_AGENT_NOTIFICATIONS = "agentNotifications"; @@ -289,6 +301,8 @@ export const DEFAULT_PREFERENCES: Preferences = { terminalLetterSpacing: 0, terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT, terminalScrollback: TERMINAL_SCROLLBACK_DEFAULT, + terminalComposerSyntaxMode: DEFAULT_COMPOSER_SYNTAX_MODE, + terminalComposerSyntaxRules: DEFAULT_COMPOSER_SYNTAX_RULES, lastWslDistro: null, zoomLevel: 1.0, agentNotifications: true, @@ -442,6 +456,12 @@ export async function loadPreferences(): Promise { get(KEY_TERMINAL_SCROLLBACK) ?? DEFAULT_PREFERENCES.terminalScrollback, ), + terminalComposerSyntaxMode: resolveComposerSyntaxMode( + get(KEY_TERMINAL_COMPOSER_SYNTAX_MODE), + ), + terminalComposerSyntaxRules: normalizeComposerSyntaxRules( + get(KEY_TERMINAL_COMPOSER_SYNTAX_RULES), + ), lastWslDistro: get(KEY_LAST_WSL_DISTRO) ?? DEFAULT_PREFERENCES.lastWslDistro, @@ -706,6 +726,24 @@ export async function setTerminalScrollback(value: number): Promise { await writePref(KEY_TERMINAL_SCROLLBACK, clampScrollback(value)); } +export async function setTerminalComposerSyntaxMode( + value: ComposerSyntaxMode, +): Promise { + await writePref( + KEY_TERMINAL_COMPOSER_SYNTAX_MODE, + resolveComposerSyntaxMode(value), + ); +} + +export async function setTerminalComposerSyntaxRules( + value: ComposerSyntaxRule[], +): Promise { + await writePref( + KEY_TERMINAL_COMPOSER_SYNTAX_RULES, + normalizeComposerSyntaxRules(value), + ); +} + export async function setLastWslDistro(value: string | null): Promise { await writePref(KEY_LAST_WSL_DISTRO, value); } @@ -794,6 +832,8 @@ export async function onPreferencesChange( [KEY_TERMINAL_LETTER_SPACING]: "terminalLetterSpacing", [KEY_TERMINAL_FONT_SIZE]: "terminalFontSize", [KEY_TERMINAL_SCROLLBACK]: "terminalScrollback", + [KEY_TERMINAL_COMPOSER_SYNTAX_MODE]: "terminalComposerSyntaxMode", + [KEY_TERMINAL_COMPOSER_SYNTAX_RULES]: "terminalComposerSyntaxRules", [KEY_LAST_WSL_DISTRO]: "lastWslDistro", [KEY_ZOOM_LEVEL]: "zoomLevel", [KEY_AGENT_NOTIFICATIONS]: "agentNotifications", diff --git a/src/modules/shortcuts/lib/useGlobalShortcuts.ts b/src/modules/shortcuts/lib/useGlobalShortcuts.ts index ea4f59f8f..a8fa639cb 100644 --- a/src/modules/shortcuts/lib/useGlobalShortcuts.ts +++ b/src/modules/shortcuts/lib/useGlobalShortcuts.ts @@ -31,9 +31,9 @@ export function useGlobalShortcuts( const bindings = userShortcuts[s.id] || s.defaultBindings; const isMatch = bindings.some((b) => matchBinding(e, b, s.id)); if (!isMatch) continue; - if (options?.isDisabled?.(s.id, e)) return; + if (options?.isDisabled?.(s.id, e)) continue; const h = handlers[s.id]; - if (!h) return; + if (!h) continue; e.preventDefault(); e.stopImmediatePropagation(); h(e); diff --git a/src/modules/shortcuts/shortcuts.test.ts b/src/modules/shortcuts/shortcuts.test.ts new file mode 100644 index 000000000..fdcb1e2b6 --- /dev/null +++ b/src/modules/shortcuts/shortcuts.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { MOD_PROP } from "@/lib/platform"; +import { getBindingTokens, SHORTCUTS } from "./shortcuts"; + +const shortcutsById = new Map( + SHORTCUTS.map((shortcut) => [shortcut.id, shortcut]), +); + +describe("composer shortcuts", () => { + it("registers toggle, send, and queue shortcuts in the Composer group", () => { + expect(shortcutsById.get("terminalComposer.toggle")?.group).toBe( + "Composer", + ); + expect(shortcutsById.get("terminalComposer.send")?.group).toBe("Composer"); + expect(shortcutsById.get("terminalComposer.queue")?.group).toBe("Composer"); + }); + + it("documents the default composer editor bindings", () => { + expect( + getBindingTokens( + shortcutsById.get("terminalComposer.send")?.defaultBindings[0], + ), + ).toContain("Enter"); + expect( + getBindingTokens( + shortcutsById.get("terminalComposer.queue")?.defaultBindings[0], + ), + ).toContain("Enter"); + expect( + shortcutsById.get("terminalComposer.queue")?.defaultBindings[0]?.shift, + ).toBe(true); + }); + + it("uses the platform primary modifier for composer defaults", () => { + for (const id of [ + "terminalComposer.toggle", + "terminalComposer.send", + "terminalComposer.queue", + "terminalComposer.sendQueued", + ] as const) { + const binding = shortcutsById.get(id)?.defaultBindings[0]; + expect(binding?.[MOD_PROP]).toBe(true); + expect(binding?.[MOD_PROP === "meta" ? "ctrl" : "meta"]).not.toBe(true); + } + }); +}); diff --git a/src/modules/shortcuts/shortcuts.ts b/src/modules/shortcuts/shortcuts.ts index 7285a84ca..a85120c0c 100644 --- a/src/modules/shortcuts/shortcuts.ts +++ b/src/modules/shortcuts/shortcuts.ts @@ -26,6 +26,10 @@ export type ShortcutId = | "pane.source" | "terminal.clear" | "terminal.toggleInput" + | "terminalComposer.toggle" + | "terminalComposer.send" + | "terminalComposer.queue" + | "terminalComposer.sendQueued" | "blocks.prev" | "blocks.next" | "search.focus" @@ -49,6 +53,7 @@ export type ShortcutGroup = | "Spaces" | "Panes" | "Terminal" + | "Composer" | "Search" | "AI" | "View" @@ -171,6 +176,34 @@ export const SHORTCUTS: Shortcut[] = [ group: "Terminal", defaultBindings: [{ [MOD_PROP]: true, key: "u" }], }, + { + id: "terminalComposer.toggle", + label: "Toggle terminal composer", + group: "Composer", + defaultBindings: [{ [MOD_PROP]: true, shift: true, key: "e" }], + }, + + { + id: "terminalComposer.send", + label: "Send composer draft", + group: "Composer", + defaultBindings: [{ [MOD_PROP]: true, key: "Enter" }], + }, + + { + id: "terminalComposer.queue", + label: "Queue composer draft", + group: "Composer", + defaultBindings: [{ [MOD_PROP]: true, shift: true, key: "Enter" }], + }, + + { + id: "terminalComposer.sendQueued", + label: "Send next queued terminal prompt", + group: "Composer", + defaultBindings: [{ [MOD_PROP]: true, alt: true, key: "Enter" }], + }, + { id: "blocks.prev", label: "Previous command block", @@ -327,6 +360,7 @@ export const SHORTCUT_GROUPS: ShortcutGroup[] = [ "Tabs", "Panes", "Terminal", + "Composer", "View", "Search", "AI", diff --git a/src/modules/terminal/composer/TerminalComposer.tsx b/src/modules/terminal/composer/TerminalComposer.tsx new file mode 100644 index 000000000..f73011d75 --- /dev/null +++ b/src/modules/terminal/composer/TerminalComposer.tsx @@ -0,0 +1,310 @@ +import { Button } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuLabel, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { resolveFontFamily } from "@/lib/fonts"; +import { cn } from "@/lib/utils"; +import { usePreferencesStore } from "@/modules/settings/preferences"; +import { + SHORTCUTS, + type KeyBinding, + type ShortcutId, +} from "@/modules/shortcuts"; +import { + activeAgentForLeaf, + subscribeTerminalAgentActivity, +} from "@/modules/terminal/lib/useTerminalSession"; +import { + ArrowRight01Icon, + Cancel01Icon, + TerminalIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { + codeMirrorKeyForBinding, + createComposerEditor, + type ComposerEditorHandle, +} from "./composerEditor"; +import { clampComposerHeight, DEFAULT_COMPOSER_HEIGHT } from "./composerLayout"; +import { + COMPOSER_SYNTAX_MODES, + type ComposerSyntaxMode, + loadComposerSyntaxExtension, + resolveComposerSyntaxMode, + resolveComposerSyntaxModeForContext, +} from "./composerLanguage"; +import { useTerminalComposerStore } from "./terminalComposerStore"; + +type Props = { + leafId: number; + onSend: (text: string) => void; + onClose: () => void; +}; + +const SHORTCUTS_BY_ID = new Map( + SHORTCUTS.map((shortcut) => [shortcut.id, shortcut]), +); + +export function TerminalComposer({ leafId, onSend, onClose }: Props) { + const hostRef = useRef(null); + const handleRef = useRef(null); + const stopResizeRef = useRef<(() => void) | null>(null); + const [composerHeight, setComposerHeight] = useState(DEFAULT_COMPOSER_HEIGHT); + const [syntaxOverride, setSyntaxOverride] = + useState(null); + const [activeAgent, setActiveAgent] = useState(() => + activeAgentForLeaf(leafId), + ); + const setDraft = useTerminalComposerStore((state) => state.setDraft); + const fontFamilyPref = usePreferencesStore( + (state) => state.terminalFontFamily, + ); + const fontSize = usePreferencesStore((state) => state.terminalFontSize); + const userShortcuts = usePreferencesStore((state) => state.shortcuts); + const defaultSyntaxMode = usePreferencesStore( + (state) => state.terminalComposerSyntaxMode, + ); + const syntaxRules = usePreferencesStore( + (state) => state.terminalComposerSyntaxRules, + ); + const fontFamily = resolveFontFamily(fontFamilyPref); + const sendKeys = useMemo( + () => shortcutKeysFor("terminalComposer.send", userShortcuts), + [userShortcuts], + ); + const queueKeys = useMemo( + () => shortcutKeysFor("terminalComposer.queue", userShortcuts), + [userShortcuts], + ); + const contextSyntaxMode = useMemo( + () => + resolveComposerSyntaxModeForContext({ + agentName: activeAgent, + defaultMode: defaultSyntaxMode, + rules: syntaxRules, + }), + [activeAgent, defaultSyntaxMode, syntaxRules], + ); + const syntaxMode = syntaxOverride ?? contextSyntaxMode; + + useEffect(() => { + setSyntaxOverride(null); + const update = () => setActiveAgent(activeAgentForLeaf(leafId)); + update(); + return subscribeTerminalAgentActivity(update); + }, [leafId]); + + useEffect(() => { + const parent = hostRef.current; + if (!parent) return; + const handle = createComposerEditor({ + parent, + doc: useTerminalComposerStore.getState().draftFor(leafId), + fontFamily, + fontSize, + sendKeys, + queueKeys, + shellCompletion: syntaxMode === "bash", + syntaxExtension: [], + onChange: (text) => setDraft(leafId, text), + onSend: (text) => sendDraft(leafId, text, onSend), + onQueue: (text) => queueDraft(leafId, text), + onClose, + }); + handleRef.current = handle; + requestAnimationFrame(() => handle.focus()); + return () => { + const value = handle.getValue(); + setDraft(leafId, value); + handle.destroy(); + handleRef.current = null; + }; + }, [ + leafId, + fontFamily, + fontSize, + onClose, + onSend, + queueKeys, + sendKeys, + setDraft, + syntaxMode, + ]); + + useEffect(() => { + let cancelled = false; + void loadComposerSyntaxExtension(syntaxMode).then((extension) => { + if (!cancelled) handleRef.current?.setSyntaxExtension(extension); + }); + return () => { + cancelled = true; + }; + }, [syntaxMode]); + + useEffect(() => { + return () => stopResizeRef.current?.(); + }, []); + + const beginResize = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + stopResizeRef.current?.(); + + const startY = event.clientY; + const startHeight = composerHeight; + const previousCursor = document.body.style.cursor; + + const cleanup = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", cleanup); + window.removeEventListener("pointercancel", cleanup); + document.body.style.cursor = previousCursor; + stopResizeRef.current = null; + }; + const onMove = (moveEvent: PointerEvent) => { + setComposerHeight( + clampComposerHeight(startHeight + startY - moveEvent.clientY), + ); + }; + + document.body.style.cursor = "ns-resize"; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", cleanup, { once: true }); + window.addEventListener("pointercancel", cleanup, { once: true }); + stopResizeRef.current = cleanup; + }, + [composerHeight], + ); + + const sendCurrent = () => { + const text = handleRef.current?.getValue() ?? ""; + if (!sendDraft(leafId, text, onSend)) return; + handleRef.current?.clear(); + }; + + const queueCurrent = () => { + const text = handleRef.current?.getValue() ?? ""; + if (!queueDraft(leafId, text)) return; + handleRef.current?.clear(); + }; + + return ( +
+ + + + + + + Syntax mode + + + setSyntaxOverride(resolveComposerSyntaxMode(value)) + } + > + {COMPOSER_SYNTAX_MODES.map((mode) => ( + + {mode.label} + + ))} + + + +
+
+
+ + + +
+
+
+ ); +} + +function sendDraft( + leafId: number, + text: string, + onSend: (text: string) => void, +): boolean { + const store = useTerminalComposerStore.getState(); + store.setDraft(leafId, text); + const draft = store.consumeDraft(leafId); + if (!draft) return false; + onSend(draft); + return true; +} + +function queueDraft(leafId: number, text: string): boolean { + const store = useTerminalComposerStore.getState(); + store.setDraft(leafId, text); + return store.enqueueDraft(leafId) !== null; +} + +function shortcutKeysFor( + id: ShortcutId, + userShortcuts: Record, +): string[] { + const bindings = + userShortcuts[id] ?? SHORTCUTS_BY_ID.get(id)?.defaultBindings ?? []; + return bindings.map(codeMirrorKeyForBinding); +} diff --git a/src/modules/terminal/composer/TerminalPromptQueue.tsx b/src/modules/terminal/composer/TerminalPromptQueue.tsx new file mode 100644 index 000000000..a8fa50d51 --- /dev/null +++ b/src/modules/terminal/composer/TerminalPromptQueue.tsx @@ -0,0 +1,67 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ArrowRight01Icon, TerminalIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useTerminalComposerStore } from "./terminalComposerStore"; + +type Props = { + leafId: number; + onSend: (text: string) => boolean; +}; + +export function TerminalPromptQueue({ leafId, onSend }: Props) { + const queued = useTerminalComposerStore( + (state) => state.queues[leafId] ?? [], + ); + const dequeueById = useTerminalComposerStore((state) => state.dequeueById); + + if (queued.length === 0) return null; + + const sendById = (id: string) => { + const item = queued.find((entry) => entry.id === id); + if (item && onSend(item.text)) dequeueById(leafId, id); + }; + const sendNext = () => { + const item = queued[0]; + if (item && onSend(item.text)) dequeueById(leafId, item.id); + }; + + return ( +
+ +
+ {queued.map((item, index) => ( + + ))} +
+
+ ); +} + +function previewText(text: string): string { + return text.trim().replace(/\s+/g, " "); +} diff --git a/src/modules/terminal/composer/composerEditor.test.ts b/src/modules/terminal/composer/composerEditor.test.ts new file mode 100644 index 000000000..ee004c70b --- /dev/null +++ b/src/modules/terminal/composer/composerEditor.test.ts @@ -0,0 +1,54 @@ +import { insertBracket } from "@codemirror/autocomplete"; +import { tags as t } from "@lezer/highlight"; +import { describe, expect, it, vi } from "vitest"; + +import { + composerHighlightStyle, + createComposerEditorState, + type ComposerEditorOptions, +} from "./composerEditor"; + +const baseOptions: ComposerEditorOptions = { + parent: null as unknown as HTMLElement, + doc: "", + fontFamily: "monospace", + fontSize: 14, + sendKeys: [], + queueKeys: [], + shellCompletion: true, + syntaxExtension: [], + onChange: vi.fn(), + onSend: vi.fn(() => true), + onQueue: vi.fn(() => true), + onClose: vi.fn(), +}; + +describe("terminal composer editor extensions", () => { + it("installs syntax highlighting for loaded language modes", () => { + expect(composerHighlightStyle.style([t.keyword])).toBeTruthy(); + expect(composerHighlightStyle.style([t.heading2])).toBeTruthy(); + }); + + it("uses composer-specific token colors instead of UI theme colors", () => { + const styleValues = composerHighlightStyle.specs.flatMap((spec) => + Object.entries(spec) + .filter(([key]) => key !== "tag") + .map(([, value]) => String(value)), + ); + + expect(styleValues).toContain("var(--composer-syntax-keyword)"); + expect(styleValues.some((value) => value.includes("var(--primary)"))).toBe( + false, + ); + expect(styleValues.some((value) => value.includes("var(--chart-"))).toBe( + false, + ); + expect(styleValues).not.toContain("underline"); + }); + + it("enables bracket closing in the editor state", () => { + const state = createComposerEditorState(baseOptions); + + expect(insertBracket(state, "(")).not.toBeNull(); + }); +}); diff --git a/src/modules/terminal/composer/composerEditor.ts b/src/modules/terminal/composer/composerEditor.ts new file mode 100644 index 000000000..2b8b64a08 --- /dev/null +++ b/src/modules/terminal/composer/composerEditor.ts @@ -0,0 +1,254 @@ +import { + autocompletion, + closeBrackets, + completionStatus, +} from "@codemirror/autocomplete"; +import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; +import { + bracketMatching, + HighlightStyle, + syntaxHighlighting, +} from "@codemirror/language"; +import { + Compartment, + type Extension, + EditorState, + Prec, +} from "@codemirror/state"; +import { + drawSelection, + EditorView, + keymap, + placeholder, +} from "@codemirror/view"; +import { tags as t } from "@lezer/highlight"; +import type { KeyBinding } from "@/modules/shortcuts"; +import { composerShellCompletionSource } from "./composerShellCompletion"; + +export type ComposerEditorOptions = { + parent: HTMLElement; + doc: string; + fontFamily: string; + fontSize: number; + sendKeys: string[]; + queueKeys: string[]; + shellCompletion: boolean; + syntaxExtension: Extension; + onChange: (text: string) => void; + onSend: (text: string) => boolean; + onQueue: (text: string) => boolean; + onClose: () => void; +}; + +export type ComposerEditorHandle = { + readonly view: EditorView; + focus: () => void; + getValue: () => string; + setValue: (text: string) => void; + clear: () => void; + retheme: (fontFamily: string, fontSize: number) => void; + setSyntaxExtension: (extension: Extension) => void; + destroy: () => void; +}; + +function clear(view: EditorView) { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: "" }, + }); +} + +function editorTheme(fontFamily: string, fontSize: number) { + return EditorView.theme({ + "&": { + "--composer-syntax-heading": + "color-mix(in srgb, var(--terminal-ansi-bright-magenta, #c084fc) 86%, var(--foreground))", + "--composer-syntax-keyword": + "color-mix(in srgb, var(--terminal-ansi-bright-cyan, #22d3ee) 86%, var(--foreground))", + "--composer-syntax-string": + "color-mix(in srgb, var(--terminal-ansi-bright-green, #4ade80) 82%, var(--foreground))", + "--composer-syntax-comment": + "color-mix(in srgb, var(--terminal-ansi-bright-black, #71717a) 82%, var(--foreground))", + "--composer-syntax-number": + "color-mix(in srgb, var(--terminal-ansi-bright-yellow, #facc15) 76%, var(--foreground))", + "--composer-syntax-variable": + "color-mix(in srgb, var(--terminal-ansi-bright-blue, #60a5fa) 84%, var(--foreground))", + "--composer-syntax-operator": + "color-mix(in srgb, var(--terminal-ansi-white, #a1a1aa) 68%, var(--foreground))", + "--composer-syntax-tag": + "color-mix(in srgb, var(--terminal-ansi-bright-red, #f87171) 84%, var(--foreground))", + "--composer-syntax-link": + "color-mix(in srgb, var(--terminal-ansi-bright-blue, #60a5fa) 90%, var(--foreground))", + backgroundColor: "transparent", + color: "var(--foreground)", + fontSize: `${fontSize}px`, + height: "100%", + minHeight: "100%", + }, + ".cm-content": { + caretColor: "var(--foreground)", + fontFamily, + lineHeight: "1.5", + minHeight: "100%", + padding: "0", + }, + ".cm-line": { + padding: "0", + lineHeight: "1.5", + }, + ".cm-scroller": { + fontFamily, + lineHeight: "1.5", + height: "100%", + maxHeight: "100%", + overflow: "auto", + }, + "&.cm-focused": { outline: "none" }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "var(--foreground)", + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": + { + backgroundColor: "var(--accent)", + }, + ".cm-placeholder": { + color: "var(--muted-foreground)", + }, + }); +} + +export const composerHighlightStyle = HighlightStyle.define([ + { + tag: [t.heading, t.heading1, t.heading2, t.heading3, t.heading4], + color: "var(--composer-syntax-heading)", + fontWeight: "700", + }, + { tag: t.keyword, color: "var(--composer-syntax-keyword)" }, + { + tag: [t.string, t.special(t.string)], + color: "var(--composer-syntax-string)", + }, + { + tag: t.comment, + color: "var(--composer-syntax-comment)", + fontStyle: "italic", + }, + { tag: [t.number, t.bool, t.atom], color: "var(--composer-syntax-number)" }, + { + tag: [t.variableName, t.definition(t.variableName)], + color: "var(--composer-syntax-variable)", + }, + { + tag: [t.operator, t.punctuation], + color: "var(--composer-syntax-operator)", + }, + { tag: [t.tagName, t.attributeName], color: "var(--composer-syntax-tag)" }, + { tag: t.link, color: "var(--composer-syntax-link)" }, + { tag: t.emphasis, fontStyle: "italic" }, + { tag: t.strong, fontWeight: "700" }, +]); + +export function codeMirrorKeyForBinding(binding: KeyBinding): string { + const parts: string[] = []; + if (binding.ctrl) parts.push("Ctrl"); + if (binding.alt) parts.push("Alt"); + if (binding.shift) parts.push("Shift"); + if (binding.meta) parts.push("Meta"); + const key = binding.key === " " ? "Space" : binding.key; + parts.push(key); + return parts.join("-"); +} + +export function createComposerEditor( + opts: ComposerEditorOptions, +): ComposerEditorHandle { + const themeComp = new Compartment(); + const syntaxComp = new Compartment(); + const state = createComposerEditorState(opts, themeComp, syntaxComp); + + const view = new EditorView({ state, parent: opts.parent }); + + return { + view, + focus: () => view.focus(), + getValue: () => view.state.doc.toString(), + setValue: (text) => + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: text }, + selection: { anchor: text.length }, + }), + clear: () => clear(view), + retheme: (fontFamily, fontSize) => { + view.dispatch({ + effects: themeComp.reconfigure(editorTheme(fontFamily, fontSize)), + }); + }, + setSyntaxExtension: (extension) => { + view.dispatch({ + effects: syntaxComp.reconfigure(extension), + }); + }, + destroy: () => view.destroy(), + }; +} + +export function createComposerEditorState( + opts: ComposerEditorOptions, + themeComp = new Compartment(), + syntaxComp = new Compartment(), +): EditorState { + const submitKeys = Prec.highest( + keymap.of([ + ...opts.sendKeys.map((key) => ({ + key, + run: (view: EditorView) => { + if (!opts.onSend(view.state.doc.toString())) return true; + clear(view); + return true; + }, + })), + ...opts.queueKeys.map((key) => ({ + key, + run: (view: EditorView) => { + if (!opts.onQueue(view.state.doc.toString())) return true; + clear(view); + return true; + }, + })), + { + key: "Escape", + run: (view: EditorView) => { + if (completionStatus(view.state) !== null) return false; + opts.onClose(); + return true; + }, + }, + ]), + ); + + const state = EditorState.create({ + doc: opts.doc, + extensions: [ + history(), + drawSelection({ cursorBlinkRate: 1100 }), + EditorState.allowMultipleSelections.of(true), + EditorView.lineWrapping, + placeholder("Draft terminal input"), + EditorView.updateListener.of((update) => { + if (update.docChanged) opts.onChange(update.state.doc.toString()); + }), + submitKeys, + closeBrackets(), + bracketMatching(), + autocompletion({ + override: opts.shellCompletion + ? [composerShellCompletionSource] + : undefined, + }), + keymap.of([...defaultKeymap, ...historyKeymap]), + syntaxComp.of(opts.syntaxExtension), + syntaxHighlighting(composerHighlightStyle, { fallback: true }), + themeComp.of(editorTheme(opts.fontFamily, opts.fontSize)), + ], + }); + return state; +} diff --git a/src/modules/terminal/composer/composerLanguage.test.ts b/src/modules/terminal/composer/composerLanguage.test.ts new file mode 100644 index 000000000..3fe91ea8b --- /dev/null +++ b/src/modules/terminal/composer/composerLanguage.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + COMPOSER_SYNTAX_MODES, + DEFAULT_COMPOSER_SYNTAX_MODE, + DEFAULT_COMPOSER_SYNTAX_RULES, + resolveComposerSyntaxMode, + resolveComposerSyntaxModeForContext, +} from "./composerLanguage"; + +describe("terminal composer syntax mode", () => { + it("defaults to bash highlighting", () => { + expect(DEFAULT_COMPOSER_SYNTAX_MODE).toBe("bash"); + }); + + it("offers markdown and xml modes", () => { + expect(COMPOSER_SYNTAX_MODES.map((mode) => mode.id)).toEqual( + expect.arrayContaining(["bash", "markdown", "xml"]), + ); + }); + + it("falls back to bash for unknown persisted values", () => { + expect(resolveComposerSyntaxMode("xml")).toBe("xml"); + expect(resolveComposerSyntaxMode("unknown")).toBe("bash"); + expect(resolveComposerSyntaxMode(null)).toBe("bash"); + }); + + it("defaults active AI CLIs to markdown", () => { + expect(DEFAULT_COMPOSER_SYNTAX_RULES).toContainEqual( + expect.objectContaining({ + pattern: "claude|codex|gemini", + mode: "markdown", + }), + ); + expect( + resolveComposerSyntaxModeForContext({ + agentName: "codex", + defaultMode: "bash", + rules: DEFAULT_COMPOSER_SYNTAX_RULES, + }), + ).toBe("markdown"); + }); + + it("uses the first matching custom default rule before the unified default", () => { + expect( + resolveComposerSyntaxModeForContext({ + agentName: "psql", + defaultMode: "bash", + rules: [ + { id: "ai", pattern: "claude|codex", mode: "markdown" }, + { id: "psql", pattern: "psql", mode: "sql" }, + ], + }), + ).toBe("sql"); + }); +}); diff --git a/src/modules/terminal/composer/composerLanguage.ts b/src/modules/terminal/composer/composerLanguage.ts new file mode 100644 index 000000000..6d7eca5f3 --- /dev/null +++ b/src/modules/terminal/composer/composerLanguage.ts @@ -0,0 +1,121 @@ +import type { Extension } from "@codemirror/state"; +import { resolveLanguage } from "@/modules/editor/lib/languageResolver"; + +export type ComposerSyntaxMode = + | "bash" + | "markdown" + | "xml" + | "json" + | "yaml" + | "python" + | "javascript" + | "typescript" + | "html" + | "css" + | "sql" + | "plain"; + +export type ComposerSyntaxModeOption = { + id: ComposerSyntaxMode; + label: string; + extension: string | null; +}; + +export type ComposerSyntaxRule = { + id: string; + pattern: string; + mode: ComposerSyntaxMode; +}; + +export const DEFAULT_COMPOSER_SYNTAX_MODE: ComposerSyntaxMode = "bash"; + +export const DEFAULT_COMPOSER_SYNTAX_RULES: ComposerSyntaxRule[] = [ + { id: "ai-cli", pattern: "claude|codex|gemini", mode: "markdown" }, +]; + +export const COMPOSER_SYNTAX_MODES: ComposerSyntaxModeOption[] = [ + { id: "bash", label: "Bash", extension: "sh" }, + { id: "markdown", label: "Markdown", extension: "md" }, + { id: "xml", label: "XML", extension: "xml" }, + { id: "json", label: "JSON", extension: "json" }, + { id: "yaml", label: "YAML", extension: "yaml" }, + { id: "python", label: "Python", extension: "py" }, + { id: "javascript", label: "JavaScript", extension: "js" }, + { id: "typescript", label: "TypeScript", extension: "ts" }, + { id: "html", label: "HTML", extension: "html" }, + { id: "css", label: "CSS", extension: "css" }, + { id: "sql", label: "SQL", extension: "sql" }, + { id: "plain", label: "Plain text", extension: null }, +]; + +const modeIds = new Set(COMPOSER_SYNTAX_MODES.map((mode) => mode.id)); + +export function resolveComposerSyntaxMode(value: unknown): ComposerSyntaxMode { + return typeof value === "string" && modeIds.has(value as ComposerSyntaxMode) + ? (value as ComposerSyntaxMode) + : DEFAULT_COMPOSER_SYNTAX_MODE; +} + +export function normalizeComposerSyntaxRules( + value: unknown, +): ComposerSyntaxRule[] { + if (!Array.isArray(value)) return DEFAULT_COMPOSER_SYNTAX_RULES; + return value + .map((item, index) => { + if (!item || typeof item !== "object") return null; + const raw = item as { pattern?: unknown; mode?: unknown }; + const id = + typeof (raw as { id?: unknown }).id === "string" && + (raw as { id?: string }).id?.trim() + ? (raw as { id: string }).id.trim() + : `composer-rule-${index}`; + const pattern = typeof raw.pattern === "string" ? raw.pattern.trim() : ""; + return { + id, + pattern, + mode: resolveComposerSyntaxMode(raw.mode), + }; + }) + .filter((item): item is ComposerSyntaxRule => item !== null); +} + +export function resolveComposerSyntaxModeForContext({ + agentName, + defaultMode, + rules, +}: { + agentName: string | null; + defaultMode: ComposerSyntaxMode; + rules: ComposerSyntaxRule[]; +}): ComposerSyntaxMode { + if (!agentName) return defaultMode; + for (const rule of rules) { + if (matchesRule(rule.pattern, agentName)) return rule.mode; + } + return defaultMode; +} + +export async function loadComposerSyntaxExtension( + mode: ComposerSyntaxMode, +): Promise { + if (mode === "xml") { + const { html } = await import("@codemirror/lang-html"); + return html({ selfClosingTags: true }); + } + + const option = COMPOSER_SYNTAX_MODES.find((item) => item.id === mode); + if (!option?.extension) return []; + const resolved = await resolveLanguage(`composer.${option.extension}`); + return resolved?.ext ?? []; +} + +function matchesRule(pattern: string, value: string): boolean { + const needle = value.trim(); + const source = pattern.trim(); + if (!needle || !source) return false; + try { + return new RegExp(source, "i").test(needle); + } catch { + return needle.toLowerCase().includes(source.toLowerCase()); + } +} diff --git a/src/modules/terminal/composer/composerLayout.test.ts b/src/modules/terminal/composer/composerLayout.test.ts new file mode 100644 index 000000000..51623a419 --- /dev/null +++ b/src/modules/terminal/composer/composerLayout.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { + clampComposerHeight, + DEFAULT_COMPOSER_HEIGHT, + MAX_COMPOSER_HEIGHT, + MIN_COMPOSER_HEIGHT, +} from "./composerLayout"; + +describe("terminal composer layout", () => { + it("uses a bounded default height", () => { + expect(DEFAULT_COMPOSER_HEIGHT).toBeGreaterThan(MIN_COMPOSER_HEIGHT); + expect(DEFAULT_COMPOSER_HEIGHT).toBeLessThan(MAX_COMPOSER_HEIGHT); + }); + + it("clamps resized heights", () => { + expect(clampComposerHeight(MIN_COMPOSER_HEIGHT - 80)).toBe( + MIN_COMPOSER_HEIGHT, + ); + expect(clampComposerHeight(MAX_COMPOSER_HEIGHT + 80)).toBe( + MAX_COMPOSER_HEIGHT, + ); + expect(clampComposerHeight(180)).toBe(180); + }); +}); diff --git a/src/modules/terminal/composer/composerLayout.ts b/src/modules/terminal/composer/composerLayout.ts new file mode 100644 index 000000000..ac7b9b5b9 --- /dev/null +++ b/src/modules/terminal/composer/composerLayout.ts @@ -0,0 +1,11 @@ +export const MIN_COMPOSER_HEIGHT = 96; +export const DEFAULT_COMPOSER_HEIGHT = 152; +export const MAX_COMPOSER_HEIGHT = 360; + +export function clampComposerHeight(height: number): number { + if (!Number.isFinite(height)) return DEFAULT_COMPOSER_HEIGHT; + return Math.min( + MAX_COMPOSER_HEIGHT, + Math.max(MIN_COMPOSER_HEIGHT, Math.round(height)), + ); +} diff --git a/src/modules/terminal/composer/composerShellCompletion.test.ts b/src/modules/terminal/composer/composerShellCompletion.test.ts new file mode 100644 index 000000000..432c60a32 --- /dev/null +++ b/src/modules/terminal/composer/composerShellCompletion.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { shellCompletionOptions } from "./composerShellCompletion"; + +describe("terminal composer shell completion", () => { + it("suggests common commands and shell keywords", () => { + expect(shellCompletionOptions("git").map((item) => item.label)).toContain( + "git", + ); + expect(shellCompletionOptions("expo").map((item) => item.label)).toContain( + "export", + ); + }); + + it("adds useful words already present in the draft", () => { + const labels = shellCompletionOptions("DEP", "export DEPLOY_TARGET=prod").map( + (item) => item.label, + ); + expect(labels).toContain("DEPLOY_TARGET"); + }); +}); diff --git a/src/modules/terminal/composer/composerShellCompletion.ts b/src/modules/terminal/composer/composerShellCompletion.ts new file mode 100644 index 000000000..03c519f6b --- /dev/null +++ b/src/modules/terminal/composer/composerShellCompletion.ts @@ -0,0 +1,192 @@ +import type { + Completion, + CompletionContext, + CompletionResult, +} from "@codemirror/autocomplete"; + +const SHELL_KEYWORDS = [ + "if", + "then", + "else", + "elif", + "fi", + "for", + "while", + "do", + "done", + "case", + "esac", + "in", + "function", + "select", + "until", + "return", + "break", + "continue", + "export", + "unset", + "alias", + "unalias", + "source", + "set", + "local", + "read", +] as const; + +const SHELL_COMMANDS = [ + "cd", + "ls", + "pwd", + "echo", + "printf", + "cat", + "grep", + "rg", + "find", + "fd", + "awk", + "sed", + "sort", + "uniq", + "wc", + "head", + "tail", + "less", + "tee", + "xargs", + "cut", + "tr", + "diff", + "touch", + "mkdir", + "rmdir", + "rm", + "cp", + "mv", + "ln", + "chmod", + "chown", + "stat", + "du", + "df", + "ps", + "top", + "htop", + "kill", + "jobs", + "nohup", + "env", + "which", + "man", + "history", + "clear", + "exit", + "ssh", + "scp", + "rsync", + "curl", + "wget", + "ping", + "tar", + "gzip", + "zip", + "unzip", + "make", + "cmake", + "gcc", + "clang", + "git", + "gh", + "node", + "npm", + "npx", + "pnpm", + "yarn", + "bun", + "deno", + "python", + "python3", + "pip", + "pip3", + "cargo", + "rustc", + "rustup", + "go", + "docker", + "kubectl", + "helm", + "terraform", + "brew", + "apt", + "systemctl", + "code", + "vim", + "nvim", + "nano", + "open", + "claude", + "codex", + "gemini", + "aider", +] as const; + +const WORD_RE = /[\w./+-]*/; +const DOC_WORD_RE = /[A-Za-z_][\w./-]+/g; +const VALID_FOR = /^[\w./+-]*$/; +const SEGMENT_START = /(^|[\n;&|(){}])\s*$/; + +export function shellCompletionOptions( + prefix: string, + doc = "", +): Completion[] { + const lower = prefix.toLowerCase(); + const seen = new Set(); + const options: Completion[] = []; + + for (const keyword of SHELL_KEYWORDS) { + if (keyword.startsWith(lower)) addOption(options, seen, keyword, "keyword"); + } + for (const command of SHELL_COMMANDS) { + if (command.startsWith(lower)) addOption(options, seen, command, "function"); + } + for (const match of doc.matchAll(DOC_WORD_RE)) { + const word = match[0]; + if (word === prefix || !word.toLowerCase().startsWith(lower)) continue; + addOption(options, seen, word, "text"); + if (options.length >= 80) break; + } + + return options; +} + +export function composerShellCompletionSource( + ctx: CompletionContext, +): CompletionResult | null { + const word = ctx.matchBefore(WORD_RE); + if (!word || (word.from === word.to && !ctx.explicit)) return null; + + const line = ctx.state.doc.lineAt(word.from); + const before = ctx.state.doc.sliceString(line.from, word.from); + const doc = ctx.state.doc.toString(); + const options = shellCompletionOptions(word.text, doc); + + if (options.length === 0) return null; + return { + from: word.from, + options: SEGMENT_START.test(before) + ? options + : options.filter((option) => option.type !== "function"), + validFor: VALID_FOR, + }; +} + +function addOption( + options: Completion[], + seen: Set, + label: string, + type: Completion["type"], +): void { + if (seen.has(label)) return; + seen.add(label); + options.push({ label, type }); +} diff --git a/src/modules/terminal/composer/terminalComposerStore.test.ts b/src/modules/terminal/composer/terminalComposerStore.test.ts new file mode 100644 index 000000000..831a3c8e4 --- /dev/null +++ b/src/modules/terminal/composer/terminalComposerStore.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { useTerminalComposerStore } from "./terminalComposerStore"; + +describe("terminal composer store", () => { + beforeEach(() => { + useTerminalComposerStore.getState().reset(); + }); + + it("keeps independent drafts per terminal leaf", () => { + const store = useTerminalComposerStore.getState(); + + store.setDraft(101, "first draft"); + store.setDraft(202, "second draft"); + + expect(useTerminalComposerStore.getState().draftFor(101)).toBe( + "first draft", + ); + expect(useTerminalComposerStore.getState().draftFor(202)).toBe( + "second draft", + ); + }); + + it("consumes a non-blank draft without changing other leaves", () => { + const store = useTerminalComposerStore.getState(); + + store.setDraft(101, " run this\nthen that "); + store.setDraft(202, "keep me"); + + expect(useTerminalComposerStore.getState().consumeDraft(101)).toBe( + " run this\nthen that ", + ); + expect(useTerminalComposerStore.getState().draftFor(101)).toBe(""); + expect(useTerminalComposerStore.getState().draftFor(202)).toBe("keep me"); + }); + + it("does not consume blank drafts", () => { + const store = useTerminalComposerStore.getState(); + + store.setDraft(101, " \n\t "); + + expect(useTerminalComposerStore.getState().consumeDraft(101)).toBeNull(); + expect(useTerminalComposerStore.getState().draftFor(101)).toBe(" \n\t "); + }); + + it("queues drafts FIFO per leaf and clears the queued draft", () => { + const store = useTerminalComposerStore.getState(); + + store.setDraft(101, "first queued prompt"); + const first = useTerminalComposerStore.getState().enqueueDraft(101); + store.setDraft(101, "second queued prompt"); + const second = useTerminalComposerStore.getState().enqueueDraft(101); + store.setDraft(202, "other leaf prompt"); + useTerminalComposerStore.getState().enqueueDraft(202); + + expect(first?.text).toBe("first queued prompt"); + expect(second?.text).toBe("second queued prompt"); + expect(useTerminalComposerStore.getState().draftFor(101)).toBe(""); + expect(useTerminalComposerStore.getState().queuedFor(101)).toMatchObject([ + { text: "first queued prompt" }, + { text: "second queued prompt" }, + ]); + expect(useTerminalComposerStore.getState().dequeueNext(101)?.text).toBe( + "first queued prompt", + ); + expect(useTerminalComposerStore.getState().dequeueNext(101)?.text).toBe( + "second queued prompt", + ); + expect(useTerminalComposerStore.getState().dequeueNext(101)).toBeNull(); + expect(useTerminalComposerStore.getState().queuedFor(202)).toHaveLength(1); + }); + + it("dequeues a specific queued draft by id", () => { + const store = useTerminalComposerStore.getState(); + + store.setDraft(101, "first queued prompt"); + const first = useTerminalComposerStore.getState().enqueueDraft(101); + store.setDraft(101, "second queued prompt"); + const second = useTerminalComposerStore.getState().enqueueDraft(101); + + expect(second).not.toBeNull(); + expect( + useTerminalComposerStore.getState().dequeueById(101, second?.id ?? ""), + ).toMatchObject({ text: "second queued prompt" }); + expect(useTerminalComposerStore.getState().queuedFor(101)).toMatchObject([ + { id: first?.id, text: "first queued prompt" }, + ]); + expect( + useTerminalComposerStore.getState().dequeueById(101, "missing"), + ).toBeNull(); + }); +}); diff --git a/src/modules/terminal/composer/terminalComposerStore.ts b/src/modules/terminal/composer/terminalComposerStore.ts new file mode 100644 index 000000000..577bc2085 --- /dev/null +++ b/src/modules/terminal/composer/terminalComposerStore.ts @@ -0,0 +1,86 @@ +import { create } from "zustand"; + +export type QueuedTerminalPrompt = { + id: string; + text: string; +}; + +type TerminalComposerState = { + drafts: Record; + queues: Record; + setDraft: (leafId: number, text: string) => void; + draftFor: (leafId: number) => string; + consumeDraft: (leafId: number) => string | null; + enqueueDraft: (leafId: number) => QueuedTerminalPrompt | null; + queuedFor: (leafId: number) => QueuedTerminalPrompt[]; + dequeueNext: (leafId: number) => QueuedTerminalPrompt | null; + dequeueById: (leafId: number, id: string) => QueuedTerminalPrompt | null; + reset: () => void; +}; + +let nextQueueId = 1; + +function newQueueId(): string { + return `terminal-composer-${nextQueueId++}`; +} + +function initialState() { + return { + drafts: {}, + queues: {}, + }; +} + +export const useTerminalComposerStore = create( + (set, get) => ({ + ...initialState(), + setDraft: (leafId, text) => + set((state) => ({ drafts: { ...state.drafts, [leafId]: text } })), + draftFor: (leafId) => get().drafts[leafId] ?? "", + consumeDraft: (leafId) => { + const text = get().draftFor(leafId); + if (!text.trim()) return null; + set((state) => ({ drafts: { ...state.drafts, [leafId]: "" } })); + return text; + }, + enqueueDraft: (leafId) => { + const text = get().draftFor(leafId); + if (!text.trim()) return null; + const item = { id: newQueueId(), text }; + set((state) => ({ + drafts: { ...state.drafts, [leafId]: "" }, + queues: { + ...state.queues, + [leafId]: [...(state.queues[leafId] ?? []), item], + }, + })); + return item; + }, + queuedFor: (leafId) => get().queues[leafId] ?? [], + dequeueNext: (leafId) => { + const queue = get().queues[leafId] ?? []; + const [next, ...rest] = queue; + if (!next) return null; + set((state) => ({ queues: { ...state.queues, [leafId]: rest } })); + return next; + }, + dequeueById: (leafId, id) => { + const queue = get().queues[leafId] ?? []; + const item = queue.find((entry) => entry.id === id); + if (!item) return null; + set((state) => ({ + queues: { + ...state.queues, + [leafId]: (state.queues[leafId] ?? []).filter( + (entry) => entry.id !== id, + ), + }, + })); + return item; + }, + reset: () => { + nextQueueId = 1; + set(initialState()); + }, + }), +); diff --git a/src/modules/terminal/index.ts b/src/modules/terminal/index.ts index 7533d35c3..8dc4d79bb 100644 --- a/src/modules/terminal/index.ts +++ b/src/modules/terminal/index.ts @@ -2,11 +2,14 @@ export { TerminalPane, type TerminalPaneHandle } from "./TerminalPane"; export { TerminalStack } from "./TerminalStack"; export { clearFocusedTerminal, + activeAgentForLeaf, disposeSession, leafHasForegroundProcess, leafIdForPty, navigateFocusedBlocks, respawnSession, + subscribeTerminalAgentActivity, + submitToLeaf, whenSessionReady, writeToSession, } from "./lib/useTerminalSession"; diff --git a/src/modules/terminal/lib/agentActivity.ts b/src/modules/terminal/lib/agentActivity.ts index b929482f0..6d964937f 100644 --- a/src/modules/terminal/lib/agentActivity.ts +++ b/src/modules/terminal/lib/agentActivity.ts @@ -1,24 +1,26 @@ import { listen } from "@tauri-apps/api/event"; -type AgentSignal = { id: number; kind: string }; +type AgentSignal = { id: number; kind: string; agent?: string | null }; -const active = new Set(); +const active = new Map(); +const listeners = new Set<() => void>(); let onExited: ((ptyId: number) => void) | null = null; let bound = false; -// Covers shells without an OSC 133 C preexec hook (pwsh): the Rust detector -// arms via the Claude Code OSC 777 marker and reports per-pty lifecycle. -export function ensureAgentActivityListener( - exited: (ptyId: number) => void, -): void { +// Covers shells without an OSC 133 C preexec hook (pwsh): Rust detector +// arms via Claude Code OSC 777 marker and reports per-pty lifecycle. +export function ensureAgentActivityListener(exited: (ptyId: number) => void) { onExited = exited; if (bound || typeof window === "undefined") return; bound = true; - void listen("terax:agent-signal", (e) => { + listen("terax:agent-signal", (e) => { if (e.payload.kind === "started") { - active.add(e.payload.id); - } else if (e.payload.kind === "exited") { + active.set(e.payload.id, e.payload.agent || null); + notify(); + } + if (e.payload.kind === "exited") { active.delete(e.payload.id); + notify(); onExited?.(e.payload.id); } }); @@ -27,3 +29,16 @@ export function ensureAgentActivityListener( export function isAgentActivePty(ptyId: number): boolean { return active.has(ptyId); } + +export function activeAgentForPty(ptyId: number): string | null { + return active.get(ptyId) ?? null; +} + +export function subscribeAgentActivity(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function notify(): void { + for (const listener of listeners) listener(); +} diff --git a/src/modules/terminal/lib/useTerminalSession.ts b/src/modules/terminal/lib/useTerminalSession.ts index 5d14e73ee..7f9e9da1a 100644 --- a/src/modules/terminal/lib/useTerminalSession.ts +++ b/src/modules/terminal/lib/useTerminalSession.ts @@ -18,7 +18,12 @@ import { } from "./osc-handlers"; import { openPty, type PtySession } from "./pty-bridge"; import "../block/block.css"; -import { ensureAgentActivityListener, isAgentActivePty } from "./agentActivity"; +import { + activeAgentForPty, + ensureAgentActivityListener, + isAgentActivePty, + subscribeAgentActivity, +} from "./agentActivity"; import { acquireSlot, applyBackgroundActive, @@ -142,9 +147,10 @@ const PENDING_INPUT_MAX = 256 * 1024; // Input typed before the pty attaches is queued and flushed on attach. Cap the // queue so a large paste into a still-spawning pane can't grow it without bound. -function queuePendingInput(s: Session, data: string): void { - if (s.pendingInput.length + data.length > PENDING_INPUT_MAX) return; +function queuePendingInput(s: Session, data: string): boolean { + if (s.pendingInput.length + data.length > PENDING_INPUT_MAX) return false; s.pendingInput += data; + return true; } export function writeToSession(leafId: number, data: string): boolean { @@ -154,20 +160,20 @@ export function writeToSession(leafId: number, data: string): boolean { void s.pty.write(data); return true; } - queuePendingInput(s, data); - return true; + return queuePendingInput(s, data); } -export function submitToLeaf(leafId: number, text: string): void { +export function submitToLeaf(leafId: number, text: string): boolean { const s = sessions.get(leafId); - if (!s || s.shellExited) return; - s.everSubmitted = true; + if (!s || s.shellExited) return false; // Bracketed paste keeps a multiline command atomic; trailing CR runs it. const data = text.includes("\n") ? `\x1b[200~${text}\x1b[201~\r` : `${text}\r`; if (s.pty) void s.pty.write(data); - else queuePendingInput(s, data); + else if (!queuePendingInput(s, data)) return false; + s.everSubmitted = true; + return true; } export function interruptLeaf(leafId: number): void { @@ -288,6 +294,18 @@ export function leafIdForPty(ptyId: number): number | null { return null; } +export function activeAgentForLeaf(leafId: number): string | null { + const s = sessions.get(leafId); + if (!s?.pty) return null; + return activeAgentForPty(s.pty.id); +} + +export function subscribeTerminalAgentActivity( + listener: () => void, +): () => void { + return subscribeAgentActivity(listener); +} + function leafBusy(s: Session): boolean { return s.commandRunning || (s.pty !== null && isAgentActivePty(s.pty.id)); } diff --git a/src/settings/sections/GeneralSection.tsx b/src/settings/sections/GeneralSection.tsx index 321df08e1..6cb6c9ca9 100644 --- a/src/settings/sections/GeneralSection.tsx +++ b/src/settings/sections/GeneralSection.tsx @@ -1,3 +1,4 @@ +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, @@ -28,6 +29,8 @@ import { setRestoreWindowState, setShowHidden, setTerminalCursorBlink, + setTerminalComposerSyntaxMode, + setTerminalComposerSyntaxRules, setTerminalFontFamily, setTerminalFontSize, setTerminalFontWeight, @@ -40,6 +43,11 @@ import { TERMINAL_FONT_SIZES, TERMINAL_SCROLLBACK_PRESETS, } from "@/modules/settings/store"; +import { + COMPOSER_SYNTAX_MODES, + type ComposerSyntaxRule, + resolveComposerSyntaxMode, +} from "@/modules/terminal/composer/composerLanguage"; import { useTheme } from "@/modules/theme"; import { ComputerIcon, @@ -49,7 +57,7 @@ import { import { HugeiconsIcon } from "@hugeicons/react"; import { invoke } from "@tauri-apps/api/core"; import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { LspServersGroup } from "../components/LspServersGroup"; import { SectionHeader } from "../components/SectionHeader"; import { SettingRow } from "../components/SettingRow"; @@ -98,6 +106,19 @@ export function GeneralSection() { (s) => s.terminalWebglEnabled, ); const terminalCursorBlink = usePreferencesStore((s) => s.terminalCursorBlink); + const terminalComposerSyntaxMode = usePreferencesStore( + (s) => s.terminalComposerSyntaxMode, + ); + const terminalComposerSyntaxRules = usePreferencesStore( + (s) => s.terminalComposerSyntaxRules, + ); + const [composerSyntaxRulesDraft, setComposerSyntaxRulesDraft] = useState( + terminalComposerSyntaxRules, + ); + const composerSyntaxRulesDraftRef = useRef(terminalComposerSyntaxRules); + const composerSyntaxRulesPersistTimer = useRef | null>(null); const terminalFontFamily = usePreferencesStore((s) => s.terminalFontFamily); const terminalFontWeight = usePreferencesStore((s) => s.terminalFontWeight); const terminalShell = usePreferencesStore((s) => s.terminalShell); @@ -146,6 +167,67 @@ export function GeneralSection() { } }; + useEffect(() => { + setComposerSyntaxRulesDraft(terminalComposerSyntaxRules); + composerSyntaxRulesDraftRef.current = terminalComposerSyntaxRules; + }, [terminalComposerSyntaxRules]); + useEffect( + () => () => { + if (composerSyntaxRulesPersistTimer.current === null) return; + clearTimeout(composerSyntaxRulesPersistTimer.current); + void setTerminalComposerSyntaxRules(composerSyntaxRulesDraftRef.current); + }, + [], + ); + const persistComposerSyntaxRules = (rules: ComposerSyntaxRule[]) => { + if (composerSyntaxRulesPersistTimer.current !== null) { + clearTimeout(composerSyntaxRulesPersistTimer.current); + composerSyntaxRulesPersistTimer.current = null; + } + void setTerminalComposerSyntaxRules(rules); + }; + const scheduleComposerSyntaxRulesPersist = (rules: ComposerSyntaxRule[]) => { + if (composerSyntaxRulesPersistTimer.current !== null) { + clearTimeout(composerSyntaxRulesPersistTimer.current); + } + composerSyntaxRulesPersistTimer.current = setTimeout(() => { + composerSyntaxRulesPersistTimer.current = null; + void setTerminalComposerSyntaxRules(rules); + }, 300); + }; + const updateComposerRule = ( + index: number, + patch: Partial, + persist: "debounced" | "now" = "debounced", + ) => { + const next = composerSyntaxRulesDraft.map((rule, i) => + i === index ? { ...rule, ...patch } : rule, + ); + setComposerSyntaxRulesDraft(next); + composerSyntaxRulesDraftRef.current = next; + if (persist === "now") persistComposerSyntaxRules(next); + else scheduleComposerSyntaxRulesPersist(next); + }; + const removeComposerRule = (index: number) => { + const next = composerSyntaxRulesDraft.filter((_, i) => i !== index); + setComposerSyntaxRulesDraft(next); + composerSyntaxRulesDraftRef.current = next; + persistComposerSyntaxRules(next); + }; + const addComposerRule = () => { + const next = [ + ...composerSyntaxRulesDraft, + { + id: newComposerRuleId(), + pattern: "", + mode: terminalComposerSyntaxMode, + }, + ]; + setComposerSyntaxRulesDraft(next); + composerSyntaxRulesDraftRef.current = next; + persistComposerSyntaxRules(next); + }; + return (
@@ -301,6 +383,101 @@ export function GeneralSection() { onCheckedChange={(v) => void setTerminalCursorBlink(v)} /> + + + + +
+ {composerSyntaxRulesDraft.map((rule, index) => ( +
+ + updateComposerRule(index, { pattern: e.target.value }) + } + onBlur={() => + persistComposerSyntaxRules( + composerSyntaxRulesDraftRef.current, + ) + } + /> + + +
+ ))} + +
+
void setTerminalFontFamily(v)} @@ -618,3 +795,9 @@ function AutoSaveDelayInput({ ); } + +function newComposerRuleId(): string { + return `composer-rule-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 8)}`; +}