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..059771380 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, @@ -107,12 +109,18 @@ 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; + terminalLabel: string; cwd: string; runtimeEnv?: Record; onSessionExited: () => void; + onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; resizeEpoch: number; @@ -122,9 +130,11 @@ interface TerminalViewportProps { function TerminalViewport({ threadId, terminalId, + terminalLabel, cwd, runtimeEnv, onSessionExited, + onAddTerminalContext, focusRequestId, autoFocus, resizeEpoch, @@ -134,12 +144,34 @@ 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<{ + left: number; + top: number; + selection: TerminalContextSelection; + } | null>(null); useEffect(() => { 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; @@ -165,6 +197,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: terminalLabelRef.current, + lineStart, + lineEnd, + text: normalizedText, + }, + }); + }; + const sendTerminalInput = async (data: string, fallbackError: string) => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; @@ -259,6 +330,26 @@ function TerminalViewport({ ); }); + const selectionDisposable = terminal.onSelectionChange(() => { + window.requestAnimationFrame(updateSelectionAction); + }); + + const handleMouseUp = (event: MouseEvent) => { + if (isTerminalSelectionActionTarget(event.target)) { + return; + } + selectionPointerRef.current = { x: event.clientX, y: event.clientY }; + window.requestAnimationFrame(updateSelectionAction); + }; + const handlePointerDown = (event: PointerEvent) => { + if (isTerminalSelectionActionTarget(event.target)) { + return; + } + clearSelectionAction(); + }; + mount.addEventListener("mouseup", handleMouseUp); + mount.addEventListener("pointerdown", handlePointerDown); + const themeObserver = new MutationObserver(() => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; @@ -310,11 +401,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 +416,7 @@ function TerminalViewport({ } if (event.type === "cleared") { + clearSelectionAction(); activeTerminal.clear(); activeTerminal.write("\u001bc"); return; @@ -383,7 +477,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 +527,43 @@ function TerminalViewport({ window.cancelAnimationFrame(frame); }; }, [drawerHeight, resizeEpoch, terminalId, threadId]); - return
; + return ( +
+ {selectionAction ? ( +
+
+ +
+
+ ) : null} +
+ ); } interface ThreadTerminalDrawerProps { @@ -451,6 +584,7 @@ interface ThreadTerminalDrawerProps { onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; + onAddTerminalContext: (selection: TerminalContextSelection) => void; } interface TerminalActionButtonProps { @@ -500,6 +634,7 @@ export default function ThreadTerminalDrawer({ onActiveTerminalChange, onCloseTerminal, onHeightChange, + onAddTerminalContext, }: ThreadTerminalDrawerProps) { const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const [resizeEpoch, setResizeEpoch] = useState(0); @@ -796,9 +931,11 @@ export default function ThreadTerminalDrawer({ onCloseTerminal(terminalId)} + onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} autoFocus={terminalId === resolvedActiveTerminalId} resizeEpoch={resizeEpoch} @@ -814,9 +951,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..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, @@ -33,9 +42,18 @@ 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 { + 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; @@ -337,6 +355,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ row.message.role === "user" && (() => { const userImages = row.message.attachments ?? []; + const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); + const terminalContexts = displayedUserMessage.contexts; const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); return (
@@ -378,14 +398,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}
)} - {row.message.text && ( -
-                    {row.message.text}
-                  
+ {(displayedUserMessage.visibleText.trim().length > 0 || + terminalContexts.length > 0) && ( + )}
- {row.message.text && } + {displayedUserMessage.copyText && ( + + )} {canRevertAgentWork && (