@leftoff/cli 0.6.3 → 0.6.8

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.6.3",
3
+ "version": "0.6.8",
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
@@ -285,8 +285,119 @@ const CP_ORDER = ['id', 'session_id', 'session_title', 'created_at', 'trigger',
285
285
  export function resumeCommand(session) {
286
286
  const id = session.id || session.sessionId
287
287
  const cwd = session.cwd
288
- const q = (p) => (/^[A-Za-z0-9_./~@+-]+$/.test(p) ? p : "'" + String(p).replace(/'/g, "'\\''") + "'")
289
- return (cwd ? 'cd ' + q(cwd) + ' && ' : '') + 'claude --resume ' + id
288
+ return (cwd ? 'cd ' + shArg(cwd) + ' && ' : '') + claudeInvocation(session, ['--resume', id])
289
+ }
290
+
291
+ // POSIX single quotes when needed (also right in fish).
292
+ export function shArg(s) {
293
+ const v = String(s)
294
+ return /^[A-Za-z0-9_./~@%+=:,-]+$/.test(v) ? v : "'" + v.replace(/'/g, "'\\''") + "'"
295
+ }
296
+
297
+ // ---------------------------------------------------------------- backend (0.6.8)
298
+ //
299
+ // Claude Code pointed at another server. `ollama launch claude --model M`
300
+ // (ollama/ollama cmd/launch/claude.go) runs `claude --model M [args after --]`
301
+ // with ANTHROPIC_BASE_URL = the Ollama host (OLLAMA_HOST, default
302
+ // http://127.0.0.1:11434, also for :cloud models: the local server forwards
303
+ // them), ANTHROPIC_AUTH_TOKEN=ollama, ANTHROPIC_API_KEY empty and
304
+ // ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL / CLAUDE_CODE_SUBAGENT_MODEL = M.
305
+ // We keep { kind: 'ollama', model, baseUrl } (the origin only) or, for any other
306
+ // non-Anthropic base URL, { kind: 'custom', host, model }. Never a token or key:
307
+ // the auth token is only compared with the literal "ollama".
308
+
309
+ const OLLAMA_PORT = '11434'
310
+ const LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1', '0.0.0.0'])
311
+
312
+ // "opus", "sonnet[1m]", "claude-opus-4-5", "us.anthropic.claude-…": a Claude model or alias.
313
+ export function isClaudeModel(m) {
314
+ const v = String(m || '').trim().toLowerCase()
315
+ if (!v || v === '<synthetic>') return true
316
+ return /claude/.test(v) || /^(opus|sonnet|haiku|opusplan|default|best)(\[1m\])?$/.test(v)
317
+ }
318
+
319
+ function httpUrl(raw) {
320
+ try { const u = new URL(String(raw).trim()); return /^https?:$/.test(u.protocol) ? u : null } catch { return null }
321
+ }
322
+
323
+ export function isAnthropicHost(host) { return /(^|\.)anthropic\.com$/i.test(String(host || '')) }
324
+
325
+ // The model Claude Code really talks to: the session's model when it isn't a
326
+ // Claude name, else what the alias resolves to (ANTHROPIC_DEFAULT_*_MODEL),
327
+ // else ANTHROPIC_MODEL, else any tier's pin.
328
+ export function realModel(env = {}, model = null) {
329
+ if (model && !isClaudeModel(model)) return String(model)
330
+ const m = String(model || '').toLowerCase()
331
+ const tier = /haiku/.test(m) ? env.ANTHROPIC_DEFAULT_HAIKU_MODEL : /sonnet/.test(m) ? env.ANTHROPIC_DEFAULT_SONNET_MODEL : env.ANTHROPIC_DEFAULT_OPUS_MODEL
332
+ for (const c of [tier, env.ANTHROPIC_MODEL, env.ANTHROPIC_DEFAULT_OPUS_MODEL, env.ANTHROPIC_DEFAULT_SONNET_MODEL, env.ANTHROPIC_DEFAULT_HAIKU_MODEL, env.CLAUDE_CODE_SUBAGENT_MODEL]) {
333
+ if (c && String(c).trim() && !isClaudeModel(c)) return String(c).trim()
334
+ }
335
+ return null
336
+ }
337
+
338
+ // env (Claude Code's own) -> null (Anthropic) | { kind: 'ollama', model, baseUrl } | { kind: 'custom', host, model }
339
+ export function detectBackend(env = {}, { model = null } = {}) {
340
+ const u = env.ANTHROPIC_BASE_URL ? httpUrl(env.ANTHROPIC_BASE_URL) : null
341
+ if (!u || isAnthropicHost(u.hostname)) return null
342
+ const host = u.hostname.toLowerCase()
343
+ const real = realModel(env, model)
344
+ const ollama = u.port === OLLAMA_PORT || /(^|\.)ollama\.com$/.test(host) || env.ANTHROPIC_AUTH_TOKEN === 'ollama'
345
+ if (ollama) return stripNulls({ kind: 'ollama', model: real, baseUrl: u.origin })
346
+ return stripNulls({ kind: 'custom', host: u.host, model: real })
347
+ }
348
+
349
+ // Whatever arrives on the wire or from a file -> a clean backend, or null.
350
+ export function cleanBackend(b) {
351
+ if (!b || typeof b !== 'object') return null
352
+ const model = typeof b.model === 'string' && b.model.trim() ? clip(b.model.trim(), 120) : null
353
+ if (b.kind === 'ollama') {
354
+ const u = b.baseUrl ? httpUrl(b.baseUrl) : null
355
+ return stripNulls({ kind: 'ollama', model, baseUrl: u ? u.origin : null, inferred: b.inferred ? true : null })
356
+ }
357
+ if (b.kind === 'custom') {
358
+ const host = typeof b.host === 'string' && b.host.trim() ? clip(b.host.trim().replace(/^[a-z]+:\/\//i, '').replace(/[/?#@].*$/, ''), 120) : null
359
+ return stripNulls({ kind: 'custom', host, model, inferred: b.inferred ? true : null })
360
+ }
361
+ return null
362
+ }
363
+
364
+ // A transcript's assistant message.model (what the server echoed) -> a backend
365
+ // for a session that has none (sessions from before 0.6.8). Only a model that
366
+ // isn't Claude's; an Ollama-style "name:tag" is taken as Ollama.
367
+ export function backendFromTranscriptModel(model) {
368
+ if (!model || isClaudeModel(model)) return null
369
+ const m = String(model).trim()
370
+ return /^[\w.\-/]+:[\w.\-]+$/.test(m) ? { kind: 'ollama', model: m, inferred: true } : { kind: 'custom', model: m, inferred: true }
371
+ }
372
+
373
+ // "deepseek-v4-flash:cloud · Ollama" | "qwen3 · gw.example:4000" | "opus" | null
374
+ export function modelLabel(s) {
375
+ const b = s && s.backend
376
+ if (!b) return (s && s.model) || null
377
+ const model = b.model || (s.model && !isClaudeModel(s.model) ? s.model : null)
378
+ if (b.kind === 'ollama') return model ? model + ' · Ollama' : 'Ollama'
379
+ const where = b.host || 'custom server'
380
+ return model ? model + ' · ' + where : where
381
+ }
382
+
383
+ // Resuming through Ollama: `ollama launch claude --model M -- <claude args>`
384
+ // (docs.ollama.com/integrations/claude-code: arguments after -- go to Claude
385
+ // Code). OLLAMA_HOST is set only when the session used a non-default Ollama.
386
+ // Straight to ollama.com (no ollama launch): the env form, no key; the shell's
387
+ // own ANTHROPIC_AUTH_TOKEN signs in.
388
+ export function viaOllama(b) { return !!(b && b.kind === 'ollama' && !isOllamaCom(b.baseUrl)) }
389
+ function isOllamaCom(url) { const u = url ? httpUrl(url) : null; return !!(u && /(^|\.)ollama\.com$/i.test(u.hostname)) }
390
+
391
+ export function claudeInvocation(session, args = []) {
392
+ const b = session && session.backend
393
+ const tail = args.map(shArg).join(' ')
394
+ if (b && b.kind === 'ollama') {
395
+ if (isOllamaCom(b.baseUrl)) return 'ANTHROPIC_BASE_URL=' + shArg(b.baseUrl) + ' ANTHROPIC_API_KEY= claude' + (b.model ? ' --model ' + shArg(b.model) : '') + (tail ? ' ' + tail : '')
396
+ const u = b.baseUrl ? httpUrl(b.baseUrl) : null
397
+ const host = u && !(LOCAL_HOSTS.has(u.hostname) && u.port === OLLAMA_PORT && u.protocol === 'http:') ? 'OLLAMA_HOST=' + shArg(u.origin) + ' ' : ''
398
+ return host + 'ollama launch claude' + (b.model ? ' --model ' + shArg(b.model) : '') + ' --' + (tail ? ' ' + tail : '')
399
+ }
400
+ return 'claude' + (tail ? ' ' + tail : '')
290
401
  }
291
402
 
292
403
  export function renderCheckpointMd(cp) {
@@ -328,7 +439,7 @@ export function renderCheckpointMd(cp) {
328
439
  else if (k === 'nextSteps') v.forEach((x, n) => lines.push((n + 1) + '. ' + oneLine(x).replace(/^\*+/, '')))
329
440
  else for (const x of v) lines.push(bullet(x))
330
441
  }
331
- const resume = resumeCommand({ id: cp.sessionId, cwd: cp.cwd })
442
+ const resume = resumeCommand({ id: cp.sessionId, cwd: cp.cwd, backend: cp.backend })
332
443
  return toFrontmatter(fm, CP_ORDER) + lines.join('\n') + '\n\nResume: `' + resume + '`\n'
333
444
  }
334
445
 
@@ -383,7 +494,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
383
494
  export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
384
495
  'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
385
496
  'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
386
- 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason']
497
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend']
387
498
 
388
499
  export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
389
500
  'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
@@ -433,6 +544,7 @@ export function parseSessionMd(text) {
433
544
  s.backgroundTasks = typeof s.backgroundTasks === 'number' ? s.backgroundTasks : 0
434
545
  s.pinned = !!s.pinned
435
546
  s.archived = !!s.archived
547
+ if (s.backend !== undefined) { const b = cleanBackend(s.backend); if (b) s.backend = b; else delete s.backend }
436
548
  return s
437
549
  }
438
550
 
@@ -604,7 +716,10 @@ export function checkpointDue(s, now, cfg = DEFAULT_CONFIG) {
604
716
  const gapOk = isNaN(lastCp) || now - lastCp >= cfg.minGapMin * MIN
605
717
  const activitySinceCp = (s.turnsSinceCheckpoint || 0) > 0 || (!isNaN(lastActivity) && (isNaN(lastCp) ? !!s.lastActivityAt : lastActivity > lastCp))
606
718
  if (status === 'ended' || status === 'stale') {
607
- if (s.finalCheckpointDone || !activitySinceCp || !gapOk) return null
719
+ // v0.6.7: closing always leaves a checkpoint when anything happened since the
720
+ // last one, even minutes after it (no minGap here): the Closed list and
721
+ // Resume read it. Nothing new, or an empty session, still writes none.
722
+ if (s.finalCheckpointDone || !activitySinceCp) return null
608
723
  if (!s.lastActivityAt) return null
609
724
  return s.endReason === 'clear' ? 'clear' : 'session_end'
610
725
  }
@@ -653,6 +768,8 @@ export function applyEvent(s, ev) {
653
768
  if (d.cwd) s.cwd = d.cwd
654
769
  if (d.transcriptPath) s.transcriptPath = d.transcriptPath
655
770
  if (d.model) s.model = d.model
771
+ // 0.6.8: where the model runs. A capture that says null (Anthropic) clears it; no key leaves it.
772
+ if ('backend' in d) { const b = cleanBackend(d.backend); if (b) s.backend = b; else delete s.backend }
656
773
  if (d.surface) s.surface = d.surface
657
774
  if (d.repo && typeof d.repo === 'object') s.repo = { ...(s.repo || {}), ...stripNulls(d.repo) }
658
775
  // A title carrying our own tab marker came from the mod's /rename: it is the
@@ -18,7 +18,7 @@ import {
18
18
  sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
19
19
  shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
21
- listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs,
21
+ listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend,
22
22
  } from './core.js'
23
23
 
24
24
  const PANE_ID = 'checkpoints'
@@ -27,7 +27,7 @@ const END_TIMEOUT_MS = 1000
27
27
  const MODEL_TIMEOUT_MS = 20000
28
28
 
29
29
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
30
- const MOD_VERSION = '0.6.3' // keep in step with .claude-plugin/plugin.json
30
+ const MOD_VERSION = '0.6.8' // keep in step with .claude-plugin/plugin.json
31
31
 
32
32
  const st = {
33
33
  internal: false,
@@ -461,6 +461,24 @@ async function readTermEnv($) {
461
461
  return env
462
462
  }
463
463
 
464
+ // 0.6.8: is this Claude Code talking to Ollama or another server? (literal
465
+ // names only). The auth token is only compared with "ollama" and never kept.
466
+ async function readBackend($, model) {
467
+ try {
468
+ const env = {}
469
+ const put = (k, v) => { if (v) env[k] = String(v) }
470
+ put('ANTHROPIC_BASE_URL', await $.env.get('ANTHROPIC_BASE_URL'))
471
+ if (!env.ANTHROPIC_BASE_URL) return null
472
+ if ((await $.env.get('ANTHROPIC_AUTH_TOKEN')) === 'ollama') env.ANTHROPIC_AUTH_TOKEN = 'ollama'
473
+ put('ANTHROPIC_MODEL', await $.env.get('ANTHROPIC_MODEL'))
474
+ put('ANTHROPIC_DEFAULT_OPUS_MODEL', await $.env.get('ANTHROPIC_DEFAULT_OPUS_MODEL'))
475
+ put('ANTHROPIC_DEFAULT_SONNET_MODEL', await $.env.get('ANTHROPIC_DEFAULT_SONNET_MODEL'))
476
+ put('ANTHROPIC_DEFAULT_HAIKU_MODEL', await $.env.get('ANTHROPIC_DEFAULT_HAIKU_MODEL'))
477
+ put('CLAUDE_CODE_SUBAGENT_MODEL', await $.env.get('CLAUDE_CODE_SUBAGENT_MODEL'))
478
+ return detectBackend(env, { model })
479
+ } catch { return null }
480
+ }
481
+
464
482
  // The tab title: the session's name ("<name> · cp:<id>" only when the daemon
465
483
  // says Go needs the marker, v0.5.5). Claude Code draws the terminal title
466
484
  // itself (✳ / ◐ + the session's name, repainted about once a second while it
@@ -515,9 +533,12 @@ async function callModel($, ctx) {
515
533
  if (!r || r.timedOut) return null
516
534
  // The types say complete() resolves to the reply's text; the spec sketch
517
535
  // mentions { isAnswered }. Accept both.
518
- if (typeof r === 'string') return { text: r, model }
536
+ // 0.6.8: through Ollama the alias resolves to the Ollama model: name that one.
537
+ const used = st.backend && st.backend.model ? st.backend.model : model
538
+ const backend = st.backend ? st.backend.kind : undefined
539
+ if (typeof r === 'string') return { text: r, model: used, backend }
519
540
  if (r.isAnswered === false) return null
520
- return { text: r.text || '', model }
541
+ return { text: r.text || '', model: used, backend }
521
542
  } catch {
522
543
  return null
523
544
  }
@@ -539,7 +560,7 @@ async function writeCheckpointDirect($, cp) {
539
560
  try { taken = await $.fs.exists(file) } catch {}
540
561
  if (taken) continue
541
562
  const rec = { ...cp, id: checkpointId(st.sessionId, cp.createdAt, n) }
542
- await $.fs.write(file, renderCheckpointMd(rec))
563
+ await $.fs.write(file, renderCheckpointMd({ ...rec, backend: st.backend || undefined }))
543
564
  return rec
544
565
  }
545
566
  return null
@@ -557,7 +578,7 @@ async function makeCheckpoint($, trigger, userNote, length) {
557
578
  const ctx = buildCheckpointPrompt({ session: record, previous: st.lastCheckpoint, messages, cfg: st.cfg, userNote, length })
558
579
  const reply = await callModel($, ctx)
559
580
  let parsed = reply ? parseCheckpointReply(reply.text) : null
560
- let generator = reply ? { by: 'mod', model: reply.model } : null
581
+ let generator = reply ? { by: 'mod', model: reply.model, ...(reply.backend ? { backend: reply.backend } : {}) } : null
561
582
  if (!parsed) {
562
583
  // No model answer: let the daemon try its own summarizer, if it's there.
563
584
  const r = await api($, 'POST', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/checkpoints', { trigger, generate: true, userNote: userNote || undefined, length: ctx.length }, MODEL_TIMEOUT_MS * 3)
@@ -826,7 +847,8 @@ export function register(on, options) {
826
847
  const termEnv = await readTermEnv($)
827
848
  st.term = detectTerminal(termEnv)
828
849
  st.surface = surface
829
- st.hello = { cwd, repo, model, surface, proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term }
850
+ st.backend = await readBackend($, model)
851
+ st.hello = { cwd, repo, model, backend: st.backend, surface, proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term }
830
852
  await sendOrSpool($, 'session.opened', st.hello)
831
853
  if (st.heartbeat) st.heartbeat.cancel()
832
854
  st.heartbeat = $.clock.every(60000, () => { heartbeat($).catch(() => {}); startReplyLoop($) })
@@ -854,6 +876,7 @@ export function register(on, options) {
854
876
  if (!st.sessionId) st.sessionId = e.session_id
855
877
  await sendOrSpool($, 'session.started', {
856
878
  source: e.source, transcriptPath: e.transcript_path, cwd: e.cwd, sessionTitle: e.session_title, model: e.model, parentSessionId,
879
+ backend: e.model ? await readBackend($, e.model) : (st.backend || null),
857
880
  // a new id in the same process (/clear, /resume) runs in the same tab
858
881
  ...(parentSessionId ? { proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term } : {}),
859
882
  })
@@ -984,7 +1007,7 @@ export function register(on, options) {
984
1007
  Button({ key: 'cp', label: 'Checkpoint', hotkey: 'c', onPress: async () => { await makeCheckpoint($, 'manual', null) } }),
985
1008
  Button({ key: 'copy', label: 'Copy resume', hotkey: 'r', onPress: async () => {
986
1009
  const me = st.paneRows.find((r) => r.id === st.sessionId)
987
- const cmd = me ? me.resume : resumeCommand({ id: st.sessionId, cwd: st.record && st.record.cwd })
1010
+ const cmd = me ? me.resume : resumeCommand({ id: st.sessionId, cwd: st.record && st.record.cwd, backend: st.backend || undefined })
988
1011
  try { await $.ui.copy(cmd); $.ui.toast('Copied: ' + cmd) } catch { $.ui.toast(cmd, { timeoutMs: 10000 }) }
989
1012
  } }),
990
1013
  Button({ key: 'ui', label: 'Open board', hotkey: 'b', onPress: async () => { await openBoard($) } }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.6.3",
3
+ "version": "0.6.8",
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
@@ -95,7 +95,7 @@ h2 .count { color: var(--faint); font-weight: 500; }
95
95
  .pagehead .actions { margin-left: auto; display: flex; gap: 8px; }
96
96
 
97
97
  /* summary strip */
98
- .stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin: 18px 0 4px; }
98
+ .stats { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin: 18px 0 4px; }
99
99
  .stat { background: var(--card); box-shadow: var(--card-shadow); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; text-align: left; cursor: pointer; font: inherit; color: inherit; transition: border-color .15s, background .15s; }
100
100
  .stat:hover { border-color: var(--line-strong); }
101
101
  .stat.on { border-color: var(--accent); background: var(--card2); }
@@ -126,7 +126,7 @@ h2 .count { color: var(--faint); font-weight: 500; }
126
126
  .s-background { color: var(--cyan); background: var(--tint-cyan); }
127
127
  .s-needs_input { color: var(--red); background: var(--tint-red); }
128
128
  .s-dormant { color: var(--accent); background: var(--tint-accent); }
129
- .s-stale, .s-ended { color: var(--gray); background: var(--tint-gray); }
129
+ .s-stale, .s-ended, .s-closed { color: var(--gray); background: var(--tint-gray); }
130
130
  .s-active::before { animation: pulse 1.6s ease-in-out infinite; }
131
131
  @keyframes pulse { 50% { opacity: .35; } }
132
132
 
@@ -212,7 +212,7 @@ h2 .count { color: var(--faint); font-weight: 500; }
212
212
 
213
213
  @media (max-width: 900px) {
214
214
  main { padding: 18px 16px 60px; }
215
- .stats { grid-template-columns: repeat(2, 1fr); }
215
+ .stats { grid-template-columns: repeat(3, 1fr); }
216
216
  .pagehead { flex-wrap: wrap; }
217
217
  .pagehead .actions { margin-left: 0; }
218
218
  .facts { grid-template-columns: repeat(2, 1fr); }
@@ -221,6 +221,10 @@ h2 .count { color: var(--faint); font-weight: 500; }
221
221
  }
222
222
 
223
223
  .chip.machine { color: var(--machine); border-color: color-mix(in srgb, var(--accent) 35%, transparent); background: var(--tint-accent); }
224
+ .chip.model { max-width: 100%; white-space: nowrap; color: var(--text2); gap: 4px; }
225
+ .chip.model .m { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
226
+ .chip.model .via { flex: none; color: var(--muted); }
227
+ .fact.wrap .v { white-space: normal; overflow-wrap: anywhere; }
224
228
  .chip.machine.remote { color: var(--machine-remote); border-color: color-mix(in srgb, var(--yellow) 35%, transparent); background: var(--tint-yellow); }
225
229
  .ro { color: var(--faint); font-size: 12px; align-self: center; margin-left: 4px; }
226
230
  .dates a .m { color: var(--faint); font-size: 11px; margin-left: 6px; }
@@ -510,6 +514,23 @@ h2 .count { color: var(--faint); font-weight: 500; }
510
514
  }
511
515
  .tabs a { white-space: nowrap; }
512
516
  .recent .hint { color: var(--muted); font-size: 12px; margin-top: 4px; }
517
+ /* Dismiss a need (0.6.5): a 44px target in the card's top-right corner. */
518
+ .need { position: relative; }
519
+ .need:has(.need-dismiss) > .need-head { padding-right: 34px; }
520
+ .need-dismiss { position: absolute; top: 2px; right: 2px; width: 44px; height: 44px; display: grid; place-items: center; border: 0; background: transparent; color: var(--muted); font-size: 15px; line-height: 1; border-radius: 10px; cursor: pointer; padding: 0; }
521
+ .need-dismiss:hover { color: var(--text); background: var(--subtle); }
522
+ .need-dismiss:focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; }
523
+ .toast.has-action { pointer-events: auto; display: flex; align-items: center; gap: 14px; }
524
+ .toast-undo { border: 0; background: transparent; color: var(--accent); font: inherit; font-weight: 700; padding: 10px 6px; margin: -10px -6px; min-height: 44px; cursor: pointer; }
525
+ .dismissed { margin-top: 14px; }
526
+ .dismissed summary { cursor: pointer; font-size: 13px; color: var(--muted); padding: 10px 0; }
527
+ .dismissed ul { list-style: none; padding: 0; margin: 4px 0 8px; display: grid; gap: 6px; }
528
+ .dismissed li { display: flex; gap: 10px; align-items: center; font-size: 13px; padding: 7px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); }
529
+ .dismissed .dl-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
530
+ .dismissed .dl-title { font-weight: 600; }
531
+ .dismissed .dl-line { color: var(--text2); overflow-wrap: anywhere; }
532
+ .dismissed .dl-at { color: var(--muted); font-size: 12px; }
533
+ .dismissed li .btn { min-height: 44px; }
513
534
 
514
535
  /* ---------------------------------------------------------------- v0.4.6 */
515
536
  /* Safe areas (viewport-fit=cover): notches and the home indicator, in the
@@ -598,6 +619,7 @@ html { background: var(--bg); }
598
619
  .banner.restore .rs-main { display: flex; flex-direction: column; min-width: 0; }
599
620
  .banner.restore .rs-t { color: var(--text); font-weight: 600; overflow-wrap: anywhere; }
600
621
  .banner.restore .rs-m { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
622
+ .banner.restore .rs-m.err { color: var(--red); }
601
623
  .banner.restore .rs-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
602
624
  .banner.restore .rs-note { color: var(--muted); font-size: 12px; }
603
625
  .banner .rs-cmds { margin: 8px 0 0; padding: 8px 10px; background: var(--bg2); border: 1px solid var(--line); border-radius: 8px; font: 12px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text2); }
@@ -750,6 +772,10 @@ html { background: var(--bg); }
750
772
  .chip.warn { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 45%, transparent); background: var(--tint-yellow); }
751
773
  .chip .ico, .okbox .ico { width: 13px; height: 13px; }
752
774
  .joinline code.cmd { font-family: var(--mono); font-size: 12px; background: var(--bg2); border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; overflow-wrap: anywhere; min-width: 0; }
775
+ .ghbox { display: contents; }
776
+ .ghcell { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
777
+ .ghrow { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-width: 0; }
778
+ .ghrow .ghwhat { color: var(--text2); min-width: 0; flex: 1 1 200px; }
753
779
  .btn.danger { color: var(--red); border-color: color-mix(in srgb, var(--red) 50%, transparent); }
754
780
  .wizard { border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; background: var(--subtle); display: flex; flex-direction: column; gap: 12px; min-width: 0; width: 100%; }
755
781
  .form .full .wizard { justify-content: flex-start; }
@@ -1027,6 +1053,13 @@ lo-combo .lo-combo-input:focus { outline: none; border-color: var(--accent); box
1027
1053
  .settings { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: 28px; align-items: start; }
1028
1054
  .set-nav { position: sticky; top: 74px; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
1029
1055
  .set-navhead h1 { margin: 0 0 2px 4px; }
1056
+ /* Board → Closed (0.6.7) */
1057
+ .closed-head { margin: 14px 0 0; max-width: 420px; }
1058
+ .closed-card .row1 .title a { color: var(--text); }
1059
+ .closed-card .meta .chip.machine { display: inline-flex; }
1060
+ .closed-more { display: flex; justify-content: center; margin: 16px 0 0; }
1061
+ .closed-end { text-align: center; font-size: 12.5px; margin: 14px 0 0; }
1062
+ @media (max-width: 640px) { .closed-head { max-width: none; } }
1030
1063
  .set-search { position: relative; }
1031
1064
  .set-search .ic { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: var(--faint); pointer-events: none; }
1032
1065
  .set-search input { width: 100%; height: 34px; padding: 0 10px 0 32px; background: var(--field); color: var(--text); border: 1px solid var(--line); border-radius: 9px; font: inherit; font-size: 13.5px; }
@@ -1280,3 +1313,69 @@ body.pairpage { background: var(--bg); color: var(--text); margin: 0; }
1280
1313
  .card .title input { padding: 2px 6px; }
1281
1314
  .standup-out textarea { line-height: 1.4; }
1282
1315
  }
1316
+
1317
+ /* Needs you: context (0.6.4). What Claude said just before it asked, the
1318
+ branch and how long ago, "Show more" with the last turns, the branch's open
1319
+ PR and tap-to-send quick replies. Long code scrolls sideways, never the page. */
1320
+ .need-ctx { display: grid; gap: 6px; min-width: 0; }
1321
+ .ctx-lines { display: grid; gap: 3px; color: var(--text2); font-size: 13.5px; line-height: 1.45; border-left: 3px solid var(--line-strong); padding: 1px 0 1px 10px; min-width: 0; }
1322
+ .ctx-lines p { margin: 0; overflow-wrap: anywhere; }
1323
+ .ctx-lines code, .turn-text code { font-size: 12px; background: var(--field); border: 1px solid var(--line); border-radius: 5px; padding: 0 4px; }
1324
+ .ctx-lines a, .turn-text a { color: var(--accent); }
1325
+ .ctx-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 2px 4px; color: var(--muted); font-size: 12px; min-width: 0; }
1326
+ .ctx-meta .mono { font-size: 12px; overflow-wrap: anywhere; }
1327
+ .linkbtn { background: none; border: 0; padding: 6px 2px; margin-left: auto; font: inherit; font-size: 12.5px; font-weight: 600; color: var(--accent); cursor: pointer; min-height: 32px; }
1328
+ .linkbtn:hover { text-decoration: underline; }
1329
+ .linkbtn:focus-visible { outline: 2px solid var(--focus); border-radius: 4px; }
1330
+ .need-turns { border-top: 1px solid var(--line); padding-top: 8px; min-width: 0; max-height: 70vh; overflow-y: auto; overscroll-behavior: contain; }
1331
+ .turns { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; min-width: 0; }
1332
+ .turn { display: grid; gap: 4px; min-width: 0; }
1333
+ .turn-who { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
1334
+ .turn.you .turn-who { color: var(--accent); }
1335
+ .turn.you .turn-text { background: var(--subtle); border-radius: 8px; padding: 6px 9px; }
1336
+ .turn-text { font-size: 13.5px; line-height: 1.45; color: var(--text); min-width: 0; overflow-wrap: anywhere; }
1337
+ .turn-text p { margin: 0 0 6px; }
1338
+ .turn-text p:last-child { margin-bottom: 0; }
1339
+ .turn-asked { font-size: 12px; color: var(--muted); font-style: italic; }
1340
+ .turn.asked-only { gap: 0; }
1341
+ .turn-text .th { font-weight: 700; }
1342
+ .turn-text ul, .turn-text ol { margin: 0 0 6px; padding-left: 20px; }
1343
+ .turn-text pre.tcode { margin: 0 0 6px; max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; background: var(--field); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-family: var(--mono); font-size: 12px; line-height: 1.45; white-space: pre; overflow-wrap: normal; }
1344
+ .turn-text pre.tcode code { background: none; border: 0; padding: 0; font-size: inherit; }
1345
+ .tchips { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
1346
+ .tchip { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; color: var(--text2); background: var(--card2); border: 1px solid var(--line); border-radius: 999px; padding: 2px 8px; }
1347
+ .tchip.err { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); }
1348
+ .need-pr { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; align-items: center; text-decoration: none; color: var(--text); background: var(--card2); border: 1px solid var(--line); border-radius: 10px; padding: 7px 10px; min-width: 0; }
1349
+ a.need-pr:hover { border-color: var(--line-strong); }
1350
+ .need-pr .pr-top { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; font-size: 12px; min-width: 0; }
1351
+ .need-pr .pr-num { font-weight: 700; font-family: var(--mono); }
1352
+ .need-pr .pr-state { font-weight: 600; color: var(--green); }
1353
+ .need-pr .pr-state.draft { color: var(--muted); }
1354
+ .need-pr .pr-checks { color: var(--muted); }
1355
+ .need-pr .pr-checks.pass { color: var(--green); }
1356
+ .need-pr .pr-checks.fail { color: var(--red); }
1357
+ .need-pr .pr-checks.pending { color: var(--yellow); }
1358
+ .need-pr .pr-title { grid-column: 1; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }
1359
+ .need-pr .pr-go { grid-column: 2; grid-row: 1 / span 2; color: var(--muted); }
1360
+ .quick-replies { display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; }
1361
+ .quick-replies .qr { font: inherit; font-size: 13px; color: var(--text); background: var(--card2); border: 1px solid var(--line-strong); border-radius: 999px; padding: 6px 12px; min-height: 34px; cursor: pointer; max-width: 100%; text-align: left; }
1362
+ .quick-replies .qr:hover:not(:disabled) { border-color: var(--accent); }
1363
+ .quick-replies .qr:focus-visible { outline: 2px solid var(--focus); }
1364
+ .quick-replies .qr.on { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, var(--card2)); }
1365
+ .quick-replies .qr:disabled { opacity: .55; cursor: default; }
1366
+ @media (hover: none), (max-width: 700px) { .quick-replies .qr { min-height: 40px; font-size: 14px; } .linkbtn { min-height: 40px; } }
1367
+ .need-ctx.open .ctx-lines { display: none; }
1368
+
1369
+ /* Settings → Advanced (0.6.6): one closed fold per section, laid out on the form's own grid. */
1370
+ .form .set-adv { grid-column: 1 / -1; border-top: 1px solid var(--line); padding-top: 10px; margin-top: 4px; grid-template-columns: inherit; gap: inherit; align-items: inherit; }
1371
+ .set-pane { grid-template-columns: inherit; gap: inherit; align-items: inherit; }
1372
+ .form .set-adv > summary { list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; font-weight: 600; padding: 6px 0; min-height: 32px; -webkit-tap-highlight-color: transparent; }
1373
+ .form .set-adv > summary::-webkit-details-marker { display: none; }
1374
+ .form .set-adv > summary::before { content: ''; width: 7px; height: 7px; border-right: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor; transform: rotate(-45deg); transition: transform .15s; margin-right: 2px; }
1375
+ .form .set-adv[open] > summary::before { transform: rotate(45deg); }
1376
+ .form .set-adv > summary:hover { color: var(--text); }
1377
+ .form .set-adv > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
1378
+ .form .set-advbody { display: grid; grid-template-columns: inherit; gap: 14px 18px; align-items: center; margin-top: 10px; }
1379
+ @media (max-width: 640px) { .form .set-advbody { gap: 12px 18px; } }
1380
+ .form .set-advbody > .section:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
1381
+ .form .hint.nomt { margin-top: 0; }