@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
|
@@ -0,0 +1,73 @@
|
|
|
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
|
|
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
|
+
}
|
package/ui/walk-pop.js
CHANGED
|
@@ -67,6 +67,9 @@ export function popContent(kind, { size = null, away = false } = {}) {
|
|
|
67
67
|
? { icon: 'circle-dashed', text: label + ' · no changes yet, so nothing to walk through', dim: true }
|
|
68
68
|
: { icon: 'git-compare', text: label + ' · ' + plural(size.files, 'file') })
|
|
69
69
|
}
|
|
70
|
+
// 2026-11-03 what was already changed when the session started isn't counted: said quietly
|
|
71
|
+
if (w && size.excluded > 0) lines.push({ icon: 'circle-dashed', text: plural(size.excluded, 'file') + ' changed before this session, left out', dim: true })
|
|
72
|
+
else if (w && size.fallback) lines.push({ icon: 'circle-dashed', text: 'Only the files this session edited', dim: true })
|
|
70
73
|
if (size && size.writer) lines.push({ icon: 'bot', text: 'Written by ' + size.writer })
|
|
71
74
|
return {
|
|
72
75
|
title: 'Walk me through the changes',
|
package/ui/what-it-did.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// app.js) so they can be checked without a browser.
|
|
6
6
|
import { icon } from './icons.js'
|
|
7
7
|
import { diffBlocks, splitPath } from './diff-view.js'
|
|
8
|
+
import { fileScopeSegHtml, scopeTagHtml, unreadableLines } from './semdiff.js' // 2026-11-03 This session · Whole folder, the walkthrough's own rule
|
|
8
9
|
|
|
9
10
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
10
11
|
const plural = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
|
|
@@ -63,12 +64,12 @@ export function fileRowHtml(f, ui, sid) {
|
|
|
63
64
|
const bodyId = 'wid-f-' + esc(encodeURIComponent(f.path).replace(/%/g, '_'))
|
|
64
65
|
const bar = '<span class="gd-bar" aria-hidden="true">' + diffBlocks(f.add, f.del).map((b) => '<i class="gd-b' + b + '"></i>').join('') + '</span>'
|
|
65
66
|
const counts = f.binary ? '<span class="wid-n">binary</span>' : f.big ? '<span class="wid-n">big</span>' : '<span class="wid-add">+' + esc(f.add) + '</span> <span class="wid-del">−' + esc(f.del) + '</span>' + bar
|
|
66
|
-
let h = '<li class="wid-item' + (open ? ' open' : '') + '">'
|
|
67
|
+
let h = '<li class="wid-item' + (open ? ' open' : '') + (f.notSession ? ' nots' : '') + '">'
|
|
67
68
|
h += '<button type="button" class="wid-row wid-file" data-action="wid-file" data-id="' + esc(sid) + '" data-path="' + esc(f.path) + '" aria-expanded="' + open + '" aria-controls="' + bodyId + '">'
|
|
68
69
|
h += '<span class="wid-chev" aria-hidden="true">' + icon(open ? 'chevron-down' : 'chevron-right') + '</span>'
|
|
69
70
|
h += '<span class="wid-st st-' + esc(f.status) + '" data-tip="' + esc(word) + '" aria-label="' + esc(word) + '">' + letter + '</span>'
|
|
70
71
|
const [dir, base] = splitPath(f.path)
|
|
71
|
-
h += '<span class="wid-path mono">' + (dir ? '<span class="wid-dir">' + esc(dir) + '</span>' : '') + '<b class="wid-base">' + esc(base) + '</b
|
|
72
|
+
h += '<span class="wid-path mono">' + (dir ? '<span class="wid-dir">' + esc(dir) + '</span>' : '') + '<b class="wid-base">' + esc(base) + '</b>' + scopeTagHtml(f, { esc }) + '</span><span class="wid-counts">' + counts + '</span></button>'
|
|
72
73
|
if (open) {
|
|
73
74
|
const v = ui.files[f.path]
|
|
74
75
|
h += '<div class="wid-out" id="' + bodyId + '">'
|
|
@@ -91,16 +92,22 @@ export function fileRowHtml(f, ui, sid) {
|
|
|
91
92
|
|
|
92
93
|
function diffHtml(ui, sid) {
|
|
93
94
|
const d = ui.diff
|
|
94
|
-
|
|
95
|
+
// 2026-11-03 scoped like the walkthrough: This session (the default) or Whole folder, counts on both
|
|
96
|
+
const fsc = d && d.fileScope && d.fileScope.session != null ? d.fileScope : null
|
|
97
|
+
const title = fsc && fsc.key !== 'folder' ? 'This session’s changes' : 'This folder’s changes'
|
|
98
|
+
let h = '<div class="wid-sub"><h3>' + title + '</h3>'
|
|
95
99
|
if (d && d.folder && !d.tooBig && !d.noRepo) h += '<span class="wid-meta">' + esc(d.folder) + (d.totals && d.totals.files ? ' · ' + plural(d.totals.files, 'file') + ' · <span class="wid-add">+' + esc(d.totals.add) + '</span> <span class="wid-del">−' + esc(d.totals.del) + '</span>' : '') + '</span>'
|
|
96
100
|
h += '</div>'
|
|
101
|
+
if (fsc) h += '<div class="wid-scope">' + fileScopeSegHtml(fsc, { esc, icon, action: 'wid-files', busy: !!ui.loading }) + '</div>'
|
|
97
102
|
if (ui.diffError) return h + '<p class="wid-note">' + esc(ui.diffError) + '</p>'
|
|
98
103
|
if (!d) return h + '<p class="wid-note">Loading…</p>'
|
|
99
104
|
if (d.tooBig) return h + '<p class="wid-note">Too big to show here.</p>'
|
|
100
105
|
if (d.noRepo) return h + '<p class="wid-note">' + (d.noRepo === 'not_a_repo' ? 'This folder isn’t a git repository.' : d.noRepo === 'no_git' ? 'git isn’t installed on that Mac.' : 'This session’s folder isn’t there any more.') + '</p>'
|
|
101
|
-
|
|
102
|
-
h
|
|
106
|
+
const notes = unreadableLines({ baseNotes: d.baseNotes }).map((l) => '<p class="wid-note wid-scope-n">' + icon('info') + '<span>' + esc(l) + '</span></p>').join('')
|
|
107
|
+
if (!d.files || !d.files.length) return h + notes + '<p class="wid-note">' + (fsc && fsc.key !== 'folder' ? (fsc.folder ? 'No uncommitted changes from this session (' + plural(fsc.folder, 'file') + ' in the whole folder).' : 'No uncommitted changes from this session.') : 'No uncommitted changes.') + '</p>'
|
|
108
|
+
h += notes + '<ul class="wid-list">' + d.files.map((f) => fileRowHtml(f, ui, sid)).join('') + '</ul>'
|
|
103
109
|
if (d.truncated) h += '<p class="wid-note">Showing the first ' + d.files.length + ' of ' + esc(d.totals.files) + ' files.</p>'
|
|
110
|
+
if (fsc && fsc.key !== 'folder') return h + '<p class="wid-foot">Uncommitted changes this session made in its folder. Whole folder shows everything, other sessions’ changes too.</p>'
|
|
104
111
|
return h + '<p class="wid-foot">Uncommitted changes in the session’s folder (staged, unstaged and new files). Other sessions in this folder show the same changes.</p>'
|
|
105
112
|
}
|
|
106
113
|
|