@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/CHANGELOG.md +15 -0
- package/bin/leftoff.js +350 -324
- package/docs/SECURITY.md +1 -0
- package/docs/peer-protocol.md +2 -0
- 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 +103 -5
- package/ui/app.js +341 -64
- package/ui/cp-text.js +43 -0
- package/ui/handoffs.js +1 -1
- package/ui/icons.js +2 -1
- package/ui/prs.js +1 -1
- package/ui/settings-dirty.js +27 -0
- package/ui/writing.js +55 -0
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/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.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.
|
|
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 {
|
|
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,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
|
+
}
|