wip(0.8.62): GLM-5.2 default routing, sub-agent provenance, OSC8 rewrite, user-input event

Checkpoint of in-flight 0.8.62 work (compiles, fmt clean, targeted tests green)
before layering the multi-subagent TUI freeze investigation on top. Four
orthogonal themes, none of which touch the freeze path:

- GLM-5.2 default + sub-agent model routing: DEFAULT_ZAI_MODEL -> GLM-5.2;
  GLM-5-Turbo registered as the faster/explore sibling; type:explore now
  defaults to model_strength:faster unless an explicit model is given;
  Codex faster lane resolves honest `low` instead of silent `off`.

- Sub-agent summary provenance (#2652): child self-reports are stamped with a
  re-verify note, or head+tail truncated past the 12k char budget, and the
  sentinel carries summary_kind=complete|truncated so the parent can branch.

- OSC8 hyperlink rewrite (#3029): link payloads are extracted out of the
  ratatui buffer cells and re-emitted out-of-band by ColorCompatBackend, so
  the ESC bytes never corrupt display columns.

- User-input request event (#3102): protocol parity for the new
  EventFrame::UserInputRequest variant with backwards-compatible defaults.

Workspace version intentionally left at 0.8.61 per the CHANGELOG note; the
full bump to 0.8.62 is deferred until routing is smoke-tested end to end.
This commit is contained in:
CodeWhale Agent committed 2026-06-16 15:44:03 -07:00
1 parent 14ac0319e8
commit fab0c036b9
26 files changed
+1619 -227

No files matched your search

+33
View File
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Default model + sub-agent routing
- **GLM-5.2 is now the default direct Z.AI model.** `DEFAULT_ZAI_MODEL` resolves
to `GLM-5.2` in both `codewhale-tui` and `codewhale-config`; the `glm-5.1`
alias still resolves to `GLM-5.1` (the defaulting was decoupled from the alias
arm so it no longer tracks the default). Docs and `config.example.toml` no
longer describe GLM-5.2 as an opt-in preview.
- **GLM-5-Turbo registered as a real model** and wired as the faster/explore
sub-agent sibling for the GLM family: a `GLM-5.2` parent routes
faster/explore children to `GLM-5-Turbo` (direct Z.ai) and `z-ai/glm-5-turbo`
(OpenRouter), instead of down to GLM-5.1. GLM-5.1 and GLM-5-Turbo themselves
have no cheaper tier and keep children on the parent.
- **`type: "explore"` sub-agents default to `model_strength: "faster"`.** Bounded
read-only lookup/search/status work now uses the cheaper same-family sibling
automatically, unless an explicit `model` or `model_strength: "same"` is
supplied. Non-explore roles keep the conservative `same` default.
- **GPT-5.5 / OpenAI Codex faster route stays on GPT-5.5** with reasoning
resolved to `low` (the Codex Responses API has no true `off`, so the resolved
effort is now honest `low` rather than `off` silently rewritten). No
DeepSeek/GLM fallback is fabricated when no cheaper same-provider sibling
exists. DeepSeek Pro→Flash routing and its no-thinking faster lane are
unchanged.
- **Base prompt / delegate skill guidance** updated to encourage parallel
read-only exploration (2-4 `type: "explore"` sub-agents) for broad repo,
version, branch, benchmark, and API-surface investigations, while keeping
architecture, integration, and final verification in the parent. The
delegate skill examples now use provider-neutral `model_strength` instead of
hardcoded DeepSeek model ids.
> Note: the workspace version is intentionally still `0.8.61`. A full version
> bump to `0.8.62` is deferred until the routing change is smoke-tested end to
> end against the live Z.ai and OpenRouter endpoints.
### Retroactive credits
A credit-reconciliation pass found shipped community fixes that were never
+6 -4
View File
@@ -42,9 +42,11 @@ base_url = "https://api.deepseek.com/beta"
# xiaomi/mimo-v2.5-pro — OpenRouter Xiaomi MiMo 2.5 Pro
# xiaomi/mimo-v2.5 — OpenRouter Xiaomi MiMo 2.5
# z-ai/glm-5.1 — OpenRouter Z.AI GLM 5.1
# z-ai/glm-5.2 — OpenRouter Z.AI GLM 5.2 (opt-in preview)
# GLM-5.1 — default direct Z.AI Coding Plan model
# GLM-5.2 — direct Z.AI opt-in preview model
# z-ai/glm-5.2 — OpenRouter Z.AI GLM 5.2 (default)
# z-ai/glm-5-turbo — OpenRouter Z.AI GLM 5 Turbo (faster/explore sub-agent sibling)
# GLM-5.2 — default direct Z.AI Coding Plan model
# GLM-5.1 — direct Z.AI smaller model
# GLM-5-Turbo — direct Z.AI fast model (faster/explore sub-agent sibling)
# step-3.7-flash — default direct StepFun / StepFlash model ID
# kimi-k2.6 — default Moonshot/Kimi model ID
# gpt-4.1 — default generic OpenAI-compatible model ID
@@ -414,7 +416,7 @@ max_subagents = 10 # optional (1-20)
# base_url = "https://api.z.ai/api/coding/paas/v4"
# # General API endpoint, if you are not using the Coding Plan:
# # base_url = "https://api.z.ai/api/paas/v4"
# model = "GLM-5.1" # default; GLM-5.2 is opt-in preview
# model = "GLM-5.2" # default; GLM-5.1 is the smaller model, GLM-5-Turbo the fast sub-agent sibling
# StepFun / StepFlash direct OpenAI-compatible endpoint (https://platform.stepfun.com)
[providers.stepfun]
+28 -6
View File
@@ -328,6 +328,25 @@ impl Default for ModelRegistry {
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "z-ai/glm-5-turbo".to_string(),
provider: ProviderKind::Openrouter,
aliases: vec!["glm-5-turbo".to_string(), "zai-glm-5-turbo".to_string()],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "GLM-5.2".to_string(),
provider: ProviderKind::Zai,
aliases: vec![
"glm-5.2".to_string(),
"glm-5-2".to_string(),
"zai-glm-5.2".to_string(),
"zai-glm-5-2".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "GLM-5.1".to_string(),
provider: ProviderKind::Zai,
@@ -341,13 +360,12 @@ impl Default for ModelRegistry {
supports_reasoning: true,
},
ModelInfo {
id: "GLM-5.2".to_string(),
id: "GLM-5-Turbo".to_string(),
provider: ProviderKind::Zai,
aliases: vec![
"glm-5.2".to_string(),
"glm-5-2".to_string(),
"zai-glm-5.2".to_string(),
"zai-glm-5-2".to_string(),
"glm-5-turbo".to_string(),
"glm-5turbo".to_string(),
"zai-glm-5-turbo".to_string(),
],
supports_tools: true,
supports_reasoning: true,
@@ -1395,9 +1413,10 @@ mod tests {
fn zai_direct_models_resolve_when_provider_hinted() {
let registry = ModelRegistry::default();
// GLM-5.2 is now the default direct Z.AI model.
let default = registry.resolve(None, Some(ProviderKind::Zai));
assert_eq!(default.resolved.provider, ProviderKind::Zai);
assert_eq!(default.resolved.id, "GLM-5.1");
assert_eq!(default.resolved.id, "GLM-5.2");
for (alias, expected) in [
("GLM-5.1", "GLM-5.1"),
@@ -1405,6 +1424,9 @@ mod tests {
("GLM-5.2", "GLM-5.2"),
("glm-5.2", "GLM-5.2"),
("zai-glm-5-2", "GLM-5.2"),
("GLM-5-Turbo", "GLM-5-Turbo"),
("glm-5-turbo", "GLM-5-Turbo"),
("zai-glm-5-turbo", "GLM-5-Turbo"),
] {
let resolved = registry.resolve(Some(alias), Some(ProviderKind::Zai));
+43 -1
View File
@@ -16,7 +16,7 @@ use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink, UnixSocketH
use codewhale_mcp::McpManager;
use codewhale_protocol::{
AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadGoalClearParams,
ThreadGoalGetParams, ThreadGoalSetParams, ThreadRequest, ThreadResponse,
ThreadGoalGetParams, ThreadGoalSetParams, ThreadRequest, ThreadResponse, UserInputAnswerEvent,
};
use codewhale_state::StateStore;
use codewhale_tools::{ToolCall, ToolRegistry};
@@ -28,6 +28,16 @@ use tokio::sync::{Mutex, RwLock};
use tower_http::cors::CorsLayer;
use uuid::Uuid;
/// Answers submitted for a pending `request_user_input` clarification.
///
/// The headless runtime emits [`codewhale_protocol::EventFrame::UserInputRequest`]
/// fire-and-return (it has no resume channel, mirroring headless approval).
/// Clients POST answers back via [`AppRequest::SubmitUserInput`]; we record
/// them here keyed by `request_id` so a driver can retrieve and feed them into
/// the next turn as structured context. True in-flight resume would require an
/// awaiter in `invoke_tool` and is left as a follow-up.
type PendingUserInputAnswers = Vec<UserInputAnswerEvent>;
mod chat_completions;
const DEFAULT_CORS_ORIGINS: &[&str] = &[
@@ -71,6 +81,10 @@ struct AppState {
runtime: Arc<Mutex<Runtime>>,
registry: ModelRegistry,
auth_token: Option<String>,
/// Answers submitted via `AppRequest::SubmitUserInput`, keyed by
/// `request_id`. A driver polls this to resolve clarification questions
/// raised by the model during a headless run.
pending_user_input: Arc<Mutex<std::collections::HashMap<String, PendingUserInputAnswers>>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -367,6 +381,7 @@ fn build_state(config_path: Option<PathBuf>, auth_token: Option<String>) -> Resu
runtime: Arc::new(Mutex::new(runtime)),
registry,
auth_token,
pending_user_input: Arc::new(Mutex::new(std::collections::HashMap::new())),
})
}
@@ -992,6 +1007,33 @@ async fn process_app_request(
},
}
}
AppRequest::SubmitUserInput {
request_id,
answers,
} => {
// Record the user's answers against the pending clarification
// request so a driver can retrieve them. The headless runtime does
// not block on `request_user_input` (fire-and-return, like
// approval), so there is no in-flight turn to resume here — the
// caller is expected to feed these answers into the next turn.
let mut pending = state.pending_user_input.lock().await;
if pending.contains_key(&request_id) {
return AppResponse {
ok: false,
data: json!({
"error": "request_id already resolved",
"request_id": request_id,
}),
events: Vec::new(),
};
}
pending.insert(request_id.clone(), answers);
AppResponse {
ok: true,
data: json!({ "request_id": request_id, "resolved": true }),
events: Vec::new(),
}
}
}
}
+19 -5
View File
@@ -105,8 +105,14 @@ const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b";
const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
// Z.ai (GLM Coding Plan) defaults
const DEFAULT_ZAI_MODEL: &str = "GLM-5.1";
const DEFAULT_ZAI_MODEL: &str = "GLM-5.2";
const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1";
// GLM-5.2 is both the default and a named tier; the alias arm resolves the
// `glm-5.2` spelling to DEFAULT_ZAI_MODEL directly, so this constant is only
// referenced by the invariant test below.
#[allow(dead_code)]
const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2";
const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo";
const DEFAULT_ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
// StepFun / StepFlash defaults
const DEFAULT_STEPFUN_MODEL: &str = "step-3.7-flash";
@@ -2855,8 +2861,9 @@ fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
let normalized = model.trim().to_ascii_lowercase();
let normalized = normalized.replace(['_', ' '], "-");
match normalized.as_str() {
"glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(DEFAULT_ZAI_MODEL),
"glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(ZAI_GLM_5_2_MODEL),
"glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
"glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
"glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
_ => None,
}
}
@@ -6479,13 +6486,20 @@ mode = "token-plan-usa"
#[test]
fn zai_aliases_resolve_to_canonical_models() {
// GLM-5.2 is the default; the glm-5.1 alias must still resolve to 5.1
// (not to the default), and GLM-5-Turbo resolves to its own id.
assert_eq!(
normalize_model_for_provider(ProviderKind::Zai, "glm-5.1"),
DEFAULT_ZAI_MODEL
ZAI_GLM_5_1_MODEL
);
assert_eq!(
normalize_model_for_provider(ProviderKind::Zai, "glm-5-2"),
ZAI_GLM_5_2_MODEL
DEFAULT_ZAI_MODEL
);
assert_eq!(DEFAULT_ZAI_MODEL, ZAI_GLM_5_2_MODEL);
assert_eq!(
normalize_model_for_provider(ProviderKind::Zai, "glm-5-turbo"),
ZAI_GLM_5_TURBO_MODEL
);
assert_eq!(
normalize_model_for_provider(ProviderKind::Zai, "custom-glm-preview"),
+127 -1
View File
@@ -18,7 +18,7 @@ use codewhale_protocol::{
ResponseChannel, ReviewDecision, Thread, ThreadForkParams, ThreadGoal, ThreadGoalClearParams,
ThreadGoalGetParams, ThreadGoalProgressParams, ThreadGoalSetParams, ThreadGoalStatus,
ThreadListParams, ThreadReadParams, ThreadRequest, ThreadResponse, ThreadResumeParams,
ThreadSetNameParams, ThreadStatus, ToolPayload,
ThreadSetNameParams, ThreadStatus, ToolPayload, UserInputRequestEvent,
};
use codewhale_state::{
JobStateRecord, JobStateStatus, SessionSource, StateStore, ThreadGoalRecord,
@@ -1382,6 +1382,48 @@ impl Runtime {
}));
}
// Headless `request_user_input`: mirror the approval fire-and-return
// branch (issue #3102). The TUI intercepts this tool by name before
// dispatch and blocks on a reply channel; the headless runtime instead
// emits a typed `UserInputRequest` frame and returns a
// `user_input_required` status so the client can render the question
// and POST answers back via `AppRequest::SubmitUserInput`. It does NOT
// block — consistent with the headless approval model, which has no
// resume channel either.
if call.name == REQUEST_USER_INPUT_TOOL_NAME {
let request_id = format!("user-input-{}", Uuid::new_v4());
let arguments = match &call.payload {
ToolPayload::Function { arguments } => arguments.as_str(),
// Custom/Mcp/LocalShell can't carry a user_input payload; fall
// through to the generic dispatch error below.
_ => "",
};
let maybe_frame = user_input_request_frame(
call_id.clone(),
response_id.clone(),
request_id.clone(),
arguments,
);
let mut events = Vec::new();
if let Some(frame) = maybe_frame {
self.hooks
.emit(HookEvent::GenericEventFrame {
frame: Box::new(frame.clone()),
})
.await;
events.push(event_frame_payload(&frame));
}
return Ok(json!({
"ok": false,
"status": "user_input_required",
"execution_kind": execution_kind,
"response_id": response_id,
"request_id": request_id,
"precheck": precheck,
"events": events,
}));
}
let start_frame = EventFrame::ToolCallStart {
response_id: response_id.clone(),
tool_name: call.name.clone(),
@@ -1853,6 +1895,33 @@ fn approval_request_frame(
})
}
/// Build an [`EventFrame::UserInputRequest`] for a headless
/// `request_user_input` tool call, mirroring [`approval_request_frame`].
///
/// `arguments` is the raw JSON arguments string the model supplied to the
/// `request_user_input` tool (a `ToolPayload::Function` body). On parse
/// failure we return `None` so the caller falls through to the generic tool
/// error path rather than silently dropping the request.
fn user_input_request_frame(
call_id: String,
turn_id: String,
request_id: String,
arguments: &str,
) -> Option<EventFrame> {
let parsed: Value = serde_json::from_str(arguments).ok()?;
// Extract the `questions` array and lift it into the headless event
// shape. We tolerate missing `allow_free_text`/`multi_select` (default
// false) and extra fields, matching the lenient TUI `from_value` path.
let questions = parsed.get("questions").cloned().filter(Value::is_array)?;
let request = UserInputRequestEvent {
call_id,
turn_id,
request_id,
questions: serde_json::from_value(questions).ok()?,
};
Some(EventFrame::UserInputRequest { request })
}
fn approval_requirement_payload(requirement: &ExecApprovalRequirement) -> Value {
match requirement {
ExecApprovalRequirement::Skip {
@@ -1923,6 +1992,13 @@ fn event_frame_payload(frame: &EventFrame) -> Value {
.unwrap_or_else(|_| json!({"event":"error","message":"failed to encode event frame"}))
}
/// Tool name that triggers the headless clarification-question flow.
///
/// Mirrors the TUI's `REQUEST_USER_INPUT_NAME`
/// (`crates/tui/src/core/engine/tool_catalog.rs`); duplicated here rather than
/// depended on across crates so `core` stays free of `tui` imports.
const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input";
fn json_optional_string(value: &Value) -> Option<String> {
if value.is_null() {
None
@@ -2212,6 +2288,56 @@ mod tests {
assert_eq!(request.reason, requirement.reason());
}
#[test]
fn user_input_request_frame_lifts_questions_from_arguments() {
// issue #3102: the headless frame constructor must parse the model's
// `request_user_input` arguments and lift the questions into the
// UserInputRequestEvent, defaulting the boolean flags when omitted.
let arguments = r#"{"questions":[{"header":"Scope","id":"scope","question":"Which?","options":[{"label":"A","description":"a"},{"label":"B","description":"b"}],"allow_free_text":true}]}"#;
let frame = user_input_request_frame(
"call-1".to_string(),
"turn-1".to_string(),
"ui-1".to_string(),
arguments,
)
.expect("user input frame");
let EventFrame::UserInputRequest { request } = frame else {
panic!("expected user_input_request frame");
};
assert_eq!(request.call_id, "call-1");
assert_eq!(request.turn_id, "turn-1");
assert_eq!(request.request_id, "ui-1");
assert_eq!(request.questions.len(), 1);
assert_eq!(request.questions[0].id, "scope");
assert!(request.questions[0].allow_free_text);
// multi_select omitted in the payload → defaults to false.
assert!(!request.questions[0].multi_select);
assert_eq!(request.questions[0].options.len(), 2);
}
#[test]
fn user_input_request_frame_returns_none_on_invalid_arguments() {
// On parse failure the constructor returns None so invoke_tool falls
// through to the generic tool error path instead of silently dropping.
let frame = user_input_request_frame(
"call-1".to_string(),
"turn-1".to_string(),
"ui-1".to_string(),
"not json",
);
assert!(frame.is_none());
// Valid JSON but missing the questions array is also rejected.
let frame = user_input_request_frame(
"call-1".to_string(),
"turn-1".to_string(),
"ui-1".to_string(),
r#"{"foo":"bar"}"#,
);
assert!(frame.is_none());
}
#[test]
fn enqueue_creates_queued_job_with_zero_progress() {
let mut jm = JobManager::default();
+74
View File
@@ -279,6 +279,14 @@ pub enum AppRequest {
Models,
/// List threads that are currently loaded in memory.
ThreadLoadedList,
/// Submit answers to a prior [`EventFrame::UserInputRequest`].
///
/// `request_id` must match a pending clarification request. Headless
/// clients use this to return the user's selections back to the runtime.
SubmitUserInput {
request_id: String,
answers: Vec<UserInputAnswerEvent>,
},
}
/// Response to an [`AppRequest`].
@@ -492,6 +500,67 @@ pub struct NetworkApprovalContext {
pub protocol: String,
}
/// A selectable option presented to the user in a clarification question.
///
/// Headless serialization shape for the `request_user_input` model tool,
/// mirrored after the TUI's `UserInputOption`. Shared by the
/// [`EventFrame::UserInputRequest`] frame and the [`AppRequest::SubmitUserInput`]
/// reply path so both surfaces agree on the question schema.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UserInputOptionEvent {
/// Short label for the option (also the value submitted when picked).
pub label: String,
/// Longer description shown alongside the label.
pub description: String,
}
/// A single clarification question posed to the user.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UserInputQuestionEvent {
/// Compact header shown as the question title.
pub header: String,
/// Stable identifier used to correlate answers back to this question.
pub id: String,
/// The question body.
pub question: String,
/// 2-4 suggested answers.
pub options: Vec<UserInputOptionEvent>,
/// When `true`, the client should also offer a free-text response.
#[serde(default)]
pub allow_free_text: bool,
/// When `true`, the user may select more than one option.
#[serde(default)]
pub multi_select: bool,
}
/// An event requesting structured user input via a model-tool call.
///
/// Sibling of [`ExecApprovalRequestEvent`] for the clarification-question
/// flow. Emitted fire-and-return by `Runtime::invoke_tool` when the model
/// invokes `request_user_input` in a headless context.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UserInputRequestEvent {
/// Identifier of the tool call requesting input.
pub call_id: String,
/// The turn during which the request was made.
pub turn_id: String,
/// Unique identifier for this user-input request (clients reply with it).
pub request_id: String,
/// 1-3 questions to present.
pub questions: Vec<UserInputQuestionEvent>,
}
/// One answer to a clarification question.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UserInputAnswerEvent {
/// The `id` of the question this answer corresponds to.
pub id: String,
/// The selected option's label, or `"Other"` for a free-text response.
pub label: String,
/// The resolved value (option label, or the typed free-text).
pub value: String,
}
/// An event requesting user approval for a command execution or patch application.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecApprovalRequestEvent {
@@ -605,6 +674,11 @@ pub enum EventFrame {
ExecApprovalRequest { request: ExecApprovalRequestEvent },
/// User approval is needed for applying a patch.
ApplyPatchApprovalRequest { request: ExecApprovalRequestEvent },
/// A model tool is requesting structured clarification input from the user.
///
/// Headless sibling of the TUI's `request_user_input` modal flow.
/// `request_id` correlates with an [`AppRequest::SubmitUserInput`] reply.
UserInputRequest { request: UserInputRequestEvent },
/// An MCP server is requesting user input (elicitation).
ElicitationRequest {
server_name: String,
+115 -2
View File
@@ -1,6 +1,7 @@
use codewhale_protocol::{
EventFrame, ThreadGoal, ThreadGoalProgressParams, ThreadGoalSetParams, ThreadGoalStatus,
ThreadListParams, ThreadRequest, ThreadResumeParams,
AppRequest, EventFrame, ThreadGoal, ThreadGoalProgressParams, ThreadGoalSetParams,
ThreadGoalStatus, ThreadListParams, ThreadRequest, ThreadResumeParams, UserInputAnswerEvent,
UserInputOptionEvent, UserInputQuestionEvent, UserInputRequestEvent,
runtime::{RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION, RuntimeEventEnvelope},
};
use serde_json::{Value, json};
@@ -226,3 +227,115 @@ fn runtime_event_envelope_preserves_unknown_fields() {
assert!(encoded["turn_id"].is_null());
assert!(encoded["item_id"].is_null());
}
#[test]
fn user_input_request_event_frame_round_trip() {
// issue #3102: the new EventFrame::UserInputRequest variant must tag as
// "user_input_request" and round-trip the full nested question schema,
// including the allow_free_text / multi_select booleans.
let frame = EventFrame::UserInputRequest {
request: UserInputRequestEvent {
call_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
request_id: "ui-1".to_string(),
questions: vec![UserInputQuestionEvent {
header: "Scope".to_string(),
id: "scope".to_string(),
question: "Which surfaces?".to_string(),
options: vec![
UserInputOptionEvent {
label: "TUI".to_string(),
description: "Modal flow".to_string(),
},
UserInputOptionEvent {
label: "All".to_string(),
description: "TUI + headless".to_string(),
},
],
allow_free_text: true,
multi_select: true,
}],
},
};
let encoded = serde_json::to_value(&frame).expect("serialize user input frame");
assert_eq!(encoded["event"], "user_input_request");
assert_eq!(encoded["request"]["call_id"], "call-1");
assert_eq!(encoded["request"]["request_id"], "ui-1");
assert_eq!(encoded["request"]["questions"][0]["header"], "Scope");
assert_eq!(encoded["request"]["questions"][0]["allow_free_text"], true);
assert_eq!(encoded["request"]["questions"][0]["multi_select"], true);
assert_eq!(
encoded["request"]["questions"][0]["options"][0]["label"],
"TUI"
);
// Round-trips back through serde.
let decoded: EventFrame =
serde_json::from_value(encoded).expect("deserialize user input frame");
let EventFrame::UserInputRequest { request } = decoded else {
panic!("expected user_input_request frame after round-trip");
};
assert_eq!(request.request_id, "ui-1");
assert_eq!(request.questions.len(), 1);
assert!(request.questions[0].allow_free_text);
assert!(request.questions[0].multi_select);
}
#[test]
fn user_input_request_event_defaults_flags_when_omitted() {
// Backwards compatibility: omitting allow_free_text/multi_select in the
// wire JSON must deserialize both to false (matching the TUI's leniency).
let input = json!({
"event": "user_input_request",
"request": {
"call_id": "c",
"turn_id": "t",
"request_id": "r",
"questions": [{
"header": "H",
"id": "i",
"question": "Q?",
"options": [
{ "label": "A", "description": "a" },
{ "label": "B", "description": "b" }
]
}]
}
});
let decoded: EventFrame = serde_json::from_value(input).expect("deserialize without flags");
let EventFrame::UserInputRequest { request } = decoded else {
panic!("expected user_input_request frame");
};
assert!(!request.questions[0].allow_free_text);
assert!(!request.questions[0].multi_select);
}
#[test]
fn submit_user_input_app_request_round_trip() {
// issue #3102: the headless client→server reply variant must tag as
// "submit_user_input" and carry the answer list.
let req = AppRequest::SubmitUserInput {
request_id: "ui-1".to_string(),
answers: vec![UserInputAnswerEvent {
id: "scope".to_string(),
label: "All".to_string(),
value: "All".to_string(),
}],
};
let encoded = serde_json::to_string(&req).expect("serialize submit request");
assert!(encoded.contains("submit_user_input"));
assert!(encoded.contains("\"request_id\":\"ui-1\""));
let decoded: AppRequest = serde_json::from_str(&encoded).expect("deserialize submit request");
let AppRequest::SubmitUserInput {
request_id,
answers,
} = decoded
else {
panic!("expected submit_user_input after round-trip");
};
assert_eq!(request_id, "ui-1");
assert_eq!(answers.len(), 1);
assert_eq!(answers[0].label, "All");
}
+5 -3
View File
@@ -32,24 +32,26 @@ Do not delegate tiny one-step tasks, ambiguous product decisions, destructive op
Use `agent` for a focused child run. Launch independent children together so they can run in parallel.
Prefer provider-neutral `model_strength` over hardcoded model ids — `type: "explore"` already defaults to `model_strength: "faster"` (the cheaper same-family sibling), so for read-only exploration you can usually omit it entirely:
```json
{
"name": "config_audit",
"prompt": "Inspect crates/tui/src/config.rs and crates/tui/src/settings.rs for duplicate model-default logic. Return file/line findings only; do not edit files.",
"type": "explore",
"model": "deepseek-v4-flash",
"model_strength": "faster",
"cwd": "."
}
```
For code changes, give the child a precise write boundary and tell it not to revert unrelated edits:
For code changes, give the child a precise write boundary and tell it not to revert unrelated edits. Keep implementation children capable with `model_strength: "same"`:
```json
{
"name": "docs_patch",
"prompt": "Update only docs/configuration.md to document the new [statusline] keys. Match the surrounding style. Do not edit other files.",
"type": "implementer",
"model": "deepseek-v4-flash",
"model_strength": "same",
"cwd": "."
}
```
+47 -20
View File
@@ -67,6 +67,7 @@ pub const OPENROUTER_GEMMA_4_31B_MODEL: &str = "google/gemma-4-31b-it";
pub const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it";
pub const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1";
pub const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2";
pub const OPENROUTER_GLM_5_TURBO_MODEL: &str = "z-ai/glm-5-turbo";
pub const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code";
pub const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6";
pub const OPENROUTER_MINIMAX_M3_MODEL: &str = "minimax/minimax-m3";
@@ -172,8 +173,10 @@ pub const COMMON_DEEPSEEK_MODELS: &[&str] = &[
"deepseek/deepseek-v4-flash",
];
pub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &["deepseek-v4-pro", "deepseek-v4-flash"];
pub const DEFAULT_ZAI_MODEL: &str = "GLM-5.1";
pub const DEFAULT_ZAI_MODEL: &str = "GLM-5.2";
pub const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1";
pub const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2";
pub const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo";
pub const DEFAULT_ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
pub const DEFAULT_STEPFUN_MODEL: &str = "step-3.7-flash";
pub const DEFAULT_STEPFUN_BASE_URL: &str = "https://api.stepfun.ai/v1";
@@ -729,6 +732,9 @@ fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
Some(OPENROUTER_GLM_5_2_MODEL)
}
OPENROUTER_GLM_5_TURBO_MODEL | "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => {
Some(OPENROUTER_GLM_5_TURBO_MODEL)
}
OPENROUTER_KIMI_K2_7_CODE_MODEL
| "kimi"
| "kimi-k2"
@@ -880,8 +886,9 @@ fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
let normalized = model.trim().to_ascii_lowercase();
let normalized = normalized.replace(['_', ' '], "-");
match normalized.as_str() {
"glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(DEFAULT_ZAI_MODEL),
"glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(ZAI_GLM_5_2_MODEL),
"glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
"glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
"glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
_ => None,
}
}
@@ -1046,7 +1053,7 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati
ApiProvider::Openai | ApiProvider::Atlascloud => OFFICIAL_DEEPSEEK_MODELS.to_vec(),
ApiProvider::Together => vec![DEFAULT_TOGETHER_MODEL],
ApiProvider::OpenaiCodex => vec![DEFAULT_OPENAI_CODEX_MODEL],
ApiProvider::Zai => vec![DEFAULT_ZAI_MODEL, ZAI_GLM_5_2_MODEL],
ApiProvider::Zai => vec![DEFAULT_ZAI_MODEL, ZAI_GLM_5_1_MODEL, ZAI_GLM_5_TURBO_MODEL],
ApiProvider::Stepfun => vec![DEFAULT_STEPFUN_MODEL],
ApiProvider::Anthropic => vec![
ANTHROPIC_OPUS_MODEL,
@@ -1123,8 +1130,10 @@ pub struct TuiConfig {
/// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,
/// WezTerm, Alacritty, recent gnome-terminal/konsole) make them
/// Cmd+click-openable. Terminals without OSC 8 support render the plain
/// label and ignore the escape. Defaults to `true`; set `false` for
/// terminals that misrender the sequence.
/// label and ignore the escape. Defaults to on for macOS/Linux and off for
/// Windows legacy consoles; set `false` to suppress everywhere (e.g. for a
/// terminal that misrenders the sequence). OSC 8 escapes are emitted
/// out-of-band, so buffer-column corruption is not a concern.
pub osc8_links: Option<bool>,
/// High-level notification trigger condition. When set, overrides the
/// `[notifications].threshold_secs` gate from the lower-level
@@ -8804,20 +8813,32 @@ api_key = "old-openrouter-key"
}
#[test]
fn model_completion_names_for_zai_keep_5_1_default_and_include_5_2() {
fn model_completion_names_for_zai_lists_default_5_1_and_turbo() {
let models = model_completion_names_for_provider(ApiProvider::Zai);
// GLM-5.2 is the default and must be first; GLM-5.1 stays available,
// and GLM-5-Turbo is the faster sub-agent sibling.
assert_eq!(models.first().copied(), Some(DEFAULT_ZAI_MODEL));
assert!(models.contains(&ZAI_GLM_5_2_MODEL));
assert_eq!(DEFAULT_ZAI_MODEL, ZAI_GLM_5_2_MODEL);
assert!(models.contains(&ZAI_GLM_5_1_MODEL));
assert!(models.contains(&ZAI_GLM_5_TURBO_MODEL));
// No accidental duplicate entries.
let mut sorted = models.to_vec();
sorted.sort_unstable();
let mut deduped = sorted.clone();
deduped.dedup();
assert_eq!(sorted, deduped);
}
#[test]
fn normalize_model_name_for_zai_canonicalizes_current_glm_models() {
for (alias, expected) in [
("glm-5.1", DEFAULT_ZAI_MODEL),
("glm-5-1", DEFAULT_ZAI_MODEL),
("glm-5.2", ZAI_GLM_5_2_MODEL),
("zai-glm-5-2", ZAI_GLM_5_2_MODEL),
("glm-5.1", ZAI_GLM_5_1_MODEL),
("glm-5-1", ZAI_GLM_5_1_MODEL),
("glm-5.2", DEFAULT_ZAI_MODEL),
("zai-glm-5-2", DEFAULT_ZAI_MODEL),
("glm-5-turbo", ZAI_GLM_5_TURBO_MODEL),
("zai-glm-5-turbo", ZAI_GLM_5_TURBO_MODEL),
] {
assert_eq!(
normalize_model_name_for_provider(ApiProvider::Zai, alias).as_deref(),
@@ -11815,20 +11836,26 @@ model = "deepseek-ai/deepseek-v4-pro"
}
#[test]
fn provider_capability_zai_keeps_5_1_default_and_tracks_5_2_window() {
fn provider_capability_zai_defaults_to_5_2_and_tracks_5_1_and_turbo() {
// GLM-5.2 is now the default direct Z.AI model (1M context window).
let default = provider_capability(ApiProvider::Zai, DEFAULT_ZAI_MODEL);
assert_eq!(default.resolved_model, DEFAULT_ZAI_MODEL);
assert_eq!(default.context_window, 202_752);
assert_eq!(default.resolved_model, ZAI_GLM_5_2_MODEL);
assert_eq!(default.context_window, 1_000_000);
assert_eq!(default.max_output, 131_072);
assert!(default.thinking_supported);
assert!(!default.cache_telemetry_supported);
let preview = provider_capability(ApiProvider::Zai, ZAI_GLM_5_2_MODEL);
assert_eq!(preview.resolved_model, ZAI_GLM_5_2_MODEL);
assert_eq!(preview.context_window, 1_000_000);
assert_eq!(preview.max_output, 131_072);
assert!(preview.thinking_supported);
assert!(!preview.cache_telemetry_supported);
// GLM-5.1 remains available as an explicit model (smaller window).
let v51 = provider_capability(ApiProvider::Zai, ZAI_GLM_5_1_MODEL);
assert_eq!(v51.resolved_model, ZAI_GLM_5_1_MODEL);
assert_eq!(v51.context_window, 202_752);
assert_eq!(v51.max_output, 131_072);
assert!(v51.thinking_supported);
// GLM-5-Turbo is the faster sub-agent sibling.
let turbo = provider_capability(ApiProvider::Zai, ZAI_GLM_5_TURBO_MODEL);
assert_eq!(turbo.resolved_model, ZAI_GLM_5_TURBO_MODEL);
}
#[test]
+23 -8
View File
@@ -57,8 +57,11 @@ pub(crate) fn provider_router_candidates(
let normalized = crate::config::normalize_model_name_for_provider(provider, current_model)
.unwrap_or_else(|| current_model.to_string());
return RouterCandidates {
// GLM-5.2 (the default) routes faster/explore children to GLM-5-Turbo,
// the same-family fast sibling. GLM-5.1 and GLM-5-Turbo itself have no
// cheaper tier and keep children on the parent model.
cheap: if normalized == crate::config::ZAI_GLM_5_2_MODEL {
Some(crate::config::DEFAULT_ZAI_MODEL.to_string())
Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string())
} else {
None
},
@@ -71,12 +74,16 @@ pub(crate) fn provider_router_candidates(
crate::config::normalize_model_name_for_provider(provider, current_model)
&& matches!(
normalized.as_str(),
crate::config::OPENROUTER_GLM_5_1_MODEL | crate::config::OPENROUTER_GLM_5_2_MODEL
crate::config::OPENROUTER_GLM_5_1_MODEL
| crate::config::OPENROUTER_GLM_5_2_MODEL
| crate::config::OPENROUTER_GLM_5_TURBO_MODEL
)
{
return RouterCandidates {
// z-ai/glm-5.2 routes faster children to z-ai/glm-5-turbo; the 5.1
// and turbo ids have no cheaper tier and keep children on parent.
cheap: if normalized == crate::config::OPENROUTER_GLM_5_2_MODEL {
Some(crate::config::OPENROUTER_GLM_5_1_MODEL.to_string())
Some(crate::config::OPENROUTER_GLM_5_TURBO_MODEL.to_string())
} else {
None
},
@@ -1089,15 +1096,23 @@ mod tests {
let zai = provider_router_candidates(ApiProvider::Zai, "GLM-5.2");
assert_eq!(zai.big, "GLM-5.2");
assert_eq!(zai.cheap.as_deref(), Some("GLM-5.1"));
// GLM-5.2 faster/explore children route to GLM-5-Turbo (same-family fast
// sibling), not back down to GLM-5.1.
assert_eq!(zai.cheap.as_deref(), Some("GLM-5-Turbo"));
let openrouter_glm = provider_router_candidates(ApiProvider::Openrouter, "z-ai/glm-5.2");
assert_eq!(openrouter_glm.big, "z-ai/glm-5.2");
assert_eq!(openrouter_glm.cheap.as_deref(), Some("z-ai/glm-5.1"));
assert_eq!(openrouter_glm.cheap.as_deref(), Some("z-ai/glm-5-turbo"));
let zai_default = provider_router_candidates(ApiProvider::Zai, "GLM-5.1");
assert_eq!(zai_default.big, "GLM-5.1");
assert_eq!(zai_default.cheap, None);
// GLM-5.1 has no cheaper tier; faster children stay on the parent.
let zai_51 = provider_router_candidates(ApiProvider::Zai, "GLM-5.1");
assert_eq!(zai_51.big, "GLM-5.1");
assert_eq!(zai_51.cheap, None);
// GLM-5-Turbo is itself the cheap tier; no further downgrade.
let zai_turbo = provider_router_candidates(ApiProvider::Zai, "GLM-5-Turbo");
assert_eq!(zai_turbo.big, "GLM-5-Turbo");
assert_eq!(zai_turbo.cheap, None);
// Providers without a known cheap tier: big = session model, no cheap.
let ollama = provider_router_candidates(ApiProvider::Ollama, "qwen3:32b");
+15 -1
View File
@@ -305,6 +305,7 @@ fn known_context_window_for_model(model_lower: &str) -> Option<u32> {
| "minimax-m2.1-highspeed"
| "minimax-m2" => Some(204_800),
"z-ai/glm-5.1" | "z-ai/glm-5v-turbo" | "glm-5.1" | "glm-5v-turbo" => Some(202_752),
"z-ai/glm-5-turbo" | "glm-5-turbo" => Some(202_752),
"z-ai/glm-5.2" | "glm-5.2" => Some(1_000_000),
"minimax/minimax-m3" | "minimax-m3" | "qwen/qwen3.6-flash" | "qwen/qwen3.6-plus" => {
Some(1_000_000)
@@ -350,7 +351,8 @@ pub fn max_output_tokens_for_model(model: &str) -> Option<u32> {
"minimax/minimax-m3" | "minimax-m3" => Some(524_288),
"qwen/qwen3.6-35b-a3b" | "qwen/qwen3.6-27b" => Some(262_140),
"qwen/qwen3.6-flash" | "qwen/qwen3.6-max-preview" | "qwen/qwen3.6-plus" => Some(65_536),
"z-ai/glm-5.1" | "z-ai/glm-5.2" | "glm-5.1" | "glm-5.2" => Some(131_072),
"z-ai/glm-5.1" | "z-ai/glm-5.2" | "z-ai/glm-5-turbo" | "glm-5.1" | "glm-5.2"
| "glm-5-turbo" => Some(131_072),
"xiaomi/mimo-v2.5-pro" | "xiaomi/mimo-v2.5" | "mimo-v2.5-pro" | "mimo-v2.5" => {
Some(131_072)
}
@@ -426,8 +428,10 @@ pub fn model_supports_reasoning(model: &str) -> bool {
| "mimo-v2.5"
| "z-ai/glm-5.1"
| "z-ai/glm-5.2"
| "z-ai/glm-5-turbo"
| "glm-5.1"
| "glm-5.2"
| "glm-5-turbo"
) || is_openai_gpt_55_api_model(&lower)
|| is_openai_codex_model(&lower)
}
@@ -821,6 +825,11 @@ mod tests {
);
assert_eq!(max_output_tokens_for_model("z-ai/glm-5.1"), Some(131_072));
assert_eq!(max_output_tokens_for_model("z-ai/glm-5.2"), Some(131_072));
assert_eq!(
max_output_tokens_for_model("z-ai/glm-5-turbo"),
Some(131_072)
);
assert_eq!(max_output_tokens_for_model("glm-5-turbo"), Some(131_072));
}
#[test]
@@ -837,6 +846,7 @@ mod tests {
("minimax-m2", 204_800),
("glm-5.1", 202_752),
("glm-5.2", 1_000_000),
("glm-5-turbo", 202_752),
] {
assert_eq!(context_window_for_model(model), Some(expected_window));
assert!(model_supports_reasoning(model));
@@ -845,6 +855,10 @@ mod tests {
assert!(model_supports_reasoning("kimi-for-coding"));
assert_eq!(context_window_for_model("glm-5v-turbo"), Some(202_752));
assert!(!model_supports_reasoning("glm-5v-turbo"));
// GLM-5-Turbo is a fast text sibling (distinct from the glm-5v-turbo
// vision model): same compact window as 5.1 but reasoning-capable.
assert_eq!(context_window_for_model("z-ai/glm-5-turbo"), Some(202_752));
assert!(model_supports_reasoning("z-ai/glm-5-turbo"));
assert_eq!(max_output_tokens_for_model("kimi-k2.7-code"), Some(262_144));
assert_eq!(max_output_tokens_for_model("kimi-k2.6"), Some(262_144));
assert_eq!(
+5
View File
@@ -2804,6 +2804,11 @@ mod tests {
assert!(prompt.contains("thinking: \"high\""));
assert!(prompt.contains("thinking: \"max\""));
assert!(prompt.contains("thinking: \"auto\""));
// explore defaults to the faster lane, and parallel exploration is
// encouraged for broad investigations.
assert!(prompt.contains("defaults to `model_strength: \"faster\"`"));
assert!(prompt.contains("2-4 `type: \"explore\"` sub-agents"));
assert!(prompt.contains("self-reports"));
}
#[test]
+17 -3
View File
@@ -16,11 +16,25 @@ Read its `summary` field and integrate the work — do not re-do what the child
Use the returned transcript handle with `handle_read` only when the completion summary is insufficient.
Child model choice is explicit. Use `model_strength: "same"` when the child needs your current
capability level. Use `model_strength: "faster"` for `type: "explore"`, read-only lookup/search,
status, or other low-risk tasks that should run on a smaller/faster same-family model. Use an exact
`model` only when you know the provider-specific id; it overrides `model_strength`.
capability level. Use `model_strength: "faster"` for read-only lookup/search, status, or other
low-risk tasks that should run on a smaller/faster same-family model — `type: "explore"` already
defaults to `model_strength: "faster"` for exactly this kind of bounded read-only work, so you only
need to set it for non-explore children. Use an exact `model` only when you know the
provider-specific id; it overrides `model_strength`.
Child thinking is explicit too. Use `thinking: "off"` for fast explore/lookups, `thinking: "high"`
for ordinary reasoning, `thinking: "max"` for hard design/debug/release/security work, and
`thinking: "auto"` when you want CodeWhale to choose from the child prompt. Omit it to inherit the
parent thinking mode; explicit `thinking` overrides the default off used with `model_strength:
"faster"`.
Prefer parallel exploration for broad investigations. For repo, version, branch, benchmark,
API-surface, bug, PR, issue, or multi-module investigations, start by splitting independent
read-only exploration across 2-4 `type: "explore"` sub-agents when that will reduce uncertainty
faster than reading sequentially. Each child runs concurrently in one turn and returns findings you
synthesize; keep architecture decisions, integration, verification, and the final response in the
parent. Do not open sub-agents for tiny one-step tasks — the spawn overhead is not worth it for a
single read or search.
Sub-agent outputs are self-reports, not verified facts. Re-check material claims before relying on
them: read changed files directly, run the relevant tests, and inspect unexpected results. Keep
final verification in the parent.
+10 -5
View File
@@ -250,11 +250,16 @@ For the work itself:
with its own runtime, so the win is a clean context, not free parallelism.
Reach for them when the work is genuinely independent:
- **Parallel investigation**: When you need to understand three or more
independent files or modules, open one read-only sub-agent session per
target. They run concurrently in a single turn and return structured
findings you synthesize. This is faster and more thorough than reading
sequentially.
- **Parallel investigation**: For repo, version, branch, benchmark,
API-surface, bug, PR, issue, or multi-module investigations, start by
splitting independent read-only exploration across 2-4 `type: "explore"`
sub-agents when that will reduce uncertainty faster than reading
sequentially. They run concurrently in a single turn and return structured
findings you synthesize — faster and more thorough than reading yourself.
`type: "explore"` already defaults to `model_strength: "faster"` for bounded
read-only lookup/search/status work, so prefer it over hand-picking a model.
Keep architecture decisions, integration, verification, and the final
response in the parent.
- **Parallel implementation**: After a plan is laid out, open one
sub-agent session per independent leaf task. Each does one thing well;
you integrate the results.
+102 -8
View File
@@ -3008,7 +3008,7 @@ impl ToolSpec for AgentTool {
"model_strength": {
"type": "string",
"enum": ["same", "faster"],
"description": "Optional child model strength. Use same (default) when the child should be as capable as the current model. Use faster for type=explore, read-only lookup/search, status, or other low-risk tasks that can run on a smaller/faster same-family sibling; CodeWhale maps known families such as DeepSeek V4 Pro to Flash and GLM-5.2 to GLM-5.1. No hidden auto-downgrade happens."
"description": "Optional child model strength. Use same when the child should be as capable as the current model. Use faster for type=explore, read-only lookup/search, status, or other low-risk tasks that can run on a smaller/faster same-family sibling; CodeWhale maps known families such as DeepSeek V4 Pro to Flash and GLM-5.2 to GLM-5-Turbo. type=explore defaults to faster unless you pass model_strength or model explicitly. No hidden auto-downgrade happens."
},
"model": {
"type": "string",
@@ -3365,10 +3365,18 @@ async fn run_subagent_task(task: SubAgentTask) {
// text.
let model_id = task.runtime.model.clone();
let (summary, sentinel) = match &result {
Ok(res) => (
summarize_subagent_result(res),
subagent_done_sentinel(&task.agent_id, res),
),
Ok(res) => {
// Issue #2652: the child's free-text result is its self-report, not
// verified evidence. Stamp it with a provenance marker: a soft
// "re-verify" note when short, or a head+tail truncation (reusing
// the tool-output vocabulary) when it exceeds the wire budget. The
// resulting `truncated` flag is carried in the sentinel so the
// parent model can branch on `summary_kind`.
let raw = summarize_subagent_result(res);
let (summary, truncated) = stamp_subagent_summary(&raw);
let sentinel = subagent_done_sentinel(&task.agent_id, res, truncated);
(summary, sentinel)
}
Err(err) => {
let annotated = annotate_child_model_error(&err.to_string(), &model_id);
(
@@ -3494,7 +3502,12 @@ pub(crate) fn emit_parent_completion(
/// line immediately before the sentinel; duplicating it here bloats the next
/// parent request's cache-miss tail. Wall-clock duration is useful UI
/// telemetry, but it is volatile and not useful for model coordination.
fn subagent_done_sentinel(agent_id: &str, res: &SubAgentResult) -> String {
///
/// `truncated` reflects whether the previous-line summary was length-gated by
/// [`stamp_subagent_summary`] (issue #2652); it surfaces as `summary_kind` so
/// the parent model can tell a complete self-report from a clipped one and
/// verify material claims accordingly.
fn subagent_done_sentinel(agent_id: &str, res: &SubAgentResult, truncated: bool) -> String {
let mut payload = json!({
"agent_id": agent_id,
// Whale name — a stable, human-friendly handle the orchestrator can use
@@ -3503,6 +3516,9 @@ fn subagent_done_sentinel(agent_id: &str, res: &SubAgentResult) -> String {
"agent_type": res.agent_type.as_str(),
"status": subagent_status_name(&res.status),
"summary_location": "previous_line",
// issue #2652: lets the parent branch on whether the previous-line
// summary is the full child report or a head+tail excerpt.
"summary_kind": if truncated { "truncated" } else { "complete" },
});
if let Some(needs_input) = res.needs_input.clone() {
payload["needs_input"] = json!(needs_input);
@@ -4381,7 +4397,20 @@ fn parse_spawn_request(input: &Value) -> Result<SpawnRequest, ToolError> {
let model_strength = optional_input_str(input, &["model_strength", "modelStrength"])
.map(SubAgentModelStrength::parse)
.transpose()?
.unwrap_or(SubAgentModelStrength::Same);
.unwrap_or_else(|| {
// Default model strength. `type: "explore"` defaults to Faster for
// bounded read-only lookup/search/status work — the cheap, fast
// same-family sibling is exactly the lossy-breadth job a child
// should run. Every other role (and any call that supplies an
// explicit `model`) stays conservative at Same. Explicit
// model_strength above already wins via .parse(); explicit `model`
// wins downstream in assignment_model_route regardless of strength.
if agent_type == SubAgentType::Explore && model.is_none() {
SubAgentModelStrength::Faster
} else {
SubAgentModelStrength::Same
}
});
let thinking = optional_input_str(input, &["thinking", "reasoning_effort", "reasoningEffort"])
.map(SubAgentThinking::parse)
.transpose()?
@@ -4670,7 +4699,18 @@ fn subagent_reasoning_effort_for_request(
.to_string(),
),
SubAgentThinking::Inherit if requested_fast_lane => {
Some(ReasoningEffort::Off.as_setting().to_string())
// Faster/explore lane: cheaper reasoning by default. The OpenAI Codex
// (GPT-5.5) adapter has no true "off" on the wire (it collapses off
// to low), so we resolve Low honestly for that provider instead of
// emitting an off that is silently rewritten. Explicit thinking
// passed by the caller already won via the arms above.
let provider = runtime.client.api_provider();
let effort = if matches!(provider, crate::config::ApiProvider::OpenaiCodex) {
ReasoningEffort::Low
} else {
ReasoningEffort::Off
};
Some(effort.as_setting().to_string())
}
SubAgentThinking::Inherit => fallback_subagent_reasoning_effort(runtime, prompt),
}
@@ -5178,6 +5218,60 @@ fn annotate_child_model_error(err: &str, model: &str) -> String {
}
}
/// Char budget above which a sub-agent summary is treated as a large dump and
/// head+tail truncated. Mirrors `TOOL_RESULT_SENT_CHAR_BUDGET` in
/// `crates/tui/src/client/chat.rs:702` so sub-agent summaries use the same
/// threshold as regular tool outputs. Duplicated locally to avoid coupling the
/// sub-agent module to the wire-compaction internals.
const SUBAGENT_SUMMARY_CHAR_BUDGET: usize = 12_000;
/// Head/tail slice sizes when truncating; mirror the wire constants
/// (`TOOL_RESULT_HEAD_CHARS`/`TOOL_RESULT_TAIL_CHARS`, chat.rs:703-704).
const SUBAGENT_SUMMARY_HEAD_CHARS: usize = 4_000;
const SUBAGENT_SUMMARY_TAIL_CHARS: usize = 4_000;
/// One-line provenance suffix reinforcing that a sub-agent summary is a
/// self-report (issue #2652). Appended only when the summary was NOT
/// length-truncated, so every summary carries exactly one boundary marker.
const SUBAGENT_SELF_REPORT_NOTE: &str = "\n[Sub-agent self-report — re-verify material claims (read changed files, \
run the relevant tests) before relying on it.]";
/// Stamp a sub-agent summary with a provenance/clip marker (issue #2652).
///
/// Returns `(stamped_summary, truncated)`:
/// - When the raw summary is within the budget, append the soft self-report
/// note and report `truncated: false`.
/// - When it exceeds the budget, keep a head+tail slice and stamp it with the
/// existing `[Output truncated ...]` vocabulary (reused from tool-output
/// truncation), adapted to be honest that the elided middle is NOT in the
/// spillover store — there is no `retrieve_tool_result` handle for
/// sub-agent summaries. Report `truncated: true`.
///
/// Every summary therefore gets exactly one boundary marker, never both.
fn stamp_subagent_summary(raw: &str) -> (String, bool) {
let total = raw.chars().count();
if total <= SUBAGENT_SUMMARY_CHAR_BUDGET {
return (format!("{raw}{SUBAGENT_SELF_REPORT_NOTE}"), false);
}
let chars: Vec<char> = raw.chars().collect();
let head: String = chars.iter().take(SUBAGENT_SUMMARY_HEAD_CHARS).collect();
let tail: String = chars
.iter()
.skip(total.saturating_sub(SUBAGENT_SUMMARY_TAIL_CHARS))
.collect();
let omitted = total
.saturating_sub(SUBAGENT_SUMMARY_HEAD_CHARS)
.saturating_sub(SUBAGENT_SUMMARY_TAIL_CHARS);
let stamped = format!(
"{head}\n\n[Sub-agent summary truncated: {head_chars} + {tail_chars} of {total} \
chars shown. This is the child's self-report; the elided middle ({omitted} chars) is not in \
the spillover store and cannot be retrieved via retrieve_tool_result. Re-open the child or \
read changed files directly to verify material claims.]\n\n{tail}",
head_chars = SUBAGENT_SUMMARY_HEAD_CHARS,
tail_chars = SUBAGENT_SUMMARY_TAIL_CHARS,
);
(stamped, true)
}
fn summarize_subagent_result(result: &SubAgentResult) -> String {
if let Some(needs_input) = result.needs_input.as_ref() {
return format!("Needs input: {}", needs_input.question);
+175 -5
View File
@@ -668,6 +668,57 @@ fn test_parse_spawn_request_accepts_model_strength() {
assert_eq!(parsed.model_strength, SubAgentModelStrength::Same);
}
#[test]
fn explore_subagent_defaults_to_faster_model_strength() {
// type: "explore" with no model_strength and no model defaults to Faster:
// bounded read-only lookup is exactly the cheap-sibling job.
let input = json!({
"prompt": "find every caller of normalize_model_name",
"type": "explore"
});
let parsed = parse_spawn_request(&input).expect("spawn request should parse");
assert_eq!(parsed.agent_type, SubAgentType::Explore);
assert_eq!(parsed.model_strength, SubAgentModelStrength::Faster);
// Explicit model_strength: "same" wins for explore too.
let input = json!({
"prompt": "explore but stay capable",
"type": "explore",
"model_strength": "same"
});
let parsed = parse_spawn_request(&input).expect("spawn request should parse");
assert_eq!(parsed.agent_type, SubAgentType::Explore);
assert_eq!(parsed.model_strength, SubAgentModelStrength::Same);
// An explicit model pins the child (downstream Fixed route) and disables
// the explore→faster default, so model_strength falls back to Same.
let input = json!({
"prompt": "explore on a specific model",
"type": "explore",
"model": "GLM-5.2"
});
let parsed = parse_spawn_request(&input).expect("spawn request should parse");
assert_eq!(parsed.agent_type, SubAgentType::Explore);
assert_eq!(parsed.model_strength, SubAgentModelStrength::Same);
}
#[test]
fn non_explore_subagents_keep_default_same_model_strength() {
// Non-explore roles keep the conservative Same default even with no model.
for role in ["general", "plan", "review", "implementer"] {
let input = json!({
"prompt": "do some work",
"type": role
});
let parsed = parse_spawn_request(&input).expect("spawn request should parse");
assert_eq!(
parsed.model_strength,
SubAgentModelStrength::Same,
"role {role:?} should default to Same"
);
}
}
#[test]
fn test_parse_spawn_request_accepts_child_thinking() {
let input = json!({
@@ -2112,7 +2163,7 @@ fn build_subagent_system_prompt_skips_role_when_blank() {
#[test]
fn subagent_done_sentinel_format_is_well_formed() {
let res = make_snapshot(SubAgentStatus::Completed);
let sentinel = subagent_done_sentinel("agent_xyz", &res);
let sentinel = subagent_done_sentinel("agent_xyz", &res, false);
assert!(sentinel.starts_with("<codewhale:subagent.done>"));
assert!(sentinel.ends_with("</codewhale:subagent.done>"));
@@ -2125,6 +2176,8 @@ fn subagent_done_sentinel_format_is_well_formed() {
assert_eq!(parsed["status"], "completed");
assert_eq!(parsed["agent_type"], "general");
assert_eq!(parsed["summary_location"], "previous_line");
// issue #2652: a complete (non-truncated) summary is tagged as such.
assert_eq!(parsed["summary_kind"], "complete");
assert!(parsed.get("details").is_none());
assert!(parsed.get("result_clipped").is_none());
assert!(parsed.get("summary_complete").is_none());
@@ -2138,13 +2191,14 @@ fn subagent_done_sentinel_format_is_well_formed() {
fn subagent_done_sentinel_keeps_large_result_out_of_metadata() {
let mut res = make_snapshot(SubAgentStatus::Completed);
res.result = Some("x".repeat(2048));
let sentinel = subagent_done_sentinel("agent_big", &res);
let sentinel = subagent_done_sentinel("agent_big", &res, false);
let inner = sentinel
.trim_start_matches("<codewhale:subagent.done>")
.trim_end_matches("</codewhale:subagent.done>");
let parsed: serde_json::Value = serde_json::from_str(inner).expect("inner JSON parses");
assert_eq!(parsed["agent_id"], "agent_big");
assert_eq!(parsed["summary_location"], "previous_line");
assert_eq!(parsed["summary_kind"], "complete");
assert!(parsed.get("result_clipped").is_none());
assert!(parsed.get("summary_complete").is_none());
assert!(parsed.get("next_action").is_none());
@@ -2154,6 +2208,66 @@ fn subagent_done_sentinel_keeps_large_result_out_of_metadata() {
);
}
#[test]
fn subagent_done_sentinel_marks_truncated_summaries() {
// issue #2652: when the child summary was length-gated, the sentinel must
// advertise summary_kind:"truncated" so the parent can steer verification.
let res = make_snapshot(SubAgentStatus::Completed);
let sentinel = subagent_done_sentinel("agent_trunc", &res, true);
let inner = sentinel
.trim_start_matches("<codewhale:subagent.done>")
.trim_end_matches("</codewhale:subagent.done>");
let parsed: serde_json::Value = serde_json::from_str(inner).expect("inner JSON parses");
assert_eq!(parsed["summary_kind"], "truncated");
}
#[test]
fn stamp_subagent_summary_appends_note_when_short() {
// issue #2652: a short (complete) summary gets the soft self-report note
// and is NOT marked truncated.
let (stamped, truncated) = stamp_subagent_summary("All tests pass.");
assert!(!truncated);
assert!(stamped.starts_with("All tests pass."));
assert!(
stamped.contains("[Sub-agent self-report"),
"short summary gets the provenance note"
);
assert!(
!stamped.contains("[Sub-agent summary truncated"),
"short summary must not get the truncation footer"
);
}
#[test]
fn stamp_subagent_summary_truncates_when_over_budget() {
// issue #2652: a summary exceeding the budget is head+tail truncated using
// the existing [Output truncated ...] vocabulary, honestly noting there is
// no retrieve handle, and is marked truncated.
let big = "a".repeat(SUBAGENT_SUMMARY_CHAR_BUDGET + 5_000);
let (stamped, truncated) = stamp_subagent_summary(&big);
assert!(truncated);
assert!(
stamped.contains("[Sub-agent summary truncated"),
"long summary gets the truncation footer"
);
assert!(
stamped.contains("not in the spillover store"),
"footer is honest about the missing retrieve handle"
);
assert!(
!stamped.contains("[Sub-agent self-report"),
"truncated summary must not also get the self-report note"
);
// Head and tail slices are present; a run of budget-length 'a's is gone
// from the middle.
assert!(stamped.contains(&"a".repeat(SUBAGENT_SUMMARY_HEAD_CHARS)));
assert!(stamped.contains(&"a".repeat(SUBAGENT_SUMMARY_TAIL_CHARS)));
assert!(
stamped.chars().filter(|c| *c == 'a').count() < big.chars().count(),
"truncation removed middle characters"
);
}
#[test]
fn subagent_failed_sentinel_format_is_well_formed() {
let sentinel = subagent_failed_sentinel("agent_zzz", "boom");
@@ -2836,6 +2950,14 @@ fn stub_client_for_provider(provider: &str) -> DeepSeekClient {
..Default::default()
};
}
// OpenAI Codex (ChatGPT backend). Exercises the faster-lane reasoning
// rule: GPT-5.5 children stay on GPT-5.5 and resolve Low reasoning.
"openai-codex" => {
providers.openai_codex = crate::config::ProviderConfig {
api_key: Some("test-key".to_string()),
..Default::default()
};
}
// Ollama is keyless (local runtime); extend per-provider as needed.
"ollama" => {}
other => panic!("extend stub_client_for_provider for provider {other}"),
@@ -3310,7 +3432,7 @@ fn subagent_completion_payload_carries_existing_sentinel_format() {
snap.result = Some("Found three errors.".to_string());
let summary = summarize_subagent_result(&snap);
let sentinel = subagent_done_sentinel("agent_test", &snap);
let sentinel = subagent_done_sentinel("agent_test", &snap, false);
let payload = format!("{summary}\n{sentinel}");
let mut lines = payload.lines();
@@ -3415,7 +3537,10 @@ fn faster_route_uses_known_deepseek_and_glm_family_siblings() {
SubAgentThinking::Inherit,
"inspect docs",
);
assert_eq!(route.model, "GLM-5.1");
// GLM-5.2 faster/explore children route to GLM-5-Turbo (same-family fast
// sibling), not down to GLM-5.1.
assert_eq!(route.model, "GLM-5-Turbo");
assert_ne!(route.model, "GLM-5.1");
let mut openrouter = stub_runtime_for_provider("openrouter");
openrouter.model = "z-ai/glm-5.2".to_string();
@@ -3426,7 +3551,52 @@ fn faster_route_uses_known_deepseek_and_glm_family_siblings() {
SubAgentThinking::Inherit,
"inspect docs",
);
assert_eq!(route.model, "z-ai/glm-5.1");
assert_eq!(route.model, "z-ai/glm-5-turbo");
assert_ne!(route.model, "z-ai/glm-5.1");
}
#[test]
fn gpt55_faster_route_stays_on_gpt55_with_low_reasoning() {
// AC: a faster/explore child of a GPT-5.5 (OpenAI Codex) parent must stay
// on GPT-5.5 — there is no cheaper same-provider sibling, so we never
// fabricate a DeepSeek/GLM id — and resolve Low reasoning rather than Off,
// because the Codex adapter has no true "off" on the wire.
//
// The Codex client validates OAuth credentials at construction time, so we
// stub the access-token env var for the duration of this test (save/restore
// to avoid leaking into parallel tests).
let prev_token = std::env::var_os("OPENAI_CODEX_ACCESS_TOKEN");
// Safety: this test does not run concurrently with other tests that read
// OPENAI_CODEX_ACCESS_TOKEN, and we restore the original value below.
unsafe {
std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
}
let mut codex = stub_runtime_for_provider("openai-codex");
unsafe {
match prev_token {
Some(prev) => std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev),
None => std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN"),
}
}
codex.model = "gpt-5.5".to_string();
let route = fallback_subagent_assignment_route(
&codex,
None,
ModelRoute::Faster,
SubAgentThinking::Inherit,
"inspect one file",
);
assert_eq!(route.model, "gpt-5.5");
assert!(
!route.model.contains("deepseek"),
"no DeepSeek id may be fabricated: {route:?}"
);
assert!(
!route.model.contains("glm"),
"no GLM id may be fabricated: {route:?}"
);
assert_eq!(route.reasoning_effort.as_deref(), Some("low"));
assert_ne!(route.reasoning_effort.as_deref(), Some("off"));
}
#[test]
+114 -63
View File
@@ -19,6 +19,14 @@ pub struct UserInputQuestion {
pub id: String,
pub question: String,
pub options: Vec<UserInputOption>,
/// When `true`, the modal offers a free-text "Other" response in addition
/// to the fixed options. Defaults to `false` for backwards compatibility
/// (older payloads omitting the field get the previous behavior).
#[serde(default)]
pub allow_free_text: bool,
/// When `true`, the user may select more than one option before confirming.
#[serde(default)]
pub multi_select: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -62,9 +70,9 @@ impl UserInputRequest {
"request_user_input.questions.question cannot be empty",
));
}
if q.options.len() < 2 || q.options.len() > 3 {
if q.options.len() < 2 || q.options.len() > 4 {
return Err(ToolError::invalid_input(
"request_user_input.questions.options must contain 2 or 3 items",
"request_user_input.questions.options must contain 2 to 4 items",
));
}
for opt in &q.options {
@@ -131,7 +139,17 @@ impl ToolSpec for RequestUserInputTool {
"required": ["label", "description"]
},
"minItems": 2,
"maxItems": 3
"maxItems": 4
},
"allow_free_text": {
"type": "boolean",
"description": "When true, also offer a free-text 'Other' response. Defaults to false.",
"default": false
},
"multi_select": {
"type": "boolean",
"description": "When true, allow selecting more than one option. Defaults to false.",
"default": false
}
},
"required": ["header", "id", "question", "options"]
@@ -184,75 +202,108 @@ mod tests {
description: "Option B".to_string(),
},
],
allow_free_text: false,
multi_select: false,
}],
};
assert!(request.validate().is_ok());
}
#[test]
fn from_value_accepts_four_options_and_flags() {
// Mirrors the json!-literal style used in tools/subagent/tests.rs and
// exercises the schema-loosening from issue #3102: 4 options (was capped
// at 3) plus the new allow_free_text / multi_select flags.
let input = json!({
"questions": [{
"header": "Scope",
"id": "scope",
"question": "Which surfaces should this change affect?",
"options": [
{ "label": "TUI", "description": "Visible modal flow only" },
{ "label": "Headless", "description": "Protocol event only" },
{ "label": "All surfaces", "description": "TUI and headless" },
{ "label": "CLI", "description": "Command-line surface" }
],
"allow_free_text": true,
"multi_select": true
}]
});
let request = UserInputRequest::from_value(&input).expect("4 options + flags parse");
assert_eq!(request.questions.len(), 1);
assert_eq!(request.questions[0].options.len(), 4);
assert!(request.questions[0].allow_free_text);
assert!(request.questions[0].multi_select);
}
#[test]
fn from_value_defaults_flags_when_omitted() {
// Backwards compatibility: a legacy payload omitting the new boolean
// fields must still parse, defaulting both to false.
let input = json!({
"questions": [{
"header": "Pick",
"id": "choice",
"question": "Which?",
"options": [
{ "label": "A", "description": "a" },
{ "label": "B", "description": "b" }
]
}]
});
let request = UserInputRequest::from_value(&input).expect("legacy payload parses");
assert!(!request.questions[0].allow_free_text);
assert!(!request.questions[0].multi_select);
}
#[test]
fn rejects_five_options() {
let input = json!({
"questions": [{
"header": "Pick",
"id": "choice",
"question": "Which?",
"options": [
{ "label": "A", "description": "a" },
{ "label": "B", "description": "b" },
{ "label": "C", "description": "c" },
{ "label": "D", "description": "d" },
{ "label": "E", "description": "e" }
]
}]
});
let err = UserInputRequest::from_value(&input).expect_err("5 options must fail");
assert!(err.to_string().contains("2 to 4 items"));
}
fn yes_no_question(header: &str, id: &str) -> UserInputQuestion {
UserInputQuestion {
header: header.to_string(),
id: id.to_string(),
question: "?".to_string(),
options: vec![
UserInputOption {
label: "A".to_string(),
description: "A".to_string(),
},
UserInputOption {
label: "B".to_string(),
description: "B".to_string(),
},
],
allow_free_text: false,
multi_select: false,
}
}
#[test]
fn rejects_too_many_questions() {
let request = UserInputRequest {
questions: vec![
UserInputQuestion {
header: "Q1".to_string(),
id: "q1".to_string(),
question: "?".to_string(),
options: vec![
UserInputOption {
label: "A".to_string(),
description: "A".to_string(),
},
UserInputOption {
label: "B".to_string(),
description: "B".to_string(),
},
],
},
UserInputQuestion {
header: "Q2".to_string(),
id: "q2".to_string(),
question: "?".to_string(),
options: vec![
UserInputOption {
label: "A".to_string(),
description: "A".to_string(),
},
UserInputOption {
label: "B".to_string(),
description: "B".to_string(),
},
],
},
UserInputQuestion {
header: "Q3".to_string(),
id: "q3".to_string(),
question: "?".to_string(),
options: vec![
UserInputOption {
label: "A".to_string(),
description: "A".to_string(),
},
UserInputOption {
label: "B".to_string(),
description: "B".to_string(),
},
],
},
UserInputQuestion {
header: "Q4".to_string(),
id: "q4".to_string(),
question: "?".to_string(),
options: vec![
UserInputOption {
label: "A".to_string(),
description: "A".to_string(),
},
UserInputOption {
label: "B".to_string(),
description: "B".to_string(),
},
],
},
yes_no_question("Q1", "q1"),
yes_no_question("Q2", "q2"),
yes_no_question("Q3", "q3"),
yes_no_question("Q4", "q4"),
],
};
assert!(request.validate().is_err());
+60
View File
@@ -708,4 +708,64 @@ mod tests {
let z_at = out.find('Z').expect("unlinked glyph");
assert!(first_close_at < z_at && z_at < second_at, "{out:?}");
}
/// #3029 end-to-end: the in-band `wrap_link` payload rendered into a Buffer
/// by `Paragraph` must (a) be cleaned out of the cells by the extractor and
/// (b) re-emitted out-of-band by the backend around the label glyph. This
/// proves producer (`extract_buffer_link_regions` + `set_frame_links`) and
/// consumer (`ColorCompatBackend::draw`) compose.
#[test]
fn osc8_extractor_feeds_backend_out_of_band() {
use crate::tui::osc8;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
// 1. Render an in-band link payload into a buffer, exactly as the
// transcript render seam would.
let target = "https://example.test/e2e";
let label = "open";
let wrapped = osc8::wrap_link(target, label);
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
Paragraph::new(vec![Line::from(vec![Span::raw(wrapped)])]).render(area, &mut buf);
// 2. The extractor recovers the region AND blanks the payload cells.
// Capture the region once — re-running would find nothing because
// the payload is now gone (that's the point).
let regions = osc8::extract_buffer_link_regions(&mut buf, area);
assert_eq!(regions.len(), 1, "one link recovered: {regions:?}");
osc8::set_frame_links(regions);
// 3. Hand the cleaned cells to the backend and capture the byte stream.
let writer = SharedWriter::default();
let capture = writer.0.clone();
let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark);
let cells: Vec<(u16, u16, ratatui::buffer::Cell)> = (0..area.width)
.map(|x| {
let cell = buf[(x, 0)].clone();
(x, 0u16, cell)
})
.collect();
backend
.draw(cells.iter().map(|(x, y, cell)| (*x, *y, cell)))
.unwrap();
let out = String::from_utf8_lossy(&capture.borrow()).to_string();
// 4. The backend re-emitted the link out-of-band around the label.
let open = format!("\x1b]8;;{target}\x1b\\");
let close = "\x1b]8;;\x1b\\";
assert_eq!(
out.matches(open.as_str()).count(),
1,
"open emitted once: {out:?}"
);
assert_eq!(out.matches(close).count(), 1, "close emitted once: {out:?}");
let open_at = out.find(open.as_str()).expect("open");
let close_at = out.find(close).expect("close");
let label_at = out.find(label).expect("label glyph");
assert!(open_at < label_at, "open precedes label: {out:?}");
assert!(label_at < close_at, "close follows label: {out:?}");
}
}
+7
View File
@@ -577,6 +577,13 @@ impl ModalView for LiveTranscriptOverlay {
.block(block)
.wrap(Wrap { trim: false });
paragraph.render(popup_area, buf);
// #3029: same in-band OSC 8 recovery as the main transcript — extract
// link regions from the rendered buffer and blank the payload cells.
// Append (not replace) so a same-frame main transcript's regions
// survive alongside the overlay's.
let regions = crate::tui::osc8::extract_buffer_link_regions(buf, popup_area);
crate::tui::osc8::append_frame_links(regions);
}
}
+328 -7
View File
@@ -12,10 +12,22 @@
//! `LABEL` and ignore the escape. So emitting OSC 8 is a strict UX upgrade for
//! supporting terminals and a no-op for the rest.
//!
//! The TUI emits these inside `Span::content` strings so the existing
//! ratatui pipeline carries them through. The tradeoff is that the clipboard
//! / selection extraction path must strip the codes before handing text to the
//! user — that's what [`strip_into`] is for.
//! # Architecture (#3029)
//!
//! The markdown renderer embeds link payloads *in-band* inside `Span::content`
//! via [`wrap_link`]. ratatui's buffer pipeline drops the leading `ESC` byte
//! but paints the rest of the payload one-byte-per-cell, which would corrupt
//! columns. So each render seam calls [`extract_buffer_link_regions`] after
//! `Paragraph::render`: it recovers each link's target + label display
//! columns, blanks the payload cells (no cell ever holds `\x1b` or `]8;;`),
//! and publishes [`LinkRegion`]s to a thread-local. `ColorCompatBackend::draw`
//! then consumes those regions and emits the OSC 8 escapes *out-of-band* —
//! interleaved with the cell stream through the backend's `Write` impl, never
//! inside a buffer cell. The in-band path is the source of link info; the
//! out-of-band path is what reaches the terminal.
//!
//! The clipboard/selection extraction path still strips any residual codes via
//! [`strip_into`] / [`strip_ansi_into`] as a defense-in-depth.
use std::sync::atomic::{AtomicBool, Ordering};
@@ -44,8 +56,10 @@ pub fn write_osc8_close(w: &mut impl std::io::Write) -> std::io::Result<()> {
w.write_all(OSC8_CLOSE.as_bytes())
}
/// Process-wide enable flag. `true` by default. Set once at app init from
/// `[ui] osc8_links` (when present) and read by the renderer.
/// Process-wide enable flag. Set once at app init from `[tui] osc8_links`
/// (when present); otherwise defaults to on for macOS/Linux and off for
/// Windows legacy consoles (see `ui.rs`'s `osc8_default_on`). Read by the
/// renderer to gate out-of-band OSC 8 emission.
static ENABLED: AtomicBool = AtomicBool::new(true);
/// Set the process-wide OSC 8 enable flag. Intended to be called once at
@@ -72,18 +86,180 @@ thread_local! {
}
/// Replace the thread-local frame link buffer with `links`.
#[allow(dead_code)] // called from render closure (future integration)
pub fn set_frame_links(links: Vec<LinkRegion>) {
FRAME_LINKS.with(|cell| {
*cell.borrow_mut() = links;
});
}
/// Append `links` to the thread-local frame link buffer. Used when more than
/// one widget renders link-bearing content into the same frame (e.g. the main
/// transcript and the live-transcript overlay): each seam appends rather than
/// replacing, so all regions reach `ColorCompatBackend::draw`.
pub fn append_frame_links(links: Vec<LinkRegion>) {
FRAME_LINKS.with(|cell| cell.borrow_mut().extend(links));
}
/// Take the thread-local frame links, leaving an empty vec behind.
pub fn take_frame_links() -> Vec<LinkRegion> {
FRAME_LINKS.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
}
// --- In-band payload extraction (#3029) ---
//
// The markdown renderer embeds OSC 8 hyperlinks *in-band* inside `Span`
// content via [`wrap_link`]. ratatui's buffer pipeline drops the leading
// `ESC` byte but paints every other byte of the payload into its own cell,
// which drifts columns and corrupts the visible glyph stream. Rather than
// thread structured link metadata through the whole render pipeline, we scan
// the rendered `Buffer` after each `Paragraph::render` and:
//
// 1. recover each link's target + the display-column span of its label, and
// 2. blank the payload cells (the `]8;;`, target, and terminators), leaving
// only the clean label behind.
//
// The recovered [`LinkRegion`]s are handed to [`set_frame_links`] /
// [`append_frame_links`]; `ColorCompatBackend::draw` consumes them and emits
// the OSC 8 escapes *out-of-band* through the backend's `Write` impl, so no
// payload byte ever reaches a buffer cell. This satisfies the #3029
// acceptance criterion ("no Buffer cell contains `\x1b` or `]8;;`") by
// construction.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
/// The four cells of the OSC 8 open prefix `ESC ] 8 ; ;` after ratatui strips
/// the leading ESC: `]`, `8`, `;`, `;`.
const OPEN_CELLS: [char; 4] = [']', '8', ';', ';'];
/// Scan `area` of `buf` for in-band OSC 8 link payloads, blank their payload
/// cells, and return one [`LinkRegion`] per recovered link (over the label's
/// display columns, in absolute buffer coordinates).
///
/// A complete payload in the buffer (ESC already stripped by ratatui) looks
/// like `]8;;TARGET\LABEL]8;;\` — four open cells, target bytes, a `\`
/// terminator, the visible label, then the four-cell close `]8;;\`. If the
/// close is missing (e.g. the payload was truncated by wrapping), the whole
/// run is treated as corruption: cells are blanked but no region is emitted,
/// since a half-link is worse than no link.
///
/// `row`/`col_start`/`col_end` are absolute buffer coordinates (they include
/// `area.x`/`area.y`), matching what `ColorCompatBackend::draw` tests against.
#[must_use]
pub fn extract_buffer_link_regions(buf: &mut Buffer, area: Rect) -> Vec<LinkRegion> {
let mut regions = Vec::new();
let x_start = area.x;
let x_end = area.x.saturating_add(area.width);
let y_start = area.y;
let y_end = area.y.saturating_add(area.height);
for y in y_start..y_end {
let mut x = x_start;
while x < x_end {
// Look for the open prefix `]8;;` at the current column.
if matches_open(buf, x, y, x_end) {
let payload_start = x;
// Skip the 4 open cells, then consume the target up to `\`.
let mut scan = x + OPEN_CELLS.len() as u16;
let mut target = String::new();
let mut found_target_term = false;
while scan < x_end {
let ch = cell_char(buf, scan, y);
scan += 1;
if ch == '\\' {
found_target_term = true;
break;
}
target.push(ch);
}
if !found_target_term {
// Unterminated payload: blank what we can prove is payload
// (the open prefix) and bail on this run — the rest may be
// legitimate content we must not destroy.
blank_cells(buf, payload_start..payload_start + 4, y);
x = scan;
continue;
}
let label_start = scan;
// Consume label cells until the close prefix `]8;;\`. `scan`
// walks one cell at a time; when the next four cells spell
// `]8;;` and the fifth is `\`, the label ends just before them.
let mut found_close = false;
while scan + 4 < x_end {
if matches_open(buf, scan, y, x_end) && cell_char(buf, scan + 4, y) == '\\' {
found_close = true;
break;
}
scan += 1;
}
// `scan` is now either at the close prefix (found) or past the
// row end (not found); in both cases the label occupies
// `label_start..scan` (exclusive end).
if !found_close {
// No close within the row: blank the open+target+term and
// the partial label, emit no region.
blank_cells(buf, payload_start..scan, y);
x = scan;
continue;
}
let close_start = scan;
let close_end = scan + (OPEN_CELLS.len() as u16) + 1; // `]8;;` + `\`
// Record the region over the label's columns. LinkRegion uses
// inclusive end coordinates, matching ColorCompatBackend's
// `x >= col_start && x <= col_end` test. Skip empty labels.
if scan > label_start {
regions.push(LinkRegion {
row: y,
col_start: label_start,
col_end: scan - 1,
target,
});
}
// Blank the payload cells AROUND the label, never the label
// itself: the open prefix + target + first `\`, then the close
// `]8;;\`. The label cells in `label_start..scan` are left
// intact so the visible glyph stream is unchanged.
blank_cells(buf, payload_start..label_start, y);
blank_cells(buf, close_start..close_end, y);
x = close_end;
continue;
}
x += 1;
}
}
regions
}
/// Whether the four cells starting at `(x, y)` spell the OSC 8 open prefix
/// `]8;;` (clamped to `x_end`).
fn matches_open(buf: &Buffer, x: u16, y: u16, x_end: u16) -> bool {
if x.saturating_add(OPEN_CELLS.len() as u16) > x_end {
return false;
}
OPEN_CELLS
.iter()
.enumerate()
.all(|(i, want)| cell_char(buf, x + i as u16, y) == *want)
}
/// First char of the symbol at `(x, y)` (payload bytes are ASCII, so the cell
/// symbol is a single char). Returns `'\0'` for empty cells so they never
/// falsely match a payload char.
fn cell_char(buf: &Buffer, x: u16, y: u16) -> char {
let sym = buf[(x, y)].symbol();
sym.chars().next().unwrap_or('\0')
}
/// Reset the cells in `cols` (relative to absolute `x`) on row `y` to a blank
/// space, clearing any payload bytes.
fn blank_cells(buf: &mut Buffer, cols: std::ops::Range<u16>, y: u16) {
for x in cols {
if let Some(cell) = buf.cell_mut(ratatui::layout::Position { x, y }) {
cell.set_symbol(" ");
}
}
}
/// Wrap `label` so it links to `target` in OSC 8-aware terminals. The returned
/// string contains the full `\x1b]8;;TARGET\x1b\LABEL\x1b]8;;\x1b\` payload.
///
@@ -362,4 +538,149 @@ mod tests {
assert!(enabled());
set_enabled(prior);
}
// ── #3029: extract_buffer_link_regions ───────────────────────────────
/// Render `lines` (whose spans may contain in-band `wrap_link` payloads)
/// into a fresh Buffer of `area` and return it, mirroring how the real
/// transcript path lays text into the buffer.
fn render_lines(
lines: Vec<ratatui::text::Line<'static>>,
area: ratatui::layout::Rect,
) -> Buffer {
use ratatui::widgets::{Paragraph, Widget};
let mut buf = Buffer::empty(area);
Paragraph::new(lines).render(area, &mut buf);
buf
}
fn row_text(buf: &Buffer, y: u16, x_start: u16, x_end: u16) -> String {
(x_start..x_end)
.map(|x| buf[(x, y)].symbol().to_string())
.collect()
}
#[test]
fn extract_finds_label_span_target_and_blanks_payload() {
// wrap_link("https://x.test", "click") occupies, after ratatui strips
// ESC: ]8;;<target>\<label>]8;;\ (label "click" between terminators).
let target = "https://x.test";
let label = "click";
let wrapped = wrap_link(target, label);
let area = ratatui::layout::Rect::new(0, 0, 40, 1);
let mut buf = render_lines(
vec![ratatui::text::Line::from(vec![ratatui::text::Span::raw(
wrapped,
)])],
area,
);
let regions = extract_buffer_link_regions(&mut buf, area);
assert_eq!(regions.len(), 1, "exactly one link region");
let r = &regions[0];
assert_eq!(r.row, 0);
assert_eq!(r.target, target);
// Label columns derived from the payload layout: open(4) + target + \(1),
// then label cells. Compute rather than hardcode to stay correct if the
// fixture changes.
let expected_start = 4 + target.len() as u16 + 1;
let expected_end = expected_start + label.len() as u16 - 1;
assert_eq!(r.col_start, expected_start);
assert_eq!(r.col_end, expected_end);
// Label cells survive intact.
assert_eq!(
row_text(&buf, 0, expected_start, expected_start + label.len() as u16),
label
);
// No payload byte remains anywhere: open, target, and both terminators
// are blanked. The whole row, outside the label span, is spaces.
let full = row_text(&buf, 0, 0, expected_end + 6);
assert!(
!full.contains(']') && !full.contains('\\') && !full.contains('h'),
"payload bytes blanked, got: {full:?}"
);
}
#[test]
fn extract_handles_two_links_same_row() {
let w1 = wrap_link("https://a.test", "AAA");
let w2 = wrap_link("https://b.test", "BB");
let combined = format!("{w1} {w2}");
let area = ratatui::layout::Rect::new(0, 0, 60, 1);
let mut buf = render_lines(
vec![ratatui::text::Line::from(vec![ratatui::text::Span::raw(
combined,
)])],
area,
);
let regions = extract_buffer_link_regions(&mut buf, area);
assert_eq!(regions.len(), 2, "two disjoint links");
assert_eq!(regions[0].target, "https://a.test");
assert_eq!(regions[1].target, "https://b.test");
// Labels survive and are disjoint.
let a_span = regions[0].col_start..=regions[0].col_end;
let b_span = regions[1].col_start..=regions[1].col_end;
assert!(a_span.end() < b_span.start(), "regions must not overlap");
// No residual payload bytes anywhere on the row.
let full = row_text(&buf, 0, 0, 60);
assert!(!full.contains(']'), "no open/close brackets remain");
assert!(!full.contains('\\'), "no terminator backslash remains");
}
#[test]
fn extract_uses_absolute_coordinates_with_area_offset() {
// The backend tests absolute (x,y); regions must include area.x/area.y.
let wrapped = wrap_link("u", "L");
let area = ratatui::layout::Rect::new(5, 3, 30, 2);
let mut buf = render_lines(
vec![ratatui::text::Line::from(vec![ratatui::text::Span::raw(
wrapped,
)])],
area,
);
let regions = extract_buffer_link_regions(&mut buf, area);
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].row, 3, "row includes area.y");
assert!(regions[0].col_start >= 5, "col includes area.x");
assert_eq!(regions[0].target, "u");
}
#[test]
fn extract_preserves_plain_text_and_emits_no_regions() {
let area = ratatui::layout::Rect::new(0, 0, 20, 1);
let mut buf = render_lines(
vec![ratatui::text::Line::from(vec![ratatui::text::Span::raw(
"just plain text",
)])],
area,
);
let before = row_text(&buf, 0, 0, 15);
let regions = extract_buffer_link_regions(&mut buf, area);
let after = row_text(&buf, 0, 0, 15);
assert!(regions.is_empty());
assert_eq!(before, after, "plain text untouched");
}
#[test]
fn extract_blanks_unterminated_payload_and_emits_no_region() {
// A payload whose close was truncated (e.g. by wrapping) must not
// produce a half-link; its payload cells are still blanked.
// Build a buffer that has `]8;;ab\cd` with NO trailing close.
let area = ratatui::layout::Rect::new(0, 0, 12, 1);
let mut buf = render_lines(
vec![ratatui::text::Line::from(vec![ratatui::text::Span::raw(
// wrap_link minus the trailing close: open+target+term+label.
// We can't easily produce "no close" via wrap_link, so craft
// the in-band bytes directly (ESC will be stripped by ratatui).
"\x1b]8;;t\x1b\\lab",
)])],
area,
);
let regions = extract_buffer_link_regions(&mut buf, area);
assert!(regions.is_empty(), "no close -> no region");
let text = row_text(&buf, 0, 0, 12);
assert!(!text.contains(']'), "open payload blanked");
}
}
+13 -15
View File
@@ -378,21 +378,19 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
// Apply OSC 8 hyperlink toggle from config.
//
// Default-off on Windows because legacy `cmd.exe` and pre-Win11
// PowerShell consoles don't always honor the OSC 8 string
// terminator (`ESC \`) cleanly — emitting the escape can leave
// stray bytes that eat the leading column of the next line and
// duplicate the composer panel during scroll. Reported on a
// Windows session (issue forthcoming, screenshot showed
// "eepseek-v4-flash" with the leading `d` consumed and three
// overlapping composer panels). v0.8.8 also surfaced macOS
// corruption ("526sOPEN" instead of "526 OPEN") because OSC 8
// wrappers are emitted inside ratatui `Span` content; ratatui's
// grapheme filter drops the bare ESC byte but paints every other
// byte of the wrapper into a buffer cell, drifting columns. Until
// OSC 8 is emitted out-of-band of the buffer pipeline, default off
// on every platform; opt back in via `[ui] osc8_links = true`.
let osc8_default_on = false;
// #3029: OSC 8 hyperlinks are now emitted out-of-band. The transcript
// carries the link payloads in-band inside `Span` content, but each render
// seam calls `osc8::extract_buffer_link_regions`, which blanks the payload
// cells (so no buffer cell ever holds `\x1b` or `]8;;` — the old column-
// drift corruption is gone by construction) and publishes `LinkRegion`s.
// `ColorCompatBackend::draw` then re-emits the OSC 8 escapes through the
// backend's `Write` impl, interleaved with the cell stream — never inside a
// buffer cell. So the corruption that previously forced this default off is
// fixed, and hyperlinks are on by default for terminals that handle the OSC
// terminator (`ESC \`) cleanly. Windows legacy consoles (conhost) still
// mishandle the terminator, so the default stays off there; opt in via
// `[tui] osc8_links = true` on any platform.
let osc8_default_on = !cfg!(target_os = "windows");
crate::tui::osc8::set_enabled(
config
.tui
+231 -60
View File
@@ -53,6 +53,7 @@ fn push_option_lines(
number: usize,
label: String,
description: String,
ticked: bool,
) {
let row_style = if selected {
Style::default()
@@ -68,13 +69,16 @@ fn push_option_lines(
Style::default().fg(palette::TEXT_MUTED)
};
let prefix = if selected { ">" } else { " " };
// Multi-select rows get a check-mark gutter when toggled into the pending
// set, mirroring the affordance used in other multi-option pickers.
let mark = if ticked { "✔ " } else { " " };
lines.push(Line::from(Span::styled(
format!("{prefix} {number}) {label}"),
format!("{prefix}{mark}{number}) {label}"),
row_style,
)));
lines.push(Line::from(Span::styled(
format!(" {description}"),
format!(" {description}"),
detail_style,
)));
}
@@ -94,6 +98,9 @@ pub struct UserInputView {
mode: InputMode,
other_input: String,
answers: Vec<UserInputAnswer>,
/// Indices toggled into the pending multi-select set for the current
/// question. Only used when `question.multi_select` is true.
multi_pending: Vec<usize>,
}
impl UserInputView {
@@ -106,6 +113,7 @@ impl UserInputView {
mode: InputMode::Selecting,
other_input: String::new(),
answers: Vec::new(),
multi_pending: Vec::new(),
}
}
@@ -113,16 +121,61 @@ impl UserInputView {
&self.request.questions[self.question_index]
}
/// Whether the "Other" free-text row is offered for the current question.
fn offers_other(&self) -> bool {
self.current_question().allow_free_text
}
fn option_count(&self) -> usize {
self.current_question().options.len() + 1
// Options + conditional "Other" row + conditional "Confirm" row.
let mut count = self.current_question().options.len();
count += usize::from(self.offers_other());
count += usize::from(self.is_multi_select());
count
}
fn is_other_selected(&self) -> bool {
self.selected + 1 == self.option_count()
// "Other" sits immediately before the Confirm row when both exist, and
// is last otherwise.
let other_last = !self.is_multi_select();
if other_last {
self.offers_other() && self.selected + 1 == self.option_count()
} else {
self.offers_other() && self.selected + 2 == self.option_count()
}
}
fn advance_question(&mut self, answer: UserInputAnswer) -> ViewAction {
self.answers.push(answer);
/// True when the multi-select "Confirm selection" row is highlighted.
fn is_confirm_selected(&self) -> bool {
self.is_multi_select() && self.selected + 1 == self.option_count()
}
fn is_multi_select(&self) -> bool {
self.current_question().multi_select
}
fn toggle_pending(&mut self, index: usize) {
if let Some(pos) = self.multi_pending.iter().position(|i| *i == index) {
self.multi_pending.remove(pos);
} else {
self.multi_pending.push(index);
}
}
/// Build the answer(s) for the current question from a single selected
/// option index (single-select and the confirm step of multi-select).
fn answers_for_selection(&self, index: usize) -> Vec<UserInputAnswer> {
let question = self.current_question();
let option = &question.options[index];
vec![UserInputAnswer {
id: question.id.clone(),
label: option.label.clone(),
value: option.label.clone(),
}]
}
fn advance_question(&mut self, new_answers: Vec<UserInputAnswer>) -> ViewAction {
self.answers.extend(new_answers);
if self.question_index + 1 >= self.request.questions.len() {
let response = UserInputResponse {
answers: self.answers.clone(),
@@ -136,6 +189,7 @@ impl UserInputView {
self.selected = 0;
self.mode = InputMode::Selecting;
self.other_input.clear();
self.multi_pending.clear();
ViewAction::None
}
@@ -161,37 +215,17 @@ impl UserInputView {
return ViewAction::None;
}
self.selected = index;
if self.is_other_selected() {
self.mode = InputMode::OtherInput;
self.other_input.clear();
ViewAction::None
} else {
let question = self.current_question();
let option = &question.options[self.selected];
let answer = UserInputAnswer {
id: question.id.clone(),
label: option.label.clone(),
value: option.label.clone(),
};
self.advance_question(answer)
}
self.activate_or_confirm_selection()
}
KeyCode::Enter => {
if self.is_other_selected() {
self.mode = InputMode::OtherInput;
self.other_input.clear();
ViewAction::None
} else {
let question = self.current_question();
let option = &question.options[self.selected];
let answer = UserInputAnswer {
id: question.id.clone(),
label: option.label.clone(),
value: option.label.clone(),
};
self.advance_question(answer)
KeyCode::Char(' ') if self.is_multi_select() => {
// Space toggles the highlighted option in the pending set
// without leaving the picker (standard multi-select affordance).
if !self.is_other_selected() {
self.toggle_pending(self.selected);
}
ViewAction::None
}
KeyCode::Enter => self.activate_or_confirm_selection(),
KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::UserInputCancelled {
tool_id: self.tool_id.clone(),
}),
@@ -199,6 +233,45 @@ impl UserInputView {
}
}
/// Resolve a digit/Enter activation for the currently highlighted row.
///
/// - "Other" row → enter free-text input mode.
/// - multi-select option → toggle into the pending set (Enter confirms on
/// the dedicated "Confirm" step; here it just toggles, like Space).
/// - single-select option → submit immediately (legacy behavior).
fn activate_or_confirm_selection(&mut self) -> ViewAction {
if self.is_other_selected() {
self.mode = InputMode::OtherInput;
self.other_input.clear();
return ViewAction::None;
}
if self.is_multi_select() {
if self.is_confirm_selected() {
// Flush the pending set as this question's answers. An empty
// set is allowed (skip-like) — the model is expected to offer a
// sensible default, but we don't deadlock.
let question = self.current_question();
let answers: Vec<UserInputAnswer> = self
.multi_pending
.iter()
.filter_map(|i| question.options.get(*i))
.map(|opt| UserInputAnswer {
id: question.id.clone(),
label: opt.label.clone(),
value: opt.label.clone(),
})
.collect();
return self.advance_question(answers);
}
// Enter/Space on a real option toggles it into the pending set.
self.toggle_pending(self.selected);
return ViewAction::None;
}
// Single-select: submit immediately.
let answers = self.answers_for_selection(self.selected);
self.advance_question(answers)
}
fn handle_other_input_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc => {
@@ -213,7 +286,20 @@ impl UserInputView {
label: "Other".to_string(),
value: self.other_input.trim().to_string(),
};
self.advance_question(answer)
// In multi-select mode a free-text "Other" is still a single
// answer appended to whatever options were toggled.
let mut answers: Vec<UserInputAnswer> = self
.multi_pending
.iter()
.filter_map(|i| question.options.get(*i))
.map(|opt| UserInputAnswer {
id: question.id.clone(),
label: opt.label.clone(),
value: opt.label.clone(),
})
.collect();
answers.push(answer);
self.advance_question(answers)
}
KeyCode::Backspace => {
self.other_input.pop();
@@ -288,24 +374,46 @@ impl ModalView for UserInputView {
for (idx, option) in question.options.iter().enumerate() {
let number = idx + 1;
let ticked = self.is_multi_select() && self.multi_pending.contains(&idx);
push_option_lines(
&mut lines,
self.selected == idx,
number,
option.label.clone(),
option.description.clone(),
ticked,
);
}
let other_index = question.options.len();
let other_number = other_index + 1;
push_option_lines(
&mut lines,
self.selected == other_index,
other_number,
"Other".to_string(),
"Type a custom response".to_string(),
);
// The free-text "Other" row is now conditional on allow_free_text.
if self.offers_other() {
let other_index = question.options.len();
let other_number = other_index + 1;
push_option_lines(
&mut lines,
self.selected == other_index,
other_number,
"Other".to_string(),
"Type a custom response".to_string(),
false,
);
}
// Multi-select gets a dedicated "Confirm selection" row after the
// options (and after "Other" when present). Selecting and pressing
// Enter on it flushes the pending set as the question's answers.
if self.is_multi_select() {
let confirm_index = self.option_count();
let confirm_number = confirm_index + 1;
push_option_lines(
&mut lines,
self.selected == confirm_index,
confirm_number,
"Confirm selection".to_string(),
format!("Submit {} selected", self.multi_pending.len()),
false,
);
}
if self.mode == InputMode::OtherInput {
lines.push(Line::from(""));
@@ -342,22 +450,41 @@ impl ModalView for UserInputView {
} else {
"digit".to_string()
};
lines.push(Line::from(vec![
Span::styled(
quick_pick_label,
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
),
Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Up/Down", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
]));
if self.is_multi_select() {
lines.push(Line::from(vec![
Span::styled(
quick_pick_label,
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
),
Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Space", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" toggle", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" toggle/confirm", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
]));
} else {
lines.push(Line::from(vec![
Span::styled(
quick_pick_label,
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
),
Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Up/Down", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
]));
}
}
let paragraph = Paragraph::new(lines)
@@ -425,6 +552,8 @@ mod tests {
description: "Return to editing before continuing".to_string(),
},
],
allow_free_text: true,
multi_select: false,
}],
},
)
@@ -437,6 +566,8 @@ mod tests {
assert!(rendered.contains("Action required"));
assert!(rendered.contains("Question 1 of 1"));
assert!(rendered.contains("quick pick"));
// allow_free_text=true surfaces the Other row.
assert!(rendered.contains("Other"));
}
#[test]
@@ -453,4 +584,44 @@ mod tests {
assert!(rendered.contains("Enter"));
assert!(rendered.contains("submit"));
}
#[test]
fn user_input_modal_hides_other_row_when_free_text_disabled() {
// Issue #3102: allow_free_text=false must NOT render the hardcoded
// "Other" pseudo-option. Previously "Other" was always appended.
let mut view = sample_view();
view.request.questions[0].allow_free_text = false;
// Reset selection to a valid option index (no Other row to land on).
view.selected = 0;
let rendered = render_view(&view, 110, 36);
assert!(
!rendered.contains("Type a custom response"),
"Other row should be hidden when allow_free_text is false"
);
assert!(!rendered.contains("\nOther\n"));
}
#[test]
fn user_input_modal_renders_multi_select_ticks_and_confirm() {
// Issue #3102: multi_select=true renders a check-mark gutter on
// toggled options plus a trailing "Confirm selection" row, and the
// controls hint advertises Space/Enter toggle semantics.
let mut view = sample_view();
view.request.questions[0].multi_select = true;
view.request.questions[0].allow_free_text = false;
// Toggle the first option into the pending set.
view.multi_pending.push(0);
// Highlight the confirm row (last selectable row).
view.selected = view.option_count() - 1;
let rendered = render_view(&view, 120, 40);
assert!(rendered.contains("✔"), "toggled option shows a check mark");
assert!(
rendered.contains("Confirm selection"),
"multi-select renders a confirm row"
);
assert!(rendered.contains("Submit 1 selected"));
assert!(rendered.contains("toggle"));
}
}
+10
View File
@@ -455,6 +455,16 @@ impl Renderable for ChatWidget {
Paragraph::new(self.lines.clone()).style(Style::default().bg(self.background));
paragraph.render(area, buf);
// #3029: the transcript carries OSC 8 hyperlinks in-band inside span
// content. Scan the rendered buffer for those payloads, blank the
// payload cells (so no cell ever holds `\x1b`/`]8;;` — fixes the
// column-drift corruption), and publish the recovered link regions
// for ColorCompatBackend::draw to re-emit out-of-band. This is the
// main transcript surface; the live-transcript overlay appends its
// own regions separately. Replaces the frame buffer each render.
let regions = crate::tui::osc8::extract_buffer_link_regions(buf, area);
crate::tui::osc8::set_frame_links(regions);
if let Some(scrollbar) = self.scrollbar {
let scrollable_range = scrollbar.total.saturating_sub(scrollbar.visible);
let mut state = ScrollbarState::new(scrollable_range)
+2 -2
View File
@@ -953,7 +953,7 @@ If you are upgrading from older releases:
- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://ark.cn-beijing.volces.com/api/coding/v3` for `volcengine`, `https://openrouter.ai/api/v1` for `openrouter`, `https://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.siliconflow.cn/v1` for `siliconflow-CN`, `https://api.arcee.ai/api/v1` for `arcee`, `https://api.moonshot.ai/v1` for `moonshot`, `https://api.minimax.io/v1` for `minimax`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://router.huggingface.co/v1` for `huggingface`, `https://api.together.xyz/v1` for `together`, `https://chatgpt.com/backend-api` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `base_url = "https://token-plan-cn.xiaomimimo.com/v1"` explicitly if your Xiaomi MiMo Token Plan account is provisioned in the China region. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.
- `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = "/chat/completions"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing.
- `insecure_skip_tls_verify` (bool, optional provider-table key): disabled by default. When true on the active provider table, only the LLM provider HTTP client skips TLS certificate verification. Prefer `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports this setting when enabled.
- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `DeepSeek-V4-Pro` for Volcengine Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.1` for Z.ai, `step-3.7-flash` for StepFun, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-2.7`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k2.7-code`, `kimi-k2.6`, and Kimi Code's stable `kimi-for-coding`; direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro` and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `zai`, `stepfun`, and Ollama model IDs are passed through unchanged after known aliases are normalized. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias.
- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `DeepSeek-V4-Pro` for Volcengine Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.2` for Z.ai, `step-3.7-flash` for StepFun, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-2.7`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k2.7-code`, `kimi-k2.6`, and Kimi Code's stable `kimi-for-coding`; direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro` and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `zai`, `stepfun`, and Ollama model IDs are passed through unchanged after known aliases are normalized. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias.
- `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, `max`, `xhigh`, or `ultracode`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. OpenAI Codex normalizes stale `off` to `low` and sends `max` / `ultracode` as Responses `xhigh`. Z.ai receives documented `thinking` controls and treats enabled thinking as the GLM coding high/max lane. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.
- `verbosity` (string, optional): `normal` or `concise`. `normal` keeps the
default conversational prompt. `concise` appends a prompt discipline block
@@ -1091,7 +1091,7 @@ If you are upgrading from older releases:
- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right sidebar and include visual wraps because the terminal, not the TUI, owns the selection.
- `tui.terminal_probe_timeout_ms` (int, optional, default `500`): startup terminal-mode probe timeout in milliseconds. Values are clamped to `100..=5000`; timeout emits a warning and aborts startup instead of hanging indefinitely.
- `tui.stream_chunk_timeout_secs` (int, optional, default `300`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs <seconds>`; `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.
- `tui.osc8_links` (bool, optional, default `true`): emit OSC 8 escape sequences around URLs in transcript output so terminals that support them (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) render them as Cmd+click hyperlinks. Terminals without OSC 8 support render the plain URL and ignore the escape. Set `false` for terminals that misrender the sequence; selection/clipboard output always strips the escapes.
- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so terminals that support them (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) render them as Cmd+click hyperlinks. Terminals without OSC 8 support render the plain URL and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.
- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).
- `features.*` (optional): feature flag overrides (see below).
+10 -8
View File
@@ -138,7 +138,7 @@ endpoint.
| `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = "siliconflow-CN"` or `CODEWHALE_PROVIDER=siliconflow-CN`. |
| `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. |
| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | `kimi-k2.7-code`, `kimi-k2.6`; Kimi Code path uses `kimi-for-coding` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; CodeWhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. `[providers.moonshot] auth_mode = "kimi_oauth"` reads Kimi Code OAuth credentials from `KIMI_CODE_HOME`/`~/.kimi-code`, with legacy `KIMI_SHARE_DIR`/`~/.kimi` fallback. |
| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general API `https://api.z.ai/api/paas/v4` | `GLM-5.1` default; `GLM-5.2` opt-in preview | Z.AI GLM Coding Plan route. Keep `GLM-5.1` as the default until 5.2 is generally documented; set `model = "GLM-5.2"` or `ZAI_MODEL=GLM-5.2` to try the preview. |
| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general API `https://api.z.ai/api/paas/v4` | `GLM-5.2` default; `GLM-5.1`, `GLM-5-Turbo` available | Z.AI GLM Coding Plan route. `GLM-5.2` is the default; set `model = "GLM-5.1"` or `ZAI_MODEL=GLM-5.1` for the smaller model, or `GLM-5-Turbo` for the fast variant used by faster/explore sub-agents. |
| `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. |
| `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; Anthropic-compatible routes are `https://api.minimax.io/anthropic` globally and `https://api.minimaxi.com/anthropic` in China | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. CodeWhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text, and direct MiniMax IDs stay distinct from OpenRouter namespaced IDs such as `minimax/minimax-m3`. |
| `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. |
@@ -198,14 +198,15 @@ large models verified through OpenRouter's model metadata:
`qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`,
`qwen/qwen3.6-plus`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`,
`xiaomi/mimo-v2.5`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`,
`z-ai/glm-5.1`, `z-ai/glm-5.2`, `tencent/hy3-preview`,
`z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`,
`google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, and
`nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`.
`minimax/minimax-m3` was added from OpenRouter's May 31, 2026 listing as a 1M
context multimodal model for coding, tool use, and long-horizon agentic work.
`z-ai/glm-5.2` is listed as an opt-in preview route ahead of broad availability;
`GLM-5.1` remains the default direct Z.AI model until 5.2 is generally
documented and smoke-tested.
`z-ai/glm-5.2` is now the default GLM route on both the Z.AI Coding Plan and
OpenRouter; `GLM-5.1` / `z-ai/glm-5.1` remain available as the smaller model,
and `GLM-5-Turbo` / `z-ai/glm-5-turbo` serve as the faster same-family sibling
used by faster/explore sub-agents.
## Static Model Registry
@@ -222,14 +223,14 @@ endpoint when the endpoint supports model listing.
| `atlascloud` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | yes | yes |
| `wanjie-ark` | `deepseek-reasoner` | yes | yes |
| `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes |
| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-2.7`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, `nvidia/nemotron-3-ultra-550b-a55b` | yes | yes |
| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-2.7`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, `nvidia/nemotron-3-ultra-550b-a55b` | yes | yes |
| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5`; speech/TTS IDs are selected through `codewhale speech` / `tts` | yes | yes for chat models; no for speech/TTS models |
| `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes |
| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes |
| `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |
| `arcee` | `trinity-large-thinking`, `trinity-large-preview`; provider-hinted custom model IDs pass through | yes | yes for `trinity-large-thinking`; no for `trinity-large-preview` |
| `moonshot` | `kimi-k2.7-code`, `kimi-k2.6` | yes | yes |
| `zai` | `GLM-5.1`, `GLM-5.2`; provider-hinted custom model IDs pass through | yes | yes |
| `zai` | `GLM-5.2`, `GLM-5.1`, `GLM-5-Turbo`; provider-hinted custom model IDs pass through | yes | yes |
| `stepfun` | `step-3.7-flash` | yes | no |
| `minimax` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | yes | yes |
| `sglang` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |
@@ -274,8 +275,9 @@ Anthropic uses Messages, and `openai-codex` uses Responses.
| Direct Arcee API `trinity-large-thinking` | 262,144 | 262,144 | yes | no | not documented in code |
| Direct Arcee API `trinity-large-preview` | 262,144 | 4,096 | no in doctor capability metadata | no | not documented in code |
| Direct Moonshot/Kimi `kimi-k2.7-code`, `kimi-k2.6`, `kimi-for-coding` | 262,144 | 262,144 | yes | no | not documented in code |
| Direct Z.AI `GLM-5.2` (default) | 1,000,000 | 131,072 | yes | no | not documented in code |
| Direct Z.AI `GLM-5.1` | 202,752 | 131,072 | yes | no | not documented in code |
| Direct Z.AI `GLM-5.2` | 1,000,000 | 131,072 provisional | yes | no | not documented in code |
| Direct Z.AI `GLM-5-Turbo` | 202,752 | 131,072 | yes | no | faster/explore sub-agent sibling |
| Direct MiniMax `MiniMax-M3` | 1,000,000 | 524,288 | yes | no | not documented in code |
| Direct MiniMax M2.x models | 204,800 | 4,096 fallback until MiniMax output metadata is promoted | yes | no | not documented in code |
| Generic `openai` and AtlasCloud | 128,000 | 4,096 | no in doctor capability metadata | no | not documented in code |