SLOPSHOPPER

rustgis-tui

RustGIS in a Claude Code pane: rustgis-tui's layers, panels and real map picture, driven by you with keys and mouse and by Claude with its screen and send…

newpaneguardcommandtoasttool
v0.1.0MITupdated 2026-10-15helenkwok/rustgis-tui/claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rustgis-tui
│ ┃ RustGIS ✕ › fix the failing auth ╭───────────────────────────────────────────╮ │ ┃ RustGIS is not running: /rustgis starts it. │ rustgis-tui │ │ ⏺ Read(src/auth.ts) │ rustgis-tui or rustgis-cli is not on PATH │ │ ⎿ Read 6 lines ╰───────────────────────────────────────────╯ │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /rustgis │ ⎿ rustgis-tui: RustGIS is open. Click the pane to type into it; ` │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · RustGIS
RustGIS is not running: /rustgis starts it.
README

<h1 align="center">rustgis-tui</h1>

<a href="https://github.com/helenkwok/rustgis-tui/actions/workflows/test.yml"><img alt="tests" src="https://github.com/helenkwok/rustgis-tui/actions/workflows/test.yml/badge.svg"></a> <img alt="version 0.1.0" src="https://img.shields.io/badge/version-0.1.0-d97757"> <img alt="RustGIS 0.2.0" src="https://img.shields.io/badge/RustGIS-0.2.0-2563eb"> <img alt="Claude Code 2.1.300+" src="https://img.shields.io/badge/Claude%20Code-2.1.300%2B-d97757"> <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-3fb950"></a>

A terminal UI for RustGIS: layers, a real map picture, every RustGIS command and geoprocessing tool, attribute tables, identify, select, draw and measure, from the keyboard and the mouse. It also comes as a Claude Code mod: a pane in Claude Code that shows the same app, which you drive with keys and clicks while Claude drives the same running app with its own tools.

[!NOTE] Unofficial. Not affiliated with opengeos, the RustGIS project or Anthropic. It drives the rustgis-cli program through its MCP server (rustgis-cli mcp), the same commands an AI agent would use, and contains no RustGIS code. RustGIS is at 0.x and changes daily; this was built against RustGIS 0.2.0.

📥 Install

  1. RustGIS's command line, rustgis-cli 0.2.0, on your PATH. The RustGIS releases have it as its own download for macOS (rustgis-cli-0.2.0-macos-aarch64.tar.gz, -x86_64). Whether the Linux and Windows packages include it has not been checked. Or set RUSTGIS_CLI=/path/to/rustgis-cli.
  2. rustgis-tui itself (Rust 1.85 or later):

cargo install --locked --git https://github.com/helenkwok/rustgis-tui

  1. The Claude Code mod (optional; Claude Code 2.1.300 or later), from the helenkwok-mods marketplace:

claude plugin marketplace add helenkwok/gsd-status-mod claude plugin install rustgis-tui@helenkwok-mods

Then /rustgis in a session opens the pane. To try it without installing, see Try it from a clone.

✨ Features

  • The map as a real picture in Ghostty, kitty, WezTerm and iTerm2 (half blocks elsewhere, and always inside tmux), drawn by RustGIS at the pane's exact size. The pointer's longitude and latitude, the scale and a scale bar sit on the map's border.
  • Layers with a colour each: choose one and its shapes are highlighted on the map in that colour, the rest dimmed. The chosen layer is also RustGIS's active layer, so commands that take no layer act on it.
  • Every command and tool: : searches all of RustGIS's commands and geoprocessing tools (352 in 0.2.0), with their parameters as JSON.
  • A panel with three tabs: the chosen layer's details, messages, and its whole attribute table (every row: 300,000 rows open in about half a second).
  • Mouse: click to identify (every visible layer at the spot, the chosen one first), drag to pan, wheel to zoom around the pointer, right-click or right-drag to select.
  • Draw and measure: points, lines, polygons, rectangles and circles into a Graphics layer; distance and area on the sphere.
  • The AI assistant (a), through rustgis-cli ask with your own model provider.
  • The Claude Code pane: the same app in a pane, driven by you and by Claude together. See The Claude Code pane.

🕹️ Using it

rustgis-tui # the World Atlas sample rustgis-tui --data parcels.geojson # your data (--data more than once, or --project p.rgis) rustgis-tui --safe # no file or network access (see What it can touch)

Three panes: Layers on the left, the Map, and the panel under it. The active pane has a thick cyan border; Tab and Shift+Tab move between them, a click makes a pane active, and e gives it the whole screen. Keys depend on the active pane. ? lists them all: the list is built from the same table the keys run from, and so is the footer.

KeyWhereWhat it does
arrows, j kLayersChoose a layer
Space, click ●LayersShow or hide it
Enter, double-clickLayersZoom to it
arrows, dragMapPan
wheel, + -, 0anywhereZoom (the wheel at the pointer), full extent
click, EnterMapIdentify (Enter: at the crosshair)
right-click, xMapSelect (right-drag: by box; Shift adds)
Shift+arrows, EscMapMove the crosshair, hide it
f, zanywhereFit every visible layer, zoom to the chosen one
hanywhereHighlight the chosen layer on the map, on or off
d then p l o r canywhereDraw a point, line, polygon, rectangle or circle
d then m / ManywhereMeasure distance / area
1 2 3, tanywhereDetails, Messages, Attributes (t: the attribute table)
arrows, PgUp/PgDn, g G, ←→panelScroll, first and last row, more columns
: or /anywhereSearch every command and tool
g, bMapGlobe or flat, next basemap
u U, s, a, r, qanywhereUndo, redo, save as, ask the assistant, redraw, quit

While drawing: click or Space adds a point, double-click or Enter finishes, Backspace removes the last point, Esc cancels. The map title shows the length or area so far.

🧩 The Claude Code pane

The mod runs rustgis-tui --serve (no terminal of its own) and shows its screen in a Claude Code pane, with the real map picture laid over the map area.

  • /rustgis opens the pane and starts RustGIS; /rustgis close, or closing the pane, stops it.
  • You: click the pane, then type and click as in the terminal. In the pane ` `` (backtick) stands in for Escape, which Claude Code keeps for itself.
  • Claude: two tools, screen (the whole screen as text, and the path of the map picture) and send (keys, typed text, clicks, drags and the wheel, then the new screen). They go into the same running app, through the same input queue as yours, so what Claude does you see, and the other way round.
  • Idle, it costs about 0.1% CPU for the app and about 2% for the pane's frame checks; it checks often for two seconds after any input or new frame and four times a second otherwise.

The pane needs a terminal with kitty graphics and unicode placeholders for the picture: Ghostty or kitty.

🧠 How it works

  • One RustGIS, through MCP. rustgis-tui starts rustgis-cli mcp and talks to it over stdio: the same command registry the desktop app's ribbon and the AI assistant use. The map is RustGIS's own view_map picture at the pane's size; a click is turned into longitude and latitude from the view's extent (Web Mercator).
  • Highlights are drawn by rustgis-tui into the picture, not by RustGIS: the layer's shapes are exported once as GeoJSON to a temporary file, read and deleted. Nothing in your project changes.
  • Attribute tables come from RustGIS's Export Features to a temporary CSV (deleted after reading), because the MCP replies are cut at about 8,000 characters. With --safe it falls back to reading at most 200 rows.
  • Serve mode (--serve DIR, for the pane): each frame is written to DIR/frame.json (the screen as cells, the same as text, and where the map sits) with the map as DIR/map-N.png, and input is read from DIR/in/. While a popup is open the map is drawn in the cells instead, so the popup shows on top.

🔒 What it can touch (reach)

  • rustgis-tui starts rustgis-cli mcp --yes, which lets RustGIS read and write files and use the network when a command asks for it (web basemaps, Export, Save As, Add Data from a URL). --safe leaves --yes out: RustGIS then refuses those commands, and the full attribute table, highlights and the assistant are off.
  • It writes temporary files in the system temp folder (rustgis-tui-<pid>-…) and deletes them after reading.
  • The assistant (a) runs rustgis-cli ask with whatever model provider your environment configures (RUSTGIS_ASSISTANT_PROVIDER, RUSTGIS_ASSISTANT_MODEL, or an API key). It may change the project; u undoes.
  • The mod runs rustgis-tui --serve as a child process, reads and writes its frames and input under /tmp/rustgis-tui-pane-<random> (removed when it stops), and makes no network calls of its own. claude plugin validate claude-mod lists every hook and $ call it makes.

🧪 Try it from a clone

git clone https://github.com/helenkwok/rustgis-tui ~/rustgis-tui cd ~/rustgis-tui cargo run --release # the terminal UI claude --plugin-dir ~/rustgis-tui/claude-mod # the mod, for one session (needs rustgis-tui on PATH)

Checks:

cargo test # key map, CSV, geometry, serve-mode input claude plugin validate claude-mod claude plugin test claude-mod python3 docs/tools/screenshots.py # redraw the README screenshots (needs Pillow)

The images are drawn from serve-mode frames, the same ones the pane shows, not captured from a terminal; the window frame and the orange notes are added.

❓ FAQ

The map is blocky. The terminal has no image support, or it runs inside tmux (where rustgis-tui uses half blocks on purpose: the image query would eat the first key press). Ghostty, kitty, WezTerm and iTerm2 show the real picture.

A click on the map says to switch to the flat map. Clicks map to coordinates on the flat (Web Mercator) map only; g switches from the globe.

A plain drag no longer selects text in the terminal. The app takes the mouse. Hold Shift (Ghostty) to select text.

Where does the pane go? /rustgis opens it docked beside the conversation in a fullscreen terminal 110 columns or wider, and above the prompt otherwise. Close it with /rustgis close or Claude Code's own close mark.

🚧 Not handled yet

  • A drag pans when you let go, not while you drag: each move redraws the whole picture.
  • Clicks on the globe.
  • The crosshair shows as ticks on the map's border in image terminals (a character over the picture breaks it there).
  • The mod has not been tried on Linux or Windows.
  • Binary releases: for now cargo install builds it.

📝 Changelog and license

Release notes: CHANGELOG.md. MIT, see LICENSE. Not affiliated with opengeos, RustGIS or Anthropic. RustGIS is © its authors under the Apache License 2.0.

Source 3 files
hooks/register.tsx 273 lines
1// RustGIS in a pane. rustgis-tui runs in serve mode (`--serve DIR`, no terminal of its own): it
2// writes each frame to DIR (screen cells for a Raster, plain text, the map PNG and where it sits)
3// and reads input files from DIR/in. This pane shows the frame with the real map laid over it,
4// and sends the person's keys and clicks; the `screen` and `send` tools let Claude drive the same
5// running app. One app, one input queue, so the two never disagree.
6import { atom, read, update } from 'claude-code'
7import type { Register } from 'claude-code'
8
9const PANE = 'rustgis'
10// One folder per loaded copy of the mod, so two sessions never share one; removed when it stops.
11const DIR = `/tmp/rustgis-tui-pane-${Math.random().toString(36).slice(2, 10)}`
12const shown = atom({ plugin: 'rustgis-tui', key: 'gen' } as const, 0)
13
14type MapRect = { x: number; y: number; w: number; h: number; file: string }
15type Frame = { gen: number; cols: number; rows: number; cells: string; text: string[]; map: MapRect | null }
16
17let frame: Frame | null = null
18let running = false
19let seq = 0
20let mounted = '' // the layout the pane last drew; a frame with the same one is blitted
21let asked = '' // the size last asked of the app
22let pointer: { x: number; y: number } | null = null
23let lively = 0 // when input or a new frame last came: frames are checked often for 2 s after
24
25const layout = (f: Frame) => `${f.cols}x${f.rows}:${f.map ? [f.map.x, f.map.y, f.map.w, f.map.h].join(',') : '-'}`
26
27async function send($: any, events: unknown[]) {
28  seq += 1
29  lively = Date.now()
30  const name = `${String(Date.now()).padStart(15, '0')}-${String(seq).padStart(6, '0')}.json`
31  await $.fs.write(`${DIR}/in/${name}`, JSON.stringify(events))
32}
33
34// Start rustgis-tui once, and watch its frames for as long as it runs.
35async function start($: any) {
36  if (running) return
37  running = true
38  frame = null
39  mounted = ''
40  asked = ''
41  const found = await $.process.run(['/bin/sh', '-lc', 'command -v rustgis-tui; command -v rustgis-cli'])
42  const [tui, cli] = found.stdout.trim().split('\n')
43  if (!tui || !cli) {
44    running = false
45    $.ui.toast('rustgis-tui or rustgis-cli is not on PATH')
46    return
47  }
48  await $.process.run(['/bin/rm', '-rf', DIR])
49  void (async () => {
50    const app = $.process.spawn({ argv: [tui, '--serve', DIR], env: { RUSTGIS_CLI: cli } })
51    for await (const piece of app) {
52      if (piece.stream === 'stderr') $.ui.log(`rustgis-tui: ${piece.text}`, { to: 'debug' })
53    }
54    running = false
55    await $.process.run(['/bin/rm', '-rf', DIR])
56  })()
57  void (async () => {
58    let last = ''
59    while (running) {
60      await $.clock.sleep(Date.now() - lively < 2000 ? 40 : 250)
61      let gen = ''
62      try {
63        gen = await $.fs.read(`${DIR}/gen`)
64      } catch {
65        continue // not started yet
66      }
67      if (gen === last) continue
68      last = gen
69      lively = Date.now()
70      let f: Frame
71      try {
72        f = JSON.parse(await $.fs.read(`${DIR}/frame.json`))
73      } catch {
74        continue
75      }
76      const before = frame
77      frame = f
78      // Same layout: swap the cells and the picture in place. Otherwise draw the pane again.
79      let redraw = layout(f) !== mounted
80      if (!redraw) {
81        const cells = await $.ui.blit({ requestId: PANE, key: 'screen', cells: f.cells })
82        if ('deny' in cells) redraw = true
83        if (!redraw && f.map && f.map.file !== before?.map?.file) {
84          const pic = await $.ui.blit({ requestId: PANE, key: 'map', source: { file: f.map.file, format: 'png' } })
85          if ('deny' in pic) redraw = true
86        }
87      }
88      if (redraw) await update($, shown, () => f.gen)
89    }
90    frame = null
91    mounted = ''
92    await update($, shown, n => n + 1)
93  })()
94}
95
96async function stop($: any) {
97  if (running) await send($, [{ quit: true }])
98  running = false
99}
100
101// Wait (up to `ms`) for a frame newer than `gen`.
102async function settle($: any, gen: number, ms: number) {
103  for (let t = 0; t < ms && (frame?.gen ?? 0) <= gen; t += 50) await $.clock.sleep(50)
104}
105
106function screenText() {
107  if (!frame) return 'RustGIS is not running. Call send (or /rustgis) to start it.'
108  const m = frame.map
109  const where = m
110    ? `The map picture is ${m.file}, drawn over columns ${m.x}-${m.x + m.w - 1}, rows ${m.y}-${m.y + m.h - 1}; those cells are blank in the text.`
111    : 'No map picture.'
112  return `Frame ${frame.gen}, ${frame.cols}x${frame.rows} cells (x = column, y = row, from 0).\n${where}\n\n${frame.text.join('\n')}`
113}
114
115export const register: Register = on => {
116  on('session.start', async ($, e, next) => {
117    await $.command.register({
118      name: 'rustgis',
119      description: 'Open RustGIS in a pane you and Claude both drive (/rustgis close stops it)',
120      argumentHint: '[close]',
121    })
122    await $.tool.register({
123      name: 'screen',
124      description:
125        "Read the RustGIS pane: rustgis-tui's whole screen as text, and the path of the map picture shown over the map area. The person sees and drives the same app.",
126      inputSchema: { type: 'object', properties: {} },
127    })
128    await $.tool.register({
129      name: 'send',
130      description:
131        "Drive RustGIS in the pane (the same running app the person sees and drives), then read the screen. Keys use rustgis-tui's own spelling: a character (\"j\", \"U\", \":\"), or Enter, Esc, Tab, BackTab, Backspace, Space, Up/Down/Left/Right, Home, End, PageUp, PageDown, with S- or C- for Shift or Ctrl (\"S-Left\"). x is the column and y the row on screen, from 0, as in the screen text. Starts RustGIS if it is not running.",
132      inputSchema: {
133        type: 'object',
134        properties: {
135          keys: { type: 'array', items: { type: 'string' }, description: 'Keys, in order' },
136          text: { type: 'string', description: 'Characters to type, one key each (palette, prompts)' },
137          click: {
138            type: 'object',
139            properties: {
140              x: { type: 'integer' },
141              y: { type: 'integer' },
142              button: { type: 'string', enum: ['left', 'right'] },
143              shift: { type: 'boolean' },
144              double: { type: 'boolean' },
145            },
146            required: ['x', 'y'],
147          },
148          drag: {
149            type: 'object',
150            properties: {
151              from: { type: 'array', items: { type: 'integer' }, minItems: 2, maxItems: 2 },
152              to: { type: 'array', items: { type: 'integer' }, minItems: 2, maxItems: 2 },
153              button: { type: 'string', enum: ['left', 'right'] },
154            },
155            required: ['from', 'to'],
156          },
157          wheel: {
158            type: 'object',
159            properties: { x: { type: 'integer' }, y: { type: 'integer' }, dir: { type: 'string', enum: ['up', 'down'] }, times: { type: 'integer' } },
160            required: ['x', 'y', 'dir'],
161          },
162        },
163      },
164    })
165    return next(e)
166  })
167
168  on('command.run', { command: 'rustgis' }, async ($, e) => {
169    if (String(e.args ?? '').trim() === 'close') {
170      await stop($)
171      await $.ui.close({ id: PANE })
172      return { text: 'RustGIS stopped.' }
173    }
174    await start($)
175    await $.ui.open({ id: PANE, title: 'RustGIS', focus: true })
176    return { text: 'RustGIS is open. Click the pane to type into it; ` stands in for Escape there.' }
177  })
178
179  on('ui.close', { id: PANE }, async ($, e, next) => {
180    await stop($)
181    return next(e)
182  })
183
184  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
185    await read($, shown)
186    if (e.surface !== 'terminal') {
187      const { Text } = $.ui.resolve(e)
188      return <Text>RustGIS draws in the terminal.</Text>
189    }
190    const { Box, Text, Raster, Image, Client } = $.ui.resolve(e)
191    // Ask the app for the pane's size; it draws the next frame to fit.
192    const size = `${e.props.bodyColumns}x${e.props.scroll.bodyRows}`
193    if (running && size !== asked && e.props.bodyColumns > 0) {
194      asked = size
195      void send($, [{ size: [e.props.bodyColumns, e.props.scroll.bodyRows] }])
196    }
197    if (!frame) return <Text dimColor>{running ? 'Starting RustGIS…' : 'RustGIS is not running: /rustgis starts it.'}</Text>
198    const f = frame
199    mounted = layout(f)
200    return (
201      <Box position="relative" width={f.cols} height={f.rows}>
202        <Raster key="screen" columns={f.cols} rows={f.rows} cells={f.cells} />
203        {f.map && (
204          <Box position="absolute" top={f.map.y} left={f.map.x}>
205            <Image key="map" source={{ file: f.map.file, format: 'png' }} columns={f.map.w} rows={f.map.h} alt="map" />
206          </Box>
207        )}
208        <Box position="absolute" top={0} left={0}>
209          <Client key="input" module="./surface.tsx" width={f.cols} height={f.rows} />
210        </Box>
211      </Box>
212    )
213  })
214
215  // The person's keys and pointer, batched by the input layer (surface.tsx).
216  on('ui.message', { requestId: PANE }, async ($, e) => {
217    const events = (e.data as { events?: { mouse?: string; x?: number; y?: number }[] })?.events ?? []
218    for (const ev of events) if (ev.mouse && ev.x !== undefined && ev.y !== undefined) pointer = { x: ev.x, y: ev.y }
219    if (events.length && running) await send($, events)
220    return {}
221  })
222
223  // The wheel arrives as a scroll of the pane: hand it to the app where the pointer is.
224  on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
225    if (running && pointer && e.by !== 0) {
226      const n = Math.min(10, Math.abs(Math.round(e.by)) || 1)
227      await send($, Array.from({ length: n }, () => ({ mouse: 'scroll', dir: e.by < 0 ? 'up' : 'down', ...pointer })))
228    }
229    return next(e)
230  })
231
232  on('tool.call', { tool: 'mcp__rustgis-tui__screen' }, async () => ({ result: screenText() }))
233
234  on('tool.call', { tool: 'mcp__rustgis-tui__send' }, async ($, e) => {
235    // the tool's own arguments sit on the event beside `tool`
236    const input = e as unknown as {
237      keys?: string[]
238      text?: string
239      click?: { x: number; y: number; button?: string; shift?: boolean; double?: boolean }
240      drag?: { from: number[]; to: number[]; button?: string }
241      wheel?: { x: number; y: number; dir: string; times?: number }
242    }
243    if (!running) await start($)
244    for (let t = 0; t < 8000 && !frame; t += 100) await $.clock.sleep(100)
245    const gen = frame?.gen ?? 0
246    const events: unknown[] = []
247    for (const k of input.keys ?? []) events.push({ key: k })
248    for (const ch of Array.from(input.text ?? '')) events.push({ key: ch === ' ' ? 'Space' : ch })
249    if (input.click) {
250      const { x, y, button = 'left', shift } = input.click
251      for (let i = 0; i < (input.click.double ? 2 : 1); i++) {
252        events.push({ mouse: 'down', x, y, button, shift }, { mouse: 'up', x, y, button, shift })
253      }
254    }
255    if (input.drag) {
256      const [x0, y0] = input.drag.from
257      const [x1, y1] = input.drag.to
258      const button = input.drag.button ?? 'left'
259      events.push({ mouse: 'down', x: x0, y: y0, button }, { mouse: 'move', x: x1, y: y1, button }, { mouse: 'up', x: x1, y: y1, button })
260    }
261    if (input.wheel) {
262      const { x, y, dir, times = 1 } = input.wheel
263      for (let i = 0; i < Math.min(times, 20); i++) events.push({ mouse: 'scroll', x, y, dir })
264    }
265    if (events.length) await send($, events)
266    await settle($, gen, 3000)
267    await $.clock.sleep(150) // a command's redraw can take a second frame
268    return { result: screenText() }
269  }).catch(($, e, next) => ({ result: `RustGIS: the send failed (${JSON.stringify(next.error)}).
270
271${screenText()}` }))
272}
273
hooks/surface.tsx 54 lines
1// The RustGIS pane's input layer: a transparent region over the screen that turns the person's
2// keys and pointer into rustgis-tui's input events and posts them, batched, to the hooks module.
3import type { ClientKeyEvent, ClientModule } from 'claude-code'
4
5type State = { queue: unknown[] }
6
7const NAMES: Record<string, string> = {
8  up: 'Up',
9  down: 'Down',
10  left: 'Left',
11  right: 'Right',
12  return: 'Enter',
13  backspace: 'Backspace',
14  delete: 'Delete',
15  pageup: 'PageUp',
16  pagedown: 'PageDown',
17  home: 'Home',
18  end: 'End',
19  ' ': 'Space',
20}
21
22// A key as rustgis-tui's key map spells it ("j", "S-Left", "C-c", "BackTab").
23function spell(k: ClientKeyEvent): string | null {
24  if (k.key === 'tab') return k.shift ? 'BackTab' : 'Tab'
25  const name = NAMES[k.key]
26  if (name) return k.shift && name !== 'Space' ? `S-${name}` : name
27  if ([...k.key].length !== 1) return null
28  return k.ctrl ? `C-${k.key}` : k.key
29}
30
31const Input: ClientModule<null, State> = (_props, surface) => {
32  if (surface.state === undefined) {
33    const queue: unknown[] = []
34    let at = ''
35    surface.setState({ queue })
36    surface.onKey(k => {
37      const key = spell(k)
38      if (key) queue.push({ key })
39    })
40    surface.onPointer(p => {
41      if (p.type === 'enter' || p.type === 'leave') return
42      if (p.type === 'move' && `${p.x},${p.y},${p.button}` === at) return // same cell
43      at = `${p.x},${p.y},${p.button}`
44      queue.push({ mouse: p.type, x: p.x, y: p.y, button: p.button, shift: p.shift })
45    })
46    surface.every(20, () => {
47      if (queue.length) surface.post({ events: queue.splice(0) as never })
48    })
49  }
50  return surface.elements.Box({ width: surface.columns, height: surface.rows })
51}
52
53export default Input
54
types/index.d.ts 9 lines
1/** The number of the frame the pane last drew in full (it redraws when this changes). */
2export type FrameNumber = number
3
4declare module 'claude-code' {
5  interface PluginState {
6    'rustgis-tui': { gen: FrameNumber }
7  }
8}
9