diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index bcfb284..5f617e7 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -49,7 +49,7 @@ Do NOT invoke git-commit until steps 1-3 are done. ## Current State Branch: `main` -In-progress: None. Audit centralisation complete and tested. +In-progress: None. `/model` command complete and tested (#81). ## Architecture diff --git a/.claude/sessions/2026-03-15.md b/.claude/sessions/2026-03-15.md index e77b48c..21acb05 100644 --- a/.claude/sessions/2026-03-15.md +++ b/.claude/sessions/2026-03-15.md @@ -1,3 +1,10 @@ +### 01:50 — feature/#81 /model slash command + +- Did: Added `/model [haiku|sonnet|opus]` command — sets a process-local model override for the current session; `/model` with no arg clears it and reverts to config. Override survives config hot reload (`updateConfig()` leaves `sessionModelOverride` untouched). Status line shows amber `model: sonnet*` indicator when override is active. +- Files: `src/session.ts`, `src/ClaudeCli.ts`, `src/terminal.ts` +- Decisions: Named the session-level field `sessionModelOverride` to avoid confusion with the existing `modelOverride` parameter on `send()` (used for compact model). Override priority chain in `send()`: per-call param (`compactModel`) → `sessionModelOverride` → `this.model` (config). `Terminal.modelOverride` holds the short name (e.g. `sonnet`) for display; `session.sessionModelOverride` holds the full model ID. Pre-existing biome formatting fix applied to `.claude/settings.local.json` (unrelated, caught by CI). +- Next: Nothing — all scenarios tested and passing. + ### 00:55 — audit/centralise - Did: Centralised audit storage from per-project `.claude/audit.jsonl` to `~/.claude/audit/.jsonl`. Fixed timing bug where `setSessionId` was called after all messages had already been processed (moved call to `init` message handler in `onMessage`). Fixed `/session ` switch missing `audit.setSessionId()` call. diff --git a/src/ClaudeCli.ts b/src/ClaudeCli.ts index fa45dec..d08f433 100644 --- a/src/ClaudeCli.ts +++ b/src/ClaudeCli.ts @@ -9,6 +9,7 @@ import { CommandMode } from './CommandMode.js'; import { CONFIG_PATH, LOCAL_CONFIG_PATH } from './cli-config/consts.js'; import { diffConfig } from './cli-config/diffConfig.js'; import { loadCliConfig } from './cli-config/loadCliConfig.js'; +import type { BaseModel } from './cli-config/schema.js'; import type { ResolvedCliConfig } from './cli-config/types.js'; import { validateRawConfig } from './cli-config/validateRawConfig.js'; import { readClipboardImage, readClipboardText, truncateText } from './clipboard.js'; @@ -143,6 +144,12 @@ export class ClaudeCli { this.term.log(`${' '.repeat(indent)}session: $${this.usage.sessionCost.toFixed(4)}`); } + private static readonly MODEL_ALIASES: Record = { + haiku: 'claude-haiku-4-5-20251001', + sonnet: 'claude-sonnet-4-6', + opus: 'claude-opus-4-6', + }; + private async handleCommand(text: string): Promise { const trimmed = text.trim(); if (trimmed === '/quit' || trimmed === '/exit') { @@ -193,6 +200,26 @@ export class ClaudeCli { this.submit('/compact'); return true; } + if (trimmed === '/model' || trimmed.startsWith('/model ')) { + const arg = trimmed.slice('/model'.length).trim(); + if (!arg) { + this.session.clearSessionModelOverride(); + this.term.modelOverride = undefined; + this.term.info(`Model override cleared — using config: ${this.session.activeModel}`); + } else { + const resolved = ClaudeCli.MODEL_ALIASES[arg]; + if (!resolved) { + const valid = Object.keys(ClaudeCli.MODEL_ALIASES).join(', '); + this.term.error(`Unknown model: ${arg}. Valid: ${valid}`); + } else { + this.session.setSessionModelOverride(resolved); + this.term.modelOverride = arg; + this.term.info(`Model override set: ${arg} (${resolved})`); + } + } + this.redraw(); + return true; + } if (trimmed === '/add-dir' || trimmed.startsWith('/add-dir ')) { const arg = trimmed.slice('/add-dir'.length).trim(); if (!arg) { @@ -835,7 +862,7 @@ export class ClaudeCli { this.term.info('Starting new session'); } this.term.info('Enter = newline, Ctrl+Enter = send, Ctrl+C = quit'); - this.term.info('Commands: /help, /version, /quit, /exit, /session [id], /compact-at , /add-dir '); + this.term.info('Commands: /help, /version, /quit, /exit, /session [id], /compact-at , /add-dir , /model [haiku|sonnet|opus]'); this.term.info('---'); if (process.stdin.isTTY) { diff --git a/src/cli-config/schema.ts b/src/cli-config/schema.ts index 1076cae..0e5c800 100644 --- a/src/cli-config/schema.ts +++ b/src/cli-config/schema.ts @@ -28,8 +28,9 @@ const BASE_MODELS = [ 'claude-3-haiku-20240307', ] as const satisfies readonly string[]; -type BaseModel = (typeof BASE_MODELS)[number]; -type ExtendedModel = `${BaseModel}[1m]`; +export type BaseModel = (typeof BASE_MODELS)[number]; +export type ExtendedModel = `${BaseModel}[1m]`; +export type ClaudeModel = BaseModel | ExtendedModel; const claudeModelSchema = z.enum([...BASE_MODELS, ...BASE_MODELS.map((m) => `${m}[1m]` as ExtendedModel)] as [BaseModel | ExtendedModel, ...(BaseModel | ExtendedModel)[]]); diff --git a/src/session.ts b/src/session.ts index 582ccf6..8482ce2 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3,6 +3,7 @@ import { appendFileSync } from 'node:fs'; import { type CanUseTool, type Options, type Query, query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; import type { ImageBlockParam, TextBlockParam } from '@anthropic-ai/sdk/resources/messages/messages'; import type { Attachment } from './AttachmentStore.js'; +import type { ClaudeModel } from './cli-config/schema.js'; import type { ThinkingEffort } from './cli-config/types.js'; import { READ_ONLY_TOOLS } from './config.js'; @@ -18,13 +19,14 @@ export class QuerySession extends EventEmitter { private activeQuery: Query | undefined; private aborted = false; private additionalDirs: string[] = []; + private sessionModelOverride: ClaudeModel | undefined; public canUseTool: CanUseTool | undefined; public systemPromptAppend: string | undefined; public disableTools = false; public removeTools = false; public constructor( - private model: string, + private model: ClaudeModel, private maxTurns: number, private thinking: boolean, private thinkingEffort: ThinkingEffort, @@ -32,11 +34,28 @@ export class QuerySession extends EventEmitter { super(); } - public updateConfig(model: string, maxTurns: number, thinking: boolean, thinkingEffort: ThinkingEffort): void { + public updateConfig(model: ClaudeModel, maxTurns: number, thinking: boolean, thinkingEffort: ThinkingEffort): void { this.model = model; this.maxTurns = maxTurns; this.thinking = thinking; this.thinkingEffort = thinkingEffort; + // sessionModelOverride intentionally not cleared — it must survive config hot reload + } + + public get activeModel(): ClaudeModel { + return this.sessionModelOverride ?? this.model; + } + + public get hasSessionModelOverride(): boolean { + return this.sessionModelOverride !== undefined; + } + + public setSessionModelOverride(model: ClaudeModel): void { + this.sessionModelOverride = model; + } + + public clearSessionModelOverride(): void { + this.sessionModelOverride = undefined; } public get isActive(): boolean { @@ -106,13 +125,13 @@ export class QuerySession extends EventEmitter { return generateMessages(); } - public async send(input: string, onMessage: (msg: SDKMessage) => void, attachments?: readonly Attachment[], modelOverride?: string): Promise { + public async send(input: string, onMessage: (msg: SDKMessage) => void, attachments?: readonly Attachment[], modelOverride?: ClaudeModel): Promise { this.aborted = false; const abort = new AbortController(); this.abort = abort; const options: Options = { - model: modelOverride ?? this.model, + model: modelOverride ?? this.sessionModelOverride ?? this.model, thinking: { type: this.thinking ? 'adaptive' : 'disabled', }, diff --git a/src/terminal.ts b/src/terminal.ts index e1e17ed..29cee06 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -31,6 +31,7 @@ export class Terminal { private pauseBuffer: string[] = []; private questionLines: string[] = []; public sessionId: string | undefined; + public modelOverride: string | undefined; public constructor( private readonly appState: AppState, @@ -117,6 +118,9 @@ export class Terminal { return null; } b.emoji('⚡'); + if (this.modelOverride) { + b.text(` \x1b[33mmodel: ${this.modelOverride}*\x1b[0m`); + } break; case 'sending': { const elapsed = this.appState.elapsedSeconds ?? 0;