cld-pi 0.0.0-stage → 0.0.2

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.
@@ -0,0 +1,230 @@
1
+ // Ctrl+P: the settings panel. Every control is a button whose label is
2
+ // hot-swapped; a change goes to CC and the label only moves once the new
3
+ // state is read back off the screen (or the settings file).
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { dirname, join } from 'node:path'
7
+ import { bashOutputRows, effortOf, efforts, modeOf, modes, modelFamily, models, summarizeTranscript } from '../session.js'
8
+ import { clearTrust } from './f12.js'
9
+
10
+ // Permission checkboxes → allow rules in the project's settings.local.json
11
+ // (CC reloads it on change).
12
+ const perms = { file: ['Edit', 'Write'], web: ['WebFetch', 'WebSearch'] }
13
+
14
+ export const settingsPath = (cwd = process.cwd()) => join(cwd, '.claude', 'settings.local.json')
15
+
16
+ export function readSettings(path) {
17
+ try {
18
+ return JSON.parse(readFileSync(path, 'utf8'))
19
+ } catch {
20
+ return {}
21
+ }
22
+ }
23
+
24
+ export const isAllowed = (settings, key) => perms[key].every(r => settings.permissions?.allow?.includes(r))
25
+
26
+ /** Add / remove `key`'s allow rules, keeping everything else in the file. */
27
+ export function setAllowed(path, key, on) {
28
+ const s = readSettings(path)
29
+ const allow = new Set(s.permissions?.allow ?? [])
30
+ for (const r of perms[key]) on ? allow.add(r) : allow.delete(r)
31
+ s.permissions = { ...s.permissions, allow: [...allow] }
32
+ mkdirSync(dirname(path), { recursive: true })
33
+ writeFileSync(path, JSON.stringify(s, null, 2) + '\n')
34
+ }
35
+
36
+ export const radio = (v, cur) => `${v === cur ? '●' : '○'} ${v}`
37
+ export const checkbox = (name, on) => `[${on ? 'x' : ' '}] ${name}`
38
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
39
+
40
+ /** "3h7m": whole hours and minutes since an epoch-seconds time. */
41
+ export const sinceLabel = sec => `${Math.floor(sec / 3600)}h${Math.floor((sec % 3600) / 60)}m`
42
+
43
+ /** ~/.F12-mcp.json: the sites the f12 plugin's addTrust let Chrome reach. */
44
+ export const trustPath = join(homedir(), '.F12-mcp.json')
45
+
46
+ /** The sites pi.ext.f12.addTrust has trusted (~/.F12-mcp.json), as hosts. */
47
+ export function trustedSites(path = trustPath) {
48
+ try {
49
+ const list = JSON.parse(readFileSync(path, 'utf8')).allowedUrlPattern
50
+ if (!Array.isArray(list)) return []
51
+ return list.map(p => {
52
+ try {
53
+ return new URL(String(p)).host
54
+ } catch {
55
+ return String(p)
56
+ }
57
+ })
58
+ } catch {
59
+ return []
60
+ }
61
+ }
62
+
63
+ /** Files git reports as changed, and how long ago the last commit was. */
64
+ export function gitSummary(status, commitTime, now = Date.now() / 1000) {
65
+ const n = String(status ?? '').split('\n').filter(l => l.trim()).length
66
+ const t = Number(String(commitTime ?? '').trim())
67
+ const age = Number.isFinite(t) && t > 0 ? sinceLabel(Math.max(0, now - t)) : null
68
+ return `${n} changed file${n === 1 ? '' : 's'}${age ? ` · ${age} since last commit` : ''}`
69
+ }
70
+
71
+ // CC's answer to `/model x`: "Set model to `Name`" / "Kept model as `Name`".
72
+ // A gateway (ANTHROPIC_DEFAULT_*_MODEL) may resolve every alias to the same
73
+ // model, so the family picked is not in the text — match the message only.
74
+ const modelSet = text => /Set model to|Kept model as/.test(text)
75
+ const actions = ['CtrlO', 'CtrlR', 'Undo', 'Copy']
76
+
77
+ export default pi => {
78
+ const { tui } = pi
79
+ let hideBash = null // disposer of the sed that blanks Bash output
80
+ let open = false
81
+
82
+ // A fresh frame: waiting subscribes to frames, and Zig dumps one at once.
83
+ const screen = async () => {
84
+ await tui.wait(() => true, 500)
85
+ return tui.state.scr?.text ?? ''
86
+ }
87
+
88
+ const bashRows = new WeakMap()
89
+ const hideOutput = () =>
90
+ pi.sed((ln, i, rows) => {
91
+ if (!bashRows.has(rows)) bashRows.set(rows, bashOutputRows(rows))
92
+ return bashRows.get(rows).has(i) ? `\x1b[48;5;236m${' '.repeat(tui.state.cols || 80)}` : null
93
+ })
94
+
95
+ async function show() {
96
+ open = true
97
+ const text = await screen()
98
+ let sum = { cost: 0, unpriced: 0, name: null, model: null }
99
+ try {
100
+ if (tui.state.transcript) sum = summarizeTranscript(readFileSync(tui.state.transcript, 'utf8'))
101
+ } catch {}
102
+ const path = settingsPath()
103
+ const cur = { model: modelFamily(sum.model), effort: effortOf(text), mode: modeOf(text) }
104
+ let file = isAllowed(readSettings(path), 'file')
105
+ let web = isAllowed(readSettings(path), 'web')
106
+ // The permission checkboxes' hints: what git sees right now, and the
107
+ // sites f12's addTrust has let Chrome reach (nothing else).
108
+ const [status, commit] = await Promise.all([
109
+ pi.sh`git status --porcelain 2>/dev/null`,
110
+ pi.sh`git log -1 --format=%ct 2>/dev/null`,
111
+ ])
112
+ const fileHint = gitSummary(status.stdout, commit.stdout)
113
+ const webHint = trustedSites().join(' · ')
114
+
115
+ // Rows of buttons; a radio row is relabeled as a whole once CC agrees.
116
+ const rows = { model: models, effort: efforts, mode: modes }
117
+ const offset = { model: 0, effort: models.length, mode: models.length + efforts.length }
118
+ // file / web / bash sit right after the radio rows.
119
+ const checkboxBase = models.length + efforts.length + modes.length
120
+ let dlg
121
+ const pick = (row, v) => {
122
+ cur[row] = v
123
+ rows[row].forEach((x, i) => dlg.relabel(offset[row] + i, radio(x, v)))
124
+ return radio(v, v)
125
+ }
126
+ const radios = (row, apply) =>
127
+ Object.fromEntries(rows[row].map(v => [radio(v, cur[row]), async () => (v === cur[row] ? radio(v, v) : apply(v))]))
128
+ const radioHints = (row, hint) => Object.fromEntries(rows[row].map(v => [radio(v, cur[row]), hint(v)]))
129
+
130
+ const setModel = async to => {
131
+ const before = await screen()
132
+ await pi`/model ${to}`
133
+ // The frame has to change too: an older "Set model to" line may still
134
+ // be on screen from a previous switch.
135
+ if (await tui.wait(s => s.text !== before && modelSet(s.text), 3000)) return pick('model', to)
136
+ return radio(to, cur.model)
137
+ }
138
+ const setEffort = async to => {
139
+ await pi`/effort ${to}`
140
+ let ok = await tui.wait(s => effortOf(s.text) === to, 2000)
141
+ // No argument support: CC opened its slider instead; walk it.
142
+ if (!ok && (await screen()).includes('to adjust')) {
143
+ const steps = efforts.indexOf(to) - efforts.indexOf(cur.effort)
144
+ for (let i = 0; i < Math.abs(steps); i++) await tui.press(steps > 0 ? 'right' : 'left')
145
+ await tui.press('enter')
146
+ ok = await tui.wait(s => effortOf(s.text) === to, 2000)
147
+ }
148
+ const now = effortOf(await screen()) ?? cur.effort
149
+ return now === to ? pick('effort', to) : radio(to, now)
150
+ }
151
+ const setMode = async to => {
152
+ let now = cur.mode
153
+ for (let i = 0; i < 4 && now !== to; i++) {
154
+ const before = now
155
+ await tui.press('shift-tab')
156
+ await tui.wait(s => modeOf(s.text) !== before, 1000)
157
+ now = modeOf(await screen())
158
+ }
159
+ if (now === to) return pick('mode', to)
160
+ cur.mode = now
161
+ modes.forEach((x, i) => dlg.relabel(offset.mode + i, radio(x, now)))
162
+ return radio(to, now)
163
+ }
164
+
165
+ const cost = `$${sum.cost.toFixed(2)}${sum.unpriced ? '+?' : ''}`
166
+ const labels = { file: checkbox('file', file), web: checkbox('web', web), bash: checkbox('bash', !hideBash) }
167
+ dlg = pi.dialog({
168
+ title: `${sum.name ?? 'this session'} · ${cost}`,
169
+ footer: '↑↓←→ move · Enter apply · Esc close',
170
+ eachRow: [models.length, efforts.length, modes.length, 3, actions.length],
171
+ hints: {
172
+ ...radioHints('model', v => `/model ${v}`),
173
+ ...radioHints('effort', v => `/effort ${v}`),
174
+ ...radioHints('mode', v => `${v === 'acc' ? 'accept edits' : v} mode (Shift+Tab until the status line agrees)`),
175
+ [labels.file]: fileHint,
176
+ [labels.web]: webHint,
177
+ [labels.bash]: 'Show or hide Bash tool output on screen',
178
+ CtrlO: 'Open the transcript (Ctrl+O)',
179
+ CtrlR: 'Search prompt history (Ctrl+R)',
180
+ Undo: 'Rewind the conversation (Esc Esc)',
181
+ Copy: 'Copy the last reply (/copy)',
182
+ },
183
+ ...radios('model', setModel),
184
+ ...radios('effort', setEffort),
185
+ ...radios('mode', setMode),
186
+ [labels.file]: () => {
187
+ setAllowed(path, 'file', !file)
188
+ file = isAllowed(readSettings(path), 'file')
189
+ dlg.hint(checkboxBase, '')
190
+ return checkbox('file', file)
191
+ },
192
+ [labels.web]: async () => {
193
+ setAllowed(path, 'web', !web)
194
+ web = isAllowed(readSettings(path), 'web')
195
+ // Toggling also revokes what addTrust granted, not just the hint.
196
+ try {
197
+ await clearTrust(trustPath)
198
+ } catch (e) {
199
+ pi.dialog({ body: `allowedUrlPattern not cleared: ${e.message}`, timeout: 4000 })
200
+ }
201
+ dlg.hint(checkboxBase + 1, '')
202
+ return checkbox('web', web)
203
+ },
204
+ [labels.bash]: () => {
205
+ if (hideBash) {
206
+ hideBash()
207
+ hideBash = null
208
+ } else hideBash = hideOutput()
209
+ return checkbox('bash', !hideBash)
210
+ },
211
+ CtrlO: () => tui.press('ctrl-o').then(() => {}),
212
+ CtrlR: () => tui.press('ctrl-r').then(() => {}),
213
+ Undo: async () => {
214
+ await tui.press('esc')
215
+ await sleep(200) // two separate presses, not one ESC ESC burst
216
+ await tui.press('esc')
217
+ },
218
+ Copy: () => pi`/copy`.then(() => {}),
219
+ })
220
+ await dlg
221
+ open = false
222
+ }
223
+
224
+ pi.on({
225
+ CtrlP: () => {
226
+ if (!open) show().catch(() => (open = false))
227
+ return false // not CC's Ctrl+P
228
+ },
229
+ })
230
+ }
@@ -0,0 +1,108 @@
1
+ // pi.ext.webpane + /w. webpane.boundPort(port): free → returns the port
2
+ // (the caller starts its server), taken → null; either way a dialog lists
3
+ // where it can be reached, http-server style, with buttons to copy the LAN /
4
+ // tailscale URL or show the tailscale one as a QR code (for a phone). /w does
5
+ // that for the cloudcli web UI on :3001 and starts it in the background when
6
+ // nothing serves it.
7
+ import { spawn } from 'node:child_process'
8
+ import { openSync } from 'node:fs'
9
+ import net from 'node:net'
10
+ import os from 'node:os'
11
+ import { join } from 'node:path'
12
+
13
+ const port = 3001
14
+
15
+ /** Whether something already accepts connections on localhost:port. */
16
+ export const portInUse = (port, host = '127.0.0.1') =>
17
+ new Promise(resolve => {
18
+ const sock = net.connect({ port, host })
19
+ const done = used => {
20
+ sock.destroy()
21
+ resolve(used)
22
+ }
23
+ sock.once('connect', () => done(true))
24
+ sock.once('error', () => done(false))
25
+ sock.setTimeout(500, () => done(false))
26
+ })
27
+
28
+ /** Where a server on `port` can be reached: localhost, LAN, tailscale. */
29
+ export function addresses(port, ifaces = os.networkInterfaces()) {
30
+ const out = [{ url: `http://localhost:${port}`, kind: 'local' }]
31
+ for (const [name, addrs] of Object.entries(ifaces)) {
32
+ for (const a of addrs ?? []) {
33
+ if (a.family !== 'IPv4' || a.internal) continue
34
+ // Tailscale hands out CGNAT addresses (100.64.0.0/10).
35
+ const [o1, o2] = a.address.split('.').map(Number)
36
+ const tailscale = name.startsWith('tailscale') || (o1 === 100 && o2 >= 64 && o2 < 128)
37
+ out.push({ url: `http://${a.address}:${port}`, kind: tailscale ? 'tailscale' : 'LAN' })
38
+ }
39
+ }
40
+ return out
41
+ }
42
+
43
+ /**
44
+ * qrcode-terminal's output (two spaces per module, white \e[47m / black
45
+ * \e[40m) as half blocks: two module rows per line, light modules drawn in
46
+ * the foreground colour, so it reads right on a dark background.
47
+ */
48
+ export function halfBlocks(ansi) {
49
+ const rows = ansi
50
+ .split('\n')
51
+ .filter(l => l.includes('\x1b['))
52
+ .map(l => [...l.matchAll(/\x1b\[(4[07])m {2}/g)].map(m => m[1] === '47'))
53
+ const out = []
54
+ for (let y = 0; y < rows.length; y += 2) {
55
+ const top = rows[y]
56
+ const bottom = rows[y + 1] ?? []
57
+ out.push(top.map((t, x) => (t && bottom[x] ? '█' : t ? '▀' : bottom[x] ? '▄' : ' ')).join(''))
58
+ }
59
+ return out.join('\n')
60
+ }
61
+
62
+ const copy = (pi, text) => pi.sh`printf %s ${text} | (wl-copy || xclip -selection clipboard || pbcopy) 2>/dev/null`
63
+
64
+ export default pi => {
65
+ pi.ext.webpane = {
66
+ async boundPort(port) {
67
+ const free = !(await portInUse(port))
68
+ const all = addresses(port)
69
+ const body = all.map(a => `${a.url}${a.kind === 'local' ? '' : ` (${a.kind})`}`).join('\n')
70
+ const url = kind => all.find(a => a.kind === kind)?.url
71
+ const missing = (kind, label) => (dlg.text({ footer: `no ${kind} address` }), label)
72
+ const copied = (kind, label) => async () => {
73
+ if (!url(kind)) return missing(kind, label)
74
+ await copy(pi, url(kind))
75
+ dlg.text({ footer: `copied ${url(kind)}` })
76
+ return label
77
+ }
78
+ const dlg = pi.dialog({
79
+ title: `:${port} ${free ? 'starting…' : 'already running'}`,
80
+ body,
81
+ footer: 'Esc closes',
82
+ 'Copy LAN': copied('LAN', 'Copy LAN'),
83
+ 'Copy tailscale': copied('tailscale', 'Copy tailscale'),
84
+ 'tailscale QR': async () => {
85
+ const ts = url('tailscale')
86
+ if (!ts) return missing('tailscale', 'tailscale QR')
87
+ dlg.text({ footer: 'npx qrcode-terminal …' })
88
+ const qr = await pi.sh`printf %s ${ts} | npx -y qrcode-terminal`
89
+ if (qr.code !== 0) return (dlg.text({ footer: `qrcode-terminal failed (exit ${qr.code})` }), 'tailscale QR')
90
+ dlg.text({ body: `${halfBlocks(qr.stdout)}\n${ts}`, footer: 'scan with your phone · Esc closes' })
91
+ return 'tailscale QR'
92
+ },
93
+ })
94
+ return free ? port : null
95
+ },
96
+ }
97
+ pi.tui.dispose(() => delete pi.ext.webpane)
98
+
99
+ pi.command('w', async () => {
100
+ if ((await pi.ext.webpane.boundPort(port)) == null) return
101
+ const log = openSync(join(pi.tui.state.init?.dataDir ?? '.', 'cloudcli.log'), 'a')
102
+ spawn('npx', ['-y', '@cloudcli-ai/cloudcli'], {
103
+ detached: true,
104
+ stdio: ['ignore', log, log],
105
+ env: { ...process.env, PORT: String(port) },
106
+ }).unref()
107
+ })
108
+ }