@leftoff/cli 0.9.4 → 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.4",
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/core.js CHANGED
@@ -293,7 +293,7 @@ export function parseFrontmatter(text) {
293
293
  // ---------------------------------------------------------------- checkpoint markdown
294
294
 
295
295
  const CP_ORDER = ['id', 'session_id', 'session_title', 'created_at', 'trigger', 'machine', 'repo', 'branch', 'cwd',
296
- 'files_touched', 'turns_since_last', 'user_note', 'open_question', 'parse_ok', 'length', 'generator', 'schema']
296
+ 'files_touched', 'turns_since_last', 'user_note', 'open_question', 'parse_ok', 'format', 'length', 'generator', 'schema']
297
297
 
298
298
  export function resumeCommand(session) {
299
299
  const id = session.id || session.sessionId
@@ -459,14 +459,18 @@ export function renderCheckpointMd(cp) {
459
459
  user_note: cp.userNote || undefined,
460
460
  open_question: cp.openQuestion || undefined,
461
461
  parse_ok: cp.parseOk === false ? false : undefined,
462
+ format: cp.format === 'markdown' ? 'markdown' : undefined,
462
463
  length: cp.length && SUMMARY_LENGTHS[cp.length] ? cp.length : undefined,
463
464
  generator: cp.generator ? toSnake(cp.generator) : { by: 'unknown' },
464
465
  schema: SCHEMA,
465
466
  }
467
+ // 0.9.5: a markdown checkpoint keeps its lines: the ones after the first are
468
+ // indented two spaces under their label (older readers take the first line).
469
+ const field = cp.format === 'markdown' ? mdField : oneLine
466
470
  const lines = [
467
- '**Doing:** ' + oneLine(cp.doing),
468
- '**Stopped at:** ' + oneLine(cp.stoppedAt),
469
- '**Next:** ' + oneLine(cp.next),
471
+ '**Doing:** ' + field(cp.doing),
472
+ '**Stopped at:** ' + field(cp.stoppedAt),
473
+ '**Next:** ' + field(cp.next),
470
474
  ]
471
475
  if (cp.openQuestion) lines.push('**Open question:** ' + oneLine(cp.openQuestion))
472
476
  if (cp.userNote) lines.push('**Note:** ' + oneLine(cp.userNote))
@@ -487,6 +491,7 @@ export function renderCheckpointMd(cp) {
487
491
  }
488
492
 
489
493
  function oneLine(s) { return String(s == null ? '' : s).replace(/\s*\n\s*/g, ' ').trim() }
494
+ function mdField(s) { return String(s == null ? '' : s).replace(/\r\n?/g, '\n').trim().split('\n').map((l, i) => (i ? ' ' + l.replace(/\s+$/, '') : l)).join('\n') }
490
495
 
491
496
  const BODY_FIELDS = { 'doing': 'doing', 'stopped at': 'stoppedAt', 'next': 'next', 'open question': 'openQuestion', 'note': 'userNote' }
492
497
 
@@ -507,20 +512,25 @@ export function parseCheckpointMd(text) {
507
512
  userNote: data.user_note || null,
508
513
  openQuestion: data.open_question || null,
509
514
  parseOk: data.parse_ok === false ? false : true,
515
+ ...(data.format === 'markdown' ? { format: 'markdown' } : {}),
510
516
  generator: data.generator && typeof data.generator === 'object' ? toCamel(data.generator) : { by: 'unknown' },
511
517
  length: data.length && SUMMARY_LENGTHS[data.length] ? data.length : null,
512
518
  doing: '', stoppedAt: '', next: '',
513
519
  }
514
520
  const lines = body.split('\n')
515
521
  let k = 0
522
+ let open = null // 0.9.5: the field a markdown checkpoint's indented lines continue
516
523
  for (; k < lines.length; k++) {
517
524
  const line = lines[k]
525
+ if (open && cp.format === 'markdown' && (/^ /.test(line) || (!line.trim() && /^ \S/.test(lines.slice(k + 1).find((x) => x.trim()) || '')))) { cp[open] += '\n' + line.slice(2); continue }
526
+ open = null
518
527
  if (/^###\s/.test(line)) break // the sections of an informative / long checkpoint
519
528
  const m = line.match(/^\s*\*\*([A-Za-z ]+):\*\*\s*(.*)$/)
520
529
  if (!m) continue
521
530
  const f = BODY_FIELDS[m[1].trim().toLowerCase()]
522
- if (f) cp[f] = m[2].trim()
531
+ if (f) { cp[f] = m[2].trim(); open = f }
523
532
  }
533
+ if (cp.format === 'markdown') for (const f of ['doing', 'stoppedAt', 'next']) cp[f] = cp[f].replace(/\n{3,}/g, '\n\n').trim()
524
534
  const sec = []
525
535
  for (; k < lines.length; k++) { if (/^Resume: `/.test(lines[k])) break; sec.push(lines[k]) }
526
536
  Object.assign(cp, parseSections(sec))
@@ -1348,12 +1358,68 @@ export function parseCheckpointReply(text) {
1348
1358
  else rest.push(raw)
1349
1359
  }
1350
1360
  if (!out.doing || !out.stoppedAt || !out.next) {
1351
- return { doing: clip(text, 600) || '(empty reply)', stoppedAt: out.stoppedAt || '', next: out.next || '', title: out.title, openQuestion: null, parseOk: false, ...emptyExtras() }
1361
+ // 0.9.5: not the format asked for (a small model's markdown write-up): read what we can
1362
+ // from its headings and labels, keep the whole text (no 600-character cut), mark it.
1363
+ return { ...salvageCheckpointReply(text), title: out.title ? clip(out.title.replace(/^["']|["']$/g, ''), 60) : null }
1352
1364
  }
1353
1365
  if (out.title) out.title = clip(out.title.replace(/^["']|["']$/g, ''), 60)
1354
1366
  return { ...out, ...parseSections(rest) }
1355
1367
  }
1356
1368
 
1369
+ // 0.9.5: a reply that didn't follow the format, read leniently. Headings
1370
+ // (### Summary), bold labels (**Status:** …, **Next steps**) and plain labels
1371
+ // (Next: …) map onto the three fields; whatever doesn't map stays in Doing.
1372
+ // The markdown is kept as written (the session page renders it; cards and the
1373
+ // digest strip it), up to MD_CAP characters a field.
1374
+ export const MD_CAP = 8000
1375
+ const SALVAGE_KEYS = [
1376
+ ['doing', /^(doing|what i'?m doing|what i am doing|working on|what was being done|summary|overview|tl;?dr|current (task|work|focus)|task|goal|context|what happened|background)$/],
1377
+ ['stoppedAt', /^(stopped( at)?|where (it|we|i|things) (stopped|left off|stands?)|left off|status|current status|state|current state|progress|where things stand|last step|result|results|outcome)$/],
1378
+ ['next', /^(next|next steps?|what'?s next|to ?dos?|todo list|next actions?|remaining( work)?|follow[- ]?ups?|action items?|recommendations?)$/],
1379
+ ]
1380
+ function salvageKey(label) {
1381
+ const l = String(label || '').replace(/[’`]/g, '\'').replace(/\*\*|__/g, '').replace(/^[^A-Za-z]+/, '').replace(/[\s:.\-–—]+$/, '').trim().toLowerCase()
1382
+ if (!l || l.length > 40) return null
1383
+ // "Session summary", "Summary of the session", "Current status", "Next steps for the PR"
1384
+ const core_ = l.replace(/^(the|my|our|session|current|work|overall)\s+/, '').replace(/\s+(of|for|on|so far|in)\b.*$/, '').trim()
1385
+ for (const [k, re] of SALVAGE_KEYS) if (re.test(l) || re.test(core_)) return k
1386
+ return null
1387
+ }
1388
+ const capText = (s) => { const t = String(s || '').replace(/\n{3,}/g, '\n\n').trim(); return t.length > MD_CAP ? t.slice(0, MD_CAP - 1).trimEnd() + '…' : t }
1389
+ export function salvageCheckpointReply(text) {
1390
+ const buckets = { doing: [], stoppedAt: [], next: [], rest: [] }
1391
+ let cur = 'rest'
1392
+ for (const raw of String(text || '').replace(/\r\n?/g, '\n').split('\n')) {
1393
+ const line = raw.replace(/\s+$/, '')
1394
+ let m
1395
+ if ((m = line.match(/^\s{0,3}#{1,6}\s+(.*?)\s*#*$/))) {
1396
+ const k = salvageKey(m[1])
1397
+ if (k) { cur = k; continue }
1398
+ cur = 'rest'; buckets.rest.push(line); continue
1399
+ }
1400
+ if ((m = line.match(/^\s*(?:[-*+]\s+)?(?:\*\*|__)([^*_]{1,40}?)(?:\*\*|__)\s*(:?)\s*(.*)$/))) {
1401
+ const label = m[1].replace(/:\s*$/, '')
1402
+ const k = (m[1].trim().endsWith(':') || m[2] || !m[3]) ? salvageKey(label) : null
1403
+ if (k) { cur = k; if (m[3].trim()) buckets[k].push(m[3].trim()); continue }
1404
+ }
1405
+ if ((m = line.match(/^\s*([A-Za-z][A-Za-z '’]{1,30}):\s*(.*)$/))) {
1406
+ const k = salvageKey(m[1])
1407
+ if (k) { cur = k; if (m[2].trim()) buckets[k].push(m[2].trim()); continue }
1408
+ }
1409
+ buckets[cur].push(line)
1410
+ }
1411
+ const doing = capText([...buckets.doing, ...(buckets.doing.length && buckets.rest.some((x) => x.trim()) ? [''] : []), ...buckets.rest].join('\n'))
1412
+ return { doing: doing || '(empty reply)', stoppedAt: capText(buckets.stoppedAt.join('\n')), next: capText(buckets.next.join('\n')), title: null, openQuestion: null, parseOk: false, format: 'markdown', ...emptyExtras() }
1413
+ }
1414
+ // Of two salvaged replies (the first and the retry's), the one that filled more fields.
1415
+ export function betterSalvage(a, b) {
1416
+ const score = (r) => (r && r.stoppedAt ? 1 : 0) + (r && r.next ? 1 : 0)
1417
+ return score(b) > score(a) ? b : a
1418
+ }
1419
+ // The retry's prompt: the same request, with the format restated plainly.
1420
+ export const RETRY_REMINDER = '\n\nIMPORTANT: your reply must use exactly this format, starting with the first line. No headings (#), no bold (**), no preamble:\nDoing: <one or two sentences>\nStopped at: <one or two sentences>\nNext: <one sentence>\n(Then the section headings above, if this length asks for them.)'
1421
+ export const retryCheckpointPrompt = (prompt) => String(prompt || '') + RETRY_REMINDER
1422
+
1357
1423
  // 0.9.2: a checkpoint written with nothing to go on: no prompt and no reply were
1358
1424
  // found (the heuristic's placeholder lines). Never saved; old ones are set aside.
1359
1425
  export function isEmptyCheckpoint(cp) {
@@ -14,7 +14,7 @@
14
14
  // each event is registered once per matcher.
15
15
  import {
16
16
  DEFAULT_CONFIG, DEFAULT_PORT, mergeConfig, resolveMachine, applyEvent, checkpointDue, buildCheckpointPrompt,
17
- parseCheckpointReply, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
17
+ parseCheckpointReply, retryCheckpointPrompt, betterSalvage, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
18
18
  sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
19
19
  shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
@@ -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.4' // 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,
@@ -645,6 +645,14 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
645
645
  const reply = await callModel($, ctx)
646
646
  let parsed = reply ? parseCheckpointReply(reply.text) : null
647
647
  let generator = reply ? { by: 'mod', model: reply.model, ...(reply.backend ? { backend: reply.backend } : {}) } : null
648
+ // 0.9.5: a reply not in the format (a markdown write-up): ask once more, with the format restated
649
+ if (parsed && parsed.parseOk === false) {
650
+ const again = await callModel($, { ...ctx, prompt: retryCheckpointPrompt(ctx.prompt) })
651
+ const p2 = again ? parseCheckpointReply(again.text) : null
652
+ if (p2 && p2.parseOk !== false) parsed = p2
653
+ else if (p2) parsed = betterSalvage(parsed, p2)
654
+ generator = { ...generator, retried: true }
655
+ }
648
656
  if (!parsed) {
649
657
  // No model answer: let the daemon try its own summarizer, if it's there.
650
658
  const r = await api($, 'POST', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/checkpoints', { trigger, generate: true, userNote: userNote || undefined, length: ctx.length, next: opts.next || undefined }, MODEL_TIMEOUT_MS * 3)
@@ -660,7 +668,7 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
660
668
  sessionTitle: record.title || null, repo: record.repo ? repoName(record) : null, branch: (record.repo && record.repo.branch) || null, cwd: record.cwd || null,
661
669
  doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
662
670
  userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
663
- parseOk: parsed.parseOk, generator, length: ctx.length,
671
+ parseOk: parsed.parseOk, ...(parsed.format === 'markdown' ? { format: 'markdown' } : {}), generator, length: ctx.length,
664
672
  decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
665
673
  didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
666
674
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.4",
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
@@ -1106,6 +1106,11 @@ lo-combo .lo-combo-input:focus { outline: none; border-color: var(--accent); box
1106
1106
  .form .set-pane > .section:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
1107
1107
  .form .set-save { align-items: center; justify-content: flex-end; flex-wrap: wrap; border-top: 1px solid var(--line); padding-top: 14px; }
1108
1108
  .form .set-save .faint { color: var(--faint); font-size: 12px; margin-right: auto; }
1109
+ /* 0.9.5: Save is off until something changes. Off reads as a plain grey button, still legible. */
1110
+ .form .set-save .btn.primary { transition: background-color .15s, border-color .15s, color .15s; }
1111
+ .form .set-save .btn.primary:disabled { background: var(--bg2); border-color: var(--line); color: var(--muted); font-weight: 500; opacity: 1; cursor: default; filter: none; }
1112
+ .form .set-save.dirty .faint { color: var(--muted); }
1113
+ @media (prefers-reduced-motion: reduce) { .form .set-save .btn.primary { transition: none; } }
1109
1114
  .form .set-empty, .form .set-link { grid-column: 1 / -1; margin-top: 0; }
1110
1115
  .form .aboutline { display: flex; align-items: center; gap: 6px 14px; flex-wrap: wrap; }
1111
1116
  .form .aboutline a { color: var(--accent); text-decoration: none; }
@@ -1431,8 +1436,7 @@ a.cpr:hover { border-color: var(--line-strong); }
1431
1436
  .pr-sess .sesslink::before { content: '↳ '; color: var(--faint); }
1432
1437
  .pr-actions { display: flex; flex-direction: column; gap: 6px; flex: none; align-items: stretch; }
1433
1438
  .prlist { margin-bottom: 8px; }
1434
- .prlink { display: flex; gap: 8px; margin: 6px 0 16px; }
1435
- .prlink input { flex: 1 1 auto; min-width: 0; font-size: 16px; }
1439
+ .prlink { margin: 6px 0 16px; }
1436
1440
  .prs-gh { margin-top: 6px; }
1437
1441
  .prs-foot { font-size: 12px; margin-top: 6px; }
1438
1442
  /* 0.8.5: PR filters (search, repo chips, state/check chips), the why, Quiet, details on tap */
@@ -1532,9 +1536,11 @@ li.todo.ticked .todo-text { text-decoration: line-through; }
1532
1536
  .todo-code code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
1533
1537
  .todo-code .todo-cp { flex: none; font-size: 11.5px; color: var(--accent); }
1534
1538
  .todo-meta { margin-top: 3px; font-size: 12px; color: var(--faint); }
1535
- .todo-add { display: flex; gap: 8px; margin-top: 10px; }
1536
- .todo-add input { flex: 1; min-width: 0; font-size: 16px; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--field); color: var(--text); }
1537
- .todo-add input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
1539
+ /* 0.9.5: the session page's inline fields (Add a to-do, Link a PR) share one look */
1540
+ .sess-add { display: flex; gap: 8px; align-items: center; }
1541
+ .sess-add input { flex: 1; min-width: 0; font-size: 16px; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--field); color: var(--text); }
1542
+ .sess-add input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
1543
+ .todo-add { margin-top: 10px; }
1538
1544
  /* 0.8.2: Parked */
1539
1545
  .s-parked { color: var(--accent); background: var(--tint-accent); }
1540
1546
  .parked-sec { margin-top: 22px; }
@@ -1833,3 +1839,95 @@ html.dv-open .lo-menu { z-index: 62; }
1833
1839
  .statuspop .sp-theme .theme { flex: 1 1 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); flex-wrap: nowrap; }
1834
1840
  .statuspop .sp-theme .theme button { justify-content: center; white-space: nowrap; min-width: 0; height: 30px; padding: 0 6px; }
1835
1841
  @media (pointer: coarse) { .statuspop .sp-theme .theme button { height: 36px; font-size: 13px; } }
1842
+
1843
+ /* 0.9.5: Checkpoint now while it's written: a line that stays until it lands or fails */
1844
+ .cp-writing { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--muted); background: var(--tint-accent); border: 1px solid var(--line); border-radius: 10px; padding: 7px 10px; min-height: 34px; }
1845
+ .card > .cp-writing { margin-top: 8px; }
1846
+ .cp-stale > .cp { margin-top: 0; }
1847
+ .cp-writing .wtext { flex: 1; min-width: 0; overflow-wrap: anywhere; }
1848
+ .cp-writing.writing .wtext, .cp-writing.syncing .wtext { color: var(--text); font-weight: 500; }
1849
+ .cp-writing .ic { width: 14px; height: 14px; flex: none; }
1850
+ .cp-writing.late { background: var(--subtle); color: var(--faint); }
1851
+ .cp-writing.failed { background: var(--tint-red); border-color: transparent; color: var(--red); }
1852
+ .cp-writing.failed .wtext { color: var(--red); }
1853
+ .cp-writing .wx { margin-left: auto; color: inherit; opacity: .8; min-width: 28px; min-height: 28px; display: inline-flex; align-items: center; justify-content: center; }
1854
+ .cp-writing .wx:hover { opacity: 1; }
1855
+ .cp-writing.tl { margin: 0; }
1856
+ .cp-stale { opacity: .45; transition: opacity .2s; }
1857
+ .tl-item.tl-writing { padding-top: 2px; }
1858
+ .split.is-writing .split-main { gap: 7px; }
1859
+ .split.is-writing .btn:disabled { opacity: .8; cursor: progress; }
1860
+ .split.is-writing .spin { width: 12px; height: 12px; border-color: color-mix(in srgb, var(--on-accent) 35%, transparent); border-top-color: var(--on-accent); }
1861
+ @keyframes cpfresh { 0%, 60% { background-color: var(--tint-accent); box-shadow: 0 0 0 4px var(--tint-accent); } 100% { background-color: transparent; box-shadow: 0 0 0 4px transparent; } }
1862
+ .cp-fresh > .cp, .tl-item.cp-fresh { animation: cpfresh 2.4s ease-out; border-radius: 10px; }
1863
+ @media (prefers-reduced-motion: reduce) { .cp-fresh > .cp, .tl-item.cp-fresh { animation: none; } .cp-writing.writing .spin, .cp-writing.syncing .spin { animation: pulse 1.6s ease-in-out infinite; } .cp-stale { transition: none; } }
1864
+
1865
+ /* 0.9.5: a markdown checkpoint (a model's write-up) rendered on the session page; elsewhere a long one is clamped */
1866
+ .cp dd.clamp { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; line-clamp: 4; overflow: hidden; }
1867
+ .cp dd.cp-md { min-width: 0; overflow-wrap: anywhere; }
1868
+ .cp dd.cp-md > :first-child { margin-top: 0; }
1869
+ .cp dd.cp-md > :last-child { margin-bottom: 0; }
1870
+ .cp dd.cp-md p { margin: 0 0 6px; }
1871
+ .cp dd.cp-md h2, .cp dd.cp-md h3, .cp dd.cp-md h4, .cp dd.cp-md h5, .cp dd.cp-md h6 { font-size: 13px; font-weight: 650; color: var(--text); margin: 10px 0 4px; letter-spacing: 0; text-transform: none; }
1872
+ .cp dd.cp-md ul, .cp dd.cp-md ol { margin: 2px 0 6px; padding-left: 18px; }
1873
+ .cp dd.cp-md li { margin: 2px 0; }
1874
+ .cp dd.cp-md li > ul, .cp dd.cp-md li > ol { margin: 2px 0; }
1875
+ .cp dd.cp-md code { font-family: var(--mono); font-size: 12px; background: var(--tint-gray); padding: 1px 4px; border-radius: 4px; }
1876
+ .cp dd.cp-md pre { overflow-x: auto; background: var(--subtle); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin: 4px 0 8px; }
1877
+ .cp dd.cp-md pre code { background: none; padding: 0; }
1878
+ .cp dd.cp-md a { color: var(--accent); }
1879
+ .cp dd.cp-md blockquote { margin: 4px 0; padding-left: 10px; border-left: 2px solid var(--line-strong); color: var(--muted); }
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
+ }