Skip to content

Commit c7245f7

Browse files
authored
Send terminal selections directly from the composer (#57)
- route terminal shortcut selections to the send path when available - snapshot live composer state before sending - add tests for direct-send and add-to-chat fallback
1 parent 58fbef8 commit c7245f7

3 files changed

Lines changed: 114 additions & 7 deletions

File tree

apps/web/src/components/ChatView.tsx

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2696,6 +2696,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
26962696
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
26972697
);
26982698

2699+
const readLiveComposerDraftSnapshot = useCallback(() => {
2700+
const latestDraft = activeThread
2701+
? useComposerDraftStore.getState().draftsByThreadId[activeThread.id]
2702+
: null;
2703+
const nextPrompt = latestDraft?.prompt ?? promptRef.current;
2704+
const nextImages = latestDraft?.images ?? composerImagesRef.current;
2705+
const nextTerminalContexts =
2706+
latestDraft?.terminalContexts ?? composerTerminalContextsRef.current;
2707+
promptRef.current = nextPrompt;
2708+
composerImagesRef.current = nextImages;
2709+
composerTerminalContextsRef.current = nextTerminalContexts;
2710+
return {
2711+
prompt: nextPrompt,
2712+
images: nextImages,
2713+
terminalContexts: nextTerminalContexts,
2714+
};
2715+
}, [activeThread]);
2716+
26992717
const onSend = async (e?: { preventDefault: () => void }) => {
27002718
e?.preventDefault();
27012719
const api = readNativeApi();
@@ -2704,16 +2722,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
27042722
onAdvanceActivePendingUserInput();
27052723
return;
27062724
}
2707-
const promptForSend = promptRef.current;
2725+
const liveComposerDraft = readLiveComposerDraftSnapshot();
2726+
const promptForSend = liveComposerDraft.prompt;
2727+
const composerImagesForSend = liveComposerDraft.images;
2728+
const composerTerminalContextsForSend = liveComposerDraft.terminalContexts;
27082729
const {
27092730
trimmedPrompt: trimmed,
27102731
sendableTerminalContexts: sendableComposerTerminalContexts,
27112732
expiredTerminalContextCount,
27122733
hasSendableContent,
27132734
} = deriveComposerSendState({
27142735
prompt: promptForSend,
2715-
imageCount: composerImages.length,
2716-
terminalContexts: composerTerminalContexts,
2736+
imageCount: composerImagesForSend.length,
2737+
terminalContexts: composerTerminalContextsForSend,
27172738
});
27182739
if (showPlanFollowUpPrompt && activeProposedPlan) {
27192740
const followUp = resolvePlanFollowUpSubmission({
@@ -2732,7 +2753,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
27322753
return;
27332754
}
27342755
const standaloneSlashCommand =
2735-
composerImages.length === 0 && sendableComposerTerminalContexts.length === 0
2756+
composerImagesForSend.length === 0 && sendableComposerTerminalContexts.length === 0
27362757
? parseStandaloneComposerSlashCommand(trimmed)
27372758
: null;
27382759
if (standaloneSlashCommand) {
@@ -2761,7 +2782,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
27612782

27622783
// ── Queue message if a turn is already running ────────────────────
27632784
if (phase === "running") {
2764-
const composerImagesSnapshot = [...composerImages];
2785+
const composerImagesSnapshot = [...composerImagesForSend];
27652786
const messageTextForSend = appendTerminalContextsToPrompt(
27662787
promptForSend,
27672788
sendableComposerTerminalContexts,
@@ -2847,7 +2868,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
28472868
sendInFlightRef.current = true;
28482869
beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn");
28492870

2850-
const composerImagesSnapshot = [...composerImages];
2871+
const composerImagesSnapshot = [...composerImagesForSend];
28512872
const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];
28522873
const messageTextForSend = appendTerminalContextsToPrompt(
28532874
promptForSend,
@@ -3089,6 +3110,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
30893110
}
30903111
};
30913112

3113+
const sendSelectedTerminalContext = (selection: TerminalContextSelection) => {
3114+
if (!activeThread) {
3115+
return;
3116+
}
3117+
addComposerDraftTerminalContexts(activeThread.id, [
3118+
{
3119+
id: randomUUID(),
3120+
threadId: activeThread.id,
3121+
createdAt: new Date().toISOString(),
3122+
...selection,
3123+
},
3124+
]);
3125+
void onSend();
3126+
};
3127+
30923128
const onInterrupt = async () => {
30933129
const api = readNativeApi();
30943130
if (!api || !activeThread || isRemoteActionBlocked) return;
@@ -4852,6 +4888,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
48524888
onCloseTerminal={closeTerminal}
48534889
onHeightChange={setTerminalHeight}
48544890
onAddTerminalContext={addTerminalContextToDraft}
4891+
onSendTerminalContext={sendSelectedTerminalContext}
48554892
onPreviewUrl={onPreviewUrl}
48564893
/>
48574894
);

apps/web/src/components/ThreadTerminalDrawer.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22

33
import {
4+
dispatchTerminalShortcutSelection,
45
resolveTerminalSelectionActionPosition,
56
shouldHandleTerminalSelectionMouseUp,
67
terminalSelectionActionDelayForClickCount,
@@ -72,4 +73,49 @@ describe("resolveTerminalSelectionActionPosition", () => {
7273
expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false);
7374
expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false);
7475
});
76+
77+
it("routes shortcut selections to direct send when available", () => {
78+
const addSelections: string[] = [];
79+
const sendSelections: string[] = [];
80+
dispatchTerminalShortcutSelection(
81+
{
82+
terminalId: "default",
83+
terminalLabel: "Terminal 1",
84+
lineStart: 12,
85+
lineEnd: 14,
86+
text: "git diff",
87+
},
88+
{
89+
onAddTerminalContext: (selection) => {
90+
addSelections.push(selection.text);
91+
},
92+
onSendTerminalContext: (selection) => {
93+
sendSelections.push(selection.text);
94+
},
95+
},
96+
);
97+
98+
expect(sendSelections).toEqual(["git diff"]);
99+
expect(addSelections).toEqual([]);
100+
});
101+
102+
it("falls back to add-to-chat when no direct-send handler exists", () => {
103+
const addSelections: string[] = [];
104+
dispatchTerminalShortcutSelection(
105+
{
106+
terminalId: "default",
107+
terminalLabel: "Terminal 1",
108+
lineStart: 7,
109+
lineEnd: 7,
110+
text: "bun lint",
111+
},
112+
{
113+
onAddTerminalContext: (selection) => {
114+
addSelections.push(selection.text);
115+
},
116+
},
117+
);
118+
119+
expect(addSelections).toEqual(["bun lint"]);
120+
});
75121
});

apps/web/src/components/ThreadTerminalDrawer.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,17 @@ export function shouldHandleTerminalSelectionMouseUp(
185185
return selectionGestureActive && button === 0;
186186
}
187187

188+
export function dispatchTerminalShortcutSelection(
189+
selection: TerminalContextSelection,
190+
callbacks: {
191+
onAddTerminalContext: (selection: TerminalContextSelection) => void;
192+
onSendTerminalContext?: ((selection: TerminalContextSelection) => void) | undefined;
193+
},
194+
): void {
195+
const handler = callbacks.onSendTerminalContext ?? callbacks.onAddTerminalContext;
196+
handler(selection);
197+
}
198+
188199
interface TerminalViewportProps {
189200
threadId: ThreadId;
190201
terminalId: string;
@@ -193,6 +204,7 @@ interface TerminalViewportProps {
193204
runtimeEnv?: Record<string, string>;
194205
onSessionExited: () => void;
195206
onAddTerminalContext: (selection: TerminalContextSelection) => void;
207+
onSendTerminalContext?: ((selection: TerminalContextSelection) => void) | undefined;
196208
onPreviewUrl?: ((url: string) => void) | undefined;
197209
focusRequestId: number;
198210
autoFocus: boolean;
@@ -208,6 +220,7 @@ function TerminalViewport({
208220
runtimeEnv,
209221
onSessionExited,
210222
onAddTerminalContext,
223+
onSendTerminalContext,
211224
onPreviewUrl,
212225
focusRequestId,
213226
autoFocus,
@@ -219,6 +232,7 @@ function TerminalViewport({
219232
const fitAddonRef = useRef<FitAddon | null>(null);
220233
const onSessionExitedRef = useRef(onSessionExited);
221234
const onAddTerminalContextRef = useRef(onAddTerminalContext);
235+
const onSendTerminalContextRef = useRef(onSendTerminalContext);
222236
const onPreviewUrlRef = useRef(onPreviewUrl);
223237
const terminalLabelRef = useRef(terminalLabel);
224238
const hasHandledExitRef = useRef(false);
@@ -242,6 +256,10 @@ function TerminalViewport({
242256
onAddTerminalContextRef.current = onAddTerminalContext;
243257
}, [onAddTerminalContext]);
244258

259+
useEffect(() => {
260+
onSendTerminalContextRef.current = onSendTerminalContext;
261+
}, [onSendTerminalContext]);
262+
245263
useEffect(() => {
246264
onPreviewUrlRef.current = onPreviewUrl;
247265
}, [onPreviewUrl]);
@@ -366,7 +384,10 @@ function TerminalViewport({
366384
event.stopPropagation();
367385
const action = readSelectionAction();
368386
if (action) {
369-
onAddTerminalContextRef.current(action.selection);
387+
dispatchTerminalShortcutSelection(action.selection, {
388+
onAddTerminalContext: onAddTerminalContextRef.current,
389+
onSendTerminalContext: onSendTerminalContextRef.current,
390+
});
370391
terminalRef.current?.clearSelection();
371392
terminalRef.current?.focus();
372393
}
@@ -783,6 +804,7 @@ interface ThreadTerminalDrawerProps {
783804
onCloseTerminal: (terminalId: string) => void;
784805
onHeightChange: (height: number) => void;
785806
onAddTerminalContext: (selection: TerminalContextSelection) => void;
807+
onSendTerminalContext?: ((selection: TerminalContextSelection) => void) | undefined;
786808
onPreviewUrl?: ((url: string) => void) | undefined;
787809
}
788810

@@ -834,6 +856,7 @@ export default function ThreadTerminalDrawer({
834856
onCloseTerminal,
835857
onHeightChange,
836858
onAddTerminalContext,
859+
onSendTerminalContext,
837860
onPreviewUrl,
838861
}: ThreadTerminalDrawerProps) {
839862
const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
@@ -1136,6 +1159,7 @@ export default function ThreadTerminalDrawer({
11361159
{...(runtimeEnv ? { runtimeEnv } : {})}
11371160
onSessionExited={() => onCloseTerminal(terminalId)}
11381161
onAddTerminalContext={onAddTerminalContext}
1162+
onSendTerminalContext={onSendTerminalContext}
11391163
onPreviewUrl={onPreviewUrl}
11401164
focusRequestId={focusRequestId}
11411165
autoFocus={terminalId === resolvedActiveTerminalId}

0 commit comments

Comments
 (0)