@leftoff/cli 0.9.3 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
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.3' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.5' // 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.3",
3
+ "version": "0.9.5",
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; }
@@ -1784,13 +1790,42 @@ html.dv-open .lo-menu { z-index: 62; }
1784
1790
  .lo-act-sub { font-size: 12.5px; color: var(--muted); font-weight: 400; line-height: 1.35; }
1785
1791
  .lo-act-note { display: flex; align-items: flex-start; gap: 10px; padding: 8px 10px 10px; margin-bottom: 4px; border-bottom: 1px solid var(--line); color: var(--text2); font-size: 13.5px; line-height: 1.4; white-space: normal; }
1786
1792
  .lo-act-note .ic { width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--muted); }
1787
- .lo-act-row { display: flex; align-items: center; gap: 6px; padding: 2px 10px 6px 36px; }
1788
- .lo-act-rowlabel { font-size: 12.5px; color: var(--muted); margin-right: 2px; }
1789
- .lo-act.alt { width: auto; flex: 0 0 auto; padding: 5px 12px; border: 1px solid var(--line-strong); border-radius: 999px; font-size: 13px; color: var(--text2); }
1790
1793
  .lo-menu.sheet .lo-act-note { font-size: 15px; padding: 10px 10px 12px; }
1791
1794
  .lo-menu.sheet .lo-act-sub { font-size: 14px; }
1792
- .lo-menu.sheet .lo-act-row { padding: 0 10px 10px 36px; gap: 8px; }
1793
- .lo-menu.sheet .lo-act.alt { flex: 1 1 0; justify-content: center; padding: 11px 12px; font-size: 16px; min-height: 44px; }
1795
+ /* 0.9.4: a row with a second page (Checkpoint now ›), the iOS way: the row
1796
+ does its action, the › at its right edge (its own target, a hairline before
1797
+ it) slides the menu to the page; ‹ title goes back. Fixed width on a
1798
+ desktop so the page change only eases the height. */
1799
+ .lo-actmenu.has-pages { overflow-x: hidden; }
1800
+ .lo-menu.lo-actmenu.has-pages:not(.sheet) { width: min(312px, calc(100vw - 16px)); max-width: none; }
1801
+ .lo-act-split { display: flex; align-items: stretch; position: relative; }
1802
+ .lo-act-split .lo-act-main { flex: 1 1 auto; min-width: 0; }
1803
+ .lo-act-next { flex: none; display: flex; align-items: center; justify-content: center; width: 40px; margin-left: 2px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--muted); cursor: pointer; position: relative; }
1804
+ .lo-act-next::before { content: ""; position: absolute; left: -1px; top: 9px; bottom: 9px; width: 1px; background: var(--line); }
1805
+ .lo-act-next .ic { width: 16px; height: 16px; }
1806
+ .lo-act-next:hover, .lo-act-next:focus { outline: none; background: var(--bg2); color: var(--text); }
1807
+ [data-theme="dark"] .lo-act-next:hover, [data-theme="dark"] .lo-act-next:focus, :root:not([data-theme="light"]) .lo-act-next:hover, :root:not([data-theme="light"]) .lo-act-next:focus { background: var(--line); }
1808
+ .lo-act-next:focus-visible { box-shadow: inset 0 0 0 2px var(--focus); }
1809
+ .lo-act-split:hover .lo-act-next::before, .lo-act-split:focus-within .lo-act-next::before { opacity: 0; }
1810
+ .lo-act-back { color: var(--text); font-weight: 600; border-bottom: 1px solid var(--line); border-radius: 8px 8px 0 0; margin-bottom: 4px; }
1811
+ .lo-act-back .ic { color: var(--accent); }
1812
+ .lo-act-page .lo-act.has-sub { padding-top: 9px; padding-bottom: 9px; }
1813
+ .lo-act-page .lo-act.on { font-weight: 600; }
1814
+ .lo-act .lo-act-check { color: var(--accent); }
1815
+ .lo-act .lo-act-sub { white-space: normal; }
1816
+ .lo-vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
1817
+ .lo-menu.lo-act-sizing { transition: height .2s cubic-bezier(.2, .8, .2, 1), top .2s cubic-bezier(.2, .8, .2, 1); overflow-y: hidden; }
1818
+ @keyframes lo-page-fwd { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: none; } }
1819
+ @keyframes lo-page-back { from { opacity: 0; transform: translateX(-18px); } to { opacity: 1; transform: none; } }
1820
+ .lo-act-page.lo-in-fwd { animation: lo-page-fwd .2s cubic-bezier(.2, .8, .2, 1); }
1821
+ .lo-act-page.lo-in-back { animation: lo-page-back .2s cubic-bezier(.2, .8, .2, 1); }
1822
+ .lo-menu.sheet .lo-act-next { width: 52px; min-height: 44px; }
1823
+ .lo-menu.sheet .lo-act-next .ic { width: 20px; height: 20px; }
1824
+ .lo-menu.sheet .lo-act-next::before { top: 12px; bottom: 12px; }
1825
+ .lo-menu.sheet .lo-act-page .lo-act.has-sub { padding-top: 12px; padding-bottom: 12px; }
1826
+ /* a phone keeps :hover on the last row tapped; only focus highlights there */
1827
+ @media (hover: none) { :root .lo-act:hover:not(:focus), :root .lo-act-next:hover:not(:focus) { background: none; } }
1828
+ @media (prefers-reduced-motion: reduce) { .lo-act-page.lo-in-fwd, .lo-act-page.lo-in-back { animation: none; } .lo-menu.lo-act-sizing { transition: none; } }
1794
1829
  .split { display: inline-flex; align-items: stretch; }
1795
1830
  .split .btn.split-main { border-top-right-radius: 0; border-bottom-right-radius: 0; }
1796
1831
  .split .btn.split-more { border-top-left-radius: 0; border-bottom-left-radius: 0; padding: 0 8px; margin-left: 1px; display: inline-flex; align-items: center; }
@@ -1804,3 +1839,42 @@ html.dv-open .lo-menu { z-index: 62; }
1804
1839
  .statuspop .sp-theme .theme { flex: 1 1 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); flex-wrap: nowrap; }
1805
1840
  .statuspop .sp-theme .theme button { justify-content: center; white-space: nowrap; min-width: 0; height: 30px; padding: 0 6px; }
1806
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; }