@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.
- package/CHANGELOG.md +14 -0
- package/bin/leftoff.js +675 -549
- package/bin/pty-host-main.js +66 -0
- package/docs/macos-permissions.md +52 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +23 -2
- package/ui/app.js +31 -10
- package/ui/semdiff.js +58 -6
- package/ui/term.js +11 -3
- package/ui/walk-pop.js +3 -0
- package/ui/what-it-did.js +12 -5
|
@@ -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.
|
|
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"
|
package/mod/hooks/register.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
|
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
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
h
|
|
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
|
|