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
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions .claude/sessions/2026-03-15.md
Original file line number Diff line number Diff line change
@@ -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/<session-id>.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 <id>` switch missing `audit.setSessionId()` call.
Expand Down
29 changes: 28 additions & 1 deletion src/ClaudeCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -143,6 +144,12 @@ export class ClaudeCli {
this.term.log(`${' '.repeat(indent)}session: $${this.usage.sessionCost.toFixed(4)}`);
}

private static readonly MODEL_ALIASES: Record<string, BaseModel> = {
haiku: 'claude-haiku-4-5-20251001',
sonnet: 'claude-sonnet-4-6',
opus: 'claude-opus-4-6',
};

private async handleCommand(text: string): Promise<boolean> {
const trimmed = text.trim();
if (trimmed === '/quit' || trimmed === '/exit') {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <uuid>, /add-dir <path>');
this.term.info('Commands: /help, /version, /quit, /exit, /session [id], /compact-at <uuid>, /add-dir <path>, /model [haiku|sonnet|opus]');
this.term.info('---');

if (process.stdin.isTTY) {
Expand Down
5 changes: 3 additions & 2 deletions src/cli-config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)[]]);

Expand Down
27 changes: 23 additions & 4 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -18,25 +19,43 @@ export class QuerySession extends EventEmitter<SessionEvents> {
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,
) {
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 {
Expand Down Expand Up @@ -106,13 +125,13 @@ export class QuerySession extends EventEmitter<SessionEvents> {
return generateMessages();
}

public async send(input: string, onMessage: (msg: SDKMessage) => void, attachments?: readonly Attachment[], modelOverride?: string): Promise<void> {
public async send(input: string, onMessage: (msg: SDKMessage) => void, attachments?: readonly Attachment[], modelOverride?: ClaudeModel): Promise<void> {
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',
},
Expand Down
4 changes: 4 additions & 0 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Loading