OpenSearch & Elasticsearch Dev Tools-style console in a Claude Code side pane: Claude injects queries, you edit and run them (read-only).

A Kibana / OpenSearch Dashboards Dev Tools-style console for OpenSearch and Elasticsearch that lives in a Claude Code side pane. Claude writes queries into it, you read, edit and run them, and Claude reads the response back. Works with OpenSearch and Elasticsearch through the REST API they share, and every write asks you first.

When you debug a search index with an agent, the agent usually runs curl itself and you squint at JSON in the transcript. This mod keeps you in the loop instead:
1. You ask, Claude writes the query into your console. It shows up in the pane on the right, ready to read and edit.

2. You press ▶. Claude reads the response from the console's response file and explains it, as in the screenshot at the top.
| Editor | Dev Tools syntax (METHOD path + JSON body, many requests per file), JSON colors, auto-indent and bracket pairing, selection, copy / cut / paste, undo / redo, format, autocomplete for methods, index names, endpoints, query parameters and query DSL keys |
| Run | Click ▶ in the gutter next to a request, or ctrl+r / ctrl+enter |
| Response | Colored, foldable JSON (click, Enter, ←/→, e/c expand/collapse all, 1–9 fold to depth), mouse-wheel scroll, y to copy |
| Find | Search the response; matches are highlighted and collapsed parents open by themselves; n/N next/previous |
| Servers | Any number of clusters in a ▾ menu, tagged production / staging / development / local; passwords from the file, an environment variable or a command |
| Agents | One JSON file per console, so several sessions or agents never overwrite each other; a tool documents the protocol for the model |
| Server menu | Write lock | Find in the response |
|---|---|---|
![]() | ![]() | ![]() |
| Folding | Built-in help |
|---|---|
![]() | ![]() |
All screenshots come from a throwaway demo session against made-up data (
demo/mock_opensearch.py), not a real cluster or terminal.
claude plugin marketplace add shohamtal/cc-mod-opensearch-console
claude plugin install opensearch-console@cc-mod-opensearch-console --scope user
Or from inside a Claude Code session:
/plugin marketplace add shohamtal/cc-mod-opensearch-console
/plugin install opensearch-console@cc-mod-opensearch-console
Then type /opensearch to toggle the pane.
Requirements: Claude Code with function-hook mods (2.1.29x or newer) and curl on your PATH.
Platforms:
ctrl+v reads the clipboard with pbpaste, wl-paste or xclip; your terminal's own paste works everywhere.curl.exe ships with Windows 10 and 11, and the mod runs it from System32 by full path. Without it, the mod uses the curl on your PATH.%USERPROFILE%\.claude\mods\opensearch when HOME is unset, and ~ in paths means the same folder.\ or /, and C:\… paths are absolute.passwordEnv is read with System32\cmd.exe /d /c set (never a printenv from PATH), which allows only plain variable names.ctrl+v uses PowerShell's Get-Clipboard.System32\clip.exe. Under WSL, install wl-copy or xclip for copying, or the terminal's own clipboard route (OSC 52) is used.passwordCommand runs as given, so use a Windows command there.git clone git@github.com:shohamtal/cc-mod-opensearch-console.git
claude --plugin-dir ./cc-mod-opensearch-console
Or load it in every session with CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
On first use the mod creates ~/.claude/mods/opensearch/servers.json with one localhost example. Add as many servers as you like; the pane lists them in its ▾ server menu:
{
"servers": [
{ "id": "local", "name": "Local", "host": "localhost", "port": 9200, "tag": "local" },
{ "id": "staging", "name": "Staging", "host": "search-staging.example.com", "port": 443, "https": true,
"username": "reader", "passwordEnv": "OS_STAGING_PASSWORD", "tag": "staging" },
{ "id": "prod-eu", "name": "Production EU", "host": "search.example.com", "port": 443, "https": true,
"username": "reader", "passwordCommand": ["security", "find-generic-password", "-s", "os-prod", "-w"], "tag": "production" }
]
}
| Field | Meaning |
|---|---|
id | Stable key, stored in session files (defaults to name) |
name | What the menu shows |
host, port, https | Where to connect (port defaults to 9200, https to false) |
insecure | Skip the certificate check (self-signed certs, port-forwards, Elasticsearch 8's default HTTPS) |
username + one of password, passwordEnv (an environment variable), passwordCommand (argv whose output is the password: Keychain, 1Password, pass) | Basic auth |
tag | production, staging, development or local. Production is shown in red |
path, headers, indices | URL prefix behind a proxy, extra headers (for example Authorization: ApiKey …), extra autocomplete names |
Older configs with a single url field still work.
ps. curl runs with -q, so it never reads ~/.curlrc.chmod 600). It never leaves your machine.Type /opensearch and ask Claude for a query, e.g. "find products from brand Northpeak that mention trail, with a brand aggregation". Claude writes the query into the console, you check it and press ▶, and Claude reads the response.
Everything the pane shows is backed by plain files, so the agent needs no special API:
| File | What it holds |
|---|---|
~/.claude/mods/opensearch/sessions/<name>.json | { server, cursorRow, query, lastRun }, which is the editor text and which request ▶ runs |
~/.claude/mods/opensearch/sessions/<name>.response.json | The last response body (pretty JSON) |
~/.claude/mods/opensearch/servers.json | Clusters and credentials (any path via --configfilepath) |
~/.claude/mods/opensearch/indices.json | Cached index and alias names for autocomplete, per server URL |
Writes to these files show up in the pane within a second, and your edits are written back.
/opensearch --filepath investigation-42.json --configfilepath ~/work/opensearch-servers.json
Each console keeps its own query, server and last response. Relative names live under ~/.claude/mods/opensearch/sessions/. Agents can do the same through the mcp__opensearch-console__console tool (action: open / close / status, filepath, configfilepath); its description tells the model the whole protocol.
The console sends only requests that read:
GET and HEADPOST to _search, _msearch, _count, _mget, _explain, _validate, _field_caps, _analyze, _termvectors, _mtermvectors, _rank_eval, search templates, scroll and point-in-time (OpenSearch _search/point_in_time, Elasticsearch _pit)DELETE only to clear a scroll or point-in-timeAnything else (PUT, PATCH, _bulk, _doc, _update, _delete_by_query, _reindex, _aliases, _close, …) opens a prompt that shows the exact request as it will be sent:
_bulk actions (for example 1 index, 2 delete).A body that repeats a JSON key is refused, since only the last copy would count.
The prompt then offers:
production, nor for one that was tagged production earlier in the session. 🔓 locks the server again.Unlocks live in memory only. No file the console reads can unlock writes, so an agent editing servers.json can't either.
Credentials ask first. A server that sends a password or headers asks once before its first request, "Send credentials to <server>? https://… gets user reader, password from $OS_PW". It asks again whenever the server's host or credential source changes in the config, so a config change can't send your secret somewhere new silently.
The guard lives in hooks/lib.ts (writeGuard) and is covered by tests. Path tricks are refused, so the guard always judges the exact path curl sends:
# fragments%-encoding. or .. segmentsRequest bodies go as data-raw, so a body starting with @ is never read from a local file. Use a read-only cluster user where you can.
The console treats its config and its own store as yours. An agent with file access could edit them; the prompts above limit what that can do. A deny rule in ~/.claude/settings.json makes Claude Code refuse edits to these paths through its file tools:
{
"permissions": {
"deny": [
"Edit(~/.claude/mods/opensearch/*.json)",
"Edit(~/.claude/plugins/store/**)",
"Edit(~/.curlrc)"
]
}
}
Edit rules cover Claude Code's file-editing tools, not shell commands. If agents can run Bash without asking you, they can still write these files, so keep Bash on approval (or deny writes there too). Agents still write their session files under ~/.claude/mods/opensearch/sessions/.
| Where | Keys |
|---|---|
| Editor | ctrl+r run · alt+z / alt+y undo / redo (or ↶ ↷; ctrl+z suspends Claude Code) · ctrl+l format · shift+arrows / drag select · ctrl+a select all · ctrl+c / ctrl+x / ctrl+v · ctrl+k kill to end of line · Tab / Enter accept autocomplete |
| Response | ↑↓ j k · PgUp / PgDn · g / G · Enter / Space / click fold · ←/→ · e / c · 1–9 · / or ctrl+f find · n / N · y copy |
| Anywhere | Mouse wheel scrolls the half under the pointer · Esc returns to the prompt · /opensearch toggles the pane |
claude plugin validate . # what the engine will load
claude plugin test . # unit + render tests
python3 demo/mock_opensearch.py 9200 & # fake cluster with demo data
claude --plugin-dir .
/opensearch --filepath demo.json --configfilepath ./demo/servers.json
Layout: hooks/register.tsx (events, pane, files, curl), hooks/editor.tsx and hooks/viewer.tsx (surface modules that draw the two halves), hooks/lib.ts (parsing, JSON tree, search, read-only guard).
MIT
hooks/register.tsx 1209 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { OsConfirm, OsPending, OsResult, OsServer, OsUnlock, OsView } from '../types'
5import {
6 ancestorsOpen,
7 buildLines,
8 curlConfig,
9 END,
10 findMatches,
11 foldablePaths,
12 formatAt,
13 highlight,
14 parseCurlOutput,
15 parseJson,
16 pretty,
17 rawLines,
18 requestAt,
19 type Line,
20 type ServerCreds,
21 parseArgs,
22 writeGuard,
23 normalizeServer,
24 baseUrl,
25 tagColor,
26 PASTE_COMMANDS,
27 COPY_COMMANDS,
28 credentialSource,
29 credentialFingerprint,
30 uniqueServers,
31 duplicateKey,
32 sentBody,
33 wrapRows,
34 asciiView,
35 pageFrom,
36 toSlash,
37 isAbsolutePath,
38 homeOf,
39 fromCmdSet,
40 ENV_NAME,
41 type Server,
42} from './lib'
43
44const PANE = 'opensearch'
45// The longest body the write prompt shows in full; longer ones can't be sent from a prompt.
46const BODY_LIMIT = 20_000
47const query = atom({ plugin: 'opensearch-console', key: 'query' } as const, '')
48const rev = atom({ plugin: 'opensearch-console', key: 'rev' } as const, 0)
49const cursorRow = atom({ plugin: 'opensearch-console', key: 'cursorRow' } as const, 0)
50const result = atom({ plugin: 'opensearch-console', key: 'result' } as const, null)
51const running = atom({ plugin: 'opensearch-console', key: 'running' } as const, false)
52const server = atom({ plugin: 'opensearch-console', key: 'server' } as const, '')
53const servers = atom({ plugin: 'opensearch-console', key: 'servers' } as const, [])
54const EMPTY_VIEW: OsView = { collapsed: [], top: 0, cursor: 0, search: '', match: 0 }
55const showHelp = atom({ plugin: 'opensearch-console', key: 'showHelp' } as const, false)
56const paste = atom({ plugin: 'opensearch-console', key: 'paste' } as const, null)
57const indices = atom({ plugin: 'opensearch-console', key: 'indices' } as const, {})
58const indicesLoading = atom({ plugin: 'opensearch-console', key: 'indicesLoading' } as const, '')
59const paths = atom({ plugin: 'opensearch-console', key: 'paths' } as const, null)
60const editorScroll = atom({ plugin: 'opensearch-console', key: 'editorScroll' } as const, null)
61const view = atom({ plugin: 'opensearch-console', key: 'view' } as const, EMPTY_VIEW)
62const menuOpen = atom({ plugin: 'opensearch-console', key: 'menuOpen' } as const, false)
63const confirm = atom({ plugin: 'opensearch-console', key: 'confirm' } as const, null)
64const editorCmd = atom({ plugin: 'opensearch-console', key: 'editorCmd' } as const, null)
65// Servers whose writes the person unlocked from the pane, for this session only. Never read from disk.
66const unlocked = atom({ plugin: 'opensearch-console', key: 'unlocked' } as const, [])
67// Ids and URLs seen tagged production in this session: removing the tag later doesn't count.
68const prodSeen = atom({ plugin: 'opensearch-console', key: 'prodSeen' } as const, [])
69
70const HELP = `**Top bar**
71- **▾ Server** — open the list of servers from \`servers.json\` and pick one (● is active; production is red)
72- **🔒 / 🔓** — writes are locked; a write asks first (run once, or unlock the server for this session) · **↶ ↷** undo / redo
73- **Format** — pretty-print the body under the cursor · **↻ Indices** — reload the server's index/alias names · **Help** — this screen
74
75**Editor** (click it to type)
76- \`ctrl+r\` / \`ctrl+enter\` or click **▶** in the gutter — run that request
77- Select: shift+arrows, mouse drag, shift+click · \`ctrl+a\` select all
78- \`ctrl+c\` copy · \`ctrl+x\` cut · \`ctrl+v\` paste (or your terminal's paste) · Backspace/Delete/typing replace the selection
79- \`alt+z\` undo · \`alt+y\` / \`alt+shift+z\` redo, or the ↶ ↷ buttons (\`ctrl+z\` suspends Claude Code)
80- \`ctrl+l\` format · Home/End or \`ctrl+e\` line end · \`ctrl+k\` delete to line end · Tab = 2 spaces
81- Autocomplete: methods, index names, endpoints (\`_search\`, \`_cat/…\`), query parameters after \`?\`, and query DSL keys inside \`"…"\` · ↑↓ pick, Tab/Enter accept (index names are cached in \`indices.json\`)
82- Syntax: \`METHOD path\` line, then an optional JSON body; several requests per file
83
84**Response** (click it first)
85- Mouse wheel scrolls the editor or the response (whichever is under the pointer)
86- \`↑↓\` / \`j k\` move · PgUp/PgDn · \`g\`/\`G\` top/bottom
87- Enter / Space / click — fold or unfold · \`←\`/\`→\` collapse/expand · \`e\` expand all · \`c\` collapse all
88- \`1\`–\`9\` fold below that depth · \`y\` copy the response
89- \`/\` or \`ctrl+f\` — jump to **Find** · Enter in Find / \`n\` next match · \`N\` previous
90
91**Writes are locked** — only GET/HEAD and POST to search-type endpoints (_search, _count, _msearch, _mget, _explain, _validate, scroll, PIT…) are sent. A write opens a prompt that shows the exact request: **Run this request once**, **Unlock for this session** (not on production servers) or **Cancel**. Unlocks live in memory only; 🔓 locks again. A server that sends credentials asks once before its first request (and again if its host or credential source changes).
92
93**Files** — one JSON session file per console (default \`~/.claude/mods/opensearch/sessions/default.json\`: server, cursorRow, query, lastRun) + \`<name>.response.json\`; servers in \`servers.json\`. Edits show here live.
94
95**Own console per session/agent** — \`/opensearch --filepath <name>.json [--configfilepath <servers>.json]\` (relative names live under \`~/.claude/mods/opensearch/sessions/\`).
96
97Esc returns the keyboard to the prompt · \`/opensearch\` toggles this pane.`
98
99const GUIDE = `OpenSearch / Elasticsearch console (a Kibana Dev Tools-style pane the user sees; works with both). Each console is one JSON session file, so sessions and agents never overwrite each other: open your own with this tool (filepath) or the user types /opensearch --filepath <name>.json [--configfilepath <servers>.json]. The current session's files are kept across reloads.
100
101Session file (default ~/.claude/mods/opensearch/sessions/default.json): {"server": "<name from config>", "cursorRow": <0-based line of the request the user's Run sends>, "query": "<editor text>", "lastRun": {server, method, path, status, ms, at, error?}}.
102- query uses Dev Tools syntax: a "METHOD path" line (GET _cat/indices?v, POST my-index/_search), then an optional JSON body; several requests separated by blank lines.
103- To inject a query, write the session file (keep the other keys); the pane updates within a second. Set cursorRow to the request's METHOD line so Run sends it. Read the file to see the user's edits.
104Response file: the session file with .response.json in place of .json, the last response body (pretty JSON). Read it after the user runs.
105Config file (default ~/.claude/mods/opensearch/servers.json): {"servers": [{"id","name","host","port","https","insecure","username","password" | "passwordEnv" | "passwordCommand","tag","headers"?,"indices"?}]}. The session file's "server" is a server id. Never print passwords.
106Writes: only reads are sent (GET/HEAD and POST to _search/_count/_msearch/_mget/_explain/_validate/_field_caps/_analyze/_termvectors/templates/scroll/PIT). A write opens a prompt for the user (run once / unlock for this session); unlocks are never read from disk, so editing servers.json cannot unlock anything. A server with credentials asks the user once before its first request. With this tool, configfilepath must be inside ~/.claude/mods/opensearch/.
107The user runs requests (▶ in the editor gutter or ctrl+r); do not run them yourself unless asked.`
108
109const DEFAULT_SERVERS = {
110 _help:
111 'One entry per cluster, listed in the pane\'s server menu by name. Fields: id, name, host, port (9200), https, insecure (skip the certificate check), username, password or passwordEnv (an environment variable) or passwordCommand (argv whose output is the password), tag (production | staging | development | local), path (URL prefix), headers, indices (extra autocomplete names).',
112 servers: [{ id: 'local', name: 'Local', host: 'localhost', port: 9200, https: false, tag: 'local' }],
113}
114
115const DEFAULT_CONSOLE = `GET _cluster/health
116
117GET _cat/indices?v&s=index
118
119POST my-index/_search
120{
121 "query": {
122 "match_all": {}
123 },
124 "size": 10
125}
126`
127
128type Model = { id: number; isJson: boolean; value: unknown; text: string; all: Line[] }
129
130// Where this session's console lives. Kept in $.state so a reload finds them again.
131let base = ''
132let sessionFile = ''
133let configFile = ''
134const seen: Record<string, number> = {}
135// The session file's text as this pane last read or wrote it: two writes within one mtime tick
136// still differ in content.
137const seenText: Record<string, string> = {}
138// The server id the session file (or the person's menu pick) asked for. When the config doesn't
139// have it (yet), the pane shows another entry but never sends to it or saves it in its place.
140let wantedServer = ''
141let saveTimer: Timer | undefined
142let viewerHeight = 20
143let editorBottom = 14
144let model: Model | undefined
145let matchCache: { key: string; paths: string[] } | undefined
146
147const responseFile = () => sessionFile.replace(/(\.json)?$/, '.response.json')
148const indicesFile = () => `${base}/indices.json`
149
150async function onWindows($: EngineInterface): Promise<boolean> {
151 return (await $.env.get('OS')) === 'Windows_NT'
152}
153
154async function homeDir($: EngineInterface): Promise<string> {
155 return homeOf(await $.env.get('HOME'), await $.env.get('USERPROFILE'), await onWindows($))
156}
157
158// A Windows program by full path, so a same-named .exe in the working folder never runs instead.
159async function system32($: EngineInterface, exe: string): Promise<string> {
160 return `${toSlash((await $.env.get('SystemRoot')) || 'C:\\Windows').replace(/\/+$/, '')}/System32/${exe}`
161}
162
163// curl: Windows' own curl.exe when it's there, else the one on PATH.
164async function curlBin($: EngineInterface): Promise<string> {
165 if (!(await onWindows($))) return 'curl'
166 const own = await system32($, 'curl.exe')
167 return (await $.fs.exists(own).catch(() => false)) ? own : 'curl'
168}
169
170async function resolvePath($: EngineInterface, p: string, under: string): Promise<string> {
171 if (!(await onWindows($))) {
172 if (p.startsWith('~/')) return (await homeDir($)) + p.slice(1)
173 if (p.startsWith('/')) return p
174 return `${under}/${p}`
175 }
176 if (p.startsWith('~/') || p.startsWith('~\\')) return (await homeDir($)) + toSlash(p.slice(1))
177 if (isAbsolutePath(p)) return toSlash(p)
178 return `${under}/${toSlash(p)}`
179}
180
181async function write($: EngineInterface, file: string, text: string) {
182 // Recorded before the write: a sync landing between the write and the stat knows it's ours.
183 seenText[file] = text
184 await $.fs.write(file, text)
185 seen[file] = (await $.fs.stat(file)).mtimeMs
186}
187
188async function readJson($: EngineInterface, file: string): Promise<any> {
189 try {
190 return JSON.parse(await $.fs.read(file))
191 } catch {
192 return undefined
193 }
194}
195
196async function readServers($: EngineInterface): Promise<Server[]> {
197 const data = await readJson($, configFile)
198 const list: unknown[] = Array.isArray(data?.servers) ? data.servers : []
199 return uniqueServers(list.map(x => normalizeServer(x)).filter((s): s is Server => !!s)).servers
200}
201
202// The password from the file, an environment variable or a command (keychain, 1Password, pass).
203async function passwordOf($: EngineInterface, s: Server): Promise<string> {
204 if (s.passwordCommand) {
205 const out = await $.process.run(s.passwordCommand, { timeoutMs: 15_000 })
206 if (out.exitCode !== 0) throw new Error(`passwordCommand for ${s.name} exited ${out.exitCode}`)
207 return out.stdout.replace(/\r?\n$/, '')
208 }
209 if (s.passwordEnv) {
210 // $.env.get takes only literal names, so a configured variable is read through another program:
211 // printenv, or on Windows System32's cmd `set` (by full path, for a plain variable name only).
212 if (!(await onWindows($))) {
213 const out = await $.process.run(['printenv', s.passwordEnv]).catch(() => undefined)
214 return out && out.exitCode === 0 ? out.stdout.replace(/\r?\n$/, '') : ''
215 }
216 if (!ENV_NAME.test(s.passwordEnv)) return ''
217 const set = await $.process.run([await system32($, 'cmd.exe'), '/d', '/c', 'set', s.passwordEnv]).catch(() => undefined)
218 return set && set.exitCode === 0 ? fromCmdSet(set.stdout, s.passwordEnv) ?? '' : ''
219 }
220 return s.password ?? ''
221}
222
223async function credsFor($: EngineInterface, s: Server): Promise<ServerCreds> {
224 // A password is only resolved (and a passwordCommand only run) when there is a user to send it for.
225 return { name: s.id, url: baseUrl(s), username: s.username, password: s.username ? await passwordOf($, s) : undefined, insecure: s.insecure, headers: s.headers }
226}
227
228async function findServer($: EngineInterface, id: string): Promise<Server | undefined> {
229 return (await readServers($)).find(s => s.id === id)
230}
231
232// Approved credential fingerprints live in the mod's own store, not in a file the console reads.
233async function isTrusted($: EngineInterface, s: Server): Promise<boolean> {
234 if (!credentialSource(s)) return true
235 const trusted = ((await $.store.get('trusted')) as string[] | undefined) ?? []
236 return trusted.includes(credentialFingerprint(s))
237}
238
239async function trust($: EngineInterface, fingerprint: string) {
240 const trusted = ((await $.store.get('trusted')) as string[] | undefined) ?? []
241 if (!trusted.includes(fingerprint)) await $.store.set('trusted', [...trusted, fingerprint].slice(-200))
242}
243
244async function isProduction($: EngineInterface, s: Server): Promise<boolean> {
245 const seen = [...((await read($, prodSeen)) ?? []), ...(((await $.store.get('prodSeen')) as string[] | undefined) ?? [])]
246 return s.tag === 'production' || seen.includes(`id:${s.id}`) || seen.includes(`url:${baseUrl(s)}`)
247}
248
249async function rememberProduction($: EngineInterface, list: Server[]) {
250 const marks = list.filter(s => s.tag === 'production').flatMap(s => [`id:${s.id}`, `url:${baseUrl(s)}`])
251 if (!marks.length) return
252 await update($, prodSeen, seen => [...new Set([...(seen ?? []), ...marks])])
253 // Kept across sessions too: a production server stays one even if its tag is gone next time.
254 const kept = ((await $.store.get('prodSeen')) as string[] | undefined) ?? []
255 const next = [...new Set([...kept, ...marks])]
256 if (next.length !== kept.length) await $.store.set('prodSeen', next.slice(-20000))
257}
258
259// A session unlock counts only for the server entry it was given to, unchanged.
260async function isUnlockedNow($: EngineInterface, s: Server): Promise<boolean> {
261 if (await isProduction($, s)) return false
262 const fp = credentialFingerprint(s)
263 return ((await read($, unlocked)) ?? []).some(u => u.id === s.id && u.url === baseUrl(s) && u.fingerprint === fp)
264}
265
266async function askTrust($: EngineInterface, s: Server, req: OsPending | null, then: 'run' | 'indices') {
267 await update($, confirm, () => ({
268 kind: 'trust' as const,
269 req,
270 serverId: s.id,
271 serverName: s.name,
272 url: baseUrl(s),
273 source: credentialSource(s) ?? '',
274 fingerprint: credentialFingerprint(s),
275 then,
276 }))
277}
278
279function getModel(r: OsResult): Model {
280 if (model?.id === r.id) return model
281 const parsed = parseJson(r.text)
282 const value = parsed.ok ? parsed.value : undefined
283 model = {
284 id: r.id,
285 isJson: parsed.ok,
286 value,
287 text: r.text,
288 all: parsed.ok ? buildLines(value, new Set(), true) : rawLines(r.text),
289 }
290 matchCache = undefined
291 return model
292}
293
294function matchesOf(m: Model, term: string) {
295 const key = JSON.stringify([m.id, term])
296 if (matchCache?.key !== key) matchCache = { key, paths: findMatches(m.all, term) }
297 return matchCache.paths
298}
299
300function linesOf(m: Model, v: OsView) {
301 return (m.isJson ? buildLines(m.value, new Set(v.collapsed)) : m.all)
302}
303
304function keepVisible(v: OsView, count: number): OsView {
305 const cursor = Math.max(0, Math.min(v.cursor, count - 1))
306 let top = Math.max(0, Math.min(v.top, Math.max(0, count - viewerHeight)))
307 if (cursor < top) top = cursor
308 if (cursor >= top + viewerHeight) top = cursor - viewerHeight + 1
309 return { ...v, cursor, top }
310}
311
312function jump(m: Model, v: OsView, idx: number): OsView {
313 const paths = matchesOf(m, v.search)
314 if (!paths.length) return { ...v, match: 0 }
315 const match = ((idx % paths.length) + paths.length) % paths.length
316 const collapsed = ancestorsOpen(v.collapsed, paths[match])
317 const lines = linesOf(m, { ...v, collapsed })
318 const cursor = Math.max(0, lines.findIndex(l => l.path === paths[match]))
319 return { ...v, collapsed, match, cursor, top: Math.max(0, cursor - Math.floor(viewerHeight / 2)) }
320}
321
322async function editView($: EngineInterface, fn: (v: OsView, m: Model) => OsView) {
323 const r = await read($, result)
324 if (!r) return
325 const m = getModel(r)
326 await update($, view, v => {
327 const next = fn(v ?? EMPTY_VIEW, m)
328 return keepVisible(next, linesOf(m, next).length)
329 })
330}
331
332// ---- files → state ----
333
334async function changed($: EngineInterface, file: string): Promise<boolean> {
335 let mtime: number
336 try {
337 mtime = (await $.fs.stat(file)).mtimeMs
338 } catch {
339 return false
340 }
341 if (seen[file] === mtime) return false
342 seen[file] = mtime
343 return true
344}
345
346// The session file may name a server by its display name (older files): match it to an id.
347async function resolveServer($: EngineInterface) {
348 const list = (await read($, servers)) ?? []
349 if (!list.length) return
350 const want = wantedServer || (await read($, server))
351 const hit = list.find(s => s.id === want) ?? list.find(s => s.name === want)
352 if (hit) {
353 wantedServer = hit.id
354 if (hit.id !== (await read($, server))) await update($, server, () => hit.id)
355 return
356 }
357 if (!wantedServer) {
358 wantedServer = list[0]!.id
359 await update($, server, () => list[0]!.id)
360 } else {
361 const shown = await read($, server)
362 if (!list.some(s => s.id === shown)) await update($, server, () => list[0]!.id)
363 }
364}
365
366// The session asks for a server the config doesn't have: say so rather than send elsewhere.
367async function missingServer($: EngineInterface): Promise<string | undefined> {
368 const list = (await read($, servers)) ?? []
369 return wantedServer && list.length && !list.some(s => s.id === wantedServer) ? wantedServer : undefined
370}
371
372async function sync($: EngineInterface) {
373 if (!sessionFile) return
374 if (await changed($, configFile)) {
375 const list = await readServers($)
376 await rememberProduction($, list)
377 await update($, servers, () =>
378 list.map(s => ({
379 id: s.id,
380 name: s.name,
381 host: s.host,
382 url: baseUrl(s),
383 ...(s.tag ? { tag: s.tag } : {}),
384 ...(s.indices ? { indices: s.indices } : {}),
385 })),
386 )
387 }
388 // Changed by mtime or, within one mtime tick, by content; the pane's own last write is not a change.
389 const moved = await changed($, sessionFile)
390 const raw = moved || seenText[sessionFile] !== undefined ? await $.fs.read(sessionFile).catch(() => undefined) : undefined
391 if (raw !== undefined && raw !== seenText[sessionFile]) {
392 seenText[sessionFile] = raw
393 // A prompt answers for the request it shows; any outside change to the console cancels it.
394 if (await read($, confirm)) await update($, confirm, () => null)
395 let st: any
396 try {
397 st = raw === undefined ? undefined : JSON.parse(raw)
398 } catch {
399 st = undefined
400 }
401 if (st) {
402 if (typeof st.server === 'string' && st.server) wantedServer = st.server
403 if (typeof st.query === 'string' && st.query !== (await read($, query))) {
404 await update($, query, () => st.query)
405 await update($, rev, n => n + 1)
406 }
407 if (typeof st.cursorRow === 'number' && st.cursorRow !== (await read($, cursorRow))) {
408 await update($, cursorRow, () => st.cursorRow)
409 await update($, rev, n => n + 1)
410 }
411 }
412 }
413 await resolveServer($)
414 if (await changed($, indicesFile())) {
415 const data = (await readJson($, indicesFile())) ?? {}
416 const map: Record<string, string[]> = {}
417 for (const [key, entry] of Object.entries(data as Record<string, any>)) {
418 if (Array.isArray(entry?.names)) map[key] = entry.names.map(String)
419 }
420 await update($, indices, () => map)
421 }
422 if (await changed($, responseFile())) {
423 const text = await $.fs.read(responseFile()).catch(() => undefined)
424 const cur = await read($, result)
425 const meta = (await readJson($, sessionFile))?.lastRun ?? {}
426 // Another run with the same body still brings its own status line.
427 if (text !== undefined && (text !== cur?.text || String(meta.at ?? '') !== cur?.at)) {
428 const next: OsResult = {
429 id: Date.now(),
430 server: String(meta.server ?? ''),
431 method: String(meta.method ?? ''),
432 path: String(meta.path ?? ''),
433 status: Number(meta.status ?? 0),
434 ms: Number(meta.ms ?? 0),
435 text,
436 at: String(meta.at ?? ''),
437 }
438 await update($, result, () => next)
439 await update($, view, v => ({ ...EMPTY_VIEW, search: v?.search ?? '' }))
440 }
441 }
442}
443
444async function ensureBase($: EngineInterface) {
445 base = `${await homeDir($)}/.claude/mods/opensearch`
446 if (!(await $.fs.exists(`${base}/servers.json`))) await $.fs.write(`${base}/servers.json`, JSON.stringify(DEFAULT_SERVERS, null, 2) + '\n')
447}
448
449// Points this session's console at a session file and a config file, creating the session file.
450// Answers why it could not, or undefined.
451async function attach($: EngineInterface, file?: string, config?: string): Promise<string | undefined> {
452 await ensureBase($)
453 const kept = await read($, paths)
454 const nextConfig = config ? await resolvePath($, config, base) : kept?.config ?? `${base}/servers.json`
455 const nextFile = file ? await resolvePath($, file.endsWith('.json') ? file : `${file}.json`, `${base}/sessions`) : kept?.file ?? `${base}/sessions/default.json`
456 if (!(await $.fs.exists(nextConfig))) return `Config file not found: ${nextConfig}`
457 if (!(await $.fs.exists(nextFile))) {
458 // A new console starts from the old single-file console when there is one.
459 const legacy = nextFile === `${base}/sessions/default.json` ? await $.fs.read(`${base}/console.http`).catch(() => undefined) : undefined
460 const firstRaw = (await readJson($, nextConfig))?.servers?.[0]
461 const first = firstRaw?.id ?? firstRaw?.name ?? ''
462 await $.fs.write(nextFile, JSON.stringify({ server: first, cursorRow: 0, query: legacy ?? DEFAULT_CONSOLE }, null, 2) + '\n')
463 }
464 if (nextFile !== sessionFile || nextConfig !== configFile) {
465 // A save still pending for the old console lands there now, never in the new one.
466 if (saveTimer) {
467 saveTimer.cancel()
468 saveTimer = undefined
469 await saveSession($).catch(() => undefined)
470 }
471 sessionFile = nextFile
472 configFile = nextConfig
473 for (const k of Object.keys(seen)) delete seen[k]
474 for (const k of Object.keys(seenText)) delete seenText[k]
475 wantedServer = ''
476 await update($, paths, () => ({ file: nextFile, config: nextConfig }))
477 await update($, result, () => null)
478 await update($, view, () => EMPTY_VIEW)
479 }
480 await sync($)
481 return undefined
482}
483
484// The system clipboard first (pbcopy, wl-copy, xclip, clip.exe): the terminal's own route (OSC 52)
485// is off by default in iTerm2 and missing in Terminal.app, where it reports success and copies nothing.
486async function copyText($: EngineInterface, text: string): Promise<boolean> {
487 const windows = await onWindows($)
488 for (const cmd of COPY_COMMANDS) {
489 // On Windows, System32's clip.exe by full path, so a same-named .exe in the working folder never runs.
490 const argv = cmd[0] === 'clip.exe' ? (windows ? [await system32($, 'clip.exe')] : null) : windows ? null : cmd
491 if (!argv) continue
492 const out = await $.process.run(argv, { stdin: text, timeoutMs: 5_000 }).catch(() => undefined)
493 if (out && out.exitCode === 0) return true
494 }
495 return (await $.ui.copy({ text })).isCopied
496}
497
498// Closes the prompt only if it is still the one this drawing showed: a press on a prompt that an
499// outside change already cancelled, or replaced, does nothing.
500async function takePrompt($: EngineInterface, same: (c: OsConfirm | null) => boolean): Promise<boolean> {
501 let ok = false
502 await update($, confirm, c => {
503 if (!same(c ?? null)) return c
504 ok = true
505 return null
506 })
507 return ok
508}
509
510// One save at a time: two overlapping saves would each take the other's write for an outside one.
511// Never call saveSession from anything a save runs (sync, resolveServer): it would wait on itself.
512// A failed save tells the person, since nothing else would.
513let saving: Promise<void> = Promise.resolve()
514let badJson = ''
515// A toast that can't show must not stop the saves after it.
516function tell($: EngineInterface, text: string) {
517 try {
518 void Promise.resolve($.ui.toast(text)).catch(() => undefined)
519 } catch {
520 // nothing else to tell with
521 }
522}
523
524function saveSession($: EngineInterface, extra: Record<string, unknown> = {}, file = sessionFile): Promise<void> {
525 const run = saving.then(() => saveSessionNow($, extra, file))
526 saving = run.catch(err => tell($, `Not saved: ${file}: ${String(err).slice(0, 160)}`))
527 return run
528}
529
530async function saveSessionNow($: EngineInterface, extra: Record<string, unknown>, file: string) {
531 if (!file) return
532 // Claude Code has no atomic write, so the file is read again just before the write: when someone
533 // else saved in between, the merge starts over on top of their save, and after three tries the pane
534 // gives up rather than overwrite them.
535 for (let tries = 0; ; tries++) {
536 // Someone else (the agent, another editor) wrote the file since this pane last read or wrote it:
537 // their editor fields win, only `extra` (a run's lastRun, a menu pick) is merged in, and the pane
538 // reloads them. The mtime is read before the text, so a write in between counts as theirs.
539 const mtime = await $.fs.stat(file).then(st => st.mtimeMs, () => undefined)
540 const raw = await $.fs.read(file).catch(() => undefined)
541 let old: Record<string, unknown> = {}
542 try {
543 old = raw === undefined ? {} : (JSON.parse(raw) ?? {})
544 } catch {
545 // A hand edit with a JSON typo is never replaced by the pane's state; said once per broken version.
546 if (badJson !== raw) tell($, `Not saved: ${file} is not valid JSON. Fix it, or delete it to start over.`)
547 badJson = raw ?? ''
548 return
549 }
550 const external =
551 file !== sessionFile ||
552 (mtime !== undefined && seen[file] !== undefined && mtime !== seen[file]) ||
553 (raw !== undefined && seenText[file] !== undefined && raw !== seenText[file])
554 // Nothing of the pane's to add to their save: it is left as they wrote it.
555 if (!external || Object.keys(extra).length) {
556 // A server the config lacks is saved as asked for, never as the entry shown in its place.
557 // Read after the file, as a sync that lands first has then already loaded an outside save into the pane.
558 const mine = external ? {} : { server: wantedServer || (await read($, server)), cursorRow: await read($, cursorRow), query: await read($, query) }
559 const text = JSON.stringify({ ...old, ...mine, ...extra }, null, 2) + '\n'
560 if ((await $.fs.read(file).catch(() => undefined)) !== raw) {
561 if (tries < 2) continue
562 tell($, `Not saved: ${file} kept changing while the pane saved, so the request's result or menu pick was not recorded.`)
563 return
564 }
565 await write($, file, text)
566 }
567 if (external && file === sessionFile) {
568 delete seen[file]
569 delete seenText[file]
570 await sync($)
571 }
572 return
573 }
574}
575
576function scheduleSave($: EngineInterface) {
577 saveTimer?.cancel()
578 saveTimer = $.clock.after(250, () => void saveSession($).catch(() => undefined))
579}
580
581
582// ---- running a request ----
583
584async function catNames($: EngineInterface, creds: ServerCreds, path: string, field: string): Promise<string[]> {
585 // -q first: curl must not read ~/.curlrc (a proxy or trace file there would see credentials).
586 const out = await $.process.run([await curlBin($), '-q', '-K', '-'], {
587 stdin: curlConfig(creds, { start: 0, end: 0, method: 'GET', path, body: '' }),
588 timeoutMs: 30_000,
589 })
590 const parsed = parseJson(parseCurlOutput(out.stdout).text)
591 if (!parsed.ok || !Array.isArray(parsed.value)) throw new Error(out.stderr.trim() || 'unexpected answer')
592 return (parsed.value as Record<string, unknown>[]).map(r => String(r[field] ?? '')).filter(Boolean)
593}
594
595// Reads the server's index and alias names into indices.json (GET only).
596async function refreshIndices($: EngineInterface, name: string) {
597 if ((await read($, indicesLoading)) === name) return
598 const s = await findServer($, name)
599 if (!s) return
600 if (!(await isTrusted($, s))) return askTrust($, s, null, 'indices')
601 await update($, indicesLoading, () => name)
602 try {
603 const creds = await credsFor($, s)
604 const [idx, aliases] = await Promise.all([
605 catNames($, creds, '_cat/indices?h=index&format=json&expand_wildcards=open', 'index'),
606 catNames($, creds, '_cat/aliases?h=alias&format=json', 'alias').catch(() => [] as string[]),
607 ])
608 const names = [...new Set([...aliases, ...idx])].sort((a, b) => Number(a.startsWith('.')) - Number(b.startsWith('.')) || a.localeCompare(b))
609 const data = (await readJson($, indicesFile())) ?? {}
610 data[creds.url] = { at: new Date().toISOString(), server: s.name, names }
611 await write($, indicesFile(), JSON.stringify(data, null, 2) + '\n')
612 await update($, indices, m => ({ ...(m ?? {}), [creds.url]: names }))
613 $.ui.toast(`${s.name}: ${names.length} indices and aliases loaded`)
614 } catch (err) {
615 $.ui.toast(`${name}: could not list indices (${String(err).slice(0, 120)})`)
616 } finally {
617 await update($, indicesLoading, () => '')
618 }
619}
620
621async function ensureIndices($: EngineInterface) {
622 const name = await read($, server)
623 const s = await findServer($, name)
624 // Loads names only for a server that needs no credentials or whose credentials the person approved.
625 if (s && !(await read($, indices))?.[baseUrl(s)] && (await isTrusted($, s))) await refreshIndices($, name)
626}
627
628async function run($: EngineInterface) {
629 if (await read($, running)) return
630 const text = await read($, query)
631 const req = requestAt(text, await read($, cursorRow))
632 if (!req) return $.ui.toast('No request in the console (a line like `GET index/_search`).')
633 const missing = await missingServer($)
634 if (missing) return $.ui.toast(`Not sent: this console asks for server "${missing}", which isn't in ${configFile}. Add it, or pick a server from the menu.`)
635 const name = await read($, server)
636 const target = await findServer($, name)
637 if (!target) return $.ui.toast(`No server "${name}" in ${configFile}`)
638 const shown = sentBody(req)
639 const dup = req.body ? duplicateKey(req.body) : undefined
640 if (dup) return $.ui.toast(`Not sent: the body repeats the key "${dup}" (only the last one would count). Remove the duplicate.`)
641 const pending: OsPending = {
642 serverId: target.id,
643 serverName: target.name,
644 url: baseUrl(target),
645 fingerprint: credentialFingerprint(target),
646 method: req.method,
647 path: req.path,
648 body: req.body,
649 sent: shown.body,
650 ...(shown.summary ? { summary: shown.summary } : {}),
651 production: await isProduction($, target),
652 }
653 // Writes ask first, unless the person unlocked this exact server entry from the pane in this session.
654 const isWrite = !!writeGuard(req)
655 if (isWrite && !(await isUnlockedNow($, target))) {
656 await update($, menuOpen, () => false)
657 await update($, confirm, () => ({ kind: 'write' as const, req: pending, shownFrom: 0 }))
658 return
659 }
660 await send($, pending)
661}
662
663// Sends exactly `pending`: the server must still resolve to the same address, and its
664// credentials must be approved. Nothing here reads the editor again.
665async function send($: EngineInterface, pending: OsPending) {
666 const target = await findServer($, pending.serverId)
667 if (!target || baseUrl(target) !== pending.url || credentialFingerprint(target) !== pending.fingerprint) {
668 return $.ui.toast(`Not sent: server "${pending.serverName}" changed in ${configFile}. Run it again.`)
669 }
670 if (!(await isTrusted($, target))) return askTrust($, target, pending, 'run')
671 const req = { start: 0, end: 0, method: pending.method, path: pending.path, body: pending.body }
672 // The console this request belongs to: a switch to another session file meanwhile doesn't move it.
673 const file = sessionFile
674 const responsePath = responseFile()
675 await update($, running, () => true)
676 const started = Date.now()
677 let next: OsResult
678 try {
679 const creds = await credsFor($, target)
680 const out = await $.process.run([await curlBin($), '-q', '-K', '-'], { stdin: curlConfig(creds, req), timeoutMs: 130_000 })
681 const parsed = parseCurlOutput(out.stdout)
682 next = {
683 id: Date.now(),
684 server: target.name,
685 method: req.method,
686 path: req.path,
687 status: parsed.status,
688 ms: parsed.ms || Date.now() - started,
689 text: pretty(parsed.text || (out.exitCode !== 0 ? out.stderr : '')),
690 ...(out.exitCode !== 0 ? { error: out.stderr.trim() || `curl exited ${out.exitCode}` } : {}),
691 at: new Date().toISOString(),
692 }
693 } catch (err) {
694 next = { id: Date.now(), server: target.name, method: req.method, path: req.path, status: 0, ms: Date.now() - started, text: String(err), error: String(err), at: new Date().toISOString() }
695 }
696 await update($, running, () => false)
697 if (file === sessionFile) {
698 await update($, result, () => next)
699 await update($, view, v => ({ ...EMPTY_VIEW, search: v?.search ?? '' }))
700 }
701 // lastRun first: a pane that sees the new response then reads the matching status line.
702 const { text: _t, id: _i, ...meta } = next
703 // A failed lastRun save (it toasts) must not lose the response itself.
704 await saveSession($, { lastRun: meta }, file).catch(() => undefined)
705 await write($, responsePath, next.text)
706 if (next.error) $.ui.toast(`OpenSearch: ${next.error.slice(0, 200)}`)
707}
708
709async function viewerKey($: EngineInterface, key: string, ctrl: boolean, meta: boolean, shift: boolean) {
710 if (key === '/' || ((ctrl || meta) && key === 'f')) {
711 await $.ui.focus({ requestId: PANE, key: 'search' }).catch(() => undefined)
712 return
713 }
714 if (key === 'y') {
715 const r = await read($, result)
716 if (r) await $.ui.copy({ text: r.text })
717 $.ui.toast('Response copied')
718 return
719 }
720 await editView($, (v, m) => {
721 const lines = linesOf(m, v)
722 const cur = lines[v.cursor]
723 const toggleTo = (p: string, open: boolean | undefined) => {
724 const isCol = v.collapsed.includes(p)
725 const want = open === undefined ? !isCol : !open
726 const collapsed = want ? (isCol ? v.collapsed : [...v.collapsed, p]) : v.collapsed.filter(c => c !== p)
727 const after = linesOf(m, { ...v, collapsed })
728 return { ...v, collapsed, cursor: Math.max(0, after.findIndex(l => l.path === p)) }
729 }
730 switch (key) {
731 case 'up':
732 case 'k':
733 return { ...v, cursor: v.cursor - 1 }
734 case 'down':
735 case 'j':
736 return { ...v, cursor: v.cursor + 1 }
737 case 'pageup':
738 return { ...v, cursor: v.cursor - viewerHeight, top: v.top - viewerHeight }
739 case 'pagedown':
740 return { ...v, cursor: v.cursor + viewerHeight, top: v.top + viewerHeight }
741 case 'home':
742 case 'g':
743 return { ...v, cursor: 0, top: 0 }
744 case 'end':
745 case 'G':
746 return { ...v, cursor: lines.length - 1 }
747 case 'return':
748 case 'enter':
749 case 'space':
750 case ' ': {
751 if (!cur) return v
752 const p = cur.path.endsWith(END) ? cur.path.slice(0, -END.length) : cur.path
753 return cur.fold || cur.path.endsWith(END) ? toggleTo(p, undefined) : v
754 }
755 case 'left':
756 case 'h': {
757 if (!cur) return v
758 if (cur.fold && !v.collapsed.includes(cur.path)) return toggleTo(cur.path, false)
759 const parent = cur.path.replace(new RegExp(`(/[^/]*)?(${END})?$`), '')
760 const idx = lines.findIndex(l => l.path === parent)
761 return idx >= 0 ? { ...v, cursor: idx } : v
762 }
763 case 'right':
764 case 'l':
765 return cur?.fold ? toggleTo(cur.path, true) : v
766 case 'e':
767 return { ...v, collapsed: [] }
768 case 'c':
769 return m.isJson ? { ...v, collapsed: foldablePaths(m.value, 1), cursor: 0, top: 0 } : v
770 case 'n':
771 return jump(m, v, v.match + (shift ? -1 : 1))
772 case 'N':
773 return jump(m, v, v.match - 1)
774 }
775 if (/^[1-9]$/.test(key) && m.isJson) {
776 return { ...v, collapsed: foldablePaths(m.value, Number(key)), cursor: 0, top: 0 }
777 }
778 return v
779 })
780}
781
782
783export const register: Register = on => {
784
785 on('session.start', async ($, e, next) => {
786 await $.command.register({
787 name: 'opensearch',
788 description: 'Toggle the OpenSearch console pane (Dev Tools style)',
789 argumentHint: '[--filepath <session>.json] [--configfilepath <servers>.json]',
790 })
791 await $.tool.register({
792 name: 'console',
793 description: GUIDE,
794 isDeferred: false,
795 inputSchema: {
796 type: 'object',
797 properties: {
798 action: { type: 'string', enum: ['open', 'close', 'status'], description: 'open (default) attaches and shows the pane; close hides it; status only reports the files' },
799 filepath: { type: 'string', description: 'Session file for this console (own state per session/agent). Relative = under ~/.claude/mods/opensearch/sessions/; .json added if missing; created when new.' },
800 configfilepath: { type: 'string', description: 'Servers config file (urls + creds). Relative = under ~/.claude/mods/opensearch/. Must exist.' },
801 },
802 },
803 })
804 const kept = await read($, paths)
805 await attach($, kept?.file, kept?.config)
806 $.clock.every(600, () => void sync($).catch(() => undefined))
807 return next(e)
808 })
809
810 on('tool.call', { tool: 'mcp__opensearch-console__console' }, async ($, e) => {
811 // A plugin tool's arguments arrive on the event itself, beside `tool` and `tool_use_id`.
812 const input = e as unknown as { action?: string; filepath?: string; configfilepath?: string }
813 const action = input.action ?? 'open'
814 if (action === 'close') {
815 if ((await $.ui.panes()).some(pane => pane.id === PANE)) await $.ui.close({ id: PANE })
816 return { result: 'OpenSearch console closed.' }
817 }
818 // An agent may open its own console, but only with a config inside the mod's folder:
819 // pointing the pane at another config is the person's call (/opensearch --configfilepath).
820 if (input.configfilepath) {
821 if (!base) await ensureBase($)
822 const want = await resolvePath($, input.configfilepath, base)
823 // Backslashes are separators only on Windows; elsewhere `opensearch\\x` is a sibling folder.
824 const slash = (await onWindows($)) ? toSlash : (p: string) => p
825 const real = slash((await $.fs.stat(want, { resolve: true }).catch(() => undefined))?.realPath ?? want)
826 const root = slash((await $.fs.stat(base, { resolve: true }).catch(() => undefined))?.realPath ?? base)
827 if (!real.startsWith(root + '/') || real.toLowerCase().startsWith((root + '/sessions/').toLowerCase())) return { result: `configfilepath must be inside ${base}/ (not sessions/). Ask the user to open other configs with /opensearch --configfilepath.`, isError: true }
828 }
829 const refused = await attach($, input.filepath, input.configfilepath)
830 if (refused) return { result: refused, isError: true }
831 let placed = ''
832 if (action === 'open') {
833 const opened = await $.ui.open({ id: PANE, title: `OpenSearch · ${sessionFile.split('/').pop()}` })
834 placed = opened.isPlaced ? 'Pane is open. ' : `Pane not shown yet (${(opened as any).reason ?? 'terminal too narrow'}); the user can type /opensearch. `
835 }
836 return { result: `${placed}Session file: ${sessionFile}\nResponse file: ${responseFile()}\nConfig file: ${configFile}` }
837 })
838
839 on('command.run', { command: 'opensearch' }, async ($, e) => {
840 const args = parseArgs(e.args ?? '')
841 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
842 if (isOpen && !args.file && !args.config) {
843 await $.ui.close({ id: PANE })
844 return { text: 'OpenSearch console closed.' }
845 }
846 const refused = await attach($, args.file, args.config)
847 if (refused) return { text: refused }
848 await $.ui.open({ id: PANE, title: `OpenSearch · ${sessionFile.split('/').pop()}`, focus: true, columns: 110 })
849 void ensureIndices($).catch(() => undefined)
850 return {
851 text: `OpenSearch console opened: ${sessionFile}`,
852 context: [`${GUIDE}\n\nThis session's console: session file ${sessionFile}, response file ${responseFile()}, config file ${configFile}.`],
853 }
854 })
855
856 // The wheel scrolls whichever half the pointer is over; the pane itself never moves.
857 on('ui.scroll', { requestId: PANE }, async ($, e) => {
858 const row = e.pointer?.row
859 if (row === undefined) return {}
860 const by = Math.sign(e.by) * Math.max(1, Math.min(10, Math.abs(e.by) * 3))
861 if (row < editorBottom) {
862 await update($, editorScroll, () => ({ id: Date.now(), by }))
863 } else {
864 await editView($, (v0, m) => {
865 // Start from the window as drawn: a taller viewer shows rows above the stored top.
866 const v = keepVisible(v0, linesOf(m, v0).length)
867 const top = Math.max(0, v.top + by)
868 const cursor = Math.min(Math.max(v.cursor, top), top + viewerHeight - 1)
869 return { ...v, top, cursor }
870 })
871 }
872 return {}
873 })
874
875 on('ui.message', async ($, e) => {
876 const data = (e.data ?? {}) as Record<string, any>
877 if (e.element === 'editor') {
878 if (data.type !== 'run' && data.type !== 'copy' && (await read($, confirm))) await update($, confirm, () => null)
879 if (typeof data.text === 'string') await update($, query, () => data.text)
880 if (typeof data.row === 'number') await update($, cursorRow, () => data.row)
881 // A cut carries its text on the edit post; a copy has its own.
882 if (typeof data.copy === 'string') $.ui.toast((await copyText($, data.copy)) ? 'Copied' : 'Copy failed')
883 if (data.type === 'copy') {
884 // nothing more: the text and cursor were taken above
885 } else if (data.type === 'paste') {
886 for (const argv of PASTE_COMMANDS) {
887 const out = await $.process.run(argv).catch(() => undefined)
888 if (out && out.exitCode === 0) {
889 await update($, paste, () => ({ id: Date.now(), text: out.stdout.replace(/\r\n/g, '\n') }))
890 break
891 }
892 }
893 } else if (data.type === 'run') {
894 scheduleSave($)
895 await run($)
896 } else if (data.type === 'format') {
897 const text = formatAt(await read($, query), await read($, cursorRow))
898 await update($, query, () => text)
899 await update($, rev, n => n + 1)
900 scheduleSave($)
901 } else {
902 scheduleSave($)
903 }
904 } else if (e.element === 'viewer') {
905 if (data.type === 'click') {
906 await editView($, (v0, m) => {
907 const v = keepVisible(v0, linesOf(m, v0).length)
908 const lines = linesOf(m, v)
909 const idx = v.top + Number(data.y)
910 const l = lines[idx]
911 if (!l) return v
912 if (l.fold) {
913 const collapsed = v.collapsed.includes(l.path) ? v.collapsed.filter(c => c !== l.path) : [...v.collapsed, l.path]
914 return { ...v, collapsed, cursor: idx }
915 }
916 return { ...v, cursor: idx }
917 })
918 } else if (data.type === 'key') {
919 await viewerKey($, String(data.key), !!data.ctrl, !!data.meta, !!data.shift)
920 }
921 }
922 return {}
923 })
924
925 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
926 const { Box, Text, Button, Select, Input, Client, Markdown } = $.ui.resolve(e) as any
927 // The pane's own rows (the terminal's height is larger: transcript, prompt and footer share it).
928 const rows = (e.props as any).scroll?.bodyRows ?? e.viewport?.rows ?? 36
929 const text = await read($, query)
930 const r = await read($, result)
931 const v = (await read($, view)) ?? EMPTY_VIEW
932 const isRunning = await read($, running)
933 const current = await read($, server)
934 const list: OsServer[] = (await read($, servers)) ?? []
935 const row = await read($, cursorRow)
936 const req = requestAt(text, row)
937 const target = list.find(s => s.id === current)
938 const isMenu = await read($, menuOpen)
939 const targetFull = target ? await findServer($, target.id) : undefined
940 const isUnlocked = !!targetFull && (await isUnlockedNow($, targetFull))
941 const ask = await read($, confirm)
942 const cols = (e.props as any).bodyColumns ?? 100
943 // A write prompt takes the whole pane. It draws ASCII only (\uXXXX for anything else), counts
944 // every row it uses from the pane's real size, and shows the body forward from the furthest
945 // point already shown; the send buttons appear only once the end has been on screen.
946 const promptWidth = Math.max(10, cols - 4)
947 const isWritePrompt = !!ask && ask.kind === 'write'
948 const urlRows = isWritePrompt ? wrapRows(asciiView(`${ask.req.method} ${ask.req.url}/${ask.req.path.replace(/^\/+/, '')}`), promptWidth) : []
949 const bodyView = isWritePrompt ? asciiView(ask.req.sent) : ''
950 const chrome = 8 + urlRows.length + (isWritePrompt && ask.req.summary ? 1 : 0)
951 const pageSize = rows - chrome
952 const tooLong = bodyView.length > BODY_LIMIT
953 const tooSmall = isWritePrompt && pageSize < (bodyView ? 3 : 0)
954 const shown = isWritePrompt && bodyView && !tooLong && !tooSmall ? pageFrom(bodyView, ask.shownFrom, promptWidth, pageSize) : { rows: [] as string[], end: bodyView.length }
955 const bodySeen = isWritePrompt && !tooLong && !tooSmall && shown.end >= bodyView.length
956 const cmd = await read($, editorCmd)
957 const help = await read($, showHelp)
958 const pasted = await read($, paste)
959 const scrolled = await read($, editorScroll)
960 const loadingIdx = (await read($, indicesLoading)) === current
961 let budget = 60_000
962 const known = [...new Set([...(target?.indices ?? []), ...(((await read($, indices)) ?? {})[target?.url ?? ''] ?? [])])].filter(n => (budget -= n.length + 3) > 0)
963
964 const fixed = 11
965 // An open menu or prompt sits under the toolbar; the editor gives up its rows.
966 const extra = isMenu ? Math.max(1, list.length) + 2 : ask?.kind === 'trust' ? 6 : 0
967 const editorHeight = Math.max(3, Math.floor((rows - fixed) * 0.4) - extra)
968 viewerHeight = Math.max(4, rows - fixed - editorHeight)
969 editorBottom = 2 + extra + editorHeight
970
971 let viewerProps: { lines: [string, string][][]; cursor: number; height: number; status: string }
972 if (!r) {
973 viewerProps = {
974 lines: [[[' ', 'f'], ['Click ▶ in the editor gutter (or ctrl+r) to send the request under the cursor.', 'm']]],
975 cursor: -1,
976 height: viewerHeight,
977 status: '',
978 }
979 } else {
980 const m = getModel(r)
981 const lines = linesOf(m, v)
982 const vv = keepVisible(v, lines.length)
983 const paths = matchesOf(m, v.search)
984 const currentMatch = paths.length ? paths[Math.min(v.match, paths.length - 1)] : undefined
985 const window = lines
986 .slice(vv.top, vv.top + viewerHeight)
987 .map(l => highlight(l.segs, v.search, l.path === currentMatch ? 'C' : 'H') as [string, string][])
988 const found = v.search ? (paths.length ? ` · match ${Math.min(v.match, paths.length - 1) + 1}/${paths.length}` : ' · no matches') : ''
989 viewerProps = {
990 lines: window,
991 cursor: vv.cursor - vv.top,
992 height: viewerHeight,
993 status: `${vv.cursor + 1}/${lines.length}${found} · ↑↓ ⏎ fold · e/c all · 1-9 depth · / find · n/N · y copy`,
994 }
995 }
996
997 const statusColor = !r ? undefined : r.error || r.status >= 400 || r.status === 0 ? 'error' : 'success'
998 const statusText = isRunning
999 ? 'running…'
1000 : r
1001 ? `${r.status || 'ERR'} · ${r.ms} ms · ${r.method} ${r.path} · ${r.server}`
1002 : 'no response yet'
1003
1004 return (
1005 <Box flexDirection="column">
1006 <Box flexDirection="row" gap={1} height={1}>
1007 <Button
1008 key="server-menu"
1009 label={`▾ ${target ? target.name : 'Choose server'}`}
1010 variant="primary"
1011 // No menu while a write prompt is open: it would push the prompt's buttons out of view.
1012 onPress={() => void (ask?.kind === 'write' ? $.ui.toast('Finish or cancel the write prompt first.') : update($, menuOpen, o => !o))}
1013 />
1014 <Button
1015 key="lock"
1016 label={isUnlocked ? '🔓 writes on' : '🔒 writes locked'}
1017 onPress={() =>
1018 void (async () => {
1019 if (!target) return
1020 if (isUnlocked) {
1021 await update($, unlocked, l => (l ?? []).filter(u => u.id !== target.id))
1022 $.ui.toast(`${target.name}: writes locked`)
1023 } else $.ui.toast('Writes unlock from the prompt that a write opens, one request or one session at a time.')
1024 })().catch(() => undefined)
1025 }
1026 />
1027 <Button key="undo" label="↶" onPress={() => void update($, editorCmd, () => ({ id: Date.now(), cmd: 'undo' }))} />
1028 <Button key="redo" label="↷" onPress={() => void update($, editorCmd, () => ({ id: Date.now(), cmd: 'redo' }))} />
1029 <Text dimColor>│</Text>
1030 <Button
1031 key="format"
1032 label="Format"
1033 onPress={() =>
1034 void (async () => {
1035 await update($, query, t => formatAt(t, row))
1036 await update($, rev, n => n + 1)
1037 scheduleSave($)
1038 })()
1039 }
1040 />
1041 <Button
1042 key="indices"
1043 label={loadingIdx ? '… Indices' : '↻ Indices'}
1044 onPress={() => void refreshIndices($, current)}
1045 />
1046 <Button key="help" label={help ? 'Close help' : 'Help'} onPress={() => void update($, showHelp, h => !h)} />
1047 </Box>
1048 <Text wrap="truncate-end">
1049 <Text dimColor>{req ? `${req.method} ${req.path}` : 'no request'} → </Text>
1050 <Text color={tagColor(target?.tag)} bold={target?.tag === 'production' ? true : undefined}>
1051 {target ? `${target.url}${target.tag ? ` · ${target.tag}` : ''}` : '(no server)'}
1052 </Text>
1053 </Text>
1054 {isMenu && ask?.kind !== 'write' ? (
1055 <Box flexDirection="column" borderStyle="round" borderColor="#4ec9b0" backgroundColor="#1e1e1e" paddingX={1}>
1056 {list.length === 0 && <Text color="error">no servers in {configFile}</Text>}
1057 {list.map(sv => (
1058 <Box flexDirection="row" gap={1} height={1}>
1059 <Button
1060 key={`pick:${sv.id}`}
1061 plain
1062 label={`${sv.id === current ? '●' : ' '} ${sv.name}`}
1063 onPress={() =>
1064 void (async () => {
1065 await update($, server, () => sv.id)
1066 await update($, menuOpen, () => false)
1067 await update($, confirm, () => null)
1068 // The person's pick wins over a session file someone else just wrote.
1069 wantedServer = sv.id
1070 await saveSession($, { server: sv.id })
1071 void ensureIndices($).catch(() => undefined)
1072 })()
1073 }
1074 />
1075 {sv.tag ? <Text color={tagColor(sv.tag)}>{sv.tag}</Text> : null}
1076 <Text dimColor>{sv.url}</Text>
1077 </Box>
1078 ))}
1079 </Box>
1080 ) : null}
1081 {ask && ask.kind === 'write' ? (
1082 <Box flexDirection="column" borderStyle="round" borderColor="#f14c4c" backgroundColor="#1e1e1e" paddingX={1}>
1083 <Text bold color="error" wrap="truncate-end">{`Writes are locked for ${asciiView(ask.req.serverName)}`}</Text>
1084 {urlRows.map(r => (
1085 <Text wrap="truncate-end">{r}</Text>
1086 ))}
1087 {ask.req.summary ? <Text color="warning" wrap="truncate-end">{ask.req.summary}</Text> : null}
1088 {!bodyView ? (
1089 <Text dimColor>no body</Text>
1090 ) : tooLong ? (
1091 <Text color="error" wrap="truncate-end">{`body: ${bodyView.length} characters, too long to review here, so it can't be sent.`}</Text>
1092 ) : tooSmall ? (
1093 <Text color="error" wrap="truncate-end">Make the pane taller to review this body.</Text>
1094 ) : (
1095 <Box flexDirection="column">
1096 <Text dimColor wrap="truncate-end">{`body as sent (${bodyView.length} characters, shown up to ${Math.floor((shown.end / bodyView.length) * 100)}%):`}</Text>
1097 {shown.rows.map(l => (
1098 <Text wrap="truncate-end">{l || ' '}</Text>
1099 ))}
1100 </Box>
1101 )}
1102 <Text dimColor wrap="truncate-end">{bodySeen ? 'This request can change data. Run sends exactly what is shown here.' : tooLong ? 'Cancel, and send a smaller body.' : tooSmall ? 'Enlarge the pane, or cancel.' : 'Read the whole body before you can run it.'}</Text>
1103 <Box flexDirection="row" gap={2} height={1}>
1104 {!bodySeen && !tooLong && !tooSmall ? (
1105 <Button
1106 key="body-next"
1107 label="Next page"
1108 variant="primary"
1109 onPress={() => {
1110 // Moves on from what this drawing showed; a stale press (another prompt by now) does nothing.
1111 const from = ask.shownFrom
1112 const to = shown.end
1113 const same = JSON.stringify(ask.req)
1114 void update($, confirm, c => (c && c.kind === 'write' && JSON.stringify(c.req) === same && c.shownFrom === from ? { ...c, shownFrom: to } : c))
1115 }}
1116 />
1117 ) : null}
1118 {bodySeen ? (
1119 <Button
1120 key="confirm-once"
1121 label="Run this request once"
1122 variant="primary"
1123 onPress={() =>
1124 void (async () => {
1125 const req = ask.req
1126 if (!(await takePrompt($, c => c?.kind === 'write' && JSON.stringify(c.req) === JSON.stringify(req)))) return
1127 await send($, req)
1128 })().catch(() => undefined)
1129 }
1130 />
1131 ) : null}
1132 {bodySeen && !ask.req.production ? (
1133 <Button
1134 key="confirm-session"
1135 label="Unlock for this session"
1136 onPress={() =>
1137 void (async () => {
1138 const req = ask.req
1139 if (!(await takePrompt($, c => c?.kind === 'write' && JSON.stringify(c.req) === JSON.stringify(req)))) return
1140 await update($, unlocked, l => [...(l ?? []).filter(u => u.id !== req.serverId), { id: req.serverId, url: req.url, fingerprint: req.fingerprint } as OsUnlock])
1141 $.ui.toast(`${req.serverName}: writes unlocked until this session ends (🔓 locks again)`)
1142 await send($, req)
1143 })().catch(() => undefined)
1144 }
1145 />
1146 ) : null}
1147 <Button key="confirm-no" label="Cancel" onPress={() => void update($, confirm, () => null)} />
1148 </Box>
1149 </Box>
1150 ) : null}
1151 {ask && ask.kind === 'trust' ? (
1152 <Box flexDirection="column" borderStyle="round" borderColor="#d7a700" backgroundColor="#1e1e1e" paddingX={1}>
1153 <Text bold color="warning" wrap="truncate-end">{`Send credentials to ${asciiView(ask.serverName)}?`}</Text>
1154 <Text wrap="wrap">{asciiView(`${ask.url} gets ${ask.source}.`)}</Text>
1155 <Text dimColor>Asked once per server, and again whenever its host or credential source changes in the config.</Text>
1156 <Box flexDirection="row" gap={2} height={1}>
1157 <Button
1158 key="trust-yes"
1159 label="Allow"
1160 variant="primary"
1161 onPress={() =>
1162 void (async () => {
1163 const a = ask
1164 if (!(await takePrompt($, c => c?.kind === 'trust' && c.fingerprint === a.fingerprint && c.then === a.then && JSON.stringify(c.req) === JSON.stringify(a.req)))) return
1165 await trust($, a.fingerprint)
1166 if (a.then === 'run' && a.req) await send($, a.req)
1167 else await refreshIndices($, a.serverId)
1168 })().catch(() => undefined)
1169 }
1170 />
1171 <Button key="trust-no" label="Cancel" onPress={() => void update($, confirm, () => null)} />
1172 </Box>
1173 </Box>
1174 ) : null}
1175 {ask?.kind === 'write' ? null : (<>
1176 <Client
1177 key="editor"
1178 module="./editor.tsx"
1179 width="100%"
1180 height={editorHeight}
1181 props={{ text, rev: await read($, rev), height: editorHeight, row, ...(pasted ? { paste: pasted } : {}), ...(scrolled ? { scroll: scrolled } : {}), ...(cmd ? { cmd } : {}), indices: known }}
1182 />
1183 <Text dimColor wrap="truncate-end">
1184 {'─'.repeat(Math.max(10, (e.props as any).bodyColumns ?? 60))}
1185 </Text>
1186 <Text color={statusColor} wrap="truncate-end">
1187 {statusText}
1188 </Text>
1189 <Input
1190 key="search"
1191 label="Find "
1192 placeholder="search the response (/ or ctrl+f from it) · Enter = next"
1193 value={v.search}
1194 onInput={(value: string) => void editView($, (vw, m) => jump(m, { ...vw, search: value }, 0))}
1195 onSubmit={(value: string) => void editView($, (vw, m) => jump(m, { ...vw, search: value }, vw.search === value ? vw.match + 1 : 0))}
1196 />
1197 {help ? (
1198 <Box flexDirection="column" height={viewerHeight + 1} overflow="hidden">
1199 <Markdown text={HELP} />
1200 </Box>hooks/lib.ts 605 lines1// Pure helpers: request parsing, curl config, the JSON tree the viewer folds.
2
3export type Seg = [string, string]
4export type Line = { path: string; segs: Seg[]; fold: boolean; depth: number }
5export type Request = { start: number; end: number; method: string; path: string; body: string }
6
7const METHOD = /^\s*(GET|POST|PUT|DELETE|HEAD|PATCH)\s+(\S.*)?$/i
8
9export function isRequestLine(line: string): boolean {
10 return METHOD.test(line)
11}
12
13export function parseRequests(text: string): Request[] {
14 const lines = text.split('\n')
15 const out: Request[] = []
16 for (let i = 0; i < lines.length; i++) {
17 const m = METHOD.exec(lines[i])
18 if (!m) continue
19 if (out.length) out[out.length - 1].end = i - 1
20 out.push({ start: i, end: lines.length - 1, method: m[1].toUpperCase(), path: (m[2] ?? '').trim(), body: '' })
21 }
22 for (const r of out) {
23 r.body = lines
24 .slice(r.start + 1, r.end + 1)
25 .filter(l => !/^\s*(#|\/\/)/.test(l))
26 .join('\n')
27 .trim()
28 }
29 return out
30}
31
32export function requestAt(text: string, row: number): Request | undefined {
33 const all = parseRequests(text)
34 return all.filter(r => r.start <= row).pop() ?? all[0]
35}
36
37function isNdjson(path: string): boolean {
38 return /(^|\/)_(bulk|msearch|msearch\/template)(\?|$|\/)/.test(path)
39}
40
41// Splits a body into its top-level JSON values (for _bulk / _msearch).
42function splitValues(body: string): string[] {
43 const out: string[] = []
44 let depth = 0
45 let start = -1
46 let inStr = false
47 for (let i = 0; i < body.length; i++) {
48 const c = body[i]
49 if (inStr) {
50 if (c === '\\') i++
51 else if (c === '"') inStr = false
52 continue
53 }
54 if (c === '"') inStr = true
55 else if (c === '{' || c === '[') {
56 if (depth === 0) start = i
57 depth++
58 } else if (c === '}' || c === ']') {
59 depth--
60 if (depth === 0 && start >= 0) {
61 out.push(body.slice(start, i + 1))
62 start = -1
63 }
64 }
65 }
66 return out
67}
68
69export function requestBody(r: Request): { body?: string; contentType: string } {
70 if (!r.body) return { contentType: 'application/json' }
71 if (isNdjson(r.path)) {
72 const parts = splitValues(r.body).map(p => {
73 try {
74 return JSON.stringify(JSON.parse(p))
75 } catch {
76 return p.replace(/\s*\n\s*/g, ' ')
77 }
78 })
79 return { body: parts.join('\n') + '\n', contentType: 'application/x-ndjson' }
80 }
81 try {
82 return { body: JSON.stringify(JSON.parse(r.body)), contentType: 'application/json' }
83 } catch {
84 return { body: r.body, contentType: 'application/json' }
85 }
86}
87
88// Pretty-prints the body of the request under `row`; the text as it was when the body is not JSON.
89export function formatAt(text: string, row: number): string {
90 const r = requestAt(text, row)
91 if (!r || !r.body) return text
92 const lines = text.split('\n')
93 let pretty: string
94 if (isNdjson(r.path)) {
95 const parts = splitValues(r.body)
96 try {
97 pretty = parts.map(p => JSON.stringify(JSON.parse(p))).join('\n')
98 } catch {
99 return text
100 }
101 } else {
102 try {
103 pretty = JSON.stringify(JSON.parse(r.body), null, 2)
104 } catch {
105 return text
106 }
107 }
108 const head = lines.slice(0, r.start + 1)
109 const tail = lines.slice(r.end + 1)
110 const sep = tail.length ? [''] : []
111 return [...head, ...pretty.split('\n'), ...sep, ...tail.filter((l, i) => !(i === 0 && l.trim() === ''))].join('\n')
112}
113
114export function joinUrl(base: string, path: string): string {
115 return base.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '')
116}
117
118function curlQuote(s: string): string {
119 return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t') + '"'
120}
121
122export const MARK = '__OSCONSOLE__'
123
124export type ServerCreds = { name: string; url: string; username?: string; password?: string; insecure?: boolean; headers?: Record<string, string> }
125
126// A curl config read from stdin, so the password never sits on argv.
127export function curlConfig(server: ServerCreds, r: Request): string {
128 const { body, contentType } = requestBody(r)
129 const lines = [
130 `url = ${curlQuote(joinUrl(server.url, r.path))}`,
131 `request = ${curlQuote(r.method)}`,
132 `header = ${curlQuote('Content-Type: ' + contentType)}`,
133 'silent',
134 'show-error',
135 // `{}` and `[]` in a path are text, never a curl URL glob.
136 'globoff',
137 'max-time = 120',
138 `write-out = ${curlQuote('\n' + MARK + '%{http_code} %{time_total}')}`,
139 ]
140 for (const [k, v] of Object.entries(server.headers ?? {})) lines.push(`header = ${curlQuote(`${k}: ${v}`)}`)
141 if (server.username) lines.push(`user = ${curlQuote(`${server.username}:${server.password ?? ''}`)}`)
142 if (server.insecure) lines.push('insecure')
143 if (r.method === 'HEAD') lines.push('head')
144 // data-raw sends the body as written: a leading `@` is text, not a file for curl to read.
145 if (body !== undefined) lines.push(`data-raw = ${curlQuote(body)}`)
146 return lines.join('\n') + '\n'
147}
148
149export function parseCurlOutput(stdout: string): { text: string; status: number; ms: number } {
150 const at = stdout.lastIndexOf('\n' + MARK)
151 if (at < 0) return { text: stdout, status: 0, ms: 0 }
152 const [code, secs] = stdout.slice(at + MARK.length + 1).trim().split(' ')
153 return { text: stdout.slice(0, at), status: Number(code) || 0, ms: Math.round(Number(secs) * 1000) || 0 }
154}
155
156export function pretty(text: string): string {
157 try {
158 return JSON.stringify(JSON.parse(text), null, 2)
159 } catch {
160 return text
161 }
162}
163
164// ---- the viewer's tree ----
165
166const esc = (k: string) => k.replace(/~/g, '~0').replace(/\//g, '~1').replace(/#/g, '~2')
167// Marks a closing-bracket line. `#` never appears in a path: esc() encodes it as ~2.
168export const END = '#end'
169
170export function parseJson(text: string): { ok: true; value: unknown } | { ok: false } {
171 try {
172 return { ok: true, value: JSON.parse(text) }
173 } catch {
174 return { ok: false }
175 }
176}
177
178export function buildLines(value: unknown, collapsed: ReadonlySet<string>, ignoreFolds = false): Line[] {
179 const out: Line[] = []
180 const walk = (v: unknown, key: string | number | null, path: string, depth: number, last: boolean) => {
181 const indent: Seg = [' '.repeat(depth), '']
182 const keySegs: Seg[] = typeof key === 'string' ? [[JSON.stringify(key), 'k'], [': ', 'p']] : []
183 const comma: Seg = [last ? '' : ',', 'p']
184 if (v !== null && typeof v === 'object') {
185 const isArr = Array.isArray(v)
186 const entries: [string | number, unknown][] = isArr
187 ? (v as unknown[]).map((x, i) => [i, x])
188 : Object.entries(v as Record<string, unknown>)
189 const [open, close] = isArr ? ['[', ']'] : ['{', '}']
190 if (entries.length === 0) {
191 out.push({ path, depth, fold: false, segs: [[' ', 'f'], indent, ...keySegs, [open + close, 'p'], comma] })
192 return
193 }
194 if (!ignoreFolds && collapsed.has(path)) {
195 const n = entries.length
196 const what = isArr ? `${n} item${n === 1 ? '' : 's'}` : `${n} key${n === 1 ? '' : 's'}`
197 out.push({
198 path,
199 depth,
200 fold: true,
201 segs: [['▸ ', 'f'], indent, ...keySegs, [open, 'p'], [` … ${what} `, 'm'], [close, 'p'], comma],
202 })
203 return
204 }
205 out.push({ path, depth, fold: true, segs: [['▾ ', 'f'], indent, ...keySegs, [open, 'p']] })
206 entries.forEach(([k, x], i) =>
207 walk(x, isArr ? null : (k as string), `${path}/${esc(String(k))}`, depth + 1, i === entries.length - 1),
208 )
209 out.push({ path: path + END, depth, fold: false, segs: [[' ', 'f'], indent, [close, 'p'], comma] })
210 return
211 }
212 const [str, code] =
213 typeof v === 'string' ? [JSON.stringify(v), 's'] : typeof v === 'number' ? [String(v), 'n'] : [String(v), 'b']
214 out.push({ path, depth, fold: false, segs: [[' ', 'f'], indent, ...keySegs, [str, code], comma] })
215 }
216 walk(value, null, '', 0, true)
217 return out
218}
219
220export function rawLines(text: string): Line[] {
221 return text.split('\n').map((l, i) => ({ path: `#${i}`, depth: 0, fold: false, segs: [[' ', 'f'], [l, '']] }))
222}
223
224export function foldablePaths(value: unknown, minDepth = 1, maxDepth = Infinity): string[] {
225 return buildLines(value, new Set(), true)
226 .filter(l => l.fold && l.depth >= minDepth && l.depth <= maxDepth)
227 .map(l => l.path)
228}
229
230export const lineText = (l: Line) => l.segs.slice(1).map(s => s[0]).join('')
231
232// Lines (in fully expanded order) whose text holds the term, case-insensitive.
233export function findMatches(all: Line[], term: string): string[] {
234 if (!term) return []
235 const t = term.toLowerCase()
236 return all.filter(l => !l.path.endsWith(END) && lineText(l).toLowerCase().includes(t)).map(l => l.path)
237}
238
239export function ancestorsOpen(collapsed: string[], path: string): string[] {
240 return collapsed.filter(c => !(path !== c && (c === '' || path.startsWith(c + '/'))))
241}
242
243// Splits segments so every occurrence of term carries the highlight code.
244export function highlight(segs: Seg[], term: string, code: string): Seg[] {
245 if (!term) return segs
246 const t = term.toLowerCase()
247 const [gutter, ...rest] = segs
248 const full = rest.map(s => s[0]).join('')
249 const lower = full.toLowerCase()
250 const marks: boolean[] = new Array(full.length).fill(false)
251 for (let i = lower.indexOf(t); i >= 0; i = lower.indexOf(t, i + 1)) for (let j = i; j < i + t.length; j++) marks[j] = true
252 if (!marks.includes(true)) return segs
253 const out: Seg[] = [gutter]
254 let pos = 0
255 for (const [s, c] of rest) {
256 let buf = ''
257 let bufHi = false
258 for (let i = 0; i < s.length; i++, pos++) {
259 const hi = marks[pos]
260 if (buf && hi !== bufHi) {
261 out.push([buf, bufHi ? code : c])
262 buf = ''
263 }
264 buf += s[i]
265 bufHi = hi
266 }
267 if (buf) out.push([buf, bufHi ? code : c])
268 }
269 return out
270}
271
272// ---- read-only guard ----
273
274// A path segment naming an index (a comma list or wildcard too) or a document id: never `_`-led.
275const NAME = String.raw`[^/_][^/]*`
276
277// Whole routes that only read, matched against the path after an optional leading index.
278// Whole routes, not suffixes: a read-looking word in an id slot (`idx/_doc/point_in_time`) never matches.
279const POST_ANY_INDEX = [
280 '_search', '_search/template', '_msearch', '_msearch/template', '_count', '_mget',
281 '_validate/query', '_field_caps', '_analyze', '_mtermvectors', '_rank_eval', '_search_shards',
282]
283// Point in time: OpenSearch `<index>/_search/point_in_time`, Elasticsearch `<index>/_pit`.
284const POST_WITH_INDEX = [`_explain/${NAME}`, `_termvectors(/${NAME})?`, '_search/point_in_time', '_pit']
285const POST_NO_INDEX = [String.raw`_render/template(/[^/]+)?`, String.raw`_search/scroll(/[^/]+)?`]
286const DELETE_NO_INDEX = [String.raw`_search/scroll(/[^/]+)?`, '_search/point_in_time', '_pit']
287
288const whole = (routes: string[]) => new RegExp(`^(?:${routes.join('|')})$`)
289const POST_ANY_RE = whole(POST_ANY_INDEX)
290const POST_INDEX_RE = whole(POST_WITH_INDEX)
291const POST_BARE_RE = whole(POST_NO_INDEX)
292const DELETE_BARE_RE = whole(DELETE_NO_INDEX)
293
294// Why the path cannot be checked as written, or undefined. The guard must judge the exact path
295// curl sends: `#` starts a fragment curl drops, `%` encodings and `.`/`..` segments are decoded
296// or normalized on the way, whitespace and control characters split or confuse the request line.
297function unsafePath(path: string): string | undefined {
298 if (/[#\s\x00-\x1f\x7f\\]/.test(path)) return 'it holds `#`, whitespace, a backslash or a control character'
299 const route = path.split('?')[0]!
300 if (route.includes('%')) return 'its path holds a %-encoded character'
301 const segs = route.replace(/^\/+/, '').split('/')
302 if (segs.some((seg, i) => seg === '.' || seg === '..' || (seg === '' && i < segs.length - 1))) return 'it has an empty, `.` or `..` path segment'
303 return undefined
304}
305
306// Why the request is refused, or undefined when it only reads.
307export function writeGuard(r: Pick<Request, 'method' | 'path'>): string | undefined {
308 const bad = unsafePath(r.path)
309 if (bad) return `Read-only console: ${r.method} ${r.path} was not sent because ${bad}, so the guard cannot tell what it would do.`
310 if (r.method === 'GET' || r.method === 'HEAD') return undefined
311 const p = r.path.split('?')[0]!.replace(/^\/+|\/+$/g, '')
312 const [first = '', ...rest] = p.split('/')
313 const hasIndex = first !== '' && !first.startsWith('_')
314 const route = hasIndex ? rest.join('/') : p
315 if (r.method === 'POST') {
316 if (POST_ANY_RE.test(route)) return undefined
317 if (hasIndex && POST_INDEX_RE.test(route)) return undefined
318 if (!hasIndex && POST_BARE_RE.test(route)) return undefined
319 }
320 if (r.method === 'DELETE' && !hasIndex && DELETE_BARE_RE.test(route)) return undefined
321 return `Read-only console: ${r.method} ${r.path || '/'} could write to the cluster, so it was not sent. Allowed: GET, HEAD, and POST to search/count/mget/explain/validate/field_caps/analyze/termvectors/templates/scroll.`
322}
323
324// `--filepath x.json --configfilepath y.json` (also `--file`, `--config`, `=` forms, quotes).
325export function parseArgs(args: string): { file?: string; config?: string; rest: string[] } {
326 const tokens = [...args.matchAll(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)].map(m => m[0].replace(/"([^"]*)"|'([^']*)'/g, '$1$2'))
327 const out: { file?: string; config?: string; rest: string[] } = { rest: [] }
328 for (let i = 0; i < tokens.length; i++) {
329 const t = tokens[i]!
330 const m = /^--?(filepath|file|configfilepath|config)(?:=(.*))?$/.exec(t)
331 if (!m) {
332 out.rest.push(t)
333 continue
334 }
335 const value = m[2] ?? tokens[++i] ?? ''
336 if (m[1] === 'filepath' || m[1] === 'file') out.file = value
337 else out.config = value
338 }
339 return out
340}
341
342// ---- servers ----
343
344export type ServerTag = 'production' | 'staging' | 'development' | 'local'
345
346// One entry of servers.json. `name` is what the dropdown shows; `id` is what session files keep.
347export type Server = {
348 id: string
349 name: string
350 host: string
351 port?: number
352 https?: boolean
353 insecure?: boolean
354 username?: string
355 password?: string
356 passwordEnv?: string
357 passwordCommand?: string[]
358 tag?: ServerTag
359 path?: string
360 headers?: Record<string, string>
361 indices?: string[]
362}
363
364const TAGS = new Set(['production', 'staging', 'development', 'local'])
365
366// Reads one config entry into the current shape. An old entry with `url` instead of
367// host / port / https still works; `id` falls back to `name`.
368export function normalizeServer(raw: unknown, defaultPort = 9200): Server | undefined {
369 if (!raw || typeof raw !== 'object') return undefined
370 const r = raw as Record<string, unknown>
371 const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
372 let host = str(r.host)
373 let port = typeof r.port === 'number' ? r.port : undefined
374 let https = typeof r.https === 'boolean' ? r.https : undefined
375 let path = str(r.path)
376 const url = str(r.url)
377 if (!host && url) {
378 try {
379 const u = new URL(url)
380 host = u.hostname
381 https = u.protocol === 'https:'
382 port = u.port ? Number(u.port) : https ? 443 : 80
383 if (u.pathname && u.pathname !== '/') path = u.pathname.replace(/\/+$/, '')
384 } catch {
385 return undefined
386 }
387 }
388 const id = str(r.id) ?? str(r.name)
389 if (!id || !host) return undefined
390 // A host is a name or an address, never text that could garble a prompt.
391 if (!/^[A-Za-z0-9._\-:[\]]+$/.test(host)) return undefined
392 const s: Server = { id, name: str(r.name) ?? id, host: host.toLowerCase(), port: port ?? defaultPort, https: https ?? false }
393 if (r.insecure === true) s.insecure = true
394 if (str(r.username)) s.username = str(r.username)
395 if (typeof r.password === 'string') s.password = r.password
396 if (str(r.passwordEnv)) s.passwordEnv = str(r.passwordEnv)
397 if (Array.isArray(r.passwordCommand) && r.passwordCommand.length && r.passwordCommand.every(a => typeof a === 'string')) s.passwordCommand = r.passwordCommand as string[]
398 if (typeof r.tag === 'string' && TAGS.has(r.tag)) s.tag = r.tag as ServerTag
399 if (path) s.path = path.startsWith('/') ? path : `/${path}`
400 if (r.headers && typeof r.headers === 'object') s.headers = Object.fromEntries(Object.entries(r.headers as Record<string, unknown>).filter(([, v]) => typeof v === 'string')) as Record<string, string>
401 if (Array.isArray(r.indices)) s.indices = r.indices.map(String)
402 return s
403}
404
405export const baseUrl = (s: Pick<Server, 'host' | 'port' | 'https' | 'path'>) =>
406 `${s.https ? 'https' : 'http'}://${s.host}:${s.port ?? (s.https ? 443 : 9200)}${s.path ?? ''}`
407
408// The color a tag gets in the server menu and the status line.
409export const tagColor = (tag?: ServerTag) => (tag === 'production' ? 'error' : tag === 'staging' ? 'warning' : tag === 'development' ? 'success' : undefined)
410
411// Clipboard readers, tried in turn: macOS, Wayland, X11, Windows.
412// Paths with forward slashes on every platform: Windows accepts them, and the mod compares paths by prefix.
413export function toSlash(p: string): string {
414 return p.replace(/\\/g, '/')
415}
416
417// `/x`, `C:/x`, `C:\x` and `\\server\share` are absolute; anything else is relative to the mod's folder.
418export function isAbsolutePath(p: string): boolean {
419 return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(p)
420}
421
422// The home folder: HOME, or USERPROFILE on Windows where HOME is usually unset. Backslashes are
423// separators only on Windows; on macOS and Linux they're part of a file name and stay.
424export function homeOf(home: string | undefined, userProfile: string | undefined, windows: boolean): string {
425 const h = home || (windows ? userProfile : '') || ''
426 return (windows ? toSlash(h) : h).replace(/\/+$/, '')
427}
428
429// `set NAME` in cmd prints every variable starting with NAME; this picks the exact one.
430export function fromCmdSet(out: string, name: string): string | undefined {
431 const line = out.split(/\r?\n/).find(l => l.slice(0, name.length + 1).toUpperCase() === `${name.toUpperCase()}=`)
432 return line === undefined ? undefined : line.slice(name.length + 1)
433}
434
435export const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
436
437export const COPY_COMMANDS = [['pbcopy'], ['wl-copy'], ['xclip', '-i', '-selection', 'clipboard'], ['clip.exe']]
438export const PASTE_COMMANDS = [['pbpaste'], ['wl-paste', '-n'], ['xclip', '-o', '-selection', 'clipboard'], ['powershell.exe', '-NoProfile', '-Command', 'Get-Clipboard']]
439
440// Servers that send credentials need the person's OK before the first request: the host and
441// where the secret comes from, so a changed config (an agent can write it) never sends a secret
442// somewhere new on its own. The fingerprint changes when any of those change.
443// Anything that sends a secret or runs a program counts, with or without a username.
444export function credentialSource(s: Server): string | undefined {
445 const parts: string[] = []
446 if (s.username) parts.push(`user ${JSON.stringify(s.username)}`)
447 if (s.passwordCommand) parts.push(`password from the command ${JSON.stringify(s.passwordCommand)}`)
448 else if (s.passwordEnv) parts.push(`password from $${JSON.stringify(s.passwordEnv).slice(1, -1)}`)
449 else if (s.username || s.password) parts.push('password from servers.json')
450 if (s.headers && Object.keys(s.headers).length) parts.push(`headers ${Object.keys(s.headers).join(', ')}`)
451 return parts.length ? parts.join(', ') : undefined
452}
453
454export function credentialFingerprint(s: Server): string {
455 return JSON.stringify([baseUrl(s), s.insecure ?? false, s.username ?? '', s.passwordCommand ?? null, s.passwordEnv ?? null, s.password === undefined ? null : 'file', s.headers ?? null])
456}
457
458// The first entry wins when ids repeat (trimmed, as normalizeServer reads them).
459export function uniqueServers(list: Server[]): { servers: Server[]; duplicates: string[] } {
460 const seen = new Set<string>()
461 const servers: Server[] = []
462 const duplicates: string[] = []
463 for (const s of list) {
464 if (seen.has(s.id)) duplicates.push(s.id)
465 else {
466 seen.add(s.id)
467 servers.push(s)
468 }
469 }
470 return { servers, duplicates }
471}
472
473// Keys that repeat inside one JSON object: OpenSearch keeps the last one, so a prompt could
474// show one value while another is sent. Answers the first repeated key's path, or undefined.
475// Reads every top-level value (one for a body, several for _bulk / _msearch lines).
476export function duplicateKey(text: string): string | undefined {
477 let i = 0
478 const ws = () => {
479 while (i < text.length && /\s/.test(text[i]!)) i++
480 }
481 const str = (): string => {
482 let out = ''
483 i++
484 while (i < text.length && text[i] !== '"') {
485 if (text[i] === '\\') i++
486 out += text[i] ?? ''
487 i++
488 }
489 i++
490 return out
491 }
492 // Returns a repeated key path found inside the value at i, or undefined; moves i past it.
493 const value = (path: string): string | undefined => {
494 ws()
495 const c = text[i]
496 if (c === '{') {
497 i++
498 const keys = new Set<string>()
499 for (;;) {
500 ws()
501 if (text[i] === '}') {
502 i++
503 return undefined
504 }
505 if (text[i] !== '"') return undefined
506 const k = str()
507 if (keys.has(k)) return path ? `${path}.${k}` : k
508 keys.add(k)
509 ws()
510 if (text[i] === ':') i++
511 const inner = value(path ? `${path}.${k}` : k)
512 if (inner) return inner
513 ws()
514 if (text[i] === ',') i++
515 else if (text[i] === '}') {
516 i++
517 return undefined
518 } else return undefined
519 }
520 }
521 if (c === '[') {
522 i++
523 for (let n = 0; ; n++) {
524 ws()
525 if (text[i] === ']') {
526 i++
527 return undefined
528 }
529 const inner = value(`${path}[${n}]`)
530 if (inner) return inner
531 ws()
532 if (text[i] === ',') i++
533 else if (text[i] === ']') {
534 i++
535 return undefined
536 } else return undefined
537 }
538 }
539 if (c === '"') {
540 str()
541 return undefined
542 }
543 while (i < text.length && !/[\s,}\]]/.test(text[i]!)) i++
544 return undefined
545 }
546 while (i < text.length) {
547 const start = i
548 const found = value('')
549 if (found) return found
550 ws()
551 if (i === start) i++
552 }
553 return undefined
554}
555
556// The body exactly as curl will send it, and a one-line summary of _bulk / _msearch actions.
557export function sentBody(r: Pick<Request, 'path' | 'body'>): { body: string; summary?: string } {
558 const b = requestBody({ start: 0, end: 0, method: 'POST', path: r.path, body: r.body }).body ?? ''
559 if (!/(^|\/)_(bulk|msearch)/.test(r.path.split('?')[0]!)) return { body: b }
560 const counts: Record<string, number> = {}
561 for (const line of b.split('\n')) {
562 try {
563 const v = JSON.parse(line)
564 const k = v && typeof v === 'object' ? Object.keys(v)[0] : undefined
565 if (k && ['index', 'create', 'update', 'delete'].includes(k)) counts[k] = (counts[k] ?? 0) + 1
566 } catch {
567 /* not an action line */
568 }
569 }
570 const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(', ')
571 return { body: b, summary: summary ? `bulk actions: ${summary}` : undefined }
572}
573
574// The rows a text takes at a width: its lines, each cut into width-long pieces.
575export function wrapRows(text: string, width: number): string[] {
576 const w = Math.max(10, width)
577 const out: string[] = []
578 for (const line of text.split('\n')) {
579 if (!line) out.push('')
580 for (let i = 0; i < line.length; i += w) out.push(line.slice(i, i + w))
581 }
582 return out
583}
584
585// Text as a prompt draws it: printable ASCII and newlines only, anything else as \uXXXX, so every
586// character takes exactly one column and nothing (wide, zero-width or bidi) hides what is sent.
587export const asciiView = (t: string) => t.replace(/[^\x20-\x7e\n]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'))
588
589// From offset `from` in an ASCII text, the rows that fit `height` rows of `width` columns, and the
590// offset just after the last character shown. Reading moves only forward, one shown page at a time.
591export function pageFrom(text: string, from: number, width: number, height: number): { rows: string[]; end: number } {
592 const w = Math.max(10, width)
593 const rows: string[] = []
594 let i = Math.max(0, from)
595 while (rows.length < height && i < text.length) {
596 const nl = text.indexOf('\n', i)
597 const lineEnd = nl < 0 ? text.length : nl
598 const piece = text.slice(i, Math.min(lineEnd, i + w))
599 rows.push(piece)
600 i += piece.length
601 if (i === lineEnd && nl >= 0) i++
602 }
603 return { rows, end: i }
604}
605hooks/editor.tsx 508 lines1// Surface module: the query editor. Keeps its text locally and posts every
2// change to the hooks module; a new `rev` in props replaces the text.
3import type { ClientModule, ClientKeyEvent } from 'claude-code'
4
5type Props = { text: string; rev: number; height: number; row: number; paste?: { id: number; text: string }; indices?: string[]; scroll?: { id: number; by: number }; cmd?: { id: number; cmd: 'undo' | 'redo' } }
6type Pos = { row: number; col: number }
7type State = { lines: string[]; row: number; col: number; top: number; rev: number; anchor?: Pos; pasteId?: number; dragging?: boolean; sugSel?: number; sugHide?: string; scrollId?: number; cmdId?: number; hist?: Snap[]; fut?: Snap[]; group?: string }
8type Snap = { lines: string[]; row: number; col: number }
9
10const COLORS: Record<string, { color?: string; bold?: boolean; dimColor?: boolean }> = {
11 method: { color: '#4ec9b0', bold: true },
12 url: { color: '#dcdcaa' },
13 k: { color: '#9cdcfe' },
14 s: { color: '#ce9178' },
15 n: { color: '#b5cea8' },
16 b: { color: '#569cd6' },
17 p: { dimColor: true },
18 c: { color: '#6a9955' },
19 '': {},
20}
21
22const METHOD = /^(\s*)(GET|POST|PUT|DELETE|HEAD|PATCH)(\s+)(.*)$/i
23const SPECIAL = new Set([
24 'up', 'down', 'left', 'right', 'return', 'enter', 'tab', 'backspace', 'delete', 'pageup', 'pagedown',
25 'home', 'end', 'escape', 'space',
26])
27
28function lex(line: string): [string, string][] {
29 const m = METHOD.exec(line)
30 if (m) return [[m[1], ''], [m[2], 'method'], [m[3], ''], [m[4], 'url']]
31 const out: [string, string][] = []
32 let i = 0
33 while (i < line.length) {
34 const c = line[i]
35 if (c === '"') {
36 let j = i + 1
37 while (j < line.length && line[j] !== '"') j += line[j] === '\\' ? 2 : 1
38 j = Math.min(j + 1, line.length)
39 const rest = line.slice(j).trimStart()
40 out.push([line.slice(i, j), rest.startsWith(':') ? 'k' : 's'])
41 i = j
42 } else if (c === '#' || (c === '/' && line[i + 1] === '/')) {
43 out.push([line.slice(i), 'c'])
44 break
45 } else if (/[-0-9]/.test(c)) {
46 const m2 = /^-?\d+(\.\d+)?([eE][+-]?\d+)?/.exec(line.slice(i))
47 const t = m2 ? m2[0] : c
48 out.push([t, m2 ? 'n' : ''])
49 i += t.length
50 } else if (/[a-z]/i.test(c)) {
51 const t = /^[a-z_]+/i.exec(line.slice(i))![0]
52 out.push([t, /^(true|false|null)$/.test(t) ? 'b' : ''])
53 i += t.length
54 } else if ('{}[],:'.includes(c)) {
55 out.push([c, 'p'])
56 i++
57 } else {
58 let j = i
59 while (j < line.length && !/["#/a-z0-9{}[\],:-]/i.test(line[j])) j++
60 if (j === i) j = i + 1
61 out.push([line.slice(i, j), ''])
62 i = j
63 }
64 }
65 return out
66}
67
68// Control characters can't be drawn (the engine refuses the tree): tabs become two spaces,
69// the rest are dropped. Applies to typed keys, pastes and text written into the session file.
70const clean = (t: string) => t.replace(/\r\n?/g, '\n').replace(/\t/g, ' ').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
71
72export const suggestFreeInit = (text: string) => init({ text, rev: 0, height: 5, row: 0 })
73
74const init = (p: Props): State => {
75 const lines = clean(p.text).split('\n')
76 const row = Math.min(Math.max(0, p.row), lines.length - 1)
77 return { lines, row, col: 0, top: Math.max(0, row - 2), rev: p.rev, pasteId: p.paste?.id, scrollId: p.scroll?.id, cmdId: p.cmd?.id }
78}
79
80const before = (a: Pos, b: Pos) => a.row < b.row || (a.row === b.row && a.col < b.col)
81
82// The selection as [start, end), or undefined when nothing is selected.
83const range = (s: State): [Pos, Pos] | undefined => {
84 if (!s.anchor) return undefined
85 const cur = { row: s.row, col: s.col }
86 if (s.anchor.row === cur.row && s.anchor.col === cur.col) return undefined
87 return before(s.anchor, cur) ? [s.anchor, cur] : [cur, s.anchor]
88}
89
90const selectedText = (s: State): string => {
91 const r = range(s)
92 if (!r) return ''
93 const [a, b] = r
94 if (a.row === b.row) return s.lines[a.row].slice(a.col, b.col)
95 return [s.lines[a.row].slice(a.col), ...s.lines.slice(a.row + 1, b.row), s.lines[b.row].slice(0, b.col)].join('\n')
96}
97
98const deleteSelection = (s: State): State => {
99 const r = range(s)
100 if (!r) return { ...s, anchor: undefined }
101 const [a, b] = r
102 const lines = [...s.lines]
103 lines.splice(a.row, b.row - a.row + 1, lines[a.row].slice(0, a.col) + lines[b.row].slice(b.col))
104 return { ...s, lines, row: a.row, col: a.col, anchor: undefined }
105}
106
107const insert = (s0: State, text: string): State => {
108 const s = deleteSelection(s0)
109 const lines = [...s.lines]
110 const cur = lines[s.row]
111 const parts = clean(text).split('\n')
112 const head = cur.slice(0, s.col)
113 const tail = cur.slice(s.col)
114 if (parts.length === 1) {
115 lines[s.row] = head + parts[0] + tail
116 return { ...s, lines, col: s.col + parts[0].length }
117 }
118 const last = parts[parts.length - 1]
119 lines.splice(s.row, 1, head + parts[0], ...parts.slice(1, -1), last + tail)
120 return { ...s, lines, row: s.row + parts.length - 1, col: last.length }
121}
122
123type Suggest = { start: number; prefix: string; items: string[]; id: string }
124
125const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD']
126const ROOT = ['_search', '_count', '_msearch', '_mget', '_cat', '_cluster', '_nodes', '_aliases', '_alias', '_mapping', '_settings', '_stats', '_tasks', '_analyze', '_field_caps', '_validate', '_snapshot', '_ingest', '_index_template', '_component_template', '_template', '_resolve', '_pit', '_plugins', '_sql', '_security']
127const AFTER: Record<string, string[]> = {
128 _cat: ['indices', 'aliases', 'health', 'nodes', 'shards', 'count', 'allocation', 'segments', 'recovery', 'thread_pool', 'templates', 'pending_tasks', 'plugins', 'master', 'cluster_manager', 'nodeattrs', 'repositories', 'snapshots', 'tasks', 'fielddata'],
129 _cluster: ['health', 'state', 'stats', 'settings', 'pending_tasks', 'allocation'],
130 _nodes: ['stats', 'info', 'hot_threads', 'usage'],
131 _search: ['scroll', 'point_in_time', 'template'],
132 _validate: ['query'],
133 _resolve: ['index'],
134 _ingest: ['pipeline'],
135}
136const INDEX = ['_search', '_count', '_mapping', '_settings', '_doc', '_source', '_explain', '_validate', '_field_caps', '_stats', '_alias', '_aliases', '_mget', '_msearch', '_termvectors', '_search_shards', '_pit', '_refresh', '_analyze', '_segments', '_recovery']
137const PARAMS = ['v', 'pretty', 'format=json', 'human', 's=', 'h=', 'size=', 'from=', 'q=', 'filter_path=', 'expand_wildcards=', 'ignore_unavailable=', 'scroll=', 'keep_alive=', 'timeout=', 'track_total_hits=', 'error_trace', 'bytes=', 'time=']
138const DSL = ['query', 'bool', 'must', 'should', 'filter', 'must_not', 'minimum_should_match', 'match', 'match_phrase', 'match_phrase_prefix', 'multi_match', 'term', 'terms', 'range', 'gte', 'gt', 'lte', 'lt', 'exists', 'field', 'fields', 'wildcard', 'prefix', 'regexp', 'fuzzy', 'ids', 'nested', 'path', 'query_string', 'simple_query_string', 'aggs', 'aggregations', 'size', 'from', 'sort', 'order', 'asc', 'desc', '_source', 'includes', 'excludes', 'track_total_hits', 'highlight', 'post_filter', 'script', 'script_score', 'function_score', 'boost', 'date_histogram', 'calendar_interval', 'fixed_interval', 'histogram', 'interval', 'avg', 'sum', 'min', 'max', 'cardinality', 'value_count', 'stats', 'extended_stats', 'top_hits', 'percentiles', 'composite', 'sources', 'after', 'search_after', 'collapse', 'explain', 'timeout', 'knn', 'k', 'num_candidates', 'pit', 'keep_alive', 'id']
139
140const rank = (names: readonly string[], prefix: string) => {
141 const low = prefix.toLowerCase()
142 const starts = names.filter(n => n.toLowerCase().startsWith(low) && n.toLowerCase() !== low)
143 const inside = low ? names.filter(n => !n.toLowerCase().startsWith(low) && n.toLowerCase().includes(low)) : []
144 return [...new Set([...starts, ...inside])]
145}
146
147// What fits where the cursor is: a method at the start of a request line; on the path, index names,
148// endpoints (`_search`, `_cat/…`) and query parameters; inside a JSON key, query DSL words.
149export const suggest = (s: State, names: readonly string[]): Suggest | undefined => {
150 if (s.anchor) return undefined
151 const head = s.lines[s.row]!.slice(0, s.col)
152 let start = s.col
153 let prefix = ''
154 let items: string[] = []
155 const req = /^(\s*)(GET|POST|PUT|DELETE|HEAD|PATCH)(\s+)(\S*)$/i.exec(head)
156 if (/^[A-Za-z]+$/.test(head)) {
157 prefix = head
158 items = rank(METHODS, prefix)
159 } else if (req) {
160 const path = req[4]!
161 const q = path.indexOf('?')
162 if (q >= 0) {
163 prefix = /[^?&]*$/.exec(path)![0]
164 items = rank(PARAMS, prefix)
165 } else {
166 const segs = path.replace(/^\//, '').split('/')
167 const cur = segs.pop()!
168 prefix = /[^,]*$/.exec(cur)![0]
169 if (!prefix && !(path === '' || path.endsWith('/') || path.endsWith(','))) return undefined
170 const first = segs[0]
171 if (!segs.length) items = prefix.startsWith('_') ? rank(ROOT, prefix) : rank([...names, ...(prefix ? [] : ROOT)], prefix)
172 else if (first?.startsWith('_')) items = rank(AFTER[segs[segs.length - 1]!] ?? [], prefix)
173 else if (segs.length === 1) items = rank(INDEX, prefix)
174 else items = rank(AFTER[segs[segs.length - 1]!] ?? [], prefix)
175 }
176 } else {
177 // A key opens after `{`, `,` or at the start of a line; after `:` it is a value.
178 const key = /"([A-Za-z_]*)$/.exec(head)
179 const before = key ? head.slice(0, head.length - key[1]!.length - 1) : ''
180 if (!key || (before.match(/"/g)?.length ?? 0) % 2 === 1 || !/(^|[{,])\s*$/.test(before)) return undefined
181 prefix = key[1]!
182 if (!prefix) return undefined
183 items = rank(DSL, prefix)
184 }
185 start = s.col - prefix.length
186 items = items.slice(0, 8)
187 if (!items.length) return undefined
188 const id = `${s.row}:${start}:${prefix}`
189 if (s.sugHide === id) return undefined
190 return { start, prefix, items, id }
191}
192
193const Editor: ClientModule<Props, State> = (props, surface) => {
194 const { Box, Text } = surface.elements
195 const prev = surface.state
196 let st: State =
197 prev && prev.rev === props.rev
198 ? prev
199 : prev
200 ? { ...init(props), hist: [...(prev.hist ?? []), { lines: prev.lines, row: prev.row, col: prev.col }].slice(-200), fut: [] }
201 : init(props)
202 const height = Math.max(1, props.height)
203 const gutter = String(st.lines.length).length + 3
204
205 // `copy` rides on the same post: a second post in one frame would replace the first.
206 const commit = (next: State, type = 'edit', group = '', copy?: string) => {
207 if (type === 'edit' && next.sugSel === st.sugSel) next.sugSel = 0
208 if (type === 'edit' && next.lines !== st.lines) {
209 // Typing on one line is one undo step; any other edit is its own step.
210 const same = group === 'type' && st.group === 'type' && next.row === st.row
211 next.hist = same ? st.hist : [...(st.hist ?? []), { lines: st.lines, row: st.row, col: st.col }].slice(-200)
212 next.fut = []
213 next.group = group
214 } else if (type === 'cursor') next.group = ''
215 if (next.row < next.top) next.top = next.row
216 if (next.row >= next.top + height) next.top = next.row - height + 1
217 surface.setState(next)
218 surface.post({ type, text: next.lines.join('\n'), row: next.row, ...(copy !== undefined ? { copy } : {}) })
219 }
220
221 // A wheel tick the hooks module heard over the editor moves the window, not the cursor.
222 if (props.scroll && props.scroll.id !== st.scrollId) {
223 const maxTop = Math.max(0, st.lines.length - height)
224 st = { ...st, top: Math.max(0, Math.min(maxTop, st.top + props.scroll.by)), scrollId: props.scroll.id }
225 surface.setState(st)
226 }
227
228 const undo = (s: State): State | undefined => {
229 const h = s.hist ?? []
230 if (!h.length) return undefined
231 const back = h[h.length - 1]!
232 const next: State = { ...s, ...back, anchor: undefined, hist: h.slice(0, -1), fut: [...(s.fut ?? []), { lines: s.lines, row: s.row, col: s.col }], group: '' }
233 commit(next, 'restore')
234 return next
235 }
236 const redo = (s: State): State | undefined => {
237 const f = s.fut ?? []
238 if (!f.length) return undefined
239 const fwd = f[f.length - 1]!
240 const next: State = { ...s, ...fwd, anchor: undefined, fut: f.slice(0, -1), hist: [...(s.hist ?? []), { lines: s.lines, row: s.row, col: s.col }], group: '' }
241 commit(next, 'restore')
242 return next
243 }
244
245 // Undo / redo pressed on the pane's buttons arrive once each.
246 if (props.cmd && props.cmd.id !== st.cmdId) {
247 st = { ...st, cmdId: props.cmd.id }
248 st = (props.cmd.cmd === 'undo' ? undo(st) : redo(st)) ?? st
249 surface.setState(st)
250 }
251
252 // A paste the hooks module read from the clipboard (ctrl+v) lands once.
253 if (props.paste && props.paste.id !== st.pasteId) {
254 st = { ...insert(st, props.paste.text), pasteId: props.paste.id }
255 commit(st)
256 }
257
258 surface.onKey((k: ClientKeyEvent) => {
259 const s: State = st
260 const sug = suggest(s, props.indices ?? [])
261 if (sug && !k.ctrl && !k.meta && !k.shift) {
262 const sel = Math.min(s.sugSel ?? 0, sug.items.length - 1)
263 if (k.key === 'down' || k.key === 'up') {
264 const n = (sel + (k.key === 'down' ? 1 : -1) + sug.items.length) % sug.items.length
265 surface.setState({ ...s, sugSel: n })
266 return
267 }
268 if (k.key === 'tab' || k.key === 'return' || k.key === 'enter') {
269 const pick = sug.items[sel]!
270 const lines = [...s.lines]
271 const l = lines[s.row]!
272 lines[s.row] = l.slice(0, sug.start) + pick + l.slice(s.col)
273 const col = sug.start + pick.length
274 return commit({ ...s, lines, col, sugSel: 0, sugHide: `${s.row}:${sug.start}:${pick}` })
275 }
276 }
277 const line = s.lines[s.row]
278 const key = k.key
279 const text = () => s.lines.join('\n')
280 if ((k.ctrl && (key === 'return' || key === 'enter' || key === 'r')) || (k.meta && key === 'return')) {
281 surface.post({ type: 'run', text: text(), row: s.row })
282 return
283 }
284 if ((k.ctrl || k.meta) && key === 'l') return surface.post({ type: 'format', text: text(), row: s.row })
285 if ((k.ctrl || k.meta) && (key === 'z' || key === 'Z')) {
286 void (k.shift || key === 'Z' ? redo(s) : undo(s))
287 return
288 }
289 if ((k.ctrl || k.meta) && key === 'y') {
290 void redo(s)
291 return
292 }
293 if ((k.ctrl || k.meta) && key === 'a') {
294 const last = s.lines.length - 1
295 return commit({ ...s, anchor: { row: 0, col: 0 }, row: last, col: s.lines[last].length }, 'cursor')
296 }
297 if ((k.ctrl || k.meta) && (key === 'c' || key === 'x')) {
298 const sel = selectedText(s) || line
299 if (key === 'x') {
300 if (range(s)) return commit(deleteSelection(s), 'edit', '', sel)
301 const lines = [...s.lines]
302 lines.splice(s.row, 1)
303 if (!lines.length) lines.push('')
304 const row = Math.min(s.row, lines.length - 1)
305 return commit({ ...s, lines, row, col: 0, anchor: undefined }, 'edit', '', sel)
306 }
307 surface.post({ type: 'copy', copy: sel, text: text(), row: s.row })
308 return
309 }
310 if ((k.ctrl || k.meta) && key === 'v') return surface.post({ type: 'paste', text: text(), row: s.row })
311
312 // Movement; shift extends the selection from where it started.
313 const move = (to: Pos) =>
314 commit({ ...s, ...to, anchor: k.shift ? (s.anchor ?? { row: s.row, col: s.col }) : undefined }, 'cursor')
315 const clamp = (row: number) => Math.min(s.col, s.lines[row].length)
316 switch (key) {
317 case 'up': { const row = Math.max(0, s.row - 1); return move({ row, col: clamp(row) }) }
318 case 'down': { const row = Math.min(s.lines.length - 1, s.row + 1); return move({ row, col: clamp(row) }) }
319 case 'pageup': { const row = Math.max(0, s.row - height); return move({ row, col: clamp(row) }) }
320 case 'pagedown': { const row = Math.min(s.lines.length - 1, s.row + height); return move({ row, col: clamp(row) }) }
321 case 'left': {
322 const r = range(s)
323 if (r && !k.shift) return move(r[0])
324 if (s.col > 0) return move({ row: s.row, col: s.col - 1 })
325 if (s.row > 0) return move({ row: s.row - 1, col: s.lines[s.row - 1].length })
326 return move({ row: 0, col: 0 })
327 }
328 case 'right': {
329 const r = range(s)
330 if (r && !k.shift) return move(r[1])
331 if (s.col < line.length) return move({ row: s.row, col: s.col + 1 })
332 if (s.row < s.lines.length - 1) return move({ row: s.row + 1, col: 0 })
333 return move({ row: s.row, col: s.col })
334 }
335 case 'home': return move({ row: s.row, col: 0 })
336 case 'end': return move({ row: s.row, col: line.length })
337 case 'tab': return commit(insert(s, ' '))
338 case 'space': return commit(insert(s, ' '))
339 case 'return':
340 case 'enter': {
341 const base = deleteSelection(s)
342 const l = base.lines[base.row]
343 const indent = /^\s*/.exec(l)![0]
344 const prev = l.slice(0, base.col).trimEnd()
345 const next = l.slice(base.col).trimStart()
346 const opens = prev.endsWith('{') || prev.endsWith('[')
347 const closes = next.startsWith('}') || next.startsWith(']')
348 if (opens && closes) {
349 const inner = indent + ' '
350 const n = insert(base, '\n' + inner + '\n' + indent)
351 return commit({ ...n, row: n.row - 1, col: inner.length })
352 }
353 return commit(insert(base, '\n' + indent + (opens ? ' ' : '')))
354 }
355 case 'backspace': {
356 if (range(s)) return commit(deleteSelection(s))
357 const lines = [...s.lines]
358 if (s.col > 0) {
359 const back = /^ +$/.test(line.slice(0, s.col)) && s.col % 2 === 0 ? 2 : 1
360 lines[s.row] = line.slice(0, s.col - back) + line.slice(s.col)
361 return commit({ ...s, lines, col: s.col - back, anchor: undefined })
362 }
363 if (s.row === 0) return
364 const col = lines[s.row - 1].length
365 lines.splice(s.row - 1, 2, lines[s.row - 1] + line)
366 return commit({ ...s, lines, row: s.row - 1, col, anchor: undefined })
367 }
368 case 'delete': {
369 if (range(s)) return commit(deleteSelection(s))
370 const lines = [...s.lines]
371 if (s.col < line.length) lines[s.row] = line.slice(0, s.col) + line.slice(s.col + 1)
372 else if (s.row < lines.length - 1) lines.splice(s.row, 2, line + lines[s.row + 1])
373 else return
374 return commit({ ...s, lines, anchor: undefined })
375 }
376 }
377 if (k.ctrl) {
378 if (key === 'e') return move({ row: s.row, col: line.length })
379 if (key === 'k') {
380 const lines = [...s.lines]
381 lines[s.row] = line.slice(0, s.col)
382 return commit({ ...s, lines, anchor: undefined })
383 }
384 return
385 }
386 if (k.meta || SPECIAL.has(key)) return
387 // A character, or a paste arriving as one run of text.
388 const typed = key
389 const pairs: Record<string, string> = { '{': '}', '[': ']', '"': '"' }
390 if (!range(s) && typed.length === 1 && pairs[typed] && (s.col === line.length || /[\s,\]}]/.test(line[s.col]))) {
391 if (!(typed === '"' && line[s.col] === '"')) {
392 const n = insert(s, typed + pairs[typed])
393 return commit({ ...n, col: n.col - 1 })
394 }
395 }
396 if (!range(s) && typed.length === 1 && (typed === '}' || typed === ']' || typed === '"') && line[s.col] === typed) {
397 return commit({ ...s, col: s.col + 1 }, 'cursor')
398 }
399 return commit(insert(s, typed), 'edit', typed.length === 1 && typed !== ' ' ? 'type' : '')
400 })
401
402 surface.onPointer(ev => {
403 const toPos = (): Pos => {
404 const row = Math.max(0, Math.min(st.lines.length - 1, st.top + ev.y))
405 return { row, col: Math.min(Math.max(0, ev.x - gutter), st.lines[row].length) }
406 }
407 if (ev.type === 'down' && ev.button === 'left') {
408 const row = st.top + ev.y
409 if (row < st.lines.length && ev.x < gutter - 1 && METHOD.test(st.lines[row])) {
410 surface.post({ type: 'run', text: st.lines.join('\n'), row })
411 return
412 }
413 const pos = toPos()
414 const anchor = ev.shift ? (st.anchor ?? { row: st.row, col: st.col }) : pos
415 commit({ ...st, ...pos, anchor, dragging: true }, 'cursor')
416 } else if (ev.type === 'move' && st.dragging && ev.button === 'left') {
417 const pos = toPos()
418 if (pos.row !== st.row || pos.col !== st.col) commit({ ...st, ...pos }, 'cursor')
419 } else if (ev.type === 'up' && st.dragging) {
420 commit({ ...st, ...toPos(), dragging: false }, 'cursor')
421 }
422 })
423
424 const sel = range(st)
425 const isSel = (r: number, c: number) =>
426 !!sel && !before({ row: r, col: c }, sel[0]) && before({ row: r, col: c }, sel[1])
427
428 const rows = []
429 for (let r = st.top; r < Math.min(st.lines.length, st.top + height); r++) {
430 const text = st.lines[r]
431 const isReq = METHOD.test(text)
432 const num = String(r + 1).padStart(gutter - 3)
433 const parts = []
434 let pos = 0
435 for (const [t, c] of lex(text)) {
436 const style = COLORS[c] ?? {}
437 // Split the token where cursor or selection changes the look.
438 let buf = ''
439 let look = ''
440 const flush = () => {
441 if (!buf) return
442 parts.push(
443 <Text {...style} inverse={look === 'cur' ? true : undefined} backgroundColor={look === 'sel' ? '#264f78' : undefined}>
444 {buf}
445 </Text>,
446 )
447 buf = ''
448 }
449 for (let i = 0; i < t.length; i++) {
450 const col = pos + i
451 const l = r === st.row && col === st.col ? 'cur' : isSel(r, col) ? 'sel' : ''
452 if (l !== look) {
453 flush()
454 look = l
455 }
456 buf += t[i]
457 }
458 flush()
459 pos += t.length
460 }
461 if (r === st.row && st.col >= text.length) parts.push(<Text inverse> </Text>)
462 else if (sel && isSel(r, text.length)) parts.push(<Text backgroundColor="#264f78"> </Text>)
463 rows.push(
464 <Box flexDirection="row" height={1}>
465 <Text color={isReq ? '#4ec9b0' : undefined} dimColor={!isReq}>
466 {isReq ? '▶' : ' '}
467 {num}{' '}
468 </Text>
469 <Text wrap="truncate-end">{parts}</Text>
470 </Box>,
471 )
472 }
473 const sug = suggest(st, props.indices ?? [])
474 let popup = null
475 if (sug) {
476 const sel = Math.min(st.sugSel ?? 0, sug.items.length - 1)
477 const width = Math.min(60, Math.max(...sug.items.map(i => i.length)) + 4)
478 const y = st.row - st.top
479 const below = y + 1 + sug.items.length + 2 <= height
480 popup = (
481 <Box
482 position="absolute"
483 top={below ? y + 1 : Math.max(0, y - sug.items.length - 2)}
484 left={gutter + sug.start}
485 width={width}
486 flexDirection="column"
487 borderStyle="round"
488 borderColor="#4ec9b0"
489 backgroundColor="#1e1e1e"
490 >
491 {sug.items.map((item, i) => (
492 <Text wrap="truncate-end" inverse={i === sel ? true : undefined} color={i === sel ? undefined : '#dcdcaa'}>
493 {item}
494 </Text>
495 ))}
496 </Box>
497 )
498 }
499 return (
500 <Box flexDirection="column" height={height}>
501 {rows}
502 {popup}
503 </Box>
504 )
505}
506
507export default Editor
508hooks/viewer.tsx 48 lines1// Surface module: draws the window of response lines the hooks module hands
2// it and posts keys and clicks back; folding and search live in the hooks module.
3import type { ClientModule, ClientKeyEvent } from 'claude-code'
4
5type Props = { lines: [string, string][][]; cursor: number; height: number; status: string }
6
7const COLORS: Record<string, { color?: string; bold?: boolean; dimColor?: boolean; backgroundColor?: string }> = {
8 k: { color: '#9cdcfe' },
9 s: { color: '#ce9178' },
10 n: { color: '#b5cea8' },
11 b: { color: '#569cd6' },
12 p: { dimColor: true },
13 m: { dimColor: true },
14 f: { color: '#c586c0' },
15 H: { backgroundColor: '#614d00', color: '#ffffff' },
16 C: { backgroundColor: '#d7a700', color: '#000000', bold: true },
17 '': {},
18}
19
20const Viewer: ClientModule<Props, number> = (props, surface) => {
21 const { Box, Text } = surface.elements
22
23 surface.onKey((k: ClientKeyEvent) => surface.post({ type: 'key', key: k.key, ctrl: !!k.ctrl, shift: !!k.shift, meta: !!k.meta }))
24 surface.onPointer(ev => {
25 if (ev.type === 'down' && ev.y < props.lines.length) surface.post({ type: 'click', y: ev.y, x: ev.x })
26 })
27
28 return (
29 <Box flexDirection="column" height={props.height + 1}>
30 {props.lines.map((segs, i) => (
31 <Box height={1}>
32 <Text wrap="truncate-end" backgroundColor={i === props.cursor ? '#264f78' : undefined}>
33 {segs.map(([t, c]) => (
34 <Text {...(COLORS[c] ?? {})}>{t}</Text>
35 ))}
36 </Text>
37 </Box>
38 ))}
39 {props.lines.length < props.height && <Box height={props.height - props.lines.length} />}
40 <Text dimColor wrap="truncate-end">
41 {props.status}
42 </Text>
43 </Box>
44 )
45}
46
47export default Viewer
48types/index.d.ts 58 lines1export type OsResult = {
2 id: number
3 server: string
4 method: string
5 path: string
6 status: number
7 ms: number
8 text: string
9 error?: string
10 at: string
11}
12
13export type OsView = {
14 collapsed: string[]
15 top: number
16 cursor: number
17 search: string
18 match: number
19}
20
21export type OsServer = { id: string; name: string; host: string; url: string; tag?: 'production' | 'staging' | 'development' | 'local'; indices?: string[] }
22
23// The exact request a prompt shows; Yes sends this, never what the editor holds by then.
24export type OsPending = { serverId: string; serverName: string; url: string; fingerprint: string; method: string; path: string; body: string; sent: string; summary?: string; production: boolean }
25
26// A session unlock holds for one server entry exactly as it was: same id, URL and credentials.
27export type OsUnlock = { id: string; url: string; fingerprint: string }
28
29export type OsConfirm =
30 | { kind: 'write'; req: OsPending; shownFrom: number }
31 | { kind: 'trust'; req: OsPending | null; serverId: string; serverName: string; url: string; source: string; fingerprint: string; then: 'run' | 'indices' }
32
33declare module 'claude-code' {
34 interface PluginState {
35 'opensearch-console': {
36 query: string
37 rev: number
38 cursorRow: number
39 result: OsResult | null
40 view: OsView
41 running: boolean
42 server: string
43 servers: OsServer[]
44 showHelp: boolean
45 paste: { id: number; text: string } | null
46 indices: Record<string, string[]>
47 indicesLoading: string
48 paths: { file: string; config: string } | null
49 editorScroll: { id: number; by: number } | null
50 menuOpen: boolean
51 confirm: OsConfirm | null
52 unlocked: OsUnlock[]
53 prodSeen: string[]
54 editorCmd: { id: number; cmd: 'undo' | 'redo' } | null
55 }
56 }
57}
58