@leftoff/cli 0.9.18-beta.8 → 0.9.18-beta.9
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 +4 -0
- package/bin/leftoff-statusline.js +9 -0
- package/bin/leftoff.js +511 -511
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +3 -3
- package/package.json +1 -1
- package/ui/app.js +36 -11
- package/ui/live-status.js +27 -2
- package/ui/viewport-fit.js +22 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
3
|
+
"version": "0.9.18-beta.9",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "One Silly App"
|
package/mod/hooks/register.js
CHANGED
|
@@ -32,7 +32,7 @@ const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can
|
|
|
32
32
|
const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
|
|
33
33
|
|
|
34
34
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
35
|
-
const MOD_VERSION = '0.9.18-beta.
|
|
35
|
+
const MOD_VERSION = '0.9.18-beta.9' // keep in step with .claude-plugin/plugin.json
|
|
36
36
|
|
|
37
37
|
const st = {
|
|
38
38
|
internal: false,
|
|
@@ -495,8 +495,8 @@ async function decisionContext($, e) {
|
|
|
495
495
|
|
|
496
496
|
// 1b /cp-decisions: the same text SessionStart adds (src/decisions.js decisionContextText, from the daemon),
|
|
497
497
|
// shown to you and handed to Claude as the command's context. The daemon notes that this session was given them.
|
|
498
|
-
// Runs where the classic hooks are skipped: a command is not a classic event (Claude Code
|
|
499
|
-
//
|
|
498
|
+
// Runs where the classic hooks are skipped: a command is not a classic event (Claude Code's Security default
|
|
499
|
+
// hooks classic.* but not command.run; checked on 2.1.317 and 2.1.318).
|
|
500
500
|
async function decisionsCommand($) {
|
|
501
501
|
if (st.internal) return { text: 'Leftoff: not in Leftoff\'s own runs.' }
|
|
502
502
|
let cwd = null
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
3
|
+
"version": "0.9.18-beta.9",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// /v1 API on the same origin (auth rides the cookie set at GET /).
|
|
3
3
|
import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
|
|
4
4
|
import { icon } from './icons.js'
|
|
5
|
-
import { fullViewportFit, isTypingTarget, SETTLE_AFTER_BLUR_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes
|
|
5
|
+
import { fullViewportFit, isTypingTarget, outOfView, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
|
|
6
6
|
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet, openProjectSheet, refreshActionMenu } from './picker.js'
|
|
7
7
|
import { sheetRepos, sheetPlan, runSheetPlan, askScopeItems } from './project-sheet.js' // 0.9.12: New project with several repos
|
|
8
8
|
import { watchChipFades } from './chip-fade.js' // 0.9.12: chip rows fade only where chips run off
|
|
@@ -3451,23 +3451,48 @@ function fitFullViewport() {
|
|
|
3451
3451
|
html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
|
|
3452
3452
|
// the keyboard's gone: the page under full screen back where it was before the text box took focus
|
|
3453
3453
|
if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
|
|
3454
|
+
if (typing) keepTypingInView()
|
|
3455
|
+
}
|
|
3456
|
+
// 2026-11-04 (switching to another keyboard mid-typing hid the composer, and switching back didn't
|
|
3457
|
+
// bring it back): while you type, check where the composer really is against what you can see, from
|
|
3458
|
+
// this moment's numbers only, and if it isn't in view move full screen by the difference.
|
|
3459
|
+
// (Since 0.9.16 a session always opens in full screen, so that's the Live composer's only home.)
|
|
3460
|
+
function keepTypingInView() {
|
|
3461
|
+
const vv = window.visualViewport, el = document.activeElement, html = document.documentElement
|
|
3462
|
+
if (!vv || !html.classList.contains('live-full') || !isTypingTarget(el) || !el.closest) return
|
|
3463
|
+
const box = el.closest('.live-box.full')
|
|
3464
|
+
if (!box) return
|
|
3465
|
+
const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv)
|
|
3466
|
+
if (d) html.style.setProperty('--lf-top', Math.round((parseFloat(html.style.getPropertyValue('--lf-top')) || 0) - d) + 'px')
|
|
3467
|
+
}
|
|
3468
|
+
// Every report while you type (a keyboard opening, swapping, changing height, or iOS scrolling the
|
|
3469
|
+
// page under it) fits full screen then and looks again a few beats after: a swap's last change can
|
|
3470
|
+
// come with no report of its own, and a passing bogus one is put right by the next look.
|
|
3471
|
+
function onViewportChange(ev) {
|
|
3472
|
+
fitFullViewport()
|
|
3473
|
+
if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) settleWhileTyping(1)
|
|
3474
|
+
}
|
|
3475
|
+
function settleWhileTyping(from = 0) {
|
|
3476
|
+
clearTimeout(lfKb.settle)
|
|
3477
|
+
if (from >= SETTLE_WHILE_TYPING_MS.length) return
|
|
3478
|
+
let i = from
|
|
3479
|
+
const step = () => {
|
|
3480
|
+
if (!isTypingTarget(document.activeElement)) return
|
|
3481
|
+
fitFullViewport()
|
|
3482
|
+
if (++i < SETTLE_WHILE_TYPING_MS.length) lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - SETTLE_WHILE_TYPING_MS[i - 1])
|
|
3483
|
+
}
|
|
3484
|
+
lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - (i ? SETTLE_WHILE_TYPING_MS[i - 1] : 0))
|
|
3454
3485
|
}
|
|
3455
|
-
if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev,
|
|
3456
|
-
|
|
3486
|
+
if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, onViewportChange)
|
|
3487
|
+
window.addEventListener('scroll', (ev) => { if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) onViewportChange(ev) }, { passive: true })
|
|
3488
|
+
for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, onViewportChange)
|
|
3457
3489
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') fitFullViewport() })
|
|
3458
3490
|
document.addEventListener('focusin', (e) => {
|
|
3459
3491
|
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3460
|
-
clearTimeout(lfKb.settle)
|
|
3461
3492
|
if (lfKb.scroll == null) lfKb.scroll = window.scrollY
|
|
3462
3493
|
// 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
|
|
3463
3494
|
// visual viewport without a resize event of its own: measure again as it opens, as on the way down
|
|
3464
|
-
|
|
3465
|
-
const step = () => {
|
|
3466
|
-
if (!isTypingTarget(document.activeElement)) return
|
|
3467
|
-
fitFullViewport()
|
|
3468
|
-
if (++i < SETTLE_AFTER_BLUR_MS.length) lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[i] - SETTLE_AFTER_BLUR_MS[i - 1])
|
|
3469
|
-
}
|
|
3470
|
-
lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[0])
|
|
3495
|
+
settleWhileTyping(0)
|
|
3471
3496
|
})
|
|
3472
3497
|
// A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
|
|
3473
3498
|
// goes down, whatever iOS reports or doesn't on the way.
|
package/ui/live-status.js
CHANGED
|
@@ -44,17 +44,42 @@ function resetWords(raw) {
|
|
|
44
44
|
const m = raw.match(/\bresets?\s+(?:at\s+)?([^·|\n.]{1,40})/i) // "You've hit your limit · resets 7am (America/Chicago)"
|
|
45
45
|
return m ? 'resets ' + ago(m[1]).replace(/\s*\(.*$/, '') : ''
|
|
46
46
|
}
|
|
47
|
+
// Claude Code 2.1.318 prints one stderr line from 10 days before a stored
|
|
48
|
+
// claude.ai sign-in ends (text output only; read from its own code). It is a
|
|
49
|
+
// notice, not why anything failed, and its "claude auth login" would read as
|
|
50
|
+
// "signed out": every classifier drops it first. Its three wordings, exactly:
|
|
51
|
+
// Warning: this sign-in ends between <d> and <d> (in N day(s) at the earliest). Renewing it does not move those dates. Run claude auth login before then, or use claude setup-token for unattended jobs.
|
|
52
|
+
// Warning: this sign-in can end at any time from now until <d>. Renewing it does not move that date. Run claude auth login now, or use claude setup-token for unattended jobs.
|
|
53
|
+
// Warning: this sign-in may have ended between <d> and <d>. If requests are refused, run claude auth login, or use claude setup-token for unattended jobs.
|
|
54
|
+
const SIGNIN_WARNING_RE = /^[ \t]*Warning: this sign-in (?:ends between [^\n]*? at the earliest\)\. Renewing it does not move those dates\.|can end at any time from now until [^\n]*?\. Renewing it does not move that date\.|may have ended between [^\n]*?\. If requests are refused,) run claude auth login[^\n]*? or use claude setup-token for unattended jobs\.[ \t]*(?:\r?\n|$)/gim
|
|
55
|
+
export function dropSignInWarning(text) { return String(text == null ? '' : text).replace(SIGNIN_WARNING_RE, '') }
|
|
56
|
+
|
|
57
|
+
// 2.1.318: claude -p refuses to start on an invalid config file (it no longer
|
|
58
|
+
// resets it): "Configuration error in <file>: <why>" / "Claude configuration
|
|
59
|
+
// file at <file> is corrupted" / "Fix the JSON in <file>, or move the file aside".
|
|
60
|
+
// -> the file (home shown as ~ when given), or '' when named nowhere, or null.
|
|
61
|
+
const CONFIG_BAD_RE = /(?:Configuration error in|Claude configuration file at|Fix the JSON in) (\S+?\.json)\b/i
|
|
62
|
+
export function claudeConfigError(text, home = '') {
|
|
63
|
+
const m = CONFIG_BAD_RE.exec(String(text == null ? '' : text))
|
|
64
|
+
if (!m) return null
|
|
65
|
+
const f = m[1]
|
|
66
|
+
return home && f.startsWith(home + '/') ? '~' + f.slice(home.length) : f
|
|
67
|
+
}
|
|
68
|
+
|
|
47
69
|
export function turnErrorWhy(raw0, { subtype = null, code = null, signal = null, endReason = null } = {}) {
|
|
48
|
-
const raw = ago(raw0).slice(0, WHY_RAW_MAX)
|
|
70
|
+
const raw = ago(dropSignInWarning(raw0)).slice(0, WHY_RAW_MAX)
|
|
49
71
|
const w = (label) => ({ label, raw: raw || null })
|
|
50
72
|
if (raw && /^\[?Request interrupted by user/i.test(raw)) return null
|
|
51
73
|
if (!raw && endReason === 'aborted') return null
|
|
74
|
+
const cfg = claudeConfigError(raw)
|
|
75
|
+
if (cfg != null) return w('Claude Code’s config file is invalid · fix the JSON in ' + (cfg || '~/.claude.json') + ', or move it aside')
|
|
52
76
|
if (/usage limit|hit your (usage )?limit|limit reached|out of (extra )?usage|weekly limit|session limit/i.test(raw)) { const r = resetWords(raw); return w('Claude’s usage limit is reached' + (r ? ' · ' + r : '')) }
|
|
53
77
|
if (/rate[ _-]?limit|\b429\b|too many requests/i.test(raw)) return w('Claude is rate limited right now · try again in a minute')
|
|
54
78
|
if (/prompt is too long|context (length|window)|too many tokens|maximum context|input is too long/i.test(raw)) return w('The conversation is too long for Claude · run /compact in its terminal, then retry')
|
|
55
79
|
if (/overloaded|\b529\b/i.test(raw)) return w('Claude’s servers are overloaded · try again in a minute')
|
|
56
80
|
if (/credit balance|billing|payment required|\b402\b/i.test(raw)) return w('Claude’s account is out of credit')
|
|
57
|
-
|
|
81
|
+
// 2.1.318's "Failed to authenticate: this login reached its time limit … Run `claude auth login` …" too
|
|
82
|
+
if (/invalid api key|run \/login|please (log|sign) ?in|not logged in|oauth token|token (has )?expired|authenticat|claude auth login|\b401\b|\b403\b|unauthori[sz]ed|forbidden/i.test(raw)) return w(SIGNED_OUT_LABEL)
|
|
58
83
|
if (/timed? ?out|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|fetch failed|network|socket hang up|connection (error|refused|reset)/i.test(raw)) return w('Claude couldn’t reach its servers · check this Mac’s connection')
|
|
59
84
|
if (/no conversation found|session .*not found/i.test(raw)) return w('Claude Code couldn’t reopen this conversation')
|
|
60
85
|
const api = raw.match(/API Error:?\s*(\d{3})?/i)
|
package/ui/viewport-fit.js
CHANGED
|
@@ -23,18 +23,37 @@ export const KEYBOARD_MIN_PX = 120 // less than this is the browser's own bars,
|
|
|
23
23
|
|
|
24
24
|
// innerHeight: the layout viewport's height; vv: { height, offsetTop, scale }; typing: a text box has focus.
|
|
25
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.
|
|
26
31
|
export function fullViewportFit({ innerHeight, vv, typing = false } = {}) {
|
|
27
32
|
if (!vv || !(Number(innerHeight) > 0)) return null
|
|
28
33
|
const ih = Number(innerHeight)
|
|
29
34
|
const vh = Number(vv.height) > 0 ? Number(vv.height) : ih
|
|
30
35
|
const zoomed = Number(vv.scale) > 1.01 // pinch-zoomed: follow the visual viewport as before
|
|
31
36
|
const kb = !!typing && ih - vh > KEYBOARD_MIN_PX
|
|
32
|
-
if (!
|
|
33
|
-
const room = Math.max(0, ih - vh)
|
|
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)
|
|
34
39
|
const top = Math.min(Math.max(0, Number(vv.offsetTop) || 0), room)
|
|
35
|
-
return { h: Math.round(vh), top: Math.round(top), kb }
|
|
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
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
// When to look again after a text box lets go (the keyboard takes ~0.3–0.5 s to
|
|
39
55
|
// close, and iOS's reports during it can be stale or missing), in ms.
|
|
40
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
|