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)} + );