Compare commits

..
Author SHA1 Message Date
an 96020ab773 fix(terminal): forward native touch gestures to mouse-aware apps
Fullscreen applications request wheel reports, while native touch drags
previously moved only local scrollback. Route gestures through the
existing terminal input path and preserve the requested modes on replay.

Co-authored-by: an <4361526@naver.com>
2026-10-24 04:52:34 +02:00
26 changed files with 582 additions and 424 deletions
+1 -1
View File
@@ -242,7 +242,7 @@ parentAgentId === thisAgent.id AND !archivedAt
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
@@ -99,16 +99,6 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expectSubagentRowVisible(page, child.id);
});
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
const childSheet = sheet(page, child.title);
await expectSheetAtSnap(page, child.title);
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
await childSheet.getByTestId("parent-agent-callout-open").click();
await expect(childSheet).toHaveCount(0);
await expectOpenTabCount(page, 1);
});
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await expectSheetAtSnap(page, child.title);
@@ -136,8 +136,7 @@ test.describe("real provider subagent timelines", () => {
panel.getByText("Start chatting with this agent...", { exact: true }),
).toHaveCount(0);
// A provider subagent has no composer; its callout is the way back to the parent.
await page.getByTestId("parent-agent-callout-open").click();
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
await expect(
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
).toBeVisible({ timeout: 60_000 });
@@ -125,29 +125,6 @@ test("opening an already-tabbed subagent reveals its left tab without a duplicat
}
});
test("a subagent's parent pill returns to its parent", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "subagent-parent-pill-" });
try {
const pair = await seedParentWithSubagent(workspace, {
parentTitle: "Refactor auth flow",
childTitle: "Write session tests",
});
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, { workspaceId: pair.workspaceId, agentId: pair.child.id });
const pill = page.getByTestId("composer-parent-agent-pill");
await expect(pill).toHaveAccessibleName("Back to Refactor auth flow");
await pill.click();
await expect(page.getByTestId(`workspace-tab-agent_${pair.parent.id}`)).toHaveAttribute(
"aria-selected",
"true",
);
} finally {
await workspace.cleanup();
}
});
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
const target = "linked.ts:3";
const workspace = await seedMockAgentWorkspace({
@@ -50,9 +50,9 @@ import {
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
routeTerminalGestureRelease,
routeTerminalScrollGesture,
type TerminalGestureIntent,
} from "../terminal/native-renderer/terminal-selection-gesture";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
@@ -95,6 +95,7 @@ interface NativeTerminalPanState {
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
intent: TerminalGestureIntent | null;
origin: { x: number; y: number };
}
interface NativeTerminalSelectionPress {
@@ -214,6 +215,7 @@ function NativeTerminalEmulator({
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
origin: { x: 0, y: 0 },
});
const inputModeTrackerRef = useRef(new TerminalInputModeTracker());
const lastInputModeStateRef = useRef<TerminalInputModeState>({
@@ -775,31 +777,33 @@ function NativeTerminalEmulator({
);
const handlePanResponderScroll = useCallback(
(currentDy: number) => {
const cellHeight = metricsRef.current?.cellHeight;
if (!cellHeight || cellHeight <= 0) {
(gesture: PanResponderGestureState) => {
const metrics = metricsRef.current;
const viewport = resolveSelectionViewport();
if (!metrics || metrics.cellHeight <= 0 || !viewport) {
return;
}
const panState = panStateRef.current;
const update = advanceTerminalScrollGesture({
const { state, route } = routeTerminalScrollGesture({
state: panState,
currentDy,
cellHeight,
currentDy: gesture.dy,
point: { x: panState.origin.x + gesture.dx, y: panState.origin.y + gesture.dy },
metrics,
viewport,
inputMode: inputModeTrackerRef.current,
});
panState.lastDy = update.state.lastDy;
panState.rowRemainder = update.state.rowRemainder;
panState.didScroll = update.state.didScroll;
const { rowDelta } = update;
if (rowDelta === 0) {
panState.lastDy = state.lastDy;
panState.rowRemainder = state.rowRemainder;
panState.didScroll = state.didScroll;
if (route.kind === "scrollback") {
applyScroll(route.direction, route.rows);
return;
}
if (rowDelta > 0) {
applyScroll("up", rowDelta);
return;
if (route.kind === "input") {
callbacksRef.current.onInput?.(route.data);
}
applyScroll("down", Math.abs(rowDelta));
},
[applyScroll],
[applyScroll, resolveSelectionViewport],
);
const panResponder = useMemo(
@@ -828,6 +832,10 @@ function NativeTerminalEmulator({
return intent === "scroll" || intent === "select";
},
onPanResponderGrant: (event) => {
const point = {
x: event.nativeEvent.locationX,
y: event.nativeEvent.locationY,
};
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
@@ -835,10 +843,7 @@ function NativeTerminalEmulator({
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const point = {
x: event.nativeEvent.locationX,
y: event.nativeEvent.locationY,
origin: point,
};
const pressStatus = selectionRangeRef.current ? "selecting" : "pressing";
selectionPressRef.current = {
@@ -875,7 +880,7 @@ function NativeTerminalEmulator({
}
if (panState.intent === "scroll") {
handlePanResponderScroll(gesture.dy);
handlePanResponderScroll(gesture);
return;
}
if (panState.didNavigate) {
@@ -903,7 +908,7 @@ function NativeTerminalEmulator({
if (intent === "scroll") {
selectionPressRef.current = null;
handlePanResponderScroll(gesture.dy);
handlePanResponderScroll(gesture);
return;
}
@@ -932,8 +937,9 @@ function NativeTerminalEmulator({
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
origin: { x: 0, y: 0 },
};
const releaseAction = resolveTerminalGestureReleaseAction({
const { action: releaseAction, mouseInput } = routeTerminalGestureRelease({
status: selectionPress?.status ?? "idle",
startedWithSelection: selectionPress?.startedWithSelection,
didScroll,
@@ -942,11 +948,18 @@ function NativeTerminalEmulator({
pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: terminalScreenRef.current?.scroll.mode ?? "following",
point: { x: selectionPress?.startX ?? 0, y: selectionPress?.startY ?? 0 },
metrics: metricsRef.current,
viewport: resolveSelectionViewport(),
inputMode: inputModeTrackerRef.current,
});
if (releaseAction === "select" && selectionPress) {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
return;
}
if (mouseInput !== null) {
callbacksRef.current.onInput?.(mouseInput);
}
if (releaseAction === "focus" || releaseAction === "clear") {
focusTerminalFromTap();
}
@@ -961,6 +974,7 @@ function NativeTerminalEmulator({
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
origin: { x: 0, y: 0 },
};
},
}),
@@ -969,6 +983,7 @@ function NativeTerminalEmulator({
clearSelectionLongPressTimeout,
focusTerminalFromTap,
handlePanResponderScroll,
resolveSelectionViewport,
updateSelectionAtPoint,
],
);
+1 -5
View File
@@ -276,11 +276,7 @@ export function ComposerTrackRow({
* knockout for marks that sit on top of an icon, and nothing sits under this one, so it comes off
* too — leaving every mark's visible edge on the same rail whatever state it is in.
*/
export function ComposerTrackMark({
bucket,
}: {
bucket: SidebarStateBucket | null;
}): ReactElement | null {
function ComposerTrackMark({ bucket }: { bucket: SidebarStateBucket | null }): ReactElement | null {
if (!bucket) {
return null;
}
-7
View File
@@ -1908,13 +1908,6 @@ export const ar: TranslationResources = {
backdrop: "خلفية القائمة",
},
subagents: {
parent: {
backToParent: "العودة إلى الوكيل الأصلي",
backTo: "العودة إلى {{label}}",
parent: "الأصل",
parentAgent: "الوكيل الأصلي",
openParent: "فتح الوكيل الأصلي",
},
title: "الوكلاء الفرعيون",
pillLabelOne: "وكيل فرعي واحد",
pillLabelMany: "{{count}} وكلاء فرعيين",
-7
View File
@@ -1933,13 +1933,6 @@ export const en = {
backdrop: "Menu backdrop",
},
subagents: {
parent: {
backToParent: "Back to parent",
backTo: "Back to {{label}}",
parent: "Parent",
parentAgent: "Parent agent",
openParent: "Open parent",
},
title: "Subagents",
pillLabelOne: "1 subagent",
pillLabelMany: "{{count}} subagents",
-7
View File
@@ -1956,13 +1956,6 @@ export const es: TranslationResources = {
backdrop: "Fondo del menú",
},
subagents: {
parent: {
backToParent: "Volver al padre",
backTo: "Volver a {{label}}",
parent: "Padre",
parentAgent: "Agente padre",
openParent: "Abrir padre",
},
title: "Subagentes",
pillLabelOne: "1 subagente",
pillLabelMany: "{{count}} subagentes",
-7
View File
@@ -1954,13 +1954,6 @@ export const fr: TranslationResources = {
backdrop: "Arrière-plan du menu",
},
subagents: {
parent: {
backToParent: "Retour au parent",
backTo: "Retour à {{label}}",
parent: "Parent",
parentAgent: "Agent parent",
openParent: "Ouvrir le parent",
},
title: "Sous-agents",
pillLabelOne: "1 sous-agent",
pillLabelMany: "{{count}} sous-agents",
-7
View File
@@ -1927,13 +1927,6 @@ export const ja: TranslationResources = {
backdrop: "メニューの背景",
},
subagents: {
parent: {
backToParent: "親に戻る",
backTo: "{{label}} に戻る",
parent: "親",
parentAgent: "親エージェント",
openParent: "親を開く",
},
title: "サブエージェント",
pillLabelOne: "サブエージェント 1 件",
pillLabelMany: "サブエージェント {{count}} 件",
-7
View File
@@ -1918,13 +1918,6 @@ export const ko: TranslationResources = {
backdrop: "메뉴 배경",
},
subagents: {
parent: {
backToParent: "상위 에이전트로 돌아가기",
backTo: "{{label}}(으)로 돌아가기",
parent: "상위",
parentAgent: "상위 에이전트",
openParent: "상위 에이전트 열기",
},
title: "하위 에이전트",
pillLabelOne: "하위 에이전트 1개",
pillLabelMany: "하위 에이전트 {{count}}개",
-7
View File
@@ -1941,13 +1941,6 @@ export const ptBR: TranslationResources = {
backdrop: "Fundo do menu",
},
subagents: {
parent: {
backToParent: "Voltar ao pai",
backTo: "Voltar para {{label}}",
parent: "Pai",
parentAgent: "Agente pai",
openParent: "Abrir pai",
},
title: "Subagentes",
pillLabelOne: "1 subagente",
pillLabelMany: "{{count}} subagentes",
-7
View File
@@ -1940,13 +1940,6 @@ export const ru: TranslationResources = {
backdrop: "Фон меню",
},
subagents: {
parent: {
backToParent: "Назад к родителю",
backTo: "Назад к {{label}}",
parent: "Родитель",
parentAgent: "Родительский агент",
openParent: "Открыть родителя",
},
title: "Субагенты",
pillLabelOne: "1 субагент",
pillLabelMany: "Субагентов: {{count}}",
-7
View File
@@ -1886,13 +1886,6 @@ export const zhCN: TranslationResources = {
backdrop: "菜单背景",
},
subagents: {
parent: {
backToParent: "返回父代理",
backTo: "返回 {{label}}",
parent: "父代理",
parentAgent: "父代理",
openParent: "打开父代理",
},
title: "Subagent",
pillLabelOne: "1 个 subagent",
pillLabelMany: "{{count}} 个 subagent",
+1 -12
View File
@@ -80,7 +80,6 @@ import {
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import {
@@ -1110,11 +1109,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
});
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
const parentAgentId = useParentAgentId(serverId, agentId);
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
const parentPillAgentId = showsComposer ? parentAgentId : null;
const hasVisibleAgentTracks = hasAgentTracks({
parentAgentId: parentPillAgentId,
subagentRows,
tasks,
archiveFinishedStatus: archiveFinishedSubagents.status,
@@ -1166,9 +1161,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
composerState,
],
);
const parentCallout = parentAgentId ? (
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
) : null;
const composerSection = showsComposer ? (
<RenderProfile id={`AgentComposerSection:${agentId}`}>
<AgentComposerSection
@@ -1187,9 +1179,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onMessageSent={handleMessageSent}
/>
</RenderProfile>
) : (
parentCallout
);
) : null;
const streamContent = (
<View style={animatedStaticStyles.content}>
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
@@ -1213,7 +1203,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
parentAgentId={parentPillAgentId}
subagentRows={subagentRows}
tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status}
+2 -14
View File
@@ -16,7 +16,6 @@ import {
useSubagentOpensInSheet,
type SubagentRow,
} from "@/subagents";
import { ParentAgentPill } from "@/subagents/parent";
import { SubagentsTrack } from "@/subagents/track";
import type { TodoEntry } from "@/types/stream";
import { navigateToAgent } from "@/utils/navigate-to-agent";
@@ -35,7 +34,6 @@ export const AgentTracks = memo(function AgentTracks({
serverId,
workspaceId,
agentId,
parentAgentId,
subagentRows,
tasks,
archiveFinishedStatus,
@@ -45,7 +43,6 @@ export const AgentTracks = memo(function AgentTracks({
serverId: string;
workspaceId: string;
agentId: string;
parentAgentId: string | null;
subagentRows: SubagentRow[];
tasks: TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
@@ -91,12 +88,8 @@ export const AgentTracks = memo(function AgentTracks({
[openSubagentTarget, serverId, workspaceId],
);
const handleOpenProviderSubagent = useCallback(
(ownerAgentId: string, subagentId: string) => {
openSubagentTarget({
kind: "provider_subagent",
parentAgentId: ownerAgentId,
subagentId,
});
(parentAgentId: string, subagentId: string) => {
openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId });
},
[openSubagentTarget],
);
@@ -115,7 +108,6 @@ export const AgentTracks = memo(function AgentTracks({
if (
!hasWorkspaceDiffStat &&
!hasAgentTracks({
parentAgentId,
subagentRows,
tasks,
archiveFinishedStatus,
@@ -127,7 +119,6 @@ export const AgentTracks = memo(function AgentTracks({
return (
<ComposerTrackBar>
{parentAgentId ? <ParentAgentPill serverId={serverId} parentAgentId={parentAgentId} /> : null}
<AgentTaskList tasks={tasks} />
<SubagentsTrack
serverId={serverId}
@@ -156,20 +147,17 @@ export const AgentTracks = memo(function AgentTracks({
});
export function hasAgentTracks({
parentAgentId,
subagentRows,
tasks,
archiveFinishedStatus,
hasPluginComposerPills = false,
}: {
parentAgentId: string | null;
subagentRows: readonly SubagentRow[];
tasks: readonly TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
hasPluginComposerPills?: boolean;
}): boolean {
return (
parentAgentId !== null ||
subagentRows.length > 0 ||
Boolean(tasks?.length) ||
archiveFinishedStatus.kind !== "idle" ||
@@ -16,7 +16,6 @@ import { usePaneContext } from "@/panels/pane-context";
import { PaneSubtitle } from "@/panels/pane-subtitle";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import { ParentAgentCallout } from "@/subagents/parent";
import { type SubagentRow, useSubagentsForParent } from "@/subagents/select";
import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet";
import { SubagentsTrack } from "@/subagents/track";
@@ -254,37 +253,33 @@ function ProviderSubagentPanel() {
return (
<View style={styles.container} testID="provider-subagent-panel">
{subtitle ? <PaneSubtitle text={subtitle} testID="provider-subagent-pane-subtitle" /> : null}
<View style={styles.stream}>
<AgentStreamView
agentId={streamId}
serverId={serverId}
context={streamContext}
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
turnPresentation={turnPresentation}
pendingPermissions={EMPTY_PERMISSIONS}
isAuthoritativeHistoryReady
onOpenWorkspaceFile={openFileInWorkspace}
readOnly
historyPagination={historyPagination}
bottomOverlayTailClearance={childTrackClearance.tail}
bottomOverlayControlClearance={childTrackClearance.controls}
/>
<ProviderSubagentChildTrack
serverId={serverId}
rows={childRows}
onOpenProviderSubagent={openProviderChild}
opensInSheet={opensInSheet}
/>
</View>
<ParentAgentCallout serverId={serverId} parentAgentId={target.parentAgentId} />
<AgentStreamView
agentId={streamId}
serverId={serverId}
context={streamContext}
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
turnPresentation={turnPresentation}
pendingPermissions={EMPTY_PERMISSIONS}
isAuthoritativeHistoryReady
onOpenWorkspaceFile={openFileInWorkspace}
readOnly
historyPagination={historyPagination}
bottomOverlayTailClearance={childTrackClearance.tail}
bottomOverlayControlClearance={childTrackClearance.controls}
/>
<ProviderSubagentChildTrack
serverId={serverId}
rows={childRows}
onOpenProviderSubagent={openProviderChild}
opensInSheet={opensInSheet}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: { flex: 1, minHeight: 0 },
stream: { flex: 1, minHeight: 0 },
unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
}));
@@ -1,116 +0,0 @@
import type { ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { CornerUpLeft } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { ComposerTrackMark } from "@/composer/tracks";
import { FOOTER_HEIGHT } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
import { useParentAgent, type ParentAgent } from "./use-parent-agent";
function ParentIcon({
icon: Icon,
size,
color = "",
}: {
icon: ParentAgent["icon"];
size: number;
color?: string;
}) {
return <Icon size={size} color={color} />;
}
const ThemedParentIcon = withUnistyles(ParentIcon);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
/** Stands in the composer's place on a subagent shown without one: who the parent is, and the way back. */
export function ParentAgentCallout({
serverId,
parentAgentId,
}: {
serverId: string;
parentAgentId: string;
}): ReactElement {
const { t } = useTranslation();
const parent = useParentAgent(serverId, parentAgentId);
return (
<View style={styles.rail}>
<View style={styles.callout} testID="parent-agent-callout">
<View style={styles.avatar}>
<ThemedParentIcon icon={parent.icon} size={18} uniProps={foregroundColorMapping} />
</View>
<View style={styles.body}>
<Text style={styles.eyebrow}>{t("subagents.parent.parent")}</Text>
<View style={styles.titleRow}>
<Text style={styles.title} numberOfLines={1}>
{parent.label ?? t("subagents.parent.parentAgent")}
</Text>
<ComposerTrackMark bucket={parent.statusBucket} />
</View>
</View>
<Button
size="sm"
variant="secondary"
leftIcon={CornerUpLeft}
onPress={parent.open}
testID="parent-agent-callout-open"
>
{t("subagents.parent.openParent")}
</Button>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
rail: {
minHeight: FOOTER_HEIGHT,
alignItems: "center",
width: "100%",
padding: theme.spacing[4],
},
callout: {
width: "100%",
maxWidth: theme.contentMaxWidth,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius["2xl"],
padding: theme.spacing[3],
paddingRight: { xs: theme.spacing[3], md: theme.spacing[4] },
},
avatar: {
width: 36,
height: 36,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
},
body: {
flex: 1,
minWidth: 0,
gap: theme.spacing[0.5],
},
eyebrow: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minWidth: 0,
},
title: {
flexShrink: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
}));
@@ -1,3 +0,0 @@
export { ParentAgentCallout } from "./callout";
export { ParentAgentPill } from "./pill";
export { useParentAgentId } from "./use-parent-agent";
@@ -1,79 +0,0 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { Text } from "react-native";
import { useTranslation } from "react-i18next";
import { ChevronLeft } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { PressHighlight } from "@/components/ui/press-highlight";
import { composerPillStyles } from "@/composer/pill-styles";
import { ComposerTrackMark } from "@/composer/tracks";
import type { Theme } from "@/styles/theme";
import { useParentAgent } from "./use-parent-agent";
const ThemedChevronLeft = withUnistyles(ChevronLeft);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
/**
* The way back from a subagent to the agent that started it. It leads the pill rail and names
* the parent rather than saying "parent": in a stack of subagents the title is what tells you
* where you will land.
*/
export function ParentAgentPill({
serverId,
parentAgentId,
}: {
serverId: string;
parentAgentId: string;
}): ReactElement {
const { t } = useTranslation();
const parent = useParentAgent(serverId, parentAgentId);
const [isHovered, setIsHovered] = useState(false);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const bodyStyle = useMemo(
() => [composerPillStyles.body, styles.body, isHovered && composerPillStyles.bodyActive],
[isHovered],
);
const labelStyle = useMemo(
() => [composerPillStyles.label, styles.label, isHovered && composerPillStyles.labelActive],
[isHovered],
);
const label = parent.label ?? t("subagents.parent.backToParent");
return (
<PressHighlight
testID="composer-parent-agent-pill"
accessibilityRole="button"
accessibilityLabel={t("subagents.parent.backTo", { label })}
onPress={parent.open}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={bodyStyle}
highlightStyle={composerPillStyles.bodyActive}
>
<ThemedChevronLeft
size={14}
uniProps={isHovered ? foregroundColorMapping : mutedColorMapping}
/>
<ComposerTrackMark bucket={parent.statusBucket} />
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
</PressHighlight>
);
}
const styles = StyleSheet.create((theme) => ({
// The chevron's box is wider than its stroke, so the pill pulls it toward the edge to keep the
// visible glyph on the same inset as other pills' leading marks.
body: {
flexShrink: 1,
minWidth: 0,
gap: theme.spacing[1.5],
paddingLeft: theme.spacing[2],
},
label: {
flexShrink: 1,
maxWidth: { xs: 140, md: 240 },
},
}));
@@ -1,38 +0,0 @@
import { useCallback } from "react";
import { useAgentDescriptor } from "@/agent-presentation";
import type { PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { navigateToAgent } from "@/utils/navigate-to-agent";
export interface ParentAgent {
/** The parent's title, or null while it has none — callers fall back to a generic name. */
label: string | null;
icon: PanelDescriptor["icon"];
statusBucket: SidebarStateBucket | null;
open: () => void;
}
/** The parent of a managed agent, or null for a root or detached agent. */
export function useParentAgentId(serverId: string, agentId: string): string | null {
return useSessionStore((state) => {
const session = state.sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
return agent?.parentAgentId ?? null;
});
}
/** The parent as its own tab presents it, and the way back to it. */
export function useParentAgent(serverId: string, parentAgentId: string): ParentAgent {
const descriptor = useAgentDescriptor({ kind: "agent", agentId: parentAgentId }, { serverId });
// The parent may live in another workspace; navigateToAgent opens it wherever it is.
const open = useCallback(() => {
navigateToAgent({ serverId, agentId: parentAgentId });
}, [parentAgentId, serverId]);
return {
label: descriptor.label || null,
icon: descriptor.icon,
statusBucket: descriptor.statusBucket,
open,
};
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
@@ -7,8 +8,64 @@ import {
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
routeTerminalGestureRelease,
routeTerminalScrollGesture,
type TerminalGestureReleaseRouteInput,
type TerminalScrollGestureRoute,
} from "./terminal-selection-gesture";
const PI_FULLSCREEN_MOUSE_ENABLE =
"\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
const PI_FULLSCREEN_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
function trackerFromOutput(output: string): TerminalInputModeTracker {
const tracker = new TerminalInputModeTracker();
tracker.feed(output);
return tracker;
}
function dragRoutes(input: {
inputMode: TerminalInputModeTracker;
dys: number[];
point?: { x: number; y: number };
}): TerminalScrollGestureRoute[] {
let state = { lastDy: 0, rowRemainder: 0, didScroll: false };
const routes: TerminalScrollGestureRoute[] = [];
for (const currentDy of input.dys) {
const update = routeTerminalScrollGesture({
state,
currentDy,
point: input.point ?? { x: 25, y: 50 },
metrics: { cellWidth: 8, cellHeight: 14 },
viewport: { rows: 30, cols: 40 },
inputMode: input.inputMode,
});
state = update.state;
routes.push(update.route);
}
return routes;
}
function tapRelease(
overrides: Partial<TerminalGestureReleaseRouteInput> = {},
): ReturnType<typeof routeTerminalGestureRelease> {
return routeTerminalGestureRelease({
status: "pressing",
startedWithSelection: false,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
point: { x: 25, y: 50 },
metrics: { cellWidth: 8, cellHeight: 14 },
viewport: { rows: 30, cols: 40 },
inputMode: trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE),
...overrides,
});
}
describe("native terminal gesture policy", () => {
describe("advanceTerminalScrollGesture", () => {
it("keeps consuming a slow drag after scroll intent arms below one row", () => {
@@ -351,4 +408,143 @@ describe("native terminal gesture policy", () => {
).toEqual("none");
});
});
describe("routeTerminalScrollGesture", () => {
it("sends SGR wheel reports at the touched cell while a fullscreen app tracks the mouse", () => {
const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
expect(dragRoutes({ inputMode, dys: [13, 28, 42, 14] })).toEqual([
{ kind: "none" },
{ kind: "input", data: "\x1b[<64;4;4M\x1b[<64;4;4M" },
{ kind: "input", data: "\x1b[<64;4;4M" },
{ kind: "input", data: "\x1b[<65;4;4M\x1b[<65;4;4M" },
]);
});
it("keeps ordinary shell drags in local scrollback", () => {
const inputMode = trackerFromOutput("\x1b[?2004h$ ls\r\n");
expect(dragRoutes({ inputMode, dys: [13, 28, 14] })).toEqual([
{ kind: "none" },
{ kind: "scrollback", direction: "up", rows: 2 },
{ kind: "scrollback", direction: "down", rows: 1 },
]);
});
it("returns drags to local scrollback after the app disables mouse tracking", () => {
const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
inputMode.feed(PI_FULLSCREEN_MOUSE_DISABLE);
expect(dragRoutes({ inputMode, dys: [28] })).toEqual([
{ kind: "scrollback", direction: "up", rows: 2 },
]);
});
it("keeps forwarding wheel input after a reconnect replays the daemon preamble", () => {
const daemon = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
const client = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
client.reset();
client.feed(daemon.getPreamble());
expect(dragRoutes({ inputMode: client, dys: [14] })).toEqual([
{ kind: "input", data: "\x1b[<64;4;4M" },
]);
});
it("keeps pixel reports integral at fractional cell boundaries", () => {
const inputMode = trackerFromOutput("\x1b[?1003h\x1b[?1016h");
const { route } = routeTerminalScrollGesture({
state: { lastDy: 0, rowRemainder: 0, didScroll: false },
currentDy: 15,
point: { x: 999, y: 999 },
metrics: { cellWidth: 8.25, cellHeight: 14.25 },
viewport: { rows: 3, cols: 3 },
inputMode,
});
expect(route).toEqual({ kind: "input", data: "\x1b[<64;25;43M" });
});
it("clamps wheel reports to the visible grid", () => {
const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
expect(dragRoutes({ inputMode, dys: [-14], point: { x: 999, y: -5 } })).toEqual([
{ kind: "input", data: "\x1b[<65;40;1M" },
]);
});
});
describe("routeTerminalGestureRelease", () => {
it("clicks the tapped cell and still focuses while a fullscreen app tracks the mouse", () => {
expect(tapRelease()).toEqual({
action: "focus",
mouseInput: "\x1b[<0;4;4M\x1b[<0;4;4m",
});
});
it("only focuses on an ordinary shell tap", () => {
expect(tapRelease({ inputMode: trackerFromOutput("\x1b[?2004h$ ls\r\n") })).toEqual({
action: "focus",
mouseInput: null,
});
});
it("sends only the press for X10 tracking", () => {
expect(tapRelease({ inputMode: trackerFromOutput("\x1b[?9h") })).toEqual({
action: "focus",
mouseInput: "\x1b[M $$",
});
});
it("keeps a long press as local selection without clicking", () => {
expect(tapRelease({ pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50 })).toEqual({
action: "select",
mouseInput: null,
});
});
it.each([
["moved beyond tap tolerance", { movedBeyondTapTolerance: true }],
["scrolled", { didScroll: true }],
])("never clicks after a drag that %s", (_name, overrides) => {
expect(tapRelease(overrides)).toEqual({ action: "none", mouseInput: null });
});
it("clears an existing selection without clicking", () => {
expect(tapRelease({ status: "selecting", startedWithSelection: true })).toEqual({
action: "clear",
mouseInput: null,
});
});
it("does not send live mouse input from a scrolled-back viewport", () => {
expect(tapRelease({ scrollMode: "scrolled" })).toEqual({
action: "focus",
mouseInput: null,
});
});
it("does not click before the grid is measured", () => {
expect(tapRelease({ metrics: null, viewport: null })).toEqual({
action: "focus",
mouseInput: null,
});
});
it("clamps clicks to the visible grid", () => {
expect(tapRelease({ point: { x: 999, y: -5 } })).toEqual({
action: "focus",
mouseInput: "\x1b[<0;40;1M\x1b[<0;40;1m",
});
});
it("keeps pixel clicks integral at fractional cell boundaries", () => {
expect(
tapRelease({
inputMode: trackerFromOutput("\x1b[?1000h\x1b[?1016h"),
point: { x: 999, y: 999 },
metrics: { cellWidth: 8.25, cellHeight: 14.25 },
viewport: { rows: 3, cols: 3 },
}),
).toEqual({ action: "focus", mouseInput: "\x1b[<0;25;43M\x1b[<0;25;43m" });
});
});
});
@@ -1,3 +1,9 @@
import type {
TerminalInputModeTracker,
TerminalMousePosition,
} from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
export type TerminalGestureStatus = "idle" | "pressing" | "selecting";
export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll";
export type TerminalGestureScrollMode = "following" | "scrolled";
@@ -33,8 +39,39 @@ export interface TerminalScrollGestureUpdate {
rowDelta: number;
}
export type TerminalScrollGestureRoute =
| { kind: "none" }
| { kind: "scrollback"; direction: "up" | "down"; rows: number }
| { kind: "input"; data: string };
export interface TerminalScrollGestureRouteInput {
state: TerminalScrollGestureState;
currentDy: number;
point: { x: number; y: number };
metrics: TerminalGridCellMetrics;
viewport: { rows: number; cols: number };
inputMode: Pick<TerminalInputModeTracker, "encodeWheel">;
}
export interface TerminalScrollGestureRouteUpdate {
state: TerminalScrollGestureState;
route: TerminalScrollGestureRoute;
}
export type TerminalGestureAction = "none" | "focus" | "select" | "clear";
export interface TerminalGestureReleaseRouteInput extends TerminalGestureReleaseInput {
point: { x: number; y: number };
metrics: TerminalGridCellMetrics | null;
viewport: { rows: number; cols: number } | null;
inputMode: Pick<TerminalInputModeTracker, "encodeLeftClick">;
}
export interface TerminalGestureReleaseRoute {
action: TerminalGestureAction;
mouseInput: string | null;
}
export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
export const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12;
@@ -78,6 +115,60 @@ export function advanceTerminalScrollGesture(input: {
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/** Clamps a touch point to the visible grid; pixel reports stay integral at fractional cell sizes. */
function resolveTerminalMousePosition(input: {
point: { x: number; y: number };
metrics: TerminalGridCellMetrics;
viewport: { rows: number; cols: number };
}): TerminalMousePosition {
const { cellWidth, cellHeight } = input.metrics;
const pixelX = clamp(
Math.floor(input.point.x),
0,
Math.ceil(input.viewport.cols * cellWidth) - 1,
);
const pixelY = clamp(
Math.floor(input.point.y),
0,
Math.ceil(input.viewport.rows * cellHeight) - 1,
);
return {
col: Math.floor(pixelX / cellWidth) + 1,
row: Math.floor(pixelY / cellHeight) + 1,
pixelX: pixelX + 1,
pixelY: pixelY + 1,
};
}
/** A drag scrolls local scrollback unless the foreground app asked for wheel reports. */
export function routeTerminalScrollGesture(
input: TerminalScrollGestureRouteInput,
): TerminalScrollGestureRouteUpdate {
const { state, rowDelta } = advanceTerminalScrollGesture({
state: input.state,
currentDy: input.currentDy,
cellHeight: input.metrics.cellHeight,
});
if (rowDelta === 0) {
return { state, route: { kind: "none" } };
}
const direction = rowDelta > 0 ? "up" : "down";
const rows = Math.abs(rowDelta);
const report = input.inputMode.encodeWheel({
direction,
...resolveTerminalMousePosition(input),
});
if (report === null) {
return { state, route: { kind: "scrollback", direction, rows } };
}
return { state, route: { kind: "input", data: report.repeat(rows) } };
}
export function resolveTerminalGestureReleaseAction(
input: TerminalGestureReleaseInput,
): TerminalGestureAction {
@@ -106,3 +197,19 @@ export function resolveTerminalGestureReleaseAction(
return "focus";
}
/**
* Only a short tap on the live screen clicks; long press, drags and selection clears stay local.
* The caller sends the click before focusing, so the keyboard resize cannot move the target cell.
*/
export function routeTerminalGestureRelease(
input: TerminalGestureReleaseRouteInput,
): TerminalGestureReleaseRoute {
const action = resolveTerminalGestureReleaseAction(input);
const { metrics, viewport } = input;
if (action !== "focus" || input.scrollMode !== "following" || !metrics || !viewport) {
return { action, mouseInput: null };
}
const position = resolveTerminalMousePosition({ point: input.point, metrics, viewport });
return { action, mouseInput: input.inputMode.encodeLeftClick(position) };
}
@@ -118,6 +118,111 @@ describe("TerminalInputModeTracker", () => {
expect(tracker.getPreamble()).toBe("");
});
const PI_FULLSCREEN_MOUSE_ENABLE =
"\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
const PI_FULLSCREEN_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
const wheelUpAt3x4 = { direction: "up" as const, col: 3, row: 4, pixelX: 30, pixelY: 80 };
it("encodes SGR wheel reports while a fullscreen app tracks the mouse", () => {
const tracker = new TerminalInputModeTracker();
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
expect(tracker.feed(PI_FULLSCREEN_MOUSE_ENABLE).changed).toBe(true);
expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;3;4M");
expect(tracker.encodeWheel({ ...wheelUpAt3x4, direction: "down" })).toBe("\x1b[<65;3;4M");
});
it("stops encoding wheel reports once the app disables mouse tracking", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed(PI_FULLSCREEN_MOUSE_ENABLE);
expect(tracker.feed(PI_FULLSCREEN_MOUSE_DISABLE).changed).toBe(true);
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
expect(tracker.getPreamble()).toBe("");
});
it("replays mouse tracking so a reconnected client keeps forwarding wheel input", () => {
const daemon = new TerminalInputModeTracker();
daemon.feed(PI_FULLSCREEN_MOUSE_ENABLE);
const client = new TerminalInputModeTracker();
client.feed(PI_FULLSCREEN_MOUSE_ENABLE);
client.reset();
expect(client.encodeWheel(wheelUpAt3x4)).toBeNull();
expect(daemon.getPreamble()).toBe("\x1b[?1003h\x1b[?1006h");
client.feed(daemon.getPreamble());
expect(client.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;3;4M");
});
it("encodes SGR pixel wheel reports with pixel coordinates", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[?1000h\x1b[?1016h");
expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;30;80M");
expect(tracker.getPreamble()).toBe("\x1b[?1000h\x1b[?1016h");
});
it("encodes default wheel reports only while every byte stays ASCII", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[?1000h");
expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[M`#$");
expect(tracker.encodeWheel({ ...wheelUpAt3x4, col: 96 })).toBeNull();
expect(tracker.encodeWheel({ ...wheelUpAt3x4, row: 96 })).toBeNull();
});
it("does not encode wheel reports for X10 press-only tracking", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[?9h\x1b[?1006h");
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
});
const tapAt3x4 = { col: 3, row: 4, pixelX: 30, pixelY: 80 };
it.each([
["no tracking", "", null],
["SGR press/release", PI_FULLSCREEN_MOUSE_ENABLE, "\x1b[<0;3;4M\x1b[<0;3;4m"],
["SGR pixel press/release", "\x1b[?1000h\x1b[?1016h", "\x1b[<0;30;80M\x1b[<0;30;80m"],
["default press and button-3 release", "\x1b[?1000h", "\x1b[M #$\x1b[M##$"],
["X10 default press only", "\x1b[?9h", "\x1b[M #$"],
["X10 SGR press only", "\x1b[?9h\x1b[?1006h", "\x1b[<0;3;4M"],
[
"tracking disabled again",
`${PI_FULLSCREEN_MOUSE_ENABLE}${PI_FULLSCREEN_MOUSE_DISABLE}`,
null,
],
])("encodes a left click with %s", (_name, output, expected) => {
const tracker = new TerminalInputModeTracker();
tracker.feed(output);
expect(tracker.encodeLeftClick(tapAt3x4)).toBe(expected);
});
it("does not encode a default left click that leaves ASCII", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[?1000h");
expect(tracker.encodeLeftClick({ ...tapAt3x4, col: 96 })).toBeNull();
});
it.each([["\x1bc"], ["\x1b", "c"]])(
"clears every tracked mode on a full reset split as %j",
(...chunks) => {
const tracker = new TerminalInputModeTracker();
tracker.feed(`\x1b[>7u\x1b[?2004h${PI_FULLSCREEN_MOUSE_ENABLE}`);
const results = chunks.map((chunk) => tracker.feed(chunk));
expect(results.at(-1)).toEqual({ changed: true, responses: [] });
expect(tracker.getPreamble()).toBe("");
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
},
);
it("ignores encoded key input sequences", () => {
const tracker = new TerminalInputModeTracker();
+107 -7
View File
@@ -10,6 +10,17 @@ export interface TerminalInputModeState {
bracketedPaste?: boolean;
}
export interface TerminalMousePosition {
col: number;
row: number;
pixelX: number;
pixelY: number;
}
export interface TerminalWheelInput extends TerminalMousePosition {
direction: "up" | "down";
}
export const DEFAULT_TERMINAL_INPUT_MODE_STATE: TerminalInputModeState = {
kittyKeyboardFlags: 0,
win32InputMode: false,
@@ -21,8 +32,20 @@ const ESC = String.fromCharCode(0x1b);
const APPLICATION_CURSOR_KEYS_MODE = 1;
const WIN32_INPUT_MODE = 9001;
const BRACKETED_PASTE_MODE = 2004;
const CSI_INPUT_MODE_SEQUENCE = new RegExp(
`${ESC}\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl]))`,
const X10_MOUSE_TRACKING_MODE = 9;
const MOUSE_TRACKING_MODES = new Set([X10_MOUSE_TRACKING_MODE, 1000, 1002, 1003]);
const SGR_MOUSE_ENCODING_MODE = 1006;
const SGR_PIXELS_MOUSE_ENCODING_MODE = 1016;
const MOUSE_ENCODING_MODES = new Set([SGR_MOUSE_ENCODING_MODE, SGR_PIXELS_MOUSE_ENCODING_MODE]);
const LEFT_BUTTON = 0;
const DEFAULT_RELEASE_BUTTON = 3;
const WHEEL_UP_BUTTON = 64;
const WHEEL_DOWN_BUTTON = 65;
const DEFAULT_MOUSE_ENCODING_OFFSET = 32;
const MAX_ASCII_CODE = 127;
const FULL_RESET = `${ESC}c`;
const INPUT_MODE_SEQUENCE = new RegExp(
`${ESC}(?:c|\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl])))`,
"g",
);
const INCOMPLETE_CSI_INPUT_MODE_SEQUENCE = new RegExp(`${ESC}\\[[<>=?]?[0-9;]*$`);
@@ -74,6 +97,8 @@ export class TerminalInputModeTracker {
private win32InputMode = false;
private applicationCursorKeys = false;
private bracketedPaste = false;
private mouseTrackingMode = 0;
private mouseEncodingMode = 0;
private readonly kittyKeyboardStack: number[] = [];
private pending = "";
@@ -89,13 +114,19 @@ export class TerminalInputModeTracker {
const responses: string[] = [];
let consumedUntil = 0;
CSI_INPUT_MODE_SEQUENCE.lastIndex = 0;
INPUT_MODE_SEQUENCE.lastIndex = 0;
for (;;) {
const match = CSI_INPUT_MODE_SEQUENCE.exec(text);
const match = INPUT_MODE_SEQUENCE.exec(text);
if (!match) {
break;
}
consumedUntil = CSI_INPUT_MODE_SEQUENCE.lastIndex;
consumedUntil = INPUT_MODE_SEQUENCE.lastIndex;
if (match[0] === FULL_RESET) {
changed = this.getPreamble().length > 0 || changed;
this.reset();
continue;
}
if (match[4]) {
changed = this.applyPrivateModeSequence(match[3] ?? "", match[4]) || changed;
@@ -108,10 +139,10 @@ export class TerminalInputModeTracker {
}
const tail = text.slice(consumedUntil);
const pendingStart = tail.lastIndexOf(`${ESC}[`);
const pendingStart = tail.lastIndexOf(ESC);
if (pendingStart >= 0) {
const pending = tail.slice(pendingStart);
if (INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
if (pending === ESC || INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
this.pending = pending;
}
}
@@ -124,6 +155,8 @@ export class TerminalInputModeTracker {
this.win32InputMode = false;
this.applicationCursorKeys = false;
this.bracketedPaste = false;
this.mouseTrackingMode = 0;
this.mouseEncodingMode = 0;
this.kittyKeyboardStack.length = 0;
this.pending = "";
}
@@ -159,9 +192,64 @@ export class TerminalInputModeTracker {
if (this.bracketedPaste) {
parts.push("\x1b[?2004h");
}
// The alternate screen is never replayed here: re-entering it would clear the restored screen.
if (this.mouseTrackingMode > 0) {
parts.push(`\x1b[?${this.mouseTrackingMode}h`);
}
if (this.mouseEncodingMode > 0) {
parts.push(`\x1b[?${this.mouseEncodingMode}h`);
}
return parts.join("");
}
/** Returns the wheel report the foreground app requested, or null when it does not track the wheel. */
encodeWheel(input: TerminalWheelInput): string | null {
const isWheelReported =
this.mouseTrackingMode > 0 && this.mouseTrackingMode !== X10_MOUSE_TRACKING_MODE;
if (!isWheelReported) {
return null;
}
const button = input.direction === "up" ? WHEEL_UP_BUTTON : WHEEL_DOWN_BUTTON;
return this.encodeMouseReport(button, input, "M");
}
/** Returns a left press and release, press only under X10, or null when the app does not track the mouse. */
encodeLeftClick(position: TerminalMousePosition): string | null {
if (this.mouseTrackingMode === 0) {
return null;
}
const press = this.encodeMouseReport(LEFT_BUTTON, position, "M");
if (press === null || this.mouseTrackingMode === X10_MOUSE_TRACKING_MODE) {
return press;
}
const isSgr = this.mouseEncodingMode !== 0;
const release = isSgr
? this.encodeMouseReport(LEFT_BUTTON, position, "m")
: this.encodeMouseReport(DEFAULT_RELEASE_BUTTON, position, "M");
return release === null ? null : `${press}${release}`;
}
private encodeMouseReport(
button: number,
position: TerminalMousePosition,
sgrFinal: "M" | "m",
): string | null {
if (this.mouseEncodingMode === SGR_PIXELS_MOUSE_ENCODING_MODE) {
return `\x1b[<${button};${position.pixelX};${position.pixelY}${sgrFinal}`;
}
if (this.mouseEncodingMode === SGR_MOUSE_ENCODING_MODE) {
return `\x1b[<${button};${position.col};${position.row}${sgrFinal}`;
}
// Input travels as UTF-8 text, so a default-encoded byte above ASCII would be re-encoded.
const codes = [button, position.col, position.row].map(
(value) => value + DEFAULT_MOUSE_ENCODING_OFFSET,
);
if (codes.some((code) => code > MAX_ASCII_CODE)) {
return null;
}
return `\x1b[M${String.fromCharCode(...codes)}`;
}
private applyKittyKeyboardSequence(
prefix: string,
params: string,
@@ -205,6 +293,18 @@ export class TerminalInputModeTracker {
const modes = parsePrivateModeParams(params);
let changed = false;
for (const mode of modes) {
const next = final === "h" ? mode : 0;
if (MOUSE_TRACKING_MODES.has(mode)) {
changed = this.mouseTrackingMode !== next || changed;
this.mouseTrackingMode = next;
}
if (MOUSE_ENCODING_MODES.has(mode)) {
changed = this.mouseEncodingMode !== next || changed;
this.mouseEncodingMode = next;
}
}
if (modes.has(WIN32_INPUT_MODE)) {
const previous = this.win32InputMode;
this.win32InputMode = final === "h";