A vim command line for Claude Code: :w and :e keep a draft per session, :q quits (! to force), and any other name runs that slash command, with Tab completion…

A command line as in vim for Claude Code: :w and :e keep a draft per session, :q quits, and any other name runs that slash command, with Tab completion. Its commands, their ! and their wording are those of opencode.vim.
claude plugin marketplace add thefuga/claude-x
claude plugin install vim@claude-x
:e is colored at the next typed key.Open it with Claude Code's own chord for the area above the prompt, Ctrl+X Tab, from insert or normal mode. To open it with Ctrl+X : as well, bind that to the same action in ~/.claude/keybindings.json:
{ "context": "Chat", "bindings": { "ctrl+x :": "abovePrompt:focus" } }
A bare : cannot open it. Claude Code's key bindings do not know the vim editor's mode, so the binding would take every : typed in insert mode as well.
Enter runs the line and Escape leaves it; either way the keys go back to the prompt, in the mode it was in. What a command answers stands in the same row for a few seconds.
| Command | What it does |
|---|---|
:w | Saves the draft. With an empty prompt, clears the saved one. |
:e, :e! | Loads the saved draft back. Refused while the prompt has changes that were not saved; ! drops them. |
:q, :q! | Quits Claude Code. Refused while the draft has unsaved changes or a turn is running; ! quits anyway, and ends the turn first. |
:wq, :x | Saves the draft and quits. |
:h | Lists these in the transcript. |
:<name> [args] | Runs the slash command /<name>, once Claude is idle. |
:write, :edit, :quit, :qa and :help are the long names. :expand runs the statusline mod's /expand, which makes the prompt box taller until the next prompt is sent.
Tab completes a command's name, as in opencode.vim. It opens a menu, just above the line, of the names that start with what is typed (the line's own first, then Claude Code's), and moves down it; Shift+Tab moves up, and so do Down and Up. Typing narrows the menu. Enter takes the picked name into the line, and the next Enter runs it. Only the name is completed, not what follows it. Escape leaves the line, menu and all.
A draft belongs to its session. It is put back in the prompt when the session is opened again (claude --resume, claude -c, /resume), and dropped when a prompt is sent. A session nothing was sent in cannot be opened again, so until then its draft is kept for the folder it runs in, and the next new session there starts with it. Nothing is saved without :w.
The line is typed into a field nobody sees. A mod's field can take the keyboard in one place only, the band above the prompt, so the field stands there in a box of no height, and what is typed in it is drawn in the statusline mod's bar. That has its limits:
hooks/register.tsx 449 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { FieldState, MenuItem } from '../types'
5import { HELP, NEXT, PREVIOUS, SAID, commandOf, completionsOf, draftKey, fieldKey, isField, keptDraft } from './commands'
6import { CommandField } from './view'
7
8// The slash command that quits.
9const EXIT = 'exit'
10// How often an open command line asks whether it still has the keyboard, how long its field is
11// left undrawn to hand the keys back, and how long an answer stays in the bar.
12const WATCH_MS = 100
13const FIELD_DOWN_MS = 80
14const ECHO_MS = 3000
15// How long after Enter a completion is given to the field: the engine empties it once the submit
16// has been answered. And how many completions are kept for the menu around the picked one.
17const FIELD_VALUE_MS = 30
18const MENU_KEPT = 16
19// A prompt box is bound a moment after its session starts: how long a saved draft waits for one.
20const BOX_WAITS = [0, 100, 400, 1500]
21
22const FIRST_FIELD: FieldState = { drawn: 0, isDown: false, value: '' }
23
24const command = atom({ plugin: 'vim', key: 'command' } as const, null)
25const echo = atom({ plugin: 'vim', key: 'echo' } as const, null)
26const field = atom({ plugin: 'vim', key: 'commandField' } as const, FIRST_FIELD)
27const menu = atom({ plugin: 'vim', key: 'menu' } as const, null)
28
29// The open command line: the timer that asks after its keyboard, the times in a row it was told no,
30// what is typed in it, and the text its field was last given.
31let line: { watch: Timer; denied: number; typed: string; given: string } | undefined
32// The completions while they are up, and Claude Code's commands as fetched for this line.
33let completion: { items: MenuItem[]; picked: number } | undefined
34let natives: MenuItem[] | undefined
35let answered: Timer | undefined
36// The turn of the main loop that is running, by the id it started under.
37let turn: string | undefined
38// Whether a prompt was sent in this session: until one is, its draft is kept for its folder.
39let isStarted = false
40
41// Nothing here is worth failing a hook over.
42const quietly = async ($: EngineInterface, label: string, work: Promise<unknown>) => {
43 try {
44 await work
45 } catch (error) {
46 $.ui.log(`${label}: ${String(error)}`, { to: 'debug' })
47 }
48}
49
50// What the command line answers stands in the statusline mod's bar, as vim's messages do, for a while.
51const say = async ($: EngineInterface, text: string, isWarning = false) => {
52 answered?.cancel()
53 await update($, echo, () => ({ text, isWarning }))
54 answered = $.clock.after(ECHO_MS, () => {
55 void quietly($, 'command', update($, echo, () => null))
56 })
57}
58
59// The command line closes when its command is run and when the keys go back to the prompt. A field
60// that is not drawn can hold no keyboard, so leaving it out of one drawing is what hands the keys
61// back, and the one drawn after it is another field, with nothing typed in it.
62const closeLine = async ($: EngineInterface) => {
63 line?.watch.cancel()
64 line = undefined
65 completion = undefined
66 natives = undefined
67 await update($, menu, () => null)
68 await update($, command, () => null)
69 await update($, field, ({ drawn }) => ({ drawn: drawn + 1, isDown: true, value: '' }))
70 $.clock.after(FIELD_DOWN_MS, () => {
71 void quietly($, 'command', update($, field, held => ({ ...held, isDown: false })))
72 })
73}
74
75// Escape hands the keys back to the prompt and raises nothing. The engine refuses to move the ring
76// of a site that does not hold the keyboard, so asking it to keep the ring where it is tells; two
77// refusals in a row, since a ring on the move is refused too. A call that fails is as good as one.
78const watchLine = async ($: EngineInterface, requestId: string, key: string) => {
79 const held = await $.ui.focus({ requestId, key }).catch((error: unknown) => ({ deny: String(error) }))
80
81 if (line === undefined) {
82 return
83 }
84
85 line.denied = held.deny === undefined ? 0 : line.denied + 1
86
87 if (line.denied >= 2) {
88 await closeLine($)
89 }
90}
91
92const openLine = async ($: EngineInterface, requestId: string, key: string) => {
93 if (line !== undefined) {
94 return
95 }
96
97 answered?.cancel()
98 line = {
99 denied: 0,
100 typed: '',
101 given: '',
102 watch: $.clock.every(WATCH_MS, () => {
103 void quietly($, 'command', watchLine($, requestId, key))
104 }),
105 }
106 await update($, echo, () => null)
107 await update($, command, () => '')
108}
109
110// A key typed in the field. One typed in a field nobody saw taking the keyboard (the mod was loaded
111// again under it) opens the line as the first does.
112const typeLine = async ($: EngineInterface, requestId: string, key: string, typed: string) => {
113 await openLine($, requestId, key)
114
115 if (line !== undefined) {
116 line.typed = typed
117 }
118
119 await update($, command, () => typed)
120
121 // While the completions are up, what is typed narrows them, and past the name ends them.
122 if (completion !== undefined) {
123 const items = completionsOf(typed, await nativeItems($))
124 completion = items.length === 0 ? undefined : { items, picked: 0 }
125 await showMenu($)
126 }
127}
128
129// Claude Code's commands as the menu names them, fetched once for each time the line opens.
130const nativeItems = async ($: EngineInterface) => {
131 natives ??= (await $.command.list()).map(({ name, description }) => ({ name, description }))
132
133 return natives
134}
135
136// The menu keeps a window of the completions round the picked one: the footer draws what fits.
137const showMenu = async ($: EngineInterface) => {
138 const held = completion
139
140 if (held === undefined) {
141 await update($, menu, () => null)
142
143 return
144 }
145
146 const first = Math.max(0, Math.min(held.picked - MENU_KEPT / 2, held.items.length - MENU_KEPT))
147 await update($, menu, () => ({ items: held.items.slice(first, first + MENU_KEPT), picked: held.picked - first, total: held.items.length }))
148}
149
150// Tab or Down, and Shift+Tab or Up, in the field. The first opens the completions of the name typed
151// so far, picking the first or the last, and each after it moves the pick, round the ends.
152const stepCompletion = async ($: EngineInterface, step: number) => {
153 if (line === undefined) {
154 return
155 }
156
157 if (completion === undefined) {
158 const items = completionsOf(line.typed, await nativeItems($))
159 completion = items.length === 0 ? undefined : { items, picked: step > 0 ? 0 : items.length - 1 }
160 } else {
161 const count = completion.items.length
162 completion = { ...completion, picked: (completion.picked + step + count) % count }
163 }
164
165 await showMenu($)
166}
167
168// Enter while the completions are up takes the picked one into the line, as opencode.vim does, and
169// the next Enter runs it. The field is given the text once the engine has emptied it after Enter;
170// one it was last given already gets a space after it, so as to be another, which the engine takes.
171const takeCompletion = async ($: EngineInterface, picked: MenuItem) => {
172 if (line === undefined) {
173 return
174 }
175
176 const text = picked.name === line.given ? `${picked.name} ` : picked.name
177 line.typed = text
178 line.given = text
179 completion = undefined
180 await showMenu($)
181 await update($, command, () => text)
182 $.clock.after(FIELD_VALUE_MS, () => {
183 void quietly($, 'command', update($, field, held => ({ ...held, value: text })))
184 })
185}
186
187const draftPlace = async ($: EngineInterface) => draftKey(isStarted, await $.session.id(), await $.session.root())
188
189const readDraft = async ($: EngineInterface) => keptDraft(await $.store.get(await draftPlace($)))
190
191// The box takes a draft whole. A fill is no keystroke, so the syntax mod colors it at the next one.
192const fillDraft = ($: EngineInterface, text: string) => $.prompt.fill({ text, mode: 'replace' })
193
194const saveDraft = async ($: EngineInterface) => {
195 const { text } = await $.prompt.read()
196 const place = await draftPlace($)
197
198 if (text === '') {
199 await $.store.delete(place)
200 await say($, SAID.cleared)
201
202 return
203 }
204
205 await $.store.set(place, text)
206 await say($, SAID.saved)
207}
208
209const reloadDraft = async ($: EngineInterface, isForced: boolean) => {
210 const [{ text }, saved] = await Promise.all([$.prompt.read(), readDraft($)])
211
212 if (text !== saved && !isForced) {
213 await say($, SAID.unsavedReload, true)
214
215 return
216 }
217
218 await fillDraft($, saved)
219}
220
221// Quitting is the engine's own `/exit`, which waits for a turn to end: a forced quit ends it first.
222const quit = async ($: EngineInterface, isForced: boolean, isSaving: boolean) => {
223 const [{ text }, saved] = await Promise.all([$.prompt.read(), readDraft($)])
224
225 if (!isForced && !isSaving && text !== saved) {
226 await say($, SAID.unsavedQuit, true)
227
228 return
229 }
230
231 if (!isForced && turn !== undefined) {
232 await say($, SAID.running(isSaving ? 'wq' : 'q'), true)
233
234 return
235 }
236
237 if (isSaving) {
238 await saveDraft($)
239 }
240
241 if (turn !== undefined) {
242 await $.turn.abort({ turnId: turn })
243 }
244
245 await $.command.run({ command: EXIT })
246}
247
248// Any other name is a slash command's, or one of its aliases, which only running it tells: the
249// engine refuses a name it does not know, and the list it gives has no aliases in it.
250const runCommand = async ($: EngineInterface, name: string, args: string) => {
251 try {
252 await $.command.run({ command: name, args })
253 } catch (error) {
254 if ((await $.command.list()).some(entry => entry.name === name)) {
255 throw error
256 }
257
258 await say($, SAID.unknown(name), true)
259 }
260}
261
262const runLine = async ($: EngineInterface, typed: string) => {
263 const action = commandOf(typed)
264
265 if (action.kind === 'refused') {
266 await say($, action.reason, true)
267 } else if (action.kind === 'save') {
268 await saveDraft($)
269 } else if (action.kind === 'reload') {
270 await reloadDraft($, action.isForced)
271 } else if (action.kind === 'quit') {
272 await quit($, action.isForced, action.isSaving)
273 } else if (action.kind === 'help') {
274 // A transcript notice is one line: a line break in it is drawn as a mark.
275 HELP.forEach(row => {
276 $.ui.log(row)
277 })
278 } else if (action.kind === 'other') {
279 await runCommand($, action.command, action.args)
280 }
281}
282
283// Enter in the field: the line closes first, so that the keys are the prompt's again whatever the
284// command does, and a command that fails says so where its answer would have stood.
285const submitLine = async ($: EngineInterface, typed: string) => {
286 const picked = completion?.items[completion.picked]
287
288 if (picked !== undefined) {
289 await takeCompletion($, picked)
290
291 return
292 }
293
294 await closeLine($)
295
296 try {
297 await runLine($, typed)
298 } catch (error) {
299 await say($, `command failed: ${error instanceof Error ? error.message : String(error)}`, true)
300 }
301}
302
303// A session that starts, is opened again or is cleared. The event says which: the session is not
304// bound to the mod yet, so its turns cannot be counted, and only one that was opened again has had
305// a prompt sent in it. That says where its draft is kept, and a draft kept there is put back in an
306// empty box once there is a box. A compaction starts nothing the person sees, and loads none.
307const adoptDraft = async ($: EngineInterface, sessionId: string, source: string) => {
308 isStarted = source !== 'startup' && source !== 'clear'
309 const saved = source === 'compact' ? '' : keptDraft(await $.store.get(draftKey(isStarted, sessionId, await $.session.root())))
310
311 if (saved === '') {
312 return
313 }
314
315 for (const wait of BOX_WAITS) {
316 await $.clock.sleep(wait)
317
318 if ((await $.prompt.read()).text !== '') {
319 return
320 }
321
322 const { isFilled, refusal } = await fillDraft($, saved)
323
324 if (isFilled || refusal !== 'no_composer') {
325 return
326 }
327 }
328}
329
330// A prompt that is sent takes its draft with it, as in opencode.vim: the one kept for the session,
331// or for the folder when this is the session's first.
332const forgetDraft = async ($: EngineInterface) => {
333 await $.store.delete(await draftPlace($))
334 isStarted = true
335}
336
337// A command line the mod left open before it was loaded again: nothing here knows of it, and what
338// it showed stays in the session's state, so it is closed.
339const closeStaleLine = async ($: EngineInterface) => {
340 if ((await read($, command)) !== null || (await read($, menu)) !== null) {
341 await closeLine($)
342 }
343}
344
345const boot = async ($: EngineInterface) => {
346 await Promise.all([
347 // The mod loaded again under a running session: a session that only starts is not counted yet.
348 quietly($, 'draft', $.session.turns().then(turns => {
349 isStarted = isStarted || turns > 0
350 })),
351 quietly($, 'command', closeStaleLine($)),
352 ])
353}
354
355export const register: Register = on => {
356 on('session.start', async ($, e, next) => {
357 const started = await next(e)
358 void boot($)
359
360 return started
361 })
362
363 on('classic.SessionStart', ($, e, next) => {
364 void quietly($, 'draft', adoptDraft($, e.session_id, e.source))
365
366 return next(e)
367 })
368
369 on('classic.UserPromptSubmit', ($, e, next) => {
370 if (e.agent_id === undefined) {
371 void quietly($, 'draft', forgetDraft($))
372 }
373
374 return next(e)
375 })
376
377 // The main loop's turn, which a forced quit ends; a subagent's run raises no `turn.start`.
378 on('turn.start', ($, e, next) => {
379 turn = e.turnId
380
381 return next(e)
382 })
383
384 on('turn.complete', ($, e, next) => {
385 if (e.agentId === undefined) {
386 turn = undefined
387 }
388
389 return next(e)
390 })
391
392 // The band above the prompt is where the command line's field is: unseen, under whatever else is
393 // drawn there, until the person's focus chord (`abovePrompt:focus`) moves the keys into it.
394 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
395 const beneath = await next(e)
396
397 if (e.surface !== 'terminal') {
398 return beneath
399 }
400
401 const { drawn, isDown, value } = await read($, field)
402
403 if (isDown) {
404 return beneath
405 }
406
407 const table = $.ui.resolve(e)
408 const { Box } = table
409 const { requestId } = e
410 const key = fieldKey(drawn)
411
412 return (
413 <Box flexDirection="column">
414 {beneath}
415 {CommandField(table, {
416 key,
417 value,
418 guards: { previous: PREVIOUS, next: NEXT },
419 onInput: typed => {
420 void quietly($, 'command', typeLine($, requestId, key, typed))
421 },
422 onSubmit: typed => {
423 void quietly($, 'command', submitLine($, typed))
424 },
425 })}
426 </Box>
427 )
428 })
429
430 // The keys moving into the field is the command line opening. Tab, Shift+Tab and the arrows in the
431 // field move the ring onto an element beside it: the completions step instead, and the ring is
432 // kept on the field by not passing the move on.
433 on('ui.focus', async ($, e, next) => {
434 if (e.component === 'AbovePrompt' && e.origin.kind === 'person' && (e.element === NEXT || e.element === PREVIOUS)) {
435 void quietly($, 'command', stepCompletion($, e.element === NEXT ? 1 : -1))
436
437 return {}
438 }
439
440 const moved = await next(e)
441
442 if (e.component === 'AbovePrompt' && e.element !== undefined && isField(e.element) && e.origin.kind === 'person' && moved.deny === undefined) {
443 void quietly($, 'command', openLine($, e.requestId, e.element))
444 }
445
446 return moved
447 })
448}
449hooks/commands.ts 107 lines1// The command line's commands, as vim names them and as opencode.vim has them (its `commands.ts` is
2// where the names, the `!` and the wording come from): the draft saved, loaded back, the session
3// quit, and by any other name a slash command of Claude Code's. What each one does is the hooks'
4// business; here a typed line is only told apart, and its name completed.
5
6import type { MenuItem } from '../types'
7
8export type Action =
9 | { kind: 'save' }
10 | { kind: 'reload'; isForced: boolean }
11 | { kind: 'quit'; isForced: boolean; isSaving: boolean }
12 | { kind: 'help' }
13 // A name that is none of the command line's own: a slash command, if Claude Code has one by it.
14 | { kind: 'other'; command: string; args: string }
15 | { kind: 'refused'; reason: string }
16 | { kind: 'none' }
17
18type Entry = { names: readonly string[]; action: Action; description: string }
19
20const OWN: readonly Entry[] = [
21 { names: ['w', 'write'], action: { kind: 'save' }, description: 'Save the draft for this session' },
22 { names: ['e', 'edit'], action: { kind: 'reload', isForced: false }, description: 'Load the saved draft' },
23 { names: ['e!', 'edit!'], action: { kind: 'reload', isForced: true }, description: 'Load the saved draft, dropping what was typed since' },
24 // One session to a window, so quitting all of them is quitting this one.
25 { names: ['q', 'quit', 'qa', 'qall'], action: { kind: 'quit', isForced: false, isSaving: false }, description: 'Quit Claude Code' },
26 {
27 names: ['q!', 'quit!', 'qa!', 'qall!'],
28 action: { kind: 'quit', isForced: true, isSaving: false },
29 description: 'Quit, leaving an unsaved draft or a running turn behind',
30 },
31 { names: ['wq', 'x'], action: { kind: 'quit', isForced: false, isSaving: true }, description: 'Save the draft and quit' },
32 { names: ['wq!', 'x!'], action: { kind: 'quit', isForced: true, isSaving: true }, description: 'Save the draft and quit, ending a running turn' },
33 { names: ['h', 'help'], action: { kind: 'help' }, description: 'List these commands' },
34]
35
36export const SAID = {
37 saved: 'draft saved',
38 cleared: 'draft cleared',
39 unsavedReload: 'no write since last change (add ! to override)',
40 unsavedQuit: 'no write since last change (:q! to override)',
41 running: (command: string) => `session is running (:${command}! to override)`,
42 unknown: (command: string) => `unknown command: :${command}`,
43 noArguments: (command: string) => `command does not accept arguments: :${command}`,
44}
45
46export const HELP = [
47 ':w save the draft for this session',
48 ':e :e! load the saved draft; ! drops what was typed since',
49 ':q :q! quit Claude Code; ! leaves an unsaved draft or a running turn behind',
50 ':wq :x save the draft and quit',
51 ':<command> run a slash command, as /<command> does',
52]
53
54// What a typed line asks for. The colon is the line's own mark and is not typed, but one typed
55// anyway is let go.
56export const commandOf = (typed: string): Action => {
57 const line = typed.trim().replace(/^:\s*/, '')
58 const [command = ''] = line.split(/\s+/, 1)
59 const args = line.slice(command.length).trim()
60 const own = OWN.find(({ names }) => names.includes(command))
61
62 if (command === '') {
63 return { kind: 'none' }
64 }
65
66 if (own === undefined) {
67 return { kind: 'other', command, args }
68 }
69
70 return args === '' ? own.action : { kind: 'refused', reason: SAID.noArguments(command) }
71}
72
73// What the name typed so far could become, the line's own commands first and then Claude Code's,
74// matched from their start as opencode.vim matches them. Only a name is completed: past it the
75// arguments are the command's own business.
76export const completionsOf = (typed: string, natives: readonly MenuItem[]): MenuItem[] => {
77 const line = typed.replace(/^\s*:?\s*/, '')
78 const own = OWN.flatMap(({ names, description }) => names.map(name => ({ name, description })))
79 const taken = new Set(own.map(({ name }) => name))
80 const prefix = line.toLowerCase()
81
82 if (/\s/.test(line)) {
83 return []
84 }
85
86 return [...own, ...natives.filter(({ name }) => !taken.has(name))].filter(({ name }) => name.toLowerCase().startsWith(prefix))
87}
88
89// The field the line is typed in is drawn under a new key each time the line closes. The engine
90// keeps what was typed in a field by its key, and a line left with Escape would open on it.
91export const fieldKey = (drawn: number) => `command:${drawn}`
92
93export const isField = (key: string | undefined) => key !== undefined && /^command:\d+$/.test(key)
94
95// The two elements drawn either side of the field. The engine moves the ring onto one for Tab or
96// Down and for Shift+Tab or Up; that move is a step through the completions instead.
97export const NEXT = 'complete:next'
98export const PREVIOUS = 'complete:previous'
99
100// A draft is its session's, as in opencode.vim. A session nothing was sent in cannot be opened
101// again, so until then the draft is kept for the folder the session runs in: the plugin's `home`.
102export const draftKey = (isStarted: boolean, sessionId: string, root: string) =>
103 isStarted ? `draft:session:${sessionId}` : `draft:home:${root}`
104
105// What the store keeps under a draft's key, or the empty draft where it keeps none.
106export const keptDraft = (kept: unknown) => (typeof kept === 'string' ? kept : '')
107hooks/view.tsx 29 lines1import type { Elements } from 'claude-code'
2
3type Fields = Pick<Elements['terminal'], 'Box' | 'Button' | 'Input'>
4
5// What the command line's field is drawn with: its address, the text it holds when drawn, what
6// typing and Enter run, and the two elements beside it, which the keys that move the ring land on.
7type Field = {
8 key: string
9 value: string
10 onInput: (value: string) => void
11 onSubmit: (value: string) => void
12 guards: { previous: string; next: string }
13}
14
15// The command line's field, which is typed into where nobody sees it: it stands in the band above
16// the prompt, the one place a mod's field can take the keyboard, in a box of no height, and what
17// is typed is drawn by the statusline mod in its bar, where vim has its command line.
18export const CommandField = ({ Box, Button, Input }: Fields, { key, value, onInput, onSubmit, guards }: Field) => (
19 <Box height={0} overflow="hidden">
20 <Button key={guards.previous} onPress={() => undefined}>
21 previous
22 </Button>
23 <Input key={key} value={value} autoFocus onInput={onInput} onSubmit={onSubmit} />
24 <Button key={guards.next} onPress={() => undefined}>
25 next
26 </Button>
27 </Box>
28)
29types/index.d.ts 28 lines1// What the command line said last: a command's answer, or why it would not run.
2export type Echo = { text: string; isWarning: boolean }
3
4// The command line's field: how many were taken down before it, which is what the one drawn now is
5// keyed by, whether it is left undrawn for the moment, and the text it was last drawn holding (a
6// completion taken into it).
7export type FieldState = { drawn: number; isDown: boolean; value: string }
8
9// A command the line can complete to, and what the menu says of it.
10export type MenuItem = { name: string; description: string }
11
12// The completions on show: a window of them, the one picked in it, and how many there are in all.
13export type Menu = { items: MenuItem[]; picked: number; total: number }
14
15// These outlive a reload of the mod, so a value whose shape changes takes a new key. The statusline
16// mod reads `command`, `echo` and `menu` to draw the line in its bar.
17declare module 'claude-code' {
18 interface PluginState {
19 vim: {
20 // What is typed in the command line while it is open, and what it said last.
21 command: string | null
22 echo: Echo | null
23 commandField: FieldState
24 menu: Menu | null
25 }
26 }
27}
28