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…

<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-cliprogram 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.
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.cargo install --locked --git https://github.com/helenkwok/rustgis-tui
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.
: searches all of RustGIS's commands and geoprocessing tools (352 in 0.2.0), with their parameters as JSON.a), through rustgis-cli ask with your own model provider.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.
| Key | Where | What it does |
|---|---|---|
arrows, j k | Layers | Choose a layer |
Space, click ● | Layers | Show or hide it |
| Enter, double-click | Layers | Zoom to it |
| arrows, drag | Map | Pan |
wheel, + -, 0 | anywhere | Zoom (the wheel at the pointer), full extent |
| click, Enter | Map | Identify (Enter: at the crosshair) |
right-click, x | Map | Select (right-drag: by box; Shift adds) |
| Shift+arrows, Esc | Map | Move the crosshair, hide it |
f, z | anywhere | Fit every visible layer, zoom to the chosen one |
h | anywhere | Highlight the chosen layer on the map, on or off |
d then p l o r c | anywhere | Draw a point, line, polygon, rectangle or circle |
d then m / M | anywhere | Measure distance / area |
1 2 3, t | anywhere | Details, Messages, Attributes (t: the attribute table) |
arrows, PgUp/PgDn, g G, ←→ | panel | Scroll, first and last row, more columns |
: or / | anywhere | Search every command and tool |
g, b | Map | Globe or flat, next basemap |
u U, s, a, r, q | anywhere | Undo, 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 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. `` (backtick) stands in for Escape, which Claude Code keeps for itself.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.The pane needs a terminal with kitty graphics and unicode placeholders for the picture: Ghostty or kitty.
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).--safe it falls back to reading at most 200 rows.--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.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.rustgis-tui-<pid>-…) and deletes them after reading.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.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.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.
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.
cargo install builds it.Release notes: CHANGELOG.md. MIT, see LICENSE. Not affiliated with opengeos, RustGIS or Anthropic. RustGIS is © its authors under the Apache License 2.0.
hooks/register.tsx 273 lines1// 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}
273hooks/surface.tsx 54 lines1// 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
54types/index.d.ts 9 lines1/** 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