@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
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,13 +374,14 @@ 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
|
-
if (ok) h += '<p class="faint wt-by">Written by ' + (data.modelLabel ? esc(data.modelLabel) : 'your summarizer') + (when ? ' · ' + esc(when) : '') +
|
|
380
|
-
(data.path === 'fallback' ? ' · <span class="wt-path" tabindex="0" data-tip="' + esc('Not the session’s agent: ' + (data.pathWhy || 'it couldn’t be forked here')) + '">not the session’s agent</span>' : '') + '</p>'
|
|
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) : '') +
|
|
384
|
+
(data.path === 'fallback' && !data.unavailable ? ' · <span class="wt-path" tabindex="0" data-tip="' + esc('Not the session’s agent: ' + (data.pathWhy || 'it couldn’t be forked here')) + '">not the session’s agent</span>' : '') + '</p>'
|
|
381
385
|
// 2026-10-30 a kept walkthrough for a diff that has moved on: said so, with Update (never shown as current)
|
|
382
386
|
if (ok && data.stale) h += staleBarHtml(data.stale, { esc, icon, sid, loading: ui.loading })
|
|
383
387
|
// nothing new since the last checkpoint: the whole session instead, and a quiet word saying so
|
|
@@ -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/start.js
CHANGED
|
@@ -51,8 +51,36 @@ export function startPick(data, ui = {}) {
|
|
|
51
51
|
const where = ui.where === 'worktree' && !(repo && repo.git === false) ? 'worktree' : 'repo'
|
|
52
52
|
// Permissions: what you picked, else what this board last used for that Mac (kept on the Mac you're connected to), else Ask me
|
|
53
53
|
const mode = START_MODES.some((x) => x.v === ui.mode) ? ui.mode : m && START_MODES.some((x) => x.v === m.lastMode) ? m.lastMode : 'default'
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// 0.9.17 Claude Code or Codex: what you picked (when that Mac has both), else what it used last, else the one it has
|
|
55
|
+
const ag = m && m.agents ? m.agents : null
|
|
56
|
+
const agent = !ag ? 'claude' : ag.choice && (ui.agent === 'claude' || ui.agent === 'codex') ? ui.agent : ag.last || (ag.claude ? 'claude' : ag.codex ? 'codex' : 'claude')
|
|
57
|
+
// 1.0 Mode: what you picked (when it's possible for that agent there), else what that Mac used last for the agent, else how Start ran it before
|
|
58
|
+
const rs = m && m.runs ? m.runs : null
|
|
59
|
+
const okRun = (r) => !!(rs && rs.options && rs.options[agent] && rs.options[agent][r] && rs.options[agent][r].ok)
|
|
60
|
+
const dflt = agent === 'codex' ? 'board' : 'headless'
|
|
61
|
+
const lastRun = rs && rs.last ? rs.last[agent] : null
|
|
62
|
+
const run = !rs ? dflt : START_RUNS.some((x) => x.v === ui.run) && okRun(ui.run) ? ui.run : okRun(lastRun) ? lastRun : okRun(dflt) ? dflt : (START_RUNS.find((x) => okRun(x.v)) || { v: dflt }).v
|
|
63
|
+
return { m, repo, where, mode, project, also, dropped, missing, agent, agents: ag, run, runs: rs }
|
|
64
|
+
}
|
|
65
|
+
// 1.0 where the agent runs (src/start-run.js)
|
|
66
|
+
export const START_RUNS = Object.freeze([
|
|
67
|
+
{ v: 'headless', label: 'Headless', sub: 'chat in Live, from any device', glyph: '◇' },
|
|
68
|
+
{ v: 'board', label: 'Board terminal', sub: 'a terminal here and on your phone', glyph: '▭' },
|
|
69
|
+
{ v: 'app', label: 'My terminal app', sub: 'Ghostty, iTerm or Terminal', glyph: '▣' },
|
|
70
|
+
])
|
|
71
|
+
export function startRunIntro(agent, run) {
|
|
72
|
+
const AG = agent === 'codex' ? 'Codex' : 'Claude Code'
|
|
73
|
+
if (run === 'board') return 'Leftoff starts ' + AG + ' in a terminal it keeps on the Mac you pick. It shows in the board and on your phone, and it keeps running when you close the tab.'
|
|
74
|
+
if (run === 'app') return 'Leftoff starts ' + AG + ' in a terminal it keeps on the Mac you pick, and opens it in your terminal app. Closing that window doesn’t stop it: Open in Terminal (or leftoff attach) brings it back.'
|
|
75
|
+
return 'Leftoff starts ' + AG + ' on the Mac you pick, with no terminal. It shows on every board right away: talk to it in Live, and approve its edits and commands in Needs you, from any device.'
|
|
76
|
+
}
|
|
77
|
+
export const AGENT_NAMES = Object.freeze({ claude: 'Claude Code', codex: 'Codex' })
|
|
78
|
+
// 0.9.17 the Permissions cards in Codex's words (src/codex-start.js CODEX_MODES_TEXT)
|
|
79
|
+
export const CODEX_MODE_SUBS = Object.freeze({
|
|
80
|
+
default: { sub: 'Codex’s own settings', text: 'Codex runs with your own Codex settings: it asks before anything its sandbox doesn’t allow. You answer in its terminal.' },
|
|
81
|
+
acceptEdits: { sub: 'edits in the folder, asks for the rest', text: 'Codex may edit files and run commands inside the folder (its workspace-write sandbox); anything outside it asks first, in its terminal.' },
|
|
82
|
+
auto: { sub: 'no asks, sandboxed to the folder', text: 'Codex never stops to ask: commands run inside the folder’s sandbox, and anything the sandbox blocks fails back to Codex instead of asking you.' },
|
|
83
|
+
})
|
|
56
84
|
export const ADD_DIRS_MAX = 8 // src/claude-headless.js
|
|
57
85
|
|
|
58
86
|
// The body New session posts (src/session-start.js): a project start carries
|
|
@@ -60,6 +88,8 @@ export const ADD_DIRS_MAX = 8 // src/claude-headless.js
|
|
|
60
88
|
// added (0.9.16) carries just those folders.
|
|
61
89
|
export function startBody(pick, prompt, { draft = null, atts = [] } = {}) {
|
|
62
90
|
const b = { machine: pick.m.machine, cwd: pick.repo.path, prompt, where: pick.where, mode: pick.mode }
|
|
91
|
+
if (pick.agents) b.agent = pick.agent // 0.9.17 (a Mac that didn't say which it has gets none: Claude Code, as before)
|
|
92
|
+
if (pick.runs) b.run = pick.run // 1.0 (an older Mac gets none: the way it always started)
|
|
63
93
|
if (pick.project) Object.assign(b, { project: pick.project.id, addDirs: pick.also.map((r) => r.path) })
|
|
64
94
|
else if (pick.also && pick.also.length) b.addDirs = pick.also.map((r) => r.path) // 0.9.16 + Add another repo
|
|
65
95
|
const ids = (atts || []).filter((a) => a && a.id && a.status === 'done').map((a) => a.id) // 0.9.13
|
|
@@ -127,6 +157,17 @@ export function modePillHtml(s, { esc, seen = null }) {
|
|
|
127
157
|
return ' <span class="mode-pill m-' + esc(m) + '" data-tip="' + esc('Permissions: ' + modeLabel(m) + (h.state === 'running' ? '' : ' (when it runs again)')) + '">' + esc(modeLabel(m)) + '</span>'
|
|
128
158
|
}
|
|
129
159
|
|
|
160
|
+
// 1.0 A session in one of Leftoff's terminals: where it runs, by shape and
|
|
161
|
+
// words. ▣ a terminal app is attached; ◌ running · detached (nobody looking;
|
|
162
|
+
// it keeps running, Go or Open in Terminal brings it back); nothing otherwise.
|
|
163
|
+
export function termPillHtml(s, { esc }) {
|
|
164
|
+
const t = s && s.terminal
|
|
165
|
+
if (!t || t.kind !== 'leftoff' || !t.termId || s.endedAt) return ''
|
|
166
|
+
if (t.apps > 0) return ' <span class="term-pill t-app" data-tip="' + esc('Open in a terminal app (leftoff attach). Closing that window leaves it running.') + '"><span aria-hidden="true">▣</span> in a terminal app</span>'
|
|
167
|
+
if (t.detached) return ' <span class="term-pill t-detached" data-tip="' + esc('Its terminal is running with no window on it. Go shows it here; Open in Terminal attaches a window. Only Stop or Exit ends it.') + '"><span aria-hidden="true">◌</span> running · detached</span>'
|
|
168
|
+
return ''
|
|
169
|
+
}
|
|
170
|
+
|
|
130
171
|
// Is a path inside one of a Mac's approved folders (its real paths)?
|
|
131
172
|
export function underApproved(p, approved) {
|
|
132
173
|
const x = String(p || '')
|
|
@@ -160,8 +201,8 @@ const ago = (iso, now) => {
|
|
|
160
201
|
return Math.round(s / 86400) + 'd ago'
|
|
161
202
|
}
|
|
162
203
|
|
|
163
|
-
function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
|
|
164
|
-
return '<button type="button" class="start-opt" role="radio" aria-checked="' + on + '" data-action="start-pick" data-k="' + esc(k) + '" data-v="' + esc(v) + '"' + (disabled ? ' disabled' : '') + '>' +
|
|
204
|
+
function opt({ k, v, label, sub = '', on = false, disabled = false, tip = '', esc }) {
|
|
205
|
+
return '<button type="button" class="start-opt" role="radio" aria-checked="' + on + '" data-action="start-pick" data-k="' + esc(k) + '" data-v="' + esc(v) + '"' + (disabled ? ' disabled' : '') + (tip ? ' data-tip="' + esc(tip) + '"' : '') + '>' +
|
|
165
206
|
'<span class="start-opt-main">' + label + '</span>' + (sub ? '<span class="start-opt-sub">' + sub + '</span>' : '') + '</button>'
|
|
166
207
|
}
|
|
167
208
|
|
|
@@ -169,6 +210,7 @@ function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
|
|
|
169
210
|
export function startAskLine(ask, { clock = (x) => x } = {}) {
|
|
170
211
|
if (!ask) return null
|
|
171
212
|
if (ask.state === 'pending') return { tone: 'wait', text: 'Waiting for a yes on ' + ask.machine + ' (until ' + clock(ask.expiresAt) + '). It shows on that Mac’s board and as a notification.' + (ask.firstTime ? ' A first time in a folder asks there, unless it’s inside a folder approved there.' : '') }
|
|
213
|
+
if (ask.state === 'starting' && ask.agent === 'codex') return { tone: 'wait', text: 'Codex is running in a terminal on ' + ask.machine + '. Its session opens here as soon as Codex writes it (after its first message).' }
|
|
172
214
|
if (ask.state === 'starting') return { tone: 'wait', text: 'Starting on ' + ask.machine + '…' }
|
|
173
215
|
if (ask.state === 'declined') return { tone: 'bad', text: 'Declined on ' + ask.machine + '.' }
|
|
174
216
|
if (ask.state === 'expired') return { tone: 'bad', text: 'Nobody confirmed it on ' + ask.machine + ' in time.' }
|
|
@@ -179,8 +221,10 @@ export function startAskLine(ask, { clock = (x) => x } = {}) {
|
|
|
179
221
|
export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', local = true, now = Date.now(), clock = (x) => x, draft = '', busy = false, atts = null, chipHtml = null, mayProject = true } = {}) {
|
|
180
222
|
const pick = startPick(data, ui)
|
|
181
223
|
const rule = startRule(data, pick, { local })
|
|
224
|
+
const codex = pick.agent === 'codex'
|
|
225
|
+
const AG = AGENT_NAMES[pick.agent] || 'Claude Code'
|
|
182
226
|
let h = '<a class="back" href="#/">← All sessions</a>' +
|
|
183
|
-
'<div class="pagehead"><div><h1>New session</h1><div class="sub">
|
|
227
|
+
'<div class="pagehead"><div><h1>New session</h1><div class="sub">' + esc(startRunIntro(pick.agent, pick.run)) + '</div></div></div>'
|
|
184
228
|
h += '<div class="start" data-start>'
|
|
185
229
|
// Mac
|
|
186
230
|
const ms = (data && data.machines) || []
|
|
@@ -188,6 +232,20 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
|
|
|
188
232
|
ms.map((x) => opt({ k: 'machine', v: x.machine, on: !!pick.m && x.machine === pick.m.machine, disabled: !x.online, esc,
|
|
189
233
|
label: esc((emoji(x.machine) ? emoji(x.machine) + ' ' : '') + x.machine), sub: esc(x.here ? (local ? 'this Mac' : 'the Mac you’re connected to') : x.online ? 'online' : 'offline') })).join('') +
|
|
190
234
|
'</div></section>'
|
|
235
|
+
// Agent (0.9.17): only when that Mac has both
|
|
236
|
+
if (pick.agents && pick.agents.choice) {
|
|
237
|
+
h += '<section class="start-sec start-agent"><h2 class="start-h">Agent</h2><div class="start-opts" role="radiogroup" aria-label="Agent">' +
|
|
238
|
+
opt({ k: 'agent', v: 'claude', on: !codex, esc, label: 'Claude Code', sub: 'Anthropic' }) +
|
|
239
|
+
opt({ k: 'agent', v: 'codex', on: codex, esc, label: 'Codex', sub: 'OpenAI' }) +
|
|
240
|
+
'</div></section>'
|
|
241
|
+
}
|
|
242
|
+
// Mode (1.0): headless, the board's terminal, or your terminal app; the ones that Mac can't do say why
|
|
243
|
+
if (pick.runs && pick.runs.options) {
|
|
244
|
+
const o = pick.runs.options[pick.agent] || {}
|
|
245
|
+
h += '<section class="start-sec start-run"><h2 class="start-h">Mode</h2><div class="start-opts" role="radiogroup" aria-label="Mode">' +
|
|
246
|
+
START_RUNS.map((x) => { const can = o[x.v] || { ok: true }; return opt({ k: 'run', v: x.v, on: x.v === pick.run, disabled: !can.ok, tip: can.ok ? '' : can.why || '', esc, label: '<span class="run-glyph" aria-hidden="true">' + x.glyph + '</span> ' + esc(x.label), sub: esc(can.ok ? x.sub : 'not on this Mac') }) }).join('') +
|
|
247
|
+
'</div></section>'
|
|
248
|
+
}
|
|
191
249
|
// Project (0.9.12): only when there are projects
|
|
192
250
|
const projects = (pick.m && pick.m.projects) || []
|
|
193
251
|
if (projects.length) {
|
|
@@ -228,17 +286,17 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
|
|
|
228
286
|
// Where
|
|
229
287
|
h += '<section class="start-sec"><h2 class="start-h">Where</h2><div class="start-opts start-where" role="radiogroup" aria-label="Where">' +
|
|
230
288
|
opt({ k: 'where', v: 'repo', on: pick.where === 'repo', esc, label: 'In the repo', sub: 'on its current branch' }) +
|
|
231
|
-
opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: pick.repo && pick.repo.git === false ? 'needs a git repo' : 'its own branch and folder', disabled: !!(pick.repo && pick.repo.git === false) }) +
|
|
289
|
+
opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: pick.repo && pick.repo.git === false ? 'needs a git repo' : codex ? 'Codex’s own worktree' : 'its own branch and folder', disabled: !!(pick.repo && pick.repo.git === false) }) +
|
|
232
290
|
'</div></section>'
|
|
233
291
|
// Permissions (0.9.9)
|
|
234
292
|
const mo = START_MODES.find((x) => x.v === pick.mode) || START_MODES[0]
|
|
235
|
-
const moreText = mo.text + ' ' + TERMINAL_MODE_HINT // behind the (?): the cards' own lines say enough
|
|
293
|
+
const moreText = codex ? (CODEX_MODE_SUBS[mo.v] || CODEX_MODE_SUBS.default).text : mo.text + ' ' + TERMINAL_MODE_HINT // behind the (?): the cards' own lines say enough
|
|
236
294
|
h += '<section class="start-sec"><h2 class="start-h start-h-help">Permissions' + helpQHtml({ id: 'start-mode-help', label: 'About Permissions', text: moreText, open: !!ui.helpOpen }, { esc }) + '</h2><div class="start-opts start-mode" role="radiogroup" aria-label="Permissions">' +
|
|
237
|
-
START_MODES.map((x) => opt({ k: 'mode', v: x.v, on: x.v === mo.v, esc, label: esc(x.label), sub: esc(x.sub) })).join('') +
|
|
295
|
+
START_MODES.map((x) => opt({ k: 'mode', v: x.v, on: x.v === mo.v, esc, label: esc(x.label), sub: esc(codex ? CODEX_MODE_SUBS[x.v].sub : x.sub) })).join('') +
|
|
238
296
|
'</div>' + helpMoreHtml({ id: 'start-mode-help', text: moreText, open: !!ui.helpOpen, cls: 'start-hint start-mode-hint' }, { esc }) + '</section>'
|
|
239
297
|
// First message
|
|
240
298
|
h += '<section class="start-sec"><h2 class="start-h" id="start-msg-h">First message</h2><div class="start-box">' +
|
|
241
|
-
composerBoxHtml({ submit: 'start', sendAction: 'start-send', draftKey: 'start', draft, ...(Array.isArray(atts) ? { attach: true, atts, chipHtml, sid: START_SID } : {}), may: rule.ok && !ui.ask, busy, placeholder: 'What should Claude do?', label: 'First message', inputClass: 'start-text', keys: 'Enter Meta+Enter Control+Enter' }) +
|
|
299
|
+
composerBoxHtml({ submit: 'start', sendAction: 'start-send', draftKey: 'start', draft, ...(Array.isArray(atts) && !codex ? { attach: true, atts, chipHtml, sid: START_SID } : {}), may: rule.ok && !(ui.ask && ui.ask.state !== 'starting'), busy, placeholder: codex ? 'What should Codex do? (or leave it empty)' : 'What should Claude do?', label: 'First message', inputClass: 'start-text', keys: 'Enter Meta+Enter Control+Enter' }) +
|
|
242
300
|
'</div><p class="start-rule' + (rule.ok ? '' : ' bad') + '" role="status">' + esc(rule.text) + '</p>'
|
|
243
301
|
const line = startAskLine(ui.ask, { clock })
|
|
244
302
|
if (line) h += '<div class="start-ask ' + line.tone + '" role="status">' + esc(line.text) + (ui.ask && ['declined', 'expired', 'failed'].includes(ui.ask.state) ? ' <button type="button" class="btn" data-action="start-again">Try again</button>' : '') + '</div>'
|
|
@@ -458,20 +516,37 @@ export function headlessBarHtml(s, { esc, icon, may = true, busy = null, local =
|
|
|
458
516
|
if (!h) return ''
|
|
459
517
|
const from = s.startedFrom && s.startedFrom.device ? ' from ' + s.startedFrom.device : ''
|
|
460
518
|
const where = s.machine ? ' on ' + s.machine : ''
|
|
519
|
+
const agent = h.agent === 'codex' || s.codingAgent === 'codex' ? 'Codex' : 'Claude Code'
|
|
520
|
+
// 1.0 the same conversation in a terminal: Leftoff's own (a terminal app attaches to it on the Mac itself)
|
|
521
|
+
const cont = may && h.state !== 'stopping' && h.state !== 'resuming' ? continueButtonHtml(s, 'board', { esc }) : ''
|
|
461
522
|
let text, btn = ''
|
|
462
|
-
if (h.state === 'running') { text = 'Running' + where + ', started' + from + ' by Leftoff' + (h.awake ? ' · keeping it awake' : ''); if (may) btn = '<button type="button" class="btn" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' }
|
|
523
|
+
if (h.state === 'running') { text = 'Running' + where + ', started' + from + ' by Leftoff' + (h.awake ? ' · keeping it awake' : ''); if (may) btn = cont + '<button type="button" class="btn" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' }
|
|
463
524
|
else if (h.state === 'stopping') text = 'Stopping' + where + '…'
|
|
464
|
-
else if (h.state === 'resuming') text = 'Starting
|
|
525
|
+
else if (h.state === 'resuming') text = 'Starting ' + agent + where + '…' // 0.9.9: Resume on its way
|
|
465
526
|
else {
|
|
466
527
|
// 0.9.13: Paused (its process went: a restart, an exit) is still open; a message resumes it, as does Stopped
|
|
467
528
|
text = (h.error ? 'Claude Code stopped' + where : h.state === 'paused' ? 'Paused' + where + (h.pausedWhy && h.pausedWhy !== 'restart' ? '' : ' (Leftoff restarted)') : 'Stopped' + where) + (from ? ', started' + from : '') + ' · a message picks it up'
|
|
468
|
-
if (may) btn = '<button type="button" class="btn primary" data-action="headless-run" data-id="' + esc(s.id) + '">' + icon('play') + 'Resume here</button>'
|
|
529
|
+
if (may) btn = cont + '<button type="button" class="btn primary" data-action="headless-run" data-id="' + esc(s.id) + '">' + icon('play') + 'Resume here</button>'
|
|
469
530
|
}
|
|
470
531
|
return '<div class="headless-bar ' + esc(h.state) + '" data-sid="' + esc(s.id) + '"><span class="grow">' + icon('play') + esc(text) + '</span>' + btn + '</div>' +
|
|
471
532
|
(h.error && h.state !== 'running' ? '<pre class="headless-err" data-headless-err="' + esc(s.id) + '"></pre>' : '') +
|
|
472
533
|
(h.state !== 'stopping' && h.state !== 'resuming' ? headlessModeHtml(s, { esc, may, busy, local, helpOpen }) : '')
|
|
473
534
|
}
|
|
474
535
|
|
|
536
|
+
// 1.0 Continue in terminal / Continue headless: one button, the same conversation the other way
|
|
537
|
+
export function continueButtonHtml(s, run, { esc }) {
|
|
538
|
+
const label = run === 'headless' ? 'Continue headless' : 'Continue in terminal'
|
|
539
|
+
const tip = run === 'headless' ? 'Ends it in the terminal, then picks the same conversation up headless (Live). Only when it’s idle.' : 'Ends the headless process, then picks the same conversation up in a terminal of Leftoff’s. Only when it’s idle.'
|
|
540
|
+
return '<button type="button" class="btn run-continue" data-action="run-continue" data-run="' + esc(run) + '" data-id="' + esc(s.id) + '" data-tip="' + esc(tip) + '"><span class="run-glyph" aria-hidden="true">' + (run === 'headless' ? '◇' : '▭') + '</span>' + esc(label) + '</button>'
|
|
541
|
+
}
|
|
542
|
+
// The same for a session in Leftoff's own terminal (not headless): where it runs, and the way to headless
|
|
543
|
+
export function termBarHtml(s, { esc, may = true } = {}) {
|
|
544
|
+
const t = s && s.terminal
|
|
545
|
+
if (!s || s.headless || s.endedAt || !t || t.kind !== 'leftoff' || !t.termId) return ''
|
|
546
|
+
const where = t.apps > 0 ? 'In a terminal app' : t.detached ? 'Running · detached' : 'In Leftoff’s terminal'
|
|
547
|
+
return '<div class="headless-bar term-bar" data-sid="' + esc(s.id) + '"><span class="grow"><span class="run-glyph" aria-hidden="true">' + (t.apps > 0 ? '▣' : t.detached ? '◌' : '▭') + '</span>' + esc(where + (s.machine ? ' on ' + s.machine : '')) + '</span>' + (may ? continueButtonHtml(s, 'headless', { esc }) : '') + '</div>'
|
|
548
|
+
}
|
|
549
|
+
|
|
475
550
|
// The session page's Permissions switch for a session Leftoff runs: the same
|
|
476
551
|
// three as New session. Running, it switches Claude Code mid-session; stopped,
|
|
477
552
|
// it's the mode Resume starts it in.
|
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
|
|