@leftoff/cli 0.9.17 → 0.9.18-beta.2
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 +31 -1
- package/bin/leftoff.js +698 -528
- 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 +29 -2
- package/ui/app.js +83 -22
- package/ui/ask.js +1 -0
- package/ui/live-view.js +3 -3
- package/ui/semdiff.js +60 -8
- package/ui/start.js +87 -12
- 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.
|
|
3
|
+
"version": "0.9.18-beta.2",
|
|
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.
|
|
31
|
+
const MOD_VERSION = '0.9.18-beta.2' // 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.
|
|
3
|
+
"version": "0.9.18-beta.2",
|
|
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
|
@@ -3283,6 +3283,10 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
3283
3283
|
/* 0.9.9 Permissions: the New session choice, the session page's switch and its pill beside Live */
|
|
3284
3284
|
.start-mode { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
3285
3285
|
.start-mode .start-opt-sub { white-space: normal; }
|
|
3286
|
+
/* 1.0 Mode: headless, board terminal, my terminal app (shape + words) */
|
|
3287
|
+
.run-glyph { color: var(--muted); font-weight: 400; display: inline-block; width: 1.1em; }
|
|
3288
|
+
.start-opt[aria-checked="true"] .run-glyph { color: var(--accent); }
|
|
3289
|
+
.start-run .start-opt-sub { white-space: normal; }
|
|
3286
3290
|
.headless-mode { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: -4px 0 10px; }
|
|
3287
3291
|
.headless-mode .hm-l { font-size: 12.5px; font-weight: 600; color: var(--muted); }
|
|
3288
3292
|
.hm-seg { display: inline-flex; border: 1px solid var(--line); border-radius: 10px; overflow: visible; background: var(--card); }
|
|
@@ -3321,6 +3325,8 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
3321
3325
|
.help-more[hidden] { display: none; }
|
|
3322
3326
|
.start-h-help { display: flex; align-items: center; gap: 6px; }
|
|
3323
3327
|
@media (hover: hover) and (pointer: fine) { .help-q { width: 28px; height: 28px; margin: 0 -4px; } }
|
|
3328
|
+
.term-pill { display: inline-flex; align-items: center; gap: 4px; margin-left: 6px; padding: 1px 8px; border-radius: 999px; border: 1px dashed var(--line-strong); color: var(--text2); font-size: 11.5px; font-weight: 600; line-height: 1.5; white-space: nowrap; } /* 1.0 */
|
|
3329
|
+
.term-pill.t-app { border-style: solid; }
|
|
3324
3330
|
.mode-pill { position: relative; display: inline-block; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--bg2); color: var(--text2); font-size: 11.5px; font-weight: 600; line-height: 1.5; white-space: nowrap; }
|
|
3325
3331
|
.mode-pill.m-auto { border-color: var(--accent); background: var(--tint-accent); color: var(--text); }
|
|
3326
3332
|
.mode-pill.m-acceptEdits { border-color: var(--yellow); background: var(--tint-yellow); color: var(--text); }
|
|
@@ -5281,8 +5287,8 @@ code .file-ref-off { font-size: inherit; }
|
|
|
5281
5287
|
.wt-dmode { display: none; }
|
|
5282
5288
|
@media (min-width: 1000px) { .wt-dmode { display: inline-flex; } }
|
|
5283
5289
|
.wt-r2 { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin: 6px 0 0; }
|
|
5284
|
-
.wt-r2:not(:has(.wt-spot)):not(:has(.wt-dmode:not([hidden]))) { display: none; }
|
|
5285
|
-
@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; } }
|
|
5286
5292
|
/* the side-by-side drawing */
|
|
5287
5293
|
.gd-split .gd-srow { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-width: 0; }
|
|
5288
5294
|
.gd-half { display: grid; grid-template-columns: var(--gd-gw) 2ch minmax(0, 1fr); min-width: 0; }
|
|
@@ -5351,3 +5357,24 @@ code .file-ref-off { font-size: inherit; }
|
|
|
5351
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; }
|
|
5352
5358
|
.fs-wt-rv .ic { width: 11px; height: 11px; color: var(--green); margin-top: -1px; }
|
|
5353
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
|
@@ -23,7 +23,7 @@ import { parseBang, dropBang, ptyInput, commandOutput, outputMessage, createBang
|
|
|
23
23
|
import { withFresh, pickFresh, newerFresh, freshWins, pendingHeadless, settled as headlessSettled } from './headless-fresh.js' // 0.9.9 another Mac's session, current after Stop/Resume/Permissions
|
|
24
24
|
import { bgPillHtml } from './bgtasks.js' // 0.9.13 "2 tasks running"
|
|
25
25
|
import { scopeHtml, scopeHintHtml, scopeStart } from './sm-scope.js' // 0.9.15 per-machine summarizer
|
|
26
|
-
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
|
|
26
|
+
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, termPillHtml, termBarHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
|
|
27
27
|
import { parseNewSession, projectStartItems, newSessionContext, startStateFor } from './cmdk-new.js' // 0.9.13 ⌘K New session…
|
|
28
28
|
import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
|
|
29
29
|
import { wireTips } from './tips.js'
|
|
@@ -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
|
|
@@ -529,7 +529,7 @@ function agentChip(a, { title } = {}) {
|
|
|
529
529
|
|
|
530
530
|
// 0.8.0: a Codex session wears a small "Codex" badge; Claude Code's wear none.
|
|
531
531
|
function codexBadge(s) {
|
|
532
|
-
return s && s.codingAgent === 'codex' ? '<span class="chip agent-badge codex"
|
|
532
|
+
return s && s.codingAgent === 'codex' ? '<span class="chip agent-badge codex" data-tip="' + esc('OpenAI Codex session' + (s.surface === 'codex-ide' ? ' (IDE)' : s.surface === 'codex-app' ? ' (app)' : '')) + '">Codex</span>' : ''
|
|
533
533
|
}
|
|
534
534
|
|
|
535
535
|
// 0.7.1: a session in a git worktree says which ("⑂ fix-login"); one filed
|
|
@@ -1088,7 +1088,7 @@ function card(s) {
|
|
|
1088
1088
|
'<div class="row1"><div class="title">' + cardGlyph(s) +
|
|
1089
1089
|
(s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
|
|
1090
1090
|
'<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
|
|
1091
|
-
'</div>' + statusPill(s) + liveBadge(s) + eyeCountHtml(s.lookCount, { esc }) + '</div>' +
|
|
1091
|
+
'</div>' + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + eyeCountHtml(s.lookCount, { esc }) + '</div>' +
|
|
1092
1092
|
'<div class="meta">' + spaceChip(s) + where(s, { repo: !titleIsWhere(s) }) + (ln && !off ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
|
|
1093
1093
|
(s.needsInput && s.lastNotification && !s.needsYou && !off ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
1094
1094
|
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + recapLineHtml(s, { esc, ago, clock }) + cardCheckpoint(s) + beforeClearHtml(s, { esc }) +
|
|
@@ -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,10 +1424,18 @@ 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) {
|
|
1431
|
+
// 0.9.17 a Codex session in the board's terminal: the question goes into Codex's own box there (pasted, not sent)
|
|
1432
|
+
const cs = wtSession(sid)
|
|
1433
|
+
if (cs && cs.codingAgent === 'codex' && cs.terminal && cs.terminal.kind === 'leftoff' && cs.terminal.termId) {
|
|
1434
|
+
const tid = cs.terminal.termId
|
|
1435
|
+
wtUi.ask = ''; wtClose()
|
|
1436
|
+
terminal.show(tid).then((ok) => ok && api('POST', '/v1/term/' + tid + '/in', { data: '\x1b[200~' + msg + '\x1b[201~' })).then((r) => toast(r ? 'In Codex’s box: press Enter there to send' : 'Its terminal isn’t running any more', 4000)).catch((e) => toast(e.message, 5000))
|
|
1437
|
+
return
|
|
1438
|
+
}
|
|
1412
1439
|
const k = 'live:' + sid
|
|
1413
1440
|
const typed = ((state.drafts && state.drafts[k]) || '').trim()
|
|
1414
1441
|
state.drafts[k] = typed ? typed + '\n\n' + msg : msg
|
|
@@ -2577,6 +2604,7 @@ function freshSession(s) {
|
|
|
2577
2604
|
if (op && out.headless) out = { ...out, headless: pendingHeadless(out.headless, op) }
|
|
2578
2605
|
return out
|
|
2579
2606
|
}
|
|
2607
|
+
const switchPending = new Set() // 1.0 a Continue in terminal / headless on its way
|
|
2580
2608
|
const fetchFresh = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/headless', undefined, { timeoutMs: 8000 }).then((r) => (r && r.fresh) || null, () => null)
|
|
2581
2609
|
// Redraw the session page from what it has (no fetch): the bar and the switch at once.
|
|
2582
2610
|
function repaintSession(id) {
|
|
@@ -2741,7 +2769,7 @@ async function loadWid(id) {
|
|
|
2741
2769
|
paintWid(id)
|
|
2742
2770
|
await Promise.all([
|
|
2743
2771
|
api('GET', widPath(id, 'tools')).then((r) => { ui.tools = r.tools || [] }, (e) => { ui.toolsError = widError(e) }),
|
|
2744
|
-
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) }),
|
|
2745
2773
|
])
|
|
2746
2774
|
ui.loading = false
|
|
2747
2775
|
// a file you had open shows its lines again
|
|
@@ -2753,7 +2781,7 @@ async function loadWidFile(id, p) {
|
|
|
2753
2781
|
const f = ui.diff && (ui.diff.files || []).find((x) => x.path === p)
|
|
2754
2782
|
if (!f || f.hidden || f.binary || f.big || ui.fileLoading[p]) return
|
|
2755
2783
|
ui.fileLoading[p] = true; paintWid(id)
|
|
2756
|
-
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) } }
|
|
2757
2785
|
delete ui.fileLoading[p]
|
|
2758
2786
|
paintWid(id)
|
|
2759
2787
|
}
|
|
@@ -2852,7 +2880,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2852
2880
|
const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
|
|
2853
2881
|
'<button type="button" class="fs-side-x" data-action="fs-side" aria-expanded="true" aria-controls="fsSide" aria-label="Hide the session panel" data-tip="Hide panel (⌘\\)">' + icon('chevron-right') + '</button>' +
|
|
2854
2882
|
title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
|
|
2855
|
-
'<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
|
|
2883
|
+
'<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
|
|
2856
2884
|
// 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
|
|
2857
2885
|
(where.length ? '<div class="fs-where fs-repo" data-tip="' + esc(wherePath ? wherePath + (branch ? ' · ' + branch : '') : where.join(' · ')) + '">' + icon('folder') + '<span>' + esc(where.join(' · ')) + '</span></div>' : '') +
|
|
2858
2886
|
'<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
|
|
@@ -2873,6 +2901,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2873
2901
|
side += '<section class="scu-wrap" id="session-catchup">' + sessionCatchupHtml(scuOf(s.id), { esc, icon, sid: s.id }) + '</section>' // 0.9.9 Catch me up on this session, at the top
|
|
2874
2902
|
side += '<div class="sask-wrap" id="session-ask">' + sAskPanel(s).html + '</div>' // 0.9.11 Ask about this session
|
|
2875
2903
|
side += ownerOfflineNote(s)
|
|
2904
|
+
side += termBarHtml(s, { esc, may: state.mayAnswer !== false }) // 1.0 in Leftoff's terminal: Continue headless
|
|
2876
2905
|
side += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false, busy: modeBusy.get(s.id) || null, local: state.viewerLocal !== false, helpOpen: helpOpen.has(hmHelpId(s.id)) }) // 0.9.9: started from the board: Stop / Resume, Permissions
|
|
2877
2906
|
if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
|
|
2878
2907
|
// Checkpoints: a timeline, latest first, the latest open
|
|
@@ -3162,7 +3191,7 @@ function liveOpenHtml(s, v, sub) {
|
|
|
3162
3191
|
if (isFullLive(s.id)) {
|
|
3163
3192
|
const h = s.headless
|
|
3164
3193
|
const stop = h && h.state === 'running' && state.mayAnswer !== false ? '<button type="button" class="btn lf-stop" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' : ''
|
|
3165
|
-
return '<section class="live-box full" id="live" aria-label="Live link, full screen" data-id="' + esc(s.id) + '">' + fullBarHtml(s.id, { esc, icon, glyph: glyphFor(s, { cls: 'lf-sg' }), pills: statusPill(s) + liveBadge(s) + mode, stop, retitle: retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id), cls: 'lf-retitle' }) }) +
|
|
3194
|
+
return '<section class="live-box full" id="live" aria-label="Live link, full screen" data-id="' + esc(s.id) + '">' + fullBarHtml(s.id, { esc, icon, glyph: glyphFor(s, { cls: 'lf-sg' }), pills: statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + mode, stop, retitle: retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id), cls: 'lf-retitle' }) }) +
|
|
3166
3195
|
'<nav class="lf-tabs" id="lfTabs" aria-label="Open sessions"' + (lfTabsUi.html ? '' : ' hidden') + '>' + lfTabsUi.html + '</nav>' // 0.9.14: the open tabs stay in full screen (paintLfTabs fills it)
|
|
3167
3196
|
}
|
|
3168
3197
|
return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2>' + mode + '<span class="faint">' + sub + '</span>' + fullButtonHtml(s.id, { esc, icon }) + '</div>'
|
|
@@ -5110,6 +5139,7 @@ function walkValueOf(w) {
|
|
|
5110
5139
|
const s = w.saved || null
|
|
5111
5140
|
const choice = s ? s.choice : w.choice
|
|
5112
5141
|
if (choice === 'agent') return 'agent'
|
|
5142
|
+
if (choice === 'codex') return 'codex' // 0.9.17
|
|
5113
5143
|
if (choice === 'claude') return 'claude:' + ((s && s.model) || 'sonnet')
|
|
5114
5144
|
if (choice === 'ollama') return 'ollama:' + ((s && s.model) || w.model || '')
|
|
5115
5145
|
return 'summarizer'
|
|
@@ -5151,7 +5181,7 @@ function paintWalk() {
|
|
|
5151
5181
|
let h = '', h0 = ''
|
|
5152
5182
|
// first: this session's own agent (recommended where Claude Code is here)
|
|
5153
5183
|
const ag = opt('agent')
|
|
5154
|
-
const fbLine = d.fallback ? 'When a session can’t be forked
|
|
5184
|
+
const fbLine = d.fallback ? 'When a session can’t be forked, ' + d.fallback.label + ' writes it' + (d.codexFound ? '; Codex sessions use Codex, and Codex stands in when Claude is unavailable.' : '.') : ''
|
|
5155
5185
|
h0 = card({ value: 'agent', on: !typed && kind === 'agent', name: ag.name, note: ag.line, suggested: d.claudeFound && d.suggested === 'agent', disabled: !d.claudeFound,
|
|
5156
5186
|
side: d.claudeFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.claudeFound ? (kind === 'agent' && !typed ? fbLine : '') : 'Needs Claude Code on this machine.' })
|
|
5157
5187
|
const cl = opt('claude')
|
|
@@ -5166,6 +5196,10 @@ function paintWalk() {
|
|
|
5166
5196
|
h += h0
|
|
5167
5197
|
h += card({ value: 'claude:' + cm, on: claudeOn, name: cl.name, note: cl.line, suggested: d.claudeFound && d.suggested === 'claude', disabled: !d.claudeFound,
|
|
5168
5198
|
side: d.claudeFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.claudeFound ? '' : 'Install Claude Code on this machine to use it.', extra: seg })
|
|
5199
|
+
// 0.9.17 Codex: read-only codex exec in the session's folder; Codex sessions use it by default, and Claude's stand-in when it's unavailable
|
|
5200
|
+
const cx = opt('codex')
|
|
5201
|
+
h += card({ value: 'codex', on: !typed && kind === 'codex', name: cx.name, note: cx.line, disabled: !d.codexFound,
|
|
5202
|
+
side: d.codexFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.codexFound ? '' : 'Install Codex on this machine to use it.' })
|
|
5169
5203
|
const smo = opt('summarizer')
|
|
5170
5204
|
h += card({ value: 'summarizer', on: !typed && kind === 'summarizer', name: smo.name, note: smo.line, warn: d.summarizerSmall ? 'Small for this job: expect thinner walkthroughs.' : '' })
|
|
5171
5205
|
const ol = d.ollama || {}
|
|
@@ -7171,9 +7205,9 @@ async function startSend(btn) {
|
|
|
7171
7205
|
const box = btn.closest('.start-box')
|
|
7172
7206
|
const ta = box && box.querySelector('.start-text')
|
|
7173
7207
|
const text = ta ? ta.value.trim() : ''
|
|
7174
|
-
if (!text && !attachList(START_SID).length) { if (ta) ta.focus(); return }
|
|
7175
|
-
if (startUi.sending) return
|
|
7176
7208
|
const pick = startPick(startUi.data, startUi)
|
|
7209
|
+
if (!text && pick.agent !== 'codex' && !attachList(START_SID).length) { if (ta) ta.focus(); return } // 0.9.17 Codex may start with no first message
|
|
7210
|
+
if (startUi.sending) return
|
|
7177
7211
|
if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
|
|
7178
7212
|
startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
|
|
7179
7213
|
try {
|
|
@@ -7182,14 +7216,21 @@ async function startSend(btn) {
|
|
|
7182
7216
|
await uploadsSettled(START_SID)
|
|
7183
7217
|
const atts = attachList(START_SID)
|
|
7184
7218
|
if (atts.some((a) => a.status === 'failed')) throw new Error('An attachment didn’t upload. Retry it or remove it.')
|
|
7185
|
-
const r = await api('POST', '/v1/sessions/start', startBody(pick, text, { draft: startUi.draft, atts }), { timeoutMs: 60000 })
|
|
7219
|
+
const r = await api('POST', '/v1/sessions/start', startBody(pick, text, { draft: pick.agent === 'codex' ? null : startUi.draft, atts: pick.agent === 'codex' ? [] : atts }), { timeoutMs: 60000 })
|
|
7186
7220
|
lastStartWrite(pick) // 0.9.13 ⌘K's New session… starts from here next time
|
|
7187
7221
|
if (atts.length) { attachUi.by.delete(START_SID); startUi.draft = null }
|
|
7188
7222
|
const xn = startExtrasNote(pick, r); if (xn) setTimeout(() => toast(xn, 6000), 1200) // 0.9.12: never a silent drop of a project's repo
|
|
7189
7223
|
if (state.drafts) delete state.drafts.start
|
|
7190
7224
|
if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // 0.9.9: a phone's keyboard closes
|
|
7191
|
-
if (r.
|
|
7192
|
-
|
|
7225
|
+
if (r.app && !r.app.ok && r.app.message) setTimeout(() => toast(r.app.message, 7000), 900) // 1.0 My terminal app: it still runs (in the board's terminal)
|
|
7226
|
+
if (r.state === 'started' && r.sessionId) {
|
|
7227
|
+
startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); openStarted(r.sessionId, r.peerVia ? r.machine || pick.m.machine : null, text)
|
|
7228
|
+
if (r.run === 'board' && r.terminal && r.terminal.id && !r.peerVia) setTimeout(() => terminal.show(r.terminal.id).catch(() => {}), 50) // 1.0 Board terminal: shown now
|
|
7229
|
+
return
|
|
7230
|
+
}
|
|
7231
|
+
// 0.9.17 Codex: it runs in the board's terminal (shown now, on this Mac's board), and its session opens once Codex writes it
|
|
7232
|
+
if (r.agent === 'codex' && r.terminal && r.terminal.id && !r.peerVia) { toast('Codex started on ' + (r.machine || pick.m.machine)); if (r.run !== 'app' || !(r.app && r.app.ok)) terminal.show(r.terminal.id).catch(() => {}) }
|
|
7233
|
+
startUi.ask = { ...r, machine: r.machine || pick.m.machine, prompt: text }
|
|
7193
7234
|
pollStartAsk()
|
|
7194
7235
|
} catch (e) { toast(e.message, 6000) } finally { startUi.sending = false }
|
|
7195
7236
|
if (state.route && state.route.name === 'new') render()
|
|
@@ -8471,6 +8512,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8471
8512
|
if (action === 'wt-close') return wtClose()
|
|
8472
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 }
|
|
8473
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 }) }
|
|
8474
8516
|
if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
|
|
8475
8517
|
if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
|
|
8476
8518
|
if (action === 'wt-reviewed') return wtReviewed(wtUi.sid, btn.dataset.step)
|
|
@@ -8561,6 +8603,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8561
8603
|
// 0.9.8 What it did
|
|
8562
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 }
|
|
8563
8605
|
if (action === 'wid-refresh') return loadWid(id)
|
|
8606
|
+
if (action === 'wid-files') return setFileScope(btn.dataset.files, { from: 'wid', id })
|
|
8564
8607
|
if (action === 'wid-tool') {
|
|
8565
8608
|
const ui = widOf(id), t = btn.dataset.tool
|
|
8566
8609
|
ui.toolOpen[t] = !ui.toolOpen[t]; paintWid(id)
|
|
@@ -8742,7 +8785,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8742
8785
|
// 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
|
|
8743
8786
|
if (action === 'start-pick') {
|
|
8744
8787
|
const k = btn.dataset.k, v = btn.dataset.v
|
|
8745
|
-
if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null; Object.assign(startUi, { extra: [], adding: false, saving: false, find: '' }); if (attachList(START_SID).length) setTimeout(startReupload, 0) /* 0.9.13: its files go to that Mac */ } else if (k === 'project') { startUi.project = v || null; startUi.repo = null; startUi.drop = []; startUi.custom = null; startUi.browse = null; Object.assign(startUi, { extra: [], adding: false, saving: false }) } else if (k === 'repo') { startUi.repo = v; startUi.extra = startUi.extra.filter((x) => x !== v) } else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v
|
|
8788
|
+
if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null; Object.assign(startUi, { extra: [], adding: false, saving: false, find: '' }); if (attachList(START_SID).length) setTimeout(startReupload, 0) /* 0.9.13: its files go to that Mac */ } else if (k === 'project') { startUi.project = v || null; startUi.repo = null; startUi.drop = []; startUi.custom = null; startUi.browse = null; Object.assign(startUi, { extra: [], adding: false, saving: false }) } else if (k === 'repo') { startUi.repo = v; startUi.extra = startUi.extra.filter((x) => x !== v) } else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v; else if (k === 'agent') startUi.agent = v === 'codex' ? 'codex' : 'claude' /* 0.9.17 */; else if (k === 'run') startUi.run = ['headless', 'board', 'app'].includes(v) ? v : null // 1.0
|
|
8746
8789
|
return render()
|
|
8747
8790
|
}
|
|
8748
8791
|
// 0.9.16 + Add another repo, and Save these as a project…
|
|
@@ -8821,6 +8864,21 @@ document.addEventListener('click', async (ev) => {
|
|
|
8821
8864
|
return render()
|
|
8822
8865
|
}
|
|
8823
8866
|
if (action === 'ask-auto') return autoRestClick(btn)
|
|
8867
|
+
// 1.0 Continue in terminal / Continue headless (src/run-switch.js)
|
|
8868
|
+
if (action === 'run-continue') {
|
|
8869
|
+
const run = btn.dataset.run === 'headless' ? 'headless' : 'board'
|
|
8870
|
+
if (!id || hlPending.has(id) || switchPending.has(id)) return
|
|
8871
|
+
switchPending.add(id); btn.disabled = true
|
|
8872
|
+
toast(run === 'headless' ? 'Ending it in the terminal, then continuing headless…' : 'Continuing in a terminal…', 3000)
|
|
8873
|
+
try {
|
|
8874
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/continue', { run }, { timeoutMs: 45000 })
|
|
8875
|
+
toast(run === 'headless' ? 'Continuing headless: talk to it here' : 'In the terminal now', 3500)
|
|
8876
|
+
if (r && r.terminal && r.terminal.id && run !== 'headless') terminal.show(r.terminal.id)
|
|
8877
|
+
} catch (e) { toast(e.message, 6000) }
|
|
8878
|
+
switchPending.delete(id)
|
|
8879
|
+
repaintSession(id)
|
|
8880
|
+
return render()
|
|
8881
|
+
}
|
|
8824
8882
|
if (action === 'headless-stop' || action === 'headless-run') {
|
|
8825
8883
|
const op = action === 'headless-stop' ? 'stop' : 'resume'
|
|
8826
8884
|
if (!id || hlPending.has(id)) return
|
|
@@ -9721,6 +9779,9 @@ async function needDismissClick(btn) {
|
|
|
9721
9779
|
|
|
9722
9780
|
// Go: "Switching…" at once, a note if it's slow, the result (with timings when slow) after.
|
|
9723
9781
|
function goClick(btn, id, s) {
|
|
9782
|
+
// 0.9.17 a session in the board's own terminal (a Codex start): show that terminal, here, on any device
|
|
9783
|
+
// 1.0 unless a terminal app is attached to it (leftoff attach): then Go brings that window forward on its Mac
|
|
9784
|
+
if (s && s.isLocal !== false && s.terminal && s.terminal.kind === 'leftoff' && s.terminal.termId && !(s.terminal.apps > 0 && state.viewerLocal !== false)) return terminal.show(s.terminal.termId).then((ok) => { if (!ok) toast('Its terminal in Leftoff isn’t running any more', 4000) })
|
|
9724
9785
|
return goWithFeedback({
|
|
9725
9786
|
id,
|
|
9726
9787
|
title: s && s.title,
|
package/ui/ask.js
CHANGED
|
@@ -94,6 +94,7 @@ export function byLabel(t, { machine = null } = {}) {
|
|
|
94
94
|
if (!by) return ''
|
|
95
95
|
const on = machine ? ' on ' + machine : ''
|
|
96
96
|
if (by.engine === 'claude') return 'Claude Code (' + (by.model || 'sonnet') + ', read-only)' + on + (by.readRepo === false ? ', from your notes only' : '')
|
|
97
|
+
if (by.engine === 'codex') return 'Codex (read-only)' + (by.unavailable ? ' (Claude unavailable: ' + by.unavailable + ')' : '') + on + (by.readRepo === false ? ', from your notes only' : '') // 0.9.17
|
|
97
98
|
if (by.engine === 'summary') return 'From your notes (no repo reading), by ' + (by.model || by.provider || 'your summary model') + on
|
|
98
99
|
if (by.engine === 'findings') return 'What I found (no model answered)' + on
|
|
99
100
|
if (by.engine === 'empty') return ''
|
package/ui/live-view.js
CHANGED
|
@@ -20,7 +20,7 @@ export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.en
|
|
|
20
20
|
// a thread while it is live-linked and the how-to otherwise.
|
|
21
21
|
// -> 'none' | 'thread' | 'readonly' (a stopped headless one: no composer) | 'how'
|
|
22
22
|
export function liveBoxKind(s) {
|
|
23
|
-
if (!s || s.agent || s.codingAgent === 'codex') return 'none'
|
|
23
|
+
if (!s || s.agent || (s.codingAgent === 'codex' && !s.headless)) return 'none' // 1.0: headless Codex has Live
|
|
24
24
|
if (s.capture === 'hooks' && !s.headless) return 'none'
|
|
25
25
|
if (s.liveLink && s.liveLink.on) return 'thread'
|
|
26
26
|
if (s.headless) return 'thread' // 0.9.13: not running (Paused, Stopped): a message resumes it
|
|
@@ -123,7 +123,7 @@ const LIVE_TIP = 'Live link: send this session messages from the board or your p
|
|
|
123
123
|
export function liveBadgeHtml(s, now = Date.now()) {
|
|
124
124
|
if (!cardPills(s).live) return ''
|
|
125
125
|
const w = liveWorking(s, now)
|
|
126
|
-
return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? 'Claude is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
|
|
126
|
+
return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? (s.codingAgent === 'codex' ? 'Codex' : 'Claude') + ' is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
// The board redraws a card (innerHTML) on every update, which would restart a
|
|
@@ -503,7 +503,7 @@ export function pendingClaudeHtml(you, { esc, ago, now = Date.now(), lvActs = nu
|
|
|
503
503
|
if (st.phase === 'stopped') inner = (st.why && st.why.label ? whyHtml(st.why, { esc }) : '<div class="lv-text lv-none">' + esc(st.lost ? 'No reply recorded' : 'Stopped without a reply') + '</div>') + acts + toolsHtml(st, { esc, now })
|
|
504
504
|
else if (st.phase === 'replied') inner = '<div class="lv-text lv-none">Replied in the terminal</div>' + toolsHtml(st, { esc, now }) // settled, its words not kept: never a Working bubble
|
|
505
505
|
else inner = workingHtml(st, { esc, now })
|
|
506
|
-
return '<li class="lv lv-claude lv-pending' + (FINAL_PHASES.has(st.phase) ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st) + (acts ? '|a' + actsSig(lvActs) : ''))) + '"><div class="lv-who">Claude ' + timeHtml(st.last ? new Date(st.last).toISOString() : you.at, { esc, now }) + '</div>' + inner + '</li>'
|
|
506
|
+
return '<li class="lv lv-claude lv-pending' + (FINAL_PHASES.has(st.phase) ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st) + (acts ? '|a' + actsSig(lvActs) : ''))) + '"><div class="lv-who">' + esc(you.answerBy || 'Claude') + ' ' + timeHtml(st.last ? new Date(st.last).toISOString() : you.at, { esc, now }) + '</div>' + inner + '</li>'
|
|
507
507
|
}
|
|
508
508
|
|
|
509
509
|
// The whole thread: each message you sent, then Claude's side of that turn.
|