@leftoff/cli 0.9.9-beta.5 → 0.9.9-beta.6

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.5",
3
+ "version": "0.9.9-beta.6",
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.5' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.9-beta.6' // 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.5",
3
+ "version": "0.9.9-beta.6",
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
@@ -2896,8 +2896,8 @@ del.gd-x { background: var(--gd-del-word); }
2896
2896
  the restore banner): the words use the whole width, the buttons a row of
2897
2897
  their own below, 44px tall */
2898
2898
  @media (max-width: 640px) {
2899
- .start-ask-banner > .grow, .restore-ask > .grow, .banner.restore:not(.open) > .grow { flex: 1 1 100%; }
2900
- .start-ask-banner > .btn, .restore-ask > .btn, .banner.restore:not(.open) > .btn:not(.x) { flex: 1 1 0; justify-content: center; min-height: 44px; }
2899
+ .start-ask-banner > .grow, .restore-ask > .grow, .banner.restore:not(.open) > .grow, .pair-ask > .grow { flex: 1 1 100%; }
2900
+ .start-ask-banner > .btn, .restore-ask > .btn, .banner.restore:not(.open) > .btn:not(.x), .pair-ask > .btn { flex: 1 1 0; justify-content: center; min-height: 44px; }
2901
2901
  .restore-ask > .rs-m { flex: 1 1 100%; }
2902
2902
  }
2903
2903
 
@@ -2932,9 +2932,22 @@ del.gd-x { background: var(--gd-del-word); }
2932
2932
  .sb-foot { display: flex; justify-content: flex-end; }
2933
2933
  .sb-foot .btn { min-height: 44px; }
2934
2934
  .sb-crumb:focus-visible, .sb-cut:focus-visible, .sb-dir:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2935
+ /* Go to a folder: type a path you know */
2936
+ .sb-path { display: flex; flex-direction: column; gap: 4px; margin: 0; }
2937
+ .sb-path-l { font-size: 12px; color: var(--muted); }
2938
+ .sb-path-row { display: flex; gap: 8px; min-width: 0; }
2939
+ .sb-path-in { flex: 1 1 auto; min-width: 0; min-height: 44px; padding: 0 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--card); color: var(--text); font: inherit; font-family: var(--mono); font-size: 16px; }
2940
+ .sb-path-in:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2941
+ .sb-path-in[aria-invalid="true"] { border-color: var(--red); }
2942
+ .sb-path-row .btn { min-height: 44px; min-width: 44px; justify-content: center; }
2943
+ .sb-path-err { margin: 0; }
2935
2944
  .start-custom .start-opt-main { white-space: normal; }
2936
2945
  @media (max-width: 640px) {
2937
2946
  .start-browse { padding: 10px; margin-left: -4px; margin-right: -4px; }
2938
2947
  .sb-list { grid-template-columns: 1fr; max-height: none; overflow: visible; }
2939
2948
  .sb-here .sb-use { flex: 1 1 100%; justify-content: center; }
2940
2949
  }
2950
+
2951
+ /* 0.9.9 asked at pairing time: may other devices start sessions here? */
2952
+ .pair-ask .grow { min-width: 0; }
2953
+ @media (pointer: coarse) { .pair-ask > .btn { min-height: 44px; } }
package/ui/app.js CHANGED
@@ -16,7 +16,7 @@ import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
16
16
  import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
17
17
  import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (pdf.js loads on the first PDF)
18
18
  import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml } from './composer.js' // 0.9.8 the Live composer
19
- import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget } from './start.js' // 0.9.9 New session
19
+ import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath } from './start.js' // 0.9.9 New session
20
20
  import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
21
21
  import { wireTips } from './tips.js'
22
22
  import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
@@ -841,6 +841,7 @@ async function viewBoard() {
841
841
  '<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
842
842
  '<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
843
843
  html += syncDetails() + staleLine(sessions)
844
+ html += startPairAskHtml(state.starts, { esc, local: startViewerLocal(state.starts && state.starts.viewer), busy: startUi.pairBusy }) // 0.9.9: asked once, at pairing time
844
845
  html += restoreAsksBanner() + startAsksBanner(state.starts, { esc, icon, local: startViewerLocal(state.starts && state.starts.viewer), clock, busy: startUi.busy }) + restoreBanner()
845
846
  // Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
846
847
  const needs = needsItems(sessions)
@@ -4176,24 +4177,48 @@ async function viewNewSession() {
4176
4177
  const kf = fa && fa.classList && fa.classList.contains('start-text') ? { a: fa.selectionStart, b: fa.selectionEnd } : null
4177
4178
  $app.innerHTML = startPageHtml(startUi.data, startUi, { esc, icon, emoji: mEmoji, local: startViewerLocal(startUi.data.viewer), clock, draft: (state.drafts && state.drafts.start) || '', busy: startUi.sending })
4178
4179
  fillStartTexts($app, startTexts(startUi.data, startUi)) // folder names and paths: text, never markup
4180
+ { const pi = $app.querySelector('.sb-path-in'); if (pi && startUi.browse && startUi.browse.typed && pi.value !== startUi.browse.typed) pi.value = startUi.browse.typed } // what you were typing survives a redraw
4179
4181
  const ta = $app.querySelector('.start-text')
4180
4182
  if (ta) { fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
4181
4183
  }
4182
4184
  // Other folder…: list one folder on the picked Mac (that Mac's own disk, over the peer link for another Mac)
4183
- async function startBrowse(machine, p) {
4185
+ async function startBrowse(machine, p, { typed = false } = {}) {
4184
4186
  const seq = (startUi.browseSeq = (startUi.browseSeq || 0) + 1)
4185
- startUi.browse = { ...(startUi.browse && startUi.browse.machine === machine ? startUi.browse : {}), machine, loading: true, error: null }
4187
+ const prev = startUi.browse && startUi.browse.machine === machine ? startUi.browse : {}
4188
+ startUi.browse = { ...prev, machine, loading: true, error: null, pathError: null }
4186
4189
  if (state.route && state.route.name === 'new') render()
4187
4190
  try {
4188
4191
  const d = await api('GET', '/v1/start/dirs?machine=' + encodeURIComponent(machine) + (p ? '&path=' + encodeURIComponent(p) : ''), undefined, { timeoutMs: 20000 })
4189
4192
  if (seq !== startUi.browseSeq || !startUi.browse) return
4190
- startUi.browse = { machine, loading: false, error: null, data: d }
4193
+ startUi.browse = { machine, loading: false, error: null, pathError: null, typed: typed ? '' : startUi.browse.typed, data: d }
4191
4194
  } catch (e) {
4192
4195
  if (seq !== startUi.browseSeq || !startUi.browse) return
4193
- startUi.browse = { ...startUi.browse, loading: false, error: e.message }
4196
+ // a typed path that isn't one you can open: said under the box, and the folder you were in stays
4197
+ startUi.browse = typed ? { ...startUi.browse, loading: false, pathError: e.message } : { ...startUi.browse, loading: false, error: e.message }
4198
+ }
4199
+ if (state.route && state.route.name === 'new') {
4200
+ await render() // the page re-reads before it redraws: focus after
4201
+ const pi = typed && startUi.browse.pathError ? $app.querySelector('.sb-path-in') : null
4202
+ const f = pi || (startUi.browseFocus ? $app.querySelector('.sb-crumb[aria-current]') : null)
4203
+ if (f) f.focus({ preventScroll: true })
4194
4204
  }
4195
- if (state.route && state.route.name === 'new') { render(); const f = $app.querySelector('.sb-crumb[aria-current]'); if (f && startUi.browseFocus) f.focus({ preventScroll: true }) }
4196
4205
  }
4206
+ // "Go to a folder": Enter or Go takes the browser there (the Mac checks it like any other step)
4207
+ document.addEventListener('submit', (ev) => {
4208
+ const f = ev.target && ev.target.closest && ev.target.closest('form[data-form="start-browse-path"]')
4209
+ if (!f) return
4210
+ ev.preventDefault()
4211
+ const b = startUi.browse, inp = f.querySelector('.sb-path-in')
4212
+ const want = typedPath(inp ? inp.value : '')
4213
+ if (!b) return
4214
+ if (!want) { startUi.browse = { ...b, pathError: 'Type a folder, like ~/code/web.' }; render().then(() => { const pi = $app.querySelector('.sb-path-in'); if (pi) pi.focus() }); return }
4215
+ startUi.browseFocus = true
4216
+ startBrowse(b.machine, want, { typed: true })
4217
+ })
4218
+ document.addEventListener('input', (ev) => {
4219
+ if (!ev.target || !ev.target.classList || !ev.target.classList.contains('sb-path-in') || !startUi.browse) return
4220
+ startUi.browse.typed = ev.target.value // kept across the board's redraws
4221
+ })
4197
4222
  // Claude Code's own words when a session it ran stopped with an error: text, never markup.
4198
4223
  const headlessErrors = new Map()
4199
4224
  function fillHeadlessErrors() {
@@ -5407,6 +5432,17 @@ document.addEventListener('click', async (ev) => {
5407
5432
  return
5408
5433
  }
5409
5434
  if (action === 'start-all-repos') { startUi.allRepos = true; return render() }
5435
+ if (action === 'pair-ask-yes' || action === 'pair-ask-no') {
5436
+ if (startUi.pairBusy) return
5437
+ startUi.pairBusy = true
5438
+ try {
5439
+ const r = await api('POST', '/v1/starts/pair-ask', { yes: action === 'pair-ask-yes' })
5440
+ toast(r.answered === 'yes' ? 'Other devices can start sessions here' + (r.trustTurnedOn ? ' (Trust my paired devices is on too)' : '') : 'OK, they’ll ask here first', 4000)
5441
+ } catch (e) { toast(e.message, 5000) }
5442
+ startUi.pairBusy = false
5443
+ try { state.starts = await api('GET', '/v1/starts') } catch {}
5444
+ return render()
5445
+ }
5410
5446
  if (action === 'start-again') { startUi.ask = null; return render() }
5411
5447
  if (action === 'start-send') return startSend(btn)
5412
5448
  if (action === 'start-ask-confirm' || action === 'start-ask-decline') {
@@ -6154,7 +6190,7 @@ document.addEventListener('submit', async (ev) => {
6154
6190
 
6155
6191
  // Live updates over SSE; re-render the current view, at most every 800 ms.
6156
6192
  let pending = null
6157
- const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.start-box') && document.activeElement.value)
6193
+ const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.start-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('sb-path-in') && document.activeElement.value)
6158
6194
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
6159
6195
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
6160
6196
  function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name)) && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
package/ui/start.js CHANGED
@@ -122,6 +122,12 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
122
122
  export function startBrowseHtml(b, { esc, icon = () => '', machine = '' } = {}) {
123
123
  let h = '<div class="start-browse" id="start-browse" role="region" aria-label="Folders on ' + esc(machine) + '">'
124
124
  const d = b && b.data
125
+ // or type one you know (~/src/project, or a full path): the browser goes there; the same rules as walking to it
126
+ const goTo = '<form class="sb-path" data-form="start-browse-path" novalidate><label class="sb-path-l" for="sb-path-in">Go to a folder</label><div class="sb-path-row">' +
127
+ '<input type="text" id="sb-path-in" class="sb-path-in" name="path" placeholder="~/code/web" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go"' + (b && b.pathError ? ' aria-invalid="true" aria-describedby="sb-path-err"' : '') + '>' +
128
+ '<button type="submit" class="btn">Go</button></div>' +
129
+ (b && b.pathError ? '<p class="start-rule bad sb-path-err" id="sb-path-err" role="alert" data-st="path-err"></p>' : '') + '</form>'
130
+ if (!d) h += goTo
125
131
  if (d) {
126
132
  h += '<nav class="sb-crumbs" aria-label="Folder">' + d.crumbs.map((c, i) => (i ? '<span class="sb-sep" aria-hidden="true">/</span>' : '') +
127
133
  '<button type="button" class="sb-crumb" data-action="start-browse-go" data-crumb="' + i + '"' + (i === d.crumbs.length - 1 ? ' aria-current="location"' : '') + ' data-st="crumb-' + i + '"></button>').join('') + '</nav>'
@@ -129,6 +135,7 @@ export function startBrowseHtml(b, { esc, icon = () => '', machine = '' } = {})
129
135
  (d.parent ? '<button type="button" class="btn sb-up" data-action="start-browse-up" aria-label="Up one folder">' + icon('chevron-left') + 'Up</button>' : '') +
130
136
  '<span class="sb-cur"><span class="sb-cur-name" data-st="cur"></span>' + (d.git ? ' <span class="start-badge git">git</span>' : ' <span class="start-badge">not a git repo</span>') + '</span>' +
131
137
  '<button type="button" class="btn primary sb-use" data-action="start-browse-use"' + (d.parent ? '' : ' disabled') + '>Use this folder</button></div>'
138
+ h += goTo
132
139
  if (d.shortcuts && d.shortcuts.length > 1) h += '<div class="sb-cuts"><span class="sb-cuts-l">Jump to</span>' + d.shortcuts.map((c, i) => '<button type="button" class="sb-cut" data-action="start-browse-go" data-cut="' + i + '" data-st="cut-' + i + '"></button>').join('') + '</div>'
133
140
  if (!d.entries.length) h += '<p class="start-empty">No folders in here.</p>'
134
141
  else h += '<ul class="sb-list">' + d.entries.map((e, i) => '<li><button type="button" class="sb-dir" data-action="start-browse-go" data-entry="' + i + '">' + icon('folder') + '<span class="sb-name" data-st="entry-' + i + '"></span>' + (e.git ? '<span class="start-badge git">git</span>' : '') + icon('chevron-right', 'ic sb-go') + '</button></li>').join('') + '</ul>'
@@ -141,6 +148,17 @@ export function startBrowseHtml(b, { esc, icon = () => '', machine = '' } = {})
141
148
  return h
142
149
  }
143
150
 
151
+ // What you typed in "Go to a folder", as a path to ask for: no spaces around
152
+ // it, no quotes a copy brought along, a shell's "\ " back to a space, no
153
+ // trailing slash. "~" stays "~"; the Mac resolves it (src/dirs.js checkFolder).
154
+ export function typedPath(v) {
155
+ let t = String(v == null ? '' : v).trim()
156
+ if (/^(['"]).*\1$/.test(t) && t.length >= 2) t = t.slice(1, -1).trim()
157
+ t = t.replace(/\\ /g, ' ')
158
+ if (t.length > 1) t = t.replace(/\/+$/, '') || '/'
159
+ return t
160
+ }
161
+
144
162
  // The text for every [data-st] on the page: names and paths from a Mac's disk.
145
163
  export function startTexts(data, ui = {}) {
146
164
  const t = {}
@@ -153,6 +171,7 @@ export function startTexts(data, ui = {}) {
153
171
  ;(d.shortcuts || []).forEach((c, i) => { t['cut-' + i] = c.label })
154
172
  d.entries.forEach((e, i) => { t['entry-' + i] = e.name })
155
173
  }
174
+ if (ui.browse && ui.browse.pathError) t['path-err'] = ui.browse.pathError
156
175
  for (const a of (data && Array.isArray(data.pending) ? data.pending : [])) if (a.firstTime) t['ask-folder-' + a.id] = a.folder || ''
157
176
  return t
158
177
  }
@@ -189,6 +208,22 @@ export function startAsksBanner(info, { esc, icon, local = true, clock = (x) =>
189
208
  }).join('')
190
209
  }
191
210
 
211
+ // Asked once on THIS Mac's board when it pairs a phone or sees a new Mac:
212
+ // may it start sessions here? No is the default (first, and what doing
213
+ // nothing means); Yes turns on the switch, and Trust my paired devices if it's
214
+ // off (the words say so). Never shown off this Mac.
215
+ export function startPairAskHtml(info, { esc, local = true, busy = false } = {}) {
216
+ const a = info && info.pairAsk
217
+ if (!a || !local) return ''
218
+ const mac = info.machine || 'this Mac'
219
+ const who = a.kind === 'mac' ? (a.name || 'your other Mac') : 'your phone'
220
+ const what = a.kind === 'mac' ? 'Let ' + who + ' start sessions here?' : 'Let your phone start sessions here?'
221
+ const yes = 'Yes turns on “Allow starting sessions from other devices” for ' + mac + (info.trust ? '' : ', and Trust my paired devices so you can answer its prompts there') + '. It applies to every paired phone and Mac; change it any time in Settings → Privacy & security.'
222
+ return '<div class="banner info pair-ask" role="region" aria-label="' + esc(what) + '"><span class="grow"><b>' + esc(what) + '</b> ' + esc(a.kind === 'mac' ? who + ' could start Claude Code on ' + mac + ' with nobody here;' : 'It could start Claude Code on ' + mac + ' with nobody here;') + ' its edits and commands still wait for you in Needs you. ' + esc(yes) + '</span>' +
223
+ '<button type="button" class="btn" data-action="pair-ask-no"' + (busy ? ' aria-disabled="true"' : '') + '>No</button>' +
224
+ '<button type="button" class="btn" data-action="pair-ask-yes"' + (busy ? ' aria-disabled="true"' : '') + '>Yes, allow</button></div>'
225
+ }
226
+
192
227
  // The session page's line for a session Leftoff runs: where it runs, who
193
228
  // started it, and Stop / Resume. The error (Claude Code's own words) is put
194
229
  // in with textContent by app.js ([data-headless-err]).