SLOPSHOPPER

rich-input

Warp-style Rich Input for Claude Code: the prompt box as a multi-line editor you switch on and off (Enter for a new line, Ctrl+Enter to send), an editor pane…

newpanebandspinnercommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rich-input
│ ┃ rich-input-pane ✕ › fix the failing auth test and add an audit log call │ ┃ Enter new line · Ctrl+S puts it in the prom… │ ┃ ▣ client module ./editor.tsx ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /rich │ ⎿ rich-input: Rich input on: Enter takes a new line, Ctrl+Enter se │ │ ✎ Rich input: on Enter new line · Ctrl+Enter send ▤ Editor Alt+E ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✎ Rich input: on Enter new line · Ctrl+Enter send ▤ Editor Alt+E
Pane · rich-input-pane
Enter new line · Ctrl+S puts it in the prompt · right-click… ▣ client module ./editor.tsx
README

Rich Input Editor for Claude Code

English · Português

A Warp-style Rich Input Editor for Claude Code, built as a plugin. It turns Claude Code's own prompt box into a multi-line editor that you switch on and off: while it's on, Enter starts a new line where the cursor is and Ctrl+Enter sends. @ completes files and folders, one folder at a time.

Why

Claude Code already has Ctrl+G, which opens the prompt in your $EDITOR. But in an external editor you lose @ autocomplete, so you have to type file paths from memory. And in the plain prompt box, Enter sends, so writing several lines takes Shift+Enter or \ at each line end. With this plugin the prompt box itself works like an editor when you want it to.

Features

  • The prompt box is the editor. The cursor moves freely: arrows, Home/End, Enter in the middle of a line splits it, Backspace at the start of a line joins it to the one above. Pasting, images and your prompt history work as usual, because it is Claude Code's own box.
  • On and off with one key: Alt+R or /rich. Off, Enter sends as usual.
  • You can see when it's on: the text you type turns blue, rich input shows in the footer under the prompt, and the row above the prompt says ✎ Rich input: on.
  • An editor pane (Alt+E or /rich pane) for terminals that pass mouse clicks to Claude Code: a free-cursor editor beside the conversation, with its own @ menu, right-click to paste and Ctrl+S to put the text in the prompt. See Editor pane.
  • @ completion that browses folders:
  • @ lists the top level of the project.
  • The plugin's rows appear under the prompt's own suggestions. Taking a folder (@src/) keeps the mention open and lists that folder's contents, so you go @src/ → @src/lib/ → file.
  • A bare name (@parse) searches the whole project.
  • Uses git ls-files, so .gitignore is respected. Outside a git repository it walks the folders instead (up to 5,000 entries, skipping node_modules, dist, etc.).
  • Paths with spaces are quoted (@"docs/my notes.md"), the same way the native prompt does it.

Requirements

  • Claude Code 2.1.292 or newer. The plugin uses Claude Code's function hooks API (TypeScript hooks modules), which is in early access and may change between releases.

Installation

Option 1: from GitHub (recommended)

claude plugin marketplace add higorcesarqn/rich-input-editor
claude plugin install rich-input@rich-input-editor

Or, from inside a Claude Code session:

/plugin marketplace add higorcesarqn/rich-input-editor
/plugin install rich-input@rich-input-editor
/reload-plugins

To update later:

claude plugin marketplace update rich-input-editor
claude plugin update rich-input@rich-input-editor

Option 2: from a local clone

git clone https://github.com/higorcesarqn/rich-input-editor.git
claude --plugin-dir ./rich-input-editor/plugins/rich-input

--plugin-dir loads the plugin for that session only. To load it every time, install it from the local marketplace instead:

claude plugin marketplace add ./rich-input-editor
claude plugin install rich-input@rich-input-editor

Turning it on and off

Run /rich, or press Alt+R, to switch. Plugins can't change what Enter does in the prompt box, but Claude Code's keybindings can, so switching edits the Chat block of ~/.claude/keybindings.json:

  • On adds "enter": "chat:newline" and "ctrl+enter": "chat:submit".
  • Off removes those two entries, and only while they still say that. Everything else in the file stays as it is.

Claude Code picks the change up right away, and it applies to every session, since the file is shared. If the file isn't JSON the plugin can edit, it's left alone and /rich says so.

Some terminals send Ctrl+Enter as a plain Enter. There, add another key for sending to the Chat block yourself, for example "ctrl+s": "chat:submit"; switching off leaves it in place.

The Alt+R and Alt+E shortcuts

Plugins can't register keys of their own. The buttons above the prompt listen to keybinding actions, and you bind keys to those actions: ✎ Rich input listens to app:toggleReplTab, ▤ Editor to app:toggleDiffPreSession. Add this to ~/.claude/keybindings.json (create the file if it doesn't exist):

{
  "$schema": "https://www.schemastore.org/claude-code-keybindings.json",
  "$docs": "https://code.claude.com/docs/en/keybindings",
  "bindings": [
    {
      "context": "Global",
      "bindings": {
        "alt+r": "app:toggleReplTab",
        "alt+e": "app:toggleDiffPreSession"
      }
    }
  ]
}

If one of those actions is already in use in your setup, pick another action with no handler and set it in the plugin's toggleAction or paneAction option (/plugin configure rich-input@rich-input-editor, or /config). The shortcuts only work while the row above the prompt is visible (showBand, on by default).

Coming from 0.4: keep the alt+r binding, and remove the ctrl+s → app:toggleDiffPreSession one in the PaneField context, or it presses ▤ Editor.

Editor pane

Alt+E (or /rich pane) opens an editor beside the conversation, with whatever the prompt holds moved into it. It needs a terminal that passes mouse clicks to Claude Code: the pane only gets the keyboard after a click, which no plugin can skip. Until then its top row says ▶ Click here to start typing.

  • Type anywhere: arrows, Home/End, a click on the text moves the cursor there, Enter splits the line, Backspace at a line start joins it to the one above, Delete joins the next.
  • @ opens a menu of files and folders under the line; arrows pick, Enter or Tab takes, a folder lists its contents next.
  • Right click pastes the clipboard. Ctrl+V doesn't reach the pane, so the plugin reads the clipboard itself (powershell Get-Clipboard on Windows, pbpaste, wl-paste or xclip elsewhere).
  • Ctrl+S closes the pane and puts the text in the prompt; press Enter there to send, so @ references are resolved.
  • Esc hands the keyboard back to Claude Code without closing the pane; a click goes back in. Alt+E again (or the pane's close mark) closes it and puts the draft back in the prompt.
  • It docks on the right with Claude Code's fullscreen layout and at least 110 columns; otherwise it opens above the prompt.

Keys

KeyAction
Alt+R / /richTurn rich input on or off
Alt+E / /rich paneOpen or close the editor pane
EnterOn: new line where the cursor is (with @ suggestions open: take the highlighted one). Off: send
Ctrl+EnterOn: send
← → ↑ ↓ Home EndMove the cursor
Backspace at the start of a lineJoin it to the line above
@Complete files and folders; taking a folder lists its contents

How it works

plugins/rich-input/
├── .claude-plugin/plugin.json   manifest and options (toggleAction, paneAction, showBand)
├── hooks/
│   ├── hooks.json               points Claude Code at register.tsx
│   ├── register.tsx             /rich, the row above the prompt, the footer label, the blue draft, the @ rows, the editor pane
│   ├── editor.tsx               the pane editor: draws the text and cursor, takes keys and clicks
│   ├── editor-core.ts           the pane editor's edits, cursor moves, @ menu and scrolling
│   ├── bindings.ts              turning the Chat bindings in keybindings.json on and off
│   └── complete.ts              @ completion: browse by folder, fuzzy search, typeahead rows
├── types/index.d.ts             shared types and the plugin's state contract
└── tests/rich-input.test.tsx    tests (claude plugin test)

register.tsx reads keybindings.json when the session starts and keeps whether rich input is on in the plugin's state. /rich and the button rewrite the file through bindings.ts. While it's on, a prompt.edit hook paints the draft, and the footer's mode list gets rich input. A prompt.autocomplete hook adds the @ rows from a project index cached for 15 seconds; a folder's row leaves the cursor in the mention, so Claude Code asks again and the folder's contents come up.

The editor pane is a Client surface module (editor.tsx) that gets raw keys and clicks once clicked. It posts its draft, the @ query it needs and its send and paste requests to register.tsx, which answers through the pane's props: completions from the same index, the clipboard's text, and on send, the prompt box.

Development

cd plugins/rich-input
claude plugin validate .   # checks the manifest and the hooks module
claude plugin test .       # runs tests/*.test.tsx

Once Claude Code has loaded the plugin from disk, it writes the API types to .claude-plugin/types/ (ignored by git). After that, npx -p typescript tsc -p plugins/rich-input type-checks the plugin.

For live editing, run claude --plugin-dir plugins/rich-input. The plugin reloads by itself when you save a file.

License

MIT © Higor César

Source 6 files
hooks/register.tsx 294 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { EditorPost, EditorProps, Mode } from '../types'
5import { isOn, type Keybindings, parse, withRich } from './bindings'
6import { complete, type Entry, rows, withDirs } from './complete'
7
8/** Folders the fallback walk skips: build output and dependencies. */
9const SKIPPED = new Set(['.git', 'node_modules', 'dist', 'build', 'out', 'target', '.next', '.venv', 'venv', '__pycache__', '.cache'])
10const WALK_LIMIT = 5000
11const INDEX_TTL_MS = 15_000
12/** The theme color the draft takes while rich input is on. */
13const DRAFT_COLOR = 'suggestion'
14const MODE_LABEL = 'rich input'
15
16const ON_TEXT = 'Rich input on: Enter takes a new line, Ctrl+Enter sends (Ctrl+S where the terminal sends Ctrl+Enter as Enter).'
17const OFF_TEXT = 'Rich input off: Enter sends again.'
18
19const PANE = 'rich-input-pane'
20/** The rows the pane asks for when it opens above the prompt, not docked. */
21const PANE_ROWS = 20
22const PANE_HINT = 'Enter new line · Ctrl+S puts it in the prompt · right-click pastes · @ files'
23/** Programs that print the clipboard, tried in turn: Windows, macOS, Wayland, X11. */
24const CLIPBOARD = [
25  ['powershell', '-NoProfile', '-Command', 'Get-Clipboard -Raw'],
26  ['pbpaste'],
27  ['wl-paste', '-n'],
28  ['xclip', '-selection', 'clipboard', '-o'],
29]
30
31/** Whether rich input is on, as keybindings.json last read said. */
32const mode = atom({ plugin: 'rich-input', key: 'mode' } as const, { isOn: false } as Mode)
33
34/** What the pane editor opens with and the answers it is handed. */
35const editor = atom({ plugin: 'rich-input', key: 'editor' } as const, { initial: '', menu: null, paste: null } as EditorProps)
36
37type $ = EngineInterface
38
39/** The editor's last draft, and which of its asks were answered already. */
40let draft = ''
41let answered = { queryId: 0, send: 0, pasteId: 0 }
42let isSending = false
43
44let index: { root: string; at: number; entries: Entry[] } | null = null
45
46/** The project's files and folders: git's list, else a bounded walk. */
47async function entries($: $): Promise<Entry[]> {
48  const root = await $.session.cwd()
49  const now = await $.clock.now()
50  if (index !== null && index.root === root && now - index.at < INDEX_TTL_MS) return index.entries
51
52  let files: string[] | null = null
53  try {
54    const git = await $.process.run(
55      ['git', '-c', 'core.quotepath=off', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'],
56      { cwd: root, timeoutMs: 5000 },
57    )
58    if (git.exitCode === 0) files = git.stdout.split('\0').filter(f => f !== '')
59  } catch {
60    files = null
61  }
62  files ??= await walk($, root)
63  index = { root, at: now, entries: withDirs(files) }
64  return index.entries
65}
66
67async function walk($: $, root: string): Promise<string[]> {
68  const files: string[] = []
69  const queue = ['']
70  while (queue.length > 0 && files.length < WALK_LIMIT) {
71    const dir = queue.shift()!
72    let listed
73    try {
74      listed = await $.fs.list(dir === '' ? root : `${root}/${dir}`)
75    } catch {
76      continue
77    }
78    for (const entry of listed) {
79      const path = dir === '' ? entry.name : `${dir}/${entry.name}`
80      if (entry.kind === 'dir') {
81        if (!SKIPPED.has(entry.name)) {
82          queue.push(path)
83          files.push(`${path}/`)
84        }
85      } else {
86        files.push(path)
87      }
88    }
89  }
90  return files
91}
92
93/** Where Claude Code reads the person's keybindings from. */
94async function bindingsPath($: $): Promise<string | null> {
95  const dir = await $.env.get('CLAUDE_CONFIG_DIR')
96  if (dir !== undefined && dir !== '') return `${dir}/keybindings.json`
97  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
98  return home === undefined || home === '' ? null : `${home}/.claude/keybindings.json`
99}
100
101/** The person's keybindings: null for no file, an error text when unreadable. */
102async function load($: $, path: string): Promise<Keybindings | null | string> {
103  if (!(await $.fs.exists(path))) return null
104  try {
105    return parse(await $.fs.read(path)) ?? `${path} is not keybindings JSON that Rich Input can edit; it was left as it is.`
106  } catch {
107    return `could not read ${path}.`
108  }
109}
110
111/** Reads whether rich input is on, for the drawings that show it. */
112async function sync($: $): Promise<void> {
113  const path = await bindingsPath($)
114  const kb = path === null ? null : await load($, path)
115  if (typeof kb !== 'string') await update($, mode, () => ({ isOn: isOn(kb) }))
116}
117
118/** Turns rich input on or off in keybindings.json; says what happened. */
119async function toggle($: $): Promise<string> {
120  const path = await bindingsPath($)
121  if (path === null) return 'Rich input: no home folder to find keybindings.json in.'
122  const kb = await load($, path)
123  if (typeof kb === 'string') return `Rich input: ${kb}`
124  const on = !isOn(kb)
125  await $.fs.write(path, `${JSON.stringify(withRich(kb, on), null, 2)}\n`)
126  await update($, mode, () => ({ isOn: on }))
127  return on ? ON_TEXT : OFF_TEXT
128}
129
130/** The clipboard's text, from the first program that prints it; '' if none. */
131async function clipboard($: $): Promise<string> {
132  for (const argv of CLIPBOARD) {
133    try {
134      const run = await $.process.run(argv, { timeoutMs: 3000 })
135      if (run.exitCode === 0) return run.stdout.replace(/\r\n?/g, '\n').replace(/\n$/, '')
136    } catch {
137      // Not installed here: try the next one.
138    }
139  }
140  return ''
141}
142
143/** Opens the pane editor with what the prompt box holds, moving it out of the box. */
144async function openPane($: $): Promise<string> {
145  const box = await $.prompt.read()
146  draft = box.text
147  answered = { queryId: 0, send: 0, pasteId: 0 }
148  await update($, editor, () => ({ initial: box.text, menu: null, paste: null }))
149  if (box.text !== '') await $.prompt.fill({ text: '', mode: 'replace' })
150  const opened = await $.ui.open({ id: PANE, title: 'Rich Input', rows: PANE_ROWS })
151  void entries($).catch(() => undefined)
152  return opened.isPlaced ? `Rich Input pane opened. ${PANE_HINT}.` : `Rich Input pane waits: ${opened.reason ?? 'no room'}`
153}
154
155/** Closes the pane; with a text, puts it in the prompt box to be sent. */
156async function closePane($: $, text: string | null) {
157  isSending = text !== null
158  await $.ui.close({ id: PANE })
159  isSending = false
160  if (text === null) return
161  draft = ''
162  const filled = await $.prompt.fill({ text, mode: 'replace' })
163  if (filled.isFilled) $.ui.toast('Text is in the prompt: press Enter to send (@ references are resolved on send)')
164}
165
166/** Opens the pane editor, or closes it handing the draft back; says which. */
167async function togglePane($: $): Promise<string> {
168  if ((await $.ui.panes()).some(p => p.id === PANE)) {
169    await closePane($, null)
170    return 'Rich Input pane closed; the draft is back in the prompt.'
171  }
172  return openPane($)
173}
174
175/** What the editor posted: keep its draft, answer a new query, paste or send. */
176async function heard($: $, post: EditorPost) {
177  draft = post.draft
178  if (post.query !== null && post.queryId > answered.queryId) {
179    answered = { ...answered, queryId: post.queryId }
180    const items = complete(await entries($), post.query)
181    await update($, editor, ed => ({ ...ed, menu: { queryId: post.queryId, items } }))
182  }
183  if (post.pasteId > answered.pasteId) {
184    answered = { ...answered, pasteId: post.pasteId }
185    const text = await clipboard($)
186    if (text !== '') await update($, editor, ed => ({ ...ed, paste: { id: post.pasteId, text } }))
187  }
188  if (post.send > answered.send) {
189    answered = { ...answered, send: post.send }
190    await closePane($, post.draft)
191  }
192}
193
194const isPost = (data: unknown): data is EditorPost => {
195  const d = data as EditorPost
196  return (
197    typeof d === 'object' && d !== null && typeof d.draft === 'string' && (d.query === null || typeof d.query === 'string') &&
198    typeof d.queryId === 'number' && typeof d.send === 'number' && typeof d.pasteId === 'number'
199  )
200}
201
202export const register: Register = (on, options) => {
203  const showBand = options.showBand !== false
204  const toggleAction = typeof options.toggleAction === 'string' ? options.toggleAction : 'app:toggleReplTab'
205  const paneAction = typeof options.paneAction === 'string' ? options.paneAction : 'app:toggleDiffPreSession'
206
207  on('session.start', async ($, e, next) => {
208    await $.command.register({
209      name: 'rich',
210      description: 'Turn rich input on or off (Enter new line, Ctrl+Enter send); /rich pane opens the editor pane',
211      argumentHint: '[pane]',
212    })
213    await sync($).catch(() => undefined)
214    void entries($).catch(() => undefined)
215    return next(e)
216  })
217
218  on('command.run', { command: 'rich' }, async ($, e) => {
219    return { text: e.args.trim() === 'pane' ? await togglePane($) : await toggle($) }
220  })
221
222  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
223    const ui = $.ui.resolve(e)
224    const { Box, Text } = ui
225    if (!('Client' in ui)) return <Text>This surface can't draw the editor; use /rich for the prompt box instead.</Text>
226    const props = await read($, editor)
227    const height = Math.max(3, e.props.scroll.bodyRows - 1)
228    return (
229      <Box flexDirection="column">
230        <Text dimColor wrap="truncate-end">
231          {PANE_HINT}
232        </Text>
233        <ui.Client key="editor" module="./editor.tsx" props={props} height={height} />
234      </Box>
235    )
236  })
237
238  on('ui.message', { requestId: PANE }, async ($, e) => {
239    if (isPost(e.data)) await heard($, e.data)
240    return {}
241  })
242
243  // Closing without sending (the close mark, /rich pane) hands the draft back
244  // to the prompt box, so nothing typed is lost.
245  on('ui.close', { id: PANE }, async ($, e, next) => {
246    const closed = await next(e)
247    if (isSending || e.origin.kind === 'unload') return closed
248    if (draft !== '') await $.prompt.fill({ text: draft, mode: 'replace' })
249    draft = ''
250    return closed
251  })
252
253  // The `@` rows join beneath the prompt box's own: taking a folder leaves the
254  // cursor in it, and the box asks again for what it holds.
255  on('prompt.autocomplete', { token: /^@/ }, async ($, e, next) => {
256    const before = await next(e)
257    return { suggestions: [...before.suggestions, ...rows(await entries($), e.token)] }
258  })
259
260  // While on, the draft takes a color; the engine's own runs (a mention's)
261  // come after it and win where they paint.
262  on('prompt.edit', async ($, e, next) => {
263    const box = await next(e)
264    if (!(await read($, mode)).isOn || box.text === '') return box
265    return { ...box, decorations: [{ start: 0, end: box.text.length, color: DRAFT_COLOR }, ...(box.decorations ?? [])] }
266  })
267
268  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
269    if (!(await read($, mode)).isOn) return next(e)
270    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, MODE_LABEL] } })
271  })
272
273  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
274    if (!showBand || e.props.hasSurvey) return next(e)
275    const { Box, Button, Text } = $.ui.resolve(e)
276    const { isOn: richOn } = await read($, mode)
277    return (
278      <Box flexDirection="row">
279        <Button
280          key="toggle-rich"
281          label={richOn ? '✎ Rich input: on' : '✎ Rich input: off'}
282          plain
283          dimColor={!richOn}
284          action={toggleAction}
285          onPress={async () => $.ui.toast(await toggle($))}
286        />
287        <Text dimColor>{richOn ? '  Enter new line · Ctrl+Enter send  ' : '  Alt+R  '}</Text>
288        <Button key="open-pane" label="▤ Editor" plain dimColor action={paneAction} onPress={async () => $.ui.toast(await togglePane($))} />
289        <Text dimColor>  Alt+E</Text>
290      </Box>
291    )
292  })
293}
294
hooks/bindings.ts 58 lines
1/**
2 * Rich input on or off is two entries of the `Chat` block of the person's
3 * keybindings.json: Enter takes a new line, Ctrl+Enter sends. Turning it off
4 * removes only those entries, and only while they still say what we wrote.
5 */
6export const RICH: Readonly<Record<string, string>> = { enter: 'chat:newline', 'ctrl+enter': 'chat:submit' }
7
8export type Block = { context: string; bindings: Record<string, string | null> }
9export type Keybindings = { bindings: Block[]; [key: string]: unknown }
10
11const FRESH = {
12  $schema: 'https://www.schemastore.org/claude-code-keybindings.json',
13  $docs: 'https://code.claude.com/docs/en/keybindings',
14}
15
16/** The file's JSON as keybindings, or null when its shape is not one. */
17export function parse(text: string): Keybindings | null {
18  let data: unknown
19  try {
20    data = JSON.parse(text)
21  } catch {
22    return null
23  }
24  if (typeof data !== 'object' || data === null || Array.isArray(data)) return null
25  const bindings = (data as { bindings?: unknown }).bindings ?? []
26  if (!Array.isArray(bindings)) return null
27  const isBlock = (b: unknown) =>
28    typeof b === 'object' && b !== null && typeof (b as Block).context === 'string' && typeof (b as Block).bindings === 'object'
29  if (!bindings.every(isBlock)) return null
30  return { ...(data as object), bindings: bindings as Block[] }
31}
32
33/** Whether Enter takes a new line in the prompt box. */
34export function isOn(kb: Keybindings | null): boolean {
35  return kb?.bindings.some(b => b.context === 'Chat' && b.bindings.enter === RICH.enter) ?? false
36}
37
38/** The keybindings with rich input turned on or off; null stands for no file. */
39export function withRich(kb: Keybindings | null, on: boolean): Keybindings {
40  const base: Keybindings = kb ?? { ...FRESH, bindings: [] }
41  let blocks = base.bindings.map(b => ({ ...b, bindings: { ...b.bindings } }))
42  if (on) {
43    let chat = blocks.find(b => b.context === 'Chat')
44    if (chat === undefined) {
45      chat = { context: 'Chat', bindings: {} }
46      blocks.push(chat)
47    }
48    Object.assign(chat.bindings, RICH)
49  } else {
50    for (const b of blocks) {
51      if (b.context !== 'Chat') continue
52      for (const [key, action] of Object.entries(RICH)) if (b.bindings[key] === action) delete b.bindings[key]
53    }
54    blocks = blocks.filter(b => b.context !== 'Chat' || Object.keys(b.bindings).length > 0)
55  }
56  return { ...base, bindings: blocks }
57}
58
hooks/complete.ts 135 lines
1import type { Suggestion } from '../types'
2
3/** One path of the project index, `/`-separated and relative to its root. */
4export type Entry = { path: string; isDir: boolean }
5
6/** One row of the prompt box's typeahead, as `prompt.autocomplete` takes it. */
7export type Row = { text: string; label: string; description?: string }
8
9const LIMIT = 10
10
11/**
12 * Every directory the files sit under, added to the files themselves; a
13 * path ending in `/` is a folder of its own (one the walk saw empty).
14 */
15export function withDirs(files: readonly string[]): Entry[] {
16  const dirs = new Set<string>()
17  const entries: Entry[] = []
18  for (const raw of files) {
19    const path = raw.replace(/\\/g, '/').replace(/^\.\//, '')
20    if (path === '' || path === '/') continue
21    if (path.endsWith('/')) {
22      dirs.add(path.slice(0, -1))
23      continue
24    }
25    entries.push({ path, isDir: false })
26    let cut = path.lastIndexOf('/')
27    while (cut > 0) {
28      const dir = path.slice(0, cut)
29      if (dirs.has(dir)) break
30      dirs.add(dir)
31      cut = dir.lastIndexOf('/')
32    }
33  }
34  for (const dir of dirs) entries.push({ path: dir, isDir: true })
35  return entries
36}
37
38/** What replaces `@query` in the text: quoted when the path has a space. */
39export function mention(entry: Entry): string {
40  const path = entry.isDir ? `${entry.path}/` : entry.path
41  return /\s/.test(path) ? `@"${path}"` : `@${path}`
42}
43
44function parentOf(path: string): string {
45  const cut = path.lastIndexOf('/')
46  return cut < 0 ? '' : path.slice(0, cut)
47}
48
49function baseOf(path: string): string {
50  return path.slice(path.lastIndexOf('/') + 1)
51}
52
53function isSubsequence(needle: string, hay: string): boolean {
54  let i = 0
55  for (let j = 0; j < hay.length && i < needle.length; j++) {
56    if (hay[j] === needle[i]) i++
57  }
58  return i === needle.length
59}
60
61function toSuggestion(entry: Entry): Suggestion {
62  return {
63    label: entry.isDir ? `${entry.path}/` : entry.path,
64    insert: mention(entry),
65    isDir: entry.isDir,
66  }
67}
68
69/**
70 * The completions for what follows `@`.
71 *
72 * With a `/` in it the query browses: the children of the folder before the
73 * last `/` whose name holds the rest. Without one it searches the whole
74 * index by name, then by path, then by letters in order.
75 */
76export function complete(entries: readonly Entry[], rawQuery: string): Suggestion[] {
77  const query = rawQuery.replace(/\\/g, '/').replace(/^"/, '').toLowerCase()
78  const byName = (a: [number, Entry], b: [number, Entry]) =>
79    a[0] - b[0] || Number(b[1].isDir) - Number(a[1].isDir) || a[1].path.localeCompare(b[1].path)
80  const byRank = (a: [number, Entry], b: [number, Entry]) =>
81    a[0] - b[0] || a[1].path.length - b[1].path.length || byName(a, b)
82
83  if (query === '' || query.includes('/')) {
84    const cut = query.lastIndexOf('/')
85    const dir = cut < 0 ? '' : query.slice(0, cut)
86    const name = query.slice(cut + 1)
87    const ranked: [number, Entry][] = []
88    for (const entry of entries) {
89      if (parentOf(entry.path).toLowerCase() !== dir) continue
90      const base = baseOf(entry.path).toLowerCase()
91      if (base.startsWith(name)) ranked.push([0, entry])
92      else if (name !== '' && base.includes(name)) ranked.push([1, entry])
93    }
94    return ranked.sort(byName).slice(0, LIMIT).map(([, e]) => toSuggestion(e))
95  }
96
97  const ranked: [number, Entry][] = []
98  for (const entry of entries) {
99    const path = entry.path.toLowerCase()
100    const base = baseOf(path)
101    if (base.startsWith(query)) ranked.push([0, entry])
102    else if (base.includes(query)) ranked.push([1, entry])
103    else if (path.includes(query)) ranked.push([2, entry])
104    else if (isSubsequence(query, path)) ranked.push([3, entry])
105  }
106  return ranked.sort(byRank).slice(0, LIMIT).map(([, e]) => toSuggestion(e))
107}
108
109/**
110 * The typeahead rows for the token at the cursor, when it is an `@` mention:
111 * a folder's row leaves the cursor in it, so its contents come up next; a
112 * file's row ends the mention with a space.
113 */
114export function rows(entries: readonly Entry[], token: string): Row[] {
115  if (!token.startsWith('@')) return []
116  return complete(entries, token.slice(1)).map(s =>
117    s.isDir ? { text: s.insert, label: s.label, description: 'folder' } : { text: `${s.insert} `, label: s.label },
118  )
119}
120
121/**
122 * The `@` mention the cursor is in: where its `@` is, where it ends and the
123 * query typed after the `@` up to the cursor; null outside one.
124 */
125export function mentionAt(text: string, cursor: number): { start: number; end: number; query: string } | null {
126  let i = cursor
127  while (i > 0 && !/\s/.test(text[i - 1]!) && text[i - 1] !== '@') i--
128  if (i === 0 || text[i - 1] !== '@') return null
129  const start = i - 1
130  if (start > 0 && !/\s/.test(text[start - 1]!)) return null
131  let end = cursor
132  while (end < text.length && !/\s/.test(text[end]!)) end++
133  return { start, end, query: text.slice(start + 1, cursor) }
134}
135
hooks/editor.tsx 119 lines
1import type { ClientModule, ClientSurface, RenderElement } from 'claude-code'
2
3import type { EditorProps } from '../types'
4import { type Ed, fresh, insert, layout, moveTo, accept, press, scrolled, textOf, withMenu } from './editor-core'
5
6/**
7 * The editor, which of the hooks module's answers it has taken in, and
8 * whether a click or a key has reached it yet: the keyboard comes only
9 * with a click, so until then the top row asks for one.
10 */
11type S = { ed: Ed; seenMenu: number; seenPaste: number; isActive: boolean }
12
13/** The top row tells whether the editor has the keyboard; the text is below it. */
14const STATUS_ROWS = 1
15const textRows = (surface: ClientSurface<S>) => Math.max(1, surface.rows - STATUS_ROWS)
16
17const gutterWidth = (ed: Ed) => String(ed.lines.length).length + 3
18
19/** How far the cursor's line is scrolled sideways so the cursor shows. */
20function shift(ed: Ed, columns: number): number {
21  const room = Math.max(1, columns - gutterWidth(ed) - 1)
22  return Math.max(0, ed.col - room)
23}
24
25/** Keeps the cursor in view, stores the editor and tells the hooks module. */
26function commit(surface: ClientSurface<S>, s: S, ed: Ed) {
27  const next = scrolled(ed, textRows(surface))
28  surface.setState({ ...s, ed: next, isActive: true })
29  surface.post({ draft: textOf(next), query: next.query, queryId: next.queryId, send: next.send, pasteId: next.pasteId })
30}
31
32const Editor: ClientModule<EditorProps, S> = (props, surface) => {
33  const { Box, Text } = surface.elements
34
35  if (surface.state === undefined) {
36    surface.onKey(k => {
37      const s = surface.state!
38      commit(surface, s, press(s.ed, k))
39    })
40    surface.onPointer(p => {
41      if (p.type !== 'down') return
42      const s = surface.state!
43      if (p.button === 'right') return commit(surface, s, { ...s.ed, pasteId: s.ed.pasteId + 1 })
44      if (p.button !== 'left') return
45      if (p.y < STATUS_ROWS) return commit(surface, s, s.ed)
46      const at = layout(s.ed, textRows(surface))[p.y - STATUS_ROWS]
47      if (at === undefined) return commit(surface, s, moveTo(s.ed, Infinity, Infinity))
48      if (at.kind === 'menu') return commit(surface, s, accept({ ...s.ed, menu: { ...s.ed.menu!, index: at.index } }))
49      const sideways = at.row === s.ed.row ? shift(s.ed, surface.columns) : 0
50      commit(surface, s, moveTo(s.ed, at.row, p.x - gutterWidth(s.ed) + sideways))
51    })
52    surface.setState({ ed: fresh(props.initial), seenMenu: 0, seenPaste: 0, isActive: false })
53    return <Text dimColor>Loading…</Text>
54  }
55
56  // Take in what the hooks module answered: completions, a paste.
57  let s = surface.state
58  if (props.menu !== null && props.menu.queryId > s.seenMenu) {
59    s = { ...s, ed: withMenu(s.ed, props.menu), seenMenu: props.menu.queryId }
60  }
61  if (props.paste !== null && props.paste.id > s.seenPaste) {
62    s = { ...s, ed: insert(s.ed, props.paste.text), seenPaste: props.paste.id }
63  }
64  if (s !== surface.state) {
65    commit(surface, { ...s, isActive: surface.state.isActive }, s.ed)
66    return <Text dimColor>…</Text>
67  }
68
69  const { ed } = s
70  const width = String(ed.lines.length).length
71  const status = s.isActive ? (
72    <Text dimColor wrap="truncate-end">
73      ✎ editing · Esc leaves the editor, a click comes back
74    </Text>
75  ) : (
76    <Text inverse bold wrap="truncate-end">
77      {' ▶ Click here to start typing '}
78    </Text>
79  )
80  const rows: RenderElement[] = layout(ed, textRows(surface)).map(at => {
81    if (at.kind === 'menu') {
82      const item = ed.menu!.items[at.index]!
83      const picked = at.index === ed.menu!.index
84      return (
85        <Text wrap="truncate-end" inverse={picked} dimColor={!picked && !item.isDir}>
86          {`${' '.repeat(width + 3)}${item.label}`}
87        </Text>
88      )
89    }
90    const gutter = `${String(at.row + 1).padStart(width)} │ `
91    const text = ed.lines[at.row]!
92    if (at.row !== ed.row) {
93      return (
94        <Text wrap="truncate-end">
95          <Text dimColor>{gutter}</Text>
96          {text}
97        </Text>
98      )
99    }
100    const from = shift(ed, surface.columns)
101    return (
102      <Text wrap="truncate-end">
103        <Text dimColor>{gutter}</Text>
104        {text.slice(from, ed.col)}
105        <Text inverse>{text[ed.col] ?? ' '}</Text>
106        {text.slice(ed.col + 1)}
107      </Text>
108    )
109  })
110  return (
111    <Box flexDirection="column">
112      {status}
113      {rows}
114    </Box>
115  )
116}
117
118export default Editor
119
hooks/editor-core.ts 185 lines
1import type { Suggestion } from '../types'
2import { mentionAt } from './complete'
3
4/** The `@` completions open under the cursor, and the one picked. */
5export type Menu = { items: Suggestion[]; index: number }
6
7/**
8 * The editor: its lines, the cursor, the first line in view, the `@` menu,
9 * and the counters it posts to the hooks module. A post replaces one still
10 * undelivered, so each carries the whole of what is asked: the draft, the
11 * mention being completed (`query`, numbered by `queryId`), and how many
12 * sends and pastes were asked so far.
13 */
14export type Ed = {
15  lines: string[]
16  row: number
17  col: number
18  top: number
19  menu: Menu | null
20  query: string | null
21  queryId: number
22  send: number
23  pasteId: number
24}
25
26export type Key = { key: string; ctrl?: true; shift?: true; meta?: true }
27
28/** How many completions the menu shows. */
29export const MENU_SIZE = 8
30
31export function fresh(text: string): Ed {
32  const lines = text === '' ? [''] : text.split(/\r?\n/)
33  const row = lines.length - 1
34  return { lines, row, col: lines[row]!.length, top: 0, menu: null, query: null, queryId: 0, send: 0, pasteId: 0 }
35}
36
37export const textOf = (ed: Ed) => ed.lines.join('\n')
38
39const line = (ed: Ed) => ed.lines[ed.row]!
40
41/** The cursor moved or the text changed: which mention, if any, it is in now. */
42function settle(ed: Ed): Ed {
43  const m = mentionAt(line(ed), ed.col)
44  if (m === null) return ed.query === null && ed.menu === null ? ed : { ...ed, query: null, menu: null }
45  if (m.query === ed.query) return ed
46  return { ...ed, query: m.query, queryId: ed.queryId + 1 }
47}
48
49/** Puts `text` at the cursor; a newline in it starts a new line. */
50export function insert(ed: Ed, text: string): Ed {
51  const parts = text.replace(/\r\n?/g, '\n').split('\n')
52  const cur = line(ed)
53  const before = cur.slice(0, ed.col)
54  const after = cur.slice(ed.col)
55  const added = parts.map((p, i) => (i === 0 ? before + p : p))
56  const last = added.length - 1
57  const col = added[last]!.length
58  added[last] += after
59  const lines = [...ed.lines.slice(0, ed.row), ...added, ...ed.lines.slice(ed.row + 1)]
60  return settle({ ...ed, lines, row: ed.row + last, col })
61}
62
63function backspace(ed: Ed): Ed {
64  if (ed.col > 0) {
65    const cur = line(ed)
66    const lines = [...ed.lines]
67    lines[ed.row] = cur.slice(0, ed.col - 1) + cur.slice(ed.col)
68    return settle({ ...ed, lines, col: ed.col - 1 })
69  }
70  if (ed.row === 0) return ed
71  // At the start of a line: join it to the one above.
72  const above = ed.lines[ed.row - 1]!
73  const lines = [...ed.lines.slice(0, ed.row - 1), above + line(ed), ...ed.lines.slice(ed.row + 1)]
74  return settle({ ...ed, lines, row: ed.row - 1, col: above.length })
75}
76
77function del(ed: Ed): Ed {
78  const cur = line(ed)
79  if (ed.col < cur.length) {
80    const lines = [...ed.lines]
81    lines[ed.row] = cur.slice(0, ed.col) + cur.slice(ed.col + 1)
82    return settle({ ...ed, lines })
83  }
84  if (ed.row === ed.lines.length - 1) return ed
85  const lines = [...ed.lines.slice(0, ed.row), cur + ed.lines[ed.row + 1]!, ...ed.lines.slice(ed.row + 2)]
86  return settle({ ...ed, lines })
87}
88
89/** Moves the cursor to a line and column, each kept inside the text. */
90export function moveTo(ed: Ed, row: number, col: number): Ed {
91  const r = Math.max(0, Math.min(ed.lines.length - 1, row))
92  return settle({ ...ed, row: r, col: Math.max(0, Math.min(ed.lines[r]!.length, col)) })
93}
94
95function move(ed: Ed, key: string): Ed {
96  switch (key) {
97    case 'left':
98      return ed.col > 0 ? moveTo(ed, ed.row, ed.col - 1) : ed.row > 0 ? moveTo(ed, ed.row - 1, Infinity) : ed
99    case 'right':
100      return ed.col < line(ed).length ? moveTo(ed, ed.row, ed.col + 1) : moveTo(ed, ed.row + 1, ed.row + 1 < ed.lines.length ? 0 : Infinity)
101    case 'up':
102      return moveTo(ed, ed.row - 1, ed.col)
103    case 'down':
104      return moveTo(ed, ed.row + 1, ed.col)
105    case 'home':
106      return moveTo(ed, ed.row, 0)
107    case 'end':
108      return moveTo(ed, ed.row, Infinity)
109    default:
110      return ed
111  }
112}
113
114/** Puts the picked completion in place of the mention; a folder opens its own. */
115export function accept(ed: Ed): Ed {
116  const choice = ed.menu?.items[ed.menu.index]
117  const m = mentionAt(line(ed), ed.col)
118  if (choice === undefined || m === null) return { ...ed, menu: null }
119  const cur = line(ed)
120  const put = choice.insert + (choice.isDir ? '' : ' ')
121  const lines = [...ed.lines]
122  lines[ed.row] = cur.slice(0, m.start) + put + cur.slice(m.end)
123  return settle({ ...ed, lines, col: m.start + put.length, menu: null })
124}
125
126/** The completions the hooks module found for a query, if still the one asked. */
127export function withMenu(ed: Ed, answer: { queryId: number; items: Suggestion[] }): Ed {
128  if (answer.queryId !== ed.queryId || ed.query === null) return ed
129  return { ...ed, menu: answer.items.length === 0 ? null : { items: answer.items.slice(0, MENU_SIZE), index: 0 } }
130}
131
132/** One key pressed while the editor has the focus. */
133export function press(ed: Ed, k: Key): Ed {
134  if (k.ctrl || k.meta) return k.ctrl && k.key === 's' ? { ...ed, send: ed.send + 1 } : ed
135  const menu = ed.menu
136  if (menu !== null) {
137    const n = menu.items.length
138    if (k.key === 'down') return { ...ed, menu: { ...menu, index: (menu.index + 1) % n } }
139    if (k.key === 'up') return { ...ed, menu: { ...menu, index: (menu.index + n - 1) % n } }
140    if (k.key === 'return' || k.key === 'tab') return accept(ed)
141  }
142  switch (k.key) {
143    case 'return':
144      return insert(ed, '\n')
145    case 'backspace':
146      return backspace(ed)
147    case 'delete':
148      return del(ed)
149    case 'tab':
150      return insert(ed, '  ')
151    case 'space':
152      return insert(ed, ' ')
153    case 'left':
154    case 'right':
155    case 'up':
156    case 'down':
157    case 'home':
158    case 'end':
159      return move(ed, k.key)
160    default:
161      return [...k.key].length === 1 ? insert(ed, k.key) : ed
162  }
163}
164
165/** The first line in view, so that the cursor's line and its menu fit in `rows`. */
166export function scrolled(ed: Ed, rows: number): Ed {
167  const below = ed.menu === null ? 0 : ed.menu.items.length
168  const room = Math.max(1, rows - below)
169  let top = Math.min(ed.top, ed.row)
170  if (ed.row >= top + room) top = ed.row - room + 1
171  return top === ed.top ? ed : { ...ed, top }
172}
173
174/** What each row of the view holds, top to bottom: a line, or a menu entry. */
175export type ViewRow = { kind: 'line'; row: number } | { kind: 'menu'; index: number }
176
177export function layout(ed: Ed, rows: number): ViewRow[] {
178  const view: ViewRow[] = []
179  for (let r = ed.top; r < ed.lines.length && view.length < rows; r++) {
180    view.push({ kind: 'line', row: r })
181    if (r === ed.row && ed.menu !== null) ed.menu.items.forEach((_, index) => view.push({ kind: 'menu', index }))
182  }
183  return view.slice(0, rows)
184}
185
types/index.d.ts 25 lines
1/** One `@` completion: what the row shows and what replaces `@query`. */
2export type Suggestion = { label: string; insert: string; isDir: boolean }
3
4/** Whether rich input is on, as the person's keybindings.json last said. */
5export type Mode = { isOn: boolean }
6
7/**
8 * What the pane editor is handed: the text it opens with, and the hooks
9 * module's answers to what it asked (completions for a query, a paste).
10 */
11export type EditorProps = {
12  initial: string
13  menu: { queryId: number; items: Suggestion[] } | null
14  paste: { id: number; text: string } | null
15}
16
17/** What the editor posts on every change: the whole of what it asks. */
18export type EditorPost = { draft: string; query: string | null; queryId: number; send: number; pasteId: number }
19
20declare module 'claude-code' {
21  interface PluginState {
22    'rich-input': { mode: Mode; editor: EditorProps }
23  }
24}
25