Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The format is based on Keep a Changelog, and this project currently tracks chang

### Added

- Docker as an alternative sandbox backend (`sandbox.backend = "docker"`) for stronger execution isolation with configurable resource limits, network isolation, and automatic image management.
- Built-in `gemini` provider profile so `oh setup` offers Google Gemini as a first-class provider choice, with `gemini_api_key` auth source and `gemini-2.5-flash` as the default model.
- `diagnose` skill: trace agent run failures and regressions using structured evidence from run artifacts.
- OpenAI-compatible API client (`--api-format openai`) supporting any provider that implements the OpenAI `/v1/chat/completions` format, including Alibaba DashScope, DeepSeek, GitHub Models, Groq, Together AI, Ollama, and more.
- `OPENHARNESS_API_FORMAT` environment variable for selecting the API format.
Expand All @@ -16,9 +18,12 @@ The format is based on Keep a Changelog, and this project currently tracks chang
- `CONTRIBUTING.md` with local setup, validation commands, and PR expectations.
- `docs/SHOWCASE.md` with concrete OpenHarness usage patterns and demo commands.
- GitHub issue templates and a pull request template.
- React TUI assistant messages now render structured Markdown blocks, including headings, lists, code fences, blockquotes, links, and tables.

### Fixed

- React TUI spinner now stays visible throughout the entire agent turn: `assistant_complete` no longer resets `busy` state prematurely, and `tool_started` explicitly sets `busy=true` so the status bar remains active even when tool calls follow an assistant message. `line_complete` is the sole signal that ends the turn and clears the spinner.
- Skill loader now uses `yaml.safe_load` to parse SKILL.md frontmatter, correctly handling YAML block scalars (`>`, `|`), quoted values, and other standard YAML constructs instead of naive line-by-line splitting.
- `BackendHostConfig` was missing the `cwd` field, causing `AttributeError: 'BackendHostConfig' object has no attribute 'cwd'` on startup when `oh` was run after the runtime refactor that added `cwd` support to `build_runtime`.
- Shell-escape `$ARGUMENTS` substitution in command hooks to prevent shell injection from payload values containing metacharacters like `$(...)` or backticks.
- Swarm `_READ_ONLY_TOOLS` now uses actual registered tool names (snake_case) instead of PascalCase, fixing read-only auto-approval in `handle_permission_request`.
Expand All @@ -27,9 +32,13 @@ The format is based on Keep a Changelog, and this project currently tracks chang
- Memory search tokenizer handles Han characters for multilingual queries.
- Fixed duplicate response in React TUI caused by double Enter key submission in the input handler.
- 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.
- 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.
- 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.
- 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.

### Changed

- React TUI now groups consecutive `tool` + `tool_result` transcript rows into a single compound row: success shows the result line count inline (e.g. `→ 24L`), errors show a red icon and up to 5 lines of error detail beneath the tool row. Standalone successful tool results are suppressed to reduce transcript noise; standalone errors are still surfaced.
- README now links to contribution docs, changelog, showcase material, and provider compatibility guidance.
- README quick start now includes a one-command demo and clearer provider compatibility notes.
- README provider compatibility section updated to include OpenAI-format providers.
Expand Down
4 changes: 3 additions & 1 deletion frontend/terminal/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
}, [session.commands, input]);

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

useEffect(() => {
setPickerIndex(0);
Expand Down Expand Up @@ -382,7 +383,8 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
<ConversationView
items={session.transcript}
assistantBuffer={session.assistantBuffer}
showWelcome={session.ready}
showWelcome={session.ready && outputStyle !== 'codex'}
outputStyle={outputStyle}
/>
</Box>

Expand Down
57 changes: 51 additions & 6 deletions frontend/terminal/src/components/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ export function ConversationView({
items,
assistantBuffer,
showWelcome,
outputStyle,
}: {
items: TranscriptItem[];
assistantBuffer: string;
showWelcome: boolean;
outputStyle: string;
}): React.JSX.Element {
const {theme} = useTheme();
const isCodexStyle = outputStyle === 'codex';
// Show the most recent items that fit the viewport
const visible = items.slice(-40);

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

{visible.map((item, index) => (
<MessageRow key={index} item={item} theme={theme} />
<MessageRow key={index} item={item} theme={theme} outputStyle={outputStyle} />
))}

{assistantBuffer ? (
<Box flexDirection="row" marginTop={0}>
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
<Text>{assistantBuffer}</Text>
<Box flexDirection="row" marginTop={isCodexStyle ? 0 : 1}>
{isCodexStyle ? (
<Text>{assistantBuffer}</Text>
) : (
<>
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
<Text>{assistantBuffer}</Text>
</>
)}
</Box>
) : null}
</Box>
);
}

function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<typeof useTheme>['theme']}): React.JSX.Element {
function MessageRow({
item,
theme,
outputStyle,
}: {
item: TranscriptItem;
theme: ReturnType<typeof useTheme>['theme'];
outputStyle: string;
}): React.JSX.Element {
const isCodexStyle = outputStyle === 'codex';
switch (item.role) {
case 'user':
if (isCodexStyle) {
return (
<Box marginTop={0}>
<Text>
<Text dimColor>{'> '}</Text>
<Text>{item.text}</Text>
</Text>
</Box>
);
}
return (
<Box marginTop={1} marginBottom={0}>
<Text>
Expand All @@ -50,6 +78,13 @@ function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<type
);

case 'assistant':
if (isCodexStyle) {
return (
<Box marginTop={0} marginBottom={0}>
<Text>{item.text}</Text>
</Box>
);
}
return (
<Box marginTop={1} marginBottom={0} flexDirection="column">
<Text>
Expand All @@ -61,9 +96,19 @@ function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<type

case 'tool':
case 'tool_result':
return <ToolCallDisplay item={item} />;
return <ToolCallDisplay item={item} outputStyle={outputStyle} />;

case 'system':
if (isCodexStyle) {
return (
<Box marginTop={0}>
<Text>
<Text color={theme.colors.warning}>[system]</Text>
<Text> {item.text}</Text>
</Text>
</Box>
);
}
return (
<Box marginTop={0}>
<Text>
Expand Down
121 changes: 114 additions & 7 deletions frontend/terminal/src/components/PromptInput.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
import React from 'react';
import {Box, Text} from 'ink';
import TextInput from 'ink-text-input';
import React, {useEffect, useRef, useState} from 'react';
import {Box, Text, useInput, useStdout} from 'ink';

import {useTheme} from '../theme/ThemeContext.js';
import {Spinner} from './Spinner.js';

const noop = (): void => {};
function wrapLine(line: string, width: number): string[] {
if (width <= 0) {
return [line];
}
if (!line) {
return [''];
}

const segments: string[] = [];
let index = 0;
while (index < line.length) {
segments.push(line.slice(index, index + width));
index += width;
}
return segments;
}

function wrapInput(text: string, width: number): string[] {
if (width <= 0) {
return [text];
}
return text.split('\n').flatMap((line) => wrapLine(line, width));
}

export function PromptInput({
busy,
Expand All @@ -23,6 +44,80 @@ export function PromptInput({
suppressSubmit?: boolean;
}): React.JSX.Element {
const {theme} = useTheme();
const {stdout} = useStdout();
const [cursorOffset, setCursorOffset] = useState(input.length);
const localEditRef = useRef(false);

useEffect(() => {
if (localEditRef.current) {
localEditRef.current = false;
setCursorOffset((offset) => Math.min(Math.max(0, offset), input.length));
return;
}
setCursorOffset(input.length);
}, [input]);

const insertText = (text: string): void => {
if (!text) {
return;
}
localEditRef.current = true;
const nextValue = input.slice(0, cursorOffset) + text + input.slice(cursorOffset);
setInput(nextValue);
setCursorOffset(cursorOffset + text.length);
};

const removeBeforeCursor = (): void => {
if (cursorOffset <= 0) {
return;
}
localEditRef.current = true;
const nextValue = input.slice(0, cursorOffset - 1) + input.slice(cursorOffset);
setInput(nextValue);
setCursorOffset(cursorOffset - 1);
};

useInput((chunk, key) => {
if (busy) {
return;
}
if (key.ctrl && chunk === 'c') {
return;
}
if (key.upArrow || key.downArrow || key.tab || (key.shift && key.tab) || key.escape) {
return;
}

if (key.return) {
if (key.shift) {
insertText('\n');
return;
}
if (!suppressSubmit) {
onSubmit(input);
}
return;
}

if (key.leftArrow) {
setCursorOffset((offset) => Math.max(0, offset - 1));
return;
}
if (key.rightArrow) {
setCursorOffset((offset) => Math.min(input.length, offset + 1));
return;
}
if (key.backspace || key.delete) {
removeBeforeCursor();
return;
}

if (key.ctrl || key.meta) {
return;
}

insertText(chunk);
}, {isActive: !busy});

if (busy) {
return (
Expand All @@ -32,10 +127,22 @@ export function PromptInput({
);
}

const rendered = input.slice(0, cursorOffset) + '|' + input.slice(cursorOffset);
const renderWidth = Math.max(10, (stdout?.columns ?? process.stdout.columns ?? 80) - 4);
const wrappedLines = wrapInput(rendered, renderWidth);

return (
<Box>
<Text color={theme.colors.primary} bold>{'> '}</Text>
<TextInput value={input} onChange={setInput} onSubmit={suppressSubmit ? noop : onSubmit} />
<Box flexDirection="column">
<Box>
<Text color={theme.colors.primary} bold>{'> '}</Text>
<Text>{wrappedLines[0] ?? ''}</Text>
</Box>
{wrappedLines.slice(1).map((line, index) => (
<Box key={index}>
<Text color={theme.colors.primary}>{' '}</Text>
<Text>{line}</Text>
</Box>
))}
</Box>
);
}
31 changes: 27 additions & 4 deletions frontend/terminal/src/components/ToolCallDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ import {Box, Text} from 'ink';
import {useTheme} from '../theme/ThemeContext.js';
import type {TranscriptItem} from '../types.js';

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

if (item.role === 'tool') {
const toolName = item.tool_name ?? 'tool';
const summary = summarizeInput(toolName, item.tool_input, item.text);
const summary = summarizeInput(toolName, item.tool_input, item.text).replace(/\s+/g, ' ').trim();
if (isCodexStyle) {
return (
<Box marginLeft={0} flexDirection="column">
<Text dimColor>{`• Ran ${toolName}${summary ? ` ${summary}` : ''}`}</Text>
</Box>
);
}
return (
<Box marginLeft={2} flexDirection="column">
<Text>
Expand All @@ -22,10 +30,25 @@ export function ToolCallDisplay({item}: {item: TranscriptItem}): React.JSX.Eleme
}

if (item.role === 'tool_result') {
const lines = item.text.split('\n');
const maxLines = 12;
const lines = item.text.length > 0 ? item.text.split('\n') : [''];
const maxLines = isCodexStyle ? 8 : 12;
const display = lines.length > maxLines ? [...lines.slice(0, maxLines), `... (${lines.length - maxLines} more lines)`] : lines;
const color = item.is_error ? theme.colors.error : undefined;
if (isCodexStyle) {
return (
<Box marginLeft={0} flexDirection="column">
{display.map((line, i) => {
const prefix = i === display.length - 1 ? '└ ' : '│ ';
return (
<Text key={i} color={color} dimColor={!item.is_error}>
{prefix}
{line}
</Text>
);
})}
</Box>
);
}
return (
<Box marginLeft={4} flexDirection="column">
{display.map((line, i) => (
Expand Down
Loading