SLOPSHOPPER

vime

Japanese input in the prompt box the vime.nvim way: type romaji, convert to kanji with Space, no OS IME switching

newbandcommandtoaststatusprompt
★ 11v0.1.0no licenseupdated 2026-10-03skanehira/claude-vime
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vime
› 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 › /vime ⎿ vime: vime: on ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ vime: あ
README

claude-vime

Claude Code のプロンプト欄で、vime.nvim と同じ方法でローマ字入力で日本語を入力する mod です。

<img width="516" height="184" alt="画面収録 2026-10-03 18 25 43" src="https://github.com/user-attachments/assets/101de51c-e1b3-43d6-b310-7efbd0b5fa86" />

必要環境

  • Claude Code 2.1.288。 動作を確認したバージョンです。mod は早期アクセスで、API はリリース間で変わることがあります。
  • anthy のコマンドライン agent。 anthy-unicode の anthy-agent-unicode、または原 anthy 9100h の anthy-agent です。mod は変換のたびにこれを egg モードで動かします。
  • hooks が許可されていること。 mod はプラグインの hooks として動くので、設定や組織のポリシーで hooks を止めている環境では動きません。

動作を確認したのは、nixpkgs の anthy (9100h) の anthy-agent と、anthy-unicode をソースビルドした anthy-agent-unicode です。どちらも macOS で確認しました。各ディストリビューションの anthy パッケージにもどちらかが含まれているはずですが、確認はしていません。

入手先方法
Nixnix profile install nixpkgs#anthy、または構成に pkgs.anthy (anthy-agent)
ソースビルド (macOS も可)下記のとおり anthy-unicode をビルド (anthy-agent-unicode)
git clone https://github.com/fujiwarat/anthy-unicode && cd anthy-unicode
meson setup build --prefix=$HOME/.local --sysconfdir=$HOME/.local/etc -Demacs=disabled
meson compile -C build && meson install -C build

--sysconfdir は絶対パスで指定してください。相対パスだと anthy が起動に失敗します。Claude Code を起動するときの PATH に ~/.local/bin を含めてください。

mod はセッション開始時に、次の順で agent を探します。それぞれ --version で動くかを確かめます。

  1. $VIME_ANTHY_AGENT (設定されていればこれだけ。コマンド名でもパスでもよい)
  2. PATH 上の anthy-agent-unicode
  3. PATH 上の anthy-agent

導入

/plugin marketplace add skanehira/claude-vime
/plugin install vime@claude-vime
/reload-plugins

読み込まれたかは /vi と打って確かめます。/vime が「Turn Japanese (romaji to kana and kanji) input on or off」という説明付きで候補に出れば読み込まれています。

日本語入力の ON / OFF は /vime で切り替えます。他のコマンドと同じく欄の先頭で打ちます。日本語入力が ON の間も、コマンド名は打ったとおりに入ります。OFF にすると、入力中の内容は確定します。キーでの切り替えはありません。Claude Code のキー割り当てを変えずに、どのターミナルでも mod まで届くキーが無いためです。

キー

日本語入力が ON の間、ステータス行に あ が出ます。ASCII モード中は A が出ます。

キー何も入力していないときかなを入力中変換中
a–z , . - / [ ]かなの入力を始めるかなに追加変換を確定し、その後ろで新しいかなを始める
Space空白を入力かなを変換注目文節の次の候補 (shift+Space で前の候補)
1–9数字を入力かなに数字を追加 (3ji → 3じ)帯でその番号の候補を選ぶ
左 / 右、ctrl+b / ctrl+fカーソル移動かなを確定してからカーソル移動前 / 次の文節に注目を移す
ctrl+a / ctrl+e (option+左 / 右 も可)(mod が無いときと同じ)かなを確定してから通常の動作注目文節を縮める / 伸ばす
;ASCII モードに入るかなを保留したまま ASCII モードに入る / 抜ける変換を確定して ASCII モードに入る
A–Z英字入力を始めるかなを確定して英字入力を始める変換を確定して英字入力を始める
ctrl+k行末まで削除かなを確定変換中の区間を確定して次のかなの区間へ進む。最後なら全体を確定
Backspace1 文字削除最後のかなを削除 (きょ は 1 単位)かなに戻す
Enterプロンプトを送信かなを確定して送信変換を確定して送信
その他のキー(mod が無いときと同じ)かなを確定してから通常の動作変換を確定し、その後ろに入れる

ctrl+k はプロンプトを送信せずに確定し、日本語入力は ON のままです。かな入力中・変換中は何も削除しません。

shift+Space で前の候補に戻れるのは、ターミナルが shift+Space と Space を区別して送る場合だけです。区別しないターミナルでは Space として届き、次の候補に進みます。WezTerm は既定で普通の Space として送ります。config.keys に次の項目を足すと、Claude Code が前面にあるときだけ shift+Space を ESC [ 32 ; 2 u で送り、それ以外では普通の空白を送ります:

{
  key = "Space",
  mods = "SHIFT",
  action = wezterm.action_callback(function(window, pane)
    local name = pane:get_foreground_process_name() or ""
    if name:find("claude", 1, true) then
      window:perform_action(wezterm.action.SendString("\x1b[32;2u"), pane)
    else
      window:perform_action(wezterm.action.SendString(" "), pane)
    end
  end),
},

tmux などのマルチプレクサの中では、前面のプロセスがマルチプレクサになるため、この設定は普通の空白を送ります。

ctrl+左 / ctrl+右 でも文節を縮める / 伸ばすことができます。ただし端末がそのキーを渡す場合に限ります (macOS は既定でデスクトップの切り替えに使います)。

末尾の n は、次のキーで ん か な行かが決まるまで n のまま表示します。確定すると ん になり、nn は常に ん です。' も入力中のかなに続けて入ります。

変換中は、カーソルを注目文節の末尾に置きます。左右どちらにも動ける余地を残すためです。欄の端でカーソルが動けない矢印キーは、mod まで届きません。

欄の先頭で打つスラッシュコマンドの名前は、打ったとおりに入ります。日本語入力が ON のままでも /vime などのコマンドを使えます。名前の後に空白を打つと、そこからはローマ字がまたかなになります。

1 回の編集で複数の文字が届いた場合 (ペースト、または Claude Code がまとめて渡したキー) は、全文字がローマ字なら 1 文字ずつ処理します (ASCII モード中と英字入力中は表示可能な文字すべてが対象)。それ以外は、かなを確定してから届いたとおりに入れます。

英字入力 (大文字始まり)

vime.nvim と同じく、大文字を打つと、保留中のかなをその場で確定して英字入力が始まります。その後に打った文字 (小文字・数字・記号・Space を含む) は変換されずにそのまま入り、ctrl+k で確定するまで続きます (プロンプトを送信したときと、日本語入力を OFF にしたときも確定します)。; ; で閉じると、その後のローマ字はまたかなになります。

入力   kanReact is   欄の表示  かんReact is   (「かん」は確定済み、React is は入力中)
ctrl+k               欄の表示  かんReact is

ASCII モードとの違いは、かなを確定せずに保留したままにすることと、次の ; で終わることです。

ASCII モード (;)

vime.nvim と同じく、; で ASCII モードに入ります。もう一度 ; を押すまで、打った文字 (大文字・数字・記号・空白) がそのまま入ります。それまでに打ったかなは保留したままで、閉じる ; の後に打ったローマ字は新しいかなになります:

入力   ;React;wotsukatte   欄の表示  Reactをつかって
Space                      欄の表示  Reactを使って   (かなの部分だけを変換)
ctrl+k                     欄の表示  Reactを使って
  • Space は最初のかなの部分を変換し、英字の部分は前後にそのまま残ります。かなの部分が複数あるときは、ctrl+k で 1 つずつ確定して次の部分の変換へ進みます。
  • 英字の部分の直後では、Space は空白として入ります。
  • Backspace は英字を 1 文字ずつ消し、ASCII モードは続きます。
  • ASCII モード中に ; そのものを入力する方法はありません。いったんモードを抜けてから打ってください。

知っておくべき挙動

変換のたびに agent を起動する

かな入力中の Space と、ctrl+a / ctrl+e (option+左 / 右) では、agent を 1 回動かして結果を待ちます。動作確認したマシンでは約 10 ms でした。変換を確定したときも、選んだ候補を anthy に学習させるために agent をもう 1 回動かします。ただしこれはキーへの応答を返した後に行うので、打鍵が待たされることはありません。かなの入力では何も起動しません。

変換の処理中に打ったキーは、処理が終わってから生のまま欄に入ることがあります (今日は良いka)。次のキーを打つと、それを取り出して打ったものとして処理し直します。i を打てば欄は 今日は良いかい になります。すぐにプロンプトを送信した場合も同じように処理します。

読みが 500 バイト (かなでおよそ 166 文字) を超えると、agent のコマンド 1 行に収まりません。その場合は変換せずにエラーを出します。

学習

anthy は、変換の記録と確定した候補の記録を残し、候補の順位に使います。mod 自体は何も保存しません。

agentanthy の記録先
anthy-agent-unicode$XDG_CONFIG_HOME/anthy (XDG_CONFIG_HOME が未設定なら ~/.config/anthy)
anthy-agent (9100h)アカウントのホームディレクトリの ~/.anthy (HOME の値によらない)

vim モード

挿入モードでは、上記のとおりにキーが働きます。Esc はノーマルモードへの切り替えに使われ、mod には届きません。ノーマルモードのコマンドは、mod を通らずに欄を変えます。挿入モードに戻ったとき、欄が変わっていなければかなの入力はそのまま続きます。ノーマルモードで欄を変えた場合は、かなはそのまま残り、次のキーから入力し直しになります。

欄の上の帯

変換中は、欄の上の帯に注目文節の候補を出します。その間、他の mod の帯 (session-brief など) は隠れます。

できないこと

  • Esc では確定しません。 vim モード以外では Esc は Claude Code の取消キーで、vim モードではノーマルモードへの切り替えです。どちらも mod には届きません。
  • 前の候補に戻すには、ターミナルが shift+Space を区別して送る必要があります。 ctrl+p は履歴の呼び出しになり、上・下・ctrl+n・Tab・shift+矢印は mod に届きません。shift+Space が Space として届く環境では、番号で候補を選んでください。
  • カタカナ確定・英字確定 (vime.nvim の F7 / F10)、辞書登録、SKK 辞書の取り込み、補完はありません。

うまくいかないとき

変換に失敗すると、理由をトーストで出し、かなはそのまま残します:

トースト対処
vime: anthy-agent not found: …agent を導入する (必要環境を参照) か VIME_ANTHY_AGENT を設定し、セッションを開き直す
vime: anthy-agent exited with 1: …このリポジトリで scripts/test-agent.sh を実行し、agent の失敗のしかたを見る
vime: the reading is too long to convert at once (…)数語ごとに Space を押して、短く区切って変換する

アンインストール

claude plugin uninstall vime@claude-vime
claude plugin marketplace remove claude-vime

開発

tsc には TypeScript 5.0 以上が必要です。設定は .claude-plugin/types/ から読みます。このディレクトリは Claude Code がこのフォルダから mod を読み込むときに書き出すので、clone 後に一度 claude --plugin-dir . を実行してください (git の管理対象外です)。

claude plugin validate .claude-plugin/plugin.json   # プラグイン: manifest と hooks module
claude plugin validate .                            # marketplace の manifest
claude plugin test .                                # hooks/*.test.ts(x) を Claude Code 自身の mod 実行環境で走らせる
scripts/test-agent.sh                               # 導入済みの各 agent について mod が頼る挙動を確かめる
claude --plugin-dir .                               # セッションで試す
tsc -p .                                            # 型チェック

scripts/test-agent.sh は、学習の記録を手元の記録と分けて書きます。anthy-unicode は一時的な XDG_CONFIG_HOME の下に、anthy 9100h は ~/.anthy の試験用 personality に書き、後者のファイルは終了時に削除します。セッションで試す場合は、変換するだけで agent が手元の記録に書き込みます。anthy-unicode の記録を分けたいときは、XDG_CONFIG_HOME を一時ディレクトリに向けてセッションを起動してください。

Source 7 files
hooks/register.tsx 103 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { anthyEngine, findAgent, learnLater } from './anthy'
5import type { Run } from './anthy'
6import { candidatePage } from './band'
7import { Composer } from './editor'
8import type { Candidates } from '../types'
9
10const candidates = atom({ plugin: 'vime', key: 'candidates' } as const, null as Candidates | null)
11
12// The run function of the dispatch being answered: an edit's agent process
13// belongs to that edit's hook, so each hook sets it before the composer runs.
14let run: Run | undefined
15// Made on session.start, once the agent is found (and again on a reload).
16let composer: Composer | undefined
17
18const runAgent: Run = (argv, init) =>
19  run === undefined ? Promise.reject(new Error('vime: no hook is running')) : run(argv, init)
20
21const PROBE_TIMEOUT_MS = 3000
22
23/** VIME_ANTHY_AGENT, or the first installed of anthy-agent-unicode and anthy-agent. */
24async function agentOf($: EngineInterface): Promise<string | undefined> {
25  const custom = await $.env.get('VIME_ANTHY_AGENT')
26  const probe = (argv: readonly string[]) =>
27    $.process.run(argv, { timeoutMs: PROBE_TIMEOUT_MS }).then(
28      ran => ran.exitCode === 0,
29      () => false,
30    )
31  return findAgent(probe, custom === undefined || custom === '' ? undefined : custom)
32}
33
34async function showState($: EngineInterface, current: Composer) {
35  $.ui.status(current.status())
36  const next = current.candidates() ?? null
37  await update($, candidates, () => next)
38}
39
40export const register: Register = on => {
41  on('session.start', async ($, e, next) => {
42    const agent = await agentOf($)
43    // Learning runs after the edit that committed has been answered, on the session's own $.
44    const learning = anthyEngine((argv, init) => $.process.run(argv, init), agent)
45    const commit = learnLater(learning.commit, message => $.ui.toast(message))
46    composer = new Composer({ convert: anthyEngine(runAgent, agent).convert, commit })
47    // A reload starts a fresh composer (off): clear what the last one left on screen.
48    await showState($, composer)
49    await $.command.register({ name: 'vime', description: 'Turn Japanese (romaji to kana and kanji) input on or off', immediate: true })
50    return next(e)
51  })
52
53  on('command.run', { command: 'vime' }, async $ => {
54    if (composer === undefined) return { text: 'vime: not ready yet' }
55    run = (argv, init) => $.process.run(argv, init)
56    await composer.toggle()
57    await showState($, composer)
58    return { text: composer.isOn ? 'vime: on' : 'vime: off' }
59  })
60
61  on('prompt.edit', async ($, e, next) => {
62    if (composer === undefined) return next(e)
63    run = (argv, init) => $.process.run(argv, init)
64    const answer = await composer.edit(e)
65    await showState($, composer)
66    if (answer.kind === 'pass') {
67      const edit = answer.edit
68      return next(edit === undefined ? e : { ...e, text: edit.text, start: edit.start, end: edit.end, cursor: edit.cursor })
69    }
70    if (answer.error !== undefined) $.ui.toast(answer.error)
71    return answer.box
72  })
73
74  // Enter sends the prompt when it does not reach prompt.edit: the run goes out committed.
75  on('prompt.submit', async ($, e, next) => {
76    if (composer === undefined) return next(e)
77    run = (argv, init) => $.process.run(argv, init)
78    const text = await composer.commitForSubmit(e.text)
79    await showState($, composer)
80    return next(text === e.text ? e : { ...e, text })
81  })
82
83  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
84    const current = await read($, candidates)
85    if (current === null || e.props.hasSurvey) return next(e)
86    const page = candidatePage(current.list, current.index)
87    const { Box, Text } = $.ui.resolve(e)
88
89    return (
90      <Box>
91        {page.items.map(item => (
92          <Box key={item.text} flexShrink={0} marginRight={1}>
93            <Text bold={item.isChosen} underline={item.isChosen} dimColor={!item.isChosen}>
94              {item.text}
95            </Text>
96          </Box>
97        ))}
98        <Text dimColor>{page.position}</Text>
99      </Box>
100    )
101  })
102}
103
hooks/anthy.ts 152 lines
1// The conversion engine backed by anthy-agent in egg mode (shipped with anthy
2// 9100h as anthy-agent, with anthy-unicode as anthy-agent-unicode). Each call
3// starts the agent once, writes the whole session of commands to its standard
4// input, and reads the answers back.
5import type { ConversionEngine, Resize, Segment } from './session'
6
7export type RunResult = { exitCode: number; stdout: string; stderr: string }
8
9/** Starts a host command and resolves once it exited; the hooks module passes `$.process.run`. */
10export type Run = (argv: readonly string[], init: { stdin: string; timeoutMs: number }) => Promise<RunResult>
11
12const TIMEOUT_MS = 5000
13// More candidates than any segment has: the agent clamps the request to what there is.
14const MAX_CANDIDATES = 1024
15// egg reads a command line into 512 bytes, its newline included; "CONVERT 0 " takes 10.
16const MAX_READING_BYTES = 500
17const AGENTS = ['anthy-agent-unicode', 'anthy-agent'] as const
18const NOT_FOUND =
19  'vime: anthy-agent not found: install anthy-unicode (anthy-agent-unicode) or anthy (anthy-agent), or set VIME_ANTHY_AGENT'
20
21/**
22 * The agent to run: `custom` (VIME_ANTHY_AGENT) alone when set, otherwise the
23 * first of anthy-agent-unicode and anthy-agent that answers `--version`.
24 */
25export async function findAgent(
26  probe: (argv: readonly string[]) => Promise<boolean>,
27  custom: string | undefined,
28): Promise<string | undefined> {
29  for (const agent of custom === undefined ? AGENTS : [custom]) {
30    if (await probe([agent, '--version'])) return agent
31  }
32  return undefined
33}
34
35type Commit = ConversionEngine['commit']
36
37/**
38 * A commit that answers at once and has the agent learn in the background, one
39 * commit after another: learning changes nothing on screen, and a key typed
40 * while a hook waits is one the engine handles on its own.
41 */
42export function learnLater(commit: Commit, report: (message: string) => void): Commit {
43  let queue = Promise.resolve()
44  return async (yomi, resizes, choices) => {
45    queue = queue
46      .then(() => commit(yomi, resizes, choices))
47      .catch((error: unknown) => report(error instanceof Error ? error.message : String(error)))
48  }
49}
50
51export function anthyEngine(run: Run, agent: string | undefined): ConversionEngine {
52  // --utf8 is anthy 9100h's switch away from EUC-JP; anthy-unicode speaks UTF-8 and ignores it.
53  const session = async (yomi: string, commands: readonly string[]): Promise<string[]> => {
54    if (agent === undefined) throw new Error(NOT_FOUND)
55    if (new TextEncoder().encode(yomi).length > MAX_READING_BYTES) {
56      throw new Error(`vime: the reading is too long to convert at once (over ${MAX_READING_BYTES} bytes)`)
57    }
58    const script = ['NEW-CONTEXT INPUT=#18 OUTPUT=#18', `CONVERT 0 ${yomi}`, ...commands, 'QUIT']
59    const ran = await run([agent, '--egg', '--utf8'], { stdin: script.map(line => `${line}\n`).join(''), timeoutMs: TIMEOUT_MS })
60    if (ran.exitCode !== 0) {
61      const reason = ran.stderr.split('\n')[0]
62      throw new Error(`vime: anthy-agent exited with ${ran.exitCode}${reason ? `: ${reason}` : ''}`)
63    }
64    const lines = ran.stdout.split('\n').map(line => line.replace(/\r$/, ''))
65    // The last answer ends in a newline, which leaves one empty piece past it.
66    if (lines.at(-1) === '') lines.pop()
67    return lines
68  }
69  // RESIZE-SEGMENT's last field is a direction flag, not an amount: 0 lengthens, anything else shortens.
70  const resizesOf = (resizes: readonly Resize[]) =>
71    resizes.map(([segment, delta]) => `RESIZE-SEGMENT 0 ${segment} ${delta < 0 ? 1 : 0}`)
72
73  return {
74    async convert(yomi, resizes) {
75      // A reading of n characters holds at most n segments; a request past the last answers -1.
76      const requests = Array.from({ length: [...yomi].length }, (_, i) => `GET-CANDIDATES 0 ${i} 0 ${MAX_CANDIDATES}`)
77      const answers = new Answers(await session(yomi, [...resizesOf(resizes), ...requests]))
78      answers.skip(2 + resizes.length) // NEW-CONTEXT and CONVERT, then each RESIZE-SEGMENT
79      return requests
80        .map(() => answers.candidates())
81        .filter(candidates => candidates !== undefined)
82        .map((candidates): Segment => ({ candidates }))
83    },
84    async commit(yomi, resizes, choices) {
85      const selections = choices.map((choice, segment) => `SELECT-CANDIDATE 0 ${segment} ${choice}`)
86      const answers = new Answers(await session(yomi, [...resizesOf(resizes), ...selections, 'COMMIT 0 0']))
87      answers.skip(2 + resizes.length + selections.length)
88      answers.expect('+OK')
89    },
90  }
91}
92
93/** The agent's answers, read in the order the commands went in. */
94class Answers {
95  private at = 0
96
97  constructor(private readonly lines: readonly string[]) {
98    // The greeting comes first.
99    this.at = 1
100  }
101
102  /** Reads past the answers to `count` commands. */
103  skip(count: number): void {
104    for (let i = 0; i < count; i++) this.next()
105  }
106
107  /** One GET-CANDIDATES answer: the candidates, or undefined past the last segment. */
108  candidates(): string[] | undefined {
109    const head = this.line()
110    const match = /^\+DATA (\d+) (-?\d+)$/.exec(head)
111    if (match === null) throw unexpected(head)
112    const count = Number(match[2]) - Number(match[1])
113    const list = count < 0 ? [] : Array.from({ length: count }, () => this.line())
114    this.blank()
115    return count < 0 ? undefined : list
116  }
117
118  expect(prefix: string): void {
119    const head = this.line()
120    if (!head.startsWith(prefix)) throw unexpected(head)
121  }
122
123  /** Reads past one answer of any kind. */
124  private next(): void {
125    const head = this.line()
126    const segments = /^\+DATA \d+ \d+ (\d+)$/.exec(head)
127    if (segments !== null) {
128      for (let i = 0; i < Number(segments[1]); i++) this.line()
129      this.blank()
130      return
131    }
132    if (!head.startsWith('+OK')) throw unexpected(head)
133  }
134
135  private blank(): void {
136    const line = this.line()
137    if (line !== '') throw unexpected(line)
138  }
139
140  private line(): string {
141    const line = this.lines[this.at]
142    this.at += 1
143    if (line === undefined) throw unexpected('(nothing)')
144    if (line.startsWith('-ERR')) throw new Error(`vime: anthy-agent: ${line}`)
145    return line
146  }
147}
148
149function unexpected(line: string): Error {
150  return new Error(`vime: anthy-agent answered something unexpected: ${line}`)
151}
152
hooks/band.ts 25 lines
1// What the band above the prompt lists while converting: the focused
2// segment's candidates, nine to a page, the page holding the chosen one.
3
4const PAGE_SIZE = 9
5
6/** One candidate as the band shows it, `<number>:<candidate>`. */
7export type CandidateItem = { text: string; isChosen: boolean }
8
9export type CandidatePage = { items: CandidateItem[]; position: string }
10
11export function candidatePage(list: readonly string[], index: number): CandidatePage {
12  const first = Math.floor(index / PAGE_SIZE) * PAGE_SIZE
13  const items = list.slice(first, first + PAGE_SIZE).map((text, i) => ({
14    text: `${i + 1}:${text}`,
15    isChosen: first + i === index,
16  }))
17  return { items, position: `(${index + 1}/${list.length})` }
18}
19
20/** The index of the candidate the band numbers `number` (1 to 9), on the page holding `index`. */
21export function candidateByNumber(count: number, index: number, number: number): number | undefined {
22  const at = Math.floor(index / PAGE_SIZE) * PAGE_SIZE + number - 1
23  return at < count ? at : undefined
24}
25
hooks/editor.ts 331 lines
1// What one edit of the prompt box does while Japanese input is on: the run of
2// kana (or its conversion) lives in the box's own text, underlined, between
3// `anchor` and `anchor + shown.length`, and each edit either changes the run,
4// commits it, or passes through to the engine's editor.
5import { candidateByNumber } from './band'
6import { Session } from './session'
7import type { ConversionEngine } from './session'
8
9export type KeyEvent = { key: string; ctrl?: true; shift?: true; meta?: true }
10
11/** One edit as `prompt.edit` hands it: the box before it and the splice. */
12export type Edit = { text: string; cursor: number; start: number; end: number; inputText: string; key?: KeyEvent }
13
14export type Decoration = { start: number; end: number; underline: true; bold?: true; backgroundColor?: string }
15
16export type BoxAnswer = { text: string; cursor: number; decorations: Decoration[] }
17
18/**
19 * `box`: the composer answers the edit itself with this box (the key is consumed).
20 * `pass`: the editor applies the edit as usual, to `edit` when the composer
21 * rewrote the box first (a commit that turned a trailing n into ん).
22 */
23export type Answer = { kind: 'box'; box: BoxAnswer; error?: string } | { kind: 'pass'; edit?: Edit }
24
25// A run starts on a lowercase letter or Japanese punctuation, and goes on with digits and ' too.
26// ; opens and closes ASCII mode (vime.nvim's ascii_toggle) and an uppercase letter starts English
27// text; in either, any printable character goes in as typed.
28const STARTS_RUN = /^[a-zA-Z,.\-/[\];]$/
29const CONTINUES_RUN = /^[a-zA-Z0-9,.\-/[\]';]$/
30const PRINTABLE = /^[\x20-\x7e]$/
31// A theme key (Claude Code's text-selection background), so it follows the light or dark theme.
32const FOCUSED_BACKGROUND = 'selectionBg'
33
34// Key shapes as a terminal delivers them to prompt.edit (observed on Claude Code 2.1.288):
35// a plain Enter never arrives (it submits); option+arrows arrive with `meta`, ctrl+arrows
36// with `ctrl`, and shift+arrows not reliably at all. No key turns input on or off: no key
37// reaches a mod in every terminal without a key binding, so /vime does.
38const isCtrl = (key: KeyEvent | undefined, name: string) => key?.ctrl === true && key.key === name
39const isResize = (key: KeyEvent | undefined) => key?.meta === true || key?.ctrl === true
40
41// A slash command's name being typed at the start of the box goes in as typed.
42const isSlashCommandName = (before: string) => /^\/\S*$/.test(before)
43
44// What the editor does with an edit the composer lets through.
45function applied(answer: Answer, e: Edit): { text: string; cursor: number } {
46  if (answer.kind === 'box') return answer.box
47  const edit = answer.edit ?? e
48  return { text: edit.text.slice(0, edit.start) + edit.inputText + edit.text.slice(edit.end), cursor: edit.start + edit.inputText.length }
49}
50
51export class Composer {
52  private session: Session
53  private on = false
54  private isBusy = false
55  private anchor = 0
56  /** The run as it stands in the box; '' when there is none. */
57  private shown = ''
58  /**
59   * The last box this composer answered, and the box the engine guessed before
60   * the answer came: keys typed while an answer was pending arrive on the guess.
61   */
62  private last: { answered: { text: string; cursor: number }; guessed: { text: string; cursor: number } } | undefined
63  /**
64   * Keys the engine put in raw (it applies keys typed while an answer was pending
65   * itself, whatever the hook answers), and the box it put them into.
66   */
67  private raw: { box: string; text: string } | undefined
68
69  constructor(private readonly engine: ConversionEngine) {
70    this.session = new Session(engine)
71  }
72
73  get isOn(): boolean {
74    return this.on
75  }
76
77  /** What the status line shows: A in ASCII mode, あ otherwise while on, nothing while off. */
78  status(): string | undefined {
79    if (!this.on) return undefined
80    return this.session.isAscii() ? 'A' : 'あ'
81  }
82
83  /** The focused segment's candidates while converting. */
84  candidates() {
85    return this.session.candidates()
86  }
87
88  /** Turns Japanese input on, or off committing the run (the box keeps what it shows). */
89  async toggle(): Promise<void> {
90    if (this.on) {
91      await this.session.commit()
92      this.shown = ''
93    }
94    this.on = !this.on
95  }
96
97  async edit(input: Edit): Promise<Answer> {
98    if (this.isBusy) return { kind: 'box', box: { text: input.text, cursor: input.cursor, decorations: this.decorations() } }
99    const last = this.last
100    this.last = undefined
101    if (last !== undefined && input.text === last.guessed.text && input.text !== last.answered.text) {
102      // Typed while the last answer was pending: the engine puts these keys in itself.
103      if (input.start === input.end && input.inputText !== '') this.raw = { box: last.answered.text, text: input.inputText }
104      else this.forget()
105      return { kind: 'pass' }
106    }
107    this.isBusy = true
108    try {
109      // Raw keys taken back out leave a run, so a pass from here always carries the repaired box.
110      const e = await this.repaired(input)
111      if (this.shown !== '' && e.text.slice(this.anchor, this.anchor + this.shown.length) !== this.shown) this.forget()
112      const answer = await this.answer(e)
113      if (answer.kind === 'box') this.last = { answered: answer.box, guessed: applied({ kind: 'pass' }, input) }
114      return answer
115    } catch (error) {
116      // The run stays as it was (a failed conversion changes nothing), so it keeps its underline.
117      const message = error instanceof Error ? error.message : String(error)
118      return { kind: 'box', box: { text: input.text, cursor: input.cursor, decorations: this.decorations() }, error: message }
119    } finally {
120      this.isBusy = false
121    }
122  }
123
124  /** The prompt about to be sent, with the run committed into it (learned when it was converted). */
125  async commitForSubmit(sent: string): Promise<string> {
126    const found = this.findRaw(sent)
127    const text = found === undefined ? sent : (await this.replay(found)).text
128    // A prompt that no longer holds the run goes as it is; the next edit starts over.
129    if (this.shown === '' || text.slice(this.anchor, this.anchor + this.shown.length) !== this.shown) {
130      // Nothing of the run is in what is sent (or ASCII mode was open with nothing in it): start over.
131      this.forget()
132      return text
133    }
134    return (await this.commitInto(text)).text
135  }
136
137  /**
138   * The edit with any raw keys the engine put in taken back out of its box and
139   * replayed as typed; the edit's own offsets past them move with the result.
140   */
141  private async repaired(input: Edit): Promise<Edit> {
142    const found = this.findRaw(input.text)
143    if (found === undefined) return input
144    const replayed = await this.replay(found)
145    const rawEnd = found.at + found.text.length
146    const moved = (at: number) => (at >= rawEnd ? at + replayed.cursor - rawEnd : at)
147    return { ...input, text: replayed.text, cursor: moved(input.cursor), start: moved(input.start), end: moved(input.end) }
148  }
149
150  /** Where the engine put the raw keys, when `text` is the box it showed with them in. */
151  private findRaw(text: string): { box: string; at: number; text: string } | undefined {
152    const raw = this.raw
153    this.raw = undefined
154    if (raw === undefined || text.length !== raw.box.length + raw.text.length) return undefined
155    let common = 0
156    while (common < raw.box.length && text[common] === raw.box[common]) common++
157    for (let at = Math.max(0, common - raw.text.length); at <= common; at++) {
158      if (text.slice(at, at + raw.text.length) === raw.text && text.slice(0, at) + text.slice(at + raw.text.length) === raw.box) {
159        return { box: raw.box, at, text: raw.text }
160      }
161    }
162    return undefined
163  }
164
165  /** The raw keys typed again on the box they went into. */
166  private async replay(found: { box: string; at: number; text: string }): Promise<{ text: string; cursor: number }> {
167    const typed: Edit = { text: found.box, cursor: found.at, start: found.at, end: found.at, inputText: found.text }
168    return applied(await this.answer(typed), typed)
169  }
170
171  private async answer(e: Edit): Promise<Answer> {
172    if (!this.on) return { kind: 'pass' }
173    // A burst of keys or a paste comes with no key; a key that puts in text of its own (ctrl+y) is not one.
174    const takes = this.session.isTypingLatin() ? PRINTABLE : CONTINUES_RUN
175    if (e.key === undefined && e.start === e.end && e.inputText.length > 1 && [...e.inputText].every(c => takes.test(c))) {
176      return this.burst(e)
177    }
178
179    const ch = e.start === e.end ? e.inputText : ''
180    if (this.session.isEmpty()) {
181      if (!STARTS_RUN.test(ch) || isSlashCommandName(e.text.slice(0, e.start) + ch)) return { kind: 'pass' }
182      this.anchor = e.start
183      await this.session.input(ch)
184      return this.render(e.text)
185    }
186
187    const end = this.anchor + this.shown.length
188    const isAtEnd = e.start === end && e.end === end
189    const key = e.key?.key
190    const candidates = this.session.candidates()
191
192    if (isCtrl(e.key, 'k')) {
193      // vime.nvim's Enter, without sending: commits the converted part and goes on to the next
194      // kana part, or commits the run; Japanese input stays on, and nothing after it is killed.
195      const committed = await this.session.commitStep()
196      if (committed === undefined) return this.render(e.text)
197      const text = e.text.slice(0, this.anchor) + committed + e.text.slice(end)
198      this.shown = ''
199      return { kind: 'box', box: { text, cursor: this.anchor + committed.length, decorations: [] } }
200    }
201    if (this.shown === '' && !(isAtEnd && PRINTABLE.test(ch))) {
202      // ASCII mode open with nothing typed in it yet: other keys act as usual, and the
203      // mode goes on from wherever the cursor lands.
204      this.anchor = applied({ kind: 'pass' }, e).cursor
205      return { kind: 'pass' }
206    }
207
208    if (candidates !== undefined) {
209      // Converting: the cursor sits on the focused segment, so a key typed or a Backspace
210      // anywhere in the run counts (a Backspace at the run's first character included).
211      const isInRun = e.start >= this.anchor && e.end <= end
212      const number = /^[1-9]$/.test(ch) ? candidateByNumber(candidates.list.length, candidates.index, Number(ch)) : undefined
213      if (key === 'backspace' && e.end >= this.anchor && e.end <= end && e.start === e.end - 1) this.session.cancel()
214      // Space picks the next candidate, shift+space (where the terminal reports shift) the previous one.
215      else if (isInRun && ch === ' ') {
216        if (e.key?.shift === true) this.session.prevCandidate()
217        else this.session.nextCandidate()
218      }
219      else if (isInRun && /^[1-9]$/.test(ch)) {
220        if (number !== undefined) this.session.select(number)
221      } else if ((key === 'left' && isResize(e.key)) || isCtrl(e.key, 'a')) await this.session.shrink()
222      else if ((key === 'right' && isResize(e.key)) || isCtrl(e.key, 'e')) await this.session.expand()
223      else if (key === 'left' || isCtrl(e.key, 'b')) this.session.prevSegment()
224      else if (key === 'right' || isCtrl(e.key, 'f')) this.session.nextSegment()
225      else if (isInRun && CONTINUES_RUN.test(ch)) return this.continueAfterCommit(e.text, ch)
226      // Anything else typed in the run goes in after all of it, as a kana key would.
227      else if (isInRun && e.start === e.end) return this.commitAndPass({ ...e, cursor: end, start: end, end })
228      else return this.commitAndPass(e)
229      return this.render(e.text)
230    }
231    if (key === 'backspace' && e.end === end && e.start === end - 1) {
232      this.session.backspace()
233      return this.render(e.text)
234    }
235    if (isAtEnd && ch === ' ') {
236      // After a latin part (ASCII mode, or just closed) Space is a space, as vime.nvim takes it.
237      if (this.session.isLatinTail()) await this.session.input(ch)
238      else await this.session.startConversion()
239      return this.render(e.text)
240    }
241    if (isAtEnd && takes.test(ch)) {
242      // An uppercase letter may commit what is pending first; the new run goes in after it.
243      return this.continueAfterCommit(e.text, ch)
244    }
245    return this.commitAndPass(e)
246  }
247
248  /** Several characters in one edit (a burst of keys, or a paste of romaji): one at a time. */
249  private async burst(e: Edit): Promise<Answer> {
250    let box = { text: e.text, cursor: e.start }
251    for (const ch of e.inputText) {
252      const one: Edit = { text: box.text, cursor: box.cursor, start: box.cursor, end: box.cursor, inputText: ch }
253      box = applied(await this.answer(one), one)
254    }
255    return { kind: 'box', box: { ...box, decorations: this.decorations() } }
256  }
257
258  /** Replaces the run in `text` with the session's preedit, and answers that box. */
259  private render(text: string): Answer {
260    const preedit = this.preeditText()
261    const next = text.slice(0, this.anchor) + preedit + text.slice(this.anchor + this.shown.length)
262    this.shown = preedit
263    return { kind: 'box', box: { text: next, cursor: this.cursor(), decorations: this.decorations() } }
264  }
265
266  /**
267   * Composing: after the kana. Converting: on the focused segment's last character, where the
268   * terminal draws its cursor over it; that leaves text after the cursor for right / ctrl+e, and
269   * before it for left / ctrl+a unless the segment is one character at the start of the line.
270   */
271  private cursor(): number {
272    const p = this.session.preedit()
273    if (p.kind === 'composing') return this.anchor + p.text.length
274    return this.anchor + p.before.length + p.segments.slice(0, p.current + 1).join('').length - 1
275  }
276
277  private async continueAfterCommit(text: string, ch: string): Promise<Answer> {
278    const committed = await this.session.input(ch)
279    const before = text.slice(0, this.anchor) + committed + text.slice(this.anchor + this.shown.length)
280    this.anchor += committed.length
281    this.shown = ''
282    return this.render(before)
283  }
284
285  /**
286   * Commits the run, then lets `edit` through (the edit as received, or moved by the
287   * caller); the run keeps its length, so the edit's offsets hold.
288   */
289  private async commitAndPass(edit: Edit): Promise<Answer> {
290    const { text } = await this.commitInto(edit.text)
291    return { kind: 'pass', edit: { ...edit, text } }
292  }
293
294  /** Commits the run: `text` with the committed text in its place, and where that text ends. */
295  private async commitInto(text: string): Promise<{ text: string; end: number }> {
296    const committed = await this.session.commit()
297    const next = text.slice(0, this.anchor) + committed + text.slice(this.anchor + this.shown.length)
298    this.shown = ''
299    return { text: next, end: this.anchor + committed.length }
300  }
301
302  /** The run was changed from outside (sent, cleared, edited): start over without learning. */
303  private forget() {
304    this.session = new Session(this.engine)
305    this.shown = ''
306  }
307
308  private preeditText(): string {
309    const p = this.session.preedit()
310    return p.kind === 'composing' ? p.text : p.before + p.segments.join('') + p.after
311  }
312
313  private decorations(): Decoration[] {
314    const p = this.session.preedit()
315    if (p.kind === 'composing') {
316      return p.text === '' ? [] : [{ start: this.anchor, end: this.anchor + p.text.length, underline: true }]
317    }
318    // The parts around the conversion stay underlined as composing kana are.
319    const around = (start: number, text: string): Decoration[] => (text === '' ? [] : [{ start, end: start + text.length, underline: true }])
320    let at = this.anchor + p.before.length
321    const segments = p.segments.map((segment, i): Decoration => {
322      const run: Decoration = { start: at, end: at + segment.length, underline: true }
323      at += segment.length
324      // The focused segment carries the theme's selection background, so all of it stands out
325      // (the terminal's cursor marks only its last character).
326      return i === p.current ? { ...run, bold: true, backgroundColor: FOCUSED_BACKGROUND } : run
327    })
328    return [...around(this.anchor, p.before), ...segments, ...around(at, p.after)]
329  }
330}
331
hooks/session.ts 305 lines
1// The conversion state machine: composing (romaji shown as kana) and converting
2// (the reading split into segments, each showing a chosen candidate). Ported from
3// vime.nvim lua/vime/session.lua: the run is a list of parts, and a conversion
4// works on one kana part at a time.
5import { toKana } from './romaji'
6
7/** One segment of a conversion: its candidates, best first. */
8export type Segment = { candidates: readonly string[] }
9
10/** A resize of the segment at `[0]` (0-based) by `[1]` characters: +1 longer, -1 shorter. */
11export type Resize = readonly [number, number]
12
13/**
14 * Kana-to-kanji conversion with no state of its own: each call converts the
15 * reading again and replays the resizes so far, as one anthy-agent run does.
16 */
17export interface ConversionEngine {
18  convert(yomi: string, resizes: readonly Resize[]): Promise<readonly Segment[]>
19  /** Learns the choice made for each segment (0-based candidate indices). */
20  commit(yomi: string, resizes: readonly Resize[], choices: readonly number[]): Promise<void>
21}
22
23/**
24 * What the prompt box shows for the session. While converting, `before` and
25 * `after` are the run's other parts as they stand, around the part converted.
26 */
27export type Preedit =
28  | { kind: 'composing'; text: string }
29  | { kind: 'converting'; before: string; segments: readonly string[]; current: number; after: string }
30
31/** The key that opens and closes ASCII mode, as vime.nvim's ascii_toggle defaults to. */
32const ASCII_TOGGLE = ';'
33
34/**
35 * One part of the run: romaji shown as kana, text kept as typed in ASCII mode
36 * (`isClosed` once ; ended it), or text a step of a mixed run already committed.
37 */
38type Part = { kind: 'kana'; romaji: string } | { kind: 'latin'; text: string; isClosed: boolean } | { kind: 'confirmed'; text: string }
39
40type Conversion = {
41  /** The index of the kana part being converted. */
42  at: number
43  yomi: string
44  resizes: readonly Resize[]
45  segments: readonly Segment[]
46  choices: readonly number[]
47  current: number
48}
49
50export class Session {
51  private parts: Part[] = []
52  private conversion: Conversion | undefined
53  private ascii = false
54
55  constructor(private readonly engine: ConversionEngine) {}
56
57  preedit(): Preedit {
58    const c = this.conversion
59    if (c === undefined) return { kind: 'composing', text: shownOf(this.parts, true) }
60    const before = shownOf(this.parts.slice(0, c.at), false)
61    const after = shownOf(this.parts.slice(c.at + 1), true)
62    return { kind: 'converting', before, segments: chosen(c), current: c.current, after }
63  }
64
65  /** The focused segment's candidates and the chosen one's index; undefined while composing. */
66  candidates(): { list: readonly string[]; index: number } | undefined {
67    const c = this.conversion
68    if (c === undefined) return undefined
69    return { list: c.segments[c.current]!.candidates, index: c.choices[c.current]! }
70  }
71
72  /** In ASCII mode: what is typed goes in as it is, until ; again. */
73  isAscii(): boolean {
74    return this.ascii
75  }
76
77  /** Nothing typed and not in ASCII mode. */
78  isEmpty(): boolean {
79    return this.parts.length === 0 && !this.ascii
80  }
81
82  /** The run ends in a latin part: Space then goes in as a space, as vime.nvim's latin run takes it. */
83  isLatinTail(): boolean {
84    return this.parts.at(-1)?.kind === 'latin'
85  }
86
87  /** What is typed goes in as it is: ASCII mode, or English text an uppercase letter started. */
88  isTypingLatin(): boolean {
89    const tail = this.parts.at(-1)
90    return this.ascii || (tail?.kind === 'latin' && !tail.isClosed)
91  }
92
93  /** Adds one typed character; while converting, commits first and answers what was committed. */
94  async input(ch: string): Promise<string> {
95    const committed = this.conversion === undefined ? '' : await this.commit()
96    if (ch === ASCII_TOGGLE) this.toggleAscii()
97    else if (this.isTypingLatin()) this.latinTail().text += ch
98    else if (/^[A-Z]$/.test(ch)) {
99      // vime.nvim's latin run: what is pending is committed in place, and English text starts.
100      const pending = this.parts.length > 0 ? await this.commit() : ''
101      this.parts.push({ kind: 'latin', text: ch, isClosed: false })
102      return committed + pending
103    } else this.kanaTail().romaji += ch
104    return committed
105  }
106
107  /** Removes the last kana while composing (a youon such as きょ is one unit). */
108  backspace(): void {
109    const tail = this.parts.at(-1)
110    if (this.conversion !== undefined || tail === undefined || tail.kind === 'confirmed') return
111    if (tail.kind === 'latin') {
112      // ASCII mode stays as it is: only ; leaves it.
113      tail.text = tail.text.slice(0, -1)
114      if (tail.text === '') this.parts.pop()
115      return
116    }
117    const before = [...toKana(tail.romaji, true)].length
118    let romaji = tail.romaji
119    while (romaji.length > 0) {
120      romaji = romaji.slice(0, -1)
121      const kana = toKana(romaji, true)
122      if (!/[A-Za-z]$/.test(kana) && [...kana].length < before) break
123    }
124    tail.romaji = romaji
125    if (romaji === '') this.parts.pop()
126  }
127
128  async startConversion(): Promise<void> {
129    if (this.conversion !== undefined || this.ascii) return
130    await this.convertFrom(0)
131  }
132
133  /** Picks the focused segment's candidate at `index`. */
134  select(index: number): void {
135    const c = this.conversion
136    if (c === undefined) return
137    this.conversion = { ...c, choices: c.choices.map((choice, i) => (i === c.current ? index : choice)) }
138  }
139
140  nextCandidate(): void {
141    this.moveCandidate(1)
142  }
143
144  prevCandidate(): void {
145    this.moveCandidate(-1)
146  }
147
148  nextSegment(): void {
149    this.moveSegment(1)
150  }
151
152  prevSegment(): void {
153    this.moveSegment(-1)
154  }
155
156  async expand(): Promise<void> {
157    await this.resize(1)
158  }
159
160  async shrink(): Promise<void> {
161    await this.resize(-1)
162  }
163
164  /**
165   * Ends the session's text. Converting: the chosen candidates (learned), and
166   * every other kana part converted and learned by its first candidates.
167   * Composing: the kana and latin parts as typed.
168   */
169  async commit(): Promise<string> {
170    if (this.conversion !== undefined) {
171      await this.commitConverted()
172      while (await this.convertFrom(0)) await this.commitConverted()
173    }
174    return this.finish()
175  }
176
177  /**
178   * vime.nvim's Enter: commits the converted part and goes on to convert the next
179   * kana part (answering undefined), or, with none left, ends the run and answers it.
180   */
181  async commitStep(): Promise<string | undefined> {
182    if (this.conversion === undefined) return this.finish()
183    const at = this.conversion.at
184    await this.commitConverted()
185    if (await this.convertFrom(at + 1)) return undefined
186    return this.finish()
187  }
188
189  /** Converting: back to the kana as typed. Composing: drops the run and leaves ASCII mode. */
190  cancel(): void {
191    if (this.conversion !== undefined) {
192      this.conversion = undefined
193      return
194    }
195    this.parts = []
196    this.ascii = false
197  }
198
199  private toggleAscii() {
200    if (!this.ascii) {
201      this.ascii = true
202      this.latinTail()
203      return
204    }
205    this.ascii = false
206    const tail = this.parts.at(-1)
207    if (tail?.kind !== 'latin') return
208    if (tail.text === '') this.parts.pop()
209    else tail.isClosed = true
210  }
211
212  /** The open latin part at the end, or a new one after the last part. */
213  private latinTail(): Extract<Part, { kind: 'latin' }> {
214    const tail = this.parts.at(-1)
215    if (tail?.kind === 'latin' && !tail.isClosed) return tail
216    const part = { kind: 'latin' as const, text: '', isClosed: false }
217    this.parts.push(part)
218    return part
219  }
220
221  /** The kana part at the end, or a new one after the last part. */
222  private kanaTail(): Extract<Part, { kind: 'kana' }> {
223    const tail = this.parts.at(-1)
224    if (tail?.kind === 'kana') return tail
225    const part = { kind: 'kana' as const, romaji: '' }
226    this.parts.push(part)
227    return part
228  }
229
230  /** Converts the first kana part at or after `from`; false when there is none to convert. */
231  private async convertFrom(from: number): Promise<boolean> {
232    const at = this.parts.findIndex((part, i) => i >= from && part.kind === 'kana' && toKana(part.romaji) !== '')
233    if (at < 0) return false
234    const yomi = toKana((this.parts[at] as Extract<Part, { kind: 'kana' }>).romaji)
235    const segments = await this.engine.convert(yomi, [])
236    if (segments.length === 0) return false
237    this.conversion = { at, yomi, resizes: [], segments, choices: segments.map(() => 0), current: 0 }
238    return true
239  }
240
241  /** Learns the conversion and puts its chosen text in place of its part. */
242  private async commitConverted() {
243    const c = this.conversion
244    if (c === undefined) return
245    await this.engine.commit(c.yomi, c.resizes, c.choices)
246    this.parts[c.at] = { kind: 'confirmed', text: chosen(c).join('') }
247    this.conversion = undefined
248  }
249
250  /** The run as committed text; the session is empty afterwards. */
251  private finish(): string {
252    const text = this.parts.map(part => (part.kind === 'kana' ? toKana(part.romaji) : part.text)).join('')
253    this.parts = []
254    this.ascii = false
255    return text
256  }
257
258  private moveCandidate(delta: number) {
259    const c = this.conversion
260    if (c === undefined) return
261    const n = c.segments[c.current]!.candidates.length
262    const choices = c.choices.map((choice, i) => (i === c.current ? (choice + delta + n) % n : choice))
263    this.conversion = { ...c, choices }
264  }
265
266  private moveSegment(delta: number) {
267    const c = this.conversion
268    if (c === undefined) return
269    this.conversion = { ...c, current: clamp(c.current + delta, 0, c.segments.length - 1) }
270  }
271
272  private async resize(delta: number) {
273    const c = this.conversion
274    if (c === undefined) return
275    const resizes: readonly Resize[] = [...c.resizes, [c.current, delta]]
276    const segments = await this.engine.convert(c.yomi, resizes)
277    if (segments.length === 0) return
278    this.conversion = {
279      at: c.at,
280      yomi: c.yomi,
281      resizes,
282      segments,
283      choices: segments.map(() => 0),
284      current: clamp(c.current, 0, segments.length - 1),
285    }
286  }
287}
288
289/**
290 * The parts as the box shows them while composing: a trailing n held as n only
291 * in the last kana part, the one still being typed.
292 */
293function shownOf(parts: readonly Part[], endsRun: boolean): string {
294  const last = parts.length - 1
295  return parts.map((part, i) => (part.kind === 'kana' ? toKana(part.romaji, endsRun && i === last) : part.text)).join('')
296}
297
298function chosen(c: Conversion): string[] {
299  return c.segments.map((segment, i) => segment.candidates[c.choices[i]!]!)
300}
301
302function clamp(n: number, min: number, max: number): number {
303  return Math.max(min, Math.min(n, max))
304}
305
hooks/romaji.ts 137 lines
1// Romaji to hiragana (wapuro romaji), ported from vime.nvim lua/vime/romaji.lua.
2
3const TABLE: Record<string, string> = {
4  a: 'あ', i: 'い', u: 'う', e: 'え', o: 'お',
5  ka: 'か', ki: 'き', ku: 'く', ke: 'け', ko: 'こ',
6  ga: 'が', gi: 'ぎ', gu: 'ぐ', ge: 'げ', go: 'ご',
7  sa: 'さ', si: 'し', shi: 'し', su: 'す', se: 'せ', so: 'そ',
8  za: 'ざ', zi: 'じ', ji: 'じ', zu: 'ず', ze: 'ぜ', zo: 'ぞ',
9  ta: 'た', ti: 'ち', chi: 'ち', tu: 'つ', tsu: 'つ', te: 'て', to: 'と',
10  da: 'だ', di: 'ぢ', du: 'づ', de: 'で', do: 'ど',
11  na: 'な', ni: 'に', nu: 'ぬ', ne: 'ね', no: 'の',
12  ha: 'は', hi: 'ひ', hu: 'ふ', fu: 'ふ', he: 'へ', ho: 'ほ',
13  ba: 'ば', bi: 'び', bu: 'ぶ', be: 'べ', bo: 'ぼ',
14  pa: 'ぱ', pi: 'ぴ', pu: 'ぷ', pe: 'ぺ', po: 'ぽ',
15  ma: 'ま', mi: 'み', mu: 'む', me: 'め', mo: 'も',
16  ya: 'や', yu: 'ゆ', yo: 'よ',
17  ra: 'ら', ri: 'り', ru: 'る', re: 'れ', ro: 'ろ',
18  wa: 'わ', wo: 'を', wi: 'うぃ', wu: 'う', we: 'うぇ',
19  kya: 'きゃ', kyu: 'きゅ', kyo: 'きょ',
20  gya: 'ぎゃ', gyu: 'ぎゅ', gyo: 'ぎょ',
21  sya: 'しゃ', syu: 'しゅ', syo: 'しょ',
22  sha: 'しゃ', shu: 'しゅ', sho: 'しょ', she: 'しぇ',
23  ja: 'じゃ', ju: 'じゅ', jo: 'じょ', je: 'じぇ',
24  jya: 'じゃ', jyi: 'じぃ', jyu: 'じゅ', jye: 'じぇ', jyo: 'じょ',
25  zya: 'じゃ', zyu: 'じゅ', zyo: 'じょ',
26  tya: 'ちゃ', tyu: 'ちゅ', tyo: 'ちょ', tye: 'ちぇ',
27  cha: 'ちゃ', chu: 'ちゅ', cho: 'ちょ', che: 'ちぇ',
28  cya: 'ちゃ', cyu: 'ちゅ', cyo: 'ちょ',
29  dya: 'ぢゃ', dyu: 'ぢゅ', dyo: 'ぢょ',
30  nya: 'にゃ', nyu: 'にゅ', nyo: 'にょ',
31  hya: 'ひゃ', hyu: 'ひゅ', hyo: 'ひょ',
32  bya: 'びゃ', byu: 'びゅ', byo: 'びょ',
33  pya: 'ぴゃ', pyu: 'ぴゅ', pyo: 'ぴょ',
34  mya: 'みゃ', myu: 'みゅ', myo: 'みょ',
35  rya: 'りゃ', ryu: 'りゅ', ryo: 'りょ',
36  vu: 'ゔ',
37  xa: 'ぁ', xi: 'ぃ', xu: 'ぅ', xe: 'ぇ', xo: 'ぉ',
38  la: 'ぁ', li: 'ぃ', lu: 'ぅ', le: 'ぇ', lo: 'ぉ',
39  xya: 'ゃ', xyu: 'ゅ', xyo: 'ょ',
40  lya: 'ゃ', lyu: 'ゅ', lyo: 'ょ',
41  xtu: 'っ', ltu: 'っ', xtsu: 'っ', ltsu: 'っ',
42  xwa: 'ゎ', lwa: 'ゎ',
43  xn: 'ん',
44  ye: 'いぇ',
45  '-': 'ー',
46  ',': '、', '.': '。', '/': '・', '[': '「', ']': '」',
47  zh: '←', zj: '↓', zk: '↑', zl: '→',
48  'z-': '〜', 'z,': '‥', 'z.': '…', 'z/': '・', 'z[': '『', 'z]': '』',
49}
50
51const VOWELS = ['a', 'i', 'u', 'e', 'o'] as const
52type Vowel = (typeof VOWELS)[number]
53
54// Small a-row (ふぁ, つぁ, くぁ ...) and small ya-row (てゃ, でゃ, ふゃ ...).
55const SMALL_A: Record<Vowel, string> = { a: 'ぁ', i: 'ぃ', u: 'ぅ', e: 'ぇ', o: 'ぉ' }
56const SMALL_Y: Record<Vowel, string> = { a: 'ゃ', i: 'ぃ', u: 'ゅ', e: 'ぇ', o: 'ょ' }
57
58// "Consonant glide + vowel" becomes "base kana + small vowel"; a skipped vowel has a base sound of its own.
59function expand(prefix: string, base: string, small: Record<Vowel, string>, skip: readonly Vowel[] = []) {
60  for (const v of VOWELS) {
61    if (!skip.includes(v)) TABLE[prefix + v] = base + small[v]
62  }
63}
64
65expand('f', 'ふ', SMALL_A, ['u'])
66expand('v', 'ゔ', SMALL_A, ['u'])
67expand('ts', 'つ', SMALL_A, ['u'])
68expand('wh', 'う', SMALL_A, ['u'])
69expand('kw', 'く', SMALL_A)
70expand('gw', 'ぐ', SMALL_A)
71expand('tw', 'と', SMALL_A)
72expand('dw', 'ど', SMALL_A)
73expand('q', 'く', SMALL_A)
74expand('qw', 'く', SMALL_A)
75expand('th', 'て', SMALL_Y)
76expand('dh', 'で', SMALL_Y)
77expand('fy', 'ふ', SMALL_Y)
78expand('vy', 'ゔ', SMALL_Y)
79
80const isConsonant = (ch: string) => /^[bcdfghjkmpqrstvwxyz]$/.test(ch)
81const isVowel = (ch: string) => /^[aeiou]$/.test(ch)
82
83/**
84 * Converts romaji to hiragana, uppercase read as lowercase.
85 *
86 * With `isInProgress`, a trailing single `n` stays `n` (the next key decides
87 * between な-row and ん, as a real IME shows it while typing); without it, it
88 * resolves to ん, which is what a commit takes.
89 *
90 * Order: the table's longest match (4 to 1 characters), then ん by look-ahead,
91 * then っ for a doubled consonant (or tch), and anything else passes through.
92 */
93export function toKana(input: string, isInProgress = false): string {
94  const s = input.toLowerCase()
95  let out = ''
96  let i = 0
97  while (i < s.length) {
98    const matched = matchTable(s, i)
99    if (matched !== undefined) {
100      out += matched.kana
101      i += matched.length
102      continue
103    }
104    const c = s[i]!
105    const nx = s[i + 1] ?? ''
106    if (c === 'n') {
107      if (nx === 'n') {
108        out += 'ん'
109        i += 2
110        continue
111      }
112      const isHeld = nx === '' ? isInProgress : isVowel(nx) || nx === 'y'
113      if (!isHeld) {
114        out += 'ん'
115        i += 1
116        continue
117      }
118    }
119    if (isConsonant(c) && (nx === c || (c === 't' && nx === 'c' && s[i + 2] === 'h'))) {
120      out += 'っ'
121      i += 1
122      continue
123    }
124    out += c
125    i += 1
126  }
127  return out
128}
129
130function matchTable(s: string, at: number): { kana: string; length: number } | undefined {
131  for (let length = 4; length >= 1; length--) {
132    const kana = TABLE[s.slice(at, at + length)]
133    if (kana !== undefined) return { kana, length }
134  }
135  return undefined
136}
137
types/index.d.ts 9 lines
1/** The focused segment's candidates and the chosen one's index, while converting. */
2export type Candidates = { list: readonly string[]; index: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    vime: { candidates: Candidates | null }
7  }
8}
9