1pub(crate) mod config {
3 use crate::tools::{Approval, ToolPolicy};
4 use anyhow::{Context, Result, bail};
5 use chrono::Utc;
6 use dirs::config_dir;
7 use serde::{Deserialize, Serialize};
8 use std::env;
9 use std::fs;
10 use std::io::{IsTerminal as _, Write as _};
11 use std::path::{Path, PathBuf};
12
13 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
14 pub struct SavedModelConfig {
15 pub model: Option<String>,
16 pub shim: Option<String>,
17 }
18
19 #[derive(Debug, Clone, Serialize, Deserialize)]
20 pub struct SessionFile {
21 pub model: String,
22 pub saved_at: String,
23 #[serde(default)]
24 pub workspace_root: Option<PathBuf>,
25 pub transcript: serde_json::Value,
26 #[serde(default)]
27 pub todos: Vec<crate::tools::TodoItem>,
28 }
29
30 #[derive(Debug, Clone, Copy)]
31 pub struct ContextConfig {
32 pub limit_tokens: usize,
33 pub output_reserve_tokens: usize,
34 pub safety_reserve_tokens: usize,
35 pub trigger_ratio: f64,
36 pub recent_messages: usize,
37 pub tool_output_tokens: usize,
38 pub summary_tokens: usize,
39 }
40
41 impl ContextConfig {
42 pub fn input_budget_tokens(self) -> usize {
43 self.limit_tokens
44 .saturating_sub(self.output_reserve_tokens)
45 .saturating_sub(self.safety_reserve_tokens)
46 .max(1)
47 }
48
49 pub fn trigger_tokens(self) -> usize {
50 ((self.input_budget_tokens() as f64) * self.trigger_ratio) as usize
51 }
52 }
53
54 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
55 pub enum SafetyMode {
56 Default,
57 Plan,
58 AutoEdits,
59 AutoAll,
60 }
61
62 impl SafetyMode {
63 pub fn parse(value: &str) -> Result<Self> {
64 match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
65 "" | "default" | "ask" => Ok(Self::Default),
66 "plan" | "read-only" | "readonly" | "read" => Ok(Self::Plan),
67 "accept-edits" | "edit" | "edits" | "auto-edits" | "write" => Ok(Self::AutoEdits),
68 "auto-approve" | "auto" | "yolo" => Ok(Self::AutoAll),
69 other => bail!("Unknown mode `{other}`. Available: plan, ask, edit, auto"),
70 }
71 }
72
73 pub fn name(self) -> &'static str {
74 match self {
75 Self::Default => "default",
76 Self::Plan => "plan",
77 Self::AutoEdits => "accept-edits",
78 Self::AutoAll => "auto-approve",
79 }
80 }
81
82 fn system_prompt_suffix(self) -> &'static str {
83 match self {
84 Self::Default => "",
85 Self::Plan => PLAN_SYSTEM,
86 Self::AutoEdits => ACCEPT_EDITS_SYSTEM,
87 Self::AutoAll => AUTO_APPROVE_SYSTEM,
88 }
89 }
90
91 fn policy(self) -> ToolPolicy {
92 match self {
93 Self::Plan => ToolPolicy::read_only(),
94 Self::Default => ToolPolicy {
95 read_only: false,
96 files_write: Approval::Ask,
97 shell: Approval::Ask,
98 network: true,
99 },
100 Self::AutoEdits => ToolPolicy {
101 read_only: false,
102 files_write: Approval::Auto,
103 shell: Approval::Ask,
104 network: true,
105 },
106 Self::AutoAll => ToolPolicy {
107 read_only: false,
108 files_write: Approval::Auto,
109 shell: Approval::Auto,
110 network: true,
111 },
112 }
113 }
114 }
115
116 const DEFAULT_CONFIG_DIR_NAME: &str = "oy-rust";
117
118 const BASE_SYSTEM: &str = r#"You are oy, a coding CLI with tools.
119Optimize for the human reviewing your work: be terse, evidence-first, and explicit about changed files/commands.
120Follow the user's output constraints exactly.
121Work inspect → edit → verify. Use the cheapest sufficient tool:
1221. `list` for discovery.
1232. `search` for symbols, paths, and strings.
1243. `read` only narrow file slices you need.
1254. `replace` for surgical edits.
1265. `bash` only when file tools are insufficient or when you must run/check something.
127Batch independent reads/searches. Stop when enough evidence exists.
128Prefer small, boring, idiomatic, functional, testable code with explicit data flow.
129For security-sensitive work, name the trust boundary, validate near it, fail closed, and add focused tests.
130Do not add file, process, network, credential, or persistence capability unless necessary.
131For 3+ step work, keep a short in-memory todo; persist `TODO.md` only on explicit request or quit prompt.
132Use `webfetch` for public docs/API research when useful; prefer it over guessing.
133Tool arguments are schemas, not prose: use documented names, numeric `limit`/`offset`/timeouts, and `mode=literal` for exact search/replace when regex metacharacters are not intended.
134Manage context aggressively: keep only key facts and paths. Prefer narrow `path`, `offset`, `limit`, and `exclude`; use `sloc` if you need a repo-size snapshot.
135Before mutating files or running commands, state the next action briefly. After finishing, report changed files and checks.
136When context gets long, compress to the plan, key evidence, and next action. If blocked, say what you tried and the next step."#;
137
138 const INTERACTIVE_SUFFIX: &str =
139 "Use `ask` only for genuine ambiguity or irreversible user-facing choices. Batch prompts.";
140 const NONINTERACTIVE_SUFFIX: &str = "Non-interactive mode: stay unblocked without questions. Choose the safest reasonable path, state brief assumptions, and finish the inspect/edit/verify flow.";
141 const ASK_SUFFIX: &str = r#"RESEARCH-ONLY mode. Use only list, read, search, sloc, and webfetch. Stay no-write: leave files unchanged and skip `bash`. Focus on facts only, citing file paths and brief evidence."#;
142 const PLAN_SYSTEM: &str = r#"PLAN mode. Stay read-only. Use only list, read, search, sloc, todo for in-memory planning, ask when interactive, and webfetch when available. Keep files unchanged, skip shell commands, and describe changes as proposed rather than applied."#;
143 const ACCEPT_EDITS_SYSTEM: &str = r#"ACCEPT-EDITS mode. File edits may run without asking. Keep edits small and targeted, inspect before changing, and reach for `bash` only when genuinely necessary."#;
144 const AUTO_APPROVE_SYSTEM: &str = r#"AUTO-APPROVE mode. Tools may run without asking. Still avoid destructive commands, broad rewrites, credential exposure, persistence changes, and network/file/process expansion unless clearly needed. Treat shell and replacement tools as strict side effects: inspect first, then run the smallest command/edit."#;
145 const TODO_SYSTEM: &str = r#"Current in-memory todo:
146{todos}"#;
147
148 pub fn session_text_value(section: &str, key: &str) -> Result<String> {
149 let value = match (section, key) {
150 ("system", "base") => BASE_SYSTEM,
151 ("system", "interactive_suffix") => INTERACTIVE_SUFFIX,
152 ("system", "noninteractive_suffix") => NONINTERACTIVE_SUFFIX,
153 ("system", "ask_suffix") => ASK_SUFFIX,
154 ("transcript", "todo_system") => TODO_SYSTEM,
155 _ => bail!("missing session text key: {section}.{key}"),
156 };
157 Ok(value.to_string())
158 }
159
160 pub fn tool_description(name: &str) -> String {
161 match name {
162 "list" => "List workspace paths. Use first for discovery. `path` is a workspace-relative glob and defaults to `*`. Returns items, count, and truncation state.",
163 "read" => "Read one UTF-8 text file. Prefer narrow `offset`/`limit` slices over full-file reads.",
164 "search" => "Search workspace text with ripgrep-style Rust regex. Use `mode=literal` for exact strings.",
165 "replace" => "Replace workspace text with Rust regex captures, or exact text with `mode=literal`. Inspect/search before changing.",
166 "sloc" => "Count source lines with tokei for repository sizing. `path` may be one path or whitespace-separated paths.",
167 "bash" => "Run a shell command in the workspace. Use only when file tools are insufficient or when you must run/check something.",
168 "ask" => "Ask the user in interactive runs. Reserve for genuine ambiguity or irreversible choices.",
169 "webfetch" => "Fetch public web pages/files. Blocks localhost/private IPs and sensitive headers.",
170 "todo" => "Manage the in-memory todo list. Available in read-only modes; persistence to TODO.md is opt-in and requires write approval.",
171 other => other,
172 }
173 .to_string()
174 }
175
176 pub fn safety_mode(mode: &str) -> Result<SafetyMode> {
177 SafetyMode::parse(mode)
178 }
179
180 pub fn tool_policy(mode: &str) -> ToolPolicy {
181 let mode = SafetyMode::parse(mode).unwrap_or(SafetyMode::Default);
182 mode.policy()
183 }
184
185 pub fn config_root() -> PathBuf {
186 if let Ok(raw) = env::var("OY_CONFIG") {
187 return PathBuf::from(&raw)
188 .expand_home()
189 .unwrap_or_else(|_| PathBuf::from(raw));
190 }
191 config_dir()
192 .unwrap_or_else(|| PathBuf::from(".config"))
193 .join(DEFAULT_CONFIG_DIR_NAME)
194 .join("config.json")
195 }
196
197 pub fn oy_root() -> Result<PathBuf> {
198 let raw_root = env::var("OY_ROOT").unwrap_or_else(|_| ".".to_string());
199 let path = PathBuf::from(&raw_root)
200 .expand_home()
201 .unwrap_or_else(|_| PathBuf::from(raw_root))
202 .canonicalize()
203 .context("failed to resolve workspace root")?;
204 if !path.is_dir() {
205 bail!("Workspace root is not a directory: {}", path.display());
206 }
207 Ok(path)
208 }
209
210 pub fn config_dir_path() -> PathBuf {
211 config_root()
212 .parent()
213 .map(Path::to_path_buf)
214 .unwrap_or_else(|| PathBuf::from(format!(".config/{DEFAULT_CONFIG_DIR_NAME}")))
215 }
216
217 pub fn sessions_dir() -> Result<PathBuf> {
218 let dir = config_dir_path().join("sessions");
219 create_private_dir_all(&dir)?;
220 Ok(dir)
221 }
222
223 pub fn load_model_config() -> Result<SavedModelConfig> {
224 let path = config_root();
225 if !path.exists() {
226 return Ok(SavedModelConfig::default());
227 }
228 let data = fs::read_to_string(&path)
229 .with_context(|| format!("failed reading {}", path.display()))?;
230 let parsed = serde_json::from_str::<SavedModelConfig>(&data)
231 .with_context(|| format!("failed parsing {}", path.display()))?;
232 Ok(parsed)
233 }
234
235 pub fn save_model_config(model_spec: &str) -> Result<()> {
236 let path = config_root();
237 if let Some(parent) = path.parent() {
238 create_private_dir_all(parent)?;
239 }
240 let payload = saved_model_config_from_selection(model_spec);
241 let text = serde_json::to_string_pretty(&payload)?;
242 write_private_file(&path, text.as_bytes())?;
243 Ok(())
244 }
245
246 pub fn saved_model_config_from_selection(model_spec: &str) -> SavedModelConfig {
247 let model_spec = model_spec.trim();
248 let (prefix, model) = split_model_spec(model_spec);
249 if let Some(shim) = prefix.filter(|shim| is_routing_shim(shim)) {
250 return SavedModelConfig {
251 model: Some(genai_model_for_shim(shim, model)),
252 shim: Some(shim.to_string()),
253 };
254 }
255 SavedModelConfig {
256 model: Some(model_spec.to_string()),
257 shim: None,
258 }
259 }
260
261 fn genai_model_for_shim(shim: &str, model: &str) -> String {
262 if is_copilot_shim(shim) && is_openai_responses_model(model) {
263 format!("openai_resp::{model}")
264 } else {
265 model.to_string()
266 }
267 }
268
269 pub fn policy_risk_label(policy: &ToolPolicy) -> &'static str {
270 if policy.read_only {
271 "read-only: no file edits or shell"
272 } else if policy.shell == Approval::Auto {
273 "high: auto shell"
274 } else if policy.files_write == Approval::Auto {
275 "medium: auto edits"
276 } else {
277 "normal: asks before edits/shell"
278 }
279 }
280
281 pub fn is_openai_responses_model(model: &str) -> bool {
282 let (_, model) = split_model_spec(model);
283 let model = model
284 .rsplit_once('/')
285 .map(|(_, name)| name)
286 .unwrap_or(model);
287 model.starts_with("gpt-5.5")
288 || (model.starts_with("gpt") && (model.contains("codex") || model.contains("pro")))
289 }
290
291 pub fn is_routing_shim(shim: &str) -> bool {
292 matches!(
293 shim,
294 "openai" | "copilot" | "bedrock-mantle" | "opencode" | "opencode-go"
295 ) || shim
296 .strip_prefix("local-")
297 .is_some_and(|port| port.parse::<u16>().is_ok())
298 }
299
300 fn is_copilot_shim(shim: &str) -> bool {
301 shim == "copilot"
302 }
303
304 pub fn split_model_spec(spec: &str) -> (Option<&str>, &str) {
305 if let Some(index) = spec.find("::") {
306 let (left, right) = spec.split_at(index);
307 return (Some(left), &right[2..]);
308 }
309 (None, spec)
310 }
311
312 pub fn non_interactive() -> bool {
313 env_flag("OY_NON_INTERACTIVE", false)
314 }
315
316 pub fn can_prompt() -> bool {
317 std::io::stdin().is_terminal() && !non_interactive()
318 }
319
320 pub fn context_config() -> ContextConfig {
321 let limit_tokens = parse_usize_env("OY_CONTEXT_LIMIT", 128_000).max(1_000);
322 let output_reserve_tokens = parse_usize_env("OY_CONTEXT_OUTPUT_RESERVE", 12_000);
323 let safety_reserve_tokens = parse_usize_env("OY_CONTEXT_SAFETY_RESERVE", 4_000);
324 ContextConfig {
325 limit_tokens,
326 output_reserve_tokens,
327 safety_reserve_tokens,
328 trigger_ratio: parse_f64_env("OY_COMPACT_TRIGGER", 0.80).clamp(0.10, 1.0),
329 recent_messages: parse_usize_env("OY_COMPACT_RECENT_MESSAGES", 16).max(1),
330 tool_output_tokens: parse_usize_env("OY_COMPACT_TOOL_OUTPUT_TOKENS", 4_000).max(256),
331 summary_tokens: parse_usize_env("OY_COMPACT_SUMMARY_TOKENS", 8_000).max(512),
332 }
333 }
334
335 pub fn system_prompt(interactive: bool, mode: &str) -> String {
336 let mut prompt = BASE_SYSTEM.to_string();
337 prompt.push('\n');
338 prompt.push_str(if interactive {
339 INTERACTIVE_SUFFIX
340 } else {
341 NONINTERACTIVE_SUFFIX
342 });
343 if let Ok(mode) = safety_mode(mode) {
344 let suffix = mode.system_prompt_suffix().trim();
345 if !suffix.is_empty() {
346 prompt.push_str("\n\n");
347 prompt.push_str(suffix);
348 }
349 }
350 if let Ok(raw) = env::var("OY_SYSTEM_FILE") {
351 let path = PathBuf::from(&raw)
352 .expand_home()
353 .unwrap_or_else(|_| PathBuf::from(raw));
354 if path.is_file()
355 && let Ok(extra) = fs::read_to_string(path)
356 && !extra.trim().is_empty()
357 {
358 prompt.push_str("\n\n");
359 prompt.push_str(extra.trim());
360 }
361 }
362 prompt
363 }
364
365 pub fn ask_system_prompt(prompt: &str) -> String {
366 format!("{}\n\n{}", prompt.trim_end(), ASK_SUFFIX)
367 }
368
369 pub fn max_bash_cmd_bytes() -> usize {
370 env::var("OY_MAX_BASH_CMD_BYTES")
371 .ok()
372 .and_then(|v| v.parse().ok())
373 .unwrap_or(16 * 1024)
374 }
375
376 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
377 pub enum ToolRoundLimit {
378 Limited(usize),
379 Unlimited,
380 }
381
382 impl ToolRoundLimit {
383 pub fn exceeded(self, completed_rounds: usize) -> bool {
384 matches!(self, Self::Limited(max) if completed_rounds > max)
385 }
386
387 pub fn label(self) -> String {
388 match self {
389 Self::Limited(max) => max.to_string(),
390 Self::Unlimited => "unlimited".to_string(),
391 }
392 }
393 }
394
395 pub fn max_tool_rounds(default: usize) -> ToolRoundLimit {
396 parse_tool_round_limit(env::var("OY_MAX_TOOL_ROUNDS").ok().as_deref(), default)
397 }
398
399 pub fn save_session_file(name: Option<&str>, file: &SessionFile) -> Result<PathBuf> {
400 let sessions = sessions_dir()?;
401 let stem = name
402 .filter(|s| !s.trim().is_empty())
403 .map(sanitize_session_name)
404 .unwrap_or_else(|| Utc::now().format("%Y%m%d-%H%M%S").to_string());
405 let path = sessions.join(format!("{stem}.json"));
406 let body = serde_json::to_string_pretty(file)?;
407 write_private_file(&path, body.as_bytes())?;
408 Ok(path)
409 }
410
411 pub fn list_saved_sessions() -> Result<Vec<PathBuf>> {
412 let dir = sessions_dir()?;
413 let mut items = fs::read_dir(&dir)?
414 .filter_map(|entry| entry.ok().map(|e| e.path()))
415 .filter(|path| path.extension().and_then(|e| e.to_str()) == Some("json"))
416 .collect::<Vec<_>>();
417 items.sort_by_key(|path| {
418 fs::metadata(path)
419 .and_then(|m| m.modified())
420 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
421 });
422 items.reverse();
423 Ok(items)
424 }
425
426 pub fn resolve_saved_session(name: Option<&str>) -> Result<Option<PathBuf>> {
427 let sessions = list_saved_sessions()?;
428 if sessions.is_empty() {
429 return Ok(None);
430 }
431 let Some(name) = name else {
432 return Ok(sessions.first().cloned());
433 };
434 if let Ok(index) = name.parse::<usize>()
435 && index >= 1
436 && index <= sessions.len()
437 {
438 return Ok(Some(sessions[index - 1].clone()));
439 }
440 if let Some(exact) = sessions
441 .iter()
442 .find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(name))
443 {
444 return Ok(Some(exact.clone()));
445 }
446 Ok(sessions
447 .iter()
448 .find(|p| {
449 p.file_stem()
450 .and_then(|s| s.to_str())
451 .is_some_and(|s| s.contains(name))
452 })
453 .cloned())
454 }
455
456 pub fn load_session_file(path: &Path) -> Result<SessionFile> {
457 let data = fs::read_to_string(path)
458 .with_context(|| format!("failed reading {}", path.display()))?;
459 serde_json::from_str(&data).with_context(|| format!("failed parsing {}", path.display()))
460 }
461
462 pub fn sanitize_session_name(name: &str) -> String {
463 let mut out = String::with_capacity(name.len());
464 for ch in name.chars() {
465 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
466 out.push(ch);
467 } else if ch.is_whitespace() {
468 out.push('-');
469 }
470 }
471 let trimmed = out.trim_matches('-');
472 if trimmed.is_empty() {
473 "session".to_string()
474 } else {
475 trimmed.to_string()
476 }
477 }
478
479 fn parse_usize_env(name: &str, default: usize) -> usize {
480 env::var(name)
481 .ok()
482 .and_then(|v| v.trim().parse::<usize>().ok())
483 .unwrap_or(default)
484 }
485
486 fn parse_tool_round_limit(value: Option<&str>, default: usize) -> ToolRoundLimit {
487 let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
488 return ToolRoundLimit::Limited(default.max(1));
489 };
490 if matches!(
491 value.to_ascii_lowercase().as_str(),
492 "unlimited" | "none" | "off"
493 ) {
494 return ToolRoundLimit::Unlimited;
495 }
496 match value.parse::<usize>() {
497 Ok(0) => ToolRoundLimit::Unlimited,
498 Ok(max) => ToolRoundLimit::Limited(max),
499 Err(_) => ToolRoundLimit::Limited(default.max(1)),
500 }
501 }
502
503 fn parse_f64_env(name: &str, default: f64) -> f64 {
504 env::var(name)
505 .ok()
506 .and_then(|v| v.trim().parse::<f64>().ok())
507 .filter(|v| v.is_finite())
508 .unwrap_or(default)
509 }
510
511 pub fn write_workspace_file(path: &Path, bytes: &[u8]) -> Result<()> {
512 reject_symlink_destination(path)?;
513 if let Some(parent) = path.parent() {
514 fs::create_dir_all(parent)
515 .with_context(|| format!("failed creating {}", parent.display()))?;
516 }
517 #[cfg(unix)]
518 {
519 use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
520 let mode = fs::metadata(path)
521 .ok()
522 .map(|m| m.permissions().mode() & 0o777)
523 .unwrap_or(0o600);
524 let mut file = fs::OpenOptions::new()
525 .create(true)
526 .write(true)
527 .truncate(true)
528 .mode(mode)
529 .open(path)
530 .with_context(|| format!("failed writing {}", path.display()))?;
531 file.write_all(bytes)
532 .with_context(|| format!("failed writing {}", path.display()))?;
533 let mut perms = file.metadata()?.permissions();
534 perms.set_mode(mode);
535 file.set_permissions(perms)?;
536 Ok(())
537 }
538 #[cfg(not(unix))]
539 {
540 fs::write(path, bytes).with_context(|| format!("failed writing {}", path.display()))
541 }
542 }
543
544 pub fn resolve_workspace_output_path(root: &Path, requested: &Path) -> Result<PathBuf> {
545 if requested.is_absolute()
546 || requested
547 .components()
548 .any(|c| matches!(c, std::path::Component::ParentDir))
549 {
550 bail!(
551 "output path must stay inside workspace: {}",
552 requested.display()
553 );
554 }
555 let root = root
556 .canonicalize()
557 .context("failed to resolve workspace root")?;
558 let path = root.join(requested);
559 let parent = path.parent().unwrap_or(&root);
560 if parent.exists() {
561 let resolved_parent = parent
562 .canonicalize()
563 .with_context(|| format!("failed resolving {}", parent.display()))?;
564 if !resolved_parent.starts_with(&root) {
565 bail!("output path escapes workspace: {}", requested.display());
566 }
567 }
568 reject_symlink_destination(&path)?;
569 Ok(path)
570 }
571
572 pub fn reject_symlink_destination(path: &Path) -> Result<()> {
573 match fs::symlink_metadata(path) {
574 Ok(meta) if meta.file_type().is_symlink() => {
575 bail!("refusing to write symlink: {}", path.display())
576 }
577 Ok(_) => Ok(()),
578 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
579 Err(err) => Err(err).with_context(|| format!("failed checking {}", path.display())),
580 }
581 }
582
583 pub fn write_private_file(path: &Path, bytes: &[u8]) -> Result<()> {
584 #[cfg(unix)]
585 {
586 use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
587 if let Some(parent) = path.parent() {
588 create_private_dir_all(parent)?;
589 }
590 let mut file = fs::OpenOptions::new()
591 .create(true)
592 .write(true)
593 .truncate(true)
594 .mode(0o600)
595 .open(path)
596 .with_context(|| format!("failed writing {}", path.display()))?;
597 file.write_all(bytes)
598 .with_context(|| format!("failed writing {}", path.display()))?;
599 let mut perms = file.metadata()?.permissions();
600 perms.set_mode(0o600);
601 file.set_permissions(perms)?;
602 Ok(())
603 }
604 #[cfg(not(unix))]
605 {
606 fs::write(path, bytes).with_context(|| format!("failed writing {}", path.display()))
607 }
608 }
609
610 pub fn create_private_dir_all(path: &Path) -> Result<()> {
611 fs::create_dir_all(path).with_context(|| format!("failed to create {}", path.display()))?;
612 #[cfg(unix)]
613 {
614 use std::os::unix::fs::PermissionsExt as _;
615 let mut perms = fs::metadata(path)?.permissions();
616 perms.set_mode(0o700);
617 fs::set_permissions(path, perms)?;
618 }
619 Ok(())
620 }
621
622 fn env_flag(name: &str, default: bool) -> bool {
623 match env::var(name) {
624 Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
625 "1" | "true" | "yes" | "on" => true,
626 "0" | "false" | "no" | "off" => false,
627 _ => default,
628 },
629 Err(_) => default,
630 }
631 }
632
633 trait ExpandHome {
634 fn expand_home(self) -> Result<PathBuf>;
635 }
636
637 impl ExpandHome for PathBuf {
638 fn expand_home(self) -> Result<PathBuf> {
639 let text = self.to_string_lossy();
640 if text == "~" || text.starts_with("~/") {
641 let home = dirs::home_dir().context("home directory not found")?;
642 let suffix = text
643 .strip_prefix('~')
644 .unwrap_or_default()
645 .trim_start_matches('/');
646 return Ok(if suffix.is_empty() {
647 home
648 } else {
649 home.join(suffix)
650 });
651 }
652 Ok(self)
653 }
654 }
655
656 #[cfg(test)]
657 mod tests {
658 use super::*;
659
660 #[test]
661 fn mode_policy_and_risk_labels_are_centralized() {
662 let plan = tool_policy("plan");
663 assert_eq!(safety_mode("ask").unwrap().name(), "default");
664 assert_eq!(safety_mode("read_only").unwrap().name(), "plan");
665 assert_eq!(safety_mode("edit").unwrap().name(), "accept-edits");
666 assert_eq!(safety_mode("yolo").unwrap().name(), "auto-approve");
667 assert!(plan.read_only);
668 assert_eq!(
669 policy_risk_label(&plan),
670 "read-only: no file edits or shell"
671 );
672 assert_eq!(
673 policy_risk_label(&tool_policy("accept-edits")),
674 "medium: auto edits"
675 );
676 assert_eq!(
677 policy_risk_label(&tool_policy("auto-approve")),
678 "high: auto shell"
679 );
680 }
681
682 #[test]
683 fn output_paths_stay_in_workspace() {
684 let dir = tempfile::tempdir().unwrap();
685 assert!(resolve_workspace_output_path(dir.path(), Path::new("notes/out.md")).is_ok());
686 assert!(resolve_workspace_output_path(dir.path(), Path::new("../out.md")).is_err());
687 assert!(resolve_workspace_output_path(dir.path(), Path::new("/tmp/out.md")).is_err());
688 }
689
690 #[cfg(unix)]
691 #[test]
692 fn output_paths_reject_symlink_destinations() {
693 use std::os::unix::fs::symlink;
694 let dir = tempfile::tempdir().unwrap();
695 let target = dir.path().join("target.md");
696 fs::write(&target, "safe").unwrap();
697 symlink(&target, dir.path().join("link.md")).unwrap();
698 let err = resolve_workspace_output_path(dir.path(), Path::new("link.md")).unwrap_err();
699 assert!(err.to_string().contains("refusing to write symlink"));
700 }
701
702 #[test]
703 fn default_config_dir_name_is_rust_specific() {
704 assert_eq!(DEFAULT_CONFIG_DIR_NAME, "oy-rust");
705 }
706
707 #[test]
708 fn saved_model_config_keeps_exact_genai_model_and_infers_routing_shim() {
709 let saved = saved_model_config_from_selection("copilot::gpt-5.5");
710 assert_eq!(saved.model.as_deref(), Some("openai_resp::gpt-5.5"));
711 assert_eq!(saved.shim.as_deref(), Some("copilot"));
712
713 let saved = saved_model_config_from_selection("openai_resp::gpt-5.5");
714 assert_eq!(saved.model.as_deref(), Some("openai_resp::gpt-5.5"));
715 assert_eq!(saved.shim.as_deref(), None);
716 }
717
718 #[test]
719 fn split_model_spec_supports_double_colon() {
720 assert_eq!(
721 split_model_spec("copilot::gpt-4.1-mini"),
722 (Some("copilot"), "gpt-4.1-mini")
723 );
724 }
725
726 #[test]
727 fn split_model_spec_leaves_plain_models_untouched() {
728 assert_eq!(split_model_spec("gpt-5.4-mini"), (None, "gpt-5.4-mini"));
729 }
730
731 #[test]
732 fn session_text_loads_base_prompt() {
733 assert!(
734 session_text_value("system", "base")
735 .unwrap()
736 .contains("You are oy")
737 );
738 }
739
740 #[test]
741 fn session_file_ignores_legacy_mode_and_defaults_missing_fields() {
742 let raw = r#"{
743 "model": "gpt-test",
744 "agent": "default",
745 "mode": "auto-approve",
746 "saved_at": "2026-01-01T00:00:00",
747 "transcript": {"messages": []}
748 }"#;
749 let file: SessionFile = serde_json::from_str(raw).unwrap();
750 assert_eq!(file.model, "gpt-test");
751 assert!(file.todos.is_empty());
752 assert!(file.workspace_root.is_none());
753 }
754
755 #[test]
756 fn session_file_save_omits_mode() {
757 let file = SessionFile {
758 model: "gpt-test".into(),
759 saved_at: "2026-01-01T00:00:00".into(),
760 workspace_root: None,
761 transcript: serde_json::json!({"messages": []}),
762 todos: Vec::new(),
763 };
764 let raw = serde_json::to_value(&file).unwrap();
765 assert!(raw.get("mode").is_none());
766 assert!(raw.get("agent").is_none());
767 }
768
769 #[test]
770 fn tool_round_limit_supports_high_and_unlimited_values() {
771 assert_eq!(
772 parse_tool_round_limit(None, 512),
773 ToolRoundLimit::Limited(512)
774 );
775 assert_eq!(
776 parse_tool_round_limit(Some("2048"), 512),
777 ToolRoundLimit::Limited(2048)
778 );
779 assert_eq!(
780 parse_tool_round_limit(Some("0"), 512),
781 ToolRoundLimit::Unlimited
782 );
783 assert_eq!(
784 parse_tool_round_limit(Some("unlimited"), 512),
785 ToolRoundLimit::Unlimited
786 );
787 assert_eq!(
788 parse_tool_round_limit(Some("bad"), 512),
789 ToolRoundLimit::Limited(512)
790 );
791 assert!(ToolRoundLimit::Limited(2).exceeded(3));
792 assert!(!ToolRoundLimit::Unlimited.exceeded(usize::MAX));
793 }
794 }
795}
796
797pub(crate) mod ui {
799 use std::borrow::Cow;
800 use std::fmt::{Display, Write as _};
801 use std::io::IsTerminal as _;
802 use std::sync::LazyLock;
803 use std::sync::atomic::{AtomicU8, Ordering};
804 use std::time::Duration;
805 use syntect::easy::HighlightLines;
806 use syntect::highlighting::{Theme, ThemeSet};
807 use syntect::parsing::SyntaxSet;
808 use syntect::util::as_24_bit_terminal_escaped;
809 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
810
811 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
812 pub enum OutputMode {
813 Quiet = 0,
814 Normal = 1,
815 Verbose = 2,
816 Json = 3,
817 }
818
819 static OUTPUT_MODE: AtomicU8 = AtomicU8::new(OutputMode::Normal as u8);
820
821 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
822 enum ColorMode {
823 Auto,
824 Always,
825 Never,
826 }
827
828 static COLOR_MODE: LazyLock<ColorMode> = LazyLock::new(color_mode_from_env);
829
830 pub fn init_output_mode(mode: Option<OutputMode>) {
831 let mode = mode
832 .or_else(output_mode_from_env)
833 .unwrap_or(OutputMode::Normal);
834 set_output_mode(mode);
835 }
836
837 pub fn set_output_mode(mode: OutputMode) {
838 OUTPUT_MODE.store(mode as u8, Ordering::Relaxed);
839 }
840
841 pub fn output_mode() -> OutputMode {
842 match OUTPUT_MODE.load(Ordering::Relaxed) {
843 0 => OutputMode::Quiet,
844 2 => OutputMode::Verbose,
845 3 => OutputMode::Json,
846 _ => OutputMode::Normal,
847 }
848 }
849
850 pub fn is_quiet() -> bool {
851 matches!(output_mode(), OutputMode::Quiet | OutputMode::Json)
852 }
853
854 pub fn is_json() -> bool {
855 matches!(output_mode(), OutputMode::Json)
856 }
857
858 pub fn is_verbose() -> bool {
859 matches!(output_mode(), OutputMode::Verbose)
860 }
861
862 fn output_mode_from_env() -> Option<OutputMode> {
863 if truthy_env("OY_QUIET") {
864 return Some(OutputMode::Quiet);
865 }
866 if truthy_env("OY_VERBOSE") {
867 return Some(OutputMode::Verbose);
868 }
869 match std::env::var("OY_OUTPUT")
870 .ok()?
871 .to_ascii_lowercase()
872 .as_str()
873 {
874 "quiet" => Some(OutputMode::Quiet),
875 "verbose" => Some(OutputMode::Verbose),
876 "json" => Some(OutputMode::Json),
877 "normal" => Some(OutputMode::Normal),
878 _ => None,
879 }
880 }
881
882 fn truthy_env(name: &str) -> bool {
883 matches!(
884 std::env::var(name).ok().as_deref(),
885 Some("1" | "true" | "yes" | "on")
886 )
887 }
888
889 fn color_mode_from_env() -> ColorMode {
890 color_mode_from_values(
891 std::env::var_os("NO_COLOR").is_some(),
892 std::env::var("OY_COLOR").ok().as_deref(),
893 )
894 }
895
896 fn color_mode_from_values(no_color: bool, oy_color: Option<&str>) -> ColorMode {
897 if no_color {
898 return ColorMode::Never;
899 }
900 match oy_color.map(str::to_ascii_lowercase).as_deref() {
901 Some("always" | "1" | "true" | "yes" | "on") => ColorMode::Always,
902 Some("never" | "0" | "false" | "no" | "off") => ColorMode::Never,
903 _ => ColorMode::Auto,
904 }
905 }
906
907 pub fn color_enabled() -> bool {
908 color_enabled_for_stdout(std::io::stdout().is_terminal())
909 }
910
911 fn color_enabled_for_stdout(stdout_is_terminal: bool) -> bool {
912 color_enabled_for_mode(*COLOR_MODE, stdout_is_terminal)
913 }
914
915 fn color_enabled_for_mode(mode: ColorMode, stdout_is_terminal: bool) -> bool {
916 match mode {
917 ColorMode::Always => true,
918 ColorMode::Never => false,
919 ColorMode::Auto => stdout_is_terminal,
920 }
921 }
922
923 pub fn terminal_width() -> usize {
924 terminal_size::terminal_size()
925 .map(|(terminal_size::Width(width), _)| width as usize)
926 .filter(|width| *width >= 40)
927 .unwrap_or(100)
928 }
929
930 pub fn paint(code: &str, text: impl Display) -> String {
931 if color_enabled() {
932 format!("\x1b[{code}m{text}\x1b[0m")
933 } else {
934 text.to_string()
935 }
936 }
937
938 pub fn faint(text: impl Display) -> String {
939 paint("2", text)
940 }
941
942 pub fn bold(text: impl Display) -> String {
943 paint("1", text)
944 }
945
946 pub fn cyan(text: impl Display) -> String {
947 paint("36", text)
948 }
949
950 pub fn green(text: impl Display) -> String {
951 paint("32", text)
952 }
953
954 pub fn yellow(text: impl Display) -> String {
955 paint("33", text)
956 }
957
958 pub fn red(text: impl Display) -> String {
959 paint("31", text)
960 }
961
962 pub fn magenta(text: impl Display) -> String {
963 paint("35", text)
964 }
965
966 pub fn status_text(ok: bool, text: impl Display) -> String {
967 if ok { green(text) } else { red(text) }
968 }
969
970 pub fn bool_text(value: bool) -> String {
971 status_text(value, value)
972 }
973
974 pub fn path(text: impl Display) -> String {
975 paint("1;36", text)
976 }
977
978 pub fn out(text: &str) {
979 print!("{text}");
980 }
981
982 pub fn err(text: &str) {
983 eprint!("{text}");
984 }
985
986 pub fn line(text: impl Display) {
987 out(&format!("{text}\n"));
988 }
989
990 pub fn err_line(text: impl Display) {
991 err(&format!("{text}\n"));
992 }
993
994 pub fn markdown(text: &str) {
995 out(&render_markdown(text));
996 }
997
998 fn render_markdown(text: &str) -> String {
999 if !color_enabled() {
1000 return text.to_string();
1001 }
1002 let mut in_fence = false;
1003 let mut out = String::new();
1004 for line in text.lines() {
1005 let trimmed = line.trim_start();
1006 let rendered = if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
1007 in_fence = !in_fence;
1008 faint(line)
1009 } else if in_fence {
1010 cyan(line)
1011 } else if trimmed.starts_with('#') {
1012 paint("1;35", line)
1013 } else if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
1014 cyan(line)
1015 } else {
1016 line.to_string()
1017 };
1018 let _ = writeln!(out, "{rendered}");
1019 }
1020 if text.ends_with('\n') {
1021 out
1022 } else {
1023 out.trim_end_matches('\n').to_string()
1024 }
1025 }
1026
1027 pub fn code(path: &str, text: &str, first_line: usize) -> String {
1028 numbered_block(path, &normalize_code_preview_text(text), first_line)
1029 }
1030
1031 pub fn text_block(title: &str, text: &str) -> String {
1032 numbered_block(title, text, 1)
1033 }
1034
1035 pub fn block_title(title: &str) -> String {
1036 path(format_args!("── {title}"))
1037 }
1038
1039 #[cfg(test)]
1040 fn numbered_line(line_number: usize, width: usize, text: &str) -> String {
1041 numbered_line_with_max_width(line_number, width, text, usize::MAX)
1042 }
1043
1044 fn numbered_line_with_max_width(
1045 line_number: usize,
1046 width: usize,
1047 text: &str,
1048 max_width: usize,
1049 ) -> String {
1050 let text = normalize_code_preview_text(text);
1051 let prefix = format!(
1052 "{} {} ",
1053 faint(format_args!("{line_number:>width$}")),
1054 faint("│")
1055 );
1056 let available = max_width
1057 .saturating_sub(ansi_stripped_width(&prefix))
1058 .max(1);
1059 format!("{prefix}{}", truncate_width(&text, available))
1060 }
1061
1062 fn normalize_code_preview_text(text: &str) -> Cow<'_, str> {
1063 const TAB_WIDTH: usize = 4;
1064 if !text.contains('\t') {
1065 return Cow::Borrowed(text);
1066 }
1067
1068 let mut out = String::with_capacity(text.len());
1069 let mut column = 0usize;
1070 for ch in text.chars() {
1071 match ch {
1072 '\t' => {
1073 let spaces = TAB_WIDTH - (column % TAB_WIDTH);
1074 out.extend(std::iter::repeat_n(' ', spaces));
1075 column += spaces;
1076 }
1077 '\n' | '\r' => {
1078 out.push(ch);
1079 column = 0;
1080 }
1081 _ => {
1082 out.push(ch);
1083 column += UnicodeWidthChar::width(ch).unwrap_or(0);
1084 }
1085 }
1086 }
1087 Cow::Owned(out)
1088 }
1089
1090 fn numbered_block(title: &str, text: &str, first_line: usize) -> String {
1091 let title = if title.is_empty() { "text" } else { title };
1092 let line_count = text.lines().count().max(1);
1093 let width = first_line
1094 .saturating_add(line_count.saturating_sub(1))
1095 .max(1)
1096 .to_string()
1097 .len();
1098 let max_width = terminal_width().saturating_sub(4).max(40);
1099 let code_width = max_width.saturating_sub(width + 3).max(1);
1100 let mut out = String::new();
1101 let _ = writeln!(out, "{}", truncate_width(&block_title(title), max_width));
1102 if text.is_empty() {
1103 let _ = writeln!(
1104 out,
1105 "{}",
1106 numbered_line_with_max_width(first_line, width, "", max_width)
1107 );
1108 } else {
1109 let display_text = text
1110 .lines()
1111 .map(|line| truncate_width(line, code_width))
1112 .collect::<Vec<_>>()
1113 .join("\n");
1114 let highlighted = highlighted_block(title, &display_text);
1115 let lines = highlighted.as_deref().unwrap_or(&display_text).lines();
1116 for (idx, line) in lines.enumerate() {
1117 let _ = writeln!(
1118 out,
1119 "{}",
1120 numbered_line_with_max_width(first_line + idx, width, line, max_width)
1121 );
1122 }
1123 }
1124 out.trim_end().to_string()
1125 }
1126
1127 static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
1128 static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
1129
1130 fn highlighted_block(title: &str, text: &str) -> Option<String> {
1131 if !color_enabled() {
1132 return None;
1133 }
1134 let syntax = syntax_for_title(title)?;
1135 let theme = terminal_theme()?;
1136 let mut highlighter = HighlightLines::new(syntax, theme);
1137 let mut out = String::new();
1138 for line in text.lines() {
1139 let ranges = highlighter.highlight_line(line, &SYNTAX_SET).ok()?;
1140 let _ = writeln!(out, "{}", as_24_bit_terminal_escaped(&ranges, false));
1141 }
1142 Some(if text.ends_with('\n') {
1143 out
1144 } else {
1145 out.trim_end_matches('\n').to_string()
1146 })
1147 }
1148
1149 fn syntax_for_title(title: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
1150 let syntaxes = &*SYNTAX_SET;
1151 let name = title.rsplit('/').next().unwrap_or(title);
1152 if let Some(ext) = name.rsplit_once('.').map(|(_, ext)| ext) {
1153 syntaxes.find_syntax_by_extension(ext)
1154 } else {
1155 syntaxes.find_syntax_by_token(name)
1156 }
1157 .or_else(|| syntaxes.find_syntax_by_name(title))
1158 }
1159
1160 fn terminal_theme() -> Option<&'static Theme> {
1161 THEME_SET
1162 .themes
1163 .get("base16-ocean.dark")
1164 .or_else(|| THEME_SET.themes.values().next())
1165 }
1166
1167 pub fn diff(text: &str) -> String {
1168 if !color_enabled() {
1169 return text.to_string();
1170 }
1171 let mut out = String::new();
1172 for line in text.lines() {
1173 let rendered = if line.starts_with("+++") || line.starts_with("---") {
1174 bold(line)
1175 } else if line.starts_with("@@") {
1176 cyan(line)
1177 } else if line.starts_with('+') {
1178 green(line)
1179 } else if line.starts_with('-') {
1180 red(line)
1181 } else {
1182 line.to_string()
1183 };
1184 let _ = writeln!(out, "{rendered}");
1185 }
1186 if text.ends_with('\n') {
1187 out
1188 } else {
1189 out.trim_end_matches('\n').to_string()
1190 }
1191 }
1192
1193 pub fn section(title: &str) {
1194 line(bold(title));
1195 }
1196
1197 pub fn kv(key: &str, value: impl Display) {
1198 line(format_args!(
1199 " {} {value}",
1200 faint(format_args!("{key:<11}"))
1201 ));
1202 }
1203
1204 pub fn success(text: impl Display) {
1205 line(format_args!("{} {text}", green("✓")));
1206 }
1207
1208 pub fn warn(text: impl Display) {
1209 line(format_args!("{} {text}", yellow("!")));
1210 }
1211
1212 pub fn progress(
1213 label: &str,
1214 current: usize,
1215 total: usize,
1216 detail: impl Display,
1217 elapsed: Duration,
1218 ) {
1219 if is_quiet() {
1220 return;
1221 }
1222 line(progress_line(
1223 label,
1224 current,
1225 total,
1226 &detail.to_string(),
1227 elapsed,
1228 ));
1229 }
1230
1231 fn progress_line(
1232 label: &str,
1233 current: usize,
1234 total: usize,
1235 detail: &str,
1236 elapsed: Duration,
1237 ) -> String {
1238 let total = total.max(1);
1239 let current = current.min(total);
1240 let head = format!(
1241 " {} {current}/{total} {}",
1242 progress_bar(current, total, 18),
1243 cyan(label)
1244 );
1245 if detail.trim().is_empty() {
1246 format!("{head} · {}", faint(format_duration(elapsed)))
1247 } else {
1248 format!("{head} · {detail} · {}", faint(format_duration(elapsed)))
1249 }
1250 }
1251
1252 fn progress_bar(current: usize, total: usize, width: usize) -> String {
1253 let width = width.max(1);
1254 let total = total.max(1);
1255 let current = current.min(total);
1256 let filled = current.saturating_mul(width) / total;
1257 format!(
1258 "[{}{}]",
1259 green("█".repeat(filled)),
1260 faint("░".repeat(width.saturating_sub(filled)))
1261 )
1262 }
1263
1264 pub fn tool_batch(round: usize, count: usize) {
1265 if is_quiet() {
1266 return;
1267 }
1268 err_line(tool_batch_line(round, count));
1269 }
1270
1271 pub fn tool_start(name: &str, detail: &str) {
1272 if is_quiet() {
1273 return;
1274 }
1275 err_line(tool_start_line(name, detail));
1276 }
1277
1278 pub fn tool_result(name: &str, elapsed: Duration, preview: &str) {
1279 if is_quiet() {
1280 return;
1281 }
1282 let preview = preview.trim_end();
1283 let head = tool_result_head(name, elapsed);
1284 let Some((first, rest)) = preview.split_once('\n') else {
1285 if preview.is_empty() {
1286 err_line(head);
1287 } else {
1288 err_line(format_args!("{head} · {first}", first = preview));
1289 }
1290 return;
1291 };
1292 err_line(format_args!("{head} · {first}"));
1293 for line in rest.lines() {
1294 err_line(format_args!(" {line}"));
1295 }
1296 }
1297
1298 pub fn tool_error(name: &str, elapsed: Duration, err: impl Display) {
1299 if is_quiet() {
1300 return;
1301 }
1302 err_line(format_args!(
1303 " {} {name} {} · {err:#}",
1304 red("✗"),
1305 format_duration(elapsed)
1306 ));
1307 }
1308
1309 pub fn format_duration(elapsed: Duration) -> String {
1310 if elapsed.as_millis() < 1000 {
1311 format!("{}ms", elapsed.as_millis())
1312 } else {
1313 format!("{:.1}s", elapsed.as_secs_f64())
1314 }
1315 }
1316
1317 fn tool_batch_line(round: usize, count: usize) -> String {
1318 format!("{} tools r{round} ×{count}", magenta("↻"))
1319 }
1320
1321 fn tool_start_line(name: &str, detail: &str) -> String {
1322 if detail.is_empty() {
1323 format!(" {} {name}", cyan("→"))
1324 } else {
1325 format!(" {} {name} · {detail}", cyan("→"))
1326 }
1327 }
1328
1329 fn tool_result_head(name: &str, elapsed: Duration) -> String {
1330 format!(" {} {name} {}", green("✓"), format_duration(elapsed))
1331 }
1332
1333 pub fn compact_spaces(value: &str) -> String {
1334 value.split_whitespace().collect::<Vec<_>>().join(" ")
1335 }
1336
1337 pub fn truncate_chars(text: &str, max: usize) -> String {
1338 truncate_width(text, max)
1339 }
1340
1341 pub fn truncate_width(text: &str, max_width: usize) -> String {
1342 if ansi_stripped_width(text) <= max_width {
1343 return text.to_string();
1344 }
1345 truncate_plain_width(text, max_width)
1346 }
1347
1348 fn truncate_plain_width(text: &str, max_width: usize) -> String {
1349 if UnicodeWidthStr::width(text) <= max_width {
1350 return text.to_string();
1351 }
1352 let ellipsis = "…";
1353 let limit = max_width.saturating_sub(UnicodeWidthStr::width(ellipsis));
1354 let mut out = String::new();
1355 let mut width = 0usize;
1356 for ch in text.chars() {
1357 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
1358 if width + ch_width > limit {
1359 break;
1360 }
1361 width += ch_width;
1362 out.push(ch);
1363 }
1364 out.push_str(ellipsis);
1365 out
1366 }
1367
1368 fn ansi_stripped_width(text: &str) -> usize {
1369 let mut width = 0usize;
1370 let mut chars = text.chars().peekable();
1371 while let Some(ch) = chars.next() {
1372 if ch == '\u{1b}' && chars.peek() == Some(&'[') {
1373 chars.next();
1374 for next in chars.by_ref() {
1375 if ('@'..='~').contains(&next) {
1376 break;
1377 }
1378 }
1379 } else {
1380 width += UnicodeWidthChar::width(ch).unwrap_or(0);
1381 }
1382 }
1383 width
1384 }
1385
1386 pub fn compact_preview(text: &str, max: usize) -> String {
1387 truncate_width(&compact_spaces(text), max)
1388 }
1389
1390 pub fn clamp_lines(text: &str, max_lines: usize, max_cols: usize) -> String {
1391 let mut out = String::new();
1392 let lines = text.lines().collect::<Vec<_>>();
1393 for line in lines.iter().take(max_lines) {
1394 if !out.is_empty() {
1395 out.push('\n');
1396 }
1397 out.push_str(&truncate_width(line, max_cols));
1398 }
1399 if lines.len() > max_lines {
1400 let _ = write!(out, "\n… {} more lines", lines.len() - max_lines);
1401 }
1402 out
1403 }
1404
1405 #[allow(dead_code)]
1406 pub fn wrap_line(text: &str, indent: &str) -> String {
1407 let width = terminal_width().saturating_sub(indent.width()).max(20);
1408 textwrap::wrap(text, width)
1409 .into_iter()
1410 .map(|line| format!("{indent}{line}"))
1411 .collect::<Vec<_>>()
1412 .join("\n")
1413 }
1414
1415 pub fn head_tail(text: &str, max_chars: usize) -> (String, bool) {
1416 if text.chars().count() <= max_chars {
1417 return (text.to_string(), false);
1418 }
1419 let head_len = max_chars / 2;
1420 let tail_len = max_chars.saturating_sub(head_len);
1421 let head = text.chars().take(head_len).collect::<String>();
1422 let tail = text
1423 .chars()
1424 .rev()
1425 .take(tail_len)
1426 .collect::<Vec<_>>()
1427 .into_iter()
1428 .rev()
1429 .collect::<String>();
1430 let hidden = text
1431 .chars()
1432 .count()
1433 .saturating_sub(head.chars().count() + tail.chars().count());
1434 (
1435 format!("{head}\n… [truncated {hidden} chars] …\n{tail}"),
1436 true,
1437 )
1438 }
1439
1440 #[cfg(test)]
1441 mod tests {
1442 use super::*;
1443
1444 fn color_mode_name(mode: ColorMode) -> &'static str {
1445 match mode {
1446 ColorMode::Auto => "auto",
1447 ColorMode::Always => "always",
1448 ColorMode::Never => "never",
1449 }
1450 }
1451
1452 #[test]
1453 fn color_mode_env_parsing() {
1454 assert_eq!(color_mode_name(color_mode_from_values(false, None)), "auto");
1455 assert_eq!(
1456 color_mode_name(color_mode_from_values(false, Some("always"))),
1457 "always"
1458 );
1459 assert_eq!(
1460 color_mode_name(color_mode_from_values(false, Some("on"))),
1461 "always"
1462 );
1463 assert_eq!(
1464 color_mode_name(color_mode_from_values(false, Some("off"))),
1465 "never"
1466 );
1467 assert_eq!(
1468 color_mode_name(color_mode_from_values(true, Some("always"))),
1469 "never"
1470 );
1471 }
1472
1473 #[test]
1474 fn color_auto_requires_terminal() {
1475 assert!(!color_enabled_for_mode(ColorMode::Auto, false));
1476 assert!(color_enabled_for_mode(ColorMode::Auto, true));
1477 assert!(color_enabled_for_mode(ColorMode::Always, false));
1478 assert!(!color_enabled_for_mode(ColorMode::Never, true));
1479 }
1480
1481 #[test]
1482 fn elapsed_format_is_compact() {
1483 assert_eq!(format_duration(Duration::from_millis(42)), "42ms");
1484 assert_eq!(format_duration(Duration::from_millis(1250)), "1.2s");
1485 }
1486
1487 #[test]
1488 fn progress_line_shows_bar_count_detail_and_elapsed() {
1489 set_output_mode(OutputMode::Normal);
1490 assert_eq!(progress_bar(2, 4, 8), "[████░░░░]");
1491 assert_eq!(
1492 progress_line("review", 2, 4, "chunk 3", Duration::from_millis(1250)),
1493 " [█████████░░░░░░░░░] 2/4 review · chunk 3 · 1.2s"
1494 );
1495 }
1496
1497 #[test]
1498 fn tool_progress_lines_are_dense() {
1499 set_output_mode(OutputMode::Normal);
1500 assert_eq!(tool_batch_line(2, 3), "↻ tools r2 ×3");
1501 assert_eq!(
1502 tool_start_line("read", "path=src/main.rs"),
1503 " → read · path=src/main.rs"
1504 );
1505 assert_eq!(
1506 tool_result_head("read", Duration::from_millis(42)),
1507 " ✓ read 42ms"
1508 );
1509 }
1510
1511 #[test]
1512 fn numbered_line_expands_tabs_to_stable_columns() {
1513 set_output_mode(OutputMode::Normal);
1514 assert_eq!(numbered_line(7, 1, "\tlet x = 1;"), "7 │ let x = 1;");
1515 assert_eq!(numbered_line(8, 1, "ab\tcd"), "8 │ ab cd");
1516 assert_eq!(
1517 code("demo.rs", "\tfn main() {}\n\t\tprintln!(\"hi\");", 1),
1518 "── demo.rs\n1 │ fn main() {}\n2 │ println!(\"hi\");"
1519 );
1520 }
1521
1522 #[test]
1523 fn numbered_line_clamps_long_read_lines_to_preview_width() {
1524 set_output_mode(OutputMode::Normal);
1525 let line = numbered_line_with_max_width(
1526 394,
1527 3,
1528 r#" .filter(|line| !line.starts_with(&format!("> {}", prompts::AUDIT_TRANSPARENCY_PREFIX)))"#,
1529 40,
1530 );
1531 assert!(UnicodeWidthStr::width(line.as_str()) <= 40, "{line}");
1532 assert!(line.starts_with("394 │ "));
1533 assert!(line.ends_with('…'));
1534 assert!(!line.contains('\n'));
1535 }
1536
1537 #[test]
1538 fn code_preview_lines_fit_tool_result_indent_width() {
1539 set_output_mode(OutputMode::Normal);
1540 let preview = code(
1541 "src/audit.rs",
1542 r#"pub(crate) fn with_transparency_line(report: &str, snippet: &str) -> String {
1543 .filter(|line| !line.starts_with(&format!("> {}", prompts::AUDIT_TRANSPARENCY_PREFIX)))"#,
1544 390,
1545 );
1546 let max_width = terminal_width().saturating_sub(4).max(40);
1547 for line in preview.lines() {
1548 assert!(
1549 UnicodeWidthStr::width(line) <= max_width,
1550 "line exceeded {max_width}: {line}"
1551 );
1552 }
1553 }
1554 }
1555}
1556
1557pub(crate) mod chat {
1559 use anyhow::Result;
1560 use dialoguer::{Input, Select, theme::ColorfulTheme};
1561 use std::fmt::Display;
1562
1563 use reedline_repl_rs::reedline::{
1564 DefaultPrompt, DefaultPromptSegment, EditCommand, Emacs, FileBackedHistory, KeyCode,
1565 KeyModifiers, Reedline, ReedlineEvent, Signal, default_emacs_keybindings,
1566 };
1567 use std::path::PathBuf;
1568
1569 use crate::config;
1570 use crate::model;
1571 use crate::session::{self, Session};
1572
1573 const HISTORY_SIZE: usize = 10_000;
1574
1575 fn chat_line_editor(history_path: PathBuf) -> Result<Reedline> {
1576 let mut keybindings = default_emacs_keybindings();
1577 keybindings.add_binding(KeyModifiers::NONE, KeyCode::Enter, ReedlineEvent::Submit);
1578 let insert_newline = ReedlineEvent::Edit(vec![EditCommand::InsertNewline]);
1579 keybindings.add_binding(KeyModifiers::SHIFT, KeyCode::Enter, insert_newline.clone());
1580 keybindings.add_binding(KeyModifiers::ALT, KeyCode::Enter, insert_newline);
1581
1582 Ok(Reedline::create()
1583 .with_history(Box::new(FileBackedHistory::with_file(
1584 HISTORY_SIZE,
1585 history_path,
1586 )?))
1587 .with_edit_mode(Box::new(Emacs::new(keybindings)))
1588 .use_bracketed_paste(true))
1589 }
1590
1591 pub async fn run_chat(session: &mut Session) -> Result<i32> {
1592 crate::ui::section("oy chat");
1593 crate::ui::kv("keys", "Enter sends · Alt/Shift+Enter newline · /? help");
1594 let history_path = history_path("chat")?;
1595 let mut line_editor = chat_line_editor(history_path.clone())?;
1596 let prompt = DefaultPrompt::new(
1597 DefaultPromptSegment::Basic("oy".to_string()),
1598 DefaultPromptSegment::Empty,
1599 );
1600
1601 loop {
1602 let signal = match line_editor.read_line(&prompt) {
1603 Ok(signal) => signal,
1604 Err(err) if is_cursor_position_timeout(&err) => {
1605 crate::ui::warn("terminal cursor position timed out; resetting prompt");
1606 line_editor = chat_line_editor(history_path.clone())?;
1607 continue;
1608 }
1609 Err(err) => return Err(err.into()),
1610 };
1611
1612 match signal {
1613 Signal::Success(line) => {
1614 line_editor.sync_history()?;
1615 if !handle_chat_line(session, line.trim()).await? {
1616 break;
1617 }
1618 }
1619 Signal::CtrlD => break,
1620 Signal::CtrlC => {
1621 line_editor.sync_history()?;
1622 break;
1623 }
1624 }
1625 }
1626 prompt_update_todo_on_quit(session);
1627 Ok(0)
1628 }
1629
1630 fn is_cursor_position_timeout(err: &impl Display) -> bool {
1631 let text = err.to_string();
1632 text.contains("cursor position") && text.contains("could not be read")
1633 }
1634
1635 fn prompt_update_todo_on_quit(session: &Session) {
1636 if crate::config::can_prompt() && !session.todos.is_empty() {
1637 let active = session
1638 .todos
1639 .iter()
1640 .filter(|item| item.status != "done")
1641 .count();
1642 crate::ui::line(format_args!(
1643 "todo summary: {active}/{} active in memory; use the todo tool with persist=true to write TODO.md",
1644 session.todos.len()
1645 ));
1646 }
1647 }
1648
1649 async fn handle_chat_line(session: &mut Session, line: &str) -> Result<bool> {
1650 if line.is_empty() {
1651 return Ok(true);
1652 }
1653 if let Some(command) = line.strip_prefix('/') {
1654 return handle_slash_command(session, command.trim()).await;
1655 }
1656 run_prompt_with_model_reselect(session, line).await?;
1657 Ok(true)
1658 }
1659
1660 async fn handle_slash_command(session: &mut Session, command: &str) -> Result<bool> {
1661 let mut parts = command.split_whitespace();
1662 let raw_name = parts.next().unwrap_or_default();
1663 let name = normalize_chat_command(raw_name);
1664 match name {
1665 "" => Ok(true),
1666 "help" => {
1667 crate::ui::markdown(&format!("{}\n", chat_help_text()));
1668 Ok(true)
1669 }
1670 "tokens" => tokens_command(session),
1671 "compact" => compact_command(parts.next(), session).await,
1672 "model" => model_command(parts.next(), session).await,
1673 "thinking" => thinking_command(parts.next()),
1674 "debug" | "status" => status_command(session),
1675 "ask" => {
1676 let prompt = parts.collect::<Vec<_>>().join(" ");
1677 ask_command(session, &prompt).await
1678 }
1679 "save" => save_command(parts.next(), session),
1680 "load" => load_command(parts.next(), session),
1681 "undo" => undo_command(session),
1682 "clear" => clear_command(session),
1683 "quit" | "exit" => Ok(false),
1684 other => {
1685 crate::ui::warn(format_args!("unknown command /{other}"));
1686 Ok(true)
1687 }
1688 }
1689 }
1690
1691 fn normalize_chat_command(command: &str) -> &str {
1692 match command {
1693 "h" | "?" => "help",
1694 "t" => "tokens",
1695 "k" => "compact",
1696 "m" => "model",
1697 "d" => "debug",
1698 "s" => "status",
1699 "u" => "undo",
1700 "c" => "clear",
1701 "q" => "quit",
1702 other => other,
1703 }
1704 }
1705
1706 pub(crate) fn chat_help_text() -> String {
1707 [
1708 "Enter sends; Alt/Shift+Enter inserts newline",
1709 "/help (/h, /?) -- show help",
1710 "/status (/s), /debug (/d) -- show model, mode, context, and todos",
1711 "/model [value] (/m) -- show or switch model",
1712 "/ask <question> -- research-only query",
1713 "/save [name], /load [name] -- save or load a session",
1714 "/undo (/u), /clear (/c) -- repair conversation state",
1715 "/quit (/q), /exit -- end session",
1716 "Advanced: /tokens, /compact [llm|deterministic], /thinking [auto|off|low|medium|high]",
1717 ]
1718 .join("\n")
1719 }
1720
1721 async fn ask_command(session: &mut Session, prompt: &str) -> Result<bool> {
1722 if prompt.is_empty() {
1723 anyhow::bail!("Usage: /ask <question>");
1724 }
1725 let answer =
1726 session::run_prompt_read_only(session, &config::ask_system_prompt(prompt)).await?;
1727 if !answer.is_empty() {
1728 crate::ui::markdown(&format!("{answer}\n"));
1729 }
1730 Ok(true)
1731 }
1732
1733 fn tokens_command(session: &Session) -> Result<bool> {
1734 let status = session.context_status();
1735 crate::ui::section("Context");
1736 crate::ui::kv("messages", status.estimate.messages);
1737 crate::ui::kv(
1738 "system",
1739 format_args!("~{} tokens", status.estimate.system_tokens),
1740 );
1741 crate::ui::kv(
1742 "messages",
1743 format_args!("~{} tokens", status.estimate.message_tokens),
1744 );
1745 crate::ui::kv(
1746 "total",
1747 format_args!("~{} tokens", status.estimate.total_tokens),
1748 );
1749 crate::ui::kv("limit", format_args!("{} tokens", status.limit_tokens));
1750 crate::ui::kv(
1751 "input budget",
1752 format_args!("{} tokens", status.input_budget_tokens),
1753 );
1754 crate::ui::kv("trigger", format_args!("{} tokens", status.trigger_tokens));
1755 crate::ui::kv("summary", crate::ui::bool_text(status.summary_present));
1756 Ok(true)
1757 }
1758
1759 async fn compact_command(mode: Option<&str>, session: &mut Session) -> Result<bool> {
1760 let before = session.context_status().estimate.total_tokens;
1761 let stats = match mode.unwrap_or("llm") {
1762 "" | "llm" | "smart" => session.compact_llm().await?,
1763 "deterministic" | "det" | "fast" => session.compact_deterministic(),
1764 other => anyhow::bail!("compact mode must be llm or deterministic; got {other}"),
1765 };
1766 let after = session.context_status().estimate.total_tokens;
1767 crate::ui::section("Compaction");
1768 if let Some(stats) = stats {
1769 crate::ui::kv(
1770 "tokens",
1771 format_args!("{} -> {}", stats.before_tokens, stats.after_tokens),
1772 );
1773 crate::ui::kv("removed messages", stats.removed_messages);
1774 crate::ui::kv("tool outputs", stats.compacted_tools);
1775 crate::ui::kv("summarized", stats.summarized);
1776 } else {
1777 crate::ui::kv("tokens", format_args!("{before} -> {after}"));
1778 crate::ui::line("nothing to compact");
1779 }
1780 Ok(true)
1781 }
1782
1783 async fn model_command(value: Option<&str>, session: &mut Session) -> Result<bool> {
1784 if let Some(value) = value {
1785 config::save_model_config(value)?;
1786 session.model = model::resolve_model(Some(value))?;
1787 }
1788 crate::ui::line(format_args!("model: {}", session.model));
1789 Ok(true)
1790 }
1791
1792 fn thinking_command(value: Option<&str>) -> Result<bool> {
1793 if let Some(value) = value {
1794 match value {
1795 "" | "auto" => unsafe { std::env::remove_var("OY_THINKING") },
1796 "off" | "none" => unsafe { std::env::set_var("OY_THINKING", "none") },
1797 "minimal" | "low" | "medium" | "high" => unsafe {
1798 std::env::set_var("OY_THINKING", value)
1799 },
1800 other => anyhow::bail!(
1801 "thinking must be auto, off, minimal, low, medium, or high; got {other}"
1802 ),
1803 }
1804 }
1805 crate::ui::line(format_args!(
1806 "thinking: {}",
1807 std::env::var("OY_THINKING").unwrap_or_else(|_| "auto".to_string())
1808 ));
1809 Ok(true)
1810 }
1811
1812 fn status_command(session: &Session) -> Result<bool> {
1813 crate::ui::section("Status");
1814 crate::ui::kv("workspace", session.root.display());
1815 crate::ui::kv("model", &session.model);
1816 crate::ui::kv("genai", model::to_genai_model_spec(&session.model));
1817 crate::ui::kv(
1818 "thinking",
1819 model::default_reasoning_effort(&session.model).unwrap_or("auto/off"),
1820 );
1821 crate::ui::kv("mode", &session.mode);
1822 crate::ui::kv("interactive", crate::ui::bool_text(session.interactive));
1823 crate::ui::kv(
1824 "files-write",
1825 format_args!("{:?}", session.policy.files_write),
1826 );
1827 crate::ui::kv("shell", format_args!("{:?}", session.policy.shell));
1828 crate::ui::kv("network", crate::ui::bool_text(session.policy.network));
1829 crate::ui::kv("risk", config::policy_risk_label(&session.policy));
1830 crate::ui::kv("messages", session.transcript.messages.len());
1831 crate::ui::kv("todos", session.todos.len());
1832 let status = session.context_status();
1833 crate::ui::kv(
1834 "context",
1835 format_args!(
1836 "~{} / {} tokens",
1837 status.estimate.total_tokens, status.input_budget_tokens
1838 ),
1839 );
1840 crate::ui::kv("summary", crate::ui::bool_text(status.summary_present));
1841 Ok(true)
1842 }
1843
1844 fn save_command(name: Option<&str>, session: &mut Session) -> Result<bool> {
1845 let path = session.save(name)?;
1846 crate::ui::success(format_args!("saved session {}", path.display()));
1847 Ok(true)
1848 }
1849
1850 fn load_command(name: Option<&str>, session: &mut Session) -> Result<bool> {
1851 if let Some(new_session) =
1852 session::load_saved(name, true, session.mode.clone(), session.policy)?
1853 {
1854 *session = new_session;
1855 crate::ui::success("loaded session");
1856 } else {
1857 crate::ui::warn("no saved sessions found");
1858 }
1859 Ok(true)
1860 }
1861
1862 fn undo_command(session: &mut Session) -> Result<bool> {
1863 if session.transcript.undo_last_turn() {
1864 crate::ui::success("undid last turn");
1865 } else {
1866 crate::ui::warn("nothing to undo");
1867 }
1868 Ok(true)
1869 }
1870
1871 fn clear_command(session: &mut Session) -> Result<bool> {
1872 session.transcript.messages.clear();
1873 crate::ui::success("conversation cleared");
1874 Ok(true)
1875 }
1876
1877 async fn run_prompt_with_model_reselect(session: &mut Session, prompt: &str) -> Result<()> {
1878 loop {
1879 match session::run_prompt(session, prompt).await {
1880 Ok(answer) => {
1881 if !answer.is_empty() {
1882 crate::ui::markdown(&format!("{answer}\n"));
1883 }
1884 return Ok(());
1885 }
1886 Err(err) if config::can_prompt() => {
1887 crate::ui::err_line(format_args!("model call failed: {err:#}"));
1888 session.transcript.undo_last_turn();
1889 let Some(model) = choose_replacement_model(session).await? else {
1890 return Err(err);
1891 };
1892 session.model = model;
1893 config::save_model_config(&session.model)?;
1894 crate::ui::err_line(format_args!("retrying with model: {}", session.model));
1895 }
1896 Err(err) => return Err(err),
1897 }
1898 }
1899 }
1900
1901 async fn choose_replacement_model(session: &Session) -> Result<Option<String>> {
1902 let listing = model::inspect_models().await?;
1903 let items = replacement_model_choices(&session.model, listing.all_models, listing.hints);
1904 if items.is_empty() {
1905 return Ok(None);
1906 }
1907 choose_model(None, &items)
1908 }
1909
1910 fn replacement_model_choices(
1911 current: &str,
1912 mut models: Vec<String>,
1913 hints: Vec<String>,
1914 ) -> Vec<String> {
1915 models.extend(hints);
1916 models.retain(|item| item != current);
1917 models.sort();
1918 models.dedup();
1919 models
1920 }
1921
1922 pub fn choose_model(current: Option<&str>, items: &[String]) -> Result<Option<String>> {
1923 choose_model_with_initial_list(current, items, true)
1924 }
1925
1926 pub fn choose_model_with_initial_list(
1927 current: Option<&str>,
1928 items: &[String],
1929 _print_initial_list: bool,
1930 ) -> Result<Option<String>> {
1931 if items.is_empty() || !config::can_prompt() {
1932 return Ok(None);
1933 }
1934 let theme = ColorfulTheme::default();
1935 let default = current.and_then(|value| items.iter().position(|item| item == value));
1936 let mut prompt = Select::with_theme(&theme)
1937 .with_prompt("Models")
1938 .items(items)
1939 .default(default.unwrap_or(0));
1940 if current.is_some() {
1941 prompt = prompt.with_prompt("Models (Esc keeps current)");
1942 }
1943 Ok(prompt.interact_opt()?.map(|index| items[index].clone()))
1944 }
1945
1946 pub fn ask(question: &str, choices: Option<&[String]>) -> Result<String> {
1947 if let Some(choices) = choices {
1948 if choices.is_empty() {
1949 return Ok(String::new());
1950 }
1951 let index = Select::with_theme(&ColorfulTheme::default())
1952 .with_prompt(question)
1953 .items(choices)
1954 .default(0)
1955 .interact_opt()?;
1956 return Ok(index
1957 .map(|index| choices[index].clone())
1958 .unwrap_or_default());
1959 }
1960 Ok(Input::<String>::with_theme(&ColorfulTheme::default())
1961 .with_prompt(question)
1962 .interact_text()?)
1963 }
1964
1965 fn history_path(name: &str) -> Result<PathBuf> {
1966 history_path_in(config::config_dir_path(), name)
1967 }
1968
1969 fn history_path_in(config_dir: PathBuf, name: &str) -> Result<PathBuf> {
1970 let history = config_dir.join("history");
1971 config::create_private_dir_all(&history)?;
1972 let path = history.join(format!("{name}.txt"));
1973 if !path.exists() {
1974 config::write_private_file(&path, b"")?;
1975 }
1976 Ok(path)
1977 }
1978
1979 #[cfg(test)]
1980 mod tests {
1981 use super::*;
1982
1983 #[test]
1984 fn history_path_uses_named_private_history_file() {
1985 let dir = tempfile::tempdir().unwrap();
1986 let path = history_path_in(dir.path().to_path_buf(), "chat").unwrap();
1987 assert!(path.ends_with("history/chat.txt"));
1988 assert!(path.exists());
1989
1990 #[cfg(unix)]
1991 {
1992 use std::os::unix::fs::PermissionsExt as _;
1993 let history_dir_mode = std::fs::metadata(path.parent().unwrap())
1994 .unwrap()
1995 .permissions()
1996 .mode()
1997 & 0o777;
1998 let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1999 assert_eq!(history_dir_mode, 0o700);
2000 assert_eq!(file_mode, 0o600);
2001 }
2002 }
2003
2004 #[test]
2005 fn normalize_chat_command_maps_slash_aliases() {
2006 assert_eq!(normalize_chat_command("q"), "quit");
2007 assert_eq!(normalize_chat_command("tokens"), "tokens");
2008 assert_eq!(normalize_chat_command("k"), "compact");
2009 assert_eq!(normalize_chat_command("s"), "status");
2010 }
2011
2012 #[test]
2013 fn chat_help_uses_slash_commands() {
2014 let help = chat_help_text();
2015 assert!(help.contains("/help"));
2016 assert!(help.contains("/quit"));
2017 assert!(help.contains("/compact"));
2018 assert!(help.contains("/status"));
2019 }
2020
2021 #[test]
2022 fn replacement_model_choices_drop_current_and_dedup() {
2023 let choices = replacement_model_choices(
2024 "broken",
2025 vec!["broken".into(), "ok".into()],
2026 vec!["ok".into(), "other".into()],
2027 );
2028 assert_eq!(choices, vec!["ok".to_string(), "other".to_string()]);
2029 }
2030 }
2031}
2032
2033pub(crate) mod app {
2035 use anyhow::{Result, bail};
2036 use clap::{Args, Parser, Subcommand};
2037 use std::io::IsTerminal as _;
2038 use std::path::{Path, PathBuf};
2039
2040 use crate::audit;
2041 use crate::config;
2042 use crate::model;
2043 use crate::session::{self, Session};
2044
2045 const MODEL_LIST_LIMIT: usize = 30;
2046
2047 #[derive(Debug, Parser)]
2048 #[command(
2049 name = "oy",
2050 version,
2051 about = "Small local AI coding assistant for your shell.",
2052 after_help = "Examples:\n oy doctor\n oy model\n oy \"inspect this repo and summarize risks\"\n oy chat --mode plan\n oy run --out plan.md \"write a migration plan\"\n\nSafety: file tools stay inside the workspace, but oy is not a sandbox. Use --mode plan or a container/VM for untrusted repos."
2053 )]
2054 struct Cli {
2055 #[arg(long, global = true, conflicts_with_all = ["verbose", "json"], help = "Suppress normal progress output")]
2056 quiet: bool,
2057 #[arg(long, global = true, conflicts_with_all = ["quiet", "json"], help = "Show fuller tool previews")]
2058 verbose: bool,
2059 #[arg(long, global = true, conflicts_with_all = ["quiet", "verbose"], help = "Print machine-readable JSON where supported")]
2060 json: bool,
2061 #[command(subcommand)]
2062 command: Option<Command>,
2063 }
2064
2065 #[derive(Debug, Subcommand)]
2066 enum Command {
2067 Run(RunArgs),
2069 Chat(ChatArgs),
2071 Model(ModelArgs),
2073 Doctor(DoctorArgs),
2075 Audit(AuditArgs),
2077 }
2078
2079 #[derive(Debug, Args, Clone)]
2080 struct SharedModeArgs {
2081 #[arg(
2082 long,
2083 alias = "agent",
2084 default_value = "default",
2085 help = "Safety mode (default: balanced): plan, ask, edit, or auto"
2086 )]
2087 mode: String,
2088 #[arg(
2089 long = "continue-session",
2090 default_value_t = false,
2091 help = "Resume the most recent saved session"
2092 )]
2093 continue_session: bool,
2094 #[arg(
2095 long,
2096 default_value = "",
2097 value_name = "NAME_OR_NUMBER",
2098 help = "Resume a named or numbered saved session"
2099 )]
2100 resume: String,
2101 }
2102
2103 #[derive(Debug, Args, Clone)]
2104 struct RunArgs {
2105 #[command(flatten)]
2106 shared: SharedModeArgs,
2107 #[arg(
2108 long,
2109 value_name = "PATH",
2110 help = "Write the final answer to a workspace file"
2111 )]
2112 out: Option<PathBuf>,
2113 #[arg(
2114 value_name = "PROMPT",
2115 help = "Task prompt; omitted means read stdin or start chat in a TTY"
2116 )]
2117 task: Vec<String>,
2118 }
2119
2120 #[derive(Debug, Args, Clone)]
2121 struct ChatArgs {
2122 #[command(flatten)]
2123 shared: SharedModeArgs,
2124 }
2125
2126 #[derive(Debug, Args, Clone)]
2127 struct ModelArgs {
2128 #[arg(
2129 value_name = "MODEL",
2130 help = "Model id or routing shim selection, e.g. copilot::gpt-4.1-mini"
2131 )]
2132 model: Option<String>,
2133 }
2134
2135 #[derive(Debug, Args, Clone)]
2136 struct DoctorArgs {
2137 #[arg(
2138 long,
2139 alias = "agent",
2140 default_value = "default",
2141 help = "Safety mode to inspect (default: balanced): plan, ask, edit, or auto"
2142 )]
2143 mode: String,
2144 }
2145
2146 #[derive(Debug, Args, Clone)]
2147 struct AuditArgs {
2148 #[arg(value_name = "FOCUS", help = "Optional audit focus text")]
2149 focus: Vec<String>,
2150 #[arg(
2151 long,
2152 value_name = "PATH",
2153 default_value = "ISSUES.md",
2154 help = "Write findings to a workspace file (default: ISSUES.md)"
2155 )]
2156 out: PathBuf,
2157 }
2158
2159 pub async fn run(argv: Vec<String>) -> Result<i32> {
2160 let normalized = normalize_args(argv);
2161 let cli = Cli::parse_from(std::iter::once("oy".to_string()).chain(normalized.clone()));
2162 crate::ui::init_output_mode(cli_output_mode(&cli));
2163 match cli.command.unwrap_or(Command::Run(RunArgs {
2164 shared: SharedModeArgs {
2165 mode: "default".to_string(),
2166 continue_session: false,
2167 resume: String::new(),
2168 },
2169 out: None,
2170 task: Vec::new(),
2171 })) {
2172 Command::Run(args) => run_command(args).await,
2173 Command::Chat(args) => chat_command(args).await,
2174 Command::Model(args) => model_command(args).await,
2175 Command::Doctor(args) => doctor_command(args).await,
2176 Command::Audit(args) => audit_command(args).await,
2177 }
2178 }
2179
2180 fn cli_output_mode(cli: &Cli) -> Option<crate::ui::OutputMode> {
2181 if cli.quiet {
2182 Some(crate::ui::OutputMode::Quiet)
2183 } else if cli.verbose {
2184 Some(crate::ui::OutputMode::Verbose)
2185 } else if cli.json {
2186 Some(crate::ui::OutputMode::Json)
2187 } else {
2188 None
2189 }
2190 }
2191
2192 fn normalize_args(mut args: Vec<String>) -> Vec<String> {
2193 if args.is_empty() {
2194 return if config::can_prompt() {
2195 vec!["--help".to_string()]
2196 } else {
2197 vec!["run".to_string()]
2198 };
2199 }
2200 if matches!(
2201 args.first().map(String::as_str),
2202 Some("--continue") | Some("-c")
2203 ) {
2204 return std::iter::once("run".to_string())
2205 .chain(std::iter::once("--continue-session".to_string()))
2206 .chain(args.drain(1..))
2207 .collect();
2208 }
2209 if args.first().map(String::as_str) == Some("--resume") {
2210 return std::iter::once("run".to_string()).chain(args).collect();
2211 }
2212 let commands = ["run", "chat", "model", "doctor", "audit", "-h", "--help"];
2213 if args
2214 .first()
2215 .is_some_and(|arg| !arg.starts_with('-') && !commands.contains(&arg.as_str()))
2216 {
2217 let mut out = vec!["run".to_string()];
2218 out.extend(args);
2219 return out;
2220 }
2221 args
2222 }
2223
2224 async fn run_command(args: RunArgs) -> Result<i32> {
2225 let task = collect_task(&args.task)?;
2226 if task.trim().is_empty() {
2227 return chat_command(ChatArgs {
2228 shared: args.shared,
2229 })
2230 .await;
2231 }
2232 let mut session = load_or_new(
2233 false,
2234 &args.shared.mode,
2235 args.shared.continue_session,
2236 &args.shared.resume,
2237 )?;
2238 print_session_intro("run", &session, Some(&task));
2239 let answer = session::run_prompt(&mut session, &task).await?;
2240 if crate::ui::is_json() {
2241 print_run_json(&session, &answer)?;
2242 } else if let Some(path) = args.out {
2243 write_workspace_file(&session.root, &path, &answer)?;
2244 crate::ui::success(format_args!("wrote {}", path.display()));
2245 } else if !answer.is_empty() {
2246 crate::ui::markdown(&format!("{answer}\n"));
2247 }
2248 Ok(0)
2249 }
2250
2251 fn print_run_json(session: &Session, answer: &str) -> Result<()> {
2252 let status = session.context_status();
2253 let payload = serde_json::json!({
2254 "answer": answer,
2255 "model": session.model,
2256 "mode": session.mode,
2257 "workspace": session.root,
2258 "tokens": status.estimate,
2259 "context": status,
2260 "messages": status.estimate.messages,
2261 "todos": session.todos,
2262 });
2263 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2264 Ok(())
2265 }
2266
2267 async fn chat_command(args: ChatArgs) -> Result<i32> {
2268 let mut session = load_or_new(
2269 true,
2270 &args.shared.mode,
2271 args.shared.continue_session,
2272 &args.shared.resume,
2273 )?;
2274 print_session_intro("chat", &session, None);
2275 crate::chat::run_chat(&mut session).await
2276 }
2277
2278 async fn model_command(args: ModelArgs) -> Result<i32> {
2279 if let Some(model_spec) = args
2280 .model
2281 .as_deref()
2282 .filter(|value| is_exact_model_spec(value))
2283 {
2284 let normalized = model::canonical_model_spec(model_spec);
2285 config::save_model_config(&normalized)?;
2286 if crate::ui::is_json() {
2287 print_saved_model_json(&normalized)?;
2288 } else {
2289 print_saved_model(&normalized);
2290 }
2291 return Ok(0);
2292 }
2293
2294 let listing = model::inspect_models().await?;
2295 if let Some(model_spec) = args.model {
2296 let normalized = resolve_model_choice(&listing, &model_spec)?;
2297 config::save_model_config(&normalized)?;
2298 if crate::ui::is_json() {
2299 print_model_json(&listing, Some(&normalized))?;
2300 } else {
2301 print_saved_model(&normalized);
2302 }
2303 return Ok(0);
2304 }
2305 if crate::ui::is_json() {
2306 print_model_json(&listing, None)?;
2307 return Ok(0);
2308 }
2309 print_model_listing(&listing);
2310 if config::can_prompt()
2311 && !listing.all_models.is_empty()
2312 && let Some(chosen) = crate::chat::choose_model_with_initial_list(
2313 listing.current.as_deref(),
2314 &listing.all_models,
2315 false,
2316 )?
2317 {
2318 config::save_model_config(&chosen)?;
2319 print_saved_model(&chosen);
2320 }
2321 Ok(0)
2322 }
2323
2324 fn is_exact_model_spec(value: &str) -> bool {
2325 let value = value.trim();
2326 value.contains("::") || value.contains('/') || value.contains(':') || value.contains('.')
2327 }
2328
2329 fn print_saved_model_json(saved: &str) -> Result<()> {
2330 let payload = serde_json::json!({ "saved": saved });
2331 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2332 Ok(())
2333 }
2334
2335 fn print_model_json(listing: &model::ModelListing, saved: Option<&str>) -> Result<()> {
2336 let payload = serde_json::json!({
2337 "current": listing.current,
2338 "current_shim": listing.current_shim,
2339 "saved": saved,
2340 "auth": listing.auth,
2341 "recommended": listing.recommended,
2342 "dynamic": listing.dynamic,
2343 "hints": listing.hints,
2344 "all_models": listing.all_models,
2345 });
2346 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2347 Ok(())
2348 }
2349
2350 fn print_model_listing(listing: &model::ModelListing) {
2351 crate::ui::section("Models");
2352 crate::ui::kv(
2353 "current",
2354 current_model_text(
2355 listing.current.as_deref().unwrap_or("<unset>"),
2356 listing.current_shim.as_deref(),
2357 ),
2358 );
2359 crate::ui::kv("selectable", listing.all_models.len());
2360 if !listing.recommended.is_empty() {
2361 crate::ui::kv("recommended", listing.recommended.join(", "));
2362 if listing.current.is_none() {
2363 crate::ui::line(format_args!(" Try: oy model {}", listing.recommended[0]));
2364 }
2365 }
2366
2367 if !listing.auth.is_empty() {
2368 crate::ui::line("");
2369 crate::ui::section("Auth / shims");
2370 for item in &listing.auth {
2371 let env_var = item.env_var.as_deref().unwrap_or("-");
2372 let active = if listing.current_shim.as_deref() == Some(item.adapter.as_str()) {
2373 " *"
2374 } else {
2375 ""
2376 };
2377 crate::ui::line(format_args!(
2378 " {}{} {} ({})",
2379 item.adapter, active, env_var, item.source
2380 ));
2381 crate::ui::line(format_args!(" {}", item.detail));
2382 }
2383 }
2384
2385 crate::ui::line("");
2386 crate::ui::section("Introspected endpoint models");
2387 if listing.dynamic.is_empty() {
2388 crate::ui::line(" none found from configured OpenAI-compatible endpoints");
2389 } else {
2390 for item in &listing.dynamic {
2391 if !item.ok {
2392 crate::ui::line(format_args!(
2393 " {} failed via {}",
2394 item.adapter, item.source
2395 ));
2396 if let Some(error) = item.error.as_deref() {
2397 crate::ui::line(format_args!(
2398 " {}",
2399 crate::ui::truncate_chars(error, 140)
2400 ));
2401 }
2402 continue;
2403 }
2404 crate::ui::line(format_args!(
2405 " {} {} models via {}",
2406 item.adapter, item.count, item.source
2407 ));
2408 for model_name in item.models.iter().take(MODEL_LIST_LIMIT) {
2409 let marker = if listing.current.as_deref() == Some(model_name.as_str()) {
2410 "*"
2411 } else {
2412 " "
2413 };
2414 crate::ui::line(format_args!(" {marker} {model_name}"));
2415 }
2416 if item.models.len() > MODEL_LIST_LIMIT {
2417 crate::ui::line(format_args!(
2418 " … {} more; use `oy model <filter>` or interactive selection",
2419 item.models.len() - MODEL_LIST_LIMIT
2420 ));
2421 }
2422 }
2423 }
2424
2425 let hinted = listing
2426 .hints
2427 .iter()
2428 .filter(|hint| {
2429 !listing
2430 .dynamic
2431 .iter()
2432 .any(|group| group.models.iter().any(|model| model == *hint))
2433 })
2434 .collect::<Vec<_>>();
2435 if !hinted.is_empty() {
2436 crate::ui::line("");
2437 crate::ui::section("Built-in selectable hints");
2438 for hint in hinted.iter().take(MODEL_LIST_LIMIT) {
2439 crate::ui::line(format_args!(" {hint}"));
2440 }
2441 if hinted.len() > MODEL_LIST_LIMIT {
2442 crate::ui::line(format_args!(
2443 " … {} more hints",
2444 hinted.len() - MODEL_LIST_LIMIT
2445 ));
2446 }
2447 }
2448 }
2449
2450 fn current_model_text(model_spec: &str, shim: Option<&str>) -> String {
2451 match shim.filter(|value| !value.is_empty()) {
2452 Some(shim) => format!("{model_spec} (shim: {shim})"),
2453 None => model_spec.to_string(),
2454 }
2455 }
2456
2457 fn print_saved_model(selection: &str) {
2458 let saved = config::saved_model_config_from_selection(selection);
2459 crate::ui::success(format_args!(
2460 "saved model {}",
2461 saved.model.as_deref().unwrap_or(selection)
2462 ));
2463 if let Some(shim) = saved.shim {
2464 crate::ui::kv("shim", shim);
2465 }
2466 }
2467
2468 fn resolve_model_choice(listing: &model::ModelListing, query: &str) -> Result<String> {
2469 let normalized = model::canonical_model_spec(query);
2470 if listing.all_models.iter().any(|item| item == &normalized) {
2471 return Ok(normalized);
2472 }
2473 if !config::can_prompt() {
2474 bail!(
2475 "No exact model match for `{}`. Re-run in a TTY to choose interactively.",
2476 query
2477 );
2478 }
2479 let matches = listing
2480 .all_models
2481 .iter()
2482 .filter(|item| {
2483 item.to_ascii_lowercase()
2484 .contains(&query.to_ascii_lowercase())
2485 })
2486 .cloned()
2487 .collect::<Vec<_>>();
2488 if matches.is_empty() {
2489 bail!("No matching model for `{}`", query);
2490 }
2491 crate::chat::choose_model(listing.current.as_deref(), &matches)
2492 .map(|value| value.unwrap_or(normalized))
2493 }
2494
2495 async fn doctor_command(args: DoctorArgs) -> Result<i32> {
2496 let root = config::oy_root()?;
2497 let listing = model::inspect_models().await?;
2498 let mode = config::safety_mode(&args.mode)?;
2499 let policy = config::tool_policy(mode.name());
2500 let config_file = config::config_root();
2501 let config_dir = config::config_dir_path();
2502 let sessions_dir = config::sessions_dir().unwrap_or_else(|_| config_dir.join("sessions"));
2503 let history_dir = config_dir.join("history");
2504 let bash_ok = std::process::Command::new("bash")
2505 .arg("--version")
2506 .stdout(std::process::Stdio::null())
2507 .stderr(std::process::Stdio::null())
2508 .status()
2509 .map(|status| status.success())
2510 .unwrap_or(false);
2511
2512 if crate::ui::is_json() {
2513 let payload = serde_json::json!({
2514 "workspace": root,
2515 "model": listing.current,
2516 "shim": listing.current_shim,
2517 "auth": listing.auth,
2518 "mode": mode.name(),
2519 "policy": policy,
2520 "interactive": config::can_prompt(),
2521 "non_interactive": config::non_interactive(),
2522 "config_file": config_file,
2523 "config_dir": config_dir,
2524 "sessions_dir": sessions_dir,
2525 "history_dir": history_dir,
2526 "bash": bash_ok,
2527 "recommended": listing.recommended,
2528 "next_step": recommended_next_step(&listing),
2529 });
2530 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2531 return Ok(0);
2532 }
2533
2534 crate::ui::section("Doctor");
2535 crate::ui::kv("workspace", root.display());
2536 crate::ui::kv("model", listing.current.as_deref().unwrap_or("<unset>"));
2537 crate::ui::kv("shim", listing.current_shim.as_deref().unwrap_or("<none>"));
2538 crate::ui::kv("mode", mode.name());
2539 crate::ui::kv("files-write", format_args!("{:?}", policy.files_write));
2540 crate::ui::kv("shell", format_args!("{:?}", policy.shell));
2541 crate::ui::kv("network", crate::ui::bool_text(policy.network));
2542 crate::ui::kv("risk", config::policy_risk_label(&policy));
2543 crate::ui::kv("interactive", crate::ui::bool_text(config::can_prompt()));
2544 crate::ui::kv(
2545 "bash",
2546 crate::ui::status_text(bash_ok, if bash_ok { "ok" } else { "missing" }),
2547 );
2548 crate::ui::line("");
2549 crate::ui::section("Local state");
2550 crate::ui::kv("config", config_file.display());
2551 crate::ui::kv("sessions", sessions_dir.display());
2552 crate::ui::kv("history", history_dir.display());
2553 crate::ui::line(
2554 " Treat local state as sensitive: prompts, source snippets, tool output, and command output may be saved.",
2555 );
2556 crate::ui::line("");
2557 crate::ui::section("Auth / shims");
2558 if listing.auth.is_empty() {
2559 crate::ui::warn("no provider auth detected");
2560 } else {
2561 for item in &listing.auth {
2562 crate::ui::line(format_args!(
2563 " {} {} ({})",
2564 item.adapter,
2565 item.env_var.as_deref().unwrap_or("-"),
2566 item.source
2567 ));
2568 crate::ui::line(format_args!(" {}", item.detail));
2569 }
2570 }
2571 if listing.current.is_none() {
2572 crate::ui::line("");
2573 crate::ui::warn("no model configured");
2574 crate::ui::line(format_args!(" {}", recommended_next_step(&listing)));
2575 }
2576 crate::ui::line("");
2577 crate::ui::section("Recommended next steps");
2578 crate::ui::line(format_args!(" 1. {}", recommended_next_step(&listing)));
2579 crate::ui::line(" 2. For untrusted repos: `oy chat --mode plan`");
2580 crate::ui::line(format_args!(
2581 " • Read-only container: {}",
2582 safe_container_command(&root, true)
2583 ));
2584 crate::ui::line("");
2585 crate::ui::section("Safety");
2586 crate::ui::line(
2587 " oy is not a sandbox. Use `oy chat --mode plan` or a disposable container/VM for untrusted repos.",
2588 );
2589 crate::ui::line(
2590 " Mount only needed credentials/env vars. Do not mount the host Docker socket into AI-assisted containers.",
2591 );
2592 Ok(0)
2593 }
2594
2595 fn recommended_next_step(listing: &model::ModelListing) -> String {
2596 if listing.current.is_some() {
2597 return "Run `oy \"inspect this repo\"` or `oy chat`.".to_string();
2598 }
2599 if let Some(choice) = listing.recommended.first() {
2600 return format!("Configure a model: `oy model {choice}`.");
2601 }
2602 "Configure provider auth, then run `oy model`; see `oy doctor` output.".to_string()
2603 }
2604
2605 fn safe_container_command(root: &Path, read_only: bool) -> String {
2606 let mode = if read_only { "ro" } else { "rw" };
2607 format!(
2608 "docker run --rm -it -v \"{}:/workspace:{mode}\" -w /workspace oy-image oy chat --mode plan",
2609 root.display()
2610 )
2611 }
2612
2613 async fn audit_command(args: AuditArgs) -> Result<i32> {
2614 let started = std::time::Instant::now();
2615 let focus = args.focus.join(" ");
2616 let root = config::oy_root()?;
2617 let model = model::resolve_model(None)?;
2618 if !crate::ui::is_quiet() {
2619 crate::ui::section("audit");
2620 crate::ui::kv("workspace", root.display());
2621 crate::ui::kv("model", &model);
2622 crate::ui::kv("mode", "no-tools");
2623 crate::ui::kv("out", args.out.display());
2624 if !focus.trim().is_empty() {
2625 crate::ui::kv("focus", crate::ui::compact_preview(&focus, 100));
2626 }
2627 }
2628 let result = audit::run(audit::AuditOptions {
2629 root,
2630 model,
2631 focus,
2632 out: args.out,
2633 })
2634 .await?;
2635 if crate::ui::is_json() {
2636 let payload = serde_json::json!({
2637 "output": result.output_path,
2638 "files": result.file_count,
2639 "chunks": result.chunk_count,
2640 "elapsed_ms": started.elapsed().as_millis(),
2641 });
2642 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2643 } else {
2644 crate::ui::success(format_args!(
2645 "wrote {} ({} files, {} chunks, {})",
2646 result.output_path.display(),
2647 result.file_count,
2648 result.chunk_count,
2649 crate::ui::format_duration(started.elapsed())
2650 ));
2651 }
2652 Ok(0)
2653 }
2654
2655 fn load_or_new(
2656 interactive: bool,
2657 mode_name: &str,
2658 continue_session: bool,
2659 resume: &str,
2660 ) -> Result<Session> {
2661 let mode = config::safety_mode(mode_name)?;
2662 let policy = config::tool_policy(mode.name());
2663 if continue_session || !resume.is_empty() {
2664 let name = if continue_session { None } else { Some(resume) };
2665 if let Some(session) =
2666 session::load_saved(name, interactive, mode.name().to_string(), policy)?
2667 {
2668 return Ok(session);
2669 }
2670 }
2671 let root = config::oy_root()?;
2672 let model = model::resolve_model(None)?;
2673 Ok(Session::new(
2674 root,
2675 model,
2676 interactive,
2677 mode.name().to_string(),
2678 policy,
2679 ))
2680 }
2681
2682 fn collect_task(parts: &[String]) -> Result<String> {
2683 if !parts.is_empty() {
2684 return Ok(parts.join(" "));
2685 }
2686 if std::io::stdin().is_terminal() {
2687 return Ok(String::new());
2688 }
2689 let mut input = String::new();
2690 use std::io::Read as _;
2691 std::io::stdin().read_to_string(&mut input)?;
2692 Ok(input.trim().to_string())
2693 }
2694
2695 fn print_session_intro(mode: &str, session: &Session, prompt: Option<&str>) {
2696 if crate::ui::is_quiet() {
2697 return;
2698 }
2699 crate::ui::section(mode);
2700 crate::ui::kv("workspace", session.root.display());
2701 crate::ui::kv("model", &session.model);
2702 crate::ui::kv("mode", &session.mode);
2703 crate::ui::kv("risk", config::policy_risk_label(&session.policy));
2704 if let Some(prompt) = prompt {
2705 crate::ui::kv("prompt", crate::ui::compact_preview(prompt, 100));
2706 }
2707 }
2708
2709 fn write_workspace_file(root: &Path, requested: &Path, body: &str) -> Result<()> {
2710 let path = config::resolve_workspace_output_path(root, requested)?;
2711 let mut out = body.trim_end().to_string();
2712 out.push('\n');
2713 config::write_workspace_file(&path, out.as_bytes())
2714 }
2715
2716 #[cfg(test)]
2717 mod audit_tests {
2718 use super::*;
2719
2720 #[test]
2721 fn exact_model_specs_are_endpoint_qualified_or_provider_ids() {
2722 assert!(is_exact_model_spec("copilot::gpt-4.1-mini"));
2723 assert!(is_exact_model_spec("openai/gpt-4.1-mini"));
2724 assert!(is_exact_model_spec(
2725 "bedrock::global.amazon.nova-2-lite-v1:0"
2726 ));
2727 assert!(!is_exact_model_spec("gpt"));
2728 assert!(!is_exact_model_spec("nova"));
2729 }
2730 }
2731}