From d84b15ad7514751b1d2c0adc80b9fd6db45e7c10 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Tue, 29 Sep 2026 12:13:04 +0200 Subject: [PATCH] Fix mobile transcript presentation and background shell output Align mobile layouts and typography with the OpenCode reference while retaining gray user bubbles. Preserve transcript chronology, collapse updates and skills, and aggregate turn timing. Gate backgrounding on eligible foreground tools. Read shell output independently of completed tool handoff messages and session activity, with bounded buffers, scoped reconciliation, and lifecycle-aware refreshes. Add Execute and Webfetch input previews and regression coverage. --- .../src/components/copy-text-button.tsx | 2 +- apps/mobile/src/components/modal-sheet.tsx | 4 +- .../mobile/src/navigation/root-navigation.tsx | 5 +- .../navigation/workspace-header-actions.tsx | 8 +- apps/mobile/src/screens/app-shell.tsx | 12 +- apps/mobile/src/screens/connection-screen.tsx | 4 +- apps/mobile/src/screens/diff-screen.tsx | 2 +- apps/mobile/src/screens/model-picker.tsx | 11 +- .../mobile/src/screens/new-session-screen.tsx | 6 +- .../screens/notification-pairing-screen.tsx | 4 +- apps/mobile/src/screens/session-composer.tsx | 14 +- .../screens/session-execution-panel.test.tsx | 17 + .../src/screens/session-execution-panel.tsx | 8 +- .../src/screens/session-shell-output.test.tsx | 238 ++++++++++ .../src/screens/session-shell-output.tsx | 159 +++++++ .../src/screens/session-transcript-model.ts | 19 + .../src/screens/session-transcript.test.tsx | 254 +++++++++- .../mobile/src/screens/session-transcript.tsx | 444 ++++++++++++++---- .../src/screens/transcript-markdown.tsx | 8 +- .../screens/use-session-execution.test.tsx | 61 ++- .../src/screens/use-session-execution.ts | 6 +- .../workspace-screen.integration.test.tsx | 117 +++++ apps/mobile/src/screens/workspace-screen.tsx | 270 ++++++----- .../connection-event-query-bridge.test.ts | 24 +- .../state/connection-event-query-bridge.ts | 15 +- apps/mobile/src/state/open-code-query-keys.ts | 3 + apps/mobile/src/theme.ts | 17 +- packages/opencode-adapter/src/index.test.ts | 54 +++ packages/opencode-adapter/src/index.ts | 55 +++ packages/test-fixtures/src/index.ts | 22 + 30 files changed, 1590 insertions(+), 273 deletions(-) create mode 100644 apps/mobile/src/screens/session-shell-output.test.tsx create mode 100644 apps/mobile/src/screens/session-shell-output.tsx 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 32cbdf7..d6d5d63 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 ?? [] }); }