@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/CHANGELOG.md +5 -0
- package/bin/leftoff.js +324 -319
- package/docs/SECURITY.md +1 -0
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +53 -0
- package/ui/app.js +173 -15
- package/ui/icons.js +2 -1
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.
|
package/docs/peer-protocol.md
CHANGED
|
@@ -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.
|
|
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"
|
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.
|
|
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.
|
|
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
|
|
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>' : '') +
|
|
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) => {
|
|
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
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
if (
|
|
2895
|
-
|
|
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
|
-
|
|
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) + '
|
|
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') {
|