@leftoff/cli 0.9.18-beta.1 → 0.9.18-beta.11
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 +46 -1
- package/bin/leftoff-statusline.js +9 -0
- package/bin/leftoff.js +787 -557
- package/bin/pty-host-main.js +66 -0
- package/docs/SECURITY.md +1 -1
- 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 +211 -13
- package/mod/hooks/register.js +196 -7
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +279 -4
- package/ui/app.js +661 -53
- package/ui/ask.js +5 -3
- package/ui/chip-fade.js +1 -1
- package/ui/cp-fold.js +22 -0
- package/ui/decisions.js +245 -0
- package/ui/fs-side.js +29 -0
- package/ui/hook-fix.js +109 -0
- package/ui/live-status.js +68 -2
- package/ui/live-view.js +36 -5
- package/ui/prs.js +15 -5
- package/ui/pull.js +14 -1
- package/ui/semdiff.js +163 -20
- package/ui/session-catchup.js +18 -2
- package/ui/session-title.js +88 -0
- package/ui/sglyph.js +2 -2
- package/ui/slash-commands.js +89 -0
- package/ui/slash.js +82 -0
- package/ui/summary-examples.js +2 -2
- package/ui/tabs.js +10 -3
- package/ui/term.js +11 -3
- package/ui/testplan.js +162 -0
- package/ui/viewport-fit.js +73 -0
- package/ui/walk-pop.js +3 -0
- package/ui/what-it-did.js +12 -5
package/ui/session-catchup.js
CHANGED
|
@@ -29,17 +29,33 @@ export function sessionCatchupHtml(ui, { esc, icon = () => '', sid = '' } = {})
|
|
|
29
29
|
: '<ul class="scu-body scu-list">' + body.map((_, i) => '<li data-scu="b-' + i + '"></li>').join('') + '</ul>'
|
|
30
30
|
return '<div class="scu" role="region" aria-label="Catch me up">' + head('<span class="scu-when" data-scu="when"></span>') +
|
|
31
31
|
(goal ? '<p class="scu-goal" data-scu="goal"></p>' : '') +
|
|
32
|
-
text + (next != null ? '<p class="scu-next" data-scu="next"></p>' : '') +
|
|
32
|
+
text + (next != null ? '<p class="scu-next" data-scu="next"></p>' : '') + recapDecisionsHtml(ui.data, { esc }) +
|
|
33
33
|
'<div class="msg-foot"><p class="scu-by" data-scu="by"></p>' + copyButtonHtml(recapSource(ui.data), { deferred: true }) + '</div></div>'
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// testplan Part 2: what the session decided since you looked, each with its word in the log (shape plus word).
|
|
37
|
+
const DEC_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻', unlogged: '·' }
|
|
38
|
+
const DEC_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded', unlogged: 'Not in the log' }
|
|
39
|
+
export function recapDecisionsHtml(d, { esc }) {
|
|
40
|
+
const list = (d && Array.isArray(d.decisions) ? d.decisions : []).filter((x) => x && x.text)
|
|
41
|
+
if (!list.length) return ''
|
|
42
|
+
const sinceWord = d && d.recap && d.recap.since ? 'Decided since you looked' : 'Decided in this session'
|
|
43
|
+
return '<div class="scu-decs"><h3 class="scu-dec-h">' + esc(sinceWord) + '</h3><ul class="scu-dec-l">' + list.map((x) =>
|
|
44
|
+
'<li><span class="dec-st dec-st-' + esc(x.status) + '"><span aria-hidden="true">' + (DEC_SHAPE[x.status] || '·') + '</span> ' + esc(DEC_WORD[x.status] || x.status) + '</span> ' +
|
|
45
|
+
(x.id ? '<span class="dec-id">' + esc(x.id) + '</span> ' : '') + '<span class="dec-text">' + esc(x.text) + '</span>' + (x.supersededBy ? ' <span class="faint">→ ' + esc(x.supersededBy) + '</span>' : '') + '</li>').join('') + '</ul></div>'
|
|
46
|
+
}
|
|
47
|
+
export function recapDecisionsText(d) {
|
|
48
|
+
const list = (d && Array.isArray(d.decisions) ? d.decisions : []).filter((x) => x && x.text)
|
|
49
|
+
return list.length ? 'Decided:\n' + list.map((x) => '- ' + (x.id ? x.id + ' ' : '') + x.text + ' (' + (DEC_WORD[x.status] || x.status).toLowerCase() + ')').join('\n') : ''
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
// 0.9.11: what Copy takes from a recap, as markdown: the goal, the lines as a
|
|
37
53
|
// list (or a sentence or two, as shown), and the Next line.
|
|
38
54
|
export function recapSource(d) {
|
|
39
55
|
const { body, next } = recapParts(d)
|
|
40
56
|
const goal = recapGoal(d)
|
|
41
57
|
const text = body.length <= 2 ? body.join(' ') : body.map((b) => '- ' + b).join('\n')
|
|
42
|
-
return [goal, text, next].filter((x) => x && String(x).trim()).join('\n\n')
|
|
58
|
+
return [goal, text, next, recapDecisionsText(d)].filter((x) => x && String(x).trim()).join('\n\n')
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
// The recap's lines: what happened, and the one "Next: …" / "Up to you…" /
|
|
@@ -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/sglyph.js
CHANGED
|
@@ -109,14 +109,14 @@ export const countsAsWaiting = (s, offline = false) => !!s && !(offline && s.isL
|
|
|
109
109
|
// hidden inside a control that already names the state: aria-hidden, no role
|
|
110
110
|
// closed a closed session's quiet ring is fainter
|
|
111
111
|
// arrive the page-clock time the new-reply dot appeared (it pulses once from then)
|
|
112
|
-
export function glyphHtml(state, { esc = escDefault, tip = '', quiet = false, hidden = false, closed = false, arrive = null, cls = '' } = {}) {
|
|
112
|
+
export function glyphHtml(state, { esc = escDefault, tip = '', quiet = false, hidden = false, closed = false, arrive = null, cls = '', noTip = false } = {}) {
|
|
113
113
|
if (!GLYPHS.includes(state)) state = 'quiet'
|
|
114
114
|
if (state === 'quiet' && !quiet) return ''
|
|
115
115
|
const label = GLYPH_LABEL[state]
|
|
116
116
|
const inner = state === 'waiting' ? '<span class="sg-in" aria-hidden="true">!</span>' : state === 'offline' ? '<span class="sg-in" aria-hidden="true"></span><span class="sg-slash" aria-hidden="true"></span>' : '<span class="sg-in" aria-hidden="true"></span>'
|
|
117
117
|
const c = 'sg sg-' + state + (state === 'quiet' && closed ? ' sg-closed' : '') + (state === 'unseen' && arrive != null ? ' sg-arrive' : '') + (cls ? ' ' + cls : '')
|
|
118
118
|
const a11y = hidden ? ' aria-hidden="true"' : ' role="img" aria-label="' + esc(tip || label) + '"'
|
|
119
|
-
return '<span class="' + c + '" data-sg="' + state + '"' + a11y + ' data-tip="' + esc(tip || label) + '"' + (state === 'unseen' && arrive != null ? ' data-arrive="' + Math.round(Number(arrive) || 0) + '"' : '') + '>' + inner + '</span>'
|
|
119
|
+
return '<span class="' + c + '" data-sg="' + state + '"' + a11y + (noTip ? '' : ' data-tip="' + esc(tip || label) + '"') + (state === 'unseen' && arrive != null ? ' data-arrive="' + Math.round(Number(arrive) || 0) + '"' : '') + '>' + inner + '</span>'
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
// The words for a glyph in a control's own label (", waiting on you").
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Claude Code slash commands the board can send into a session (Live box).
|
|
2
|
+
// Shared by the daemon (src/live-link.js: what it lets through) and the board
|
|
3
|
+
// (ui/slash.js: the "/" suggestions and the /model and /effort pickers).
|
|
4
|
+
//
|
|
5
|
+
// Checked against Claude Code's own command table (2.1.300, the `claude -p`
|
|
6
|
+
// build on npm; /model and /effort run again on 2.1.319): each of these has a non-interactive form (supportsNonInteractive),
|
|
7
|
+
// so in a session Leftoff runs (`claude -p`, stream-json) "/effort high" written
|
|
8
|
+
// as a user message runs as the command, locally, and its output comes back as
|
|
9
|
+
// the turn's result and as a local_command line in the transcript. In a terminal
|
|
10
|
+
// session the mod runs it with $.command.run, the way it already runs /rename,
|
|
11
|
+
// except a command whose terminal form saves your default (saves, below).
|
|
12
|
+
// Bare /model and /effort open a picker in the terminal, so the board asks for
|
|
13
|
+
// the choice itself. /status and the bare pickers need Claude Code's terminal UI
|
|
14
|
+
// and are left out; anything not listed here is refused.
|
|
15
|
+
|
|
16
|
+
export const SLASH_MAX = 500
|
|
17
|
+
|
|
18
|
+
// arg: 'required' | 'optional' | 'none'. choices: what the board's picker offers
|
|
19
|
+
// (Claude Code's own list); strict: only those. tui: false when the terminal's
|
|
20
|
+
// form opens a panel (so the mod path refuses it rather than leave a panel open).
|
|
21
|
+
// saves: the terminal's form also writes it into ~/.claude/settings.json as your
|
|
22
|
+
// default for new sessions (checked on 2.1.319: "Set model to Haiku 5.5 and saved
|
|
23
|
+
// as your default for new sessions"; `claude -p` says "for this session only").
|
|
24
|
+
// Leftoff never writes ~/.claude, so these go only to sessions Leftoff runs.
|
|
25
|
+
export const SLASH_COMMANDS = [
|
|
26
|
+
{ name: 'model', arg: 'required', hint: '<model>', what: 'Switch the model',
|
|
27
|
+
choices: ['default', 'opus', 'sonnet', 'haiku', 'fable', 'best', 'opus[1m]', 'sonnet[1m]', 'opusplan'], saves: true },
|
|
28
|
+
{ name: 'effort', arg: 'required', hint: '<level>', what: 'Set how hard Claude thinks',
|
|
29
|
+
choices: ['low', 'medium', 'high', 'xhigh', 'max', 'auto'], strict: true, saves: true },
|
|
30
|
+
{ name: 'compact', arg: 'optional', hint: '[instructions]', what: 'Summarize the conversation to free up context' },
|
|
31
|
+
{ name: 'clear', arg: 'none', hint: '', what: 'Start over with empty context (the old one stays resumable)' },
|
|
32
|
+
{ name: 'context', arg: 'none', hint: '', what: 'Show how full the context is' },
|
|
33
|
+
{ name: 'usage', aliases: ['cost'], arg: 'none', hint: '', what: 'Show session cost and plan usage', tui: false },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const BY_NAME = new Map()
|
|
37
|
+
for (const c of SLASH_COMMANDS) { BY_NAME.set(c.name, c); for (const a of c.aliases || []) BY_NAME.set(a, c) }
|
|
38
|
+
|
|
39
|
+
export function slashCommand(name) { return BY_NAME.get(String(name || '').toLowerCase()) || null }
|
|
40
|
+
export function isSlashText(text) { return /^\s*\//.test(String(text == null ? '' : text)) }
|
|
41
|
+
export function slashNames() { return SLASH_COMMANDS.map((c) => '/' + c.name) }
|
|
42
|
+
export function allowedText() { return slashNames().join(', ') }
|
|
43
|
+
|
|
44
|
+
const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:\-[\]]{0,79}$/
|
|
45
|
+
// eslint-disable-next-line no-control-regex
|
|
46
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f\u2028\u2029]/
|
|
47
|
+
|
|
48
|
+
function bad(message) { const e = new Error(message); e.status = 400; e.code = 'bad_command'; throw e }
|
|
49
|
+
|
|
50
|
+
// "/effort high" -> { name: 'effort', args: 'high', line: '/effort high', cmd }.
|
|
51
|
+
// One command on one line; leading and trailing spaces are fine; the name is
|
|
52
|
+
// the command's own (an alias becomes it: /cost -> /usage). Throws (400,
|
|
53
|
+
// bad_command) with a message that says what is allowed.
|
|
54
|
+
export function parseSlash(text) {
|
|
55
|
+
const raw = String(text == null ? '' : text)
|
|
56
|
+
const t = raw.replace(/^[ \t]+|[ \t]+$/g, '')
|
|
57
|
+
if (!t.startsWith('/')) bad('a command starts with “/”')
|
|
58
|
+
if (/[\r\n]/.test(t.trim())) bad('one command a message, on one line')
|
|
59
|
+
const s = t.trim()
|
|
60
|
+
if (CONTROL_RE.test(s)) bad('the command has a control character in it')
|
|
61
|
+
if (s.length > SLASH_MAX) bad('the command is over ' + SLASH_MAX + ' characters')
|
|
62
|
+
const m = /^\/([A-Za-z][\w-]*)(?:[ \t]+([\s\S]*))?$/.exec(s)
|
|
63
|
+
if (!m) bad('the board can send ' + allowedText())
|
|
64
|
+
const c = slashCommand(m[1])
|
|
65
|
+
if (!c) bad('/' + m[1] + ' can’t be sent from the board. It can send ' + allowedText())
|
|
66
|
+
const args = (m[2] || '').replace(/[ \t]+/g, ' ').trim()
|
|
67
|
+
if (c.arg === 'none' && args) bad('/' + c.name + ' takes nothing after it')
|
|
68
|
+
if (c.arg === 'required' && !args) bad('/' + c.name + ' needs a choice: ' + c.choices.join(', '))
|
|
69
|
+
if (args.startsWith('/')) bad('one command a message')
|
|
70
|
+
if (c.name === 'model' && !MODEL_RE.test(args)) bad('/model needs one model name, like ' + c.choices.slice(1, 4).join(', '))
|
|
71
|
+
if (c.strict && !c.choices.includes(args.toLowerCase())) bad('/' + c.name + ' takes one of ' + c.choices.join(', '))
|
|
72
|
+
const a = c.strict ? args.toLowerCase() : args
|
|
73
|
+
return { name: c.name, args: a, line: '/' + c.name + (a ? ' ' + a : ''), cmd: c }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The "/" suggestions for what's typed so far ("/ef" -> [effort]).
|
|
77
|
+
export function slashMatches(text) {
|
|
78
|
+
const m = /^\s*\/([\w-]*)$/.exec(String(text == null ? '' : text))
|
|
79
|
+
if (!m) return []
|
|
80
|
+
const q = m[1].toLowerCase()
|
|
81
|
+
return SLASH_COMMANDS.filter((c) => c.name.startsWith(q) || (c.aliases || []).some((a) => a.startsWith(q)))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A bare "/model" or "/effort" (nothing after it): the board shows its picker.
|
|
85
|
+
export function needsPicker(text) {
|
|
86
|
+
const m = /^\s*\/([\w-]+)\s*$/.exec(String(text == null ? '' : text))
|
|
87
|
+
const c = m && slashCommand(m[1])
|
|
88
|
+
return c && c.arg === 'required' && c.choices ? c : null
|
|
89
|
+
}
|
package/ui/slash.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The Live composer's "/" menu (slash commands from the board). Pure functions
|
|
2
|
+
// here, shared with the unit tests; app.js wires them to the page. What can be
|
|
3
|
+
// sent, and the words for it, is ui/slash-commands.js (the daemon checks it too).
|
|
4
|
+
//
|
|
5
|
+
// Typing "/" lists the commands ("/ef" narrows to /effort); picking one fills
|
|
6
|
+
// it in. A command that takes a choice (/model, /effort) then lists its
|
|
7
|
+
// choices ("/effort h" narrows to high). Picking never sends: Enter sends once
|
|
8
|
+
// the menu has nothing more to offer (an exact command or choice closes it).
|
|
9
|
+
// In a terminal session /model and /effort show, disabled, with why: Claude
|
|
10
|
+
// Code's terminal saves them as your default for new sessions.
|
|
11
|
+
|
|
12
|
+
import { slashCommand, slashMatches } from './slash-commands.js'
|
|
13
|
+
|
|
14
|
+
const escS = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
15
|
+
|
|
16
|
+
export const SAVES_WHY = 'In a terminal session this also changes your default for new sessions, so the board doesn’t send it. Type it in the terminal.'
|
|
17
|
+
|
|
18
|
+
// Where commands go for this session: 'headless' (Leftoff runs it: every
|
|
19
|
+
// command, for this session only), 'terminal' (the mod runs it: not /model or
|
|
20
|
+
// /effort), or null (Codex, or nothing to run it: no menu).
|
|
21
|
+
export function slashTarget(s) {
|
|
22
|
+
if (!s || s.codingAgent === 'codex' || s.capture === 'codex' || s.endedAt) return null
|
|
23
|
+
if (s.headless) return 'headless'
|
|
24
|
+
if (s.capture === 'mod') return 'terminal'
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// text -> null (no menu) or { kind: 'commands' | 'choices', cmd?, items: [{ value, label, hint, what, off }] }.
|
|
29
|
+
// value: the composer's text after a pick. off: why it can't be picked here (null when it can).
|
|
30
|
+
export function slashMenu(text, { target = 'headless' } = {}) {
|
|
31
|
+
if (!target) return null
|
|
32
|
+
const t = String(text == null ? '' : text)
|
|
33
|
+
const offFor = (c) => (target === 'terminal' && c.saves ? SAVES_WHY : null)
|
|
34
|
+
const m = /^\s*\/([\w-]*)$/.exec(t)
|
|
35
|
+
if (m) {
|
|
36
|
+
const list = slashMatches(t)
|
|
37
|
+
if (!list.length) return null
|
|
38
|
+
// an exact command that takes nothing: the menu has nothing to add, Enter sends it
|
|
39
|
+
const exact = slashCommand(m[1])
|
|
40
|
+
if (exact && exact.arg === 'none' && list.length === 1 && (exact.name === m[1].toLowerCase() || (exact.aliases || []).includes(m[1].toLowerCase()))) return null
|
|
41
|
+
return { kind: 'commands', items: list.map((c) => ({ value: '/' + c.name + (c.arg === 'none' ? '' : ' '), label: '/' + c.name, hint: c.hint || '', what: c.what, off: offFor(c) })) }
|
|
42
|
+
}
|
|
43
|
+
const a = /^\s*\/([\w-]+)[ \t]+(\S*)$/.exec(t)
|
|
44
|
+
const c = a && slashCommand(a[1])
|
|
45
|
+
if (!c || !c.choices) return null
|
|
46
|
+
if (offFor(c)) return { kind: 'choices', cmd: c.name, items: [{ value: t, label: '/' + c.name, hint: '', what: c.what, off: offFor(c) }] }
|
|
47
|
+
const q = a[2].toLowerCase()
|
|
48
|
+
const items = c.choices.filter((x) => x.toLowerCase().startsWith(q))
|
|
49
|
+
if (!items.length || (items.length === 1 && items[0].toLowerCase() === q)) return null // nothing to pick, or already picked
|
|
50
|
+
return { kind: 'choices', cmd: c.name, items: items.map((x) => ({ value: '/' + c.name + ' ' + x, label: x, hint: '', what: '', off: null })) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The highlighted item after a key: ArrowDown / ArrowUp wrap, skipping disabled ones. -> index or -1
|
|
54
|
+
export function slashMove(menu, active, key) {
|
|
55
|
+
const items = (menu && menu.items) || []
|
|
56
|
+
const ok = items.map((x, i) => (x.off ? -1 : i)).filter((i) => i >= 0)
|
|
57
|
+
if (!ok.length) return -1
|
|
58
|
+
const at = ok.indexOf(active)
|
|
59
|
+
if (key === 'ArrowDown') return ok[at < 0 ? 0 : (at + 1) % ok.length]
|
|
60
|
+
if (key === 'ArrowUp') return ok[at < 0 ? ok.length - 1 : (at - 1 + ok.length) % ok.length]
|
|
61
|
+
return at < 0 ? ok[0] : active
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// What a key does with the menu open: 'move' | 'pick' | 'close' | null (the composer's own).
|
|
65
|
+
export function slashKey(ev) {
|
|
66
|
+
if (!ev || ev.isComposing || ev.keyCode === 229) return null
|
|
67
|
+
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') return ev.altKey || ev.metaKey || ev.ctrlKey ? null : 'move'
|
|
68
|
+
if ((ev.key === 'Enter' && !ev.shiftKey && !ev.metaKey && !ev.ctrlKey && !ev.altKey) || (ev.key === 'Tab' && !ev.shiftKey)) return 'pick'
|
|
69
|
+
if (ev.key === 'Escape') return 'close'
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The listbox, above the composer. id: the listbox's id (the textarea's aria-controls);
|
|
74
|
+
// each option's id is id + '-' + index (aria-activedescendant).
|
|
75
|
+
export function slashMenuHtml(menu, { active = -1, id = 'slashMenu', esc = escS } = {}) {
|
|
76
|
+
if (!menu || !menu.items.length) return ''
|
|
77
|
+
const head = menu.kind === 'choices' ? '<div class="slash-h" aria-hidden="true">' + esc('/' + menu.cmd) + '</div>' : ''
|
|
78
|
+
return '<div class="slash-menu" role="listbox" id="' + esc(id) + '" aria-label="' + esc(menu.kind === 'choices' ? 'Choices for /' + menu.cmd : 'Commands') + '">' + head +
|
|
79
|
+
menu.items.map((x, i) => '<div class="slash-opt' + (x.off ? ' off' : '') + '" role="option" id="' + esc(id + '-' + i) + '" data-i="' + i + '" data-value="' + esc(x.value) + '" aria-selected="' + (i === active ? 'true' : 'false') + '"' + (x.off ? ' aria-disabled="true" data-tip="' + esc(x.off) + '"' : '') + '>' +
|
|
80
|
+
'<span class="slash-l">' + esc(x.label) + (x.hint ? ' <span class="slash-hint">' + esc(x.hint) + '</span>' : '') + '</span>' +
|
|
81
|
+
(x.what ? '<span class="slash-w">' + esc(x.off ? 'Not in a terminal session' : x.what) + '</span>' : '') + '</div>').join('') + '</div>'
|
|
82
|
+
}
|
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/tabs.js
CHANGED
|
@@ -222,6 +222,13 @@ export function stateMark(v, esc) {
|
|
|
222
222
|
// 0.9.17 the walkthrough's Look-closely count: a small eye and a number, as quiet as the
|
|
223
223
|
// status word (not a badge), only when there's something; the words in the tip.
|
|
224
224
|
const EYE = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>'
|
|
225
|
+
// 0.9.18 the strip: a closable tab's shape sits in the ×'s place (the × shows on hover or focus),
|
|
226
|
+
// so a busy strip keeps room for titles; the word is in the tab's tip and its label. A pinned tab
|
|
227
|
+
// (no ×) and a touch screen (no hover) keep the shape beside the title. Shapes differ per state.
|
|
228
|
+
export function slotMark(v, esc) {
|
|
229
|
+
const st = stateOf(v)
|
|
230
|
+
return st !== 'quiet' ? glyphHtml(st, { esc, hidden: true, noTip: true, cls: 'tab-sg tab-xsg' }) : ''
|
|
231
|
+
}
|
|
225
232
|
export function lookMark(v, esc) {
|
|
226
233
|
const n = Number(v && v.look) || 0
|
|
227
234
|
if (n <= 0) return ''
|
|
@@ -240,12 +247,12 @@ export function stripHtml(views, activeId, onBoard, esc) {
|
|
|
240
247
|
const tabs = views.map((v) => {
|
|
241
248
|
const on = v.id === activeId
|
|
242
249
|
return '<div class="tab-cell' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + (v.pinned ? ' pinned' : '') + '" data-tab-id="' + esc(v.id) + '">' +
|
|
243
|
-
'<a class="tab tab-s' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + '" href="' + esc(v.href) + '" data-tab="' + esc(v.id) + '"' + (on ? ' aria-current="true"' : '') + ' data-tip="' + esc(v.title + (v.pinned ? ' · pinned' : '') + (v.preview ? ' · preview: double-click to keep it open' : '')) + '" aria-label="' + esc((v.pinned ? 'Pinned: ' : '') + label(v)) + '" aria-keyshortcuts="Alt+Shift+ArrowLeft Alt+Shift+ArrowRight" draggable="false">' +
|
|
244
|
-
(v.pinned ? PIN_GLYPH : '') + '<span class="tab-t">' + esc(v.title) + '</span>' +
|
|
250
|
+
'<a class="tab tab-s' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + '" href="' + esc(v.href) + '" data-tab="' + esc(v.id) + '"' + (on ? ' aria-current="true"' : '') + ' data-tip="' + esc(v.title + (SHORT[stateOf(v)] ? ' · ' + SHORT[stateOf(v)] : '') + (v.pinned ? ' · pinned' : '') + (v.preview ? ' · preview: double-click to keep it open' : '')) + '" aria-label="' + esc((v.pinned ? 'Pinned: ' : '') + label(v)) + '" aria-keyshortcuts="Alt+Shift+ArrowLeft Alt+Shift+ArrowRight" draggable="false">' +
|
|
251
|
+
(v.pinned ? PIN_GLYPH : '') + '<span class="tab-t">' + esc(v.title) + '</span>' + '<span class="tab-st' + (v.pinned ? '' : ' tab-st-touch') + '">' + slotMark(v, esc) + '</span>' + lookMark(v, esc) + '</a>' +
|
|
245
252
|
(v.preview
|
|
246
253
|
? '<button type="button" class="tab-x tab-keep" data-tab-keep="' + esc(v.id) + '" data-tip="Keep open" aria-label="' + esc('Keep ' + v.title + ' open') + '">' + PIN + '</button>'
|
|
247
254
|
: '') +
|
|
248
|
-
(v.pinned ? '' : '<button type="button" class="tab-x" data-tab-close="' + esc(v.id) + '" data-tip="Close tab" aria-label="' + esc('Close ' + v.title) + '">' + X + '</button>') + // a pinned tab stays: Unpin is on its right-click
|
|
255
|
+
(v.pinned ? '' : '<button type="button" class="tab-x' + (slotMark(v, esc) ? ' has-st' : '') + '" data-tab-close="' + esc(v.id) + '" data-tip="Close tab" aria-label="' + esc('Close ' + v.title) + '">' + slotMark(v, esc) + X + '</button>') + // a pinned tab stays: Unpin is on its right-click
|
|
249
256
|
'</div>'
|
|
250
257
|
}).join('')
|
|
251
258
|
// 0.9.16: + New session right after the open tabs
|
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
|
+
}
|