@leftoff/cli 0.9.18-beta.2 → 0.9.18-beta.4

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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import y from"node:fs";import it from"node:net";import W from"node:path";import M from"node:fs";import N from"node:path";import{spawn as Z,execFile as ut}from"node:child_process";import{createRequire as q}from"node:module";import{StringDecoder as Q}from"node:string_decoder";var wt=2*1024*1024;var gt=192*1024;var yt=64*1024;var tt=String.raw`
2
+ import y from"node:fs";import it from"node:net";import W from"node:path";import C from"node:fs";import P from"node:path";import{spawn as Z,execFile as ut}from"node:child_process";import{createRequire as q}from"node:module";import{StringDecoder as Q}from"node:string_decoder";var wt=2*1024*1024;var gt=192*1024;var yt=64*1024;var tt=String.raw`
3
3
  import os, sys, pty, select, struct, fcntl, termios, signal
4
4
  cols, rows = int(sys.argv[1]), int(sys.argv[2])
5
5
  argv = sys.argv[3:]
@@ -51,16 +51,16 @@ while True:
51
51
  st = os.waitpid(pid, 0)[1]
52
52
  code = os.waitstatus_to_exitcode(st)
53
53
  sys.exit(code if code >= 0 else 128 - code)
54
- `;function et({platform:t=process.platform,exists:n=c=>{try{return M.statSync(c).isFile()}catch{return!1}},isDir:o=c=>{try{return M.statSync(c).isDirectory()}catch{return!1}}}={}){return(t==="darwin"?["/opt/homebrew/bin/python3","/usr/local/bin/python3",...o("/Library/Developer/CommandLineTools")||o("/Applications/Xcode.app")?["/usr/bin/python3"]:[]]:["/usr/bin/python3","/usr/local/bin/python3","/bin/python3"]).find(n)||null}function st(t){return{kind:"node-pty",spawn({file:n,args:o,cwd:c,env:a,cols:d,rows:e}){let r=t.spawn(n,o,{name:"xterm-256color",cols:d,rows:e,cwd:c,env:a});return{pid:r.pid,onData:s=>r.onData(s),onExit:s=>r.onExit(({exitCode:p,signal:u})=>s({code:p,signal:u||null})),write:s=>r.write(s),resize:(s,p)=>{try{r.resize(s,p)}catch{}},kill:(s="SIGHUP")=>{try{r.kill(s)}catch{}}}}}}function U(t,{spawnImpl:n=Z}={}){return{kind:"python",spawn({file:o,args:c,cwd:a,env:d,cols:e,rows:r}){let s=n(t,["-c",tt,String(e),String(r),o,...c],{cwd:a,env:d,stdio:["pipe","pipe","pipe","pipe"]}),p=new Q("utf8"),u=[],H=[],j=!1;s.stdout.on("data",i=>{let h=p.write(i);if(h)for(let b of u)b(h)}),s.stderr.on("data",i=>{let h=String(i);for(let b of u)b(h)});let R=s.stdio[3];for(let i of[s.stdin,R])i&&i.on("error",()=>{});let G=(i,h)=>{if(j)return;j=!0;let b=p.end();if(b)for(let P of u)P(b);for(let P of H)P({code:i??null,signal:h||null})};s.on("exit",(i,h)=>setTimeout(()=>G(i,h),20)),s.on("error",i=>{for(let h of u)h(`\r
54
+ `;function et({platform:t=process.platform,exists:n=c=>{try{return C.statSync(c).isFile()}catch{return!1}},isDir:o=c=>{try{return C.statSync(c).isDirectory()}catch{return!1}}}={}){return(t==="darwin"?["/opt/homebrew/bin/python3","/usr/local/bin/python3",...o("/Library/Developer/CommandLineTools")||o("/Applications/Xcode.app")?["/usr/bin/python3"]:[]]:["/usr/bin/python3","/usr/local/bin/python3","/bin/python3"]).find(n)||null}function st(t){return{kind:"node-pty",spawn({file:n,args:o,cwd:c,env:a,cols:d,rows:e}){let r=t.spawn(n,o,{name:"xterm-256color",cols:d,rows:e,cwd:c,env:a});return{pid:r.pid,onData:s=>r.onData(s),onExit:s=>r.onExit(({exitCode:p,signal:u})=>s({code:p,signal:u||null})),write:s=>r.write(s),resize:(s,p)=>{try{r.resize(s,p)}catch{}},kill:(s="SIGHUP")=>{try{r.kill(s)}catch{}}}}}}function U(t,{spawnImpl:n=Z}={}){return{kind:"python",spawn({file:o,args:c,cwd:a,env:d,cols:e,rows:r}){let s=n(t,["-c",tt,String(e),String(r),o,...c],{cwd:a,env:d,stdio:["pipe","pipe","pipe","pipe"]}),p=new Q("utf8"),u=[],H=[],j=!1;s.stdout.on("data",i=>{let h=p.write(i);if(h)for(let b of u)b(h)}),s.stderr.on("data",i=>{let h=String(i);for(let b of u)b(h)});let A=s.stdio[3];for(let i of[s.stdin,A])i&&i.on("error",()=>{});let G=(i,h)=>{if(j)return;j=!0;let b=p.end();if(b)for(let F of u)F(b);for(let F of H)F({code:i??null,signal:h||null})};s.on("exit",(i,h)=>setTimeout(()=>G(i,h),20)),s.on("error",i=>{for(let h of u)h(`\r
55
55
  [terminal] `+i.message+`\r
56
- `);G(127,null)});let V={SIGHUP:1,SIGINT:2,SIGKILL:9,SIGTERM:15};return{pid:s.pid,onData:i=>u.push(i),onExit:i=>H.push(i),write:i=>{try{s.stdin.write(i)}catch{}},resize:(i,h)=>{try{R.write("R "+i+" "+h+`
57
- `)}catch{}},kill:(i="SIGHUP")=>{try{R.write("K "+(V[i]||1)+`
58
- `)}catch{}if(i==="SIGKILL")try{s.kill("SIGKILL")}catch{}}}}}}function nt(t,{platform:n=process.platform,arch:o=process.arch,stat:c=d=>M.statSync(d),chmod:a=(d,e)=>M.chmodSync(d,e)}={}){let d=[];for(let e of[N.join(t,"prebuilds",n+"-"+o,"spawn-helper"),N.join(t,"build","Release","spawn-helper")]){let r;try{r=c(e)}catch{continue}if(!(!r.isFile()||(r.mode&73)===73))try{a(e,r.mode&511|493),d.push(e)}catch{}}return d}function rt(t,n){if(!n)return t;let o=t;return{get kind(){return o.kind},spawn(c){try{return o.spawn(c)}catch(a){if(o!==t)throw a;return o=n(),o.spawn(c)}}}}function K({requireImpl:t=q(import.meta.url),python:n=et(),env:o=process.env}={}){let c=null;if(o.LEFTOFF_TERM_BACKEND!=="python")try{let a=t("node-pty");try{nt(N.dirname(t.resolve("node-pty/package.json")))}catch{}return rt(st(a),n?()=>U(n):null)}catch(a){c=a}return n?U(n):{kind:null,why:"No terminal backend: node-pty isn’t installed"+(c&&!/Cannot find module/.test(c.message)?" ("+String(c.message).split(`
56
+ `);G(127,null)});let V={SIGHUP:1,SIGINT:2,SIGKILL:9,SIGTERM:15};return{pid:s.pid,onData:i=>u.push(i),onExit:i=>H.push(i),write:i=>{try{s.stdin.write(i)}catch{}},resize:(i,h)=>{try{A.write("R "+i+" "+h+`
57
+ `)}catch{}},kill:(i="SIGHUP")=>{try{A.write("K "+(V[i]||1)+`
58
+ `)}catch{}if(i==="SIGKILL")try{s.kill("SIGKILL")}catch{}}}}}}function nt(t,{platform:n=process.platform,arch:o=process.arch,stat:c=d=>C.statSync(d),chmod:a=(d,e)=>C.chmodSync(d,e)}={}){let d=[];for(let e of[P.join(t,"prebuilds",n+"-"+o,"spawn-helper"),P.join(t,"build","Release","spawn-helper")]){let r;try{r=c(e)}catch{continue}if(!(!r.isFile()||(r.mode&73)===73))try{a(e,r.mode&511|493),d.push(e)}catch{}}return d}function rt(t,n){if(!n)return t;let o=t;return{get kind(){return o.kind},spawn(c){try{return o.spawn(c)}catch(a){if(o!==t)throw a;return o=n(),o.spawn(c)}}}}function K({requireImpl:t=q(import.meta.url),python:n=et(),env:o=process.env}={}){let c=null;if(o.LEFTOFF_TERM_BACKEND!=="python")try{let a=t("node-pty");try{nt(P.dirname(t.resolve("node-pty/package.json")))}catch{}return rt(st(a),n?()=>U(n):null)}catch(a){c=a}return n?U(n):{kind:null,why:"No terminal backend: node-pty isn’t installed"+(c&&!/Cannot find module/.test(c.message)?" ("+String(c.message).split(`
59
59
  `)[0]+")":"")+" and there’s no python3. Run `leftoff upgrade`, or install python3."}}var X=process.argv[2],l;try{l=JSON.parse(y.readFileSync(X,"utf8"))}catch(t){process.stderr.write("pty-host: no spec: "+t.message+`
60
- `),process.exit(2)}try{y.rmSync(X,{force:!0})}catch{}var{id:_,dir:B}=l,v=W.join(B,_+".sock"),F=W.join(B,_+".json"),ot=l.bufferMax||2*1024*1024,E=t=>process.stderr.write(new Date().toISOString()+" "+_+" "+t+`
61
- `);process.on("SIGHUP",()=>{});process.on("SIGPIPE",()=>{});var T=K({env:l.env||process.env});T.kind||(E("no backend: "+T.why),process.exit(3));var f;try{f=T.spawn({file:l.file,args:l.args||[],cwd:l.cwd,env:l.env||process.env,cols:l.cols||80,rows:l.rows||24})}catch(t){E("spawn failed: "+t.message),process.exit(4)}var k=[],C=0,x=0,m=!1,O=null,S=null,J=!1,w=l.cols||80,g=l.rows||24,L={v:1,id:_,hostPid:process.pid,pid:f.pid||null,cwd:l.cwd,createdAt:new Date().toISOString(),cols:w,rows:g,backend:T.kind,...l.meta||{}},D=()=>{try{let t=F+".tmp";y.writeFileSync(t,JSON.stringify({...L,cols:w,rows:g,exited:m,code:O}),{mode:384}),y.renameSync(t,F)}catch(t){E("meta: "+t.message)}},I=new Set,A=t=>{let n=JSON.stringify(t)+`
62
- `;for(let o of I)if(o.ready)try{o.write(n)}catch{}},$=()=>[...I].filter(t=>t.ready).map(t=>({role:t.role,...t.info||{}}));f.onData(t=>{for(k.push(t),C+=t.length,x+=t.length;C>ot&&k.length>1;)C-=k[0].length,k.shift();A({t:"out",d:t,end:x})});var ct=null;f.onExit(({code:t,signal:n})=>{m=!0,O=t??null,S=n||null,E("program exited ("+O+(S?", "+S:"")+")"),D(),A({t:"exit",code:O,signal:S}),ct=setTimeout(Y,J?300:l.linger==null?3e4:l.linger)});function Y(){for(let t of I)try{t.end()}catch{}try{z.close()}catch{}for(let t of[v,F])try{y.rmSync(t,{force:!0})}catch{}setTimeout(()=>process.exit(0),50)}try{y.rmSync(v,{force:!0})}catch{}var z=it.createServer(t=>{t.setEncoding("utf8");let n="";I.add(t),t.on("data",c=>{n+=c;let a;for(;(a=n.indexOf(`
63
- `))>=0;){let d=n.slice(0,a);n=n.slice(a+1);let e;try{e=JSON.parse(d)}catch{continue}if(e.t==="hello"){t.role=e.role==="attach"?"attach":"daemon",t.info=e.role==="attach"?{pid:Number(e.pid)||null,tty:e.tty?String(e.tty).slice(0,80):null,term:e.term&&typeof e.term=="object"?e.term:null}:{},t.ready=!0;let r=k.join("");k=r?[r]:[];let s=x-C,p=typeof e.since=="number"&&e.since>=s&&e.since<=x?e.since:s;t.write(JSON.stringify({t:"hello",id:_,pid:f.pid||null,start:s,end:x,exited:m,code:O,signal:S,cols:w,rows:g,meta:L})+`
60
+ `),process.exit(2)}try{y.rmSync(X,{force:!0})}catch{}var{id:_,dir:B}=l,v=W.join(B,_+".sock"),N=W.join(B,_+".json"),ot=l.bufferMax||2*1024*1024,E=t=>process.stderr.write(new Date().toISOString()+" "+_+" "+t+`
61
+ `);process.on("SIGHUP",()=>{});process.on("SIGPIPE",()=>{});var I=K({env:l.env||process.env});I.kind||(E("no backend: "+I.why),process.exit(3));var f;try{f=I.spawn({file:l.file,args:l.args||[],cwd:l.cwd,env:l.env||process.env,cols:l.cols||80,rows:l.rows||24})}catch(t){E("spawn failed: "+t.message),process.exit(4)}var k=[],M=0,x=0,m=!1,O=null,S=null,J=!1,w=l.cols||80,g=l.rows||24,L={v:1,id:_,hostPid:process.pid,pid:f.pid||null,cwd:l.cwd,createdAt:new Date().toISOString(),cols:w,rows:g,backend:I.kind,...l.meta||{}},D=()=>{try{let t=N+".tmp";y.writeFileSync(t,JSON.stringify({...L,cols:w,rows:g,exited:m,code:O}),{mode:384}),y.renameSync(t,N)}catch(t){E("meta: "+t.message)}},T=new Set,R=t=>{let n=JSON.stringify(t)+`
62
+ `;for(let o of T)if(o.ready)try{o.write(n)}catch{}},$=()=>[...T].filter(t=>t.ready).map(t=>({role:t.role,...t.info||{}}));f.onData(t=>{for(k.push(t),M+=t.length,x+=t.length;M>ot&&k.length>1;)M-=k[0].length,k.shift();R({t:"out",d:t,end:x})});var ct=null;f.onExit(({code:t,signal:n})=>{m=!0,O=t??null,S=n||null,E("program exited ("+O+(S?", "+S:"")+")"),D(),R({t:"exit",code:O,signal:S}),ct=setTimeout(Y,J?300:l.linger==null?3e4:l.linger)});function Y(){for(let t of T)try{t.end()}catch{}try{z.close()}catch{}for(let t of[v,N])try{y.rmSync(t,{force:!0})}catch{}setTimeout(()=>process.exit(0),50)}try{y.rmSync(v,{force:!0})}catch{}var z=it.createServer(t=>{t.setEncoding("utf8");let n="";T.add(t),t.on("data",c=>{n+=c;let a;for(;(a=n.indexOf(`
63
+ `))>=0;){let d=n.slice(0,a);n=n.slice(a+1);let e;try{e=JSON.parse(d)}catch{continue}if(e.t==="hello"){t.role=e.role==="attach"?"attach":"daemon",t.info=e.role==="attach"?{pid:Number(e.pid)||null,tty:e.tty?String(e.tty).slice(0,80):null,term:e.term&&typeof e.term=="object"?e.term:null}:{},t.ready=!0;let r=k.join("");k=r?[r]:[];let s=x-M,p=typeof e.since=="number"&&e.since>=s&&e.since<=x?e.since:s;t.write(JSON.stringify({t:"hello",id:_,pid:f.pid||null,start:s,end:x,exited:m,code:O,signal:S,cols:w,rows:g,meta:L})+`
64
64
  `);let u=r.slice(p-s);u&&t.write(JSON.stringify({t:"out",d:u,end:x})+`
65
65
  `),m&&t.write(JSON.stringify({t:"exit",code:O,signal:S})+`
66
- `),A({t:"clients",list:$()})}else if(e.t==="in"){if(!m)try{f.write(String(e.d||""))}catch{}}else if(e.t==="resize"){let r=Math.max(2,Math.min(1e3,Number(e.c)||w)),s=Math.max(1,Math.min(500,Number(e.r)||g));if(r!==w||s!==g){w=r,g=s;try{f.resize(w,g)}catch{}}}else if(e.t==="kill")if(J=!0,m)Y();else{try{f.kill(e.sig||"SIGHUP")}catch{}setTimeout(()=>{if(!m)try{f.kill("SIGKILL")}catch{}},3e3).unref()}else if(e.t==="meta"&&e.patch&&typeof e.patch=="object"){for(let[r,s]of Object.entries(e.patch))/^(title|kind|sessionId|run|startCwd|renamed)$/.test(r)&&(L[r]=s);D()}}});let o=()=>{I.delete(t)&&A({t:"clients",list:$()})};t.on("close",o),t.on("error",o)}),lt=process.umask(127);z.listen(v,()=>{process.umask(lt);try{y.chmodSync(v,384)}catch{}D(),E("listening ("+T.kind+", pid "+f.pid+")")});z.on("error",t=>{E("socket: "+t.message);try{f.kill("SIGHUP")}catch{}process.exit(5)});
66
+ `),R({t:"clients",list:$()})}else if(e.t==="in"){if(!m)try{f.write(String(e.d||""))}catch{}}else if(e.t==="resize"){let r=Math.max(2,Math.min(1e3,Number(e.c)||w)),s=Math.max(1,Math.min(500,Number(e.r)||g));if(r!==w||s!==g){w=r,g=s;try{f.resize(w,g)}catch{}}}else if(e.t==="kill")if(J=!0,m)Y();else{try{f.kill(e.sig||"SIGHUP")}catch{}setTimeout(()=>{if(!m)try{f.kill("SIGKILL")}catch{}},3e3).unref()}else if(e.t==="meta"&&e.patch&&typeof e.patch=="object"){for(let[r,s]of Object.entries(e.patch))/^(title|kind|sessionId|run|startCwd|renamed)$/.test(r)&&(L[r]=s);D()}}});let o=()=>{T.delete(t)&&R({t:"clients",list:$()})};t.on("close",o),t.on("error",o)}),lt=process.umask(127);z.listen(v,()=>{process.umask(lt);try{y.chmodSync(v,384)}catch{}D(),E("listening ("+I.kind+", pid "+f.pid+")")});z.on("error",t=>{E("socket: "+t.message);try{f.kill("SIGHUP")}catch{}process.exit(5)});
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.18-beta.2",
3
+ "version": "0.9.18-beta.4",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "One Silly App"
@@ -26,9 +26,10 @@ const PANE_ID = 'checkpoints'
26
26
  const POST_TIMEOUT_MS = 800
27
27
  const END_TIMEOUT_MS = 1000
28
28
  const MODEL_TIMEOUT_MS = 20000
29
+ const STOP_GRACE_MS = 2500 // 2026-11-03 how long a completed turn waits for its Stop before the mod reports the end itself
29
30
 
30
31
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
31
- const MOD_VERSION = '0.9.18-beta.2' // keep in step with .claude-plugin/plugin.json
32
+ const MOD_VERSION = '0.9.18-beta.4' // keep in step with .claude-plugin/plugin.json
32
33
 
33
34
  const st = {
34
35
  internal: false,
@@ -69,6 +70,8 @@ const st = {
69
70
  listSig: null, // v0.5.1: the signature of this session's lists Claude last heard about
70
71
  listPending: false, // a list was saved or ticked here: tell Claude with the next prompt
71
72
  paneNeeds: 0, // sessions that need you (the pane's top line)
73
+ turnStartAt: 0, // 2026-11-03 when this process's current turn started (turn.start)
74
+ stopAt: 0, // and when a Stop last came (classic.Stop)
72
75
  }
73
76
 
74
77
  // Millisecond precision: the daemon dedupes events per (sessionId, type, at).
@@ -243,6 +246,16 @@ async function heartbeat($) {
243
246
  }
244
247
  }
245
248
 
249
+ // 2026-11-03 a turn completed and no Stop came for it within STOP_GRACE_MS:
250
+ // report its end (once; nothing when this process doesn't hold a turn open).
251
+ async function stopFallback($, completedAt, reason) {
252
+ if (st.internal || !st.sessionId) return
253
+ if (st.stopAt && st.stopAt >= (st.turnStartAt || 0)) return // its Stop came
254
+ if (!(st.record && st.record.inTurn)) return
255
+ st.stopAt = Date.now()
256
+ await sendOrSpool($, 'turn_completed', { endReason: String(reason || 'end').slice(0, 40), settledBy: 'mod', completedAt: new Date(completedAt).toISOString() })
257
+ }
258
+
246
259
  // The commands, in the order they're tried. Claude Code 2.1.295's built-ins
247
260
  // own /checkpoint (an alias of /rewind) and /name (an alias of /rename), so
248
261
  // ours share a /cp prefix; each command lists fallbacks in case a later
@@ -1083,6 +1096,7 @@ export function register(on, options) {
1083
1096
  })
1084
1097
 
1085
1098
  on('turn.start', async ($, e, next) => {
1099
+ st.turnStartAt = Date.now()
1086
1100
  await sendOrSpool($, 'turn_started', {})
1087
1101
  return next(e)
1088
1102
  })
@@ -1091,10 +1105,18 @@ export function register(on, options) {
1091
1105
  // classic.Stop reports a normal end with its details; Stop doesn't fire for
1092
1106
  // an interrupted or failed turn, so those are reported here.
1093
1107
  if (e.reason === 'aborted' || e.reason === 'error' || e.reason === 'refusal') await sendOrSpool($, 'turn_completed', { endReason: e.reason })
1108
+ // 2026-11-03 Claude Code doesn't run every Stop for every end of a turn
1109
+ // (2.1.317: a turn its loop ends runs Stop without the in-process hooks,
1110
+ // and a tool can end a turn), and then this turn stayed open on the board
1111
+ // for good. If no Stop came for this turn shortly after it completed, the
1112
+ // end is reported from here; a Stop that comes later is the same turn
1113
+ // (the daemon doesn't count it twice).
1114
+ else { const at = Date.now(); $.clock.after(STOP_GRACE_MS, () => { stopFallback($, at, e.reason).catch(() => {}) }) }
1094
1115
  return next(e)
1095
1116
  })
1096
1117
 
1097
1118
  on('classic.Stop', async ($, e, next) => {
1119
+ st.stopAt = Date.now()
1098
1120
  // v0.5.1: a checklist of 3+ in this reply (a cheap regex, no model) -> the
1099
1121
  // board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
1100
1122
  const listHint = listHintFrom(e.last_assistant_message || '', { fallback: st.daemonTitle || null })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.18-beta.2",
3
+ "version": "0.9.18-beta.4",
4
4
  "description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
5
5
  "keywords": [
6
6
  "claude",
package/ui/app.css CHANGED
@@ -5274,6 +5274,18 @@ code .file-ref-off { font-size: inherit; }
5274
5274
  .wt-sf .wt-snip + .wt-snip { border-top: 1px dashed var(--line); }
5275
5275
  .wt-sf .wt-snip-h { margin: 0; padding: 4px 10px; }
5276
5276
  @media (pointer: fine) { .wt-sf > summary { min-height: 34px; } .wt-sfa-b { min-height: 32px; } }
5277
+ /* 2026-11-03 a file a step shares with an earlier one: folded, with a quiet way back to where it's open */
5278
+ .wt-also { display: inline-flex; align-items: center; gap: 4px; align-self: stretch; padding: 0 6px; font-size: 12px; color: var(--text2); text-decoration: none; white-space: nowrap; }
5279
+ .wt-also .ic { width: 13px; height: 13px; color: var(--muted); }
5280
+ .wt-also:hover { color: var(--text); text-decoration: underline; text-underline-offset: 2px; }
5281
+ .wt-also:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 6px; }
5282
+ .wt-sf-also > summary .wt-sf-p { color: var(--text2); }
5283
+ /* the lines the step's refs name, when it shows a hunk whole: the rest steps back */
5284
+ .wt-gd-focus .gd-row:not(.wt-focus), .wt-gd-focus .gd-half:not(.wt-focus):not(.gd-empty) { opacity: .55; }
5285
+ .wt-gd-focus .gd-row.wt-focus .gd-n, .wt-gd-focus .gd-half.wt-focus .gd-gut { box-shadow: inset 2px 0 0 var(--muted); }
5286
+ .wt-focus-w { display: inline-flex; align-items: center; gap: 6px; }
5287
+ .wt-focus-g { width: 2px; height: 12px; border-radius: 1px; background: var(--muted); }
5288
+ .wt-snip-none { margin: 0; padding: 10px; font-size: 12.5px; }
5277
5289
  /* "changed since": a half-filled circle and the words (the stale bar uses the same glyph) */
5278
5290
  .wt-chg { flex: none; display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 1px 7px 0; box-sizing: border-box; border: 1px dashed var(--line-strong); border-radius: 6px; font-size: 11.5px; font-weight: 600; line-height: 1; color: var(--text2); }
5279
5291
  .wt-chg-g { flex: none; width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid currentColor; background: linear-gradient(90deg, currentColor 50%, transparent 50%); box-sizing: border-box; }
@@ -5378,3 +5390,9 @@ code .file-ref-off { font-size: inherit; }
5378
5390
  .wid-path .wt-nots, .wid-path .wt-pre { font-family: var(--sans, inherit); }
5379
5391
  .wid-scope-n { display: flex; gap: 6px; align-items: flex-start; }
5380
5392
  .wid-scope-n .ic { width: 13px; height: 13px; flex: none; margin-top: 2px; }
5393
+
5394
+ /* 2026-11-03 "hooks not reporting": a quiet note beside the session's pills (a dashed ring plus words) */
5395
+ .sub .s-hooks { display: inline-flex; align-items: center; gap: 5px; margin-left: 4px; font-size: 12px; color: var(--muted); white-space: nowrap; }
5396
+ .sub .s-hooks-ring { width: 8px; height: 8px; border-radius: 50%; border: 1.5px dashed currentColor; box-sizing: border-box; flex: none; }
5397
+ .fs-head .sub > .s-hooks { height: 22px; vertical-align: middle; }
5398
+ .fs-side.is-scrolled .fs-head .sub > .s-hooks { display: none; }
package/ui/app.js CHANGED
@@ -54,7 +54,7 @@ import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember
54
54
  import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
55
55
  import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
56
56
  import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
57
- import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine } from './semdiff.js' // 0.9.17 Walk me through it
57
+ import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, uniqueStepIds, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine } from './semdiff.js' // 0.9.17 Walk me through it
58
58
  import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
59
59
  import { spotsFor, nextSpotIndex, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
60
60
  import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
@@ -468,6 +468,10 @@ const liveBadge = (s) => (ownerOffline(s) ? '' : liveBadgeHtml(s)) // 0.9.14: an
468
468
  // The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
469
469
  // 0.9.14: another Mac that's asleep or unreachable: "offline · last seen 12m ago" instead (never "needs you").
470
470
  const statusPill = (s) => { if (ownerOffline(s)) return offlinePill(s); const st = cardPills(s).status; return st ? pill(st, s) : '' }
471
+ // 2026-11-03 Claude Code's own hook events never came for this session (only the mod's), so its turns
472
+ // are read from the transcript: a quiet note, a dashed ring plus words, the why in the tip
473
+ const HOOKS_QUIET_TIP = 'Claude Code’s hooks aren’t reaching Leftoff for this session, so its prompts and turn ends are read from the transcript instead (a few seconds late). Restarting Claude Code in this folder, or leftoff doctor, usually brings them back.'
474
+ const hooksQuietHtml = (s) => (s && s.hooksQuiet && !s.endedAt ? '<span class="s-hooks" data-tip="' + esc(HOOKS_QUIET_TIP) + '"><span class="s-hooks-ring" aria-hidden="true"></span>hooks not reporting</span>' : '')
471
475
  const offlinePill = (s) => { const w = offlineLine(s); return '<span class="pill s-offline" data-tip="' + esc(offlineTip(s, w, { was: offlineWas(s) })) + '">' + esc(w) + '</span>' }
472
476
 
473
477
  const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
@@ -1142,6 +1146,7 @@ async function wtCheck(sid) {
1142
1146
  const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { stale: true, peek: true }), { timeoutMs: 60000 })
1143
1147
  const had = wtUi.data.get(k)
1144
1148
  if (r && r.status === 'ok' && had && !wtUi.loading.has(k) && (r.fingerprint !== had.fingerprint || JSON.stringify(r.stale || null) !== JSON.stringify(had.stale || null))) {
1149
+ if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
1145
1150
  wtUi.data.set(k, r)
1146
1151
  if (wtUi.sid === sid) { wtUi.sig = ''; wtFill(wtSession(sid)) }
1147
1152
  }
@@ -1215,6 +1220,7 @@ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {})
1215
1220
  wtFill(wtSession(sid))
1216
1221
  try {
1217
1222
  const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { ...(fresh ? { fresh: true } : {}), ...(fresh || update ? {} : { stale: true }), ...(peek && !fresh && !update ? { peek: true } : {}) }), { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
1223
+ if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
1218
1224
  wtUi.data.set(k, r)
1219
1225
  if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
1220
1226
  // 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
@@ -1339,18 +1345,21 @@ function wtRevNext() {
1339
1345
  if (!done) wtReviewed(sid, step, { force: true, advance: false })
1340
1346
  wtSlideGo(st.to)
1341
1347
  }
1342
- function wtGo(sid, step, { group = null } = {}) {
1348
+ function wtGo(sid, step, { group = null, file = null } = {}) {
1343
1349
  const node = wtUi.node; if (!node) return
1344
1350
  if (group && node.querySelector('#wtDeck')) { const d = node.querySelector('#wtDeck'), sk = d.lastElementChild; if (sk && !sk.dataset.step && sk.dataset.slide !== '0') wtSlideGo(Number(sk.dataset.slide)) }
1345
1351
  if (group) { const g = node.querySelector('#wt-g-' + CSS.escape(group)); if (g) { g.open = true; g.scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }) } return }
1346
1352
  const k = wtKey(sid)
1347
1353
  const deck = node.querySelector('#wtDeck')
1348
- if (deck) { const sl = [...deck.children].findIndex((x) => x.dataset.step === step); if (sl >= 0) { wtUi.cur.set(k, step); wtSlideGo(sl) } return } // 0.9.18 the slideshow: that step's slide
1354
+ // 2026-11-03 "Also in step N": that step's row for the file, opened, is where it lands
1355
+ const fileRow = () => { if (!file) return null; const d = node.querySelector('details.wt-sf[data-sfk="' + CSS.escape(step + '|' + file) + '"]'); if (d && !d.open) d.open = true; return d }
1356
+ if (deck) { const sl = [...deck.children].findIndex((x) => x.dataset.step === step); if (sl >= 0) { wtUi.cur.set(k, step); wtSlideGo(sl); const d = fileRow(); if (d) d.scrollIntoView({ block: 'nearest', behavior: 'auto' }) } return } // 0.9.18 the slideshow: that step's slide
1349
1357
  wtUi.cur.set(k, step)
1350
1358
  for (const a of node.querySelectorAll('article.wt-st')) a.classList.toggle('cur', a.dataset.step === step)
1351
1359
  for (const a of node.querySelectorAll('.wt-fm-i[data-step]')) a.classList.toggle('cur', a.dataset.step === step)
1352
1360
  const el = node.querySelector('#wt-' + CSS.escape(step))
1353
- if (el) { el.scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }); el.focus({ preventScroll: true }) }
1361
+ const row = fileRow()
1362
+ if (el) { (row || el).scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }); el.focus({ preventScroll: true }) }
1354
1363
  const data = wtUi.data.get(k); const n = node.querySelector('#wtPnavN'); if (n && data) n.textContent = stepPos(data.steps, { filter: wtUi.filter, cur: step })
1355
1364
  }
1356
1365
  // 0.9.18 the walkthrough as slides (ui/semdiff.js deckHtml): "Show all" (kept per device)
@@ -2880,7 +2889,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2880
2889
  const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
2881
2890
  '<button type="button" class="fs-side-x" data-action="fs-side" aria-expanded="true" aria-controls="fsSide" aria-label="Hide the session panel" data-tip="Hide panel (⌘\\)">' + icon('chevron-right') + '</button>' +
2882
2891
  title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
2883
- '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
2892
+ '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + hooksQuietHtml(s) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
2884
2893
  // 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
2885
2894
  (where.length ? '<div class="fs-where fs-repo" data-tip="' + esc(wherePath ? wherePath + (branch ? ' · ' + branch : '') : where.join(' · ')) + '">' + icon('folder') + '<span>' + esc(where.join(' · ')) + '</span></div>' : '') +
2886
2895
  '<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
@@ -8543,7 +8552,7 @@ document.addEventListener('click', async (ev) => {
8543
8552
  const sm = d.querySelector('summary'); if (sm) sm.focus({ preventScroll: true })
8544
8553
  return
8545
8554
  }
8546
- if (action === 'wt-go') { ev.preventDefault(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null }) }
8555
+ if (action === 'wt-go') { ev.preventDefault(); ev.stopPropagation(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null, file: btn.dataset.file || null }) }
8547
8556
  if (action === 'wt-step') return wtStep(Number(btn.dataset.dir) || 1)
8548
8557
  // 2026-10-30 walkthrough round 3
8549
8558
  if (action === 'wt-dmode') { try { if (btn.dataset.mode === 'split') localStorage.setItem('lo-wt-split', '1'); else localStorage.removeItem('lo-wt-split') } catch {} wtUi.sig = ''; return void wtFill(wtSession(wtUi.sid)) }
package/ui/semdiff.js CHANGED
@@ -142,9 +142,11 @@ export function snipsByFile(st) {
142
142
  return out
143
143
  }
144
144
  // the files of a walkthrough that start folded: generated or lockfiles, and anything in Safe to skip
145
+ // (2026-11-03 not "Tests updated to match": a test a step names, like the one that pins the bug,
146
+ // is part of that step's story even when the rest of its lines landed in that group)
145
147
  export function skipFiles(data) {
146
148
  const out = new Set()
147
- for (const g of (data && data.groups) || []) for (const f of g.files || []) out.add(f.path)
149
+ for (const g of (data && data.groups) || []) if (g.kind !== 'tests') for (const f of g.files || []) out.add(f.path)
148
150
  for (const f of (data && data.files) || []) if (f.generated) out.add(f.path)
149
151
  return out
150
152
  }
@@ -163,10 +165,58 @@ export function sfSet(fp, keys, open) {
163
165
  if (all.length > 30) for (const k of all.filter((x) => x !== SF_KEY + fp).slice(0, all.length - 30)) localStorage.removeItem(k)
164
166
  } catch {}
165
167
  }
166
- export function sfOpen(state, step, file, skip) { const k = step + '|' + file; return k in (state || {}) ? !!state[k] : fileFoldsDefault(file, skip) }
168
+ // def: the default when nothing was chosen (null: by the file's kind)
169
+ export function sfOpen(state, step, file, skip, def = null) { const k = step + '|' + file; return k in (state || {}) ? !!state[k] : def != null ? !!def : fileFoldsDefault(file, skip) }
170
+ // 2026-11-03 ("Where's my diff?": steps 1 and 2 both named one new test file; step 1's row came up
171
+ // empty and its diff showed in step 2 only). Two steps naming exactly the same lines had one id,
172
+ // so the one drawn diff moved to the later step. Walkthroughs kept before the fix still carry the
173
+ // shared id: the later ones get their count appended, as src/semdiff.js uniqueStepIds does. In place.
174
+ export function uniqueStepIds(steps) {
175
+ const seen = new Set()
176
+ for (const st of steps || []) {
177
+ if (!st || typeof st.id !== 'string' || !st.id) continue
178
+ if (!seen.has(st.id)) { seen.add(st.id); continue }
179
+ let k = 2
180
+ while (seen.has(st.id + k.toString(16))) k++
181
+ st.id = st.id + k.toString(16)
182
+ seen.add(st.id)
183
+ }
184
+ return steps
185
+ }
186
+ // The same lines shown again by a later step: "<step id>:<snippet i>" -> { id, n } of the first
187
+ // step (in reading order) that shows them. That first step shows them open; a later one folds
188
+ // that file with "Also in step N", which goes there.
189
+ export function sharedSnips(data) {
190
+ const first = new Map(), out = new Map()
191
+ for (const st of readingOrder((data && data.steps) || [], data)) {
192
+ ;(st.snippets || []).forEach((sn, i) => {
193
+ const k = sn.hunk + '\0' + sn.patch + '\0' + JSON.stringify(sn.focus || null)
194
+ const f = first.get(k)
195
+ if (!f) first.set(k, { id: st.id, n: st.n })
196
+ else if (f.id !== st.id) out.set(st.id + ':' + i, f)
197
+ })
198
+ }
199
+ return out
200
+ }
201
+ // "Lines 12–30", "Lines 3–5, 9" (old side: "Old lines …"), or '' with no focus
202
+ export function focusWords(focus) {
203
+ if (!Array.isArray(focus) || !focus.length) return ''
204
+ const r = (f) => (f.from === f.to ? String(f.from) : f.from + '–' + f.to)
205
+ const nw = focus.filter((f) => f.side !== 'old'), od = focus.filter((f) => f.side === 'old')
206
+ const parts = []
207
+ if (nw.length) parts.push((nw.length === 1 && nw[0].from === nw[0].to ? 'Line ' : 'Lines ') + nw.map(r).join(', '))
208
+ if (od.length) parts.push((parts.length ? 'old ' : 'Old ') + (od.length === 1 && od[0].from === od[0].to ? 'line ' : 'lines ') + od.map(r).join(', '))
209
+ return parts.join(', ')
210
+ }
211
+ // a file of a step whose every snippet an earlier step shows: that step, else null
212
+ export function alsoIn(st, g, shared) {
213
+ if (!shared || !shared.size || !g.snips.length) return null
214
+ const fs = g.snips.map(([, i]) => shared.get(st.id + ':' + i))
215
+ return fs.every((f) => f && f.id === fs[0].id) ? fs[0] : null
216
+ }
167
217
  export const CHANGED_TIP = 'Its files changed since this walkthrough was written'
168
218
 
169
- function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, stat = null }) {
219
+ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, stat = null, shared = null, foldShared = true }) {
170
220
  const core = st.tag === 'Core'
171
221
  // 2026-10-29 several repos: the step's repo leads ("cube_cloud · src/x.ts"); one repo: the file names
172
222
  const rp = st.repo ? [st.repo] : []
@@ -178,15 +228,17 @@ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, s
178
228
  '<button type="button" class="wt-rev' + (done ? ' on' : '') + '" data-action="wt-reviewed" data-id="' + esc(sid) + '" data-step="' + esc(st.id) + '" aria-pressed="' + (done ? 'true' : 'false') + '" data-tip="' + (done ? 'Reviewed · tap to undo' : 'Mark reviewed') + '">' + icon('check') + '<span>' + (done ? 'Reviewed' : 'Mark reviewed') + '</span></button></header>'
179
229
  const why = st.why ? '<div class="wt-why wt-md">' + walkBlock(st.why) + '</div>' : ''
180
230
  const byFile = snipsByFile(st)
181
- const fileHead = (g, isOpen) => '<summary class="wt-sf-h" aria-label="' + esc((st.repo ? repoSplit(g.file, rp).filter(Boolean).join(' · ') : g.file) + ', ' + g.add + ' added, ' + g.del + ' removed') + '">' + icon('chevron-right', 'ic wt-g-chev') +
231
+ const fileHead = (g, isOpen, also) => '<summary class="wt-sf-h" aria-label="' + esc((st.repo ? repoSplit(g.file, rp).filter(Boolean).join(' · ') : g.file) + ', ' + g.add + ' added, ' + g.del + ' removed' + (also ? ', also in step ' + also.n : '')) + '">' + icon('chevron-right', 'ic wt-g-chev') +
182
232
  (stat && stat.get(g.file) ? statusHtml(stat.get(g.file), { esc, icon }) : '') +
183
233
  '<span class="mono wt-sf-p">' + esc(st.repo ? repoSplit(g.file, rp).filter(Boolean).join(' · ') : g.file) + '</span>' +
234
+ (also ? '<a class="wt-also" href="#wt-' + esc(also.id) + '" data-action="wt-go" data-step="' + esc(also.id) + '" data-file="' + esc(g.file) + '" data-tip="The same lines, open in step ' + also.n + '">' + icon('arrow-up') + 'Also in step ' + also.n + '</a>' : '') +
184
235
  '<span class="wt-file-n"><span class="wt-add">+' + n0(g.add) + '</span> <span class="wt-del">−' + n0(g.del) + '</span></span></summary>'
185
236
  const snips = (byFile.length > 1 ? '<div class="wt-sfa" role="group" aria-label="Files in this step"><button type="button" class="linkish wt-sfa-b" data-action="wt-sf-all" data-step="' + esc(st.id) + '" data-open="0">' + icon('chevrons-down-up') + 'Collapse all</button><button type="button" class="linkish wt-sfa-b" data-action="wt-sf-all" data-step="' + esc(st.id) + '" data-open="1">' + icon('chevrons-up-down') + 'Expand all</button></div>' : '') +
186
237
  byFile.map((g) => {
187
- const isOpen = sfOpen(sf, st.id, g.file, skip)
188
- return '<details class="wt-sf" data-sfk="' + esc(st.id + '|' + g.file) + '" data-step="' + esc(st.id) + '"' + (isOpen ? ' open' : '') + '>' + fileHead(g, isOpen) +
189
- g.snips.map(([sn, i]) => '<div class="wt-snip">' + (sn.partial ? '<div class="wt-snip-h"><span class="faint">Part of the change</span></div>' : '') + '<div class="wt-snip-d" data-snip="' + esc(st.id) + ':' + i + '"></div></div>').join('') + '</details>'
238
+ const also = alsoIn(st, g, shared)
239
+ const isOpen = sfOpen(sf, st.id, g.file, skip, also && foldShared ? false : null)
240
+ return '<details class="wt-sf' + (also ? ' wt-sf-also' : '') + '" data-sfk="' + esc(st.id + '|' + g.file) + '" data-step="' + esc(st.id) + '"' + (isOpen ? ' open' : '') + '>' + fileHead(g, isOpen, also) +
241
+ g.snips.map(([sn, i]) => '<div class="wt-snip">' + (sn.partial ? '<div class="wt-snip-h"><span class="faint">Part of the change</span></div>' : focusWords(sn.focus) ? '<div class="wt-snip-h"><span class="faint wt-focus-w"><i class="wt-focus-g" aria-hidden="true"></i>' + esc(focusWords(sn.focus)) + ' for this step</span></div>' : '') + '<div class="wt-snip-d" data-snip="' + esc(st.id) + ':' + i + '"></div></div>').join('') + '</details>'
190
242
  }).join('')
191
243
  const show = core || open
192
244
  const lines = st.snippets.reduce((a, s) => a + s.changed, 0)
@@ -424,7 +476,8 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
424
476
  const vis = new Set(visibleSteps(steps, ui.filter).map((x) => x.id))
425
477
  const core = steps.filter((x) => x.tag === 'Core' && vis.has(x.id)), sup = steps.filter((x) => x.tag !== 'Core' && vis.has(x.id))
426
478
  const bd = data.stats.breakdown || {}
427
- const one = (st) => stepHtml(st, { esc, icon, done: reviewed.has(st.id), cur: ui.cur === st.id, open: ui.open && ui.open.has(st.id), sid, sf: ui.sf || {}, skip: skipFiles(data), stat: statusMap(data) })
479
+ const shared = sharedSnips(data), skip = skipFiles(data), stat = statusMap(data)
480
+ const one = (st) => stepHtml(st, { esc, icon, done: reviewed.has(st.id), cur: ui.cur === st.id, open: ui.open && ui.open.has(st.id), sid, sf: ui.sf || {}, skip, stat, shared })
428
481
  const repos = walkRepos(data)
429
482
  // 2026-10-29 several repos: one section per repo (its header with counts), its steps in reading order
430
483
  if (repos.length) {
@@ -481,6 +534,7 @@ export function deckSlides(data, ui = {}) {
481
534
  function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
482
535
  const { slides, cur } = deckSlides(data, ui)
483
536
  const steps = data.steps || []
537
+ const shared = sharedSnips(data)
484
538
  const total = slides.length
485
539
  const sec = (i, inner, label, extra = '') => '<section class="wt-slide' + (i === cur ? ' on' : '') + '" data-slide="' + i + '"' + extra + ' role="group" aria-roledescription="slide" aria-label="' + esc(label) + '"' + (i === cur ? '' : ' hidden') + '>' + inner + '</section>'
486
540
  let h = '<div class="wt-deck" id="wtDeck" data-cur="' + cur + '" data-n="' + total + '" tabindex="-1" aria-roledescription="slideshow" aria-label="Walkthrough slides">'
@@ -511,7 +565,7 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
511
565
  slides.forEach((s, i) => {
512
566
  if (s.kind === 'step') {
513
567
  const x = s.step
514
- h += sec(i, stepHtml(x, { esc, icon, done: reviewed.has(x.id), cur: false, open: true, sid, sf: ui.sf || {}, skip: skipFiles(data), stat: statusMap(data) }), 'Step ' + x.n + ' of ' + steps.length + ': ' + mdPlain(x.title), ' data-step="' + esc(x.id) + '"')
568
+ h += sec(i, stepHtml(x, { esc, icon, done: reviewed.has(x.id), cur: false, open: true, sid, sf: ui.sf || {}, skip: skipFiles(data), stat: statusMap(data), shared, foldShared: false /* a slide shows one step: the earlier one isn't on screen */ }), 'Step ' + x.n + ' of ' + steps.length + ': ' + mdPlain(x.title), ' data-step="' + esc(x.id) + '"')
515
569
  } else if (s.kind === 'skip') {
516
570
  const groups = data.groups || []
517
571
  const n = groups.reduce((a, g) => a + g.files.length + (g.kind === 'repeated' ? 1 : 0), 0), l = groups.reduce((a, g) => a + g.lines, 0)
@@ -649,7 +703,7 @@ export function hydrateWalkthrough(root, data, { renderDiff, renderSplit = null,
649
703
  if (!cache) { cache = new Map(); drawnFor.set(data, cache) }
650
704
  const doc = root.ownerDocument
651
705
  const sp = !!(split && renderSplit)
652
- const draw = (key, patch, label, callouts) => {
706
+ const draw = (key, patch, label, callouts, focus = null) => {
653
707
  const ck = (sp ? 'S|' : 'U|') + key
654
708
  let node = cache.get(ck)
655
709
  if (!node) {
@@ -661,6 +715,15 @@ export function hydrateWalkthrough(root, data, { renderDiff, renderSplit = null,
661
715
  node.classList.add('wt-gd')
662
716
  node.dataset.wtk = key
663
717
  for (const g of node.querySelectorAll('.gd-gap')) g.remove()
718
+ // 2026-11-03 the lines this step's refs name, when it shows a hunk whole (a new file two steps share)
719
+ for (const f of Array.isArray(focus) ? focus : []) {
720
+ const sel = f.side === 'old' ? '.gd-o' : '.gd-n'
721
+ for (const el of node.querySelectorAll(sp ? '.gd-half' : '.gd-row')) {
722
+ const x = el.querySelector(sel), v = x && x.textContent !== '' ? Number(x.textContent) : NaN
723
+ if (v >= f.from && v <= f.to) el.classList.add('wt-focus')
724
+ }
725
+ }
726
+ if (Array.isArray(focus) && focus.length) node.classList.add('wt-gd-focus')
664
727
  for (const c of callouts || []) {
665
728
  const sel = c.side === 'old' ? '.gd-o' : '.gd-n'
666
729
  let row = null
@@ -692,8 +755,10 @@ export function hydrateWalkthrough(root, data, { renderDiff, renderSplit = null,
692
755
  const [sid, i] = slot.getAttribute('data-snip').split(':')
693
756
  const st = (data.steps || []).find((x) => x.id === sid)
694
757
  const sn = st && st.snippets[+i]
695
- if (!sn) continue
696
- slot.appendChild(draw('s:' + sid + ':' + i, sn.patch, 'Changes in ' + sn.file, st.callouts.filter((c) => c.hunk === sn.hunk))); k++
758
+ if (!sn) { slot.innerHTML = '<p class="faint wt-snip-none">No lines to show here.</p>'; continue } // never an empty row
759
+ const node = draw('s:' + sid + ':' + i, sn.patch, 'Changes in ' + sn.file, st.callouts.filter((c) => c.hunk === sn.hunk), sn.focus)
760
+ if (node.isConnected && root.contains(node) && !slot.contains(node)) { slot.appendChild(node.cloneNode(true)); k++; continue } // shown in another slot on this page: a copy, never moved out of it
761
+ slot.appendChild(node); k++
697
762
  }
698
763
  for (const slot of root.querySelectorAll('[data-group-ex]')) {
699
764
  if (slot.firstChild) continue