@leftoff/cli 0.9.18-beta.10 → 0.9.18-beta.12
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 +8 -0
- package/bin/leftoff-statusline.js +4 -4
- package/bin/leftoff.js +576 -553
- package/docs/SECURITY.md +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +50 -12
- package/mod/hooks/register.js +11 -3
- package/package.json +1 -1
- package/ui/app.css +58 -2
- package/ui/app.js +194 -11
- package/ui/chip-fade.js +1 -1
- package/ui/decisions.js +15 -5
- package/ui/fs-side.js +29 -0
- package/ui/live-view.js +16 -2
- package/ui/prs.js +28 -10
- package/ui/pull.js +14 -1
- package/ui/semdiff.js +20 -6
- package/ui/sglyph.js +2 -2
- package/ui/slash-commands.js +89 -0
- package/ui/slash.js +82 -0
- package/ui/tabs.js +10 -3
- package/ui/viewport-fit.js +14 -0
package/ui/pull.js
CHANGED
|
@@ -7,7 +7,7 @@ export const PULL_MAX = 110 // how far the spinner travels at most
|
|
|
7
7
|
|
|
8
8
|
// Where a pull never starts: text fields, the Live box, menus and sheets,
|
|
9
9
|
// sideways scrollers, and anything open on top of the page.
|
|
10
|
-
export const PULL_SKIP = 'input, textarea, [contenteditable=""], [contenteditable="true"], .live-box, lo-select, lo-combo, .lo-menu, .lo-backdrop, .cmdk, .quicklook, .moremenu, .statuspop, .doc-viewer, .emoji-sheet-wrap, .relay-screen, .tabs, pre, .scroll-x, [data-no-pull]'
|
|
10
|
+
export const PULL_SKIP = 'input, textarea, [contenteditable=""], [contenteditable="true"], .live-box, lo-select, lo-combo, .lo-menu, .lo-backdrop, .cmdk, .quicklook, .moremenu, .statuspop, .doc-viewer, .emoji-sheet-wrap, .relay-screen, .tabs, pre, .scroll-x, .fs-side, [data-no-pull]'
|
|
11
11
|
|
|
12
12
|
// The phone layout on a touch screen, at the very top of the page, from a
|
|
13
13
|
// place that isn't a field or an overlay, and with no overlay open.
|
|
@@ -15,9 +15,22 @@ export function canStartPull({ coarse, narrow, scrollY, target, overlayOpen } =
|
|
|
15
15
|
if (!coarse || !narrow || overlayOpen) return false
|
|
16
16
|
if (scrollY > 0) return false
|
|
17
17
|
if (target && typeof target.closest === 'function' && target.closest(PULL_SKIP)) return false
|
|
18
|
+
if (scrolledAbove(target)) return false
|
|
18
19
|
return true
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
// 2026-11-06 (the phone's session sheet "scrolls down okay, but I cannot scroll back up. It's very
|
|
23
|
+
// jumpy"): in full screen the page itself never scrolls (scrollY is 0), so a finger moving down
|
|
24
|
+
// inside the sheet's own scroller was taken as a pull to refresh: the board cancelled the scroll and
|
|
25
|
+
// showed the spinner instead. A pull only starts where nothing between the finger and the page is
|
|
26
|
+
// itself scrolled down: a scroller with room above means the finger is scrolling it back up.
|
|
27
|
+
export function scrolledAbove(el) {
|
|
28
|
+
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
|
29
|
+
if ((Number(n.scrollTop) || 0) > 0) return true
|
|
30
|
+
}
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
// One finger movement: how far the spinner sits (with some resistance), how
|
|
22
35
|
// far round it is, and whether letting go now refreshes. Sideways first means
|
|
23
36
|
// it isn't a pull.
|
package/ui/semdiff.js
CHANGED
|
@@ -371,11 +371,25 @@ function fileMapHtml(data, { esc, icon, cur }) {
|
|
|
371
371
|
return h + '<div class="wt-fm-k"><span class="kbd">j</span><span class="kbd">k</span> next step</div></nav>'
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
// 2026-11-06 every PR the session has is its own range ('pr:<owner/repo>#<n>'): up to PR_CHIPS of them are
|
|
375
|
+
// chips; more fold into one "PRs" chip that opens a menu (the one picked shows its number)
|
|
376
|
+
export const PR_CHIPS = 2
|
|
377
|
+
export const isPrKey = (k) => k === 'pr' || /^pr:/.test(String(k || ''))
|
|
378
|
+
const PR_WORD = { merged: 'merged', closed: 'closed', draft: 'draft' }
|
|
379
|
+
export function prScopeTip(x) { return [x.title, x.repo, x.state ? (x.state === 'open' ? 'Open' : x.state[0].toUpperCase() + x.state.slice(1)) : ''].filter(Boolean).join(' · ') }
|
|
374
380
|
function scopesHtml(data, scope, { esc, icon, sid }) {
|
|
375
381
|
const sc = (data && data.scopes) || [{ key: 'checkpoint', label: SCOPE_WORDS.checkpoint, available: false }, { key: 'session', label: SCOPE_WORDS.session, available: true }]
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
(x.key
|
|
382
|
+
const prs = sc.filter((x) => isPrKey(x.key)), rest = sc.filter((x) => !isPrKey(x.key))
|
|
383
|
+
const chip = (x) => '<button type="button" data-action="wt-scope" data-id="' + esc(sid) + '" data-scope="' + esc(x.key) + '" aria-pressed="' + (x.key === scope ? 'true' : 'false') + '"' + (x.available === false ? ' disabled data-tip="No checkpoint yet"' : isPrKey(x.key) && prScopeTip(x) ? ' data-tip="' + esc(prScopeTip(x)) + '"' : '') + '>' +
|
|
384
|
+
(isPrKey(x.key) ? icon('git-pull-request') : '') + '<span>' + esc(x.label) + (PR_WORD[x.state] ? ' · ' + PR_WORD[x.state] : '') + '</span></button>'
|
|
385
|
+
let h = rest.map(chip).join('')
|
|
386
|
+
if (prs.length <= PR_CHIPS) h += prs.map(chip).join('')
|
|
387
|
+
else {
|
|
388
|
+
const cur = prs.find((x) => x.key === scope)
|
|
389
|
+
h += '<button type="button" data-action="wt-prs" data-id="' + esc(sid) + '" aria-pressed="' + (cur ? 'true' : 'false') + '" aria-haspopup="menu" aria-expanded="false" aria-label="' + esc(cur ? cur.label + ', pick another of ' + prs.length + ' PRs' : 'Pick one of ' + prs.length + ' PRs') + '"' + (cur && prScopeTip(cur) ? ' data-tip="' + esc(prScopeTip(cur)) + '"' : '') + '>' +
|
|
390
|
+
icon('git-pull-request') + '<span>' + esc(cur ? cur.label + (PR_WORD[cur.state] ? ' · ' + PR_WORD[cur.state] : '') : 'PRs · ' + prs.length) + '</span>' + icon('chevron-down', 'ic wt-seg-chev') + '</button>'
|
|
391
|
+
}
|
|
392
|
+
return '<div class="wt-seg" role="group" aria-label="What to walk through">' + h + '</div>'
|
|
379
393
|
}
|
|
380
394
|
|
|
381
395
|
// 2026-10-30 (asked: "the left side file list should be somewhat collapsible"): folded to a
|
|
@@ -464,7 +478,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
|
|
|
464
478
|
if (ui.loading) h += '<p class="wt-sum wt-loading" role="status"><span class="wt-spin" aria-hidden="true"></span><span>Reading the change' + (data && data.status === 'ok' ? '' : '…') + '</span><button type="button" class="linkish wt-stop" data-action="wt-stop" data-id="' + esc(sid) + '">Stop</button></p>'
|
|
465
479
|
if (ui.error) h += '<p class="wt-note" role="alert">' + icon('circle-alert') + '<span>' + esc(ui.error) + '</span></p>'
|
|
466
480
|
if (data && !ui.loading && data.status !== 'ok' && data.message) {
|
|
467
|
-
const msg = data.status === 'empty' && scope === 'session' ? 'No changes in this session yet.' : data.status === 'empty' && scope
|
|
481
|
+
const msg = data.status === 'empty' && scope === 'session' ? 'No changes in this session yet.' : data.status === 'empty' && isPrKey(scope) ? 'No changes in this PR yet.' : data.message
|
|
468
482
|
h += '<p class="wt-note">' + icon('info') + '<span>' + esc(msg) + (data.status === 'no_model' ? ' <a href="#/settings/summaries">Settings</a>' : '') + '</span></p>'
|
|
469
483
|
h += unreadableHtml(data, { esc, icon }) // 2026-10-29 a repo that couldn't be read says so
|
|
470
484
|
// a fallback says why (the first reason; the model's whole reply: leftoff walkthrough debug)
|
|
@@ -473,7 +487,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
|
|
|
473
487
|
if (data.status === 'fallback' && data.small) h += '<p class="faint wt-small">' + icon('info') + '<span>Small local models struggle here. Try Claude or qwen2.5-coder 7B in <a href="#/settings/models">Settings</a>.</span></p>'
|
|
474
488
|
}
|
|
475
489
|
// 2026-11-03 switched to the other scope with nothing written for it: never a model run on its own; Write it is one tap
|
|
476
|
-
if (data && !ui.loading && data.status === 'none' && view === 'story') h += '<p class="wt-stale" role="status"><i class="wt-chg-g" aria-hidden="true"></i><span>' + (data.fileScope && data.fileScope.key === 'folder' ? 'No walkthrough of the whole folder yet.' : 'No walkthrough of this session’s changes yet.') + ' The files are under Files.</span><button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '">' + icon('refresh-cw') + 'Write it</button></p>'
|
|
490
|
+
if (data && !ui.loading && data.status === 'none' && view === 'story') h += '<p class="wt-stale" role="status"><i class="wt-chg-g" aria-hidden="true"></i><span>' + (isPrKey(ui.scope) ? 'No walkthrough of this PR yet.' : data.fileScope && data.fileScope.key === 'folder' ? 'No walkthrough of the whole folder yet.' : 'No walkthrough of this session’s changes yet.') + ' The files are under Files.</span><button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '">' + icon('refresh-cw') + 'Write it</button></p>'
|
|
477
491
|
const deck = ok && view === 'story' && ui.deck !== false && (data.steps || []).length > 0 // 0.9.18 the slideshow
|
|
478
492
|
if (ok && view === 'story' && !deck) {
|
|
479
493
|
const steps = data.steps || []
|
|
@@ -562,7 +576,7 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
|
|
|
562
576
|
let h = '<div class="wt-deck" id="wtDeck" data-cur="' + cur + '" data-n="' + total + '" tabindex="-1" aria-roledescription="slideshow" aria-label="Walkthrough slides">'
|
|
563
577
|
// the overview
|
|
564
578
|
const st = data.stats || {}
|
|
565
|
-
const scope = SCOPE_WORDS[ui.scope] || ''
|
|
579
|
+
const scope = SCOPE_WORDS[ui.scope] || (((data.scopes || []).find((x) => x.key === ui.scope) || {}).label) || ''
|
|
566
580
|
const pm = Array.isArray(data.files) && data.files.length ? data.files.reduce((a, f) => ({ add: a.add + (f.add || 0), del: a.del + (f.del || 0) }), { add: 0, del: 0 }) : null
|
|
567
581
|
let o = '<p class="wt-sum big"><b>' + esc(data.headline) + '</b></p>' + (data.summary ? '<p class="wt-sum md-in">' + walkInlineLinks(data.summary) + '</p>' : '')
|
|
568
582
|
o += '<div class="wt-meta">' + (scope ? '<span>' + icon('git-compare') + esc(scope) + '</span>' : '') + '<span>' + plural(st.files || 0, 'file') + (pm ? ' <b class="wt-pl">+' + n0(pm.add) + '</b> <b class="wt-mi">−' + n0(pm.del) + '</b>' : '') + '</span><span>' + plural(steps.length, 'step') + '</span><span id="wtProg">' + steps.filter((x) => reviewed.has(x.id)).length + ' of ' + steps.length + ' reviewed</span></div>'
|
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/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/viewport-fit.js
CHANGED
|
@@ -57,3 +57,17 @@ export const SETTLE_AFTER_BLUR_MS = [0, 120, 350, 700, 1100]
|
|
|
57
57
|
// The same beats after every report while a text box has focus: a keyboard swap's last change can
|
|
58
58
|
// come with no event of its own, so the board looks again a few times after each one.
|
|
59
59
|
export const SETTLE_WHILE_TYPING_MS = SETTLE_AFTER_BLUR_MS
|
|
60
|
+
|
|
61
|
+
// 2026-11-06 (the phone's session sheet covered the reply box while typing; "sometimes I have to
|
|
62
|
+
// scroll that down, and then the message box appears"): the sheet went away only when the board
|
|
63
|
+
// measured a keyboard's worth of lost height (lf-kb), and some keyboards (a third-party one coming up
|
|
64
|
+
// late or in steps, or iOS shrinking the window along with the keyboard) never report that. Now the
|
|
65
|
+
// sheet steps aside whenever a text box outside the sheet has focus, measured or not, and comes back
|
|
66
|
+
// a beat after it lets go (so the tap that closed the keyboard lands where it was aimed, not on a
|
|
67
|
+
// sheet sliding in under the finger). A text box inside the sheet keeps it.
|
|
68
|
+
export const SHEET_BACK_AFTER_BLUR_MS = 400
|
|
69
|
+
// typing: a text box has focus; inSheet: that box is in the sheet; blurredMs: ms since a text box outside the sheet let go (null: none lately)
|
|
70
|
+
export function sheetAside({ typing = false, inSheet = false, blurredMs = null } = {}) {
|
|
71
|
+
if (typing) return !inSheet
|
|
72
|
+
return blurredMs != null && blurredMs >= 0 && blurredMs < SHEET_BACK_AFTER_BLUR_MS
|
|
73
|
+
}
|