mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96020ab773 |
@@ -1415,14 +1415,12 @@ test("collapsing and expanding restores the real horizontal scroll offset", asyn
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
const settings = JSON.parse(localStorage.getItem(settingsKey) ?? "{}");
|
||||
if (settings.codeFontSize !== undefined) {
|
||||
if (localStorage.getItem(settingsKey)) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({
|
||||
...settings,
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
|
||||
@@ -143,7 +143,7 @@ async function configureOverviewToolCalls(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ test("measures an overview heading that becomes loading after its idle mount", a
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
|
||||
|
||||
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
|
||||
}, chatDisplay);
|
||||
}
|
||||
|
||||
async function seedAnsweredAgent(title: string) {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "turn-collapse-",
|
||||
title,
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Why does the chat stop following new output?",
|
||||
});
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
return agent;
|
||||
}
|
||||
|
||||
/** Chat rows between the latest turn's summary line and its reply, in document order. */
|
||||
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("[data-message-id]")];
|
||||
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
|
||||
const replyRow = rows.findLastIndex((row) =>
|
||||
row.querySelector("[data-testid=assistant-message]"),
|
||||
);
|
||||
return replyRow - summaryRow - 1;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Turn collapse", () => {
|
||||
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedAnsweredAgent("Turn collapse");
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||
await expect(summary).toHaveText(/^Wrote \d+ messages?, edited 1 file/);
|
||||
// Text chunks that stream back to back merge into one message, so the reply holds
|
||||
// at least the stream's last chunk.
|
||||
await expect(page.getByTestId("assistant-message").last()).toHaveText(
|
||||
/\(end of synthetic stream\)$/,
|
||||
);
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
|
||||
await expect(summary).toBeInViewport();
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -180,11 +180,6 @@ const test = daemonTest.extend<{
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
// New installs collapse finished turns; specs assert on the rows inside them, so they
|
||||
// start from the full chat unless they seed their own settings.
|
||||
if (!localStorage.getItem("@paseo:app-settings")) {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
|
||||
}
|
||||
},
|
||||
{
|
||||
daemon: testDaemon,
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function withTimelinePlugin(
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -63,9 +63,8 @@ export async function seedSidebarFooterPreferences(
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ key, sidebarFooterItems }) => {
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
if (settings.sidebarFooterItems) return;
|
||||
localStorage.setItem(key, JSON.stringify({ ...settings, sidebarFooterItems }));
|
||||
if (localStorage.getItem(key)) return;
|
||||
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
|
||||
},
|
||||
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
|
||||
);
|
||||
|
||||
@@ -24,7 +24,6 @@ function viewport(root: HTMLElement, capture: Capture) {
|
||||
const handle: StreamViewportHandle = {
|
||||
scrollToBottom() {},
|
||||
prepareForViewportChange() {},
|
||||
holdReadingPosition() {},
|
||||
scrollToMessage(_messageId, occurrence) {
|
||||
if (occurrence) capture.occurrence = occurrence;
|
||||
},
|
||||
|
||||
@@ -265,7 +265,6 @@ describe("useChatOutline", () => {
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const viewportRef = { current: viewport };
|
||||
@@ -317,7 +316,6 @@ describe("useChatOutline", () => {
|
||||
current: {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
},
|
||||
};
|
||||
@@ -373,7 +371,6 @@ describe("useChatOutline", () => {
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const livePrompt = {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/tim
|
||||
import type { InstalledPlugin } from "@/plugins/types";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
getStreamItemMessageId,
|
||||
hydrateStreamState,
|
||||
type StreamItem,
|
||||
type ToolCallItem,
|
||||
@@ -14,7 +13,7 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
|
||||
const runtime = {
|
||||
paseo: {},
|
||||
@@ -98,11 +97,7 @@ function pluginData(items: StreamItem[]): unknown[] {
|
||||
return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : []));
|
||||
}
|
||||
|
||||
const presentationOptions = {
|
||||
display: "collapseToolCalls" as const,
|
||||
expandedTurnHostIds: new Set<string>(),
|
||||
isTurnActive: true,
|
||||
};
|
||||
const presentationOptions = { level: "overview" as const, isTurnActive: true };
|
||||
|
||||
function streamHarness(transform?: TimelineItemTransform) {
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ChatDisplay } from "@/hooks/use-settings";
|
||||
import type { TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
import {
|
||||
@@ -6,32 +5,23 @@ import {
|
||||
projectToolCallDetailLevel,
|
||||
type PreparedToolCallHistory,
|
||||
type ToolCallDetailLevel,
|
||||
type ToolCallDetailProjection,
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
import type { StreamItem, UserMessageItem } from "@/types/stream";
|
||||
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
|
||||
|
||||
interface PresentationInput {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
transform: TimelineItemTransform | undefined;
|
||||
display: ChatDisplay;
|
||||
/** User messages whose collapsed turn the reader opened. */
|
||||
expandedTurnHostIds: ReadonlySet<string>;
|
||||
level: ToolCallDetailLevel;
|
||||
isTurnActive: boolean;
|
||||
}
|
||||
|
||||
export interface StreamPresentation extends ToolCallDetailProjection {
|
||||
turns: Omit<TurnCollapse, "tail">;
|
||||
/** Find and scrolling address a plugin row by the source it replaces. */
|
||||
export function getStreamItemMessageId(item: StreamItem): string {
|
||||
if (item.kind === "plugin") return item.sourceId ?? item.id;
|
||||
return item.id;
|
||||
}
|
||||
|
||||
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
|
||||
tail: [],
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
});
|
||||
|
||||
/** Source messages reach plugins before display projection. */
|
||||
export function createStreamPresentation() {
|
||||
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
|
||||
@@ -57,47 +47,12 @@ export function createStreamPresentation() {
|
||||
let preparedTail: StreamItem[] | undefined;
|
||||
let preparedLevel: ToolCallDetailLevel | undefined;
|
||||
let preparedHistory: PreparedToolCallHistory | null = null;
|
||||
let collapsedSource: StreamItem[] | undefined;
|
||||
let collapsedIsLatestTurnOpen = false;
|
||||
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
|
||||
let collapsed: TurnCollapse | undefined;
|
||||
|
||||
function presentRows(item: StreamItem): StreamItem[] {
|
||||
return [item.kind === "user_message" ? presentUserMessage(item) : item];
|
||||
}
|
||||
|
||||
function collapse(
|
||||
projection: ToolCallDetailProjection,
|
||||
input: PresentationInput,
|
||||
): StreamPresentation {
|
||||
if (input.display !== "collapseTurns") {
|
||||
return { ...projection, turns: NO_COLLAPSED_TURNS };
|
||||
}
|
||||
// Live text updates reach the head only, so the folded history is reused until the
|
||||
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
|
||||
const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
|
||||
if (
|
||||
!collapsed ||
|
||||
collapsedSource !== projection.tail ||
|
||||
collapsedIsLatestTurnOpen !== isLatestTurnOpen ||
|
||||
collapsedExpandedHostIds !== input.expandedTurnHostIds
|
||||
) {
|
||||
collapsed = collapseTurns({
|
||||
tail: projection.tail,
|
||||
isLatestTurnOpen,
|
||||
expandedHostIds: input.expandedTurnHostIds,
|
||||
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
|
||||
});
|
||||
collapsedSource = projection.tail;
|
||||
collapsedIsLatestTurnOpen = isLatestTurnOpen;
|
||||
collapsedExpandedHostIds = input.expandedTurnHostIds;
|
||||
}
|
||||
const { tail, ...turns } = collapsed;
|
||||
return { ...projection, tail, turns };
|
||||
}
|
||||
|
||||
return (input: PresentationInput): StreamPresentation => {
|
||||
const level: ToolCallDetailLevel = input.display === "full" ? "detailed" : "overview";
|
||||
return (input: PresentationInput) => {
|
||||
// Retained history is not reprojected or regrouped on each live text update.
|
||||
if (historySource !== input.tail || historyTransform !== input.transform) {
|
||||
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
|
||||
@@ -111,20 +66,17 @@ export function createStreamPresentation() {
|
||||
const blocks = presentRows(item);
|
||||
head.push(...blocks);
|
||||
}
|
||||
if (preparedTail !== historyRows || preparedLevel !== level) {
|
||||
preparedHistory = prepareToolCallHistory(level, historyRows);
|
||||
if (preparedTail !== historyRows || preparedLevel !== input.level) {
|
||||
preparedHistory = prepareToolCallHistory(input.level, historyRows);
|
||||
preparedTail = historyRows;
|
||||
preparedLevel = level;
|
||||
preparedLevel = input.level;
|
||||
}
|
||||
return collapse(
|
||||
projectToolCallDetailLevel({
|
||||
level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
}),
|
||||
input,
|
||||
);
|
||||
return projectToolCallDetailLevel({
|
||||
level: input.level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,7 +361,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
bottomAnchorController.prepareForStickyViewportChange();
|
||||
markNativeViewportSettling();
|
||||
},
|
||||
holdReadingPosition: () => bottomAnchorController.detachByUser(),
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
return () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type HistoryStartSettleScheduler,
|
||||
} from "./history-start-settle-scheduler";
|
||||
import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
import { useScrollToMessage } from "./use-scroll-to-message.web";
|
||||
|
||||
interface CreateWebStreamStrategyInput {
|
||||
@@ -1173,11 +1174,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
},
|
||||
holdReadingPosition: () => {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0);
|
||||
},
|
||||
scrollToMessage,
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
@@ -1190,7 +1186,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [
|
||||
cancelPendingStickToBottom,
|
||||
forceStickToBottom,
|
||||
readingAnchor,
|
||||
scheduleStickToBottom,
|
||||
scrollToMessage,
|
||||
viewportRef,
|
||||
|
||||
@@ -55,8 +55,6 @@ export interface StreamViewportHandle {
|
||||
focus?: () => void;
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
/** Stop following output so the rows on screen stay put while rows below them open or close. */
|
||||
holdReadingPosition: () => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem, ToolCallItem } from "@/types/stream";
|
||||
import { collapseTurns, type CollapseTurnsInput } from "./model";
|
||||
|
||||
const at = new Date("2026-01-01T00:00:00.000Z");
|
||||
|
||||
function user(id: string): StreamItem {
|
||||
return { kind: "user_message", id, text: id, timestamp: at };
|
||||
}
|
||||
|
||||
function reply(id: string): StreamItem {
|
||||
return { kind: "assistant_message", id, text: id, timestamp: at };
|
||||
}
|
||||
|
||||
function thought(id: string): StreamItem {
|
||||
return { kind: "thought", id, text: id, timestamp: at, status: "ready" };
|
||||
}
|
||||
|
||||
function shell(id: string): ToolCallItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: at,
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: id,
|
||||
name: "Bash",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "shell", command: "ls" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {}) {
|
||||
return collapseTurns({
|
||||
tail,
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
const ids = (items: StreamItem[]) => items.map((item) => item.id);
|
||||
|
||||
describe("collapseTurns", () => {
|
||||
it("folds a finished turn's work into its user message and keeps the reply", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
thought("t1"),
|
||||
reply("early"),
|
||||
shell("c1"),
|
||||
shell("c2"),
|
||||
reply("final"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "final"]);
|
||||
expect(result.turnsByHostId.get("u1")).toEqual({
|
||||
hostId: "u1",
|
||||
expanded: false,
|
||||
summary: {
|
||||
messageCount: 1,
|
||||
tools: expect.objectContaining({ commandCount: 2 }),
|
||||
},
|
||||
});
|
||||
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
|
||||
});
|
||||
|
||||
it("shows an expanded turn's work as nested rows between the prompt and the reply", () => {
|
||||
const result = collapse([user("u1"), thought("t1"), shell("c1"), reply("final")], {
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
});
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
|
||||
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
|
||||
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the latest turn alone while it is still producing output", () => {
|
||||
const tail = [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")];
|
||||
|
||||
const result = collapse(tail, { isLatestTurnOpen: true });
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
shell("c1"),
|
||||
reply("r1"),
|
||||
shell("after"),
|
||||
user("u2"),
|
||||
reply("r2"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("does not fold a turn that only thought before its reply", () => {
|
||||
const tail = [user("u1"), thought("t1"), reply("r1")];
|
||||
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
|
||||
const tail = [shell("c0"), reply("r0"), user("u1"), shell("c1")];
|
||||
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("counts every call a tool-call group row stands for", () => {
|
||||
const group = [shell("c1"), shell("c2"), shell("c3")];
|
||||
const result = collapse([user("u1"), group[0]!, reply("final")], {
|
||||
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
|
||||
});
|
||||
|
||||
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
|
||||
tools: { commandCount: 3 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream";
|
||||
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
|
||||
|
||||
export interface TurnSummary {
|
||||
messageCount: number;
|
||||
tools: OverviewSummary;
|
||||
}
|
||||
|
||||
export interface CollapsedTurn {
|
||||
/** The user message the turn answers. Its row carries the turn's summary line. */
|
||||
hostId: string;
|
||||
expanded: boolean;
|
||||
summary: TurnSummary;
|
||||
}
|
||||
|
||||
export interface TurnCollapse {
|
||||
tail: StreamItem[];
|
||||
turnsByHostId: ReadonlyMap<string, CollapsedTurn>;
|
||||
/** Rows of expanded turns, which render inside the turn's indent rail. */
|
||||
nestedItemIds: ReadonlySet<string>;
|
||||
/** The turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export interface CollapseTurnsInput {
|
||||
tail: StreamItem[];
|
||||
/** The latest turn is still producing output, so it stays unfolded. */
|
||||
isLatestTurnOpen: boolean;
|
||||
expandedHostIds: ReadonlySet<string>;
|
||||
/** The calls a tool-call group row stands for; undefined for a single call. */
|
||||
getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined;
|
||||
}
|
||||
|
||||
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
|
||||
const EMPTY_IDS: ReadonlySet<string> = new Set();
|
||||
const EMPTY_HOSTS: ReadonlyMap<string, string> = new Map();
|
||||
|
||||
function summarizeTurn(
|
||||
items: readonly StreamItem[],
|
||||
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
|
||||
): TurnSummary {
|
||||
const calls: ToolCallItem[] = [];
|
||||
let messageCount = 0;
|
||||
for (const item of items) {
|
||||
if (item.kind === "assistant_message") messageCount += 1;
|
||||
else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
||||
}
|
||||
return { messageCount, tools: summarizeToolCalls(calls) };
|
||||
}
|
||||
|
||||
function hasWork(summary: TurnSummary): boolean {
|
||||
return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
|
||||
}
|
||||
|
||||
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
|
||||
for (let index = end - 1; index >= start; index -= 1) {
|
||||
if (tail[index]?.kind === "assistant_message") return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds each answered turn into its user message: everything the agent did before its last
|
||||
* message collapses into one summary line, and that message stays in the chat. A turn folds
|
||||
* only once it is complete, its user message is loaded, and it sent messages or called tools
|
||||
* before its reply. Thoughts fold with the turn but are not counted.
|
||||
*/
|
||||
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const { tail } = input;
|
||||
const output: StreamItem[] = [];
|
||||
const turnsByHostId = new Map<string, CollapsedTurn>();
|
||||
const nestedItemIds = new Set<string>();
|
||||
const hostIdByFoldedMessageId = new Map<string, string>();
|
||||
|
||||
let runStart = 0;
|
||||
const flushRun = (runEnd: number) => {
|
||||
const host = tail[runStart - 1];
|
||||
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
|
||||
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
|
||||
if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
const folded = tail.slice(runStart, replyIndex);
|
||||
const summary = summarizeTurn(folded, input.getGroupedCalls);
|
||||
if (!hasWork(summary)) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
const expanded = input.expandedHostIds.has(host.id);
|
||||
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
|
||||
for (const item of folded) {
|
||||
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
|
||||
if (expanded) nestedItemIds.add(item.id);
|
||||
}
|
||||
if (expanded) output.push(...folded);
|
||||
output.push(...tail.slice(replyIndex, runEnd));
|
||||
};
|
||||
|
||||
for (let index = 0; index < tail.length; index += 1) {
|
||||
if (tail[index]?.kind !== "user_message") continue;
|
||||
flushRun(index);
|
||||
output.push(tail[index]!);
|
||||
runStart = index + 1;
|
||||
}
|
||||
flushRun(tail.length);
|
||||
|
||||
if (turnsByHostId.size === 0) {
|
||||
return {
|
||||
tail,
|
||||
turnsByHostId: EMPTY_TURNS,
|
||||
nestedItemIds: EMPTY_IDS,
|
||||
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tail: output.length === tail.length ? tail : output,
|
||||
turnsByHostId,
|
||||
nestedItemIds,
|
||||
hostIdByFoldedMessageId,
|
||||
};
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Summary } from "lucide-react-native";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
|
||||
import type { CollapsedTurn } from "./model";
|
||||
|
||||
/**
|
||||
* The row a collapsed turn leaves under its user message, styled like a tool-call group.
|
||||
* Pressing it opens the turn's rows beneath it.
|
||||
*/
|
||||
export const TurnSummaryLine = memo(function TurnSummaryLine({
|
||||
turn,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
turn: CollapsedTurn;
|
||||
onExpandedChange: (hostId: string, expanded: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { messageCount, tools } = turn.summary;
|
||||
const messageParts = useMemo(
|
||||
() =>
|
||||
messageCount > 0
|
||||
? [
|
||||
t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, {
|
||||
count: messageCount,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
[messageCount, t],
|
||||
);
|
||||
const label = useOverviewSummaryLabel(tools, messageParts);
|
||||
const toggle = useCallback(
|
||||
() => onExpandedChange(turn.hostId, !turn.expanded),
|
||||
[onExpandedChange, turn.expanded, turn.hostId],
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="turn-summary"
|
||||
label={label}
|
||||
icon={Summary}
|
||||
isExpanded={turn.expanded}
|
||||
onToggle={toggle}
|
||||
borderlessWhenExpanded
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
|
||||
import type { ScrollToMessageOccurrence } from "./strategy";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
|
||||
// Callers reveal a message, not a row: an assistant message is several block rows and
|
||||
// the window has to open at the first of them.
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
type InlinePathTarget,
|
||||
} from "@/components/message";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
import type { TurnPresentation } from "@/timeline/turn-liveness";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
@@ -62,10 +62,8 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { QuestionFormCard } from "@/components/question-form-card";
|
||||
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
|
||||
import { IndentRail } from "@/components/indent-rail";
|
||||
import { TurnSummaryLine } from "./turn-collapse/summary-line";
|
||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
@@ -150,16 +148,9 @@ function renderPendingPermissionsNode(input: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a row sits in an opened turn: its summary line's row, a row inside the turn, or the
|
||||
* turn's last row. The rail runs through the gaps between them so the turn reads as one block.
|
||||
*/
|
||||
type TurnIndent = "none" | "openedHost" | "nested" | "lastNested";
|
||||
|
||||
function renderStreamItemWithTurnFooter(input: {
|
||||
content: ReactNode;
|
||||
layoutItem: StreamLayoutItem;
|
||||
turnIndent: TurnIndent;
|
||||
strategy: TurnContentStrategy;
|
||||
supportsTimelineCursor: boolean;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
@@ -180,7 +171,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
/>
|
||||
) : null;
|
||||
const content = (
|
||||
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow} turnIndent={input.turnIndent}>
|
||||
<StreamItemWrapper itemId={input.layoutItem.item.id} gapBelow={input.layoutItem.gapBelow}>
|
||||
{input.content}
|
||||
</StreamItemWrapper>
|
||||
);
|
||||
@@ -342,7 +333,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const chatDisplay = useSettings((settings) => settings.chatDisplay);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
|
||||
const contentMaxWidth = useSettings(resolveContentMaxWidth);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
@@ -366,7 +357,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||
@@ -431,7 +421,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setIsNearBottom(true);
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
setExpandedToolCallGroupIds(new Set());
|
||||
setExpandedTurnHostIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const isActive = useRetainedPanelActive();
|
||||
@@ -523,8 +512,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
tail: effectiveStreamItems,
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
transform: transformTimelineItem,
|
||||
display: chatDisplay,
|
||||
expandedTurnHostIds,
|
||||
level: toolCallDetailLevel,
|
||||
isTurnActive,
|
||||
}),
|
||||
[
|
||||
@@ -532,8 +520,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
effectiveStreamItems,
|
||||
effectiveStreamHead,
|
||||
transformTimelineItem,
|
||||
chatDisplay,
|
||||
expandedTurnHostIds,
|
||||
toolCallDetailLevel,
|
||||
isTurnActive,
|
||||
],
|
||||
);
|
||||
@@ -664,43 +651,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[streamRenderStrategy],
|
||||
);
|
||||
|
||||
const setTurnExpanded = useCallback((hostId: string, expanded: boolean) => {
|
||||
// A turn opens below its summary line; keep that line under the reader's pointer.
|
||||
viewportRef.current?.holdReadingPosition();
|
||||
setExpandedTurnHostIds((previous) => {
|
||||
if (previous.has(hostId) === expanded) return previous;
|
||||
const next = new Set(previous);
|
||||
if (expanded) {
|
||||
next.add(hostId);
|
||||
} else {
|
||||
next.delete(hostId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
// Chat find reveals a match inside a collapsed turn by opening the turn first.
|
||||
const revealLoadedMessage = useStableEvent((messageId: string): boolean => {
|
||||
const hostId = presentation.turns.hostIdByFoldedMessageId.get(messageId);
|
||||
if (hostId && !expandedTurnHostIds.has(hostId)) {
|
||||
setTurnExpanded(hostId, true);
|
||||
return true;
|
||||
}
|
||||
return revealLoadedHistory(messageId);
|
||||
});
|
||||
// Read through stable events so a collapse rerun does not change the renderer identity;
|
||||
// rows whose turn state changed are revised through `historyRowRevision`.
|
||||
const getCollapsedTurn = useStableEvent((hostId: string) =>
|
||||
presentation.turns.turnsByHostId.get(hostId),
|
||||
);
|
||||
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
if (nestedItemIds.has(layoutItem.item.id)) {
|
||||
const below = layoutItem.belowItem;
|
||||
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
|
||||
}
|
||||
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
|
||||
});
|
||||
|
||||
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
|
||||
setExpandedToolCallGroupIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
@@ -715,8 +665,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const renderUserMessageItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
const collapsedTurn = getCollapsedTurn(item.id);
|
||||
const message = (
|
||||
return (
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
@@ -735,25 +684,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (!collapsedTurn) {
|
||||
return message;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{message}
|
||||
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
[
|
||||
context.capabilities,
|
||||
agentId,
|
||||
client,
|
||||
getCollapsedTurn,
|
||||
pendingClientMessageIds,
|
||||
resolvedServerId,
|
||||
setTurnExpanded,
|
||||
],
|
||||
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -961,14 +893,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return renderStreamItemWithTurnFooter({
|
||||
content,
|
||||
layoutItem,
|
||||
turnIndent: getTurnIndent(layoutItem),
|
||||
strategy: streamRenderStrategy,
|
||||
supportsTimelineCursor: supportsAgentForkContextCursor,
|
||||
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[
|
||||
getTurnIndent,
|
||||
handleForkAssistantTurn,
|
||||
readOnly,
|
||||
renderStreamItemContent,
|
||||
@@ -1130,25 +1060,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamScrollEnabled =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
expandedInlineToolCallIds.size === 0;
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
const historyRowRevision = useMemo(
|
||||
() => ({
|
||||
contentById: presentation.historyGroupUpdatesByHostId,
|
||||
displayStateById: {
|
||||
has: (id: string) =>
|
||||
expandedToolCallGroupIds.has(id) ||
|
||||
expandedTurnHostIds.has(id) ||
|
||||
nestedItemIds.has(id),
|
||||
},
|
||||
displayStateById: expandedToolCallGroupIds,
|
||||
globalDisplayState: isMobile,
|
||||
}),
|
||||
[
|
||||
expandedToolCallGroupIds,
|
||||
expandedTurnHostIds,
|
||||
isMobile,
|
||||
nestedItemIds,
|
||||
presentation.historyGroupUpdatesByHostId,
|
||||
],
|
||||
[expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId],
|
||||
);
|
||||
|
||||
const findItems = useMemo(
|
||||
@@ -1162,7 +1080,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
epoch={timelineEpoch}
|
||||
items={findItems}
|
||||
viewportRef={viewportRef}
|
||||
revealLoadedMessage={revealLoadedMessage}
|
||||
revealLoadedMessage={revealLoadedHistory}
|
||||
visibleMessageIds={mountedMessageIds}
|
||||
>
|
||||
<ToolCallSheetProvider>
|
||||
@@ -1845,41 +1763,15 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
interface StreamItemWrapperProps {
|
||||
itemId: string;
|
||||
gapBelow: number;
|
||||
turnIndent: TurnIndent;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapperProps) {
|
||||
function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) {
|
||||
const wrapperStyle = useMemo(
|
||||
() => [
|
||||
stylesheet.streamItemWrapper,
|
||||
{ marginBottom: turnIndent === "nested" || turnIndent === "openedHost" ? 0 : gapBelow },
|
||||
],
|
||||
[gapBelow, turnIndent],
|
||||
() => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }],
|
||||
[gapBelow],
|
||||
);
|
||||
const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]);
|
||||
switch (turnIndent) {
|
||||
case "none":
|
||||
return <View style={wrapperStyle}>{children}</View>;
|
||||
case "openedHost":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
{children}
|
||||
<IndentRail style={gapStyle} />
|
||||
</View>
|
||||
);
|
||||
case "nested":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
<IndentRail style={gapStyle}>{children}</IndentRail>
|
||||
</View>
|
||||
);
|
||||
case "lastNested":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
<IndentRail>{children}</IndentRail>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <View style={wrapperStyle}>{children}</View>;
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
/**
|
||||
* Collapsed chat rows badge their summary with an icon whose center sits 7px into the content
|
||||
* column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail
|
||||
* hangs from that center so opened rows read as children of the line that opened them.
|
||||
*/
|
||||
const RAIL_LEFT = 7;
|
||||
const RAIL_WIDTH = 1;
|
||||
export const INDENT_RAIL_CONTENT_OFFSET = 21;
|
||||
|
||||
/** Indents opened chat rows under the summary that collapsed them, behind a vertical line. */
|
||||
export function IndentRail({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
return <View style={[styles.rail, style]}>{children}</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
rail: {
|
||||
marginLeft: RAIL_LEFT,
|
||||
paddingLeft: INDENT_RAIL_CONTENT_OFFSET - RAIL_LEFT - RAIL_WIDTH,
|
||||
borderLeftWidth: RAIL_WIDTH,
|
||||
borderLeftColor: theme.colors.border,
|
||||
},
|
||||
}));
|
||||
@@ -971,10 +971,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
// Details that do not sit in a card leave the header as a lone highlighted pill.
|
||||
pressableExpandedDetached: {
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
detailWrapperBorderless: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
@@ -2052,10 +2048,7 @@ export const ExpandableBadge = memo(function ExpandableBadge({
|
||||
expandableBadgeStylesheet.pressable,
|
||||
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
|
||||
isExpanded && expandableBadgeStylesheet.pressableExpanded,
|
||||
isExpanded &&
|
||||
(borderlessWhenExpanded
|
||||
? expandableBadgeStylesheet.pressableExpandedDetached
|
||||
: expandableBadgeStylesheet.pressableExpandedAttached),
|
||||
isExpanded && !borderlessWhenExpanded && expandableBadgeStylesheet.pressableExpandedAttached,
|
||||
],
|
||||
[borderlessWhenExpanded, isExpanded, isInteractive, isPressed],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
saveAppSettings as saveAppSettingsPure,
|
||||
type AppSettings,
|
||||
type AppSettingsUpdate,
|
||||
type ChatDisplay,
|
||||
type OpenInSidePanePreferences,
|
||||
type PullRequestOpenLocation,
|
||||
type DesktopSettingsBridge,
|
||||
@@ -85,7 +84,6 @@ export {
|
||||
export type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
ChatDisplay,
|
||||
AppLanguage,
|
||||
OpenInSidePanePreferences,
|
||||
PullRequestOpenLocation,
|
||||
|
||||
@@ -185,7 +185,6 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
|
||||
expect(result).toEqual(DEFAULT_CLIENT_SETTINGS);
|
||||
expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system");
|
||||
expect(DEFAULT_CLIENT_SETTINGS.chatDisplay).toBe("collapseTurns");
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual(
|
||||
DEFAULT_CLIENT_SETTINGS,
|
||||
);
|
||||
@@ -455,7 +454,6 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
theme: "dark",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({
|
||||
manageBuiltInDaemon: false,
|
||||
@@ -594,7 +592,6 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -639,7 +636,6 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
@@ -661,7 +657,6 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -737,7 +732,7 @@ describe("saveAppSettings", () => {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "collapseToolCalls",
|
||||
toolCallDetailLevel: "overview",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -792,45 +787,27 @@ describe("appearance settings", () => {
|
||||
expect(result.contentFontSize).toBe(DEFAULT_UI_BASE_FONT_SIZE);
|
||||
expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
|
||||
expect(result.syntaxTheme).toBe("one");
|
||||
expect(result.chatDisplay).toBe("full");
|
||||
expect(result.toolCallDetailLevel).toBe("detailed");
|
||||
});
|
||||
|
||||
it("migrates the enabled compact tool call preference to collapsed tool calls", async () => {
|
||||
it("migrates the enabled compact tool call preference to overview", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ compactToolCalls: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseToolCalls");
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["overview", "collapseToolCalls"],
|
||||
["concise", "collapseToolCalls"],
|
||||
["detailed", "full"],
|
||||
["unknown", "full"],
|
||||
])("reads the %s tool call detail level as the %s chat display", async (legacy, display) => {
|
||||
it("clears settings with an unrecognized tool call detail level", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: legacy }),
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe(display);
|
||||
});
|
||||
|
||||
it("keeps a stored chat display over the tool call detail level it replaced", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
chatDisplay: "collapseTurns",
|
||||
toolCallDetailLevel: "detailed",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseTurns");
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed");
|
||||
});
|
||||
|
||||
it("migrates a switched-off checks row item to the hidden checks display", async () => {
|
||||
|
||||
@@ -40,8 +40,7 @@ export type WorkspaceTitleSource = "title" | "branch";
|
||||
export type PullRequestOpenLocation = "main" | "side" | "explorer";
|
||||
/** What a sidebar workspace row shows in the space to the right of its title. */
|
||||
export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
|
||||
/** How much of an agent's work the chat shows between the user's message and the reply. */
|
||||
export type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
|
||||
export type ToolCallDetailLevel = "overview" | "detailed";
|
||||
|
||||
const ThemePreferenceSchema = z.enum([
|
||||
...THEME_OPTIONS.map((option) => option.name),
|
||||
@@ -101,7 +100,7 @@ export interface AppSettings {
|
||||
/** How usage reads and which windows the sidebar summary shows. */
|
||||
usage: UsagePreferences;
|
||||
autoExpandReasoning: boolean;
|
||||
chatDisplay: ChatDisplay;
|
||||
toolCallDetailLevel: ToolCallDetailLevel;
|
||||
chatOutlineEnabled: boolean;
|
||||
vimKeybindings: boolean;
|
||||
filePanelAssociations: Record<string, string>;
|
||||
@@ -157,7 +156,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sidebarFooterItems: [],
|
||||
usage: DEFAULT_USAGE_PREFERENCES,
|
||||
autoExpandReasoning: false,
|
||||
chatDisplay: "collapseTurns",
|
||||
toolCallDetailLevel: "detailed",
|
||||
chatOutlineEnabled: true,
|
||||
vimKeybindings: false,
|
||||
filePanelAssociations: {},
|
||||
@@ -200,7 +199,6 @@ const SidebarRowItemsSchema = z
|
||||
type StoredAppSettingsFallback = AppSettings & {
|
||||
uiFontSize?: number;
|
||||
compactToolCalls?: boolean;
|
||||
toolCallDetailLevel?: "overview" | "detailed";
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
needsWrite: boolean;
|
||||
@@ -253,13 +251,11 @@ const StoredAppSettingsSchema = z
|
||||
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
usage: UsagePreferencesSchema,
|
||||
autoExpandReasoning: z.boolean().catch(false),
|
||||
chatDisplay: z.enum(["full", "collapseToolCalls", "collapseTurns"]).optional().catch("full"),
|
||||
// COMPAT(toolCallDetailLevel): replaced by chatDisplay in v0.13, remove after 2027-04-24.
|
||||
toolCallDetailLevel: z
|
||||
.enum(["overview", "detailed"])
|
||||
.or(z.literal("concise").transform(() => "overview" as const))
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
.catch("detailed"),
|
||||
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
|
||||
compactToolCalls: z.boolean().optional().catch(undefined),
|
||||
chatOutlineEnabled: z.boolean().catch(true),
|
||||
@@ -309,10 +305,8 @@ const StoredAppSettingsSchema = z
|
||||
(isChecksHiddenByLegacyRowItem(stored.sidebarRowItems)
|
||||
? "none"
|
||||
: DEFAULT_SIDEBAR_CHECKS_DISPLAY);
|
||||
const groupsToolCalls = stored.toolCallDetailLevel
|
||||
? stored.toolCallDetailLevel === "overview"
|
||||
: stored.compactToolCalls === true;
|
||||
const chatDisplay = stored.chatDisplay ?? (groupsToolCalls ? "collapseToolCalls" : "full");
|
||||
const toolCallDetailLevel =
|
||||
stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed");
|
||||
return {
|
||||
...stored,
|
||||
openInSidePane,
|
||||
@@ -327,7 +321,7 @@ const StoredAppSettingsSchema = z
|
||||
stored.sidebarRowItems.services ??
|
||||
(stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services),
|
||||
},
|
||||
chatDisplay,
|
||||
toolCallDetailLevel,
|
||||
needsWrite,
|
||||
};
|
||||
})
|
||||
@@ -455,7 +449,6 @@ export function normalizeAppSettings(value: unknown): AppSettings {
|
||||
manageBuiltInDaemon: _manageBuiltInDaemon,
|
||||
releaseChannel: _releaseChannel,
|
||||
compactToolCalls: _compactToolCalls,
|
||||
toolCallDetailLevel: _toolCallDetailLevel,
|
||||
uiFontSize: _uiFontSize,
|
||||
...settings
|
||||
} = StoredAppSettingsSchema.parse(value);
|
||||
|
||||
@@ -2008,12 +2008,6 @@ export const ar: TranslationResources = {
|
||||
input: "مدخل",
|
||||
output: "الإخراج",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "كتب {{count}} رسالة",
|
||||
other: "كتب {{count}} رسائل",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "حرّر {{count}} ملفًا",
|
||||
@@ -2318,13 +2312,12 @@ export const ar: TranslationResources = {
|
||||
label: "عرض التفكير دائماً",
|
||||
description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "عرض المحادثة",
|
||||
description: "مقدار عمل الوكيل الذي تعرضه المحادثة",
|
||||
toolCallDetail: {
|
||||
label: "عرض استدعاءات الأدوات",
|
||||
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
|
||||
options: {
|
||||
full: "كامل",
|
||||
collapseToolCalls: "طيّ استدعاءات الأدوات",
|
||||
collapseTurns: "طيّ الأدوار",
|
||||
overview: "ملخص",
|
||||
detailed: "التفاصيل الكاملة",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2033,12 +2033,6 @@ export const en = {
|
||||
input: "Input",
|
||||
output: "Output",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "wrote {{count}} message",
|
||||
other: "wrote {{count}} messages",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "edited {{count}} file",
|
||||
@@ -2406,13 +2400,12 @@ export const en = {
|
||||
label: "Always expand reasoning",
|
||||
description: "Show agent thinking and chain-of-thought blocks fully expanded by default",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Chat display",
|
||||
description: "How much of an agent's work the chat shows",
|
||||
toolCallDetail: {
|
||||
label: "Tool call display",
|
||||
description: "How tool calls appear in the timeline",
|
||||
options: {
|
||||
full: "Full",
|
||||
collapseToolCalls: "Collapse tool calls",
|
||||
collapseTurns: "Collapse turns",
|
||||
overview: "Summary",
|
||||
detailed: "Full detail",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2056,12 +2056,6 @@ export const es: TranslationResources = {
|
||||
input: "Aporte",
|
||||
output: "Producción",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "escribió {{count}} mensaje",
|
||||
other: "escribió {{count}} mensajes",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "editó {{count}} archivo",
|
||||
@@ -2334,13 +2328,12 @@ export const es: TranslationResources = {
|
||||
description:
|
||||
"Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Visualización del chat",
|
||||
description: "Cuánto del trabajo del agente muestra el chat",
|
||||
toolCallDetail: {
|
||||
label: "Visualización de llamadas a herramientas",
|
||||
description: "Cómo aparecen las llamadas a herramientas en la cronología",
|
||||
options: {
|
||||
full: "Completo",
|
||||
collapseToolCalls: "Contraer llamadas a herramientas",
|
||||
collapseTurns: "Contraer turnos",
|
||||
overview: "Resumen",
|
||||
detailed: "Detalle completo",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2055,12 +2055,6 @@ export const fr: TranslationResources = {
|
||||
input: "Entrée",
|
||||
output: "Sortie",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "{{count}} message écrit",
|
||||
other: "{{count}} messages écrits",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "{{count}} fichier modifié",
|
||||
@@ -2332,13 +2326,12 @@ export const fr: TranslationResources = {
|
||||
label: "Toujours afficher le raisonnement",
|
||||
description: "Afficher par défaut le raisonnement de l’agent entièrement déplié",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Affichage du chat",
|
||||
description: "La part du travail de l’agent affichée dans le chat",
|
||||
toolCallDetail: {
|
||||
label: "Affichage des appels d’outils",
|
||||
description: "Comment les appels d’outils apparaissent dans la chronologie",
|
||||
options: {
|
||||
full: "Complet",
|
||||
collapseToolCalls: "Réduire les appels d’outils",
|
||||
collapseTurns: "Réduire les tours",
|
||||
overview: "Résumé",
|
||||
detailed: "Détails complets",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2028,12 +2028,6 @@ export const ja: TranslationResources = {
|
||||
input: "入力",
|
||||
output: "出力",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "{{count}}件のメッセージを送信",
|
||||
other: "{{count}}件のメッセージを送信",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "{{count}}個のファイルを編集",
|
||||
@@ -2302,13 +2296,12 @@ export const ja: TranslationResources = {
|
||||
label: "常に思考プロセスを展開",
|
||||
description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "チャットの表示",
|
||||
description: "チャットに表示するエージェントの作業量",
|
||||
toolCallDetail: {
|
||||
label: "ツール呼び出しの表示",
|
||||
description: "タイムラインでのツール呼び出しの表示方法",
|
||||
options: {
|
||||
full: "すべて",
|
||||
collapseToolCalls: "ツール呼び出しを折りたたむ",
|
||||
collapseTurns: "ターンを折りたたむ",
|
||||
overview: "要約",
|
||||
detailed: "すべての詳細",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2018,12 +2018,6 @@ export const ko: TranslationResources = {
|
||||
input: "입력",
|
||||
output: "출력",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "{{count}} 메시지를 작성했습니다.",
|
||||
other: "{{count}} 메시지를 작성했습니다.",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "{{count}} 파일을 편집했습니다.",
|
||||
@@ -2293,13 +2287,12 @@ export const ko: TranslationResources = {
|
||||
label: "추론 항상 펼치기",
|
||||
description: "에이전트의 사고 및 추론 블록을 기본적으로 모두 펼쳐 표시합니다.",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "채팅 표시",
|
||||
description: "채팅에 표시할 에이전트 작업의 양",
|
||||
toolCallDetail: {
|
||||
label: "도구 호출 표시",
|
||||
description: "타임라인에 도구 호출이 표시되는 방식",
|
||||
options: {
|
||||
full: "전체",
|
||||
collapseToolCalls: "도구 호출 접기",
|
||||
collapseTurns: "턴 접기",
|
||||
overview: "요약",
|
||||
detailed: "전체 세부정보",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2041,12 +2041,6 @@ export const ptBR: TranslationResources = {
|
||||
input: "Entrada",
|
||||
output: "Saída",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "escreveu {{count}} mensagem",
|
||||
other: "escreveu {{count}} mensagens",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "editou {{count}} arquivo",
|
||||
@@ -2317,13 +2311,12 @@ export const ptBR: TranslationResources = {
|
||||
description:
|
||||
"Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Exibição do chat",
|
||||
description: "Quanto do trabalho do agente o chat mostra",
|
||||
toolCallDetail: {
|
||||
label: "Exibição de chamadas de ferramentas",
|
||||
description: "Como as chamadas de ferramentas aparecem na linha do tempo",
|
||||
options: {
|
||||
full: "Completo",
|
||||
collapseToolCalls: "Recolher chamadas de ferramentas",
|
||||
collapseTurns: "Recolher turnos",
|
||||
overview: "Resumo",
|
||||
detailed: "Detalhes completos",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2041,12 +2041,6 @@ export const ru: TranslationResources = {
|
||||
input: "Входные данные",
|
||||
output: "Выходные данные",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "написано {{count}} сообщение",
|
||||
other: "написаны сообщения ({{count}})",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "изменён {{count}} файл",
|
||||
@@ -2319,13 +2313,12 @@ export const ru: TranslationResources = {
|
||||
description:
|
||||
"По умолчанию полностью разворачивать блоки размышлений и хода рассуждений агента",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Отображение чата",
|
||||
description: "Какую часть работы агента показывает чат",
|
||||
toolCallDetail: {
|
||||
label: "Отображение вызовов инструментов",
|
||||
description: "Как вызовы инструментов отображаются на временной шкале",
|
||||
options: {
|
||||
full: "Полностью",
|
||||
collapseToolCalls: "Сворачивать вызовы инструментов",
|
||||
collapseTurns: "Сворачивать ходы",
|
||||
overview: "Сводка",
|
||||
detailed: "Все подробности",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1986,12 +1986,6 @@ export const zhCN: TranslationResources = {
|
||||
input: "输入",
|
||||
output: "输出",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "写了 {{count}} 条消息",
|
||||
other: "写了 {{count}} 条消息",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "编辑了 {{count}} 个文件",
|
||||
@@ -2257,13 +2251,12 @@ export const zhCN: TranslationResources = {
|
||||
label: "始终展开推理过程",
|
||||
description: "默认情况下完全展开 AI 的思考和推理过程",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "聊天显示",
|
||||
description: "聊天中显示多少智能体的工作",
|
||||
toolCallDetail: {
|
||||
label: "工具调用显示",
|
||||
description: "工具调用在时间线中的显示方式",
|
||||
options: {
|
||||
full: "完整",
|
||||
collapseToolCalls: "折叠工具调用",
|
||||
collapseTurns: "折叠轮次",
|
||||
overview: "摘要",
|
||||
detailed: "完整详情",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -8,25 +8,29 @@ import {
|
||||
SettingsSwitch,
|
||||
} from "@/components/settings";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useAppSettings, type ChatDisplay } from "@/hooks/use-settings";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
|
||||
const CHAT_DISPLAYS: readonly ChatDisplay[] = ["full", "collapseToolCalls", "collapseTurns"];
|
||||
const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [
|
||||
"detailed",
|
||||
"overview",
|
||||
];
|
||||
|
||||
export function ChatSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
|
||||
const chatDisplayOptions = useMemo(
|
||||
const toolCallDetailOptions = useMemo(
|
||||
() =>
|
||||
CHAT_DISPLAYS.map((value) => ({
|
||||
TOOL_CALL_DETAIL_LEVELS.map((value) => ({
|
||||
value,
|
||||
label: t(`settings.general.chatDisplay.options.${value}`),
|
||||
label: t(`settings.general.toolCallDetail.options.${value}`),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
const changeChatDisplay = useCallback(
|
||||
(chatDisplay: ChatDisplay) => void updateSettings({ chatDisplay }),
|
||||
const changeToolCallDetailLevel = useCallback(
|
||||
(toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) =>
|
||||
void updateSettings({ toolCallDetailLevel }),
|
||||
[updateSettings],
|
||||
);
|
||||
const changeAutoExpandReasoning = useCallback(
|
||||
@@ -49,11 +53,11 @@ export function ChatSection() {
|
||||
onValueChange={changeAutoExpandReasoning}
|
||||
/>
|
||||
<SettingsSelect
|
||||
label={t("settings.general.chatDisplay.label")}
|
||||
hint={t("settings.general.chatDisplay.description")}
|
||||
value={settings.chatDisplay}
|
||||
options={chatDisplayOptions}
|
||||
onValueChange={changeChatDisplay}
|
||||
label={t("settings.general.toolCallDetail.label")}
|
||||
hint={t("settings.general.toolCallDetail.description")}
|
||||
value={settings.toolCallDetailLevel}
|
||||
options={toolCallDetailOptions}
|
||||
onValueChange={changeToolCallDetailLevel}
|
||||
/>
|
||||
{isNative ? null : (
|
||||
<SettingsSwitch
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { OverviewSummary } from "./model";
|
||||
|
||||
function joinSummaryParts(parts: string[], conjunction: string): string {
|
||||
if (parts.length === 0) {
|
||||
return "";
|
||||
}
|
||||
let joined = parts[0] ?? "";
|
||||
if (parts.length === 2) {
|
||||
joined = `${parts[0]} ${conjunction} ${parts[1]}`;
|
||||
} else if (parts.length > 2) {
|
||||
joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`;
|
||||
}
|
||||
const firstCharacter = joined[0];
|
||||
return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One sentence of what a stretch of agent work did, such as "Edited 2 files and ran 3 commands".
|
||||
* Leading parts come first, for summaries that count more than tool calls.
|
||||
*/
|
||||
export function useOverviewSummaryLabel(
|
||||
summary: OverviewSummary,
|
||||
leadingParts: readonly string[] = [],
|
||||
): string {
|
||||
const { t } = useTranslation();
|
||||
return useMemo(() => {
|
||||
const parts = [...leadingParts];
|
||||
const entries = [
|
||||
[summary.editedFileCount, "toolCallGroup.editedFiles"],
|
||||
[summary.commandCount, "toolCallGroup.commands"],
|
||||
[summary.readFileCount, "toolCallGroup.readFiles"],
|
||||
[summary.searchCount, "toolCallGroup.searches"],
|
||||
[summary.otherToolCount, "toolCallGroup.otherTools"],
|
||||
[summary.paseoCallCount, "toolCallGroup.paseoCalls"],
|
||||
] as const;
|
||||
for (const [count, key] of entries) {
|
||||
if (count > 0) {
|
||||
parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count }));
|
||||
}
|
||||
}
|
||||
return joinSummaryParts(parts, t("toolCallGroup.and"));
|
||||
}, [leadingParts, summary, t]);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
|
||||
import type { ToolCallItem } from "@/types/stream";
|
||||
import { describeToolCall, type ToolCallRun } from "../grouping";
|
||||
|
||||
const DIRECT_PASEO_TOOL_PREFIX = "paseo_";
|
||||
@@ -29,18 +28,19 @@ function isSearchCall(name: string): boolean {
|
||||
return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name);
|
||||
}
|
||||
|
||||
/** Counts tool calls by what they did, for any summary of a stretch of agent work. */
|
||||
export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSummary {
|
||||
export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
|
||||
const editedFiles = new Set<string>();
|
||||
const readFiles = new Set<string>();
|
||||
let isLoading = false;
|
||||
let commandCount = 0;
|
||||
let searchCount = 0;
|
||||
let otherToolCount = 0;
|
||||
let paseoCallCount = 0;
|
||||
|
||||
for (const call of calls) {
|
||||
for (const call of run.calls) {
|
||||
const descriptor = describeToolCall(call);
|
||||
const normalizedName = descriptor.name.trim().toLowerCase();
|
||||
isLoading ||= descriptor.status === "running" || descriptor.status === "executing";
|
||||
if (isPaseoCall(descriptor.name, normalizedName)) {
|
||||
paseoCallCount += 1;
|
||||
} else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") {
|
||||
@@ -56,7 +56,7 @@ export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSumm
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const summary = {
|
||||
editedFileCount: editedFiles.size,
|
||||
commandCount,
|
||||
readFileCount: readFiles.size,
|
||||
@@ -64,17 +64,10 @@ export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSumm
|
||||
otherToolCount,
|
||||
paseoCallCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
|
||||
const isLoading = run.calls.some((call) => {
|
||||
const status = describeToolCall(call).status;
|
||||
return status === "running" || status === "executing";
|
||||
});
|
||||
return {
|
||||
mode: "overview",
|
||||
run,
|
||||
isLoading,
|
||||
summary: summarizeToolCalls(run.calls),
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { memo, useCallback, useRef, type ReactNode } from "react";
|
||||
import React, { memo, useCallback, useMemo, useRef, type ReactNode } from "react";
|
||||
import { ScrollView } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wrench } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { IndentRail } from "@/components/indent-rail";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useOverviewSummaryLabel } from "./label";
|
||||
import type { OverviewToolCallGroup } from "./model";
|
||||
import { type OverviewSummary, type OverviewToolCallGroup } from "./model";
|
||||
import { OverviewToolCallGroupSheet } from "./sheet";
|
||||
|
||||
interface OverviewGroupProps {
|
||||
@@ -19,6 +18,41 @@ interface OverviewGroupProps {
|
||||
|
||||
const TOOL_CALL_GROUP_MAX_HEIGHT = 400;
|
||||
|
||||
function joinSummaryParts(parts: string[], conjunction: string): string {
|
||||
if (parts.length === 0) {
|
||||
return "";
|
||||
}
|
||||
let joined = parts[0] ?? "";
|
||||
if (parts.length === 2) {
|
||||
joined = `${parts[0]} ${conjunction} ${parts[1]}`;
|
||||
} else if (parts.length > 2) {
|
||||
joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`;
|
||||
}
|
||||
const firstCharacter = joined[0];
|
||||
return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined;
|
||||
}
|
||||
|
||||
function useOverviewSummary(summary: OverviewSummary): string {
|
||||
const { t } = useTranslation();
|
||||
return useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
const entries = [
|
||||
[summary.editedFileCount, "toolCallGroup.editedFiles"],
|
||||
[summary.commandCount, "toolCallGroup.commands"],
|
||||
[summary.readFileCount, "toolCallGroup.readFiles"],
|
||||
[summary.searchCount, "toolCallGroup.searches"],
|
||||
[summary.otherToolCount, "toolCallGroup.otherTools"],
|
||||
[summary.paseoCallCount, "toolCallGroup.paseoCalls"],
|
||||
] as const;
|
||||
for (const [count, key] of entries) {
|
||||
if (count > 0) {
|
||||
parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count }));
|
||||
}
|
||||
}
|
||||
return joinSummaryParts(parts, t("toolCallGroup.and"));
|
||||
}, [summary, t]);
|
||||
}
|
||||
|
||||
export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView({
|
||||
group,
|
||||
expanded,
|
||||
@@ -28,7 +62,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
|
||||
}: OverviewGroupProps) {
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const aggregateSummary = useOverviewSummaryLabel(group.summary);
|
||||
const aggregateSummary = useOverviewSummary(group.summary);
|
||||
const scrollToLatest = useCallback(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
}, []);
|
||||
@@ -48,7 +82,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
|
||||
showsVerticalScrollIndicator
|
||||
onContentSizeChange={scrollToLatest}
|
||||
>
|
||||
<IndentRail>{children}</IndentRail>
|
||||
{children}
|
||||
</ScrollView>
|
||||
),
|
||||
[children, scrollToLatest],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { ToolCallDetailLevel } from "@/hooks/use-settings/storage";
|
||||
import {
|
||||
groupLiveToolCalls,
|
||||
prepareGroupedHistory,
|
||||
@@ -7,8 +8,7 @@ import {
|
||||
} from "./grouping";
|
||||
import { buildOverviewGroup, type OverviewToolCallGroup } from "./overview/model";
|
||||
|
||||
/** Whether consecutive tool calls show one by one or as one summary row. */
|
||||
export type ToolCallDetailLevel = "overview" | "detailed";
|
||||
export type { ToolCallDetailLevel } from "@/hooks/use-settings/storage";
|
||||
export type ToolCallDetailGroup = OverviewToolCallGroup;
|
||||
|
||||
export interface PreparedToolCallHistory {
|
||||
|
||||
@@ -771,12 +771,6 @@ export type AgentToolCallItem = ToolCallItem & {
|
||||
payload: { source: "agent"; data: AgentToolCallData };
|
||||
};
|
||||
|
||||
/** Find and scrolling address a plugin row by the source it replaces. */
|
||||
export function getStreamItemMessageId(item: StreamItem): string {
|
||||
if (item.kind === "plugin") return item.sourceId ?? item.id;
|
||||
return item.id;
|
||||
}
|
||||
|
||||
export function isAgentToolCallItem(item: StreamItem): item is AgentToolCallItem {
|
||||
return item.kind === "tool_call" && item.payload.source === "agent";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user