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
20 changes: 20 additions & 0 deletions src/core/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: 'モデルを選択',
Expand Down Expand Up @@ -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',
Expand Down
17 changes: 17 additions & 0 deletions src/core/sdk-parse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
10 changes: 9 additions & 1 deletion src/core/sdk-parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
25 changes: 25 additions & 0 deletions src/core/status-reducer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 5 additions & 1 deletion src/core/status-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
67 changes: 67 additions & 0 deletions src/ui/permission-dialog.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PermissionDialog request={question()} onAnswer={noop} onAllow={noop} onDeny={noop} />,
);
// 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(
<PermissionDialog request={question()} onAnswer={onAnswer} onAllow={noop} onDeny={onDeny} />,
);
// 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(
<PermissionDialog request={question()} onAnswer={onAnswer} onAllow={noop} onDeny={noop} />,
);
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(
<PermissionDialog request={question()} onAnswer={onAnswer} onAllow={noop} onDeny={noop} />,
);
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(
Expand Down
Loading
Loading