From 5f23fd470f454417affaeb613e273a4c5c748b2b Mon Sep 17 00:00:00 2001 From: sathyapramod <8750601+sathyapramod@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:13:51 +0530 Subject: [PATCH 1/3] feat: implement interactive model picker for CLI chat - Updated the CLI chat command to allow model selection via an interactive picker when the --model option is omitted. - Added `selectModel` function to manage model selection and daemon state. - Introduced `promptModelPicker` for displaying available models and handling user input. - Created tests for model selection and input parsing to ensure functionality and error handling. - Updated documentation to reflect changes in command usage. --- packages/daemon/src/daemon/chat.ts | 48 +++++- packages/daemon/src/daemon/index.ts | 6 +- .../daemon/src/daemon/model-picker.test.ts | 128 ++++++++++++++++ packages/daemon/src/daemon/model-picker.ts | 38 +++++ .../daemon/src/daemon/select-model.test.ts | 140 ++++++++++++++++++ 5 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 packages/daemon/src/daemon/model-picker.test.ts create mode 100644 packages/daemon/src/daemon/model-picker.ts create mode 100644 packages/daemon/src/daemon/select-model.test.ts diff --git a/packages/daemon/src/daemon/chat.ts b/packages/daemon/src/daemon/chat.ts index 4c5776b..3d5356e 100644 --- a/packages/daemon/src/daemon/chat.ts +++ b/packages/daemon/src/daemon/chat.ts @@ -1,9 +1,11 @@ /** - * Interactive CLI chat — `aby chat -m ` + * Interactive CLI chat — `aby chat` * * Starts the daemon in-process if not already running, then enters a * readline-based REPL that streams responses to stdout. * + * When --model is omitted, an interactive model picker is displayed. + * * All tool calls require approval by default (secure-by-default, DR-019). * Users can choose "allow always" to session-approve a tool for the * remainder of the chat session without persisting to config. @@ -289,3 +291,47 @@ function promptApproval(rl: readline.Interface): Promise<'allow' | 'allow-always rl.once('line', handler); }); } + +// ── Model picker ────────────────────────────────────────────────────── + +export interface PickerModel { + id: string; + name: string; + provider: string; +} + +export function parseModelPickerInput(raw: string, count: number): number | null { + const trimmed = raw.trim(); + if (trimmed === '') return 1; + const num = Number(trimmed); + if (!Number.isInteger(num) || num < 1 || num > count) return null; + return num; +} + +export function promptModelPicker( + models: PickerModel[], + rl: readline.Interface, +): Promise { + return new Promise((resolve) => { + process.stderr.write(`\n${BOLD}Available models:${RESET}\n`); + for (let i = 0; i < models.length; i++) { + const marker = i === 0 ? ` ${DIM}(default)${RESET}` : ''; + process.stderr.write(` ${CYAN}[${i + 1}]${RESET} ${models[i].id}${marker}\n`); + } + process.stderr.write(`\n${YELLOW}Select a model [1]:${RESET} `); + + const handler = (line: string) => { + const choice = parseModelPickerInput(line, models.length); + if (choice !== null) { + resolve(models[choice - 1].id); + } else { + process.stderr.write(`${RED}Invalid choice. Enter 1–${models.length}.${RESET}\n`); + process.stderr.write(`${YELLOW}Select a model [1]:${RESET} `); + rl.once('line', handler); + } + }; + + rl.once('line', handler); + rl.once('close', () => resolve(null)); + }); +} diff --git a/packages/daemon/src/daemon/index.ts b/packages/daemon/src/daemon/index.ts index e90ef61..0856166 100644 --- a/packages/daemon/src/daemon/index.ts +++ b/packages/daemon/src/daemon/index.ts @@ -285,8 +285,10 @@ program .option('--json', 'Output raw JSON chunks (for piping)') .action(async (options) => { if (!options.model && !options.session) { - console.error('Either --model or --session is required'); - process.exit(1); + const { selectModel } = await import('./model-picker.js'); + const picked = await selectModel(); + if (!picked) process.exit(1); + options.model = picked; } const { runInteractiveChat } = await import('./chat.js'); await runInteractiveChat(options); diff --git a/packages/daemon/src/daemon/model-picker.test.ts b/packages/daemon/src/daemon/model-picker.test.ts new file mode 100644 index 0000000..1ea0446 --- /dev/null +++ b/packages/daemon/src/daemon/model-picker.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { + parseModelPickerInput, + promptModelPicker, + type PickerModel, +} from './chat.js'; + +// ── parseModelPickerInput ───────────────────────────────────────────── + +describe('parseModelPickerInput', () => { + it('empty input selects default (1)', () => { + expect(parseModelPickerInput('', 3)).toBe(1); + expect(parseModelPickerInput(' ', 3)).toBe(1); + }); + + it('valid number in range', () => { + expect(parseModelPickerInput('2', 3)).toBe(2); + expect(parseModelPickerInput(' 3 ', 3)).toBe(3); + }); + + it('out of range returns null', () => { + expect(parseModelPickerInput('0', 3)).toBeNull(); + expect(parseModelPickerInput('4', 3)).toBeNull(); + expect(parseModelPickerInput('-1', 3)).toBeNull(); + }); + + it('non-numeric returns null', () => { + expect(parseModelPickerInput('abc', 3)).toBeNull(); + expect(parseModelPickerInput('1.5', 3)).toBeNull(); + }); + + it('single model — only 1 is valid', () => { + expect(parseModelPickerInput('1', 1)).toBe(1); + expect(parseModelPickerInput('', 1)).toBe(1); + expect(parseModelPickerInput('2', 1)).toBeNull(); + }); +}); + +// ── promptModelPicker ───────────────────────────────────────────────── + +function makeModels(...ids: string[]): PickerModel[] { + return ids.map((id) => { + const [provider, ...rest] = id.split('/'); + return { id, name: rest.join('/'), provider }; + }); +} + +function fakeRl(): EventEmitter & { once: ReturnType } { + return new EventEmitter() as EventEmitter & { once: ReturnType }; +} + +describe('promptModelPicker', () => { + let stderrSpy: ReturnType; + + beforeEach(() => { + stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + }); + + it('selects model by number', async () => { + const rl = fakeRl(); + const models = makeModels('openai/gpt-4o', 'anthropic/claude-sonnet'); + const promise = promptModelPicker(models, rl as never); + + rl.emit('line', '2'); + const result = await promise; + expect(result).toBe('anthropic/claude-sonnet'); + }); + + it('selects default on empty input', async () => { + const rl = fakeRl(); + const models = makeModels('openai/gpt-4o', 'anthropic/claude-sonnet'); + const promise = promptModelPicker(models, rl as never); + + rl.emit('line', ''); + const result = await promise; + expect(result).toBe('openai/gpt-4o'); + }); + + it('re-prompts on invalid input then accepts valid', async () => { + const rl = fakeRl(); + const models = makeModels('openai/gpt-4o', 'anthropic/claude-sonnet'); + const promise = promptModelPicker(models, rl as never); + + rl.emit('line', '9'); + // After invalid input, it re-registers a handler + await new Promise((r) => setTimeout(r, 10)); + rl.emit('line', '1'); + const result = await promise; + expect(result).toBe('openai/gpt-4o'); + }); + + it('returns null on close (cancellation)', async () => { + const rl = fakeRl(); + const models = makeModels('openai/gpt-4o'); + const promise = promptModelPicker(models, rl as never); + + rl.emit('close'); + const result = await promise; + expect(result).toBeNull(); + }); + + it('displays all model IDs in picker output', async () => { + const rl = fakeRl(); + const models = makeModels('openai/gpt-4o', 'anthropic/claude-sonnet', 'ollama/llama3'); + const promise = promptModelPicker(models, rl as never); + + rl.emit('line', ''); + await promise; + + const output = stderrSpy.mock.calls.map((c) => c[0]).join(''); + expect(output).toContain('openai/gpt-4o'); + expect(output).toContain('anthropic/claude-sonnet'); + expect(output).toContain('ollama/llama3'); + expect(output).toContain('(default)'); + }); + + it('single model — Enter selects it', async () => { + const rl = fakeRl(); + const models = makeModels('mock/echo'); + const promise = promptModelPicker(models, rl as never); + + rl.emit('line', ''); + const result = await promise; + expect(result).toBe('mock/echo'); + }); +}); + diff --git a/packages/daemon/src/daemon/model-picker.ts b/packages/daemon/src/daemon/model-picker.ts new file mode 100644 index 0000000..7634a9c --- /dev/null +++ b/packages/daemon/src/daemon/model-picker.ts @@ -0,0 +1,38 @@ +import * as readline from 'node:readline'; +import { startDaemon } from './daemon.js'; +import { isDaemonRunningSync } from './transport.js'; +import { DaemonState } from './state.js'; +import { promptModelPicker } from './chat.js'; + +const RESET = '\x1b[0m'; +const DIM = '\x1b[2m'; +const YELLOW = '\x1b[33m'; + +export async function selectModel(): Promise { + let state: DaemonState; + if (isDaemonRunningSync()) { + state = new DaemonState(); + } else { + state = await startDaemon({ keepAlive: false }); + } + + const models = await state.listModels(); + if (models.length === 0) { + console.error( + `${YELLOW}No models configured.${RESET} Run: ${DIM}aby start${RESET} -> open the web UI to add a provider.`, + ); + return null; + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + terminal: process.stdin.isTTY !== undefined, + }); + + try { + return await promptModelPicker(models, rl); + } finally { + rl.close(); + } +} diff --git a/packages/daemon/src/daemon/select-model.test.ts b/packages/daemon/src/daemon/select-model.test.ts new file mode 100644 index 0000000..eba02e5 --- /dev/null +++ b/packages/daemon/src/daemon/select-model.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('./transport.js', () => ({ + isDaemonRunningSync: vi.fn(() => true), +})); + +vi.mock('./state.js', () => ({ + DaemonState: vi.fn(), +})); + +vi.mock('./daemon.js', () => ({ + startDaemon: vi.fn(), +})); + +vi.mock('./chat.js', () => ({ + promptModelPicker: vi.fn(), +})); + +// ── selectModel ────────────────────────────────────────────────────── + +describe('selectModel', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetAllMocks(); + }); + + it('returns null and prints guidance when no models are configured', async () => { + const { DaemonState } = await import('./state.js'); + vi.mocked(DaemonState).mockImplementation(() => ({ + listModels: vi.fn().mockResolvedValue([]), + }) as any); + + const { selectModel } = await import('./model-picker.js'); + const result = await selectModel(); + + expect(result).toBeNull(); + const output = consoleSpy.mock.calls.map((c) => c[0]).join(''); + expect(output).toContain('No models configured'); + expect(output).toContain('aby start'); + expect(output).toContain('web UI'); + }); + + it('returns selected model via picker when models exist', async () => { + const models = [ + { id: 'openai/gpt-4o', name: 'gpt-4o', provider: 'openai' }, + { id: 'anthropic/claude-sonnet', name: 'claude-sonnet', provider: 'anthropic' }, + ]; + const { DaemonState } = await import('./state.js'); + vi.mocked(DaemonState).mockImplementation(() => ({ + listModels: vi.fn().mockResolvedValue(models), + }) as any); + + const { promptModelPicker } = await import('./chat.js'); + vi.mocked(promptModelPicker).mockResolvedValue('anthropic/claude-sonnet'); + + const { selectModel } = await import('./model-picker.js'); + const result = await selectModel(); + + expect(result).toBe('anthropic/claude-sonnet'); + expect(promptModelPicker).toHaveBeenCalledWith(models, expect.anything()); + }); + + it('returns null when user cancels the picker (Ctrl+C)', async () => { + const models = [ + { id: 'openai/gpt-4o', name: 'gpt-4o', provider: 'openai' }, + ]; + const { DaemonState } = await import('./state.js'); + vi.mocked(DaemonState).mockImplementation(() => ({ + listModels: vi.fn().mockResolvedValue(models), + }) as any); + + const { promptModelPicker } = await import('./chat.js'); + vi.mocked(promptModelPicker).mockResolvedValue(null); + + const { selectModel } = await import('./model-picker.js'); + const result = await selectModel(); + + expect(result).toBeNull(); + }); + + it('starts daemon if not already running', async () => { + const { isDaemonRunningSync } = await import('./transport.js'); + vi.mocked(isDaemonRunningSync).mockReturnValue(false); + + const { startDaemon } = await import('./daemon.js'); + const fakeState = { listModels: vi.fn().mockResolvedValue([]) }; + vi.mocked(startDaemon).mockResolvedValue(fakeState as any); + + const { selectModel } = await import('./model-picker.js'); + await selectModel(); + + expect(startDaemon).toHaveBeenCalledWith({ keepAlive: false }); + }); + + it('uses existing DaemonState when daemon is already running', async () => { + const { isDaemonRunningSync } = await import('./transport.js'); + vi.mocked(isDaemonRunningSync).mockReturnValue(true); + + const { DaemonState } = await import('./state.js'); + vi.mocked(DaemonState).mockImplementation(() => ({ + listModels: vi.fn().mockResolvedValue([]), + }) as any); + + const { startDaemon } = await import('./daemon.js'); + + const { selectModel } = await import('./model-picker.js'); + await selectModel(); + + expect(DaemonState).toHaveBeenCalled(); + expect(startDaemon).not.toHaveBeenCalled(); + }); +}); + +// ── --model flag bypass (chat action guard) ────────────────────────── + +describe('chat command --model bypass', () => { + it('skips picker when --model is provided', () => { + const options = { model: 'openai/gpt-4o', session: undefined }; + const shouldPickModel = !options.model && !options.session; + expect(shouldPickModel).toBe(false); + }); + + it('skips picker when --session is provided', () => { + const options = { model: undefined, session: 'abc123' }; + const shouldPickModel = !options.model && !options.session; + expect(shouldPickModel).toBe(false); + }); + + it('triggers picker when neither --model nor --session is provided', () => { + const options = { model: undefined, session: undefined }; + const shouldPickModel = !options.model && !options.session; + expect(shouldPickModel).toBe(true); + }); +}); From 3d3ca9a92e7924ae1c53a1dd56ad8cc3607790db Mon Sep 17 00:00:00 2001 From: sathyapramod <8750601+sathyapramod@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:34:17 +0530 Subject: [PATCH 2/3] refactor: update mocked implementation of DaemonState in tests - Changed the mock implementation of DaemonState in select-model tests to use a function instead of an object for better clarity and consistency. - Ensured that the listModels method returns a resolved promise with an empty array or predefined models as needed for various test cases. --- .../daemon/src/daemon/select-model.test.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/daemon/src/daemon/select-model.test.ts b/packages/daemon/src/daemon/select-model.test.ts index eba02e5..f0191c8 100644 --- a/packages/daemon/src/daemon/select-model.test.ts +++ b/packages/daemon/src/daemon/select-model.test.ts @@ -32,9 +32,9 @@ describe('selectModel', () => { it('returns null and prints guidance when no models are configured', async () => { const { DaemonState } = await import('./state.js'); - vi.mocked(DaemonState).mockImplementation(() => ({ - listModels: vi.fn().mockResolvedValue([]), - }) as any); + vi.mocked(DaemonState).mockImplementation(function () { + return { listModels: vi.fn().mockResolvedValue([]) } as any; + }); const { selectModel } = await import('./model-picker.js'); const result = await selectModel(); @@ -52,9 +52,9 @@ describe('selectModel', () => { { id: 'anthropic/claude-sonnet', name: 'claude-sonnet', provider: 'anthropic' }, ]; const { DaemonState } = await import('./state.js'); - vi.mocked(DaemonState).mockImplementation(() => ({ - listModels: vi.fn().mockResolvedValue(models), - }) as any); + vi.mocked(DaemonState).mockImplementation(function () { + return { listModels: vi.fn().mockResolvedValue(models) } as any; + }); const { promptModelPicker } = await import('./chat.js'); vi.mocked(promptModelPicker).mockResolvedValue('anthropic/claude-sonnet'); @@ -71,9 +71,9 @@ describe('selectModel', () => { { id: 'openai/gpt-4o', name: 'gpt-4o', provider: 'openai' }, ]; const { DaemonState } = await import('./state.js'); - vi.mocked(DaemonState).mockImplementation(() => ({ - listModels: vi.fn().mockResolvedValue(models), - }) as any); + vi.mocked(DaemonState).mockImplementation(function () { + return { listModels: vi.fn().mockResolvedValue(models) } as any; + }); const { promptModelPicker } = await import('./chat.js'); vi.mocked(promptModelPicker).mockResolvedValue(null); @@ -103,9 +103,9 @@ describe('selectModel', () => { vi.mocked(isDaemonRunningSync).mockReturnValue(true); const { DaemonState } = await import('./state.js'); - vi.mocked(DaemonState).mockImplementation(() => ({ - listModels: vi.fn().mockResolvedValue([]), - }) as any); + vi.mocked(DaemonState).mockImplementation(function () { + return { listModels: vi.fn().mockResolvedValue([]) } as any; + }); const { startDaemon } = await import('./daemon.js'); From 3e63ec7bbc2ab55ff6247a6f33cef3f12deac862 Mon Sep 17 00:00:00 2001 From: sathyapramod <8750601+sathyapramod@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:43:56 +0530 Subject: [PATCH 3/3] feat: enhance model selection to return state alongside model - Updated the `selectModel` function to return both the selected model and the associated DaemonState, allowing for reuse without restarting the daemon. - Modified the CLI chat command to accept the new state parameter from the model picker. - Adjusted tests to verify that the model and state are correctly returned and utilized. --- packages/daemon/src/daemon/chat.ts | 8 ++++++-- packages/daemon/src/daemon/index.ts | 7 ++++--- packages/daemon/src/daemon/model-picker.ts | 15 +++++++++++++-- packages/daemon/src/daemon/select-model.test.ts | 7 +++++-- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/daemon/src/daemon/chat.ts b/packages/daemon/src/daemon/chat.ts index 3d5356e..c700dfc 100644 --- a/packages/daemon/src/daemon/chat.ts +++ b/packages/daemon/src/daemon/chat.ts @@ -19,13 +19,15 @@ import { SessionStore } from '../core/session-store.js'; import { maybeSummarize } from '../core/session-summarizer.js'; import type { ChatToolOptions } from '../core/state.js'; -interface ChatOptions { +export interface ChatOptions { model?: string; session?: string; system?: string; policy?: string; tools?: boolean; json?: boolean; + /** Pre-initialized state from model picker — avoids a second daemon startup. */ + state?: import('./state.js').DaemonState; } const RESET = '\x1b[0m'; @@ -39,7 +41,9 @@ const RED = '\x1b[31m'; export async function runInteractiveChat(options: ChatOptions): Promise { let state: DaemonState; - if (isDaemonRunningSync()) { + if (options.state) { + state = options.state; + } else if (isDaemonRunningSync()) { console.error(`${DIM}Daemon already running — using in-process state...${RESET}`); state = new DaemonState(); } else { diff --git a/packages/daemon/src/daemon/index.ts b/packages/daemon/src/daemon/index.ts index 0856166..38e8db9 100644 --- a/packages/daemon/src/daemon/index.ts +++ b/packages/daemon/src/daemon/index.ts @@ -286,9 +286,10 @@ program .action(async (options) => { if (!options.model && !options.session) { const { selectModel } = await import('./model-picker.js'); - const picked = await selectModel(); - if (!picked) process.exit(1); - options.model = picked; + const result = await selectModel(); + if (!result) process.exit(1); + options.model = result.model; + options.state = result.state; } const { runInteractiveChat } = await import('./chat.js'); await runInteractiveChat(options); diff --git a/packages/daemon/src/daemon/model-picker.ts b/packages/daemon/src/daemon/model-picker.ts index 7634a9c..b9b4421 100644 --- a/packages/daemon/src/daemon/model-picker.ts +++ b/packages/daemon/src/daemon/model-picker.ts @@ -8,7 +8,16 @@ const RESET = '\x1b[0m'; const DIM = '\x1b[2m'; const YELLOW = '\x1b[33m'; -export async function selectModel(): Promise { +export interface ModelPickerResult { + model: string; + state: DaemonState; +} + +/** + * Interactive model picker. Returns the chosen model AND the DaemonState + * so callers can reuse it without a second startup. + */ +export async function selectModel(): Promise { let state: DaemonState; if (isDaemonRunningSync()) { state = new DaemonState(); @@ -31,7 +40,9 @@ export async function selectModel(): Promise { }); try { - return await promptModelPicker(models, rl); + const model = await promptModelPicker(models, rl); + if (!model) return null; + return { model, state }; } finally { rl.close(); } diff --git a/packages/daemon/src/daemon/select-model.test.ts b/packages/daemon/src/daemon/select-model.test.ts index f0191c8..d9b89c4 100644 --- a/packages/daemon/src/daemon/select-model.test.ts +++ b/packages/daemon/src/daemon/select-model.test.ts @@ -46,7 +46,7 @@ describe('selectModel', () => { expect(output).toContain('web UI'); }); - it('returns selected model via picker when models exist', async () => { + it('returns model and state via picker when models exist', async () => { const models = [ { id: 'openai/gpt-4o', name: 'gpt-4o', provider: 'openai' }, { id: 'anthropic/claude-sonnet', name: 'claude-sonnet', provider: 'anthropic' }, @@ -62,7 +62,10 @@ describe('selectModel', () => { const { selectModel } = await import('./model-picker.js'); const result = await selectModel(); - expect(result).toBe('anthropic/claude-sonnet'); + expect(result).not.toBeNull(); + expect(result!.model).toBe('anthropic/claude-sonnet'); + expect(result!.state).toBeDefined(); + expect(result!.state.listModels).toBeDefined(); expect(promptModelPicker).toHaveBeenCalledWith(models, expect.anything()); });