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

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
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 に記録される。
必要な権限:
| plugin | permissions |
|---|---|
| zjstatus | RunCommands, ChangeApplicationState, ReadApplicationState |
| zjstatus-hints | ReadApplicationState, MessageAndLaunchOtherPlugins |
hooks/register.tsx 126 lines1import { 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}
126hooks/blocks.ts 44 lines1export 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}
44types/index.d.ts 8 lines1export 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