@leftoff/cli 0.9.18-beta.1 → 0.9.18-beta.10
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 +39 -1
- package/bin/leftoff-statusline.js +9 -0
- package/bin/leftoff.js +767 -557
- package/bin/pty-host-main.js +66 -0
- package/docs/macos-permissions.md +52 -0
- package/docs/test-plan.md +43 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +172 -12
- package/mod/hooks/register.js +186 -5
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +234 -2
- package/ui/app.js +502 -51
- package/ui/ask.js +5 -3
- package/ui/cp-fold.js +22 -0
- package/ui/decisions.js +235 -0
- package/ui/hook-fix.js +109 -0
- package/ui/live-status.js +68 -2
- package/ui/live-view.js +20 -3
- package/ui/prs.js +15 -5
- package/ui/semdiff.js +163 -20
- package/ui/session-catchup.js +18 -2
- package/ui/session-title.js +88 -0
- package/ui/summary-examples.js +2 -2
- package/ui/term.js +11 -3
- package/ui/testplan.js +162 -0
- package/ui/viewport-fit.js +59 -0
- package/ui/walk-pop.js +3 -0
- package/ui/what-it-did.js +12 -5
package/mod/hooks/register.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// each event is registered once per matcher.
|
|
15
15
|
import {
|
|
16
16
|
DEFAULT_CONFIG, DEFAULT_PORT, mergeConfig, resolveMachine, applyEvent, checkpointDue, buildCheckpointPrompt,
|
|
17
|
-
parseCheckpointReply, retryCheckpointPrompt, betterSalvage, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
|
|
17
|
+
parseCheckpointReply, retryCheckpointPrompt, mergeDecided, betterSalvage, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
|
|
18
18
|
sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
|
|
19
19
|
shortId, formatTabTitle, parseTabTitle, tabRenameWaits, detectTerminal, parseProcLine, trailingQuestion,
|
|
20
20
|
parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
|
|
@@ -26,9 +26,13 @@ const PANE_ID = 'checkpoints'
|
|
|
26
26
|
const POST_TIMEOUT_MS = 800
|
|
27
27
|
const END_TIMEOUT_MS = 1000
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
|
+
const STOP_GRACE_MS = 2500 // 2026-11-03 how long a completed turn waits for its Stop before the mod reports the end itself
|
|
30
|
+
const SKIP_GRACE_MS = 4000 // 2026-11-03 how long a mod event waits for its classic twin before that classic event counts as passed over
|
|
31
|
+
const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can be slow)
|
|
32
|
+
const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
|
|
29
33
|
|
|
30
34
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.18-beta.
|
|
35
|
+
const MOD_VERSION = '0.9.18-beta.10' // keep in step with .claude-plugin/plugin.json
|
|
32
36
|
|
|
33
37
|
const st = {
|
|
34
38
|
internal: false,
|
|
@@ -69,6 +73,15 @@ const st = {
|
|
|
69
73
|
listSig: null, // v0.5.1: the signature of this session's lists Claude last heard about
|
|
70
74
|
listPending: false, // a list was saved or ticked here: tell Claude with the next prompt
|
|
71
75
|
paneNeeds: 0, // sessions that need you (the pane's top line)
|
|
76
|
+
turnStartAt: 0, // 2026-11-03 when this process's current turn started (turn.start)
|
|
77
|
+
stopAt: 0, // and when a Stop last came (classic.Stop)
|
|
78
|
+
// 2026-11-03 Claude Code 2.1.31x passes every classic.* event of a plugin you
|
|
79
|
+
// installed over when its built-in cc-plugin-sec-default is seated first (a
|
|
80
|
+
// Team or Enterprise plan, or managed settings): see SKIP_GRACE_MS below.
|
|
81
|
+
classicSeen: false, // a classic.* hook of this mod ran in this process
|
|
82
|
+
hooksSkipped: null, // the classic events we saw passed over, as last told to the daemon
|
|
83
|
+
permAt: 0, // when classic.PermissionRequest last ran
|
|
84
|
+
watchAsks: {}, // tool_use_id -> { tool, at, reported, done }: permission prompts tool.check saw
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
// Millisecond precision: the daemon dedupes events per (sessionId, type, at).
|
|
@@ -230,7 +243,7 @@ async function readRepo($, cwd) {
|
|
|
230
243
|
|
|
231
244
|
async function heartbeat($) {
|
|
232
245
|
if (st.internal || !st.sessionId) return
|
|
233
|
-
const res = await sendOrSpool($, 'heartbeat', { inTurn: !!(st.record && st.record.inTurn) })
|
|
246
|
+
const res = await sendOrSpool($, 'heartbeat', { inTurn: !!(st.record && st.record.inTurn), ...(st.hooksSkipped && st.hooksSkipped.length ? { hooksSkipped: st.hooksSkipped } : {}) })
|
|
234
247
|
if (res && !st.modHealthSent) await reportModHealth($)
|
|
235
248
|
if (res && st.daemonTitle) await noteTitle($, st.daemonTitle)
|
|
236
249
|
else if (res && st.tabTitle && !(st.record && st.record.inTurn)) await applyTabTitle($, null) // the marker came (it never goes: tabMarkerKept), or a rename that had to wait
|
|
@@ -243,6 +256,99 @@ async function heartbeat($) {
|
|
|
243
256
|
}
|
|
244
257
|
}
|
|
245
258
|
|
|
259
|
+
// 2026-11-03 a turn completed and no Stop came for it within STOP_GRACE_MS:
|
|
260
|
+
// report its end (once; nothing when this process doesn't hold a turn open).
|
|
261
|
+
async function stopFallback($, completedAt, reason) {
|
|
262
|
+
if (st.internal || !st.sessionId) return
|
|
263
|
+
if (st.stopAt && st.stopAt >= (st.turnStartAt || 0)) return // its Stop came
|
|
264
|
+
if (!(st.record && st.record.inTurn)) return
|
|
265
|
+
st.stopAt = Date.now()
|
|
266
|
+
await sendOrSpool($, 'turn_completed', { endReason: String(reason || 'end').slice(0, 40), settledBy: 'mod', completedAt: new Date(completedAt).toISOString() })
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------- passed-over classic hooks (2026-11-03)
|
|
270
|
+
//
|
|
271
|
+
// Claude Code 2.1.31x seats its built-in "cc-plugin-sec-default" outermost on
|
|
272
|
+
// a Team or Enterprise plan or a machine with managed settings, and its hooks
|
|
273
|
+
// module answers every classic.* event with next.to(e, "append"): the event
|
|
274
|
+
// jumps past the user tier, where a plugin you installed (this one) sits, so
|
|
275
|
+
// none of our classic.* hooks run (Claude Code then says "leftoff: its
|
|
276
|
+
// classic.Stop hook was skipped. "cc-plugin-sec-default" runs first and sent
|
|
277
|
+
// the event on past it."). Our other events are untouched: session.start,
|
|
278
|
+
// prompt.submit, turn.start, turn.complete, tool.check and tool.call.
|
|
279
|
+
// So: each of those notes the classic event that should come with it; when it
|
|
280
|
+
// doesn't come within the grace and no classic hook has run in this process at
|
|
281
|
+
// all, the classic event counts as passed over and the daemon hears which.
|
|
282
|
+
function sawClassic($, name) {
|
|
283
|
+
if (name === 'PermissionRequest') st.permAt = Date.now()
|
|
284
|
+
if (st.classicSeen) return
|
|
285
|
+
st.classicSeen = true
|
|
286
|
+
if (st.hooksSkipped && st.hooksSkipped.length) { // a slow SessionStart we counted out too early, a setting changed: they run
|
|
287
|
+
st.hooksSkipped = []
|
|
288
|
+
sendOrSpool($, 'heartbeat', { hooksSkipped: [], fromMod: true }).catch(() => {})
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function expectClassic($, name, graceMs) {
|
|
293
|
+
if (st.classicSeen || st.internal) return
|
|
294
|
+
$.clock.after(graceMs || SKIP_GRACE_MS, () => { noteSkipped($, name).catch(() => {}) })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function noteSkipped($, name) {
|
|
298
|
+
if (st.classicSeen || st.internal || !st.sessionId) return
|
|
299
|
+
const cur = st.hooksSkipped || []
|
|
300
|
+
if (cur.includes(name)) return
|
|
301
|
+
st.hooksSkipped = cur.concat(name)
|
|
302
|
+
await sendOrSpool($, 'heartbeat', { hooksSkipped: st.hooksSkipped, fromMod: true })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// A permission prompt, seen from tool.check: next(e) is the whole check
|
|
306
|
+
// (rules, modes, other plugins' hooks, Claude Code's own), and { decision: "ask" }
|
|
307
|
+
// with approver "person" is the terminal's permission dialog (a host such as
|
|
308
|
+
// headless answers "host"; no approver means it is never asked). We only
|
|
309
|
+
// look: the answer goes back as it came. Reported as Claude Code's own
|
|
310
|
+
// permission_prompt notification would be, unless classic.PermissionRequest
|
|
311
|
+
// ran for it (then permissionHook has put it on the board, answerable there).
|
|
312
|
+
function watchAsk($, e) {
|
|
313
|
+
if (st.internal || st.headless || !st.sessionId) return
|
|
314
|
+
const at = Date.now()
|
|
315
|
+
const id = String(e.tool_use_id || (String(e.tool || '') + '@' + at))
|
|
316
|
+
st.watchAsks[id] = { tool: e.tool, at, reported: false, done: false }
|
|
317
|
+
expectClassic($, 'PermissionRequest')
|
|
318
|
+
$.clock.after(PERM_GRACE_MS, () => { reportAsk($, id, e).catch(() => {}) })
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function reportAsk($, id, e) {
|
|
322
|
+
const w = st.watchAsks[id]
|
|
323
|
+
if (!w || w.done) return
|
|
324
|
+
if (st.permAt >= w.at) { delete st.watchAsks[id]; return } // its PermissionRequest hook ran: the board has it
|
|
325
|
+
w.reported = true
|
|
326
|
+
await sendOrSpool($, 'notification', {
|
|
327
|
+
notificationType: 'permission_prompt', message: clip(askMessage(e.tool), 200), tool: e.tool || null, fromMod: true,
|
|
328
|
+
...(st.hooksSkipped && st.hooksSkipped.length ? { hooksSkipped: st.hooksSkipped } : {}),
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function askMessage(tool) {
|
|
333
|
+
if (tool === 'AskUserQuestion') return 'Claude has a question for you'
|
|
334
|
+
return 'Claude needs your permission to use ' + (tool || 'a tool')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The prompt went away: the tool runs (tool.call) or the turn is over. Says so
|
|
338
|
+
// only for prompts the mod reported (an activity the board reads as a tool
|
|
339
|
+
// that ran, which ends "Needs you" for a permission prompt).
|
|
340
|
+
async function settleAsks($, toolUseId, tool) {
|
|
341
|
+
let reported = false
|
|
342
|
+
for (const id of Object.keys(st.watchAsks)) {
|
|
343
|
+
const w = st.watchAsks[id]
|
|
344
|
+
if (toolUseId !== undefined && (toolUseId ? id !== String(toolUseId) : w.tool !== tool)) continue
|
|
345
|
+
w.done = true
|
|
346
|
+
if (w.reported) reported = true
|
|
347
|
+
delete st.watchAsks[id]
|
|
348
|
+
}
|
|
349
|
+
if (reported) await sendOrSpool($, 'activity', { kind: 'tool', phase: 'post', tool: tool || null, fromMod: true })
|
|
350
|
+
}
|
|
351
|
+
|
|
246
352
|
// The commands, in the order they're tried. Claude Code 2.1.295's built-ins
|
|
247
353
|
// own /checkpoint (an alias of /rewind) and /name (an alias of /rename), so
|
|
248
354
|
// ours share a /cp prefix; each command lists fallbacks in case a later
|
|
@@ -259,6 +365,8 @@ const COMMANDS = [
|
|
|
259
365
|
{ key: 'todo', names: ['cp-todo', 'leftoff-todo'], description: 'Leftoff: save the checklist Claude just wrote as a list you can tick off (board, phone, or here)', argumentHint: '[name] [--pick [N]]', immediate: true },
|
|
260
366
|
// v0.8.2: checkpoint, mark the session Parked (not Closed when it exits), then close it.
|
|
261
367
|
{ key: 'park', names: ['cp-park', 'leftoff-park'], description: 'Leftoff: checkpoint and park this session to come back to (your note becomes the Next line), then exit', argumentHint: '[note]', immediate: false },
|
|
368
|
+
// 2026-11-03 (1b): the repo's accepted decisions into the conversation on demand (when SessionStart was skipped, or after /clear).
|
|
369
|
+
{ key: 'decisions', names: ['cp-decisions', 'leftoff-decisions'], description: 'Leftoff: give Claude this repo\'s accepted decisions (the same list a new session starts with)', immediate: false },
|
|
262
370
|
{ key: 'done', names: ['cp-done', 'leftoff-done'], description: 'Leftoff: tick items on this session\'s list (/cp-done 3, /cp-done 2 4, /cp-done undo 3)', argumentHint: '<n…> [in <list>]', immediate: true },
|
|
263
371
|
]
|
|
264
372
|
|
|
@@ -371,6 +479,37 @@ async function unlockOwnParkLocks($, cwd) {
|
|
|
371
479
|
} catch {}
|
|
372
480
|
}
|
|
373
481
|
|
|
482
|
+
// ------------------------------------------------------------ decisions (2026-11-03)
|
|
483
|
+
|
|
484
|
+
// The daemon's short read of this repo's accepted decisions (id + one line each, capped), or null:
|
|
485
|
+
// nothing for Leftoff's own runs or a board-run headless session, when the daemon doesn't answer quickly,
|
|
486
|
+
// or when the repo has none.
|
|
487
|
+
const DECISIONS_TIMEOUT_MS = 800
|
|
488
|
+
async function decisionContext($, e) {
|
|
489
|
+
if (st.internal || await isHeadless($)) return null
|
|
490
|
+
const q = '?session=' + encodeURIComponent(e.session_id || st.sessionId || '') + (e.cwd ? '&cwd=' + encodeURIComponent(e.cwd) : '')
|
|
491
|
+
const r = await api($, 'GET', '/v1/decisions/context' + q, undefined, DECISIONS_TIMEOUT_MS)
|
|
492
|
+
const t = r && r.ok && r.json && typeof r.json.context === 'string' ? r.json.context.trim() : ''
|
|
493
|
+
return t ? t.slice(0, 2000) : null
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 1b /cp-decisions: the same text SessionStart adds (src/decisions.js decisionContextText, from the daemon),
|
|
497
|
+
// shown to you and handed to Claude as the command's context. The daemon notes that this session was given them.
|
|
498
|
+
// Runs where the classic hooks are skipped: a command is not a classic event (Claude Code's Security default
|
|
499
|
+
// hooks classic.* but not command.run; checked on 2.1.317 and 2.1.318).
|
|
500
|
+
async function decisionsCommand($) {
|
|
501
|
+
if (st.internal) return { text: 'Leftoff: not in Leftoff\'s own runs.' }
|
|
502
|
+
let cwd = null
|
|
503
|
+
try { cwd = await $.session.cwd() } catch {}
|
|
504
|
+
const sid = st.sessionId || ''
|
|
505
|
+
const r = await api($, 'GET', '/v1/decisions/context?via=command&session=' + encodeURIComponent(sid) + (cwd ? '&cwd=' + encodeURIComponent(cwd) : ''), undefined, 2000)
|
|
506
|
+
if (!r || !r.ok || !r.json) return { text: 'Leftoff: the daemon didn\'t answer, so this repo\'s decisions couldn\'t be read (leftoff status).' }
|
|
507
|
+
const t = typeof r.json.context === 'string' ? r.json.context.trim().slice(0, 2000) : ''
|
|
508
|
+
if (!t) return { text: r.json.repo ? 'Leftoff: this repo has no accepted decisions yet. Review proposed ones on the board.' : 'Leftoff: this folder isn\'t a repo Leftoff keeps a decision log for.' }
|
|
509
|
+
const n = Number(r.json.count) || t.split('\n').filter((l) => l.startsWith('- ')).length
|
|
510
|
+
return { text: 'Leftoff: gave Claude this repo\'s ' + n + ' accepted decision' + (n === 1 ? '' : 's') + ':\n' + t, context: [t] }
|
|
511
|
+
}
|
|
512
|
+
|
|
374
513
|
// ------------------------------------------------------------ lists (v0.5.1)
|
|
375
514
|
|
|
376
515
|
// This session's saved lists, when they changed since Claude last heard
|
|
@@ -735,6 +874,7 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
|
|
|
735
874
|
doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
|
|
736
875
|
userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
|
|
737
876
|
parseOk: parsed.parseOk, ...(parsed.format === 'markdown' ? { format: 'markdown' } : {}), generator, length: ctx.length,
|
|
877
|
+
decided: mergeDecided(ctx.found, generator && generator.by === 'heuristic' ? [] : parsed.decided, { earlier: st.lastCheckpoint && st.lastCheckpoint.decided }), // 2026-11-03 decision memory
|
|
738
878
|
decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
|
|
739
879
|
didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
|
|
740
880
|
}
|
|
@@ -1029,6 +1169,7 @@ export function register(on, options) {
|
|
|
1029
1169
|
st.heartbeat = $.clock.every(60000, () => { heartbeat($).catch(() => {}); startReplyLoop($) })
|
|
1030
1170
|
st.ready = true
|
|
1031
1171
|
startReplyLoop($)
|
|
1172
|
+
expectClassic($, 'SessionStart', START_SKIP_GRACE_MS) // 2026-11-03 passed over? (sawClassic)
|
|
1032
1173
|
} catch {}
|
|
1033
1174
|
// Commands last, each on its own: a taken name throws, so a
|
|
1034
1175
|
// clash costs only that command, and registerCommands reports it.
|
|
@@ -1037,6 +1178,7 @@ export function register(on, options) {
|
|
|
1037
1178
|
})
|
|
1038
1179
|
|
|
1039
1180
|
on('classic.SessionStart', async ($, e, next) => {
|
|
1181
|
+
sawClassic($, 'SessionStart')
|
|
1040
1182
|
if (!(await internalOnce($)) && !st.internal && e.session_id) {
|
|
1041
1183
|
let parentSessionId
|
|
1042
1184
|
if (st.sessionId && e.session_id !== st.sessionId) {
|
|
@@ -1064,11 +1206,20 @@ export function register(on, options) {
|
|
|
1064
1206
|
const name = own && !own.id ? own.name : (own && own.name) || st.daemonTitle || null
|
|
1065
1207
|
await applyTabTitle($, name, { force: !!parentSessionId, current: e.session_title || null })
|
|
1066
1208
|
}
|
|
1209
|
+
// 2026-11-03 decision memory: the repo's accepted decisions go to Claude as this event's context.
|
|
1210
|
+
// Added to what the hooks below (yours included) answer, never in place of it.
|
|
1211
|
+
const ctx = await decisionContext($, e)
|
|
1212
|
+
if (ctx) {
|
|
1213
|
+
const res = await next(e)
|
|
1214
|
+
const r = res && typeof res === 'object' ? res : {}
|
|
1215
|
+
return { ...r, additionalContext: [...(Array.isArray(r.additionalContext) ? r.additionalContext : []), ctx] }
|
|
1216
|
+
}
|
|
1067
1217
|
}
|
|
1068
1218
|
return next(e)
|
|
1069
1219
|
})
|
|
1070
1220
|
|
|
1071
1221
|
on('classic.UserPromptSubmit', async ($, e, next) => {
|
|
1222
|
+
sawClassic($, 'UserPromptSubmit')
|
|
1072
1223
|
await sendOrSpool($, 'activity', { kind: 'prompt', prompt: clip(e.prompt || '', 300), sessionTitle: e.session_title && e.session_title === st.tabTitle ? null : e.session_title, ...moved(e) }) // our own /rename isn't a name you chose
|
|
1073
1224
|
return next(e)
|
|
1074
1225
|
})
|
|
@@ -1078,11 +1229,13 @@ export function register(on, options) {
|
|
|
1078
1229
|
// context entry: the list, its numbers, and `leftoff todo done <list> <n>`.
|
|
1079
1230
|
// Context goes on the event before next(); on the result it would be too late.
|
|
1080
1231
|
on('prompt.submit', async ($, e, next) => {
|
|
1232
|
+
expectClassic($, 'UserPromptSubmit')
|
|
1081
1233
|
const ctx = await listContextFor($)
|
|
1082
1234
|
return next(ctx ? { ...e, context: [...(Array.isArray(e.context) ? e.context : []), ctx] } : e)
|
|
1083
1235
|
})
|
|
1084
1236
|
|
|
1085
1237
|
on('turn.start', async ($, e, next) => {
|
|
1238
|
+
st.turnStartAt = Date.now()
|
|
1086
1239
|
await sendOrSpool($, 'turn_started', {})
|
|
1087
1240
|
return next(e)
|
|
1088
1241
|
})
|
|
@@ -1090,11 +1243,21 @@ export function register(on, options) {
|
|
|
1090
1243
|
on('turn.complete', async ($, e, next) => {
|
|
1091
1244
|
// classic.Stop reports a normal end with its details; Stop doesn't fire for
|
|
1092
1245
|
// an interrupted or failed turn, so those are reported here.
|
|
1246
|
+
await settleAsks($, undefined).catch(() => {}) // 2026-11-03 a permission prompt the mod reported is over with the turn
|
|
1093
1247
|
if (e.reason === 'aborted' || e.reason === 'error' || e.reason === 'refusal') await sendOrSpool($, 'turn_completed', { endReason: e.reason })
|
|
1248
|
+
// 2026-11-03 Claude Code doesn't run every Stop for every end of a turn
|
|
1249
|
+
// (2.1.317: a turn its loop ends runs Stop without the in-process hooks,
|
|
1250
|
+
// and a tool can end a turn), and then this turn stayed open on the board
|
|
1251
|
+
// for good. If no Stop came for this turn shortly after it completed, the
|
|
1252
|
+
// end is reported from here; a Stop that comes later is the same turn
|
|
1253
|
+
// (the daemon doesn't count it twice).
|
|
1254
|
+
else { const at = Date.now(); $.clock.after(STOP_GRACE_MS, () => { stopFallback($, at, e.reason).catch(() => {}) }); expectClassic($, 'Stop') }
|
|
1094
1255
|
return next(e)
|
|
1095
1256
|
})
|
|
1096
1257
|
|
|
1097
1258
|
on('classic.Stop', async ($, e, next) => {
|
|
1259
|
+
sawClassic($, 'Stop')
|
|
1260
|
+
st.stopAt = Date.now()
|
|
1098
1261
|
// v0.5.1: a checklist of 3+ in this reply (a cheap regex, no model) -> the
|
|
1099
1262
|
// board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
|
|
1100
1263
|
const listHint = listHintFrom(e.last_assistant_message || '', { fallback: st.daemonTitle || null })
|
|
@@ -1118,20 +1281,36 @@ export function register(on, options) {
|
|
|
1118
1281
|
})
|
|
1119
1282
|
|
|
1120
1283
|
on('classic.Notification', async ($, e, next) => {
|
|
1284
|
+
sawClassic($, 'Notification')
|
|
1121
1285
|
await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200), ...moved(e) })
|
|
1122
1286
|
return next(e)
|
|
1123
1287
|
})
|
|
1124
1288
|
|
|
1125
|
-
on('classic.PermissionRequest', permissionHook)
|
|
1289
|
+
on('classic.PermissionRequest', async ($, e, next) => { sawClassic($, 'PermissionRequest'); return permissionHook($, e, next) })
|
|
1290
|
+
|
|
1291
|
+
// 2026-11-03 a permission prompt, seen whether or not classic.PermissionRequest
|
|
1292
|
+
// reaches us (see watchAsk): we only look, and hand the answer back as it came.
|
|
1293
|
+
on('tool.check', async ($, e, next) => {
|
|
1294
|
+
const r = await next(e)
|
|
1295
|
+
try { if (r && r.decision === 'ask' && e && e.approver === 'person') watchAsk($, e) } catch {}
|
|
1296
|
+
return r
|
|
1297
|
+
})
|
|
1298
|
+
|
|
1299
|
+
// The tool runs: a prompt the mod reported for it was answered in the terminal.
|
|
1300
|
+
on('tool.call', async ($, e, next) => {
|
|
1301
|
+
if (e && Object.keys(st.watchAsks).length) await settleAsks($, e.tool_use_id || null, e.tool).catch(() => {})
|
|
1302
|
+
return next(e)
|
|
1303
|
+
})
|
|
1126
1304
|
|
|
1127
1305
|
on('classic.PostToolUse', async ($, e, next) => {
|
|
1306
|
+
sawClassic($, 'PostToolUse')
|
|
1128
1307
|
await toolRan($, e)
|
|
1129
1308
|
const m = moved(e) // EnterWorktree, or a cd: say so now, not at the end of the turn
|
|
1130
1309
|
if (m.cwd || m.transcriptPath) await sendOrSpool($, 'activity', { kind: 'cwd', ...m })
|
|
1131
1310
|
return next(e)
|
|
1132
1311
|
})
|
|
1133
1312
|
|
|
1134
|
-
on('classic.PostToolUseFailure', async ($, e, next) => { await toolRan($, e); return next(e) })
|
|
1313
|
+
on('classic.PostToolUseFailure', async ($, e, next) => { sawClassic($, 'PostToolUseFailure'); await toolRan($, e); return next(e) })
|
|
1135
1314
|
|
|
1136
1315
|
on('session.end', async ($, e, next) => {
|
|
1137
1316
|
// About 1.5 s shared by every SessionEnd hook: post and go. The daemon makes
|
|
@@ -1160,6 +1339,8 @@ export function register(on, options) {
|
|
|
1160
1339
|
on('command.run', { command: 'leftoff-park' }, async ($, e) => parkCommand($, e.args))
|
|
1161
1340
|
|
|
1162
1341
|
on('command.run', { command: 'checkpoints' }, async ($, e) => paneCommand($, e.args))
|
|
1342
|
+
on('command.run', { command: 'cp-decisions' }, async ($) => decisionsCommand($))
|
|
1343
|
+
on('command.run', { command: 'leftoff-decisions' }, async ($) => decisionsCommand($))
|
|
1163
1344
|
|
|
1164
1345
|
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
|
|
1165
1346
|
if (e.requestId !== PANE_ID) return next(e)
|
|
@@ -11,10 +11,12 @@ How to walk through a change:
|
|
|
11
11
|
|
|
12
12
|
1. Order the steps the way the change makes sense to read: the core idea first, then what follows from it. Not file order.
|
|
13
13
|
2. Each step gets a short title and one or two sentences of why: the request it answers, the bug it fixes, or the trade-off you chose and the alternative you didn't take.
|
|
14
|
-
3. Mark what matters. A step that changes behaviour is Core; a small follow-on (config, docs, a helper, a rename) is Supporting. Tests that only follow along, imports and formatting are mechanical and need no story.
|
|
14
|
+
3. Mark what matters. A step that changes behaviour is Core; a small follow-on (config, docs, a helper, a rename) is Supporting. Tests that only follow along (updated to match the change), imports and formatting are mechanical and need no story.
|
|
15
|
+
- A test that doesn't really test anything is never mechanical and never safe to skip. That is a test that asserts nothing meaningful or asserts a tautology, mocks the very thing under test, snapshots its own output, had an assertion loosened or deleted to make it pass, or was skipped or disabled. Put it in a step and give it a look callout at its line saying why.
|
|
15
16
|
4. Point out what deserves a second look, at the exact line: something risky or subtle (look), a guess the code makes (assumption), or behaviour no test covers (untested). Be honest about the parts you were unsure of.
|
|
16
|
-
5.
|
|
17
|
+
5. Cite the decisions behind a step. When Leftoff lists the repo's accepted decisions (D-7f3a: …), and this session's own proposed ones (marked proposed), a step may name the ones it relates to and how: "decisions": [{"id": "D-7f3a", "rel": "follows"}] or [{"id": "D-7f3a", "rel": "departs"}] (a plain "D-7f3a" means follows), using only ids from those lists. A step that reverses or departs from a decision gets a look callout at its line naming the decision and how it departs.
|
|
18
|
+
6. Never paste code. Point at it.
|
|
17
19
|
|
|
18
|
-
When Leftoff asks (the request lists hunks H1…Hn and a JSON shape): reply with that JSON object only, no prose and no code fences. Every ref is a hunk id from the list ("H3", "H3:new 14-19", "H3:old 7"); never invent ids or line numbers; put every hunk in a step or in mechanical. You may read files with Read, Grep and Glob to check details; change nothing.
|
|
20
|
+
When Leftoff asks (the request lists hunks H1…Hn and a JSON shape): reply with that JSON object only, no prose and no code fences. Every ref is a hunk id from the list ("H3", "H3:new 14-19", "H3:old 7"); never invent ids or line numbers; put every hunk in a step or in mechanical. A step's "decisions" are ids from the decisions listed, never made up, each with "rel" "follows" or "departs". You may read files with Read, Grep and Glob to check details; change nothing.
|
|
19
21
|
|
|
20
22
|
When the developer asks in the terminal: give the same walkthrough as a short numbered list (title, why, file:line for each step, then what to look at closely).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
3
|
+
"version": "0.9.18-beta.10",
|
|
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
|
@@ -2495,6 +2495,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2495
2495
|
.composer .att-chips[hidden] { display: none; }
|
|
2496
2496
|
.composer.att-drop { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); background: color-mix(in srgb, var(--accent) 7%, var(--card2)); }
|
|
2497
2497
|
.att-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; overflow: hidden; clip: rect(0 0 0 0); }
|
|
2498
|
+
.att-picker { position: fixed; left: 0; bottom: 0; } /* 0.9.18: the paperclip's picker, outside the page so a redraw can't drop what you picked */
|
|
2498
2499
|
.composer-attach { flex: none; width: 44px; height: 44px; margin: 0; padding: 0; border: 0; background: transparent; display: grid; place-items: center; cursor: pointer; color: var(--muted); -webkit-tap-highlight-color: transparent; }
|
|
2499
2500
|
.composer-clip { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; transition: background-color .15s, color .15s; }
|
|
2500
2501
|
.composer-clip .ic { width: 19px; height: 19px; }
|
|
@@ -3577,6 +3578,11 @@ html.lf-kb .live-box.full { padding-bottom: 8px; }
|
|
|
3577
3578
|
.live-fbar { display: flex; align-items: center; gap: 6px; min-height: 52px; padding-top: env(safe-area-inset-top); border-bottom: 1px solid var(--line); }
|
|
3578
3579
|
.live-box.full > .live-fbar { max-width: none; margin: 0 calc(-1 * var(--lf-pr)) 0 calc(-1 * var(--lf-pl)); width: auto; padding-left: max(4px, env(safe-area-inset-left)); padding-right: max(8px, env(safe-area-inset-right)); background: var(--bg); }
|
|
3579
3580
|
.live-fs-x { margin-left: 0; }
|
|
3581
|
+
/* 2026-11-05 session titles: the name on line one, folder@branch muted under it */
|
|
3582
|
+
.live-fbar .lf-tt { display: flex; flex-direction: column; justify-content: center; flex: 1 1 auto; min-width: 0; gap: 1px; }
|
|
3583
|
+
.live-fbar .lf-where { display: block; min-width: 0; font-size: 12px; line-height: 1.25; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3584
|
+
.live-fbar .lf-where:empty { display: none; }
|
|
3585
|
+
.s-where { margin-top: 2px; font-size: 12px; line-height: 1.3; color: var(--muted); overflow-wrap: anywhere; }
|
|
3580
3586
|
.live-fbar .lf-title { display: block; flex: 1 1 auto; min-width: 0; margin: 0; font-size: 15px; font-weight: 650; line-height: 1.3; color: var(--text); text-transform: none; letter-spacing: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3581
3587
|
.lf-pills { flex: none; display: inline-flex; align-items: center; gap: 6px; }
|
|
3582
3588
|
.lf-stop { flex: none; min-height: 36px; }
|
|
@@ -5274,6 +5280,18 @@ code .file-ref-off { font-size: inherit; }
|
|
|
5274
5280
|
.wt-sf .wt-snip + .wt-snip { border-top: 1px dashed var(--line); }
|
|
5275
5281
|
.wt-sf .wt-snip-h { margin: 0; padding: 4px 10px; }
|
|
5276
5282
|
@media (pointer: fine) { .wt-sf > summary { min-height: 34px; } .wt-sfa-b { min-height: 32px; } }
|
|
5283
|
+
/* 2026-11-03 a file a step shares with an earlier one: folded, with a quiet way back to where it's open */
|
|
5284
|
+
.wt-also { display: inline-flex; align-items: center; gap: 4px; align-self: stretch; padding: 0 6px; font-size: 12px; color: var(--text2); text-decoration: none; white-space: nowrap; }
|
|
5285
|
+
.wt-also .ic { width: 13px; height: 13px; color: var(--muted); }
|
|
5286
|
+
.wt-also:hover { color: var(--text); text-decoration: underline; text-underline-offset: 2px; }
|
|
5287
|
+
.wt-also:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 6px; }
|
|
5288
|
+
.wt-sf-also > summary .wt-sf-p { color: var(--text2); }
|
|
5289
|
+
/* the lines the step's refs name, when it shows a hunk whole: the rest steps back */
|
|
5290
|
+
.wt-gd-focus .gd-row:not(.wt-focus), .wt-gd-focus .gd-half:not(.wt-focus):not(.gd-empty) { opacity: .55; }
|
|
5291
|
+
.wt-gd-focus .gd-row.wt-focus .gd-n, .wt-gd-focus .gd-half.wt-focus .gd-gut { box-shadow: inset 2px 0 0 var(--muted); }
|
|
5292
|
+
.wt-focus-w { display: inline-flex; align-items: center; gap: 6px; }
|
|
5293
|
+
.wt-focus-g { width: 2px; height: 12px; border-radius: 1px; background: var(--muted); }
|
|
5294
|
+
.wt-snip-none { margin: 0; padding: 10px; font-size: 12.5px; }
|
|
5277
5295
|
/* "changed since": a half-filled circle and the words (the stale bar uses the same glyph) */
|
|
5278
5296
|
.wt-chg { flex: none; display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 1px 7px 0; box-sizing: border-box; border: 1px dashed var(--line-strong); border-radius: 6px; font-size: 11.5px; font-weight: 600; line-height: 1; color: var(--text2); }
|
|
5279
5297
|
.wt-chg-g { flex: none; width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid currentColor; background: linear-gradient(90deg, currentColor 50%, transparent 50%); box-sizing: border-box; }
|
|
@@ -5287,8 +5305,8 @@ code .file-ref-off { font-size: inherit; }
|
|
|
5287
5305
|
.wt-dmode { display: none; }
|
|
5288
5306
|
@media (min-width: 1000px) { .wt-dmode { display: inline-flex; } }
|
|
5289
5307
|
.wt-r2 { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin: 6px 0 0; }
|
|
5290
|
-
.wt-r2:not(:has(.wt-spot)):not(:has(.wt-dmode:not([hidden]))) { display: none; }
|
|
5291
|
-
@media (max-width: 999.98px) { .wt-r2:not(:has(.wt-spot)) { display: none; } }
|
|
5308
|
+
.wt-r2:not(:has(.wt-spot)):not(:has(.wt-dmode:not([hidden]))):not(:has(.fsc-seg)) { display: none; }
|
|
5309
|
+
@media (max-width: 999.98px) { .wt-r2:not(:has(.wt-spot)):not(:has(.fsc-seg)) { display: none; } }
|
|
5292
5310
|
/* the side-by-side drawing */
|
|
5293
5311
|
.gd-split .gd-srow { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-width: 0; }
|
|
5294
5312
|
.gd-half { display: grid; grid-template-columns: var(--gd-gw) 2ch minmax(0, 1fr); min-width: 0; }
|
|
@@ -5357,3 +5375,217 @@ code .file-ref-off { font-size: inherit; }
|
|
|
5357
5375
|
.fs-wt-rv { display: inline-flex; align-items: center; gap: 4px; height: 18px; margin-left: 6px; padding: 1px 6px 0; box-sizing: border-box; border: 1px solid var(--line); border-radius: 9px; font-size: 11px; font-weight: 600; line-height: 1; color: var(--text2); white-space: nowrap; vertical-align: middle; }
|
|
5358
5376
|
.fs-wt-rv .ic { width: 11px; height: 11px; color: var(--green); margin-top: -1px; }
|
|
5359
5377
|
.fs-wt-rv-part { border-style: dashed; }
|
|
5378
|
+
|
|
5379
|
+
/* 2026-11-03 This session · Whole folder (walkthrough, its Files view, What it did): a quiet segmented
|
|
5380
|
+
toggle with each side's count; 44px to a finger, 32px to a mouse. Files that aren't this session's carry
|
|
5381
|
+
a small hollow-dashed ring plus the words "not from this session" (shape and words, never color alone). */
|
|
5382
|
+
.fsc-seg { margin-right: auto; flex: none; }
|
|
5383
|
+
.wt-seg.fsc-seg button { height: 44px; gap: 7px; }
|
|
5384
|
+
.fsc-n { font: 500 11.5px/1 var(--mono); font-variant-numeric: tabular-nums; color: var(--muted); padding: 2px 5px 1px; border-radius: 6px; background: var(--bg2); }
|
|
5385
|
+
.wt-seg.fsc-seg button[aria-pressed="true"] .fsc-n { color: var(--text2); background: var(--bg); }
|
|
5386
|
+
@media (pointer: fine) { .wt-seg.fsc-seg button { height: 32px; } }
|
|
5387
|
+
@media (max-width: 600px) { .wt-r2 .fsc-seg { width: 100%; display: flex; } .wt-r2 .fsc-seg button { flex: 1 1 0; } }
|
|
5388
|
+
.nots-g { display: inline-block; flex: none; width: 7px; height: 7px; border: 1.5px dashed var(--muted); border-radius: 50%; box-sizing: border-box; margin: 0 5px 0 0; vertical-align: middle; }
|
|
5389
|
+
.wt-nots { display: inline-flex; align-items: center; flex: none; font-size: 11.5px; color: var(--muted); white-space: nowrap; margin-left: 6px; }
|
|
5390
|
+
.wt-pre { font-size: 11.5px; white-space: nowrap; margin-left: 6px; }
|
|
5391
|
+
.wt-fl-i .nots-g { margin: 0 2px 0 4px; }
|
|
5392
|
+
.wt-fl-i.nots .wt-fm-f, .wt-file.nots .wt-file-p, .wid-item.nots .wid-base { color: var(--text2); }
|
|
5393
|
+
.wid-scope { margin: 4px 0 8px; }
|
|
5394
|
+
.wid-scope .fsc-seg { display: flex; width: 100%; box-sizing: border-box; }
|
|
5395
|
+
.wid-scope .fsc-seg button { flex: 1 1 0; }
|
|
5396
|
+
.wid-path .wt-nots, .wid-path .wt-pre { font-family: var(--sans, inherit); }
|
|
5397
|
+
.wid-scope-n { display: flex; gap: 6px; align-items: flex-start; }
|
|
5398
|
+
.wid-scope-n .ic { width: 13px; height: 13px; flex: none; margin-top: 2px; }
|
|
5399
|
+
|
|
5400
|
+
/* 2026-11-03 "hooks not reporting": a quiet note beside the session's pills (a dashed ring plus words) */
|
|
5401
|
+
.sub .s-hooks { display: inline-flex; align-items: center; gap: 5px; margin-left: 4px; font-size: 12px; color: var(--muted); white-space: nowrap; }
|
|
5402
|
+
.sub .s-hooks-ring { width: 8px; height: 8px; border-radius: 50%; border: 1.5px dashed currentColor; box-sizing: border-box; flex: none; }
|
|
5403
|
+
.fs-head .sub > .s-hooks { height: 22px; vertical-align: middle; }
|
|
5404
|
+
.fs-side.is-scrolled .fs-head .sub > .s-hooks { display: none; }
|
|
5405
|
+
/* 2026-11-03 which hooks were skipped and why: the same note, its words cut short on a narrow header (the whole in its tip) */
|
|
5406
|
+
.sub .s-hooks-skip { max-width: min(420px, 100%); min-width: 0; }
|
|
5407
|
+
.sub .s-hooks-skip .s-hooks-t { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
5408
|
+
|
|
5409
|
+
/* 2026-11-03 "Let me answer prompts here" (ui/hook-fix.js): the hooks-skipped note's way out. Centered; a bottom sheet on a phone. */
|
|
5410
|
+
.sub .s-hooks-fix { margin-left: 6px; min-height: 44px; padding: 0 10px; border: 1px solid var(--btn-line); border-radius: 8px; background: transparent; color: var(--accent); font: inherit; font-size: 12px; cursor: pointer; white-space: nowrap; }
|
|
5411
|
+
@media (pointer: fine) { .sub .s-hooks-fix { min-height: 32px; padding: 0 9px; } }
|
|
5412
|
+
.sub .s-hooks-fix:hover { background: var(--btn-hover); }
|
|
5413
|
+
.sub .s-hooks-fix:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
5414
|
+
.fs-side.is-scrolled .fs-head .sub > .s-hooks-fix { display: none; }
|
|
5415
|
+
.sub .s-hooks-applied .s-hooks-ring { border-style: dotted; }
|
|
5416
|
+
.hf-back { position: fixed; inset: 0; z-index: 400; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; padding: 16px; }
|
|
5417
|
+
.hf-modal { width: min(560px, 100%); max-height: calc(100dvh - 32px); overflow: auto; background: var(--card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--toast-shadow); padding: 16px 18px 14px; color: var(--text2); }
|
|
5418
|
+
.hf-grab { display: none; }
|
|
5419
|
+
.hf-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
|
|
5420
|
+
.hf-head h2 { margin: 0; font-size: 15px; font-weight: 600; color: var(--text); text-transform: none; letter-spacing: normal; }
|
|
5421
|
+
.hf-p { margin: 8px 0; font-size: 13px; line-height: 1.5; }
|
|
5422
|
+
.hf-quiet { color: var(--muted); font-size: 12.5px; }
|
|
5423
|
+
.hf-file code, .hf-p code { font-size: 12px; overflow-wrap: anywhere; }
|
|
5424
|
+
.hf-file { margin: 4px 0; }
|
|
5425
|
+
.hf-code { margin: 6px 0; padding: 10px 12px; background: var(--field); border: 1px solid var(--line); border-radius: 8px; font-size: 12px; line-height: 1.45; overflow: auto; white-space: pre; color: var(--text); user-select: all; }
|
|
5426
|
+
.hf-mark { display: inline-block; width: 9px; height: 9px; margin-right: 7px; vertical-align: 0; box-sizing: border-box; }
|
|
5427
|
+
.hf-mark-ok { border-radius: 50%; background: var(--green); }
|
|
5428
|
+
.hf-mark-admin { border-radius: 2px; border: 1.5px solid var(--yellow); transform: rotate(45deg); }
|
|
5429
|
+
.hf-mark-off { border-radius: 50%; border: 1.5px dashed var(--muted); }
|
|
5430
|
+
.hf-ign { margin-top: 10px; padding-top: 6px; border-top: 1px solid var(--line); }
|
|
5431
|
+
.hf-acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
|
5432
|
+
.hf-acts .btn, .hf-x { min-height: 44px; min-width: 44px; justify-content: center; }
|
|
5433
|
+
@media (pointer: fine) { .hf-acts .btn, .hf-x { min-height: 32px; min-width: 32px; } }
|
|
5434
|
+
.hf-modal .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
5435
|
+
@media (max-width: 640px) {
|
|
5436
|
+
.hf-back { align-items: flex-end; padding: 0; }
|
|
5437
|
+
.hf-modal { width: 100%; max-height: 88dvh; border-radius: var(--radius) var(--radius) 0 0; border-bottom: none; padding: 8px 16px calc(14px + env(safe-area-inset-bottom)); }
|
|
5438
|
+
.hf-grab { display: block; width: 36px; height: 4px; border-radius: 2px; background: var(--line-strong); margin: 2px auto 8px; }
|
|
5439
|
+
.hf-acts { justify-content: stretch; }
|
|
5440
|
+
.hf-acts .btn { flex: 1 1 auto; }
|
|
5441
|
+
}
|
|
5442
|
+
|
|
5443
|
+
/* 2026-11-03 decision memory */
|
|
5444
|
+
.dec-list { list-style: none; margin: 0; padding: 0 0 6px; display: grid; gap: 10px; }
|
|
5445
|
+
.dec-row { font-size: 13.5px; line-height: 1.45; color: var(--text); }
|
|
5446
|
+
.dec-id { font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text2); }
|
|
5447
|
+
.dec-why, .dec-alt, .dec-src { font-size: 12.5px; color: var(--text2); margin-top: 2px; }
|
|
5448
|
+
.dec-src { color: var(--muted, var(--text2)); }
|
|
5449
|
+
.dec-st { margin-left: 6px; font-size: 12px; color: var(--text2); white-space: nowrap; }
|
|
5450
|
+
.dec-rc { display: inline-flex; align-items: center; gap: 2px; font-size: 12px; color: var(--text2); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
5451
|
+
a.dec-rc { min-height: 44px; text-decoration: none; }
|
|
5452
|
+
a.dec-rc:hover .dec-rc-w { text-decoration: underline; }
|
|
5453
|
+
.pchip-rc { margin-left: 6px; }
|
|
5454
|
+
.proj-review-slot:empty { display: none; }
|
|
5455
|
+
.scu-decs { margin-top: 10px; }
|
|
5456
|
+
.scu-dec-h { font-size: 12.5px; font-weight: 600; color: var(--text2); margin: 0 0 4px; }
|
|
5457
|
+
.scu-dec-l { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: 13.5px; }
|
|
5458
|
+
.scu-dec-l .dec-st { margin: 0 4px 0 0; }
|
|
5459
|
+
.dec-given { margin: 6px 0 2px; font-size: 12.5px; color: var(--text2); }
|
|
5460
|
+
.dec-given-i { display: inline-block; width: 1em; text-align: center; }
|
|
5461
|
+
.dec-missed { color: var(--text); }
|
|
5462
|
+
.dec-given-how { color: var(--text2); }
|
|
5463
|
+
.dec-given code { font-size: 12px; }
|
|
5464
|
+
.dec-open { display: inline-flex; align-items: center; min-height: 44px; font-size: 13px; }
|
|
5465
|
+
/* the panel's list: one quiet line (status · id · source), the decision, why; a hairline between rows */
|
|
5466
|
+
.dec-list { gap: 0; }
|
|
5467
|
+
.dec-list > .dec-row { padding: 10px 0; border-top: 1px solid var(--line); }
|
|
5468
|
+
.dec-list > .dec-row:first-child { border-top: 0; padding-top: 2px; }
|
|
5469
|
+
.dec-row-h { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 2px; font-size: 11.5px; line-height: 1.5; color: var(--text2); }
|
|
5470
|
+
.dec-row-h .dec-st { margin-left: 0; font-size: 11.5px; color: var(--text); }
|
|
5471
|
+
.dec-row-h .dec-id { font: 500 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text2); opacity: .85; }
|
|
5472
|
+
.dec-row-h .dec-src { margin-top: 0; font-size: 11.5px; }
|
|
5473
|
+
.dec-dot { color: var(--text2); opacity: .6; }
|
|
5474
|
+
.dec-list .dec-text { font-size: 14px; font-weight: 500; line-height: 1.4; margin-top: 2px; overflow-wrap: anywhere; }
|
|
5475
|
+
.dec-row-rejected .dec-text { text-decoration: line-through; text-decoration-thickness: 1px; color: var(--text2); }
|
|
5476
|
+
.dec-row-superseded .dec-text { color: var(--text2); }
|
|
5477
|
+
.dec-list .dec-why, .dec-list .dec-alt { font-size: 12.5px; line-height: 1.4; }
|
|
5478
|
+
/* compact buttons: a small pill inside a 44px hit area */
|
|
5479
|
+
.dec-acts-panel { gap: 6px; margin-top: 8px; }
|
|
5480
|
+
.dec-acts.dec-acts-panel .btn { position: relative; min-height: 0; height: 28px; padding: 0 10px; font-size: 12.5px; border-radius: 7px; }
|
|
5481
|
+
.dec-acts.dec-acts-panel .btn::after { content: ''; position: absolute; inset: -8px -3px; }
|
|
5482
|
+
|
|
5483
|
+
.dec-page .dec-head h1 { margin: 4px 0 2px; }
|
|
5484
|
+
.dec-tl { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 0; }
|
|
5485
|
+
.dec-item { position: relative; padding: 12px 0 12px 18px; border-left: 2px solid var(--line); margin-left: 6px; }
|
|
5486
|
+
.dec-item::before { content: ''; position: absolute; left: -6px; top: 17px; width: 10px; height: 10px; border-radius: 50%; background: var(--bg); border: 2px solid var(--text2); box-sizing: border-box; }
|
|
5487
|
+
.dec-item.dec-accepted::before { background: var(--text2); }
|
|
5488
|
+
.dec-item.dec-superseded, .dec-item.dec-rejected { opacity: .78; }
|
|
5489
|
+
.dec-item.dec-superseded::before { border-color: var(--line-strong, var(--line)); }
|
|
5490
|
+
.dec-item.dec-rejected .dec-text { text-decoration: line-through; text-decoration-thickness: 1px; }
|
|
5491
|
+
.dec-h { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 12.5px; color: var(--text2); }
|
|
5492
|
+
.dec-date { font-variant-numeric: tabular-nums; }
|
|
5493
|
+
.dec-repo { padding: 1px 6px; border: 1px solid var(--line); border-radius: 999px; font-size: 11.5px; }
|
|
5494
|
+
.dec-item .dec-st { margin-left: 0; }
|
|
5495
|
+
.dec-item .dec-text { font-size: 15px; line-height: 1.45; margin-top: 4px; color: var(--text); overflow-wrap: anywhere; }
|
|
5496
|
+
.dec-meta { font-size: 12.5px; color: var(--text2); margin-top: 4px; }
|
|
5497
|
+
.dec-chains { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 4px; font-size: 12.5px; color: var(--text2); }
|
|
5498
|
+
.dec-chip { font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); background: transparent; border: 1px solid var(--line); border-radius: 6px; padding: 4px 6px; min-height: 24px; cursor: pointer; position: relative; }
|
|
5499
|
+
.wt-dec-rel { font: 600 11.5px/1 system-ui, sans-serif; margin-left: 6px; padding: 2px 5px; border: 1px solid var(--line); border-radius: 4px; color: var(--text2); white-space: nowrap; }
|
|
5500
|
+
.wt-dec-dep { border-style: solid; border-color: var(--text2); color: var(--text); }
|
|
5501
|
+
.wt-dec-prop { border-style: dashed; }
|
|
5502
|
+
.dec-chip::after { content: ''; position: absolute; inset: -10px -4px; }
|
|
5503
|
+
.dec-chip:hover { border-color: var(--text2); }
|
|
5504
|
+
.dec-sug { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 8px; padding: 8px 10px; border: 1px dashed var(--line); border-radius: 8px; font-size: 13px; }
|
|
5505
|
+
.dec-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
|
5506
|
+
.dec-acts .btn, .dec-acts .linkish { min-height: 44px; }
|
|
5507
|
+
.dec-acts-quiet { margin-top: 0; }
|
|
5508
|
+
.dec-ctl { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 4px 0 4px; }
|
|
5509
|
+
.dec-ctl .sask-opt, .dec-ctl .btn, .dec-store-opts .sask-opt { min-height: 44px; }
|
|
5510
|
+
.dec-store-h { font-weight: 600; margin-bottom: 6px; }
|
|
5511
|
+
.dec-store-opts { display: inline-flex; flex-wrap: wrap; gap: 6px; }
|
|
5512
|
+
.dec-store p { margin: 8px 0 0; max-width: 62ch; }
|
|
5513
|
+
.dec-edit { display: grid; gap: 8px; margin-top: 8px; max-width: 640px; }
|
|
5514
|
+
.dec-edit label { display: grid; gap: 4px; font-size: 12.5px; color: var(--text2); }
|
|
5515
|
+
.dec-in { font: inherit; font-size: 15px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); min-height: 44px; box-sizing: border-box; width: 100%; }
|
|
5516
|
+
@media (pointer: coarse) { .dec-in { font-size: 16px; } }
|
|
5517
|
+
.dec-in:focus-visible { outline: 2px solid var(--accent, var(--text2)); outline-offset: 1px; }
|
|
5518
|
+
.dec-flash { background: color-mix(in srgb, var(--text2) 10%, transparent); transition: background .6s; }
|
|
5519
|
+
@media (prefers-reduced-motion: reduce) { .dec-flash { transition: none; } }
|
|
5520
|
+
.dec-proj p { margin: 4px 0 10px; }
|
|
5521
|
+
.dec-empty { margin: 6px 0 10px; } .dec-empty p, .dec-proj .dec-empty p { margin: 0 0 4px; max-width: 64ch; } /* 2026-11-05 where decisions come from, on an empty log */
|
|
5522
|
+
|
|
5523
|
+
.wt-decs { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 2px; }
|
|
5524
|
+
.wt-dec { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-height: 32px; padding: 4px 8px; font-weight: 400; }
|
|
5525
|
+
@media (pointer: coarse) { .wt-dec { min-height: 44px; } }
|
|
5526
|
+
.wt-dec .wt-dec-id { font-weight: 600; }
|
|
5527
|
+
.wt-dec .wt-dec-t { font: 12.5px/1.3 var(--sans, system-ui, sans-serif); color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42ch; }
|
|
5528
|
+
.wt-dec-gone { border-style: dashed; }
|
|
5529
|
+
.wt-dec-w { font: 12px/1 var(--sans, system-ui, sans-serif); color: var(--text2); }
|
|
5530
|
+
.dec-chainview { margin-top: 4px; }
|
|
5531
|
+
|
|
5532
|
+
.dv-isdec .dv-dl { display: none !important; }
|
|
5533
|
+
.dec-ctl .sask-opt { border: 1px solid var(--line); border-radius: 8px; padding: 0 12px; background: transparent; color: var(--text2); cursor: pointer; }
|
|
5534
|
+
.dec-ctl .sask-opt[aria-pressed="true"] { color: var(--text); border-color: var(--line-strong, var(--text2)); }
|
|
5535
|
+
@media (min-width: 700px) { .dv-isdec .dv-sheet { max-width: 760px; height: auto; max-height: 80vh; margin-left: auto; margin-right: auto; } }
|
|
5536
|
+
|
|
5537
|
+
/* testplan Part 3: the test plan page (ui/testplan.js) */
|
|
5538
|
+
.tp-page .tp-head h1 { margin: 4px 0 2px; }
|
|
5539
|
+
.tp-budget { display: inline-flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 12px; }
|
|
5540
|
+
.tp-budget .sask-opt, .tp-go .btn, .tp-exp .btn, .tp-acts .btn, .tp-open { min-height: 44px; }
|
|
5541
|
+
.tp-budget .sask-opt { border: 1px solid var(--line); border-radius: 8px; padding: 0 14px; background: transparent; color: var(--text2); cursor: pointer; font: inherit; }
|
|
5542
|
+
.tp-budget .sask-opt[aria-pressed="true"] { color: var(--text); border-color: var(--line-strong, var(--text2)); font-weight: 600; }
|
|
5543
|
+
.tp-prl, .tp-recent, .tp-ncl { list-style: none; margin: 8px 0; padding: 0; display: grid; gap: 6px; font-size: 14px; }
|
|
5544
|
+
.tp-prl a, .tp-recent a { overflow-wrap: anywhere; }
|
|
5545
|
+
.tp-wt, .tp-nowt { font-size: 12px; color: var(--text2); white-space: nowrap; }
|
|
5546
|
+
.tp-go { margin: 12px 0; }
|
|
5547
|
+
.tp-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 8px 0 4px; }
|
|
5548
|
+
.tp-prog { font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
5549
|
+
.tp-cov { color: var(--text2); font-size: 13px; }
|
|
5550
|
+
.tp-exp { display: inline-flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
|
|
5551
|
+
.tp-by { font-size: 12.5px; margin: 0 0 8px; }
|
|
5552
|
+
.tp-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 12px; }
|
|
5553
|
+
.tp-check { border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; background: var(--bg); }
|
|
5554
|
+
.tp-check.tp-done { border-style: solid; opacity: .82; }
|
|
5555
|
+
.tp-check.tp-failed { border-width: 2px; border-color: var(--text2); }
|
|
5556
|
+
.tp-ch { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
|
|
5557
|
+
.tp-n { font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text2); }
|
|
5558
|
+
.tp-title { font-size: 16px; margin: 0; flex: 1 1 260px; overflow-wrap: anywhere; }
|
|
5559
|
+
.tp-risk { font-size: 12px; white-space: nowrap; color: var(--text2); border: 1px solid var(--line); border-radius: 999px; padding: 2px 8px; }
|
|
5560
|
+
.tp-risk-high { color: var(--text); border-color: var(--text2); font-weight: 600; }
|
|
5561
|
+
.tp-min { font-size: 12px; color: var(--text2); font-variant-numeric: tabular-nums; }
|
|
5562
|
+
.tp-where, .tp-expect, .tp-why { margin: 6px 0 0; font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
|
|
5563
|
+
.tp-k { display: inline-block; min-width: 96px; font-size: 12px; color: var(--text2); }
|
|
5564
|
+
.tp-steps { margin: 6px 0 0; padding-left: 22px; font-size: 14px; line-height: 1.45; }
|
|
5565
|
+
.tp-cites { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
5566
|
+
.tp-cite { font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 5px 7px; text-decoration: none; position: relative; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
5567
|
+
a.tp-cite::after { content: ''; position: absolute; inset: -10px -4px; }
|
|
5568
|
+
.tp-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; align-items: center; }
|
|
5569
|
+
.tp-tick[aria-pressed="true"] { font-weight: 600; border-color: var(--text); }
|
|
5570
|
+
.tp-clear { min-height: 44px; }
|
|
5571
|
+
.tp-note { display: grid; gap: 8px; margin-top: 10px; max-width: 640px; }
|
|
5572
|
+
.tp-note-l { display: grid; gap: 4px; font-size: 12.5px; color: var(--text2); }
|
|
5573
|
+
.tp-propf { margin-top: 10px; }
|
|
5574
|
+
.tp-prop { display: inline-flex; align-items: center; min-height: 44px; }
|
|
5575
|
+
.tp-nc-w { overflow-wrap: anywhere; }
|
|
5576
|
+
.tp-notes { margin: 12px 0; font-size: 13px; color: var(--text2); }
|
|
5577
|
+
.tp-notes summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; }
|
|
5578
|
+
.tp-del .linkish { min-height: 44px; }
|
|
5579
|
+
.tp-wait { display: flex; align-items: center; gap: 10px; padding: 16px 0; color: var(--text2); }
|
|
5580
|
+
.tp-spin { width: 14px; height: 14px; border: 2px solid var(--line); border-top-color: var(--text2); border-radius: 50%; animation: tp-spin 1s linear infinite; }
|
|
5581
|
+
@keyframes tp-spin { to { transform: rotate(360deg); } }
|
|
5582
|
+
@media (prefers-reduced-motion: reduce) { .tp-spin { animation: none; } }
|
|
5583
|
+
@media (max-width: 600px) { .tp-exp { margin-left: 0; } .tp-k { display: block; min-width: 0; } }
|
|
5584
|
+
/* testplan Part 3: picking merged PRs on the PRs page */
|
|
5585
|
+
.prrow.pickable { display: flex; gap: 8px; align-items: flex-start; }
|
|
5586
|
+
.pr-pick { display: inline-flex; flex-direction: column; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; border: 0; background: transparent; color: var(--text2); cursor: pointer; font: inherit; font-size: 18px; line-height: 1; padding: 0; }
|
|
5587
|
+
.pr-pick-w { font-size: 11px; margin-top: 2px; }
|
|
5588
|
+
.pr-pick[aria-pressed="true"] { color: var(--text); }
|
|
5589
|
+
.prs-picks { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 8px 12px; margin: 0 0 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); }
|
|
5590
|
+
.prs-picks .btn, .prs-picks .linkish { min-height: 44px; }
|
|
5591
|
+
.wt-dec .wt-dec-id { white-space: nowrap; flex: none; }
|