SLOPSHOPPER

tcursor-spike

Spike: a vim cursor on the main transcript

newpanebandrowscommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tcursor-spike
│ ┃ keys ✕ │ ┃ 0/1 dock 56 > 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. I made refresh reject expired claims and added an audit call. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tcursor │ ⎿ tcursor-spike: tcursor: 0 messages seen │ │ msg 0/0 off 0 · · render -1ms · keys: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
msg 0/0 off 0 · · render -1ms · keys:
Pane · keys
0/1 dock 56
Pane · full
bodyColumns 56 viewport 160x42 rows 30
Your message
> fix the failing auth test and add an audit log call
Claude's reply
⏺ Done. I made refresh reject expired claims and added an audit call.
README

Spike: transcript layouts (Claude Code 2.1.286)

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
CommandTests
/tcursorA 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.
/tkeysA docked pane that only catches keys (asks for columns: 30), driving the same transcript cursor.
/tfullA pane asking for columns: 500; prints the width it got.
/toverToggles a full-viewport overlay: a position="absolute" Box drawn from the command's own output row.
Source 2 files
hooks/register.tsx 323 lines
1import { 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}
323
types/index.d.ts 8 lines
1export 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