Skip to content

Commit 7d2a010

Browse files
committed
feat(tui): add codex output style to reduce streaming flicker
1 parent a890f4e commit 7d2a010

7 files changed

Lines changed: 138 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The format is based on Keep a Changelog, and this project currently tracks chang
1616
- `CONTRIBUTING.md` with local setup, validation commands, and PR expectations.
1717
- `docs/SHOWCASE.md` with concrete OpenHarness usage patterns and demo commands.
1818
- GitHub issue templates and a pull request template.
19+
- Built-in `codex` output style for compact, low-noise transcript rendering in React TUI.
1920

2021
### Fixed
2122

@@ -27,6 +28,7 @@ The format is based on Keep a Changelog, and this project currently tracks chang
2728
- Memory search tokenizer handles Han characters for multilingual queries.
2829
- Fixed duplicate response in React TUI caused by double Enter key submission in the input handler.
2930
- Fixed concurrent permission modals overwriting each other in TUI default mode when the LLM returns multiple tool calls in one response; `_ask_permission` now serialises callers via an `asyncio.Lock` so each modal is shown and resolved before the next one is emitted.
31+
- Reduced React TUI redraw pressure when `output_style=codex` by avoiding token-level assistant buffer flushes during streaming.
3032

3133
### Changed
3234

frontend/terminal/src/App.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
102102
}, [session.commands, input]);
103103

104104
const showPicker = commandHints.length > 0 && !session.busy && !session.modal && !selectModal;
105+
const outputStyle = String(session.status.output_style ?? 'default');
105106

106107
useEffect(() => {
107108
setPickerIndex(0);
@@ -382,7 +383,8 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
382383
<ConversationView
383384
items={session.transcript}
384385
assistantBuffer={session.assistantBuffer}
385-
showWelcome={session.ready}
386+
showWelcome={session.ready && outputStyle !== 'codex'}
387+
outputStyle={outputStyle}
386388
/>
387389
</Box>
388390

frontend/terminal/src/components/ConversationView.tsx

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ export function ConversationView({
1010
items,
1111
assistantBuffer,
1212
showWelcome,
13+
outputStyle,
1314
}: {
1415
items: TranscriptItem[];
1516
assistantBuffer: string;
1617
showWelcome: boolean;
18+
outputStyle: string;
1719
}): React.JSX.Element {
1820
const {theme} = useTheme();
21+
const isCodexStyle = outputStyle === 'codex';
1922
// Show the most recent items that fit the viewport
2023
const visible = items.slice(-40);
2124

@@ -24,22 +27,47 @@ export function ConversationView({
2427
{showWelcome && items.length === 0 ? <WelcomeBanner /> : null}
2528

2629
{visible.map((item, index) => (
27-
<MessageRow key={index} item={item} theme={theme} />
30+
<MessageRow key={index} item={item} theme={theme} outputStyle={outputStyle} />
2831
))}
2932

3033
{assistantBuffer ? (
31-
<Box flexDirection="row" marginTop={0}>
32-
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
33-
<Text>{assistantBuffer}</Text>
34+
<Box flexDirection="row" marginTop={isCodexStyle ? 0 : 1}>
35+
{isCodexStyle ? (
36+
<Text>{assistantBuffer}</Text>
37+
) : (
38+
<>
39+
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
40+
<Text>{assistantBuffer}</Text>
41+
</>
42+
)}
3443
</Box>
3544
) : null}
3645
</Box>
3746
);
3847
}
3948

40-
function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<typeof useTheme>['theme']}): React.JSX.Element {
49+
function MessageRow({
50+
item,
51+
theme,
52+
outputStyle,
53+
}: {
54+
item: TranscriptItem;
55+
theme: ReturnType<typeof useTheme>['theme'];
56+
outputStyle: string;
57+
}): React.JSX.Element {
58+
const isCodexStyle = outputStyle === 'codex';
4159
switch (item.role) {
4260
case 'user':
61+
if (isCodexStyle) {
62+
return (
63+
<Box marginTop={0}>
64+
<Text>
65+
<Text dimColor>{'> '}</Text>
66+
<Text>{item.text}</Text>
67+
</Text>
68+
</Box>
69+
);
70+
}
4371
return (
4472
<Box marginTop={1} marginBottom={0}>
4573
<Text>
@@ -50,6 +78,13 @@ function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<type
5078
);
5179

5280
case 'assistant':
81+
if (isCodexStyle) {
82+
return (
83+
<Box marginTop={0} marginBottom={0}>
84+
<Text>{item.text}</Text>
85+
</Box>
86+
);
87+
}
5388
return (
5489
<Box marginTop={1} marginBottom={0} flexDirection="column">
5590
<Text>
@@ -61,9 +96,19 @@ function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<type
6196

6297
case 'tool':
6398
case 'tool_result':
64-
return <ToolCallDisplay item={item} />;
99+
return <ToolCallDisplay item={item} outputStyle={outputStyle} />;
65100

66101
case 'system':
102+
if (isCodexStyle) {
103+
return (
104+
<Box marginTop={0}>
105+
<Text>
106+
<Text color={theme.colors.warning}>[system]</Text>
107+
<Text> {item.text}</Text>
108+
</Text>
109+
</Box>
110+
);
111+
}
67112
return (
68113
<Box marginTop={0}>
69114
<Text>

frontend/terminal/src/components/ToolCallDisplay.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@ import {Box, Text} from 'ink';
44
import {useTheme} from '../theme/ThemeContext.js';
55
import type {TranscriptItem} from '../types.js';
66

7-
export function ToolCallDisplay({item}: {item: TranscriptItem}): React.JSX.Element {
7+
export function ToolCallDisplay({item, outputStyle}: {item: TranscriptItem; outputStyle?: string}): React.JSX.Element {
88
const {theme} = useTheme();
9+
const isCodexStyle = outputStyle === 'codex';
910

1011
if (item.role === 'tool') {
1112
const toolName = item.tool_name ?? 'tool';
12-
const summary = summarizeInput(toolName, item.tool_input, item.text);
13+
const summary = summarizeInput(toolName, item.tool_input, item.text).replace(/\s+/g, ' ').trim();
14+
if (isCodexStyle) {
15+
return (
16+
<Box marginLeft={0} flexDirection="column">
17+
<Text dimColor>{`• Ran ${toolName}${summary ? ` ${summary}` : ''}`}</Text>
18+
</Box>
19+
);
20+
}
1321
return (
1422
<Box marginLeft={2} flexDirection="column">
1523
<Text>
@@ -22,10 +30,25 @@ export function ToolCallDisplay({item}: {item: TranscriptItem}): React.JSX.Eleme
2230
}
2331

2432
if (item.role === 'tool_result') {
25-
const lines = item.text.split('\n');
26-
const maxLines = 12;
33+
const lines = item.text.length > 0 ? item.text.split('\n') : [''];
34+
const maxLines = isCodexStyle ? 8 : 12;
2735
const display = lines.length > maxLines ? [...lines.slice(0, maxLines), `... (${lines.length - maxLines} more lines)`] : lines;
2836
const color = item.is_error ? theme.colors.error : undefined;
37+
if (isCodexStyle) {
38+
return (
39+
<Box marginLeft={0} flexDirection="column">
40+
{display.map((line, i) => {
41+
const prefix = i === display.length - 1 ? '└ ' : '│ ';
42+
return (
43+
<Text key={i} color={color} dimColor={!item.is_error}>
44+
{prefix}
45+
{line}
46+
</Text>
47+
);
48+
})}
49+
</Box>
50+
);
51+
}
2952
return (
3053
<Box marginLeft={4} flexDirection="column">
3154
{display.map((line, i) => (

frontend/terminal/src/hooks/useBackendSession.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
3535
const [todoMarkdown, setTodoMarkdown] = useState('');
3636
const [swarmTeammates, setSwarmTeammates] = useState<SwarmTeammateSnapshot[]>([]);
3737
const [swarmNotifications, setSwarmNotifications] = useState<SwarmNotificationSnapshot[]>([]);
38+
const statusRef = useRef<Record<string, unknown>>({});
3839
const childRef = useRef<ChildProcessWithoutNullStreams | null>(null);
3940
const sentInitialPrompt = useRef(false);
4041
const lastStatusSnapshotRef = useRef('');
@@ -143,7 +144,9 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
143144
setReady(true);
144145
const statusSnapshot = stableStringify(event.state ?? {});
145146
lastStatusSnapshotRef.current = statusSnapshot;
146-
setStatus(event.state ?? {});
147+
const nextStatus = event.state ?? {};
148+
statusRef.current = nextStatus;
149+
setStatus(nextStatus);
147150
const tasksSnapshot = stableStringify(event.tasks ?? []);
148151
lastTasksSnapshotRef.current = tasksSnapshot;
149152
setTasks(event.tasks ?? []);
@@ -165,7 +168,9 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
165168
const statusSnapshot = stableStringify(event.state ?? {});
166169
if (statusSnapshot !== lastStatusSnapshotRef.current) {
167170
lastStatusSnapshotRef.current = statusSnapshot;
168-
setStatus(event.state ?? {});
171+
const nextStatus = event.state ?? {};
172+
statusRef.current = nextStatus;
173+
setStatus(nextStatus);
169174
}
170175
const mcpSnapshot = stableStringify(event.mcp_servers ?? []);
171176
if (mcpSnapshot !== lastMcpSnapshotRef.current) {
@@ -196,6 +201,13 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
196201
if (!delta) {
197202
return;
198203
}
204+
const isCodexStyle = String(statusRef.current.output_style ?? 'default') === 'codex';
205+
if (isCodexStyle) {
206+
// Keep collecting text for assistant_complete fallback, but avoid
207+
// token-level rerenders in compact codex mode.
208+
assistantBufferRef.current += delta;
209+
return;
210+
}
199211
pendingAssistantDeltaRef.current += delta;
200212
if (pendingAssistantDeltaRef.current.length >= ASSISTANT_DELTA_FLUSH_CHARS) {
201213
flushAssistantDelta();
@@ -214,7 +226,15 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
214226
clearTimeout(assistantFlushTimerRef.current);
215227
assistantFlushTimerRef.current = null;
216228
}
217-
flushAssistantDelta();
229+
const isCodexStyle = String(statusRef.current.output_style ?? 'default') === 'codex';
230+
if (isCodexStyle) {
231+
if (pendingAssistantDeltaRef.current) {
232+
assistantBufferRef.current += pendingAssistantDeltaRef.current;
233+
pendingAssistantDeltaRef.current = '';
234+
}
235+
} else {
236+
flushAssistantDelta();
237+
}
218238
const text = event.message ?? assistantBufferRef.current;
219239
setTranscript((items) => [...items, {role: 'assistant', text}]);
220240
clearAssistantDelta();
@@ -279,7 +299,11 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
279299
}
280300
if (event.type === 'plan_mode_change') {
281301
if (event.plan_mode != null) {
282-
setStatus((s) => ({...s, permission_mode: event.plan_mode}));
302+
setStatus((s) => {
303+
const next = {...s, permission_mode: event.plan_mode};
304+
statusRef.current = next;
305+
return next;
306+
});
283307
}
284308
return;
285309
}

src/openharness/output_styles/loader.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def load_output_styles() -> list[OutputStyle]:
2929
styles = [
3030
OutputStyle(name="default", content="Standard rich console output.", source="builtin"),
3131
OutputStyle(name="minimal", content="Very terse plain-text output.", source="builtin"),
32+
OutputStyle(name="codex", content="Codex-like compact transcript and tool output.", source="builtin"),
3233
]
3334
for path in sorted(get_output_styles_dir().glob("*.md")):
3435
styles.append(
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from __future__ import annotations
2+
3+
from openharness.output_styles.loader import load_output_styles
4+
5+
6+
def test_builtin_output_styles_include_codex(monkeypatch, tmp_path):
7+
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
8+
9+
styles = load_output_styles()
10+
builtin_names = {style.name for style in styles if style.source == "builtin"}
11+
12+
assert {"default", "minimal", "codex"}.issubset(builtin_names)
13+
14+
15+
def test_custom_output_style_is_loaded(monkeypatch, tmp_path):
16+
config_dir = tmp_path / "config"
17+
style_dir = config_dir / "output_styles"
18+
style_dir.mkdir(parents=True)
19+
(style_dir / "focus.md").write_text("Use focused output", encoding="utf-8")
20+
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(config_dir))
21+
22+
styles = load_output_styles()
23+
custom = {style.name: style for style in styles if style.source == "user"}
24+
25+
assert "focus" in custom
26+
assert custom["focus"].content == "Use focused output"

0 commit comments

Comments
 (0)