SLOPSHOPPER

opensearch-console

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

newpaneguardcommandtoasttool
★ 1v0.2.4MITupdated 2026-10-22shohamtal/cc-mod-opensearch-console
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · opensearch-console
│ ┃ opensearch ✕ › fix the failing auth test and add an audit log call │ ┃ [ ▾ Choose server ] [ 🔒 writes locked ] [ ↶ │ ┃ no request → (no server) ⏺ Read(src/auth.ts) │ ┃ ▣ client module ./editor.tsx ⎿ Read 6 lines │ ┃ ───────────────────────────────────────────… ⏺ Update(src/auth.ts) │ ┃ no response yet ⎿ Added 2 lines, removed 1 line │ ┃ Find : search the response (/ or ctrl+f from ⏺ Bash(bun test) │ ┃ ▣ client module ./viewer.tsx ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /opensearch │ ⎿ opensearch-console: Config file not found: /Users/dev/.claude/mo │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · opensearch
[ ▾ Choose server ] [ 🔒 writes locked ] [ ↶ ] [ ↷ ] │ [ For no request → (no server) ▣ client module ./editor.tsx ──────────────────────────────────────────────────────── no response yet Find : search the response (/ or ctrl+f from it) · Enter = n ▣ client module ./viewer.tsx
README

OpenSearch & Elasticsearch Console: a Claude Code mod

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.

Claude Code with the console docked on the right: Claude reads the response the user just ran and summarizes it

Why

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:

  • The agent proposes, you dispose. Claude injects the query into the editor; you see it, tweak it, and press ▶ yourself.
  • A real response viewer. Colored JSON with folding, search and copy, instead of a wall of text.
  • Nothing writes to your cluster unless you say so. Every write shows you the exact request first (see Writes are locked).

How it looks

1. You ask, Claude writes the query into your console. It shows up in the pane on the right, ready to read and edit.

Claude writes a search query into the console pane

2. You press ▶. Claude reads the response from the console's response file and explains it, as in the screenshot at the top.

Features

EditorDev 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
RunClick ▶ in the gutter next to a request, or ctrl+r / ctrl+enter
ResponseColored, foldable JSON (click, Enter, ←/→, e/c expand/collapse all, 1–9 fold to depth), mouse-wheel scroll, y to copy
FindSearch the response; matches are highlighted and collapsed parents open by themselves; n/N next/previous
ServersAny number of clusters in a ▾ menu, tagged production / staging / development / local; passwords from the file, an environment variable or a command
AgentsOne JSON file per console, so several sessions or agents never overwrite each other; a tool documents the protocol for the model
Server menuWrite lockFind in the response
server menuwrite locksearch
FoldingBuilt-in help
foldinghelp

All screenshots come from a throwaway demo session against made-up data (demo/mock_opensearch.py), not a real cluster or terminal.

Install

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:

  • macOS and Linux: work as is. ctrl+v reads the clipboard with pbpaste, wl-paste or xclip; your terminal's own paste works everywhere.
  • Windows: works natively, with no WSL needed.
  • 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.
  • The mod's folder is %USERPROFILE%\.claude\mods\opensearch when HOME is unset, and ~ in paths means the same folder.
  • Paths may use \ 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.
  • Copying uses System32\clip.exe. Under WSL, install wl-copy or xclip for copying, or the terminal's own clipboard route (OSC 52) is used.
  • A 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.

Configure your clusters

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" }
  ]
}
FieldMeaning
idStable key, stored in session files (defaults to name)
nameWhat the menu shows
host, port, httpsWhere to connect (port defaults to 9200, https to false)
insecureSkip 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
tagproduction, staging, development or local. Production is shown in red
path, headers, indicesURL prefix behind a proxy, extra headers (for example Authorization: ApiKey …), extra autocomplete names

Older configs with a single url field still work.

  • Credentials go to curl on stdin, never on the command line, so they don't show up in ps. curl runs with -q, so it never reads ~/.curlrc.
  • Keep this file private (chmod 600). It never leaves your machine.

Use it with Claude

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:

FileWhat 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.jsonThe last response body (pretty JSON)
~/.claude/mods/opensearch/servers.jsonClusters and credentials (any path via --configfilepath)
~/.claude/mods/opensearch/indices.jsonCached 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.

Separate consoles per session or agent

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

Writes are locked

The console sends only requests that read:

  • GET and HEAD
  • POST 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-time

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

  • Method and full URL.
  • Body, byte for byte, in pages that fit the pane. The send buttons appear only on the last page.
  • A count of _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:

  • Run this request once: sends exactly what the prompt shows, even if the editor changed meanwhile.
  • Unlock for this session: later writes to this server go out without asking until Claude Code exits. The unlock is tied to the server entry as it was: if its host or credentials change, it's gone. It is never offered for a server tagged production, nor for one that was tagged production earlier in the session. 🔓 locks the server again.
  • Cancel

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 .. segments
  • whitespace

Request 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.

Recommended: stop agents from editing the console's config

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

Keys

WhereKeys
Editorctrl+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
AnywhereMouse wheel scrolls the half under the pointer · Esc returns to the prompt · /opensearch toggles the pane

Develop

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).

License

MIT

Source 5 files
hooks/register.tsx 1209 lines
1import { 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 lines
1// 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}
605
hooks/editor.tsx 508 lines
1// 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
508
hooks/viewer.tsx 48 lines
1// 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
48
types/index.d.ts 58 lines
1export 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