-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cli): 2.E — ink streaming TUI (live node status + token stream + cost) #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
b80772c
3defc92
4a79f2a
bf970b1
0e308f4
e9f8d84
cc2f637
a67cbd6
9b4fc51
159e30a
afa8399
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| }); | ||
| }); |
| 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); | ||
| } |
| 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
|
||||||||
| 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
|
||||||||
| 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
|
||||||||
| {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
|
||||||||
| {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
|
||||||||
| ⚠ {w} | ||||||||
| </Text> | ||||||||
| ))} | ||||||||
| </Box> | ||||||||
| ) : null} | ||||||||
|
|
||||||||
| {/* Running cost / duration footer */} | ||||||||
| <Box marginTop={1}> | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
|
||||||||
| <Text {...colorProps(color, 'gray')}> | ||||||||
| cost {formatCostUsd(state.cumulativeCostMicrocents)} | ||||||||
| {state.summary?.totalTokens === undefined | ||||||||
| ? '' | ||||||||
| : ` · ${formatTokens(state.summary.totalTokens)}`} | ||||||||
| </Text> | ||||||||
| </Box> | ||||||||
| </Box> | ||||||||
| ); | ||||||||
| } | ||||||||
Uh oh!
There was an error while loading. Please reload this page.