mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7db5385a96 |
@@ -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. 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. 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.
|
||||
|
||||
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,6 +99,16 @@ 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,7 +136,8 @@ test.describe("real provider subagent timelines", () => {
|
||||
panel.getByText("Start chatting with this agent...", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
|
||||
// A provider subagent has no composer; its callout is the way back to the parent.
|
||||
await page.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
@@ -125,6 +125,29 @@ 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,
|
||||
routeTerminalGestureRelease,
|
||||
routeTerminalScrollGesture,
|
||||
resolveTerminalGestureReleaseAction,
|
||||
type TerminalGestureIntent,
|
||||
} from "../terminal/native-renderer/terminal-selection-gesture";
|
||||
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
|
||||
@@ -95,7 +95,6 @@ interface NativeTerminalPanState {
|
||||
didNavigate: boolean;
|
||||
movedBeyondTapTolerance: boolean;
|
||||
intent: TerminalGestureIntent | null;
|
||||
origin: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface NativeTerminalSelectionPress {
|
||||
@@ -215,7 +214,6 @@ function NativeTerminalEmulator({
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
origin: { x: 0, y: 0 },
|
||||
});
|
||||
const inputModeTrackerRef = useRef(new TerminalInputModeTracker());
|
||||
const lastInputModeStateRef = useRef<TerminalInputModeState>({
|
||||
@@ -777,33 +775,31 @@ function NativeTerminalEmulator({
|
||||
);
|
||||
|
||||
const handlePanResponderScroll = useCallback(
|
||||
(gesture: PanResponderGestureState) => {
|
||||
const metrics = metricsRef.current;
|
||||
const viewport = resolveSelectionViewport();
|
||||
if (!metrics || metrics.cellHeight <= 0 || !viewport) {
|
||||
(currentDy: number) => {
|
||||
const cellHeight = metricsRef.current?.cellHeight;
|
||||
if (!cellHeight || cellHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
const panState = panStateRef.current;
|
||||
const { state, route } = routeTerminalScrollGesture({
|
||||
const update = advanceTerminalScrollGesture({
|
||||
state: panState,
|
||||
currentDy: gesture.dy,
|
||||
point: { x: panState.origin.x + gesture.dx, y: panState.origin.y + gesture.dy },
|
||||
metrics,
|
||||
viewport,
|
||||
inputMode: inputModeTrackerRef.current,
|
||||
currentDy,
|
||||
cellHeight,
|
||||
});
|
||||
panState.lastDy = state.lastDy;
|
||||
panState.rowRemainder = state.rowRemainder;
|
||||
panState.didScroll = state.didScroll;
|
||||
if (route.kind === "scrollback") {
|
||||
applyScroll(route.direction, route.rows);
|
||||
panState.lastDy = update.state.lastDy;
|
||||
panState.rowRemainder = update.state.rowRemainder;
|
||||
panState.didScroll = update.state.didScroll;
|
||||
const { rowDelta } = update;
|
||||
if (rowDelta === 0) {
|
||||
return;
|
||||
}
|
||||
if (route.kind === "input") {
|
||||
callbacksRef.current.onInput?.(route.data);
|
||||
if (rowDelta > 0) {
|
||||
applyScroll("up", rowDelta);
|
||||
return;
|
||||
}
|
||||
applyScroll("down", Math.abs(rowDelta));
|
||||
},
|
||||
[applyScroll, resolveSelectionViewport],
|
||||
[applyScroll],
|
||||
);
|
||||
|
||||
const panResponder = useMemo(
|
||||
@@ -832,10 +828,6 @@ 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,
|
||||
@@ -843,7 +835,10 @@ function NativeTerminalEmulator({
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
origin: point,
|
||||
};
|
||||
const point = {
|
||||
x: event.nativeEvent.locationX,
|
||||
y: event.nativeEvent.locationY,
|
||||
};
|
||||
const pressStatus = selectionRangeRef.current ? "selecting" : "pressing";
|
||||
selectionPressRef.current = {
|
||||
@@ -880,7 +875,7 @@ function NativeTerminalEmulator({
|
||||
}
|
||||
|
||||
if (panState.intent === "scroll") {
|
||||
handlePanResponderScroll(gesture);
|
||||
handlePanResponderScroll(gesture.dy);
|
||||
return;
|
||||
}
|
||||
if (panState.didNavigate) {
|
||||
@@ -908,7 +903,7 @@ function NativeTerminalEmulator({
|
||||
|
||||
if (intent === "scroll") {
|
||||
selectionPressRef.current = null;
|
||||
handlePanResponderScroll(gesture);
|
||||
handlePanResponderScroll(gesture.dy);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -937,9 +932,8 @@ function NativeTerminalEmulator({
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
origin: { x: 0, y: 0 },
|
||||
};
|
||||
const { action: releaseAction, mouseInput } = routeTerminalGestureRelease({
|
||||
const releaseAction = resolveTerminalGestureReleaseAction({
|
||||
status: selectionPress?.status ?? "idle",
|
||||
startedWithSelection: selectionPress?.startedWithSelection,
|
||||
didScroll,
|
||||
@@ -948,18 +942,11 @@ 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();
|
||||
}
|
||||
@@ -974,7 +961,6 @@ function NativeTerminalEmulator({
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
origin: { x: 0, y: 0 },
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -983,7 +969,6 @@ function NativeTerminalEmulator({
|
||||
clearSelectionLongPressTimeout,
|
||||
focusTerminalFromTap,
|
||||
handlePanResponderScroll,
|
||||
resolveSelectionViewport,
|
||||
updateSelectionAtPoint,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -276,7 +276,11 @@ 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.
|
||||
*/
|
||||
function ComposerTrackMark({ bucket }: { bucket: SidebarStateBucket | null }): ReactElement | null {
|
||||
export function ComposerTrackMark({
|
||||
bucket,
|
||||
}: {
|
||||
bucket: SidebarStateBucket | null;
|
||||
}): ReactElement | null {
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1908,6 +1908,13 @@ export const ar: TranslationResources = {
|
||||
backdrop: "خلفية القائمة",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "العودة إلى الوكيل الأصلي",
|
||||
backTo: "العودة إلى {{label}}",
|
||||
parent: "الأصل",
|
||||
parentAgent: "الوكيل الأصلي",
|
||||
openParent: "فتح الوكيل الأصلي",
|
||||
},
|
||||
title: "الوكلاء الفرعيون",
|
||||
pillLabelOne: "وكيل فرعي واحد",
|
||||
pillLabelMany: "{{count}} وكلاء فرعيين",
|
||||
|
||||
@@ -1933,6 +1933,13 @@ 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",
|
||||
|
||||
@@ -1956,6 +1956,13 @@ 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",
|
||||
|
||||
@@ -1954,6 +1954,13 @@ 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",
|
||||
|
||||
@@ -1927,6 +1927,13 @@ export const ja: TranslationResources = {
|
||||
backdrop: "メニューの背景",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "親に戻る",
|
||||
backTo: "{{label}} に戻る",
|
||||
parent: "親",
|
||||
parentAgent: "親エージェント",
|
||||
openParent: "親を開く",
|
||||
},
|
||||
title: "サブエージェント",
|
||||
pillLabelOne: "サブエージェント 1 件",
|
||||
pillLabelMany: "サブエージェント {{count}} 件",
|
||||
|
||||
@@ -1918,6 +1918,13 @@ export const ko: TranslationResources = {
|
||||
backdrop: "메뉴 배경",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "상위 에이전트로 돌아가기",
|
||||
backTo: "{{label}}(으)로 돌아가기",
|
||||
parent: "상위",
|
||||
parentAgent: "상위 에이전트",
|
||||
openParent: "상위 에이전트 열기",
|
||||
},
|
||||
title: "하위 에이전트",
|
||||
pillLabelOne: "하위 에이전트 1개",
|
||||
pillLabelMany: "하위 에이전트 {{count}}개",
|
||||
|
||||
@@ -1941,6 +1941,13 @@ 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",
|
||||
|
||||
@@ -1940,6 +1940,13 @@ export const ru: TranslationResources = {
|
||||
backdrop: "Фон меню",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Назад к родителю",
|
||||
backTo: "Назад к {{label}}",
|
||||
parent: "Родитель",
|
||||
parentAgent: "Родительский агент",
|
||||
openParent: "Открыть родителя",
|
||||
},
|
||||
title: "Субагенты",
|
||||
pillLabelOne: "1 субагент",
|
||||
pillLabelMany: "Субагентов: {{count}}",
|
||||
|
||||
@@ -1886,6 +1886,13 @@ export const zhCN: TranslationResources = {
|
||||
backdrop: "菜单背景",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "返回父代理",
|
||||
backTo: "返回 {{label}}",
|
||||
parent: "父代理",
|
||||
parentAgent: "父代理",
|
||||
openParent: "打开父代理",
|
||||
},
|
||||
title: "Subagent",
|
||||
pillLabelOne: "1 个 subagent",
|
||||
pillLabelMany: "{{count}} 个 subagent",
|
||||
|
||||
@@ -80,6 +80,7 @@ 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 {
|
||||
@@ -1109,7 +1110,11 @@ 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,
|
||||
@@ -1161,6 +1166,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
composerState,
|
||||
],
|
||||
);
|
||||
const parentCallout = parentAgentId ? (
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
|
||||
) : null;
|
||||
const composerSection = showsComposer ? (
|
||||
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
||||
<AgentComposerSection
|
||||
@@ -1179,7 +1187,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
</RenderProfile>
|
||||
) : null;
|
||||
) : (
|
||||
parentCallout
|
||||
);
|
||||
const streamContent = (
|
||||
<View style={animatedStaticStyles.content}>
|
||||
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
|
||||
@@ -1203,6 +1213,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
parentAgentId={parentPillAgentId}
|
||||
subagentRows={subagentRows}
|
||||
tasks={tasks}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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";
|
||||
@@ -34,6 +35,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
@@ -43,6 +45,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
parentAgentId: string | null;
|
||||
subagentRows: SubagentRow[];
|
||||
tasks: TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
@@ -88,8 +91,12 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
[openSubagentTarget, serverId, workspaceId],
|
||||
);
|
||||
const handleOpenProviderSubagent = useCallback(
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
(ownerAgentId: string, subagentId: string) => {
|
||||
openSubagentTarget({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: ownerAgentId,
|
||||
subagentId,
|
||||
});
|
||||
},
|
||||
[openSubagentTarget],
|
||||
);
|
||||
@@ -108,6 +115,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
if (
|
||||
!hasWorkspaceDiffStat &&
|
||||
!hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
@@ -119,6 +127,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
|
||||
return (
|
||||
<ComposerTrackBar>
|
||||
{parentAgentId ? <ParentAgentPill serverId={serverId} parentAgentId={parentAgentId} /> : null}
|
||||
<AgentTaskList tasks={tasks} />
|
||||
<SubagentsTrack
|
||||
serverId={serverId}
|
||||
@@ -147,17 +156,20 @@ 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,6 +16,7 @@ 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";
|
||||
@@ -253,33 +254,37 @@ function ProviderSubagentPanel() {
|
||||
return (
|
||||
<View style={styles.container} testID="provider-subagent-panel">
|
||||
{subtitle ? <PaneSubtitle text={subtitle} testID="provider-subagent-pane-subtitle" /> : null}
|
||||
<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 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} />
|
||||
</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" },
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ParentAgentCallout } from "./callout";
|
||||
export { ParentAgentPill } from "./pill";
|
||||
export { useParentAgentId } from "./use-parent-agent";
|
||||
@@ -0,0 +1,79 @@
|
||||
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 },
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,38 @@
|
||||
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,5 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
|
||||
|
||||
import {
|
||||
TERMINAL_GESTURE_LONG_PRESS_MS,
|
||||
@@ -8,64 +7,8 @@ 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", () => {
|
||||
@@ -408,143 +351,4 @@ 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,9 +1,3 @@
|
||||
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";
|
||||
@@ -39,39 +33,8 @@ 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;
|
||||
@@ -115,60 +78,6 @@ 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 {
|
||||
@@ -197,19 +106,3 @@ 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,111 +118,6 @@ 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();
|
||||
|
||||
|
||||
@@ -10,17 +10,6 @@ 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,
|
||||
@@ -32,20 +21,8 @@ const ESC = String.fromCharCode(0x1b);
|
||||
const APPLICATION_CURSOR_KEYS_MODE = 1;
|
||||
const WIN32_INPUT_MODE = 9001;
|
||||
const BRACKETED_PASTE_MODE = 2004;
|
||||
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])))`,
|
||||
const CSI_INPUT_MODE_SEQUENCE = new RegExp(
|
||||
`${ESC}\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl]))`,
|
||||
"g",
|
||||
);
|
||||
const INCOMPLETE_CSI_INPUT_MODE_SEQUENCE = new RegExp(`${ESC}\\[[<>=?]?[0-9;]*$`);
|
||||
@@ -97,8 +74,6 @@ export class TerminalInputModeTracker {
|
||||
private win32InputMode = false;
|
||||
private applicationCursorKeys = false;
|
||||
private bracketedPaste = false;
|
||||
private mouseTrackingMode = 0;
|
||||
private mouseEncodingMode = 0;
|
||||
private readonly kittyKeyboardStack: number[] = [];
|
||||
private pending = "";
|
||||
|
||||
@@ -114,19 +89,13 @@ export class TerminalInputModeTracker {
|
||||
const responses: string[] = [];
|
||||
let consumedUntil = 0;
|
||||
|
||||
INPUT_MODE_SEQUENCE.lastIndex = 0;
|
||||
CSI_INPUT_MODE_SEQUENCE.lastIndex = 0;
|
||||
for (;;) {
|
||||
const match = INPUT_MODE_SEQUENCE.exec(text);
|
||||
const match = CSI_INPUT_MODE_SEQUENCE.exec(text);
|
||||
if (!match) {
|
||||
break;
|
||||
}
|
||||
consumedUntil = INPUT_MODE_SEQUENCE.lastIndex;
|
||||
|
||||
if (match[0] === FULL_RESET) {
|
||||
changed = this.getPreamble().length > 0 || changed;
|
||||
this.reset();
|
||||
continue;
|
||||
}
|
||||
consumedUntil = CSI_INPUT_MODE_SEQUENCE.lastIndex;
|
||||
|
||||
if (match[4]) {
|
||||
changed = this.applyPrivateModeSequence(match[3] ?? "", match[4]) || changed;
|
||||
@@ -139,10 +108,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 (pending === ESC || INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
|
||||
if (INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
|
||||
this.pending = pending;
|
||||
}
|
||||
}
|
||||
@@ -155,8 +124,6 @@ export class TerminalInputModeTracker {
|
||||
this.win32InputMode = false;
|
||||
this.applicationCursorKeys = false;
|
||||
this.bracketedPaste = false;
|
||||
this.mouseTrackingMode = 0;
|
||||
this.mouseEncodingMode = 0;
|
||||
this.kittyKeyboardStack.length = 0;
|
||||
this.pending = "";
|
||||
}
|
||||
@@ -192,64 +159,9 @@ 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,
|
||||
@@ -293,18 +205,6 @@ 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";
|
||||
|
||||
Reference in New Issue
Block a user