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.
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/PKG-INFO +1 -1
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/pyproject.toml +1 -1
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/page.html +201 -23
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/page.test.js +142 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_style.py +37 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/uv.lock +1 -1
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/.coverage +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/.github/workflows/publish.yml +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/.github/workflows/test.yml +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/.gitignore +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/README.md +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/__init__.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/ai.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/cli.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/config.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/corpus.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/fetch.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/library.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/store.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/src/transcript_viewer/viewer.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_ai.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_cli.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_config.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_corpus.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_fetch.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_library.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_page.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_readme.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_store.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_stream.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_tidiness.py +0 -0
- {transcript_viewer-0.6.1 → transcript_viewer-0.7.0}/tests/test_viewer.py +0 -0
|
@@ -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=>({"&":"&","<":"<",">":">",'"':"""}[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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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=()=>{
|
|
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()"
|
|
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
|
-
${
|
|
2327
|
-
|
|
2328
|
-
<
|
|
2329
|
-
|
|
2330
|
-
|
|
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">"no assistant turns"</);
|
|
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, /<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
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|