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…

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.
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.
Alt+R or /rich. Off, Enter sends as usual.rich input shows in the footer under the prompt, and the row above the prompt says ✎ Rich input: on.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.@src/) keeps the mention open and lists that folder's contents, so you go @src/ → @src/lib/ → file.@parse) searches the whole project.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.).@"docs/my notes.md"), the same way the native prompt does it.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
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
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:
"enter": "chat:newline" and "ctrl+enter": "chat:submit".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.
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.
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.
@ opens a menu of files and folders under the line; arrows pick, Enter or Tab takes, a folder lists its contents next.powershell Get-Clipboard on Windows, pbpaste, wl-paste or xclip elsewhere).@ 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.| Key | Action |
|---|---|
Alt+R / /rich | Turn rich input on or off |
Alt+E / /rich pane | Open or close the editor pane |
Enter | On: new line where the cursor is (with @ suggestions open: take the highlighted one). Off: send |
Ctrl+Enter | On: send |
← → ↑ ↓ Home End | Move the cursor |
Backspace at the start of a line | Join it to the line above |
@ | Complete files and folders; taking a folder lists its contents |
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.
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.
MIT © Higor César
hooks/register.tsx 294 lines1import { 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}
294hooks/bindings.ts 58 lines1/**
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}
58hooks/complete.ts 135 lines1import 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}
135hooks/editor.tsx 119 lines1import 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
119hooks/editor-core.ts 185 lines1import 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}
185types/index.d.ts 25 lines1/** 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