Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .claude/rules/ink-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@
`useTextBufferRef()`、`/command` の解決・実行は `useCommandRunner(handlers, onError, unknownLabel)`、
マージ/破棄の確認→実行フローは `useLifecycleAction(manager, id, onDone?)`。`useInput` 本体はフックに移さず
view に置いたまま、これらから state とハンドラを受け取る(1画面1 useInput は維持)。
- **共有 presentational**: 角丸ダイアログ枠は `<DialogBox>`、y/n 確認行は `<ConfirmPrompt>`。両 view で使う。
- **共有 presentational**: 角丸ダイアログ枠は `<DialogBox>`、y/n 確認行は `<ConfirmPrompt>`、
選択肢の 1 件(ラベル + 説明)は `<ChoiceRow>`。両 view で使う。
色は必ず `theme.ts`(`theme`/`statusColor`/`logColor`)経由で引き、`.tsx` に生 ANSI 名(`color="red"` 等)を書かない。
- **ラベルと説明を横に並べない**。同じ行(row の Box)に 2 つの `<Text>` を置くと Yoga が
**両方を縮める**ため、長いラベルも長い説明も途中で切れて読めなくなる(質問ダイアログで実際に
起きた不具合)。折返し幅は端末桁から自前で出し(`dialogContentWidth(columns)`)、行への分解は
純粋な `choiceLines()`(`core/choice-lines.ts`)に委譲して、`<ChoiceRow>` で 1 行 1 `<Text>` として
描く(ログの `logLines` と同じ考え方)。継続行は prefix の表示幅ぶん字下げしてラベルの桁に揃える。
溢れたときに縮む役は内部スクロールを持つ領域(ログ・一覧)なので、ダイアログ側の枠には
`flexShrink={0}` を付ける。

## 入力ハンドリング

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` …) |
Expand Down
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 テスト用)
Expand All @@ -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
Expand Down
73 changes: 73 additions & 0 deletions src/core/choice-lines.spec.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
67 changes: 67 additions & 0 deletions src/core/choice-lines.ts
Original file line number Diff line number Diff line change
@@ -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 が両方の
* `<Text>` を縮めるため、長いラベル・長い説明のどちらも途中で切れて読めなくなる
* (実際に起きた不具合)。行に分けて折返せば全文が必ず表示される。
*
* 幅は表示幅で数える(`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;
}
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
20 changes: 20 additions & 0 deletions src/core/layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import {
bannerGaugeWidth,
DETAIL_CHROME_ROWS,
DIALOG_CHROME_COLUMNS,
dialogContentWidth,
isFullscreenViewport,
LIST_CHROME_ROWS,
listView,
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions src/core/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 +
Expand Down
36 changes: 36 additions & 0 deletions src/ui/choice-row.tsx
Original file line number Diff line number Diff line change
@@ -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 つの `<Text>` を置かない)のが要点。
* 横に並べると 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 }) => (
<Box flexDirection="column" flexShrink={0}>
{choiceLines({ label, description }, width, prefix).map((line) => (
<Text
key={line.key}
color={!line.description && active ? theme.accent : undefined}
dimColor={line.description}
>
{/* Ink の measureText('') は高さ 0 を返すので、空行は空白 1 つで高さを確保する。 */}
{line.text.length > 0 ? line.text : ' '}
</Text>
))}
</Box>
);
1 change: 1 addition & 0 deletions src/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './banner';
export * from './choice-row';
export * from './command-palette';
export * from './confirm-prompt';
export * from './dialog-box';
Expand Down
15 changes: 15 additions & 0 deletions src/ui/model-select.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ModelSelect
current={undefined}
models={[{ value: 'sonnet', displayName: 'Sonnet', description: 'Y'.repeat(260) }]}
onSelect={noop}
onCancel={noop}
/>,
);
const frame = lastFrame() ?? '';
expect(frame.split('Y').length - 1).toBe(260);
});

it('shows only models the catalog offers (no hardcoded rows)', () => {
const { lastFrame } = render(
<ModelSelect
Expand Down
31 changes: 22 additions & 9 deletions src/ui/model-select.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Box, Text, useInput } from 'ink';
import { Box, Text, useInput, useWindowSize } from 'ink';
import { type FC, useState } from 'react';
import {
currentModelIndex,
DEFAULT_MODEL_VALUE,
dialogContentWidth,
isCurrentModel,
type ModelOption,
toConfigModel,
} from '@/core';
import { ChoiceRow } from './choice-row';
import { useMessages } from './i18n-context';
import { glyph, theme } from './theme';

Expand All @@ -33,6 +35,10 @@ export const ModelSelect: FC<{
onCancel: () => 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
Expand Down Expand Up @@ -72,7 +78,13 @@ export const ModelSelect: FC<{
});

return (
<Box flexDirection="column" borderStyle="round" borderColor={theme.accent} paddingX={1}>
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.accent}
paddingX={1}
flexShrink={0}
>
<Text color={theme.accent} bold>
{m.model.title}
</Text>
Expand All @@ -88,13 +100,14 @@ export const ModelSelect: FC<{
const label =
choice.value === DEFAULT_MODEL_VALUE ? m.model.defaultRow : choice.displayName;
return (
<Box key={choice.value}>
<Text color={active ? theme.accent : undefined}>
{active ? glyph.caret : ' '} {label}
{isCurrentModel(choice, current) ? ' ✔' : ''}
</Text>
{choice.description ? <Text dimColor> — {choice.description}</Text> : null}
</Box>
<ChoiceRow
key={choice.value}
prefix={`${active ? glyph.caret : ' '} `}
label={`${label}${isCurrentModel(choice, current) ? ' ✔' : ''}`}
description={choice.description}
active={active}
width={width}
/>
);
})
)}
Expand Down
Loading
Loading