@spexcode/transcript-ui 0.7.0-next.10 → 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 CHANGED
@@ -1,9 +1,19 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
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 { prettyInput, runKinds, splitTarget, toolName, 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,7 +34,8 @@ 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
- return _jsx("pre", { className: "tx-tool-out", children: fetched.output ?? '' });
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
@@ -44,9 +55,9 @@ export function ToolLine({ tool, open, onToggle, live = false }) {
44
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" })] }));
45
56
  return (_jsxs("div", { className: `tx-tool${running ? ' is-running' : ''}${outcome ? ` is-${outcome}` : ''}`, children: [canOpen
46
57
  ? _jsx("button", { type: "button", onClick: onToggle, "aria-expanded": open, className: "tx-tool-row is-openable", children: row })
47
- : _jsx("div", { className: "tx-tool-row", children: row }), open && canOpen && _jsxs(_Fragment, { children: [tool.input && _jsx("pre", { className: "tx-tool-in", children: prettyInput(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
48
59
  ? _jsx(WithheldOutput, { tool: tool })
49
- : 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 })] })] })] }));
50
61
  }
51
62
  // A turn's tool calls are consecutive by construction, so "a run" is just "this turn's calls". `runMin` or
52
63
  // more fold to one row; fewer stay sentences, where the verb and target are worth reading on sight. `fold`
package/dist/context.d.ts CHANGED
@@ -19,6 +19,7 @@ export type Labels = Readonly<{
19
19
  toolUses: (n: number) => string;
20
20
  lines: (n: number) => string;
21
21
  empty: string;
22
+ outputCut: (omittedBytes: number) => string;
22
23
  truncated: (info: {
23
24
  omittedTurns: number;
24
25
  omittedBytes: number;
package/dist/context.js CHANGED
@@ -12,6 +12,7 @@ export const defaultLabels = {
12
12
  toolUses: (n) => `${n} tool use${n === 1 ? '' : 's'}`,
13
13
  lines: (n) => `${n} line${n === 1 ? '' : 's'}`,
14
14
  empty: 'nothing in this interval',
15
+ outputCut: (n) => `${n.toLocaleString()} more bytes not shown`,
15
16
  truncated: ({ omittedTurns, omittedBytes, outOfOrderEvents }) => `truncated: ${omittedTurns} turns and ${omittedBytes} bytes omitted${outOfOrderEvents ? `, ${outOfOrderEvents} records out of order` : ''}`,
16
17
  };
17
18
  // the default prose renderer: paragraphs on blank lines, line breaks kept — a message was typed, not laid out
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
- out.push({ kind: 'work', work, answer, after, calls, folded: fold === 'segments' && calls >= runMin && work.length > 0, now: false });
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) {
@@ -21,6 +21,7 @@ export declare const toolVerb: (name: string | undefined, vocabulary?: Readonly<
21
21
  targetKeys: readonly string[];
22
22
  }>) => string;
23
23
  export declare function prettyInput(input: string | undefined): string;
24
+ export declare const stripAnsi: (text: string) => string;
24
25
  export declare const isQuietTool: (name: string, vocabulary?: Readonly<{
25
26
  verbs: Readonly<Record<string, string>>;
26
27
  quiet: ReadonlySet<string>;
@@ -47,11 +47,21 @@ export function prettyInput(input) {
47
47
  return input;
48
48
  }
49
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, '');
50
59
  export const isQuietTool = (name, vocabulary = defaultVocabulary) => vocabulary.quiet.has(name);
51
60
  // A command's or a script's head: its first non-empty line, clamped. A one-liner is itself; a multi-line
52
61
  // script shows the line that names it, and the CSS ellipsis takes the rest.
53
62
  function firstLine(text) {
54
- const line = text.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
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);
55
65
  if (!line)
56
66
  return null;
57
67
  return line.length <= 160 ? line : line.slice(0, 160);
@@ -76,8 +86,11 @@ export function toolTarget(input, vocabulary = defaultVocabulary) {
76
86
  return typeof parsed === 'string' ? firstLine(parsed) : null;
77
87
  for (const key of vocabulary.targetKeys) {
78
88
  const value = parsed[key];
79
- if (typeof value === 'string' && value.trim())
80
- return value.trim();
89
+ if (typeof value !== 'string')
90
+ continue;
91
+ const named = stripAnsi(value).trim();
92
+ if (named)
93
+ return named;
81
94
  }
82
95
  return null;
83
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/transcript-ui",
3
- "version": "0.7.0-next.10",
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.10"
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
@@ -106,6 +106,7 @@
106
106
  .tx-tool.is-running .tx-tool-verb { color: var(--tx-ink2); }
107
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
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; }
109
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); }
110
111
  .tx-spin { animation: tx-spin 1s linear infinite; }
111
112
  @keyframes tx-spin { to { transform: rotate(360deg); } }