Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
18a48b1
feat(server): stream running command output to open rows without pers…
scratchyone Oct 3, 2026
53c4f5d
feat(web,mobile): command rows show output live while they run
scratchyone Oct 3, 2026
1085697
docs: describe live command output
scratchyone Oct 3, 2026
22a616f
feat: command output keeps colours and redraws multi-line progress in…
scratchyone Oct 3, 2026
5c4419e
fix: coloured badges in command output stay readable on both themes
scratchyone Oct 3, 2026
ae8b549
fix: address review findings on live command output
scratchyone Oct 3, 2026
13a5482
fix: second round of review findings on live command output
scratchyone Oct 3, 2026
1fb6f4e
fix: third round of review findings on live command output
scratchyone Oct 3, 2026
d6c0862
fix: bound long lines below a cursor that moved up
scratchyone Oct 3, 2026
497a4bf
fix: keep the output cleaner linear and chunk-independent
scratchyone Oct 5, 2026
095c07a
Merge main into fix/live-command-output
scratchyone Oct 5, 2026
4114828
Merge main into fix/live-command-output
scratchyone Oct 5, 2026
94eecb9
Merge main into fix/live-command-output
scratchyone Oct 5, 2026
f2c5838
fix: fourth round of review findings on live command output
scratchyone Oct 5, 2026
d4436d8
fix: fifth round of review findings on live command output
scratchyone Oct 5, 2026
7de1233
Merge main into fix/live-command-output
scratchyone Oct 6, 2026
3a68490
Merge main into fix/live-command-output
scratchyone Oct 6, 2026
bdc1426
Merge main into fix/live-command-output
scratchyone Oct 6, 2026
eb950cb
Merge main into fix/live-command-output
scratchyone Oct 6, 2026
1686443
Merge main into fix/live-command-output
scratchyone Oct 7, 2026
1425803
Merge main into fix/live-command-output
scratchyone Oct 9, 2026
e81d4e3
fix(server): subagent Bash output streams live, and one-line final ou…
scratchyone Oct 9, 2026
57a29dc
test(server): use asserts for the subagent Bash transcript shape check
scratchyone Oct 9, 2026
97c6c7c
Merge main into fix/live-command-output
scratchyone Oct 9, 2026
d76bf45
Merge main into fix/live-command-output
scratchyone Oct 11, 2026
45f99c6
Merge main into fix/live-command-output
scratchyone Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions apps/mobile/src/features/threads/thread-command-output.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts";
import {
TERMINAL_PALETTES,
terminalOutputSpans,
type TerminalSpanStyle,
} from "@t3tools/shared/terminalOutput";
import type React from "react";
import { useMemo, useRef } from "react";
import { ScrollView, type TextStyle, View } from "react-native";

import { AppText as Text } from "../../components/AppText";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { useServerConfigs } from "../../state/entities";
import { orchestrationEnvironment } from "../../state/orchestration";
import { useEnvironmentQuery } from "../../state/query";

// Within this many points of the end still counts as following the output.
const FOLLOW_SLACK = 8;

function spanStyle(style: TerminalSpanStyle): TextStyle {
const decorations =
style.underline && style.strike
? "underline line-through"
: style.underline
? "underline"
: style.strike
? "line-through"
: undefined;
return {
...(style.color === undefined ? {} : { color: style.color }),
...(style.backgroundColor === undefined ? {} : { backgroundColor: style.backgroundColor }),
...(style.bold ? { fontWeight: "600" as const } : {}),
...(style.dim ? { opacity: 0.65 } : {}),
...(style.italic ? { fontStyle: "italic" as const } : {}),
...(decorations === undefined ? {} : { textDecorationLine: decorations }),
};
}

/**
* Output of one expanded command row: streams while the command runs, then
* shows its final output. Subscribes only while mounted, so collapsed rows and
* other threads receive nothing.
*/
/** True when the environment streams command output; older servers only return final output. */
export function useCommandOutputStreaming(environmentId: EnvironmentId): boolean {
return (
useServerConfigs().get(environmentId)?.environment.capabilities.commandOutputStreaming === true
);
}

export function ThreadCommandOutput(props: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly itemId: TurnItemId;
}) {
const supported = useCommandOutputStreaming(props.environmentId);
const { data, error } = useEnvironmentQuery(
supported
? orchestrationEnvironment.v2.commandOutput({
environmentId: props.environmentId,
input: { threadId: props.threadId, itemId: props.itemId },
})
: null,
);
const scrollRef = useRef<React.ComponentRef<typeof ScrollView>>(null);
// Follow new output until the reader scrolls up; scrolling back down resumes.
const followRef = useRef(true);
const text = data?.output.text ?? "";
const { themeAppearance } = useAppearancePreferences();
const spans = useMemo(
() =>
terminalOutputSpans(text, TERMINAL_PALETTES[themeAppearance === "dark" ? "dark" : "light"]),
[text, themeAppearance],
);
if (text.length === 0) {
const message = error
? `Couldn't load output: ${error}`
: data === null
? "Loading output…"
: data.running
? null
: "No output.";
return message === null ? null : (
<Text className="pt-1.5 font-mono text-2xs leading-normal text-foreground-muted">
{message}
</Text>
);
}
return (
<View className="pt-1.5">
{data?.output.truncated ? (
<Text className="pb-0.5 text-3xs text-foreground-subtle">Earlier output not shown</Text>
) : null}
<ScrollView
ref={scrollRef}
nestedScrollEnabled
directionalLockEnabled
showsVerticalScrollIndicator
className="max-h-60"
contentContainerStyle={{ paddingRight: 8 }}
scrollEventThrottle={100}
onScroll={(event) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
followRef.current =
contentSize.height - contentOffset.y - layoutMeasurement.height <= FOLLOW_SLACK;
}}
onContentSizeChange={() => {
if (followRef.current) scrollRef.current?.scrollToEnd({ animated: false });
}}
>
<Text selectable className="font-mono text-2xs leading-normal text-foreground-muted">
{spans.map((span, index) =>
span.style === null ? (
span.text
) : (
// Spans are rebuilt from scratch on every update and never reorder.
// oxlint-disable-next-line react/no-array-index-key
<Text key={index} style={spanStyle(span.style)}>
{span.text}
</Text>
),
)}
</Text>
</ScrollView>
</View>
);
}
50 changes: 34 additions & 16 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
WorkLogPressable,
} from "./work-log-layout";
import { QuestionAnswerHistory } from "./QuestionAnswerHistory";
import { ThreadCommandOutput, useCommandOutputStreaming } from "./thread-command-output";
import {
getQuestionAnswerPreview,
hasQuestionAnswer,
Expand Down Expand Up @@ -850,8 +851,12 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
) {
const { row, expanded } = props;
const navigation = useNavigation();
// Command rows stream their output, including the final output, from the server.
const streamsOutput =
useCommandOutputStreaming(props.environmentId) &&
row.projectedItem.item.type === "command_execution";
const fetchedDetail = useTurnItemDetail(
expanded && row.fetchesDetail
expanded && row.fetchesDetail && !streamsOutput
? { environmentId: props.environmentId, row: row.projectedItem }
: null,
);
Expand Down Expand Up @@ -963,20 +968,21 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
: null;
const outputImages = expanded && fetchedItem ? turnItemOutputImages(fetchedItem) : [];
const viewedImagePath = workEntryViewedImagePath(row.workEntry);
const fetchedOutput = !expanded
? null
: shownItem.type === "file_search" || shownItem.type === "web_search"
? turnItemOutputText(shownItem)
: fetchedItem
? (turnItemOutputText(fetchedItem) ??
(outputImages.length > 0 || viewedImagePath ? null : "No output."))
: fetchedDetail.error
? `Couldn't load output: ${fetchedDetail.error}`
: row.fetchesDetail
? fetchedDetail.data
? "Output is no longer available."
: "Loading output…"
: null;
const fetchedOutput =
!expanded || streamsOutput
? null
: shownItem.type === "file_search" || shownItem.type === "web_search"
? turnItemOutputText(shownItem)
: fetchedItem
? (turnItemOutputText(fetchedItem) ??
(outputImages.length > 0 || viewedImagePath ? null : "No output."))
: fetchedDetail.error
? `Couldn't load output: ${fetchedDetail.error}`
: row.fetchesDetail
? fetchedDetail.data
? "Output is no longer available."
: "Loading output…"
: null;
const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);
const previewText = workEntryRowLabel(row.workEntry);
const answerPreview = row.workEntry.questionAnswer
Expand Down Expand Up @@ -1206,12 +1212,24 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
{fetchedOutput}
</Text>
) : null}
{failedExitCode !== null ? (
{failedExitCode !== null && !streamsOutput ? (
<Text className="mt-1.5 font-mono text-2xs leading-normal text-danger-foreground">
exit {failedExitCode}
</Text>
) : null}
</ScrollView>
{streamsOutput ? (
<ThreadCommandOutput
environmentId={props.environmentId}
threadId={row.projectedItem.sourceThreadId}
itemId={row.projectedItem.sourceItemId}
/>
) : null}
{failedExitCode !== null && streamsOutput ? (
<Text className="mt-1.5 font-mono text-2xs leading-normal text-danger-foreground">
exit {failedExitCode}
</Text>
) : null}
</Animated.View>
) : null}
</Animated.View>
Expand Down
7 changes: 7 additions & 0 deletions apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
TOOL_CALL_READ_ONLY_WORKSPACE_ROOT,
TOOL_CALL_WRITE_PROMPT,
TURN_INTERRUPT_MID_TOOL_PROMPT,
COMMAND_OUTPUT_STREAMING_PROMPT,
TURN_INTERRUPT_PROMPT,
TURN_INTERRUPT_RECOVERY_PROMPT,
WORKSPACE_NEVER_POLICY,
Expand Down Expand Up @@ -115,6 +116,12 @@ const CLAUDE_RECORDINGS = {
queryMode: "active_steering",
enableTools: true,
},
command_output_streaming: {
prompts: [COMMAND_OUTPUT_STREAMING_PROMPT],
defaultTranscriptFile: "fixtures/command_output_streaming/claude_transcript.ndjson",
queryMode: "streaming",
enableTools: true,
},
turn_interrupt_mid_tool: {
prompts: [TURN_INTERRUPT_MID_TOOL_PROMPT],
defaultTranscriptFile: "fixtures/turn_interrupt_mid_tool/claude_transcript.ndjson",
Expand Down
25 changes: 25 additions & 0 deletions apps/server/scripts/record-codex-app-server-replay-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
TODO_LIST_PROMPT,
TOOL_CALL_WRITE_PROMPT,
TURN_INTERRUPT_MID_TOOL_PROMPT,
COMMAND_OUTPUT_STREAMING_PROMPT,
TURN_INTERRUPT_PROMPT,
WEB_SEARCH_PROMPT,
} from "../src/orchestration-v2/testkit/fixtures/shared.ts";
Expand Down Expand Up @@ -98,6 +99,7 @@ const SCENARIO_NAMES = [
"message_steering",
"turn_interrupt",
"turn_interrupt_mid_tool",
"command_output_streaming",
"thread_rollback",
"thread_rollback_after_restart",
"thread_rollback_to_stopped_turn",
Expand Down Expand Up @@ -768,6 +770,29 @@ function scenarios(): ReadonlyArray<ReplayScenario> {
},
],
},
{
name: "command_output_streaming",
fileName: "command_output_streaming.ndjson",
description: "One command prints over several seconds, so Codex streams its output deltas.",
runs: [
{
name: "command-output-streaming",
description: "Run a foreground command that prints a line a second.",
prompt: COMMAND_OUTPUT_STREAMING_PROMPT,
turnDefaults: {
approvalPolicy: "never",
sandboxPolicy: workspaceWriteSandbox(),
},
steps: [
{
type: "turn",
label: "command-output-streaming",
prompt: COMMAND_OUTPUT_STREAMING_PROMPT,
},
],
},
],
},
{
name: "thread_rollback",
fileName: "thread_rollback.ndjson",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const RPC_REQUIRED_SCOPES = {
[ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput]: AuthOrchestrationReadScope,
[WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope,
[WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ export const make = Effect.gen(function* () {
threadActiveReorder: true,
threadAutoSettleOptOut: true,
threadTitleRegeneration: true,
commandOutputStreaming: true,
threadVisitedTracking: true,
threadPullRequests: true,
threadPullRequestWatch: true,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const RPC_AGGREGATES = {
[ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.subscribeShell]: "orchestrationV2",
[ORCHESTRATION_V2_WS_METHODS.subscribeThread]: "orchestrationV2",
[ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput]: "orchestrationV2",
[WS_METHODS.projectsMutate]: "orchestration",
[WS_METHODS.serverProbe]: "server",
[WS_METHODS.serverGetConfig]: "server",
Expand Down
Loading
Loading