Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ entry point (multi-turn sessions, persistence, export-to-workflow) are shipped.
resolution (2.B) landed (PR #40), `relavium run` is wired to the engine
(2.D, the M3 keystone — PR #41), the `--json` CI machine-output contract
landed (2.F — PR #42, ADR-0049), the engine regression harness (2.K — PR #43)
completes M3, and durable run history landed (2.H — PR #44, ADR-0050). The next pickup is 2.C
(provider/keys). For live status, per-PR history,
completes M3, and durable run history landed (2.H — PR #44, ADR-0050); and the provider/key
commands with OS-keychain storage landed (2.C — PR #45, behind ADR-0019 + ADR-0006). The next
pickup is 2.E (ink TUI). For live status, per-PR history,
milestone dates, and open obligations, see the canonical home
[docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) is the
public overview.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ One engine, three modes behind the one `LLMProvider` seam:
local-first BYOK — workflow parsing, DAG execution, live streaming, checkpoint/resume,
multi-provider failover, cost governance, and multimodal media I/O. **Phase 2 (the CLI) is
underway** — the CLI skeleton, config resolution, `relavium run` (wired to the engine), its
`--json` CI machine-output contract, the engine regression harness, and durable local run history
have landed (milestone **M3** reached). For live status and the full roadmap, see
`--json` CI machine-output contract, the engine regression harness, durable local run history, and the
provider/key commands (API keys in the OS keychain) have landed (milestone **M3** reached). For live status and the full roadmap, see
[docs/roadmap/current.md](docs/roadmap/current.md) and the
[roadmap](docs/roadmap/README.md).

Expand Down
3 changes: 3 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@
"@relavium/llm": "workspace:*",
"@relavium/shared": "workspace:*",
"commander": "catalog:",
"ink": "catalog:",
"react": "catalog:",
"smol-toml": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/react": "catalog:",
"eslint": "catalog:",
"tsup": "catalog:",
"typescript": "catalog:",
Expand Down
35 changes: 35 additions & 0 deletions apps/cli/src/commands/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { isCliError } from '../process/errors.js';
import { EXIT_CODES } from '../process/exit-codes.js';
import type { CliIo } from '../process/io.js';
import type { GlobalOptions } from '../process/options.js';
import type { RunRenderer } from '../render/renderer.js';
import { captureIo } from '../test-support.js';
import { runCommand, type RunCommandDeps } from './run.js';

Expand Down Expand Up @@ -182,6 +183,40 @@ describe('runCommand', () => {
expect(out()).toContain('run completed');
});

it('awaits the renderer finalize() once after the run loop (the TUI teardown wire)', async () => {
const path = writeWorkflow('happy.relavium.yaml', HAPPY);
const { io } = captureIo();
let finalizeCalls = 0;
const renderer: RunRenderer = {
onEvent: () => {},
finalize: () => {
finalizeCalls += 1;
},
};
const code = await runCommand(
{ workflow: path, input: ['n=3'] },
deps(io, globalOptions(), { selectRenderer: () => renderer }),
);
expect(code).toBe(EXIT_CODES.success);
expect(finalizeCalls).toBe(1);
});

it('does not let a renderer finalize() error mask the run outcome (logs to stderr instead)', async () => {
const path = writeWorkflow('happy.relavium.yaml', HAPPY);
const { io, err } = captureIo();
const renderer: RunRenderer = {
onEvent: () => {},
finalize: () => Promise.reject(new Error('unmount blew up')),
};
const code = await runCommand(
{ workflow: path, input: ['n=3'] },
deps(io, globalOptions(), { selectRenderer: () => renderer }),
);
expect(code).toBe(EXIT_CODES.success); // the run outcome is preserved
expect(err()).toContain('renderer teardown failed');
expect(err()).toContain('unmount blew up');
});

it('renders --json stdout as a schema-valid RunEvent NDJSON stream in sequenceNumber order, ending in run:completed', async () => {
const path = writeWorkflow('happy.relavium.yaml', HAPPY);
const { io, out } = captureIo();
Expand Down
30 changes: 26 additions & 4 deletions apps/cli/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import { CliError } from '../process/errors.js';
import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js';
import type { CliIo } from '../process/io.js';
import type { GlobalOptions } from '../process/options.js';
import { createJsonRenderer, createPlainRenderer } from '../render/renderer.js';
import type { RunRenderer } from '../render/renderer.js';
import { selectRenderer } from '../render/select.js';
import { resolveWorkflowSource } from '../workflows/resolve.js';
import { parseInputArgs, resolveInputs } from './inputs.js';

Expand All @@ -45,6 +46,11 @@ export interface RunCommandDeps {
* 2.K harness omit it, keeping the in-memory `RunStore` so they never open `~/.relavium/history.db`.
*/
readonly openRunStore?: (workflow: WorkflowDefinition, homeDir: string) => OpenedHistory;
/**
* Injectable renderer selector (TUI / json / plain). Defaults to the real {@link selectRenderer}; tests
* inject a fake renderer (onEvent + finalize spies) to assert the finalize wiring without a TTY.
*/
readonly selectRenderer?: (io: CliIo, global: GlobalOptions) => RunRenderer;
}

type RunOutcome = 'completed' | 'failed' | 'cancelled' | 'paused';
Expand Down Expand Up @@ -116,15 +122,20 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr
);
const handle = engine.start({ workflow: def, inputs });

const renderer = deps.global.json ? createJsonRenderer(deps.io) : createPlainRenderer(deps.io);
let outcome: RunOutcome | undefined;
// Register the cancel handler immediately before the consume loop — no statement between it and the
// `try` whose `finally` removes it, so the listener can never leak on an intervening throw.
// Register the cancel handler the instant the engine is live — BEFORE constructing the renderer — so a
// failure building the renderer (e.g. ink's `render()` throwing) can never leave a running engine with no
// cooperative-cancel handler; a Ctrl-C in that window still routes to handle.cancel(). The `finally`
// removes it, so the listener can't leak on a throw.
const onSigint = (): void => {
handle.cancel(); // cooperative cancel → run:cancelled (idempotent, safe post-terminal)
};
process.once('SIGINT', onSigint);
let renderer: RunRenderer | undefined;
try {
// Output mode (commands.md "Output modes"): the ink TUI on an interactive TTY, NDJSON under --json,
// the plain line renderer otherwise — all the same `onEvent` seam over one bus (2.F / 2.K).
renderer = (deps.selectRenderer ?? selectRenderer)(deps.io, deps.global);
for await (const event of handle.events) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
renderer.onEvent(event);
outcome = nextOutcome(outcome, event);
Expand All @@ -138,6 +149,17 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr
}
} finally {
process.removeListener('SIGINT', onSigint);
// Tear the renderer down even on a throw: the ink TUI must unmount to restore the terminal and write
// its persistent final summary. The `?.` is a no-op for the line/NDJSON renderers and when `renderer`
// is still undefined (construction threw). A teardown error must NOT mask the run's real
// outcome/error — surface it to stderr and move on.
try {
await renderer?.finalize?.();
} catch (teardownErr) {
deps.io.writeErr(
`renderer teardown failed: ${teardownErr instanceof Error ? teardownErr.message : String(teardownErr)}\n`,
);
}
}

switch (outcome) {
Expand Down
6 changes: 6 additions & 0 deletions apps/cli/src/render/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import type { CliIo } from '../process/io.js';
*/
export interface RunRenderer {
onEvent: (event: RunEvent) => void;
/**
* Optional teardown, awaited by the run core after the event loop ends (even on a throw). The `ink` TUI
* (2.E) uses it to unmount the live view — restoring the terminal — and write its persistent final
* summary; the line and NDJSON renderers need no teardown and omit it. Shared by 2.G / 2.M.
*/
finalize?: () => Promise<void> | void;
}

/**
Expand Down
56 changes: 56 additions & 0 deletions apps/cli/src/render/select.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { RunEvent } from '@relavium/shared';
import { describe, expect, it } from 'vitest';

import type { GlobalOptions } from '../process/options.js';
import { detectOutputMode } from '../process/output-mode.js';
import { captureIo } from '../test-support.js';
import { selectRenderer } from './select.js';

const TS = '2026-06-23T12:00:00.000Z';

function globalOptions(over: Partial<GlobalOptions>): GlobalOptions {
return {
json: false,
color: true,
cwd: '/',
configPath: undefined,
verbosity: 'normal',
...over,
};
}

const COMPLETED: RunEvent = {
type: 'run:completed',
runId: 'r',
timestamp: TS,
sequenceNumber: 1,
outputs: {},
totalTokensUsed: { input: 1, output: 2 },
totalCostMicrocents: 0,
durationMs: 5,
};

describe('selectRenderer', () => {
it('routes --json to the NDJSON renderer (one verbatim event per line, no finalize)', () => {
const io = captureIo(); // stdoutIsTty: false
const renderer = selectRenderer(io.io, globalOptions({ json: true }));
renderer.onEvent(COMPLETED);
expect(io.out()).toBe(`${JSON.stringify(COMPLETED)}\n`);
expect(renderer.finalize).toBeUndefined(); // the NDJSON renderer needs no teardown
});

it('routes a non-TTY, non-json run to the plain line renderer', () => {
const io = captureIo();
const renderer = selectRenderer(io.io, globalOptions({ json: false }));
renderer.onEvent(COMPLETED);
expect(io.out()).toContain('done: run completed');
expect(renderer.finalize).toBeUndefined();
});

it('detectOutputMode picks the TUI only for an interactive, non-CI, non-json TTY', () => {
expect(detectOutputMode({ stdoutIsTty: true, json: false, ci: false })).toBe('tui');
expect(detectOutputMode({ stdoutIsTty: true, json: true, ci: false })).toBe('plain'); // --json wins
expect(detectOutputMode({ stdoutIsTty: true, json: false, ci: true })).toBe('plain'); // CI wins
expect(detectOutputMode({ stdoutIsTty: false, json: false, ci: false })).toBe('plain'); // no TTY
});
});
27 changes: 27 additions & 0 deletions apps/cli/src/render/select.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { CliIo } from '../process/io.js';
import type { GlobalOptions } from '../process/options.js';
import { detectOutputMode, isCiEnv } from '../process/output-mode.js';
import { createInkRenderer } from './tui/ink-renderer.js';
import { createJsonRenderer, createPlainRenderer, type RunRenderer } from './renderer.js';

/**
* Pick the {@link RunRenderer} for a `relavium run` from the resolved output mode (the "Output modes" table
* in [commands.md](../../../../docs/reference/cli/commands.md)): the `ink` TUI when an interactive TTY is
* attached, the NDJSON renderer under `--json`, and the plain line renderer otherwise (no-TTY / `CI=true`).
* All three are the same `onEvent` seam over one bus — "renderer, not a fork" (2.F / 2.K).
*/
export function selectRenderer(io: CliIo, global: GlobalOptions): RunRenderer {
const mode = detectOutputMode({
stdoutIsTty: io.stdoutIsTty,
json: global.json,
ci: isCiEnv(io.env),
});
if (mode === 'tui') {
// The ink renderer takes the real stdout stream directly (not the CliIo text seam): ink needs a
// NodeJS.WriteStream for cursor control / raw mode / terminal dimensions, which `writeOut(text)` cannot
// represent. This path is only reached on an interactive TTY (never in tests — captureIo is not a TTY).
return createInkRenderer({ color: global.color });
}
// 'plain' covers --json (NDJSON), CI, and no-TTY: NDJSON only under the explicit --json opt-in (ADR-0049).
return global.json ? createJsonRenderer(io) : createPlainRenderer(io);
}
100 changes: 100 additions & 0 deletions apps/cli/src/render/tui/RunApp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Box, Text } from 'ink';
import { useSyncExternalStore, type ReactElement } from 'react';

import { formatCostUsd, formatTokens, spinnerFrame, statusColor, statusGlyph } from './format.js';
import { colorProps, dimProps, nodeSuffix } from './projection.js';
import type { RunStore } from './run-store.js';
import { MAX_ACTIVE_TOKEN_LINES, type NodeView } from './run-view-model.js';

/**
* The thin `ink` projection of the {@link RunStore}'s snapshot (workstream **2.E**). It holds NO logic of
* its own — every value comes from the pure reducer (`run-view-model.ts`), the pure formatters
* (`format.ts`), and the pure projection helpers (`projection.ts`), all unit-tested without a TTY. It
* re-renders on the store's (throttled) frame ticks via `useSyncExternalStore`, so a high token rate never
* floods React. Color is applied only when enabled (`--no-color` passes `color: false` through the snapshot).
*/

function NodeLine(props: { node: NodeView; tick: number; useColor: boolean }): ReactElement {

Check warning on line 17 in apps/cli/src/render/tui/RunApp.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=HodeTech_Relavium&issues=AZ72OQ_vpyuGTooKJ1i4&open=AZ72OQ_vpyuGTooKJ1i4&pullRequest=46
const { node, tick, useColor } = props;
const glyph = node.status === 'running' ? spinnerFrame(tick) : statusGlyph(node.status);
return (
<Text {...colorProps(useColor, statusColor(node.status))}>
{glyph} {node.nodeId}
{nodeSuffix(node)}
</Text>
);
}

export function RunApp(props: { store: RunStore }): ReactElement {

Check warning on line 28 in apps/cli/src/render/tui/RunApp.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=HodeTech_Relavium&issues=AZ72OQ_vpyuGTooKJ1i5&open=AZ72OQ_vpyuGTooKJ1i5&pullRequest=46
const { state, tick, color } = useSyncExternalStore(
props.store.subscribe,
props.store.getSnapshot,
);

const activeNode = state.activeNodeId === undefined ? undefined : state.nodes[state.activeNodeId];
const activeLines =
state.activeTokens === '' ? [] : state.activeTokens.split('\n').slice(-MAX_ACTIVE_TOKEN_LINES);

return (
<Box flexDirection="column">
{/* Per-node status list */}
<Box flexDirection="column">
{state.nodeOrder.map((id) => {
const node = state.nodes[id];
return node === undefined ? null : (
<NodeLine key={id} node={node} tick={tick} useColor={color} />
);
})}
</Box>

{/* The active node's live token stream (trailing lines) */}
{activeNode !== undefined && activeLines.length > 0 ? (
<Box flexDirection="column" marginTop={1}>
<Text {...colorProps(color, 'cyan')}>
▌ {activeNode.nodeId}
{state.activeModel === undefined ? '' : ` · ${state.activeModel}`}
</Text>
{/* `truncate-end` bounds each logical line to one terminal row — a newline-free token blast or a
narrow terminal can't blow the live region up to dozens of wrapped rows (§2.E narrow-terminal). */}
{activeLines.map((line, i) => (
<Text key={i} {...dimProps(color)} wrap="truncate-end">

Check warning on line 60 in apps/cli/src/render/tui/RunApp.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Array index in keys

See more on https://sonarcloud.io/project/issues?id=HodeTech_Relavium&issues=AZ72OQ_vpyuGTooKJ1i6&open=AZ72OQ_vpyuGTooKJ1i6&pullRequest=46
{line}
</Text>
))}
</Box>
) : null}

{/* Recent tool activity */}
{state.toolLines.length > 0 ? (
<Box flexDirection="column" marginTop={1}>
{state.toolLines.map((line, i) => (
<Text key={i} {...dimProps(color)} wrap="truncate-end">

Check warning on line 71 in apps/cli/src/render/tui/RunApp.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Array index in keys

See more on https://sonarcloud.io/project/issues?id=HodeTech_Relavium&issues=AZ72OQ_vpyuGTooKJ1i7&open=AZ72OQ_vpyuGTooKJ1i7&pullRequest=46
{line}
</Text>
))}
</Box>
) : null}

{/* Warnings (gap / budget / gate / timeout) */}
{state.warnings.length > 0 ? (
<Box flexDirection="column" marginTop={1}>
{state.warnings.map((w, i) => (
<Text key={i} {...colorProps(color, 'yellow')} wrap="truncate-end">

Check warning on line 82 in apps/cli/src/render/tui/RunApp.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Array index in keys

See more on https://sonarcloud.io/project/issues?id=HodeTech_Relavium&issues=AZ72OQ_vpyuGTooKJ1i8&open=AZ72OQ_vpyuGTooKJ1i8&pullRequest=46
⚠ {w}
</Text>
))}
</Box>
) : null}

{/* Running cost / duration footer */}
<Box marginTop={1}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comment mentions a "duration" footer, but the actual implementation only renders the cumulative cost and final total tokens. If running duration is not intended to be displayed in the live TUI, the comment should be updated to avoid confusion.

Suggested change
{/* Running cost / duration footer */}
<Box marginTop={1}>
{/* Running cost / tokens footer */}

<Text {...colorProps(color, 'gray')}>
cost {formatCostUsd(state.cumulativeCostMicrocents)}
{state.summary?.totalTokens === undefined
? ''
: ` · ${formatTokens(state.summary.totalTokens)}`}
</Text>
</Box>
</Box>
);
}
Loading
Loading