claude-code-rust 0.12.4 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,10 @@ A native Rust terminal interface for Claude Code. Drop-in replacement for Anthro
9
9
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
10
10
  [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green.svg)](https://nodejs.org/)
11
11
 
12
+ <p align="center">
13
+ <img src="assets/banner.png" alt="Claude Code Rust running a Read tool call with syntax-highlighted output" width="900">
14
+ </p>
15
+
12
16
  ## About
13
17
 
14
18
  Claude Code Rust replaces the stock Claude Code terminal interface with a native Rust binary built on [Ratatui](https://ratatui.rs/). It connects to the same Claude API through a local Agent SDK bridge. Core Claude Code functionality - tool calls, file editing, terminal commands, and permissions - works unchanged.
@@ -43,15 +47,17 @@ Full documentation is available at [srothgan.github.io/claude-code-rust](https:/
43
47
 
44
48
  ## Why
45
49
 
46
- The stock Claude Code TUI runs on Node.js with React Ink. This causes real problems:
50
+ The stock Claude Code TUI runs on Node.js with React Ink, which renders by redrawing full frames over raw ANSI escape codes. This causes real, widely-reported problems:
47
51
 
48
- - **Memory**: 200-400MB baseline vs ~20-50MB for a native binary
49
- - **Startup**: 2-5 seconds vs under 100ms
50
- - **Scrollback**: Broken virtual scrolling that loses history
51
- - **Input latency**: Event queue delays on keystroke handling
52
- - **Copy/paste**: Custom implementation instead of native terminal support
52
+ - **Flickering**: The whole view is redrawn on every status update, causing constant flicker — bad enough to crash editors' integrated terminals during long sessions
53
+ - **CPU**: Sustained high CPU even when idle, and runaway loops that spawn multiple background processes
54
+ - **Memory**: 200-400MB baseline (and climbing with conversation length) vs ~20-50MB for a native binary
55
+ - **Resize**: Window resizing leaves duplicated frames in scrollback, loses lines when shrinking, and can garble the display
56
+ - **Input latency**: Keystrokes echo with visible delay as context fills up, and noticeably worse on Windows
57
+ - **Scrollback**: Hijacks the terminal's native scrollback, erasing history you can no longer scroll back to
58
+ - **Paste**: Large pastes can flood stdout and freeze the terminal
53
59
 
54
- Claude Code Rust fixes all of these by compiling to a single native binary with direct terminal control via Crossterm.
60
+ Claude Code Rust addresses these by compiling to a single native binary with diffed, direct terminal control via Crossterm and Ratatui — no full-frame redraws, no Node runtime overhead.
55
61
 
56
62
  ## Documentation
57
63
 
@@ -48,6 +48,16 @@ function optionalTimeout(record, context) {
48
48
  }
49
49
  return value;
50
50
  }
51
+ function optionalRequestTimeoutMs(record, context) {
52
+ const value = record.request_timeout_ms;
53
+ if (value === undefined) {
54
+ return undefined;
55
+ }
56
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 1000) {
57
+ throw new Error(`${context}.request_timeout_ms must be an integer >= 1000`);
58
+ }
59
+ return value;
60
+ }
51
61
  function optionalAlwaysLoad(record, context) {
52
62
  const value = record.always_load;
53
63
  if (value === undefined) {
@@ -98,6 +108,7 @@ export function parseMcpServerConfig(value, context) {
98
108
  throw new Error(`${context}.type must be a string`);
99
109
  }
100
110
  const timeout = optionalTimeout(record, context);
111
+ const requestTimeoutMs = optionalRequestTimeoutMs(record, context);
101
112
  const alwaysLoad = optionalAlwaysLoad(record, context);
102
113
  switch (type) {
103
114
  case "stdio": {
@@ -114,6 +125,7 @@ export function parseMcpServerConfig(value, context) {
114
125
  ...(optionalStringArray(record, "args", context) ? { args: optionalStringArray(record, "args", context) } : {}),
115
126
  ...(optionalStringMap(record, "env", context) ? { env: optionalStringMap(record, "env", context) } : {}),
116
127
  ...(timeout === undefined ? {} : { timeout }),
128
+ ...(requestTimeoutMs === undefined ? {} : { request_timeout_ms: requestTimeoutMs }),
117
129
  ...(alwaysLoad === undefined ? {} : { always_load: alwaysLoad }),
118
130
  };
119
131
  }
@@ -130,6 +142,7 @@ export function parseMcpServerConfig(value, context) {
130
142
  ...(optionalStringMap(record, "headers", context) ? { headers: optionalStringMap(record, "headers", context) } : {}),
131
143
  ...(tools === undefined ? {} : { tools }),
132
144
  ...(timeout === undefined ? {} : { timeout }),
145
+ ...(requestTimeoutMs === undefined ? {} : { request_timeout_ms: requestTimeoutMs }),
133
146
  ...(alwaysLoad === undefined ? {} : { always_load: alwaysLoad }),
134
147
  };
135
148
  }
@@ -148,6 +161,9 @@ function toSdkToolPolicies(tools) {
148
161
  ...(tool.org_max_permission === undefined ? {} : { org_max_permission: tool.org_max_permission }),
149
162
  }));
150
163
  }
164
+ function sdkRequestTimeoutConfig(config) {
165
+ return config.request_timeout_ms === undefined ? {} : { requestTimeoutMs: config.request_timeout_ms };
166
+ }
151
167
  export function bridgeMcpConfigToSdk(config) {
152
168
  switch (config.type) {
153
169
  case "stdio":
@@ -157,6 +173,7 @@ export function bridgeMcpConfigToSdk(config) {
157
173
  ...(config.args ? { args: config.args } : {}),
158
174
  ...(config.env ? { env: config.env } : {}),
159
175
  ...(config.timeout === undefined ? {} : { timeout: config.timeout }),
176
+ ...sdkRequestTimeoutConfig(config),
160
177
  ...(config.always_load === undefined ? {} : { alwaysLoad: config.always_load }),
161
178
  };
162
179
  case "sse":
@@ -166,6 +183,7 @@ export function bridgeMcpConfigToSdk(config) {
166
183
  ...(config.headers ? { headers: config.headers } : {}),
167
184
  ...(config.tools ? { tools: toSdkToolPolicies(config.tools) } : {}),
168
185
  ...(config.timeout === undefined ? {} : { timeout: config.timeout }),
186
+ ...sdkRequestTimeoutConfig(config),
169
187
  ...(config.always_load === undefined ? {} : { alwaysLoad: config.always_load }),
170
188
  };
171
189
  case "http":
@@ -175,6 +193,7 @@ export function bridgeMcpConfigToSdk(config) {
175
193
  ...(config.headers ? { headers: config.headers } : {}),
176
194
  ...(config.tools ? { tools: toSdkToolPolicies(config.tools) } : {}),
177
195
  ...(config.timeout === undefined ? {} : { timeout: config.timeout }),
196
+ ...sdkRequestTimeoutConfig(config),
178
197
  ...(config.always_load === undefined ? {} : { alwaysLoad: config.always_load }),
179
198
  };
180
199
  }
@@ -250,6 +269,16 @@ export function mapMcpServerStatus(status) {
250
269
  : [],
251
270
  };
252
271
  }
272
+ function sdkRequestTimeoutMs(config) {
273
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
274
+ return undefined;
275
+ }
276
+ const raw = config;
277
+ const value = raw.requestTimeoutMs ?? raw.request_timeout_ms;
278
+ return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value)
279
+ ? value
280
+ : undefined;
281
+ }
253
282
  export function mapMcpServerStatusConfig(config) {
254
283
  switch (config.type) {
255
284
  case "stdio":
@@ -259,6 +288,7 @@ export function mapMcpServerStatusConfig(config) {
259
288
  ...(Array.isArray(config.args) && config.args.length > 0 ? { args: config.args } : {}),
260
289
  ...(config.env ? { env: config.env } : {}),
261
290
  ...(config.timeout === undefined ? {} : { timeout: config.timeout }),
291
+ ...(sdkRequestTimeoutMs(config) === undefined ? {} : { request_timeout_ms: sdkRequestTimeoutMs(config) }),
262
292
  ...(config.alwaysLoad === undefined ? {} : { always_load: config.alwaysLoad }),
263
293
  };
264
294
  case "sse": {
@@ -269,6 +299,7 @@ export function mapMcpServerStatusConfig(config) {
269
299
  ...(config.headers ? { headers: config.headers } : {}),
270
300
  ...(tools === undefined ? {} : { tools }),
271
301
  ...(config.timeout === undefined ? {} : { timeout: config.timeout }),
302
+ ...(sdkRequestTimeoutMs(config) === undefined ? {} : { request_timeout_ms: sdkRequestTimeoutMs(config) }),
272
303
  ...(config.alwaysLoad === undefined ? {} : { always_load: config.alwaysLoad }),
273
304
  };
274
305
  }
@@ -280,6 +311,7 @@ export function mapMcpServerStatusConfig(config) {
280
311
  ...(config.headers ? { headers: config.headers } : {}),
281
312
  ...(tools === undefined ? {} : { tools }),
282
313
  ...(config.timeout === undefined ? {} : { timeout: config.timeout }),
314
+ ...(sdkRequestTimeoutMs(config) === undefined ? {} : { request_timeout_ms: sdkRequestTimeoutMs(config) }),
283
315
  ...(config.alwaysLoad === undefined ? {} : { always_load: config.alwaysLoad }),
284
316
  };
285
317
  }
@@ -321,6 +353,7 @@ function mcpStatusConfigDiagnostics(config) {
321
353
  return {
322
354
  config_type: "stdio",
323
355
  ...(config.timeout === undefined ? {} : { timeout_ms: config.timeout }),
356
+ ...(config.request_timeout_ms === undefined ? {} : { request_timeout_ms: config.request_timeout_ms }),
324
357
  ...(config.always_load === undefined ? {} : { always_load: config.always_load }),
325
358
  configured_tool_policy_count: 0,
326
359
  };
@@ -329,6 +362,7 @@ function mcpStatusConfigDiagnostics(config) {
329
362
  return {
330
363
  config_type: config.type,
331
364
  ...(config.timeout === undefined ? {} : { timeout_ms: config.timeout }),
365
+ ...(config.request_timeout_ms === undefined ? {} : { request_timeout_ms: config.request_timeout_ms }),
332
366
  ...(config.always_load === undefined ? {} : { always_load: config.always_load }),
333
367
  configured_tool_policy_count: config.tools?.length ?? 0,
334
368
  };
@@ -359,6 +393,7 @@ export function summarizeMcpServersForDiagnostics(servers) {
359
393
  config_type: config.config_type,
360
394
  ...(server.scope ? { scope: server.scope } : {}),
361
395
  ...(config.timeout_ms === undefined ? {} : { timeout_ms: config.timeout_ms }),
396
+ ...(config.request_timeout_ms === undefined ? {} : { request_timeout_ms: config.request_timeout_ms }),
362
397
  ...(config.always_load === undefined ? {} : { always_load: config.always_load }),
363
398
  tool_count: server.tools.length,
364
399
  configured_tool_policy_count: config.configured_tool_policy_count,
@@ -1,11 +1,6 @@
1
- const OPUS_MODEL_ALIAS = "opus";
1
+ const DEFAULT_MODEL_ALIAS = "fable";
2
2
  const MAX_MODEL_VERSION_PARTS = 2;
3
3
  const RELEASE_BUILD_TOKEN = /^20\d{6}$/;
4
- function isUnavailableModelId(id) {
5
- const normalized = id.trim().toLowerCase();
6
- // TODO: Revisit only after a product decision if Anthropic restores Fable 5 access.
7
- return normalized === "fable" || normalized.startsWith("claude-fable-5");
8
- }
9
4
  function isEffortLevel(value) {
10
5
  return (value === "low" ||
11
6
  value === "medium" ||
@@ -19,7 +14,7 @@ function normalizeModelKey(id) {
19
14
  return { original, family: "unknown", versionParts: [], variantParts: [], buildParts: [] };
20
15
  }
21
16
  const lower = original.toLowerCase();
22
- const contextMatch = lower.match(/\[([^\]]+)\]$/);
17
+ const contextMatch = lower.match(/\[([^[\]]+)\]$/);
23
18
  const contextSuffix = contextMatch?.[1];
24
19
  const withoutContext = contextMatch ? lower.slice(0, contextMatch.index) : lower;
25
20
  const withoutPrefix = withoutContext.startsWith("claude-")
@@ -27,7 +22,10 @@ function normalizeModelKey(id) {
27
22
  : withoutContext;
28
23
  const parts = withoutPrefix.split("-").filter((part) => part.length > 0);
29
24
  const familyPart = parts[0] ?? "";
30
- const family = familyPart === "opus" || familyPart === "sonnet" || familyPart === "haiku"
25
+ const family = familyPart === "fable" ||
26
+ familyPart === "opus" ||
27
+ familyPart === "sonnet" ||
28
+ familyPart === "haiku"
31
29
  ? familyPart
32
30
  : "unknown";
33
31
  const versionParts = [];
@@ -36,6 +34,10 @@ function normalizeModelKey(id) {
36
34
  if (family !== "unknown") {
37
35
  for (const part of parts.slice(1)) {
38
36
  if (/^\d+$/.test(part)) {
37
+ if (versionParts.length > 0 && RELEASE_BUILD_TOKEN.test(part)) {
38
+ buildParts.push(part);
39
+ continue;
40
+ }
39
41
  if (versionParts.length < MAX_MODEL_VERSION_PARTS) {
40
42
  const parsed = Number.parseInt(part, 10);
41
43
  if (Number.isFinite(parsed)) {
@@ -120,11 +122,13 @@ function humanizeModelId(id) {
120
122
  if (normalized.family === "unknown") {
121
123
  return id;
122
124
  }
123
- const familyLabel = normalized.family === "opus"
124
- ? "Opus"
125
- : normalized.family === "sonnet"
126
- ? "Sonnet"
127
- : "Haiku";
125
+ const familyLabel = normalized.family === "fable"
126
+ ? "Fable"
127
+ : normalized.family === "opus"
128
+ ? "Opus"
129
+ : normalized.family === "sonnet"
130
+ ? "Sonnet"
131
+ : "Haiku";
128
132
  const versionLabel = normalized.versionParts.length > 0 ? ` ${normalized.versionParts.join(".")}` : "";
129
133
  const contextLabel = normalized.contextSuffix?.toLowerCase() === "1m"
130
134
  ? " [1M]"
@@ -144,19 +148,23 @@ function currentModelIsAuthoritative(resolvedId, requestedId) {
144
148
  return true;
145
149
  }
146
150
  function resolveCatalogModel(availableModels, resolvedId, requestedId) {
147
- const exactResolved = availableModels.find((entry) => entry.id === resolvedId);
151
+ const exactResolved = availableModels.find((entry) => entry.id === resolvedId || entry.resolved_model === resolvedId);
148
152
  if (exactResolved) {
149
153
  return exactResolved;
150
154
  }
151
155
  if (requestedId) {
152
- const exactRequested = availableModels.find((entry) => entry.id === requestedId);
156
+ const exactRequested = availableModels.find((entry) => entry.id === requestedId || entry.resolved_model === requestedId);
153
157
  if (exactRequested &&
154
- modelKeysAreCompatible(exactRequested.id, resolvedId) &&
158
+ (modelKeysAreCompatible(exactRequested.id, resolvedId) ||
159
+ (exactRequested.resolved_model !== undefined &&
160
+ modelKeysAreCompatible(exactRequested.resolved_model, resolvedId))) &&
155
161
  !hasVariantSiblingConflict(availableModels, exactRequested.id, resolvedId)) {
156
162
  return exactRequested;
157
163
  }
158
164
  }
159
- const compatible = availableModels.filter((entry) => modelKeysAreCompatible(entry.id, resolvedId) &&
165
+ const compatible = availableModels.filter((entry) => (modelKeysAreCompatible(entry.id, resolvedId) ||
166
+ (entry.resolved_model !== undefined &&
167
+ modelKeysAreCompatible(entry.resolved_model, resolvedId))) &&
160
168
  !hasVariantSiblingConflict(availableModels, entry.id, resolvedId));
161
169
  return compatible.length === 1 ? compatible[0] : undefined;
162
170
  }
@@ -168,12 +176,14 @@ export function mapAvailableModels(models) {
168
176
  .filter((entry) => {
169
177
  return (typeof entry?.value === "string" &&
170
178
  entry.value.trim().length > 0 &&
171
- !isUnavailableModelId(entry.value) &&
172
179
  typeof entry.displayName === "string" &&
173
180
  entry.displayName.trim().length > 0);
174
181
  })
175
182
  .map((entry) => ({
176
183
  id: entry.value,
184
+ ...(typeof entry.resolvedModel === "string" && entry.resolvedModel.trim().length > 0
185
+ ? { resolved_model: entry.resolvedModel.trim() }
186
+ : {}),
177
187
  display_name: entry.displayName,
178
188
  supports_effort: entry.supportsEffort === true,
179
189
  supported_effort_levels: Array.isArray(entry.supportedEffortLevels)
@@ -198,9 +208,9 @@ export function resolveCurrentModel(session) {
198
208
  const resolvedId = session.resolvedRuntimeModelId?.trim() ||
199
209
  session.model.trim() ||
200
210
  requestedId ||
201
- OPUS_MODEL_ALIAS;
211
+ DEFAULT_MODEL_ALIAS;
202
212
  const catalogModel = resolveCatalogModel(session.availableModels, resolvedId, requestedId);
203
- const runtimeDisplayId = resolvedId || requestedId || OPUS_MODEL_ALIAS;
213
+ const runtimeDisplayId = resolvedId || requestedId || DEFAULT_MODEL_ALIAS;
204
214
  const displayNameShort = shortDisplayNameForModelId(runtimeDisplayId);
205
215
  const displayNameLong = catalogModel?.display_name ?? humanizeModelId(runtimeDisplayId);
206
216
  return {
@@ -19,7 +19,7 @@ export { shouldEmitStartupAuthRequiredForAccount } from "./account_metadata.js";
19
19
  const BRIDGE_RUNTIME_PROCESS_NAME = process.platform === "win32" ? "claude-rs-bridge-node.exe" : "claude-rs-bridge-node";
20
20
  const BRIDGE_RUNTIME_GUARD_PROMPT = `Do not terminate the Claude Rust bridge runtime process \`${BRIDGE_RUNTIME_PROCESS_NAME}\`; ` +
21
21
  "when cleaning up development servers, only stop processes by explicit PIDs you started in this session.";
22
- const STARTUP_FALLBACK_MODEL_ALIAS = "opus";
22
+ const STARTUP_FALLBACK_MODEL_ALIAS = "fable";
23
23
  function permissionDisplayFromCanUseOptions(options) {
24
24
  const title = typeof options.title === "string" ? options.title.trim() : "";
25
25
  const displayName = typeof options.displayName === "string" ? options.displayName.trim() : "";
@@ -600,6 +600,7 @@ export function buildQueryOptions(params) {
600
600
  const systemPrompt = systemPromptFromLaunchSettings(params.launchSettings);
601
601
  const modelOption = startupModelOption(params.launchSettings);
602
602
  const permissionModeOptions = startupPermissionModeOptions(params.launchSettings);
603
+ const shouldPassCanUseTool = permissionModeOptions.permissionMode !== "bypassPermissions";
603
604
  const settings = normalizedSettingsFromLaunchSettings(params.launchSettings);
604
605
  return {
605
606
  cwd: params.cwd,
@@ -658,7 +659,7 @@ export function buildQueryOptions(params) {
658
659
  settingSources: DEFAULT_SETTING_SOURCES,
659
660
  resume: params.resume,
660
661
  ...(params.resumeSessionAt ? { resumeSessionAt: params.resumeSessionAt } : {}),
661
- canUseTool: params.canUseTool,
662
+ ...(shouldPassCanUseTool ? { canUseTool: params.canUseTool } : {}),
662
663
  onElicitation: async (request) => {
663
664
  const requestId = randomUUID();
664
665
  const mode = request.mode === "form" || request.mode === "url"
@@ -142,6 +142,32 @@ function buildQuestionRequest(promptToolCall, prompt, index, total) {
142
142
  function askUserQuestionTranscript(answers) {
143
143
  return answers.map((entry) => `${entry.header}: ${entry.answer}\n ${entry.question}`).join("\n");
144
144
  }
145
+ function askUserQuestionCompletedRawInput(prompts, answers, annotations, questionResults) {
146
+ return {
147
+ questions: prompts.map((prompt) => ({
148
+ question: prompt.question,
149
+ header: prompt.header,
150
+ multiSelect: prompt.multiSelect,
151
+ options: prompt.options.map((option) => ({
152
+ label: option.label,
153
+ description: option.description,
154
+ ...(option.preview ? { preview: option.preview } : {}),
155
+ })),
156
+ })),
157
+ answers,
158
+ ...(Object.keys(annotations).length > 0 ? { annotations: questionAnnotationsJson(annotations) } : {}),
159
+ question_results: questionResults,
160
+ };
161
+ }
162
+ function questionAnnotationsJson(annotations) {
163
+ return Object.fromEntries(Object.entries(annotations).map(([question, annotation]) => [
164
+ question,
165
+ {
166
+ ...(annotation.preview ? { preview: annotation.preview } : {}),
167
+ ...(annotation.notes ? { notes: annotation.notes } : {}),
168
+ },
169
+ ]));
170
+ }
145
171
  function deriveAnnotation(selectedOptions, annotation) {
146
172
  const preview = annotation?.preview?.trim().length
147
173
  ? annotation.preview
@@ -166,6 +192,7 @@ export async function requestAskUserQuestionAnswers(session, toolUseId, inputDat
166
192
  const answers = {};
167
193
  const annotations = {};
168
194
  const transcript = [];
195
+ const questionResults = [];
169
196
  for (const [index, prompt] of prompts.entries()) {
170
197
  const promptToolCall = askUserQuestionPromptToolCall(baseToolCall, prompt, index, prompts.length);
171
198
  const fields = {
@@ -214,11 +241,35 @@ export async function requestAskUserQuestionAnswers(session, toolUseId, inputDat
214
241
  annotations[prompt.question] = annotation;
215
242
  }
216
243
  transcript.push({ header: prompt.header, question: prompt.question, answer });
244
+ questionResults.push({
245
+ question: prompt.question,
246
+ header: prompt.header,
247
+ question_index: index,
248
+ total_questions: prompts.length,
249
+ selected_options: selectedOptions.map((option) => ({
250
+ option_id: option.option_id,
251
+ label: option.label,
252
+ ...(option.description ? { description: option.description } : {}),
253
+ ...(option.preview ? { preview: option.preview } : {}),
254
+ })),
255
+ ...(annotation
256
+ ? {
257
+ annotation: {
258
+ ...(annotation.preview ? { preview: annotation.preview } : {}),
259
+ ...(annotation.notes ? { notes: annotation.notes } : {}),
260
+ },
261
+ }
262
+ : {}),
263
+ });
217
264
  const summary = askUserQuestionTranscript(transcript);
265
+ const completed = index + 1 >= prompts.length;
218
266
  const progressFields = {
219
- status: index + 1 >= prompts.length ? "completed" : "in_progress",
267
+ status: completed ? "completed" : "in_progress",
220
268
  raw_output: summary,
221
269
  content: [{ type: "content", content: { type: "text", text: summary } }],
270
+ ...(completed
271
+ ? { raw_input: askUserQuestionCompletedRawInput(prompts, answers, annotations, questionResults) }
272
+ : {}),
222
273
  };
223
274
  emitToolCallUpdate(session, toolUseId, progressFields, "summary");
224
275
  }
@@ -129,7 +129,7 @@ export function emitAgentConfigOptionUpdate(sessionId, agent) {
129
129
  value: agent,
130
130
  });
131
131
  }
132
- const EXPECTED_AGENT_SDK_VERSION = "0.3.193";
132
+ const EXPECTED_AGENT_SDK_VERSION = "0.3.198";
133
133
  const require = createRequire(import.meta.url);
134
134
  export function resolveInstalledAgentSdkVersion() {
135
135
  try {
@@ -319,6 +319,7 @@ test("parseCommandEnvelope validates mcp_set_servers command", () => {
319
319
  "X-Test": "1",
320
320
  },
321
321
  timeout: 5000,
322
+ request_timeout_ms: 30000,
322
323
  always_load: true,
323
324
  tools: [
324
325
  {
@@ -351,6 +352,7 @@ test("parseCommandEnvelope validates mcp_set_servers command", () => {
351
352
  "X-Test": "1",
352
353
  },
353
354
  timeout: 5000,
355
+ request_timeout_ms: 30000,
354
356
  always_load: true,
355
357
  tools: [
356
358
  {
@@ -386,6 +388,17 @@ test("parseCommandEnvelope rejects invalid latest MCP config fields", () => {
386
388
  session_id: "session-123",
387
389
  servers: { bad: { type: "http", url: "https://mcp.example.com", timeout: 999 } },
388
390
  })), /mcp_set_servers\.servers\.bad\.timeout must be an integer >= 1000/);
391
+ assert.throws(() => parseCommandEnvelope(JSON.stringify({
392
+ command: "mcp_set_servers",
393
+ session_id: "session-123",
394
+ servers: {
395
+ bad: {
396
+ type: "http",
397
+ url: "https://mcp.example.com",
398
+ request_timeout_ms: 999,
399
+ },
400
+ },
401
+ })), /mcp_set_servers\.servers\.bad\.request_timeout_ms must be an integer >= 1000/);
389
402
  assert.throws(() => parseCommandEnvelope(JSON.stringify({
390
403
  command: "mcp_set_servers",
391
404
  session_id: "session-123",
@@ -453,6 +466,7 @@ test("handleMcpSetServersCommand emits SDK result", async () => {
453
466
  type: "http",
454
467
  url: "https://example.test/mcp",
455
468
  always_load: true,
469
+ request_timeout_ms: 30000,
456
470
  },
457
471
  },
458
472
  }, "req-mcp-set");
@@ -462,6 +476,7 @@ test("handleMcpSetServersCommand emits SDK result", async () => {
462
476
  type: "http",
463
477
  url: "https://example.test/mcp",
464
478
  alwaysLoad: true,
479
+ requestTimeoutMs: 30000,
465
480
  },
466
481
  });
467
482
  assert.deepEqual(events, [
@@ -537,6 +552,7 @@ test("bridgeMcpConfigToSdk maps latest MCP fields to SDK casing", () => {
537
552
  type: "sse",
538
553
  url: "https://mcp.example.com/sse",
539
554
  timeout: 2500,
555
+ request_timeout_ms: 30000,
540
556
  always_load: true,
541
557
  tools: [
542
558
  { name: "search" },
@@ -546,6 +562,7 @@ test("bridgeMcpConfigToSdk maps latest MCP fields to SDK casing", () => {
546
562
  type: "sse",
547
563
  url: "https://mcp.example.com/sse",
548
564
  timeout: 2500,
565
+ requestTimeoutMs: 30000,
549
566
  alwaysLoad: true,
550
567
  tools: [
551
568
  { name: "search" },
@@ -562,6 +579,7 @@ test("mapMcpServerStatus preserves latest MCP status config fields", () => {
562
579
  url: "https://mcp.notion.com/mcp",
563
580
  headers: { Authorization: "Bearer token" },
564
581
  timeout: 5000,
582
+ requestTimeoutMs: 30000,
565
583
  alwaysLoad: true,
566
584
  tools: [
567
585
  { name: "search" },
@@ -575,6 +593,7 @@ test("mapMcpServerStatus preserves latest MCP status config fields", () => {
575
593
  url: "https://mcp.notion.com/mcp",
576
594
  headers: { Authorization: "Bearer token" },
577
595
  timeout: 5000,
596
+ request_timeout_ms: 30000,
578
597
  always_load: true,
579
598
  tools: [
580
599
  { name: "search" },
@@ -1103,6 +1122,7 @@ test("buildQueryOptions enables dangerous skip flag for bypass permissions start
1103
1122
  });
1104
1123
  assert.equal(options.permissionMode, "bypassPermissions");
1105
1124
  assert.equal(options.allowDangerouslySkipPermissions, true);
1125
+ assert.equal("canUseTool" in options, false);
1106
1126
  });
1107
1127
  test("buildQueryOptions omits optional startup overrides but keeps bridge guard prompt", () => {
1108
1128
  const input = new AsyncQueue();
@@ -3162,6 +3182,70 @@ test("requestAskUserQuestionAnswers preserves previews and annotations in update
3162
3182
  question_index: 0,
3163
3183
  total_questions: 1,
3164
3184
  });
3185
+ const completedQuestionUpdate = events
3186
+ .map((event) => (event.event === "session_update" ? event.update : undefined))
3187
+ .find((update) => {
3188
+ const toolCallUpdate = update?.tool_call_update;
3189
+ const fields = toolCallUpdate?.fields;
3190
+ return toolCallUpdate?.tool_call_id === "tool-question" && fields?.status === "completed";
3191
+ })?.tool_call_update;
3192
+ const completedFields = completedQuestionUpdate?.fields;
3193
+ assert.deepEqual(completedFields?.raw_input, {
3194
+ questions: [
3195
+ {
3196
+ question: "Pick deployment target",
3197
+ header: "Target",
3198
+ multiSelect: true,
3199
+ options: [
3200
+ {
3201
+ label: "Staging",
3202
+ description: "Low-risk validation",
3203
+ preview: "Deploy to staging first.",
3204
+ },
3205
+ {
3206
+ label: "Production",
3207
+ description: "Customer-facing rollout",
3208
+ preview: "Deploy to production after approval.",
3209
+ },
3210
+ ],
3211
+ },
3212
+ ],
3213
+ answers: {
3214
+ "Pick deployment target": "Staging, Production",
3215
+ },
3216
+ annotations: {
3217
+ "Pick deployment target": {
3218
+ preview: "Deploy to staging first.\n\nDeploy to production after approval.",
3219
+ notes: "Roll out in both environments",
3220
+ },
3221
+ },
3222
+ question_results: [
3223
+ {
3224
+ question: "Pick deployment target",
3225
+ header: "Target",
3226
+ question_index: 0,
3227
+ total_questions: 1,
3228
+ selected_options: [
3229
+ {
3230
+ option_id: "question_0",
3231
+ label: "Staging",
3232
+ description: "Low-risk validation",
3233
+ preview: "Deploy to staging first.",
3234
+ },
3235
+ {
3236
+ option_id: "question_1",
3237
+ label: "Production",
3238
+ description: "Customer-facing rollout",
3239
+ preview: "Deploy to production after approval.",
3240
+ },
3241
+ ],
3242
+ annotation: {
3243
+ preview: "Deploy to staging first.\n\nDeploy to production after approval.",
3244
+ notes: "Roll out in both environments",
3245
+ },
3246
+ },
3247
+ ],
3248
+ });
3165
3249
  });
3166
3250
  test("normalizeToolKind maps known tool names", () => {
3167
3251
  assert.equal(normalizeToolKind("Bash"), "execute");
@@ -4329,6 +4413,7 @@ test("createToolCall maps project and artifact tools to compact titles", () => {
4329
4413
  file_path: "C:/work/report.html",
4330
4414
  favicon: "R",
4331
4415
  label: "report-v2",
4416
+ description: "Quarterly report",
4332
4417
  });
4333
4418
  const artifactFallback = createToolCall("tc-artifact-path", "Artifact", {
4334
4419
  file_path: "C:/work/report.html",
@@ -4341,6 +4426,7 @@ test("createToolCall maps project and artifact tools to compact titles", () => {
4341
4426
  assert.equal(projectSearch.title, "Projects: search migration");
4342
4427
  assert.equal(artifactWithLabel.kind, "other");
4343
4428
  assert.equal(artifactWithLabel.title, "Artifact: report-v2");
4429
+ assert.equal(artifactWithLabel.raw_input.description, "Quarterly report");
4344
4430
  assert.equal(artifactFallback.title, "Artifact: C:/work/report.html");
4345
4431
  assert.equal(rolePicker.kind, "other");
4346
4432
  assert.equal(rolePicker.title, "ShowOnboardingRolePicker");
@@ -5020,7 +5106,7 @@ test("looksLikeAuthRequired detects login hints", () => {
5020
5106
  assert.equal(looksLikeAuthRequired("normal tool output"), false);
5021
5107
  });
5022
5108
  test("agent sdk version compatibility check matches pinned version", () => {
5023
- assert.equal(resolveInstalledAgentSdkVersion(), "0.3.193");
5109
+ assert.equal(resolveInstalledAgentSdkVersion(), "0.3.198");
5024
5110
  assert.equal(agentSdkVersionCompatibilityError(), undefined);
5025
5111
  });
5026
5112
  test("mapSessionMessagesToUpdates maps message content blocks", () => {
@@ -6088,6 +6174,7 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () =>
6088
6174
  const mapped = mapAvailableModels([
6089
6175
  {
6090
6176
  value: "sonnet",
6177
+ resolvedModel: "claude-sonnet-5",
6091
6178
  displayName: "Claude Sonnet",
6092
6179
  description: "Balanced model",
6093
6180
  supportsEffort: true,
@@ -6106,6 +6193,7 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () =>
6106
6193
  assert.deepEqual(mapped, [
6107
6194
  {
6108
6195
  id: "sonnet",
6196
+ resolved_model: "claude-sonnet-5",
6109
6197
  display_name: "Claude Sonnet",
6110
6198
  description: "Balanced model",
6111
6199
  supports_effort: true,
@@ -6123,12 +6211,13 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () =>
6123
6211
  },
6124
6212
  ]);
6125
6213
  });
6126
- test("mapAvailableModels filters unavailable Fable models while preserving unknown ids", () => {
6214
+ test("mapAvailableModels preserves Fable models and unknown ids", () => {
6127
6215
  const mapped = mapAvailableModels([
6128
6216
  {
6129
6217
  value: "fable",
6218
+ resolvedModel: "claude-fable-5",
6130
6219
  displayName: "Claude Fable",
6131
- description: "Unavailable model alias",
6220
+ description: "Default model alias",
6132
6221
  supportsEffort: true,
6133
6222
  },
6134
6223
  {
@@ -6151,6 +6240,28 @@ test("mapAvailableModels filters unavailable Fable models while preserving unkno
6151
6240
  },
6152
6241
  ]);
6153
6242
  assert.deepEqual(mapped, [
6243
+ {
6244
+ id: "fable",
6245
+ resolved_model: "claude-fable-5",
6246
+ display_name: "Claude Fable",
6247
+ description: "Default model alias",
6248
+ supports_effort: true,
6249
+ supported_effort_levels: [],
6250
+ },
6251
+ {
6252
+ id: "claude-fable-5",
6253
+ display_name: "Claude Fable 5",
6254
+ description: "Unavailable model",
6255
+ supports_effort: true,
6256
+ supported_effort_levels: [],
6257
+ },
6258
+ {
6259
+ id: "claude-fable-5-20260612",
6260
+ display_name: "Claude Fable 5 dated",
6261
+ description: "Unavailable dated model",
6262
+ supports_effort: true,
6263
+ supported_effort_levels: [],
6264
+ },
6154
6265
  {
6155
6266
  id: "claude-unknown-1",
6156
6267
  display_name: "Claude Unknown",
@@ -6160,6 +6271,26 @@ test("mapAvailableModels filters unavailable Fable models while preserving unkno
6160
6271
  },
6161
6272
  ]);
6162
6273
  });
6274
+ test("resolveCurrentModel matches full Fable runtime ids to the fable alias", () => {
6275
+ const session = makeSessionState();
6276
+ session.model = "fable";
6277
+ session.requestedModelId = "fable";
6278
+ session.resolvedRuntimeModelId = "claude-fable-5-20260612";
6279
+ session.availableModels = [
6280
+ {
6281
+ id: "fable",
6282
+ resolved_model: "claude-fable-5",
6283
+ display_name: "Claude Fable 5",
6284
+ supports_effort: true,
6285
+ supported_effort_levels: ["low", "medium", "high", "xhigh", "max"],
6286
+ },
6287
+ ];
6288
+ const currentModel = resolveCurrentModel(session);
6289
+ assert.equal(currentModel.display_name_short, "Fable 5");
6290
+ assert.equal(currentModel.display_name_long, "Claude Fable 5");
6291
+ assert.equal(currentModel.catalog_id, "fable");
6292
+ assert.equal(currentModel.supports_effort, true);
6293
+ });
6163
6294
  test("resolveCurrentModel keeps 1M context suffix in short and long display names", () => {
6164
6295
  const session = makeSessionState();
6165
6296
  session.resolvedRuntimeModelId = "claude-opus-4-7[1m]";
package/package.json CHANGED
@@ -1,13 +1,18 @@
1
1
  {
2
2
  "name": "claude-code-rust",
3
- "version": "0.12.4",
3
+ "version": "0.13.0",
4
4
  "description": "Claude Code Rust - native Rust terminal interface for Claude Code",
5
5
  "keywords": [
6
6
  "cli",
7
7
  "tui",
8
8
  "claude",
9
+ "claude-code",
10
+ "anthropic",
11
+ "agent-sdk",
9
12
  "ai",
10
- "terminal"
13
+ "terminal",
14
+ "rust",
15
+ "ratatui"
11
16
  ],
12
17
  "license": "Apache-2.0",
13
18
  "repository": {
@@ -29,7 +34,7 @@
29
34
  "README.md"
30
35
  ],
31
36
  "dependencies": {
32
- "@anthropic-ai/claude-agent-sdk": "0.3.193",
37
+ "@anthropic-ai/claude-agent-sdk": "0.3.198",
33
38
  "@anthropic-ai/sdk": "0.106.0",
34
39
  "@modelcontextprotocol/sdk": "1.29.0",
35
40
  "zod": "4.4.3"