The Claude Code side of lets: a bare /lets alias, a live /lets-pane, and the native panes: /lets <unique> native draws a unique (graph first) inside Claude…

The Claude Code side of lets: a mod (Claude Code function-hooks plugin, 2.1.287+) that lives inside the Claude Code terminal or the desktop app's Code tab. Optional: lets works without it (browser windows), and with it a unique can also open as a pane in Claude Code's own sidebar.
| Command | Does | |
|---|---|---|
/lets <args> | bare alias: runs /lets:lets <args>, the plugin's own command, a tick later and prints no line of its own (/lets graph opens the web window, /lets graph native the pane) | |
/lets-pane | opens a live pane: running hosts, open windows, the companion's name + mood, the latest window events | |
| `/lets-native [show\ | sync]` | lists the native windows this session shows and where each pane stands; show places their panes (a pane the mod opened from a poll may wait undrawn until the person asks for it or the window is wide enough; the mod toasts when that happens); sync polls the hosts now |
| (no command) | native panes: every native window a lets host holds gets a pane here, drawn from the tree the host renders (docs/NATIVE.md) |
/lets graph native asks the lets engine for a native window of graph. The host runs the unique's native/index.js in a vm context and renders it to a tree of plain nodes (boxes, text, buttons, inputs, selects, an SVG picture, markdown, code). This mod:
GET /lip/native/sync, every ~1.2 s while a native window is open, every ~3.6 s otherwise; the tool.call hook on the lets open tool makes a new pane appear at once);$.ui.open, id lets-w-<windowId>, the unique's title and wanted width) and draws the tree with the surface's own elements ($.ui.resolve); an element the surface lacks (SVG on the terminal, inputs on mobile) draws as text;POST /lip/native/action) and draws the tree the host answers, at once;session.close), and tells the host when the person closes the pane (POST /lip/native/close).Nothing of a unique runs in this mod. The host token comes from ~/.lets/run/host-*.json on every use and is never kept in $.state.
Several Claude Code sessions each run a lets host; the mod learns which host is this session's from the open results it sees, and until it has seen one it shows every host's native windows.
/plugin install lets-native@lets
or node install.mjs --native from the engine checkout. Then restart Claude Code and run /lets graph native.
claude --plugin-dir ./mods/lets-native (or hot reload through the plugin-authoring skill). Check it with a 2.1.287+ CLI:
claude plugin validate ./mods/lets-native
claude plugin test ./mods/lets-native
tests/native.test.tsx fakes a host (http.fetch, the run files) and mounts the pane on the desktop and terminal surfaces. Older CLIs warn about the types field and ignore modules.
hooks/register.tsx 366 lines1/**
2 * lets-native — the Claude Code side of lets, a mod (function hooks, Claude Code 2.1.287+).
3 *
4 * /lets <args> → the lets plugin's skill (/lets:lets <args>), silently: no line of its own
5 * /lets-pane → a live pane: hosts, windows, the companion's mood, the latest events
6 * /lets-native [sync] → the native windows this session shows; `sync` polls the hosts now
7 * native panes → one pane per native window the lets host holds (docs/NATIVE.md):
8 * the host runs a unique's view and renders it to a tree of plain nodes;
9 * this mod draws the tree with the surface's own elements and posts the
10 * person's presses, Enters and picks back as actions.
11 *
12 * It talks to the lets host the way the prompt hook does: ~/.lets/run/host-*.json gives port and
13 * token; GET /lip/native/sync (every ~1.2 s while a native window is open, every ~3.6 s otherwise),
14 * POST /lip/native/action, POST /lip/native/close. A `tool.call` hook on the lets `open` tool makes a
15 * new native pane appear right after Claude opened the window, without waiting for a poll, and
16 * teaches the mod which host is this session's. Nothing here runs unique code.
17 */
18import { atom, read, update } from 'claude-code'
19import type { EngineInterface, Register, RenderElement } from 'claude-code'
20import type { Feed, FeedEvent, HostInfo, HostRef, NativeState, NativeWindow, Tree, TreeNode } from '../types'
21
22const VERSION = '0.2.0'
23const PANE = 'lets'
24const PREFIX = 'lets-w-'
25const POLL_MS = 1200
26const IDLE_EVERY = 3 // with no native window open, sync every third tick (~3.6 s)
27const FEED_EVERY = 4 // the /lets-pane feed refreshes every fourth tick (~4.8 s)
28const ALIAS_MS = 50 // /lets starts the skill this long after its own answer (the engine refuses it from the hook itself)
29const EMPTY: Feed = { hosts: [], events: [], mood: 'idle', name: 'Doto', checkedAt: 0 }
30const NONE: NativeState = { windows: {}, mine: [] }
31const feed = atom({ plugin: 'lets-native', key: 'feed' } as const, EMPTY)
32const native = atom({ plugin: 'lets-native', key: 'native' } as const, NONE)
33
34type Kind = 'press' | 'submit' | 'select'
35type Table = Record<string, ((props: Record<string, unknown>) => RenderElement) | undefined>
36type Viewport = { cols: number; rows: number }
37// the elements each surface draws (a table hands out a fragment for a name it lacks, so presence is known here, not probed)
38const LACKS: Record<string, ReadonlySet<string>> = { terminal: new Set(['Svg']), mobile: new Set(['Input', 'Select']) }
39
40// module state: lost on a reload, rebuilt from the run files and the host at the next sync
41const tokens = new Map<number, string>() // host port → token (never kept in $.state)
42const viewports = new Map<string, Viewport>() // pane id → what that pane last measured; sent with every sync so each view sizes its picture
43let ticks = 0
44let busy = false
45let again = false
46
47const paneId = (windowId: string) => PREFIX + windowId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 56)
48const json = (token: string) => ({ 'content-type': 'application/json', 'x-lets-token': token, 'x-lets-native': VERSION })
49
50async function homeDir($: EngineInterface): Promise<string> {
51 const home = (await $.env.get('LETS_HOME')) || (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
52 const base = home.replace(/\\/g, '/')
53 return base.endsWith('.lets') ? base : `${base}/.lets`
54}
55
56/** Every live host's run file: port and token. Refreshes the token map. */
57async function hosts($: EngineInterface): Promise<HostRef[]> {
58 const runDir = `${await homeDir($)}/run`
59 const out: HostRef[] = []
60 try {
61 if (!(await $.fs.exists(runDir))) return out
62 for (const ent of await $.fs.list(runDir)) {
63 const name = String(ent.name ?? '')
64 if (!name.startsWith('host-') || !name.endsWith('.json')) continue
65 try {
66 const run = JSON.parse(String(await $.fs.read(`${runDir}/${name}`))) as { port?: number; token?: string }
67 if (run?.port && run?.token) { out.push({ port: Number(run.port), token: String(run.token) }); tokens.set(Number(run.port), String(run.token)) }
68 } catch { /* a half-written run file */ }
69 }
70 } catch { /* no run folder yet */ }
71 return out
72}
73
74/**
75 * One poll of the hosts: new windows get a pane, closed ones lose it, moved
76 * revs bring a fresh tree. A sync asked while one runs is run once more after.
77 */
78async function sync($: EngineInterface): Promise<void> {
79 if (busy) { again = true; return }
80 busy = true
81 try {
82 const all = await hosts($)
83 const st = await read($, native)
84 const live = st.mine.length && all.some((h) => st.mine.includes(h.port)) ? all.filter((h) => st.mine.includes(h.port)) : all
85 const next: Record<string, NativeWindow> = {}
86 const missing = new Set(Object.keys(st.windows))
87 for (const h of live) {
88 const ours = Object.values(st.windows).filter((w) => w.port === h.port)
89 const known = ours.map((w) => `${w.id}:${w.rev}`).join(',')
90 const sizes = ours.map((w) => { const v = viewports.get(w.pane); return v ? `${w.id}:${v.cols}x${v.rows}` : '' }).filter(Boolean).join(',')
91 let res: { ok: boolean; status: number; text: string }
92 try { res = await $.http.fetch(`http://127.0.0.1:${h.port}/lip/native/sync?known=${encodeURIComponent(known)}&v=${encodeURIComponent(sizes)}`, { headers: { 'x-lets-token': h.token, 'x-lets-native': VERSION } }) } catch { continue }
93 if (!res.ok) continue
94 let r: { windows?: Array<{ id: string; unique: string; title: string; columns?: number; rows?: number; rev: number; closed?: boolean; tree?: TreeNode }> }
95 try { r = JSON.parse(res.text) } catch { continue }
96 for (const w of r.windows || []) {
97 const prev = st.windows[w.id]
98 next[w.id] = { id: w.id, pane: paneId(w.id), port: h.port, unique: w.unique, title: w.title, columns: w.columns || 56, rows: w.rows || 40, rev: w.rev, tree: w.tree ?? prev?.tree ?? null, closed: !!w.closed }
99 missing.delete(w.id)
100 }
101 }
102 for (const id of missing) { const w = st.windows[id]; if (w) next[id] = { ...w, closed: true } } // its host is gone or forgot it
103 const opened: NativeWindow[] = []
104 const closed: NativeWindow[] = []
105 for (const w of Object.values(next)) {
106 if (w.closed) { if (st.windows[w.id]) closed.push(w); delete next[w.id] } // a close is acted on once
107 else if (!st.windows[w.id]) opened.push(w)
108 }
109 await update($, native, (s) => ({ ...s, windows: next }))
110 for (const w of opened) {
111 // an open the mod makes on its own (a poll) may wait undrawn below the surface's width floor; one the person asks for is placed
112 const placed = await $.ui.open({ id: w.pane, title: w.title, columns: w.columns, rows: w.rows, focus: true })
113 if (!placed.isPlaced) $.ui.toast(`lets: the "${w.title}" pane is waiting (${placed.reason}); run /lets-native show to place it`, { timeoutMs: 8000 })
114 }
115 for (const w of closed) { viewports.delete(w.pane); await $.ui.close({ id: w.pane }) }
116 } finally {
117 busy = false
118 if (again) { again = false; void sync($) }
119 }
120}
121
122/** The person acted in a pane: tell the host, draw what it answers. */
123async function act($: EngineInterface, w: NativeWindow, key: string, kind: Kind, value?: string): Promise<void> {
124 let token = tokens.get(w.port)
125 if (!token) { await hosts($); token = tokens.get(w.port) }
126 if (!token) { $.ui.toast(`lets: no run file for the host on :${w.port}`); return }
127 let res: { ok: boolean; status: number; text: string }
128 try {
129 res = await $.http.fetch(`http://127.0.0.1:${w.port}/lip/native/action?w=${encodeURIComponent(w.id)}`, { method: 'POST', headers: json(token), body: JSON.stringify({ key, kind, ...(value !== undefined ? { value } : {}) }) })
130 } catch { $.ui.toast(`lets: the host on :${w.port} is not answering`); return }
131 if (!res.ok) { $.ui.toast(`lets: the action failed (${res.status})`); return }
132 let r: { ok?: boolean; rev?: number; tree?: TreeNode; closed?: boolean; error?: string }
133 try { r = JSON.parse(res.text) } catch { return }
134 await update($, native, (s) => {
135 const cur = s.windows[w.id]
136 if (!cur) return s
137 if (r.closed) { const windows = { ...s.windows }; delete windows[w.id]; return { ...s, windows } }
138 return { ...s, windows: { ...s.windows, [w.id]: { ...cur, rev: r.rev ?? cur.rev, tree: r.tree ?? cur.tree } } }
139 })
140 if (r.closed) { viewports.delete(w.pane); await $.ui.close({ id: w.pane }) }
141}
142
143/** The text a node shows, for a label: its string children, through nested Text. */
144function textOf(node: Tree): string {
145 if (typeof node === 'string') return node
146 return (node.c || []).map(textOf).join('')
147}
148
149/**
150 * A host tree (docs/NATIVE.md §3) as the surface's elements: every node is
151 * `{t, p, c}` or a string; presses, Enters and picks become actions. An
152 * element the surface lacks (Svg on the terminal, Input on mobile) draws as
153 * text so the pane still reads.
154 */
155function draw(table: Table, surface: string, node: Tree, on: (key: string, kind: Kind, value?: string) => void): RenderElement | string {
156 if (typeof node === 'string') return node
157 const p: Record<string, unknown> = { ...(node.p || {}) }
158 const key = String(p.key ?? '')
159 const kids = (node.c || []).map((c) => draw(table, surface, c, on)).filter((x) => x != null)
160 const T = LACKS[surface]?.has(node.t) ? undefined : table[node.t]
161 const Text = table.Text!
162 const text = (s: string, dim = true) => Text({ dimColor: dim, children: s })
163 switch (node.t) {
164 case 'Box': return (table.Box ?? Text)({ ...p, children: kids })
165 case 'Text': return Text({ ...p, children: kids })
166 case 'Button': return (table.Button ?? Text)({ ...p, label: typeof p.label === 'string' ? p.label : textOf(node), onPress: () => on(key, 'press') })
167 case 'Input': return T ? T({ ...p, onSubmit: (v: string) => on(key, 'submit', v) }) : text(`${p.label ? `${p.label}: ` : ''}${p.value ?? p.placeholder ?? ''}`)
168 case 'Select': return T ? T({ ...p, onSelect: (v: string) => on(key, 'select', v) }) : text(`${p.label ? `${p.label}: ` : ''}${p.value ?? ''}`)
169 case 'Svg': return T ? T(p) : text(String(p.alt || '[picture]'))
170 case 'Markdown': return T ? T(p) : text(String(p.text ?? ''), false)
171 case 'Code': return T ? T(p) : text(String(p.source ?? ''), false)
172 case 'Link': return T ? T(p) : text(`${p.label ?? p.href} <${p.href}>`, false)
173 default: return text(`[${node.t}]`)
174 }
175}
176
177/**
178 * Starts `/lets:lets <args>`, the lets plugin's own command, as if the person had typed it. Where the engine
179 * will not queue a command from here, the skill goes in as a prompt of theirs (the model then invokes it);
180 * failing both, a toast says so. Called from a clock tick, never from the `/lets` hook itself: from there the
181 * engine refuses both calls, since they would wait on the turn that hook is holding.
182 */
183async function startSkill($: EngineInterface, args: string, skill: string): Promise<void> {
184 let why: string
185 try { await $.command.run({ command: 'lets:lets', args }); return } catch (err) { why = String((err as Error)?.message || err) }
186 let installed = true
187 try { installed = (await $.command.list()).some((c) => c.name === 'lets:lets') } catch { /* the list is a courtesy */ }
188 if (!installed) { $.ui.toast('lets: the lets plugin is not installed here (no /lets:lets). /plugin marketplace add offensiveadhd/lets, then /plugin install lets@lets, and restart Claude Code.', { timeoutMs: 12000 }); return }
189 try { await $.prompt.submit({ text: skill, asUser: true }) } catch { $.ui.toast(`lets: could not start ${skill} (${why}); type it yourself`, { timeoutMs: 12000 }) }
190}
191
192/** The /lets-pane feed: every host's windows, the companion, the latest events. */
193async function poll($: EngineInterface): Promise<Feed> {
194 const next: Feed = { ...EMPTY, checkedAt: Date.now() }
195 try {
196 for (const run of await hosts($)) {
197 const origin = `http://127.0.0.1:${run.port}`
198 let res: { ok: boolean; text: string }
199 try { res = await $.http.fetch(`${origin}/lip/drain?cursor=pane&max=12`, { headers: { 'x-lets-token': run.token } }) } catch { continue }
200 if (!res.ok) continue
201 const r = JSON.parse(res.text)
202 const windows = (r.windows || []) as { unique: string; connected: boolean; mode?: string }[]
203 next.hosts.push({ port: run.port, windows: windows.length, connected: windows.filter((w) => w.connected).length, native: windows.filter((w) => w.mode === 'native').length, uniques: [...new Set(windows.map((w) => w.unique))] } as HostInfo)
204 for (const e of r.events || []) {
205 const data = JSON.stringify(e.data ?? {})
206 next.events.push({ t: e.type, from: String(e.from).replace(/^unique:/, ''), ts: e.ts, data: data.length > 90 ? data.slice(0, 90) + '…' : data } as FeedEvent)
207 }
208 try {
209 const p = await $.http.fetch(`${origin}/lip/persona`, { headers: { 'x-lets-token': run.token } })
210 if (p.ok) { const persona = JSON.parse(p.text); next.mood = persona.mood || next.mood; next.name = persona.name || next.name }
211 } catch { /* optional */ }
212 }
213 } catch (err) {
214 next.error = String((err as Error)?.message || err)
215 }
216 return next
217}
218
219export const register: Register = (on) => {
220 on('session.start', async ($, e, next) => {
221 await $.command.register({ name: 'lets', description: 'Open a lets unique (e.g. /lets graph, /lets graph native) — alias for /lets:lets', argumentHint: '<unique> [web|native] [intent] | list | sessions | store <query> | entity | plan' })
222 await $.command.register({ name: 'lets-pane', description: 'Show the lets pane: windows, companion mood, latest window events' })
223 await $.command.register({ name: 'lets-native', description: 'List the lets native windows of this session; "show" places their panes (a pane opened by a poll may wait undrawn); "sync" polls the hosts now' })
224 const started = await next(e)
225 // panes a reload left behind: the engine keeps them, the module's state says which windows they show
226 try {
227 const st = await read($, native)
228 const panes = await $.ui.panes()
229 for (const w of Object.values(st.windows)) if (!panes.some((p) => p.id === w.pane)) await $.ui.open({ id: w.pane, title: w.title, columns: w.columns, rows: w.rows })
230 } catch { /* drawn later */ }
231 $.clock.every(POLL_MS, async () => {
232 ticks++
233 const st = await read($, native)
234 if (Object.keys(st.windows).length || ticks % IDLE_EVERY === 0) await sync($)
235 if (ticks % FEED_EVERY === 0) {
236 const fresh = await poll($)
237 await update($, feed, (old) => {
238 const seen = new Set(old.events.map((x) => `${x.ts}${x.t}`))
239 const merged = [...old.events, ...fresh.events.filter((x) => !seen.has(`${x.ts}${x.t}`))].slice(-40)
240 return { ...fresh, events: merged }
241 })
242 }
243 })
244 return started
245 })
246
247 // the lets `open` tool answered: a native window wants its pane now, and its host is this session's
248 for (const tool of ['mcp__plugin_lets_lets__open', 'mcp__lets__open'] as const) {
249 on('tool.call', { tool }, async ($, e, next) => {
250 const r = await next(e)
251 try {
252 const text = (r as { text?: string }).text
253 const j = text ? JSON.parse(text) as { mode?: string; origin?: string } : null
254 if (j?.origin) {
255 const port = Number(new URL(j.origin).port)
256 if (port) await update($, native, (s) => (s.mine.includes(port) ? s : { ...s, mine: [...s.mine, port] }))
257 }
258 if (j?.mode === 'native') await sync($)
259 } catch { /* not a lets result */ }
260 return r
261 }).catch(($, e, next) => next(e)) // whatever went wrong here, the tool's own result stands
262 }
263
264 // the bare alias: `/lets <args>` runs the plugin's own command, `/lets:lets <args>`, as if the person had
265 // typed it (a plugin's command is namespaced `<plugin>:<name>`; a bare name is a mod's alone). A command's
266 // own text starts no turn, so the note this once left for Claude was read only at the next prompt; and from
267 // inside this hook the engine refuses `$.command.run` and `$.prompt.submit` alike (they would wait on the
268 // turn the hook holds), so the run starts from a clock tick, once this answer is out. The answer itself
269 // shows nothing: the skill's own run is the output, as if `/lets:lets <args>` had been typed (a forward
270 // line such as `lets → /lets:lets graph` read as a different command running, and was all one saw when
271 // nothing followed).
272 on('command.run', { command: 'lets' }, async ($, e) => {
273 const args = (e.args || '').trim() || 'list'
274 const skill = `/lets:lets ${args}`
275 $.clock.after(ALIAS_MS, () => startSkill($, args, skill))
276 return {}
277 })
278
279 on('command.run', { command: 'lets-pane' }, async ($) => {
280 await $.ui.open({ id: PANE, title: 'lets' })
281 return { text: 'lets pane opened.' }
282 })
283
284 on('command.run', { command: 'lets-native' }, async ($, e) => {
285 const args = e.args || ''
286 if (/\bsync\b/.test(args) || /\bshow\b/.test(args)) await sync($)
287 const st = await read($, native)
288 const list = Object.values(st.windows)
289 const placed: string[] = []
290 if (/\bshow\b/.test(args)) {
291 // the person asked: these opens are placed at any width, and the first one takes the keyboard
292 for (const [i, w] of list.entries()) { const r = await $.ui.open({ id: w.pane, title: w.title, columns: w.columns, rows: w.rows, ...(i === 0 ? { focus: true as const } : {}) }); if (r.isPlaced) placed.push(w.pane) }
293 }
294 let panes: readonly { id: string; isPlaced: boolean; isShown: boolean }[] = []
295 try { panes = await $.ui.panes() } catch { /* none */ }
296 const lines = list.map((w) => {
297 const p = panes.find((x) => x.id === w.pane)
298 const where = !p ? 'no pane' : !p.isPlaced ? 'pane waiting (run /lets-native show)' : p.isShown ? 'shown' : 'a tab'
299 return `${w.pane}: ${w.unique} "${w.title}" on :${w.port}, rev ${w.rev}${w.tree ? '' : ', no tree yet'}; ${where}`
300 })
301 const head = list.length ? `${list.length} native window(s)${placed.length ? `, ${placed.length} pane(s) placed` : ''}:` : 'No lets native window is open. /lets graph native opens one (lets 0.7.0+).'
302 return { text: list.length ? `${head}\n${lines.join('\n')}` : head }
303 })
304
305 // the person closed a native pane: the host drops the window; closed by this mod: the host already knows
306 on('ui.close', async ($, e, next) => {
307 if (!e.id.startsWith(PREFIX)) return next(e)
308 const r = await next(e)
309 viewports.delete(e.id)
310 const st = await read($, native)
311 const w = Object.values(st.windows).find((x) => x.pane === e.id)
312 if (w) {
313 await update($, native, (s) => { const windows = { ...s.windows }; delete windows[w.id]; return { ...s, windows } })
314 if (e.origin.kind === 'person') {
315 const token = tokens.get(w.port) || (await hosts($), tokens.get(w.port))
316 if (token) { try { await $.http.fetch(`http://127.0.0.1:${w.port}/lip/native/close?w=${encodeURIComponent(w.id)}`, { method: 'POST', headers: json(token), body: JSON.stringify({ reason: 'person' }) }) } catch { /* host gone */ } }
317 }
318 }
319 return r
320 }).catch(($, e, next) => next(e)) // a failure here must never keep a pane open
321
322 // a native window's pane: the host's tree, drawn with this surface's elements
323 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
324 if (!e.requestId.startsWith(PREFIX)) return next(e)
325 const cols = e.props.bodyColumns || e.viewport?.columns || 0
326 const rows = e.viewport?.rows || 0
327 if (cols) viewports.set(e.requestId, { cols, rows })
328 const table = $.ui.resolve(e) as unknown as Table
329 const st = await read($, native)
330 const w = Object.values(st.windows).find((x) => x.pane === e.requestId)
331 if (!w) return table.Text!({ dimColor: true, children: 'This lets window is gone. Close the pane, or /lets graph native for a new one.' })
332 if (!w.tree) return table.Text!({ dimColor: true, children: `${w.title}: waiting for the lets host on :${w.port}…` })
333 const drawn = draw(table, e.surface, w.tree, (key, kind, value) => { void act($, w, key, kind, value) })
334 return typeof drawn === 'string' ? table.Text!({ children: drawn }) : drawn
335 })
336
337 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
338 const { Box, Text } = $.ui.resolve(e)
339 const f = await read($, feed)
340 const st = await read($, native)
341 const rows = Math.max(3, (e.viewport?.rows ?? 24) - 9)
342 const ago = f.checkedAt ? `${Math.round((Date.now() - f.checkedAt) / 1000)}s ago` : 'never'
343 const nativeCount = Object.keys(st.windows).length
344 return (
345 <Box flexDirection="column">
346 <Text bold>◉ {f.name} · {f.mood}</Text>
347 {f.hosts.length === 0 && <Text dimColor>no lets host running — /lets graph starts one</Text>}
348 {f.hosts.map((h) => (
349 <Text>host :{h.port} · {h.connected}/{h.windows} window(s){h.native ? ` · ${h.native} native` : ''} {h.uniques.length ? `· ${h.uniques.join(', ')}` : ''}</Text>
350 ))}
351 <Text dimColor>{nativeCount ? `${nativeCount} native pane(s) in this session` : 'no native pane here — /lets graph native opens one'}</Text>
352 <Text dimColor>── events (checked {ago}) ──</Text>
353 {f.events.length === 0 && <Text dimColor>nothing yet</Text>}
354 {f.events.slice(-rows).map((ev) => (
355 <Text>
356 <Text dimColor>{new Date(ev.ts).toISOString().slice(11, 19)} </Text>
357 <Text color="magenta">{ev.t}</Text>
358 <Text dimColor> {ev.data}</Text>
359 </Text>
360 ))}
361 {f.error && <Text color="red">{f.error}</Text>}
362 </Box>
363 )
364 })
365}
366types/index.d.ts 37 lines1export type FeedEvent = { t: string; from: string; ts: number; data: string }
2export type HostInfo = { port: number; windows: number; connected: number; native: number; uniques: string[] }
3export type Feed = { hosts: HostInfo[]; events: FeedEvent[]; mood: string; name: string; checkedAt: number; error?: string }
4
5/** A live lets host, from its run file (`~/.lets/run/host-<pid>.json`). */
6export type HostRef = { port: number; token: string }
7
8/** One node of a host-rendered tree (docs/NATIVE.md §3): the element, its props, its children. */
9export type TreeNode = { t: string; p?: Record<string, unknown>; c?: Tree[] }
10export type Tree = TreeNode | string
11
12/** A native window this session draws as a pane. The host token is never kept here. */
13export type NativeWindow = {
14 id: string
15 pane: string
16 port: number
17 unique: string
18 title: string
19 columns: number
20 rows: number
21 rev: number
22 tree: TreeNode | null
23 closed: boolean
24}
25
26export type NativeState = {
27 windows: Record<string, NativeWindow>
28 /** Ports of the hosts this session's `open` tool answered from; empty means every live host. */
29 mine: number[]
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'lets-native': { feed: Feed; native: NativeState }
35 }
36}
37