transcript-viewer 0.6.1__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.1 → transcript_viewer-0.7.0}/PKG-INFO +1 -1
  2. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/pyproject.toml +1 -1
  3. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/page.html +201 -23
  4. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/page.test.js +142 -0
  5. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_style.py +37 -0
  6. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/uv.lock +1 -1
  7. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/.coverage +0 -0
  8. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/.github/workflows/publish.yml +0 -0
  9. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/.github/workflows/test.yml +0 -0
  10. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/.gitignore +0 -0
  11. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/README.md +0 -0
  12. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/__init__.py +0 -0
  13. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/ai.py +0 -0
  14. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/cli.py +0 -0
  15. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/config.py +0 -0
  16. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/corpus.py +0 -0
  17. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/fetch.py +0 -0
  18. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/library.py +0 -0
  19. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/store.py +0 -0
  20. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/viewer.py +0 -0
  21. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_ai.py +0 -0
  22. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_cli.py +0 -0
  23. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_config.py +0 -0
  24. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_corpus.py +0 -0
  25. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_fetch.py +0 -0
  26. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_library.py +0 -0
  27. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_page.py +0 -0
  28. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_readme.py +0 -0
  29. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_store.py +0 -0
  30. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_stream.py +0 -0
  31. {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_tidiness.py +0 -0
  32. {transcript_viewer-0.6.1 → 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.1
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.1"
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 {
@@ -838,6 +1005,8 @@ function pre(content) {
838
1005
 
839
1006
  const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
840
1007
  const num=n=>(n??0).toLocaleString();
1008
+ /* How many sessions are in the library, said the same way everywhere. */
1009
+ const showCount=()=>{count.textContent=`${num(INDEX.length)} session${INDEX.length===1?"":"s"}`};
841
1010
  /* One size formatter. Everything used to divide by 1048576 and say MB, so a
842
1011
  900-byte log read as "0.0 MB" and a 144 GB bucket as "147456.0 MB". Each step
843
1012
  keeps three significant figures, which is as much as anyone reads. */
@@ -870,7 +1039,7 @@ function reveal(a){
870
1039
  async function openFiles(files){
871
1040
  if(!files||!files.length)return;
872
1041
  const names=[...files].map(f=>f.name);
873
- count.textContent=`opening ${names.length} file${names.length>1?"s":""}…`;
1042
+ count.textContent=`opening ${num(names.length)} file${names.length>1?"s":""}…`;
874
1043
  const problems=[];
875
1044
  let first=null;
876
1045
  for(const file of files){
@@ -884,7 +1053,7 @@ async function openFiles(files){
884
1053
  }
885
1054
  const fresh=await fetch("/api/index").then(r=>r.json());
886
1055
  INDEX=fresh.sessions||[];GROUPS=fresh.groups||[];TAGS=fresh.tags||[];AI=fresh.ai||{};DOWNLOADS=fresh.downloads||DOWNLOADS;
887
- count.textContent=INDEX.length+" sessions";drawList();
1056
+ showCount();drawList();
888
1057
  if(first!==null)pick(first);
889
1058
  if(problems.length)note(problems.join("\n"));
890
1059
  picker.value="";
@@ -1015,10 +1184,10 @@ async function openLibrary(){
1015
1184
  try{
1016
1185
  const d=await readIndex();
1017
1186
  INDEX=d.sessions||[];GROUPS=d.groups||[];TAGS=d.tags||[];AI=d.ai||{};DOWNLOADS=d.downloads||DOWNLOADS;
1018
- bootSay("Drawing "+INDEX.length+" sessions…",0,0);
1187
+ bootSay(`Drawing ${num(INDEX.length)} sessions…`,0,0);
1019
1188
  // Let that line paint before the work that blocks the thread.
1020
1189
  await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));
1021
- count.textContent=INDEX.length+" sessions";drawList();showLibrary();
1190
+ showCount();drawList();showLibrary();
1022
1191
  }catch(err){
1023
1192
  // Leave it on screen: a blank window would say nothing at all.
1024
1193
  bootSay("Could not open your library — "+((err&&err.message)||err),0,0);
@@ -1117,13 +1286,13 @@ function drawList(){
1117
1286
  <button title="Remove these sessions from the library" class="dang"
1118
1287
  onclick="event.stopPropagation();dropGroup('${esc(r.path)}')">✕</button>
1119
1288
  </span>`}
1120
- <span class="rc">${r.count}</span></div>`;
1289
+ <span class="rc">${num(r.count)}</span></div>`;
1121
1290
  }).join("");
1122
1291
 
1123
1292
  tagbar.innerHTML=TAGS.map(t=>{
1124
1293
  const on=!!ACTIVE_TAGS[t.name];
1125
1294
  return `<span class="pill tag${on?" on":""}" onclick="toggleTag('${esc(t.name)}')">
1126
- ${esc(t.name)}<b>${t.count}</b></span>`;
1295
+ ${esc(t.name)}<b>${num(t.count)}</b></span>`;
1127
1296
  }).join("");
1128
1297
  }
1129
1298
 
@@ -1685,7 +1854,7 @@ async function planUrl(){
1685
1854
  showLibrary();
1686
1855
  closeUrl();
1687
1856
  showProgress(0,0);
1688
- if(result)note(`Added ${result.added} session${result.added===1?"":"s"} — ${result.into}`);
1857
+ if(result)note(`Added ${num(result.added)} session${result.added===1?"":"s"} — ${result.into}`);
1689
1858
  return;
1690
1859
  }
1691
1860
 
@@ -1712,8 +1881,8 @@ async function planUrl(){
1712
1881
  const size=p.bytes?` · ${bytes(p.bytes)}`:"";
1713
1882
  const sample=p.names.length?` — ${p.names.slice(0,3).join(", ")}${
1714
1883
  p.count>3?", …":""}`:"";
1715
- setUrlState(`${p.count} file${p.count===1?"":"s"} in ${p.label}${size}${sample}\n`
1716
- +`→ ${p.into}`, `Download ${p.count}`);
1884
+ setUrlState(`${num(p.count)} file${p.count===1?"":"s"} in ${p.label}${size}${sample}\n`
1885
+ +`→ ${p.into}`, `Download ${num(p.count)}`);
1717
1886
  }catch(e){
1718
1887
  err.textContent=e.message;err.hidden=false;
1719
1888
  setUrlState("","Look");PLANNED=null;
@@ -1874,7 +2043,7 @@ async function annotate(key,fields){
1874
2043
  async function refreshIndex(){
1875
2044
  const d=await fetch("/api/index").then(r=>r.json());
1876
2045
  INDEX=d.sessions||[];GROUPS=d.groups||[];TAGS=d.tags||[];AI=d.ai||{};DOWNLOADS=d.downloads||DOWNLOADS;
1877
- count.textContent=INDEX.length+" sessions";
2046
+ showCount();
1878
2047
  drawList();
1879
2048
  if(!cur)showLibrary();
1880
2049
  }
@@ -1919,7 +2088,12 @@ function pick(key){
1919
2088
 
1920
2089
  const toggle=r=>{show[r]=!show[r];limit=PAGE_SIZE;render()};
1921
2090
  const more=()=>{limit+=PAGE_SIZE*4;render()};
1922
- 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
+ };
1923
2097
 
1924
2098
  /* Open or close every tool call and branch at once. Applied to the elements
1925
2099
  already on screen rather than by repainting: a repaint would drop the reader
@@ -2094,7 +2268,8 @@ function trajectoryTab(t,e,branches,branchSteps,counts,q){
2094
2268
  <span class="fsep"></span>
2095
2269
  <span class="chip${OPEN_ALL?" on":""}" id="xall" onclick="toggleExpand()">${
2096
2270
  OPEN_ALL?"collapse all":"expand all"}</span>
2097
- <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>
2098
2273
  ${q?`<span class="hits">${num(total)} of ${num(t.steps.length)} match “${esc(query)}”</span>`:""}
2099
2274
  </div>
2100
2275
  ${shown.map(({s,i})=>step(s,t,0,i,"")).join("")
@@ -2277,7 +2452,7 @@ function branchNav(branches){
2277
2452
  <span class="sid">${b.stepId}</span>
2278
2453
  <b>${esc(label)}</b>
2279
2454
  <span>${esc(x.description||"")||"—"}</span>
2280
- <em>${b.sub?b.sub.steps.length+" steps":"external"}</em>
2455
+ <em>${b.sub?num(b.sub.steps.length)+" steps":"external"}</em>
2281
2456
  </button>`;
2282
2457
  }).join("")+`</div></div>`;
2283
2458
  }
@@ -2305,7 +2480,7 @@ function step(s,ctx,depth,idx,prefix){
2305
2480
  const on=starredSteps().includes(key);
2306
2481
  let h=`<div class="step ${s.source}" id="step-${key}">
2307
2482
  <div class="gut">
2308
- <a class="sid" href="#step-${key}" title="step ${s.step_id}">${s.step_id}</a>
2483
+ <a class="sid" href="#step-${key}" title="step ${num(s.step_id)}">${num(s.step_id)}</a>
2309
2484
  <span class="sstar${on?" on":""}" onclick="toggleStep(event,'${key}')"
2310
2485
  title="${on?"Remove from favourites":"Add to favourites"}">
2311
2486
  <svg width="11" height="11" viewBox="0 0 24 24" fill="${on?"currentColor":"none"}"
@@ -2322,15 +2497,18 @@ function step(s,ctx,depth,idx,prefix){
2322
2497
  for(const c of s.tool_calls||[]){
2323
2498
  const r=(s.observation?.results||[]).find(x=>x.source_call_id===c.tool_call_id);
2324
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||{})||""):"";
2325
2506
  h+=`<details class="tool"${OPEN_ALL?" open":""}><summary><span class="tname">${esc(c.function_name)}</span>
2326
- ${refs.length?`<span class="pill br">delegates</span>`:""}</summary>
2327
- <div class="tbody">
2328
- <div class="io in"><span class="iol">call</span>
2329
- <pre class="json">${hjson(c.arguments)}</pre></div>
2330
- ${r?.content?`<div class="io out"><span class="iol">output</span>
2331
- ${typeof r.content==="string"?pre(r.content):body(r.content)}</div>`:""}
2332
- ${aiOn()?callSummary(c.tool_call_id):""}
2333
- </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>`;
2334
2512
  for(const ref of refs)h+=branch(ref,ctx,depth);
2335
2513
  }
2336
2514
  return h+`</div>`;
@@ -2349,7 +2527,7 @@ function branch(ref,ctx,depth){
2349
2527
  return `<details class="branch"${OPEN_ALL?" open":""}><summary>
2350
2528
  <b>${esc(label)}</b>
2351
2529
  <span class="desc">${esc(x.description||"")}</span>
2352
- <span class="pill br">${sub.steps.length} steps</span>
2530
+ <span class="pill br">${num(sub.steps.length)} steps</span>
2353
2531
  ${depth?`<span class="pill">depth ${depth+1}</span>`:""}
2354
2532
  </summary><div class="inner">
2355
2533
  ${sub.steps.map(s=>step(s,sub,depth+1,null,esc(sub.trajectory_id||"sub")+"-")).join("")}
@@ -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
+ });
@@ -110,3 +110,40 @@ def test_sizes_go_through_one_formatter():
110
110
  )
111
111
  assert "1048576" not in code, "a size is still being divided by hand"
112
112
  assert code.count("function bytes(") == 1
113
+
114
+
115
+ # Numbers a reader sees are formatted in one place, so a corpus of thousands
116
+ # reads as "2,258 sessions" rather than "2258 sessions". These are the shapes
117
+ # that reached the screen raw before anyone looked.
118
+ RAW_NUMBERS = (
119
+ (r"\.length\s*\+\s*[\"']", "a count concatenated onto a string"),
120
+ (r"\$\{[^}]*\.length\}\s*(?:session|step|file|result|match)", "${…length} beside a noun"),
121
+ (r"\$\{\s*[a-zA-Z_$][\w.$]*\.count\s*\}", "${…count}"),
122
+ (r"\$\{\s*[a-zA-Z_$][\w.$]*\.added\s*\}", "${…added}"),
123
+ (r"\$\{\s*s\.step_id\s*\}", "${s.step_id}"),
124
+ )
125
+
126
+
127
+ @pytest.mark.parametrize(("pattern", "what"), RAW_NUMBERS, ids=[w for _, w in RAW_NUMBERS])
128
+ def test_a_number_on_screen_goes_through_the_formatter(pattern, what):
129
+ script = PAGE[PAGE.index("<script>") : PAGE.rindex("</script>")]
130
+ hits = []
131
+ for i, line in enumerate(script.splitlines(), start=1):
132
+ stripped = line.strip()
133
+ if stripped.startswith("//") or stripped.startswith("*"):
134
+ continue
135
+ for match in re.finditer(pattern, line):
136
+ around = line[max(0, match.start() - 14) : match.end() + 3]
137
+ if "num(" in around or "bytes(" in around:
138
+ continue
139
+ hits.append(f"line {i}: {stripped[:90]}")
140
+ assert not hits, f"{what} without num(): " + "; ".join(hits)
141
+
142
+
143
+ def test_the_session_count_is_said_in_one_place():
144
+ """It was written out three times, and one of them was already wrong."""
145
+ script = PAGE[PAGE.index("<script>") : PAGE.rindex("</script>")]
146
+ assert "const showCount=" in script, "the count should have a single home"
147
+ assert script.count('count.textContent=') <= 2, (
148
+ "the session count is being written in more than one place again"
149
+ )
@@ -388,7 +388,7 @@ wheels = [
388
388
 
389
389
  [[package]]
390
390
  name = "transcript-viewer"
391
- version = "0.6.1"
391
+ version = "0.7.0"
392
392
  source = { editable = "." }
393
393
  dependencies = [
394
394
  { name = "atif-make" },