Per review: StreamRow was meant to match what the server sends in TermMsg, not be a separate model needing a translation step. - classifyEvent.ts and streamRow.ts deleted; termMsg.ts holds the wire types (TermMsg/TermEnvelope) plus TermRow, a TermMsg with just the key/fromHistory bookkeeping Preact needs for list rendering. - Row.tsx renders a TermRow directly: level -> CSS class, empty summary + markdown body -> flat row, everything else with a body -> expandable details gated by the operator's preference. No separate classification step. - useLiveStream.ts drops ClassifyCtx (a single incrementing key counter didn't need a whole context object) and maps envelopes to rows inline. - docs/terminal-rendering.md trimmed substantially — was documenting more implementation detail than useful; points at stream_enrich.rs for the per-tool specifics instead of duplicating them in prose.
82 lines
3 KiB
TypeScript
82 lines
3 KiB
TypeScript
// Renders one `TermRow` — flat `<div class="row …">` or expandable
|
|
// `<details class="row …">`, driven straight off the wire shape
|
|
// (`lib/termMsg.ts`'s `TermMsg`, mirroring hive-agent's `term_msg.rs`):
|
|
// `level` picks the colour class, an empty `summary` + markdown `body`
|
|
// is a flat row with just the body (assistant text), anything else with
|
|
// a body is an expandable details row gated by the operator's
|
|
// expand-tool-output preference. No separate classification step —
|
|
// mara: "StreamRow should now match what the server sends in TermMsg."
|
|
import { useEffect, useRef } from 'preact/hooks';
|
|
import type { TermRow } from '../lib/termMsg.js';
|
|
import { linkifyToNodes } from '../lib/linkify.js';
|
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
import { getExpandDetailsPref } from '@hive/shared/prefs.js';
|
|
|
|
function MarkdownBody({ text }: { text: string }) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const html = renderMarkdown(text);
|
|
useEffect(() => {
|
|
// marked autolinks URLs but leaves them same-tab — open externally
|
|
// so a click never navigates the terminal away.
|
|
ref.current?.querySelectorAll('a[href]').forEach((a) => {
|
|
a.setAttribute('target', '_blank');
|
|
a.setAttribute('rel', 'noopener noreferrer');
|
|
});
|
|
}, [html]);
|
|
// eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown
|
|
return <div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />;
|
|
}
|
|
|
|
function DiffBody({ text }: { text: string }) {
|
|
const lines = String(text).split('\n');
|
|
return (
|
|
<pre className="tool-body diff-body">
|
|
{lines.map((line, i) => {
|
|
const cls = line.startsWith('+ ') ? 'diff-add' : line.startsWith('- ') ? 'diff-del' : 'diff-ctx';
|
|
return (
|
|
<span key={i} className={cls}>
|
|
{line}
|
|
{'\n'}
|
|
</span>
|
|
);
|
|
})}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
export function Row({ row }: { row: TermRow }) {
|
|
const cssClass = 'level-' + row.level;
|
|
const icon = row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>;
|
|
|
|
if (row.body == null) {
|
|
return (
|
|
<div className={`row ${cssClass}`}>
|
|
{icon}
|
|
{linkifyToNodes(row.summary)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Empty summary + markdown body → the body itself is the whole row
|
|
// (assistant text), no summary prefix line, never collapsible.
|
|
if (row.body_format === 'markdown' && row.summary === '') {
|
|
return (
|
|
<div className={`row ${cssClass}`}>
|
|
{icon}
|
|
<MarkdownBody text={row.body} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<details className={`row ${cssClass}`} open={getExpandDetailsPref() || undefined}>
|
|
<summary>
|
|
{icon}
|
|
<span className="summary-text">{row.summary}</span>
|
|
</summary>
|
|
{row.body_format === 'diff' && <DiffBody text={row.body} />}
|
|
{row.body_format === 'markdown' && <MarkdownBody text={row.body} />}
|
|
{row.body_format == null && <pre className="tool-body">{linkifyToNodes(row.body)}</pre>}
|
|
</details>
|
|
);
|
|
}
|