Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/sharedSettingsFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ describe("sharedSettingsFile", () => {
acpRegistryInstalledAgents: {},
agentInstances: {},
collapseTerminalComposer: false,
cliPickerTarget: "ask",
staleThreadUnloadMinutes: 20,
autoArchiveDoneAfterDays: 7,
scrollSpeed: 2,
Expand Down Expand Up @@ -143,6 +144,7 @@ describe("sharedSettingsFile", () => {
acpRegistryInstalledAgents: {},
agentInstances: {},
collapseTerminalComposer: false,
cliPickerTarget: "ask",
staleThreadUnloadMinutes: 20,
autoArchiveDoneAfterDays: 7,
scrollSpeed: 2,
Expand Down
5 changes: 4 additions & 1 deletion src/renderer/components/thread/AgentDiscoveryScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,10 @@ export function AgentDiscoveryScreen(props: {
<div>Provider</div>
{statusTargets.map((target) => (
<div key={target.key} className="text-center">
{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}
</div>
))}
</div>
Expand Down
42 changes: 29 additions & 13 deletions src/renderer/components/thread/ThreadComposerSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ??
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/renderer/state/browserAttachInbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
41 changes: 41 additions & 0 deletions src/renderer/state/composerUiStore.ts
Original file line number Diff line number Diff line change
@@ -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<string, ComposerUiInfo>;
setComposerUi: (threadId: string, info: ComposerUiInfo) => void;
clearComposerUi: (threadId: string) => void;
}

export const useComposerUiStore = create<ComposerUiState>((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 };
}),
}));
7 changes: 7 additions & 0 deletions src/renderer/state/sharedSettingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readBridge } from "../bridge";
import {
defaultSharedSettings,
normalizeSharedSettings,
type CliPickerTarget,
type SharedSettings,
type SharedSettingsInput,
} from "@/shared/settings";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -261,6 +263,10 @@ export const useSharedSettings = create<SharedSettingsState>()((set, get) => ({
set({ collapseTerminalComposer });
persistSettings(selectSharedSettings(get()));
},
setCliPickerTarget: (cliPickerTarget) => {
set({ cliPickerTarget });
persistSettings(selectSharedSettings(get()));
},
setStaleThreadUnloadMinutes: (staleThreadUnloadMinutes) => {
set({ staleThreadUnloadMinutes });
persistSettings(selectSharedSettings(get()));
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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<PickerOutcome> => {
Expand All @@ -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<PickerOutcome> => {
if (pickerActive) {
return await cancelPicker();
Expand Down Expand Up @@ -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 };
Expand All @@ -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(() => {
Expand Down
Loading