SLOPSHOPPER

lets-native

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…

paneguardcommandtoastnetworktimer
v0.2.1MITupdated 2026-10-18offensiveadhd/lets/mods/lets-native
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lets-native
│ ┃ lets ✕ › fix the failing auth test and add an audit log call │ ┃ ◉ Doto · idle │ ┃ no lets host running — /lets graph starts ⏺ Read(src/auth.ts) │ ┃ one ⎿ Read 6 lines │ ┃ no native pane here — /lets graph native ⏺ Update(src/auth.ts) │ ┃ opens one ⎿ Added 2 lines, removed 1 line │ ┃ ── events (checked never) ── ⏺ Bash(bun test) │ ┃ nothing yet ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /lets │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · lets
◉ Doto · idle no lets host running — /lets graph starts one no native pane here — /lets graph native opens one ── events (checked never) ── nothing yet
README

lets-native

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.

CommandDoes
/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-paneopens 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)

Native panes

/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:

  1. polls every live host (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);
  2. opens one pane per window ($.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;
  3. posts the person's presses, Enters and picks back (POST /lip/native/action) and draws the tree the host answers, at once;
  4. closes the pane when the host says the window closed (Send to Claude's farewell, Claude's 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.

Install

/plugin install lets-native@lets

or node install.mjs --native from the engine checkout. Then restart Claude Code and run /lets graph native.

Develop

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.

Source 2 files
hooks/register.tsx 366 lines
1/**
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}
366
types/index.d.ts 37 lines
1export 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