diff --git a/src/core/i18n.ts b/src/core/i18n.ts
index 586dc41..231cb82 100644
--- a/src/core/i18n.ts
+++ b/src/core/i18n.ts
@@ -86,6 +86,16 @@ export interface Messages {
deny: string;
questionTitle: (index: number, total: number, header: string) => string;
questionHelp: (multiSelect: boolean) => string;
+ /** 「自分で入力する」選択肢(自由記述へ切り替える。Claude Code の Type something.) */
+ typeSomething: string;
+ /** 自由記述モードの入力欄プレースホルダ */
+ typePlaceholder: string;
+ /** 自由記述モードの操作ヒント */
+ typingHelp: string;
+ /** 質問をスキップして会話に戻る選択肢(Chat about this) */
+ chatAboutThis: string;
+ /** 「相談する」を選んだときにツールへ返す拒否理由(モデルに伝わる) */
+ chatMessage: string;
};
/** モデル選択ダイアログ(model-select.tsx。/model コマンドで開く) */
model: {
@@ -203,6 +213,11 @@ const ja: Messages = {
questionTitle: (index, total, header) => `質問 (${index}/${total}) ${header}`,
questionHelp: (multiSelect) =>
`↑↓: 選択 ・ ${multiSelect ? 'Space: トグル ・ ' : ''}Enter: 決定`,
+ typeSomething: '自分で入力する',
+ typePlaceholder: '回答を入力…',
+ typingHelp: 'Enter: 送信 ・ 空欄で Backspace: 選択に戻る',
+ chatAboutThis: 'これについて相談する',
+ chatMessage: 'ユーザーは選択肢を選ばず、この件について会話で相談することを選びました。',
},
model: {
title: 'モデルを選択',
@@ -311,6 +326,11 @@ const en: Messages = {
questionTitle: (index, total, header) => `Question (${index}/${total}) ${header}`,
questionHelp: (multiSelect) =>
`↑↓: select · ${multiSelect ? 'Space: toggle · ' : ''}Enter: confirm`,
+ typeSomething: 'Type something.',
+ typePlaceholder: 'Type your answer…',
+ typingHelp: 'Enter: submit · Backspace on empty: back to choices',
+ chatAboutThis: 'Chat about this',
+ chatMessage: 'The user chose to chat about this instead of picking an option.',
},
model: {
title: 'Select model',
diff --git a/src/core/sdk-parse.spec.ts b/src/core/sdk-parse.spec.ts
index 83ba92c..319f7bc 100644
--- a/src/core/sdk-parse.spec.ts
+++ b/src/core/sdk-parse.spec.ts
@@ -147,6 +147,23 @@ describe('applySdkMessage interaction with pending control state', () => {
expect(state.pendingPermission?.kind).toBe('question');
});
+ it('keeps awaiting_input when a system/init arrives while a question is pending', () => {
+ // Defensive: a (re)started query emits system/init → running. It must not
+ // downgrade a session that is blocked on a pending question back to Running.
+ const req: PermissionRequest = {
+ id: 'q1',
+ toolName: 'AskUserQuestion',
+ input: {},
+ kind: 'question',
+ questions: [{ question: 'Which one?', header: 'x', multiSelect: false, options: [] }],
+ };
+ let state = reduce(initialState(BASE), { kind: 'permission_request', request: req, at: 2000 });
+ expect(state.status).toBe('awaiting_input');
+ state = sdk(state, { type: 'system', subtype: 'init', session_id: 'abc' }, 2001);
+ expect(state.status).toBe('awaiting_input');
+ expect(state.pendingPermission?.kind).toBe('question');
+ });
+
it('keeps awaiting_permission when a stream delta arrives while a tool prompt is pending', () => {
const req: PermissionRequest = { id: 'p1', toolName: 'Bash', input: {}, kind: 'tool' };
let state = reduce(initialState(BASE), { kind: 'permission_request', request: req, at: 2000 });
diff --git a/src/core/sdk-parse.ts b/src/core/sdk-parse.ts
index 1c2d633..edbca52 100644
--- a/src/core/sdk-parse.ts
+++ b/src/core/sdk-parse.ts
@@ -241,7 +241,15 @@ function reduceSdk(
const sid = typeof message.session_id === 'string' ? message.session_id : state.sdkSessionId;
// init carries the *resolved* model even when config left it unset.
const model = typeof message.model === 'string' ? message.model : state.model;
- return { ...state, status: 'running', sdkSessionId: sid ?? state.sdkSessionId, model };
+ return {
+ ...state,
+ // pendingPermission がある間は awaiting_* を維持する(#37 と同じ不変条件)。
+ // 通常の初回 init は pending 無し(creating → running)で通り、保留中に
+ // 別の init が来ても質問ダイアログの裏で "Running" に戻さない。
+ status: state.pendingPermission ? state.status : 'running',
+ sdkSessionId: sid ?? state.sdkSessionId,
+ model,
+ };
}
return state;
}
diff --git a/src/core/status-reducer.spec.ts b/src/core/status-reducer.spec.ts
index 9365d32..6f69933 100644
--- a/src/core/status-reducer.spec.ts
+++ b/src/core/status-reducer.spec.ts
@@ -76,6 +76,31 @@ describe('control events', () => {
expect(state.messages.at(-1)?.text).toBe('do more');
});
+ it('user_input keeps a pending question in awaiting_input (does not flip to Running)', () => {
+ // Regression: sending a follow-up while an AskUserQuestion is pending must not
+ // downgrade the session to running — the dialog stays up, so the badge must
+ // remain "Question", not "Running" (pendingPermission is untouched).
+ const req: PermissionRequest = {
+ id: 'q1',
+ toolName: 'AskUserQuestion',
+ input: {},
+ kind: 'question',
+ questions: [{ question: 'Which one?', header: 'x', multiSelect: false, options: [] }],
+ };
+ let state = reduce(initialState(BASE), { kind: 'permission_request', request: req, at: 2000 });
+ state = reduce(state, { kind: 'user_input', text: 'also do X', at: 2500 });
+ expect(state.status).toBe('awaiting_input');
+ expect(state.pendingPermission?.kind).toBe('question');
+ });
+
+ it('user_input keeps a pending tool prompt in awaiting_permission', () => {
+ const req: PermissionRequest = { id: 'p1', toolName: 'Bash', input: {}, kind: 'tool' };
+ let state = reduce(initialState(BASE), { kind: 'permission_request', request: req, at: 2000 });
+ state = reduce(state, { kind: 'user_input', text: 'note', at: 2500 });
+ expect(state.status).toBe('awaiting_permission');
+ expect(state.pendingPermission?.toolName).toBe('Bash');
+ });
+
it('aborted → failed with an error', () => {
const state = reduce(initialState(BASE), { kind: 'aborted', error: 'killed', at: 7000 });
expect(state.status).toBe('failed');
diff --git a/src/core/status-reducer.ts b/src/core/status-reducer.ts
index 38f549d..5f73125 100644
--- a/src/core/status-reducer.ts
+++ b/src/core/status-reducer.ts
@@ -122,7 +122,11 @@ export function reduce(state: SessionState, event: CodivaEvent): SessionState {
const withLog = appendLog(state, 'user', event.text, event.at);
return {
...state,
- status: 'running',
+ // 保留中の決定(質問/許可待ち)があるセッションを running へ降格させない。
+ // 追加指示を送っても pendingPermission は解決されないため、ダイアログは
+ // 出たまま awaiting_* を維持する(#37 と同じ不変条件: pending がある間は
+ // 決して "Running" に戻さない)。解決は permission_resolved のみが行う。
+ status: state.pendingPermission ? state.status : 'running',
finishedAt: undefined,
streamingText: undefined,
messages: withLog.messages,
diff --git a/src/ui/permission-dialog.spec.tsx b/src/ui/permission-dialog.spec.tsx
index 1e93a22..cf48c94 100644
--- a/src/ui/permission-dialog.spec.tsx
+++ b/src/ui/permission-dialog.spec.tsx
@@ -48,6 +48,73 @@ describe('PermissionDialog — question', () => {
expect(onAnswer).toHaveBeenCalledWith({ 'Which language?': 'Japanese' });
});
+ it('always offers a free-text and a skip-to-chat option after the real ones', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ // ja catalog strings (the test env resolves to Japanese).
+ expect(lastFrame()).toContain('自分で入力する');
+ expect(lastFrame()).toContain('これについて相談する');
+ });
+
+ it('"Chat about this" skips the question and denies the tool (returns to chat)', async () => {
+ const onAnswer = vi.fn();
+ const onDeny = vi.fn();
+ const { stdin } = render(
+ ,
+ );
+ // English → Japanese → 自分で入力する → これについて相談する
+ stdin.write('\x1B[B');
+ await flush();
+ stdin.write('\x1B[B');
+ await flush();
+ stdin.write('\x1B[B');
+ await flush();
+ stdin.write('\r');
+ await flush();
+ expect(onDeny).toHaveBeenCalledWith(expect.stringContaining('相談'));
+ expect(onAnswer).not.toHaveBeenCalled();
+ });
+
+ it('"Type something." lets the user answer with free-form text', async () => {
+ const onAnswer = vi.fn();
+ const { stdin } = render(
+ ,
+ );
+ stdin.write('\x1B[B'); // Japanese
+ await flush();
+ stdin.write('\x1B[B'); // 自分で入力する
+ await flush();
+ stdin.write('\r'); // enter typing mode
+ await flush();
+ stdin.write('my own answer');
+ await flush();
+ stdin.write('\r'); // submit
+ await flush();
+ expect(onAnswer).toHaveBeenCalledWith({ 'Which language?': 'my own answer' });
+ });
+
+ it('returns from free-text back to the choices on Backspace when empty', async () => {
+ const onAnswer = vi.fn();
+ const { stdin } = render(
+ ,
+ );
+ stdin.write('\x1B[B'); // Japanese
+ await flush();
+ stdin.write('\x1B[B'); // 自分で入力する
+ await flush();
+ stdin.write('\r'); // enter typing mode
+ await flush();
+ stdin.write('\x7f'); // Backspace on empty buffer → back to choices
+ await flush();
+ // Back in select mode: up moves to Japanese and Enter picks it (not free-text).
+ stdin.write('\x1B[A'); // up → Japanese
+ await flush();
+ stdin.write('\r');
+ await flush();
+ expect(onAnswer).toHaveBeenCalledWith({ 'Which language?': 'Japanese' });
+ });
+
it('toggles options with space in multi-select mode', async () => {
const onAnswer = vi.fn();
const { stdin } = render(
diff --git a/src/ui/permission-dialog.tsx b/src/ui/permission-dialog.tsx
index 4351bc0..fb5d872 100644
--- a/src/ui/permission-dialog.tsx
+++ b/src/ui/permission-dialog.tsx
@@ -1,7 +1,10 @@
-import { Box, Text, useInput } from 'ink';
+import { Box, Text, useInput, useWindowSize } from 'ink';
import { type FC, useState } from 'react';
-import type { PermissionRequest } from '@/core';
+import { emptyBuffer, type PermissionRequest } from '@/core';
+import { useTextBufferRef } from './hooks';
import { useMessages } from './i18n-context';
+import { editText } from './input';
+import { PromptInput } from './prompt-input';
import { statusColor, theme } from './theme';
/**
@@ -18,7 +21,7 @@ export const PermissionDialog: FC<{
onDeny: (message: string) => void;
}> = ({ request, onAnswer, onAllow, onDeny }) => {
if (request.kind === 'question') {
- return ;
+ return ;
}
return ;
};
@@ -59,32 +62,92 @@ const ToolDialog: FC<{
);
};
+/**
+ * AskUserQuestion のダイアログ。実選択肢に加えて Claude Code に倣った 2 つの導線を
+ * 必ず末尾に足す:
+ * - 「自分で入力する」(Type something.) — 選択肢ではなく自由記述で答える。選ぶと
+ * typing モードへ入り、入力テキストがその質問の回答になる。
+ * - 「これについて相談する」(Chat about this) — 区切り線の下に置き、質問をスキップ
+ * してツールを拒否し、通常の会話へ戻す(`onDeny`)。
+ */
const QuestionDialog: FC<{
request: PermissionRequest;
onAnswer: (answers: Record) => void;
-}> = ({ request, onAnswer }) => {
+ onDeny: (message: string) => void;
+}> = ({ request, onAnswer, onDeny }) => {
const m = useMessages();
+ const { columns } = useWindowSize();
const questions = request.questions ?? [];
const [qIndex, setQIndex] = useState(0);
const [cursor, setCursor] = useState(0);
const [answers, setAnswers] = useState>({});
const [multi, setMulti] = useState>(new Set());
+ // 'select' = カーソルで選択肢を選ぶ / 'typing' = 「自分で入力する」で自由記述中。
+ const [mode, setMode] = useState<'select' | 'typing'>('select');
+ const { buffer, bufferRef, updateBuffer } = useTextBufferRef();
const current = questions[qIndex];
+ // 実選択肢の後ろに「自分で入力する」(typeIndex) と「これについて相談する」(chatIndex)
+ // を仮想的に並べる。カーソルは [0, chatIndex] を移動する。
+ const optionCount = current?.options.length ?? 0;
+ const typeIndex = optionCount;
+ const chatIndex = optionCount + 1;
+
+ // 質問への回答を確定し、次の質問へ進む(最後なら全回答を返す)。選択肢・自由記述で共通。
+ const submit = (chosen: string) => {
+ if (!current) {
+ return;
+ }
+ const nextAnswers = { ...answers, [current.question]: chosen };
+ if (qIndex < questions.length - 1) {
+ setAnswers(nextAnswers);
+ setQIndex(qIndex + 1);
+ setCursor(0);
+ setMulti(new Set());
+ setMode('select');
+ updateBuffer(emptyBuffer());
+ } else {
+ onAnswer(nextAnswers);
+ }
+ };
useInput((input, key) => {
if (!current) {
return;
}
+ // 自由記述モード: テキスト編集に専念(Enter で送信)。
+ // 「選択へ戻る」は空バッファでの Backspace で行う。Esc は背後の view
+ // (一覧/詳細)が先取りして戻る/フォーカス移動に使うため、ここでは使わない。
+ if (mode === 'typing') {
+ if ((key.backspace || key.delete) && bufferRef.current.value.length === 0) {
+ setMode('select');
+ return;
+ }
+ if (key.return) {
+ const text = bufferRef.current.value.trim();
+ if (text.length > 0) {
+ submit(text);
+ }
+ return;
+ }
+ const edit = editText(bufferRef.current, input, key, { arrows: true });
+ if (edit.changed) {
+ updateBuffer(edit.buffer);
+ }
+ return;
+ }
+
+ // 選択モード
if (key.upArrow) {
setCursor((c) => Math.max(0, c - 1));
return;
}
if (key.downArrow) {
- setCursor((c) => Math.min(current.options.length - 1, c + 1));
+ setCursor((c) => Math.min(chatIndex, c + 1));
return;
}
- if (input === ' ' && current.multiSelect) {
+ // Space は複数選択の実選択肢に対してのみトグル(特別項目には効かない)。
+ if (input === ' ' && current.multiSelect && cursor < optionCount) {
const label = current.options[cursor]?.label;
if (label) {
setMulti((prev) => {
@@ -100,18 +163,21 @@ const QuestionDialog: FC<{
return;
}
if (key.return) {
+ // 「これについて相談する」: 質問をスキップしてツールを拒否 → 会話へ戻す。
+ if (cursor === chatIndex) {
+ onDeny(m.permission.chatMessage);
+ return;
+ }
+ // 「自分で入力する」: 自由記述モードへ切り替える。
+ if (cursor === typeIndex) {
+ updateBuffer(emptyBuffer());
+ setMode('typing');
+ return;
+ }
const chosen = current.multiSelect
? [...multi].join(', ')
: (current.options[cursor]?.label ?? '');
- const nextAnswers = { ...answers, [current.question]: chosen };
- if (qIndex < questions.length - 1) {
- setAnswers(nextAnswers);
- setQIndex(qIndex + 1);
- setCursor(0);
- setMulti(new Set());
- } else {
- onAnswer(nextAnswers);
- }
+ submit(chosen);
}
});
@@ -119,6 +185,11 @@ const QuestionDialog: FC<{
return null;
}
+ // カーソル記号(typing 中はカーソル表示を出さない)。
+ const marker = (i: number) => (mode === 'select' && cursor === i ? '❯' : ' ');
+ // 区切り線幅(枠内に収まる範囲でほどほどに)。
+ const dividerWidth = Math.max(1, Math.min(40, columns - 4));
+
return (
{current.options.map((opt, i) => {
const checked = current.multiSelect && multi.has(opt.label);
- const marker = current.multiSelect ? (checked ? '[x]' : '[ ]') : i === cursor ? '❯' : ' ';
+ const mk = current.multiSelect ? (checked ? '[x]' : '[ ]') : marker(i);
return (
-
- {marker} {opt.label}
+
+ {mk} {opt.label}
{opt.description ? — {opt.description} : null}
);
})}
+ {/* 「自分で入力する」— 実選択肢の直後(メインブロックの一部)。 */}
+
+
+ {marker(typeIndex)} {m.permission.typeSomething}
+
+
+
+ {mode === 'typing' ? (
+
+
+
+ ) : null}
+
+ {/* 区切り線 + 「これについて相談する」— 質問をスキップして会話へ戻る導線。 */}
+
+ {'─'.repeat(dividerWidth)}
+
+
+ {marker(chatIndex)} {m.permission.chatAboutThis}
+
+
+
+
- {m.permission.questionHelp(current.multiSelect ?? false)}
+
+ {mode === 'typing'
+ ? m.permission.typingHelp
+ : m.permission.questionHelp(current.multiSelect ?? false)}
+
);