@leftoff/cli 0.9.18-beta.1 → 0.9.18-beta.3

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.
@@ -0,0 +1,66 @@
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`
3
+ import os, sys, pty, select, struct, fcntl, termios, signal
4
+ cols, rows = int(sys.argv[1]), int(sys.argv[2])
5
+ argv = sys.argv[3:]
6
+ pid, fd = pty.fork()
7
+ if pid == 0:
8
+ try:
9
+ os.execvp(argv[0], argv)
10
+ finally:
11
+ os._exit(127)
12
+ def size(c, r):
13
+ try: fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', r, c, 0, 0))
14
+ except OSError: pass
15
+ size(cols, rows)
16
+ def wall(f, b):
17
+ while b:
18
+ try: n = os.write(f, b)
19
+ except InterruptedError: continue
20
+ b = b[n:]
21
+ ins = [0, fd, 3]
22
+ ctl = b''
23
+ while True:
24
+ try: r = select.select(ins, [], [])[0]
25
+ except InterruptedError: continue
26
+ if fd in r:
27
+ try: d = os.read(fd, 65536)
28
+ except OSError: d = b''
29
+ if not d: break
30
+ wall(1, d)
31
+ if 0 in r:
32
+ d = os.read(0, 65536)
33
+ if not d:
34
+ ins.remove(0)
35
+ try: os.kill(pid, signal.SIGHUP)
36
+ except OSError: pass
37
+ else:
38
+ try: wall(fd, d)
39
+ except OSError: pass
40
+ if 3 in r:
41
+ d = os.read(3, 4096)
42
+ if not d: ins.remove(3)
43
+ ctl += d
44
+ while b'\n' in ctl:
45
+ line, ctl = ctl.split(b'\n', 1)
46
+ p = line.split()
47
+ try:
48
+ if p[0] == b'R': size(int(p[1]), int(p[2]))
49
+ elif p[0] == b'K': os.kill(pid, int(p[1]))
50
+ except (IndexError, ValueError, OSError): pass
51
+ st = os.waitpid(pid, 0)[1]
52
+ code = os.waitstatus_to_exitcode(st)
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
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(`
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})+`
64
+ `);let u=r.slice(p-s);u&&t.write(JSON.stringify({t:"out",d:u,end:x})+`
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)});
@@ -0,0 +1,52 @@
1
+ # macOS permission prompts
2
+
3
+ ## What the prompt means
4
+
5
+ > **"node" would like to access data from other apps.**
6
+ > Keeping app data separate makes it easier to manage your privacy and security.
7
+
8
+ macOS 14 and later ask this when a program reads inside another app's data: `~/Library/Containers/<app>`, `~/Library/Group Containers/<group>`, and on macOS 15 and later an app's own folder in `~/Library/Application Support`. The privacy service is called App Data (`kTCCServiceSystemPolicyAppData`).
9
+
10
+ macOS asks on behalf of the *responsible* program. A program started by the login service is responsible for everything it starts, and so is a terminal app for its shells. Before Leftoff 1.0 the login service started `node` itself. So anything Leftoff ran showed up as "node": a headless Claude Code or Codex session, a board terminal, git. Claude Code's command sandbox asks for App Data on its own when it sets up.
11
+
12
+ Two things made it repeat:
13
+ - A bare `node` binary is remembered by its path, and a Homebrew or nvm upgrade puts node at a new path.
14
+ - macOS keeps no "Don't Allow" for this service, so after Don't Allow it simply asks again.
15
+
16
+ ## What Leftoff does about it (1.0)
17
+
18
+ - **Leftoff's own code stays out of other apps' data.** No walk, listing, git call or file read goes into `~/Library` except the two places Leftoff uses: its own `Application Support/Leftoff` and `LaunchAgents`. In particular:
19
+ - the board terminal's "Match my Ghostty" reads Ghostty's folder only once you pick that theme, not every time the menu opens;
20
+ - Syncthing's `config.xml` is read every 10 minutes instead of every minute;
21
+ - a git repo rooted at your home folder (dotfiles) never lets git walk `~/Library`.
22
+ - **The rest asks as "Leftoff", once.** On a Mac the login service now starts a small launcher inside `~/Library/Application Support/Leftoff/Agent/Leftoff.app` (bundle id `dev.leftoff.Leftoff`). That launcher runs node as its child, so macOS names Leftoff, and an Allow is kept for the app across Leftoff and node upgrades.
23
+ - The app is built on your Mac once, with the Command Line Tools' compiler, and signed ad hoc. It is never rebuilt by an upgrade.
24
+ - Without the Command Line Tools the service runs node directly, as before, and `leftoff doctor` says so (`xcode-select --install`, then `leftoff update`).
25
+
26
+ After `leftoff update`, expect at most one prompt that says "Leftoff". Click **Allow**.
27
+
28
+ ## If you clicked Don't Allow
29
+
30
+ macOS doesn't keep a Don't Allow for App Data, so the next request asks again; click Allow then.
31
+
32
+ To take back an Allow and be asked again (no sudo, only your own user's answers):
33
+
34
+ tccutil reset SystemPolicyAppData dev.leftoff.Leftoff
35
+
36
+ `tccutil reset SystemPolicyAppData` without the id resets the answer for every app.
37
+
38
+ ## See who is asking
39
+
40
+ leftoff doctor --tcc
41
+
42
+ This reads macOS's privacy log for the last 2 hours, then watches for a minute while you use Leftoff. It prints each request: who it was asked for (Leftoff, node, a terminal), which program touched the data, and whether a prompt showed. Options: `--seconds=120`, `--since=6h`, `--all` for every privacy service. It needs an admin account, never sudo; some fields may read `<private>`.
43
+
44
+ To watch it by hand:
45
+
46
+ log stream --style compact --predicate 'subsystem == "com.apple.TCC" AND eventMessage CONTAINS "AUTHREQ"'
47
+
48
+ `leftoff doctor` (without `--tcc`) says whether the login service runs as Leftoff.app.
49
+
50
+ ## Rebuilding
51
+
52
+ `leftoff update --force` rebuilds Leftoff.app. A new build is a new code signature, so macOS asks once more.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.18-beta.1",
3
+ "version": "0.9.18-beta.3",
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"
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
28
28
  const MODEL_TIMEOUT_MS = 20000
29
29
 
30
30
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
31
- const MOD_VERSION = '0.9.18-beta.1' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.18-beta.3' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.18-beta.1",
3
+ "version": "0.9.18-beta.3",
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
@@ -5287,8 +5287,8 @@ code .file-ref-off { font-size: inherit; }
5287
5287
  .wt-dmode { display: none; }
5288
5288
  @media (min-width: 1000px) { .wt-dmode { display: inline-flex; } }
5289
5289
  .wt-r2 { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin: 6px 0 0; }
5290
- .wt-r2:not(:has(.wt-spot)):not(:has(.wt-dmode:not([hidden]))) { display: none; }
5291
- @media (max-width: 999.98px) { .wt-r2:not(:has(.wt-spot)) { display: none; } }
5290
+ .wt-r2:not(:has(.wt-spot)):not(:has(.wt-dmode:not([hidden]))):not(:has(.fsc-seg)) { display: none; }
5291
+ @media (max-width: 999.98px) { .wt-r2:not(:has(.wt-spot)):not(:has(.fsc-seg)) { display: none; } }
5292
5292
  /* the side-by-side drawing */
5293
5293
  .gd-split .gd-srow { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-width: 0; }
5294
5294
  .gd-half { display: grid; grid-template-columns: var(--gd-gw) 2ch minmax(0, 1fr); min-width: 0; }
@@ -5357,3 +5357,24 @@ code .file-ref-off { font-size: inherit; }
5357
5357
  .fs-wt-rv { display: inline-flex; align-items: center; gap: 4px; height: 18px; margin-left: 6px; padding: 1px 6px 0; box-sizing: border-box; border: 1px solid var(--line); border-radius: 9px; font-size: 11px; font-weight: 600; line-height: 1; color: var(--text2); white-space: nowrap; vertical-align: middle; }
5358
5358
  .fs-wt-rv .ic { width: 11px; height: 11px; color: var(--green); margin-top: -1px; }
5359
5359
  .fs-wt-rv-part { border-style: dashed; }
5360
+
5361
+ /* 2026-11-03 This session · Whole folder (walkthrough, its Files view, What it did): a quiet segmented
5362
+ toggle with each side's count; 44px to a finger, 32px to a mouse. Files that aren't this session's carry
5363
+ a small hollow-dashed ring plus the words "not from this session" (shape and words, never color alone). */
5364
+ .fsc-seg { margin-right: auto; flex: none; }
5365
+ .wt-seg.fsc-seg button { height: 44px; gap: 7px; }
5366
+ .fsc-n { font: 500 11.5px/1 var(--mono); font-variant-numeric: tabular-nums; color: var(--muted); padding: 2px 5px 1px; border-radius: 6px; background: var(--bg2); }
5367
+ .wt-seg.fsc-seg button[aria-pressed="true"] .fsc-n { color: var(--text2); background: var(--bg); }
5368
+ @media (pointer: fine) { .wt-seg.fsc-seg button { height: 32px; } }
5369
+ @media (max-width: 600px) { .wt-r2 .fsc-seg { width: 100%; display: flex; } .wt-r2 .fsc-seg button { flex: 1 1 0; } }
5370
+ .nots-g { display: inline-block; flex: none; width: 7px; height: 7px; border: 1.5px dashed var(--muted); border-radius: 50%; box-sizing: border-box; margin: 0 5px 0 0; vertical-align: middle; }
5371
+ .wt-nots { display: inline-flex; align-items: center; flex: none; font-size: 11.5px; color: var(--muted); white-space: nowrap; margin-left: 6px; }
5372
+ .wt-pre { font-size: 11.5px; white-space: nowrap; margin-left: 6px; }
5373
+ .wt-fl-i .nots-g { margin: 0 2px 0 4px; }
5374
+ .wt-fl-i.nots .wt-fm-f, .wt-file.nots .wt-file-p, .wid-item.nots .wid-base { color: var(--text2); }
5375
+ .wid-scope { margin: 4px 0 8px; }
5376
+ .wid-scope .fsc-seg { display: flex; width: 100%; box-sizing: border-box; }
5377
+ .wid-scope .fsc-seg button { flex: 1 1 0; }
5378
+ .wid-path .wt-nots, .wid-path .wt-pre { font-family: var(--sans, inherit); }
5379
+ .wid-scope-n { display: flex; gap: 6px; align-items: flex-start; }
5380
+ .wid-scope-n .ic { width: 13px; height: 13px; flex: none; margin-top: 2px; }
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 } 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, 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
@@ -1104,7 +1104,11 @@ function card(s) {
1104
1104
  // own node is kept between the page's redraws (the live poll repaints the page
1105
1105
  // every few seconds) so the scroll, the drawn code and the open groups stay.
1106
1106
  // Mark reviewed is the session's own record (session.md, like a pin).
1107
- function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') }
1107
+ // 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
1108
+ const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
1109
+ const filesQ = (sep = '?') => (fileScope.get() === 'folder' ? sep + 'files=folder' : '')
1110
+ function wtBody(sid, extra = {}) { return { scope: wtUi.scope.get(sid) || 'session', ...(fileScope.get() === 'folder' ? { files: 'folder' } : {}), ...extra } }
1111
+ function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') + (fileScope.get() === 'folder' ? '|folder' : '') }
1108
1112
  function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
1109
1113
  function wtRemember() { try { if (wtUi.sid) sessionStorage.setItem('lo-wt', JSON.stringify({ sid: wtUi.sid, scope: wtUi.scope.get(wtUi.sid) || 'session', view: wtUi.view })); else sessionStorage.removeItem('lo-wt') } catch {} }
1110
1114
  function wtRestore(sid) {
@@ -1135,7 +1139,7 @@ async function wtCheck(sid) {
1135
1139
  if (wtUi.checking.has(k)) return
1136
1140
  wtUi.checking.add(k)
1137
1141
  try {
1138
- const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', { scope: wtUi.scope.get(sid) || 'session', stale: true, peek: true }, { timeoutMs: 60000 })
1142
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { stale: true, peek: true }), { timeoutMs: 60000 })
1139
1143
  const had = wtUi.data.get(k)
1140
1144
  if (r && r.status === 'ok' && had && !wtUi.loading.has(k) && (r.fingerprint !== had.fingerprint || JSON.stringify(r.stale || null) !== JSON.stringify(had.stale || null))) {
1141
1145
  wtUi.data.set(k, r)
@@ -1143,6 +1147,18 @@ async function wtCheck(sid) {
1143
1147
  }
1144
1148
  } catch {} finally { wtUi.checking.delete(k) }
1145
1149
  }
1150
+ // 2026-11-03 This session · Whole folder: kept on this device; the walkthrough's story goes stale (Update writes
1151
+ // it again, never a model run on its own: peek), its Files view and What it did show the other side at once
1152
+ function setFileScope(v, { id = null } = {}) {
1153
+ const want = v === 'folder' ? 'folder' : 'session'
1154
+ if (fileScope.get() === want) return
1155
+ fileScope.set(want)
1156
+ wtUi.filter = 'all'; wtUi.picks = []
1157
+ for (const [k, z] of wtSize) { if (!z.pending) { z.at = 0; wtSizeFetch(k) } }
1158
+ for (const [k, w] of widUi) { if (w.open && w.diff) loadWid(k); else if (w.diff) Object.assign(w, { tools: null, diff: null, files: {} }) }
1159
+ if (wtUi.sid) { wtUi.sig = ''; wtLoad(wtUi.sid, { peek: true }) }
1160
+ else if (id) repaintSession(id)
1161
+ }
1146
1162
  function wtClose() {
1147
1163
  const sid = wtUi.sid
1148
1164
  document.documentElement.classList.remove('wt-open')
@@ -1165,7 +1181,7 @@ async function wtSizeFetch(sid) {
1165
1181
  e.pending = true; wtSize.set(sid, e)
1166
1182
  const sc = wtUi.scope.get(sid)
1167
1183
  let data = e.data
1168
- try { data = await api('GET', '/v1/sessions/' + encodeURIComponent(sid) + '/change-size' + (sc === 'checkpoint' || sc === 'session' ? '?scope=' + sc : '')) } catch { data = e.data || null } // an older Mac: no line
1184
+ try { data = await api('GET', '/v1/sessions/' + encodeURIComponent(sid) + '/change-size' + (sc === 'checkpoint' || sc === 'session' ? '?scope=' + sc + filesQ('&') : filesQ('?'))) } catch { data = e.data || null } // an older Mac: no line
1169
1185
  wtSize.set(sid, { at: Date.now(), data, pending: false })
1170
1186
  for (const slot of document.querySelectorAll('.fs-wt-size[data-id="' + CSS.escape(sid) + '"]')) {
1171
1187
  slot.innerHTML = sizeLineHtml(data, { esc, icon, reviewed: wtRevCount(wtSession(sid), data) })
@@ -1190,7 +1206,7 @@ let loPop = null
1190
1206
  function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid); wtRemember() }
1191
1207
  // 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
1192
1208
  // walkthrough it kept for this session and scope, marked stale, instead of a model run (Update: update)
1193
- async function wtLoad(sid, { fresh = false, update = false } = {}) {
1209
+ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
1194
1210
  const k = wtKey(sid)
1195
1211
  const had = wtUi.data.get(k)
1196
1212
  if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
@@ -1198,7 +1214,7 @@ async function wtLoad(sid, { fresh = false, update = false } = {}) {
1198
1214
  wtUi.loading.add(k); wtUi.error.delete(k)
1199
1215
  wtFill(wtSession(sid))
1200
1216
  try {
1201
- const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', { scope: wtUi.scope.get(sid) || 'session', ...(fresh ? { fresh: true } : {}), ...(fresh || update ? {} : { stale: true }) }, { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
1217
+ 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
1202
1218
  wtUi.data.set(k, r)
1203
1219
  if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
1204
1220
  // 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
@@ -1396,8 +1412,11 @@ function wtAsk(sid, text) {
1396
1412
  const data = wtUi.data.get(wtKey(sid))
1397
1413
  // 2026-10-30 lines you picked: each ref and its exact lines go in with the question (ui/walk-lines.js)
1398
1414
  const picks = wtPicksNow(data)
1415
+ // 2026-11-03 which changes the question is about, as the toggle has it right now
1416
+ const line = scopeAskLine(data)
1417
+ const lead = (m) => (line ? line + '\n' + m : m)
1399
1418
  if (picks.length) {
1400
- const msg = refsPrompt(describePicks(picks, (p) => { const x = patchFor(data, p.k); return x && { rows: rowsOf(x.patch), label: x.label, stepTitle: x.stepTitle } }), text)
1419
+ const msg = lead(refsPrompt(describePicks(picks, (p) => { const x = patchFor(data, p.k); return x && { rows: rowsOf(x.patch), label: x.label, stepTitle: x.stepTitle } }), text))
1401
1420
  wtUi.picks = []
1402
1421
  return wtAskPut(sid, msg)
1403
1422
  }
@@ -1405,7 +1424,7 @@ function wtAsk(sid, text) {
1405
1424
  const st = data && data.status === 'ok' ? (data.steps || []).find((x) => x.id === wtUi.cur.get(wtKey(sid))) : null
1406
1425
  const co = st && st.callouts[0]
1407
1426
  const where = st ? (co ? co.file + ':' + co.line : st.files[0] + (st.snippets[0] ? ':' + ((/^@@ -\d+(?:,\d+)? \+(\d+)/.exec(st.snippets[0].patch) || [])[1] || '') : '')).replace(/:$/, '') : ''
1408
- const msg = (where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text
1427
+ const msg = lead((where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text)
1409
1428
  wtAskPut(sid, msg)
1410
1429
  }
1411
1430
  function wtAskPut(sid, msg) {
@@ -2750,7 +2769,7 @@ async function loadWid(id) {
2750
2769
  paintWid(id)
2751
2770
  await Promise.all([
2752
2771
  api('GET', widPath(id, 'tools')).then((r) => { ui.tools = r.tools || [] }, (e) => { ui.toolsError = widError(e) }),
2753
- api('GET', widPath(id, 'diff')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
2772
+ api('GET', widPath(id, 'diff') + filesQ('?')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
2754
2773
  ])
2755
2774
  ui.loading = false
2756
2775
  // a file you had open shows its lines again
@@ -2762,7 +2781,7 @@ async function loadWidFile(id, p) {
2762
2781
  const f = ui.diff && (ui.diff.files || []).find((x) => x.path === p)
2763
2782
  if (!f || f.hidden || f.binary || f.big || ui.fileLoading[p]) return
2764
2783
  ui.fileLoading[p] = true; paintWid(id)
2765
- try { const r = await api('GET', widPath(id, 'diff') + '?file=' + encodeURIComponent(p)); ui.files[p] = r.file || { error: 'Not found.' } } catch (e) { ui.files[p] = { error: widError(e) } }
2784
+ try { const r = await api('GET', widPath(id, 'diff') + '?file=' + encodeURIComponent(p) + filesQ('&')); ui.files[p] = r.file || { error: 'Not found.' } } catch (e) { ui.files[p] = { error: widError(e) } }
2766
2785
  delete ui.fileLoading[p]
2767
2786
  paintWid(id)
2768
2787
  }
@@ -8493,6 +8512,7 @@ document.addEventListener('click', async (ev) => {
8493
8512
  if (action === 'wt-close') return wtClose()
8494
8513
  if (action === 'wt-stop') { const sid = btn.dataset.id; btn.disabled = true; const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough/stop').catch(() => null); if (!r || !r.stopped) { const k = wtKey(sid); wtUi.loading.delete(k); wtUi.error.set(k, 'Stopped waiting. It may still finish: open it again in a minute.'); wtUi.sig = ''; wtFill(wtSession(sid)) } return }
8495
8514
  if (action === 'wt-scope') { if (btn.disabled) return; wtUi.fellBack.delete(wtUi.sid); wtUi.scope.set(wtUi.sid, btn.dataset.scope); wtUi.filter = 'all'; wtRemember(); return wtLoad(wtUi.sid) }
8515
+ if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
8496
8516
  if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
8497
8517
  if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
8498
8518
  if (action === 'wt-reviewed') return wtReviewed(wtUi.sid, btn.dataset.step)
@@ -8583,6 +8603,7 @@ document.addEventListener('click', async (ev) => {
8583
8603
  // 0.9.8 What it did
8584
8604
  if (action === 'wid-toggle') { const ui = widOf(id); ui.open = !ui.open; paintWid(id); if (ui.open && !ui.tools && !ui.diff && !ui.loading) loadWid(id); return }
8585
8605
  if (action === 'wid-refresh') return loadWid(id)
8606
+ if (action === 'wid-files') return setFileScope(btn.dataset.files, { from: 'wid', id })
8586
8607
  if (action === 'wid-tool') {
8587
8608
  const ui = widOf(id), t = btn.dataset.tool
8588
8609
  ui.toolOpen[t] = !ui.toolOpen[t]; paintWid(id)
package/ui/semdiff.js CHANGED
@@ -83,8 +83,11 @@ export function readingOrder(steps, data) {
83
83
  return (steps || []).slice().sort((a, b) => ri(a) - ri(b) || tagRank(a) - tagRank(b) || (a.n || 0) - (b.n || 0))
84
84
  }
85
85
  // A repo the session changed that couldn't be read here: one quiet line each, never dropped silently
86
+ // 2026-11-03 and what it left out because it was already changed before the session (baseNotes)
86
87
  export function unreadableLines(data) {
87
- return data && Array.isArray(data.unreadable) ? data.unreadable.map((u) => (u && (u.line || (u.repo ? u.repo + ': not readable from here' + (u.why ? ' (' + u.why + ')' : '') : ''))) || '').filter(Boolean) : []
88
+ const un = data && Array.isArray(data.unreadable) ? data.unreadable.map((u) => (u && (u.line || (u.repo ? u.repo + ': not readable from here' + (u.why ? ' (' + u.why + ')' : '') : ''))) || '').filter(Boolean) : []
89
+ const bn = data && Array.isArray(data.baseNotes) ? data.baseNotes.map((n) => (n && typeof n.line === 'string' ? n.line : '')).filter(Boolean) : []
90
+ return un.concat(bn)
88
91
  }
89
92
  function unreadableHtml(data, { esc, icon }) {
90
93
  const ls = unreadableLines(data)
@@ -371,9 +374,10 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
371
374
  exportsHtml(ok, ui, { icon }) + '</div>' +
372
375
  '<button type="button" class="wt-x" data-action="wt-close" data-id="' + esc(sid) + '" aria-label="Close the walkthrough">' + icon('x') + '</button></div>'
373
376
  // 2026-10-30 the reading tools, a slim second row of the toolbar (the first stays one row): Next spot, Unified / Split
374
- const tools = spotBtnHtml(ok && view === 'story' ? spotsFor(data, readingOrder(data.steps, data)).length : 0, ui.spot, { icon }) +
377
+ // 2026-11-03 This session · Whole folder, at the top of the story and the Files list alike
378
+ const tools = fileScopeSegHtml(data && data.fileScope, { esc, icon, action: 'wt-files', busy: !!ui.loading }) + spotBtnHtml(ok && view === 'story' ? spotsFor(data, readingOrder(data.steps, data)).length : 0, ui.spot, { icon }) +
375
379
  modeSegHtml(ok || (data && Array.isArray(data.files) && data.files.length), ui.split)
376
- if (tools) h += '<div class="wt-r2" role="toolbar" aria-label="Reading tools">' + tools + '</div>'
380
+ if (tools) h += '<div class="wt-r2" role="toolbar" aria-label="Which changes and reading tools">' + tools + '</div>'
377
381
  // which model wrote it (Settings → Models → Walk me through it picks it)
378
382
  // the session's own agent couldn't (a Codex session, no transcript here, it didn't answer): said quietly, the reason in the tip
379
383
  if (ok) h += '<p class="faint wt-by">Written by ' + (data.modelLabel ? esc(data.modelLabel) : 'your summarizer') + (data.unavailable ? ' (Claude unavailable: ' + esc(data.unavailable) + ')' : '') + (when ? ' · ' + esc(when) : '') +
@@ -394,6 +398,8 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
394
398
  // a small local model (under 7B) is the likely reason: one quiet line pointing at a better one
395
399
  if (data.status === 'fallback' && data.small) h += '<p class="faint wt-small">' + icon('info') + '<span>Small local models struggle here. Try Claude or qwen2.5-coder 7B in <a href="#/settings/models">Settings</a>.</span></p>'
396
400
  }
401
+ // 2026-11-03 switched to the other scope with nothing written for it: never a model run on its own; Write it is one tap
402
+ if (data && !ui.loading && data.status === 'none' && view === 'story') h += '<p class="wt-stale" role="status"><i class="wt-chg-g" aria-hidden="true"></i><span>' + (data.fileScope && data.fileScope.key === 'folder' ? 'No walkthrough of the whole folder yet.' : 'No walkthrough of this session’s changes yet.') + ' The files are under Files.</span><button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '">' + icon('refresh-cw') + 'Write it</button></p>'
397
403
  const deck = ok && view === 'story' && ui.deck !== false && (data.steps || []).length > 0 // 0.9.18 the slideshow
398
404
  if (ok && view === 'story' && !deck) {
399
405
  const steps = data.steps || []
@@ -444,6 +450,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
444
450
  }
445
451
  let fl = ''
446
452
  if (!(ok && view === 'story') && data && Array.isArray(data.files) && data.files.length && !ui.loading) {
453
+ if (ok || !data.message) body += unreadableHtml(data, { esc, icon }) // 2026-11-03 what the scope left out, or the no-snapshot note
447
454
  if (data.files.length > 1) { fl = filesListHtml(data, { esc, icon }); body += filesListHtml(data, { esc, icon, mobile: true }) }
448
455
  body += filesHtml(data.files, { esc, icon })
449
456
  }
@@ -557,18 +564,63 @@ export function filesHtml(files, { esc, icon }) {
557
564
  const open = files.length <= 10
558
565
  return '<div class="wt-files-v">' + files.map((f, i) => {
559
566
  const what = f.hidden ? 'not shown (keys and env files never are)' : f.binary ? 'binary file' : f.generated ? 'generated or lockfile · ' + plural(f.add + f.del, 'line') : !f.lines ? (f.status === 'renamed' ? 'renamed, nothing changed inside' : 'no lines changed') : ''
560
- return '<details class="wt-file" id="wt-f-' + i + '" data-file-i="' + i + '"' + (open && !what ? ' open' : '') + '><summary>' + icon('chevron-right', 'ic wt-g-chev') + statusHtml(f, { esc, icon }) + '<span class="mono wt-file-p">' + esc(f.repo && String(f.path).startsWith(f.repo + '/') ? f.repo + ' · ' + f.path.slice(f.repo.length + 1) : f.path) + '</span>' + (f.oldPath ? '<span class="faint wt-file-o">from ' + esc(f.oldPath) + '</span>' : '') +
567
+ return '<details class="wt-file' + (f.notSession ? ' nots' : '') + '" id="wt-f-' + i + '" data-file-i="' + i + '"' + (open && !what ? ' open' : '') + '><summary>' + icon('chevron-right', 'ic wt-g-chev') + statusHtml(f, { esc, icon }) + '<span class="mono wt-file-p">' + esc(f.repo && String(f.path).startsWith(f.repo + '/') ? f.repo + ' · ' + f.path.slice(f.repo.length + 1) : f.path) + '</span>' + (f.oldPath ? '<span class="faint wt-file-o">from ' + esc(f.oldPath) + '</span>' : '') + scopeTagHtml(f, { esc }) +
561
568
  '<span class="wt-file-n"><span class="wt-add">+' + n0(f.add) + '</span> <span class="wt-del">−' + n0(f.del) + '</span></span></summary>' +
562
569
  (what ? '<p class="faint wt-g-p">' + esc(what) + '</p>' : '<div class="wt-snip-d" data-file="' + i + '"></div>' + (f.clipped ? '<p class="faint wt-g-p">… too big to show all of it here</p>' : '')) + '</details>'
563
570
  }).join('') + '</div>'
564
571
  }
572
+ // 2026-11-03 (asked: "should Files/Ask be scoped too? Or should it be a toggle?") Which files the
573
+ // walkthrough, its Files view, What it did and Ask cover: This session (the default, only what the
574
+ // session changed) or Whole folder (everything uncommitted, the rest marked "not from this
575
+ // session"). The daemon cuts both the same way (Engine#scopeDiff); the choice is kept on this device.
576
+ export const FILE_SCOPE_KEY = 'lo-file-scope'
577
+ export function fileScopeStore(storage) {
578
+ return {
579
+ get() { try { return storage && storage.getItem(FILE_SCOPE_KEY) === 'folder' ? 'folder' : 'session' } catch { return 'session' } },
580
+ set(v) { try { if (!storage) return; if (v === 'folder') storage.setItem(FILE_SCOPE_KEY, 'folder'); else storage.removeItem(FILE_SCOPE_KEY) } catch {} },
581
+ }
582
+ }
583
+ // "This session 4 · Whole folder 24": a segmented toggle with each side's count. fs: the daemon's
584
+ // { key, session, folder } (none from an older Mac, or with the snapshot rule off: no toggle).
585
+ export function fileScopeSegHtml(fs, { esc, icon, action = 'wt-files', busy = false } = {}) {
586
+ if (!fs || typeof fs !== 'object' || fs.session == null || fs.folder == null) return ''
587
+ const key = fs.key === 'folder' ? 'folder' : 'session'
588
+ const n = (v) => '<span class="fsc-n" aria-hidden="true">' + esc(Number(v) || 0) + '</span>'
589
+ const one = (k, label, cnt, tip) => '<button type="button" data-action="' + esc(action) + '" data-files="' + k + '" aria-pressed="' + (key === k) + '" aria-label="' + esc(label + ', ' + (Number(cnt) || 0) + ' file' + (Number(cnt) === 1 ? '' : 's')) + '" data-tip="' + esc(tip) + '"' + (busy ? ' disabled' : '') + '>' + label + ' ' + n(cnt) + '</button>'
590
+ return '<div class="wt-seg wt-seg-sm fsc-seg" role="group" aria-label="Which changes">' +
591
+ one('session', 'This session', fs.session, 'Only what this session changed') +
592
+ one('folder', 'Whole folder', fs.folder, 'Everything uncommitted in the folder, other sessions’ changes too') + '</div>'
593
+ }
594
+ // The context line Ask puts above a question: "This session’s changes (4 files)" / "Whole folder (24 files)"
595
+ export function scopeAskLine(data) {
596
+ const fs = data && data.fileScope
597
+ if (!fs || fs.session == null || fs.folder == null) return ''
598
+ const folder = fs.key === 'folder'
599
+ const k = Number(folder ? fs.folder : fs.session) || 0
600
+ const files = k + ' file' + (k === 1 ? '' : 's')
601
+ return folder ? 'Whole folder (' + files + ')' : 'This session’s changes (' + files + ')'
602
+ }
603
+ // the quiet per-file words: not from this session (Whole folder), or changed before it too
604
+ export function scopeTagWords(f) {
605
+ if (!f) return ''
606
+ if (f.notSession) return 'not from this session'
607
+ if (f.preDirty === 'start') return 'only this session’s change'
608
+ if (f.preDirty === 'whole') return 'changed before this session too'
609
+ if (f.preDirty === 'before') return 'has changes from before this session'
610
+ return ''
611
+ }
612
+ export function scopeTagHtml(f, { esc }) {
613
+ const w = scopeTagWords(f)
614
+ if (!w) return ''
615
+ return f.notSession ? '<span class="wt-nots" data-tip="Changed in this folder, but not by this session"><i class="nots-g" aria-hidden="true"></i>not from this session</span>' : '<span class="wt-pre faint">' + esc(w) + '</span>'
616
+ }
565
617
  // 0.9.18 (Joel): the Files view's list of files, beside the diff on a wide screen (each
566
618
  // scrolls on its own) and folded above it on a phone. Folders like the file map;
567
619
  // each file with its status by shape and its +/− count; a click opens that file's diff.
568
620
  function flFileHtml(it, { esc, icon, depth }) {
569
621
  const f = it.f
570
- return '<a class="wt-fm-i wt-fl-i" href="#" data-action="wt-file" data-i="' + it.i + '"' + (depth ? ' data-d="' + Math.min(depth, 6) + '"' : '') + ' aria-label="' + esc(f.path + ', ' + statusWord(f).toLowerCase() + ', ' + f.add + ' added, ' + f.del + ' removed') + '">' +
571
- statusHtml(f, { esc, icon }) + '<span class="wt-fm-f">' + esc(it.base) + '</span><span class="wt-fl-n" aria-hidden="true"><span class="wt-add">+' + n0(f.add) + '</span><span class="wt-del">−' + n0(f.del) + '</span></span></a>'
622
+ return '<a class="wt-fm-i wt-fl-i' + (f.notSession ? ' nots' : '') + '" href="#" data-action="wt-file" data-i="' + it.i + '"' + (depth ? ' data-d="' + Math.min(depth, 6) + '"' : '') + ' aria-label="' + esc(f.path + ', ' + statusWord(f).toLowerCase() + ', ' + f.add + ' added, ' + f.del + ' removed' + (scopeTagWords(f) ? ', ' + scopeTagWords(f) : '')) + '"' + (f.notSession ? ' data-tip="Not from this session"' : '') + '>' +
623
+ statusHtml(f, { esc, icon }) + '<span class="wt-fm-f">' + esc(it.base) + '</span>' + (f.notSession ? '<i class="nots-g" aria-hidden="true"></i>' : '') + '<span class="wt-fl-n" aria-hidden="true"><span class="wt-add">+' + n0(f.add) + '</span><span class="wt-del">−' + n0(f.del) + '</span></span></a>'
572
624
  }
573
625
  export function filesListHtml(data, { esc, icon, mobile = false }) {
574
626
  const files = (data && data.files) || []
package/ui/term.js CHANGED
@@ -171,7 +171,7 @@ async function loadLib() {
171
171
  }
172
172
 
173
173
  export function mountTerminal({ api, icon = () => '', toast = () => {}, context = () => null, openMenu = null, relayApi = () => (typeof window !== 'undefined' && window.leftoffRelay) || null } = {}) {
174
- const st = { max: ls.get(MAX_KEY) === '1', open: false, info: null, tabs: [], active: ls.get(ACTIVE_KEY), views: new Map(), ghostty: undefined, ctrl: false, loading: false, listTimer: 0, sid: null }
174
+ const st = { max: ls.get(MAX_KEY) === '1', open: false, info: null, tabs: [], active: ls.get(ACTIVE_KEY), views: new Map(), ghostty: undefined, ghosttyPeek: undefined, ctrl: false, loading: false, listTimer: 0, sid: null }
175
175
  // 0.9.15 each session's own terminal tab (term-sess.js); st.sid: the session in view, if any
176
176
  let sess = null
177
177
  try { sess = createTermSessStore({ storage: window.localStorage }) } catch { sess = createTermSessStore({ storage: null }) }
@@ -694,6 +694,14 @@ export function mountTerminal({ api, icon = () => '', toast = () => {}, context
694
694
  try { st.ghostty = await api('GET', '/v1/term/theme/ghostty') } catch { st.ghostty = null }
695
695
  return st.ghostty
696
696
  }
697
+ // 1.0: the ⋯ menu only asks whether there is a Ghostty to match; Ghostty's own
698
+ // files (its app data on a Mac) are read when you pick it, not when the menu opens
699
+ async function peekGhostty() {
700
+ if (st.ghostty !== undefined) return st.ghostty
701
+ if (st.ghosttyPeek !== undefined) return st.ghosttyPeek
702
+ try { st.ghosttyPeek = await api('GET', '/v1/term/theme/ghostty?peek=1') } catch { st.ghosttyPeek = null }
703
+ return st.ghosttyPeek
704
+ }
697
705
  applyLook()
698
706
  if ((ls.get(THEME_KEY) || '') === 'ghostty') loadGhostty().then(applyLook)
699
707
  new MutationObserver(() => applyLook()).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
@@ -701,7 +709,7 @@ export function mountTerminal({ api, icon = () => '', toast = () => {}, context
701
709
 
702
710
  // ---------- menu
703
711
  async function menuItems() {
704
- const g = await loadGhostty()
712
+ const g = await peekGhostty()
705
713
  const cur = ls.get(THEME_KEY) || 'auto'
706
714
  const ctx = context() || null
707
715
  const items = []
@@ -713,7 +721,7 @@ export function mountTerminal({ api, icon = () => '', toast = () => {}, context
713
721
  items.push({ label: 'Run claude here', sub: ctx && ctx.sessionId ? 'in ' + (ctx.folder || 'this session’s folder') : 'in ~', icon: icon('terminal'), cls: 'one-line', attrs: { 'data-term-act': 'claude', 'data-tip': 'Runs claude in ' + where } })
714
722
  if (ctx && ctx.sessionId && ctx.resumable) items.push({ label: 'Resume this session', sub: ctx.resumeCmd || 'claude --resume', icon: icon('play'), cls: 'one-line', attrs: { 'data-term-act': 'resume', 'data-tip': (ctx.resumeCmd || 'claude --resume') + ' in ' + where } })
715
723
  items.push({ sep: true })
716
- const choices = THEME_CHOICES.filter((k) => k !== 'ghostty' || (g && g.found && (g.theme || g.light || g.dark)))
724
+ const choices = THEME_CHOICES.filter((k) => k !== 'ghostty' || (g && g.found && (g.theme || g.light || g.dark || g.deferred)))
717
725
  items.push({ label: 'Theme', sub: themeLabel(cur, g), icon: icon('palette'), cls: 'one-line', whole: true, more: { title: 'Theme', items: choices.map((k) => ({ label: themeLabel(k, g), on: k === cur, cls: 'one-line', after: k === cur ? '<span class="lo-act-cur">current</span>' : '', attrs: { 'data-term-theme': k } })) } })
718
726
  return items
719
727
  }
package/ui/walk-pop.js CHANGED
@@ -67,6 +67,9 @@ export function popContent(kind, { size = null, away = false } = {}) {
67
67
  ? { icon: 'circle-dashed', text: label + ' · no changes yet, so nothing to walk through', dim: true }
68
68
  : { icon: 'git-compare', text: label + ' · ' + plural(size.files, 'file') })
69
69
  }
70
+ // 2026-11-03 what was already changed when the session started isn't counted: said quietly
71
+ if (w && size.excluded > 0) lines.push({ icon: 'circle-dashed', text: plural(size.excluded, 'file') + ' changed before this session, left out', dim: true })
72
+ else if (w && size.fallback) lines.push({ icon: 'circle-dashed', text: 'Only the files this session edited', dim: true })
70
73
  if (size && size.writer) lines.push({ icon: 'bot', text: 'Written by ' + size.writer })
71
74
  return {
72
75
  title: 'Walk me through the changes',
package/ui/what-it-did.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // app.js) so they can be checked without a browser.
6
6
  import { icon } from './icons.js'
7
7
  import { diffBlocks, splitPath } from './diff-view.js'
8
+ import { fileScopeSegHtml, scopeTagHtml, unreadableLines } from './semdiff.js' // 2026-11-03 This session · Whole folder, the walkthrough's own rule
8
9
 
9
10
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
10
11
  const plural = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
@@ -63,12 +64,12 @@ export function fileRowHtml(f, ui, sid) {
63
64
  const bodyId = 'wid-f-' + esc(encodeURIComponent(f.path).replace(/%/g, '_'))
64
65
  const bar = '<span class="gd-bar" aria-hidden="true">' + diffBlocks(f.add, f.del).map((b) => '<i class="gd-b' + b + '"></i>').join('') + '</span>'
65
66
  const counts = f.binary ? '<span class="wid-n">binary</span>' : f.big ? '<span class="wid-n">big</span>' : '<span class="wid-add">+' + esc(f.add) + '</span> <span class="wid-del">−' + esc(f.del) + '</span>' + bar
66
- let h = '<li class="wid-item' + (open ? ' open' : '') + '">'
67
+ let h = '<li class="wid-item' + (open ? ' open' : '') + (f.notSession ? ' nots' : '') + '">'
67
68
  h += '<button type="button" class="wid-row wid-file" data-action="wid-file" data-id="' + esc(sid) + '" data-path="' + esc(f.path) + '" aria-expanded="' + open + '" aria-controls="' + bodyId + '">'
68
69
  h += '<span class="wid-chev" aria-hidden="true">' + icon(open ? 'chevron-down' : 'chevron-right') + '</span>'
69
70
  h += '<span class="wid-st st-' + esc(f.status) + '" data-tip="' + esc(word) + '" aria-label="' + esc(word) + '">' + letter + '</span>'
70
71
  const [dir, base] = splitPath(f.path)
71
- h += '<span class="wid-path mono">' + (dir ? '<span class="wid-dir">' + esc(dir) + '</span>' : '') + '<b class="wid-base">' + esc(base) + '</b></span><span class="wid-counts">' + counts + '</span></button>'
72
+ h += '<span class="wid-path mono">' + (dir ? '<span class="wid-dir">' + esc(dir) + '</span>' : '') + '<b class="wid-base">' + esc(base) + '</b>' + scopeTagHtml(f, { esc }) + '</span><span class="wid-counts">' + counts + '</span></button>'
72
73
  if (open) {
73
74
  const v = ui.files[f.path]
74
75
  h += '<div class="wid-out" id="' + bodyId + '">'
@@ -91,16 +92,22 @@ export function fileRowHtml(f, ui, sid) {
91
92
 
92
93
  function diffHtml(ui, sid) {
93
94
  const d = ui.diff
94
- let h = '<div class="wid-sub"><h3>This folder’s changes</h3>'
95
+ // 2026-11-03 scoped like the walkthrough: This session (the default) or Whole folder, counts on both
96
+ const fsc = d && d.fileScope && d.fileScope.session != null ? d.fileScope : null
97
+ const title = fsc && fsc.key !== 'folder' ? 'This session’s changes' : 'This folder’s changes'
98
+ let h = '<div class="wid-sub"><h3>' + title + '</h3>'
95
99
  if (d && d.folder && !d.tooBig && !d.noRepo) h += '<span class="wid-meta">' + esc(d.folder) + (d.totals && d.totals.files ? ' · ' + plural(d.totals.files, 'file') + ' · <span class="wid-add">+' + esc(d.totals.add) + '</span> <span class="wid-del">−' + esc(d.totals.del) + '</span>' : '') + '</span>'
96
100
  h += '</div>'
101
+ if (fsc) h += '<div class="wid-scope">' + fileScopeSegHtml(fsc, { esc, icon, action: 'wid-files', busy: !!ui.loading }) + '</div>'
97
102
  if (ui.diffError) return h + '<p class="wid-note">' + esc(ui.diffError) + '</p>'
98
103
  if (!d) return h + '<p class="wid-note">Loading…</p>'
99
104
  if (d.tooBig) return h + '<p class="wid-note">Too big to show here.</p>'
100
105
  if (d.noRepo) return h + '<p class="wid-note">' + (d.noRepo === 'not_a_repo' ? 'This folder isn’t a git repository.' : d.noRepo === 'no_git' ? 'git isn’t installed on that Mac.' : 'This session’s folder isn’t there any more.') + '</p>'
101
- if (!d.files || !d.files.length) return h + '<p class="wid-note">No uncommitted changes.</p>'
102
- h += '<ul class="wid-list">' + d.files.map((f) => fileRowHtml(f, ui, sid)).join('') + '</ul>'
106
+ const notes = unreadableLines({ baseNotes: d.baseNotes }).map((l) => '<p class="wid-note wid-scope-n">' + icon('info') + '<span>' + esc(l) + '</span></p>').join('')
107
+ if (!d.files || !d.files.length) return h + notes + '<p class="wid-note">' + (fsc && fsc.key !== 'folder' ? (fsc.folder ? 'No uncommitted changes from this session (' + plural(fsc.folder, 'file') + ' in the whole folder).' : 'No uncommitted changes from this session.') : 'No uncommitted changes.') + '</p>'
108
+ h += notes + '<ul class="wid-list">' + d.files.map((f) => fileRowHtml(f, ui, sid)).join('') + '</ul>'
103
109
  if (d.truncated) h += '<p class="wid-note">Showing the first ' + d.files.length + ' of ' + esc(d.totals.files) + ' files.</p>'
110
+ if (fsc && fsc.key !== 'folder') return h + '<p class="wid-foot">Uncommitted changes this session made in its folder. Whole folder shows everything, other sessions’ changes too.</p>'
104
111
  return h + '<p class="wid-foot">Uncommitted changes in the session’s folder (staged, unstaged and new files). Other sessions in this folder show the same changes.</p>'
105
112
  }
106
113