@leftoff/cli 0.9.4 → 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.
- package/CHANGELOG.md +10 -0
- package/bin/leftoff.js +310 -289
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +72 -6
- package/mod/hooks/register.js +11 -3
- package/package.json +1 -1
- package/ui/app.css +50 -5
- package/ui/app.js +168 -49
- package/ui/cp-text.js +43 -0
- package/ui/handoffs.js +1 -1
- package/ui/prs.js +1 -1
- package/ui/settings-dirty.js +27 -0
- package/ui/writing.js +55 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.
|
|
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:** ' +
|
|
468
|
-
'**Stopped at:** ' +
|
|
469
|
-
'**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
|
-
|
|
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) {
|
package/mod/hooks/register.js
CHANGED
|
@@ -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.
|
|
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
|
+
"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 {
|
|
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
|
-
.
|
|
1536
|
-
.
|
|
1537
|
-
.
|
|
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,42 @@ 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; }
|