@spexcode/transcript-ui 0.7.0-next.1 → 0.7.0-next.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ToolLine.js +26 -10
- package/dist/TranscriptView.d.ts +3 -0
- package/dist/TranscriptView.js +11 -2
- package/dist/context.d.ts +6 -0
- package/dist/context.js +6 -0
- package/dist/envelope.d.ts +10 -0
- package/dist/envelope.js +18 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/segments.js +6 -1
- package/dist/vocabulary.d.ts +6 -0
- package/dist/vocabulary.js +57 -6
- package/package.json +2 -2
- package/styles.css +4 -0
package/dist/ToolLine.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import { useTranscriptUi } from './context.js';
|
|
4
4
|
import { Caret, Spinner } from './icons.js';
|
|
5
5
|
import { isRunning } from './segments.js';
|
|
6
|
-
import { runKinds, splitTarget, toolTarget, toolVerb } from './vocabulary.js';
|
|
6
|
+
import { prettyInput, runKinds, splitTarget, stripAnsi, toolName, toolTarget, toolVerb } from './vocabulary.js';
|
|
7
|
+
const utf8 = new TextEncoder();
|
|
8
|
+
// THE CAP IS SAID WHERE IT BIT. The reader keeps `outputBytes` at the result's true size while the body it
|
|
9
|
+
// carries stops at the per-tool cap ([[transcript-reader]]), so the difference is exactly what this call is
|
|
10
|
+
// missing. The read as a whole already reports its omitted bytes, but that line cannot say WHICH result was
|
|
11
|
+
// cut, and a prefix drawn with no mark reads as the whole output.
|
|
12
|
+
function OutputCut({ tool, body }) {
|
|
13
|
+
const { labels } = useTranscriptUi();
|
|
14
|
+
const omitted = (tool.outputBytes || 0) - utf8.encode(body).length;
|
|
15
|
+
return omitted > 0 ? _jsx("div", { className: "tx-tool-cut", children: labels.outputCut(omitted) }) : null;
|
|
16
|
+
}
|
|
7
17
|
// A LIVE FRAME WITHHOLDS OUTPUT BODIES: a recorded result is `null` on the wire, its size told, and the body
|
|
8
18
|
// is fetched once when a person opens the call, through the host's loader.
|
|
9
19
|
function WithheldOutput({ tool }) {
|
|
@@ -24,12 +34,14 @@ function WithheldOutput({ tool }) {
|
|
|
24
34
|
return _jsx("div", { className: "tx-tool-out tx-tool-out-state", children: labels.loading });
|
|
25
35
|
if (!fetched.ok)
|
|
26
36
|
return _jsx("div", { className: "tx-tool-out tx-tool-out-state is-error", children: fetched.error });
|
|
27
|
-
|
|
37
|
+
const body = fetched.output ?? '';
|
|
38
|
+
return _jsxs(_Fragment, { children: [_jsx("pre", { className: "tx-tool-out", children: stripAnsi(body) }), _jsx(OutputCut, { tool: tool, body: body })] });
|
|
28
39
|
}
|
|
29
40
|
// One tool call as a SENTENCE, not a card: verb, target, and the size of what came back. It is
|
|
30
41
|
// `inline-flex` so a dozen of them read as a list of things that happened rather than a dozen boxes. There
|
|
31
|
-
// is no success mark
|
|
32
|
-
//
|
|
42
|
+
// is no success mark — the past-tense verb is the whole claim. A running call wears a small spinner and the
|
|
43
|
+
// word; a call whose harness recorded a structured failure wears `failed`, and one the person refused wears
|
|
44
|
+
// `rejected` — the outcome is the transcript's own field ([[transcript-reader]]), never read off the output prose.
|
|
33
45
|
export function ToolLine({ tool, open, onToggle, live = false }) {
|
|
34
46
|
const { labels, vocabulary } = useTranscriptUi();
|
|
35
47
|
const target = toolTarget(tool.input, vocabulary);
|
|
@@ -38,12 +50,14 @@ export function ToolLine({ tool, open, onToggle, live = false }) {
|
|
|
38
50
|
const withheld = tool.output === null;
|
|
39
51
|
const canOpen = !!tool.input || tool.output !== undefined || withheld;
|
|
40
52
|
const running = isRunning(tool, live);
|
|
41
|
-
const
|
|
42
|
-
|
|
53
|
+
const outcome = tool.outcome;
|
|
54
|
+
const { server } = toolName(tool.name);
|
|
55
|
+
const row = (_jsxs(_Fragment, { children: [_jsx("span", { className: "tx-tool-verb", children: toolVerb(tool.name, vocabulary) }), server && _jsx("span", { className: "tx-tool-server", children: server }), lead && _jsx("span", { className: "tx-tool-target", children: lead }), trail && _jsx("span", { className: "tx-tool-trail", children: trail }), lines > 0 && _jsx("span", { className: "tx-tool-size", children: labels.lines(lines) }), running && _jsxs("span", { className: "tx-tool-running", children: [_jsx(Spinner, {}), labels.running] }), outcome && _jsx("span", { className: `tx-tool-outcome is-${outcome}`, children: outcome === 'failed' ? labels.failed : labels.rejected }), canOpen && _jsx(Caret, { open: open, className: "tx-tool-caret" })] }));
|
|
56
|
+
return (_jsxs("div", { className: `tx-tool${running ? ' is-running' : ''}${outcome ? ` is-${outcome}` : ''}`, children: [canOpen
|
|
43
57
|
? _jsx("button", { type: "button", onClick: onToggle, "aria-expanded": open, className: "tx-tool-row is-openable", children: row })
|
|
44
|
-
: _jsx("div", { className: "tx-tool-row", children: row }), open && canOpen && _jsxs(_Fragment, { children: [tool.input && _jsx("pre", { className: "tx-tool-in", children: tool.input }), withheld
|
|
58
|
+
: _jsx("div", { className: "tx-tool-row", children: row }), open && canOpen && _jsxs(_Fragment, { children: [tool.input && _jsx("pre", { className: "tx-tool-in", children: stripAnsi(prettyInput(tool.input)) }), withheld
|
|
45
59
|
? _jsx(WithheldOutput, { tool: tool })
|
|
46
|
-
: tool.output !== undefined && _jsx("pre", { className: "tx-tool-out", children: tool.output })] })] }));
|
|
60
|
+
: tool.output !== undefined && _jsxs(_Fragment, { children: [_jsx("pre", { className: "tx-tool-out", children: stripAnsi(tool.output) }), _jsx(OutputCut, { tool: tool, body: tool.output })] })] })] }));
|
|
47
61
|
}
|
|
48
62
|
// A turn's tool calls are consecutive by construction, so "a run" is just "this turn's calls". `runMin` or
|
|
49
63
|
// more fold to one row; fewer stay sentences, where the verb and target are worth reading on sight. `fold`
|
|
@@ -58,5 +72,7 @@ export function ToolRun({ tools, openIds, onToggle, live = false, fold = true })
|
|
|
58
72
|
const id = `run:${tools[0].id}`;
|
|
59
73
|
const open = openIds.has(id);
|
|
60
74
|
const running = tools.some((tool) => isRunning(tool, live));
|
|
61
|
-
|
|
75
|
+
// a fold must not hide a failure: the row counts the calls that did not succeed
|
|
76
|
+
const failed = tools.filter((tool) => tool.outcome).length;
|
|
77
|
+
return _jsx("div", { className: "tx-tools", children: _jsxs("div", { className: `tx-tool${running ? ' is-running' : ''}${failed ? ' is-failed' : ''}`, children: [_jsxs("button", { type: "button", className: "tx-tool-row is-openable is-run", "aria-expanded": open, onClick: () => onToggle(id), children: [_jsx("span", { className: "tx-tool-verb", children: labels.toolUses(tools.length) }), _jsx("span", { className: "tx-tool-trail", children: runKinds(tools, vocabulary) }), failed > 0 && _jsx("span", { className: "tx-tool-outcome is-failed", children: labels.failedCount(failed) }), _jsx(Caret, { open: open, className: "tx-tool-caret" })] }), open && _jsx("div", { className: "tx-tool-kids", children: tools.map(line) })] }) });
|
|
62
78
|
}
|
package/dist/TranscriptView.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ export declare function TurnBody({ turn, openIds, onToggle, live, fold }: {
|
|
|
8
8
|
live: boolean;
|
|
9
9
|
fold?: boolean;
|
|
10
10
|
} & Disclosure): import("react").JSX.Element;
|
|
11
|
+
export declare function QuotedTurn({ turn }: {
|
|
12
|
+
turn: AnyTurn;
|
|
13
|
+
}): import("react").JSX.Element;
|
|
11
14
|
export declare function WorkSegmentView({ segment, openIds, onToggle, live }: {
|
|
12
15
|
segment: Work;
|
|
13
16
|
live: boolean;
|
package/dist/TranscriptView.js
CHANGED
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useTranscriptUi } from './context.js';
|
|
3
3
|
import { Caret } from './icons.js';
|
|
4
4
|
import { Quote } from './Quote.js';
|
|
5
|
+
import { parseEnvelope } from './envelope.js';
|
|
5
6
|
import { segments } from './segments.js';
|
|
6
7
|
import { ToolRun } from './ToolLine.js';
|
|
7
8
|
import { useDisclosure } from './useDisclosure.js';
|
|
@@ -11,19 +12,27 @@ export function TurnBody({ turn, openIds, onToggle, live, fold = true }) {
|
|
|
11
12
|
const { renderText } = useTranscriptUi();
|
|
12
13
|
return _jsxs("div", { className: "tx-say", children: [turn.text && _jsx("div", { className: "tx-say-text", children: renderText(turn.text) }), _jsx(ToolRun, { tools: turn.tools, openIds: openIds, onToggle: onToggle, live: live, fold: fold })] });
|
|
13
14
|
}
|
|
15
|
+
// a quoted turn is read through the envelope rows: the sender it names, the body it carried
|
|
16
|
+
export function QuotedTurn({ turn }) {
|
|
17
|
+
const { envelopes } = useTranscriptUi();
|
|
18
|
+
const envelope = parseEnvelope(turn.text || '', envelopes);
|
|
19
|
+
return _jsx(Quote, { who: envelope.who, ts: envelope.at ?? turn.at, text: envelope.body, className: "tx-quote-nested" });
|
|
20
|
+
}
|
|
14
21
|
export function WorkSegmentView({ segment, openIds, onToggle, live }) {
|
|
15
22
|
const { labels, vocabulary } = useTranscriptUi();
|
|
16
23
|
const id = `seg:${segment.work[0]?.id || segment.answer?.id}`;
|
|
17
24
|
const open = openIds.has(id);
|
|
18
25
|
const kinds = runKinds(segment.work.flatMap((turn) => turn.tools ?? []), vocabulary);
|
|
19
26
|
const foldedCalls = segment.work.reduce((n, turn) => n + (turn.tools?.length || 0), 0);
|
|
27
|
+
// a fold must not hide a failure: the row counts the calls whose harness recorded one
|
|
28
|
+
const failedCalls = segment.work.reduce((n, turn) => n + (turn.tools?.filter((tool) => tool.outcome).length || 0), 0);
|
|
20
29
|
// history folds its runs; the work in progress (a live segment's calls after its newest prose) does not
|
|
21
30
|
const history = !segment.now || !!segment.answer;
|
|
22
|
-
return _jsxs(_Fragment, { children: [segment.folded ? (_jsxs("div", { className:
|
|
31
|
+
return _jsxs(_Fragment, { children: [segment.folded ? (_jsxs("div", { className: `tx-work${failedCalls ? ' is-failed' : ''}`, children: [_jsxs("button", { type: "button", className: "tx-work-row", "aria-expanded": open, onClick: () => onToggle(id), children: [_jsx("span", { className: "tx-work-lead", children: labels.toolUses(foldedCalls) }), kinds && _jsx("span", { className: "tx-work-detail", children: kinds }), failedCalls > 0 && _jsx("span", { className: "tx-tool-outcome is-failed", children: labels.failedCount(failedCalls) }), _jsx(Caret, { open: open, className: "tx-work-caret" })] }), open && _jsx("div", { className: "tx-work-body", children: segment.work.map((turn) => _jsx(TurnBody, { turn: turn, openIds: openIds, onToggle: onToggle, live: live }, turn.id)) })] })) : segment.work.map((turn) => _jsx(TurnBody, { turn: turn, openIds: openIds, onToggle: onToggle, live: live, fold: history }, turn.id)), segment.answer && _jsx(TurnBody, { turn: segment.answer, openIds: openIds, onToggle: onToggle, live: live, fold: !segment.now }), segment.after.map((turn) => _jsx(TurnBody, { turn: turn, openIds: openIds, onToggle: onToggle, live: live, fold: !segment.now }, turn.id))] });
|
|
23
32
|
}
|
|
24
33
|
export function SegmentView({ segment, ...rest }) {
|
|
25
34
|
if (segment.kind === 'quote')
|
|
26
|
-
return _jsx(
|
|
35
|
+
return _jsx(QuotedTurn, { turn: segment.turn });
|
|
27
36
|
return _jsx(WorkSegmentView, { segment: segment, ...rest });
|
|
28
37
|
}
|
|
29
38
|
// THE TURNS, in the grammar: quotes where the host wants them, work segments folded behind their answers
|
package/dist/context.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { type Vocabulary } from './vocabulary.js';
|
|
3
3
|
import type { FoldPolicy, UserTurnPolicy } from './segments.js';
|
|
4
|
+
import { type EnvelopeParser } from './envelope.js';
|
|
4
5
|
export type ToolOutputResult = {
|
|
5
6
|
ok: true;
|
|
6
7
|
output: string | null;
|
|
@@ -11,10 +12,14 @@ export type ToolOutputResult = {
|
|
|
11
12
|
export type Labels = Readonly<{
|
|
12
13
|
loading: string;
|
|
13
14
|
running: string;
|
|
15
|
+
failed: string;
|
|
16
|
+
rejected: string;
|
|
17
|
+
failedCount: (n: number) => string;
|
|
14
18
|
more: string;
|
|
15
19
|
toolUses: (n: number) => string;
|
|
16
20
|
lines: (n: number) => string;
|
|
17
21
|
empty: string;
|
|
22
|
+
outputCut: (omittedBytes: number) => string;
|
|
18
23
|
truncated: (info: {
|
|
19
24
|
omittedTurns: number;
|
|
20
25
|
omittedBytes: number;
|
|
@@ -27,6 +32,7 @@ export type TranscriptUiOptions = Readonly<{
|
|
|
27
32
|
loadToolOutput: ((toolId: string) => Promise<ToolOutputResult>) | null;
|
|
28
33
|
labels: Labels;
|
|
29
34
|
vocabulary: Vocabulary;
|
|
35
|
+
envelopes: readonly EnvelopeParser[];
|
|
30
36
|
fold: FoldPolicy;
|
|
31
37
|
runMin: number;
|
|
32
38
|
userTurns: UserTurnPolicy;
|
package/dist/context.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useContext } from 'react';
|
|
3
3
|
import { defaultVocabulary } from './vocabulary.js';
|
|
4
|
+
import { defaultEnvelopes } from './envelope.js';
|
|
4
5
|
export const defaultLabels = {
|
|
5
6
|
loading: 'loading…',
|
|
6
7
|
running: 'running',
|
|
8
|
+
failed: 'failed',
|
|
9
|
+
rejected: 'rejected',
|
|
10
|
+
failedCount: (n) => `${n} failed`,
|
|
7
11
|
more: 'more',
|
|
8
12
|
toolUses: (n) => `${n} tool use${n === 1 ? '' : 's'}`,
|
|
9
13
|
lines: (n) => `${n} line${n === 1 ? '' : 's'}`,
|
|
10
14
|
empty: 'nothing in this interval',
|
|
15
|
+
outputCut: (n) => `${n.toLocaleString()} more bytes not shown`,
|
|
11
16
|
truncated: ({ omittedTurns, omittedBytes, outOfOrderEvents }) => `truncated: ${omittedTurns} turns and ${omittedBytes} bytes omitted${outOfOrderEvents ? `, ${outOfOrderEvents} records out of order` : ''}`,
|
|
12
17
|
};
|
|
13
18
|
// the default prose renderer: paragraphs on blank lines, line breaks kept — a message was typed, not laid out
|
|
@@ -20,6 +25,7 @@ export const defaultOptions = {
|
|
|
20
25
|
loadToolOutput: null,
|
|
21
26
|
labels: defaultLabels,
|
|
22
27
|
vocabulary: defaultVocabulary,
|
|
28
|
+
envelopes: defaultEnvelopes,
|
|
23
29
|
fold: 'segments',
|
|
24
30
|
runMin: 3,
|
|
25
31
|
userTurns: 'boundary',
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type Envelope = Readonly<{
|
|
2
|
+
who: string | null;
|
|
3
|
+
id?: string | null;
|
|
4
|
+
at?: number | null;
|
|
5
|
+
body: string;
|
|
6
|
+
}>;
|
|
7
|
+
export type EnvelopeParser = (text: string) => Envelope | null;
|
|
8
|
+
export declare const spexEnvelope: EnvelopeParser;
|
|
9
|
+
export declare const defaultEnvelopes: readonly EnvelopeParser[];
|
|
10
|
+
export declare function parseEnvelope(text: string, parsers?: readonly EnvelopeParser[]): Envelope;
|
package/dist/envelope.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// `— from session "label" (id) on machine m. To reply: spex session send [--ssh addr] id "<your reply>"`
|
|
2
|
+
const SPEX_FOOTER = /\n*— from session (?:"(.*?)" \(([^\s)]+)\)|(\S+))(?: on machine \S+)?\. To reply: spex session send (?:--ssh \S+ )?\S+ "<your reply>"\s*$/;
|
|
3
|
+
export const spexEnvelope = (text) => {
|
|
4
|
+
const m = SPEX_FOOTER.exec(text || '');
|
|
5
|
+
if (!m)
|
|
6
|
+
return null;
|
|
7
|
+
const id = m[2] || m[3] || null;
|
|
8
|
+
return { who: m[1] || id, id, body: text.slice(0, m.index) };
|
|
9
|
+
};
|
|
10
|
+
export const defaultEnvelopes = [spexEnvelope];
|
|
11
|
+
export function parseEnvelope(text, parsers = defaultEnvelopes) {
|
|
12
|
+
for (const parse of parsers) {
|
|
13
|
+
const envelope = parse(text);
|
|
14
|
+
if (envelope)
|
|
15
|
+
return envelope;
|
|
16
|
+
}
|
|
17
|
+
return { who: null, body: text };
|
|
18
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/segments.js
CHANGED
|
@@ -37,7 +37,12 @@ export function segments(turns, options = {}) {
|
|
|
37
37
|
const answer = run[lead]?.text ? run[lead] : null;
|
|
38
38
|
const work = answer ? run.slice(0, lead) : run;
|
|
39
39
|
const after = answer ? run.slice(lead + 1) : [];
|
|
40
|
-
|
|
40
|
+
// THE FOLD DECIDES ON WHAT IT WILL HIDE, which is the WORK's calls — not the segment's. `calls` counts the
|
|
41
|
+
// whole run, answer included, and a run whose calls all sit on its answer turn hides none of them: deciding
|
|
42
|
+
// on that total drew a fold row reading "0 tool uses" over prose, a row naming something it did not stand
|
|
43
|
+
// for. The row counts the hidden calls, so the decision must count the same ones.
|
|
44
|
+
const hidden = work.reduce((n, turn) => n + (turn.tools?.length || 0), 0);
|
|
45
|
+
out.push({ kind: 'work', work, answer, after, calls, folded: fold === 'segments' && hidden >= runMin && work.length > 0, now: false });
|
|
41
46
|
run = [];
|
|
42
47
|
};
|
|
43
48
|
for (const turn of turns) {
|
package/dist/vocabulary.d.ts
CHANGED
|
@@ -11,11 +11,17 @@ export declare function extendVocabulary(base: Vocabulary, extra: Partial<{
|
|
|
11
11
|
quiet: Iterable<string>;
|
|
12
12
|
targetKeys: readonly string[];
|
|
13
13
|
}>): Vocabulary;
|
|
14
|
+
export declare function toolName(name: string | undefined): {
|
|
15
|
+
tool: string;
|
|
16
|
+
server: string | null;
|
|
17
|
+
};
|
|
14
18
|
export declare const toolVerb: (name: string | undefined, vocabulary?: Readonly<{
|
|
15
19
|
verbs: Readonly<Record<string, string>>;
|
|
16
20
|
quiet: ReadonlySet<string>;
|
|
17
21
|
targetKeys: readonly string[];
|
|
18
22
|
}>) => string;
|
|
23
|
+
export declare function prettyInput(input: string | undefined): string;
|
|
24
|
+
export declare const stripAnsi: (text: string) => string;
|
|
19
25
|
export declare const isQuietTool: (name: string, vocabulary?: Readonly<{
|
|
20
26
|
verbs: Readonly<Record<string, string>>;
|
|
21
27
|
quiet: ReadonlySet<string>;
|
package/dist/vocabulary.js
CHANGED
|
@@ -3,6 +3,7 @@ export const defaultVocabulary = {
|
|
|
3
3
|
Read: 'Read', NotebookRead: 'Read',
|
|
4
4
|
Grep: 'Searched', Glob: 'Searched', WebSearch: 'Searched the web',
|
|
5
5
|
Bash: 'Ran', BashOutput: 'Read output',
|
|
6
|
+
exec: 'Ran', shell: 'Ran', wait: 'Waited',
|
|
6
7
|
Edit: 'Edited', MultiEdit: 'Edited', NotebookEdit: 'Edited',
|
|
7
8
|
Write: 'Wrote',
|
|
8
9
|
WebFetch: 'Fetched',
|
|
@@ -10,7 +11,7 @@ export const defaultVocabulary = {
|
|
|
10
11
|
TodoWrite: 'Updated the plan',
|
|
11
12
|
},
|
|
12
13
|
quiet: new Set(['Read', 'NotebookRead', 'Grep', 'Glob', 'WebFetch', 'WebSearch']),
|
|
13
|
-
targetKeys: ['file_path', 'filePath', 'path', 'notebook_path', 'pattern', 'query', 'command', 'cmd', 'url', 'description'],
|
|
14
|
+
targetKeys: ['file_path', 'filePath', 'path', 'notebook_path', 'pattern', 'query', 'command', 'cmd', 'url', 'cell_id', 'description'],
|
|
14
15
|
};
|
|
15
16
|
export function extendVocabulary(base, extra) {
|
|
16
17
|
return {
|
|
@@ -19,8 +20,52 @@ export function extendVocabulary(base, extra) {
|
|
|
19
20
|
targetKeys: extra.targetKeys ? [...extra.targetKeys, ...base.targetKeys.filter((key) => !extra.targetKeys.includes(key))] : base.targetKeys,
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
+
// AN MCP TOOL IS NAMED BY ITS SERVER AND ITS TOOL. Every harness that speaks MCP writes the call as
|
|
24
|
+
// `mcp__<server>__<tool>`; the reader wants both halves, apart — measured across four transcript renderers,
|
|
25
|
+
// three knew the server in their data and lost it on the screen. The vocabulary may name the full id or the
|
|
26
|
+
// bare tool; an unnamed MCP tool reads as its tool half, never the whole mangled id.
|
|
27
|
+
export function toolName(name) {
|
|
28
|
+
const m = name ? /^mcp__(.+?)__(.+)$/.exec(name) : null;
|
|
29
|
+
return m ? { tool: m[2], server: m[1] } : { tool: name || 'tool', server: null };
|
|
30
|
+
}
|
|
31
|
+
export const toolVerb = (name, vocabulary = defaultVocabulary) => {
|
|
32
|
+
if (name && vocabulary.verbs[name])
|
|
33
|
+
return vocabulary.verbs[name];
|
|
34
|
+
const { tool } = toolName(name);
|
|
35
|
+
return vocabulary.verbs[tool] || tool;
|
|
36
|
+
};
|
|
37
|
+
// THE ARGUMENTS, WHEN OPENED, READ AS THE ARGUMENTS: a JSON object pretty-printed one field per line, a bare
|
|
38
|
+
// string (a script, a command) as itself. The wire form is one line; nobody reads one line of JSON.
|
|
39
|
+
export function prettyInput(input) {
|
|
40
|
+
if (typeof input !== 'string' || !input)
|
|
41
|
+
return '';
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(input);
|
|
44
|
+
return parsed && typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : input;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return input;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// TOOL OUTPUT IS A RECORD OF WHAT A PROGRAM PRINTED, AND PROGRAMS PRINT COLOUR. Real transcripts are full of
|
|
51
|
+
// it — tens of thousands of escape sequences across a few hundred Claude and Codex files — and a `<pre>` draws
|
|
52
|
+
// them as literal `[0m[91m` debris in the middle of the sentence a person is trying to read. The reader keeps
|
|
53
|
+
// those bytes faithfully; this view is prose rather than a terminal (a terminal is [[terminal-ui]]'s job), so
|
|
54
|
+
// the sequences are dropped at the moment of drawing and never from the record. Because the page then holds no
|
|
55
|
+
// escapes, text copied off it is already clean — no separate copy path is needed. Covers the CSI forms colour
|
|
56
|
+
// uses, OSC strings with either terminator, and the two-byte escapes; a lone ESC in prose is left alone.
|
|
57
|
+
const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g;
|
|
58
|
+
export const stripAnsi = (text) => text.replace(ANSI, '');
|
|
23
59
|
export const isQuietTool = (name, vocabulary = defaultVocabulary) => vocabulary.quiet.has(name);
|
|
60
|
+
// A command's or a script's head: its first non-empty line, clamped. A one-liner is itself; a multi-line
|
|
61
|
+
// script shows the line that names it, and the CSS ellipsis takes the rest.
|
|
62
|
+
function firstLine(text) {
|
|
63
|
+
// escapes go before the 160-char cut, or the cut lands inside a sequence and leaves half of one on the row
|
|
64
|
+
const line = stripAnsi(text).split(/\r?\n/).map((l) => l.trim()).find(Boolean);
|
|
65
|
+
if (!line)
|
|
66
|
+
return null;
|
|
67
|
+
return line.length <= 160 ? line : line.slice(0, 160);
|
|
68
|
+
}
|
|
24
69
|
// The target, from the call's own arguments. `input` is the raw JSON of the arguments (or a bare string), so
|
|
25
70
|
// this reads the field the tool actually names and shows NOTHING when it cannot — a wrong target is worse
|
|
26
71
|
// than no target, and a truncated blob of JSON is not a target at all.
|
|
@@ -28,18 +73,24 @@ export function toolTarget(input, vocabulary = defaultVocabulary) {
|
|
|
28
73
|
if (typeof input !== 'string' || !input)
|
|
29
74
|
return null;
|
|
30
75
|
let parsed = null;
|
|
76
|
+
// A tool whose input is a bare string is a command or a script (a codex `exec` cell, a shell one-liner). Its
|
|
77
|
+
// FIRST non-empty line names it — a wall of script has a head worth reading, and nothing is worse than a row
|
|
78
|
+
// that says only "exec". JSON is parsed for its named target below; a bare string is its own head.
|
|
31
79
|
try {
|
|
32
80
|
parsed = JSON.parse(input);
|
|
33
81
|
}
|
|
34
82
|
catch {
|
|
35
|
-
return input
|
|
83
|
+
return firstLine(input);
|
|
36
84
|
}
|
|
37
85
|
if (!parsed || typeof parsed !== 'object')
|
|
38
|
-
return typeof parsed === 'string' ? parsed : null;
|
|
86
|
+
return typeof parsed === 'string' ? firstLine(parsed) : null;
|
|
39
87
|
for (const key of vocabulary.targetKeys) {
|
|
40
88
|
const value = parsed[key];
|
|
41
|
-
if (typeof value
|
|
42
|
-
|
|
89
|
+
if (typeof value !== 'string')
|
|
90
|
+
continue;
|
|
91
|
+
const named = stripAnsi(value).trim();
|
|
92
|
+
if (named)
|
|
93
|
+
return named;
|
|
43
94
|
}
|
|
44
95
|
return null;
|
|
45
96
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spexcode/transcript-ui",
|
|
3
|
-
"version": "0.7.0-next.
|
|
3
|
+
"version": "0.7.0-next.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "React components that draw a normalized agent transcript — the person quoted, the agent as the page, a tool call as a sentence, the work folded behind its answer — with the fold, the prose renderer, the tool vocabulary, the labels and the design tokens all tunable.",
|
|
6
6
|
"files": [
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"test": "npm run build && tsx --test src/*.test.tsx"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@spexcode/transcript": "0.7.0-next.
|
|
27
|
+
"@spexcode/transcript": "0.7.0-next.11"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"react": "^18.2.0 || ^19.0.0"
|
package/styles.css
CHANGED
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
.tx-tool-target { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--tx-ink); }
|
|
86
86
|
.tx-tool-trail { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--tx-muted); }
|
|
87
87
|
.tx-tool-size { flex: none; color: var(--tx-muted); font-variant-numeric: tabular-nums; }
|
|
88
|
+
.tx-tool-server { flex: none; color: var(--tx-muted); font-size: var(--tx-type-caption); padding: 0 5px; border: 1px solid var(--tx-edge); border-radius: 999px; }
|
|
88
89
|
.tx-tool-row.is-run .tx-tool-verb { color: var(--tx-muted); font-weight: inherit; }
|
|
89
90
|
.tx-tool-kids { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; margin-left: 8px; padding-left: 10px; border-left: 1px solid var(--tx-edge); }
|
|
90
91
|
.tx-tool-in, .tx-tool-out {
|
|
@@ -104,6 +105,9 @@
|
|
|
104
105
|
of words still being said. Reduced motion keeps the marks and drops the movement. */
|
|
105
106
|
.tx-tool.is-running .tx-tool-verb { color: var(--tx-ink2); }
|
|
106
107
|
.tx-tool-running { display: inline-flex; align-items: center; gap: 4px; flex: none; color: var(--tx-orange); font-size: var(--tx-type-caption); }
|
|
108
|
+
.tx-tool-outcome { flex: none; color: var(--tx-red); font-size: var(--tx-type-caption); }
|
|
109
|
+
.tx-tool-cut { color: var(--tx-muted); font-size: var(--tx-type-caption); padding: var(--tx-space-1) 0 0; }
|
|
110
|
+
.tx-tool.is-failed > .tx-tool-row .tx-tool-verb, .tx-tool.is-rejected > .tx-tool-row .tx-tool-verb { color: var(--tx-red); }
|
|
107
111
|
.tx-spin { animation: tx-spin 1s linear infinite; }
|
|
108
112
|
@keyframes tx-spin { to { transform: rotate(360deg); } }
|
|
109
113
|
.tx-live { margin-top: var(--tx-space-2); min-width: 0; }
|