Skip to content
Open
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
217 changes: 143 additions & 74 deletions apps/web/src/components/ChatView.tsx

Large diffs are not rendered by default.

141 changes: 140 additions & 1 deletion apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string>;
onSessionExited: () => void;
onAddTerminalContext: (selection: TerminalContextSelection) => void;
focusRequestId: number;
autoFocus: boolean;
resizeEpoch: number;
Expand All @@ -122,9 +130,11 @@ interface TerminalViewportProps {
function TerminalViewport({
threadId,
terminalId,
terminalLabel,
cwd,
runtimeEnv,
onSessionExited,
onAddTerminalContext,
focusRequestId,
autoFocus,
resizeEpoch,
Expand All @@ -134,12 +144,34 @@ function TerminalViewport({
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -323,6 +416,7 @@ function TerminalViewport({
}

if (event.type === "cleared") {
clearSelectionAction();
activeTerminal.clear();
activeTerminal.write("\u001bc");
return;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -430,7 +527,43 @@ function TerminalViewport({
window.cancelAnimationFrame(frame);
};
}, [drawerHeight, resizeEpoch, terminalId, threadId]);
return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />;
return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-[4px]">
{selectionAction ? (
<div
data-terminal-selection-action
className="absolute z-20"
style={{ left: `${selectionAction.left}px`, top: `${selectionAction.top}px` }}
>
<div className="rounded-full border border-border/80 bg-background/95 p-1 shadow-lg backdrop-blur">
<Button
type="button"
size="xs"
variant="secondary"
className="rounded-full px-3"
data-terminal-selection-action
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={() => {
onAddTerminalContext(selectionAction.selection);
terminalRef.current?.clearSelection();
setSelectionAction(null);
terminalRef.current?.focus();
}}
>
Add to chat
</Button>
</div>
</div>
) : null}
</div>
);
}

interface ThreadTerminalDrawerProps {
Expand All @@ -451,6 +584,7 @@ interface ThreadTerminalDrawerProps {
onActiveTerminalChange: (terminalId: string) => void;
onCloseTerminal: (terminalId: string) => void;
onHeightChange: (height: number) => void;
onAddTerminalContext: (selection: TerminalContextSelection) => void;
}

interface TerminalActionButtonProps {
Expand Down Expand Up @@ -500,6 +634,7 @@ export default function ThreadTerminalDrawer({
onActiveTerminalChange,
onCloseTerminal,
onHeightChange,
onAddTerminalContext,
}: ThreadTerminalDrawerProps) {
const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
const [resizeEpoch, setResizeEpoch] = useState(0);
Expand Down Expand Up @@ -796,9 +931,11 @@ export default function ThreadTerminalDrawer({
<TerminalViewport
threadId={threadId}
terminalId={terminalId}
terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
cwd={cwd}
{...(runtimeEnv ? { runtimeEnv } : {})}
onSessionExited={() => onCloseTerminal(terminalId)}
onAddTerminalContext={onAddTerminalContext}
focusRequestId={focusRequestId}
autoFocus={terminalId === resolvedActiveTerminalId}
resizeEpoch={resizeEpoch}
Expand All @@ -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}
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx
Original file line number Diff line number Diff line change
@@ -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<TerminalContextDraft>;
onRemove: (contextId: string) => void;
}

export function ComposerPendingTerminalContexts(props: ComposerPendingTerminalContextsProps) {
const { contexts, onRemove } = props;

if (contexts.length === 0) {
return null;
}

return (
<div className="mb-3 flex flex-wrap gap-2">
{contexts.map((context) => {
const label = formatTerminalContextLabel(context);
return (
<Tooltip key={context.id}>
<TooltipTrigger
render={
<div className="inline-flex max-w-full items-center gap-2 rounded-full border border-border/75 bg-background/80 px-3 py-1.5 text-xs text-foreground shadow-xs">
<span className="inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-accent/70 text-muted-foreground">
<TerminalIcon className="size-3" />
</span>
<span className="truncate font-medium">{label}</span>
<Button
type="button"
size="icon-xs"
variant="ghost"
className="-mr-1 size-5 rounded-full"
onClick={() => onRemove(context.id)}
aria-label={`Remove ${label}`}
>
<XIcon className="size-3" />
</Button>
</div>
}
/>
<TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">
{context.text}
</TooltipPopup>
</Tooltip>
);
})}
</div>
);
}
Loading
Loading