From c875b6f67aa285166db284482561a53a8dfc0bf8 Mon Sep 17 00:00:00 2001 From: mask Date: Fri, 13 Mar 2026 08:53:05 -0500 Subject: [PATCH 1/6] Add terminal context selections to chat --- apps/web/src/components/ChatView.tsx | 217 ++++++++++++------ .../src/components/ThreadTerminalDrawer.tsx | 113 ++++++++- .../chat/ComposerPendingTerminalContexts.tsx | 53 +++++ .../src/components/chat/MessagesTimeline.tsx | 39 +++- apps/web/src/composerDraftStore.test.ts | 53 +++++ apps/web/src/composerDraftStore.ts | 170 ++++++++++++++ apps/web/src/lib/terminalContext.test.ts | 81 +++++++ apps/web/src/lib/terminalContext.ts | 203 ++++++++++++++++ 8 files changed, 851 insertions(+), 78 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx create mode 100644 apps/web/src/lib/terminalContext.test.ts create mode 100644 apps/web/src/lib/terminalContext.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f625762c..de0efc9d9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -127,6 +127,12 @@ import { useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; +import { + appendTerminalContextsToPrompt, + formatTerminalContextLabel, + type TerminalContextDraft, + type TerminalContextSelection, +} from "../lib/terminalContext"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; @@ -140,6 +146,7 @@ import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalAc import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; +import { ComposerPendingTerminalContexts } from "./chat/ComposerPendingTerminalContexts"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; @@ -209,6 +216,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; + const composerTerminalContexts = composerDraft.terminalContexts; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); @@ -222,6 +230,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); + const addComposerDraftTerminalContext = useComposerDraftStore( + (store) => store.addTerminalContext, + ); + const addComposerDraftTerminalContexts = useComposerDraftStore( + (store) => store.addTerminalContexts, + ); + const removeComposerDraftTerminalContext = useComposerDraftStore( + (store) => store.removeTerminalContext, + ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); @@ -247,6 +264,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; + const composerTerminalContextsRef = useRef(composerTerminalContexts); const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record >({}); @@ -349,12 +367,24 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [addComposerDraftImages, threadId], ); + const addComposerTerminalContextsToDraft = useCallback( + (contexts: TerminalContextDraft[]) => { + addComposerDraftTerminalContexts(threadId, contexts); + }, + [addComposerDraftTerminalContexts, threadId], + ); const removeComposerImageFromDraft = useCallback( (imageId: string) => { removeComposerDraftImage(threadId, imageId); }, [removeComposerDraftImage, threadId], ); + const removeComposerTerminalContextFromDraft = useCallback( + (contextId: string) => { + removeComposerDraftTerminalContext(threadId, contextId); + }, + [removeComposerDraftTerminalContext, threadId], + ); const serverThread = threads.find((t) => t.id === threadId); const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); @@ -1092,6 +1122,21 @@ export default function ChatView({ threadId }: ChatViewProps) { focusComposer(); }); }, [focusComposer]); + const addTerminalContextToDraft = useCallback( + (selection: TerminalContextSelection) => { + if (!activeThread) { + return; + } + addComposerDraftTerminalContext(activeThread.id, { + id: randomUUID(), + threadId: activeThread.id, + createdAt: new Date().toISOString(), + ...selection, + }); + scheduleComposerFocus(); + }, + [activeThread, addComposerDraftTerminalContext, scheduleComposerFocus], + ); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadId) return; @@ -1728,6 +1773,10 @@ export default function ChatView({ threadId }: ChatViewProps) { composerImagesRef.current = composerImages; }, [composerImages]); + useEffect(() => { + composerTerminalContextsRef.current = composerTerminalContexts; + }, [composerTerminalContexts]); + useEffect(() => { if (!activeThread?.id) return; if (activeThread.messages.length === 0) { @@ -2220,7 +2269,9 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } const standaloneSlashCommand = - composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; + composerImages.length === 0 && composerTerminalContexts.length === 0 + ? parseStandaloneComposerSlashCommand(trimmed) + : null; if (standaloneSlashCommand) { await handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; @@ -2230,7 +2281,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerTrigger(null); return; } - if (!trimmed && composerImages.length === 0) return; + if (!trimmed && composerImages.length === 0 && composerTerminalContexts.length === 0) return; if (!activeProject) return; const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; @@ -2255,6 +2306,11 @@ export default function ChatView({ threadId }: ChatViewProps) { beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); const composerImagesSnapshot = [...composerImages]; + const composerTerminalContextsSnapshot = [...composerTerminalContexts]; + const messageTextForSend = appendTerminalContextsToPrompt( + trimmed, + composerTerminalContextsSnapshot, + ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( @@ -2279,7 +2335,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: trimmed, + text: messageTextForSend, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, @@ -2337,6 +2393,8 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; + } else if (composerTerminalContextsSnapshot.length > 0) { + titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else { titleSeed = "New thread"; } @@ -2417,7 +2475,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, attachments: turnAttachments, }, model: selectedModel || undefined, @@ -2445,7 +2503,8 @@ export default function ChatView({ threadId }: ChatViewProps) { if ( !turnStartSucceeded && promptRef.current.length === 0 && - composerImagesRef.current.length === 0 + composerImagesRef.current.length === 0 && + composerTerminalContextsRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -2459,6 +2518,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setPrompt(trimmed); setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length)); addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); + addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); } setThreadError( @@ -3365,75 +3425,81 @@ export default function ChatView({ threadId }: ChatViewProps) { )} - {!isComposerApprovalState && - pendingUserInputs.length === 0 && - composerImages.length > 0 && ( -
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} -
- )} + {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + + + ))} + + )} + + )} ); })()} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 8e480715f..5a54cd650 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -12,6 +12,8 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { Button } from "~/components/ui/button"; +import { type TerminalContextSelection } from "~/lib/terminalContext"; import { openInPreferredEditor } from "../editorPreferences"; import { extractTerminalLinks, @@ -110,9 +112,11 @@ function terminalThemeFromApp(): ITheme { interface TerminalViewportProps { threadId: ThreadId; terminalId: string; + terminalLabel: string; cwd: string; runtimeEnv?: Record; onSessionExited: () => void; + onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; resizeEpoch: number; @@ -122,9 +126,11 @@ interface TerminalViewportProps { function TerminalViewport({ threadId, terminalId, + terminalLabel, cwd, runtimeEnv, onSessionExited, + onAddTerminalContext, focusRequestId, autoFocus, resizeEpoch, @@ -135,6 +141,12 @@ function TerminalViewport({ const fitAddonRef = useRef(null); const onSessionExitedRef = useRef(onSessionExited); const hasHandledExitRef = useRef(false); + const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); + const [selectionAction, setSelectionAction] = useState<{ + left: number; + top: number; + selection: TerminalContextSelection; + } | null>(null); useEffect(() => { onSessionExitedRef.current = onSessionExited; @@ -165,6 +177,45 @@ function TerminalViewport({ const api = readNativeApi(); if (!api) return; + const clearSelectionAction = () => { + setSelectionAction(null); + }; + + const updateSelectionAction = () => { + const activeTerminal = terminalRef.current; + const mountElement = containerRef.current; + if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { + clearSelectionAction(); + return; + } + const selectionText = activeTerminal.getSelection(); + const selectionPosition = activeTerminal.getSelectionPosition(); + const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); + if (!selectionPosition || normalizedText.length === 0) { + clearSelectionAction(); + return; + } + const lineStart = selectionPosition.start.y + 1; + const lineCount = normalizedText.split("\n").length; + const lineEnd = Math.max(lineStart, lineStart + lineCount - 1); + const bounds = mountElement.getBoundingClientRect(); + const pointer = selectionPointerRef.current; + const preferredLeft = + pointer === null ? bounds.width - 116 : Math.round(pointer.x - bounds.left); + const preferredTop = pointer === null ? 12 : Math.round(pointer.y - bounds.top - 40); + setSelectionAction({ + left: Math.max(8, Math.min(preferredLeft, Math.max(bounds.width - 116, 8))), + top: Math.max(8, Math.min(preferredTop, Math.max(bounds.height - 36, 8))), + selection: { + terminalId, + terminalLabel, + lineStart, + lineEnd, + text: normalizedText, + }, + }); + }; + const sendTerminalInput = async (data: string, fallbackError: string) => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; @@ -259,6 +310,20 @@ function TerminalViewport({ ); }); + const selectionDisposable = terminal.onSelectionChange(() => { + window.requestAnimationFrame(updateSelectionAction); + }); + + const handleMouseUp = (event: MouseEvent) => { + selectionPointerRef.current = { x: event.clientX, y: event.clientY }; + window.requestAnimationFrame(updateSelectionAction); + }; + const handlePointerDown = () => { + clearSelectionAction(); + }; + mount.addEventListener("mouseup", handleMouseUp); + mount.addEventListener("pointerdown", handlePointerDown); + const themeObserver = new MutationObserver(() => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; @@ -310,11 +375,13 @@ function TerminalViewport({ if (event.type === "output") { activeTerminal.write(event.data); + clearSelectionAction(); return; } if (event.type === "started" || event.type === "restarted") { hasHandledExitRef.current = false; + clearSelectionAction(); activeTerminal.write("\u001bc"); if (event.snapshot.history.length > 0) { activeTerminal.write(event.snapshot.history); @@ -323,6 +390,7 @@ function TerminalViewport({ } if (event.type === "cleared") { + clearSelectionAction(); activeTerminal.clear(); activeTerminal.write("\u001bc"); return; @@ -383,7 +451,10 @@ function TerminalViewport({ window.clearTimeout(fitTimer); unsubscribe(); inputDisposable.dispose(); + selectionDisposable.dispose(); terminalLinksDisposable.dispose(); + mount.removeEventListener("mouseup", handleMouseUp); + mount.removeEventListener("pointerdown", handlePointerDown); themeObserver.disconnect(); terminalRef.current = null; fitAddonRef.current = null; @@ -430,7 +501,41 @@ function TerminalViewport({ window.cancelAnimationFrame(frame); }; }, [drawerHeight, resizeEpoch, terminalId, threadId]); - return
; + return ( +
+ {selectionAction ? ( +
+
+ +
+
+ ) : null} +
+ ); } interface ThreadTerminalDrawerProps { @@ -451,6 +556,7 @@ interface ThreadTerminalDrawerProps { onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; + onAddTerminalContext: (selection: TerminalContextSelection) => void; } interface TerminalActionButtonProps { @@ -500,6 +606,7 @@ export default function ThreadTerminalDrawer({ onActiveTerminalChange, onCloseTerminal, onHeightChange, + onAddTerminalContext, }: ThreadTerminalDrawerProps) { const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const [resizeEpoch, setResizeEpoch] = useState(0); @@ -796,9 +903,11 @@ export default function ThreadTerminalDrawer({ onCloseTerminal(terminalId)} + onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} autoFocus={terminalId === resolvedActiveTerminalId} resizeEpoch={resizeEpoch} @@ -814,9 +923,11 @@ export default function ThreadTerminalDrawer({ key={resolvedActiveTerminalId} threadId={threadId} terminalId={resolvedActiveTerminalId} + terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} cwd={cwd} {...(runtimeEnv ? { runtimeEnv } : {})} onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} + onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} autoFocus resizeEpoch={resizeEpoch} diff --git a/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx b/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx new file mode 100644 index 000000000..94d428b74 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx @@ -0,0 +1,53 @@ +import { TerminalIcon, XIcon } from "lucide-react"; + +import { type TerminalContextDraft, formatTerminalContextLabel } from "~/lib/terminalContext"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +interface ComposerPendingTerminalContextsProps { + contexts: ReadonlyArray; + onRemove: (contextId: string) => void; +} + +export function ComposerPendingTerminalContexts(props: ComposerPendingTerminalContextsProps) { + const { contexts, onRemove } = props; + + if (contexts.length === 0) { + return null; + } + + return ( +
+ {contexts.map((context) => { + const label = formatTerminalContextLabel(context); + return ( + + + + + + {label} + +
+ } + /> + + {context.text} + + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e30801041..ba0af7cc4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -33,6 +33,8 @@ import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { extractTrailingTerminalContexts } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; import { type TimestampFormat } from "../../appSettings"; import { formatTimestamp } from "../../timestampFormat"; @@ -337,6 +339,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ row.message.role === "user" && (() => { const userImages = row.message.attachments ?? []; + const extractedTerminalContexts = extractTrailingTerminalContexts(row.message.text); + const visibleUserText = extractedTerminalContexts.promptText; const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); return (
@@ -378,14 +382,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}
)} - {row.message.text && ( + {visibleUserText && (
-                    {row.message.text}
+                    {visibleUserText}
                   
)}
- {row.message.text && } + {visibleUserText && } {canRevertAgentWork && (
+ {extractedTerminalContexts.contextCount > 0 && ( + + + + + {extractedTerminalContexts.contextCount} + + + } + /> + + {extractedTerminalContexts.previewTitle} + + + )}

{formatTimestamp(row.message.createdAt, timestampFormat)}

diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 927a16060..d7896c524 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -6,6 +6,7 @@ import { createDebouncedStorage, useComposerDraftStore, } from "./composerDraftStore"; +import { type TerminalContextDraft } from "./lib/terminalContext"; function makeImage(input: { id: string; @@ -34,6 +35,26 @@ function makeImage(input: { }; } +function makeTerminalContext(input: { + id: string; + text?: string; + terminalId?: string; + terminalLabel?: string; + lineStart?: number; + lineEnd?: number; +}): TerminalContextDraft { + return { + id: input.id, + threadId: ThreadId.makeUnsafe("thread-dedupe"), + terminalId: input.terminalId ?? "default", + terminalLabel: input.terminalLabel ?? "Terminal 1", + lineStart: input.lineStart ?? 4, + lineEnd: input.lineEnd ?? 5, + text: input.text ?? "git status\nOn branch main", + createdAt: "2026-03-13T12:00:00.000Z", + }; +} + describe("composerDraftStore addImages", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; @@ -158,6 +179,38 @@ describe("composerDraftStore clearComposerContent", () => { }); }); +describe("composerDraftStore terminal contexts", () => { + const threadId = ThreadId.makeUnsafe("thread-dedupe"); + + beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + it("deduplicates identical terminal contexts by selection signature", () => { + const first = makeTerminalContext({ id: "ctx-1" }); + const duplicate = makeTerminalContext({ id: "ctx-2" }); + + useComposerDraftStore.getState().addTerminalContexts(threadId, [first, duplicate]); + + const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]); + }); + + it("clears terminal contexts when clearing composer content", () => { + useComposerDraftStore + .getState() + .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-1" })); + + useComposerDraftStore.getState().clearComposerContent(threadId); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); +}); + describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.makeUnsafe("project-a"); const otherProjectId = ProjectId.makeUnsafe("project-b"); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2af920527..8ef4f9be2 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -10,6 +10,10 @@ import { } from "@t3tools/contracts"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; +import { + type TerminalContextDraft, + normalizeTerminalContextSelection, +} from "./lib/terminalContext"; import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; @@ -74,6 +78,7 @@ export interface ComposerImageAttachment extends Omit void; addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; removeImage: (threadId: ThreadId, imageId: string) => void; + addTerminalContext: (threadId: ThreadId, context: TerminalContextDraft) => void; + addTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; + removeTerminalContext: (threadId: ThreadId, contextId: string) => void; + clearTerminalContexts: (threadId: ThreadId) => void; clearPersistedAttachments: (threadId: ThreadId) => void; syncPersistedAttachments: ( threadId: ThreadId, @@ -190,14 +200,17 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { const EMPTY_IMAGES: ComposerImageAttachment[] = []; const EMPTY_IDS: string[] = []; const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; +const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); +Object.freeze(EMPTY_TERMINAL_CONTEXTS); const EMPTY_THREAD_DRAFT = Object.freeze({ prompt: "", images: EMPTY_IMAGES, nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, + terminalContexts: EMPTY_TERMINAL_CONTEXTS, provider: null, model: null, runtimeMode: null, @@ -216,6 +229,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { images: [], nonPersistedImageIds: [], persistedAttachments: [], + terminalContexts: [], provider: null, model: null, runtimeMode: null, @@ -231,11 +245,16 @@ function composerImageDedupKey(image: ComposerImageAttachment): string { return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`; } +function terminalContextDedupKey(context: TerminalContextDraft): string { + return `${context.terminalId}\u0000${context.lineStart}\u0000${context.lineEnd}\u0000${context.text}`; +} + function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { return ( draft.prompt.length === 0 && draft.images.length === 0 && draft.persistedAttachments.length === 0 && + draft.terminalContexts.length === 0 && draft.provider === null && draft.model === null && draft.runtimeMode === null && @@ -290,6 +309,48 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt }; } +function normalizeTerminalContextDraft(value: unknown): TerminalContextDraft | null { + if (!value || typeof value !== "object") { + return null; + } + const candidate = value as Record; + const id = candidate.id; + const threadId = candidate.threadId; + const createdAt = candidate.createdAt; + const lineStart = candidate.lineStart; + const lineEnd = candidate.lineEnd; + if ( + typeof id !== "string" || + id.length === 0 || + typeof threadId !== "string" || + threadId.length === 0 || + typeof createdAt !== "string" || + createdAt.length === 0 || + typeof lineStart !== "number" || + !Number.isFinite(lineStart) || + typeof lineEnd !== "number" || + !Number.isFinite(lineEnd) + ) { + return null; + } + const normalizedSelection = normalizeTerminalContextSelection({ + terminalId: typeof candidate.terminalId === "string" ? candidate.terminalId : "", + terminalLabel: typeof candidate.terminalLabel === "string" ? candidate.terminalLabel : "", + lineStart, + lineEnd, + text: typeof candidate.text === "string" ? candidate.text : "", + }); + if (!normalizedSelection) { + return null; + } + return { + id, + threadId: threadId as ThreadId, + createdAt, + ...normalizedSelection, + }; +} + function normalizeDraftThreadEnvMode( value: unknown, fallbackWorktreePath: string | null, @@ -404,6 +465,12 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer return normalized ? [normalized] : []; }) : []; + const terminalContexts = Array.isArray(draftCandidate.terminalContexts) + ? draftCandidate.terminalContexts.flatMap((entry) => { + const normalized = normalizeTerminalContextDraft(entry); + return normalized ? [normalized] : []; + }) + : []; const provider = normalizeProviderKind(draftCandidate.provider); const model = typeof draftCandidate.model === "string" @@ -430,6 +497,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer if ( prompt.length === 0 && attachments.length === 0 && + terminalContexts.length === 0 && !provider && !model && !runtimeMode && @@ -442,6 +510,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer nextDraftsByThreadId[threadId as ThreadId] = { prompt, attachments, + ...(terminalContexts.length > 0 ? { terminalContexts } : {}), ...(provider ? { provider } : {}), ...(model ? { model } : {}), ...(runtimeMode ? { runtimeMode } : {}), @@ -548,6 +617,7 @@ function toHydratedThreadDraft( images: hydrateImagesFromPersisted(persistedDraft.attachments), nonPersistedImageIds: [], persistedAttachments: persistedDraft.attachments, + terminalContexts: persistedDraft.terminalContexts ?? [], provider: persistedDraft.provider ?? null, model: persistedDraft.model ?? null, runtimeMode: persistedDraft.runtimeMode ?? null, @@ -1066,6 +1136,101 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, + addTerminalContext: (threadId, context) => { + if (threadId.length === 0) { + return; + } + get().addTerminalContexts(threadId, [context]); + }, + addTerminalContexts: (threadId, contexts) => { + if (threadId.length === 0 || contexts.length === 0) { + return; + } + set((state) => { + const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); + const existingIds = new Set(existing.terminalContexts.map((context) => context.id)); + const existingDedupKeys = new Set( + existing.terminalContexts.map((context) => terminalContextDedupKey(context)), + ); + const acceptedContexts: TerminalContextDraft[] = []; + for (const context of contexts) { + const normalizedSelection = normalizeTerminalContextSelection(context); + if (!normalizedSelection) { + continue; + } + const normalizedContext: TerminalContextDraft = { + ...context, + threadId, + ...normalizedSelection, + }; + const dedupKey = terminalContextDedupKey(normalizedContext); + if (existingIds.has(normalizedContext.id) || existingDedupKeys.has(dedupKey)) { + continue; + } + acceptedContexts.push(normalizedContext); + existingIds.add(normalizedContext.id); + existingDedupKeys.add(dedupKey); + } + if (acceptedContexts.length === 0) { + return state; + } + return { + draftsByThreadId: { + ...state.draftsByThreadId, + [threadId]: { + ...existing, + terminalContexts: [...existing.terminalContexts, ...acceptedContexts], + }, + }, + }; + }); + }, + removeTerminalContext: (threadId, contextId) => { + if (threadId.length === 0 || contextId.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadId[threadId]; + if (!current) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + terminalContexts: current.terminalContexts.filter( + (context) => context.id !== contextId, + ), + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, + clearTerminalContexts: (threadId) => { + if (threadId.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadId[threadId]; + if (!current || current.terminalContexts.length === 0) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + terminalContexts: [], + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, clearPersistedAttachments: (threadId) => { if (threadId.length === 0) { return; @@ -1159,6 +1324,7 @@ export const useComposerDraftStore = create()( images: [], nonPersistedImageIds: [], persistedAttachments: [], + terminalContexts: [], }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1218,6 +1384,7 @@ export const useComposerDraftStore = create()( if ( draft.prompt.length === 0 && draft.persistedAttachments.length === 0 && + draft.terminalContexts.length === 0 && draft.provider === null && draft.model === null && draft.runtimeMode === null && @@ -1231,6 +1398,9 @@ export const useComposerDraftStore = create()( prompt: draft.prompt, attachments: draft.persistedAttachments, }; + if (draft.terminalContexts.length > 0) { + persistedDraft.terminalContexts = draft.terminalContexts; + } if (draft.model) { persistedDraft.model = draft.model; } diff --git a/apps/web/src/lib/terminalContext.test.ts b/apps/web/src/lib/terminalContext.test.ts new file mode 100644 index 000000000..f6f99f0d6 --- /dev/null +++ b/apps/web/src/lib/terminalContext.test.ts @@ -0,0 +1,81 @@ +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + appendTerminalContextsToPrompt, + buildTerminalContextBlock, + extractTrailingTerminalContexts, + formatTerminalContextLabel, + type TerminalContextDraft, +} from "./terminalContext"; + +function makeContext(overrides?: Partial): TerminalContextDraft { + return { + id: "context-1", + threadId: ThreadId.makeUnsafe("thread-1"), + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 12, + lineEnd: 13, + text: "git status\nOn branch main", + createdAt: "2026-03-13T12:00:00.000Z", + ...overrides, + }; +} + +describe("terminalContext", () => { + it("formats terminal labels with line ranges", () => { + expect(formatTerminalContextLabel(makeContext())).toBe("Terminal 1 lines 12-13"); + expect( + formatTerminalContextLabel( + makeContext({ + lineStart: 9, + lineEnd: 9, + }), + ), + ).toBe("Terminal 1 line 9"); + }); + + it("builds a numbered terminal context block", () => { + expect(buildTerminalContextBlock([makeContext()])).toBe( + [ + "", + "- Terminal 1 lines 12-13:", + " 12 | git status", + " 13 | On branch main", + "", + ].join("\n"), + ); + }); + + it("appends terminal context blocks after prompt text", () => { + expect(appendTerminalContextsToPrompt("Investigate this", [makeContext()])).toBe( + [ + "Investigate this", + "", + "", + "- Terminal 1 lines 12-13:", + " 12 | git status", + " 13 | On branch main", + "", + ].join("\n"), + ); + }); + + it("extracts terminal context blocks from message text", () => { + const prompt = appendTerminalContextsToPrompt("Investigate this", [makeContext()]); + expect(extractTrailingTerminalContexts(prompt)).toEqual({ + promptText: "Investigate this", + contextCount: 1, + previewTitle: "Terminal 1 lines 12-13\n12 | git status\n13 | On branch main", + }); + }); + + it("preserves prompt text when no trailing terminal context block exists", () => { + expect(extractTrailingTerminalContexts("No attached context")).toEqual({ + promptText: "No attached context", + contextCount: 0, + previewTitle: null, + }); + }); +}); diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts new file mode 100644 index 000000000..9da73fec5 --- /dev/null +++ b/apps/web/src/lib/terminalContext.ts @@ -0,0 +1,203 @@ +import { type ThreadId } from "@t3tools/contracts"; + +export interface TerminalContextSelection { + terminalId: string; + terminalLabel: string; + lineStart: number; + lineEnd: number; + text: string; +} + +export interface TerminalContextDraft extends TerminalContextSelection { + id: string; + threadId: ThreadId; + createdAt: string; +} + +export interface ExtractedTerminalContexts { + promptText: string; + contextCount: number; + previewTitle: string | null; +} + +const TRAILING_TERMINAL_CONTEXT_BLOCK_PATTERN = + /\n*\n([\s\S]*?)\n<\/terminal_context>\s*$/; + +function normalizeTerminalContextText(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); +} + +function previewTerminalContextText(text: string): string { + const normalized = normalizeTerminalContextText(text); + if (normalized.length === 0) { + return ""; + } + const lines = normalized.split("\n"); + const visibleLines = lines.slice(0, 3); + if (lines.length > 3) { + visibleLines.push("..."); + } + const preview = visibleLines.join("\n"); + return preview.length > 180 ? `${preview.slice(0, 177)}...` : preview; +} + +export function normalizeTerminalContextSelection( + selection: TerminalContextSelection, +): TerminalContextSelection | null { + const text = normalizeTerminalContextText(selection.text); + const terminalId = selection.terminalId.trim(); + const terminalLabel = selection.terminalLabel.trim(); + if (text.length === 0 || terminalId.length === 0 || terminalLabel.length === 0) { + return null; + } + const lineStart = Math.max(1, Math.floor(selection.lineStart)); + const lineEnd = Math.max(lineStart, Math.floor(selection.lineEnd)); + return { + terminalId, + terminalLabel, + lineStart, + lineEnd, + text, + }; +} + +export function formatTerminalContextRange(selection: { + lineStart: number; + lineEnd: number; +}): string { + return selection.lineStart === selection.lineEnd + ? `line ${selection.lineStart}` + : `lines ${selection.lineStart}-${selection.lineEnd}`; +} + +export function formatTerminalContextLabel(selection: { + terminalLabel: string; + lineStart: number; + lineEnd: number; +}): string { + return `${selection.terminalLabel} ${formatTerminalContextRange(selection)}`; +} + +export function buildTerminalContextPreviewTitle( + contexts: ReadonlyArray, +): string | null { + if (contexts.length === 0) { + return null; + } + return contexts + .map((context) => { + const normalized = normalizeTerminalContextSelection(context); + if (!normalized) { + return null; + } + const preview = previewTerminalContextText(normalized.text); + return preview.length > 0 + ? `${formatTerminalContextLabel(normalized)}\n${preview}` + : formatTerminalContextLabel(normalized); + }) + .filter((value): value is string => value !== null) + .join("\n\n"); +} + +function buildTerminalContextBodyLines(selection: TerminalContextSelection): string[] { + return normalizeTerminalContextText(selection.text) + .split("\n") + .map((line, index) => ` ${selection.lineStart + index} | ${line}`); +} + +export function buildTerminalContextBlock( + contexts: ReadonlyArray, +): string { + const normalizedContexts = contexts + .map((context) => normalizeTerminalContextSelection(context)) + .filter((context): context is TerminalContextSelection => context !== null); + if (normalizedContexts.length === 0) { + return ""; + } + const lines: string[] = []; + for (let index = 0; index < normalizedContexts.length; index += 1) { + const context = normalizedContexts[index]!; + lines.push(`- ${formatTerminalContextLabel(context)}:`); + lines.push(...buildTerminalContextBodyLines(context)); + if (index < normalizedContexts.length - 1) { + lines.push(""); + } + } + return ["", ...lines, ""].join("\n"); +} + +export function appendTerminalContextsToPrompt( + prompt: string, + contexts: ReadonlyArray, +): string { + const trimmedPrompt = prompt.trim(); + const contextBlock = buildTerminalContextBlock(contexts); + if (contextBlock.length === 0) { + return trimmedPrompt; + } + return trimmedPrompt.length > 0 ? `${trimmedPrompt}\n\n${contextBlock}` : contextBlock; +} + +export function extractTrailingTerminalContexts(prompt: string): ExtractedTerminalContexts { + const match = TRAILING_TERMINAL_CONTEXT_BLOCK_PATTERN.exec(prompt); + if (!match) { + return { + promptText: prompt, + contextCount: 0, + previewTitle: null, + }; + } + const promptText = prompt.slice(0, match.index).replace(/\n+$/, ""); + const parsedContexts = parseTerminalContextEntries(match[1] ?? ""); + return { + promptText, + contextCount: parsedContexts.length, + previewTitle: + parsedContexts.length > 0 + ? parsedContexts + .map(({ header, body }) => (body.length > 0 ? `${header}\n${body}` : header)) + .join("\n\n") + : null, + }; +} + +function parseTerminalContextEntries(block: string): Array<{ header: string; body: string }> { + const entries: Array<{ header: string; body: string }> = []; + let current: { header: string; bodyLines: string[] } | null = null; + + const commitCurrent = () => { + if (!current) { + return; + } + entries.push({ + header: current.header, + body: current.bodyLines.join("\n").trimEnd(), + }); + current = null; + }; + + for (const rawLine of block.split("\n")) { + const headerMatch = /^- (.+):$/.exec(rawLine); + if (headerMatch) { + commitCurrent(); + current = { + header: headerMatch[1]!, + bodyLines: [], + }; + continue; + } + if (!current) { + continue; + } + if (rawLine.startsWith(" ")) { + current.bodyLines.push(rawLine.slice(2)); + continue; + } + if (rawLine.length === 0) { + current.bodyLines.push(""); + } + } + + commitCurrent(); + return entries; +} From aab9382155b12d8afcca8a5f5b3237c7750c948a Mon Sep 17 00:00:00 2001 From: mask Date: Fri, 13 Mar 2026 09:02:05 -0500 Subject: [PATCH 2/6] Fix terminal add-to-chat click handling --- apps/web/src/components/ThreadTerminalDrawer.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 5a54cd650..3a6d5424c 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -109,6 +109,10 @@ function terminalThemeFromApp(): ITheme { }; } +function isTerminalSelectionActionTarget(target: EventTarget | null): boolean { + return target instanceof Element && target.closest("[data-terminal-selection-action]") !== null; +} + interface TerminalViewportProps { threadId: ThreadId; terminalId: string; @@ -315,10 +319,16 @@ function TerminalViewport({ }); const handleMouseUp = (event: MouseEvent) => { + if (isTerminalSelectionActionTarget(event.target)) { + return; + } selectionPointerRef.current = { x: event.clientX, y: event.clientY }; window.requestAnimationFrame(updateSelectionAction); }; - const handlePointerDown = () => { + const handlePointerDown = (event: PointerEvent) => { + if (isTerminalSelectionActionTarget(event.target)) { + return; + } clearSelectionAction(); }; mount.addEventListener("mouseup", handleMouseUp); @@ -505,6 +515,7 @@ function TerminalViewport({
{selectionAction ? (
@@ -514,6 +525,7 @@ function TerminalViewport({ size="xs" variant="secondary" className="rounded-full px-3" + data-terminal-selection-action onMouseDown={(event) => { event.preventDefault(); event.stopPropagation(); From 8e7031ccce2992b753a7469e3ad87f0ef6178c60 Mon Sep 17 00:00:00 2001 From: mask Date: Fri, 13 Mar 2026 10:59:58 -0500 Subject: [PATCH 3/6] Fix terminal context chat timeline regressions --- .../src/components/chat/MessagesTimeline.tsx | 2 +- .../web/src/components/timelineHeight.test.ts | 30 +++++++++++++++++++ apps/web/src/components/timelineHeight.ts | 5 +++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ba0af7cc4..4cb3ccdef 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -389,7 +389,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}
- {visibleUserText && } + {row.message.text && } {canRevertAgentWork && (
- {extractedTerminalContexts.contextCount > 0 && ( + {displayedUserMessage.contextCount > 0 && ( - {extractedTerminalContexts.contextCount} + {displayedUserMessage.contextCount} } @@ -428,7 +427,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ side="top" className="max-w-80 whitespace-pre-wrap leading-tight" > - {extractedTerminalContexts.previewTitle} + {displayedUserMessage.previewTitle} )} diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 16eb4b4dd..6b4aea804 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -1,4 +1,4 @@ -import { extractTrailingTerminalContexts } from "../lib/terminalContext"; +import { deriveDisplayedUserMessageState } from "../lib/terminalContext"; const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; const USER_CHARS_PER_LINE_FALLBACK = 56; @@ -77,8 +77,8 @@ export function estimateTimelineMessageHeight( if (message.role === "user") { const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); - const visibleUserText = extractTrailingTerminalContexts(message.text).promptText; - const estimatedLines = estimateWrappedLineCount(visibleUserText, charsPerLine); + const displayedUserMessage = deriveDisplayedUserMessageState(message.text); + const estimatedLines = estimateWrappedLineCount(displayedUserMessage.visibleText, charsPerLine); const attachmentCount = message.attachments?.length ?? 0; const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; diff --git a/apps/web/src/lib/terminalContext.test.ts b/apps/web/src/lib/terminalContext.test.ts index f6f99f0d6..67abb4874 100644 --- a/apps/web/src/lib/terminalContext.test.ts +++ b/apps/web/src/lib/terminalContext.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { appendTerminalContextsToPrompt, buildTerminalContextBlock, + deriveDisplayedUserMessageState, extractTrailingTerminalContexts, formatTerminalContextLabel, type TerminalContextDraft, @@ -71,6 +72,16 @@ describe("terminalContext", () => { }); }); + it("derives displayed user message state from terminal context prompts", () => { + const prompt = appendTerminalContextsToPrompt("Investigate this", [makeContext()]); + expect(deriveDisplayedUserMessageState(prompt)).toEqual({ + visibleText: "Investigate this", + copyText: prompt, + contextCount: 1, + previewTitle: "Terminal 1 lines 12-13\n12 | git status\n13 | On branch main", + }); + }); + it("preserves prompt text when no trailing terminal context block exists", () => { expect(extractTrailingTerminalContexts("No attached context")).toEqual({ promptText: "No attached context", diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index 9da73fec5..29db17985 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -20,6 +20,13 @@ export interface ExtractedTerminalContexts { previewTitle: string | null; } +export interface DisplayedUserMessageState { + visibleText: string; + copyText: string; + contextCount: number; + previewTitle: string | null; +} + const TRAILING_TERMINAL_CONTEXT_BLOCK_PATTERN = /\n*\n([\s\S]*?)\n<\/terminal_context>\s*$/; @@ -161,6 +168,16 @@ export function extractTrailingTerminalContexts(prompt: string): ExtractedTermin }; } +export function deriveDisplayedUserMessageState(prompt: string): DisplayedUserMessageState { + const extractedContexts = extractTrailingTerminalContexts(prompt); + return { + visibleText: extractedContexts.promptText, + copyText: prompt, + contextCount: extractedContexts.contextCount, + previewTitle: extractedContexts.previewTitle, + }; +} + function parseTerminalContextEntries(block: string): Array<{ header: string; body: string }> { const entries: Array<{ header: string; body: string }> = []; let current: { header: string; bodyLines: string[] } | null = null; From 9822e65d3691612ee550251e11be7cf4d1d76f85 Mon Sep 17 00:00:00 2001 From: mask Date: Fri, 13 Mar 2026 11:33:11 -0500 Subject: [PATCH 5/6] Fix terminal context review feedback --- .../src/components/ThreadTerminalDrawer.tsx | 18 +++++++++++++++++- apps/web/src/lib/terminalContext.test.ts | 15 +++++++++++++++ apps/web/src/lib/terminalContext.ts | 3 ++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 3a6d5424c..059771380 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -144,6 +144,7 @@ function TerminalViewport({ const terminalRef = useRef(null); const fitAddonRef = useRef(null); const onSessionExitedRef = useRef(onSessionExited); + const terminalLabelRef = useRef(terminalLabel); const hasHandledExitRef = useRef(false); const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const [selectionAction, setSelectionAction] = useState<{ @@ -156,6 +157,21 @@ function TerminalViewport({ onSessionExitedRef.current = onSessionExited; }, [onSessionExited]); + useEffect(() => { + terminalLabelRef.current = terminalLabel; + setSelectionAction((current) => + current === null + ? null + : { + ...current, + selection: { + ...current.selection, + terminalLabel, + }, + }, + ); + }, [terminalLabel]); + useEffect(() => { const mount = containerRef.current; if (!mount) return; @@ -212,7 +228,7 @@ function TerminalViewport({ top: Math.max(8, Math.min(preferredTop, Math.max(bounds.height - 36, 8))), selection: { terminalId, - terminalLabel, + terminalLabel: terminalLabelRef.current, lineStart, lineEnd, text: normalizedText, diff --git a/apps/web/src/lib/terminalContext.test.ts b/apps/web/src/lib/terminalContext.test.ts index 67abb4874..3f81d04c5 100644 --- a/apps/web/src/lib/terminalContext.test.ts +++ b/apps/web/src/lib/terminalContext.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { appendTerminalContextsToPrompt, + buildTerminalContextPreviewTitle, buildTerminalContextBlock, deriveDisplayedUserMessageState, extractTrailingTerminalContexts, @@ -89,4 +90,18 @@ describe("terminalContext", () => { previewTitle: null, }); }); + + it("returns null preview title when every context is invalid", () => { + expect( + buildTerminalContextPreviewTitle([ + makeContext({ + terminalId: " ", + }), + makeContext({ + id: "context-2", + text: "\n\n", + }), + ]), + ).toBeNull(); + }); }); diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index 29db17985..b6a980ece 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -91,7 +91,7 @@ export function buildTerminalContextPreviewTitle( if (contexts.length === 0) { return null; } - return contexts + const previews = contexts .map((context) => { const normalized = normalizeTerminalContextSelection(context); if (!normalized) { @@ -104,6 +104,7 @@ export function buildTerminalContextPreviewTitle( }) .filter((value): value is string => value !== null) .join("\n\n"); + return previews.length > 0 ? previews : null; } function buildTerminalContextBodyLines(selection: TerminalContextSelection): string[] { From 1bee2ab80f6e75e3a44d77ac0992838e1cf46d14 Mon Sep 17 00:00:00 2001 From: mask Date: Fri, 13 Mar 2026 13:08:35 -0500 Subject: [PATCH 6/6] Render terminal contexts inline in chat timeline --- .../src/components/chat/MessagesTimeline.tsx | 128 +++++++++++++----- .../chat/userMessageTerminalContexts.test.ts | 22 +++ .../chat/userMessageTerminalContexts.ts | 27 ++++ .../web/src/components/timelineHeight.test.ts | 5 +- apps/web/src/components/timelineHeight.ts | 12 +- apps/web/src/lib/terminalContext.test.ts | 13 ++ apps/web/src/lib/terminalContext.ts | 14 +- 7 files changed, 183 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/components/chat/userMessageTerminalContexts.test.ts create mode 100644 apps/web/src/components/chat/userMessageTerminalContexts.ts diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 258f16138..edca497f4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,5 +1,14 @@ import { type MessageId, type TurnId } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { measureElement as measureVirtualElement, type VirtualItem, @@ -34,10 +43,17 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { deriveDisplayedUserMessageState } from "~/lib/terminalContext"; +import { + deriveDisplayedUserMessageState, + type ParsedTerminalContextEntry, +} from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; import { type TimestampFormat } from "../../appSettings"; import { formatTimestamp } from "../../timestampFormat"; +import { + buildInlineTerminalContextText, + formatInlineTerminalContextLabel, +} from "./userMessageTerminalContexts"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -340,6 +356,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ (() => { const userImages = row.message.attachments ?? []; const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); + const terminalContexts = displayedUserMessage.contexts; const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); return (
@@ -381,10 +398,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}
)} - {displayedUserMessage.visibleText && ( -
-                    {displayedUserMessage.visibleText}
-                  
+ {(displayedUserMessage.visibleText.trim().length > 0 || + terminalContexts.length > 0) && ( + )}
@@ -404,33 +423,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}
- {displayedUserMessage.contextCount > 0 && ( - - - - - {displayedUserMessage.contextCount} - - - } - /> - - {displayedUserMessage.previewTitle} - - - )}

{formatTimestamp(row.message.createdAt, timestampFormat)}

@@ -668,6 +660,76 @@ function formatMessageMeta( return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; } +const UserMessageTerminalContextInlineLabel = memo( + function UserMessageTerminalContextInlineLabel(props: { context: ParsedTerminalContextEntry }) { + const label = + props.context.body.length > 0 + ? `${props.context.header}\n${props.context.body}` + : props.context.header; + + return ( + + + {formatInlineTerminalContextLabel(props.context.header)} + + } + /> + + {label} + + + ); + }, +); + +const UserMessageBody = memo(function UserMessageBody(props: { + text: string; + terminalContexts: ParsedTerminalContextEntry[]; +}) { + if (props.terminalContexts.length > 0) { + const inlinePrefix = buildInlineTerminalContextText(props.terminalContexts); + const inlineNodes: ReactNode[] = []; + + for (const context of props.terminalContexts) { + inlineNodes.push( + , + ); + inlineNodes.push( + , + ); + } + + if (props.text.length > 0) { + inlineNodes.push({props.text}); + } else if (inlinePrefix.length === 0) { + return null; + } + + return ( +
+ {inlineNodes} +
+ ); + } + + if (props.text.length === 0) { + return null; + } + + return ( +
+      {props.text}
+    
+ ); +}); + function workToneIcon(tone: TimelineWorkEntry["tone"]): { icon: LucideIcon; className: string; diff --git a/apps/web/src/components/chat/userMessageTerminalContexts.test.ts b/apps/web/src/components/chat/userMessageTerminalContexts.test.ts new file mode 100644 index 000000000..8d40dfe75 --- /dev/null +++ b/apps/web/src/components/chat/userMessageTerminalContexts.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { + buildInlineTerminalContextText, + formatInlineTerminalContextLabel, +} from "./userMessageTerminalContexts"; + +describe("userMessageTerminalContexts", () => { + it("builds plain inline terminal text labels", () => { + expect( + buildInlineTerminalContextText([ + { header: "Terminal 1 lines 12-13" }, + { header: "Terminal 2 line 4" }, + ]), + ).toBe("@terminal-1:12-13 @terminal-2:4"); + }); + + it("formats individual inline terminal labels compactly", () => { + expect(formatInlineTerminalContextLabel("Terminal 1 lines 12-13")).toBe("@terminal-1:12-13"); + expect(formatInlineTerminalContextLabel("Terminal 2 line 4")).toBe("@terminal-2:4"); + }); +}); diff --git a/apps/web/src/components/chat/userMessageTerminalContexts.ts b/apps/web/src/components/chat/userMessageTerminalContexts.ts new file mode 100644 index 000000000..b6d0c2071 --- /dev/null +++ b/apps/web/src/components/chat/userMessageTerminalContexts.ts @@ -0,0 +1,27 @@ +const TERMINAL_CONTEXT_HEADER_PATTERN = /^(.*?)\s+line(?:s)?\s+(\d+)(?:-(\d+))?$/i; + +export function buildInlineTerminalContextText( + contexts: ReadonlyArray<{ + header: string; + }>, +): string { + return contexts + .map((context) => context.header.trim()) + .filter((header) => header.length > 0) + .map(formatInlineTerminalContextLabel) + .join(" "); +} + +export function formatInlineTerminalContextLabel(header: string): string { + const trimmedHeader = header.trim(); + const match = TERMINAL_CONTEXT_HEADER_PATTERN.exec(trimmedHeader); + if (!match) { + return `@${trimmedHeader.toLowerCase().replace(/\s+/g, "-")}`; + } + + const terminalLabel = match[1]?.trim().toLowerCase().replace(/\s+/g, "-") ?? "terminal"; + const rangeStart = match[2] ?? ""; + const rangeEnd = match[3] ?? ""; + const range = rangeEnd.length > 0 ? `${rangeStart}-${rangeEnd}` : rangeStart; + return `@${terminalLabel}:${range}`; +} diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 953ee0b71..9b1331a9d 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { appendTerminalContextsToPrompt } from "../lib/terminalContext"; +import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; import { estimateTimelineMessageHeight } from "./timelineHeight"; describe("estimateTimelineMessageHeight", () => { @@ -76,7 +77,7 @@ describe("estimateTimelineMessageHeight", () => { ).toBe(162); }); - it("ignores trailing terminal context blocks when sizing user messages", () => { + it("adds terminal context chrome without counting the hidden block as message text", () => { const prompt = appendTerminalContextsToPrompt("Investigate this", [ { terminalId: "default", @@ -100,7 +101,7 @@ describe("estimateTimelineMessageHeight", () => { ).toBe( estimateTimelineMessageHeight({ role: "user", - text: "Investigate this", + text: `${buildInlineTerminalContextText([{ header: "Terminal 1 lines 40-43" }])} Investigate this`, }), ); }); diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 6b4aea804..998a2a0b7 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -1,4 +1,5 @@ import { deriveDisplayedUserMessageState } from "../lib/terminalContext"; +import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; const USER_CHARS_PER_LINE_FALLBACK = 56; @@ -78,7 +79,16 @@ export function estimateTimelineMessageHeight( if (message.role === "user") { const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); const displayedUserMessage = deriveDisplayedUserMessageState(message.text); - const estimatedLines = estimateWrappedLineCount(displayedUserMessage.visibleText, charsPerLine); + const renderedText = + displayedUserMessage.contexts.length > 0 + ? [ + buildInlineTerminalContextText(displayedUserMessage.contexts), + displayedUserMessage.visibleText, + ] + .filter((part) => part.length > 0) + .join(" ") + : displayedUserMessage.visibleText; + const estimatedLines = estimateWrappedLineCount(renderedText, charsPerLine); const attachmentCount = message.attachments?.length ?? 0; const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; diff --git a/apps/web/src/lib/terminalContext.test.ts b/apps/web/src/lib/terminalContext.test.ts index 3f81d04c5..404949a5f 100644 --- a/apps/web/src/lib/terminalContext.test.ts +++ b/apps/web/src/lib/terminalContext.test.ts @@ -70,6 +70,12 @@ describe("terminalContext", () => { promptText: "Investigate this", contextCount: 1, previewTitle: "Terminal 1 lines 12-13\n12 | git status\n13 | On branch main", + contexts: [ + { + header: "Terminal 1 lines 12-13", + body: "12 | git status\n13 | On branch main", + }, + ], }); }); @@ -80,6 +86,12 @@ describe("terminalContext", () => { copyText: prompt, contextCount: 1, previewTitle: "Terminal 1 lines 12-13\n12 | git status\n13 | On branch main", + contexts: [ + { + header: "Terminal 1 lines 12-13", + body: "12 | git status\n13 | On branch main", + }, + ], }); }); @@ -88,6 +100,7 @@ describe("terminalContext", () => { promptText: "No attached context", contextCount: 0, previewTitle: null, + contexts: [], }); }); diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index b6a980ece..b2c02c5b0 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -18,6 +18,7 @@ export interface ExtractedTerminalContexts { promptText: string; contextCount: number; previewTitle: string | null; + contexts: ParsedTerminalContextEntry[]; } export interface DisplayedUserMessageState { @@ -25,6 +26,12 @@ export interface DisplayedUserMessageState { copyText: string; contextCount: number; previewTitle: string | null; + contexts: ParsedTerminalContextEntry[]; +} + +export interface ParsedTerminalContextEntry { + header: string; + body: string; } const TRAILING_TERMINAL_CONTEXT_BLOCK_PATTERN = @@ -153,6 +160,7 @@ export function extractTrailingTerminalContexts(prompt: string): ExtractedTermin promptText: prompt, contextCount: 0, previewTitle: null, + contexts: [], }; } const promptText = prompt.slice(0, match.index).replace(/\n+$/, ""); @@ -166,6 +174,7 @@ export function extractTrailingTerminalContexts(prompt: string): ExtractedTermin .map(({ header, body }) => (body.length > 0 ? `${header}\n${body}` : header)) .join("\n\n") : null, + contexts: parsedContexts, }; } @@ -176,11 +185,12 @@ export function deriveDisplayedUserMessageState(prompt: string): DisplayedUserMe copyText: prompt, contextCount: extractedContexts.contextCount, previewTitle: extractedContexts.previewTitle, + contexts: extractedContexts.contexts, }; } -function parseTerminalContextEntries(block: string): Array<{ header: string; body: string }> { - const entries: Array<{ header: string; body: string }> = []; +function parseTerminalContextEntries(block: string): ParsedTerminalContextEntry[] { + const entries: ParsedTerminalContextEntry[] = []; let current: { header: string; bodyLines: string[] } | null = null; const commitCurrent = () => {