SLOPSHOPPER

orca-split-editor

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

newbandcommandtoastprocess
v0.1.0no licenseupdated 2026-10-09Kyo1M/claude-mods/orca-split-editor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orca-split-editor
› fix the failing auth test and add an audit log call ╭──────────────────────────────────────────╮ │ orca-split-editor │ ⏺ Read(src/auth.ts) │ Orca の外では使えません(Ctrl+X Ctrl+E で標準の外部エディタ) │ ⎿ 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 › /edit ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Claude Code の mod(画面や動きを変える hooks プラグイン)を置くリポジトリです。リポジトリ全体がプラグインのマーケットプレイスになっているので、mod ごとに 1 行で入れられます。

mod 一覧

modできること前提
orca-split-editorCtrl+G か /edit で Orca のペインを上下に分割し、下側のエディタでプロンプトを書く。保存して閉じると書いた文がプロンプト欄に入るOrca 上の Claude Code、$VISUAL か $EDITOR

入れ方

Claude Code のプロンプト欄で次の 1 行を入力します。

/plugin install orca-split-editor --marketplace Kyo1M/claude-mods

マーケットプレイスの追加を聞かれたら y を押し、スコープは user を選びます。

orca-split-editor のキー割り当て

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/ に置きます。

Source 3 files
hooks/register.tsx 89 lines
1import { 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}
89
hooks/editor.ts 131 lines
1import 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}
131
types/index.d.ts 16 lines
1/** 分割ペインで編集中の下書き */
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