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)}`;
+}