@leftoff/cli 0.9.9-beta.6 → 0.9.9-beta.7

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.9-beta.6",
3
+ "version": "0.9.9-beta.7",
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
@@ -1509,13 +1509,48 @@ export function fallbackTitle(session) {
1509
1509
  return repoName(session) + (branch ? '@' + branch : '') + (words ? ' · ' + words : '')
1510
1510
  }
1511
1511
 
1512
- export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. Reply with the title only, no quotes, no punctuation at the end.'
1512
+ // 0.9.9: the title is in the language the user writes in (English when unsure),
1513
+ // one language, one script: a small local model once named an English session
1514
+ // "Ants README笑话" ("joke" in Chinese).
1515
+ export const TITLE_LANGUAGE = 'Write it in the same language as the user\'s own messages (English if unsure), in one language only: never mix in words or characters from another language.'
1516
+ export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. ' + TITLE_LANGUAGE + ' Reply with the title only, no quotes, no punctuation at the end.'
1513
1517
 
1514
1518
  export function cleanTitle(text) {
1515
- const t = String(text || '').split('\n').map((l) => l.trim()).find(Boolean) || ''
1519
+ const t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').split('\n').map((l) => l.trim()).find(Boolean) || ''
1516
1520
  return clip(t.replace(/^title\s*:\s*/i, '').replace(/^["'`*]+|["'`*.]+$/g, ''), 60)
1517
1521
  }
1518
1522
 
1523
+ // The writing systems in a text's letters (digits, punctuation and emoji have none).
1524
+ // Chinese and Japanese characters count as one ("han").
1525
+ const SCRIPTS = [
1526
+ ['latin', /\p{Script=Latin}/u], ['han', /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Bopomofo}]/u], ['hangul', /\p{Script=Hangul}/u],
1527
+ ['cyrillic', /\p{Script=Cyrillic}/u], ['greek', /\p{Script=Greek}/u], ['arabic', /\p{Script=Arabic}/u], ['hebrew', /\p{Script=Hebrew}/u],
1528
+ ['thai', /\p{Script=Thai}/u], ['devanagari', /\p{Script=Devanagari}/u],
1529
+ ]
1530
+ export function scriptsOf(text) {
1531
+ const out = new Set()
1532
+ for (const ch of String(text || '')) {
1533
+ if (!/\p{L}/u.test(ch)) continue
1534
+ const hit = SCRIPTS.find(([, re]) => re.test(ch))
1535
+ out.add(hit ? hit[0] : 'other')
1536
+ }
1537
+ return out
1538
+ }
1539
+ // Does a generated title keep to the user's writing? Every script in it must
1540
+ // be one the user wrote in; Latin letters are always fine (file names, code,
1541
+ // "README"). A title that brings in another script is dropped for the fallback.
1542
+ export function titleFitsUser(title, userText) {
1543
+ const mine = scriptsOf(userText)
1544
+ for (const sc of scriptsOf(title)) if (sc !== 'latin' && !mine.has(sc)) return false
1545
+ return true
1546
+ }
1547
+ // The line under a title prompt that pins the language: written in Latin
1548
+ // letters only, the title is too (what small models drift from).
1549
+ export function titleLanguageLine(userText) {
1550
+ const mine = scriptsOf(userText)
1551
+ return [...mine].some((sc) => sc !== 'latin') ? 'Title language: the language of the messages above.' : 'Title language: the language of the messages above (English if unsure), Latin letters only.'
1552
+ }
1553
+
1519
1554
  // ---------------------------------------------------------------- catch me up (v0.5.0)
1520
1555
 
1521
1556
  // A model reply -> its bullet lines (at most max), or null when fewer than two.
@@ -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.6' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.9-beta.7' // 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.6",
3
+ "version": "0.9.9-beta.7",
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
@@ -182,6 +182,20 @@ h2 .count { color: var(--faint); font-weight: 500; }
182
182
  .fact { background: var(--card); box-shadow: var(--card-shadow); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; min-width: 0; }
183
183
  .fact .k { color: var(--faint); font-size: 11.5px; text-transform: uppercase; letter-spacing: .6px; }
184
184
  .fact .v { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
185
+ .fact .k, .fact .v { display: block; }
186
+ /* 0.9.9: a card with an id or a path is a button: tap copies the whole value, which is its tip */
187
+ .fact.copyable { position: relative; width: 100%; min-height: 44px; text-align: left; cursor: pointer; font: inherit; color: inherit; outline: none; -webkit-tap-highlight-color: transparent; transition: border-color .12s, background .12s; }
188
+ .fact.copyable .k { display: flex; align-items: center; gap: 6px; }
189
+ .fact-ic { display: inline-grid; margin-left: auto; color: var(--faint); opacity: .55; transition: opacity .12s, color .12s; }
190
+ .fact-ic svg { width: 13px; height: 13px; }
191
+ @media (hover: hover) { .fact.copyable:hover { border-color: var(--line-strong); } .fact.copyable:hover .fact-ic { opacity: 1; color: var(--text2); } }
192
+ .fact.copyable:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
193
+ .fact.copyable:focus-visible .fact-ic { opacity: 1; }
194
+ .fact.copyable.copied { border-color: var(--accent); }
195
+ .fact.copyable.copied .fact-ic { opacity: 1; color: var(--accent); }
196
+ .fact.copyable[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 20; padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font: 500 12px/1.35 var(--mono); text-transform: none; letter-spacing: normal; white-space: normal; overflow-wrap: anywhere; pointer-events: none; }
197
+ @media (hover: hover) { .fact.copyable[data-tip]:hover::after { display: block; } }
198
+ .fact.copyable[data-tip]:focus-visible::after, .fact.copyable.copied[data-tip]::after { display: block; }
185
199
  .strip { display: flex; gap: 3px; margin: 8px 0 4px; flex-wrap: wrap; }
186
200
  .strip span { font-size: 11px; padding: 2px 8px; border-radius: 6px; }
187
201
  .timeline { position: relative; margin-left: 8px; padding-left: 22px; border-left: 2px solid var(--line); }
@@ -2951,3 +2965,22 @@ del.gd-x { background: var(--gd-del-word); }
2951
2965
  /* 0.9.9 asked at pairing time: may other devices start sessions here? */
2952
2966
  .pair-ask .grow { min-width: 0; }
2953
2967
  @media (pointer: coarse) { .pair-ask > .btn { min-height: 44px; } }
2968
+
2969
+ /* 0.9.9 a setting that saves on change says so beside it, then fades */
2970
+ .saved-mark { display: inline-block; margin-left: 10px; font-size: 13px; font-weight: 600; white-space: nowrap; color: var(--green, var(--accent)); transition: opacity .35s ease; }
2971
+ .saved-mark.bad { color: var(--red); font-weight: 400; white-space: normal; }
2972
+ .saved-mark.gone { opacity: 0; }
2973
+ @media (prefers-reduced-motion: reduce) { .saved-mark { transition: none; } }
2974
+
2975
+ /* 0.9.9 settings kept on one Mac only: "🖥️ studio only" beside the label (a styled tip,
2976
+ never title=), and a locked control (a lock, aria-disabled) when you're not at that Mac */
2977
+ .local-pill { position: relative; display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--bg2); color: var(--text2); font-size: 11.5px; font-weight: 500; line-height: 1.5; white-space: nowrap; vertical-align: 1px; cursor: default; }
2978
+ .local-pill::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); left: 0; width: max-content; max-width: min(240px, 70vw); 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: normal; pointer-events: none; z-index: 20; }
2979
+ @media (hover: hover) { .local-pill:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
2980
+ .local-pill.show::after { display: block; }
2981
+ @media (prefers-reduced-motion: reduce) { .local-pill::after { animation: none !important; } }
2982
+ .local-pill .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
2983
+ .form label.check.locked { color: var(--muted); cursor: not-allowed; }
2984
+ .form label.check.locked input { cursor: not-allowed; }
2985
+ .form label.check .lock { display: inline-flex; color: var(--muted); }
2986
+ .form label.check .lock .ic { width: 14px; height: 14px; }
package/ui/app.js CHANGED
@@ -16,9 +16,10 @@ 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, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath } from './start.js' // 0.9.9 New session
19
+ import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath } 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
+ import { factHtml } from './facts.js' // 0.9.9 the session page's info cards
22
23
  import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
23
24
  import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
24
25
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
@@ -27,7 +28,7 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
27
28
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
28
29
  import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
29
30
  import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
30
- import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses, liveWorking } from './live-view.js' // 0.9.8 Live link polish
31
+ import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses, liveWorking, liveBoxKind, liveThreadWanted } from './live-view.js' // 0.9.8 Live link polish
31
32
  import { statusView, elapsedText } from './live-status.js'
32
33
  import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS } from './live-wake.js' // 0.9.8 live after sleep
33
34
  import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
@@ -1497,10 +1498,10 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
1497
1498
  html += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false }) // 0.9.9: started from the board: Stop / Resume
1498
1499
  if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
1499
1500
  html += '<div class="facts">' +
1500
- fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
1501
- fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
1502
- fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
1503
- fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
1501
+ fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : ''), '', { copy: (s.repo && s.repo.root) || null, what: 'repo folder' }) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap', { copy: s.worktree.path || null, what: 'worktree folder' }) : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—', '', { copy: s.cwd || null, clip: 34 }) + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
1502
+ fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id, '', { copy: s.id, what: 'session id' }) +
1503
+ fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown', '', { copy: s.live === 'open' && s.liveVia === 'pid' && s.pid != null ? String(s.pid) : null, what: 'process id' }) +
1504
+ fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || ''), '', { copy: s.shortId ? 'cp:' + s.shortId : null, what: 'terminal tab id' }) + '</div>'
1504
1505
  if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
1505
1506
  html += parkRow(s)
1506
1507
  html += spacePicker(s, spv, 'session', s.id)
@@ -1561,7 +1562,8 @@ async function liveHow() {
1561
1562
  return state.liveHow
1562
1563
  }
1563
1564
  // Does this session's page show a Live thread (and so fetch one)?
1564
- const liveWanted = (s) => !!(s && isLive(s) && !s.agent && s.codingAgent !== 'codex' && s.capture !== 'hooks')
1565
+ // (0.9.9: a session Leftoff started reports as "hooks" but always has its thread: before, its page never fetched it and read "Nothing sent yet")
1566
+ const liveWanted = (s) => liveThreadWanted(s)
1565
1567
  // GET the Live thread; what it says is the newest the page knows (0.9.8: the
1566
1568
  // time of the last good answer decides "Reconnecting…" on a pending bubble).
1567
1569
  async function fetchLive(id, { timeoutMs } = {}) {
@@ -1573,9 +1575,9 @@ async function fetchLive(id, { timeoutMs } = {}) {
1573
1575
  } catch (e) { return { messages: [], error: e.message } }
1574
1576
  }
1575
1577
  function liveBoxHtml(s, v) {
1576
- if (s.agent || s.codingAgent === 'codex' || (s.capture === 'hooks' && !s.headless)) return '' // 0.9.9: one Leftoff started talks Live whatever reports it
1577
- if (!isLive(s)) {
1578
- if (s.endedAt || s.live === 'closed') return ''
1578
+ const kind = liveBoxKind(s) // 0.9.9: one Leftoff started talks Live whatever reports it
1579
+ if (kind === 'none') return ''
1580
+ if (kind === 'how') {
1579
1581
  const how = state.liveHow
1580
1582
  return '<section class="live-box off" aria-label="Live link"><div class="live-head"><h2>Live <span class="pill soon">Beta</span></h2></div>' +
1581
1583
  '<p class="faint">To send this session messages from here or your phone, and answer its permission prompts, start it with <code>leftoff live</code>' +
@@ -1588,6 +1590,14 @@ function liveBoxHtml(s, v) {
1588
1590
  const now = Date.now()
1589
1591
  const lastClaude = [...msgs].reverse().find((m) => m.from === 'claude')
1590
1592
  if (lastClaude) markLiveSeen(s.id, lastClaude.at)
1593
+ // a session Leftoff started that isn't running now: what was said, read-only (Resume here sends again)
1594
+ if (kind === 'readonly') {
1595
+ if (!msgs.length) return ''
1596
+ return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2><span class="faint">Not running. Resume it to send more.</span></div>' +
1597
+ '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-label="Messages with this session">' +
1598
+ earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
1599
+ '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div></section>'
1600
+ }
1591
1601
  return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2><span class="faint">Goes straight into this session' + (s.isLocal === false ? ' on ' + esc(s.machine) : '') + '. Claude reads it as its next prompt.</span></div>' +
1592
1602
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
1593
1603
  earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
@@ -2230,7 +2240,8 @@ function spacePicker(s, spv, kind, key) {
2230
2240
  options: [['', own ? 'Default (repo or Mac)' : s.space + ' · ' + (SPACE_WHY[s.spaceSource] || 'default')], ...opts.map((x) => [x, x]), ['__new', 'New space…']] }) +
2231
2241
  '<span class="faint">' + (kind === 'agent' ? 'For everything from this agent’s feed.' : 'Just this session; repos and Macs are set in <a href="#/settings/spaces">Settings → Spaces</a>.') + '</span></div>'
2232
2242
  }
2233
- function fact(k, v, cls) { return '<div class="fact' + (cls ? ' ' + cls : '') + '"><div class="k">' + esc(k) + '</div><div class="v" title="' + esc(v) + '">' + esc(v) + '</div></div>' }
2243
+ // (0.9.9: ui/facts.js; an id or a path copies on tap, its whole value in a data-tip)
2244
+ function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
2234
2245
 
2235
2246
  // PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
2236
2247
  // PR in the repos your sessions are in). The daemon fetches with gh (or asks
@@ -3028,7 +3039,8 @@ function anywhereSection(r, adv = []) {
3028
3039
  '<div class="full hint">Your board at <b>' + esc(r.host || 'yourname.leftoff.dev') + '</b> from any browser, end-to-end encrypted. <a href="https://leftoff.dev/how-it-works" target="_blank" rel="noopener">How it works ↗</a></div>'
3029
3040
  // No name yet on any Mac in this folder: claim one (0.6.1, an email link, no admin token).
3030
3041
  if ((!r.user || su.status === 'pending') && su.status !== 'confirmed') h += signupBlock(r, su, local)
3031
- h += '<label for="f-anywhere">Use from anywhere</label><label class="check"><input id="f-anywhere" type="checkbox" data-action="relay-toggle"' + (r.enabled ? ' checked' : '') + dis + '><span>On for this Mac (' + esc(r.machine || '') + ')</span></label>'
3042
+ h += '<label for="f-anywhere">Use from anywhere' + localPillHtml({ machine: r.machine, emoji: r.machine ? mEmoji(r.machine) : '', esc }) + '</label><label class="check' + (local ? '' : ' locked') + '"><input id="f-anywhere" type="checkbox" data-action="relay-toggle"' + (r.enabled ? ' checked' : '') + lockedAttrs(local) + '>' + lockHtml(local, icon) + '<span>On for this Mac (' + esc(r.machine || '') + ')</span></label>' +
3043
+ (local ? '' : '<div class="hint local-hint">Change on ' + esc(r.machine || 'that Mac') + '.</div>')
3032
3044
  // The name: up top until there is one, then Advanced (0.6.6).
3033
3045
  const nameRow = '<label for="f-anywhere-user">' + (r.user ? 'Your name' : 'Have a name?') + '</label><div class="anyuser"><input id="f-anywhere-user" value="' + esc(r.user || '') + '" placeholder="yourname" spellcheck="false" autocapitalize="none" autocomplete="off" maxlength="32"' + dis + '><span class="faint mono">.' + dom + '</span>' +
3034
3046
  (local && r.user ? '<button type="button" class="btn sm" data-action="relay-user">Save</button>' : '') + '</div>' +
@@ -3392,14 +3404,14 @@ async function viewSettings() {
3392
3404
  '<label data-row="model">Model</label><div data-row="model" class="models" id="f-models-list" role="radiogroup" aria-label="Model"><div class="nocp">Loading…</div></div>' +
3393
3405
  '<input type="hidden" id="f-model" value="' + esc(sm.model || '') + '">' +
3394
3406
  '<label for="f-base" data-row="base">Server URL</label><input id="f-base" data-row="base" value="' + esc(sm.baseUrl || '') + '">' +
3395
- '<label for="f-key" data-row="key">API key</label><div class="keyrow" data-row="key"><input id="f-key" type="password" autocomplete="new-password" spellcheck="false"><span class="keystate" id="f-keystate"></span></div>' +
3407
+ '<label for="f-key" data-row="key">API key' + localPillHtml({ machine: c.machineResolved, emoji: c.machineResolved ? mEmoji(c.machineResolved) : '', esc }) + '</label><div class="keyrow" data-row="key"><input id="f-key" type="password" autocomplete="new-password" spellcheck="false"><span class="keystate" id="f-keystate"></span></div>' +
3396
3408
  '<div class="hint" data-row="key" id="f-key-hint"></div>' +
3397
3409
  summariesSection(c) +
3398
3410
  advanced('models', lengthExampleHtml(c)),
3399
3411
  phone: phonePane(relay, remote, unavailable),
3400
3412
  security: '<div class="section" id="answering">Other devices</div>' + (answeringSection(remote, relay) ||
3401
3413
  '<div class="full hint">Nothing reaches this board from elsewhere until <a href="#/settings/phone">Phone &amp; remote</a> is on.</div>') +
3402
- startsSettingHtml(starts, { esc, local: startViewerLocal(starts && starts.viewer) }) +
3414
+ startsSettingHtml(starts, { esc, local: startViewerLocal(starts && starts.viewer), emoji: starts ? mEmoji(starts.machine) : '', icon }) +
3403
3415
  // a changed machine key needs a click here, so Other machines stays up top then
3404
3416
  (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
3405
3417
  advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
@@ -3603,9 +3615,10 @@ function machinesSection(p) {
3603
3615
  if (!p || p.off) return ''
3604
3616
  const local = p.viewer && p.viewer.local
3605
3617
  let h = '<div class="section" id="machines">Other machines</div>' +
3606
- '<label for="f-peer-allow">Answer and Go</label><label class="check"><input id="f-peer-allow" type="checkbox"' + (p.allowIncoming ? ' checked' : '') + (local ? '' : ' disabled') + '><span>Allow other machines to answer and Go here</span></label>' +
3618
+ '<label for="f-peer-allow">Answer and Go' + localPillHtml({ machine: p.machine, emoji: p.machine ? mEmoji(p.machine) : '', esc }) + '</label><label class="check' + (local ? '' : ' locked') + '"><input id="f-peer-allow" type="checkbox"' + (p.allowIncoming ? ' checked' : '') + lockedAttrs(local) + '>' + lockHtml(local, icon) + '<span>Allow other machines to answer and Go here</span></label>' +
3607
3619
  '<div class="hint">Off by default. On, a Mac that shares this folder can answer this Mac’s permission prompts and questions and press Go, from its own board. Each command is signed with that Mac’s key from the folder, encrypted, and expires in a minute. ' +
3608
- (p.allowIncoming ? 'Listening on port ' + esc(p.listening || '—') + (p.relay ? (p.relay.connected ? ' and connected to the relay' : ' · relay not connected') : '') + '.' : 'While it’s off nothing listens.') + ' This Mac’s key: <code>' + esc(p.fingerprint) + '</code></div>'
3620
+ (p.allowIncoming ? 'Listening on port ' + esc(p.listening || '—') + (p.relay ? (p.relay.connected ? ' and connected to the relay' : ' · relay not connected') : '') + '.' : 'While it’s off nothing listens.') + ' This Mac’s key: <code>' + esc(p.fingerprint) + '</code></div>' +
3621
+ (local ? '' : '<div class="hint local-hint">Change on ' + esc(p.machine || 'that Mac') + '.</div>')
3609
3622
  h += '<div class="full">' + protectHtml('machines') + '</div>'
3610
3623
  if (!p.peers.length) return h + '<label>Machines</label><div class="hint">No other Mac in this folder yet.</div>'
3611
3624
  h += '<label>Machines</label><div class="peers">' + p.peers.map((x) => {
@@ -4219,6 +4232,30 @@ document.addEventListener('input', (ev) => {
4219
4232
  if (!ev.target || !ev.target.classList || !ev.target.classList.contains('sb-path-in') || !startUi.browse) return
4220
4233
  startUi.browse.typed = ev.target.value // kept across the board's redraws
4221
4234
  })
4235
+ // The per-Mac pill sits inside its setting's <label>: a tap on it shows its tip
4236
+ // (touch has no hover) and never flips the setting.
4237
+ document.addEventListener('click', (ev) => {
4238
+ const pill = ev.target && ev.target.closest && ev.target.closest('.local-pill')
4239
+ for (const x of document.querySelectorAll('.local-pill.show')) if (x !== pill) x.classList.remove('show')
4240
+ if (!pill) return
4241
+ ev.preventDefault()
4242
+ pill.classList.toggle('show')
4243
+ }, true)
4244
+ // 0.9.9: a setting that saves the moment you change it (no Save button) says
4245
+ // so beside it: "Saved ✓" for a moment, or what went wrong, inline.
4246
+ const savedTimers = new Map()
4247
+ function markSaved(id, err = null) {
4248
+ const el = document.getElementById(id)
4249
+ const row = el && (el.closest('label.check') || el)
4250
+ if (!row) return
4251
+ let m = row.querySelector('.saved-mark')
4252
+ if (!m) { m = document.createElement('span'); m.className = 'saved-mark'; row.appendChild(m) } // right beside the checkbox's words
4253
+ m.classList.toggle('bad', !!err); m.classList.remove('gone')
4254
+ m.setAttribute('role', err ? 'alert' : 'status')
4255
+ m.textContent = err ? String(err) : 'Saved ✓'
4256
+ clearTimeout(savedTimers.get(id))
4257
+ if (!err) savedTimers.set(id, setTimeout(() => { m.classList.add('gone'); setTimeout(() => { if (m.classList.contains('gone')) m.remove() }, 400) }, 2000))
4258
+ }
4222
4259
  // Claude Code's own words when a session it ran stopped with an error: text, never markup.
4223
4260
  const headlessErrors = new Map()
4224
4261
  function fillHeadlessErrors() {
@@ -5227,6 +5264,15 @@ document.addEventListener('click', async (ev) => {
5227
5264
  const id = btn.dataset.id || (card && card.dataset.id)
5228
5265
  const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
5229
5266
  try {
5267
+ // 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
5268
+ if (action === 'copy-fact') {
5269
+ const ok = await copy(btn.dataset.copy)
5270
+ if (ok) {
5271
+ clearTimeout(btn._copied); btn.classList.add('copied'); btn.dataset.tip = 'Copied'
5272
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.dataset.tip = btn.dataset.copy }, 1500)
5273
+ }
5274
+ return
5275
+ }
5230
5276
  if (action === 'copy-cmd') {
5231
5277
  const ok = await copy(btn.dataset.cmd)
5232
5278
  if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
@@ -6230,13 +6276,17 @@ document.addEventListener('change', async (ev) => {
6230
6276
  }
6231
6277
  if (ev.target.id === 'f-start-allow') { // 0.9.9: this Mac only
6232
6278
  const cb = ev.target
6233
- try { const r = await api('POST', '/v1/starts/allow', { allow: cb.checked }); toast(r.allowRemote ? 'Your phone and other Macs can now start sessions here' : 'Sessions started from other devices wait for a yes here') } catch (e) { cb.checked = !cb.checked; toast(e.message, 6000) }
6234
- return viewSettings()
6279
+ let err = null
6280
+ try { const r = await api('POST', '/v1/starts/allow', { allow: cb.checked }); toast(r.allowRemote ? 'Your phone and other Macs can now start sessions here' : 'Sessions started from other devices wait for a yes here') } catch (e) { cb.checked = !cb.checked; err = e.message; toast(e.message, 6000) }
6281
+ await viewSettings()
6282
+ return markSaved('f-start-allow', err)
6235
6283
  }
6236
6284
  if (ev.target.id === 'f-peer-allow') {
6237
6285
  const cb = ev.target
6238
- try { const r = await api('POST', '/v1/peers/allow', { allow: cb.checked }); toast(r.allowIncoming ? 'Other machines may now answer and Go here' : 'Other machines can no longer answer or Go here') } catch (e) { cb.checked = !cb.checked; toast(e.message, 6000) }
6239
- return viewSettings()
6286
+ let err = null
6287
+ try { const r = await api('POST', '/v1/peers/allow', { allow: cb.checked }); toast(r.allowIncoming ? 'Other machines may now answer and Go here' : 'Other machines can no longer answer or Go here') } catch (e) { cb.checked = !cb.checked; err = e.message; toast(e.message, 6000) }
6288
+ await viewSettings()
6289
+ return markSaved('f-peer-allow', err)
6240
6290
  }
6241
6291
  if (ev.target.dataset && ev.target.dataset.list && ev.target.type === 'checkbox') {
6242
6292
  const cb = ev.target, done = cb.checked, li = cb.closest('li')
package/ui/facts.js ADDED
@@ -0,0 +1,34 @@
1
+ // 0.9.9 the session page's info cards (Machine, Repo, Directory, Session id…).
2
+ // Pure (the unit tests read it). A card whose value is an id or a path is a
3
+ // button: tap or click copies the whole value (the toast says it), the whole
4
+ // value is its data-tip, and it is a 44px target. A long path shows its start
5
+ // and its end, the part that tells worktrees apart.
6
+ import { icon } from './icons.js'
7
+ import { midClip } from './live-view.js'
8
+
9
+ const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
10
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
11
+
12
+ // factHtml('Turns', '3') or factHtml('Directory', cwd, { copy: cwd, what: 'directory', clip: 34 })
13
+ // copy: the full value to copy (a card without one is plain); what: its name in "Copy <what>"
14
+ // shown: what the card shows when it isn't the copied value itself (the worktree's name for its path)
15
+ // A long path: its start, then its last folder whole ("/Users/you…/add-a-joke-0994").
16
+ export function clipPath(p, n = 34) {
17
+ const t = String(p == null ? '' : p)
18
+ if (t.length <= n) return t
19
+ const i = t.replace(/\/+$/, '').lastIndexOf('/')
20
+ const last = i >= 0 ? t.slice(i + 1) : ''
21
+ if (!last || last.length > n - 8) return midClip(t, n)
22
+ return t.slice(0, n - last.length - 2) + '…/' + last
23
+ }
24
+
25
+ export function factHtml(k, v, { cls = '', copy = null, what = null, shown = null, clip = 0 } = {}) {
26
+ const value = String(v == null ? '' : v)
27
+ const view = shown != null ? String(shown) : clip ? (value.includes('/') ? clipPath(value, clip) : midClip(value, clip)) : value
28
+ if (copy == null || copy === '') {
29
+ return '<div class="fact' + (cls ? ' ' + cls : '') + '"><div class="k">' + esc(k) + '</div><div class="v">' + esc(view) + '</div></div>'
30
+ }
31
+ const full = String(copy)
32
+ return '<button type="button" class="fact copyable' + (cls ? ' ' + cls : '') + '" data-action="copy-fact" data-copy="' + esc(full) + '" data-tip="' + esc(full) + '" aria-label="' + esc('Copy ' + (what || k.toLowerCase()) + ': ' + full) + '">' +
33
+ '<span class="k">' + esc(k) + '<span class="fact-ic" aria-hidden="true">' + icon('copy') + '</span></span><span class="v">' + esc(view) + '</span></button>'
34
+ }
package/ui/icons.js CHANGED
@@ -17,6 +17,7 @@ export const ICONS = {
17
17
  "qr-code": "<rect width=\"5\" height=\"5\" x=\"3\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"16\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"3\" y=\"16\" rx=\"1\"/> <path d=\"M21 16h-3a2 2 0 0 0-2 2v3\"/> <path d=\"M21 21v.01\"/> <path d=\"M12 7v3a2 2 0 0 1-2 2H7\"/> <path d=\"M3 12h.01\"/> <path d=\"M12 3h.01\"/> <path d=\"M12 16v.01\"/> <path d=\"M16 12h1\"/> <path d=\"M21 12v.01\"/> <path d=\"M12 21v-1\"/>",
18
18
  "refresh-cw": "<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"/> <path d=\"M21 3v5h-5\"/> <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"/> <path d=\"M8 16H3v5\"/>",
19
19
  "check": "<path d=\"M20 6 9 17l-5-5\"/>",
20
+ "lock": "<rect width=\"18\" height=\"11\" x=\"3\" y=\"11\" rx=\"2\" ry=\"2\"/> <path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/>",
20
21
  "folder-sync": "<path d=\"M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v.5\"/> <path d=\"M12 10v4h4\"/> <path d=\"m12 14 1.535-1.605a5 5 0 0 1 8 1.5\"/> <path d=\"M22 22v-4h-4\"/> <path d=\"m22 18-1.535 1.605a5 5 0 0 1-8-1.5\"/>",
21
22
  "bell": "<path d=\"M10.268 21a2 2 0 0 0 3.464 0\"/> <path d=\"M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326\"/>",
22
23
  "x": "<path d=\"M18 6 6 18\"/> <path d=\"m6 6 12 12\"/>",
package/ui/live-view.js CHANGED
@@ -9,6 +9,23 @@ import { parseAttachedLines } from './attach.js'
9
9
  const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t }
10
10
  export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.endedAt && s.live !== 'closed')
11
11
 
12
+ // 0.9.9: which Live box a session's page shows, and so whether it fetches the
13
+ // thread. A session Leftoff started (headless) always has one, whatever its
14
+ // capture says ("hooks": its card came from the daemon before Claude's first
15
+ // hook): its first message and the reply are in it, while it runs and after
16
+ // it stops. Another hooks-only session has no Live at all; any other one has
17
+ // a thread while it is live-linked and the how-to otherwise.
18
+ // -> 'none' | 'thread' | 'readonly' (a stopped headless one: no composer) | 'how'
19
+ export function liveBoxKind(s) {
20
+ if (!s || s.agent || s.codingAgent === 'codex') return 'none'
21
+ if (s.capture === 'hooks' && !s.headless) return 'none'
22
+ if (s.liveLink && s.liveLink.on) return 'thread'
23
+ if (s.headless) return 'readonly'
24
+ if (s.endedAt || s.live === 'closed') return 'none'
25
+ return 'how'
26
+ }
27
+ export const liveThreadWanted = (s) => { const k = liveBoxKind(s); return k === 'thread' || k === 'readonly' }
28
+
12
29
  // The name a session goes by. A live session is never "Untitled": until it has
13
30
  // a title (its first prompt, Claude Code's /rename, a generated one) it goes by
14
31
  // its repo and branch.
package/ui/start.js CHANGED
@@ -244,8 +244,22 @@ export function headlessBarHtml(s, { esc, icon, may = true } = {}) {
244
244
  }
245
245
 
246
246
  // Settings → Privacy & security: the one switch, on this Mac only.
247
- export function startsSettingHtml(info, { esc, local = true } = {}) {
247
+ // 0.9.9: a setting kept only on one Mac (not in the synced config.json) wears a
248
+ // pill beside its label: "🖥️ studio only", with a styled tip. Synced settings
249
+ // get none, so the pill alone tells you which kind it is.
250
+ export const LOCAL_TIP = 'Kept on this Mac; doesn’t sync.'
251
+ export function localPillHtml({ machine, emoji = '', esc }) {
252
+ return ' <span class="local-pill" data-tip="' + esc(LOCAL_TIP) + '">' + (emoji ? '<span aria-hidden="true">' + esc(emoji) + '</span> ' : '') + esc(machine || 'this Mac') + ' only<span class="sr-only">: ' + esc(LOCAL_TIP) + '</span></span>'
253
+ }
254
+ // Away from that Mac the control is locked, and looks it: a lock, not just grey.
255
+ export function lockedAttrs(local) { return local ? '' : ' disabled aria-disabled="true"' }
256
+ export function lockHtml(local, icon) { return local ? '' : '<span class="lock" aria-hidden="true">' + (icon ? icon('lock') : '🔒') + '</span>' }
257
+
258
+ export function startsSettingHtml(info, { esc, local = true, emoji = '', icon = null } = {}) {
248
259
  if (!info) return ''
249
- return '<label for="f-start-allow">Start sessions</label><label class="check"><input id="f-start-allow" type="checkbox"' + (info.allowRemote ? ' checked' : '') + (local ? '' : ' disabled') + '><span>Allow starting sessions from other devices</span></label>' +
250
- '<div class="hint">Off: a session your phone or another Mac starts here waits until someone at this Mac confirms it. On: it starts right away, and this Mac shows a notification each time. Needs Trust my paired devices. Kept on this Mac only (it doesn’t sync)' + (local ? '.' : '; change it on ' + esc(info.machine || 'that Mac') + '.') + '</div>'
260
+ const m = esc(info.machine || 'that Mac')
261
+ return '<label for="f-start-allow">Start sessions' + localPillHtml({ machine: info.machine, emoji, esc }) + '</label>' +
262
+ '<label class="check' + (local ? '' : ' locked') + '"><input id="f-start-allow" type="checkbox"' + (info.allowRemote ? ' checked' : '') + lockedAttrs(local) + '>' + lockHtml(local, icon) + '<span>Allow starting sessions from other devices</span></label>' +
263
+ '<div class="hint">Off: a session your phone or another Mac starts here waits until someone at this Mac confirms it. On: it starts right away, and this Mac shows a notification each time. Needs Trust my paired devices.</div>' +
264
+ '<div class="hint local-hint">' + (local ? 'Or run <code>leftoff starts on</code> on this Mac.' : 'Change on ' + m + ', or run <code>leftoff starts on</code> there.') + '</div>'
251
265
  }