diff --git a/apps/mobile/src/components/copy-text-button.tsx b/apps/mobile/src/components/copy-text-button.tsx index 30e315b..f4622ea 100644 --- a/apps/mobile/src/components/copy-text-button.tsx +++ b/apps/mobile/src/components/copy-text-button.tsx @@ -44,7 +44,7 @@ export function CopyTextButton({ style={styles.action} > {iconOnly && !status ? ( - + ) : ( {status === "copied" ? "Copied" : status === "failed" ? "Retry copy" : label} diff --git a/apps/mobile/src/components/modal-sheet.tsx b/apps/mobile/src/components/modal-sheet.tsx index 3581c02..b1dcca2 100644 --- a/apps/mobile/src/components/modal-sheet.tsx +++ b/apps/mobile/src/components/modal-sheet.tsx @@ -156,8 +156,8 @@ const styles = StyleSheet.create({ compactScroll: { flexGrow: 0, flexShrink: 1 }, closeButton: { justifyContent: "center", minHeight: 44, paddingHorizontal: space.sm }, closeLabel: { ...typography.control, color: palette.signal }, - content: { gap: space.md, padding: space.lg, paddingBottom: space.xl }, - fixedContent: { flex: 1, gap: space.md, padding: space.lg, paddingBottom: space.xl }, + content: { gap: space.md, padding: space.md, paddingBottom: space.xl }, + fixedContent: { flex: 1, gap: space.md, padding: space.md, paddingBottom: space.xl }, header: { alignItems: "center", borderBottomColor: palette.border, diff --git a/apps/mobile/src/navigation/root-navigation.tsx b/apps/mobile/src/navigation/root-navigation.tsx index b0c97e8..19f4e56 100644 --- a/apps/mobile/src/navigation/root-navigation.tsx +++ b/apps/mobile/src/navigation/root-navigation.tsx @@ -86,6 +86,7 @@ export function RootNavigation() { headerShown: !tablet, headerStyle: { backgroundColor: palette.background }, headerTintColor: palette.ink, + headerTitleStyle: { fontSize: 16, fontWeight: "500" }, }} > + { + render( + , + ); + expect(screen.getByRole("button", { name: "Stop" })).toBeOnTheScreen(); + expect(screen.queryByRole("button", { name: "Move to background" })).toBeNull(); +}); + test("renders active execution and mutable queued inbox work", () => { const inbox = [ { @@ -31,6 +47,7 @@ test("renders active execution and mutable queued inbox work", () => { render( void; @@ -93,7 +95,7 @@ export function SessionExecutionPanel({ : "Working"} - {onBackground && permissions.length === 0 && !formRequests ? ( + {canBackground && onBackground && permissions.length === 0 && !formRequests ? ( ({ + getOpenCodeShell: jest.fn(), + getOpenCodeShellOutput: jest.fn(), + isShellNotFoundError: (error: unknown) => + (error as { _tag?: string })?._tag === "ShellNotFoundError", + maxShellOutputBytes: 65536, +})); + +const getShell = jest.mocked(getOpenCodeShell); +const getOutput = jest.mocked(getOpenCodeShellOutput); +const location = { directory: "/workspace" }; +const info: ShellGetOutput = { + location, + data: { + id: "sh_test", + status: "running", + command: "build", + cwd: "/workspace", + shell: "sh", + file: "/output", + metadata: {}, + time: { started: 1 }, + }, +}; +const clients: QueryClient[] = []; + +beforeEach(() => { + jest.useFakeTimers(); + focusManager.setFocused(true); + onlineManager.setOnline(true); + getShell.mockReset().mockResolvedValue(info); + getOutput.mockReset().mockResolvedValue(page("one", 3)); +}); +afterEach(() => { + for (const client of clients.splice(0)) client.clear(); + focusManager.setFocused(true); + onlineManager.setOnline(true); + jest.useRealTimers(); +}); + +function page(output: string, cursor: number, size = cursor): ShellOutputOutput { + return { location, data: { output, cursor, size, truncated: false } }; +} + +function setup() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(queryClient); + const tree = (connectionId = "connection-1", directory = "/workspace", enabled = true) => ( + + + + {(result) => ( + <> + {result?.snapshot?.output ?? "loading"} + + {result?.error + ? "unavailable" + : result?.snapshot?.missing + ? "missing" + : result?.snapshot?.info?.status} + + {result?.snapshot?.truncated ? "truncated" : "complete buffer"} + + )} + + + + ); + return { ...render(tree()), tree, queryClient }; +} + +async function tick(ms = 1000) { + await act(async () => { + await jest.advanceTimersByTimeAsync(ms); + }); +} + +test("follows byte cursors without session activity and stops after fetching final output", async () => { + const view = setup(); + await screen.findByText("one"); + getOutput.mockResolvedValue(page(" two", 7)); + await tick(); + expect(screen.getByText("one two")).toBeOnTheScreen(); + expect(getOutput).toHaveBeenLastCalledWith( + expect.anything(), + location, + "sh_test", + expect.objectContaining({ cursor: 3 }), + ); + getShell.mockResolvedValue({ ...info, data: { ...info.data, status: "exited", exit: 0 } }); + getOutput.mockResolvedValue(page(" done", 12)); + await tick(); + expect(screen.getByText("one two done")).toBeOnTheScreen(); + expect(screen.getByText("exited")).toBeOnTheScreen(); + const calls = getOutput.mock.calls.length; + await tick(5000); + expect(getOutput).toHaveBeenCalledTimes(calls); + view.unmount(); +}); + +test("pauses offscreen, offline and in the native background, then reconciles on return", async () => { + const view = setup(); + await screen.findByText("one"); + getOutput.mockResolvedValue(page(" two", 7)); + act(() => focusManager.setFocused(false)); + await tick(3000); + expect(getOutput).toHaveBeenCalledTimes(1); + act(() => { + onlineManager.setOnline(false); + focusManager.setFocused(true); + }); + await tick(3000); + expect(getOutput).toHaveBeenCalledTimes(1); + act(() => onlineManager.setOnline(true)); + await tick(0); + expect(await screen.findByText("one two")).toBeOnTheScreen(); + view.rerender(view.tree("connection-1", "/workspace", false)); + const calls = getOutput.mock.calls.length; + await tick(3000); + expect(getOutput).toHaveBeenCalledTimes(calls); + getOutput.mockResolvedValue(page(" done", 12)); + view.rerender(view.tree()); + expect(await screen.findByText("one two done")).toBeOnTheScreen(); + view.unmount(); +}); + +test("jumps to a bounded recent tail instead of downloading an entire large log", async () => { + getOutput + .mockResolvedValueOnce(page("x".repeat(65536), 65536, 1_000_000)) + .mockResolvedValueOnce(page("tail", 1_000_000)); + const view = setup(); + expect(await screen.findByText("tail")).toBeOnTheScreen(); + expect(screen.getByText("truncated")).toBeOnTheScreen(); + expect(getOutput).toHaveBeenCalledTimes(2); + expect(getOutput).toHaveBeenLastCalledWith( + expect.anything(), + location, + "sh_test", + expect.objectContaining({ cursor: 1_000_000 - 65536 }), + ); + view.unmount(); +}); + +test("retains observed output and stops polling when the server has removed the shell", async () => { + const view = setup(); + await screen.findByText("one"); + getShell.mockRejectedValue({ _tag: "ShellNotFoundError" }); + await tick(); + expect(screen.getByText("one")).toBeOnTheScreen(); + expect(screen.getByText("missing")).toBeOnTheScreen(); + const calls = getShell.mock.calls.length; + await tick(5000); + expect(getShell).toHaveBeenCalledTimes(calls); + view.unmount(); +}); + +test("bounds accumulated multibyte output without cutting a character in half", async () => { + getOutput + .mockResolvedValueOnce(page("ø".repeat(20000), 40000)) + .mockResolvedValueOnce(page("ø".repeat(20000), 80000)); + const view = setup(); + await screen.findByText("ø".repeat(20000)); + await tick(); + expect(screen.getByText("ø".repeat(32768))).toBeOnTheScreen(); + expect(screen.getByText("truncated")).toBeOnTheScreen(); + view.unmount(); +}); + +test("keeps previous output on transport errors and recovers on reconciliation", async () => { + const view = setup(); + await screen.findByText("one"); + getShell.mockRejectedValue(new Error("Offline")); + await tick(3000); + expect(screen.getByText("one")).toBeOnTheScreen(); + expect(screen.getByText("unavailable")).toBeOnTheScreen(); + const calls = getShell.mock.calls.length; + await tick(3000); + expect(getShell).toHaveBeenCalledTimes(calls); + getShell.mockResolvedValue({ ...info, data: { ...info.data, status: "exited" } }); + getOutput.mockResolvedValue(page(" done", 8)); + await act(async () => { + await view.queryClient.invalidateQueries(); + }); + expect(await screen.findByText("one done")).toBeOnTheScreen(); + view.unmount(); +}); + +test.each(["connection", "location"])( + "isolates late shell output after a %s change", + async (change) => { + let resolve: ((value: ShellOutputOutput) => void) | undefined; + getOutput.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + const view = setup(); + await tick(0); + const signal = getOutput.mock.calls[0]?.[3]?.signal; + getOutput.mockResolvedValue(page("new scope", 9)); + view.rerender( + view.tree( + change === "connection" ? "connection-2" : "connection-1", + change === "location" ? "/other" : "/workspace", + ), + ); + expect(await screen.findByText("new scope")).toBeOnTheScreen(); + expect(signal?.aborted).toBe(true); + await act(async () => { + resolve?.(page("old scope", 9)); + }); + expect(screen.queryByText("old scope")).toBeNull(); + expect(getOutput.mock.calls.at(-1)?.[3]?.cursor).toBe(0); + view.unmount(); + }, +); diff --git a/apps/mobile/src/screens/session-shell-output.tsx b/apps/mobile/src/screens/session-shell-output.tsx new file mode 100644 index 0000000..8ee403b --- /dev/null +++ b/apps/mobile/src/screens/session-shell-output.tsx @@ -0,0 +1,159 @@ +import { + getOpenCodeShell, + getOpenCodeShellOutput, + isShellNotFoundError, + type LocationRef, + maxShellOutputBytes, + type OpenCodeClient, + type ShellGetOutput, +} from "@opencode2-mobile/opencode-adapter"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { createContext, type ReactNode, useContext } from "react"; + +import { openCodeQueryKeys } from "../state/open-code-query-keys"; + +type ShellScope = { + client: OpenCodeClient | undefined; + connectionId: string; + enabled: boolean; + location: LocationRef; +}; + +export const SessionShellScope = createContext(undefined); + +type ShellSnapshot = { + cursor: number; + output: string; + truncated: boolean; + missing: boolean; + info?: ShellGetOutput["data"]; +}; + +type Observation = { + snapshot?: ShellSnapshot | undefined; + error: boolean; + statusLabel: string; + retry: () => void; +}; + +export function ShellObservation({ + children, + shellID, +}: { + children: (observation: Observation | undefined) => ReactNode; + shellID: string | undefined; +}) { + const scope = useContext(SessionShellScope); + if (!scope || !shellID) return children(undefined); + // Unmount on a scope change, so neither output nor an in-flight request crosses servers. + return ( + + {children} + + ); +} + +function ObservedShell({ + children, + scope, + shellID, +}: { + children: (observation: Observation) => ReactNode; + scope: ShellScope; + shellID: string; +}) { + const queryClient = useQueryClient(); + const queryKey = openCodeQueryKeys.shell(scope.connectionId, scope.location, shellID); + const query = useQuery({ + queryKey, + enabled: Boolean(scope.enabled && scope.client), + gcTime: 60_000, + retry: 1, + // Shell lifetime is independent of model/session execution. React Query's + // focus/online managers pause this when the native app is backgrounded/offline. + refetchInterval: (query) => { + const snapshot = query.state.data; + return query.state.status === "error" || + snapshot?.missing || + (snapshot?.info && snapshot.info.status !== "running") + ? false + : 1_000; + }, + queryFn: async ({ signal }): Promise => { + if (!scope.client || !scope.enabled) throw new Error("CONNECTION_NOT_READY"); + const previous = queryClient.getQueryData(queryKey); + try { + const { data: info } = await getOpenCodeShell(scope.client, scope.location, shellID, { + signal, + }); + let { data: page } = await getOpenCodeShellOutput(scope.client, scope.location, shellID, { + cursor: previous?.cursor ?? 0, + signal, + }); + let output = (previous?.output ?? "") + page.output; + let truncated = + previous?.truncated || page.truncated || output.length > maxShellOutputBytes; + // Catch up to the latest bounded tail in at most one extra request, + // including after reconnection or opening a long-finished command. + if (page.cursor < page.size || page.cursor < (previous?.cursor ?? 0)) { + const cursor = Math.max(0, page.size - maxShellOutputBytes); + ({ data: page } = await getOpenCodeShellOutput(scope.client, scope.location, shellID, { + cursor, + signal, + })); + output = page.output; + truncated = cursor > 0 || page.truncated; + } + const bytes = new TextEncoder().encode(output); + if (bytes.length > maxShellOutputBytes) { + let start = bytes.length - maxShellOutputBytes; + while (start < bytes.length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start += 1; + output = new TextDecoder().decode(bytes.slice(start)); + truncated = true; + } + return { info, cursor: page.cursor, output, truncated: Boolean(truncated), missing: false }; + } catch (error) { + if (!isShellNotFoundError(error)) throw error; + return { + cursor: previous?.cursor ?? 0, + output: previous?.output ?? "", + truncated: previous?.truncated ?? false, + missing: true, + }; + } + }, + }); + return children({ + snapshot: scope.enabled ? query.data : undefined, + error: query.isError, + statusLabel: shellObservationStatus(scope.enabled ? query.data : undefined, query.isError), + retry: () => { + if (scope.enabled) void query.refetch(); + }, + }); +} + +function shellObservationStatus(snapshot: ShellSnapshot | undefined, error: boolean) { + if (error) return "Shell output could not be refreshed."; + if (snapshot?.missing) return "Shell output is no longer available on the server."; + if (!snapshot?.info) return "Loading shell output…"; + switch (snapshot.info.status) { + case "running": + return "Running"; + case "timeout": + return "Timed out"; + case "killed": + return "Stopped"; + case "exited": + return `Exited${snapshot.info.exit === undefined ? "" : ` · ${snapshot.info.exit}`}`; + } +} diff --git a/apps/mobile/src/screens/session-transcript-model.ts b/apps/mobile/src/screens/session-transcript-model.ts index 5dc4c70..032b6d4 100644 --- a/apps/mobile/src/screens/session-transcript-model.ts +++ b/apps/mobile/src/screens/session-transcript-model.ts @@ -39,6 +39,25 @@ export function flattenTranscriptPages(pages: SessionMessagesResponse[] | undefi return newestFirst; } +// Backgrounding detaches blocking tools, not model generation or arbitrary work. +// Messages arrive newest-first. Never offer an action for an older turn's tool. +export function hasForegroundBackgroundableTool(messages: SessionMessageInfo[]) { + for (const message of messages) { + if (message.type === "idle" || message.type === "user") return false; + if (message.type !== "assistant") continue; + if (message.time.completed !== undefined) return false; + return message.content.some( + (part) => + part.type === "tool" && + (part.name === "shell" || part.name === "subagent") && + part.state.status === "running" && + part.state.metadata.background !== true && + part.state.input.background !== true, + ); + } + return false; +} + export function getSubagentPresentation(tool: AssistantTool): SubagentPresentation | undefined { const toolName = tool.name.trim().toLocaleLowerCase(); if (toolName !== "subagent" && toolName !== "task") return undefined; diff --git a/apps/mobile/src/screens/session-transcript.test.tsx b/apps/mobile/src/screens/session-transcript.test.tsx index d215100..a852846 100644 --- a/apps/mobile/src/screens/session-transcript.test.tsx +++ b/apps/mobile/src/screens/session-transcript.test.tsx @@ -8,13 +8,22 @@ import { resetTranscriptPerformanceMetrics } from "../state/transcript-performan import { markdownPalette, palette } from "../theme"; import { activitySummary, + buildTranscriptPresentation, groupTranscriptMessages, SessionTranscriptRow, TranscriptActivityGroup, + TranscriptUpdatesGroup, } from "./session-transcript"; afterEach(resetTranscriptPerformanceMetrics); +jest.mock("@opencode2-mobile/opencode-adapter", () => ({ + getOpenCodeShell: jest.fn(), + getOpenCodeShellOutput: jest.fn(), + isShellNotFoundError: jest.fn(() => false), + maxShellOutputBytes: 64 * 1024, +})); + jest.mock("expo-clipboard", () => ({ setStringAsync: jest.fn(async () => true) })); test("one response copy action combines prose parts without reasoning or model metadata", async () => { @@ -96,7 +105,7 @@ test("nested activity reveals individual calls before their output", () => { name: "shell", state: { ...tool.state, - input: { command: "echo nested" }, + input: { command: "echo nested", workdir: "/workspace", timeout: 5000 }, content: [{ type: "text" as const, text: output }], }, }, @@ -122,6 +131,9 @@ test("nested activity reveals individual calls before their output", () => { expect(screen.queryByText(output)).toBeNull(); fireEvent.press(screen.getByRole("button", { name: /Shell.*Show/ })); expect(screen.getByText(output)).toBeOnTheScreen(); + expect(screen.getAllByText("echo nested")).toHaveLength(1); + expect(screen.getByText(/"workdir": "\/workspace"/)).toBeOnTheScreen(); + expect(screen.getByText(/"timeout": 5000/)).toBeOnTheScreen(); expect(screen.queryByRole("button", { name: /Show more/ })).toBeNull(); expect(screen.getByLabelText("Shell output")).toHaveStyle({ maxHeight: 240 }); expect(screen.getByLabelText("Shell output").props.nestedScrollEnabled).toBe(true); @@ -149,6 +161,45 @@ test("activity summaries count operations rather than inventing file counts", () ).toBe("Used 4 Glob, Grep, Shell, Patch"); }); +test.each([ + [ + "execute", + { code: "return await tools.browser.tabs.list();" }, + "return await tools.browser.tabs.list();", + ], + ["webfetch", { url: "https://example.test/docs" }, "https://example.test/docs"], + ["websearch", { query: "native shell output" }, "native shell output"], +] satisfies [string, Record, string][])( + "collapsed %s tools show the same input preview as the web client", + (name, input, preview) => { + const original = messages.find((message) => message.type === "assistant"); + if (!original) throw new Error("fixture"); + render( + , + ); + expect(screen.getByText(preview)).toBeOnTheScreen(); + expect(screen.queryByText("Tool result")).toBeNull(); + }, +); + test("cross-message grouping respects replies, errors, reasoning visibility and detailed mode", () => { const original = messages.find((message) => message.type === "assistant"); if (!original) throw new Error("fixture"); @@ -197,6 +248,142 @@ test("reasoning can be hidden without hiding replies or tool failures", () => { expect(screen.getByText("Reasoning detail")).toBeOnTheScreen(); }); +test("newest-first pages keep six calls before commentary and five after it", () => { + const original = messages.find((message) => message.type === "assistant"); + if (!original) throw new Error("fixture"); + const tool = original.content.find((part) => part.type === "tool"); + if (!tool) throw new Error("fixture"); + const { retry: _retry, ...assistant } = original; + const calls = (count: number, prefix: string) => + Array.from({ length: count }, (_, index) => ({ + ...tool, + id: `${prefix}-${index}`, + name: index % 2 ? "grep" : "read", + })); + const older = { ...assistant, id: "older", content: calls(6, "before") }; + const mixed = { + ...assistant, + id: "mixed", + content: [{ type: "text" as const, text: "Progress commentary" }, ...calls(5, "after")], + }; + const presentation = buildTranscriptPresentation([mixed, older], false, true); + expect([...presentation.items].reverse()).toMatchObject([ + { type: "activity-group", count: 6 }, + { type: "assistant", content: [{ type: "text", text: "Progress commentary" }] }, + { type: "activity-group", count: 5 }, + ]); + const newer = { ...assistant, id: "newer", content: calls(1, "new") }; + const streamed = buildTranscriptPresentation([newer, mixed, older], false, true); + expect(streamed.items[0]).toMatchObject({ id: presentation.items[0]?.id, count: 6 }); + expect( + presentation.items[0]?.type === "activity-group" && + presentation.items[0].messages.flatMap((message) => + message.type === "assistant" + ? message.content.map((part) => (part.type === "tool" ? part.id : "")) + : [], + ), + ).toEqual(["after-0", "after-1", "after-2", "after-3", "after-4"]); +}); + +test("updates remain inspectable and successful idle rows do not divide a turn footer", () => { + const original = messages.find((message) => message.type === "assistant"); + if (!original) throw new Error("fixture"); + const { retry: _retry, ...assistant } = original; + const user: SessionMessageInfo = { + type: "user", + id: "start", + text: "Prompt", + time: { created: 0 }, + }; + const first = { + ...assistant, + id: "first", + content: [{ type: "text" as const, text: "Publishing" }], + time: { created: 1000, completed: 5000 }, + }; + const idle: SessionMessageInfo = { + type: "idle", + id: "idle", + outcome: "succeeded", + time: { created: 5001 }, + }; + const update: SessionMessageInfo = { + type: "synthetic", + id: "update", + description: "long background command", + text: "Background result", + time: { created: 6000 }, + }; + const last = { + ...first, + id: "last", + content: [{ type: "text" as const, text: "Published" }], + time: { created: 100000, completed: 113000 }, + }; + const result = buildTranscriptPresentation([last, update, idle, first, user], false, true); + expect(result.items.map((item) => item.type)).toEqual([ + "assistant", + "updates-group", + "assistant", + "user", + ]); + expect([...result.footers]).toEqual([["last", 113000]]); + const group = result.items[1]; + if (group?.type !== "updates-group") throw new Error("expected updates"); + render(); + expect(screen.queryByText("long background command")).toBeNull(); + expect(screen.queryByText("Background result")).toBeNull(); + fireEvent.press(screen.getByRole("button", { name: "Updates Show" })); + expect(screen.getByText("long background command")).toBeOnTheScreen(); + expect(screen.getByText("Background result")).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Updates Hide" })); + expect(screen.queryByText("Background result")).toBeNull(); + expect( + buildTranscriptPresentation([last, first], false, true).footers.get("last"), + ).toBeUndefined(); + expect(buildTranscriptPresentation([last, update, idle, first, user], true, true).items).toEqual([ + last, + update, + idle, + first, + user, + ]); +}); + +test("turn footer timing resets at a user prompt and failed outcomes stay visible", () => { + const original = messages.find((message) => message.type === "assistant"); + if (!original) throw new Error("fixture"); + const { retry: _retry, ...assistant } = original; + const user: SessionMessageInfo = { + type: "user", + id: "user", + text: "Prompt", + time: { created: 0 }, + }; + const reply = { + ...assistant, + content: [{ type: "text" as const, text: "Reply" }], + time: { created: 1000, completed: 2000 }, + }; + const failed: SessionMessageInfo = { + type: "idle", + id: "failed", + outcome: "failed", + time: { created: 2100 }, + }; + const second = { ...reply, id: "second", time: { created: 3000, completed: 8000 } }; + const result = buildTranscriptPresentation( + [second, { ...user, id: "next", time: { created: 2500 } }, failed, reply, user], + false, + false, + ); + expect([...result.footers]).toEqual([ + [reply.id, 2000], + ["second", 5500], + ]); + expect(result.items).toContain(failed); +}); + test("compact system notices expand and detailed mode shows their content", () => { const message = messages.find((item) => item.type === "synthetic"); if (!message) throw new Error("fixture"); @@ -208,6 +395,64 @@ test("compact system notices expand and detailed mode shows their content", () = expect(screen.getByText("Generated")).toBeOnTheScreen(); }); +test("skill calls use the generated id or metadata name in a compact loaded label", () => { + const original = messages.find((message) => message.type === "assistant"); + if (!original) throw new Error("fixture"); + const { retry: _retry, ...assistant } = original; + const tool = { + type: "tool" as const, + id: "tool_skill", + name: "skill", + time: { created: 1 }, + state: { + status: "completed" as const, + input: { id: "native-ui" }, + content: [{ type: "text" as const, text: "Skill instructions" }] as [ + { type: "text"; text: string }, + ], + }, + }; + const view = render( + , + ); + expect(screen.getByText("Loaded native-ui skill")).toBeOnTheScreen(); + expect(screen.queryByText(/"id":/)).toBeNull(); + expect(screen.queryByText("Skill instructions")).toBeNull(); + fireEvent.press(screen.getByRole("button", { name: "Loaded native-ui skill" })); + expect(screen.getByText("Skill instructions")).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Loaded native-ui skill" })); + expect(screen.queryByText("Skill instructions")).toBeNull(); + view.rerender( + , + ); + expect(screen.getByText("Loaded Native UI skill")).toBeOnTheScreen(); + view.rerender( + , + ); + expect(screen.getByText("Skill missing")).toBeOnTheScreen(); +}); + test("detailed mode renders grouped tool executions individually", () => { const original = messages.find((item) => item.type === "assistant"); if (original?.type !== "assistant") throw new Error("fixture"); @@ -232,6 +477,7 @@ test.each([ ] as const)("renders the idle outcome $outcome", ({ outcome, label }) => { render( , ); @@ -501,7 +747,7 @@ test("shows the responding model display name and measured runtime in the footer }} />, ); - expect(screen.getByText("Plan · Model One · 28s")).toHaveStyle({ fontSize: 15, lineHeight: 22 }); + expect(screen.getByText("Plan · Model One · 28s")).toHaveStyle({ fontSize: 14, lineHeight: 20 }); }); test("renders fenced assistant code without markdown fence markers", () => { @@ -863,7 +1109,7 @@ test("groups completed assistant activity and places narrative metadata in the f expect(screen.getByText("Ran")).toBeOnTheScreen(); expect(screen.getByText("pnpm test")).toBeOnTheScreen(); fireEvent.press(screen.getByRole("button", { name: /^Ran/ })); - expect(screen.getByText("$ pnpm test")).toBeOnTheScreen(); + expect(screen.getByText("pnpm test")).toBeOnTheScreen(); expect(screen.getByText("Build · model-1 · 2s")).toBeOnTheScreen(); }); @@ -893,7 +1139,7 @@ test("hides repeated assistant metadata for a tool-only turn", () => { />, ); - expect(screen.getByText("Used Skill")).toBeOnTheScreen(); + expect(screen.getByText("Loaded review skill")).toBeOnTheScreen(); expect(screen.queryByText("Build · model-1 · 500ms")).toBeNull(); }); diff --git a/apps/mobile/src/screens/session-transcript.tsx b/apps/mobile/src/screens/session-transcript.tsx index 11a1f1c..b0e397d 100644 --- a/apps/mobile/src/screens/session-transcript.tsx +++ b/apps/mobile/src/screens/session-transcript.tsx @@ -16,6 +16,7 @@ import { CopyTextButton } from "../components/copy-text-button"; import { SelectableTranscriptText } from "../components/selectable-transcript-text"; import { recordTranscriptRowCommit } from "../state/transcript-performance"; import { markdownPalette, palette, radius, space, typeRamp, typography } from "../theme"; +import { ShellObservation } from "./session-shell-output"; import { getSubagentPresentation, parseSubagentProtocolText, @@ -43,6 +44,11 @@ type ToolOutput = Extract["cont export type TranscriptItem = | SessionMessageInfo + | { + type: "updates-group"; + id: string; + messages: SessionMessageInfo[]; + } | { type: "activity-group"; id: string; @@ -71,7 +77,20 @@ export function groupTranscriptMessages( count, running, }); - } else result.push(...pending); + } else { + for (const message of pending) { + if (isTranscriptUpdate(message)) { + const previous = result.at(-1); + if (previous?.type === "updates-group") previous.messages.push(message); + else + result.push({ + type: "updates-group", + id: `updates:${message.id}`, + messages: [message], + }); + } else result.push(message); + } + } pending = []; count = 0; running = false; @@ -95,6 +114,11 @@ export function groupTranscriptMessages( })); }); for (const message of segments) { + if (message.type === "idle" && message.outcome === "succeeded") continue; + if (isTranscriptUpdate(message)) { + pending.push(message); + continue; + } if ( message.type === "assistant" && !message.error && @@ -119,6 +143,99 @@ export function groupTranscriptMessages( return result; } +function isTranscriptUpdate(message: SessionMessageInfo) { + return message.type === "synthetic" || message.type === "system" || message.type === "skill"; +} + +// The server pages are newest-first; assistant parts inside a message are not. +// Group in reading order, then invert only the resulting rows for the native list. +export function buildTranscriptPresentation( + messages: SessionMessageInfo[], + detailed: boolean, + showReasoning: boolean, +) { + const chronological = [...messages].reverse(); + const items = groupTranscriptMessages(chronological, detailed, showReasoning); + const footers = new Map(); + let hasTurnStart = false; + let start: number | undefined; + let end: number | undefined; + let incomplete = false; + let lastResponse: string | undefined; + const finish = () => { + if (lastResponse) { + footers.set( + lastResponse, + hasTurnStart && !incomplete && start !== undefined && end !== undefined + ? Math.max(0, end - start) + : undefined, + ); + } + start = undefined; + end = undefined; + incomplete = false; + lastResponse = undefined; + }; + for (const item of items) { + if (item.type === "user") { + finish(); + hasTurnStart = true; + start = item.time.created; + continue; + } + const messages = + item.type === "activity-group" || item.type === "updates-group" ? item.messages : [item]; + for (const message of messages) { + if (message.type !== "assistant") continue; + if (message.time.completed === undefined) incomplete = true; + else end = Math.max(end ?? message.time.completed, message.time.completed); + if ( + item.type === "assistant" && + message.content.some((part) => part.type === "text" && part.text.trim()) + ) { + lastResponse = item.id; + } + } + } + finish(); + return { items: items.reverse(), footers }; +} + +export function TranscriptUpdatesGroup({ + item, + largeText, + onOpenSubagent, +}: { + item: Extract; + largeText: boolean; + onOpenSubagent: (sessionID: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + return ( + + setExpanded((value) => !value)} + /> + {expanded + ? item.messages.map((message) => ( + + )) + : null} + + ); +} + export function activitySummary(messages: SessionMessageInfo[]) { const counts = new Map(); let failures = 0; @@ -161,8 +278,8 @@ export function TranscriptActivityGroup({ onPress={() => setExpanded((value) => !value)} style={styles.activityGroupHeader} > - - {activitySummary(item.messages)} + + Used {activitySummary(item.messages).slice(5)} {expanded ? "⌄" : "›"} @@ -194,6 +311,7 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ largeText = false, message, modelName, + turnDuration, onOpenDiff, onOpenSubagent, }: { @@ -204,6 +322,7 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ largeText?: boolean; message: SessionMessageInfo; modelName?: string | undefined; + turnDuration?: number | null | undefined; onOpenDiff?: (() => void) | undefined; onOpenSubagent?: ((sessionID: string) => void) | undefined; }) { @@ -332,18 +451,24 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ {message.content.length === 0 && !message.error ? ( No projected content ) : null} - - {!compactActivity && !hideFooter && hasNarrativeContent(message) ? ( - - ) : null} - {responseText && !hideFooter ? ( - - ) : null} - + {!hideFooter && (responseText || (!compactActivity && hasNarrativeContent(message))) ? ( + + {responseText && !hideFooter ? ( + + ) : null} + {!compactActivity && !hideFooter && hasNarrativeContent(message) ? ( + + ) : null} + + ) : null} ); } @@ -362,7 +487,16 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ ); case "skill": - return ; + return ( + + + + ); case "agent-switched": return ; case "model-switched": @@ -376,6 +510,7 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ /> ); case "idle": + if (!detailed && message.outcome === "succeeded") return null; return ( ); } + if (skillToolNames.has(tool.name.trim().toLocaleLowerCase()) && tool.state.status !== "error") { + const metadata = tool.state.status === "streaming" ? undefined : tool.state.metadata; + const name = + firstInputString(metadata, ["name"]) ?? + firstInputString(toolInputRecord(tool), ["id", "name", "skill"]); + const running = tool.state.status === "running" || tool.state.status === "streaming"; + const content = tool.state.status === "completed" ? tool.state.content : []; + const label = `${running ? "Loading" : "Loaded"} ${name ? `${sanitizeTranscriptText(name, 256)} skill` : "Skill"}`; + return ( + + setExpanded((value) => !value)} + style={styles.loadedSkill} + > + + {running ? "Loading" : "Loaded"}{" "} + + {name ? sanitizeTranscriptText(name, 256) : "Skill"} + + {name ? " skill" : ""} + + + {expanded ? ( + + {keyToolContent(content.slice(0, maxToolOutputs)).map(({ item, key }) => ( + + ))} + + ) : null} + + ); + } const content = tool.state.status === "completed" || tool.state.status === "error" ? (tool.state.content ?? []) @@ -627,8 +806,25 @@ function ToolDisclosure({ const presentation = toolPresentation(tool); const canExpand = canExpandTool(tool); const category = toolCategory(tool); + const metadata = tool.state.status === "streaming" ? undefined : tool.state.metadata; + // A terminal snapshot already contains the authoritative result. Background + // handoffs keep metadata.status="running" even after the command later exits. + const savedShellOutput = + metadata?.status === "exited" || + metadata?.status === "timeout" || + metadata?.status === "killed"; + const shellID = + category === "shell" && !savedShellOutput && typeof metadata?.shellID === "string" + ? metadata.shellID + : undefined; + const input = toolInputRecord(tool); + const commandKey = typeof input?.command === "string" ? "command" : "cmd"; + const inputDetails = + input && category === "shell" + ? Object.fromEntries(Object.entries(input).filter(([key]) => key !== commandKey)) + : input; const label = - !nested && tool.state.status === "completed" + !nested && tool.state.status === "completed" && !shellID ? completedToolLabel(category, presentation.label) : presentation.label; const visibleContent = content.slice(0, maxToolOutputs); @@ -642,7 +838,7 @@ function ToolDisclosure({ > setExpanded((current) => !current)} /> - {expanded && toolInputRecord(tool) ? ( - + {expanded && inputDetails && Object.keys(inputDetails).length > 0 ? ( + + + ) : null} {expanded ? presentation.files.map((file) => ( @@ -668,22 +870,60 @@ function ToolDisclosure({ )) : null} - {expanded && presentation.command ? ( - - {`$ ${presentation.command}`} - + {expanded && category === "shell" ? ( + + {(observation) => ( + + {observation ? ( + + {observation.statusLabel === "Running" && tool.state.status === "completed" + ? "Running in background" + : observation.statusLabel} + + ) : null} + {observation?.error ? ( + + Retry shell output + + ) : null} + {observation?.snapshot?.truncated ? ( + Showing recent output. + ) : null} + + item.type === "text" ? [parseSubagentProtocolText(item.text).text] : [], + ) + .join("\n\n") + } + /> + + )} + ) : null} {expanded ? keyToolContent(visibleContent).map(({ item, key }) => item.type === "text" ? ( - category === "shell" ? ( - - ) : ( - + accessibilityLabel={`${presentation.label} output`} + nestedScrollEnabled + style={styles.toolOutputScroll} + > + + ) ) : ( setExpanded((current) => !current)} /> - {expanded ? ( - - {`$ ${message.command}`} - + {expanded && message.output?.output ? ( + ) : null} - {expanded && message.output?.output ? : null} ); } -function ShellOutput({ text }: { text: string }) { +function ShellOutput({ command, text }: { command?: string | undefined; text: string }) { const safeText = sanitizeTranscriptText(text, text.length); return ( @@ -823,9 +1060,18 @@ function ShellOutput({ text }: { text: string }) { showsVerticalScrollIndicator keyboardShouldPersistTaps="handled" style={styles.shellOutputScroll} - contentContainerStyle={styles.shellOutputContent} > - + {command ? ( + + + {sanitizeTranscriptText(command, maxSanitizedInput)} + + + ) : null} + {safeText} @@ -836,14 +1082,20 @@ function ShellOutput({ text }: { text: string }) { function AssistantFooter({ message, modelName, + turnDuration, }: { message: AssistantMessage; modelName?: string | undefined; + turnDuration?: number | null | undefined; }) { const duration = - message.time.completed !== undefined - ? formatDuration(message.time.completed - message.time.created) - : undefined; + turnDuration === null + ? undefined + : turnDuration !== undefined + ? formatDuration(turnDuration) + : message.time.completed !== undefined + ? formatDuration(message.time.completed - message.time.created) + : undefined; return ( {sanitizeTranscriptText(sentenceCase(message.agent || "Assistant"), 128)} ·{" "} @@ -1323,7 +1575,13 @@ function toolPresentation(tool: AssistantTool) { detail = command; } else if (skillToolNames.has(name)) { label = "Skill"; - detail = firstInputString(input, ["name", "skill"]); + detail = firstInputString(input, ["id", "name", "skill"]); + } else if (name === "execute") { + label = "Execute"; + detail = firstInputString(input, ["code"]); + } else if (name === "webfetch") { + label = "Webfetch"; + detail = firstInputString(input, ["url"]); } return { @@ -1493,17 +1751,33 @@ function keyDisclosureText(entries: string[]) { const styles = StyleSheet.create({ shellOutputBox: { backgroundColor: palette.raised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: palette.border, borderRadius: radius.sm, overflow: "hidden", marginVertical: space.xs, }, - shellOutputToolbar: { alignItems: "flex-end", paddingHorizontal: space.xs }, + shellOutputToolbar: { + position: "absolute", + right: 0, + top: 0, + zIndex: 1, + }, + shellCommandHeader: { + backgroundColor: palette.background, + borderBottomColor: palette.border, + borderBottomWidth: StyleSheet.hairlineWidth, + padding: 12, + paddingRight: 48, + }, + shellCommand: { ...typography.code, color: palette.ink }, shellOutputScroll: { maxHeight: 240 }, - shellOutputText: { ...typography.code, color: palette.dim }, - shellOutputContent: { paddingHorizontal: space.sm, paddingBottom: space.sm }, + shellOutputText: { ...typography.code, color: palette.dim, padding: 12 }, + shellOutputWithoutCommand: { paddingRight: 48 }, + toolOutputScroll: { maxHeight: 240, backgroundColor: palette.raised, borderRadius: radius.sm }, activityGroup: { - marginHorizontal: space.lg, - marginVertical: space.xs, + marginHorizontal: space.md, + marginVertical: 0, overflow: "hidden", }, commandText: { @@ -1515,17 +1789,18 @@ const styles = StyleSheet.create({ activityGroupHeader: { alignItems: "center", flexDirection: "row", - flexWrap: "wrap", + flexWrap: "nowrap", gap: space.sm, - justifyContent: "space-between", - minHeight: 48, + justifyContent: "flex-start", + minHeight: 44, paddingHorizontal: 0, paddingVertical: space.sm, }, activity: { paddingHorizontal: 0, }, - activityAction: { color: palette.signal, fontSize: 11, fontWeight: "700" }, + loadedSkill: { minHeight: 44, justifyContent: "center", paddingVertical: 4 }, + activityAction: { color: palette.dim, fontSize: 14 }, activityError: { borderBottomWidth: 0, marginHorizontal: -space.sm, @@ -1543,7 +1818,7 @@ const styles = StyleSheet.create({ minWidth: 0, }, activityCopyLargeText: { alignItems: "flex-start", flexDirection: "column", gap: 2 }, - activityDetail: { color: palette.dim, flexShrink: 1, fontSize: 13, lineHeight: 18 }, + activityDetail: { color: palette.dim, flexShrink: 1, fontSize: 14, lineHeight: 18 }, activityFile: { ...typography.code, borderTopColor: palette.border, @@ -1561,17 +1836,18 @@ const styles = StyleSheet.create({ paddingVertical: 8, }, activityHeaderLargeText: { alignItems: "flex-start", flexDirection: "column" }, - activityLabel: { ...typography.control, color: palette.ink }, + activityLabel: { color: palette.ink, fontSize: 14, lineHeight: 20, fontWeight: "500" }, + activitySummary: { color: palette.dim, fontSize: 14, lineHeight: 20, flexShrink: 1 }, activityNested: { marginLeft: 0 }, compactActivity: { paddingLeft: space.sm, gap: 2 }, - toolDetails: { paddingLeft: space.md }, - responseFooter: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: space.sm }, - activityStandalone: { marginHorizontal: space.lg, paddingVertical: space.xs }, - assistantFooter: { color: palette.dim, fontSize: 15, lineHeight: 22, marginTop: space.sm }, + toolDetails: { paddingLeft: 0 }, + responseFooter: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: space.xs }, + activityStandalone: { marginHorizontal: space.md, paddingVertical: space.xs }, + assistantFooter: { color: palette.dim, fontSize: 14, lineHeight: 20, flexShrink: 1 }, assistantRow: { - gap: space.sm, - paddingHorizontal: space.lg, - paddingVertical: space.lg, + gap: space.xs, + paddingHorizontal: space.md, + paddingVertical: space.xs, }, attachmentChip: { backgroundColor: palette.background, @@ -1584,14 +1860,11 @@ const styles = StyleSheet.create({ }, attachmentLabel: { color: palette.dim, fontSize: 11, fontWeight: "600" }, attachments: { flexDirection: "row", flexWrap: "wrap", gap: space.xs, marginTop: space.sm }, - bodyText: { color: palette.ink, fontSize: 17, lineHeight: 26 }, + bodyText: { color: palette.ink, fontSize: 16, lineHeight: 23 }, disclosure: { - backgroundColor: palette.card, - borderColor: palette.border, - borderRadius: radius.sm, - borderWidth: 1, + backgroundColor: "transparent", }, - disclosureAction: { color: palette.signal, fontSize: 12, fontWeight: "700" }, + disclosureAction: { color: palette.dim, fontSize: 14 }, disclosureActionLargeText: { alignSelf: "flex-start" }, disclosureHeader: { alignItems: "center", @@ -1600,7 +1873,7 @@ const styles = StyleSheet.create({ gap: space.xs, justifyContent: "space-between", minHeight: 44, - paddingHorizontal: 12, + paddingHorizontal: 0, paddingVertical: 10, }, disclosureHeaderLargeText: { alignItems: "flex-start", flexDirection: "column" }, @@ -1609,7 +1882,7 @@ const styles = StyleSheet.create({ flex: 1, flexShrink: 1, fontSize: 12, - fontWeight: "700", + fontWeight: "500", minWidth: 0, }, disclosureLabelLargeText: { flex: 0, width: "100%" }, @@ -1619,22 +1892,18 @@ const styles = StyleSheet.create({ minHeight: 44, paddingRight: space.md, }, - diffActionLabel: { color: palette.signal, fontSize: 13, fontWeight: "700" }, + diffActionLabel: { color: palette.signal, fontSize: 14, fontWeight: "700" }, errorText: { color: palette.danger, fontSize: 14, lineHeight: 21 }, linkText: { color: markdownPalette.linkText, textDecorationLine: "underline" }, notice: { - borderBottomColor: palette.border, - borderBottomWidth: StyleSheet.hairlineWidth, - marginHorizontal: space.lg, - paddingVertical: 12, + marginHorizontal: space.md, + paddingVertical: space.xs, }, noticeLabel: { ...typography.label, color: palette.dim }, - noticeText: { color: palette.dim, fontSize: 13, lineHeight: 19, marginTop: 5 }, + noticeText: { color: palette.dim, fontSize: 14, lineHeight: 19, marginTop: 5 }, omittedText: { color: palette.dim, fontSize: 11, marginTop: 7 }, outputText: { ...typography.code, - borderTopColor: palette.border, - borderTopWidth: StyleSheet.hairlineWidth, color: palette.dim, padding: 12, }, @@ -1643,7 +1912,7 @@ const styles = StyleSheet.create({ ...typography.label, color: markdownPalette.reasoning, }, - reasoningText: { color: palette.dim, fontSize: 13, lineHeight: 19 }, + reasoningText: { color: palette.dim, fontSize: 14, lineHeight: 19 }, statusText: { color: palette.dim, fontSize: 12 }, subagent: { backgroundColor: palette.card, @@ -1664,7 +1933,7 @@ const styles = StyleSheet.create({ paddingVertical: 8, }, subagentAction: { justifyContent: "center", minHeight: 44, paddingRight: space.md }, - subagentActionLabel: { color: palette.signal, fontSize: 13, fontWeight: "700" }, + subagentActionLabel: { color: palette.signal, fontSize: 14, fontWeight: "700" }, subagentActions: { flexDirection: "row", flexWrap: "wrap" }, subagentAgent: { color: palette.dim, fontSize: 12 }, subagentHeading: { @@ -1692,13 +1961,14 @@ const styles = StyleSheet.create({ textActionLabel: { color: palette.signal, fontSize: 12, fontWeight: "700" }, userBubble: { backgroundColor: palette.prompt, - borderRadius: 16, - maxWidth: "80%", - padding: 14, + borderRadius: radius.sm, + maxWidth: "90%", + paddingHorizontal: 12, + paddingVertical: 8, }, userBubbleLargeText: { maxWidth: "100%" }, // Supply intrinsic text width to Yoga; the native selection view supplies height. textWidthMeasurement: { height: 0, overflow: "hidden", opacity: 0 }, - userRow: { alignItems: "flex-end", paddingHorizontal: space.lg, paddingVertical: space.sm }, - userText: { color: palette.ink, fontSize: 17, lineHeight: 25 }, + userRow: { alignItems: "flex-end", paddingHorizontal: space.md, paddingVertical: space.md }, + userText: { color: palette.ink, fontSize: 16, lineHeight: 23 }, }); diff --git a/apps/mobile/src/screens/transcript-markdown.tsx b/apps/mobile/src/screens/transcript-markdown.tsx index 9ae63c7..93a20be 100644 --- a/apps/mobile/src/screens/transcript-markdown.tsx +++ b/apps/mobile/src/screens/transcript-markdown.tsx @@ -289,10 +289,10 @@ const styles = StyleSheet.create({ backgroundColor: palette.raised, }, link: { color: markdownPalette.linkText, textDecorationLine: "underline" }, - heading: { fontWeight: "600", fontSize: 21, lineHeight: 29, marginTop: space.sm }, - heading1: { fontSize: 26, lineHeight: 34 }, - heading2: { fontSize: 23, lineHeight: 31 }, - headingSmall: { fontSize: 18, lineHeight: 26 }, + heading: { fontWeight: "600", fontSize: 18, lineHeight: 26, marginTop: space.sm }, + heading1: { fontSize: 22, lineHeight: 30 }, + heading2: { fontSize: 20, lineHeight: 28 }, + headingSmall: { fontSize: 16, lineHeight: 24 }, codeBlock: { backgroundColor: palette.card, borderColor: palette.border, diff --git a/apps/mobile/src/screens/use-session-execution.test.tsx b/apps/mobile/src/screens/use-session-execution.test.tsx index a9ccf96..59a60da 100644 --- a/apps/mobile/src/screens/use-session-execution.test.tsx +++ b/apps/mobile/src/screens/use-session-execution.test.tsx @@ -265,7 +265,8 @@ test("requires explicit active-turn delivery and applies inbox and execution con await waitFor(() => expect(mockInterrupt).toHaveBeenCalledTimes(1)); expect(mockInterrupt.mock.calls[0]?.[2]).toBe(false); act(() => hook.result.current.background()); - await waitFor(() => expect(mockBackground).toHaveBeenCalledTimes(1)); + expect(hook.result.current.canBackground).toBe(false); + expect(mockBackground).not.toHaveBeenCalled(); act(() => hook.result.current.wait()); await waitFor(() => expect(mockWait).toHaveBeenCalledTimes(1)); }); @@ -293,6 +294,64 @@ test("submits a command through the command endpoint with the existing admission await waitFor(() => expect(clearDraft).toHaveBeenCalledTimes(1)); }); +test("background control follows a live foreground tool and rejects completed or stale work", async () => { + mockListActive.mockResolvedValue({ ses_a: { type: "running" } }); + const queryClient = createQueryClient(); + const message: SessionMessageInfo = { + type: "assistant", + id: "msg_blocking", + agent: "build", + model: { id: "model", providerID: "provider" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool_shell", + name: "shell", + time: { created: 1 }, + state: { status: "running", input: { command: "test" }, metadata: {} }, + }, + ], + }; + const hook = renderHook( + ({ messages }: { messages: SessionMessageInfo[] }) => + useSessionExecution({ + ...executionOptions("ses_a"), + messages, + }), + { initialProps: { messages: [message] }, wrapper: queryWrapper(queryClient) }, + ); + await waitFor(() => expect(hook.result.current.canBackground).toBe(true)); + act(() => hook.result.current.background()); + await waitFor(() => expect(mockBackground).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(hook.result.current.busyAction).toBeUndefined()); + const part = message.content[0]; + if (part?.type !== "tool" || part.state.status !== "running") throw new Error("fixture"); + for (const changed of [ + { ...message, time: { created: 1, completed: 2 } }, + { ...message, content: [{ ...part, name: "read" }] }, + { + ...message, + content: [{ ...part, state: { ...part.state, metadata: { background: true } } }], + }, + { ...message, content: [{ ...part, state: { status: "streaming" as const, input: "{}" } }] }, + ]) { + hook.rerender({ messages: [changed] }); + expect(hook.result.current.canBackground).toBe(false); + act(() => hook.result.current.background()); + expect(mockBackground).toHaveBeenCalledTimes(1); + } + hook.rerender({ + messages: [ + { type: "idle", id: "msg_idle", outcome: "succeeded", time: { created: 2 } }, + message, + ], + }); + expect(hook.result.current.canBackground).toBe(false); + hook.rerender({ messages: [{ ...message, content: [{ ...part, name: "subagent" }] }] }); + expect(hook.result.current.canBackground).toBe(true); +}); + test("offers an explicit duplicate-risk retry when a command response is lost", async () => { mockCommand.mockRejectedValueOnce(new TypeError("Network request failed")); const clearDraft = jest.fn(); diff --git a/apps/mobile/src/screens/use-session-execution.ts b/apps/mobile/src/screens/use-session-execution.ts index 5f5c85b..424add6 100644 --- a/apps/mobile/src/screens/use-session-execution.ts +++ b/apps/mobile/src/screens/use-session-execution.ts @@ -55,6 +55,7 @@ import { reconcilePromptAdmission, } from "./prompt-admission-model"; import type { ComposerSubmitIntent } from "./session-composer-model"; +import { hasForegroundBackgroundableTool } from "./session-transcript-model"; type SessionExecutionOptions = { client: OpenCodeClient | undefined; @@ -171,6 +172,8 @@ export function useSessionExecution({ queryKey: [...openCodeQueryKeys.agents(scopedConnectionId, location), "default"], }); const active = Boolean(activeSessionsQuery.data?.[sessionID]); + const canBackground = + enabled && active && !activeSessionsQuery.isError && hasForegroundBackgroundableTool(messages); const executionStateReady = activeSessionsQuery.isSuccess && inboxQuery.isSuccess; const inbox = inboxQuery.data ?? []; const admissions = admissionsQuery.data ?? []; @@ -787,6 +790,7 @@ export function useSessionExecution({ return { active, + canBackground, allowRetry: (admissionID: string) => void allowRetry(admissionID), admissions, agents, @@ -878,7 +882,7 @@ export function useSessionExecution({ } function mutateControl(action: "background" | "interrupt" | "wait") { - if (!client) return; + if (!client || (action === "background" && (!canBackground || busyAction))) return; controlMutation.mutate({ action, requestAdmissionKey: admissionKey, diff --git a/apps/mobile/src/screens/workspace-screen.integration.test.tsx b/apps/mobile/src/screens/workspace-screen.integration.test.tsx index a403e2f..3064ccd 100644 --- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx +++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx @@ -13,6 +13,7 @@ import { type InfiniteData, QueryClient, QueryClientProvider } from "@tanstack/r import { act, fireEvent, render, screen, waitFor } from "@testing-library/react-native"; import type { ReactNode } from "react"; import { Dimensions, FlatList, Platform, RefreshControl } from "react-native"; +import { ConnectionEventQueryBridge } from "../state/connection-event-query-bridge"; import { openCodeQueryKeys } from "../state/open-code-query-keys"; import { WorkspaceSelectionProvider } from "../state/workspace-selection-context"; import { SessionScreen, WorkspaceScreen } from "./workspace-screen"; @@ -27,6 +28,23 @@ const location = { }; const mockListForms = jest.fn(async () => ({ data: [], location })); const mockListPermissions = jest.fn(async () => ({ data: [], location })); +const mockGetShell = jest.fn(async () => ({ + location, + data: { + id: "sh_background", + status: "running", + command: "build", + cwd: "/workspace", + shell: "sh", + file: "/output", + metadata: {}, + time: { started: 1 }, + }, +})); +const mockShellOutput = jest.fn(async () => ({ + location, + data: { output: "Build progress 50%", cursor: 18, size: 18, truncated: false }, +})); const mockReplyPermission = jest.fn(); const mockSetLocation = jest.fn(); const mockWorkspaceRefetch = jest.fn<() => Promise>(async () => undefined); @@ -58,6 +76,11 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({ tokens: { cache: { read: 0, write: 0 }, input: 0, output: 0, reasoning: 0 }, })), getOpenCodeSessionMessage: jest.fn(), + getOpenCodeShell: () => mockGetShell(), + getOpenCodeShellOutput: () => mockShellOutput(), + isShellNotFoundError: (error: unknown) => + (error as { _tag?: string })?._tag === "ShellNotFoundError", + maxShellOutputBytes: 64 * 1024, getOpenCodeVcsDiff: jest.fn(async () => ({ data: [] })), getOpenCodeVcs: jest.fn(async () => ({ data: { branch: { current: "docs/mobile-workflow-screenshots" } }, @@ -885,6 +908,100 @@ test("waits for and adopts a moved session's authoritative location", async () = } }); +test("expanded background shell follows command output after the session is idle", async () => { + mockListMessages.mockImplementationOnce(async () => ({ + cursor: {}, + data: [ + { + type: "assistant", + id: "msg_background", + agent: "build", + model: { id: "model-1", providerID: "provider" }, + time: { created: 1, completed: 2 }, + content: [ + { + type: "tool", + id: "tool_background", + name: "shell", + time: { created: 1, completed: 2 }, + state: { + status: "completed", + input: { command: "build" }, + metadata: { status: "running", shellID: "sh_background" }, + content: [ + { + type: "text", + text: "Command moved to the background. You will be notified when it finishes.", + }, + ], + }, + }, + ], + }, + ], + })); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + const view = render( + + + , + ); + try { + fireEvent.press(await screen.findByRole("button", { name: "1 tool calls" })); + fireEvent.press(screen.getByRole("button", { name: /Shell.*Show/ })); + expect(await screen.findByText("Build progress 50%")).toBeOnTheScreen(); + expect(screen.queryByText(/Command moved to the background/)).toBeNull(); + mockGetShell.mockResolvedValueOnce({ + location, + data: { + id: "sh_background", + status: "exited", + command: "build", + cwd: "/workspace", + shell: "sh", + file: "/output", + metadata: {}, + time: { started: 1 }, + }, + }); + mockShellOutput.mockResolvedValueOnce({ + location, + data: { + output: "\nBuild complete", + cursor: 33, + size: 33, + truncated: false, + }, + }); + act(() => + new ConnectionEventQueryBridge(queryClient, "connection-1", (flush) => flush()).apply({ + type: "shell.exited", + id: "evt_shell", + created: 3, + location, + data: { id: "sh_background", status: "exited", exit: 0 }, + }), + ); + expect(await screen.findByText("Build progress 50%\nBuild complete")).toBeOnTheScreen(); + } finally { + view.unmount(); + queryClient.clear(); + } +}); + test("shows running background subagents and opens their child sessions", async () => { mockListMessages.mockImplementationOnce(async () => ({ cursor: {}, diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index 911f85a..c600e18 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -69,11 +69,13 @@ import { FormRequestList } from "./form-request-list"; import { SessionComposer } from "./session-composer"; import { loadOpenCodeSessionTreeIds } from "./session-deletion"; import { SessionExecutionPanel } from "./session-execution-panel"; +import { SessionShellScope } from "./session-shell-output"; import { - groupTranscriptMessages, + buildTranscriptPresentation, SessionTranscriptRow, TranscriptActivityGroup, type TranscriptItem, + TranscriptUpdatesGroup, } from "./session-transcript"; import { resolveTranscriptLiveFollow, @@ -478,6 +480,15 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { } export function SessionScreen({ navigation, route }: SessionProps) { + const [screenFocused, setScreenFocused] = useState(() => navigation.isFocused?.() ?? true); + useEffect(() => { + const focus = navigation.addListener?.("focus", () => setScreenFocused(true)); + const blur = navigation.addListener?.("blur", () => setScreenFocused(false)); + return () => { + focus?.(); + blur?.(); + }; + }, [navigation]); const transcriptPreferences = useTranscriptPreferences(); const runtime = useConnectionRuntime(); const modelFavorites = useModelFavorites(runtime.connectionId, runtime.connectionUpdatedAtMs); @@ -602,7 +613,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { ? ({ state: "loading" } as const) : ({ state: "none" } as const); const messages = flattenTranscriptPages(messagesQuery.data?.pages); - const transcriptItems = groupTranscriptMessages( + const { items: transcriptItems, footers: transcriptFooters } = buildTranscriptPresentation( messages, transcriptPreferences.detailed, transcriptPreferences.reasoning, @@ -878,6 +889,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { active={execution.active} admissions={execution.admissions} busyAction={execution.busyAction} + canBackground={execution.canBackground} formRequests={ sessionForms.length > 0 ? ( - message.id} - ListEmptyComponent={ - messagesQuery.isPending ? ( - - + + message.id} + ListEmptyComponent={ + messagesQuery.isPending ? ( + + + + ) : messagesQuery.isError ? ( + + + + ) : null + } + ListFooterComponent={ + + {sessionQuery.isPending ? : null} + {sessionQuery.isError ? ( + + ) : null} + {canLoadOlder ? ( + { + if (!messagesQuery.isFetchingNextPage) void messagesQuery.fetchNextPage(); + }} + /> + ) : null} + {!canLoadOlder && messagesQuery.hasNextPage ? ( + + Older messages are not loaded on this device. + + ) : null} - ) : messagesQuery.isError ? ( - - + item.type === "activity-group" ? ( + 0 + ? "permission" + : sessionForms.length > 0 + ? "input" + : undefined } + item={item} + largeText={largeText} + showReasoning={transcriptPreferences.reasoning} + onOpenDiff={openDiff} + onOpenSubagent={openSubagent} /> - - ) : null - } - ListFooterComponent={ - - {sessionQuery.isPending ? : null} - {sessionQuery.isError ? ( - - ) : null} - {canLoadOlder ? ( - { - if (!messagesQuery.isFetchingNextPage) void messagesQuery.fetchNextPage(); - }} + ) : item.type === "updates-group" ? ( + - ) : null} - {!canLoadOlder && messagesQuery.hasNextPage ? ( - - Older messages are not loaded on this device. - - ) : null} - - } - maintainVisibleContentPosition={{ minIndexForVisible: 0 }} - maxToRenderPerBatch={12} - onContentSizeChange={scheduleLiveEdgeScroll} - onLayout={scheduleLiveEdgeScroll} - onMomentumScrollBegin={handleMomentumScrollBegin} - onMomentumScrollEnd={handleMomentumScrollEnd} - onScroll={handleTranscriptScroll} - onScrollBeginDrag={handleScrollBeginDrag} - onScrollEndDrag={handleScrollEndDrag} - ref={transcriptListRef} - renderItem={({ item, index }) => - item.type === "activity-group" ? ( - 0 - ? "permission" - : sessionForms.length > 0 - ? "input" + ) : ( + + model.id === item.model.id && + model.providerID === item.model.providerID, + )?.name : undefined - } - item={item} - largeText={largeText} - showReasoning={transcriptPreferences.reasoning} - onOpenDiff={openDiff} - onOpenSubagent={openSubagent} - /> - ) : ( - - model.id === item.model.id && model.providerID === item.model.providerID, - )?.name - : undefined - } - onOpenDiff={openDiff} - onOpenSubagent={openSubagent} - /> - ) - } - scrollEventThrottle={16} - style={styles.transcriptList} - updateCellsBatchingPeriod={40} - windowSize={7} - /> + } + onOpenDiff={openDiff} + onOpenSubagent={openSubagent} + /> + ) + } + scrollEventThrottle={16} + style={styles.transcriptList} + updateCellsBatchingPeriod={40} + windowSize={7} + /> + {(!liveFollowEnabled || latestJumpPending) && messages.length > 0 ? ( { queryClient.clear(); }); -test("does not refetch connection queries for shell advisory events", () => { +test("shell lifecycle events refresh shell output only within the owning connection and location", () => { const queryClient = new QueryClient(); const invalidate = jest.spyOn(queryClient, "invalidateQueries"); const scheduled: Array<() => void> = []; @@ -233,18 +233,34 @@ test("does not refetch connection queries for shell advisory events", () => { scheduled.push(callback); }); + const affected = openCodeQueryKeys.shell("connection-1", { directory: "/workspace" }, "sh_test"); + const unaffected = [ + openCodeQueryKeys.shell("connection-2", { directory: "/workspace" }, "sh_test"), + openCodeQueryKeys.shell("connection-1", { directory: "/other" }, "sh_test"), + openCodeQueryKeys.shell( + "connection-1", + { directory: "/workspace", workspaceID: "other" }, + "sh_test", + ), + openCodeQueryKeys.messages("connection-1", { directory: "/workspace" }, "ses_test", {}), + ]; + for (const key of [affected, ...unaffected]) queryClient.setQueryData(key, {}); + for (const [index, type] of ["shell.created", "shell.exited", "shell.deleted"].entries()) { bridge.apply({ created: index + 1, - data: {}, + data: { id: "sh_test", status: "exited" }, id: `event-shell-${index}`, location: { directory: "/workspace" }, type, } as unknown as OpenCodeEvent); } - expect(scheduled).toHaveLength(0); - expect(invalidate).not.toHaveBeenCalled(); + expect(scheduled).toHaveLength(1); + scheduled[0]?.(); + expect(invalidate).toHaveBeenCalledTimes(1); + expect(queryClient.getQueryState(affected)?.isInvalidated).toBe(true); + for (const key of unaffected) expect(queryClient.getQueryState(key)?.isInvalidated).toBe(false); queryClient.clear(); }); diff --git a/apps/mobile/src/state/connection-event-query-bridge.ts b/apps/mobile/src/state/connection-event-query-bridge.ts index b6ce534..351032f 100644 --- a/apps/mobile/src/state/connection-event-query-bridge.ts +++ b/apps/mobile/src/state/connection-event-query-bridge.ts @@ -23,6 +23,7 @@ type InvalidationRoot = | "messages" | "permissions" | "sessions" + | "shell" | "vcs"; type InvalidationTarget = { location?: LocationRef; root: InvalidationRoot; sessionId?: string }; type PendingTranscript = { @@ -264,6 +265,12 @@ export function eventRequiresConnectionSnapshot(event: OpenCodeEvent) { } function eventInvalidationRoot(event: OpenCodeEvent): InvalidationRoot | undefined { + if ( + event.type === "shell.created" || + event.type === "shell.exited" || + event.type === "shell.deleted" + ) + return "shell"; if (advisoryLocationEventTypes.has(event.type)) return undefined; if (event.type === "vcs.branch.updated") return "vcs"; if (inboxEventTypes.has(event.type)) return "inbox"; @@ -286,13 +293,7 @@ function eventInvalidationRoot(event: OpenCodeEvent): InvalidationRoot | undefin return "connection"; } -const advisoryLocationEventTypes = new Set([ - "filesystem.changed", - "server.connected", - "shell.created", - "shell.deleted", - "shell.exited", -]); +const advisoryLocationEventTypes = new Set(["filesystem.changed", "server.connected"]); const inboxEventTypes = new Set([ "session.inbox.enqueued", diff --git a/apps/mobile/src/state/open-code-query-keys.ts b/apps/mobile/src/state/open-code-query-keys.ts index b09c2da..33126eb 100644 --- a/apps/mobile/src/state/open-code-query-keys.ts +++ b/apps/mobile/src/state/open-code-query-keys.ts @@ -181,6 +181,9 @@ export const openCodeQueryKeys = { skills(connectionId: string, location: LocationRef) { return [...locationKey(connectionId, location), "skills"] as const; }, + shell(connectionId: string, location: LocationRef, id: string) { + return [...locationKey(connectionId, location), "shell", id] as const; + }, }; function connectionKey(connectionId: string) { diff --git a/apps/mobile/src/theme.ts b/apps/mobile/src/theme.ts index 827b8fb..842b4ab 100644 --- a/apps/mobile/src/theme.ts +++ b/apps/mobile/src/theme.ts @@ -1,17 +1,16 @@ import { Platform } from "react-native"; -// PR #33's charcoal cards and restrained content accents are the mobile baseline. -// Keep color for links and feedback; navigation and primary controls are monochrome. +// OpenCode's mobile web palette, with native system fonts and touch targets. export const palette = { accent: "#8ab4f8", activity: "#56b6c2", - background: "#000000", + background: "#161616", border: "#2c2c2c", - card: "#141414", + card: "#242424", danger: "#ff6b81", dim: "#a0a0a0", info: "#8ab4f8", - ink: "#eeeeee", + ink: "#fafafa", raised: "#242424", prompt: "#3a3a3a", signal: "#ffffff", @@ -30,11 +29,11 @@ export const diffPalette = { } as const; export const markdownPalette = { - code: "#a8d5ba", - reasoning: "#c4b5fd", + code: "#fafafa", + reasoning: "#a0a0a0", link: "#8ab4f8", linkText: "#8ab4f8", - strong: "#eeeeee", + strong: "#fafafa", } as const; export const space = { @@ -77,7 +76,7 @@ export const typography = { label: { fontSize: 12, lineHeight: 18, fontWeight: "600" }, code: { fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }), - fontSize: 13, + fontSize: 14, lineHeight: 20, }, } as const; diff --git a/packages/opencode-adapter/src/index.test.ts b/packages/opencode-adapter/src/index.test.ts index 95f4ee0..f782e44 100644 --- a/packages/opencode-adapter/src/index.test.ts +++ b/packages/opencode-adapter/src/index.test.ts @@ -20,9 +20,12 @@ import { getOpenCodeLocation, getOpenCodeSession, getOpenCodeSessionMessage, + getOpenCodeShell, + getOpenCodeShellOutput, getOpenCodeVcs, getOpenCodeVcsDiff, interruptOpenCodeSession, + isShellNotFoundError, listActiveOpenCodeSessions, listOpenCodeAgents, listOpenCodeCommands, @@ -34,6 +37,7 @@ import { listOpenCodeSessionInbox, listOpenCodeSessions, listOpenCodeSkills, + maxShellOutputBytes, normalizeOpenCodeBaseUrl, openCodeDirectoryExists, openEventStreamGeneration, @@ -54,6 +58,56 @@ import { waitForOpenCodeSession, } from "./index"; +describe("background shell output", () => { + const info = { + id: "sh_test", + status: "running", + command: "build", + cwd: "/workspace", + shell: "sh", + file: "/output", + metadata: {}, + time: { started: 1 }, + }; + it("reads generated shell endpoints with exact location and bounded byte cursors", async () => { + const fixture = createFakeOpenCodeApi({ + shells: { sh_test: { info, output: "ø\nBuild complete" } }, + }); + const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch }); + const location = { directory: "/workspace/child" }; + expect((await getOpenCodeShell(client, location, "sh_test")).data).toEqual(info); + expect( + (await getOpenCodeShellOutput(client, location, "sh_test", { cursor: 3, limit: 1_000_000 })) + .data.output, + ).toBe("Build complete"); + expect(fixture.requests.at(-1)).toMatchObject({ + path: "/api/shell/sh_test/output", + query: { + "location[directory]": ["/workspace/child"], + cursor: ["3"], + limit: [String(maxShellOutputBytes)], + }, + }); + await expect( + getOpenCodeShellOutput(client, location, "sh_test", { cursor: -1 }), + ).rejects.toThrow("INVALID_SHELL_OUTPUT_RANGE"); + }); + it("preserves typed missing-shell errors and rejects malformed output", async () => { + const fixture = createFakeOpenCodeApi({ + shells: { sh_test: { info, output: "" } }, + shellOutputResponse: { output: "bad", cursor: -1, size: 0, truncated: false }, + }); + const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch }); + await expect( + getOpenCodeShellOutput(client, { directory: "/workspace" }, "sh_test"), + ).rejects.toThrow("MALFORMED_SHELL_OUTPUT"); + const error = await getOpenCodeShell(client, { directory: "/workspace" }, "sh_missing").catch( + (error: unknown) => error, + ); + expect(isShellNotFoundError(error)).toBe(true); + }); +}); + describe("historical directory presence", () => { it("checks the live parent rather than initializing a missing worktree", async () => { const fixture = createFakeOpenCodeApi({ diff --git a/packages/opencode-adapter/src/index.ts b/packages/opencode-adapter/src/index.ts index 2f95cfe..598e2b4 100644 --- a/packages/opencode-adapter/src/index.ts +++ b/packages/opencode-adapter/src/index.ts @@ -225,6 +225,58 @@ export async function getOpenCodeVcs( return response; } +export const maxShellOutputBytes = 64 * 1024; + +export async function getOpenCodeShell( + client: OpenCodeClient, + location: LocationRef, + id: string, + options?: OpenCodeRequestOptions, +) { + const response = await client.shell.get({ id, location: locationInput(location) }, options); + validateResolvedLocation(response.location); + if ( + !isRecord(response.data) || + response.data.id !== id || + !["running", "exited", "timeout", "killed"].includes(response.data.status) + ) { + throw new Error("MALFORMED_SHELL_INFO"); + } + return response; +} + +export async function getOpenCodeShellOutput( + client: OpenCodeClient, + location: LocationRef, + id: string, + options?: OpenCodeRequestOptions & { cursor?: number; limit?: number }, +) { + const cursor = options?.cursor ?? 0; + const limit = Math.min(options?.limit ?? maxShellOutputBytes, maxShellOutputBytes); + if (!Number.isSafeInteger(cursor) || cursor < 0 || !Number.isSafeInteger(limit) || limit < 1) { + throw new Error("INVALID_SHELL_OUTPUT_RANGE"); + } + const response = await client.shell.output( + { id, location: locationInput(location), cursor, limit }, + options?.signal ? { signal: options.signal } : undefined, + ); + validateResolvedLocation(response.location); + const data = response.data; + if ( + !isRecord(data) || + typeof data.output !== "string" || + data.output.length > maxShellOutputBytes || + !Number.isSafeInteger(data.cursor) || + data.cursor < 0 || + !Number.isSafeInteger(data.size) || + data.size < data.cursor || + typeof data.truncated !== "boolean" + ) { + throw new Error("MALFORMED_SHELL_OUTPUT"); + } + return response; +} + export async function getOpenCodeVcsDiff( client: OpenCodeClient, location: LocationRef, @@ -1944,6 +1996,9 @@ export type { SessionMessageInfo, SessionMessagesResponse, SessionsResponse, + ShellGetOutput, + ShellOutputOutput, SkillInfo, } from "@opencode/client"; +export { isShellNotFoundError } from "@opencode/client"; export type { OpenCodeEvent }; diff --git a/packages/test-fixtures/src/index.ts b/packages/test-fixtures/src/index.ts index ca28a89..a5e6e51 100644 --- a/packages/test-fixtures/src/index.ts +++ b/packages/test-fixtures/src/index.ts @@ -21,6 +21,8 @@ export type FakeOpenCodeApiOptions = { serverInfoPath?: "/api/info" | "/api/status"; sessions?: FakeSession[]; skills?: unknown[]; + shells?: Record; + shellOutputResponse?: unknown; vcs?: unknown; vcsDiff?: unknown[]; }; @@ -115,6 +117,26 @@ export function createFakeOpenCodeApi(options: FakeOpenCodeApiOptions = {}) { if (url.pathname === "/api/vcs/diff") { return json({ data: options.vcsDiff ?? [], location: resolvedLocation(options, url) }); } + const shellMatch = url.pathname.match(/^\/api\/shell\/([^/]+)(\/output)?$/); + if (shellMatch && method === "GET") { + const id = decodeURIComponent(shellMatch[1] ?? ""); + const shell = options.shells?.[id]; + if (!shell) return json({ _tag: "ShellNotFoundError", id, message: "Shell not found" }, 404); + if (!shellMatch[2]) + return json({ location: resolvedLocation(options, url), data: shell.info }); + const bytes = new TextEncoder().encode(shell.output); + const start = Math.min(Number(url.searchParams.get("cursor") ?? 0), bytes.length); + const end = Math.min(start + Number(url.searchParams.get("limit") ?? 65536), bytes.length); + return json({ + location: resolvedLocation(options, url), + data: options.shellOutputResponse ?? { + output: new TextDecoder().decode(bytes.slice(start, end)), + cursor: end, + size: bytes.length, + truncated: false, + }, + }); + } if (url.pathname === "/api/agent") { return json({ location: resolvedLocation(options, url), data: options.agents ?? [] }); }