@leftoff/cli 0.9.8-beta.2 → 0.9.8-beta.21

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/ui/attach.js ADDED
@@ -0,0 +1,211 @@
1
+ // Attachments in the Live composer (0.9.8). A paperclip in the box (the
2
+ // phone's own picker: Photo Library, Take Photo, Choose File), and on a
3
+ // desktop drag and drop or paste. Each file is uploaded right away to the Mac
4
+ // that runs the session (src/uploads.js), in base64 chunks every path can
5
+ // carry (the relay takes ~1 MB a message); your message then tells Claude
6
+ // where each file is. Big photos are scaled down first (a 2048 px long edge,
7
+ // JPEG 0.85) and HEIC becomes JPEG, so Claude can read it.
8
+ //
9
+ // The pure pieces are unit tested (test/unit/attach-098.test.mjs); the rest
10
+ // needs a browser (canvas, FileReader) and is checked by test:attach-ui.
11
+
12
+ export const ATTACH_MAX_FILES = 10 // src/uploads.js UPLOAD_MAX_FILES
13
+ export const ATTACH_MAX_BYTES = 20 * 1024 * 1024 // UPLOAD_MAX_BYTES
14
+ export const ATTACH_CHUNK = 384 * 1024 // UPLOAD_CHUNK (the daemon says so too)
15
+ export const LONG_EDGE = 2048
16
+ export const JPEG_QUALITY = 0.85
17
+ export const BIG_PHOTO = 4 * 1024 * 1024 // a photo this big is re-encoded even when it fits the edge
18
+ export const THUMB_EDGE = 320
19
+ const THUMB_BYTES = 60 * 1024 // under the daemon's 64 KB
20
+
21
+ const IMG_EXT = /\.(png|jpe?g|gif|webp|heic|heif|bmp|tiff?)$/i
22
+ export function isHeic(f) { return /^image\/hei[cf]/i.test(String((f && f.type) || '')) || /\.hei[cf]$/i.test(String((f && f.name) || '')) }
23
+ export function attachKind(f) {
24
+ const t = String((f && f.type) || '').toLowerCase(), n = String((f && f.name) || '').toLowerCase()
25
+ if (/^image\//.test(t) || IMG_EXT.test(n)) return 'image'
26
+ if (t === 'application/pdf' || n.endsWith('.pdf')) return 'pdf'
27
+ if (/^text\//.test(t) || /json|xml|javascript|yaml|csv/.test(t) || /\.(txt|md|json|csv|log|ya?ml|xml|html?|css|js|mjs|ts|tsx|jsx|py|rb|go|rs|java|swift|kt|sh|sql|diff|patch)$/.test(n)) return 'text'
28
+ return 'file'
29
+ }
30
+
31
+ export function fmtSize(n) {
32
+ const b = Math.max(0, Number(n) || 0)
33
+ if (b < 1024) return b + ' B'
34
+ if (b < 1024 * 1024) return Math.round(b / 1024) + ' KB'
35
+ return (b / 1024 / 1024).toFixed(b < 10 * 1024 * 1024 ? 1 : 0) + ' MB'
36
+ }
37
+
38
+ // A long name with its end kept: "Screenshot 2026-10…5.20.24 PM.png".
39
+ export function shortName(name, max = 18) {
40
+ const n = String(name || '')
41
+ if (n.length <= max) return n
42
+ const m = n.match(/(\.[A-Za-z0-9]{1,5})$/)
43
+ const tail = Math.min(n.length - 1, Math.max(m ? m[1].length + 3 : 4, Math.floor(max / 2.5)))
44
+ return n.slice(0, max - tail - 1) + '…' + n.slice(n.length - tail)
45
+ }
46
+
47
+ // Which of the dropped/picked files to take, given how many are attached.
48
+ // -> { take: [file], skipped: [{ name, why: 'too_many' | 'too_large' }] }
49
+ export function attachPlan(files, have = 0, { maxFiles = ATTACH_MAX_FILES, maxBytes = ATTACH_MAX_BYTES } = {}) {
50
+ const take = [], skipped = []
51
+ for (const f of Array.from(files || [])) {
52
+ if (have + take.length >= maxFiles) { skipped.push({ name: f.name, why: 'too_many' }); continue }
53
+ // an image over the limit may still fit once scaled down (not a GIF or a format we can't draw)
54
+ const shrinks = attachKind(f) === 'image' && !/gif/i.test(f.type || '')
55
+ if (f.size > maxBytes && !(shrinks && f.size <= maxBytes * 3)) { skipped.push({ name: f.name, why: 'too_large' }); continue }
56
+ take.push(f)
57
+ }
58
+ return { take, skipped }
59
+ }
60
+ export function skippedText(skipped, { maxFiles = ATTACH_MAX_FILES, maxBytes = ATTACH_MAX_BYTES } = {}) {
61
+ const big = skipped.filter((x) => x.why === 'too_large').map((x) => x.name), many = skipped.filter((x) => x.why === 'too_many').length
62
+ return [big.length ? (big.length === 1 ? big[0] + ' is' : big.length + ' files are') + ' over ' + fmtSize(maxBytes) : '', many ? 'up to ' + maxFiles + ' files a message' : ''].filter(Boolean).join(' · ')
63
+ }
64
+
65
+ export function chunkRanges(size, chunk = ATTACH_CHUNK) {
66
+ const n = Math.max(0, Number(size) || 0), c = Math.max(1, Number(chunk) || ATTACH_CHUNK)
67
+ if (!n) return [[0, 0]]
68
+ const out = []
69
+ for (let a = 0; a < n; a += c) out.push([a, Math.min(n, a + c)])
70
+ return out
71
+ }
72
+
73
+ export function fitSize(w, h, max = LONG_EDGE) {
74
+ const W = Math.max(1, Number(w) || 1), H = Math.max(1, Number(h) || 1)
75
+ const long = Math.max(W, H)
76
+ if (long <= max) return { w: W, h: H }
77
+ const k = max / long
78
+ return { w: Math.max(1, Math.round(W * k)), h: Math.max(1, Math.round(H * k)) }
79
+ }
80
+
81
+ // Re-encode as JPEG? HEIC always (Claude Code reads PNG, JPEG, GIF and WebP);
82
+ // a photo or screenshot past the long edge, or a very big one; never a GIF.
83
+ export function shouldConvert(f, dims) {
84
+ if (isHeic(f)) return true
85
+ const t = String((f && f.type) || '').toLowerCase()
86
+ if (!/^image\/(jpeg|png|webp|bmp|tiff)$/.test(t)) return false
87
+ if (dims && Math.max(dims.w, dims.h) > LONG_EDGE) return true
88
+ return (Number(f.size) || 0) > BIG_PHOTO
89
+ }
90
+ export function jpegName(name) {
91
+ const n = String(name || 'photo')
92
+ return /\.[A-Za-z0-9]{1,5}$/.test(n) ? n.replace(/\.[A-Za-z0-9]{1,5}$/, '.jpg') : n + '.jpg'
93
+ }
94
+ // A pasted image is called image.png by every browser: give it a time.
95
+ export function pastedName(type, now = new Date()) {
96
+ const ext = (String(type || '').split('/')[1] || 'png').replace('jpeg', 'jpg').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'png'
97
+ const p = (n) => String(n).padStart(2, '0')
98
+ return 'pasted-' + now.getFullYear() + p(now.getMonth() + 1) + p(now.getDate()) + '-' + p(now.getHours()) + p(now.getMinutes()) + p(now.getSeconds()) + '.' + ext
99
+ }
100
+
101
+ // "Attached: /…/checkpoints/uploads/<session>/<id> (image, 412 KB)" lines in a
102
+ // message the transcript gave back: chips instead of paths (Leftoff's own
103
+ // uploads only; any other line stays text).
104
+ const ATT_LINE = /^Attached: (\/.*\/checkpoints\/uploads\/([A-Za-z0-9][A-Za-z0-9_-]{0,99})\/(\d{8}T\d{6}-[a-f0-9]{6}-([^/\n]+?))) \((image|PDF|pdf|text|file), ([^)\n]+)\)$/
105
+ export function parseAttachedLines(text) {
106
+ const atts = []
107
+ const keep = []
108
+ for (const line of String(text || '').split('\n')) {
109
+ const m = line.match(ATT_LINE)
110
+ if (m) { const kind = m[5].toLowerCase(); atts.push({ id: m[3], sessionId: m[2], name: m[4], kind, sizeLabel: m[6], thumb: kind === 'image' || kind === 'pdf' }); continue }
111
+ keep.push(line)
112
+ }
113
+ return { text: keep.join('\n').trim(), attachments: atts }
114
+ }
115
+
116
+ // --------------------------------------------------------- in the browser
117
+
118
+ export function bytesToBase64(u8) {
119
+ let s = ''
120
+ for (let i = 0; i < u8.length; i += 0x8000) s += String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000))
121
+ return btoa(s)
122
+ }
123
+ export function base64ToBytes(b64) {
124
+ const s = atob(b64 || '')
125
+ const u8 = new Uint8Array(s.length)
126
+ for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i)
127
+ return u8
128
+ }
129
+
130
+ async function decode(file) {
131
+ if (typeof createImageBitmap === 'function') {
132
+ try { const b = await createImageBitmap(file, { imageOrientation: 'from-image' }); return { src: b, w: b.width, h: b.height, close: () => b.close && b.close() } } catch {}
133
+ }
134
+ const url = URL.createObjectURL(file)
135
+ try {
136
+ const img = await new Promise((resolve, reject) => { const i = new Image(); i.onload = () => resolve(i); i.onerror = () => reject(new Error('undecodable')); i.src = url })
137
+ return { src: img, w: img.naturalWidth, h: img.naturalHeight, close: () => URL.revokeObjectURL(url) }
138
+ } catch (e) { URL.revokeObjectURL(url); return null }
139
+ }
140
+ function draw(d, w, h) {
141
+ const c = document.createElement('canvas'); c.width = w; c.height = h
142
+ const g = c.getContext('2d')
143
+ g.fillStyle = '#fff'; g.fillRect(0, 0, w, h) // a transparent PNG becomes JPEG on white, not black
144
+ g.drawImage(d.src, 0, 0, w, h)
145
+ return c
146
+ }
147
+ const toBlob = (c, type, q) => new Promise((resolve) => c.toBlob((b) => resolve(b), type, q))
148
+
149
+ // What to upload for a picked file: { file, thumb (base64 JPEG or null),
150
+ // converted, note }. An image the browser can't draw (HEIC outside Safari)
151
+ // goes as it is, with a note.
152
+ export async function prepareFile(file) {
153
+ if (attachKind(file) !== 'image' || /gif/i.test(file.type || '')) return { file, thumb: null, converted: false }
154
+ const d = await decode(file)
155
+ if (!d) return { file, thumb: null, converted: false, note: isHeic(file) ? 'heic' : null }
156
+ try {
157
+ let out = file, converted = false
158
+ if (shouldConvert(file, { w: d.w, h: d.h })) {
159
+ const { w, h } = fitSize(d.w, d.h, LONG_EDGE)
160
+ const b = await toBlob(draw(d, w, h), 'image/jpeg', JPEG_QUALITY)
161
+ if (b && (b.size < file.size || isHeic(file) || Math.max(d.w, d.h) > LONG_EDGE)) { out = new File([b], jpegName(file.name), { type: 'image/jpeg', lastModified: file.lastModified || Date.now() }); converted = true }
162
+ }
163
+ let thumb = null
164
+ const t = fitSize(d.w, d.h, THUMB_EDGE)
165
+ for (const q of [0.72, 0.5, 0.3]) {
166
+ const tb = await toBlob(draw(d, t.w, t.h), 'image/jpeg', q)
167
+ if (tb && tb.size <= THUMB_BYTES) { thumb = bytesToBase64(new Uint8Array(await tb.arrayBuffer())); break }
168
+ }
169
+ return { file: out, thumb, converted, w: d.w, h: d.h }
170
+ } finally { d.close() }
171
+ }
172
+
173
+ // Upload one prepared file to a session: begin, then chunks; a lost answer is
174
+ // picked up from what the Mac says it has. onProgress(sent, total).
175
+ export async function uploadFile({ api, sessionId, file, thumb = null, onProgress = () => {}, isCancelled = () => false }) {
176
+ const base = '/v1/sessions/' + encodeURIComponent(sessionId) + '/uploads'
177
+ const st = await api('POST', base, { name: file.name || 'file', size: file.size, type: file.type || '' })
178
+ if (st.done) { onProgress(file.size, file.size); return st.file }
179
+ const chunk = Math.max(16 * 1024, Math.min(ATTACH_CHUNK, Number(st.chunk) || ATTACH_CHUNK))
180
+ let off = 0, first = true, out = null, tries = 0
181
+ while (!out || !out.done) {
182
+ if (isCancelled()) throw Object.assign(new Error('removed'), { code: 'removed' })
183
+ const piece = new Uint8Array(await file.slice(off, Math.min(file.size, off + chunk)).arrayBuffer())
184
+ try {
185
+ out = await api('POST', base + '/' + encodeURIComponent(st.id), { offset: off, data: bytesToBase64(piece), ...(first && thumb ? { thumb } : {}) }, { timeoutMs: 60000 })
186
+ tries = 0
187
+ } catch (e) {
188
+ if (++tries > 2 || (e.status && e.status < 500 && e.status !== 408 && e.status !== 429)) throw e
189
+ await new Promise((r) => setTimeout(r, 800 * tries)); continue
190
+ }
191
+ first = false
192
+ off = Number(out.received) || 0
193
+ onProgress(off, file.size)
194
+ }
195
+ return out.file
196
+ }
197
+
198
+ // Read an attachment back from the Mac (in chunks: the relay carries ~1 MB a
199
+ // message) -> a Blob.
200
+ export async function fetchAttachment({ api, sessionId, id, thumb = false }) {
201
+ const base = '/v1/sessions/' + encodeURIComponent(sessionId) + '/uploads/' + encodeURIComponent(id)
202
+ const parts = []
203
+ let off = 0, size = null, type = 'application/octet-stream'
204
+ for (;;) {
205
+ const r = await api('GET', base + '?' + new URLSearchParams(thumb ? { thumb: '1' } : { offset: String(off), length: String(ATTACH_CHUNK) }), null, { timeoutMs: 60000 })
206
+ const b = base64ToBytes(r.data)
207
+ parts.push(b); off += b.length; size = r.size; type = r.type || type
208
+ if (thumb || off >= size || !b.length) break
209
+ }
210
+ return new Blob(parts, { type })
211
+ }
package/ui/composer.js ADDED
@@ -0,0 +1,46 @@
1
+ // The Live composer (0.9.8): one rounded box like a chat app's prompt input.
2
+ // It starts at one line and grows with what you type up to COMPOSER_MAX_LINES,
3
+ // then scrolls inside; a round ↑ button in its corner sends, dim until there's
4
+ // something to send. On a Mac (a fine pointer) Enter sends and Shift+Enter is a
5
+ // new line; on a phone or tablet Return is a new line and the button sends.
6
+ // ⌘↵ / Ctrl+↵ sends everywhere. Pure functions here (shared with the unit
7
+ // tests); app.js wires them to the page.
8
+
9
+ export const COMPOSER_MAX_LINES = 8
10
+
11
+ // Nothing worth sending: empty or only whitespace.
12
+ export const isBlank = (text) => !String(text == null ? '' : text).trim()
13
+
14
+ // What a keydown in the composer means: 'send', or null to let the browser do
15
+ // its own thing (a new line, nothing). ev: { key, shiftKey, altKey, metaKey,
16
+ // ctrlKey, isComposing, keyCode }. touch: Return types a new line (a phone's
17
+ // keyboard has no Shift+Return worth asking for).
18
+ export function composerKey(ev, { touch = false } = {}) {
19
+ if (!ev || ev.key !== 'Enter') return null
20
+ if (ev.isComposing || ev.keyCode === 229) return null // an IME is choosing a word: Enter confirms it
21
+ if (ev.metaKey || ev.ctrlKey) return ev.altKey || ev.shiftKey ? null : 'send'
22
+ if (ev.shiftKey || ev.altKey) return null
23
+ return touch ? null : 'send'
24
+ }
25
+
26
+ // Is this a touch-first screen (phone, tablet)? matchMedia is the page's.
27
+ // No fine pointer, or a coarse one as the primary pointer, counts as touch.
28
+ export function isTouchUi(matchMedia) {
29
+ if (typeof matchMedia !== 'function') return false
30
+ try {
31
+ if (matchMedia('(pointer: coarse)').matches) return true
32
+ return !matchMedia('(pointer: fine)').matches
33
+ } catch { return false }
34
+ }
35
+
36
+ // The height the box should be for its content: scrollHeight is the
37
+ // textarea's at height:auto (padding included), lineHeight and padY in px.
38
+ // -> { height, scroll } where scroll says the content is taller than the cap.
39
+ export function composerHeight({ scrollHeight = 0, lineHeight = 22, padY = 0, maxLines = COMPOSER_MAX_LINES } = {}) {
40
+ const lh = Number(lineHeight) > 0 ? Number(lineHeight) : 22
41
+ const pad = Math.max(0, Number(padY) || 0)
42
+ const min = Math.round(lh + pad)
43
+ const max = Math.round(lh * maxLines + pad)
44
+ const want = Math.ceil(Number(scrollHeight) || 0)
45
+ return { height: Math.max(min, Math.min(want, max)), scroll: want > max + 1 }
46
+ }
package/ui/index.html CHANGED
@@ -46,6 +46,7 @@
46
46
  <a href="#/digest" data-route="digest" role="menuitem"><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="M15 18h-5"/> <path d="M18 14h-8"/> <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/> <rect width="8" height="4" x="10" y="6" rx="1"/></svg><span>Digest</span></a>
47
47
  <a href="#/whats-new" data-route="whats-new" role="menuitem"><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>What’s new</span><span class="newdot" hidden></span></a>
48
48
  <a href="#/settings" data-route="settings" role="menuitem"><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>Settings</span><span class="topdot" hidden></span></a>
49
+ <div class="push-slot" data-push-slot role="group" aria-label="Notifications" hidden></div>
49
50
  </div>
50
51
  <div class="quicklook" id="quickLook" role="dialog" aria-labelledby="qlTitle" hidden></div>
51
52
  <div class="statuspop" id="statusPop" role="dialog" aria-label="Status" hidden></div>
@@ -56,6 +57,9 @@
56
57
  <div class="cmdk-foot"><span><kbd>↑</kbd><kbd>↓</kbd> to move</span><span><kbd>↵</kbd> to open</span><span><kbd>esc</kbd> to close</span></div>
57
58
  </div>
58
59
  </div>
60
+ <nav class="tabstrip" id="tabStrip" aria-label="Open sessions" hidden></nav>
61
+ <div class="openrow" id="openRow" role="navigation" aria-label="Open sessions" hidden></div>
62
+ <div class="tabpager" id="tabPager" role="navigation" aria-label="Open sessions" hidden></div>
59
63
  <main id="app"></main>
60
64
  <div class="toast" id="toast" role="status" aria-live="polite"></div>
61
65
  <script src="relay-client.js" type="module"></script>