@leftoff/cli 0.9.9-beta.3 → 0.9.9-beta.5
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 +4 -0
- package/bin/leftoff.js +421 -421
- package/docs/SECURITY.md +4 -2
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +2 -2
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +57 -2
- package/ui/app.js +50 -3
- package/ui/index.html +1 -0
- package/ui/start.js +83 -10
package/docs/SECURITY.md
CHANGED
|
@@ -111,9 +111,11 @@ Live link is opt-in, per session: only sessions started with `leftoff live` have
|
|
|
111
111
|
**New session** starts Claude Code on a Mac you pick, headless (`claude -p` with stream-json in and out, run by that Mac's daemon). That is more than answering a prompt (a new agent in your repo, on your Claude plan), so:
|
|
112
112
|
|
|
113
113
|
- **This Mac's own board** starts it at once. **A phone or another Mac** may start one only when that Mac has *Allow starting sessions from other devices* on (Settings → Privacy & security, off by default) **and** *Trust my paired devices* is on. The switch is kept on that Mac (`~/.config/checkpoints/local/`, not the synced folder, so turning it on one Mac never turns it on another) and only that Mac's own browser can change it (loopback; a phone, the relay or a peer gets 403). Every start from another device shows a notification on the Mac. With the switch off, the request waits up to 5 minutes for a yes on that Mac's board, like Remote Restore. At most 3 wait at once; 3 starts at once, then 1 a minute.
|
|
114
|
-
- **
|
|
114
|
+
- **The repo list is folders that Mac has already run Claude Code in** (from its session records; no typed paths). A headless run loads a repo's hooks and `.mcp.json` without the trust dialog, so a folder Claude Code never ran in is treated as a first time:
|
|
115
|
+
- **Other folder…** browses that Mac's folders: names only (never a file or what's in one), inside your home by real path (a symlink out of home is refused or left out), never the synced Leftoff folder or anything in it, no hidden folders, `node_modules` or `Library`, at most 500 per listing. Another Mac's are read over the peer link (`fs.dirs`, not an open action, the same as any read), a phone's through the relay. Nothing is written.
|
|
116
|
+
- **A first time starts at once only from that Mac's own board** (loopback, or your hosted board open on it): you are the trust prompt. From a phone or another Mac it **always** waits for a yes on that Mac's board, even with *Allow starting sessions from other devices* on, and the ask and the notification say "first time in <folder>; this runs its hooks". After one run it's in the list like the rest. Home itself is refused; a folder that isn't a git repo can't get a new worktree.
|
|
115
117
|
- **Permission mode `default`, always passed.** Reads in the repo run; every edit and command becomes a Needs-you request (here, the phone, any board; first answer wins) and waits for it, with no 9.5-minute cap since nobody is at a terminal. A request settled any other way is a no. Never `bypassPermissions` or `auto`. AskUserQuestion is answered "ask in words", so Claude asks in plain text and you reply in Live.
|
|
116
|
-
- **Other Macs** reach it over the signed, encrypted peer link (`session.start`, `session.start.get`, `session.headless`; not open actions, so the target Mac's *Allow other machines* applies too).
|
|
118
|
+
- **Other Macs** reach it over the signed, encrypted peer link (`session.start`, `session.start.get`, `session.headless`, `fs.dirs`; not open actions, so the target Mac's *Allow other machines* applies too).
|
|
117
119
|
- **Nothing new in the folder.** The card, its title and checkpoints as for any session, plus `startedFrom` (device and how it came, not the prompt) and the run's state. Stream output, stdin and stderr stay in the daemon's memory; Claude's transcript stays in `~/.claude` on that Mac; the Live thread and attachments stay where Live keeps them (`~/.config/checkpoints`).
|
|
118
120
|
- **Keeping the Mac awake.** While a turn runs, the daemon holds `caffeinate -i -w <pid>` (idle sleep waits; a closed lid on battery still sleeps). Stop interrupts the turn, closes stdin, then SIGINT, then SIGTERM. A daemon that stops pauses its sessions.
|
|
119
121
|
- **Logged:** one daemon-log line per start, stop and permission request (session id and tool, never prompt text).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.9-beta.
|
|
3
|
+
"version": "0.9.9-beta.5",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Joel"
|
package/mod/hooks/core.js
CHANGED
|
@@ -914,8 +914,8 @@ export function applyEvent(s, ev) {
|
|
|
914
914
|
s.inTurn = false
|
|
915
915
|
s.activeSince = null
|
|
916
916
|
s.needsInput = false
|
|
917
|
-
if (d.countTurn !== false) s.turns = (s.turns || 0) + 1 // false: Codex already counted it at the start
|
|
918
|
-
s.turnsSinceCheckpoint = (s.turnsSinceCheckpoint || 0) + 1
|
|
917
|
+
if (d.countTurn !== false && !d.again) s.turns = (s.turns || 0) + 1 // false: Codex already counted it at the start
|
|
918
|
+
if (!d.again) s.turnsSinceCheckpoint = (s.turnsSinceCheckpoint || 0) + 1 // again (0.9.9): a late report of a turn already ended and counted
|
|
919
919
|
s.backgroundTasks = Number(d.backgroundTasks || 0) + Number(d.sessionCrons || 0)
|
|
920
920
|
if (d.lastAssistantPreview) s.lastPreview = clip(d.lastAssistantPreview, 280)
|
|
921
921
|
// Claude ended the turn on a question to you (v0.4.4): the board offers a reply.
|
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.9-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.9-beta.5' // 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.9-beta.
|
|
3
|
+
"version": "0.9.9-beta.5",
|
|
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
|
@@ -2869,7 +2869,6 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
2869
2869
|
.start-ask { padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); font-size: 13.5px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
2870
2870
|
.start-ask.wait { border-color: var(--yellow); background: var(--tint-yellow); }
|
|
2871
2871
|
.start-ask.bad { border-color: var(--red); background: var(--tint-red); }
|
|
2872
|
-
.btn.start-new { min-height: 36px; }
|
|
2873
2872
|
.headless-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 4px 0 10px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); font-size: 13px; color: var(--text2); }
|
|
2874
2873
|
.headless-bar .grow { flex: 1 1 200px; display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
|
|
2875
2874
|
.headless-bar.running { border-color: var(--green); background: var(--tint-green); }
|
|
@@ -2880,6 +2879,62 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
2880
2879
|
.start-opts { grid-template-columns: 1fr; }
|
|
2881
2880
|
.start-where { grid-template-columns: 1fr 1fr; }
|
|
2882
2881
|
.start-opt { min-height: 48px; }
|
|
2883
|
-
.btn.start-new { min-height: 44px; }
|
|
2884
2882
|
.start-ask-banner .btn, .start-ask .btn { min-height: 44px; }
|
|
2885
2883
|
}
|
|
2884
|
+
|
|
2885
|
+
/* 0.9.9 New session: a + in the top bar on a phone (next to search); on a
|
|
2886
|
+
desktop the button in the board's header row, like the buttons beside it */
|
|
2887
|
+
.topright .newbtn { display: none; }
|
|
2888
|
+
.newbtn .lbl { display: none; }
|
|
2889
|
+
.newbtn[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); right: 0; width: max-content; padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font-size: 12px; line-height: 1.3; font-weight: 500; white-space: nowrap; pointer-events: none; z-index: 20; }
|
|
2890
|
+
@media (hover: hover) { .newbtn[data-tip]:hover::after { display: block; } }
|
|
2891
|
+
@media (max-width: 640px) {
|
|
2892
|
+
.topright .newbtn { display: grid; place-items: center; width: 44px; height: 44px; }
|
|
2893
|
+
.pagehead .actions .start-new { display: none; }
|
|
2894
|
+
}
|
|
2895
|
+
/* A request banner on a phone (start or Restore asked from another device,
|
|
2896
|
+
the restore banner): the words use the whole width, the buttons a row of
|
|
2897
|
+
their own below, 44px tall */
|
|
2898
|
+
@media (max-width: 640px) {
|
|
2899
|
+
.start-ask-banner > .grow, .restore-ask > .grow, .banner.restore:not(.open) > .grow { flex: 1 1 100%; }
|
|
2900
|
+
.start-ask-banner > .btn, .restore-ask > .btn, .banner.restore:not(.open) > .btn:not(.x) { flex: 1 1 0; justify-content: center; min-height: 44px; }
|
|
2901
|
+
.restore-ask > .rs-m { flex: 1 1 100%; }
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
/* 0.9.9 Other folder…: the inline folder browser (full width; no popover) */
|
|
2905
|
+
.start-other .start-opt-main { display: inline-flex; align-items: center; gap: 6px; }
|
|
2906
|
+
.start-other .ic { width: 16px; height: 16px; color: var(--muted); }
|
|
2907
|
+
.start-other { border-style: dashed; }
|
|
2908
|
+
.start-badge { display: inline-block; margin-left: 4px; padding: 0 6px; border-radius: 6px; border: 1px solid var(--line); font-size: 11px; font-weight: 600; line-height: 18px; color: var(--muted); background: var(--bg2); vertical-align: 1px; }
|
|
2909
|
+
.start-badge.git { color: var(--green); border-color: color-mix(in srgb, var(--green) 40%, var(--line)); }
|
|
2910
|
+
.start-badge.new { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 50%, var(--line)); }
|
|
2911
|
+
.start-first { color: var(--text); }
|
|
2912
|
+
.start-browse { display: flex; flex-direction: column; gap: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--bg2); min-width: 0; }
|
|
2913
|
+
.sb-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-width: 0; }
|
|
2914
|
+
.sb-crumb { min-height: 44px; min-width: 44px; padding: 0 8px; border: 0; border-radius: 8px; background: none; color: var(--accent); font: inherit; font-size: 14px; cursor: pointer; overflow-wrap: anywhere; text-align: left; }
|
|
2915
|
+
.sb-crumb[aria-current] { color: var(--text); font-weight: 600; cursor: default; }
|
|
2916
|
+
.sb-crumb:hover:not([aria-current]) { background: var(--card); }
|
|
2917
|
+
.sb-sep { color: var(--faint); }
|
|
2918
|
+
.sb-here { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
2919
|
+
.sb-here .btn { min-height: 44px; }
|
|
2920
|
+
.sb-cur { flex: 1 1 160px; min-width: 0; font-size: 13px; color: var(--text2); overflow-wrap: anywhere; }
|
|
2921
|
+
.sb-cur-name { font-family: var(--mono); font-size: 12.5px; }
|
|
2922
|
+
.sb-cuts { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
2923
|
+
.sb-cuts-l { font-size: 12px; color: var(--muted); }
|
|
2924
|
+
.sb-cut { min-height: 44px; min-width: 44px; padding: 0 12px; border: 1px solid var(--line); border-radius: 22px; background: var(--card); color: var(--text2); font: inherit; font-family: var(--mono); font-size: 12.5px; cursor: pointer; }
|
|
2925
|
+
.sb-cut:hover { border-color: var(--line-strong); }
|
|
2926
|
+
.sb-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 6px; max-height: 360px; overflow: auto; }
|
|
2927
|
+
.sb-dir { width: 100%; min-height: 44px; display: flex; align-items: center; gap: 8px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--card); color: var(--text); font: inherit; font-size: 14px; text-align: left; cursor: pointer; min-width: 0; }
|
|
2928
|
+
.sb-dir:hover { border-color: var(--line-strong); }
|
|
2929
|
+
.sb-dir .ic { width: 16px; height: 16px; flex: none; color: var(--muted); }
|
|
2930
|
+
.sb-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2931
|
+
.sb-dir .sb-go { color: var(--faint); }
|
|
2932
|
+
.sb-foot { display: flex; justify-content: flex-end; }
|
|
2933
|
+
.sb-foot .btn { min-height: 44px; }
|
|
2934
|
+
.sb-crumb:focus-visible, .sb-cut:focus-visible, .sb-dir:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
2935
|
+
.start-custom .start-opt-main { white-space: normal; }
|
|
2936
|
+
@media (max-width: 640px) {
|
|
2937
|
+
.start-browse { padding: 10px; margin-left: -4px; margin-right: -4px; }
|
|
2938
|
+
.sb-list { grid-template-columns: 1fr; max-height: none; overflow: visible; }
|
|
2939
|
+
.sb-here .sb-use { flex: 1 1 100%; justify-content: center; }
|
|
2940
|
+
}
|
package/ui/app.js
CHANGED
|
@@ -16,7 +16,7 @@ import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
|
|
|
16
16
|
import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
|
|
17
17
|
import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (pdf.js loads on the first PDF)
|
|
18
18
|
import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml } from './composer.js' // 0.9.8 the Live composer
|
|
19
|
-
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, startViewerLocal as startViewerAt } from './start.js' // 0.9.9 New session
|
|
19
|
+
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget } from './start.js' // 0.9.9 New session
|
|
20
20
|
import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
|
|
21
21
|
import { wireTips } from './tips.js'
|
|
22
22
|
import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
|
|
@@ -945,6 +945,7 @@ function boardSearchWire() {
|
|
|
945
945
|
try { history.replaceState(null, '', hashFor(state.boardFilter || { repos: [], status: null })) } catch {}
|
|
946
946
|
state.route = parseRoute()
|
|
947
947
|
await viewBoard() // the box isn't rebuilt, so the caret stays wherever you've typed to since
|
|
948
|
+
fillStartTexts($app, startTexts(state.starts, {}))
|
|
948
949
|
}, 160)
|
|
949
950
|
})
|
|
950
951
|
q.addEventListener('keydown', (ev) => {
|
|
@@ -4155,6 +4156,7 @@ async function render() {
|
|
|
4155
4156
|
else await viewBoard()
|
|
4156
4157
|
tabsAfterRender()
|
|
4157
4158
|
fillHeadlessErrors()
|
|
4159
|
+
if (r.name !== 'new') fillStartTexts($app, startTexts(state.starts, {})) // 0.9.9: a waiting start's folder, as text
|
|
4158
4160
|
// a Needs-you Reply composer (0.9.8) fits a restored multi-line draft
|
|
4159
4161
|
for (const ta of $app.querySelectorAll('.reply-composer .composer-input')) fitComposer(ta)
|
|
4160
4162
|
} catch (e) {
|
|
@@ -4167,15 +4169,31 @@ async function render() {
|
|
|
4167
4169
|
|
|
4168
4170
|
// this Mac's own browser, or the hosted board open on it (the direct door)
|
|
4169
4171
|
function startViewerLocal(v) { return startViewerAt(v, state.viewerLocal !== false) }
|
|
4170
|
-
const startUi = { data: null, machine: null, repo: null, where: 'repo', allRepos: false, ask: null, busy: new Set(), sending: false, poll: null }
|
|
4172
|
+
const startUi = { data: null, machine: null, repo: null, where: 'repo', allRepos: false, ask: null, busy: new Set(), sending: false, poll: null, browse: null, custom: null }
|
|
4171
4173
|
async function viewNewSession() {
|
|
4172
4174
|
try { startUi.data = await api('GET', '/v1/start') } catch (e) { if (!startUi.data) throw e }
|
|
4173
4175
|
const fa = document.activeElement
|
|
4174
4176
|
const kf = fa && fa.classList && fa.classList.contains('start-text') ? { a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
4175
4177
|
$app.innerHTML = startPageHtml(startUi.data, startUi, { esc, icon, emoji: mEmoji, local: startViewerLocal(startUi.data.viewer), clock, draft: (state.drafts && state.drafts.start) || '', busy: startUi.sending })
|
|
4178
|
+
fillStartTexts($app, startTexts(startUi.data, startUi)) // folder names and paths: text, never markup
|
|
4176
4179
|
const ta = $app.querySelector('.start-text')
|
|
4177
4180
|
if (ta) { fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
|
|
4178
4181
|
}
|
|
4182
|
+
// Other folder…: list one folder on the picked Mac (that Mac's own disk, over the peer link for another Mac)
|
|
4183
|
+
async function startBrowse(machine, p) {
|
|
4184
|
+
const seq = (startUi.browseSeq = (startUi.browseSeq || 0) + 1)
|
|
4185
|
+
startUi.browse = { ...(startUi.browse && startUi.browse.machine === machine ? startUi.browse : {}), machine, loading: true, error: null }
|
|
4186
|
+
if (state.route && state.route.name === 'new') render()
|
|
4187
|
+
try {
|
|
4188
|
+
const d = await api('GET', '/v1/start/dirs?machine=' + encodeURIComponent(machine) + (p ? '&path=' + encodeURIComponent(p) : ''), undefined, { timeoutMs: 20000 })
|
|
4189
|
+
if (seq !== startUi.browseSeq || !startUi.browse) return
|
|
4190
|
+
startUi.browse = { machine, loading: false, error: null, data: d }
|
|
4191
|
+
} catch (e) {
|
|
4192
|
+
if (seq !== startUi.browseSeq || !startUi.browse) return
|
|
4193
|
+
startUi.browse = { ...startUi.browse, loading: false, error: e.message }
|
|
4194
|
+
}
|
|
4195
|
+
if (state.route && state.route.name === 'new') { render(); const f = $app.querySelector('.sb-crumb[aria-current]'); if (f && startUi.browseFocus) f.focus({ preventScroll: true }) }
|
|
4196
|
+
}
|
|
4179
4197
|
// Claude Code's own words when a session it ran stopped with an error: text, never markup.
|
|
4180
4198
|
const headlessErrors = new Map()
|
|
4181
4199
|
function fillHeadlessErrors() {
|
|
@@ -5356,9 +5374,38 @@ document.addEventListener('click', async (ev) => {
|
|
|
5356
5374
|
// 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
|
|
5357
5375
|
if (action === 'start-pick') {
|
|
5358
5376
|
const k = btn.dataset.k, v = btn.dataset.v
|
|
5359
|
-
if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false } else if (k === 'repo') startUi.repo = v; else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'
|
|
5377
|
+
if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null } else if (k === 'repo') startUi.repo = v; else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'
|
|
5360
5378
|
return render()
|
|
5361
5379
|
}
|
|
5380
|
+
// Other folder…: open the browser at ~, walk it, use a folder
|
|
5381
|
+
if (action === 'start-browse') {
|
|
5382
|
+
const pick = startPick(startUi.data, startUi)
|
|
5383
|
+
if (!pick.m) return
|
|
5384
|
+
if (startUi.browse && startUi.browse.machine === pick.m.machine) { startUi.browse = null; return render() }
|
|
5385
|
+
startUi.browseFocus = false
|
|
5386
|
+
return startBrowse(pick.m.machine, null)
|
|
5387
|
+
}
|
|
5388
|
+
if (action === 'start-browse-go' || action === 'start-browse-up') {
|
|
5389
|
+
const b = startUi.browse
|
|
5390
|
+
const to = action === 'start-browse-up' ? (b && b.data && b.data.parent ? { path: b.data.parent } : null) : browseTarget(b, btn)
|
|
5391
|
+
if (!b || !to) return
|
|
5392
|
+
startUi.browseFocus = true
|
|
5393
|
+
return startBrowse(b.machine, to.path)
|
|
5394
|
+
}
|
|
5395
|
+
if (action === 'start-browse-close') { startUi.browse = null; return render() }
|
|
5396
|
+
if (action === 'start-browse-use') {
|
|
5397
|
+
const b = startUi.browse, d = b && b.data
|
|
5398
|
+
if (!d || !d.parent) return
|
|
5399
|
+
const known = startPick(startUi.data, { ...startUi, machine: b.machine }).m
|
|
5400
|
+
if (d.ran || (known && known.repos.some((r) => r.path === d.path))) startUi.custom = null
|
|
5401
|
+
else startUi.custom = { machine: b.machine, path: d.path, name: d.crumbs[d.crumbs.length - 1].name, label: d.label, git: !!d.git }
|
|
5402
|
+
startUi.repo = d.path
|
|
5403
|
+
if (!d.git) startUi.where = 'repo'
|
|
5404
|
+
startUi.browse = null
|
|
5405
|
+
render()
|
|
5406
|
+
const ta = $app.querySelector('.start-text'); if (ta && !ta.disabled) ta.focus({ preventScroll: true })
|
|
5407
|
+
return
|
|
5408
|
+
}
|
|
5362
5409
|
if (action === 'start-all-repos') { startUi.allRepos = true; return render() }
|
|
5363
5410
|
if (action === 'start-again') { startUi.ask = null; return render() }
|
|
5364
5411
|
if (action === 'start-send') return startSend(btn)
|
package/ui/index.html
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
</nav>
|
|
37
37
|
<div class="topright">
|
|
38
38
|
<button type="button" class="searchbtn" id="searchBtn" title="Search (⌘K)" aria-label="Search" aria-haspopup="dialog" aria-keyshortcuts="Meta+K Control+K /"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="8"/> <path d="m21 21-4.3-4.3"/></svg><span class="sb-lbl">Search</span><kbd class="sb-kbd" id="searchKbd">⌘K</kbd></button>
|
|
39
|
+
<a href="#/new" data-route="new" id="newTab" class="iconbtn newbtn" data-tip="New session" aria-label="New session"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12h14"/> <path d="M12 5v14"/></svg><span class="lbl">New session</span></a>
|
|
39
40
|
<button type="button" class="health" id="health" title="Status" aria-haspopup="dialog" aria-expanded="false" aria-controls="statusPop"><span class="dot"></span><span class="label">connecting…</span></button>
|
|
40
41
|
<a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn" title="What’s new" aria-label="What’s new"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span class="lbl">What’s new</span><span class="newdot" hidden></span></a>
|
|
41
42
|
<a href="#/settings" data-route="settings" id="setTab" class="iconbtn" title="Settings" aria-label="Settings"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span class="lbl">Settings</span><span class="topdot" hidden></span></a>
|
package/ui/start.js
CHANGED
|
@@ -21,8 +21,11 @@ export function startPick(data, ui = {}) {
|
|
|
21
21
|
const ms = (data && data.machines) || []
|
|
22
22
|
const m = ms.find((x) => x.machine === ui.machine && x.online) || ms.find((x) => x.here && x.online && x.repos.length) || ms.find((x) => x.online && x.repos.length) || ms.find((x) => x.here) || ms[0] || null
|
|
23
23
|
const repos = m ? m.repos : []
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Other folder…: a folder that Mac hasn't run Claude Code in (ui.custom), picked in the browser
|
|
25
|
+
const custom = m && ui.custom && ui.custom.machine === m.machine && ui.custom.path === ui.repo && !repos.some((r) => r.path === ui.custom.path) ? { ...ui.custom, isNew: true } : null
|
|
26
|
+
const repo = repos.find((r) => r.path === ui.repo) || custom || repos[0] || null
|
|
27
|
+
const where = ui.where === 'worktree' && !(repo && repo.git === false) ? 'worktree' : 'repo'
|
|
28
|
+
return { m, repo, where }
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
// What happens when you press ↑, in one sentence (or why you can't).
|
|
@@ -31,7 +34,10 @@ export function startRule(data, pick, { local = true } = {}) {
|
|
|
31
34
|
if (!m) return { ok: false, text: 'No Mac to start on.' }
|
|
32
35
|
if (!local && !data.trust) return { ok: false, text: 'Turn on Trust my paired devices on a Mac first (Settings → Privacy & security), so you can answer its prompts from here.' }
|
|
33
36
|
if (!m.online) return { ok: false, text: m.machine + ' is offline right now.' }
|
|
34
|
-
if (!pick.repo) return { ok: false, text: m.machine + ' hasn’t run Claude Code in a repo yet.
|
|
37
|
+
if (!pick.repo) return { ok: false, text: m.machine + ' hasn’t run Claude Code in a repo yet. Pick Other folder…, or open Claude Code there once and it shows up here.' }
|
|
38
|
+
// a folder that Mac hasn't run Claude Code in: its hooks run, so only a person at that Mac says yes
|
|
39
|
+
if (pick.repo.isNew && m.here && local) return { ok: true, text: 'First time Claude Code runs in this folder on this Mac: it starts right away and runs the folder’s hooks and MCP servers.' }
|
|
40
|
+
if (pick.repo.isNew) return { ok: true, text: 'First time in this folder on ' + m.machine + ', so someone there confirms it first (it runs the folder’s hooks), even with “Allow starting sessions from other devices” on.' }
|
|
35
41
|
if (m.here && local) return { ok: true, text: 'It starts right away on this Mac.' }
|
|
36
42
|
if (m.here && m.allowRemote === true) return { ok: true, text: 'It starts right away, and ' + m.machine + ' shows a notification.' }
|
|
37
43
|
if (m.here) return { ok: true, text: 'Someone at ' + m.machine + ' confirms it first (up to 5 minutes). To skip that, turn on “Allow starting sessions from other devices” in its Settings.' }
|
|
@@ -54,7 +60,7 @@ function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
|
|
|
54
60
|
// The waiting line after a start that has to be confirmed at the Mac.
|
|
55
61
|
export function startAskLine(ask, { clock = (x) => x } = {}) {
|
|
56
62
|
if (!ask) return null
|
|
57
|
-
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.' }
|
|
63
|
+
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 always asks there.' : '') }
|
|
58
64
|
if (ask.state === 'starting') return { tone: 'wait', text: 'Starting on ' + ask.machine + '…' }
|
|
59
65
|
if (ask.state === 'declined') return { tone: 'bad', text: 'Declined on ' + ask.machine + '.' }
|
|
60
66
|
if (ask.state === 'expired') return { tone: 'bad', text: 'Nobody confirmed it on ' + ask.machine + ' in time.' }
|
|
@@ -78,19 +84,25 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
|
|
|
78
84
|
const repos = pick.m ? pick.m.repos : []
|
|
79
85
|
const all = !!ui.allRepos || repos.length <= REPOS_SHOWN + 1
|
|
80
86
|
const shown = all ? repos : repos.slice(0, REPOS_SHOWN).concat(pick.repo && repos.indexOf(pick.repo) >= REPOS_SHOWN ? [pick.repo] : [])
|
|
87
|
+
const custom = pick.repo && pick.repo.isNew ? pick.repo : null
|
|
88
|
+
const browsing = !!(pick.m && ui.browse && ui.browse.machine === pick.m.machine)
|
|
81
89
|
h += '<section class="start-sec"><h2 class="start-h">Repo</h2>'
|
|
82
|
-
if (!repos.length) h += '<p class="start-empty">' + esc(pick.m ? pick.m.machine + ' hasn’t run Claude Code in a repo yet.' : 'No Mac yet.') + '</p>'
|
|
83
|
-
|
|
90
|
+
if (!repos.length && !custom) h += '<p class="start-empty">' + esc(pick.m ? pick.m.machine + ' hasn’t run Claude Code in a repo yet.' : 'No Mac yet.') + '</p>'
|
|
91
|
+
if (pick.m) {
|
|
84
92
|
h += '<div class="start-opts" role="radiogroup" aria-label="Repo">' + shown.map((r) => opt({ k: 'repo', v: r.path, on: !!pick.repo && r.path === pick.repo.path, esc,
|
|
85
|
-
label: esc(r.name) + (r.branch ? ' <span class="start-branch">' + esc(r.branch) + '</span>' : ''), sub: esc(r.path.replace(/^\/Users\/[^/]+/, '~')) + (r.lastAt ? ' · ' + esc(ago(r.lastAt, now)) : '') })).join('') +
|
|
93
|
+
label: esc(r.name) + (r.branch ? ' <span class="start-branch">' + esc(r.branch) + '</span>' : ''), sub: esc(r.path.replace(/^\/Users\/[^/]+/, '~')) + (r.lastAt ? ' · ' + esc(ago(r.lastAt, now)) : '') + (r.git === false ? ' · not a git repo' : '') })).join('') +
|
|
94
|
+
// the folder picked with Other folder… (its name and path go in as text: [data-st], startTexts)
|
|
95
|
+
(custom ? '<button type="button" class="start-opt start-custom" role="radio" aria-checked="true" data-action="start-pick" data-k="repo" data-v="' + esc(custom.path) + '"><span class="start-opt-main"><span data-st="custom-name"></span> <span class="start-badge new">first time here</span>' + (custom.git ? '' : ' <span class="start-badge">not a git repo</span>') + '</span><span class="start-opt-sub" data-st="custom-path"></span></button>' : '') +
|
|
96
|
+
(pick.m.online ? '<button type="button" class="start-opt start-other" data-action="start-browse" aria-expanded="' + browsing + '"' + (browsing ? ' aria-controls="start-browse"' : '') + '><span class="start-opt-main">' + icon('folder') + 'Other folder…</span><span class="start-opt-sub">Browse ' + esc(pick.m.machine) + '’s folders</span></button>' : '') + '</div>'
|
|
86
97
|
if (!all) h += '<button type="button" class="btn start-more" data-action="start-all-repos">Show all ' + repos.length + '</button>'
|
|
87
|
-
h +=
|
|
98
|
+
if (browsing) h += startBrowseHtml(ui.browse, { esc, icon, machine: pick.m.machine })
|
|
99
|
+
if (repos.length) h += '<p class="start-hint">Listed: repos ' + esc(pick.m.machine) + ' has already run Claude Code in. Other folder… picks any folder in your home there; the first time, ' + (pick.m.here && local ? 'starting it is your yes to its hooks.' : 'someone at ' + esc(pick.m.machine) + ' says yes first.') + '</p>'
|
|
88
100
|
}
|
|
89
101
|
h += '</section>'
|
|
90
102
|
// Where
|
|
91
103
|
h += '<section class="start-sec"><h2 class="start-h">Where</h2><div class="start-opts start-where" role="radiogroup" aria-label="Where">' +
|
|
92
104
|
opt({ k: 'where', v: 'repo', on: pick.where === 'repo', esc, label: 'In the repo', sub: 'on its current branch' }) +
|
|
93
|
-
opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: 'its own branch and folder' }) +
|
|
105
|
+
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) }) +
|
|
94
106
|
'</div></section>'
|
|
95
107
|
// First message
|
|
96
108
|
h += '<section class="start-sec"><h2 class="start-h" id="start-msg-h">First message</h2><div class="start-box">' +
|
|
@@ -103,6 +115,65 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
|
|
|
103
115
|
return h
|
|
104
116
|
}
|
|
105
117
|
|
|
118
|
+
// Other folder…: the inline folder browser for one Mac (no popover; full width
|
|
119
|
+
// on a phone). Folder names and paths come from that Mac's disk, so they go in
|
|
120
|
+
// as text afterwards ([data-st], startTexts / fillStartTexts), never as markup.
|
|
121
|
+
// b: { machine, loading, error, data: the GET /v1/start/dirs answer }
|
|
122
|
+
export function startBrowseHtml(b, { esc, icon = () => '', machine = '' } = {}) {
|
|
123
|
+
let h = '<div class="start-browse" id="start-browse" role="region" aria-label="Folders on ' + esc(machine) + '">'
|
|
124
|
+
const d = b && b.data
|
|
125
|
+
if (d) {
|
|
126
|
+
h += '<nav class="sb-crumbs" aria-label="Folder">' + d.crumbs.map((c, i) => (i ? '<span class="sb-sep" aria-hidden="true">/</span>' : '') +
|
|
127
|
+
'<button type="button" class="sb-crumb" data-action="start-browse-go" data-crumb="' + i + '"' + (i === d.crumbs.length - 1 ? ' aria-current="location"' : '') + ' data-st="crumb-' + i + '"></button>').join('') + '</nav>'
|
|
128
|
+
h += '<div class="sb-here">' +
|
|
129
|
+
(d.parent ? '<button type="button" class="btn sb-up" data-action="start-browse-up" aria-label="Up one folder">' + icon('chevron-left') + 'Up</button>' : '') +
|
|
130
|
+
'<span class="sb-cur"><span class="sb-cur-name" data-st="cur"></span>' + (d.git ? ' <span class="start-badge git">git</span>' : ' <span class="start-badge">not a git repo</span>') + '</span>' +
|
|
131
|
+
'<button type="button" class="btn primary sb-use" data-action="start-browse-use"' + (d.parent ? '' : ' disabled') + '>Use this folder</button></div>'
|
|
132
|
+
if (d.shortcuts && d.shortcuts.length > 1) h += '<div class="sb-cuts"><span class="sb-cuts-l">Jump to</span>' + d.shortcuts.map((c, i) => '<button type="button" class="sb-cut" data-action="start-browse-go" data-cut="' + i + '" data-st="cut-' + i + '"></button>').join('') + '</div>'
|
|
133
|
+
if (!d.entries.length) h += '<p class="start-empty">No folders in here.</p>'
|
|
134
|
+
else h += '<ul class="sb-list">' + d.entries.map((e, i) => '<li><button type="button" class="sb-dir" data-action="start-browse-go" data-entry="' + i + '">' + icon('folder') + '<span class="sb-name" data-st="entry-' + i + '"></span>' + (e.git ? '<span class="start-badge git">git</span>' : '') + icon('chevron-right', 'ic sb-go') + '</button></li>').join('') + '</ul>'
|
|
135
|
+
if (d.capped) h += '<p class="start-hint">Showing the first ' + esc(String(d.entries.length)) + ' of ' + esc(String(d.total)) + ' folders.</p>'
|
|
136
|
+
}
|
|
137
|
+
if (b && b.loading) h += '<p class="start-hint" role="status">Loading ' + esc(machine) + '’s folders…</p>'
|
|
138
|
+
if (b && b.error) h += '<p class="start-rule bad" role="alert">' + esc(b.error) + '</p>'
|
|
139
|
+
h += '<p class="start-hint">Folders only, by name: hidden ones, node_modules and Library are left out, and so is the synced Leftoff folder.</p>' +
|
|
140
|
+
'<div class="sb-foot"><button type="button" class="btn" data-action="start-browse-close">Cancel</button></div></div>'
|
|
141
|
+
return h
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The text for every [data-st] on the page: names and paths from a Mac's disk.
|
|
145
|
+
export function startTexts(data, ui = {}) {
|
|
146
|
+
const t = {}
|
|
147
|
+
const pick = startPick(data, ui)
|
|
148
|
+
if (pick.repo && pick.repo.isNew) { t['custom-name'] = pick.repo.name || ''; t['custom-path'] = pick.repo.label || pick.repo.path || '' }
|
|
149
|
+
const d = ui.browse && ui.browse.data
|
|
150
|
+
if (d) {
|
|
151
|
+
t.cur = d.label || d.path || ''
|
|
152
|
+
d.crumbs.forEach((c, i) => { t['crumb-' + i] = c.name })
|
|
153
|
+
;(d.shortcuts || []).forEach((c, i) => { t['cut-' + i] = c.label })
|
|
154
|
+
d.entries.forEach((e, i) => { t['entry-' + i] = e.name })
|
|
155
|
+
}
|
|
156
|
+
for (const a of (data && Array.isArray(data.pending) ? data.pending : [])) if (a.firstTime) t['ask-folder-' + a.id] = a.folder || ''
|
|
157
|
+
return t
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function fillStartTexts(root, texts) {
|
|
161
|
+
if (!root || !root.querySelectorAll) return 0
|
|
162
|
+
let n = 0
|
|
163
|
+
for (const el of root.querySelectorAll('[data-st]')) { el.textContent = texts[el.getAttribute('data-st')] || ''; n++ }
|
|
164
|
+
return n
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The folder a browser click means: { path } from a crumb, a shortcut or an entry.
|
|
168
|
+
export function browseTarget(b, el) {
|
|
169
|
+
const d = b && b.data
|
|
170
|
+
if (!d || !el || !el.dataset) return null
|
|
171
|
+
if (el.dataset.crumb != null) return d.crumbs[Number(el.dataset.crumb)] || null
|
|
172
|
+
if (el.dataset.cut != null) return (d.shortcuts || [])[Number(el.dataset.cut)] || null
|
|
173
|
+
if (el.dataset.entry != null) return d.entries[Number(el.dataset.entry)] || null
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
|
|
106
177
|
// A request waiting on THIS Mac: its board shows it with Start and Decline
|
|
107
178
|
// (only this Mac's own browser may answer).
|
|
108
179
|
export function startAsksBanner(info, { esc, icon, local = true, clock = (x) => x, busy = new Set() } = {}) {
|
|
@@ -110,7 +181,9 @@ export function startAsksBanner(info, { esc, icon, local = true, clock = (x) =>
|
|
|
110
181
|
if (!asks.length) return ''
|
|
111
182
|
return asks.map((a) => {
|
|
112
183
|
const b = busy.has(a.id)
|
|
113
|
-
|
|
184
|
+
const where = a.firstTime ? '<span data-st="ask-folder-' + esc(a.id) + '"></span>' : esc(a.repo)
|
|
185
|
+
return '<div class="banner warn start-ask-banner' + (a.firstTime ? ' first' : '') + '" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to start a session</b> in ' + where + (a.where === 'worktree' ? ' (new worktree)' : '') + ' on ' + esc(info.machine || 'this Mac') + ': “' + esc(a.prompt) + '”' +
|
|
186
|
+
(a.firstTime ? ' <b class="start-first">First time in this folder; this runs its hooks.</b>' : '') + ' <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
|
|
114
187
|
(local ? '<button type="button" class="btn primary" data-action="start-ask-confirm" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>' + icon('play') + (b ? 'Starting…' : 'Start it') + '</button>' +
|
|
115
188
|
'<button type="button" class="btn" data-action="start-ask-decline" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>Decline</button>' : '<span class="rs-m">Someone at that Mac confirms it there.</span>') + '</div>'
|
|
116
189
|
}).join('')
|