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

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" />
anthy-agent-unicode、または原 anthy 9100h の anthy-agent です。mod は変換のたびにこれを egg モードで動かします。動作を確認したのは、nixpkgs の anthy (9100h) の anthy-agent と、anthy-unicode をソースビルドした anthy-agent-unicode です。どちらも macOS で確認しました。各ディストリビューションの anthy パッケージにもどちらかが含まれているはずですが、確認はしていません。
| 入手先 | 方法 |
|---|---|
| Nix | nix 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 で動くかを確かめます。
$VIME_ANTHY_AGENT (設定されていればこれだけ。コマンド名でもパスでもよい)PATH 上の anthy-agent-unicodePATH 上の 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 | 行末まで削除 | かなを確定 | 変換中の区間を確定して次のかなの区間へ進む。最後なら全体を確定 |
| Backspace | 1 文字削除 | 最後のかなを削除 (きょ は 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 モードとの違いは、かなを確定せずに保留したままにすることと、次の ; で終わることです。
;)vime.nvim と同じく、; で ASCII モードに入ります。もう一度 ; を押すまで、打った文字 (大文字・数字・記号・空白) がそのまま入ります。それまでに打ったかなは保留したままで、閉じる ; の後に打ったローマ字は新しいかなになります:
入力 ;React;wotsukatte 欄の表示 Reactをつかって
Space 欄の表示 Reactを使って (かなの部分だけを変換)
ctrl+k 欄の表示 Reactを使って
; そのものを入力する方法はありません。いったんモードを抜けてから打ってください。かな入力中の Space と、ctrl+a / ctrl+e (option+左 / 右) では、agent を 1 回動かして結果を待ちます。動作確認したマシンでは約 10 ms でした。変換を確定したときも、選んだ候補を anthy に学習させるために agent をもう 1 回動かします。ただしこれはキーへの応答を返した後に行うので、打鍵が待たされることはありません。かなの入力では何も起動しません。
変換の処理中に打ったキーは、処理が終わってから生のまま欄に入ることがあります (今日は良いka)。次のキーを打つと、それを取り出して打ったものとして処理し直します。i を打てば欄は 今日は良いかい になります。すぐにプロンプトを送信した場合も同じように処理します。
読みが 500 バイト (かなでおよそ 166 文字) を超えると、agent のコマンド 1 行に収まりません。その場合は変換せずにエラーを出します。
anthy は、変換の記録と確定した候補の記録を残し、候補の順位に使います。mod 自体は何も保存しません。
| agent | anthy の記録先 |
|---|---|
anthy-agent-unicode | $XDG_CONFIG_HOME/anthy (XDG_CONFIG_HOME が未設定なら ~/.config/anthy) |
anthy-agent (9100h) | アカウントのホームディレクトリの ~/.anthy (HOME の値によらない) |
挿入モードでは、上記のとおりにキーが働きます。Esc はノーマルモードへの切り替えに使われ、mod には届きません。ノーマルモードのコマンドは、mod を通らずに欄を変えます。挿入モードに戻ったとき、欄が変わっていなければかなの入力はそのまま続きます。ノーマルモードで欄を変えた場合は、かなはそのまま残り、次のキーから入力し直しになります。
変換中は、欄の上の帯に注目文節の候補を出します。その間、他の mod の帯 (session-brief など) は隠れます。
変換に失敗すると、理由をトーストで出し、かなはそのまま残します:
| トースト | 対処 |
|---|---|
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 を一時ディレクトリに向けてセッションを起動してください。
hooks/register.tsx 103 lines1import { 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}
103hooks/anthy.ts 152 lines1// 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}
152hooks/band.ts 25 lines1// 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}
25hooks/editor.ts 331 lines1// 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}
331hooks/session.ts 305 lines1// 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}
305hooks/romaji.ts 137 lines1// 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}
137types/index.d.ts 9 lines1/** 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