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 ?? [] });
}