@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.9-beta.8",
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"
@@ -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.8' // keep in step with .claude-plugin/plugin.json
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.8",
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
- .lv-you.lv-queued { background: transparent; border-style: dashed; border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); }
2119
- .lv-you.lv-queued .lv-text, .lv-you.lv-queued .lv-who, .lv-you.lv-queued .att-bubble { opacity: .62; }
2120
- .lv-q { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px 8px; margin-top: 6px; font-size: 12px; color: var(--muted); }
2121
- .lv-q-tag { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; letter-spacing: .01em; color: var(--muted); }
2122
- .lv-q-tag::before { content: ""; width: 7px; height: 7px; border-radius: 50%; border: 1.5px solid currentColor; }
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-acts { display: inline-flex; gap: 6px; }
2125
- .lv-q-btn { min-height: 44px; min-width: 64px; padding: 0 14px; font-size: 14px; }
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). Anything Claude does with it ends that.
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
- body = (a.text || !a.attachments.length ? '<div class="lv-text lv-md">' + (md ? md(a.text) : esc(a.text)) + '</div>' : '') + (a.attachments.length && attHtml ? attHtml(a.attachments, m) : '')
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
- 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)) + '"><div class="lv-who"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(who) + ' ' + timeHtml(m.at, { esc, now }) + '</div>' + body + foot + '</li>'
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: "Queued", then
266
- // Edit and Cancel where Leftoff can truly take it back (a session Leftoff runs,
267
- // and you may send from this device), else the plain fact. No tooltip.
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
- ? '<span class="lv-q-acts"><button type="button" class="btn lv-q-btn" data-action="lv-queued-edit" data-msg="' + id + '" aria-label="Edit this queued message">Edit</button>' +
273
- '<button type="button" class="btn lv-q-btn" data-action="lv-queued-cancel" data-msg="' + id + '" aria-label="Cancel this queued message">Cancel</button></span>'
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