Orca のペインを上下に分割してエディタでプロンプトを書く

Claude Code の mod(画面や動きを変える hooks プラグイン)を置くリポジトリです。リポジトリ全体がプラグインのマーケットプレイスになっているので、mod ごとに 1 行で入れられます。
| mod | できること | 前提 |
|---|---|---|
| orca-split-editor | Ctrl+G か /edit で Orca のペインを上下に分割し、下側のエディタでプロンプトを書く。保存して閉じると書いた文がプロンプト欄に入る | Orca 上の Claude Code、$VISUAL か $EDITOR |
Claude Code のプロンプト欄で次の 1 行を入力します。
/plugin install orca-split-editor --marketplace Kyo1M/claude-mods
マーケットプレイスの追加を聞かれたら y を押し、スコープは user を選びます。
mod は本体の Ctrl+G(外部エディタ)を横取りできないため、~/.claude/keybindings.json で Ctrl+G を本体から外し、mod に回します。標準の外部エディタは既定の Ctrl+X Ctrl+E で引き続き使えます。
{
"$schema": "https://www.schemastore.org/claude-code-keybindings.json",
"$docs": "https://code.claude.com/docs/en/keybindings",
"bindings": [
{ "context": "Chat", "bindings": { "ctrl+g": null } },
{ "context": "Global", "bindings": { "ctrl+g": "app:toggleReplTab" } }
]
}
app:toggleReplTab は、プロンプト欄の上の帯にある mod のボタンを Ctrl+G で押すための割り当てです。キー割り当てを変えなくても /edit で使えます。
このリポジトリのフォルダをマーケットプレイスとして登録すると、インストールした mod はこのフォルダから直接読み込まれます。
claude plugin marketplace add ~/Developer/claude-mods
claude plugin install orca-split-editor@claude-mods --scope user
ファイルを直したら、Claude Code で /reload-plugins を実行すると反映されます。
確認は mod ごとに次の 2 つで行います。
claude plugin validate orca-split-editor
claude plugin test orca-split-editor
新しい mod は <mod 名>/ にフォルダを作り、.claude-plugin/marketplace.json の plugins とこの README の一覧に追記します。設計書は docs/superpowers/specs/ に置きます。
hooks/register.tsx 89 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Editing } from '../types'
5import { readHostEnv, startEdit } from './editor'
6import type { Deps } from './editor'
7
8const editing = atom({ plugin: 'orca-split-editor', key: 'editing' } as const, null as Editing | null)
9const inOrca = atom({ plugin: 'orca-split-editor', key: 'inOrca' } as const, false)
10const editor = atom({ plugin: 'orca-split-editor', key: 'editor' } as const, 'vi')
11
12// keybindings.json で Global の ctrl+g をこの action に割り当てる。
13// 本体の処理が常駐しない action なので、帯の Button が Ctrl+G で押される。
14const KEY_ACTION = 'app:toggleReplTab'
15
16function depsOf($: EngineInterface): Deps {
17 return {
18 run: argv => $.process.run(argv),
19 runLong: async argv => {
20 // $.process.run は最長 10 分で切れるので spawn で読み切る
21 const stream = $.process.spawn({ argv })
22 let stdout = ''
23 let stderr = ''
24 for await (const chunk of stream) {
25 if (chunk.stream === 'stdout') stdout += chunk.text
26 else stderr += chunk.text
27 }
28 const { code } = await stream.result
29 return { exitCode: code ?? 1, stdout, stderr }
30 },
31 readPrompt: async () => (await $.prompt.read()).text,
32 fillPrompt: async text => (await $.prompt.fill({ text, mode: 'replace' })).isFilled,
33 readFile: async path => String(await $.fs.read(path)),
34 writeFile: (path, text) => $.fs.write(path, text),
35 now: () => $.clock.now(),
36 toast: text => $.ui.toast(text),
37 getEditing: () => read($, editing),
38 setEditing: async value => {
39 await update($, editing, () => value)
40 },
41 }
42}
43
44function launch($: EngineInterface): void {
45 void startEdit(depsOf($)).catch(error => $.ui.toast(`エディタを開けませんでした: ${String(error)}`))
46}
47
48export const register: Register = on => {
49 on('session.start', async ($, e, next) => {
50 // 読み直しで待ちの処理は切れるので、編集中の印を消しておく
51 await update($, editing, () => null)
52 const env = await readHostEnv(argv => $.process.run(argv))
53 await update($, inOrca, () => env.handle !== null)
54 await update($, editor, () => env.editor)
55 await $.command.register({
56 name: 'edit',
57 description: 'Orca を上下に分割し、エディタでプロンプトを書く',
58 })
59 return next(e)
60 })
61
62 on('command.run', { command: 'edit' }, $ => {
63 launch($)
64 return {}
65 })
66
67 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
68 if (e.props.hasSurvey || !(await read($, inOrca))) return next(e)
69
70 const { Box, Button, Text } = $.ui.resolve(e)
71 const name = (await read($, editor)).split(' ')[0]?.split('/').pop() ?? 'editor'
72 const isEditing = (await read($, editing)) !== null
73
74 return (
75 <Box>
76 <Button
77 key="edit"
78 label={isEditing ? 'Ctrl+G: 編集中のペインへ' : `Ctrl+G: ${name} で書く`}
79 action={KEY_ACTION}
80 plain
81 dimColor
82 onPress={() => launch($)}
83 />
84 {isEditing && <Text dimColor> {name} で編集中(保存して閉じるとプロンプト欄に入ります)</Text>}
85 </Box>
86 )
87 })
88}
89hooks/editor.ts 131 lines1import type { Editing } from '../types'
2
3export const ORCA = '/Applications/Orca.app/Contents/Resources/bin/orca'
4export const DRAFT_DIR = '/tmp/claude-orca-split-editor'
5
6export type RunResult = { exitCode: number; stdout: string; stderr: string }
7
8/** 外の世界との接点。register.tsx が $ でつなぎ、テストは差し替える */
9export type Deps = {
10 run: (argv: readonly string[]) => Promise<RunResult>
11 /** run と同じだが時間制限なし(エディタの終了待ちに使う) */
12 runLong: (argv: readonly string[]) => Promise<RunResult>
13 readPrompt: () => Promise<string>
14 /** 入れられたら true */
15 fillPrompt: (text: string) => Promise<boolean>
16 readFile: (path: string) => Promise<string>
17 writeFile: (path: string, text: string) => Promise<void>
18 now: () => Promise<number>
19 toast: (text: string) => void
20 getEditing: () => Promise<Editing | null>
21 setEditing: (editing: Editing | null) => Promise<void>
22}
23
24export type HostEnv = { handle: string | null; editor: string }
25
26/** `env` の出力から Orca のハンドルとエディタを読む */
27export async function readHostEnv(run: Deps['run']): Promise<HostEnv> {
28 const { stdout } = await run(['/usr/bin/env'])
29 const vars = new Map<string, string>()
30 for (const line of stdout.split('\n')) {
31 const at = line.indexOf('=')
32 if (at > 0) vars.set(line.slice(0, at), line.slice(at + 1))
33 }
34 return {
35 handle: vars.get('ORCA_TERMINAL_HANDLE') || null,
36 editor: vars.get('VISUAL') || vars.get('EDITOR') || 'vi',
37 }
38}
39
40/** 1 回の待ちの上限。切れたら待ち直す */
41export const WAIT_SLICE_MS = 600_000
42
43/** エディタが終わるまで待つ。終了を確認できたら true、確認できない失敗なら false */
44export async function waitForEditor(deps: Deps, handle: string): Promise<boolean> {
45 for (;;) {
46 const r = await deps.runLong([
47 ORCA, 'terminal', 'wait', '--terminal', handle,
48 '--for', 'exit', '--timeout-ms', String(WAIT_SLICE_MS), '--json',
49 ])
50 let json: any
51 try {
52 json = JSON.parse(r.stdout)
53 } catch {
54 return false
55 }
56 if (json?.ok === true && json.result?.wait?.satisfied === true) return true
57 const isTimeout =
58 (json?.ok === false && json.error?.code === 'timeout') ||
59 (json?.ok === true && json.result?.wait?.satisfied === false)
60 if (!isTimeout) return false
61 }
62}
63
64function firstLine(r: RunResult): string {
65 return (r.stderr.trim() || r.stdout.trim()).split('\n')[0] ?? ''
66}
67
68/**
69 * 今の下書きを一時ファイルに書き、Orca を上下に分割してエディタで開く。
70 * エディタが閉じたら分割ペインを閉じ、ファイルの中身でプロンプト欄を置き換える。
71 */
72export async function startEdit(deps: Deps): Promise<void> {
73 const current = await deps.getEditing()
74 if (current) {
75 deps.toast('すでに編集中です')
76 await deps.run([ORCA, 'terminal', 'focus', '--terminal', current.handle, '--json'])
77 return
78 }
79
80 const env = await readHostEnv(deps.run)
81 if (!env.handle) {
82 deps.toast('Orca の外では使えません(Ctrl+X Ctrl+E で標準の外部エディタ)')
83 return
84 }
85
86 const draft = await deps.readPrompt()
87 const file = `${DRAFT_DIR}/draft-${await deps.now()}.md`
88 await deps.writeFile(file, draft)
89
90 const split = await deps.run([
91 ORCA, 'terminal', 'split',
92 '--terminal', env.handle,
93 '--direction', 'horizontal',
94 '--command', `exec ${env.editor} ${file}`,
95 '--json',
96 ])
97 let handle: string | undefined
98 try {
99 handle = JSON.parse(split.stdout)?.result?.split?.handle
100 } catch {
101 handle = undefined
102 }
103 if (split.exitCode !== 0 || !handle) {
104 deps.toast(`分割できませんでした: ${firstLine(split)}`)
105 return
106 }
107
108 await deps.setEditing({ handle, file })
109 try {
110 if (!(await waitForEditor(deps, handle))) {
111 // 書きかけを失わないよう、ペインは閉じず取り込みもしない
112 deps.toast(`エディタの終了を確認できませんでした。下書き: ${file}`)
113 return
114 }
115 await deps.run([ORCA, 'terminal', 'close', '--terminal', handle, '--json']).catch(() => undefined)
116 await deps.run([ORCA, 'terminal', 'focus', '--terminal', env.handle, '--json']).catch(() => undefined)
117
118 let text: string
119 try {
120 text = await deps.readFile(file)
121 } catch {
122 deps.toast(`下書きを読めませんでした: ${file}`)
123 return
124 }
125 const isFilled = await deps.fillPrompt(text.replace(/\n$/, ''))
126 if (!isFilled) deps.toast(`プロンプト欄に入れられませんでした: ${file}`)
127 } finally {
128 await deps.setEditing(null)
129 }
130}
131types/index.d.ts 16 lines1/** 分割ペインで編集中の下書き */
2export type Editing = { handle: string; file: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'orca-split-editor': {
7 /** 編集中なら分割ペインのハンドルと一時ファイル、待機中は null */
8 editing: Editing | null
9 /** Orca の中で動いているか(帯を出すかどうか) */
10 inOrca: boolean
11 /** 開くエディタのコマンド($VISUAL → $EDITOR → vi) */
12 editor: string
13 }
14 }
15}
16