SLOPSHOPPER

block-cursor

Move a cursor over code blocks and quotes in replies and copy them

bandrowstoasttimer
v0.1.0no licenseupdated 2026-10-20hitsan/dotfiles/modules/ai/claude/mods/block-cursor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · block-cursor
› 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 alt+c ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
alt+c
README

dotfiles

Nix Flakes + Home Manager で管理する dotfiles(x86_64-linux / WSL2)。

セットアップ

前提: Nix(flakes 有効化済み)

git clone git@github.com:hitsan/dotfiles.git
cd dotfiles
home-manager switch --flake .

主要コマンド

# 設定を適用
home-manager switch --flake .

# ビルドのみ(適用しない)
home-manager build --flake .

# flake の更新
nix flake update

Tips

zellij: zjstatus / zjstatus-hints の権限

load_plugins でバックグラウンド起動されるため権限プロンプトが表示されず、 無許可のまま永久に無反応になることがある。新しいマシンでは以下を一度手動実行して許可する。

zellij action launch-plugin "https://github.com/dj95/zjstatus/releases/latest/download/zjstatus.wasm"
zellij action launch-plugin "https://github.com/b0o/zjstatus-hints/releases/latest/download/zjstatus-hints.wasm"

起動したフローティングペインで許可(y)すると ~/.cache/zellij/permissions.kdl に記録される。

必要な権限:

pluginpermissions
zjstatusRunCommands, ChangeApplicationState, ReadApplicationState
zjstatus-hintsReadApplicationState, MessageAndLaunchOtherPlugins
Source 3 files
hooks/register.tsx 126 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Cursor } from '../types'
5import { scrollBlock, segments } from './blocks'
6import type { Segment } from './blocks'
7
8const cursor = atom({ plugin: 'block-cursor', key: 'cursor' } as const, null)
9const isFocused = atom({ plugin: 'block-cursor', key: 'isFocused' } as const, false)
10// True from closing the box until the band has let the keys go: a band with no Button hands them back.
11const isClosing = atom({ plugin: 'block-cursor', key: 'isClosing' } as const, false)
12let watch: Timer | undefined
13// Filled while drawing, in transcript order; a hot reload empties it until replies are drawn again.
14const drawn = new Map<string, Segment[]>()
15
16function chunks() {
17  return [...drawn].flatMap(([id, segs]) => segs.map((s, index) => ({ id, index, copy: s.copy })))
18}
19
20async function move($: EngineInterface, by: number) {
21  const all = chunks()
22  if (!all.length) {
23    $.ui.toast('画面に返信がありません')
24    return
25  }
26  const c = await read($, cursor)
27  const at = all.findIndex(k => k.id === c?.id && k.index === c?.index)
28  const i = Math.min(Math.max((at < 0 ? all.length : at) + by, 0), all.length - 1)
29  const k = all[i]!
30  await update($, cursor, (): Cursor => k)
31  // Best effort: the main screen (non-fullscreen) cannot move transcript rows.
32  // ponytail: a chunk in the middle of a reply taller than two screens stays hidden. The transcript has no row or key
33  // target (a Box key is refused); switch when the engine adds one.
34  const block = scrollBlock(k.index, drawn.get(k.id)!.length)
35  await $.ui.scroll({ to: { requestId: k.id }, block }).catch(() => undefined)
36}
37
38// The engine raises nothing when Esc gives the band's keys back, but $.ui.focus is denied from then on.
39// ponytail: polls every 300ms while the box is open; switch to an event if the engine adds one.
40function watchFocus($: EngineInterface, band: string) {
41  watch ??= $.clock.every(300, async () => {
42    if (!(await $.ui.focus({ requestId: band, key: 'close' })).deny) return
43    watch?.cancel()
44    watch = undefined
45    await update($, isFocused, () => false)
46    await update($, isClosing, () => false)
47  })
48}
49
50async function close($: EngineInterface) {
51  await update($, isClosing, () => true)
52  await update($, isFocused, () => false)
53}
54
55async function copy($: EngineInterface, c: Cursor | null, surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']) {
56  if (!c) return $.ui.toast('先に k で塊を選んでください')
57  const r = await $.ui.copy({ text: c.copy, surface })
58  $.ui.toast(r.isCopied ? 'コピーしました' : `コピー失敗: ${r.reason}`)
59}
60
61export const register: Register = on => {
62  on('ui.focus', async ($, e, next) => {
63    if (e.component === 'Pane') return next(e)
64    // k, j, g and y answer their hotkeys; the ring stays on the close mark.
65    if (e.element === 'up' || e.element === 'down' || e.element === 'bottom' || e.element === 'copy') return {}
66    await update($, isFocused, () => true)
67    watchFocus($, e.requestId)
68    return next(e)
69  })
70
71  on('turn.complete', async ($, e, next) => {
72    const all = chunks()
73    const last = all.at(-1)
74    if (last) await update($, cursor, (): Cursor => last)
75    return next(e)
76  })
77
78  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
79    const segs = segments(e.props.text)
80    drawn.set(e.requestId, segs)
81    const c = await read($, cursor)
82    if (c?.id !== e.requestId) return next(e)
83
84    const { Box, Markdown, Text } = $.ui.resolve(e)
85    return (
86      <Box flexDirection="column">
87        {segs.map((s, i) => (
88          <Box>
89            <Text>{i === c.index ? '▶ ' : '  '}</Text>
90            <Markdown text={s.raw} />
91          </Box>
92        ))}
93      </Box>
94    )
95  })
96
97  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
98    if (e.props.hasSurvey) return next(e)
99    const { Box, Button, Text } = $.ui.resolve(e)
100    const c = await read($, cursor)
101    const focused = await read($, isFocused)
102
103    // autoFocus makes the band raise ui.focus when it takes the keys, which is how isFocused learns of it.
104    if (!focused) {
105      return (
106        <Box justifyContent="flex-end" width={e.props.bodyColumns}>
107          {(await read($, isClosing)) ? (
108            <Text dimColor>alt+c</Text>
109          ) : (
110            <Button plain autoFocus key="open" label="alt+c" dimColor onPress={() => update($, isFocused, () => true)} />
111          )}
112        </Box>
113      )
114    }
115    return (
116      <Box paddingX={1} gap={2} width={e.props.bodyColumns}>
117        <Button plain autoFocus key="close" label="●" onPress={() => close($)} />
118        <Button plain key="up" hotkey="k" label="↑" onPress={() => move($, -1)} />
119        <Button plain key="down" hotkey="j" label="↓" onPress={() => move($, 1)} />
120        <Button plain key="bottom" hotkey="g" label="⤓" onPress={() => move($, Infinity)} />
121        <Button plain key="copy" hotkey="y" label="⧉" onPress={press => copy($, c, press.surface)} />
122      </Box>
123    )
124  })
125}
126
hooks/blocks.ts 44 lines
1export type Segment = { raw: string; copy: string }
2
3const FENCE = /^\s*(```|~~~)/
4
5export function segments(text: string): Segment[] {
6  const lines = text.split('\n')
7  const out: Segment[] = []
8  let plain: string[] = []
9  const flush = () => {
10    if (plain.length) out.push({ raw: plain.join('\n'), copy: plain.join('\n') })
11    plain = []
12  }
13
14  for (let i = 0; i < lines.length; ) {
15    const line = lines[i]!
16    const open = line.match(FENCE)
17    if (open) {
18      let end = i + 1
19      while (end < lines.length && !lines[end]!.trimStart().startsWith(open[1]!)) end++
20      flush()
21      out.push({ raw: lines.slice(i, end + 1).join('\n'), copy: lines.slice(i + 1, end).join('\n') })
22      i = end + 1
23    } else if (line.startsWith('>')) {
24      let end = i
25      while (end < lines.length && lines[end]!.startsWith('>')) end++
26      flush()
27      const quote = lines.slice(i, end)
28      out.push({ raw: quote.join('\n'), copy: quote.map(l => l.replace(/^> ?/, '')).join('\n') })
29      i = end
30    } else {
31      if (line.trim()) plain.push(line)
32      else flush()
33      i++
34    }
35  }
36  flush()
37  return out
38}
39
40// The transcript scrolls to a message, not a row in it, so a tall reply shows the edge nearer the chunk.
41export function scrollBlock(index: number, count: number): 'start' | 'end' {
42  return index * 2 + 1 >= count ? 'end' : 'start'
43}
44
types/index.d.ts 8 lines
1export type Cursor = { id: string; index: number; copy: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'block-cursor': { cursor: Cursor | null; isFocused: boolean; isClosing: boolean }
6  }
7}
8