Skip to content

Commit d387965

Browse files
committed
Merge branch 'pr-141' into integrate-pr141
# Conflicts: # CHANGELOG.md # frontend/terminal/src/App.tsx # frontend/terminal/src/components/ConversationView.tsx # frontend/terminal/src/components/ToolCallDisplay.tsx # frontend/terminal/src/hooks/useBackendSession.ts
2 parents 0c84d85 + 7d2a010 commit d387965

7 files changed

Lines changed: 186 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ The format is based on Keep a Changelog, and this project currently tracks chang
1919
- `docs/SHOWCASE.md` with concrete OpenHarness usage patterns and demo commands.
2020
- GitHub issue templates and a pull request template.
2121
- React TUI assistant messages now render structured Markdown blocks, including headings, lists, code fences, blockquotes, links, and tables.
22+
- Built-in `codex` output style for compact, low-noise transcript rendering in React TUI.
2223

2324
### Fixed
2425

@@ -37,6 +38,7 @@ The format is based on Keep a Changelog, and this project currently tracks chang
3738
- Fixed React TUI Markdown tables to size columns from rendered cell text so inline formatting like code spans and bold text no longer breaks alignment.
3839
- Fixed grep tool crashing with `ValueError` / `LimitOverrunError` when ripgrep outputs a line longer than 64 KB (e.g. minified assets or lock files). The asyncio subprocess stream limit is now 8 MB and oversized lines are skipped rather than terminating the session.
3940
- Fixed React TUI exit leaving the shell prompt concatenated with the last TUI line. The terminal cleanup handler now writes a trailing newline (`\n`) alongside the cursor-show escape sequence so the shell prompt always starts on a fresh line.
41+
- Reduced React TUI redraw pressure when `output_style=codex` by avoiding token-level assistant buffer flushes during streaming.
4042

4143
### Changed
4244

frontend/terminal/src/App.tsx

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

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

113114
useEffect(() => {
114115
setPickerIndex(0);
@@ -389,7 +390,8 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
389390
<ConversationView
390391
items={deferredTranscript}
391392
assistantBuffer={deferredAssistantBuffer}
392-
showWelcome={session.ready}
393+
showWelcome={session.ready && outputStyle !== 'codex'}
394+
outputStyle={outputStyle}
393395
/>
394396
</Box>
395397

frontend/terminal/src/components/ConversationView.tsx

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ function ConversationViewInner({
3131
items,
3232
assistantBuffer,
3333
showWelcome,
34+
outputStyle,
3435
}: {
3536
items: TranscriptItem[];
3637
assistantBuffer: string;
3738
showWelcome: boolean;
39+
outputStyle: string;
3840
}): React.JSX.Element {
3941
const {theme} = useTheme();
40-
// Show the most recent items that fit the viewport
42+
const isCodexStyle = outputStyle === 'codex';
4143
const visible = items.slice(-40);
4244
const grouped = groupToolPairs(visible);
4345

@@ -48,30 +50,70 @@ function ConversationViewInner({
4850
{grouped.map((group, index) => {
4951
if (Array.isArray(group)) {
5052
const [toolItem, resultItem] = group as [TranscriptItem, TranscriptItem];
51-
return <ToolCallDisplay key={index} item={toolItem} resultItem={resultItem} />;
53+
return (
54+
<ToolCallDisplay
55+
key={index}
56+
item={toolItem}
57+
resultItem={resultItem}
58+
outputStyle={outputStyle}
59+
/>
60+
);
5261
}
53-
return <MessageRow key={index} item={group as TranscriptItem} theme={theme} />;
62+
return (
63+
<MessageRow
64+
key={index}
65+
item={group as TranscriptItem}
66+
theme={theme}
67+
outputStyle={outputStyle}
68+
/>
69+
);
5470
})}
5571

5672
{assistantBuffer ? (
57-
<Box marginTop={1} marginBottom={0} flexDirection="column">
58-
<Text>
59-
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
60-
</Text>
61-
<Box marginLeft={2} flexDirection="column">
62-
<MarkdownText content={assistantBuffer} />
73+
isCodexStyle ? (
74+
<Box flexDirection="row" marginTop={0}>
75+
<Text>{assistantBuffer}</Text>
6376
</Box>
64-
</Box>
77+
) : (
78+
<Box marginTop={1} marginBottom={0} flexDirection="column">
79+
<Text>
80+
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
81+
</Text>
82+
<Box marginLeft={2} flexDirection="column">
83+
<MarkdownText content={assistantBuffer} />
84+
</Box>
85+
</Box>
86+
)
6587
) : null}
6688
</Box>
6789
);
6890
}
6991

7092
export const ConversationView = React.memo(ConversationViewInner);
7193

72-
function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<typeof useTheme>['theme']}): React.JSX.Element {
94+
function MessageRow({
95+
item,
96+
theme,
97+
outputStyle,
98+
}: {
99+
item: TranscriptItem;
100+
theme: ReturnType<typeof useTheme>['theme'];
101+
outputStyle: string;
102+
}): React.JSX.Element {
103+
const isCodexStyle = outputStyle === 'codex';
104+
73105
switch (item.role) {
74106
case 'user':
107+
if (isCodexStyle) {
108+
return (
109+
<Box marginTop={0}>
110+
<Text>
111+
<Text dimColor>{'> '}</Text>
112+
<Text>{item.text}</Text>
113+
</Text>
114+
</Box>
115+
);
116+
}
75117
return (
76118
<Box marginTop={1} marginBottom={0}>
77119
<Text>
@@ -82,6 +124,13 @@ function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<type
82124
);
83125

84126
case 'assistant':
127+
if (isCodexStyle) {
128+
return (
129+
<Box marginTop={0} marginBottom={0}>
130+
<Text>{item.text}</Text>
131+
</Box>
132+
);
133+
}
85134
return (
86135
<Box marginTop={1} marginBottom={0} flexDirection="column">
87136
<Text>
@@ -95,9 +144,19 @@ function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<type
95144

96145
case 'tool':
97146
case 'tool_result':
98-
return <ToolCallDisplay item={item} />;
147+
return <ToolCallDisplay item={item} outputStyle={outputStyle} />;
99148

100149
case 'system':
150+
if (isCodexStyle) {
151+
return (
152+
<Box marginTop={0}>
153+
<Text>
154+
<Text color={theme.colors.warning}>[system]</Text>
155+
<Text> {item.text}</Text>
156+
</Text>
157+
</Box>
158+
);
159+
}
101160
return (
102161
<Box marginTop={0}>
103162
<Text>

frontend/terminal/src/components/ToolCallDisplay.tsx

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,62 @@ 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, resultItem}: {item: TranscriptItem; resultItem?: TranscriptItem}): React.JSX.Element {
7+
export function ToolCallDisplay({
8+
item,
9+
resultItem,
10+
outputStyle,
11+
}: {
12+
item: TranscriptItem;
13+
resultItem?: TranscriptItem;
14+
outputStyle?: string;
15+
}): React.JSX.Element {
816
const {theme} = useTheme();
17+
const isCodexStyle = outputStyle === 'codex';
918

1019
if (item.role === 'tool') {
1120
const toolName = item.tool_name ?? 'tool';
12-
const summary = summarizeInput(toolName, item.tool_input, item.text);
21+
const summary = summarizeInput(toolName, item.tool_input, item.text).replace(/\s+/g, ' ').trim();
1322

1423
let statusNode: React.ReactNode = null;
1524
let errorLines: string[] | null = null;
1625

1726
if (resultItem) {
1827
if (resultItem.is_error) {
19-
statusNode = <Text color={theme.colors.error}> {theme.icons.error.trim()}</Text>;
28+
statusNode = isCodexStyle
29+
? <Text color={theme.colors.error}> error</Text>
30+
: <Text color={theme.colors.error}> {theme.icons.error.trim()}</Text>;
2031
const lines = resultItem.text.split('\n').filter((l) => l.trim());
21-
const maxErrLines = 5;
32+
const maxErrLines = isCodexStyle ? 8 : 5;
2233
errorLines = lines.length > maxErrLines
2334
? [...lines.slice(0, maxErrLines), `... (${lines.length - maxErrLines} more lines)`]
2435
: lines;
25-
} else {
36+
} else if (!isCodexStyle) {
2637
const lineCount = resultItem.text.split('\n').filter((l) => l.trim()).length;
2738
const resultLabel = lineCount > 0 ? `${lineCount}L` : theme.icons.success.trim();
2839
statusNode = <Text dimColor>{resultLabel}</Text>;
40+
} else {
41+
const lineCount = resultItem.text.split('\n').filter((l) => l.trim()).length;
42+
statusNode = <Text dimColor>{lineCount > 0 ? ` ${lineCount}L` : ''}</Text>;
2943
}
3044
}
3145

46+
if (isCodexStyle) {
47+
return (
48+
<Box marginLeft={0} flexDirection="column">
49+
<Text dimColor>{`• Ran ${toolName}${summary ? ` ${summary}` : ''}`}{statusNode}</Text>
50+
{errorLines?.map((line, i) => {
51+
const prefix = i === errorLines.length - 1 ? '└ ' : '│ ';
52+
return (
53+
<Text key={i} color={theme.colors.error}>
54+
{prefix}
55+
{line}
56+
</Text>
57+
);
58+
})}
59+
</Box>
60+
);
61+
}
62+
3263
return (
3364
<Box marginLeft={2} flexDirection="column">
3465
<Text>
@@ -46,14 +77,30 @@ export function ToolCallDisplay({item, resultItem}: {item: TranscriptItem; resul
4677
);
4778
}
4879

49-
// Standalone tool_result (unpaired — should be rare). Hide successes; surface errors.
5080
if (item.role === 'tool_result') {
5181
if (!item.is_error) {
5282
return <></>;
5383
}
54-
const lines = item.text.split('\n').filter((l) => l.trim());
55-
const maxLines = 5;
84+
const lines = item.text.length > 0
85+
? item.text.split('\n').filter((l) => l.trim())
86+
: [''];
87+
const maxLines = isCodexStyle ? 8 : 5;
5688
const display = lines.length > maxLines ? [...lines.slice(0, maxLines), `... (${lines.length - maxLines} more lines)`] : lines;
89+
if (isCodexStyle) {
90+
return (
91+
<Box marginLeft={0} flexDirection="column">
92+
{display.map((line, i) => {
93+
const prefix = i === display.length - 1 ? '└ ' : '│ ';
94+
return (
95+
<Text key={i} color={theme.colors.error}>
96+
{prefix}
97+
{line}
98+
</Text>
99+
);
100+
})}
101+
</Box>
102+
);
103+
}
57104
return (
58105
<Box marginLeft={4} flexDirection="column">
59106
{display.map((line, i) => (
@@ -92,7 +139,6 @@ function summarizeInput(toolName: string, toolInput?: Record<string, unknown>, f
92139
if (lower === 'agent' && toolInput.description) {
93140
return String(toolInput.description);
94141
}
95-
// Fallback: show first key=value
96142
const entries = Object.entries(toolInput);
97143
if (entries.length > 0) {
98144
const [key, val] = entries[0];

frontend/terminal/src/hooks/useBackendSession.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
3737
const [todoMarkdown, setTodoMarkdown] = useState('');
3838
const [swarmTeammates, setSwarmTeammates] = useState<SwarmTeammateSnapshot[]>([]);
3939
const [swarmNotifications, setSwarmNotifications] = useState<SwarmNotificationSnapshot[]>([]);
40+
const statusRef = useRef<Record<string, unknown>>({});
4041
const childRef = useRef<ChildProcessWithoutNullStreams | null>(null);
4142
const sentInitialPrompt = useRef(false);
4243
const lastStatusSnapshotRef = useRef('');
@@ -180,8 +181,10 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
180181
setReady(true);
181182
const statusSnapshot = stableStringify(event.state ?? {});
182183
lastStatusSnapshotRef.current = statusSnapshot;
184+
const nextStatus = event.state ?? {};
185+
statusRef.current = nextStatus;
183186
startTransition(() => {
184-
setStatus(event.state ?? {});
187+
setStatus(nextStatus);
185188
});
186189
const tasksSnapshot = stableStringify(event.tasks ?? []);
187190
lastTasksSnapshotRef.current = tasksSnapshot;
@@ -210,8 +213,10 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
210213
const statusSnapshot = stableStringify(event.state ?? {});
211214
if (statusSnapshot !== lastStatusSnapshotRef.current) {
212215
lastStatusSnapshotRef.current = statusSnapshot;
216+
const nextStatus = event.state ?? {};
217+
statusRef.current = nextStatus;
213218
startTransition(() => {
214-
setStatus(event.state ?? {});
219+
setStatus(nextStatus);
215220
});
216221
}
217222
const mcpSnapshot = stableStringify(event.mcp_servers ?? []);
@@ -294,6 +299,13 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
294299
if (!delta) {
295300
return;
296301
}
302+
const isCodexStyle = String(statusRef.current.output_style ?? 'default') === 'codex';
303+
if (isCodexStyle) {
304+
// Keep collecting text for assistant_complete fallback, but avoid
305+
// token-level rerenders in compact codex mode.
306+
assistantBufferRef.current += delta;
307+
return;
308+
}
297309
pendingAssistantDeltaRef.current += delta;
298310
if (pendingAssistantDeltaRef.current.length >= ASSISTANT_DELTA_FLUSH_CHARS) {
299311
flushAssistantDelta();
@@ -313,7 +325,15 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
313325
assistantFlushTimerRef.current = null;
314326
}
315327
flushTranscriptItems();
316-
flushAssistantDelta();
328+
const isCodexStyle = String(statusRef.current.output_style ?? 'default') === 'codex';
329+
if (isCodexStyle) {
330+
if (pendingAssistantDeltaRef.current) {
331+
assistantBufferRef.current += pendingAssistantDeltaRef.current;
332+
pendingAssistantDeltaRef.current = '';
333+
}
334+
} else {
335+
flushAssistantDelta();
336+
}
317337
const text = event.message ?? assistantBufferRef.current;
318338
startTransition(() => {
319339
setTranscript((items) => [...items, {role: 'assistant', text}]);
@@ -400,7 +420,11 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
400420
if (event.type === 'plan_mode_change') {
401421
if (event.plan_mode != null) {
402422
startTransition(() => {
403-
setStatus((s) => ({...s, permission_mode: event.plan_mode}));
423+
setStatus((s) => {
424+
const next = {...s, permission_mode: event.plan_mode};
425+
statusRef.current = next;
426+
return next;
427+
});
404428
});
405429
}
406430
return;

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)