transcript-viewer 0.6.2__tar.gz → 0.7.0__tar.gz

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.
Files changed (32) hide show
  1. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/PKG-INFO +1 -1
  2. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/pyproject.toml +1 -1
  3. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/src/transcript_viewer/page.html +186 -10
  4. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/page.test.js +142 -0
  5. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/uv.lock +1 -1
  6. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/.coverage +0 -0
  7. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/.github/workflows/publish.yml +0 -0
  8. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/.github/workflows/test.yml +0 -0
  9. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/.gitignore +0 -0
  10. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/README.md +0 -0
  11. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/src/transcript_viewer/__init__.py +0 -0
  12. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/src/transcript_viewer/ai.py +0 -0
  13. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/src/transcript_viewer/cli.py +0 -0
  14. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/src/transcript_viewer/config.py +0 -0
  15. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/src/transcript_viewer/corpus.py +0 -0
  16. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/src/transcript_viewer/fetch.py +0 -0
  17. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/src/transcript_viewer/library.py +0 -0
  18. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/src/transcript_viewer/store.py +0 -0
  19. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/src/transcript_viewer/viewer.py +0 -0
  20. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/test_ai.py +0 -0
  21. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/test_cli.py +0 -0
  22. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/test_config.py +0 -0
  23. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/test_corpus.py +0 -0
  24. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/test_fetch.py +0 -0
  25. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/test_library.py +0 -0
  26. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/test_page.py +0 -0
  27. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/test_readme.py +0 -0
  28. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/test_store.py +0 -0
  29. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/test_stream.py +0 -0
  30. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/test_style.py +0 -0
  31. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/test_tidiness.py +0 -0
  32. {transcript_viewer-0.6.2 → transcript_viewer-0.7.0}/tests/test_viewer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: transcript-viewer
3
- Version: 0.6.2
3
+ Version: 0.7.0
4
4
  Summary: Browse agent transcripts in a local, dependency-free web viewer.
5
5
  License: MIT
6
6
  Requires-Python: >=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "transcript-viewer"
3
- version = "0.6.2"
3
+ version = "0.7.0"
4
4
  description = "Browse agent transcripts in a local, dependency-free web viewer."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -36,6 +36,8 @@
36
36
  --user:var(--accent); --agent:var(--ink); --system:var(--muted); --tool:var(--accent);
37
37
  --user-bg:var(--soft); --agent-bg:transparent; --system-bg:transparent;
38
38
  --j-key:var(--accent); --j-str:var(--ink); --j-num:var(--muted); --j-lit:var(--muted);
39
+ --sh-cmd:var(--accent); --sh-str:var(--ink); --sh-flag:var(--muted);
40
+ --sh-op:var(--muted); --sh-var:var(--accent); --sh-com:var(--faint);
39
41
  }
40
42
  *{box-sizing:border-box;margin:0}
41
43
  a{color:var(--accent);text-decoration:none}
@@ -409,6 +411,26 @@ pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-siz
409
411
 
410
412
  /* JSON syntax colours */
411
413
  .j-key{color:var(--j-key)}
414
+ /* A command reads as a command: one gutter mark, then the script as written. */
415
+ .sh{background:var(--bg);border-radius:7px;padding:10px 12px 10px 26px;margin:0;
416
+ position:relative;overflow-x:auto;white-space:pre;font:12px/1.6 var(--font-mono);
417
+ color:var(--ink)}
418
+ .sh::before{content:"$";position:absolute;left:11px;top:10px;color:var(--faint);
419
+ user-select:none}
420
+ .sh-cmd{color:var(--sh-cmd)}
421
+ .sh-str{color:var(--sh-str)}
422
+ .sh-flag{color:var(--sh-flag)}
423
+ .sh-op{color:var(--sh-op);font-weight:500}
424
+ .sh-var{color:var(--sh-var)}
425
+ .sh-com{color:var(--sh-com);font-style:italic}
426
+ /* What the call was for, beside what it was called. */
427
+ .tdesc{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;
428
+ white-space:nowrap;flex:1;min-width:0}
429
+ .tflip{margin-left:auto;flex:none;font:11px/1 var(--font-mono);color:var(--faint);
430
+ border:1px solid var(--line);border-radius:20px;padding:3px 8px;cursor:pointer}
431
+ .tflip:hover{color:var(--ink);border-color:var(--accent)}
432
+ .turl{display:block;margin:0 0 8px;font-size:12.5px;overflow-wrap:anywhere}
433
+ .tasked{color:var(--muted);font-size:12.5px;line-height:1.55}
412
434
  .j-str{color:var(--j-str)}
413
435
  .j-num{color:var(--j-num)}
414
436
  .j-lit{color:var(--j-lit)}
@@ -768,6 +790,151 @@ const escText = (s) =>
768
790
 
769
791
  // Colourise a JSON value. Runs over already-escaped text, so nothing it emits
770
792
  // can introduce markup.
793
+ /* A shell command, coloured. Every piece is escaped as it is taken, so nothing
794
+ in a log can reach the DOM as markup, and the result is passed through
795
+ autolink so paths and URLs inside a command stay clickable. */
796
+ const SH_OPS = "|&;<>()";
797
+
798
+ function sh(src) {
799
+ const s = String(src ?? "");
800
+ let out = "",
801
+ i = 0,
802
+ // Whether the next word is the thing being run, rather than an argument.
803
+ fresh = true;
804
+ const put = (cls, text) => {
805
+ out += cls ? `<span class="${cls}">${esc(text)}</span>` : esc(text);
806
+ };
807
+
808
+ while (i < s.length) {
809
+ const ch = s[i];
810
+
811
+ if (ch === "\n") { out += "\n"; i++; fresh = true; continue; }
812
+ if (ch === " " || ch === "\t") { out += ch; i++; continue; }
813
+
814
+ // A # only starts a comment where a word could start.
815
+ if (ch === "#" && fresh) {
816
+ let j = s.indexOf("\n", i);
817
+ if (j < 0) j = s.length;
818
+ put("sh-com", s.slice(i, j));
819
+ i = j;
820
+ continue;
821
+ }
822
+
823
+ if (ch === "'" || ch === '"') {
824
+ let j = i + 1;
825
+ while (j < s.length) {
826
+ if (s[j] === "\\" && ch === '"') { j += 2; continue; }
827
+ if (s[j] === ch) { j++; break; }
828
+ j++;
829
+ }
830
+ put("sh-str", s.slice(i, j));
831
+ i = j;
832
+ fresh = false;
833
+ continue;
834
+ }
835
+
836
+ if (ch === "$") {
837
+ const m = /^\$(?:\{[^}]*\}|[A-Za-z_]\w*|[@*?#$!0-9])/.exec(s.slice(i));
838
+ if (m) { put("sh-var", m[0]); i += m[0].length; fresh = false; continue; }
839
+ }
840
+
841
+ if (SH_OPS.includes(ch)) {
842
+ let j = i;
843
+ while (j < s.length && SH_OPS.includes(s[j])) j++;
844
+ const op = s.slice(i, j);
845
+ put("sh-op", op);
846
+ i = j;
847
+ // What follows a pipe or a semicolon is another command; what follows a
848
+ // redirect is the file it is being written to, which is not one.
849
+ fresh = !/[<>]/.test(op);
850
+ continue;
851
+ }
852
+
853
+ let j = i;
854
+ while (j < s.length && !" \t\n'\"$".includes(s[j]) && !SH_OPS.includes(s[j])) j++;
855
+ const word = s.slice(i, j);
856
+ put(fresh ? "sh-cmd" : word[0] === "-" ? "sh-flag" : "", word);
857
+ i = j;
858
+ fresh = false;
859
+ }
860
+ return out;
861
+ }
862
+
863
+ const shBlock = (command) => `<pre class="sh">${autolink(sh(command))}</pre>`;
864
+
865
+ /* What a call is worth showing as, when it is not worth showing as JSON. Only
866
+ the shapes that carry a command or an address get a body of their own; the
867
+ rest gain a title, which is the part that was missing everywhere. */
868
+ const TOOLVIEW = {
869
+ Bash: {
870
+ title: (a) => a.description || "",
871
+ body: (a) =>
872
+ shBlock(a.command || "") +
873
+ (a.timeout ? `<div class="tasked">timeout ${num(a.timeout)} ms</div>` : ""),
874
+ },
875
+ WebFetch: {
876
+ title: (a) => {
877
+ try { return new URL(a.url).hostname.replace(/^www\./, ""); }
878
+ catch (e) { return a.url || ""; }
879
+ },
880
+ body: (a) =>
881
+ `<div class="turl">${autolink(esc(a.url || ""))}</div>` +
882
+ (a.prompt ? `<div class="tasked">${esc(a.prompt)}</div>` : ""),
883
+ },
884
+ WebSearch: { title: (a) => a.query || "" },
885
+ // The end of a path is the part worth reading; the full one is on hover.
886
+ Read: { title: (a) => short(a.file_path) },
887
+ Write: { title: (a) => short(a.file_path) },
888
+ Edit: { title: (a) => short(a.file_path) },
889
+ NotebookEdit: { title: (a) => short(a.notebook_path) },
890
+ Skill: { title: (a) => a.skill || "" },
891
+ Task: { title: (a) => a.description || "" },
892
+ TaskCreate: { title: (a) => a.subject || a.description || "" },
893
+ ToolSearch: { title: (a) => a.query || "" },
894
+ Grep: { title: (a) => a.pattern || "" },
895
+ Glob: { title: (a) => a.pattern || "" },
896
+ };
897
+
898
+ /* Every call rendered so far, so one box can be redrawn without repainting the
899
+ page and losing the reader's place. */
900
+ const CALLS = {};
901
+ // Boxes the reader has flipped. Absent means "do as the page is doing".
902
+ const TOOLRAW = {};
903
+
904
+ const toolIsRaw = (id) => (id in TOOLRAW ? TOOLRAW[id] : raw);
905
+
906
+ function toolBody(id) {
907
+ const held = CALLS[id];
908
+ if (!held) return "";
909
+ const { c, r } = held;
910
+ const view = TOOLVIEW[c.function_name];
911
+ const shown =
912
+ !toolIsRaw(id) && view && view.body
913
+ ? view.body(c.arguments || {})
914
+ : `<pre class="json">${hjson(c.arguments)}</pre>`;
915
+ return (
916
+ `<div class="io in"><span class="iol">call</span>${shown}</div>` +
917
+ (r && r.content
918
+ ? `<div class="io out"><span class="iol">output</span>${
919
+ typeof r.content === "string" ? pre(r.content) : body(r.content)
920
+ }</div>`
921
+ : "") +
922
+ (aiOn() ? callSummary(c.tool_call_id) : "")
923
+ );
924
+ }
925
+
926
+ /* Flip one box between what it says and what it is. Redrawing just this box
927
+ keeps every other box open and the page where the reader left it. */
928
+ function flipTool(ev, id) {
929
+ ev.preventDefault();
930
+ ev.stopPropagation();
931
+ TOOLRAW[id] = !toolIsRaw(id);
932
+ const box = document.getElementById("tb-" + id);
933
+ if (box) box.innerHTML = toolBody(id);
934
+ const flip = document.getElementById("tf-" + id);
935
+ if (flip) flip.textContent = toolIsRaw(id) ? "formatted" : "raw";
936
+ }
937
+
771
938
  function hjson(value, indent = 2) {
772
939
  let text;
773
940
  try {
@@ -1921,7 +2088,12 @@ function pick(key){
1921
2088
 
1922
2089
  const toggle=r=>{show[r]=!show[r];limit=PAGE_SIZE;render()};
1923
2090
  const more=()=>{limit+=PAGE_SIZE*4;render()};
1924
- const toggleRaw=()=>{raw=!raw;render()};
2091
+ const toggleRaw=()=>{
2092
+ raw=!raw;
2093
+ // Boxes flipped by hand were disagreeing with the old setting, not this one.
2094
+ for(const id of Object.keys(TOOLRAW))delete TOOLRAW[id];
2095
+ render();
2096
+ };
1925
2097
 
1926
2098
  /* Open or close every tool call and branch at once. Applied to the elements
1927
2099
  already on screen rather than by repainting: a repaint would drop the reader
@@ -2096,7 +2268,8 @@ function trajectoryTab(t,e,branches,branchSteps,counts,q){
2096
2268
  <span class="fsep"></span>
2097
2269
  <span class="chip${OPEN_ALL?" on":""}" id="xall" onclick="toggleExpand()">${
2098
2270
  OPEN_ALL?"collapse all":"expand all"}</span>
2099
- <span class="chip${raw?" on":""}" onclick="toggleRaw()">raw text</span>
2271
+ <span class="chip${raw?" on":""}" onclick="toggleRaw()"
2272
+ title="Show messages and calls as they were recorded. A single box can still disagree.">raw</span>
2100
2273
  ${q?`<span class="hits">${num(total)} of ${num(t.steps.length)} match “${esc(query)}”</span>`:""}
2101
2274
  </div>
2102
2275
  ${shown.map(({s,i})=>step(s,t,0,i,"")).join("")
@@ -2324,15 +2497,18 @@ function step(s,ctx,depth,idx,prefix){
2324
2497
  for(const c of s.tool_calls||[]){
2325
2498
  const r=(s.observation?.results||[]).find(x=>x.source_call_id===c.tool_call_id);
2326
2499
  const refs=r?.subagent_trajectory_ref||[];
2500
+ // An id of its own, so one box can be redrawn without repainting the page.
2501
+ // A call recorded without one still needs somewhere to be kept.
2502
+ const id=String(c.tool_call_id||`${key}c${Object.keys(CALLS).length}`);
2503
+ CALLS[id]={c,r};
2504
+ const view=TOOLVIEW[c.function_name];
2505
+ const said=view&&view.title?String(view.title(c.arguments||{})||""):"";
2327
2506
  h+=`<details class="tool"${OPEN_ALL?" open":""}><summary><span class="tname">${esc(c.function_name)}</span>
2328
- ${refs.length?`<span class="pill br">delegates</span>`:""}</summary>
2329
- <div class="tbody">
2330
- <div class="io in"><span class="iol">call</span>
2331
- <pre class="json">${hjson(c.arguments)}</pre></div>
2332
- ${r?.content?`<div class="io out"><span class="iol">output</span>
2333
- ${typeof r.content==="string"?pre(r.content):body(r.content)}</div>`:""}
2334
- ${aiOn()?callSummary(c.tool_call_id):""}
2335
- </div></details>`;
2507
+ ${said?`<span class="tdesc" title="${esc(said)}">${esc(said)}</span>`:""}
2508
+ ${refs.length?`<span class="pill br">delegates</span>`:""}
2509
+ <span class="tflip" id="tf-${esc(id)}" onclick="flipTool(event,'${esc(id)}')"
2510
+ title="Show this call as it was recorded">${toolIsRaw(id)?"formatted":"raw"}</span></summary>
2511
+ <div class="tbody" id="tb-${esc(id)}">${toolBody(id)}</div></details>`;
2336
2512
  for(const ref of refs)h+=branch(ref,ctx,depth);
2337
2513
  }
2338
2514
  return h+`</div>`;
@@ -1480,3 +1480,145 @@ test("a failure to open leaves the reason on screen", async () => {
1480
1480
  assert.match(text.textContent, /Could not open your library/);
1481
1481
  assert.strictEqual(el.hidden, false, "a blank window would say nothing at all");
1482
1482
  });
1483
+
1484
+ // ---- how a tool call reads ---------------------------------------------------
1485
+
1486
+ test("a command is coloured as a command, not printed as JSON", () => {
1487
+ const html = run(`return shBlock('ls -la /tmp')`);
1488
+ assert.match(html, /class="sh"/, "it should be a shell block");
1489
+ assert.match(html, /class="sh-cmd">ls</, "the thing being run is the command");
1490
+ assert.match(html, /class="sh-flag">-la</, "a flag reads as a flag");
1491
+ });
1492
+
1493
+ test("what follows a pipe is another command", () => {
1494
+ const html = run(`return sh('grep -rn foo src/ | head -5')`);
1495
+ const commands = [...html.matchAll(/class="sh-cmd">([^<]+)</g)].map((m) => m[1]);
1496
+ assert.deepStrictEqual(commands, ["grep", "head"]);
1497
+ });
1498
+
1499
+ test("a quoted string is one piece, whatever is inside it", () => {
1500
+ const html = run(`return sh('grep "no assistant turns" src/')`);
1501
+ assert.match(html, /class="sh-str">&quot;no assistant turns&quot;</);
1502
+ // The words inside must not be mistaken for commands of their own.
1503
+ const commands = [...html.matchAll(/class="sh-cmd">([^<]+)</g)].map((m) => m[1]);
1504
+ assert.deepStrictEqual(commands, ["grep"]);
1505
+ });
1506
+
1507
+ test("a comment is a comment only where a word could start", () => {
1508
+ const commented = run(`return sh('# count them\\nwc -l x')`);
1509
+ assert.match(commented, /class="sh-com"># count them</);
1510
+ const inside = run(`return sh('curl https://x/a#b')`);
1511
+ assert.ok(!/sh-com/.test(inside), "a # inside a word is part of the word");
1512
+ });
1513
+
1514
+ test("a variable is picked out in either spelling", () => {
1515
+ const html = run(`return sh('echo $HOME and \${PATH}')`);
1516
+ const vars = [...html.matchAll(/class="sh-var">([^<]+)</g)].map((m) => m[1]);
1517
+ assert.deepStrictEqual(vars, ["$HOME", "${PATH}"]);
1518
+ });
1519
+
1520
+ test("markup in a command cannot reach the page as markup", () => {
1521
+ const html = run(`return sh('echo "<img src=x onerror=alert(1)>"')`);
1522
+ assert.ok(!html.includes("<img"), "it must be escaped");
1523
+ assert.match(html, /&lt;img/);
1524
+ });
1525
+
1526
+ test("a path in a command still opens in Finder, and a URL still opens", () => {
1527
+ const withPath = run(`return shBlock('ls /Users/someone/Documents')`);
1528
+ assert.match(withPath, /api\/reveal\?path=/, "a path should still reveal");
1529
+ const withUrl = run(`return shBlock('curl -s https://pypi.org/simple/')`);
1530
+ assert.match(withUrl, /<a href="https:\/\/pypi\.org\/simple\/"/);
1531
+ });
1532
+
1533
+ test("a call says what it was for, beside what it was called", () => {
1534
+ const title = run(
1535
+ `return TOOLVIEW.Bash.title({command:"ls",description:"List vault root"})`,
1536
+ );
1537
+ assert.strictEqual(title, "List vault root");
1538
+ const fetched = run(
1539
+ `return TOOLVIEW.WebFetch.title({url:"https://help.obsidian.md/plugins/templates"})`,
1540
+ );
1541
+ assert.strictEqual(fetched, "help.obsidian.md");
1542
+ });
1543
+
1544
+ test("a fetch shows its address as a link and its question as prose", () => {
1545
+ const html = run(
1546
+ `return TOOLVIEW.WebFetch.body({url:"https://example.com/a",prompt:"What does it say?"})`,
1547
+ );
1548
+ assert.match(html, /<a href="https:\/\/example\.com\/a"/);
1549
+ assert.match(html, /What does it say\?/);
1550
+ });
1551
+
1552
+ test("a malformed address does not stop the box drawing", () => {
1553
+ const title = run(`return TOOLVIEW.WebFetch.title({url:"not a url"})`);
1554
+ assert.strictEqual(title, "not a url");
1555
+ });
1556
+
1557
+ test("one box can be shown raw without the rest following", () => {
1558
+ const out = run(`
1559
+ raw = false;
1560
+ CALLS["a"] = {c:{function_name:"Bash",tool_call_id:"a",arguments:{command:"ls",description:"d"}}, r:null};
1561
+ CALLS["b"] = {c:{function_name:"Bash",tool_call_id:"b",arguments:{command:"pwd",description:"d"}}, r:null};
1562
+ TOOLRAW["a"] = true;
1563
+ return [toolBody("a"), toolBody("b")];
1564
+ `);
1565
+ assert.match(out[0], /class="json"/, "the flipped one shows what was recorded");
1566
+ assert.match(out[1], /class="sh"/, "its neighbour is untouched");
1567
+ });
1568
+
1569
+ test("the page-wide raw setting is what a box does until it is told otherwise", () => {
1570
+ const out = run(`
1571
+ raw = true;
1572
+ CALLS["c"] = {c:{function_name:"Bash",tool_call_id:"c",arguments:{command:"ls"}}, r:null};
1573
+ const following = toolBody("c");
1574
+ TOOLRAW["c"] = false;
1575
+ const flipped = toolBody("c");
1576
+ raw = false;
1577
+ return [following, flipped];
1578
+ `);
1579
+ assert.match(out[0], /class="json"/, "it follows the page by default");
1580
+ assert.match(out[1], /class="sh"/, "and can disagree once flipped");
1581
+ });
1582
+
1583
+ test("a tool with no view of its own still gets its JSON", () => {
1584
+ const html = run(`
1585
+ CALLS["z"] = {c:{function_name:"SomethingNew",tool_call_id:"z",arguments:{a:1}}, r:null};
1586
+ return toolBody("z");
1587
+ `);
1588
+ assert.match(html, /class="json"/);
1589
+ });
1590
+
1591
+ test("a redirect target is a file, not a command", () => {
1592
+ const html = run(`return sh('wc -l a.txt > /tmp/out.txt')`);
1593
+ const commands = [...html.matchAll(/class="sh-cmd">([^<]+)</g)].map((m) => m[1]);
1594
+ assert.deepStrictEqual(commands, ["wc"], "only wc is being run");
1595
+ });
1596
+
1597
+ test("a subshell still starts a command", () => {
1598
+ const html = run(`return sh('(cd /tmp && ls)')`);
1599
+ const commands = [...html.matchAll(/class="sh-cmd">([^<]+)</g)].map((m) => m[1]);
1600
+ assert.deepStrictEqual(commands, ["cd", "ls"]);
1601
+ });
1602
+
1603
+ test("changing the page-wide setting means it, even for boxes flipped by hand", () => {
1604
+ const out = run(`
1605
+ raw = false;
1606
+ CALLS["g"] = {c:{function_name:"Bash",tool_call_id:"g",arguments:{command:"ls"}}, r:null};
1607
+ TOOLRAW["g"] = true; // this box was asked for the recording
1608
+ const before = toolBody("g");
1609
+ render = () => {}; // the repaint is not what is under test
1610
+ toggleRaw(); // now the whole page is
1611
+ const after = toolBody("g");
1612
+ return [before, after, raw];
1613
+ `);
1614
+ assert.match(out[0], /class="json"/, "it was showing the recording");
1615
+ assert.match(out[1], /class="json"/, "and still does, because the page now says so");
1616
+ assert.strictEqual(out[2], true);
1617
+ });
1618
+
1619
+ test("a file path is titled by its end, where the name is", () => {
1620
+ const title = run(
1621
+ `return TOOLVIEW.Read.title({file_path:"/private/tmp/very/long/way/down/ARCHITECTURE.md"})`,
1622
+ );
1623
+ assert.strictEqual(title, "down/ARCHITECTURE.md");
1624
+ });
@@ -388,7 +388,7 @@ wheels = [
388
388
 
389
389
  [[package]]
390
390
  name = "transcript-viewer"
391
- version = "0.6.2"
391
+ version = "0.7.0"
392
392
  source = { editable = "." }
393
393
  dependencies = [
394
394
  { name = "atif-make" },