SLOPSHOPPER

vim

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…

newbandtimer
v0.1.0MITupdated 2026-10-05thefuga/claude-x/mods/vim
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vim
› fix the failing auth test and add an audit log call ⏺ 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 ⟨Claude Code's own drawing⟩ [ previous ][ next ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ [ previous ][ next ]
README

vim

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.

Install

claude plugin marketplace add thefuga/claude-x
claude plugin install vim@claude-x

Works with

  • statusline: the line is drawn in its bar, after the mode's badge, with the completions just above it, so install the two together. Without it the line still runs commands, but nothing shows what is typed or what it answers.
  • syntax: a draft loaded back with :e is colored at the next typed key.

Opening it

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.

Commands

CommandWhat it does
:wSaves 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, :xSaves the draft and quits.
:hLists 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.

Completion

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.

Drafts

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.

Limits

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:

  • The line is edited at its end. The field takes typing and Backspace; Left, Right, Home and End do nothing in it.
  • After Escape the line stays for up to a fifth of a second. Claude Code raises nothing when the keys go back to the prompt, so the mod asks ten times a second whether the field still has them.
  • A draft is the prompt's text. A pasted image, or a paste long enough to be folded, comes back as its placeholder.
  • The menu covers the rows above the bar while it is up: the bottom of the prompt box. It has as many rows as there are under the box's top rule, eight at the most, so a one-row draft shows two.
  • No command sends the prompt: a mod cannot press Enter.
Source 4 files
hooks/register.tsx 449 lines
1import { 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}
449
hooks/commands.ts 107 lines
1// 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 : '')
107
hooks/view.tsx 29 lines
1import 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)
29
types/index.d.ts 28 lines
1// 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