@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.
- package/CHANGELOG.md +47 -0
- package/README.md +2 -1
- package/bin/leftoff.js +329 -313
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +122 -5
- package/mod/hooks/register.js +31 -8
- package/package.json +1 -1
- package/ui/app.css +102 -3
- package/ui/app.js +355 -63
- package/ui/needs.js +237 -5
- package/ui/relay-client.js +7 -1
- package/ui/repos.js +11 -2
- package/ui/settings-nav.js +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.6.
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
package/mod/hooks/register.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
+
"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(
|
|
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(
|
|
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; }
|