Harden terminal streaming and worktree setup flow

This commit is contained in:
Mohamed Boudra
2026-02-15 19:05:27 +07:00
parent 75dc802a3a
commit 0103b4da96
21 changed files with 1297 additions and 98 deletions
@@ -611,7 +611,7 @@ export function AgentInputArea({
]
);
const cancelButton = isAgentRunning && !hasSendableContent ? (
const cancelButton = isAgentRunning && !hasSendableContent && !isProcessing ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
@@ -755,6 +755,7 @@ export function AgentInputArea({
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
onQueue={handleQueue}
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
onKeyPress={handleCommandKeyPress}
/>
</View>
+24 -16
View File
@@ -75,6 +75,8 @@ export interface MessageInputProps {
isAgentRunning?: boolean;
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */
onSubmitLoadingPress?: () => void;
/** Intercept key press events before default handling. Return true to prevent default. */
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean;
}
@@ -132,6 +134,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceAgentId,
isAgentRunning = false,
onQueue,
onSubmitLoadingPress,
onKeyPress: onKeyPressCallback,
},
ref
@@ -576,6 +579,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const hasImages = images.length > 0;
const hasSendableContent = value.trim().length > 0 || hasImages;
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
const canPressLoadingButton =
isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled =
disabled ||
(!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
const submitAccessibilityLabel = canPressLoadingButton
? "Interrupt agent"
: isAgentRunning
? "Send and interrupt"
: "Send message";
return (
<View style={styles.container} testID="message-input-root">
@@ -724,7 +737,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
</TooltipContent>
</Tooltip>
{rightContent}
{shouldShowSendButton && isAgentRunning && onQueue && (
{hasSendableContent && isAgentRunning && onQueue && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleQueueMessage}
@@ -748,22 +761,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
)}
{shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleSendMessage}
disabled={
isSubmitDisabled ||
isSubmitLoading ||
disabled
<TooltipTrigger
onPress={
canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage
}
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
accessibilityRole="button"
style={[
styles.sendButton,
(isSubmitDisabled ||
isSubmitLoading ||
disabled) &&
styles.buttonDisabled,
]}
disabled={isSendButtonDisabled}
accessibilityLabel={submitAccessibilityLabel}
accessibilityRole="button"
style={[
styles.sendButton,
isSendButtonDisabled && styles.buttonDisabled,
]}
>
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />
@@ -1032,17 +1032,19 @@ const styles = StyleSheet.create((theme) => ({
agentTitle: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
opacity: 0.85,
opacity: 1,
},
agentTitleInactive: {
color: theme.colors.foregroundMuted,
opacity: 0.8,
},
agentTitleSelected: {
opacity: 1,
},
relativeTimeText: {
color: theme.colors.foregroundMuted,
opacity: 0.78,
fontSize: theme.fontSize.xs,
},
shortcutBadge: {
@@ -1084,7 +1086,8 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.5,
},
agentMetaProjectName: {
color: theme.colors.foregroundMuted,
color: theme.colors.foreground,
opacity: 0.78,
fontSize: theme.fontSize.xs,
flexShrink: 1,
minWidth: 0,
@@ -1101,7 +1104,8 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.borderAccent,
},
agentMetaBranchText: {
color: theme.colors.foregroundMuted,
color: theme.colors.foreground,
opacity: 0.76,
fontSize: theme.fontSize.xs,
},
selectionBar: {
@@ -26,6 +26,7 @@ interface TerminalEmulatorProps {
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
}
@@ -47,6 +48,7 @@ export default function TerminalEmulator({
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onOutputChunkConsumed,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
}: TerminalEmulatorProps) {
@@ -108,19 +110,30 @@ export default function TerminalEmulator({
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
if (outputChunkSequence <= 0) {
return;
}
if (outputChunkText.length === 0) {
runtime.clear();
if (!runtime) {
onOutputChunkConsumed?.(outputChunkSequence);
return;
}
runtime.write({ text: outputChunkText });
}, [outputChunkSequence, outputChunkText]);
if (outputChunkText.length === 0) {
runtime.clear({
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
return;
}
runtime.write({
text: outputChunkText,
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
}, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]);
useEffect(() => {
if (focusRequestToken <= 0) {
+126 -15
View File
@@ -25,6 +25,7 @@ import {
TerminalOutputPump,
type TerminalOutputChunk,
} from "@/terminal/runtime/terminal-output-pump";
import { TerminalOutputDeliveryQueue } from "@/terminal/runtime/terminal-output-delivery-queue";
import {
TerminalStreamController,
type TerminalStreamControllerStatus,
@@ -72,6 +73,22 @@ type TerminalOutputChunkState = {
text: string;
};
type PendingTerminalInput =
| {
type: "data";
data: string;
}
| {
type: "key";
input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta?: boolean;
};
};
const EMPTY_MODIFIERS: ModifierState = {
ctrl: false,
shift: false,
@@ -121,6 +138,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
const outputDeliveryQueueRef = useRef<TerminalOutputDeliveryQueue | null>(null);
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
@@ -142,16 +160,33 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
);
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
useEffect(() => {
selectedTerminalIdRef.current = selectedTerminalId;
}, [selectedTerminalId]);
useEffect(() => {
const outputDeliveryQueue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
setSelectedOutputChunk(chunk);
},
});
outputDeliveryQueueRef.current = outputDeliveryQueue;
return () => {
if (outputDeliveryQueueRef.current === outputDeliveryQueue) {
outputDeliveryQueueRef.current = null;
}
outputDeliveryQueue.reset();
};
}, []);
useEffect(() => {
const outputPump = new TerminalOutputPump({
maxOutputChars: MAX_OUTPUT_CHARS,
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => {
setSelectedOutputChunk(chunk);
outputDeliveryQueueRef.current?.enqueue(chunk);
},
});
outputPumpRef.current = outputPump;
@@ -405,6 +440,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}, [client, handleStreamControllerStatus, isConnected]);
useEffect(() => {
outputDeliveryQueueRef.current?.reset();
pendingTerminalInputRef.current = [];
setSelectedOutputChunk({ sequence: 0, text: "" });
outputPumpRef.current?.setSelectedTerminal({
terminalId: selectedTerminalId,
});
@@ -422,6 +460,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
activeStream && activeStream.terminalId === selectedTerminalId
? activeStream.streamId
: null;
const getCurrentActiveStreamId = useCallback(() => {
return streamControllerRef.current?.getActiveStreamId() ?? null;
}, []);
const selectedTerminal = useMemo(
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
@@ -431,6 +472,50 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
createTerminalMutation.mutate();
}, [createTerminalMutation]);
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
const queue = pendingTerminalInputRef.current;
queue.push(entry);
if (queue.length > 512) {
queue.splice(0, queue.length - 512);
}
}, []);
const flushPendingTerminalInput = useCallback(() => {
if (!client) {
return;
}
const currentStreamId = getCurrentActiveStreamId();
if (currentStreamId === null) {
return;
}
const queue = pendingTerminalInputRef.current;
if (queue.length === 0) {
return;
}
const pending = queue.splice(0, queue.length);
terminalDebugLog({
scope: "terminal-pane",
event: "input:queue:flush",
details: {
activeStreamId: currentStreamId,
count: pending.length,
},
});
for (const entry of pending) {
if (entry.type === "data") {
client.sendTerminalStreamInput(currentStreamId, entry.data);
continue;
}
client.sendTerminalStreamKey(currentStreamId, entry.input);
}
}, [client, getCurrentActiveStreamId]);
useEffect(() => {
flushPendingTerminalInput();
}, [activeStreamId, flushPendingTerminalInput]);
const handleCloseTerminal = useCallback(
(terminalId: string) => {
if (
@@ -462,19 +547,30 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
meta?: boolean;
}
): boolean => {
if (!client || activeStreamId === null) {
const currentStreamId = getCurrentActiveStreamId();
if (!client || currentStreamId === null) {
terminalDebugLog({
scope: "terminal-pane",
event: "input:key:drop-no-stream",
event: "input:key:queue-no-stream",
details: {
key: input.key,
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
activeStreamId,
activeStreamId: currentStreamId,
},
});
return false;
enqueuePendingTerminalInput({
type: "key",
input: {
key: normalizeTerminalTransportKey(input.key),
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
meta: input.meta,
},
});
return true;
}
const normalizedKey = normalizeTerminalTransportKey(input.key);
@@ -486,10 +582,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
activeStreamId,
activeStreamId: currentStreamId,
},
});
client.sendTerminalStreamKey(activeStreamId, {
client.sendTerminalStreamKey(currentStreamId, {
key: normalizedKey,
ctrl: input.ctrl,
shift: input.shift,
@@ -498,7 +594,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
});
return true;
},
[client, activeStreamId]
[client, enqueuePendingTerminalInput, getCurrentActiveStreamId]
);
const handleTerminalData = useCallback(
@@ -506,13 +602,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
if (data.length === 0) {
return;
}
const currentStreamId = getCurrentActiveStreamId();
terminalDebugLog({
scope: "terminal-pane",
event: "input:data:received",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
activeStreamId,
activeStreamId: currentStreamId,
},
});
@@ -541,15 +638,19 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}
}
if (!client || activeStreamId === null) {
if (!client || currentStreamId === null) {
terminalDebugLog({
scope: "terminal-pane",
event: "input:data:drop-no-stream",
event: "input:data:queue-no-stream",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
},
});
enqueuePendingTerminalInput({
type: "data",
data,
});
return;
}
terminalDebugLog({
@@ -558,19 +659,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
activeStreamId,
activeStreamId: currentStreamId,
},
});
client.sendTerminalStreamInput(activeStreamId, data);
client.sendTerminalStreamInput(currentStreamId, data);
},
[
activeStreamId,
clearPendingModifiers,
client,
getCurrentActiveStreamId,
modifiers.alt,
modifiers.ctrl,
modifiers.shift,
sendTerminalKey,
enqueuePendingTerminalInput,
]
);
@@ -626,6 +728,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
clearPendingModifiers();
}, [clearPendingModifiers]);
const handleOutputChunkConsumed = useCallback((sequence: number) => {
outputDeliveryQueueRef.current?.consume({ sequence });
}, []);
const toggleModifier = useCallback(
(modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
@@ -808,6 +914,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onPendingModifiersConsumed={handlePendingModifiersConsumed}
onOutputChunkConsumed={handleOutputChunkConsumed}
pendingModifiers={modifiers}
focusRequestToken={focusRequestToken}
/>
@@ -819,7 +926,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
)}
{isAttaching ? (
<View style={styles.attachOverlay} testID="terminal-attach-loading">
<View
style={styles.attachOverlay}
pointerEvents="none"
testID="terminal-attach-loading"
>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
) : null}