@leftoff/cli 0.9.9-beta.8 → 0.9.9-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 +1 -0
- package/bin/leftoff.js +314 -314
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +20 -8
- package/ui/app.js +23 -1
- package/ui/live-status.js +5 -4
- package/ui/live-view.js +22 -9
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.9-beta.
|
|
3
|
+
"version": "0.9.9-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": "Joel"
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.9-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.9-beta.9' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.9-beta.
|
|
3
|
+
"version": "0.9.9-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.css
CHANGED
|
@@ -2114,16 +2114,28 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2114
2114
|
.lv-tick { margin-top: 4px; font-size: 11.5px; color: var(--muted); text-align: right; }
|
|
2115
2115
|
.lv-tick.got span { color: var(--accent); letter-spacing: -2px; padding-right: 2px; }
|
|
2116
2116
|
.lv-tick.bad { color: var(--red); text-align: left; }
|
|
2117
|
-
/* 0.9.9 Queued: sent while Claude works, not taken in yet: dimmed, a dashed edge, Queued (not Working)
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
.lv-
|
|
2117
|
+
/* 0.9.9 Queued: sent while Claude works, not taken in yet: dimmed, a dashed edge, Queued (not Working).
|
|
2118
|
+
Compact: no sender line, a long one folds to two lines (a tap opens it), and one small row
|
|
2119
|
+
"◷ Queued · Send now · Edit · Cancel" of text buttons, each with a 44px hit area (the
|
|
2120
|
+
negative margins keep the row 18px tall and the words evenly spaced; neighbours' hit
|
|
2121
|
+
areas don't overlap). Several stack tight. */
|
|
2122
|
+
.lv-you.lv-queued { background: transparent; border-style: dashed; border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); padding: 6px 10px; margin-bottom: 6px; }
|
|
2123
|
+
.lv-you.lv-queued .lv-text, .lv-you.lv-queued .att-bubble { opacity: .62; }
|
|
2124
|
+
.lv-you.lv-queued .lv-text { margin-top: 0; }
|
|
2125
|
+
.lv-queued .lv-q-long { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; cursor: pointer; border-radius: 4px; }
|
|
2126
|
+
.lv-queued .lv-q-long.open { display: block; -webkit-line-clamp: unset; line-clamp: none; overflow: visible; }
|
|
2127
|
+
.lv-queued .lv-q-long:focus:not(:focus-visible) { outline: none; }
|
|
2128
|
+
.lv-queued .lv-q-long:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
2129
|
+
.lv-q { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; column-gap: 5px; row-gap: 0; margin-top: 4px; min-height: 18px; font-size: 12.5px; line-height: 18px; color: var(--muted); }
|
|
2130
|
+
.lv-q-tag { display: inline-flex; align-items: center; gap: 4px; font-weight: 600; letter-spacing: .01em; color: var(--muted); }
|
|
2131
|
+
.lv-q-tag::before { content: "◷"; font-size: 13px; font-weight: 400; }
|
|
2132
|
+
.lv-q-dot { opacity: .7; }
|
|
2123
2133
|
.lv-q-hint { color: var(--muted); }
|
|
2124
|
-
.lv-q-
|
|
2125
|
-
.lv-q-btn {
|
|
2134
|
+
.lv-q-btn { background: none; border: 0; box-shadow: none; margin: -13px -4px; padding: 0 4px; min-height: 44px; min-width: 44px; display: inline-flex; align-items: center; justify-content: center; font-size: 14px; line-height: 18px; font-weight: 600; color: var(--accent); cursor: pointer; border-radius: 8px; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
|
|
2135
|
+
.lv-q-btn:hover { text-decoration: underline; text-underline-offset: 3px; }
|
|
2126
2136
|
.lv-q-btn:focus:not(:focus-visible) { outline: none; }
|
|
2137
|
+
.lv-q-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -8px; }
|
|
2138
|
+
.lv-q-btn:disabled { opacity: .45; cursor: default; text-decoration: none; }
|
|
2127
2139
|
.lv-working .lvs-line { margin-top: 0; }
|
|
2128
2140
|
.lv-pending .lv-working { margin-top: 4px; }
|
|
2129
2141
|
.lvs-sep { color: var(--faint); }
|
package/ui/app.js
CHANGED
|
@@ -2120,6 +2120,26 @@ document.addEventListener('visibilitychange', onWake)
|
|
|
2120
2120
|
// 0.9.9 Cancel / Edit on a Queued message: the daemon takes it back from
|
|
2121
2121
|
// Claude Code's queue (only while it still waits; too late says so plainly).
|
|
2122
2122
|
// Edit then puts its words (and files) back in the box, after anything typed.
|
|
2123
|
+
// 0.9.9 Send now on a Queued message: Claude stops its current step and this
|
|
2124
|
+
// one runs next (ahead of any queued before it). The daemon says what Claude
|
|
2125
|
+
// Code did; too late (Claude already has it) is a plain message.
|
|
2126
|
+
async function sendNowClick(btn) {
|
|
2127
|
+
const box = btn.closest('.live-box')
|
|
2128
|
+
const sid = box && box.dataset.id
|
|
2129
|
+
const msg = btn.dataset.msg
|
|
2130
|
+
if (!sid || !msg || btn.disabled) return
|
|
2131
|
+
const li = btn.closest('.lv')
|
|
2132
|
+
if (li) li.querySelectorAll('.lv-q-btn').forEach((b) => { b.disabled = true })
|
|
2133
|
+
btn.setAttribute('aria-busy', 'true')
|
|
2134
|
+
try {
|
|
2135
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live/' + encodeURIComponent(msg) + '/sendnow', {})
|
|
2136
|
+
toast(r.result === 'stopped' ? 'Sent now · Claude stopped its step for it' : 'Sending now', 3000)
|
|
2137
|
+
} catch (e) {
|
|
2138
|
+
toast(e.message, 4200)
|
|
2139
|
+
}
|
|
2140
|
+
await refreshLive('sendnow')
|
|
2141
|
+
await render()
|
|
2142
|
+
}
|
|
2123
2143
|
async function queuedClick(btn, edit) {
|
|
2124
2144
|
const box = btn.closest('.live-box')
|
|
2125
2145
|
const sid = box && box.dataset.id
|
|
@@ -5336,7 +5356,7 @@ window.addEventListener('hashchange', () => closeActionMenu())
|
|
|
5336
5356
|
const tipsUi = wireTips(document) // 0.9.8: the ⓘ tooltips (Always allow here)
|
|
5337
5357
|
window.addEventListener('hashchange', () => tipsUi.hide())
|
|
5338
5358
|
// 0.8.9: a session page's title is a button too (Enter or Space renames it)
|
|
5339
|
-
document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t && t.matches && t.matches('.s-title-text[role="button"]')) { e.preventDefault(); t.click() } })
|
|
5359
|
+
document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t && t.matches && t.matches('.s-title-text[role="button"], .lv-q-long[role="button"]')) { e.preventDefault(); t.click() } })
|
|
5340
5360
|
// 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
|
|
5341
5361
|
document.addEventListener('keydown', (ev) => {
|
|
5342
5362
|
const b = ev.target && ev.target.closest && ev.target.closest('[data-action="card-more"]')
|
|
@@ -5828,6 +5848,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
5828
5848
|
if (action === 'reply-send') return replyClick(btn)
|
|
5829
5849
|
if (action === 'live-send') return liveSendClick(btn)
|
|
5830
5850
|
if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
|
|
5851
|
+
if (action === 'lv-queued-now') return sendNowClick(btn)
|
|
5852
|
+
if (action === 'lv-queued-more') { if (ev.target.closest('a[href]')) return; const open = !btn.classList.contains('open'); btn.classList.toggle('open', open); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); return }
|
|
5831
5853
|
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
|
|
5832
5854
|
if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
|
|
5833
5855
|
if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
|
package/ui/live-status.js
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
// "Answered with the reply above".
|
|
19
19
|
// 0.9.9: until Claude takes it in (at its next step, or when the turn ends)
|
|
20
20
|
// such a message is Queued (queued: true; cancellable: Leftoff can still take
|
|
21
|
-
// it back
|
|
21
|
+
// it back; sendNow: Send now can make it run next). Anything Claude does
|
|
22
|
+
// with it ends that.
|
|
22
23
|
|
|
23
24
|
export const LIVE_QUIET_MS = 3 * 60 * 1000
|
|
24
25
|
export const LIVE_PHASES = ['sent', 'received', 'working', 'replied', 'stopped', 'failed']
|
|
@@ -34,10 +35,10 @@ const KEEPS_QUEUED = new Set(['delivered', 'queued', 'ask_done', 'noop'])
|
|
|
34
35
|
export function stepStatus(st, ev = {}) {
|
|
35
36
|
const s = { ...(st || initStatus(ev.at || 0)), ran: [...((st && st.ran) || [])] }
|
|
36
37
|
const at = ev.at || s.last
|
|
37
|
-
if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) { if (s.queued) { delete s.queued; delete s.cancellable } return s }
|
|
38
|
-
if (s.queued && !KEEPS_QUEUED.has(ev.type)) { delete s.queued; delete s.cancellable }
|
|
38
|
+
if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) { if (s.queued) { delete s.queued; delete s.cancellable; delete s.sendNow } return s }
|
|
39
|
+
if (s.queued && !KEEPS_QUEUED.has(ev.type)) { delete s.queued; delete s.cancellable; delete s.sendNow }
|
|
39
40
|
switch (ev.type) {
|
|
40
|
-
case 'queued': if (s.phase === 'sent' || s.phase === 'received') { s.queued = true; s.cancellable = !!ev.cancellable } else return s; break
|
|
41
|
+
case 'queued': if (s.phase === 'sent' || s.phase === 'received') { s.queued = true; s.cancellable = !!ev.cancellable; if (ev.cancellable && ev.sendNow) s.sendNow = true; else delete s.sendNow } else return s; break
|
|
41
42
|
case 'unqueued': break
|
|
42
43
|
case 'delivered': if (s.phase === 'sent') s.phase = 'received'; break
|
|
43
44
|
case 'failed': if (s.phase === 'sent') { s.phase = 'failed'; s.error = ev.error || null; s.endedAt = at } break
|
package/ui/live-view.js
CHANGED
|
@@ -225,7 +225,10 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
|
|
|
225
225
|
if (m.from === 'claude') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
|
|
226
226
|
else if (m.from === 'you') {
|
|
227
227
|
const a = messageAttachments(m)
|
|
228
|
-
|
|
228
|
+
// 0.9.9 Queued: a long one folds to two lines; a tap opens it in place
|
|
229
|
+
const fold = isQueued(m.status) && queuedLong(a.text)
|
|
230
|
+
const open = fold ? ' lv-q-long" role="button" tabindex="0" aria-expanded="false" aria-label="Show the whole message" data-action="lv-queued-more' : ''
|
|
231
|
+
body = (a.text || !a.attachments.length ? '<div class="lv-text lv-md' + open + '">' + (md ? md(a.text) : esc(a.text)) + '</div>' : '') + (a.attachments.length && attHtml ? attHtml(a.attachments, m) : '')
|
|
229
232
|
} else if (m.from === 'terminal') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
|
|
230
233
|
else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
|
|
231
234
|
let foot = ''
|
|
@@ -246,7 +249,10 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
|
|
|
246
249
|
}
|
|
247
250
|
const queued = m.from === 'you' && isQueued(m.status)
|
|
248
251
|
const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '') + (queued ? '|q' + (may ? 1 : 0) : '')
|
|
249
|
-
|
|
252
|
+
// 0.9.9: a Queued one is compact: no "You · from this Mac 10:04 PM" line
|
|
253
|
+
// (it comes back once Claude takes the message in)
|
|
254
|
+
const head = queued ? '' : '<div class="lv-who"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(who) + ' ' + timeHtml(m.at, { esc, now }) + '</div>'
|
|
255
|
+
return '<li class="lv lv-' + esc(m.from) + (m.history ? ' lv-history' : '') + (queued ? ' lv-queued' : '') + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(sig)) + '">' + head + body + foot + '</li>'
|
|
250
256
|
}
|
|
251
257
|
|
|
252
258
|
const FINAL_PHASES = new Set(['replied', 'failed', 'stopped'])
|
|
@@ -262,18 +268,25 @@ export function deliveryHtml(st, { esc, error = null }) {
|
|
|
262
268
|
return '<div class="lv-tick' + (got ? ' got' : '') + '" title="' + esc(got ? 'Claude Code received it' : 'Waiting for the session to pick it up') + '">' + (got ? 'Received <span aria-hidden="true">✓✓</span>' : 'Sent <span aria-hidden="true">✓</span>') + '</div>'
|
|
263
269
|
}
|
|
264
270
|
|
|
265
|
-
// 0.9.9 Under a message still waiting behind Claude's turn
|
|
266
|
-
//
|
|
267
|
-
//
|
|
271
|
+
// 0.9.9 Under a message still waiting behind Claude's turn, one small row:
|
|
272
|
+
// "◷ Queued · Send now · Edit · Cancel". Send now where Claude Code can make it
|
|
273
|
+
// run next (a session Leftoff runs, a CLI with Send now); Edit and Cancel where
|
|
274
|
+
// Leftoff can truly take it back; and only if you may send from this device.
|
|
275
|
+
// Otherwise the plain fact. Small text buttons, each with a 44px hit area
|
|
276
|
+
// (app.css). No tooltip.
|
|
268
277
|
export function queuedHtml(m, { esc, may = true }) {
|
|
269
278
|
const st = m.status || {}
|
|
270
279
|
const id = esc(m.id || '')
|
|
280
|
+
const dot = '<span class="lv-q-dot" aria-hidden="true">·</span>'
|
|
281
|
+
const btn = (action, label, aria) => dot + '<button type="button" class="lv-q-btn" data-action="' + action + '" data-msg="' + id + '" aria-label="' + aria + '">' + label + '</button>'
|
|
271
282
|
const acts = st.cancellable && may
|
|
272
|
-
?
|
|
273
|
-
'
|
|
274
|
-
: '<span class="lv-q-hint">Claude reads it after its current step</span>'
|
|
283
|
+
? (st.sendNow ? btn('lv-queued-now', 'Send now', 'Send now: Claude stops its current step') : '') +
|
|
284
|
+
btn('lv-queued-edit', 'Edit', 'Edit this queued message') + btn('lv-queued-cancel', 'Cancel', 'Cancel this queued message')
|
|
285
|
+
: dot + '<span class="lv-q-hint">Claude reads it after its current step</span>'
|
|
275
286
|
return '<div class="lv-q" role="status"><span class="lv-q-tag">Queued</span>' + acts + '</div>'
|
|
276
287
|
}
|
|
288
|
+
// A queued message longer than about two lines folds (tap to open in place).
|
|
289
|
+
export function queuedLong(text) { const t = String(text || '').trim(); return t.length > 80 || t.includes('\n') }
|
|
277
290
|
|
|
278
291
|
// "git -C /a/b diff --cached --quiet" → "git -C /a… --cached --quiet": the start and the end stay visible.
|
|
279
292
|
export function midClip(s, n = 56) {
|
|
@@ -381,7 +394,7 @@ export { ranWithoutAsking }
|
|
|
381
394
|
// A content signature, to replace only the entries that changed (the time-ago
|
|
382
395
|
// text is left out so a minute ticking over doesn't redraw a reply).
|
|
383
396
|
function sigHash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36) }
|
|
384
|
-
export function entrySig(m) { return JSON.stringify([m.text, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null, !!m.status.queued, !!m.status.cancellable], m.done, m.ranWithoutAsking, m.errorText]) }
|
|
397
|
+
export function entrySig(m) { return JSON.stringify([m.text, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null, !!m.status.queued, !!m.status.cancellable, !!m.status.sendNow], m.done, m.ranWithoutAsking, m.errorText]) }
|
|
385
398
|
|
|
386
399
|
// ------------------------------------------------------------ the DOM keeper
|
|
387
400
|
|