@leftoff/cli 0.9.17 → 0.9.18-beta.10
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 +56 -2
- package/bin/leftoff-statusline.js +9 -0
- package/bin/leftoff.js +794 -540
- package/bin/pty-host-main.js +66 -0
- package/docs/macos-permissions.md +52 -0
- package/docs/test-plan.md +43 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +172 -12
- package/mod/hooks/register.js +186 -5
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +240 -2
- package/ui/app.js +553 -62
- package/ui/ask.js +6 -3
- package/ui/cp-fold.js +22 -0
- package/ui/decisions.js +235 -0
- package/ui/hook-fix.js +109 -0
- package/ui/live-status.js +68 -2
- package/ui/live-view.js +23 -6
- package/ui/prs.js +15 -5
- package/ui/semdiff.js +165 -22
- package/ui/session-catchup.js +18 -2
- package/ui/session-title.js +88 -0
- package/ui/start.js +87 -12
- package/ui/summary-examples.js +2 -2
- package/ui/term.js +11 -3
- package/ui/testplan.js +162 -0
- package/ui/viewport-fit.js +59 -0
- package/ui/walk-pop.js +3 -0
- package/ui/what-it-did.js +12 -5
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// 2026-11-05 session titles: one helper for the name a session goes by
|
|
2
|
+
// everywhere (session header, full screen's bar, cards, tabs, pickers).
|
|
3
|
+
// Line one says what the session is doing; where it runs (folder@branch) is a
|
|
4
|
+
// muted second line, never glued to the front of the name. Pure and
|
|
5
|
+
// deterministic (no model call): a prompt used as a name is tidied the way a
|
|
6
|
+
// person would write it as a title.
|
|
7
|
+
|
|
8
|
+
export const TITLE_MAX = 60
|
|
9
|
+
|
|
10
|
+
// "cube_cloud@me/some-branch · Please check…": where it runs, glued to the front
|
|
11
|
+
// of a name by whatever named it. The folder and branch have a line of their own.
|
|
12
|
+
const WHERE_GLUED = /^\s*[\w.~-]+@[\w./~+-]+\s*(?:·|•|\||—|–|:|-)\s+/
|
|
13
|
+
// Words in front of a request that a title doesn't need.
|
|
14
|
+
const FILLER = /^(?:(?:please|pls|plz|hey|hi|hello|yo|ok(?:ay)?|so|alright|now)\b[\s,!.:;-]*|(?:can|could|would|will) (?:you|u)(?: please| pls)?\s+|(?:i(?:'d| would) like you to|i (?:want|need) you to)\s+)/i
|
|
15
|
+
// A title doesn't end on a word that leads into the next one.
|
|
16
|
+
const DANGLING = /[\s,;:–—-]+(?:and|or|but|nor|the|a|an|to|of|for|with|without|in|on|at|from|by|into|onto|about|then|so|that|this|these|those|is|are|was|be|as|if|when|while|because|my|your|our|its|&|\+)$/i
|
|
17
|
+
const URL_RE = /\bhttps?:\/\/[^\s<>()]+/gi
|
|
18
|
+
const LONG_PATH = /(^|\s)(?:~|\.{1,2})?(?:\/[\w.@+-]+){3,}\/?/g
|
|
19
|
+
|
|
20
|
+
const repoOf = (s) => (s && (s.repoLabel || s.repoName || (s.repo && s.repo.name))) || ''
|
|
21
|
+
const branchOf = (s) => (s && (s.branch || (s.repo && s.repo.branch))) || ''
|
|
22
|
+
|
|
23
|
+
// Where a session runs, as one short line: "folder@branch" (the folder alone
|
|
24
|
+
// when the branch says the same or there is none). '' when unknown.
|
|
25
|
+
export function whereLine(s, repo = repoOf(s)) {
|
|
26
|
+
const b = branchOf(s)
|
|
27
|
+
if (!repo) return b || ''
|
|
28
|
+
return b && b !== repo ? repo + '@' + b : repo
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A pasted link, as the few words it stands for: a GitHub pull request or issue
|
|
32
|
+
// is "PR 408" / "issue 12", anything else its host.
|
|
33
|
+
function urlWords(u) {
|
|
34
|
+
const m = u.match(/^https?:\/\/(?:www\.)?github\.com\/[^/\s]+\/[^/\s]+\/(pull|issues)\/(\d+)/i)
|
|
35
|
+
if (m) return (m[1].toLowerCase() === 'pull' ? 'PR ' : 'issue ') + m[2]
|
|
36
|
+
try { return new URL(u).host.replace(/^www\./, '') } catch { return '' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Cut at a word boundary: never mid-word, never on a dangling "and"/"the"/"to".
|
|
40
|
+
// Text with no spaces to cut at (CJK, one long token) is cut by character.
|
|
41
|
+
export function cutTitle(text, max = TITLE_MAX) {
|
|
42
|
+
let t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim()
|
|
43
|
+
let cut = false
|
|
44
|
+
if (t.length > max) {
|
|
45
|
+
const room = t.slice(0, max)
|
|
46
|
+
const sp = room.lastIndexOf(' ')
|
|
47
|
+
t = /\s/.test(t[max]) ? room : sp > 0 ? room.slice(0, sp) : t.slice(0, max - 1)
|
|
48
|
+
cut = true
|
|
49
|
+
}
|
|
50
|
+
for (let i = 0; i < 4; i++) {
|
|
51
|
+
const before = t
|
|
52
|
+
t = t.replace(DANGLING, '').replace(/[\s,;:(\[{–—-]+$/, '')
|
|
53
|
+
if (t === before) break
|
|
54
|
+
cut = true
|
|
55
|
+
}
|
|
56
|
+
if (!t) return ''
|
|
57
|
+
return cut ? t.replace(/[.…]+$/, '') + '…' : t
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A first prompt as a title: its first real line, no greeting or "please",
|
|
61
|
+
// links as what they point at, a capital first letter, cut cleanly.
|
|
62
|
+
// "Please checkout the PR 408 and then…" -> "Check out the PR 408…"
|
|
63
|
+
export function promptTitleText(text, max = TITLE_MAX) {
|
|
64
|
+
let t = String(text == null ? '' : text).split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
65
|
+
t = t.replace(URL_RE, (u) => ' ' + urlWords(u) + ' ').replace(LONG_PATH, (m, pre) => pre + m.trim().split('/').filter(Boolean).pop())
|
|
66
|
+
t = t.replace(/\s+/g, ' ').trim()
|
|
67
|
+
for (let i = 0; i < 4; i++) { const n = t.replace(FILLER, ''); if (n === t || !n.trim()) break; t = n.trim() }
|
|
68
|
+
t = t.replace(/^checkout\s+(?=the\b|pr\b|#|branch\b)/i, 'Check out ')
|
|
69
|
+
t = t.replace(/^[\s,.:;!?–—-]+/, '')
|
|
70
|
+
t = cutTitle(t, max)
|
|
71
|
+
return t && !/^\S+\.\S/.test(t) ? t.charAt(0).toUpperCase() + t.slice(1) : t // a bare host stays as written
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A stored name, tidied for showing: where it runs comes off the front (it has
|
|
75
|
+
// its own line). A name that came from a prompt (or carried a where prefix,
|
|
76
|
+
// which only automatic names do) is tidied like a prompt; a name you or Claude
|
|
77
|
+
// gave it is kept as written, only cut at a word if it is very long.
|
|
78
|
+
export function displayTitle(title, { source = null, max = TITLE_MAX } = {}) {
|
|
79
|
+
let t = String(title == null ? '' : title).replace(/\s+/g, ' ').trim()
|
|
80
|
+
const glued = WHERE_GLUED.test(t) && t.replace(WHERE_GLUED, '').trim()
|
|
81
|
+
if (glued) t = glued
|
|
82
|
+
if (glued || source === 'prompt' || source === 'fallback') return promptTitleText(t, max) || t
|
|
83
|
+
return t.length > 2 * max ? cutTitle(t, 2 * max) : t
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Is this stored name only an automatic stand-in (where it runs + the first
|
|
87
|
+
// prompt's words)? Then a generated name may replace it, as for a prompt name.
|
|
88
|
+
export function isPlaceholderTitle(title) { return WHERE_GLUED.test(String(title || '')) }
|
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/summary-examples.js
CHANGED
|
@@ -40,7 +40,7 @@ export const SUMMARY_EXAMPLES = {
|
|
|
40
40
|
}
|
|
41
41
|
export const LENGTH_CHOICES = [
|
|
42
42
|
{ key: 'short', label: 'Short', approx: '~300 tokens', blurb: 'Doing, Stopped at, Next. Three lines.' },
|
|
43
|
-
{ key: 'informative', label: 'Informative', approx: '~700 tokens', blurb: 'Adds
|
|
43
|
+
{ key: 'informative', label: 'Informative', approx: '~700 tokens', blurb: 'Adds approach notes, open questions, files touched and why, and what didn’t work.', recommended: true },
|
|
44
44
|
{ key: 'long', label: 'Long', approx: '~1500 tokens', blurb: 'Adds a narrative recap and the next 3 steps.' },
|
|
45
45
|
]
|
|
46
46
|
|
|
@@ -56,7 +56,7 @@ export function formatCheckpointText(cp, { indent = '' } = {}) {
|
|
|
56
56
|
line(title + ':')
|
|
57
57
|
items.forEach((x, i) => line((title === 'Next steps' ? ' ' + (i + 1) + '. ' : ' - ') + fmt(x)))
|
|
58
58
|
}
|
|
59
|
-
list('
|
|
59
|
+
list('Approach notes', cp.decisions)
|
|
60
60
|
list('Open questions', cp.questions)
|
|
61
61
|
list('Files touched', cp.files, (f) => (f.path || '') + (f.path && f.why ? ' — ' : '') + (f.why || ''))
|
|
62
62
|
list('What didn’t work', cp.didntWork)
|
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/testplan.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Test plan from merged PRs (testplan Part 3, docs/test-plan.md): the board's page, its Markdown
|
|
2
|
+
// checklist and its downloadable .html (the walkthrough export's look). Pure builders; app.js wires
|
|
3
|
+
// them; unit-tested in test/unit/testplan-*.test.mjs. Words are escaped first; links only to https,
|
|
4
|
+
// the board's own #/ routes, and nothing else.
|
|
5
|
+
import { EXPORT_CSS } from './semdiff.js'
|
|
6
|
+
|
|
7
|
+
const escT = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
8
|
+
export const BUDGETS = [15, 30, 60]
|
|
9
|
+
export const RISK_SHAPE = { high: '▲', medium: '◆', low: '▽' }
|
|
10
|
+
export const RISK_WORD = { high: 'High risk', medium: 'Medium risk', low: 'Low risk' }
|
|
11
|
+
export const TP_TRUST_TIP = 'Writing a test plan from another device is off. On a Mac, turn on Settings → Privacy & security → Trust my paired devices.'
|
|
12
|
+
const off = (trusted, esc) => (trusted === false ? ' disabled aria-disabled="true" data-tip="' + esc(TP_TRUST_TIP) + '"' : '')
|
|
13
|
+
const httpsUrl = (u) => (/^https:\/\/[^\s"'<>]+$/.test(String(u || '')) ? String(u) : null)
|
|
14
|
+
const day = (iso) => String(iso || '').slice(0, 10)
|
|
15
|
+
|
|
16
|
+
export const testplanHref = (id, machine = null) => '#/testplan/' + encodeURIComponent(id) + (machine ? '?machine=' + encodeURIComponent(machine) : '')
|
|
17
|
+
export function testplanNewHref({ scope = '', prs = null, since = null } = {}) {
|
|
18
|
+
const q = []
|
|
19
|
+
if (scope) q.push('scope=' + encodeURIComponent(scope))
|
|
20
|
+
if (prs && prs.length) q.push('prs=' + prs.map(encodeURIComponent).join(','))
|
|
21
|
+
if (since) q.push('since=' + encodeURIComponent(since))
|
|
22
|
+
return '#/testplan/new' + (q.length ? '?' + q.join('&') : '')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function progressLine(plan) {
|
|
26
|
+
const cs = (plan && plan.plan && plan.plan.checks) || []
|
|
27
|
+
const t = (plan && plan.ticks) || {}
|
|
28
|
+
return cs.filter((c) => t[c.id] && t[c.id].state).length + ' of ' + cs.length + ' checked'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A citation as a small chip: a PR opens on GitHub, a decision in its log; a step or checkpoint is its words.
|
|
32
|
+
export function citeChipHtml(tag, tags, { esc = escT } = {}) {
|
|
33
|
+
const x = (tags && tags[tag]) || null
|
|
34
|
+
const [kind] = String(tag).split(':')
|
|
35
|
+
const label = kind === 'pr' ? '#' + tag.slice(3) : kind === 'dec' ? tag.slice(4) : kind === 'st' ? 'step' : 'checkpoint'
|
|
36
|
+
const tip = x && x.label ? x.label : tag
|
|
37
|
+
if (kind === 'pr' && x && httpsUrl(x.url)) return '<a class="tp-cite" href="' + esc(x.url) + '" target="_blank" rel="noopener noreferrer" data-tip="' + esc(tip) + '">' + esc(label) + '</a>'
|
|
38
|
+
if (kind === 'dec' && x && x.repo) return '<a class="tp-cite" href="#/decisions/' + esc(x.repo) + '?d=' + esc(encodeURIComponent(tag.slice(4))) + '" data-tip="' + esc(tip) + '">' + esc(label) + '</a>'
|
|
39
|
+
return '<span class="tp-cite" data-tip="' + esc(tip) + '">' + esc(label + (x && x.label ? ': ' + x.label : '')) + '</span>'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const riskHtml = (r, esc) => '<span class="tp-risk tp-risk-' + esc(r) + '"><span aria-hidden="true">' + (RISK_SHAPE[r] || '◆') + '</span> ' + esc(RISK_WORD[r] || r) + '</span>'
|
|
43
|
+
|
|
44
|
+
// One check. ticks[c.id]: { state: 'done' | 'failed' | null, note }. proposed: decision id made from it.
|
|
45
|
+
export function checkHtml(c, plan, { esc = escT, trusted = true, noting = null, proposing = null } = {}) {
|
|
46
|
+
const t = (plan.ticks || {})[c.id] || {}
|
|
47
|
+
const st = t.state || null
|
|
48
|
+
const tags = (plan.plan && plan.plan.tags) || {}
|
|
49
|
+
const prop = plan.proposed && plan.proposed[c.id]
|
|
50
|
+
const prRepo = (((plan.prs || []).find((x) => (c.prs || []).includes(x.number)) || (plan.prs || [])[0] || {}).decisionRepo) || ''
|
|
51
|
+
const tick = (v, word, shape) => '<button type="button" class="btn tp-tick tp-tick-' + v + '" data-action="tp-tick" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '" data-state="' + v + '" aria-pressed="' + (st === v) + '"><span aria-hidden="true">' + shape + '</span> ' + word + '</button>'
|
|
52
|
+
const note = st === 'failed' || noting === c.id
|
|
53
|
+
? '<form class="tp-note" data-action="tp-note" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '"><label class="tp-note-l">What went wrong<input class="dec-in tp-note-in" name="note" maxlength="280" autocomplete="off" value="' + esc(t.note || '') + '" placeholder="One line, for the export"></label><div class="dec-acts"><button type="submit" class="btn">Save note</button>' +
|
|
54
|
+
(prop ? '<a class="linkish tp-prop" href="#/decisions/' + esc(prRepo) + '?d=' + esc(encodeURIComponent(prop)) + '"><span aria-hidden="true">◌</span> Proposed ' + esc(prop) + ' →</a>'
|
|
55
|
+
: '<button type="button" class="btn" data-action="tp-propose" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '"' + off(trusted, esc) + '>Propose a decision</button>') + '</div></form>' +
|
|
56
|
+
(proposing === c.id && !prop ? '<form class="tp-propf dec-edit" data-action="tp-propose-save" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '"><label>The decision (proposed to the repo’s log; you accept it there or not)<input class="dec-in" name="text" maxlength="240" autocomplete="off" value="' + esc(t.note ? 'Correct: ' + t.note : '') + '"></label><div class="dec-acts"><button type="submit" class="btn primary">Propose</button><button type="button" class="btn" data-action="tp-propose-cancel">Cancel</button></div></form>' : '')
|
|
57
|
+
: ''
|
|
58
|
+
return '<li class="tp-check' + (st ? ' tp-' + st : '') + '" id="tp-' + esc(c.id) + '">' +
|
|
59
|
+
'<div class="tp-ch"><span class="tp-n">' + esc(c.id.slice(1)) + '</span><h3 class="tp-title">' + esc(c.title) + '</h3>' + riskHtml(c.risk, esc) + '<span class="tp-min">' + esc(c.minutes) + ' min</span></div>' +
|
|
60
|
+
(c.where ? '<p class="tp-where"><span class="tp-k">Where</span> ' + esc(c.where) + '</p>' : '') +
|
|
61
|
+
(c.steps && c.steps.length ? '<ol class="tp-steps">' + c.steps.map((x) => '<li>' + esc(x) + '</li>').join('') + '</ol>' : '') +
|
|
62
|
+
(c.expect ? '<p class="tp-expect"><span class="tp-k">You should see</span> ' + esc(c.expect) + '</p>' : '') +
|
|
63
|
+
'<p class="tp-why"><span class="tp-k">Why</span> ' + esc(c.why) + '</p>' +
|
|
64
|
+
'<div class="tp-cites" aria-label="Where this comes from">' + c.cites.map((tg) => citeChipHtml(tg, tags, { esc })).join('') + '</div>' +
|
|
65
|
+
'<div class="tp-acts">' + tick('done', 'Checked', '✓') + tick('failed', 'Failed', '✕') + (st ? '<button type="button" class="linkish tp-clear" data-action="tp-tick" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '" data-state="">Clear</button>' : '') + '</div>' + note + '</li>'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The plan's page. ui: { trusted, noting, machine (another Mac's plan), here }
|
|
69
|
+
export function testplanPageHtml(plan, ui = {}, { esc = escT } = {}) {
|
|
70
|
+
if (!plan) return '<div class="proj tp-page"><p class="faint">Loading the test plan…</p></div>'
|
|
71
|
+
const sc = plan.scope || {}
|
|
72
|
+
const back = sc.kind === 'project' ? '<a class="back" href="#/project/' + esc(encodeURIComponent(sc.id || sc.token || '')) + '">← ' + esc(sc.label || 'Project') + '</a>' : '<a class="back" href="#/prs">← Pull requests</a>'
|
|
73
|
+
const sub = [plan.window && plan.window.label, plan.minutes + '-minute budget', (plan.prs || []).length + ' merged PR' + ((plan.prs || []).length === 1 ? '' : 's'), plan.machine && ui.here && plan.machine !== ui.here ? 'on ' + plan.machine : null].filter(Boolean).join(' · ')
|
|
74
|
+
let h = back + '<header class="tp-head"><h1>Test plan <span class="faint">· ' + esc(sc.label || '') + '</span></h1><p class="sub">' + esc(sub) + '</p></header>'
|
|
75
|
+
if (plan.status === 'running') return '<div class="proj tp-page">' + h + '<div class="tp-wait" role="status" aria-live="polite"><span class="tp-spin" aria-hidden="true"></span> Writing the test plan from ' + esc((plan.prs || []).length) + ' PRs… this takes up to a couple of minutes.</div></div>'
|
|
76
|
+
if (plan.status === 'error') return '<div class="proj tp-page">' + h + '<div class="banner warn" role="alert"><span>Couldn’t write it: ' + esc(plan.error || 'unknown error') + '</span></div></div>'
|
|
77
|
+
const p = plan.plan || { checks: [], notCovered: [], coverage: {} }
|
|
78
|
+
const by = plan.by || {}
|
|
79
|
+
const byWords = by.engine === 'claude' ? 'Written by Claude Code (read-only)' : by.engine === 'summary' ? 'Written by your summary model (' + (by.model || by.provider || '') + ')' : by.engine === 'rules' ? 'Made from Leftoff’s notes without a model' : by.engine === 'empty' ? '' : ''
|
|
80
|
+
h += '<div class="tp-bar"><span class="tp-prog" role="status"><span aria-hidden="true">☐</span> ' + esc(progressLine(plan)) + '</span><span class="tp-cov">' + esc(p.coverage && p.coverage.line || '') + '</span>' +
|
|
81
|
+
'<span class="tp-exp"><button type="button" class="btn" data-action="tp-copy" data-id="' + esc(plan.id) + '">Copy as Markdown</button><button type="button" class="btn" data-action="tp-download" data-id="' + esc(plan.id) + '">Download .html</button></span></div>'
|
|
82
|
+
if (byWords) h += '<p class="faint tp-by">' + esc(byWords) + (by.why && by.engine !== 'claude' ? ' · ' + esc(by.why) : '') + '</p>'
|
|
83
|
+
if (!p.checks.length) h += '<p class="faint">' + esc((p.coverage && p.coverage.line) || 'Nothing to check.') + '</p>'
|
|
84
|
+
else h += '<ol class="tp-list">' + p.checks.map((c) => checkHtml(c, plan, { esc, trusted: ui.trusted, noting: ui.noting, proposing: ui.proposing })).join('') + '</ol>'
|
|
85
|
+
if (p.notCovered && p.notCovered.length) h += '<section class="proj-sec tp-nc"><h2>Not covered <span class="count">' + p.notCovered.length + '</span></h2><ul class="tp-ncl">' + p.notCovered.map((n) => '<li><span class="tp-nc-w">' + esc(n.what) + '</span> <span class="faint">· ' + esc(n.why) + '</span>' + ((n.cites || []).length ? ' <span class="tp-cites">' + n.cites.map((tg) => citeChipHtml(tg, p.tags || {}, { esc })).join('') + '</span>' : '') + '</li>').join('') + '</ul></section>'
|
|
86
|
+
const nw = (plan.prs || []).filter((x) => x.noWalkthrough)
|
|
87
|
+
h += '<section class="proj-sec tp-prs"><h2>Merged PRs <span class="count">' + (plan.prs || []).length + '</span></h2><ul class="tp-prl">' + (plan.prs || []).map((x) => '<li>' + (httpsUrl(x.url) ? '<a href="' + esc(x.url) + '" target="_blank" rel="noopener noreferrer">#' + esc(x.number) + '</a>' : '#' + esc(x.number)) + ' ' + esc(x.title) + (x.noWalkthrough ? ' <span class="tp-nowt"><span aria-hidden="true">◌</span> no walkthrough</span>' : '') + '</li>').join('') + '</ul>' +
|
|
88
|
+
(nw.length ? '<p class="faint">' + nw.length + ' had no walkthrough, so only their files, checkpoints and decisions were read.</p>' : '') + '</section>'
|
|
89
|
+
if (p.notes && p.notes.length) h += '<details class="tp-notes"><summary>What was checked in the answer (' + p.notes.length + ')</summary><ul>' + p.notes.map((n) => '<li>' + esc(n) + '</li>').join('') + '</ul></details>'
|
|
90
|
+
h += '<p class="tp-del"><button type="button" class="linkish" data-action="tp-delete" data-id="' + esc(plan.id) + '">Delete this test plan</button></p>'
|
|
91
|
+
return '<div class="proj tp-page">' + h + '</div>'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Before a run: the PRs in the window, the budget (15 / 30 / 60, a choice in the request, not a setting),
|
|
95
|
+
// "Walk through the N without one first", and the plans made before.
|
|
96
|
+
export function testplanNewHtml(pv, ui = {}, { esc = escT } = {}) {
|
|
97
|
+
const sc = (pv && pv.scope) || {}
|
|
98
|
+
const back = sc.kind === 'project' ? '<a class="back" href="#/project/' + esc(encodeURIComponent(sc.id || sc.token || '')) + '">← ' + esc(sc.label || 'Project') + '</a>' : '<a class="back" href="#/prs">← Pull requests</a>'
|
|
99
|
+
let h = back + '<header class="tp-head"><h1>Test plan <span class="faint">· ' + esc(sc.label || '') + '</span></h1><p class="sub">A short manual script for the PRs merged ' + esc(pv && pv.window ? pv.window.label.toLowerCase() : 'lately') + ': what to click, what you should see, why it’s on the list.</p></header>'
|
|
100
|
+
if (!pv) return '<div class="proj tp-page">' + h + '<p class="faint">Reading the merged PRs…</p></div>'
|
|
101
|
+
const prs = pv.prs || []
|
|
102
|
+
const mins = BUDGETS.includes(ui.minutes) ? ui.minutes : 30
|
|
103
|
+
h += '<div class="tp-budget" role="group" aria-label="Time budget">' + BUDGETS.map((m) => '<button type="button" class="sask-opt" data-action="tp-minutes" data-m="' + m + '" aria-pressed="' + (m === mins) + '">' + m + ' min</button>').join('') + '</div>'
|
|
104
|
+
if ((pv.notes || []).length) h += pv.notes.map((n) => '<p class="faint">' + esc(n) + '</p>').join('')
|
|
105
|
+
if (!prs.length) h += '<p class="faint">No merged PRs ' + esc(pv.window ? pv.window.label.toLowerCase() : '') + '. They show here once the PRs page has read them.</p>'
|
|
106
|
+
else {
|
|
107
|
+
h += '<ul class="tp-prl">' + prs.map((x) => '<li>' + (httpsUrl(x.url) ? '<a href="' + esc(x.url) + '" target="_blank" rel="noopener noreferrer">#' + esc(x.number) + '</a>' : '#' + esc(x.number)) + ' ' + esc(x.title) + ' <span class="faint">· ' + esc(day(x.mergedAt)) + '</span>' + (x.walkthrough ? ' <span class="tp-wt"><span aria-hidden="true">●</span> walkthrough</span>' : ' <span class="tp-nowt"><span aria-hidden="true">◌</span> no walkthrough</span>') + '</li>').join('') + '</ul>'
|
|
108
|
+
const w = pv.walking
|
|
109
|
+
const n = pv.withoutWalkthrough || 0
|
|
110
|
+
h += '<div class="dec-acts tp-go">' + (n ? '<button type="button" class="btn" data-action="tp-walk"' + off(ui.trusted, esc) + (w && w.running ? ' disabled' : '') + '>' + (w && w.running ? 'Walking through ' + (w.done + 1) + ' of ' + w.total + '…' : 'Walk through the ' + n + ' without one first') + '</button>' : '') +
|
|
111
|
+
'<button type="button" class="btn primary" data-action="tp-run"' + off(ui.trusted, esc) + (ui.starting ? ' disabled' : '') + '>' + (ui.starting ? 'Starting…' : 'Make the test plan') + '</button></div>'
|
|
112
|
+
if (ui.trusted === false) h += '<p class="faint dec-trust"><span aria-hidden="true">◌</span> ' + esc(TP_TRUST_TIP) + '</p>'
|
|
113
|
+
}
|
|
114
|
+
const recent = (ui.recent && ui.recent.items) || []
|
|
115
|
+
if (recent.length) h += '<section class="proj-sec"><h2>Earlier test plans</h2><ul class="tp-recent">' + recent.map((r) => '<li><a href="' + esc(testplanHref(r.id, r.machine && r.machine !== ui.here ? r.machine : null)) + '">' + esc((r.scope && r.scope.label) || 'Test plan') + ' · ' + esc(day(r.createdAt)) + '</a> <span class="faint">· ' + esc(r.ticked + ' of ' + r.checks + ' checked') + (r.machine && r.machine !== ui.here ? ' · on ' + esc(r.machine) : '') + '</span></li>').join('') + '</ul></section>'
|
|
116
|
+
return '<div class="proj tp-page">' + h + '</div>'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ------------------------------------------------------------ export
|
|
120
|
+
|
|
121
|
+
const mdEsc = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
|
|
122
|
+
// A checklist with checkboxes: ticked ones [x]; a failed one says so, with its note.
|
|
123
|
+
export function planMarkdown(plan) {
|
|
124
|
+
const p = (plan && plan.plan) || { checks: [], notCovered: [] }
|
|
125
|
+
const t = plan.ticks || {}
|
|
126
|
+
const L = ['# Test plan: ' + mdEsc((plan.scope && plan.scope.label) || ''), '', mdEsc([plan.window && plan.window.label, plan.minutes + '-minute budget', (plan.prs || []).length + ' merged PRs', progressLine(plan)].filter(Boolean).join(' · ')), '']
|
|
127
|
+
if (p.coverage && p.coverage.line) L.push(mdEsc(p.coverage.line), '')
|
|
128
|
+
for (const c of p.checks) {
|
|
129
|
+
const st = t[c.id] && t[c.id].state
|
|
130
|
+
L.push('- [' + (st ? 'x' : ' ') + '] **' + mdEsc(c.title) + '** (' + (RISK_WORD[c.risk] || c.risk) + ', ' + c.minutes + ' min)' + (st === 'failed' ? ' ✕ failed' + (t[c.id].note ? ': ' + mdEsc(t[c.id].note) : '') : ''))
|
|
131
|
+
if (c.where) L.push(' - Where: ' + mdEsc(c.where))
|
|
132
|
+
c.steps.forEach((x, i) => L.push(' ' + (i + 1) + '. ' + mdEsc(x)))
|
|
133
|
+
if (c.expect) L.push(' - You should see: ' + mdEsc(c.expect))
|
|
134
|
+
L.push(' - Why: ' + mdEsc(c.why) + (c.cites.length ? ' (' + c.cites.map((x) => '`' + x + '`').join(' ') + ')' : ''))
|
|
135
|
+
}
|
|
136
|
+
if (p.notCovered && p.notCovered.length) { L.push('', '## Not covered', ''); for (const n of p.notCovered) L.push('- ' + mdEsc(n.what) + ': ' + mdEsc(n.why)) }
|
|
137
|
+
L.push('', '## Merged PRs', '')
|
|
138
|
+
for (const x of plan.prs || []) L.push('- #' + x.number + ' ' + mdEsc(x.title) + (x.noWalkthrough ? ' (no walkthrough)' : ''))
|
|
139
|
+
return L.join('\n') + '\n'
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ONE self-contained file in the walkthrough export's look: inline CSS, no scripts, no links out
|
|
143
|
+
// (a link is its words and its address).
|
|
144
|
+
export function planHtmlDoc(plan) {
|
|
145
|
+
const e = escT
|
|
146
|
+
const p = (plan && plan.plan) || { checks: [], notCovered: [] }
|
|
147
|
+
const t = plan.ticks || {}
|
|
148
|
+
let b = '<main><h1>Test plan: ' + e((plan.scope && plan.scope.label) || '') + '</h1><p class="f">' + e([plan.window && plan.window.label, plan.minutes + '-minute budget', (plan.prs || []).length + ' merged PRs', progressLine(plan)].filter(Boolean).join(' · ')) + '</p>'
|
|
149
|
+
if (p.coverage && p.coverage.line) b += '<p class="sum">' + e(p.coverage.line) + '</p>'
|
|
150
|
+
for (const c of p.checks) {
|
|
151
|
+
const st = t[c.id] && t[c.id].state
|
|
152
|
+
b += '<h2>' + (st === 'done' ? '☑' : st === 'failed' ? '☒' : '☐') + ' ' + e(c.title) + '<span class="tag' + (c.risk === 'high' ? ' core' : '') + '">' + (RISK_SHAPE[c.risk] || '') + ' ' + e(RISK_WORD[c.risk] || c.risk) + '</span><span class="tag">' + e(c.minutes) + ' min</span></h2>'
|
|
153
|
+
if (st === 'failed') b += '<div class="co"><b>Failed</b> ' + e((t[c.id] && t[c.id].note) || '') + '</div>'
|
|
154
|
+
if (c.where) b += '<p class="f">Where: ' + e(c.where) + '</p>'
|
|
155
|
+
if (c.steps.length) b += '<ol>' + c.steps.map((x) => '<li>' + e(x) + '</li>').join('') + '</ol>'
|
|
156
|
+
if (c.expect) b += '<p>You should see: ' + e(c.expect) + '</p>'
|
|
157
|
+
b += '<p class="f">Why: ' + e(c.why) + (c.cites.length ? ' · ' + c.cites.map((x) => '<code>' + e(x) + '</code>').join(' ') : '') + '</p>'
|
|
158
|
+
}
|
|
159
|
+
if (p.notCovered && p.notCovered.length) b += '<h2>Not covered</h2><ul>' + p.notCovered.map((n) => '<li>' + e(n.what) + ' <span class="f">(' + e(n.why) + ')</span></li>').join('') + '</ul>'
|
|
160
|
+
b += '<h2>Merged PRs</h2><ul>' + (plan.prs || []).map((x) => '<li>#' + e(x.number) + ' ' + e(x.title) + (x.url ? ' <span class="u f">' + e(x.url) + '</span>' : '') + (x.noWalkthrough ? ' <span class="f">(no walkthrough)</span>' : '') + '</li>').join('') + '</ul></main>'
|
|
161
|
+
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'"><title>' + e('Test plan: ' + ((plan.scope && plan.scope.label) || '')) + '</title><style>' + EXPORT_CSS + '</style></head><body>' + b + '</body></html>\n'
|
|
162
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Full screen on a phone, sized to the window you can see (ui/app.js fitFullViewport).
|
|
2
|
+
// 2026-11-03 (Joel: "weird top padding after sending a message"): iOS pans the
|
|
3
|
+
// visual viewport up when the keyboard opens over the composer, and full screen
|
|
4
|
+
// follows that pan (top = visualViewport.offsetTop) so the composer stays above
|
|
5
|
+
// the keyboard. After a send the keyboard closes, but iOS's last report can
|
|
6
|
+
// still carry the old pan (or no report comes), and full screen stayed ~300px
|
|
7
|
+
// down the screen with the board showing through above it. So the pan only
|
|
8
|
+
// counts while the keyboard is really up (a text box has focus and the window
|
|
9
|
+
// lost a keyboard's worth of height), and never more than the room the
|
|
10
|
+
// keyboard took (a visual viewport as tall as the window can't be panned).
|
|
11
|
+
|
|
12
|
+
// A focused field the on-screen keyboard is up for.
|
|
13
|
+
const NO_KB_INPUT = /^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/i
|
|
14
|
+
export function isTypingTarget(el) {
|
|
15
|
+
if (!el || !el.tagName) return false
|
|
16
|
+
const tag = String(el.tagName).toUpperCase()
|
|
17
|
+
if (tag === 'TEXTAREA') return !el.readOnly && !el.disabled
|
|
18
|
+
if (tag === 'INPUT') return !NO_KB_INPUT.test(el.type || 'text') && !el.readOnly && !el.disabled
|
|
19
|
+
return !!el.isContentEditable
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const KEYBOARD_MIN_PX = 120 // less than this is the browser's own bars, not a keyboard
|
|
23
|
+
|
|
24
|
+
// innerHeight: the layout viewport's height; vv: { height, offsetTop, scale }; typing: a text box has focus.
|
|
25
|
+
// -> { h, top, kb } in CSS px (null without a visualViewport: the CSS falls back to 100dvh).
|
|
26
|
+
// 2026-11-04 (switching keyboards mid-typing hid the composer): while a text box has focus, full
|
|
27
|
+
// screen is exactly the visual viewport as iOS reports it right now, whatever the keyboard's height
|
|
28
|
+
// (a keyboard swap reports a passing full-height or tiny keyboard on the way; following each report
|
|
29
|
+
// means the next one puts it right). Nothing is remembered from when the box took focus. `kb` (the
|
|
30
|
+
// keyboard's own look: no home-indicator gap) still needs a keyboard's worth of height.
|
|
31
|
+
export function fullViewportFit({ innerHeight, vv, typing = false } = {}) {
|
|
32
|
+
if (!vv || !(Number(innerHeight) > 0)) return null
|
|
33
|
+
const ih = Number(innerHeight)
|
|
34
|
+
const vh = Number(vv.height) > 0 ? Number(vv.height) : ih
|
|
35
|
+
const zoomed = Number(vv.scale) > 1.01 // pinch-zoomed: follow the visual viewport as before
|
|
36
|
+
const kb = !!typing && ih - vh > KEYBOARD_MIN_PX
|
|
37
|
+
if (!typing && !zoomed) return { h: Math.round(ih), top: 0, kb: false } // no keyboard: the whole window, from its top
|
|
38
|
+
const room = Math.max(0, ih - vh) // a visual viewport as tall as the window can't be panned (a stale pan)
|
|
39
|
+
const top = Math.min(Math.max(0, Number(vv.offsetTop) || 0), room)
|
|
40
|
+
return { h: Math.round(Math.min(vh, ih)), top: Math.round(top), kb }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// How far full screen's composer sits outside what you can see (the visual viewport, in the same
|
|
44
|
+
// page coordinates as getBoundingClientRect): > 0 means move it up by that much, < 0 down, 0 it's in view.
|
|
45
|
+
// rect: the composer's { top, bottom }; vv: { height, offsetTop }.
|
|
46
|
+
export function outOfView(rect, vv, slack = 1) {
|
|
47
|
+
if (!rect || !vv || !(Number(vv.height) > 0)) return 0
|
|
48
|
+
const top = Number(vv.offsetTop) || 0, bottom = top + Number(vv.height)
|
|
49
|
+
if (rect.bottom > bottom + slack) return Math.round(Math.min(rect.bottom - bottom, rect.top - top)) // below the keyboard's edge (never past its own top)
|
|
50
|
+
if (rect.top < top - slack) return -Math.round(top - rect.top) // above the top of what you can see
|
|
51
|
+
return 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// When to look again after a text box lets go (the keyboard takes ~0.3–0.5 s to
|
|
55
|
+
// close, and iOS's reports during it can be stale or missing), in ms.
|
|
56
|
+
export const SETTLE_AFTER_BLUR_MS = [0, 120, 350, 700, 1100]
|
|
57
|
+
// The same beats after every report while a text box has focus: a keyboard swap's last change can
|
|
58
|
+
// come with no event of its own, so the board looks again a few times after each one.
|
|
59
|
+
export const SETTLE_WHILE_TYPING_MS = SETTLE_AFTER_BLUR_MS
|
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',
|