Spike: a vim cursor on the main transcript

Throwaway mod used on 2026-10-10 to test where an annotation UI can live. Not part of the mod; kept so the same tests can be rerun on a newer Claude Code. The findings are in docs/layout-approaches.md.
claude --plugin-dir spikes/transcript-layouts # run in the fullscreen layout, 120+ columns
| Command | Tests |
|---|---|
/tcursor | A vim cursor drawn on transcript messages (ui.render on AssistantMessage / UserMessage). Keys from the band above the prompt (after ctrl+x tab) and from the prompt box (prompt.edit). The band row shows the message index, the last keys and their route (b: band, p: prompt box), redraw time and whether $.ui.scroll was allowed. q turns it off. |
/tkeys | A docked pane that only catches keys (asks for columns: 30), driving the same transcript cursor. |
/tfull | A pane asking for columns: 500; prints the width it got. |
/tover | Toggles a full-viewport overlay: a position="absolute" Box drawn from the command's own output row. |
hooks/register.tsx 323 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SpikeCursor } from '../types'
5
6type $ = EngineInterface
7
8const MARKS = ['', '', '', '']
9
10const active = atom({ plugin: 'tcursor-spike', key: 'active' } as const, false)
11const cursor = atom({ plugin: 'tcursor-spike', key: 'cursor' } as const, { id: '', off: 0 } as SpikeCursor)
12const keysDrawn = atom({ plugin: 'tcursor-spike', key: 'keysDrawn' } as const, MARKS[0]!)
13const status = atom({ plugin: 'tcursor-spike', key: 'status' } as const, '')
14
15type Msg = { id: string; kind: string; text: string; seq: number; onScreen: unknown }
16const msgs = new Map<string, Msg>()
17let seq = 0
18let bandId = ''
19let columns = 80
20let keyAt = 0
21let latency = -1
22let pending = ''
23let keyLog: string[] = []
24let scrollNote = ""
25let overlay = false
26let lastAssistant = ''
27const consumed = new Map<string, number>()
28
29type Line = { start: number; end: number }
30
31function wrap(text: string, width: number): Line[] {
32 const lines: Line[] = []
33 let i = 0
34 for (const para of text.split('\n')) {
35 if (para.length === 0) lines.push({ start: i, end: i })
36 for (let p = 0; p < para.length; p += width) lines.push({ start: i + p, end: i + Math.min(para.length, p + width) })
37 i += para.length + 1
38 }
39 return lines
40}
41
42const lineOf = (lines: Line[], off: number) => {
43 for (let n = lines.length - 1; n >= 0; n--) if (off >= lines[n]!.start) return n
44 return 0
45}
46
47const ordered = () => [...msgs.values()].sort((a, b) => a.seq - b.seq)
48const width = () => Math.max(20, columns - 8)
49
50function move(c: SpikeCursor, key: string): SpikeCursor {
51 const list = ordered()
52 if (list.length === 0) return c
53 let at = list.findIndex(m => m.id === c.id)
54 if (at < 0) at = list.length - 1
55 const msg = list[at]!
56 const lines = wrap(msg.text, width())
57 const n = lineOf(lines, c.off)
58 const col = c.off - lines[n]!.start
59 const to = (m: Msg, line: number) => {
60 const ls = wrap(m.text, width())
61 const l = ls[Math.max(0, Math.min(ls.length - 1, line < 0 ? ls.length + line : line))]!
62 return { id: m.id, off: Math.min(l.start + col, Math.max(l.start, l.end - 1)) }
63 }
64 switch (key) {
65 case 'j':
66 if (n + 1 < lines.length) return to(msg, n + 1)
67 return at + 1 < list.length ? to(list[at + 1]!, 0) : c
68 case 'k':
69 if (n > 0) return to(msg, n - 1)
70 return at > 0 ? to(list[at - 1]!, -1) : c
71 case 'l':
72 return { ...c, off: Math.min(msg.text.length - 1, c.off + 1) }
73 case 'h':
74 return { ...c, off: Math.max(0, c.off - 1) }
75 case 'G':
76 return { id: list[list.length - 1]!.id, off: 0 }
77 case 'g':
78 return { id: list[0]!.id, off: 0 }
79 case '}':
80 return at + 1 < list.length ? { id: list[at + 1]!.id, off: 0 } : c
81 case '{':
82 return at > 0 ? { id: list[at - 1]!.id, off: 0 } : c
83 }
84 return c
85}
86
87async function onKey($: $, key: string) {
88 if (key === 'q') {
89 await update($, active, () => false)
90 return
91 }
92 if (key === 'g' && pending !== 'g') {
93 pending = 'g'
94 return
95 }
96 pending = ''
97 keyAt = Date.now()
98 const before = await read($, cursor)
99 const next = move(before, key)
100 await update($, cursor, () => next)
101 if (next.id && next.id !== before.id) {
102 const r = await $.ui.scroll({ to: { requestId: next.id }, block: 'nearest' })
103 scrollNote = (`scroll ${'deny' in r && r.deny ? `denied: ${r.deny}` : 'ok'}`)
104 }
105}
106
107async function onKeysTyped($: $, value: string) {
108 const mark = MARKS.includes(value[0] ?? '') ? value[0]! : ''
109 const body = [...(mark ? value.slice(1) : value)]
110 const done = consumed.get(mark) ?? 0
111 const typed = body.slice(done)
112 consumed.set(mark, body.length)
113 if (typed.length === 0) return
114 await update($, keysDrawn, cur => {
115 const next = MARKS[(MARKS.indexOf(cur) + 1) % MARKS.length]!
116 consumed.set(next, 0)
117 return next
118 })
119 keyLog = [...keyLog.slice(-7), ...typed.map(k => `b:${k}`)]
120 for (const k of typed) await onKey($, k)
121}
122
123export const register: Register = on => {
124 on('session.start', async ($, e, next) => {
125 await $.command.register({ name: 'tcursor', description: 'Spike: vim cursor on the transcript' })
126 await $.command.register({ name: 'tkeys', description: 'Spike: thin key pane' })
127 await $.command.register({ name: 'tfull', description: 'Spike: widest pane' })
128 await $.command.register({ name: 'tover', description: 'Spike: overlay' })
129 return next(e)
130 })
131
132 on('command.run', { command: 'tcursor' }, async $ => {
133 const list = ordered()
134 await update($, cursor, c => (c.id && msgs.has(c.id) ? c : { id: list[list.length - 1]?.id ?? '', off: 0 }))
135 await update($, active, () => true)
136 void $.clock.after(300, async () => {
137 if (!bandId) {
138 await update($, status, () => 'no band id yet')
139 return
140 }
141 const r = await $.ui.focus({ requestId: bandId, key: 'keys' }).catch(err => ({ deny: String(err) }))
142 await update($, status, () => `focus ${'deny' in r && r.deny ? `denied: ${r.deny}` : 'ok'}`)
143 })
144 return { text: `tcursor: ${list.length} messages seen` }
145 })
146
147 on('command.run', { command: 'tover' }, async $ => {
148 overlay = !overlay
149 $.ui.invalidate('ui.render')
150 return { text: `overlay ${overlay}` }
151 })
152
153 on('ui.render', { component: 'CommandOutput', props: { command: 'tover' } }, async ($, e, next) => {
154 if (!overlay) return next(e)
155 const { Box, Text } = $.ui.resolve(e)
156 const vr = e.viewport?.rows ?? 40
157 const vc = e.viewport?.columns ?? 120
158 const pad = (s: string) => (s + ' '.repeat(vc)).slice(0, vc - 2)
159 return (
160 <Box flexDirection="column">
161 <Text>overlay anchor</Text>
162 <Box position="absolute" bottom={0} left={0} width={vc} height={vr} flexDirection="column">
163 {Array.from({ length: vr }, (_, n) => (
164 <Text>{pad(n === 0 ? `scroll=${JSON.stringify(e.props.onScreen)}` : `OVERLAY row ${n} ${'x'.repeat(40)}`)}</Text>
165 ))}
166 </Box>
167 </Box>
168 )
169 })
170
171 for (const component of ['AssistantMessage', 'UserMessage'] as const) {
172 on('ui.render', { component }, async ($, e, next) => {
173 const text = e.props.text
174 const seen = msgs.get(e.requestId)
175 msgs.set(e.requestId, { id: e.requestId, kind: component, text, seq: seen?.seq ?? ++seq, onScreen: e.props.onScreen })
176 if (e.viewport?.columns) columns = e.viewport.columns
177 const mySeq = msgs.get(e.requestId)!.seq
178 if (component === 'AssistantMessage' && mySeq >= (msgs.get(lastAssistant)?.seq ?? 0)) lastAssistant = e.requestId
179 if (overlay && e.requestId === lastAssistant && false) {
180 const { Box, Text } = $.ui.resolve(e)
181 const vr = e.viewport?.rows ?? 40
182 const vc = e.viewport?.columns ?? 120
183 return (
184 <Box flexDirection="column">
185 <Text>{text.slice(0, 60)}</Text>
186 <Box position="absolute" bottom={0} left={-2} width={vc} height={vr} flexDirection="column" backgroundColor="blue" borderStyle="round">
187 {Array.from({ length: vr - 2 }, (_, n) => (
188 <Text>{`OVERLAY row ${n} ${'x'.repeat(60)}`}</Text>
189 ))}
190 </Box>
191 </Box>
192 )
193 }
194 if (!(await read($, active))) return next(e)
195 const c = await read($, cursor)
196 const { Box, Text } = $.ui.resolve(e)
197 const lines = wrap(text, width())
198 const mine = c.id === e.requestId
199 if (mine && keyAt) {
200 latency = Date.now() - keyAt
201 keyAt = 0
202 }
203 const marker = component === 'UserMessage' ? '> ' : '⏺ '
204 return (
205 <Box flexDirection="column" marginTop={1}>
206 {lines.map((l, n) => {
207 const s = text.slice(l.start, l.end)
208 const lead = <Text dimColor={component === 'UserMessage'}>{n === 0 ? marker : ' '}</Text>
209 if (!mine || c.off < l.start || c.off >= Math.max(l.end, l.start + 1)) {
210 return (
211 <Box flexDirection="row" height={1}>
212 {lead}
213 <Text>{s || ' '}</Text>
214 </Box>
215 )
216 }
217 const at = c.off - l.start
218 return (
219 <Box flexDirection="row" height={1}>
220 {lead}
221 <Text>{s.slice(0, at)}</Text>
222 <Text backgroundColor="cyan" color="black">
223 {s[at] ?? ' '}
224 </Text>
225 <Text>{s.slice(at + 1)}</Text>
226 </Box>
227 )
228 })}
229 </Box>
230 )
231 })
232 }
233
234 on('command.run', { command: 'tkeys' }, async ($, e) => {
235 const list = ordered()
236 await update($, cursor, c => (c.id && msgs.has(c.id) ? c : { id: list[list.length - 1]?.id ?? '', off: 0 }))
237 await update($, active, () => true)
238 const r = await $.ui.open({ id: 'tkeys', title: 'keys', closeOnEscape: true, holdToasts: true, rows: 2, columns: 30 })
239 void $.clock.after(300, async () => {
240 const f = await $.ui.open({ id: 'tkeys', title: 'keys', focus: true, closeOnEscape: true, holdToasts: true, rows: 2, columns: 30 })
241 scrollNote = `open ${JSON.stringify(f)}`
242 await update($, status, () => 'opened')
243 })
244 return { text: `tkeys: fullscreen=${JSON.stringify(e.presentation)} open=${JSON.stringify(r)}` }
245 })
246
247 on('ui.close', { requestId: 'tkeys' }, async ($, e, next) => {
248 if (e.origin.kind === 'person' && (await read($, active))) {
249 keyLog = [...keyLog.slice(-7), 'ESC']
250 await update($, status, () => keyLog.join(' '))
251 void $.clock.after(80, () => $.ui.focus({ requestId: 'tkeys', key: 'keys' }).catch(() => ({})))
252 return { deny: 'annotating' }
253 }
254 return next(e)
255 })
256
257 on('ui.render', { component: 'Pane', requestId: 'tkeys' }, async ($, e) => {
258 const { Box, Input, Text } = $.ui.resolve(e)
259 const drawn = await read($, keysDrawn)
260 const c = await read($, cursor)
261 await read($, status)
262 const list = ordered()
263 return (
264 <Box flexDirection="column">
265 <Text dimColor wrap="truncate-end">{`${list.findIndex(m => m.id === c.id) + 1}/${list.length} ${e.props.placement} ${e.props.bodyColumns}`}</Text>
266 <Input key="keys" label="" value={drawn} autoFocus onInput={value => onKeysTyped($, value)} onSubmit={() => onKey($, 'j')} />
267 </Box>
268 )
269 })
270
271 on('command.run', { command: 'tfull' }, async ($, e) => {
272 const r = await $.ui.open({ id: 'tfull', title: 'full', closeOnEscape: true, holdToasts: true, columns: 500 })
273 return { text: `tfull: open=${JSON.stringify(r)}` }
274 })
275
276 on('ui.render', { component: 'Pane', requestId: 'tfull' }, async ($, e) => {
277 const { Box, Text } = $.ui.resolve(e)
278 return (
279 <Box flexDirection="column">
280 <Text>{`bodyColumns ${e.props.bodyColumns} viewport ${e.viewport?.columns}x${e.viewport?.rows} rows ${e.props.scroll.bodyRows}`}</Text>
281 </Box>
282 )
283 })
284
285 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
286 keyLog = [...keyLog.slice(-7), `focus:${e.element ?? 'none'}`]
287 await update($, status, () => keyLog.join(" "))
288 if (e.element === undefined && (await read($, active))) return { deny: 'annotating' }
289 return next(e)
290 })
291
292 on('prompt.edit', async ($, e, next) => {
293 if (!(await read($, active))) return next(e)
294 const k = e.key
295 const name = k ? `${k.ctrl ? 'C-' : ''}${k.meta ? 'M-' : ''}${k.key}` : `[${JSON.stringify(e.inputText)} ${e.start}-${e.end}]`
296 keyLog = [...keyLog.slice(-7), `p:${name}`]
297 await update($, status, () => keyLog.join(" "))
298 if (k && !k.ctrl && !k.meta && k.key.length === 1) await onKey($, k.key)
299 return { text: e.text, cursor: e.cursor }
300 })
301
302 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
303 bandId = e.requestId
304 if (!(await read($, active))) return next(e)
305 const { Box, Input, Text } = $.ui.resolve(e)
306 const drawn = await read($, keysDrawn)
307 const st = await read($, status)
308 const c = await read($, cursor)
309 const list = ordered()
310 const at = list.findIndex(m => m.id === c.id)
311 return (
312 <Box flexDirection="column">
313 <Box height={1} overflow="hidden">
314 <Text dimColor wrap="truncate-end">
315 {`msg ${at + 1}/${list.length} off ${c.off} · ${st} · render ${latency}ms · ${scrollNote}`}
316 </Text>
317 </Box>
318 <Input key="keys" label="keys" value={drawn} autoFocus onInput={value => onKeysTyped($, value)} onSubmit={() => onKey($, "j")} />
319 </Box>
320 )
321 })
322}
323types/index.d.ts 8 lines1export type SpikeCursor = { id: string; off: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'tcursor-spike': { active: boolean; cursor: SpikeCursor; keysDrawn: string; status: string }
6 }
7}
8