@leftoff/cli 0.9.5 → 0.9.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.
package/docs/SECURITY.md CHANGED
@@ -35,6 +35,7 @@ The folder (`~/checkpoints`, or wherever you moved it) is plain Markdown and JSO
35
35
  | Paired browsers' keys (0.6.0) | `relay/devices/<id>.json` **in the folder, wrapped** | Each device key is encrypted to each Mac's own X25519 key (whose private half never leaves that Mac) and the file is signed by the Mac that wrote it. The sync service sees ciphertext. Revoking erases the wrapped copies. |
36
36
  | Relay admin token (0.6.0) | A wrangler secret on your Cloudflare account, and wherever you keep it | Leftoff never stores it. `leftoff relay register` sends it once, over https, to your relay. `leftoff relay signup` (0.6.1) needs no token at all. |
37
37
  | Pairing secret | `~/.config/checkpoints/syncthing-pairing.json`, mode 0600, first Mac only | Erased the moment a Mac pairs with it, or when it expires. |
38
+ | Transcript search index (0.9.6) | `~/.cache/checkpoints/search/<machine>-<hash>/` (or `$XDG_CACHE_HOME`) | Per Mac, never in the folder: words and snippets from your prompts and Claude's replies (no tool output). Another Mac only gets the search results it asks for through the sealed peer channel. Safe to delete; it is rebuilt in the background. |
38
39
  | Board token (`.secret/token`) and phone sign-ins (`.secret/remote.json`, hashes) | In the folder | **With Syncthing:** kept on each Mac by Leftoff's `.stignore`, so each Mac has its own token. **With iCloud, Dropbox or another folder:** they sync, because those tools have no ignore file Leftoff can rely on. The token only works against a Mac's board, which listens on `127.0.0.1` unless you turned on remote access. Anyone who can read your synced folder *and* reach a board could use it, so treat the folder as private. |
39
40
 
40
41
  Leftoff's `.stignore` for its Syncthing folder also keeps lock files, temp files, `.DS_Store` and the daemon's pid and log on each Mac.
@@ -58,6 +58,8 @@ Each command is one envelope. It's JSON and travels the same way over every tran
58
58
  | `session.checkpoint` | `sessionId`, `length?`, `userNote?`, `next?` | `engine.generateCheckpoint()`: Checkpoint now (0.9.2); returns the checkpoint, which the asking board lists at once |
59
59
  | `session.patch` | `sessionId`, `title?`, `pinned?`, `archived?` | `engine.patchSession()`: rename, Pin, Archive (0.9.2) |
60
60
  | `session.title.auto` | `sessionId` | `engine.regenerateTitle()`: Regenerate title (0.9.2) |
61
+ | `transcripts.search` | `q`, `limit` | `engine.searchTranscripts(q, { local: true })`: search inside that Mac's transcripts (0.9.6). Answers with snippets (plain text + mark ranges), never whole transcripts; the asking Mac rebuilds any HTML itself |
62
+ | `transcripts.around` | `sessionId`, `anchor`, `q` | `engine.transcriptAround()`: the few messages around a search hit, for the session page (0.9.6) |
61
63
 
62
64
  Answers are recorded in the session's `answers.md` as *from studio's board (direct)* or *(through the relay)*.
63
65
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.5",
3
+ "version": "0.9.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.5' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.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.5",
3
+ "version": "0.9.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
@@ -1878,3 +1878,56 @@ html.dv-open .lo-menu { z-index: 62; }
1878
1878
  .cp dd.cp-md a { color: var(--accent); }
1879
1879
  .cp dd.cp-md blockquote { margin: 4px 0; padding-left: 10px; border-left: 2px solid var(--line-strong); color: var(--muted); }
1880
1880
  .cp dd.cp-md hr { border: 0; border-top: 1px solid var(--line); margin: 8px 0; }
1881
+
1882
+ /* 0.9.6: search inside transcripts — ⌘K's "In transcripts", the Search page, the session page's "Found in the transcript" */
1883
+ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-mark); color: inherit; border-radius: 3px; padding: 0 1px; box-decoration-break: clone; -webkit-box-decoration-break: clone; }
1884
+ /* (the dark theme is the default; the light one sets data-theme="light") */
1885
+ :root, [data-theme="dark"] { --tx-mark: rgba(250, 204, 21, .32); }
1886
+ [data-theme="light"] { --tx-mark: #fde68a; }
1887
+ .cmdk-opt.tx-opt { align-items: flex-start; }
1888
+ .cmdk-opt.tx-opt .ic { margin-top: 2px; }
1889
+ .co-snip { font-size: 12.5px; color: var(--muted); line-height: 1.45; margin-top: 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
1890
+ .tx-who { font-weight: 600; font-size: 11.5px; color: var(--faint); }
1891
+ .tx-who.a { color: var(--accent); }
1892
+ .cmdk-note.tx-note { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 8px 12px 10px; }
1893
+ .cmdk-note.tx-note .spin { width: 12px; height: 12px; }
1894
+ .tx-note-line { flex-basis: 100%; }
1895
+ .search-page-form { display: flex; gap: 8px; align-items: center; margin: 4px 0 18px; max-width: 640px; }
1896
+ .search-page-form .set-search { flex: 1; }
1897
+ .search-page-form .set-search input { height: 44px; font-size: 16px; border-radius: 11px; }
1898
+ .search-page-form .btn { height: 44px; }
1899
+ .tx-section { margin: 4px 0 22px; }
1900
+ .tx-h { display: flex; align-items: baseline; gap: 8px; }
1901
+ .tx-count { font-size: 12.5px; font-weight: 500; color: var(--faint); }
1902
+ .tx-list { display: flex; flex-direction: column; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--card-shadow); overflow: hidden; }
1903
+ .tx-hit { display: flex; flex-direction: column; gap: 3px; padding: 12px 14px; min-height: 44px; color: var(--text2); text-decoration: none; border-top: 1px solid var(--line); }
1904
+ .tx-hit:first-child { border-top: 0; }
1905
+ .tx-hit:hover, .tx-hit:focus-visible { background: var(--bg2); }
1906
+ .tx-hit:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
1907
+ .tx-title { font-weight: 600; color: var(--text); display: flex; gap: 8px; align-items: baseline; min-width: 0; overflow-wrap: anywhere; }
1908
+ .tx-tag { font-size: 11px; font-weight: 500; color: var(--faint); border: 1px solid var(--line); border-radius: 6px; padding: 0 5px; }
1909
+ .tx-sub { font-size: 12.5px; color: var(--faint); }
1910
+ .tx-snip { font-size: 13.5px; line-height: 1.5; color: var(--muted); overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
1911
+ .tx-note { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; margin: 8px 2px; }
1912
+ .tx-note.tx-indexing { color: var(--text2); }
1913
+ .tx-more { margin: 8px 2px; font-size: 13.5px; }
1914
+ .tx-board-link { display: inline-flex; align-items: center; gap: 5px; min-height: 32px; }
1915
+ .tx-board-link .ic { width: 14px; height: 14px; }
1916
+ .tx-around { margin: 0 0 20px; padding: 12px 14px 14px; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--card-shadow); }
1917
+ .tx-around-head { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 600; letter-spacing: .4px; text-transform: uppercase; color: var(--faint); margin-bottom: 6px; }
1918
+ .tx-around-head .tx-close { margin-left: auto; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; font-size: 20px; text-decoration: none; color: var(--muted); text-transform: none; }
1919
+ .tx-around-head .tx-close:hover { background: var(--bg2); }
1920
+ .tx-msg { padding: 10px 12px; border-radius: 10px; margin-top: 6px; border: 1px solid transparent; }
1921
+ .tx-msg.u { background: var(--bg2); }
1922
+ .tx-msg.hit { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
1923
+ .tx-msg.hit:focus { outline: none; }
1924
+ .tx-msg-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 3px; }
1925
+ .tx-msg-head .t { font-size: 12px; color: var(--faint); }
1926
+ .tx-msg-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; line-height: 1.5; color: var(--text2); max-height: 22em; overflow-y: auto; }
1927
+ .tx-msg:not(.hit) .tx-msg-text { max-height: 7.5em; }
1928
+ @media (max-width: 899.98px) {
1929
+ .co-snip { font-size: 13.5px; }
1930
+ .tx-around-head .tx-close { width: 44px; height: 44px; margin: -8px -8px -8px auto; }
1931
+ .board-search .set-search input, .board-search input { height: 44px; }
1932
+ .tx-board-link { min-height: 44px; }
1933
+ }
package/ui/app.js CHANGED
@@ -830,9 +830,10 @@ async function viewBoard() {
830
830
  // 0.8.6: search the board. The words live in the URL (?q=), typing keeps the
831
831
  // focus, and matches among closed sessions are one tap away.
832
832
  function boardSearch(q, closedHits, n) {
833
- return '<div class="board-search-row"><div class="set-search board-search">' + icon('search') + '<input id="boardQ" type="search" placeholder="Find a session: title, repo, branch, folder or id" aria-label="Find a session" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(q) + '"></div>' +
833
+ return '<div class="board-search-row"><div class="set-search board-search">' + icon('search') + '<input id="boardQ" type="search" enterkeyhint="search" placeholder="Find a session, or anything said in one" aria-label="Find a session, or search what was said in sessions" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(q) + '"></div>' +
834
834
  (q ? '<div class="board-search-note">' + (n ? esc(n + (n === 1 ? ' match' : ' matches')) : 'No open or recent session matches.') +
835
- (closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') + '</div>' : '') + '</div>'
835
+ (closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') +
836
+ ' <a class="linkish tx-board-link" href="#/search?q=' + encodeURIComponent(q) + '">' + icon('message-square') + 'Search what was said →</a></div>' : '') + '</div>'
836
837
  }
837
838
  // A repaint from a live update (or the 30 s refresh) while you type in the
838
839
  // search box keeps the box focused, with what you typed and the caret.
@@ -862,7 +863,11 @@ function boardSearchWire() {
862
863
  if (again) { again.focus(); try { again.setSelectionRange(pos, pos) } catch {} }
863
864
  }, 160)
864
865
  })
865
- q.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) } })
866
+ q.addEventListener('keydown', (ev) => {
867
+ if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) }
868
+ // 0.9.6: Enter (Search on the phone's keyboard) opens the full results, transcripts included
869
+ else if (ev.key === 'Enter' && q.value.trim() && !ev.isComposing) { ev.preventDefault(); clearTimeout(boardQTimer); location.hash = '#/search?q=' + encodeURIComponent(q.value.trim()) }
870
+ })
866
871
  }
867
872
 
868
873
  // Closed (0.6.7): every ended session on every machine, newest first, with
@@ -1259,6 +1264,92 @@ async function viewSession(id) {
1259
1264
  if (empties.length) html += '<details class="tl-doubt"><summary>' + empties.length + ' empty checkpoint' + (empties.length === 1 ? '' : 's') + ' set aside: no messages were found when ' + (empties.length === 1 ? 'it was' : 'they were') + ' written</summary><div class="timeline">' + cpTimeline(empties, null) + '</div></details>'
1260
1265
  html += answeredList(s)
1261
1266
  $app.innerHTML = html
1267
+ aroundWire(s.id)
1268
+ }
1269
+ // ------------------------------------------------------------ search inside transcripts (0.9.6)
1270
+
1271
+ // Plain text + [start, end] ranges -> escaped HTML with <mark>s (ranges that
1272
+ // overlap, run backwards or past the end are skipped).
1273
+ function markedHtml(text, marks) {
1274
+ const t = String(text || '')
1275
+ let h = '', at = 0
1276
+ for (const m of Array.isArray(marks) ? marks : []) {
1277
+ const a = Number(m && m[0]), b = Number(m && m[1])
1278
+ if (!(Number.isInteger(a) && Number.isInteger(b) && a >= at && b > a && b <= t.length)) continue
1279
+ h += esc(t.slice(at, a)) + '<mark>' + esc(t.slice(a, b)) + '</mark>'
1280
+ at = b
1281
+ }
1282
+ return h + esc(t.slice(at))
1283
+ }
1284
+ const txHref = (h, q) => '#/s/' + encodeURIComponent(h.sessionId) + (h.anchor != null ? '?hit=' + encodeURIComponent(h.anchor) + '&q=' + encodeURIComponent(q) : '')
1285
+ const txWho = (h) => h.role === 'assistant' ? 'Claude' : 'You'
1286
+ function txSub(h) {
1287
+ return [h.project && h.project.name ? projectLabel(h.project) : h.repoLabel, multi() || (h.machine && state.health && h.machine !== state.health.machine) ? h.machine : '', h.at ? ago(h.at) : '', h.hits > 1 ? h.hits + ' matches' : ''].filter(Boolean).join(' · ')
1288
+ }
1289
+ const nfmt = (n) => Number(n || 0).toLocaleString()
1290
+ // "Still indexing (n of m)", other Macs that weren't searched, or null.
1291
+ function txNotes(r) {
1292
+ const out = []
1293
+ const me = (r.machines || []).find((m) => m.self) || {}
1294
+ if (me.status === 'indexing') out.push('Still indexing transcripts' + (me.total ? ' (' + nfmt(me.done) + ' of ' + nfmt(me.total) + ')' : '') + '. Results so far.')
1295
+ if (me.status === 'off') out.push('Transcript search is off on this Mac.')
1296
+ for (const m of r.machines || []) {
1297
+ if (m.self) continue
1298
+ if (m.status === 'indexing') out.push(m.machine + ' is still indexing' + (m.total ? ' (' + nfmt(m.done) + ' of ' + nfmt(m.total) + ')' : '') + '.')
1299
+ else if (m.status === 'unreachable') out.push(m.machine + ' wasn’t searched (not reachable right now).')
1300
+ else if (m.status === 'needs_update') out.push(m.machine + ' wasn’t searched (it needs Leftoff 0.9.6).')
1301
+ }
1302
+ return out
1303
+ }
1304
+ // One hit as a result row (search page, board).
1305
+ function txHitHtml(h, q) {
1306
+ return '<a class="tx-hit" href="' + esc(txHref(h, q)) + '">' +
1307
+ '<span class="tx-title">' + esc(h.title || 'Untitled') + (h.endedAt ? '<span class="tx-tag">closed</span>' : '') + '</span>' +
1308
+ '<span class="tx-sub">' + esc(txSub(h)) + '</span>' +
1309
+ '<span class="tx-snip"><span class="tx-who ' + (h.role === 'assistant' ? 'a' : 'u') + '">' + esc(txWho(h)) + '</span> ' + markedHtml(h.snippet, h.marks) + '</span></a>'
1310
+ }
1311
+ function txSectionHtml(r, q, { more = null } = {}) {
1312
+ if (!r) return '<h2 class="tx-h">In transcripts</h2><div class="tx-note">Searching transcripts…</div>'
1313
+ const notes = txNotes(r)
1314
+ let h = '<h2 class="tx-h">In transcripts' + (r.results.length ? ' <span class="tx-count">' + esc(r.results.length + (r.results.length === 1 ? ' session' : ' sessions')) + '</span>' : '') + '</h2>'
1315
+ const idx = notes.find((n) => /^Still indexing/.test(n))
1316
+ if (idx) h += '<div class="tx-note tx-indexing" role="status"><span class="spin" aria-hidden="true"></span>' + esc(idx) + '</div>'
1317
+ if (r.results.length) h += '<div class="tx-list">' + r.results.map((x) => txHitHtml(x, q)).join('') + '</div>'
1318
+ else if (!notes.some((n) => /^Still indexing/.test(n))) h += '<div class="tx-note tx-empty">Nothing you or Claude said matches “' + esc(q) + '”.</div>'
1319
+ for (const n of notes) if (n !== idx) h += '<div class="tx-note">' + esc(n) + '</div>'
1320
+ if (more) h += '<div class="tx-more"><a href="' + esc(more) + '">All results for “' + esc(q) + '” →</a></div>'
1321
+ return h
1322
+ }
1323
+ const txQuery = (q, limit) => api('GET', '/v1/search?in=transcripts&limit=' + limit + '&q=' + encodeURIComponent(q))
1324
+
1325
+ // The session page opened from a transcript hit: the message and the turns
1326
+ // around it, at the top, scrolled to.
1327
+ const aroundCache = new Map()
1328
+ async function aroundWire(id) {
1329
+ const r = state.route
1330
+ if (!r || r.name !== 's' || !r.params || r.params.get('hit') == null) return
1331
+ const anchor = r.params.get('hit'), q = r.params.get('q') || ''
1332
+ const key = id + '|' + anchor + '|' + q
1333
+ const head = document.querySelector('#app .pagehead')
1334
+ if (!head) return
1335
+ let box = document.getElementById('txAround')
1336
+ if (!box) { box = document.createElement('section'); box.id = 'txAround'; box.className = 'tx-around'; box.setAttribute('aria-label', 'Found in the transcript'); head.after(box) }
1337
+ const first = !aroundCache.has(key)
1338
+ let d = aroundCache.get(key)
1339
+ if (!d) {
1340
+ box.innerHTML = '<div class="tx-around-head">Found in the transcript</div><div class="tx-note">Loading…</div>'
1341
+ try { d = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/around?anchor=' + encodeURIComponent(anchor) + '&q=' + encodeURIComponent(q)) } catch (e) { d = { error: e.message, messages: [] } }
1342
+ aroundCache.set(key, d)
1343
+ if (!document.getElementById('txAround')) return
1344
+ }
1345
+ const close = '<a class="icon x tx-close" href="#/s/' + encodeURIComponent(id) + '" aria-label="Close" title="Close">×</a>'
1346
+ if (!d.messages || !d.messages.length) { box.innerHTML = '<div class="tx-around-head">Found in the transcript' + close + '</div><div class="tx-note">' + esc(d.error ? 'Couldn’t read that part of the transcript: ' + d.error : 'That message isn’t in the index any more (the transcript changed).') + '</div>'; return }
1347
+ box.innerHTML = '<div class="tx-around-head">Found in the transcript' + (q ? ' · “' + esc(q) + '”' : '') + close + '</div>' +
1348
+ d.messages.map((m) => '<div class="tx-msg ' + (m.role === 'assistant' ? 'a' : 'u') + (m.hit ? ' hit' : '') + '"' + (m.hit ? ' id="txHit" tabindex="-1"' : '') + '><div class="tx-msg-head"><span class="tx-who ' + (m.role === 'assistant' ? 'a' : 'u') + '">' + (m.role === 'assistant' ? 'Claude' : 'You') + '</span>' + (m.at ? '<span class="t" title="' + esc(clock(m.at)) + '">' + esc(ago(m.at)) + '</span>' : '') + '</div><div class="tx-msg-text">' + markedHtml(m.text, m.marks) + '</div></div>').join('')
1349
+ if (first) {
1350
+ const hit = document.getElementById('txHit')
1351
+ if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
1352
+ }
1262
1353
  }
1263
1354
  function cpTimeline(list, newestCp) {
1264
1355
  return list.map((cp) =>
@@ -2887,12 +2978,36 @@ async function stAction(action, btn) {
2887
2978
  }
2888
2979
 
2889
2980
  async function viewSearch(q) {
2890
- const r = await api('GET', '/v1/search?q=' + encodeURIComponent(q))
2891
- let html = '<div class="pagehead"><div><h1>Search</h1><div class="sub">“' + esc(q) + '” · ' + r.sessions.length + ' sessions, ' + r.checkpoints.length + ' checkpoints</div></div></div>'
2892
- if (r.sessions.length) html += '<h2>Sessions</h2><div class="grid">' + r.sessions.map(card).join('') + '</div>'
2893
- if (r.checkpoints.length) html += '<h2>Checkpoints</h2><div class="timeline">' + r.checkpoints.map((cp) => '<div class="tl-item ' + esc(cp.trigger) + '"><div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><a class="tl-title" href="#/s/' + encodeURIComponent(cp.sessionId) + '">' + esc(cp.sessionTitle || 'Untitled') + '</a></div>' + cpBlock(cp, { showWhen: false }) + '</div>').join('') + '</div>'
2894
- if (!r.sessions.length && !r.checkpoints.length) html += '<div class="empty mt">Nothing matched.</div>'
2895
- $app.innerHTML = html
2981
+ const form = '<form class="search-page-form" id="searchPageForm" role="search"><div class="set-search">' + icon('search') + '<input id="searchPageQ" type="search" enterkeyhint="search" placeholder="Search sessions and what was said in them" aria-label="Search" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(q) + '"></div><button class="btn primary" type="submit">Search</button></form>'
2982
+ if (!q.trim()) { $app.innerHTML = '<div class="pagehead"><div><h1>Search</h1><div class="sub">Titles, checkpoints, and everything you and Claude said.</div></div></div>' + form; return searchPageWire() }
2983
+ const seq = (viewSearch.seq = (viewSearch.seq || 0) + 1)
2984
+ const [r, tx] = await Promise.all([api('GET', '/v1/search?q=' + encodeURIComponent(q)), txQuery(q, 30).catch(() => null)])
2985
+ if (seq !== viewSearch.seq) return
2986
+ const paint = (tx) => {
2987
+ let html = '<div class="pagehead"><div><h1>Search</h1><div class="sub">“' + esc(q) + '” · ' + r.sessions.length + (r.sessions.length === 1 ? ' session, ' : ' sessions, ') + r.checkpoints.length + (r.checkpoints.length === 1 ? ' checkpoint' : ' checkpoints') + (tx ? ', ' + tx.results.length + ' in transcripts' : '') + '</div></div></div>' + form
2988
+ if (r.sessions.length) html += '<h2>Sessions</h2><div class="grid">' + r.sessions.map(card).join('') + '</div>'
2989
+ html += '<section class="tx-section">' + txSectionHtml(tx || { results: [], machines: [] }, q) + '</section>'
2990
+ if (r.checkpoints.length) html += '<h2>Checkpoints</h2><div class="timeline">' + r.checkpoints.map((cp) => '<div class="tl-item ' + esc(cp.trigger) + '"><div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><a class="tl-title" href="#/s/' + encodeURIComponent(cp.sessionId) + '">' + esc(cp.sessionTitle || 'Untitled') + '</a></div>' + cpBlock(cp, { showWhen: false }) + '</div>').join('') + '</div>'
2991
+ if (!r.sessions.length && !r.checkpoints.length && tx && !tx.results.length && !txNotes(tx).length) html += '<div class="empty mt">Nothing matched.</div>'
2992
+ $app.innerHTML = html
2993
+ searchPageWire()
2994
+ }
2995
+ paint(tx)
2996
+ // still indexing: look again in a moment (results fill in as the index grows)
2997
+ if (tx && (tx.machines || []).some((m) => m.status === 'indexing')) setTimeout(async () => {
2998
+ if (seq !== viewSearch.seq || !state.route || state.route.name !== 'search' || document.activeElement === document.getElementById('searchPageQ')) return
2999
+ const again = await txQuery(q, 30).catch(() => null)
3000
+ if (again && seq === viewSearch.seq && state.route.name === 'search') { paint(again); if ((again.machines || []).some((m) => m.status === 'indexing')) setTimeout(() => { if (seq === viewSearch.seq && state.route.name === 'search') render() }, 3000) }
3001
+ }, 2500)
3002
+ }
3003
+ function searchPageWire() {
3004
+ const f = document.getElementById('searchPageForm')
3005
+ if (!f) return
3006
+ f.addEventListener('submit', (e) => {
3007
+ e.preventDefault()
3008
+ const v = document.getElementById('searchPageQ').value.trim()
3009
+ location.hash = '#/search' + (v ? '?q=' + encodeURIComponent(v) : '')
3010
+ })
2896
3011
  }
2897
3012
 
2898
3013
  // ------------------------------------------------------------ lists (v0.5.1)
@@ -3280,6 +3395,21 @@ function searchGroups(q, r) {
3280
3395
  if ((r.files || []).length) groups.push(['Files', r.files.slice(0, 5).map((f) => ({ href: '#/s/' + encodeURIComponent(f.sessionId), title: f.name, sub: f.sessionTitle || '', icon: 'file-text' }))])
3281
3396
  if (r.checkpoints.length) groups.push(['Checkpoints', r.checkpoints.slice(0, 4).map((cp) => ({ href: '#/s/' + encodeURIComponent(cp.sessionId), title: plainCp(cp.doing || cp.next) || 'Checkpoint', sub: [cp.sessionTitle || 'Untitled', cp.createdAt ? clock(cp.createdAt) : ''].filter(Boolean).join(' · '), icon: 'check' }))])
3282
3397
  }
3398
+ // 0.9.6: what was said in sessions, below the rest of the results
3399
+ if (low && low.length >= 2) {
3400
+ const tx = searchUi.tx && searchUi.tx.q === q ? searchUi.tx.r : null
3401
+ if (!tx) groups.push(['In transcripts', [], '<span class="spin" aria-hidden="true"></span>Searching transcripts…'])
3402
+ else {
3403
+ const notes = txNotes(tx)
3404
+ const items = tx.results.slice(0, 6).map((h) => ({ href: txHref(h, q), title: h.title || 'Untitled', sub: txSub(h), snip: h, icon: 'message-square' }))
3405
+ const indexing = notes.find((n) => /^Still indexing/.test(n))
3406
+ // "still indexing" leads the section (what's below is partial); the rest follows the rows
3407
+ const lead = indexing ? '<span class="spin" aria-hidden="true"></span>' + esc(indexing) : ''
3408
+ const note = (!items.length && !indexing ? 'Nothing you or Claude said matches “' + esc(q) + '”.' : '') +
3409
+ notes.filter((n) => n !== indexing).map((n) => '<span class="tx-note-line">' + esc(n) + '</span>').join('')
3410
+ if (items.length || note || lead) groups.push(['In transcripts', items, note, lead])
3411
+ }
3412
+ }
3283
3413
  if (acts.length) groups.push(['Actions', acts.slice(0, low ? 4 : 5)])
3284
3414
  if (sets.length) groups.push(['Settings', sets.slice(0, 5)])
3285
3415
  if (pages.length) groups.push(['Go to', pages.slice(0, low ? 4 : 8)])
@@ -3312,14 +3442,20 @@ function paintSearch() {
3312
3442
  searchUi.items = []
3313
3443
  let h = ''
3314
3444
  if (q) { searchUi.items.push({ href: '#/search?q=' + encodeURIComponent(q), title: 'All results for “' + q + '”', icon: 'search' }) }
3315
- for (const [name, items] of groups) { h += '<div class="cmdk-group" role="presentation">' + esc(name) + '</div>'; for (const it of items) { searchUi.items.push(it); h += optHtml(it, searchUi.items.length - 1) } }
3316
- if (q) h = optHtml(searchUi.items[0], 0) + h
3445
+ const tx = searchUi.tx && searchUi.tx.q === q ? searchUi.tx.r : null
3317
3446
  if (q && q.length >= 2 && !r) h += '<div class="cmdk-note">Searching…</div>'
3318
- else if (q && r && !r.sessions.length && !r.checkpoints.length && !(r.projects || []).length && !(r.prs || []).length && !(r.files || []).length && !(r.lists || []).length && !(r.todos || []).length) h += '<div class="cmdk-note">Nothing matches “' + esc(q) + '”.</div>'
3447
+ else if (q && r && !r.sessions.length && !r.checkpoints.length && !(r.projects || []).length && !(r.prs || []).length && !(r.files || []).length && !(r.lists || []).length && !(r.todos || []).length && !(tx && tx.results.length)) h += '<div class="cmdk-note">Nothing matches “' + esc(q) + '”' + (tx ? '' : ' by name') + '.</div>'
3448
+ for (const [name, items, note, lead] of groups) {
3449
+ h += '<div class="cmdk-group' + (name === 'In transcripts' ? ' tx-group' : '') + '" role="presentation">' + esc(name) + '</div>'
3450
+ if (lead) h += '<div class="cmdk-note tx-note tx-lead">' + lead + '</div>'
3451
+ for (const it of items) { searchUi.items.push(it); h += optHtml(it, searchUi.items.length - 1) }
3452
+ if (note) h += '<div class="cmdk-note tx-note">' + note + '</div>'
3453
+ }
3454
+ if (q) h = optHtml(searchUi.items[0], 0) + h
3319
3455
  list.innerHTML = h
3320
3456
  // 0.9.0: Enter opens the best match (the first result); ↑ reaches "All results for …"
3321
3457
  setSearchActive(q && searchUi.items.length > 1 ? 1 : searchUi.items.length ? 0 : -1)
3322
- function optHtml(it, i) { return '<a class="cmdk-opt" role="option" id="cmdk-o' + i + '" data-i="' + i + '" href="' + esc(it.href || '#') + '"' + (it.ext ? ' target="_blank" rel="noopener"' : '') + (it.action ? ' data-palette-action="' + esc(it.action) + '"' : '') + ' aria-selected="false">' + icon(it.icon || 'search') + '<span class="co-text"><span class="co-title">' + esc(it.title) + '</span>' + (it.sub ? '<span class="co-sub">' + esc(it.sub) + '</span>' : '') + '</span></a>' }
3458
+ function optHtml(it, i) { return '<a class="cmdk-opt' + (it.snip ? ' tx-opt' : '') + '" role="option" id="cmdk-o' + i + '" data-i="' + i + '" href="' + esc(it.href || '#') + '"' + (it.ext ? ' target="_blank" rel="noopener"' : '') + (it.action ? ' data-palette-action="' + esc(it.action) + '"' : '') + ' aria-selected="false">' + icon(it.icon || 'search') + '<span class="co-text"><span class="co-title">' + esc(it.title) + '</span>' + (it.sub ? '<span class="co-sub">' + esc(it.sub) + '</span>' : '') + (it.snip ? '<span class="co-snip"><span class="tx-who ' + (it.snip.role === 'assistant' ? 'a' : 'u') + '">' + esc(txWho(it.snip)) + '</span> ' + markedHtml(it.snip.snippet, it.snip.marks) + '</span>' : '') + '</span></a>' }
3323
3459
  }
3324
3460
  function setSearchActive(i) {
3325
3461
  searchUi.active = i
@@ -3333,17 +3469,39 @@ async function runSearch() {
3333
3469
  if (q.length < 2) { paintSearch(); return }
3334
3470
  paintSearch()
3335
3471
  try {
3472
+ // 0.9.6: transcripts in parallel; names and checkpoints paint first
3473
+ const txp = txQuery(q, 6).then((tr) => { if (seq === searchUi.seq) { searchUi.tx = { q, r: tr }; if (searchIsOpen()) paintSearchKeeping() } }, () => { if (seq === searchUi.seq) { searchUi.tx = { q, r: { results: [], machines: [] } }; if (searchIsOpen()) paintSearchKeeping() } })
3336
3474
  const r = await api('GET', '/v1/search?light=1&limit=20&q=' + encodeURIComponent(q))
3337
3475
  if (seq !== searchUi.seq) return
3338
3476
  searchUi.last = { q, r }
3477
+ if (searchIsOpen()) paintSearchKeeping()
3478
+ await txp
3479
+ // still indexing: ask again while the palette stays open on the same words
3480
+ const tx = searchUi.tx && searchUi.tx.q === q ? searchUi.tx.r : null
3481
+ if (tx && (tx.machines || []).some((m) => m.status === 'indexing')) setTimeout(() => { if (seq === searchUi.seq && searchIsOpen()) runSearchAgain(q, seq) }, 2500)
3482
+ return
3339
3483
  } catch { if (seq === searchUi.seq) searchUi.last = { q, r: { sessions: [], checkpoints: [], prs: [], projects: [], lists: [], todos: [], files: [] } } }
3340
3484
  if (searchIsOpen()) paintSearch()
3341
3485
  }
3486
+ async function runSearchAgain(q, seq) {
3487
+ try { const tr = await txQuery(q, 6); if (seq !== searchUi.seq) return; searchUi.tx = { q, r: tr }; if (searchIsOpen()) paintSearchKeeping() } catch { return }
3488
+ const tx = searchUi.tx.r
3489
+ if ((tx.machines || []).some((m) => m.status === 'indexing')) setTimeout(() => { if (seq === searchUi.seq && searchIsOpen()) runSearchAgain(q, seq) }, 2500)
3490
+ }
3491
+ // A repaint when results arrive keeps the row you had moved to (by its link).
3492
+ function paintSearchKeeping() {
3493
+ const was = searchUi.items[searchUi.active]
3494
+ const moved = searchUi.moved
3495
+ paintSearch()
3496
+ if (!moved || !was) return
3497
+ const i = searchUi.items.findIndex((it) => it.href === was.href && it.title === was.title)
3498
+ if (i >= 0) setSearchActive(i)
3499
+ }
3342
3500
  document.getElementById('searchBtn').addEventListener('click', () => openSearch())
3343
- document.getElementById('q').addEventListener('input', () => { clearTimeout(searchUi.timer); paintSearch(); searchUi.timer = setTimeout(runSearch, 160) })
3501
+ document.getElementById('q').addEventListener('input', () => { clearTimeout(searchUi.timer); searchUi.moved = false; paintSearch(); searchUi.timer = setTimeout(runSearch, 160) })
3344
3502
  document.getElementById('q').addEventListener('keydown', (e) => {
3345
3503
  const n = searchUi.items.length
3346
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (n) setSearchActive((searchUi.active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n) }
3504
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); searchUi.moved = true; if (n) setSearchActive((searchUi.active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n) }
3347
3505
  else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeSearch(); document.getElementById('searchBtn').focus() }
3348
3506
  })
3349
3507
  document.getElementById('cmdk').addEventListener('click', (e) => {
package/ui/icons.js CHANGED
@@ -45,7 +45,8 @@ export const ICONS = {
45
45
  "pin-off": "<path d=\"M12 17v5\"/> <path d=\"M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89\"/> <path d=\"m2 2 20 20\"/> <path d=\"M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11\"/>",
46
46
  "archive": "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\"/> <path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\"/> <path d=\"M10 12h4\"/>",
47
47
  "bookmark-plus": "<path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z\"/> <path d=\"M12 7v6\"/> <path d=\"M15 10H9\"/>",
48
- "circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>"
48
+ "circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>",
49
+ "message-square": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>"
49
50
  }
50
51
 
51
52
  export function icon(name, cls = 'ic') {