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
56 changes: 53 additions & 3 deletions packages/daemon/src/daemon/chat.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/**
* Interactive CLI chat — `aby chat -m <model>`
* 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.
Expand All @@ -17,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';
Expand All @@ -37,7 +41,9 @@ const RED = '\x1b[31m';
export async function runInteractiveChat(options: ChatOptions): Promise<void> {
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 {
Expand Down Expand Up @@ -289,3 +295,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<string | null> {
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));
});
}
7 changes: 5 additions & 2 deletions packages/daemon/src/daemon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,11 @@ 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 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);
Expand Down
128 changes: 128 additions & 0 deletions packages/daemon/src/daemon/model-picker.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn> } {
return new EventEmitter() as EventEmitter & { once: ReturnType<typeof vi.fn> };
}

describe('promptModelPicker', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;

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

49 changes: 49 additions & 0 deletions packages/daemon/src/daemon/model-picker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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 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<ModelPickerResult | null> {
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 {
const model = await promptModelPicker(models, rl);
if (!model) return null;
return { model, state };
} finally {
rl.close();
}
}
Loading
Loading