- {target.label}
+ {/* The per-system label (e.g. "Windows", "WSL: …") only disambiguates
+ when there are multiple environment columns. With a single column —
+ always the case on macOS/Linux — it is redundant noise, so omit it. */}
+ {useMatrixLayout ? target.label : null}
))}
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() {
+
+
+
;
+/**
+ * Type text into a terminal-native thread's PTY input line WITHOUT submitting
+ * it (no trailing carriage return). Used to route a browser element-picker
+ * selection straight into a CLI agent's input so the user can review/extend it
+ * before pressing Enter. `segments` is formatted through the adapter (so image
+ * attachments become `@path` references and WSL paths are rewritten) and then
+ * collapsed to a single line to avoid an accidental newline submit.
+ */
+export const stageThreadInputPayloadSchema = z.object({
+ threadId: z.string().min(1),
+ prompt: z.string(),
+ segments: z.array(promptSegmentSchema).optional(),
+});
+export type StageThreadInputPayload = z.infer;
+
export const resizeTerminalPayloadSchema = terminalSizeSchema.extend({
threadId: z.string().min(1),
});
diff --git a/src/shared/ipc/procedures/thread.ts b/src/shared/ipc/procedures/thread.ts
index b03394fd..b98ef723 100644
--- a/src/shared/ipc/procedures/thread.ts
+++ b/src/shared/ipc/procedures/thread.ts
@@ -16,6 +16,7 @@ import {
sendThreadInputPayloadSchema,
setAcpRegistryAgentAuthPayloadSchema,
setPendingSteerPayloadSchema,
+ stageThreadInputPayloadSchema,
startShellPayloadSchema,
startThreadPayloadSchema,
updateAcpRegistryAgentPayloadSchema,
@@ -48,6 +49,7 @@ import type {
SendThreadInputPayload,
SetAcpRegistryAgentAuthPayload,
SetPendingSteerPayload,
+ StageThreadInputPayload,
StartShellPayload,
StartThreadPayload,
StartThreadResult,
@@ -188,6 +190,11 @@ export const threadProcedures = {
"supervisor",
writeTerminalPayloadSchema,
),
+ stageThreadInput: definePayloadProcedure(
+ "stageThreadInput",
+ "supervisor",
+ stageThreadInputPayloadSchema,
+ ),
resizeTerminal: definePayloadProcedure(
"resizeTerminal",
"supervisor",
diff --git a/src/shared/settings.ts b/src/shared/settings.ts
index 81a12229..408a078a 100644
--- a/src/shared/settings.ts
+++ b/src/shared/settings.ts
@@ -162,6 +162,13 @@ export const sharedSettingsSchema = z.object({
agentInstances: agentInstanceConfigMapSchema,
/** When true, the composer in terminal-native threads starts collapsed. */
collapseTerminalComposer: z.boolean(),
+ /**
+ * Where a browser element-picker selection is delivered for a terminal-native
+ * (CLI) thread. "ask" shows a chooser on pick (but a collapsed composer always
+ * routes straight to the terminal); "terminal" always types it into the PTY
+ * input line; "composer" always stages it in the composer attachment bar.
+ */
+ cliPickerTarget: z.enum(["ask", "terminal", "composer"]),
/** Idle minutes before a hidden resumable thread is unloaded. 0 disables auto-unload. */
staleThreadUnloadMinutes: z.number().int().min(0),
/** Days a thread can stay marked done before it is auto-archived. 0 disables auto-archive. */
@@ -272,6 +279,9 @@ export const sharedSettingsSchema = z.object({
});
export type SharedSettings = z.infer;
+/** Browser element-picker delivery target for terminal-native (CLI) threads. */
+export type CliPickerTarget = SharedSettings["cliPickerTarget"];
+
/**
* Settings as written by the renderer / IPC consumer. Excludes
* supervisor-only fields (`agentHookSupport`) that the renderer never
@@ -310,6 +320,7 @@ export const defaultSharedSettings: SharedSettings = {
acpRegistryInstalledAgents: {},
agentInstances: {},
collapseTerminalComposer: false,
+ cliPickerTarget: "ask",
staleThreadUnloadMinutes: 60,
autoArchiveDoneAfterDays: 7,
scrollSpeed: 2,
diff --git a/src/supervisor/agents/commandcode/detection.ts b/src/supervisor/agents/commandcode/detection.ts
index 066bd92b..9f5c5661 100644
--- a/src/supervisor/agents/commandcode/detection.ts
+++ b/src/supervisor/agents/commandcode/detection.ts
@@ -82,6 +82,9 @@ export const defaultCommandCodeCapabilities: AgentCapability = {
sandboxModes: [],
supportsResume: true,
supportsDirectInput: true,
+ // Command Code sandboxes file reads to its working directory, so picked
+ // screenshots / attachments must be copied into the project before use.
+ requiresWorkspaceLocalAttachments: true,
liveInputMode: "terminal",
presentationMode: "terminal",
presentationModes: ["terminal"],
diff --git a/src/supervisor/ipcHandlers.ts b/src/supervisor/ipcHandlers.ts
index 8ce1ee42..134dc08f 100644
--- a/src/supervisor/ipcHandlers.ts
+++ b/src/supervisor/ipcHandlers.ts
@@ -28,6 +28,7 @@ export function createSupervisorIpcHandlers(runtime: SupervisorRuntime): Supervi
setPendingSteer: (payload) => runtime.setPendingSteer(payload),
clearPendingSteer: (payload) => runtime.clearPendingSteer(payload),
writeTerminal: (payload) => runtime.writeTerminal(payload),
+ stageThreadInput: (payload) => runtime.stageThreadInput(payload),
resizeTerminal: (payload) => runtime.resizeTerminal(payload),
resolveThreadServerRequest: (payload) => runtime.resolveThreadServerRequest(payload),
closeThread: (payload) => runtime.closeThread(payload),
diff --git a/src/supervisor/runtime.ts b/src/supervisor/runtime.ts
index 97686476..379c2aaf 100644
--- a/src/supervisor/runtime.ts
+++ b/src/supervisor/runtime.ts
@@ -145,6 +145,7 @@ import type {
StartShellPayload,
StartThreadPayload,
StartThreadResult,
+ StageThreadInputPayload,
ThreadRuntimeSnapshot,
WriteExternalFilePayload,
WriteExternalFileResult,
@@ -784,6 +785,10 @@ export class SupervisorRuntime {
return this.threadSessionManager.writeTerminal(payload);
}
+ async stageThreadInput(payload: StageThreadInputPayload): Promise {
+ return this.threadSessionManager.stageThreadInput(payload);
+ }
+
async resizeTerminal(payload: ResizeTerminalPayload): Promise {
return this.threadSessionManager.resizeTerminal(payload);
}
diff --git a/src/supervisor/runtime/threadAttachments.ts b/src/supervisor/runtime/threadAttachments.ts
index 1ec88c8a..07ffa479 100644
--- a/src/supervisor/runtime/threadAttachments.ts
+++ b/src/supervisor/runtime/threadAttachments.ts
@@ -1,5 +1,6 @@
-import { readFile } from "node:fs/promises";
-import { basename } from "node:path";
+import { existsSync } from "node:fs";
+import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
+import { basename, isAbsolute, join, relative } from "node:path";
import type { PromptSegment, ProjectLocation } from "@/shared/contracts";
import type { WslBridgeClient, WslLocation } from "../wsl/bridge/client";
@@ -57,6 +58,13 @@ function isImageAttachmentSegment(segment: PromptSegment): boolean {
);
}
+/** A segment that carries a filesystem path eligible for copy/rewrite. */
+function isRewritableFileSegment(
+ segment: PromptSegment,
+): segment is Extract {
+ return (segment.kind === "attachment" || segment.kind === "file") && Boolean(segment.path);
+}
+
export async function rewriteSegmentsForWsl(
segments: PromptSegment[],
location: ProjectLocation,
@@ -72,7 +80,7 @@ export async function rewriteSegmentsForWsl(
let dirs: { home: string; linuxDir: string } | undefined;
const rewritten: PromptSegment[] = [];
for (const segment of segments) {
- if ((segment.kind !== "attachment" && segment.kind !== "file") || !segment.path) {
+ if (!isRewritableFileSegment(segment)) {
rewritten.push(segment);
continue;
}
@@ -102,3 +110,58 @@ export async function rewriteSegmentsForWsl(
}
return rewritten;
}
+
+const WORKSPACE_ATTACHMENT_DIR = ".lightcode";
+const WORKSPACE_ATTACHMENT_SUBDIR = "attachments";
+
+function isInsideDir(child: string, parent: string): boolean {
+ const rel = relative(parent, child);
+ return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
+}
+
+/**
+ * Copies any attachment/file segment that lives outside `projectDir` into
+ * `/.lightcode/attachments` and rewrites its path there. Some agents
+ * (e.g. Command Code) sandbox file reads to their working directory, so a
+ * picker screenshot in `~/.lightcode/attachments` is otherwise unreadable. The
+ * copied files self-ignore via a `.lightcode/.gitignore` so they never show up
+ * in `git status`. Paths already inside the workspace are left untouched.
+ */
+export async function rewriteSegmentsForWorkspace(
+ segments: PromptSegment[],
+ projectDir: string,
+): Promise {
+ const attachmentsDir = join(projectDir, WORKSPACE_ATTACHMENT_DIR, WORKSPACE_ATTACHMENT_SUBDIR);
+ let prepared = false;
+ const rewritten: PromptSegment[] = [];
+ for (const segment of segments) {
+ if (!isRewritableFileSegment(segment)) {
+ rewritten.push(segment);
+ continue;
+ }
+ if (!isAbsolute(segment.path) || isInsideDir(segment.path, projectDir)) {
+ rewritten.push(segment);
+ continue;
+ }
+ try {
+ if (!prepared) {
+ await mkdir(attachmentsDir, { recursive: true });
+ const gitignorePath = join(projectDir, WORKSPACE_ATTACHMENT_DIR, ".gitignore");
+ if (!existsSync(gitignorePath)) {
+ await writeFile(gitignorePath, "*\n");
+ }
+ prepared = true;
+ }
+ const dest = join(attachmentsDir, basename(segment.path));
+ await copyFile(segment.path, dest);
+ rewritten.push({ ...segment, path: dest });
+ } catch (error) {
+ console.warn(
+ `[workspace-attach] failed to copy ${segment.path} -> ${attachmentsDir}:`,
+ error,
+ );
+ rewritten.push(segment);
+ }
+ }
+ return rewritten;
+}
diff --git a/src/supervisor/runtime/threadAttachments.workspace.test.ts b/src/supervisor/runtime/threadAttachments.workspace.test.ts
new file mode 100644
index 00000000..2a2e7a35
--- /dev/null
+++ b/src/supervisor/runtime/threadAttachments.workspace.test.ts
@@ -0,0 +1,69 @@
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { rewriteSegmentsForWorkspace } from "./threadAttachments";
+
+const dirs: string[] = [];
+
+afterEach(() => {
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
+});
+
+function tmp(prefix: string): string {
+ const dir = mkdtempSync(join(tmpdir(), prefix));
+ dirs.push(dir);
+ return dir;
+}
+
+describe("rewriteSegmentsForWorkspace", () => {
+ it("copies an out-of-workspace attachment into .lightcode/attachments and rewrites the path", async () => {
+ const project = tmp("lc-ws-project-");
+ const outside = tmp("lc-ws-outside-");
+ const src = join(outside, "shot.png");
+ writeFileSync(src, "png-bytes");
+
+ const segments = await rewriteSegmentsForWorkspace(
+ [{ kind: "attachment", path: src, mimeType: "image/png" }],
+ project,
+ );
+
+ const dest = join(project, ".lightcode", "attachments", "shot.png");
+ expect(segments[0]).toEqual({ kind: "attachment", path: dest, mimeType: "image/png" });
+ expect(existsSync(dest)).toBe(true);
+ expect(readFileSync(dest, "utf8")).toBe("png-bytes");
+ // The copies self-ignore so they never show up in `git status`.
+ expect(readFileSync(join(project, ".lightcode", ".gitignore"), "utf8")).toContain("*");
+ });
+
+ it("leaves attachments already inside the workspace untouched", async () => {
+ const project = tmp("lc-ws-project-");
+ const inside = join(project, "assets");
+ mkdirSync(inside, { recursive: true });
+ const src = join(inside, "in.png");
+ writeFileSync(src, "x");
+
+ const segments = await rewriteSegmentsForWorkspace(
+ [{ kind: "attachment", path: src, mimeType: "image/png" }],
+ project,
+ );
+
+ expect(segments[0]).toEqual({ kind: "attachment", path: src, mimeType: "image/png" });
+ expect(existsSync(join(project, ".lightcode"))).toBe(false);
+ });
+
+ it("passes through text segments and relative paths", async () => {
+ const project = tmp("lc-ws-project-");
+ const segments = await rewriteSegmentsForWorkspace(
+ [
+ { kind: "text", content: "hi" },
+ { kind: "attachment", path: "rel.png", mimeType: "image/png" },
+ ],
+ project,
+ );
+ expect(segments).toEqual([
+ { kind: "text", content: "hi" },
+ { kind: "attachment", path: "rel.png", mimeType: "image/png" },
+ ]);
+ });
+});
diff --git a/src/supervisor/runtime/threadSessionManager.stageInput.test.ts b/src/supervisor/runtime/threadSessionManager.stageInput.test.ts
new file mode 100644
index 00000000..24eb3ff2
--- /dev/null
+++ b/src/supervisor/runtime/threadSessionManager.stageInput.test.ts
@@ -0,0 +1,191 @@
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { AgentCapability, AgentKind, ThreadStatus } from "@/shared/contracts";
+import type { SupervisorEvent } from "@/shared/ipc";
+import type { AgentAdapter } from "../agents/base";
+import type { SessionRuntime } from "./sessionTypes";
+
+vi.mock("node-pty", () => ({
+ spawn: vi.fn<() => unknown>(() => ({
+ pid: 123,
+ kill: vi.fn<() => void>(),
+ onData: vi.fn<() => void>(),
+ onExit: vi.fn<() => void>(),
+ write: vi.fn<() => void>(),
+ })),
+}));
+
+import { ThreadSessionManager } from "./threadSessionManager";
+
+/**
+ * Covers `stageThreadInput`: typing a browser element-picker selection into a
+ * terminal-native thread's PTY input line WITHOUT submitting it. The result
+ * must be a single line (no newline that would submit early) and must reference
+ * the screenshot as an `@path`, and the call must reject for non-terminal
+ * threads / threads that aren't ready.
+ */
+
+const AGENT_KIND: AgentKind = "claude";
+const THREAD_ID = "thread-stage";
+
+const managersToDispose: ThreadSessionManager[] = [];
+const tempDirs: string[] = [];
+
+afterEach(async () => {
+ for (const manager of managersToDispose.splice(0)) {
+ await manager.dispose();
+ }
+ for (const dir of tempDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+function createAdapter(
+ liveInputMode: "terminal" | "server",
+ capsOverride?: Partial,
+): AgentAdapter {
+ return {
+ kind: AGENT_KIND,
+ label: AGENT_KIND,
+ binary: AGENT_KIND,
+ capabilities: {
+ models: [],
+ efforts: [],
+ modelEfforts: {},
+ modes: [],
+ approvalPolicies: [],
+ sandboxModes: [],
+ supportsResume: true,
+ supportsDirectInput: true,
+ liveInputMode,
+ presentationMode: liveInputMode === "server" ? "gui" : "terminal",
+ presentationModes: liveInputMode === "server" ? ["gui"] : ["terminal"],
+ settingDefs: [],
+ ...capsOverride,
+ },
+ } as unknown as AgentAdapter;
+}
+
+function createManager(adapter: AgentAdapter): ThreadSessionManager {
+ const tempDir = mkdtempSync(join(tmpdir(), "lightcode-stage-input-"));
+ tempDirs.push(tempDir);
+ const manager = new ThreadSessionManager({
+ emit: (_event: SupervisorEvent) => {},
+ isDev: false,
+ logsDir: join(tempDir, "logs"),
+ settingsPath: join(tempDir, "settings.json"),
+ readDisableCliHookPlugin: () => false,
+ adapters: new Map([[AGENT_KIND, adapter]]),
+ windowsShell: { shell: "powershell.exe", kind: "powershell", args: ["-NoLogo"] },
+ });
+ managersToDispose.push(manager);
+ return manager;
+}
+
+function createSession(
+ adapter: AgentAdapter,
+ write: (data: string) => void,
+ status: ThreadStatus = "idle",
+): SessionRuntime {
+ return {
+ instanceId: "instance-stage",
+ threadId: THREAD_ID,
+ agentKind: AGENT_KIND,
+ adapter,
+ projectLocation: { kind: "windows", path: "C:\\repo" },
+ config: { model: `${AGENT_KIND}/model` },
+ terminalSize: { cols: 80, rows: 24 },
+ launchPrompt: "",
+ status,
+ attention: "none",
+ canResumeWithConfig: true,
+ outputLength: 0,
+ prevChunk: "",
+ lastStrippedPtyChunk: "",
+ ptyOscCarry: "",
+ presentationMode: adapter.capabilities.presentationMode,
+ pty: { write } as SessionRuntime["pty"],
+ } as unknown as SessionRuntime;
+}
+
+describe("ThreadSessionManager.stageThreadInput", () => {
+ it("types a single-line selection with an @path screenshot into the PTY (no submit)", async () => {
+ const adapter = createAdapter("terminal");
+ const manager = createManager(adapter);
+ const write = vi.fn<(data: string) => void>();
+ manager.sessions.set(THREAD_ID, createSession(adapter, write));
+
+ await manager.stageThreadInput({
+ threadId: THREAD_ID,
+ prompt: "Selected element `button.cta` from https://x.test",
+ segments: [
+ { kind: "attachment", path: "/var/tmp/lc/shot.png", mimeType: "image/png" },
+ {
+ kind: "text",
+ content: "\n\nSelected element `button.cta` from https://x.test\n",
+ },
+ ],
+ });
+
+ expect(write).toHaveBeenCalledTimes(1);
+ const written = write.mock.calls[0]![0];
+ // No newline — a bare \n would submit the line in most shells/TUIs.
+ expect(written).not.toContain("\n");
+ expect(written).toContain("Selected element `button.cta` from https://x.test");
+ expect(written).toContain("@/var/tmp/lc/shot.png");
+ });
+
+ it("rejects for a structured (GUI / server) thread", async () => {
+ const adapter = createAdapter("server");
+ const manager = createManager(adapter);
+ const write = vi.fn<(data: string) => void>();
+ manager.sessions.set(THREAD_ID, createSession(adapter, write));
+
+ await expect(manager.stageThreadInput({ threadId: THREAD_ID, prompt: "x" })).rejects.toThrow(
+ /terminal-native/,
+ );
+ expect(write).not.toHaveBeenCalled();
+ });
+
+ it("rejects when the thread is not ready (launching/inactive)", async () => {
+ const adapter = createAdapter("terminal");
+ const manager = createManager(adapter);
+ const write = vi.fn<(data: string) => void>();
+ manager.sessions.set(THREAD_ID, createSession(adapter, write, "launching"));
+
+ await expect(manager.stageThreadInput({ threadId: THREAD_ID, prompt: "x" })).rejects.toThrow(
+ /not ready/,
+ );
+ expect(write).not.toHaveBeenCalled();
+ });
+
+ it("copies out-of-workspace attachments into the project for sandboxed agents", async () => {
+ const projectDir = mkdtempSync(join(tmpdir(), "lightcode-stage-project-"));
+ tempDirs.push(projectDir);
+ const outsideDir = mkdtempSync(join(tmpdir(), "lightcode-stage-outside-"));
+ tempDirs.push(outsideDir);
+ const shot = join(outsideDir, "shot.png");
+ writeFileSync(shot, "png");
+
+ const adapter = createAdapter("terminal", { requiresWorkspaceLocalAttachments: true });
+ const manager = createManager(adapter);
+ const write = vi.fn<(data: string) => void>();
+ const session = createSession(adapter, write);
+ session.projectLocation = { kind: "posix", path: projectDir };
+ manager.sessions.set(THREAD_ID, session);
+
+ await manager.stageThreadInput({
+ threadId: THREAD_ID,
+ prompt: "x",
+ segments: [{ kind: "attachment", path: shot, mimeType: "image/png" }],
+ });
+
+ expect(existsSync(join(projectDir, ".lightcode", "attachments", "shot.png"))).toBe(true);
+ const written = write.mock.calls[0]![0];
+ expect(written).toContain("shot.png");
+ // The pick references the in-project copy, not the original outside path.
+ expect(written).not.toContain(outsideDir);
+ });
+});
diff --git a/src/supervisor/runtime/threadSessionManager.ts b/src/supervisor/runtime/threadSessionManager.ts
index 4bfe9e90..4a45c636 100644
--- a/src/supervisor/runtime/threadSessionManager.ts
+++ b/src/supervisor/runtime/threadSessionManager.ts
@@ -20,6 +20,7 @@ import {
type SendThreadInputPayload,
type SessionRef,
type SetPendingSteerPayload,
+ type StageThreadInputPayload,
type StartShellPayload,
type StartThreadPayload,
type StartThreadResult,
@@ -65,7 +66,7 @@ import type {
ShellSessionRuntime,
} from "./sessionTypes";
import { ThreadOutputPipeline, resolveThreadStatusSource } from "./threadOutputPipeline";
-import { rewriteSegmentsForWsl } from "./threadAttachments";
+import { rewriteSegmentsForWorkspace, rewriteSegmentsForWsl } from "./threadAttachments";
import {
isInterruptibleBusyStatus,
@@ -334,11 +335,7 @@ export class ThreadSessionManager {
preserveImageAttachments: usesStructuredFlow,
})
: undefined;
- const prompt =
- effectiveSegments && effectiveSegments.length > 0
- ? (session.adapter.formatPromptSegments?.(effectiveSegments) ??
- defaultFormatPromptSegments(effectiveSegments))
- : payload.prompt;
+ const prompt = this.formatSegmentsForPrompt(session, effectiveSegments, payload.prompt);
const effectiveConfig =
session.presentationMode !== "gui" &&
@@ -388,14 +385,23 @@ export class ThreadSessionManager {
}
const pty = requireSessionPty(session);
+ // Workspace-sandboxed agents (e.g. Command Code) can't read attachments that
+ // live outside the project, so copy them in and re-format with the new paths.
+ // localizeWorkspaceAttachments returns the same array when it's a no-op, so
+ // reuse the already-formatted prompt unless paths actually changed.
+ const ptySegments = await this.localizeWorkspaceAttachments(session, effectiveSegments);
+ const ptyPrompt =
+ ptySegments === effectiveSegments
+ ? prompt
+ : this.formatSegmentsForPrompt(session, ptySegments, payload.prompt);
await writeSubmittedPrompt(
pty,
session.adapter.buildDirectInput?.(
- prompt,
- effectiveSegments,
+ ptyPrompt,
+ ptySegments,
session.config,
session.projectLocation,
- ) ?? [prompt, "\r"],
+ ) ?? [ptyPrompt, "\r"],
session.projectLocation,
);
@@ -460,6 +466,68 @@ export class ThreadSessionManager {
this.maybeArmUserInterruptRecovery(session, payload.data);
}
+ /**
+ * Type a prompt into a terminal-native thread's PTY input line WITHOUT
+ * submitting it. Mirrors the segment formatting of {@link sendThreadInput}'s
+ * PTY path (WSL rewrite + adapter `formatPromptSegments`) but collapses the
+ * result to a single line and omits the trailing carriage return, so the text
+ * lands in the agent's input line for the user to review/extend before they
+ * press Enter. Used to route a browser element-picker selection straight to a
+ * CLI agent. Rejects for structured (server / GUI) threads, which own input
+ * through their session rather than a PTY input line.
+ */
+ async stageThreadInput(payload: StageThreadInputPayload): Promise {
+ const session = this.requireSession(payload.threadId);
+ if (session.status === "inactive" || session.status === "launching") {
+ throw new Error("This thread is not ready to receive terminal input yet.");
+ }
+ const usesStructuredFlow =
+ session.adapter.capabilities.liveInputMode === "server" || session.presentationMode === "gui";
+ if (usesStructuredFlow) {
+ throw new Error("stageThreadInput is only supported for terminal-native threads.");
+ }
+ const wslSegments = payload.segments
+ ? await rewriteSegmentsForWsl(payload.segments, session.projectLocation, {
+ preserveImageAttachments: false,
+ })
+ : undefined;
+ const effectiveSegments = await this.localizeWorkspaceAttachments(session, wslSegments);
+ const formatted = this.formatSegmentsForPrompt(session, effectiveSegments, payload.prompt);
+ // Collapse newlines so a raw PTY write cannot accidentally submit the line
+ // (a bare \n reads as Enter to most shells/TUIs); the user submits manually.
+ const singleLine = formatted.replace(/\s*\r?\n\s*/g, " ").trim();
+ if (!singleLine) return;
+ requireSessionPty(session).write(singleLine);
+ }
+
+ /** Renders prompt segments to text via the adapter (or the default), falling
+ * back to `fallbackPrompt` when there are no segments. */
+ private formatSegmentsForPrompt(
+ session: SessionRuntime,
+ segments: PromptSegment[] | undefined,
+ fallbackPrompt: string,
+ ): string {
+ return segments && segments.length > 0
+ ? (session.adapter.formatPromptSegments?.(segments) ?? defaultFormatPromptSegments(segments))
+ : fallbackPrompt;
+ }
+
+ /**
+ * Copies attachments into the workspace for adapters that can only read files
+ * inside their working directory (`requiresWorkspaceLocalAttachments`, e.g.
+ * Command Code). No-op for other adapters and for WSL sessions (whose
+ * attachments are handled by {@link rewriteSegmentsForWsl}).
+ */
+ private async localizeWorkspaceAttachments(
+ session: SessionRuntime,
+ segments: PromptSegment[] | undefined,
+ ): Promise {
+ if (!segments || segments.length === 0) return segments;
+ if (!session.adapter.capabilities.requiresWorkspaceLocalAttachments) return segments;
+ if (session.projectLocation.kind === "wsl") return segments;
+ return rewriteSegmentsForWorkspace(segments, session.projectLocation.path);
+ }
+
/**
* Fallback for Claude's hook-gap around user interrupts: arm a grace timer
* when the user presses Esc / Ctrl+C while hooks are active and the session