From 4c426f9939d0a4ed76bad8c2c637e6d6ca4d6900 Mon Sep 17 00:00:00 2001 From: takecchi Date: Fri, 31 Jul 2026 05:55:37 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=B3=AA=E5=95=8F=E3=83=80=E3=82=A4?= =?UTF-8?q?=E3=82=A2=E3=83=AD=E3=82=B0=E3=81=AE=E9=81=B8=E6=8A=9E=E8=82=A2?= =?UTF-8?q?=E3=83=A9=E3=83=99=E3=83=AB=E3=83=BB=E8=AA=AC=E6=98=8E=E3=82=92?= =?UTF-8?q?=E6=8A=98=E8=BF=94=E3=81=97=E3=81=A6=E5=85=A8=E6=96=87=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit セッションの質問(AskUserQuestion)ダイアログで、選択肢のラベルと説明を 同じ行(row の Box)に並べていたため、Yoga が両方の を縮めて長い 文言が途中で切れて読めなくなっていた。 - `core/choice-lines.ts` を追加: 選択肢 1 件を表示幅ベースで物理行へ展開する 純関数(継続行は prefix の幅ぶん字下げしてラベルの桁に揃える)。 - `core/layout.ts` に `dialogContentWidth(columns)` を追加: 枠・パディングを 引いた本文幅(極端に狭い端末向けに下限あり)。 - `ui/choice-row.tsx` を追加(共有 presentational): 1 行 1 で描き、 説明は dim。質問ダイアログとモデル選択(/model)の両方で使う。 - ツール許可ダイアログの入力要約も 1 行切り詰めから折返し表示に変更。 - ダイアログの枠に flexShrink={0}: 縦が足りないときに縮んで潰れる役は 内部スクロールを持つログ/一覧に任せる。 --- .claude/rules/ink-components.md | 10 ++++- CLAUDE.md | 1 + docs/ARCHITECTURE.md | 3 +- src/core/choice-lines.spec.ts | 73 +++++++++++++++++++++++++++++++ src/core/choice-lines.ts | 67 ++++++++++++++++++++++++++++ src/core/index.ts | 1 + src/core/layout.spec.ts | 20 +++++++++ src/core/layout.ts | 19 ++++++++ src/ui/choice-row.tsx | 36 +++++++++++++++ src/ui/index.ts | 1 + src/ui/model-select.spec.tsx | 15 +++++++ src/ui/model-select.tsx | 31 +++++++++---- src/ui/permission-dialog.spec.tsx | 67 ++++++++++++++++++++++++++++ src/ui/permission-dialog.tsx | 62 ++++++++++++++++---------- 14 files changed, 371 insertions(+), 35 deletions(-) create mode 100644 src/core/choice-lines.spec.ts create mode 100644 src/core/choice-lines.ts create mode 100644 src/ui/choice-row.tsx diff --git a/.claude/rules/ink-components.md b/.claude/rules/ink-components.md index 84eb745..bb61d5a 100644 --- a/.claude/rules/ink-components.md +++ b/.claude/rules/ink-components.md @@ -11,8 +11,16 @@ `useTextBufferRef()`、`/command` の解決・実行は `useCommandRunner(handlers, onError, unknownLabel)`、 マージ/破棄の確認→実行フローは `useLifecycleAction(manager, id, onDone?)`。`useInput` 本体はフックに移さず view に置いたまま、これらから state とハンドラを受け取る(1画面1 useInput は維持)。 -- **共有 presentational**: 角丸ダイアログ枠は ``、y/n 確認行は ``。両 view で使う。 +- **共有 presentational**: 角丸ダイアログ枠は ``、y/n 確認行は ``、 + 選択肢の 1 件(ラベル + 説明)は ``。両 view で使う。 色は必ず `theme.ts`(`theme`/`statusColor`/`logColor`)経由で引き、`.tsx` に生 ANSI 名(`color="red"` 等)を書かない。 +- **ラベルと説明を横に並べない**。同じ行(row の Box)に 2 つの `` を置くと Yoga が + **両方を縮める**ため、長いラベルも長い説明も途中で切れて読めなくなる(質問ダイアログで実際に + 起きた不具合)。折返し幅は端末桁から自前で出し(`dialogContentWidth(columns)`)、行への分解は + 純粋な `choiceLines()`(`core/choice-lines.ts`)に委譲して、`` で 1 行 1 `` として + 描く(ログの `logLines` と同じ考え方)。継続行は prefix の表示幅ぶん字下げしてラベルの桁に揃える。 + 溢れたときに縮む役は内部スクロールを持つ領域(ログ・一覧)なので、ダイアログ側の枠には + `flexShrink={0}` を付ける。 ## 入力ハンドリング diff --git a/CLAUDE.md b/CLAUDE.md index 9645322..8c131ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,7 @@ CI(`.github/workflows/ci.yml`)は `lint → typecheck → test → build`。 | 通知 | `core/notify.ts`(判定・純粋)/ `utils/notify.ts`(OS I/O) | | 学習データ利用の警告 | `core/privacy.ts`(判定・純粋)/ `utils/privacy.ts`(キャッシュ+非公開 API)/ `ui/banner.tsx` | | モデル選択 | `core/models.ts` / `utils/model-catalog.ts` / `ui/model-select.tsx` | +| 選択肢リストの表示(質問・モデル) | `core/choice-lines.ts`(折返し・純粋)/ `ui/choice-row.tsx`(1件の描画) | | アップデート通知・`/update` | `core/update.ts`(比較・判定・DI 境界)/ `utils/update.ts`(registry fetch・経路判定・`npm install`)/ `ui/update-dialog.tsx` | | 起動・副作用の配線 | `src/index.tsx`(直列の main)/ `src/bootstrap/*`(build-manager / restore-sessions / persist-controller / runtime) | | 共有 UI フック | `ui/hooks.ts`(`useSessions` / `useCommandRunner` / `useLifecycleAction` / `useTextBufferRef` / `useComposerSelection` …) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d3ea130..36d068f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -65,6 +65,7 @@ codiva/ │ │ ├── format.ts / math.ts / ansi.ts / errors.ts # 小さな純粋ヘルパ(formatDuration/clamp/…) │ │ ├── privacy.ts # 学習データ利用(grove)の判定(JSON→TrainingOptIn・純粋) │ │ ├── async-queue.ts / slug.ts / config.ts / cost.ts / notify.ts / persistence.ts / update.ts +│ │ ├── choice-lines.ts # 選択肢(ラベル + 説明)の折返し(純粋・表示幅ベース) │ │ ├── scroll.ts / text-buffer.ts / layout.ts / mouse.ts / key-sequence.ts / model.ts / models.ts / transcript.ts │ │ ├── *.spec.ts # 単体テストは実装の隣に co-located │ │ └── __fixtures__/ # サニタイズ済み実 SDK メッセージ(sdk-parse テスト用) @@ -76,7 +77,7 @@ codiva/ │ │ ├── session-detail.tsx # 詳細画面(ログ + 追加指示 + マージ/破棄。SDK セッションに直結) │ │ ├── prompt-input.tsx # 上下横罫線 + ❯ キャレットの入力欄(presentational) │ │ ├── repo-prompt-editor.tsx # /prompt のリポジトリ追加指示エディタ(モーダル・composer を置換) -│ │ ├── dialog-box.tsx / confirm-prompt.tsx # 共有 presentational(角丸枠・y/n 確認行) +│ │ ├── dialog-box.tsx / confirm-prompt.tsx / choice-row.tsx # 共有 presentational(角丸枠・y/n 確認行・選択肢1件) │ │ ├── update-dialog.tsx # /update の表示(presentational・useInput を持たない) │ │ ├── status-footer.tsx / permission-dialog.tsx / model-select.tsx / command-palette.tsx / progress-badge.tsx │ │ ├── hooks.ts # useSessions / useClock / useTextBufferRef / useCommandRunner / useLifecycleAction diff --git a/src/core/choice-lines.spec.ts b/src/core/choice-lines.spec.ts new file mode 100644 index 0000000..64dd284 --- /dev/null +++ b/src/core/choice-lines.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { CHOICE_DESCRIPTION_INDENT, choiceLines } from './choice-lines'; + +describe('choiceLines', () => { + it('keeps a short label and its description on separate lines', () => { + expect(choiceLines({ label: 'English', description: 'en' }, 40, '❯ ')).toEqual([ + { key: 'label:0', text: '❯ English', description: false }, + { key: 'desc:0', text: ` ${' '.repeat(CHOICE_DESCRIPTION_INDENT)}en`, description: true }, + ]); + }); + + it('wraps a long label instead of truncating it, aligning continuation rows', () => { + const label = 'Rewrite the persistence layer so restored sessions resume lazily'; + const lines = choiceLines({ label }, 24, '❯ '); + // 全文が残る(切り捨てられない)。折返しは端末と同じハードラップなので、 + // prefix / 字下げ(2 セル)を除いて連結すると元のラベルに戻る。 + expect(lines.map((l) => l.text.slice(2)).join('')).toBe(label); + // 1 行目だけ prefix、以降は同じ幅の字下げ。 + expect(lines[0]?.text.startsWith('❯ ')).toBe(true); + for (const line of lines.slice(1)) { + expect(line.text.startsWith(' ')).toBe(true); + } + for (const line of lines) { + expect(line.text.length).toBeLessThanOrEqual(24); + } + }); + + it('wraps a long description under the label with a deeper indent', () => { + const description = 'Uses the CLI default model and never passes --model to the SDK.'; + const lines = choiceLines({ label: 'Default', description }, 30, '❯ '); + const descLines = lines.filter((l) => l.description); + expect(descLines.length).toBeGreaterThan(1); + expect(descLines.map((l) => l.text.slice(2 + CHOICE_DESCRIPTION_INDENT)).join('')).toBe( + description, + ); + for (const line of descLines) { + expect(line.text.startsWith(' '.repeat(2 + CHOICE_DESCRIPTION_INDENT))).toBe(true); + } + }); + + it('measures width in display cells so Japanese wraps where the terminal does', () => { + // 全角 5 文字 = 10 セル。prefix 2 + 本文 4 セル → 1 行に全角 2 文字。 + const lines = choiceLines({ label: 'あいうえお' }, 6, '❯ '); + expect(lines.map((l) => l.text)).toEqual(['❯ あい', ' うえ', ' お']); + }); + + it('honors newlines inside a description', () => { + const lines = choiceLines({ label: 'x', description: 'first\nsecond' }, 40, '> '); + expect(lines.filter((l) => l.description).map((l) => l.text.trim())).toEqual([ + 'first', + 'second', + ]); + }); + + const emptyDescriptions: { name: string; description: string | undefined }[] = [ + { name: 'empty', description: '' }, + { name: 'blank', description: ' ' }, + { name: 'missing', description: undefined }, + ]; + it.each(emptyDescriptions)( + 'emits only the label line for a $name description', + ({ description }) => { + expect(choiceLines({ label: 'Yes', description }, 40, '❯ ')).toEqual([ + { key: 'label:0', text: '❯ Yes', description: false }, + ]); + }, + ); + + it('never returns a zero-width content column for absurd widths', () => { + const lines = choiceLines({ label: 'ab', description: 'cd' }, 1, '❯ '); + expect(lines.map((l) => l.text)).toEqual(['❯ a', ' b', ' c', ' d']); + }); +}); diff --git a/src/core/choice-lines.ts b/src/core/choice-lines.ts new file mode 100644 index 0000000..a86f8a5 --- /dev/null +++ b/src/core/choice-lines.ts @@ -0,0 +1,67 @@ +import stringWidth from 'string-width'; +import { wrapDisplayLines } from './scroll'; + +/** + * 選択リスト(AskUserQuestion の選択肢 / `/model` のモデル行)1 件のテキスト。 + * ラベルと、あれば補足の説明。どちらも長さの制限はない。 + */ +export interface Choice { + /** 選択肢の見出し(回答としてモデルへ返る文字列) */ + label: string; + /** 補足説明(任意)。ラベルより深く字下げして続けて描く。 */ + description?: string; +} + +/** 説明をラベルよりさらに字下げする量(セル)。ラベルと説明の境目を目で追うため。 */ +export const CHOICE_DESCRIPTION_INDENT = 2; + +/** {@link choiceLines} が返す 1 物理行。 */ +export interface ChoiceDisplayLine { + /** 安定した描画キー(`label:0` / `desc:1` …)。`DisplayLine` と同じ役割。 */ + key: string; + /** + * 描画する行の全文。1 行目は `prefix`(`❯ [x] ` 等)を含み、折返しの継続行は + * prefix と同じ表示幅のインデントを含む(= そのまま 1 行として出せる)。 + */ + text: string; + /** 説明の行(UI は dim で描く)。false ならラベルの行。 */ + description: boolean; +} + +/** + * 選択肢 1 件を、幅 `width` セルに収まる物理行へ展開する純関数。 + * + * ラベルと説明を**同じ行に並べない**のが要点。1 行に詰めると Yoga が両方の + * `` を縮めるため、長いラベル・長い説明のどちらも途中で切れて読めなくなる + * (実際に起きた不具合)。行に分けて折返せば全文が必ず表示される。 + * + * 幅は表示幅で数える(`wrapDisplayLines`)。CJK・絵文字は 2 セルなので、日本語の + * 説明でも端末が折り返す位置と一致する。継続行は `prefix` の表示幅ぶん字下げして + * ラベルの桁に揃える(`logLines` と同じ考え方)。 + */ +export function choiceLines(choice: Choice, width: number, prefix: string): ChoiceDisplayLine[] { + const lead = stringWidth(prefix); + const indent = ' '.repeat(lead); + const out: ChoiceDisplayLine[] = []; + + const labelWidth = Math.max(1, width - lead); + const labelRows = wrapDisplayLines(choice.label, labelWidth); + for (let i = 0; i < labelRows.length; i += 1) { + out.push({ + key: `label:${i}`, + text: (i === 0 ? prefix : indent) + labelRows[i], + description: false, + }); + } + + const description = choice.description?.trim(); + if (description) { + const descIndent = indent + ' '.repeat(CHOICE_DESCRIPTION_INDENT); + const descWidth = Math.max(1, width - lead - CHOICE_DESCRIPTION_INDENT); + const rows = wrapDisplayLines(description, descWidth); + for (let i = 0; i < rows.length; i += 1) { + out.push({ key: `desc:${i}`, text: descIndent + rows[i], description: true }); + } + } + return out; +} diff --git a/src/core/index.ts b/src/core/index.ts index e166185..44d9540 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,6 +2,7 @@ export * from './account'; export * from './ansi'; export * from './async-queue'; export * from './banner-lines'; +export * from './choice-lines'; export * from './commands'; export * from './config'; export * from './cost'; diff --git a/src/core/layout.spec.ts b/src/core/layout.spec.ts index 92a1d57..33f7fbf 100644 --- a/src/core/layout.spec.ts +++ b/src/core/layout.spec.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { bannerGaugeWidth, DETAIL_CHROME_ROWS, + DIALOG_CHROME_COLUMNS, + dialogContentWidth, isFullscreenViewport, LIST_CHROME_ROWS, listView, @@ -36,6 +38,24 @@ describe('showsBranchColumn', () => { }); }); +describe('dialogContentWidth', () => { + it.each([ + [100, 94], + [80, 74], + [40, 34], + // 極端に狭い端末では下限で止める(1 セルずつ折返して縦に爆発させない)。 + [12, 10], + [4, 10], + [0, 10], + ])('columns=%d → %d', (columns, expected) => { + expect(dialogContentWidth(columns)).toBe(expected); + }); + + it('leaves room for the view padding, the border and the dialog padding', () => { + expect(dialogContentWidth(100)).toBe(100 - DIALOG_CHROME_COLUMNS); + }); +}); + describe('logViewportRows', () => { it('subtracts the fixed chrome from the terminal height', () => { expect(logViewportRows(30)).toBe(30 - DETAIL_CHROME_ROWS); diff --git a/src/core/layout.ts b/src/core/layout.ts index 89bb8b2..b065420 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -57,6 +57,25 @@ export function bannerGaugeWidth(columns: number): number { return BANNER_GAUGE_STEPS.find((width) => columns >= BANNER_USAGE_FIXED_CELLS + width) ?? 0; } +/** + * ダイアログ(`ui/dialog-box.tsx` と同じ枠を持つオーバーレイ)が本文に使えない横幅: + * ビューの padding 1×2 + 枠線 1×2 + ダイアログの paddingX 1×2 = 6 セル。 + */ +export const DIALOG_CHROME_COLUMNS = 6; + +/** + * 端末桁数からダイアログ本文に使える表示幅を求める純関数。選択肢のラベル・説明を + * **折返して全文出す**ための折返し幅として使う(`core/choice-lines.ts`)。 + * + * Yoga は溢れた子を縮めるため、幅を渡さずラベルと説明を 1 行に並べると両方が + * 途中で切れる。折返し幅を端末から自前で出しておけば、枠の内側に収まる行だけを + * 描くことになり切り捨てが起きない。下限を持たせているのは極端に狭い端末で + * 1 セルずつ折り返して縦に爆発させないため(そこまで狭ければ多少の溢れは許容する)。 + */ +export function dialogContentWidth(columns: number): number { + return Math.max(10, columns - DIALOG_CHROME_COLUMNS); +} + /** * 詳細ビューでログ以外に消費される固定の縦幅の見積り: 上下パディング 2 + * コンポーザ上の余白 1 + 入力欄 3(上下ボーダー付き) + フッタ 1 + diff --git a/src/ui/choice-row.tsx b/src/ui/choice-row.tsx new file mode 100644 index 0000000..8aa9949 --- /dev/null +++ b/src/ui/choice-row.tsx @@ -0,0 +1,36 @@ +import { Box, Text } from 'ink'; +import type { FC } from 'react'; +import { choiceLines } from '@/core'; +import { theme } from './theme'; + +/** + * 選択リスト(AskUserQuestion の選択肢 / `/model` のモデル行)の 1 件。 + * ラベルを 1 行目に、説明をその下に**折返して全文**描く。 + * + * ラベルと説明を横に並べない(= 同じ Box に 2 つの `` を置かない)のが要点。 + * 横に並べると Yoga が両方を縮めるため、長いラベルも長い説明も途中で切れて読めなく + * なる。折返し位置の計算は純粋な `choiceLines` に委譲し、ここは色分けだけを担う。 + */ +export const ChoiceRow: FC<{ + /** 1 行目の行頭(`❯ `, `❯ [x] ` 等)。継続行はこの表示幅ぶん字下げされる。 */ + prefix: string; + label: string; + description?: string; + /** カーソル行(アクセント色で描く) */ + active?: boolean; + /** 折返し幅(セル)。`dialogContentWidth(columns)` を渡す。 */ + width: number; +}> = ({ prefix, label, description, active = false, width }) => ( + + {choiceLines({ label, description }, width, prefix).map((line) => ( + + {/* Ink の measureText('') は高さ 0 を返すので、空行は空白 1 つで高さを確保する。 */} + {line.text.length > 0 ? line.text : ' '} + + ))} + +); diff --git a/src/ui/index.ts b/src/ui/index.ts index fb1492a..e522f90 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,4 +1,5 @@ export * from './banner'; +export * from './choice-row'; export * from './command-palette'; export * from './confirm-prompt'; export * from './dialog-box'; diff --git a/src/ui/model-select.spec.tsx b/src/ui/model-select.spec.tsx index 994ea11..6c39ad4 100644 --- a/src/ui/model-select.spec.tsx +++ b/src/ui/model-select.spec.tsx @@ -49,6 +49,21 @@ describe('ModelSelect', () => { expect(lastFrame() ?? '').toContain('Best for everyday, complex tasks'); }); + // 回帰: 説明をラベルの右に並べていたときは Yoga が両方を縮め、長い説明が + // 途中で切れていた。折返して全文出す(`ui/choice-row.tsx`)。 + it('wraps a long description instead of truncating it', () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame.split('Y').length - 1).toBe(260); + }); + it('shows only models the catalog offers (no hardcoded rows)', () => { const { lastFrame } = render( void; }> = ({ current, models, onSelect, onCancel }) => { const m = useMessages(); + const { columns } = useWindowSize(); + // モデル名・説明の折返し幅(枠の内側の本文幅)。横に並べると Yoga が両方を縮めて + // どちらも切れるので、行に分けて折返す(`ui/choice-row.tsx`)。 + const width = dialogContentWidth(columns); // The cursor is *derived* from the catalog until the user actually moves it. // A `useState` initializer would only run at mount, and the dialog can open // while the catalog is still loading (`models === undefined`) — the cursor @@ -72,7 +78,13 @@ export const ModelSelect: FC<{ }); return ( - + {m.model.title} @@ -88,13 +100,14 @@ export const ModelSelect: FC<{ const label = choice.value === DEFAULT_MODEL_VALUE ? m.model.defaultRow : choice.displayName; return ( - - - {active ? glyph.caret : ' '} {label} - {isCurrentModel(choice, current) ? ' ✔' : ''} - - {choice.description ? — {choice.description} : null} - + ); }) )} diff --git a/src/ui/permission-dialog.spec.tsx b/src/ui/permission-dialog.spec.tsx index b63a17d..086c947 100644 --- a/src/ui/permission-dialog.spec.tsx +++ b/src/ui/permission-dialog.spec.tsx @@ -26,6 +26,39 @@ function question(multiSelect = false): PermissionRequest { }; } +/** + * 端末幅(ink-testing-library は 100 桁)より長いラベル・説明を持つ質問。 + * 文字を `X` / `Y` で埋めるので、フレーム内の出現数で「1 文字も欠けていない」ことを + * 数えられる(折返し位置に依存しない検証)。 + */ +function longQuestion(labelCells: number, descriptionCells: number): PermissionRequest { + return { + id: 'q1', + toolName: 'AskUserQuestion', + input: {}, + kind: 'question', + questions: [ + { + question: 'Which one?', + header: 'Long', + multiSelect: false, + options: [ + { label: 'X'.repeat(labelCells), description: 'Y'.repeat(descriptionCells) }, + { label: 'short', description: 'also short' }, + ], + }, + ], + }; +} + +const countOf = (frame: string, char: string) => frame.split(char).length - 1; + +// SGR(色)エスケープを落とす。正規表現に生の制御文字を書かない(biome)ため +// `tests/helpers.ts` と同じく charCode から組む。 +const SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); +/** フレームの 1 行から色と枠線を落として「枠の中身」だけにする。 */ +const inner = (line: string) => line.replace(SGR, '').replace(/│/g, '').trimEnd(); + describe('PermissionDialog — question', () => { it('renders the question and options', () => { const { lastFrame } = render( @@ -36,6 +69,40 @@ describe('PermissionDialog — question', () => { expect(lastFrame()).toContain('Japanese'); }); + // 回帰: ラベルと説明を横に並べていたときは Yoga が両方を縮め、長い文言が + // 途中で切れて読めなくなっていた(実機で報告された不具合)。折返して全文出す。 + it('wraps long labels and descriptions instead of truncating them', () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(countOf(frame, 'X')).toBe(150); + expect(countOf(frame, 'Y')).toBe(260); + // 折返した行は枠の内側に収まる(端末幅 100 桁を超える行を作らない)。 + for (const line of frame.split('\n')) { + expect(line.replace(SGR, '').length).toBeLessThanOrEqual(100); + } + }); + + it('keeps the description on its own line under the label', () => { + const { lastFrame } = render( + , + ); + // ANSI と枠線を落として「枠の中身」だけを見る。 + const lines = (lastFrame() ?? '').split('\n').map(inner); + const label = lines.findIndex((l) => l.includes('English')); + expect(label).toBeGreaterThan(0); + // ラベル行に説明は混ざらず、直後の行に字下げして出る。 + expect(lines[label]?.trim()).toBe('❯ English'); + expect(lines[label + 1]?.trim()).toBe('en'); + expect(lines[label + 1]?.startsWith(' ')).toBe(true); + }); + it('selects the highlighted option on Enter and answers by question text', async () => { const onAnswer = vi.fn(); const { stdin } = render( diff --git a/src/ui/permission-dialog.tsx b/src/ui/permission-dialog.tsx index 6b9c028..9617eef 100644 --- a/src/ui/permission-dialog.tsx +++ b/src/ui/permission-dialog.tsx @@ -1,6 +1,7 @@ import { Box, Text, useInput, useWindowSize } from 'ink'; import { type FC, useState } from 'react'; -import { emptyBuffer, type PermissionRequest } from '@/core'; +import { choiceLines, dialogContentWidth, emptyBuffer, type PermissionRequest } from '@/core'; +import { ChoiceRow } from './choice-row'; import { useTextBufferRef } from './hooks'; import { useMessages } from './i18n-context'; import { editText, normalizeChord } from './input'; @@ -32,6 +33,7 @@ const ToolDialog: FC<{ onDeny: (message: string) => void; }> = ({ request, onAllow, onDeny }) => { const m = useMessages(); + const { columns } = useWindowSize(); useInput((rawInput, rawKey) => { // 一覧/詳細ビューと同じく chord を復号する。modifyOtherKeys / CSI-u を送る端末 // (Ghostty など)では y/n も生のエスケープ列で届き、素の比較が外れるため。 @@ -43,6 +45,8 @@ const ToolDialog: FC<{ } }); + // ツール入力の要約。何を許可するのか(実行されるコマンド等)は判断材料なので、 + // 1 行に切り詰めず本文幅で折返して出す(先頭 200 文字までなので数行で収まる)。 const summary = JSON.stringify(request.input).slice(0, 200); return ( {m.permission.toolTitle(request.toolName)} - - {summary} - + {/* prefix 無しの 1 件として `choiceLines` で折返す(表示幅ベースの折返しと + 安定キーをそのまま使う。選択肢と同じ経路にして挙動を揃える)。 */} + {choiceLines({ label: summary }, dialogContentWidth(columns), '').map((line) => ( + + {line.text} + + ))} y: {m.permission.allow} ・ n:{' '} {m.permission.deny} @@ -200,8 +209,11 @@ const QuestionDialog: FC<{ // 複数選択時はチェックボックス幅("[x] ")ぶん、特別項目(自分で入力する/相談する)を // 字下げして実選択肢と桁を揃える。 const pad = current.multiSelect ? ' ' : ''; + // ラベル・説明の折返し幅(枠とパディングを引いた本文幅)。ラベルと説明を横に並べず + // ここで折返すことで、長い文言でも切り捨てずに全文を出す。 + const width = dialogContentWidth(columns); // 区切り線幅(枠内に収まる範囲でほどほどに)。 - const dividerWidth = Math.max(1, Math.min(40, columns - 4)); + const dividerWidth = Math.min(40, width); return ( {m.permission.questionTitle(qIndex + 1, questions.length, current.header)} @@ -220,22 +233,23 @@ const QuestionDialog: FC<{ // 複数選択: `❯ [x] ラベル`(ポインタ+チェックボックス)。単一選択: `❯ ラベル`。 const box = current.multiSelect ? `${checked ? '[x]' : '[ ]'} ` : ''; return ( - - - {marker(i)} {box} - {opt.label} - - {opt.description ? — {opt.description} : null} - + ); })} {/* 「自分で入力する」— 実選択肢の直後(メインブロックの一部)。 */} - - - {marker(typeIndex)} {pad} - {m.permission.typeSomething} - - + {mode === 'typing' ? ( @@ -247,12 +261,12 @@ const QuestionDialog: FC<{ {/* 区切り線 + 「これについて相談する」— 質問をスキップして会話へ戻る導線。 */} {'─'.repeat(dividerWidth)} - - - {marker(chatIndex)} {pad} - {m.permission.chatAboutThis} - - +