From 3a11f3b08ed52ef39cd5634a7fb23b703bd4e25a Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sun, 14 Jun 2026 01:49:19 -0700 Subject: [PATCH 1/2] feat(thread): route browser picks in CLI threads - add `cliPickerTarget` and live composer state for picker routing - stage terminal input through IPC and copy sandboxed attachments locally - cover shared settings and runtime staging behavior with tests --- src/main/sharedSettingsFile.test.ts | 2 + .../thread/ThreadComposerSection.tsx | 42 ++-- src/renderer/state/browserAttachInbox.ts | 11 + src/renderer/state/composerUiStore.ts | 41 ++++ src/renderer/state/sharedSettingsStore.ts | 7 + .../BrowserPanel/hooks/useElementPicker.ts | 111 ++++++++-- .../BrowserPanel/parts/BrowserToolbar.tsx | 72 ++++--- .../parts/TerminalSettings.tsx | 29 ++- .../SettingsOverlay/parts/settingsOptions.ts | 6 + src/shared/contracts/agent.ts | 7 + src/shared/contracts/thread.ts | 15 ++ src/shared/ipc/procedures/thread.ts | 7 + src/shared/settings.ts | 11 + .../agents/commandcode/detection.ts | 3 + src/supervisor/ipcHandlers.ts | 1 + src/supervisor/runtime.ts | 5 + src/supervisor/runtime/threadAttachments.ts | 69 ++++++- .../threadAttachments.workspace.test.ts | 69 +++++++ .../threadSessionManager.stageInput.test.ts | 191 ++++++++++++++++++ .../runtime/threadSessionManager.ts | 86 +++++++- 20 files changed, 721 insertions(+), 64 deletions(-) create mode 100644 src/renderer/state/composerUiStore.ts create mode 100644 src/supervisor/runtime/threadAttachments.workspace.test.ts create mode 100644 src/supervisor/runtime/threadSessionManager.stageInput.test.ts diff --git a/src/main/sharedSettingsFile.test.ts b/src/main/sharedSettingsFile.test.ts index e8197570..d29374bb 100644 --- a/src/main/sharedSettingsFile.test.ts +++ b/src/main/sharedSettingsFile.test.ts @@ -57,6 +57,7 @@ describe("sharedSettingsFile", () => { acpRegistryInstalledAgents: {}, agentInstances: {}, collapseTerminalComposer: false, + cliPickerTarget: "ask", staleThreadUnloadMinutes: 20, autoArchiveDoneAfterDays: 7, scrollSpeed: 2, @@ -143,6 +144,7 @@ describe("sharedSettingsFile", () => { acpRegistryInstalledAgents: {}, agentInstances: {}, collapseTerminalComposer: false, + cliPickerTarget: "ask", staleThreadUnloadMinutes: 20, autoArchiveDoneAfterDays: 7, scrollSpeed: 2, diff --git a/src/renderer/components/thread/ThreadComposerSection.tsx b/src/renderer/components/thread/ThreadComposerSection.tsx index 29bf52cc..fd609f10 100644 --- a/src/renderer/components/thread/ThreadComposerSection.tsx +++ b/src/renderer/components/thread/ThreadComposerSection.tsx @@ -26,7 +26,12 @@ import { getTriggerWords } from "@/renderer/components/providers"; import { readBridge } from "@/renderer/bridge"; import { captureProductEvent, threadProductProperties } from "@/renderer/analytics/posthog"; import { useAppStore } from "@/renderer/state/appStore"; -import { buildLcSelectorFence, useBrowserAttachInbox } from "@/renderer/state/browserAttachInbox"; +import { + buildLcSelectorFence, + buildSelectorPlainText, + useBrowserAttachInbox, +} from "@/renderer/state/browserAttachInbox"; +import { useComposerUiStore } from "@/renderer/state/composerUiStore"; import { useGitStore } from "@/renderer/state/gitStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { useThread } from "@/renderer/state/useThread"; @@ -314,6 +319,7 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread const [composerCollapsed, setComposerCollapsed] = useState(collapseTerminalComposerSetting); const canCollapseComposer = showTerminalComposer; const isComposerCollapsed = canCollapseComposer && composerCollapsed; + const setComposerUi = useComposerUiStore((s) => s.setComposerUi); const branchName = useGitStore( (s) => thread.worktreeBranch ?? @@ -414,19 +420,20 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread function submitPrompt(segments: PromptSegment[]) { const attachmentSegments = attachments.toSegments(); - const selectorFences = attachments.attachments + // The `lc-selector` fence is parsed only by the GUI chat SelectorBadge; a + // terminal-native agent reads raw text, so submit a plain sentence instead. + const selectorSegments: PromptSegment[] = attachments.attachments .filter((a) => a.selector && a.sourceUrl) - .map((a) => - buildLcSelectorFence({ - selector: a.selector ?? "", - sourceUrl: a.sourceUrl ?? "", - attachmentName: a.name, - }), - ); - const selectorSegments: PromptSegment[] = selectorFences.map((text) => ({ - kind: "text" as const, - content: text, - })); + .map((a) => ({ + kind: "text" as const, + content: usesTerminalPresentation + ? `\n\n${buildSelectorPlainText({ selector: a.selector ?? "", sourceUrl: a.sourceUrl ?? "" })}\n` + : buildLcSelectorFence({ + selector: a.selector ?? "", + sourceUrl: a.sourceUrl ?? "", + attachmentName: a.name, + }), + })); const allSegments = [...attachmentSegments, ...selectorSegments, ...segments]; const flat = flattenSegments(allSegments); if (flat.length === 0 || !canSubmit) return; @@ -603,6 +610,15 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread return () => window.removeEventListener("lightcode:paste-to-composer", handlePasteToComposer); }, []); + // Publish the rendered presentation + collapsed state so the browser element + // picker can decide whether a pick should go to the terminal or the composer. + useEffect(() => { + setComposerUi(thread.id, { presentation: presentationMode, collapsed: isComposerCollapsed }); + }, [thread.id, presentationMode, isComposerCollapsed, setComposerUi]); + useEffect(() => { + return () => useComposerUiStore.getState().clearComposerUi(thread.id); + }, [thread.id]); + const pendingComposerFocusThreadId = useAppStore((s) => s.pendingComposerFocusThreadId); useEffect(() => { if (pendingComposerFocusThreadId !== thread.id) return; diff --git a/src/renderer/state/browserAttachInbox.ts b/src/renderer/state/browserAttachInbox.ts index d622bd6a..c7180990 100644 --- a/src/renderer/state/browserAttachInbox.ts +++ b/src/renderer/state/browserAttachInbox.ts @@ -61,3 +61,14 @@ export function buildLcSelectorFence(item: { }); return `\n\n\`\`\`${LC_SELECTOR_LANG}\n${payload}\n\`\`\`\n`; } + +/** + * Plain-text equivalent of {@link buildLcSelectorFence} for terminal-native + * (CLI) threads. The `lc-selector` fence is parsed only by the GUI chat + * `SelectorBadge` renderer; a CLI agent reading raw terminal text needs a human + * sentence instead. Callers add their own surrounding whitespace (the composer + * separates it from the typed message; the terminal-insert path collapses it). + */ +export function buildSelectorPlainText(item: { selector: string; sourceUrl: string }): string { + return `Selected element \`${item.selector}\` from ${item.sourceUrl}`; +} diff --git a/src/renderer/state/composerUiStore.ts b/src/renderer/state/composerUiStore.ts new file mode 100644 index 00000000..741b3480 --- /dev/null +++ b/src/renderer/state/composerUiStore.ts @@ -0,0 +1,41 @@ +import { create } from "zustand"; +import type { ThreadPresentationMode } from "@/shared/contracts"; + +/** + * Live composer UI state per thread, published by the mounted composer so other + * surfaces (notably the browser element picker, which lives far from the + * composer in the component tree) can read the *rendered* presentation mode and + * collapsed state without re-deriving them. Entries exist only while a thread's + * composer is mounted; a missing entry means "no live composer" and callers + * should fall back to their default behavior. + */ +export interface ComposerUiInfo { + presentation: ThreadPresentationMode; + /** Whether the (terminal-native) composer is currently collapsed/hidden. */ + collapsed: boolean; +} + +interface ComposerUiState { + byThread: Record; + setComposerUi: (threadId: string, info: ComposerUiInfo) => void; + clearComposerUi: (threadId: string) => void; +} + +export const useComposerUiStore = create((set) => ({ + byThread: {}, + setComposerUi: (threadId, info) => + set((state) => { + const prev = state.byThread[threadId]; + if (prev && prev.presentation === info.presentation && prev.collapsed === info.collapsed) { + return {}; + } + return { byThread: { ...state.byThread, [threadId]: info } }; + }), + clearComposerUi: (threadId) => + set((state) => { + if (!(threadId in state.byThread)) return {}; + const next = { ...state.byThread }; + delete next[threadId]; + return { byThread: next }; + }), +})); diff --git a/src/renderer/state/sharedSettingsStore.ts b/src/renderer/state/sharedSettingsStore.ts index a81b2118..10c61033 100644 --- a/src/renderer/state/sharedSettingsStore.ts +++ b/src/renderer/state/sharedSettingsStore.ts @@ -3,6 +3,7 @@ import { readBridge } from "../bridge"; import { defaultSharedSettings, normalizeSharedSettings, + type CliPickerTarget, type SharedSettings, type SharedSettingsInput, } from "@/shared/settings"; @@ -42,6 +43,7 @@ interface SharedSettingsState extends SharedSettings { setAgentDisabled: (agentKind: string, disabled: boolean) => void; setProviderOrder: (order: string[]) => void; setCollapseTerminalComposer: (value: boolean) => void; + setCliPickerTarget: (value: CliPickerTarget) => void; setStaleThreadUnloadMinutes: (value: number) => void; setAutoArchiveDoneAfterDays: (value: number) => void; setScrollSpeed: (value: number) => void; @@ -261,6 +263,10 @@ export const useSharedSettings = create()((set, get) => ({ set({ collapseTerminalComposer }); persistSettings(selectSharedSettings(get())); }, + setCliPickerTarget: (cliPickerTarget) => { + set({ cliPickerTarget }); + persistSettings(selectSharedSettings(get())); + }, setStaleThreadUnloadMinutes: (staleThreadUnloadMinutes) => { set({ staleThreadUnloadMinutes }); persistSettings(selectSharedSettings(get())); @@ -550,6 +556,7 @@ function selectSharedSettings(state: SharedSettingsState): SharedSettingsInput { acpRegistryInstalledAgents: state.acpRegistryInstalledAgents, agentInstances: state.agentInstances, collapseTerminalComposer: state.collapseTerminalComposer, + cliPickerTarget: state.cliPickerTarget, staleThreadUnloadMinutes: state.staleThreadUnloadMinutes, autoArchiveDoneAfterDays: state.autoArchiveDoneAfterDays, scrollSpeed: state.scrollSpeed, diff --git a/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/hooks/useElementPicker.ts b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/hooks/useElementPicker.ts index 6e4b3a93..cb8c3c47 100644 --- a/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/hooks/useElementPicker.ts +++ b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/hooks/useElementPicker.ts @@ -1,18 +1,26 @@ import { useCallback } from "react"; import { toast } from "@heroui/react"; import { useShallow } from "zustand/shallow"; +import type { PromptSegment } from "@/shared/contracts"; import { isDraftPaneId, parseDraftProjectId } from "@/shared/paneId"; import { readBridge } from "@/renderer/bridge"; import { useAppStore } from "@/renderer/state/appStore"; -import { useBrowserAttachInbox } from "@/renderer/state/browserAttachInbox"; +import { buildSelectorPlainText, useBrowserAttachInbox } from "@/renderer/state/browserAttachInbox"; +import { useComposerUiStore } from "@/renderer/state/composerUiStore"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { useBrowserPanelStore, type PendingPickerAttachment, } from "@/renderer/state/browserPanelStore"; +/** Where a picked browser element is delivered for a given thread. */ +export type PickDestination = "terminal" | "composer"; + export interface PickerThreadTarget { threadId: string; title: string; + /** True for a launched terminal-native thread the pick can be typed into. */ + canRouteToTerminal: boolean; } interface PickerOutcome { @@ -65,6 +73,56 @@ function resolveTargetThreadIds(): string[] { return [...state.view.panes]; } +/** + * Reads the live, rendered presentation + collapsed state of a thread's + * composer (published by `ThreadComposerSection`), falling back to the stored + * thread when no composer is mounted. Drafts have no PTY, so they never route + * to the terminal. + */ +function resolveTerminalRouting(threadId: string): { isCli: boolean; collapsed: boolean } { + if (isDraftPaneId(threadId)) return { isCli: false, collapsed: false }; + const ui = useComposerUiStore.getState().byThread[threadId]; + if (ui) return { isCli: ui.presentation === "terminal", collapsed: ui.collapsed }; + const thread = useAppStore.getState().threads.find((t) => t.id === threadId); + return { isCli: (thread?.presentationMode ?? "terminal") === "terminal", collapsed: false }; +} + +/** + * Resolves where a pick should go for a single thread. GUI threads only have a + * composer. For CLI threads the `cliPickerTarget` setting decides, except that a + * collapsed composer always routes to the terminal under "ask" (the attachment + * bar is hidden, so there is nowhere visible for it to land). + */ +function resolvePickDestination(threadId: string): PickDestination | "ask" { + const { isCli, collapsed } = resolveTerminalRouting(threadId); + if (!isCli) return "composer"; + const target = useSharedSettings.getState().cliPickerTarget; + if (target === "terminal") return "terminal"; + if (target === "composer") return "composer"; + return collapsed ? "terminal" : "ask"; +} + +function buildSelectionSegments(attachment: PendingPickerAttachment): { + prompt: string; + segments: PromptSegment[]; +} { + const selectorText = buildSelectorPlainText({ + selector: attachment.selector, + sourceUrl: attachment.sourceUrl, + }); + return { + prompt: selectorText, + segments: [ + { + kind: "attachment", + path: attachment.attachmentPath, + mimeType: attachment.mimeType, + }, + { kind: "text", content: selectorText }, + ], + }; +} + export function useElementPicker() { const activeTabId = useBrowserPanelStore((s) => s.activeTabId); const pickerActive = useBrowserPanelStore((s) => s.pickerActive); @@ -82,18 +140,20 @@ export function useElementPicker() { const threads = useAppStore((s) => s.threads); const projects = useAppStore((s) => s.projects); const threadTargets: PickerThreadTarget[] = targetThreadIds.map((paneId) => { + // `resolveTerminalRouting` is the single source for the presentation/draft + // rule; it reads the live composer state on demand, so no subscription here. + const canRouteToTerminal = resolveTerminalRouting(paneId).isCli; if (isDraftPaneId(paneId)) { const projectId = parseDraftProjectId(paneId); const projectName = projects.find((p) => p.id === projectId)?.name; return { threadId: paneId, title: projectName ? `New thread — ${projectName}` : "New thread", + canRouteToTerminal, }; } - return { - threadId: paneId, - title: threads.find((thread) => thread.id === paneId)?.title ?? "Thread", - }; + const thread = threads.find((t) => t.id === paneId); + return { threadId: paneId, title: thread?.title ?? "Thread", canRouteToTerminal }; }); const cancelPicker = useCallback(async (): Promise => { @@ -104,6 +164,29 @@ export function useElementPicker() { return { ok: true, cancelled: true }; }, [setPickerActive]); + const deliverPick = useCallback( + async (threadId: string, destination: PickDestination, attachment: PendingPickerAttachment) => { + if (destination === "terminal") { + const { prompt, segments } = buildSelectionSegments(attachment); + try { + await readBridge().stageThreadInput({ threadId, prompt, segments }); + toast.success("Sent selection to terminal."); + return; + } catch (error) { + // The PTY may not be ready (still launching, exited). Don't lose the + // pick — drop it into the composer instead. + console.error("[picker] failed to stage terminal input", error); + enqueueAttach({ threadId, ...attachment }); + toast.warning("Terminal not ready — added selection to composer."); + return; + } + } + enqueueAttach({ threadId, ...attachment }); + toast.success("Attached browser selection."); + }, + [enqueueAttach], + ); + const startPicker = useCallback(async (): Promise => { if (pickerActive) { return await cancelPicker(); @@ -148,9 +231,12 @@ export function useElementPicker() { ...(anchor ? { anchorX: anchor.x, anchorY: anchor.y } : {}), }; if (targetIds.length === 1) { - enqueueAttach({ threadId: targetIds[0]!, ...attachment }); - toast.success("Attached browser selection."); - return { ok: true, cancelled: false }; + const threadId = targetIds[0]!; + const destination = resolvePickDestination(threadId); + if (destination !== "ask") { + await deliverPick(threadId, destination, attachment); + return { ok: true, cancelled: false }; + } } setPendingPickerAttachment(attachment); return { ok: true, cancelled: false, needsThreadChoice: true }; @@ -160,21 +246,20 @@ export function useElementPicker() { }, [ activeTabId, cancelPicker, - enqueueAttach, + deliverPick, pickerActive, setPendingPickerAttachment, setPickerActive, ]); const chooseTargetForPendingPick = useCallback( - (threadId: string) => { + (threadId: string, destination: PickDestination) => { const pending = useBrowserPanelStore.getState().pendingPickerAttachment; if (!pending) return; setPendingPickerAttachment(null); - enqueueAttach({ threadId, ...pending }); - toast.success("Attached browser selection."); + void deliverPick(threadId, destination, pending); }, - [enqueueAttach, setPendingPickerAttachment], + [deliverPick, setPendingPickerAttachment], ); const cancelPendingPick = useCallback(() => { diff --git a/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/parts/BrowserToolbar.tsx b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/parts/BrowserToolbar.tsx index 575e233c..9fdaad45 100644 --- a/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/parts/BrowserToolbar.tsx +++ b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/parts/BrowserToolbar.tsx @@ -14,7 +14,7 @@ import { useShallow } from "zustand/shallow"; import { readBridge } from "@/renderer/bridge"; import { useBrowserPanelStore } from "@/renderer/state/browserPanelStore"; import { panelHeaderIconButtonClass } from "@/renderer/components/layout/sidebarChrome"; -import type { PickerThreadTarget } from "../hooks/useElementPicker"; +import type { PickDestination, PickerThreadTarget } from "../hooks/useElementPicker"; const LOCALHOST_PATTERN = /^(localhost|(?:\d{1,3}\.){3}\d{1,3}|\[(?:[0-9a-f:]+)\])(?::\d+)?(?:[/?#]|$)/i; @@ -45,7 +45,7 @@ export function BrowserToolbar(props: { pickerTargets: PickerThreadTarget[]; hasPendingPick: boolean; pendingPickAnchor: { x: number; y: number } | null; - onChoosePickTarget: (threadId: string) => void; + onChoosePickTarget: (threadId: string, destination: PickDestination) => void; onCancelPendingPick: () => void; onMenuPreviewChange: (dataUrl: string | null) => void; }) { @@ -130,6 +130,48 @@ export function BrowserToolbar(props: { .catch(() => {}); }; + // CLI targets offer Terminal vs Composer; everything else only has a + // composer. The destination is encoded into the menu key as + // `:` and split on the first colon (thread ids such as + // `draft:` may themselves contain colons). + const onChoosePickAction = (key: Key) => { + const raw = String(key); + const idx = raw.indexOf(":"); + const destination = raw.slice(0, idx) as PickDestination; + props.onChoosePickTarget(raw.slice(idx + 1), destination); + }; + const renderPickItems = () => + props.pickerTargets.flatMap((target) => + target.canRouteToTerminal + ? [ + + + Terminal + , + + + Composer + , + ] + : [ + + + , + ], + ); + return (
- props.onChoosePickTarget(String(key))} - > - {props.pickerTargets.map((target) => ( - - - - ))} + + {renderPickItems()} diff --git a/src/renderer/views/SettingsOverlay/parts/TerminalSettings.tsx b/src/renderer/views/SettingsOverlay/parts/TerminalSettings.tsx index c4457b11..7faf8a5f 100644 --- a/src/renderer/views/SettingsOverlay/parts/TerminalSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/TerminalSettings.tsx @@ -1,10 +1,16 @@ import { startTransition } from "react"; import { Switch } from "@heroui/react"; import type { TerminalPosition } from "@/shared/contracts"; +import type { CliPickerTarget } from "@/shared/settings"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { Select } from "@/renderer/components/common"; import { SettingRow, SettingsPage } from "./SettingsForm"; -import { fontSizeOptions, scrollSpeedOptions, terminalPositionOptions } from "./settingsOptions"; +import { + cliPickerTargetOptions, + fontSizeOptions, + scrollSpeedOptions, + terminalPositionOptions, +} from "./settingsOptions"; export function TerminalSettings() { const terminalPosition = useSharedSettings((state) => state.terminalPosition); @@ -13,6 +19,8 @@ export function TerminalSettings() { const setCollapseTerminalComposer = useSharedSettings( (state) => state.setCollapseTerminalComposer, ); + const cliPickerTarget = useSharedSettings((state) => state.cliPickerTarget); + const setCliPickerTarget = useSharedSettings((state) => state.setCliPickerTarget); const autoShowTerminalPanel = useSharedSettings((state) => state.autoShowTerminalPanel); const setAutoShowTerminalPanel = useSharedSettings((state) => state.setAutoShowTerminalPanel); const scrollSpeed = useSharedSettings((state) => state.scrollSpeed); @@ -58,7 +66,7 @@ export function TerminalSettings() { + +