@leftoff/cli 0.9.17 → 0.9.18-beta.1
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 +17 -1
- package/bin/leftoff.js +522 -478
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +6 -0
- package/ui/app.js +52 -12
- package/ui/ask.js +1 -0
- package/ui/live-view.js +3 -3
- package/ui/semdiff.js +2 -2
- package/ui/start.js +87 -12
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.18-beta.1",
|
|
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": "One Silly App"
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.
|
|
31
|
+
const MOD_VERSION = '0.9.18-beta.1' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.18-beta.1",
|
|
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
|
@@ -3283,6 +3283,10 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
3283
3283
|
/* 0.9.9 Permissions: the New session choice, the session page's switch and its pill beside Live */
|
|
3284
3284
|
.start-mode { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
3285
3285
|
.start-mode .start-opt-sub { white-space: normal; }
|
|
3286
|
+
/* 1.0 Mode: headless, board terminal, my terminal app (shape + words) */
|
|
3287
|
+
.run-glyph { color: var(--muted); font-weight: 400; display: inline-block; width: 1.1em; }
|
|
3288
|
+
.start-opt[aria-checked="true"] .run-glyph { color: var(--accent); }
|
|
3289
|
+
.start-run .start-opt-sub { white-space: normal; }
|
|
3286
3290
|
.headless-mode { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: -4px 0 10px; }
|
|
3287
3291
|
.headless-mode .hm-l { font-size: 12.5px; font-weight: 600; color: var(--muted); }
|
|
3288
3292
|
.hm-seg { display: inline-flex; border: 1px solid var(--line); border-radius: 10px; overflow: visible; background: var(--card); }
|
|
@@ -3321,6 +3325,8 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
3321
3325
|
.help-more[hidden] { display: none; }
|
|
3322
3326
|
.start-h-help { display: flex; align-items: center; gap: 6px; }
|
|
3323
3327
|
@media (hover: hover) and (pointer: fine) { .help-q { width: 28px; height: 28px; margin: 0 -4px; } }
|
|
3328
|
+
.term-pill { display: inline-flex; align-items: center; gap: 4px; margin-left: 6px; padding: 1px 8px; border-radius: 999px; border: 1px dashed var(--line-strong); color: var(--text2); font-size: 11.5px; font-weight: 600; line-height: 1.5; white-space: nowrap; } /* 1.0 */
|
|
3329
|
+
.term-pill.t-app { border-style: solid; }
|
|
3324
3330
|
.mode-pill { position: relative; display: inline-block; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--bg2); color: var(--text2); font-size: 11.5px; font-weight: 600; line-height: 1.5; white-space: nowrap; }
|
|
3325
3331
|
.mode-pill.m-auto { border-color: var(--accent); background: var(--tint-accent); color: var(--text); }
|
|
3326
3332
|
.mode-pill.m-acceptEdits { border-color: var(--yellow); background: var(--tint-yellow); color: var(--text); }
|
package/ui/app.js
CHANGED
|
@@ -23,7 +23,7 @@ import { parseBang, dropBang, ptyInput, commandOutput, outputMessage, createBang
|
|
|
23
23
|
import { withFresh, pickFresh, newerFresh, freshWins, pendingHeadless, settled as headlessSettled } from './headless-fresh.js' // 0.9.9 another Mac's session, current after Stop/Resume/Permissions
|
|
24
24
|
import { bgPillHtml } from './bgtasks.js' // 0.9.13 "2 tasks running"
|
|
25
25
|
import { scopeHtml, scopeHintHtml, scopeStart } from './sm-scope.js' // 0.9.15 per-machine summarizer
|
|
26
|
-
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
|
|
26
|
+
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, termPillHtml, termBarHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
|
|
27
27
|
import { parseNewSession, projectStartItems, newSessionContext, startStateFor } from './cmdk-new.js' // 0.9.13 ⌘K New session…
|
|
28
28
|
import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
|
|
29
29
|
import { wireTips } from './tips.js'
|
|
@@ -529,7 +529,7 @@ function agentChip(a, { title } = {}) {
|
|
|
529
529
|
|
|
530
530
|
// 0.8.0: a Codex session wears a small "Codex" badge; Claude Code's wear none.
|
|
531
531
|
function codexBadge(s) {
|
|
532
|
-
return s && s.codingAgent === 'codex' ? '<span class="chip agent-badge codex"
|
|
532
|
+
return s && s.codingAgent === 'codex' ? '<span class="chip agent-badge codex" data-tip="' + esc('OpenAI Codex session' + (s.surface === 'codex-ide' ? ' (IDE)' : s.surface === 'codex-app' ? ' (app)' : '')) + '">Codex</span>' : ''
|
|
533
533
|
}
|
|
534
534
|
|
|
535
535
|
// 0.7.1: a session in a git worktree says which ("⑂ fix-login"); one filed
|
|
@@ -1088,7 +1088,7 @@ function card(s) {
|
|
|
1088
1088
|
'<div class="row1"><div class="title">' + cardGlyph(s) +
|
|
1089
1089
|
(s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
|
|
1090
1090
|
'<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
|
|
1091
|
-
'</div>' + statusPill(s) + liveBadge(s) + eyeCountHtml(s.lookCount, { esc }) + '</div>' +
|
|
1091
|
+
'</div>' + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + eyeCountHtml(s.lookCount, { esc }) + '</div>' +
|
|
1092
1092
|
'<div class="meta">' + spaceChip(s) + where(s, { repo: !titleIsWhere(s) }) + (ln && !off ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
|
|
1093
1093
|
(s.needsInput && s.lastNotification && !s.needsYou && !off ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
1094
1094
|
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + recapLineHtml(s, { esc, ago, clock }) + cardCheckpoint(s) + beforeClearHtml(s, { esc }) +
|
|
@@ -1409,6 +1409,14 @@ function wtAsk(sid, text) {
|
|
|
1409
1409
|
wtAskPut(sid, msg)
|
|
1410
1410
|
}
|
|
1411
1411
|
function wtAskPut(sid, msg) {
|
|
1412
|
+
// 0.9.17 a Codex session in the board's terminal: the question goes into Codex's own box there (pasted, not sent)
|
|
1413
|
+
const cs = wtSession(sid)
|
|
1414
|
+
if (cs && cs.codingAgent === 'codex' && cs.terminal && cs.terminal.kind === 'leftoff' && cs.terminal.termId) {
|
|
1415
|
+
const tid = cs.terminal.termId
|
|
1416
|
+
wtUi.ask = ''; wtClose()
|
|
1417
|
+
terminal.show(tid).then((ok) => ok && api('POST', '/v1/term/' + tid + '/in', { data: '\x1b[200~' + msg + '\x1b[201~' })).then((r) => toast(r ? 'In Codex’s box: press Enter there to send' : 'Its terminal isn’t running any more', 4000)).catch((e) => toast(e.message, 5000))
|
|
1418
|
+
return
|
|
1419
|
+
}
|
|
1412
1420
|
const k = 'live:' + sid
|
|
1413
1421
|
const typed = ((state.drafts && state.drafts[k]) || '').trim()
|
|
1414
1422
|
state.drafts[k] = typed ? typed + '\n\n' + msg : msg
|
|
@@ -2577,6 +2585,7 @@ function freshSession(s) {
|
|
|
2577
2585
|
if (op && out.headless) out = { ...out, headless: pendingHeadless(out.headless, op) }
|
|
2578
2586
|
return out
|
|
2579
2587
|
}
|
|
2588
|
+
const switchPending = new Set() // 1.0 a Continue in terminal / headless on its way
|
|
2580
2589
|
const fetchFresh = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/headless', undefined, { timeoutMs: 8000 }).then((r) => (r && r.fresh) || null, () => null)
|
|
2581
2590
|
// Redraw the session page from what it has (no fetch): the bar and the switch at once.
|
|
2582
2591
|
function repaintSession(id) {
|
|
@@ -2852,7 +2861,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2852
2861
|
const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
|
|
2853
2862
|
'<button type="button" class="fs-side-x" data-action="fs-side" aria-expanded="true" aria-controls="fsSide" aria-label="Hide the session panel" data-tip="Hide panel (⌘\\)">' + icon('chevron-right') + '</button>' +
|
|
2854
2863
|
title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
|
|
2855
|
-
'<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
|
|
2864
|
+
'<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
|
|
2856
2865
|
// 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
|
|
2857
2866
|
(where.length ? '<div class="fs-where fs-repo" data-tip="' + esc(wherePath ? wherePath + (branch ? ' · ' + branch : '') : where.join(' · ')) + '">' + icon('folder') + '<span>' + esc(where.join(' · ')) + '</span></div>' : '') +
|
|
2858
2867
|
'<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
|
|
@@ -2873,6 +2882,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2873
2882
|
side += '<section class="scu-wrap" id="session-catchup">' + sessionCatchupHtml(scuOf(s.id), { esc, icon, sid: s.id }) + '</section>' // 0.9.9 Catch me up on this session, at the top
|
|
2874
2883
|
side += '<div class="sask-wrap" id="session-ask">' + sAskPanel(s).html + '</div>' // 0.9.11 Ask about this session
|
|
2875
2884
|
side += ownerOfflineNote(s)
|
|
2885
|
+
side += termBarHtml(s, { esc, may: state.mayAnswer !== false }) // 1.0 in Leftoff's terminal: Continue headless
|
|
2876
2886
|
side += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false, busy: modeBusy.get(s.id) || null, local: state.viewerLocal !== false, helpOpen: helpOpen.has(hmHelpId(s.id)) }) // 0.9.9: started from the board: Stop / Resume, Permissions
|
|
2877
2887
|
if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
|
|
2878
2888
|
// Checkpoints: a timeline, latest first, the latest open
|
|
@@ -3162,7 +3172,7 @@ function liveOpenHtml(s, v, sub) {
|
|
|
3162
3172
|
if (isFullLive(s.id)) {
|
|
3163
3173
|
const h = s.headless
|
|
3164
3174
|
const stop = h && h.state === 'running' && state.mayAnswer !== false ? '<button type="button" class="btn lf-stop" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' : ''
|
|
3165
|
-
return '<section class="live-box full" id="live" aria-label="Live link, full screen" data-id="' + esc(s.id) + '">' + fullBarHtml(s.id, { esc, icon, glyph: glyphFor(s, { cls: 'lf-sg' }), pills: statusPill(s) + liveBadge(s) + mode, stop, retitle: retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id), cls: 'lf-retitle' }) }) +
|
|
3175
|
+
return '<section class="live-box full" id="live" aria-label="Live link, full screen" data-id="' + esc(s.id) + '">' + fullBarHtml(s.id, { esc, icon, glyph: glyphFor(s, { cls: 'lf-sg' }), pills: statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + mode, stop, retitle: retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id), cls: 'lf-retitle' }) }) +
|
|
3166
3176
|
'<nav class="lf-tabs" id="lfTabs" aria-label="Open sessions"' + (lfTabsUi.html ? '' : ' hidden') + '>' + lfTabsUi.html + '</nav>' // 0.9.14: the open tabs stay in full screen (paintLfTabs fills it)
|
|
3167
3177
|
}
|
|
3168
3178
|
return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2>' + mode + '<span class="faint">' + sub + '</span>' + fullButtonHtml(s.id, { esc, icon }) + '</div>'
|
|
@@ -5110,6 +5120,7 @@ function walkValueOf(w) {
|
|
|
5110
5120
|
const s = w.saved || null
|
|
5111
5121
|
const choice = s ? s.choice : w.choice
|
|
5112
5122
|
if (choice === 'agent') return 'agent'
|
|
5123
|
+
if (choice === 'codex') return 'codex' // 0.9.17
|
|
5113
5124
|
if (choice === 'claude') return 'claude:' + ((s && s.model) || 'sonnet')
|
|
5114
5125
|
if (choice === 'ollama') return 'ollama:' + ((s && s.model) || w.model || '')
|
|
5115
5126
|
return 'summarizer'
|
|
@@ -5151,7 +5162,7 @@ function paintWalk() {
|
|
|
5151
5162
|
let h = '', h0 = ''
|
|
5152
5163
|
// first: this session's own agent (recommended where Claude Code is here)
|
|
5153
5164
|
const ag = opt('agent')
|
|
5154
|
-
const fbLine = d.fallback ? 'When a session can’t be forked
|
|
5165
|
+
const fbLine = d.fallback ? 'When a session can’t be forked, ' + d.fallback.label + ' writes it' + (d.codexFound ? '; Codex sessions use Codex, and Codex stands in when Claude is unavailable.' : '.') : ''
|
|
5155
5166
|
h0 = card({ value: 'agent', on: !typed && kind === 'agent', name: ag.name, note: ag.line, suggested: d.claudeFound && d.suggested === 'agent', disabled: !d.claudeFound,
|
|
5156
5167
|
side: d.claudeFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.claudeFound ? (kind === 'agent' && !typed ? fbLine : '') : 'Needs Claude Code on this machine.' })
|
|
5157
5168
|
const cl = opt('claude')
|
|
@@ -5166,6 +5177,10 @@ function paintWalk() {
|
|
|
5166
5177
|
h += h0
|
|
5167
5178
|
h += card({ value: 'claude:' + cm, on: claudeOn, name: cl.name, note: cl.line, suggested: d.claudeFound && d.suggested === 'claude', disabled: !d.claudeFound,
|
|
5168
5179
|
side: d.claudeFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.claudeFound ? '' : 'Install Claude Code on this machine to use it.', extra: seg })
|
|
5180
|
+
// 0.9.17 Codex: read-only codex exec in the session's folder; Codex sessions use it by default, and Claude's stand-in when it's unavailable
|
|
5181
|
+
const cx = opt('codex')
|
|
5182
|
+
h += card({ value: 'codex', on: !typed && kind === 'codex', name: cx.name, note: cx.line, disabled: !d.codexFound,
|
|
5183
|
+
side: d.codexFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.codexFound ? '' : 'Install Codex on this machine to use it.' })
|
|
5169
5184
|
const smo = opt('summarizer')
|
|
5170
5185
|
h += card({ value: 'summarizer', on: !typed && kind === 'summarizer', name: smo.name, note: smo.line, warn: d.summarizerSmall ? 'Small for this job: expect thinner walkthroughs.' : '' })
|
|
5171
5186
|
const ol = d.ollama || {}
|
|
@@ -7171,9 +7186,9 @@ async function startSend(btn) {
|
|
|
7171
7186
|
const box = btn.closest('.start-box')
|
|
7172
7187
|
const ta = box && box.querySelector('.start-text')
|
|
7173
7188
|
const text = ta ? ta.value.trim() : ''
|
|
7174
|
-
if (!text && !attachList(START_SID).length) { if (ta) ta.focus(); return }
|
|
7175
|
-
if (startUi.sending) return
|
|
7176
7189
|
const pick = startPick(startUi.data, startUi)
|
|
7190
|
+
if (!text && pick.agent !== 'codex' && !attachList(START_SID).length) { if (ta) ta.focus(); return } // 0.9.17 Codex may start with no first message
|
|
7191
|
+
if (startUi.sending) return
|
|
7177
7192
|
if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
|
|
7178
7193
|
startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
|
|
7179
7194
|
try {
|
|
@@ -7182,14 +7197,21 @@ async function startSend(btn) {
|
|
|
7182
7197
|
await uploadsSettled(START_SID)
|
|
7183
7198
|
const atts = attachList(START_SID)
|
|
7184
7199
|
if (atts.some((a) => a.status === 'failed')) throw new Error('An attachment didn’t upload. Retry it or remove it.')
|
|
7185
|
-
const r = await api('POST', '/v1/sessions/start', startBody(pick, text, { draft: startUi.draft, atts }), { timeoutMs: 60000 })
|
|
7200
|
+
const r = await api('POST', '/v1/sessions/start', startBody(pick, text, { draft: pick.agent === 'codex' ? null : startUi.draft, atts: pick.agent === 'codex' ? [] : atts }), { timeoutMs: 60000 })
|
|
7186
7201
|
lastStartWrite(pick) // 0.9.13 ⌘K's New session… starts from here next time
|
|
7187
7202
|
if (atts.length) { attachUi.by.delete(START_SID); startUi.draft = null }
|
|
7188
7203
|
const xn = startExtrasNote(pick, r); if (xn) setTimeout(() => toast(xn, 6000), 1200) // 0.9.12: never a silent drop of a project's repo
|
|
7189
7204
|
if (state.drafts) delete state.drafts.start
|
|
7190
7205
|
if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // 0.9.9: a phone's keyboard closes
|
|
7191
|
-
if (r.
|
|
7192
|
-
|
|
7206
|
+
if (r.app && !r.app.ok && r.app.message) setTimeout(() => toast(r.app.message, 7000), 900) // 1.0 My terminal app: it still runs (in the board's terminal)
|
|
7207
|
+
if (r.state === 'started' && r.sessionId) {
|
|
7208
|
+
startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); openStarted(r.sessionId, r.peerVia ? r.machine || pick.m.machine : null, text)
|
|
7209
|
+
if (r.run === 'board' && r.terminal && r.terminal.id && !r.peerVia) setTimeout(() => terminal.show(r.terminal.id).catch(() => {}), 50) // 1.0 Board terminal: shown now
|
|
7210
|
+
return
|
|
7211
|
+
}
|
|
7212
|
+
// 0.9.17 Codex: it runs in the board's terminal (shown now, on this Mac's board), and its session opens once Codex writes it
|
|
7213
|
+
if (r.agent === 'codex' && r.terminal && r.terminal.id && !r.peerVia) { toast('Codex started on ' + (r.machine || pick.m.machine)); if (r.run !== 'app' || !(r.app && r.app.ok)) terminal.show(r.terminal.id).catch(() => {}) }
|
|
7214
|
+
startUi.ask = { ...r, machine: r.machine || pick.m.machine, prompt: text }
|
|
7193
7215
|
pollStartAsk()
|
|
7194
7216
|
} catch (e) { toast(e.message, 6000) } finally { startUi.sending = false }
|
|
7195
7217
|
if (state.route && state.route.name === 'new') render()
|
|
@@ -8742,7 +8764,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8742
8764
|
// 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
|
|
8743
8765
|
if (action === 'start-pick') {
|
|
8744
8766
|
const k = btn.dataset.k, v = btn.dataset.v
|
|
8745
|
-
if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null; Object.assign(startUi, { extra: [], adding: false, saving: false, find: '' }); if (attachList(START_SID).length) setTimeout(startReupload, 0) /* 0.9.13: its files go to that Mac */ } else if (k === 'project') { startUi.project = v || null; startUi.repo = null; startUi.drop = []; startUi.custom = null; startUi.browse = null; Object.assign(startUi, { extra: [], adding: false, saving: false }) } else if (k === 'repo') { startUi.repo = v; startUi.extra = startUi.extra.filter((x) => x !== v) } else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v
|
|
8767
|
+
if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null; Object.assign(startUi, { extra: [], adding: false, saving: false, find: '' }); if (attachList(START_SID).length) setTimeout(startReupload, 0) /* 0.9.13: its files go to that Mac */ } else if (k === 'project') { startUi.project = v || null; startUi.repo = null; startUi.drop = []; startUi.custom = null; startUi.browse = null; Object.assign(startUi, { extra: [], adding: false, saving: false }) } else if (k === 'repo') { startUi.repo = v; startUi.extra = startUi.extra.filter((x) => x !== v) } else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v; else if (k === 'agent') startUi.agent = v === 'codex' ? 'codex' : 'claude' /* 0.9.17 */; else if (k === 'run') startUi.run = ['headless', 'board', 'app'].includes(v) ? v : null // 1.0
|
|
8746
8768
|
return render()
|
|
8747
8769
|
}
|
|
8748
8770
|
// 0.9.16 + Add another repo, and Save these as a project…
|
|
@@ -8821,6 +8843,21 @@ document.addEventListener('click', async (ev) => {
|
|
|
8821
8843
|
return render()
|
|
8822
8844
|
}
|
|
8823
8845
|
if (action === 'ask-auto') return autoRestClick(btn)
|
|
8846
|
+
// 1.0 Continue in terminal / Continue headless (src/run-switch.js)
|
|
8847
|
+
if (action === 'run-continue') {
|
|
8848
|
+
const run = btn.dataset.run === 'headless' ? 'headless' : 'board'
|
|
8849
|
+
if (!id || hlPending.has(id) || switchPending.has(id)) return
|
|
8850
|
+
switchPending.add(id); btn.disabled = true
|
|
8851
|
+
toast(run === 'headless' ? 'Ending it in the terminal, then continuing headless…' : 'Continuing in a terminal…', 3000)
|
|
8852
|
+
try {
|
|
8853
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/continue', { run }, { timeoutMs: 45000 })
|
|
8854
|
+
toast(run === 'headless' ? 'Continuing headless: talk to it here' : 'In the terminal now', 3500)
|
|
8855
|
+
if (r && r.terminal && r.terminal.id && run !== 'headless') terminal.show(r.terminal.id)
|
|
8856
|
+
} catch (e) { toast(e.message, 6000) }
|
|
8857
|
+
switchPending.delete(id)
|
|
8858
|
+
repaintSession(id)
|
|
8859
|
+
return render()
|
|
8860
|
+
}
|
|
8824
8861
|
if (action === 'headless-stop' || action === 'headless-run') {
|
|
8825
8862
|
const op = action === 'headless-stop' ? 'stop' : 'resume'
|
|
8826
8863
|
if (!id || hlPending.has(id)) return
|
|
@@ -9721,6 +9758,9 @@ async function needDismissClick(btn) {
|
|
|
9721
9758
|
|
|
9722
9759
|
// Go: "Switching…" at once, a note if it's slow, the result (with timings when slow) after.
|
|
9723
9760
|
function goClick(btn, id, s) {
|
|
9761
|
+
// 0.9.17 a session in the board's own terminal (a Codex start): show that terminal, here, on any device
|
|
9762
|
+
// 1.0 unless a terminal app is attached to it (leftoff attach): then Go brings that window forward on its Mac
|
|
9763
|
+
if (s && s.isLocal !== false && s.terminal && s.terminal.kind === 'leftoff' && s.terminal.termId && !(s.terminal.apps > 0 && state.viewerLocal !== false)) return terminal.show(s.terminal.termId).then((ok) => { if (!ok) toast('Its terminal in Leftoff isn’t running any more', 4000) })
|
|
9724
9764
|
return goWithFeedback({
|
|
9725
9765
|
id,
|
|
9726
9766
|
title: s && s.title,
|
package/ui/ask.js
CHANGED
|
@@ -94,6 +94,7 @@ export function byLabel(t, { machine = null } = {}) {
|
|
|
94
94
|
if (!by) return ''
|
|
95
95
|
const on = machine ? ' on ' + machine : ''
|
|
96
96
|
if (by.engine === 'claude') return 'Claude Code (' + (by.model || 'sonnet') + ', read-only)' + on + (by.readRepo === false ? ', from your notes only' : '')
|
|
97
|
+
if (by.engine === 'codex') return 'Codex (read-only)' + (by.unavailable ? ' (Claude unavailable: ' + by.unavailable + ')' : '') + on + (by.readRepo === false ? ', from your notes only' : '') // 0.9.17
|
|
97
98
|
if (by.engine === 'summary') return 'From your notes (no repo reading), by ' + (by.model || by.provider || 'your summary model') + on
|
|
98
99
|
if (by.engine === 'findings') return 'What I found (no model answered)' + on
|
|
99
100
|
if (by.engine === 'empty') return ''
|
package/ui/live-view.js
CHANGED
|
@@ -20,7 +20,7 @@ export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.en
|
|
|
20
20
|
// a thread while it is live-linked and the how-to otherwise.
|
|
21
21
|
// -> 'none' | 'thread' | 'readonly' (a stopped headless one: no composer) | 'how'
|
|
22
22
|
export function liveBoxKind(s) {
|
|
23
|
-
if (!s || s.agent || s.codingAgent === 'codex') return 'none'
|
|
23
|
+
if (!s || s.agent || (s.codingAgent === 'codex' && !s.headless)) return 'none' // 1.0: headless Codex has Live
|
|
24
24
|
if (s.capture === 'hooks' && !s.headless) return 'none'
|
|
25
25
|
if (s.liveLink && s.liveLink.on) return 'thread'
|
|
26
26
|
if (s.headless) return 'thread' // 0.9.13: not running (Paused, Stopped): a message resumes it
|
|
@@ -123,7 +123,7 @@ const LIVE_TIP = 'Live link: send this session messages from the board or your p
|
|
|
123
123
|
export function liveBadgeHtml(s, now = Date.now()) {
|
|
124
124
|
if (!cardPills(s).live) return ''
|
|
125
125
|
const w = liveWorking(s, now)
|
|
126
|
-
return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? 'Claude is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
|
|
126
|
+
return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? (s.codingAgent === 'codex' ? 'Codex' : 'Claude') + ' is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
// The board redraws a card (innerHTML) on every update, which would restart a
|
|
@@ -503,7 +503,7 @@ export function pendingClaudeHtml(you, { esc, ago, now = Date.now(), lvActs = nu
|
|
|
503
503
|
if (st.phase === 'stopped') inner = (st.why && st.why.label ? whyHtml(st.why, { esc }) : '<div class="lv-text lv-none">' + esc(st.lost ? 'No reply recorded' : 'Stopped without a reply') + '</div>') + acts + toolsHtml(st, { esc, now })
|
|
504
504
|
else if (st.phase === 'replied') inner = '<div class="lv-text lv-none">Replied in the terminal</div>' + toolsHtml(st, { esc, now }) // settled, its words not kept: never a Working bubble
|
|
505
505
|
else inner = workingHtml(st, { esc, now })
|
|
506
|
-
return '<li class="lv lv-claude lv-pending' + (FINAL_PHASES.has(st.phase) ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st) + (acts ? '|a' + actsSig(lvActs) : ''))) + '"><div class="lv-who">Claude ' + timeHtml(st.last ? new Date(st.last).toISOString() : you.at, { esc, now }) + '</div>' + inner + '</li>'
|
|
506
|
+
return '<li class="lv lv-claude lv-pending' + (FINAL_PHASES.has(st.phase) ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st) + (acts ? '|a' + actsSig(lvActs) : ''))) + '"><div class="lv-who">' + esc(you.answerBy || 'Claude') + ' ' + timeHtml(st.last ? new Date(st.last).toISOString() : you.at, { esc, now }) + '</div>' + inner + '</li>'
|
|
507
507
|
}
|
|
508
508
|
|
|
509
509
|
// The whole thread: each message you sent, then Claude's side of that turn.
|
package/ui/semdiff.js
CHANGED
|
@@ -376,8 +376,8 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
|
|
|
376
376
|
if (tools) h += '<div class="wt-r2" role="toolbar" aria-label="Reading tools">' + tools + '</div>'
|
|
377
377
|
// which model wrote it (Settings → Models → Walk me through it picks it)
|
|
378
378
|
// the session's own agent couldn't (a Codex session, no transcript here, it didn't answer): said quietly, the reason in the tip
|
|
379
|
-
if (ok) h += '<p class="faint wt-by">Written by ' + (data.modelLabel ? esc(data.modelLabel) : 'your summarizer') + (when ? ' · ' + esc(when) : '') +
|
|
380
|
-
(data.path === 'fallback' ? ' · <span class="wt-path" tabindex="0" data-tip="' + esc('Not the session’s agent: ' + (data.pathWhy || 'it couldn’t be forked here')) + '">not the session’s agent</span>' : '') + '</p>'
|
|
379
|
+
if (ok) h += '<p class="faint wt-by">Written by ' + (data.modelLabel ? esc(data.modelLabel) : 'your summarizer') + (data.unavailable ? ' (Claude unavailable: ' + esc(data.unavailable) + ')' : '') + (when ? ' · ' + esc(when) : '') +
|
|
380
|
+
(data.path === 'fallback' && !data.unavailable ? ' · <span class="wt-path" tabindex="0" data-tip="' + esc('Not the session’s agent: ' + (data.pathWhy || 'it couldn’t be forked here')) + '">not the session’s agent</span>' : '') + '</p>'
|
|
381
381
|
// 2026-10-30 a kept walkthrough for a diff that has moved on: said so, with Update (never shown as current)
|
|
382
382
|
if (ok && data.stale) h += staleBarHtml(data.stale, { esc, icon, sid, loading: ui.loading })
|
|
383
383
|
// nothing new since the last checkpoint: the whole session instead, and a quiet word saying so
|
package/ui/start.js
CHANGED
|
@@ -51,8 +51,36 @@ export function startPick(data, ui = {}) {
|
|
|
51
51
|
const where = ui.where === 'worktree' && !(repo && repo.git === false) ? 'worktree' : 'repo'
|
|
52
52
|
// Permissions: what you picked, else what this board last used for that Mac (kept on the Mac you're connected to), else Ask me
|
|
53
53
|
const mode = START_MODES.some((x) => x.v === ui.mode) ? ui.mode : m && START_MODES.some((x) => x.v === m.lastMode) ? m.lastMode : 'default'
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// 0.9.17 Claude Code or Codex: what you picked (when that Mac has both), else what it used last, else the one it has
|
|
55
|
+
const ag = m && m.agents ? m.agents : null
|
|
56
|
+
const agent = !ag ? 'claude' : ag.choice && (ui.agent === 'claude' || ui.agent === 'codex') ? ui.agent : ag.last || (ag.claude ? 'claude' : ag.codex ? 'codex' : 'claude')
|
|
57
|
+
// 1.0 Mode: what you picked (when it's possible for that agent there), else what that Mac used last for the agent, else how Start ran it before
|
|
58
|
+
const rs = m && m.runs ? m.runs : null
|
|
59
|
+
const okRun = (r) => !!(rs && rs.options && rs.options[agent] && rs.options[agent][r] && rs.options[agent][r].ok)
|
|
60
|
+
const dflt = agent === 'codex' ? 'board' : 'headless'
|
|
61
|
+
const lastRun = rs && rs.last ? rs.last[agent] : null
|
|
62
|
+
const run = !rs ? dflt : START_RUNS.some((x) => x.v === ui.run) && okRun(ui.run) ? ui.run : okRun(lastRun) ? lastRun : okRun(dflt) ? dflt : (START_RUNS.find((x) => okRun(x.v)) || { v: dflt }).v
|
|
63
|
+
return { m, repo, where, mode, project, also, dropped, missing, agent, agents: ag, run, runs: rs }
|
|
64
|
+
}
|
|
65
|
+
// 1.0 where the agent runs (src/start-run.js)
|
|
66
|
+
export const START_RUNS = Object.freeze([
|
|
67
|
+
{ v: 'headless', label: 'Headless', sub: 'chat in Live, from any device', glyph: '◇' },
|
|
68
|
+
{ v: 'board', label: 'Board terminal', sub: 'a terminal here and on your phone', glyph: '▭' },
|
|
69
|
+
{ v: 'app', label: 'My terminal app', sub: 'Ghostty, iTerm or Terminal', glyph: '▣' },
|
|
70
|
+
])
|
|
71
|
+
export function startRunIntro(agent, run) {
|
|
72
|
+
const AG = agent === 'codex' ? 'Codex' : 'Claude Code'
|
|
73
|
+
if (run === 'board') return 'Leftoff starts ' + AG + ' in a terminal it keeps on the Mac you pick. It shows in the board and on your phone, and it keeps running when you close the tab.'
|
|
74
|
+
if (run === 'app') return 'Leftoff starts ' + AG + ' in a terminal it keeps on the Mac you pick, and opens it in your terminal app. Closing that window doesn’t stop it: Open in Terminal (or leftoff attach) brings it back.'
|
|
75
|
+
return 'Leftoff starts ' + AG + ' on the Mac you pick, with no terminal. It shows on every board right away: talk to it in Live, and approve its edits and commands in Needs you, from any device.'
|
|
76
|
+
}
|
|
77
|
+
export const AGENT_NAMES = Object.freeze({ claude: 'Claude Code', codex: 'Codex' })
|
|
78
|
+
// 0.9.17 the Permissions cards in Codex's words (src/codex-start.js CODEX_MODES_TEXT)
|
|
79
|
+
export const CODEX_MODE_SUBS = Object.freeze({
|
|
80
|
+
default: { sub: 'Codex’s own settings', text: 'Codex runs with your own Codex settings: it asks before anything its sandbox doesn’t allow. You answer in its terminal.' },
|
|
81
|
+
acceptEdits: { sub: 'edits in the folder, asks for the rest', text: 'Codex may edit files and run commands inside the folder (its workspace-write sandbox); anything outside it asks first, in its terminal.' },
|
|
82
|
+
auto: { sub: 'no asks, sandboxed to the folder', text: 'Codex never stops to ask: commands run inside the folder’s sandbox, and anything the sandbox blocks fails back to Codex instead of asking you.' },
|
|
83
|
+
})
|
|
56
84
|
export const ADD_DIRS_MAX = 8 // src/claude-headless.js
|
|
57
85
|
|
|
58
86
|
// The body New session posts (src/session-start.js): a project start carries
|
|
@@ -60,6 +88,8 @@ export const ADD_DIRS_MAX = 8 // src/claude-headless.js
|
|
|
60
88
|
// added (0.9.16) carries just those folders.
|
|
61
89
|
export function startBody(pick, prompt, { draft = null, atts = [] } = {}) {
|
|
62
90
|
const b = { machine: pick.m.machine, cwd: pick.repo.path, prompt, where: pick.where, mode: pick.mode }
|
|
91
|
+
if (pick.agents) b.agent = pick.agent // 0.9.17 (a Mac that didn't say which it has gets none: Claude Code, as before)
|
|
92
|
+
if (pick.runs) b.run = pick.run // 1.0 (an older Mac gets none: the way it always started)
|
|
63
93
|
if (pick.project) Object.assign(b, { project: pick.project.id, addDirs: pick.also.map((r) => r.path) })
|
|
64
94
|
else if (pick.also && pick.also.length) b.addDirs = pick.also.map((r) => r.path) // 0.9.16 + Add another repo
|
|
65
95
|
const ids = (atts || []).filter((a) => a && a.id && a.status === 'done').map((a) => a.id) // 0.9.13
|
|
@@ -127,6 +157,17 @@ export function modePillHtml(s, { esc, seen = null }) {
|
|
|
127
157
|
return ' <span class="mode-pill m-' + esc(m) + '" data-tip="' + esc('Permissions: ' + modeLabel(m) + (h.state === 'running' ? '' : ' (when it runs again)')) + '">' + esc(modeLabel(m)) + '</span>'
|
|
128
158
|
}
|
|
129
159
|
|
|
160
|
+
// 1.0 A session in one of Leftoff's terminals: where it runs, by shape and
|
|
161
|
+
// words. ▣ a terminal app is attached; ◌ running · detached (nobody looking;
|
|
162
|
+
// it keeps running, Go or Open in Terminal brings it back); nothing otherwise.
|
|
163
|
+
export function termPillHtml(s, { esc }) {
|
|
164
|
+
const t = s && s.terminal
|
|
165
|
+
if (!t || t.kind !== 'leftoff' || !t.termId || s.endedAt) return ''
|
|
166
|
+
if (t.apps > 0) return ' <span class="term-pill t-app" data-tip="' + esc('Open in a terminal app (leftoff attach). Closing that window leaves it running.') + '"><span aria-hidden="true">▣</span> in a terminal app</span>'
|
|
167
|
+
if (t.detached) return ' <span class="term-pill t-detached" data-tip="' + esc('Its terminal is running with no window on it. Go shows it here; Open in Terminal attaches a window. Only Stop or Exit ends it.') + '"><span aria-hidden="true">◌</span> running · detached</span>'
|
|
168
|
+
return ''
|
|
169
|
+
}
|
|
170
|
+
|
|
130
171
|
// Is a path inside one of a Mac's approved folders (its real paths)?
|
|
131
172
|
export function underApproved(p, approved) {
|
|
132
173
|
const x = String(p || '')
|
|
@@ -160,8 +201,8 @@ const ago = (iso, now) => {
|
|
|
160
201
|
return Math.round(s / 86400) + 'd ago'
|
|
161
202
|
}
|
|
162
203
|
|
|
163
|
-
function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
|
|
164
|
-
return '<button type="button" class="start-opt" role="radio" aria-checked="' + on + '" data-action="start-pick" data-k="' + esc(k) + '" data-v="' + esc(v) + '"' + (disabled ? ' disabled' : '') + '>' +
|
|
204
|
+
function opt({ k, v, label, sub = '', on = false, disabled = false, tip = '', esc }) {
|
|
205
|
+
return '<button type="button" class="start-opt" role="radio" aria-checked="' + on + '" data-action="start-pick" data-k="' + esc(k) + '" data-v="' + esc(v) + '"' + (disabled ? ' disabled' : '') + (tip ? ' data-tip="' + esc(tip) + '"' : '') + '>' +
|
|
165
206
|
'<span class="start-opt-main">' + label + '</span>' + (sub ? '<span class="start-opt-sub">' + sub + '</span>' : '') + '</button>'
|
|
166
207
|
}
|
|
167
208
|
|
|
@@ -169,6 +210,7 @@ function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
|
|
|
169
210
|
export function startAskLine(ask, { clock = (x) => x } = {}) {
|
|
170
211
|
if (!ask) return null
|
|
171
212
|
if (ask.state === 'pending') return { tone: 'wait', text: 'Waiting for a yes on ' + ask.machine + ' (until ' + clock(ask.expiresAt) + '). It shows on that Mac’s board and as a notification.' + (ask.firstTime ? ' A first time in a folder asks there, unless it’s inside a folder approved there.' : '') }
|
|
213
|
+
if (ask.state === 'starting' && ask.agent === 'codex') return { tone: 'wait', text: 'Codex is running in a terminal on ' + ask.machine + '. Its session opens here as soon as Codex writes it (after its first message).' }
|
|
172
214
|
if (ask.state === 'starting') return { tone: 'wait', text: 'Starting on ' + ask.machine + '…' }
|
|
173
215
|
if (ask.state === 'declined') return { tone: 'bad', text: 'Declined on ' + ask.machine + '.' }
|
|
174
216
|
if (ask.state === 'expired') return { tone: 'bad', text: 'Nobody confirmed it on ' + ask.machine + ' in time.' }
|
|
@@ -179,8 +221,10 @@ export function startAskLine(ask, { clock = (x) => x } = {}) {
|
|
|
179
221
|
export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', local = true, now = Date.now(), clock = (x) => x, draft = '', busy = false, atts = null, chipHtml = null, mayProject = true } = {}) {
|
|
180
222
|
const pick = startPick(data, ui)
|
|
181
223
|
const rule = startRule(data, pick, { local })
|
|
224
|
+
const codex = pick.agent === 'codex'
|
|
225
|
+
const AG = AGENT_NAMES[pick.agent] || 'Claude Code'
|
|
182
226
|
let h = '<a class="back" href="#/">← All sessions</a>' +
|
|
183
|
-
'<div class="pagehead"><div><h1>New session</h1><div class="sub">
|
|
227
|
+
'<div class="pagehead"><div><h1>New session</h1><div class="sub">' + esc(startRunIntro(pick.agent, pick.run)) + '</div></div></div>'
|
|
184
228
|
h += '<div class="start" data-start>'
|
|
185
229
|
// Mac
|
|
186
230
|
const ms = (data && data.machines) || []
|
|
@@ -188,6 +232,20 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
|
|
|
188
232
|
ms.map((x) => opt({ k: 'machine', v: x.machine, on: !!pick.m && x.machine === pick.m.machine, disabled: !x.online, esc,
|
|
189
233
|
label: esc((emoji(x.machine) ? emoji(x.machine) + ' ' : '') + x.machine), sub: esc(x.here ? (local ? 'this Mac' : 'the Mac you’re connected to') : x.online ? 'online' : 'offline') })).join('') +
|
|
190
234
|
'</div></section>'
|
|
235
|
+
// Agent (0.9.17): only when that Mac has both
|
|
236
|
+
if (pick.agents && pick.agents.choice) {
|
|
237
|
+
h += '<section class="start-sec start-agent"><h2 class="start-h">Agent</h2><div class="start-opts" role="radiogroup" aria-label="Agent">' +
|
|
238
|
+
opt({ k: 'agent', v: 'claude', on: !codex, esc, label: 'Claude Code', sub: 'Anthropic' }) +
|
|
239
|
+
opt({ k: 'agent', v: 'codex', on: codex, esc, label: 'Codex', sub: 'OpenAI' }) +
|
|
240
|
+
'</div></section>'
|
|
241
|
+
}
|
|
242
|
+
// Mode (1.0): headless, the board's terminal, or your terminal app; the ones that Mac can't do say why
|
|
243
|
+
if (pick.runs && pick.runs.options) {
|
|
244
|
+
const o = pick.runs.options[pick.agent] || {}
|
|
245
|
+
h += '<section class="start-sec start-run"><h2 class="start-h">Mode</h2><div class="start-opts" role="radiogroup" aria-label="Mode">' +
|
|
246
|
+
START_RUNS.map((x) => { const can = o[x.v] || { ok: true }; return opt({ k: 'run', v: x.v, on: x.v === pick.run, disabled: !can.ok, tip: can.ok ? '' : can.why || '', esc, label: '<span class="run-glyph" aria-hidden="true">' + x.glyph + '</span> ' + esc(x.label), sub: esc(can.ok ? x.sub : 'not on this Mac') }) }).join('') +
|
|
247
|
+
'</div></section>'
|
|
248
|
+
}
|
|
191
249
|
// Project (0.9.12): only when there are projects
|
|
192
250
|
const projects = (pick.m && pick.m.projects) || []
|
|
193
251
|
if (projects.length) {
|
|
@@ -228,17 +286,17 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
|
|
|
228
286
|
// Where
|
|
229
287
|
h += '<section class="start-sec"><h2 class="start-h">Where</h2><div class="start-opts start-where" role="radiogroup" aria-label="Where">' +
|
|
230
288
|
opt({ k: 'where', v: 'repo', on: pick.where === 'repo', esc, label: 'In the repo', sub: 'on its current branch' }) +
|
|
231
|
-
opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: pick.repo && pick.repo.git === false ? 'needs a git repo' : 'its own branch and folder', disabled: !!(pick.repo && pick.repo.git === false) }) +
|
|
289
|
+
opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: pick.repo && pick.repo.git === false ? 'needs a git repo' : codex ? 'Codex’s own worktree' : 'its own branch and folder', disabled: !!(pick.repo && pick.repo.git === false) }) +
|
|
232
290
|
'</div></section>'
|
|
233
291
|
// Permissions (0.9.9)
|
|
234
292
|
const mo = START_MODES.find((x) => x.v === pick.mode) || START_MODES[0]
|
|
235
|
-
const moreText = mo.text + ' ' + TERMINAL_MODE_HINT // behind the (?): the cards' own lines say enough
|
|
293
|
+
const moreText = codex ? (CODEX_MODE_SUBS[mo.v] || CODEX_MODE_SUBS.default).text : mo.text + ' ' + TERMINAL_MODE_HINT // behind the (?): the cards' own lines say enough
|
|
236
294
|
h += '<section class="start-sec"><h2 class="start-h start-h-help">Permissions' + helpQHtml({ id: 'start-mode-help', label: 'About Permissions', text: moreText, open: !!ui.helpOpen }, { esc }) + '</h2><div class="start-opts start-mode" role="radiogroup" aria-label="Permissions">' +
|
|
237
|
-
START_MODES.map((x) => opt({ k: 'mode', v: x.v, on: x.v === mo.v, esc, label: esc(x.label), sub: esc(x.sub) })).join('') +
|
|
295
|
+
START_MODES.map((x) => opt({ k: 'mode', v: x.v, on: x.v === mo.v, esc, label: esc(x.label), sub: esc(codex ? CODEX_MODE_SUBS[x.v].sub : x.sub) })).join('') +
|
|
238
296
|
'</div>' + helpMoreHtml({ id: 'start-mode-help', text: moreText, open: !!ui.helpOpen, cls: 'start-hint start-mode-hint' }, { esc }) + '</section>'
|
|
239
297
|
// First message
|
|
240
298
|
h += '<section class="start-sec"><h2 class="start-h" id="start-msg-h">First message</h2><div class="start-box">' +
|
|
241
|
-
composerBoxHtml({ submit: 'start', sendAction: 'start-send', draftKey: 'start', draft, ...(Array.isArray(atts) ? { attach: true, atts, chipHtml, sid: START_SID } : {}), may: rule.ok && !ui.ask, busy, placeholder: 'What should Claude do?', label: 'First message', inputClass: 'start-text', keys: 'Enter Meta+Enter Control+Enter' }) +
|
|
299
|
+
composerBoxHtml({ submit: 'start', sendAction: 'start-send', draftKey: 'start', draft, ...(Array.isArray(atts) && !codex ? { attach: true, atts, chipHtml, sid: START_SID } : {}), may: rule.ok && !(ui.ask && ui.ask.state !== 'starting'), busy, placeholder: codex ? 'What should Codex do? (or leave it empty)' : 'What should Claude do?', label: 'First message', inputClass: 'start-text', keys: 'Enter Meta+Enter Control+Enter' }) +
|
|
242
300
|
'</div><p class="start-rule' + (rule.ok ? '' : ' bad') + '" role="status">' + esc(rule.text) + '</p>'
|
|
243
301
|
const line = startAskLine(ui.ask, { clock })
|
|
244
302
|
if (line) h += '<div class="start-ask ' + line.tone + '" role="status">' + esc(line.text) + (ui.ask && ['declined', 'expired', 'failed'].includes(ui.ask.state) ? ' <button type="button" class="btn" data-action="start-again">Try again</button>' : '') + '</div>'
|
|
@@ -458,20 +516,37 @@ export function headlessBarHtml(s, { esc, icon, may = true, busy = null, local =
|
|
|
458
516
|
if (!h) return ''
|
|
459
517
|
const from = s.startedFrom && s.startedFrom.device ? ' from ' + s.startedFrom.device : ''
|
|
460
518
|
const where = s.machine ? ' on ' + s.machine : ''
|
|
519
|
+
const agent = h.agent === 'codex' || s.codingAgent === 'codex' ? 'Codex' : 'Claude Code'
|
|
520
|
+
// 1.0 the same conversation in a terminal: Leftoff's own (a terminal app attaches to it on the Mac itself)
|
|
521
|
+
const cont = may && h.state !== 'stopping' && h.state !== 'resuming' ? continueButtonHtml(s, 'board', { esc }) : ''
|
|
461
522
|
let text, btn = ''
|
|
462
|
-
if (h.state === 'running') { text = 'Running' + where + ', started' + from + ' by Leftoff' + (h.awake ? ' · keeping it awake' : ''); if (may) btn = '<button type="button" class="btn" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' }
|
|
523
|
+
if (h.state === 'running') { text = 'Running' + where + ', started' + from + ' by Leftoff' + (h.awake ? ' · keeping it awake' : ''); if (may) btn = cont + '<button type="button" class="btn" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' }
|
|
463
524
|
else if (h.state === 'stopping') text = 'Stopping' + where + '…'
|
|
464
|
-
else if (h.state === 'resuming') text = 'Starting
|
|
525
|
+
else if (h.state === 'resuming') text = 'Starting ' + agent + where + '…' // 0.9.9: Resume on its way
|
|
465
526
|
else {
|
|
466
527
|
// 0.9.13: Paused (its process went: a restart, an exit) is still open; a message resumes it, as does Stopped
|
|
467
528
|
text = (h.error ? 'Claude Code stopped' + where : h.state === 'paused' ? 'Paused' + where + (h.pausedWhy && h.pausedWhy !== 'restart' ? '' : ' (Leftoff restarted)') : 'Stopped' + where) + (from ? ', started' + from : '') + ' · a message picks it up'
|
|
468
|
-
if (may) btn = '<button type="button" class="btn primary" data-action="headless-run" data-id="' + esc(s.id) + '">' + icon('play') + 'Resume here</button>'
|
|
529
|
+
if (may) btn = cont + '<button type="button" class="btn primary" data-action="headless-run" data-id="' + esc(s.id) + '">' + icon('play') + 'Resume here</button>'
|
|
469
530
|
}
|
|
470
531
|
return '<div class="headless-bar ' + esc(h.state) + '" data-sid="' + esc(s.id) + '"><span class="grow">' + icon('play') + esc(text) + '</span>' + btn + '</div>' +
|
|
471
532
|
(h.error && h.state !== 'running' ? '<pre class="headless-err" data-headless-err="' + esc(s.id) + '"></pre>' : '') +
|
|
472
533
|
(h.state !== 'stopping' && h.state !== 'resuming' ? headlessModeHtml(s, { esc, may, busy, local, helpOpen }) : '')
|
|
473
534
|
}
|
|
474
535
|
|
|
536
|
+
// 1.0 Continue in terminal / Continue headless: one button, the same conversation the other way
|
|
537
|
+
export function continueButtonHtml(s, run, { esc }) {
|
|
538
|
+
const label = run === 'headless' ? 'Continue headless' : 'Continue in terminal'
|
|
539
|
+
const tip = run === 'headless' ? 'Ends it in the terminal, then picks the same conversation up headless (Live). Only when it’s idle.' : 'Ends the headless process, then picks the same conversation up in a terminal of Leftoff’s. Only when it’s idle.'
|
|
540
|
+
return '<button type="button" class="btn run-continue" data-action="run-continue" data-run="' + esc(run) + '" data-id="' + esc(s.id) + '" data-tip="' + esc(tip) + '"><span class="run-glyph" aria-hidden="true">' + (run === 'headless' ? '◇' : '▭') + '</span>' + esc(label) + '</button>'
|
|
541
|
+
}
|
|
542
|
+
// The same for a session in Leftoff's own terminal (not headless): where it runs, and the way to headless
|
|
543
|
+
export function termBarHtml(s, { esc, may = true } = {}) {
|
|
544
|
+
const t = s && s.terminal
|
|
545
|
+
if (!s || s.headless || s.endedAt || !t || t.kind !== 'leftoff' || !t.termId) return ''
|
|
546
|
+
const where = t.apps > 0 ? 'In a terminal app' : t.detached ? 'Running · detached' : 'In Leftoff’s terminal'
|
|
547
|
+
return '<div class="headless-bar term-bar" data-sid="' + esc(s.id) + '"><span class="grow"><span class="run-glyph" aria-hidden="true">' + (t.apps > 0 ? '▣' : t.detached ? '◌' : '▭') + '</span>' + esc(where + (s.machine ? ' on ' + s.machine : '')) + '</span>' + (may ? continueButtonHtml(s, 'headless', { esc }) : '') + '</div>'
|
|
548
|
+
}
|
|
549
|
+
|
|
475
550
|
// The session page's Permissions switch for a session Leftoff runs: the same
|
|
476
551
|
// three as New session. Running, it switches Claude Code mid-session; stopped,
|
|
477
552
|
// it's the mode Resume starts it in.
|