@leftoff/cli 0.6.5 → 0.6.9
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 +37 -0
- package/bin/leftoff.js +272 -269
- 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 +91 -3
- package/ui/app.js +299 -64
- package/ui/index.html +1 -0
- package/ui/prs.js +128 -0
- package/ui/relay-client.js +7 -1
- package/ui/repos.js +2 -0
- package/ui/settings-nav.js +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
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', 'prLinks', 'prUnlinked']
|
|
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.9' // 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.9",
|
|
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; }
|
|
@@ -615,6 +619,7 @@ html { background: var(--bg); }
|
|
|
615
619
|
.banner.restore .rs-main { display: flex; flex-direction: column; min-width: 0; }
|
|
616
620
|
.banner.restore .rs-t { color: var(--text); font-weight: 600; overflow-wrap: anywhere; }
|
|
617
621
|
.banner.restore .rs-m { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
622
|
+
.banner.restore .rs-m.err { color: var(--red); }
|
|
618
623
|
.banner.restore .rs-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
619
624
|
.banner.restore .rs-note { color: var(--muted); font-size: 12px; }
|
|
620
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); }
|
|
@@ -1048,6 +1053,13 @@ lo-combo .lo-combo-input:focus { outline: none; border-color: var(--accent); box
|
|
|
1048
1053
|
.settings { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: 28px; align-items: start; }
|
|
1049
1054
|
.set-nav { position: sticky; top: 74px; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
|
1050
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; } }
|
|
1051
1063
|
.set-search { position: relative; }
|
|
1052
1064
|
.set-search .ic { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: var(--faint); pointer-events: none; }
|
|
1053
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; }
|
|
@@ -1353,3 +1365,79 @@ a.need-pr:hover { border-color: var(--line-strong); }
|
|
|
1353
1365
|
.quick-replies .qr:disabled { opacity: .55; cursor: default; }
|
|
1354
1366
|
@media (hover: none), (max-width: 700px) { .quick-replies .qr { min-height: 40px; font-size: 14px; } .linkbtn { min-height: 40px; } }
|
|
1355
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; }
|
|
1382
|
+
|
|
1383
|
+
/* PRs (0.6.9): a card's PRs, the session page's list and Link a PR, the PRs tab */
|
|
1384
|
+
.pr-num { font-weight: 700; font-family: var(--mono); }
|
|
1385
|
+
.pr-state { font-weight: 600; color: var(--green); }
|
|
1386
|
+
.pr-state.draft, .pr-state.closed { color: var(--muted); }
|
|
1387
|
+
.pr-state.merged { color: var(--accent); }
|
|
1388
|
+
.pr-checks { color: var(--muted); white-space: nowrap; }
|
|
1389
|
+
.pr-checks.pass { color: var(--green); }
|
|
1390
|
+
.pr-checks.fail { color: var(--red); }
|
|
1391
|
+
.pr-checks.pending { color: var(--yellow); }
|
|
1392
|
+
.card-prs { display: flex; flex-direction: column; gap: 4px; margin: 8px 0 2px; min-width: 0; }
|
|
1393
|
+
.cpr { display: flex; align-items: center; gap: 7px; min-width: 0; font-size: 12.5px; color: var(--text); text-decoration: none; background: var(--card2); border: 1px solid var(--line); border-radius: 8px; padding: 4px 9px; }
|
|
1394
|
+
a.cpr:hover { border-color: var(--line-strong); }
|
|
1395
|
+
.cpr .pr-title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; }
|
|
1396
|
+
.cpr .pr-num, .cpr .pr-state, .cpr .pr-checks { flex: none; }
|
|
1397
|
+
.cpr.more { color: var(--muted); justify-content: center; }
|
|
1398
|
+
.prs-filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 4px 0 14px; }
|
|
1399
|
+
.prgroup { margin-bottom: 18px; }
|
|
1400
|
+
.prgroup h2 { font-size: 14px; margin: 14px 0 8px; }
|
|
1401
|
+
.prgroup.attention h2 { color: var(--red); }
|
|
1402
|
+
.prrow { display: flex; gap: 10px; align-items: flex-start; background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; margin-bottom: 8px; min-width: 0; }
|
|
1403
|
+
.prrow.slim { background: var(--card2); }
|
|
1404
|
+
.pr-main { flex: 1 1 auto; min-width: 0; }
|
|
1405
|
+
.pr-line1 { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 9px; font-size: 12.5px; }
|
|
1406
|
+
.pr-att { color: var(--red); font-weight: 600; }
|
|
1407
|
+
.pr-review { color: var(--muted); }
|
|
1408
|
+
.pr-review.approved { color: var(--green); }
|
|
1409
|
+
.pr-review.changes_requested { color: var(--red); }
|
|
1410
|
+
.prrow .pr-title { font-weight: 500; margin-top: 3px; overflow-wrap: anywhere; }
|
|
1411
|
+
.prrow .pr-title a { color: var(--text); text-decoration: none; }
|
|
1412
|
+
.prrow .pr-title a:hover { text-decoration: underline; }
|
|
1413
|
+
.pr-meta { font-size: 12px; color: var(--faint); margin-top: 2px; overflow-wrap: anywhere; }
|
|
1414
|
+
.pr-sessions { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
|
|
1415
|
+
.pr-sess { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; min-width: 0; }
|
|
1416
|
+
.pr-sess .sesslink { color: var(--accent); text-decoration: none; overflow-wrap: anywhere; }
|
|
1417
|
+
.pr-sess .sesslink::before { content: '↳ '; color: var(--faint); }
|
|
1418
|
+
.pr-actions { display: flex; flex-direction: column; gap: 6px; flex: none; align-items: stretch; }
|
|
1419
|
+
.prlist { margin-bottom: 8px; }
|
|
1420
|
+
.prlink { display: flex; gap: 8px; margin: 6px 0 16px; }
|
|
1421
|
+
.prlink input { flex: 1 1 auto; min-width: 0; font-size: 16px; }
|
|
1422
|
+
.prs-gh { margin-top: 6px; }
|
|
1423
|
+
.prs-foot { font-size: 12px; margin-top: 6px; }
|
|
1424
|
+
@media (max-width: 639.98px) {
|
|
1425
|
+
.tabs { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
|
1426
|
+
.prrow { flex-direction: column; gap: 8px; }
|
|
1427
|
+
.pr-actions { flex-direction: row; flex-wrap: wrap; }
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/* 0.6.9: "Closed (N) →" under the Board's grid */
|
|
1431
|
+
.closed-link { margin: 18px 0 6px; text-align: center; }
|
|
1432
|
+
.closed-link .linkish { color: var(--muted); font-size: 13.5px; padding: 8px 12px; }
|
|
1433
|
+
.closed-link .linkish:hover { color: var(--text); }
|
|
1434
|
+
|
|
1435
|
+
/* 0.6.9: the Board's stat tiles read as filters you can tap: a lift on hover,
|
|
1436
|
+
a press on touch, and the picked one clearly on (accent ring, tint, number). */
|
|
1437
|
+
.stat { transition: border-color .15s, background .15s, transform .12s, box-shadow .15s; -webkit-tap-highlight-color: transparent; }
|
|
1438
|
+
@media (hover: hover) { .stat:hover { transform: translateY(-1px); border-color: var(--accent); box-shadow: var(--card-shadow), 0 4px 14px rgba(0, 0, 0, .12); } }
|
|
1439
|
+
.stat:active { transform: scale(.97); }
|
|
1440
|
+
.stat:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
1441
|
+
.stat.on { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent), var(--card-shadow); background: color-mix(in srgb, var(--accent) 12%, var(--card)); }
|
|
1442
|
+
.stat.on .n { color: var(--accent); }
|
|
1443
|
+
.stat.on .k { color: var(--text); }
|