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. Follows public redirects by default; 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)]
813 pub enum OutputMode {
814 Quiet = 0,
816 Normal = 1,
818 Verbose = 2,
820 Json = 3,
822 }
823
824 static OUTPUT_MODE: AtomicU8 = AtomicU8::new(OutputMode::Normal as u8);
825
826 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
827 enum ColorMode {
828 Auto,
829 Always,
830 Never,
831 }
832
833 static COLOR_MODE: LazyLock<ColorMode> = LazyLock::new(color_mode_from_env);
834
835 pub fn init_output_mode(mode: Option<OutputMode>) {
836 let mode = mode
837 .or_else(output_mode_from_env)
838 .unwrap_or(OutputMode::Normal);
839 set_output_mode(mode);
840 }
841
842 pub fn set_output_mode(mode: OutputMode) {
844 OUTPUT_MODE.store(mode as u8, Ordering::Relaxed);
845 }
846
847 pub fn output_mode() -> OutputMode {
848 match OUTPUT_MODE.load(Ordering::Relaxed) {
849 0 => OutputMode::Quiet,
850 2 => OutputMode::Verbose,
851 3 => OutputMode::Json,
852 _ => OutputMode::Normal,
853 }
854 }
855
856 pub fn is_quiet() -> bool {
857 matches!(output_mode(), OutputMode::Quiet | OutputMode::Json)
858 }
859
860 pub fn is_json() -> bool {
861 matches!(output_mode(), OutputMode::Json)
862 }
863
864 pub fn is_verbose() -> bool {
865 matches!(output_mode(), OutputMode::Verbose)
866 }
867
868 fn output_mode_from_env() -> Option<OutputMode> {
869 if truthy_env("OY_QUIET") {
870 return Some(OutputMode::Quiet);
871 }
872 if truthy_env("OY_VERBOSE") {
873 return Some(OutputMode::Verbose);
874 }
875 match std::env::var("OY_OUTPUT")
876 .ok()?
877 .to_ascii_lowercase()
878 .as_str()
879 {
880 "quiet" => Some(OutputMode::Quiet),
881 "verbose" => Some(OutputMode::Verbose),
882 "json" => Some(OutputMode::Json),
883 "normal" => Some(OutputMode::Normal),
884 _ => None,
885 }
886 }
887
888 fn truthy_env(name: &str) -> bool {
889 matches!(
890 std::env::var(name).ok().as_deref(),
891 Some("1" | "true" | "yes" | "on")
892 )
893 }
894
895 fn color_mode_from_env() -> ColorMode {
896 color_mode_from_values(
897 std::env::var_os("NO_COLOR").is_some(),
898 std::env::var("OY_COLOR").ok().as_deref(),
899 )
900 }
901
902 fn color_mode_from_values(no_color: bool, oy_color: Option<&str>) -> ColorMode {
903 if no_color {
904 return ColorMode::Never;
905 }
906 match oy_color.map(str::to_ascii_lowercase).as_deref() {
907 Some("always" | "1" | "true" | "yes" | "on") => ColorMode::Always,
908 Some("never" | "0" | "false" | "no" | "off") => ColorMode::Never,
909 _ => ColorMode::Auto,
910 }
911 }
912
913 pub fn color_enabled() -> bool {
914 color_enabled_for_stdout(std::io::stdout().is_terminal())
915 }
916
917 fn color_enabled_for_stdout(stdout_is_terminal: bool) -> bool {
918 color_enabled_for_mode(*COLOR_MODE, stdout_is_terminal)
919 }
920
921 fn color_enabled_for_mode(mode: ColorMode, stdout_is_terminal: bool) -> bool {
922 match mode {
923 ColorMode::Always => true,
924 ColorMode::Never => false,
925 ColorMode::Auto => stdout_is_terminal,
926 }
927 }
928
929 pub fn terminal_width() -> usize {
930 terminal_size::terminal_size()
931 .map(|(terminal_size::Width(width), _)| width as usize)
932 .filter(|width| *width >= 40)
933 .unwrap_or(100)
934 }
935
936 pub fn paint(code: &str, text: impl Display) -> String {
937 if color_enabled() {
938 format!("\x1b[{code}m{text}\x1b[0m")
939 } else {
940 text.to_string()
941 }
942 }
943
944 pub fn faint(text: impl Display) -> String {
945 paint("2", text)
946 }
947
948 pub fn bold(text: impl Display) -> String {
949 paint("1", text)
950 }
951
952 pub fn cyan(text: impl Display) -> String {
953 paint("36", text)
954 }
955
956 pub fn green(text: impl Display) -> String {
957 paint("32", text)
958 }
959
960 pub fn yellow(text: impl Display) -> String {
961 paint("33", text)
962 }
963
964 pub fn red(text: impl Display) -> String {
965 paint("31", text)
966 }
967
968 pub fn magenta(text: impl Display) -> String {
969 paint("35", text)
970 }
971
972 pub fn status_text(ok: bool, text: impl Display) -> String {
973 if ok { green(text) } else { red(text) }
974 }
975
976 pub fn bool_text(value: bool) -> String {
977 status_text(value, value)
978 }
979
980 pub fn path(text: impl Display) -> String {
981 paint("1;36", text)
982 }
983
984 pub fn out(text: &str) {
985 print!("{text}");
986 }
987
988 pub fn err(text: &str) {
989 eprint!("{text}");
990 }
991
992 pub fn line(text: impl Display) {
993 out(&format!("{text}\n"));
994 }
995
996 pub fn err_line(text: impl Display) {
997 err(&format!("{text}\n"));
998 }
999
1000 pub fn markdown(text: &str) {
1001 out(&render_markdown(text));
1002 }
1003
1004 fn render_markdown(text: &str) -> String {
1005 if !color_enabled() {
1006 return text.to_string();
1007 }
1008 let mut in_fence = false;
1009 let mut out = String::new();
1010 for line in text.lines() {
1011 let trimmed = line.trim_start();
1012 let rendered = if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
1013 in_fence = !in_fence;
1014 faint(line)
1015 } else if in_fence {
1016 cyan(line)
1017 } else if trimmed.starts_with('#') {
1018 paint("1;35", line)
1019 } else if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
1020 cyan(line)
1021 } else {
1022 line.to_string()
1023 };
1024 let _ = writeln!(out, "{rendered}");
1025 }
1026 if text.ends_with('\n') {
1027 out
1028 } else {
1029 out.trim_end_matches('\n').to_string()
1030 }
1031 }
1032
1033 pub fn code(path: &str, text: &str, first_line: usize) -> String {
1034 numbered_block(path, &normalize_code_preview_text(text), first_line)
1035 }
1036
1037 pub fn text_block(title: &str, text: &str) -> String {
1038 numbered_block(title, text, 1)
1039 }
1040
1041 pub fn block_title(title: &str) -> String {
1042 path(format_args!("── {title}"))
1043 }
1044
1045 #[cfg(test)]
1046 fn numbered_line(line_number: usize, width: usize, text: &str) -> String {
1047 numbered_line_with_max_width(line_number, width, text, usize::MAX)
1048 }
1049
1050 fn numbered_line_with_max_width(
1051 line_number: usize,
1052 width: usize,
1053 text: &str,
1054 max_width: usize,
1055 ) -> String {
1056 let text = normalize_code_preview_text(text);
1057 let prefix = format!(
1058 "{} {} ",
1059 faint(format_args!("{line_number:>width$}")),
1060 faint("│")
1061 );
1062 let available = max_width
1063 .saturating_sub(ansi_stripped_width(&prefix))
1064 .max(1);
1065 format!("{prefix}{}", truncate_width(&text, available))
1066 }
1067
1068 fn normalize_code_preview_text(text: &str) -> Cow<'_, str> {
1069 const TAB_WIDTH: usize = 4;
1070 if !text.contains('\t') {
1071 return Cow::Borrowed(text);
1072 }
1073
1074 let mut out = String::with_capacity(text.len());
1075 let mut column = 0usize;
1076 for ch in text.chars() {
1077 match ch {
1078 '\t' => {
1079 let spaces = TAB_WIDTH - (column % TAB_WIDTH);
1080 out.extend(std::iter::repeat_n(' ', spaces));
1081 column += spaces;
1082 }
1083 '\n' | '\r' => {
1084 out.push(ch);
1085 column = 0;
1086 }
1087 _ => {
1088 out.push(ch);
1089 column += UnicodeWidthChar::width(ch).unwrap_or(0);
1090 }
1091 }
1092 }
1093 Cow::Owned(out)
1094 }
1095
1096 fn numbered_block(title: &str, text: &str, first_line: usize) -> String {
1097 let title = if title.is_empty() { "text" } else { title };
1098 let line_count = text.lines().count().max(1);
1099 let width = first_line
1100 .saturating_add(line_count.saturating_sub(1))
1101 .max(1)
1102 .to_string()
1103 .len();
1104 let max_width = terminal_width().saturating_sub(4).max(40);
1105 let code_width = max_width.saturating_sub(width + 3).max(1);
1106 let mut out = String::new();
1107 let _ = writeln!(out, "{}", truncate_width(&block_title(title), max_width));
1108 if text.is_empty() {
1109 let _ = writeln!(
1110 out,
1111 "{}",
1112 numbered_line_with_max_width(first_line, width, "", max_width)
1113 );
1114 } else {
1115 let display_text = text
1116 .lines()
1117 .map(|line| truncate_width(line, code_width))
1118 .collect::<Vec<_>>()
1119 .join("\n");
1120 let highlighted = highlighted_block(title, &display_text);
1121 let lines = highlighted.as_deref().unwrap_or(&display_text).lines();
1122 for (idx, line) in lines.enumerate() {
1123 let _ = writeln!(
1124 out,
1125 "{}",
1126 numbered_line_with_max_width(first_line + idx, width, line, max_width)
1127 );
1128 }
1129 }
1130 out.trim_end().to_string()
1131 }
1132
1133 static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
1134 static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
1135
1136 fn highlighted_block(title: &str, text: &str) -> Option<String> {
1137 if !color_enabled() {
1138 return None;
1139 }
1140 let syntax = syntax_for_title(title)?;
1141 let theme = terminal_theme()?;
1142 let mut highlighter = HighlightLines::new(syntax, theme);
1143 let mut out = String::new();
1144 for line in text.lines() {
1145 let ranges = highlighter.highlight_line(line, &SYNTAX_SET).ok()?;
1146 let _ = writeln!(out, "{}", as_24_bit_terminal_escaped(&ranges, false));
1147 }
1148 Some(if text.ends_with('\n') {
1149 out
1150 } else {
1151 out.trim_end_matches('\n').to_string()
1152 })
1153 }
1154
1155 fn syntax_for_title(title: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
1156 let syntaxes = &*SYNTAX_SET;
1157 let name = title.rsplit('/').next().unwrap_or(title);
1158 if let Some(ext) = name.rsplit_once('.').map(|(_, ext)| ext) {
1159 syntaxes.find_syntax_by_extension(ext)
1160 } else {
1161 syntaxes.find_syntax_by_token(name)
1162 }
1163 .or_else(|| syntaxes.find_syntax_by_name(title))
1164 }
1165
1166 fn terminal_theme() -> Option<&'static Theme> {
1167 THEME_SET
1168 .themes
1169 .get("base16-ocean.dark")
1170 .or_else(|| THEME_SET.themes.values().next())
1171 }
1172
1173 pub fn diff(text: &str) -> String {
1174 if !color_enabled() {
1175 return text.to_string();
1176 }
1177 let mut out = String::new();
1178 for line in text.lines() {
1179 let rendered = if line.starts_with("+++") || line.starts_with("---") {
1180 bold(line)
1181 } else if line.starts_with("@@") {
1182 cyan(line)
1183 } else if line.starts_with('+') {
1184 green(line)
1185 } else if line.starts_with('-') {
1186 red(line)
1187 } else {
1188 line.to_string()
1189 };
1190 let _ = writeln!(out, "{rendered}");
1191 }
1192 if text.ends_with('\n') {
1193 out
1194 } else {
1195 out.trim_end_matches('\n').to_string()
1196 }
1197 }
1198
1199 pub fn section(title: &str) {
1200 line(bold(title));
1201 }
1202
1203 pub fn kv(key: &str, value: impl Display) {
1204 line(format_args!(
1205 " {} {value}",
1206 faint(format_args!("{key:<11}"))
1207 ));
1208 }
1209
1210 pub fn success(text: impl Display) {
1211 line(format_args!("{} {text}", green("✓")));
1212 }
1213
1214 pub fn warn(text: impl Display) {
1215 line(format_args!("{} {text}", yellow("!")));
1216 }
1217
1218 pub fn progress(
1219 label: &str,
1220 current: usize,
1221 total: usize,
1222 detail: impl Display,
1223 elapsed: Duration,
1224 ) {
1225 if is_quiet() {
1226 return;
1227 }
1228 line(progress_line(
1229 label,
1230 current,
1231 total,
1232 &detail.to_string(),
1233 elapsed,
1234 ));
1235 }
1236
1237 fn progress_line(
1238 label: &str,
1239 current: usize,
1240 total: usize,
1241 detail: &str,
1242 elapsed: Duration,
1243 ) -> String {
1244 let total = total.max(1);
1245 let current = current.min(total);
1246 let head = format!(
1247 " {} {current}/{total} {}",
1248 progress_bar(current, total, 18),
1249 cyan(label)
1250 );
1251 if detail.trim().is_empty() {
1252 format!("{head} · {}", faint(format_duration(elapsed)))
1253 } else {
1254 format!("{head} · {detail} · {}", faint(format_duration(elapsed)))
1255 }
1256 }
1257
1258 fn progress_bar(current: usize, total: usize, width: usize) -> String {
1259 let width = width.max(1);
1260 let total = total.max(1);
1261 let current = current.min(total);
1262 let filled = current.saturating_mul(width) / total;
1263 format!(
1264 "[{}{}]",
1265 green("█".repeat(filled)),
1266 faint("░".repeat(width.saturating_sub(filled)))
1267 )
1268 }
1269
1270 pub fn tool_batch(round: usize, count: usize) {
1271 if is_quiet() {
1272 return;
1273 }
1274 err_line(tool_batch_line(round, count));
1275 }
1276
1277 pub fn tool_start(name: &str, detail: &str) {
1278 if is_quiet() {
1279 return;
1280 }
1281 err_line(tool_start_line(name, detail));
1282 }
1283
1284 pub fn tool_result(name: &str, elapsed: Duration, preview: &str) {
1285 if is_quiet() {
1286 return;
1287 }
1288 let preview = preview.trim_end();
1289 let head = tool_result_head(name, elapsed);
1290 let Some((first, rest)) = preview.split_once('\n') else {
1291 if preview.is_empty() {
1292 err_line(head);
1293 } else {
1294 err_line(format_args!("{head} · {first}", first = preview));
1295 }
1296 return;
1297 };
1298 err_line(format_args!("{head} · {first}"));
1299 for line in rest.lines() {
1300 err_line(format_args!(" {line}"));
1301 }
1302 }
1303
1304 pub fn tool_error(name: &str, elapsed: Duration, err: impl Display) {
1305 if is_quiet() {
1306 return;
1307 }
1308 err_line(format_args!(
1309 " {} {name} {} · {err:#}",
1310 red("✗"),
1311 format_duration(elapsed)
1312 ));
1313 }
1314
1315 pub fn format_duration(elapsed: Duration) -> String {
1316 if elapsed.as_millis() < 1000 {
1317 format!("{}ms", elapsed.as_millis())
1318 } else {
1319 format!("{:.1}s", elapsed.as_secs_f64())
1320 }
1321 }
1322
1323 fn tool_batch_line(round: usize, count: usize) -> String {
1324 format!("{} tools r{round} ×{count}", magenta("↻"))
1325 }
1326
1327 fn tool_start_line(name: &str, detail: &str) -> String {
1328 if detail.is_empty() {
1329 format!(" {} {name}", cyan("→"))
1330 } else {
1331 format!(" {} {name} · {detail}", cyan("→"))
1332 }
1333 }
1334
1335 fn tool_result_head(name: &str, elapsed: Duration) -> String {
1336 format!(" {} {name} {}", green("✓"), format_duration(elapsed))
1337 }
1338
1339 pub fn compact_spaces(value: &str) -> String {
1340 value.split_whitespace().collect::<Vec<_>>().join(" ")
1341 }
1342
1343 pub fn truncate_chars(text: &str, max: usize) -> String {
1344 truncate_width(text, max)
1345 }
1346
1347 pub fn truncate_width(text: &str, max_width: usize) -> String {
1348 if ansi_stripped_width(text) <= max_width {
1349 return text.to_string();
1350 }
1351 truncate_plain_width(text, max_width)
1352 }
1353
1354 fn truncate_plain_width(text: &str, max_width: usize) -> String {
1355 if UnicodeWidthStr::width(text) <= max_width {
1356 return text.to_string();
1357 }
1358 let ellipsis = "…";
1359 let limit = max_width.saturating_sub(UnicodeWidthStr::width(ellipsis));
1360 let mut out = String::new();
1361 let mut width = 0usize;
1362 for ch in text.chars() {
1363 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
1364 if width + ch_width > limit {
1365 break;
1366 }
1367 width += ch_width;
1368 out.push(ch);
1369 }
1370 out.push_str(ellipsis);
1371 out
1372 }
1373
1374 fn ansi_stripped_width(text: &str) -> usize {
1375 let mut width = 0usize;
1376 let mut chars = text.chars().peekable();
1377 while let Some(ch) = chars.next() {
1378 if ch == '\u{1b}' && chars.peek() == Some(&'[') {
1379 chars.next();
1380 for next in chars.by_ref() {
1381 if ('@'..='~').contains(&next) {
1382 break;
1383 }
1384 }
1385 } else {
1386 width += UnicodeWidthChar::width(ch).unwrap_or(0);
1387 }
1388 }
1389 width
1390 }
1391
1392 pub fn compact_preview(text: &str, max: usize) -> String {
1393 truncate_width(&compact_spaces(text), max)
1394 }
1395
1396 pub fn clamp_lines(text: &str, max_lines: usize, max_cols: usize) -> String {
1397 let mut out = String::new();
1398 let lines = text.lines().collect::<Vec<_>>();
1399 for line in lines.iter().take(max_lines) {
1400 if !out.is_empty() {
1401 out.push('\n');
1402 }
1403 out.push_str(&truncate_width(line, max_cols));
1404 }
1405 if lines.len() > max_lines {
1406 let _ = write!(out, "\n… {} more lines", lines.len() - max_lines);
1407 }
1408 out
1409 }
1410
1411 #[allow(dead_code)]
1412 pub fn wrap_line(text: &str, indent: &str) -> String {
1413 let width = terminal_width().saturating_sub(indent.width()).max(20);
1414 textwrap::wrap(text, width)
1415 .into_iter()
1416 .map(|line| format!("{indent}{line}"))
1417 .collect::<Vec<_>>()
1418 .join("\n")
1419 }
1420
1421 pub fn head_tail(text: &str, max_chars: usize) -> (String, bool) {
1422 if text.chars().count() <= max_chars {
1423 return (text.to_string(), false);
1424 }
1425 let head_len = max_chars / 2;
1426 let tail_len = max_chars.saturating_sub(head_len);
1427 let head = text.chars().take(head_len).collect::<String>();
1428 let tail = text
1429 .chars()
1430 .rev()
1431 .take(tail_len)
1432 .collect::<Vec<_>>()
1433 .into_iter()
1434 .rev()
1435 .collect::<String>();
1436 let hidden = text
1437 .chars()
1438 .count()
1439 .saturating_sub(head.chars().count() + tail.chars().count());
1440 (
1441 format!("{head}\n… [truncated {hidden} chars] …\n{tail}"),
1442 true,
1443 )
1444 }
1445
1446 #[cfg(test)]
1447 mod tests {
1448 use super::*;
1449
1450 fn color_mode_name(mode: ColorMode) -> &'static str {
1451 match mode {
1452 ColorMode::Auto => "auto",
1453 ColorMode::Always => "always",
1454 ColorMode::Never => "never",
1455 }
1456 }
1457
1458 #[test]
1459 fn color_mode_env_parsing() {
1460 assert_eq!(color_mode_name(color_mode_from_values(false, None)), "auto");
1461 assert_eq!(
1462 color_mode_name(color_mode_from_values(false, Some("always"))),
1463 "always"
1464 );
1465 assert_eq!(
1466 color_mode_name(color_mode_from_values(false, Some("on"))),
1467 "always"
1468 );
1469 assert_eq!(
1470 color_mode_name(color_mode_from_values(false, Some("off"))),
1471 "never"
1472 );
1473 assert_eq!(
1474 color_mode_name(color_mode_from_values(true, Some("always"))),
1475 "never"
1476 );
1477 }
1478
1479 #[test]
1480 fn color_auto_requires_terminal() {
1481 assert!(!color_enabled_for_mode(ColorMode::Auto, false));
1482 assert!(color_enabled_for_mode(ColorMode::Auto, true));
1483 assert!(color_enabled_for_mode(ColorMode::Always, false));
1484 assert!(!color_enabled_for_mode(ColorMode::Never, true));
1485 }
1486
1487 #[test]
1488 fn elapsed_format_is_compact() {
1489 assert_eq!(format_duration(Duration::from_millis(42)), "42ms");
1490 assert_eq!(format_duration(Duration::from_millis(1250)), "1.2s");
1491 }
1492
1493 #[test]
1494 fn progress_line_shows_bar_count_detail_and_elapsed() {
1495 set_output_mode(OutputMode::Normal);
1496 assert_eq!(progress_bar(2, 4, 8), "[████░░░░]");
1497 assert_eq!(
1498 progress_line("review", 2, 4, "chunk 3", Duration::from_millis(1250)),
1499 " [█████████░░░░░░░░░] 2/4 review · chunk 3 · 1.2s"
1500 );
1501 }
1502
1503 #[test]
1504 fn tool_progress_lines_are_dense() {
1505 set_output_mode(OutputMode::Normal);
1506 assert_eq!(tool_batch_line(2, 3), "↻ tools r2 ×3");
1507 assert_eq!(
1508 tool_start_line("read", "path=src/main.rs"),
1509 " → read · path=src/main.rs"
1510 );
1511 assert_eq!(
1512 tool_result_head("read", Duration::from_millis(42)),
1513 " ✓ read 42ms"
1514 );
1515 }
1516
1517 #[test]
1518 fn numbered_line_expands_tabs_to_stable_columns() {
1519 set_output_mode(OutputMode::Normal);
1520 assert_eq!(numbered_line(7, 1, "\tlet x = 1;"), "7 │ let x = 1;");
1521 assert_eq!(numbered_line(8, 1, "ab\tcd"), "8 │ ab cd");
1522 assert_eq!(
1523 code("demo.rs", "\tfn main() {}\n\t\tprintln!(\"hi\");", 1),
1524 "── demo.rs\n1 │ fn main() {}\n2 │ println!(\"hi\");"
1525 );
1526 }
1527
1528 #[test]
1529 fn numbered_line_clamps_long_read_lines_to_preview_width() {
1530 set_output_mode(OutputMode::Normal);
1531 let line = numbered_line_with_max_width(
1532 394,
1533 3,
1534 r#" .filter(|line| !line.starts_with(&format!("> {}", prompts::AUDIT_TRANSPARENCY_PREFIX)))"#,
1535 40,
1536 );
1537 assert!(UnicodeWidthStr::width(line.as_str()) <= 40, "{line}");
1538 assert!(line.starts_with("394 │ "));
1539 assert!(line.ends_with('…'));
1540 assert!(!line.contains('\n'));
1541 }
1542
1543 #[test]
1544 fn code_preview_lines_fit_tool_result_indent_width() {
1545 set_output_mode(OutputMode::Normal);
1546 let preview = code(
1547 "src/audit.rs",
1548 r#"pub(crate) fn with_transparency_line(report: &str, snippet: &str) -> String {
1549 .filter(|line| !line.starts_with(&format!("> {}", prompts::AUDIT_TRANSPARENCY_PREFIX)))"#,
1550 390,
1551 );
1552 let max_width = terminal_width().saturating_sub(4).max(40);
1553 for line in preview.lines() {
1554 assert!(
1555 UnicodeWidthStr::width(line) <= max_width,
1556 "line exceeded {max_width}: {line}"
1557 );
1558 }
1559 }
1560 }
1561}
1562
1563pub(crate) mod chat {
1565 use anyhow::Result;
1566 use dialoguer::{Input, Select, theme::ColorfulTheme};
1567 use std::fmt::Display;
1568
1569 use reedline_repl_rs::reedline::{
1570 DefaultPrompt, DefaultPromptSegment, EditCommand, Emacs, FileBackedHistory, KeyCode,
1571 KeyModifiers, Reedline, ReedlineEvent, Signal, default_emacs_keybindings,
1572 };
1573 use std::path::PathBuf;
1574
1575 use crate::config;
1576 use crate::model;
1577 use crate::session::{self, Session};
1578
1579 const HISTORY_SIZE: usize = 10_000;
1580
1581 fn chat_line_editor(history_path: PathBuf) -> Result<Reedline> {
1582 let mut keybindings = default_emacs_keybindings();
1583 keybindings.add_binding(KeyModifiers::NONE, KeyCode::Enter, ReedlineEvent::Submit);
1584 let insert_newline = ReedlineEvent::Edit(vec![EditCommand::InsertNewline]);
1585 keybindings.add_binding(KeyModifiers::SHIFT, KeyCode::Enter, insert_newline.clone());
1586 keybindings.add_binding(KeyModifiers::ALT, KeyCode::Enter, insert_newline);
1587
1588 Ok(Reedline::create()
1589 .with_history(Box::new(FileBackedHistory::with_file(
1590 HISTORY_SIZE,
1591 history_path,
1592 )?))
1593 .with_edit_mode(Box::new(Emacs::new(keybindings)))
1594 .use_bracketed_paste(true))
1595 }
1596
1597 pub async fn run_chat(session: &mut Session) -> Result<i32> {
1598 crate::ui::section("oy chat");
1599 crate::ui::kv("keys", "Enter sends · Alt/Shift+Enter newline · /? help");
1600 let history_path = history_path("chat")?;
1601 let mut line_editor = chat_line_editor(history_path.clone())?;
1602 let prompt = DefaultPrompt::new(
1603 DefaultPromptSegment::Basic("oy".to_string()),
1604 DefaultPromptSegment::Empty,
1605 );
1606
1607 loop {
1608 let signal = match line_editor.read_line(&prompt) {
1609 Ok(signal) => signal,
1610 Err(err) if is_cursor_position_timeout(&err) => {
1611 crate::ui::warn("terminal cursor position timed out; resetting prompt");
1612 line_editor = chat_line_editor(history_path.clone())?;
1613 continue;
1614 }
1615 Err(err) => return Err(err.into()),
1616 };
1617
1618 match signal {
1619 Signal::Success(line) => {
1620 line_editor.sync_history()?;
1621 if !handle_chat_line(session, line.trim()).await? {
1622 break;
1623 }
1624 }
1625 Signal::CtrlD => break,
1626 Signal::CtrlC => {
1627 line_editor.sync_history()?;
1628 break;
1629 }
1630 }
1631 }
1632 prompt_update_todo_on_quit(session);
1633 Ok(0)
1634 }
1635
1636 fn is_cursor_position_timeout(err: &impl Display) -> bool {
1637 let text = err.to_string();
1638 text.contains("cursor position") && text.contains("could not be read")
1639 }
1640
1641 fn prompt_update_todo_on_quit(session: &Session) {
1642 if crate::config::can_prompt() && !session.todos.is_empty() {
1643 let active = session
1644 .todos
1645 .iter()
1646 .filter(|item| item.status != "done")
1647 .count();
1648 crate::ui::line(format_args!(
1649 "todo summary: {active}/{} active in memory; use the todo tool with persist=true to write TODO.md",
1650 session.todos.len()
1651 ));
1652 }
1653 }
1654
1655 async fn handle_chat_line(session: &mut Session, line: &str) -> Result<bool> {
1656 if line.is_empty() {
1657 return Ok(true);
1658 }
1659 if let Some(command) = line.strip_prefix('/') {
1660 return handle_slash_command(session, command.trim()).await;
1661 }
1662 run_prompt_with_model_reselect(session, line).await?;
1663 Ok(true)
1664 }
1665
1666 async fn handle_slash_command(session: &mut Session, command: &str) -> Result<bool> {
1667 let mut parts = command.split_whitespace();
1668 let raw_name = parts.next().unwrap_or_default();
1669 let name = normalize_chat_command(raw_name);
1670 match name {
1671 "" => Ok(true),
1672 "help" => {
1673 crate::ui::markdown(&format!("{}\n", chat_help_text()));
1674 Ok(true)
1675 }
1676 "tokens" => tokens_command(session),
1677 "compact" => compact_command(parts.next(), session).await,
1678 "model" => model_command(parts.next(), session).await,
1679 "thinking" => thinking_command(parts.next()),
1680 "debug" | "status" => status_command(session),
1681 "ask" => {
1682 let prompt = parts.collect::<Vec<_>>().join(" ");
1683 ask_command(session, &prompt).await
1684 }
1685 "save" => save_command(parts.next(), session),
1686 "load" => load_command(parts.next(), session),
1687 "undo" => undo_command(session),
1688 "clear" => clear_command(session),
1689 "quit" | "exit" => Ok(false),
1690 other => {
1691 crate::ui::warn(format_args!("unknown command /{other}"));
1692 Ok(true)
1693 }
1694 }
1695 }
1696
1697 fn normalize_chat_command(command: &str) -> &str {
1698 match command {
1699 "h" | "?" => "help",
1700 "t" => "tokens",
1701 "k" => "compact",
1702 "m" => "model",
1703 "d" => "debug",
1704 "s" => "status",
1705 "u" => "undo",
1706 "c" => "clear",
1707 "q" => "quit",
1708 other => other,
1709 }
1710 }
1711
1712 pub(crate) fn chat_help_text() -> String {
1713 [
1714 "Enter sends; Alt/Shift+Enter inserts newline",
1715 "/help (/h, /?) -- show help",
1716 "/status (/s), /debug (/d) -- show model, mode, context, and todos",
1717 "/model [value] (/m) -- show or switch model",
1718 "/ask <question> -- research-only query",
1719 "/save [name], /load [name] -- save or load a session",
1720 "/undo (/u), /clear (/c) -- repair conversation state",
1721 "/quit (/q), /exit -- end session",
1722 "Advanced: /tokens, /compact [llm|deterministic], /thinking [auto|off|low|medium|high]",
1723 ]
1724 .join("\n")
1725 }
1726
1727 async fn ask_command(session: &mut Session, prompt: &str) -> Result<bool> {
1728 if prompt.is_empty() {
1729 anyhow::bail!("Usage: /ask <question>");
1730 }
1731 let answer =
1732 session::run_prompt_read_only(session, &config::ask_system_prompt(prompt)).await?;
1733 if !answer.is_empty() {
1734 crate::ui::markdown(&format!("{answer}\n"));
1735 }
1736 Ok(true)
1737 }
1738
1739 fn tokens_command(session: &Session) -> Result<bool> {
1740 let status = session.context_status();
1741 crate::ui::section("Context");
1742 crate::ui::kv("messages", status.estimate.messages);
1743 crate::ui::kv(
1744 "system",
1745 format_args!("~{} tokens", status.estimate.system_tokens),
1746 );
1747 crate::ui::kv(
1748 "messages",
1749 format_args!("~{} tokens", status.estimate.message_tokens),
1750 );
1751 crate::ui::kv(
1752 "total",
1753 format_args!("~{} tokens", status.estimate.total_tokens),
1754 );
1755 crate::ui::kv("limit", format_args!("{} tokens", status.limit_tokens));
1756 crate::ui::kv(
1757 "input budget",
1758 format_args!("{} tokens", status.input_budget_tokens),
1759 );
1760 crate::ui::kv("trigger", format_args!("{} tokens", status.trigger_tokens));
1761 crate::ui::kv("summary", crate::ui::bool_text(status.summary_present));
1762 Ok(true)
1763 }
1764
1765 async fn compact_command(mode: Option<&str>, session: &mut Session) -> Result<bool> {
1766 let before = session.context_status().estimate.total_tokens;
1767 let stats = match mode.unwrap_or("llm") {
1768 "" | "llm" | "smart" => session.compact_llm().await?,
1769 "deterministic" | "det" | "fast" => session.compact_deterministic(),
1770 other => anyhow::bail!("compact mode must be llm or deterministic; got {other}"),
1771 };
1772 let after = session.context_status().estimate.total_tokens;
1773 crate::ui::section("Compaction");
1774 if let Some(stats) = stats {
1775 crate::ui::kv(
1776 "tokens",
1777 format_args!("{} -> {}", stats.before_tokens, stats.after_tokens),
1778 );
1779 crate::ui::kv("removed messages", stats.removed_messages);
1780 crate::ui::kv("tool outputs", stats.compacted_tools);
1781 crate::ui::kv("summarized", stats.summarized);
1782 } else {
1783 crate::ui::kv("tokens", format_args!("{before} -> {after}"));
1784 crate::ui::line("nothing to compact");
1785 }
1786 Ok(true)
1787 }
1788
1789 async fn model_command(value: Option<&str>, session: &mut Session) -> Result<bool> {
1790 if let Some(value) = value {
1791 config::save_model_config(value)?;
1792 session.model = model::resolve_model(Some(value))?;
1793 }
1794 crate::ui::line(format_args!("model: {}", session.model));
1795 Ok(true)
1796 }
1797
1798 fn thinking_command(value: Option<&str>) -> Result<bool> {
1799 if let Some(value) = value {
1800 match value {
1801 "" | "auto" => unsafe { std::env::remove_var("OY_THINKING") },
1802 "off" | "none" => unsafe { std::env::set_var("OY_THINKING", "none") },
1803 "minimal" | "low" | "medium" | "high" => unsafe {
1804 std::env::set_var("OY_THINKING", value)
1805 },
1806 other => anyhow::bail!(
1807 "thinking must be auto, off, minimal, low, medium, or high; got {other}"
1808 ),
1809 }
1810 }
1811 crate::ui::line(format_args!(
1812 "thinking: {}",
1813 std::env::var("OY_THINKING").unwrap_or_else(|_| "auto".to_string())
1814 ));
1815 Ok(true)
1816 }
1817
1818 fn status_command(session: &Session) -> Result<bool> {
1819 crate::ui::section("Status");
1820 crate::ui::kv("workspace", session.root.display());
1821 crate::ui::kv("model", &session.model);
1822 crate::ui::kv("genai", model::to_genai_model_spec(&session.model));
1823 crate::ui::kv(
1824 "thinking",
1825 model::default_reasoning_effort(&session.model).unwrap_or("auto/off"),
1826 );
1827 crate::ui::kv("mode", &session.mode);
1828 crate::ui::kv("interactive", crate::ui::bool_text(session.interactive));
1829 crate::ui::kv(
1830 "files-write",
1831 format_args!("{:?}", session.policy.files_write),
1832 );
1833 crate::ui::kv("shell", format_args!("{:?}", session.policy.shell));
1834 crate::ui::kv("network", crate::ui::bool_text(session.policy.network));
1835 crate::ui::kv("risk", config::policy_risk_label(&session.policy));
1836 crate::ui::kv("messages", session.transcript.messages.len());
1837 crate::ui::kv("todos", session.todos.len());
1838 let status = session.context_status();
1839 crate::ui::kv(
1840 "context",
1841 format_args!(
1842 "~{} / {} tokens",
1843 status.estimate.total_tokens, status.input_budget_tokens
1844 ),
1845 );
1846 crate::ui::kv("summary", crate::ui::bool_text(status.summary_present));
1847 Ok(true)
1848 }
1849
1850 fn save_command(name: Option<&str>, session: &mut Session) -> Result<bool> {
1851 let path = session.save(name)?;
1852 crate::ui::success(format_args!("saved session {}", path.display()));
1853 Ok(true)
1854 }
1855
1856 fn load_command(name: Option<&str>, session: &mut Session) -> Result<bool> {
1857 if let Some(new_session) =
1858 session::load_saved(name, true, session.mode.clone(), session.policy)?
1859 {
1860 *session = new_session;
1861 crate::ui::success("loaded session");
1862 } else {
1863 crate::ui::warn("no saved sessions found");
1864 }
1865 Ok(true)
1866 }
1867
1868 fn undo_command(session: &mut Session) -> Result<bool> {
1869 if session.transcript.undo_last_turn() {
1870 crate::ui::success("undid last turn");
1871 } else {
1872 crate::ui::warn("nothing to undo");
1873 }
1874 Ok(true)
1875 }
1876
1877 fn clear_command(session: &mut Session) -> Result<bool> {
1878 session.transcript.messages.clear();
1879 crate::ui::success("conversation cleared");
1880 Ok(true)
1881 }
1882
1883 async fn run_prompt_with_model_reselect(session: &mut Session, prompt: &str) -> Result<()> {
1884 loop {
1885 match session::run_prompt(session, prompt).await {
1886 Ok(answer) => {
1887 if !answer.is_empty() {
1888 crate::ui::markdown(&format!("{answer}\n"));
1889 }
1890 return Ok(());
1891 }
1892 Err(err) if config::can_prompt() => {
1893 crate::ui::err_line(format_args!("model call failed: {err:#}"));
1894 session.transcript.undo_last_turn();
1895 let Some(model) = choose_replacement_model(session).await? else {
1896 return Err(err);
1897 };
1898 session.model = model;
1899 config::save_model_config(&session.model)?;
1900 crate::ui::err_line(format_args!("retrying with model: {}", session.model));
1901 }
1902 Err(err) => return Err(err),
1903 }
1904 }
1905 }
1906
1907 async fn choose_replacement_model(session: &Session) -> Result<Option<String>> {
1908 let listing = model::inspect_models().await?;
1909 let items = replacement_model_choices(&session.model, listing.all_models, listing.hints);
1910 if items.is_empty() {
1911 return Ok(None);
1912 }
1913 choose_model(None, &items)
1914 }
1915
1916 fn replacement_model_choices(
1917 current: &str,
1918 mut models: Vec<String>,
1919 hints: Vec<String>,
1920 ) -> Vec<String> {
1921 models.extend(hints);
1922 models.retain(|item| item != current);
1923 models.sort();
1924 models.dedup();
1925 models
1926 }
1927
1928 pub fn choose_model(current: Option<&str>, items: &[String]) -> Result<Option<String>> {
1929 choose_model_with_initial_list(current, items, true)
1930 }
1931
1932 pub fn choose_model_with_initial_list(
1933 current: Option<&str>,
1934 items: &[String],
1935 _print_initial_list: bool,
1936 ) -> Result<Option<String>> {
1937 if items.is_empty() || !config::can_prompt() {
1938 return Ok(None);
1939 }
1940 let theme = ColorfulTheme::default();
1941 let default = current.and_then(|value| items.iter().position(|item| item == value));
1942 let mut prompt = Select::with_theme(&theme)
1943 .with_prompt("Models")
1944 .items(items)
1945 .default(default.unwrap_or(0));
1946 if current.is_some() {
1947 prompt = prompt.with_prompt("Models (Esc keeps current)");
1948 }
1949 Ok(prompt.interact_opt()?.map(|index| items[index].clone()))
1950 }
1951
1952 pub fn ask(question: &str, choices: Option<&[String]>) -> Result<String> {
1953 if let Some(choices) = choices {
1954 if choices.is_empty() {
1955 return Ok(String::new());
1956 }
1957 let index = Select::with_theme(&ColorfulTheme::default())
1958 .with_prompt(question)
1959 .items(choices)
1960 .default(0)
1961 .interact_opt()?;
1962 return Ok(index
1963 .map(|index| choices[index].clone())
1964 .unwrap_or_default());
1965 }
1966 Ok(Input::<String>::with_theme(&ColorfulTheme::default())
1967 .with_prompt(question)
1968 .interact_text()?)
1969 }
1970
1971 fn history_path(name: &str) -> Result<PathBuf> {
1972 history_path_in(config::config_dir_path(), name)
1973 }
1974
1975 fn history_path_in(config_dir: PathBuf, name: &str) -> Result<PathBuf> {
1976 let history = config_dir.join("history");
1977 config::create_private_dir_all(&history)?;
1978 let path = history.join(format!("{name}.txt"));
1979 if !path.exists() {
1980 config::write_private_file(&path, b"")?;
1981 }
1982 Ok(path)
1983 }
1984
1985 #[cfg(test)]
1986 mod tests {
1987 use super::*;
1988
1989 #[test]
1990 fn history_path_uses_named_private_history_file() {
1991 let dir = tempfile::tempdir().unwrap();
1992 let path = history_path_in(dir.path().to_path_buf(), "chat").unwrap();
1993 assert!(path.ends_with("history/chat.txt"));
1994 assert!(path.exists());
1995
1996 #[cfg(unix)]
1997 {
1998 use std::os::unix::fs::PermissionsExt as _;
1999 let history_dir_mode = std::fs::metadata(path.parent().unwrap())
2000 .unwrap()
2001 .permissions()
2002 .mode()
2003 & 0o777;
2004 let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2005 assert_eq!(history_dir_mode, 0o700);
2006 assert_eq!(file_mode, 0o600);
2007 }
2008 }
2009
2010 #[test]
2011 fn normalize_chat_command_maps_slash_aliases() {
2012 assert_eq!(normalize_chat_command("q"), "quit");
2013 assert_eq!(normalize_chat_command("tokens"), "tokens");
2014 assert_eq!(normalize_chat_command("k"), "compact");
2015 assert_eq!(normalize_chat_command("s"), "status");
2016 }
2017
2018 #[test]
2019 fn chat_help_uses_slash_commands() {
2020 let help = chat_help_text();
2021 assert!(help.contains("/help"));
2022 assert!(help.contains("/quit"));
2023 assert!(help.contains("/compact"));
2024 assert!(help.contains("/status"));
2025 }
2026
2027 #[test]
2028 fn replacement_model_choices_drop_current_and_dedup() {
2029 let choices = replacement_model_choices(
2030 "broken",
2031 vec!["broken".into(), "ok".into()],
2032 vec!["ok".into(), "other".into()],
2033 );
2034 assert_eq!(choices, vec!["ok".to_string(), "other".to_string()]);
2035 }
2036 }
2037}
2038
2039pub(crate) mod app {
2041 use anyhow::{Result, bail};
2042 use clap::{Args, Parser, Subcommand, ValueEnum};
2043 use std::io::IsTerminal as _;
2044 use std::path::{Path, PathBuf};
2045
2046 use crate::audit;
2047 use crate::config;
2048 use crate::model;
2049 use crate::session::{self, Session};
2050
2051 const MODEL_LIST_LIMIT: usize = 30;
2052
2053 #[derive(Debug, Parser)]
2054 #[command(
2055 name = "oy",
2056 version,
2057 about = "Small local AI coding assistant for your shell.",
2058 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."
2059 )]
2060 struct Cli {
2061 #[arg(long, global = true, conflicts_with_all = ["verbose", "json"], help = "Suppress normal progress output")]
2062 quiet: bool,
2063 #[arg(long, global = true, conflicts_with_all = ["quiet", "json"], help = "Show fuller tool previews")]
2064 verbose: bool,
2065 #[arg(long, global = true, conflicts_with_all = ["quiet", "verbose"], help = "Print machine-readable JSON where supported")]
2066 json: bool,
2067 #[command(subcommand)]
2068 command: Option<Command>,
2069 }
2070
2071 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
2072 enum AuditFormat {
2073 Markdown,
2074 Sarif,
2075 }
2076
2077 impl From<AuditFormat> for audit::AuditOutputFormat {
2078 fn from(format: AuditFormat) -> Self {
2079 match format {
2080 AuditFormat::Markdown => Self::Markdown,
2081 AuditFormat::Sarif => Self::Sarif,
2082 }
2083 }
2084 }
2085
2086 #[derive(Debug, Subcommand)]
2087 enum Command {
2088 Run(RunArgs),
2090 Chat(ChatArgs),
2092 Model(ModelArgs),
2094 Doctor(DoctorArgs),
2096 Audit {
2098 #[arg(
2099 long,
2100 value_enum,
2101 default_value_t = AuditFormat::Markdown,
2102 help = "Output format: markdown or sarif"
2103 )]
2104 format: AuditFormat,
2105 #[arg(
2106 long,
2107 value_name = "PATH",
2108 help = "Write findings to a workspace file (default: ISSUES.md or oy.sarif)"
2109 )]
2110 out: Option<PathBuf>,
2111 #[arg(
2112 long,
2113 value_name = "N",
2114 default_value_t = audit::DEFAULT_MAX_REVIEW_CHUNKS,
2115 help = "Maximum audit chunks to review before failing closed"
2116 )]
2117 max_chunks: usize,
2118 #[arg(value_name = "FOCUS", help = "Optional audit focus text")]
2119 focus: Vec<String>,
2120 },
2121 }
2122
2123 #[derive(Debug, Args, Clone)]
2124 struct SharedModeArgs {
2125 #[arg(
2126 long,
2127 alias = "agent",
2128 default_value = "default",
2129 help = "Safety mode (default: balanced): plan, ask, edit, or auto"
2130 )]
2131 mode: String,
2132 #[arg(
2133 long = "continue-session",
2134 default_value_t = false,
2135 help = "Resume the most recent saved session"
2136 )]
2137 continue_session: bool,
2138 #[arg(
2139 long,
2140 default_value = "",
2141 value_name = "NAME_OR_NUMBER",
2142 help = "Resume a named or numbered saved session"
2143 )]
2144 resume: String,
2145 }
2146
2147 #[derive(Debug, Args, Clone)]
2148 struct RunArgs {
2149 #[command(flatten)]
2150 shared: SharedModeArgs,
2151 #[arg(
2152 long,
2153 value_name = "PATH",
2154 help = "Write the final answer to a workspace file"
2155 )]
2156 out: Option<PathBuf>,
2157 #[arg(
2158 value_name = "PROMPT",
2159 help = "Task prompt; omitted means read stdin or start chat in a TTY"
2160 )]
2161 task: Vec<String>,
2162 }
2163
2164 #[derive(Debug, Args, Clone)]
2165 struct ChatArgs {
2166 #[command(flatten)]
2167 shared: SharedModeArgs,
2168 }
2169
2170 #[derive(Debug, Args, Clone)]
2171 struct ModelArgs {
2172 #[arg(
2173 value_name = "MODEL",
2174 help = "Model id or routing shim selection, e.g. copilot::gpt-4.1-mini"
2175 )]
2176 model: Option<String>,
2177 }
2178
2179 #[derive(Debug, Args, Clone)]
2180 struct DoctorArgs {
2181 #[arg(
2182 long,
2183 alias = "agent",
2184 default_value = "default",
2185 help = "Safety mode to inspect (default: balanced): plan, ask, edit, or auto"
2186 )]
2187 mode: String,
2188 }
2189
2190 pub async fn run(argv: Vec<String>) -> Result<i32> {
2191 let normalized = normalize_args(argv);
2192 let mut cli = Cli::parse_from(std::iter::once("oy".to_string()).chain(normalized.clone()));
2193 restore_trailing_audit_options(&mut cli);
2194 crate::ui::init_output_mode(cli_output_mode(&cli));
2195 match cli.command.unwrap_or(Command::Run(RunArgs {
2196 shared: SharedModeArgs {
2197 mode: "default".to_string(),
2198 continue_session: false,
2199 resume: String::new(),
2200 },
2201 out: None,
2202 task: Vec::new(),
2203 })) {
2204 Command::Run(args) => run_command(args).await,
2205 Command::Chat(args) => chat_command(args).await,
2206 Command::Model(args) => model_command(args).await,
2207 Command::Doctor(args) => doctor_command(args).await,
2208 Command::Audit {
2209 format,
2210 out,
2211 max_chunks,
2212 focus,
2213 } => {
2214 audit_command(AuditArgs {
2215 focus,
2216 out: out.unwrap_or_else(|| audit::default_output_path(format.into())),
2217 max_chunks,
2218 format: format.into(),
2219 })
2220 .await
2221 }
2222 }
2223 }
2224
2225 fn restore_trailing_audit_options(cli: &mut Cli) {
2226 let Some(Command::Audit {
2227 format: _,
2228 out: _,
2229 max_chunks,
2230 focus,
2231 }) = &mut cli.command
2232 else {
2233 return;
2234 };
2235 let mut filtered_focus = Vec::new();
2236 let mut i = 0usize;
2237 while i < focus.len() {
2238 match focus[i].as_str() {
2239 "--max-chunks" => {
2240 if let Some(value) = focus.get(i + 1)
2241 && let Ok(parsed) = value.parse::<usize>()
2242 {
2243 *max_chunks = parsed;
2244 i += 2;
2245 continue;
2246 }
2247 }
2248 raw if raw.starts_with("--max-chunks=") => {
2249 if let Some((_, value)) = raw.split_once('=')
2250 && let Ok(parsed) = value.parse::<usize>()
2251 {
2252 *max_chunks = parsed;
2253 i += 1;
2254 continue;
2255 }
2256 }
2257 _ => {}
2258 }
2259 filtered_focus.push(focus[i].clone());
2260 i += 1;
2261 }
2262 *focus = filtered_focus;
2263 }
2264
2265 fn cli_output_mode(cli: &Cli) -> Option<crate::ui::OutputMode> {
2266 if cli.quiet {
2267 Some(crate::ui::OutputMode::Quiet)
2268 } else if cli.verbose {
2269 Some(crate::ui::OutputMode::Verbose)
2270 } else if cli.json {
2271 Some(crate::ui::OutputMode::Json)
2272 } else {
2273 None
2274 }
2275 }
2276
2277 #[cfg(test)]
2278 fn parse_cli_for_test(args: &[&str]) -> Cli {
2279 let mut cli = Cli::parse_from(args);
2280 restore_trailing_audit_options(&mut cli);
2281 cli
2282 }
2283
2284 #[cfg(test)]
2285 fn command_help_for_test(command: &str) -> String {
2286 let mut cmd = <Cli as clap::CommandFactory>::command();
2287 let Some(subcommand) = cmd.find_subcommand_mut(command) else {
2288 panic!("unknown command: {command}");
2289 };
2290 let mut help = Vec::new();
2291 subcommand.write_long_help(&mut help).expect("write help");
2292 String::from_utf8(help).expect("utf8 help")
2293 }
2294
2295 fn normalize_args(mut args: Vec<String>) -> Vec<String> {
2296 if args.is_empty() {
2297 return if config::can_prompt() {
2298 vec!["--help".to_string()]
2299 } else {
2300 vec!["run".to_string()]
2301 };
2302 }
2303 if matches!(
2304 args.first().map(String::as_str),
2305 Some("--continue") | Some("-c")
2306 ) {
2307 return std::iter::once("run".to_string())
2308 .chain(std::iter::once("--continue-session".to_string()))
2309 .chain(args.drain(1..))
2310 .collect();
2311 }
2312 if args.first().map(String::as_str) == Some("--resume") {
2313 return std::iter::once("run".to_string()).chain(args).collect();
2314 }
2315 let commands = ["run", "chat", "model", "doctor", "audit", "-h", "--help"];
2316 if args
2317 .first()
2318 .is_some_and(|arg| !arg.starts_with('-') && !commands.contains(&arg.as_str()))
2319 {
2320 let mut out = vec!["run".to_string()];
2321 out.extend(args);
2322 return out;
2323 }
2324 args
2325 }
2326
2327 async fn run_command(args: RunArgs) -> Result<i32> {
2328 let task = collect_task(&args.task)?;
2329 if task.trim().is_empty() {
2330 return chat_command(ChatArgs {
2331 shared: args.shared,
2332 })
2333 .await;
2334 }
2335 let mut session = load_or_new(
2336 false,
2337 &args.shared.mode,
2338 args.shared.continue_session,
2339 &args.shared.resume,
2340 )?;
2341 print_session_intro("run", &session, Some(&task));
2342 let answer = session::run_prompt(&mut session, &task).await?;
2343 if crate::ui::is_json() {
2344 print_run_json(&session, &answer)?;
2345 } else if let Some(path) = args.out {
2346 write_workspace_file(&session.root, &path, &answer)?;
2347 crate::ui::success(format_args!("wrote {}", path.display()));
2348 } else if !answer.is_empty() {
2349 crate::ui::markdown(&format!("{answer}\n"));
2350 }
2351 Ok(0)
2352 }
2353
2354 fn print_run_json(session: &Session, answer: &str) -> Result<()> {
2355 let status = session.context_status();
2356 let payload = serde_json::json!({
2357 "answer": answer,
2358 "model": session.model,
2359 "mode": session.mode,
2360 "workspace": session.root,
2361 "tokens": status.estimate,
2362 "context": status,
2363 "messages": status.estimate.messages,
2364 "todos": session.todos,
2365 });
2366 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2367 Ok(())
2368 }
2369
2370 async fn chat_command(args: ChatArgs) -> Result<i32> {
2371 let mut session = load_or_new(
2372 true,
2373 &args.shared.mode,
2374 args.shared.continue_session,
2375 &args.shared.resume,
2376 )?;
2377 print_session_intro("chat", &session, None);
2378 crate::chat::run_chat(&mut session).await
2379 }
2380
2381 async fn model_command(args: ModelArgs) -> Result<i32> {
2382 if let Some(model_spec) = args
2383 .model
2384 .as_deref()
2385 .filter(|value| is_exact_model_spec(value))
2386 {
2387 let normalized = model::canonical_model_spec(model_spec);
2388 config::save_model_config(&normalized)?;
2389 if crate::ui::is_json() {
2390 print_saved_model_json(&normalized)?;
2391 } else {
2392 print_saved_model(&normalized);
2393 }
2394 return Ok(0);
2395 }
2396
2397 let listing = model::inspect_models().await?;
2398 if let Some(model_spec) = args.model {
2399 let normalized = resolve_model_choice(&listing, &model_spec)?;
2400 config::save_model_config(&normalized)?;
2401 if crate::ui::is_json() {
2402 print_model_json(&listing, Some(&normalized))?;
2403 } else {
2404 print_saved_model(&normalized);
2405 }
2406 return Ok(0);
2407 }
2408 if crate::ui::is_json() {
2409 print_model_json(&listing, None)?;
2410 return Ok(0);
2411 }
2412 print_model_listing(&listing);
2413 if config::can_prompt()
2414 && !listing.all_models.is_empty()
2415 && let Some(chosen) = crate::chat::choose_model_with_initial_list(
2416 listing.current.as_deref(),
2417 &listing.all_models,
2418 false,
2419 )?
2420 {
2421 config::save_model_config(&chosen)?;
2422 print_saved_model(&chosen);
2423 }
2424 Ok(0)
2425 }
2426
2427 fn is_exact_model_spec(value: &str) -> bool {
2428 let value = value.trim();
2429 value.contains("::") || value.contains('/') || value.contains(':') || value.contains('.')
2430 }
2431
2432 fn print_saved_model_json(saved: &str) -> Result<()> {
2433 let payload = serde_json::json!({ "saved": saved });
2434 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2435 Ok(())
2436 }
2437
2438 fn print_model_json(listing: &model::ModelListing, saved: Option<&str>) -> Result<()> {
2439 let payload = serde_json::json!({
2440 "current": listing.current,
2441 "current_shim": listing.current_shim,
2442 "saved": saved,
2443 "auth": listing.auth,
2444 "recommended": listing.recommended,
2445 "dynamic": listing.dynamic,
2446 "hints": listing.hints,
2447 "all_models": listing.all_models,
2448 });
2449 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2450 Ok(())
2451 }
2452
2453 fn print_model_listing(listing: &model::ModelListing) {
2454 crate::ui::section("Models");
2455 crate::ui::kv(
2456 "current",
2457 current_model_text(
2458 listing.current.as_deref().unwrap_or("<unset>"),
2459 listing.current_shim.as_deref(),
2460 ),
2461 );
2462 crate::ui::kv("selectable", listing.all_models.len());
2463 if !listing.recommended.is_empty() {
2464 crate::ui::kv("recommended", listing.recommended.join(", "));
2465 if listing.current.is_none() {
2466 crate::ui::line(format_args!(" Try: oy model {}", listing.recommended[0]));
2467 }
2468 }
2469
2470 if !listing.auth.is_empty() {
2471 crate::ui::line("");
2472 crate::ui::section("Auth / shims");
2473 for item in &listing.auth {
2474 let env_var = item.env_var.as_deref().unwrap_or("-");
2475 let active = if listing.current_shim.as_deref() == Some(item.adapter.as_str()) {
2476 " *"
2477 } else {
2478 ""
2479 };
2480 crate::ui::line(format_args!(
2481 " {}{} {} ({})",
2482 item.adapter, active, env_var, item.source
2483 ));
2484 crate::ui::line(format_args!(" {}", item.detail));
2485 }
2486 }
2487
2488 crate::ui::line("");
2489 crate::ui::section("Introspected endpoint models");
2490 if listing.dynamic.is_empty() {
2491 crate::ui::line(" none found from configured OpenAI-compatible endpoints");
2492 } else {
2493 for item in &listing.dynamic {
2494 if !item.ok {
2495 crate::ui::line(format_args!(
2496 " {} failed via {}",
2497 item.adapter, item.source
2498 ));
2499 if let Some(error) = item.error.as_deref() {
2500 crate::ui::line(format_args!(
2501 " {}",
2502 crate::ui::truncate_chars(error, 140)
2503 ));
2504 }
2505 continue;
2506 }
2507 crate::ui::line(format_args!(
2508 " {} {} models via {}",
2509 item.adapter, item.count, item.source
2510 ));
2511 for model_name in item.models.iter().take(MODEL_LIST_LIMIT) {
2512 let marker = if listing.current.as_deref() == Some(model_name.as_str()) {
2513 "*"
2514 } else {
2515 " "
2516 };
2517 crate::ui::line(format_args!(" {marker} {model_name}"));
2518 }
2519 if item.models.len() > MODEL_LIST_LIMIT {
2520 crate::ui::line(format_args!(
2521 " … {} more; use `oy model <filter>` or interactive selection",
2522 item.models.len() - MODEL_LIST_LIMIT
2523 ));
2524 }
2525 }
2526 }
2527
2528 let hinted = listing
2529 .hints
2530 .iter()
2531 .filter(|hint| {
2532 !listing
2533 .dynamic
2534 .iter()
2535 .any(|group| group.models.iter().any(|model| model == *hint))
2536 })
2537 .collect::<Vec<_>>();
2538 if !hinted.is_empty() {
2539 crate::ui::line("");
2540 crate::ui::section("Built-in selectable hints");
2541 for hint in hinted.iter().take(MODEL_LIST_LIMIT) {
2542 crate::ui::line(format_args!(" {hint}"));
2543 }
2544 if hinted.len() > MODEL_LIST_LIMIT {
2545 crate::ui::line(format_args!(
2546 " … {} more hints",
2547 hinted.len() - MODEL_LIST_LIMIT
2548 ));
2549 }
2550 }
2551 }
2552
2553 fn current_model_text(model_spec: &str, shim: Option<&str>) -> String {
2554 match shim.filter(|value| !value.is_empty()) {
2555 Some(shim) => format!("{model_spec} (shim: {shim})"),
2556 None => model_spec.to_string(),
2557 }
2558 }
2559
2560 fn print_saved_model(selection: &str) {
2561 let saved = config::saved_model_config_from_selection(selection);
2562 crate::ui::success(format_args!(
2563 "saved model {}",
2564 saved.model.as_deref().unwrap_or(selection)
2565 ));
2566 if let Some(shim) = saved.shim {
2567 crate::ui::kv("shim", shim);
2568 }
2569 }
2570
2571 fn resolve_model_choice(listing: &model::ModelListing, query: &str) -> Result<String> {
2572 let normalized = model::canonical_model_spec(query);
2573 if listing.all_models.iter().any(|item| item == &normalized) {
2574 return Ok(normalized);
2575 }
2576 if !config::can_prompt() {
2577 bail!(
2578 "No exact model match for `{}`. Re-run in a TTY to choose interactively.",
2579 query
2580 );
2581 }
2582 let matches = listing
2583 .all_models
2584 .iter()
2585 .filter(|item| {
2586 item.to_ascii_lowercase()
2587 .contains(&query.to_ascii_lowercase())
2588 })
2589 .cloned()
2590 .collect::<Vec<_>>();
2591 if matches.is_empty() {
2592 bail!("No matching model for `{}`", query);
2593 }
2594 crate::chat::choose_model(listing.current.as_deref(), &matches)
2595 .map(|value| value.unwrap_or(normalized))
2596 }
2597
2598 async fn doctor_command(args: DoctorArgs) -> Result<i32> {
2599 let root = config::oy_root()?;
2600 let listing = model::inspect_models().await?;
2601 let mode = config::safety_mode(&args.mode)?;
2602 let policy = config::tool_policy(mode.name());
2603 let config_file = config::config_root();
2604 let config_dir = config::config_dir_path();
2605 let sessions_dir = config::sessions_dir().unwrap_or_else(|_| config_dir.join("sessions"));
2606 let history_dir = config_dir.join("history");
2607 let bash_ok = std::process::Command::new("bash")
2608 .arg("--version")
2609 .stdout(std::process::Stdio::null())
2610 .stderr(std::process::Stdio::null())
2611 .status()
2612 .map(|status| status.success())
2613 .unwrap_or(false);
2614
2615 if crate::ui::is_json() {
2616 let payload = serde_json::json!({
2617 "workspace": root,
2618 "model": listing.current,
2619 "shim": listing.current_shim,
2620 "auth": listing.auth,
2621 "mode": mode.name(),
2622 "policy": policy,
2623 "interactive": config::can_prompt(),
2624 "non_interactive": config::non_interactive(),
2625 "config_file": config_file,
2626 "config_dir": config_dir,
2627 "sessions_dir": sessions_dir,
2628 "history_dir": history_dir,
2629 "bash": bash_ok,
2630 "recommended": listing.recommended,
2631 "next_step": recommended_next_step(&listing),
2632 });
2633 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2634 return Ok(0);
2635 }
2636
2637 crate::ui::section("Doctor");
2638 crate::ui::kv("workspace", root.display());
2639 crate::ui::kv("model", listing.current.as_deref().unwrap_or("<unset>"));
2640 crate::ui::kv("shim", listing.current_shim.as_deref().unwrap_or("<none>"));
2641 crate::ui::kv("mode", mode.name());
2642 crate::ui::kv("files-write", format_args!("{:?}", policy.files_write));
2643 crate::ui::kv("shell", format_args!("{:?}", policy.shell));
2644 crate::ui::kv("network", crate::ui::bool_text(policy.network));
2645 crate::ui::kv("risk", config::policy_risk_label(&policy));
2646 crate::ui::kv("interactive", crate::ui::bool_text(config::can_prompt()));
2647 crate::ui::kv(
2648 "bash",
2649 crate::ui::status_text(bash_ok, if bash_ok { "ok" } else { "missing" }),
2650 );
2651 crate::ui::line("");
2652 crate::ui::section("Local state");
2653 crate::ui::kv("config", config_file.display());
2654 crate::ui::kv("sessions", sessions_dir.display());
2655 crate::ui::kv("history", history_dir.display());
2656 crate::ui::line(
2657 " Treat local state as sensitive: prompts, source snippets, tool output, and command output may be saved.",
2658 );
2659 crate::ui::line("");
2660 crate::ui::section("Auth / shims");
2661 if listing.auth.is_empty() {
2662 crate::ui::warn("no provider auth detected");
2663 } else {
2664 for item in &listing.auth {
2665 crate::ui::line(format_args!(
2666 " {} {} ({})",
2667 item.adapter,
2668 item.env_var.as_deref().unwrap_or("-"),
2669 item.source
2670 ));
2671 crate::ui::line(format_args!(" {}", item.detail));
2672 }
2673 }
2674 if listing.current.is_none() {
2675 crate::ui::line("");
2676 crate::ui::warn("no model configured");
2677 crate::ui::line(format_args!(" {}", recommended_next_step(&listing)));
2678 }
2679 crate::ui::line("");
2680 crate::ui::section("Recommended next steps");
2681 crate::ui::line(format_args!(" 1. {}", recommended_next_step(&listing)));
2682 crate::ui::line(" 2. For untrusted repos: `oy chat --mode plan`");
2683 crate::ui::line(format_args!(
2684 " • Read-only container: {}",
2685 safe_container_command(&root, true)
2686 ));
2687 crate::ui::line("");
2688 crate::ui::section("Safety");
2689 crate::ui::line(
2690 " oy is not a sandbox. Use `oy chat --mode plan` or a disposable container/VM for untrusted repos.",
2691 );
2692 crate::ui::line(
2693 " Mount only needed credentials/env vars. Do not mount the host Docker socket into AI-assisted containers.",
2694 );
2695 Ok(0)
2696 }
2697
2698 fn recommended_next_step(listing: &model::ModelListing) -> String {
2699 if listing.current.is_some() {
2700 return "Run `oy \"inspect this repo\"` or `oy chat`.".to_string();
2701 }
2702 if let Some(choice) = listing.recommended.first() {
2703 return format!("Configure a model: `oy model {choice}`.");
2704 }
2705 "Configure provider auth, then run `oy model`; see `oy doctor` output.".to_string()
2706 }
2707
2708 fn safe_container_command(root: &Path, read_only: bool) -> String {
2709 let mode = if read_only { "ro" } else { "rw" };
2710 format!(
2711 "docker run --rm -it -v \"{}:/workspace:{mode}\" -w /workspace oy-image oy chat --mode plan",
2712 root.display()
2713 )
2714 }
2715
2716 #[derive(Debug, Clone)]
2717 struct AuditArgs {
2718 focus: Vec<String>,
2719 out: PathBuf,
2720 max_chunks: usize,
2721 format: audit::AuditOutputFormat,
2722 }
2723
2724 async fn audit_command(args: AuditArgs) -> Result<i32> {
2725 let started = std::time::Instant::now();
2726 let focus = args.focus.join(" ");
2727 let root = config::oy_root()?;
2728 let model = model::resolve_model(None)?;
2729 if !crate::ui::is_quiet() {
2730 crate::ui::section("audit");
2731 crate::ui::kv("workspace", root.display());
2732 crate::ui::kv("model", &model);
2733 crate::ui::kv("mode", "no-tools");
2734 crate::ui::kv("format", args.format.name());
2735 crate::ui::kv("out", args.out.display());
2736 crate::ui::kv("max chunks", args.max_chunks);
2737 if !focus.trim().is_empty() {
2738 crate::ui::kv("focus", crate::ui::compact_preview(&focus, 100));
2739 }
2740 }
2741 let result = audit::run(audit::AuditOptions {
2742 root,
2743 model,
2744 focus,
2745 out: args.out,
2746 max_chunks: args.max_chunks,
2747 format: args.format,
2748 })
2749 .await?;
2750 if crate::ui::is_json() {
2751 let payload = serde_json::json!({
2752 "output": result.output_path,
2753 "files": result.file_count,
2754 "chunks": result.chunk_count,
2755 "format": args.format.name(),
2756 "elapsed_ms": started.elapsed().as_millis(),
2757 });
2758 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2759 } else {
2760 crate::ui::success(format_args!(
2761 "wrote {} ({} files, {} chunks, {})",
2762 result.output_path.display(),
2763 result.file_count,
2764 result.chunk_count,
2765 crate::ui::format_duration(started.elapsed())
2766 ));
2767 }
2768 Ok(0)
2769 }
2770
2771 fn load_or_new(
2772 interactive: bool,
2773 mode_name: &str,
2774 continue_session: bool,
2775 resume: &str,
2776 ) -> Result<Session> {
2777 let mode = config::safety_mode(mode_name)?;
2778 let policy = config::tool_policy(mode.name());
2779 if continue_session || !resume.is_empty() {
2780 let name = if continue_session { None } else { Some(resume) };
2781 if let Some(session) =
2782 session::load_saved(name, interactive, mode.name().to_string(), policy)?
2783 {
2784 return Ok(session);
2785 }
2786 }
2787 let root = config::oy_root()?;
2788 let model = model::resolve_model(None)?;
2789 Ok(Session::new(
2790 root,
2791 model,
2792 interactive,
2793 mode.name().to_string(),
2794 policy,
2795 ))
2796 }
2797
2798 fn collect_task(parts: &[String]) -> Result<String> {
2799 if !parts.is_empty() {
2800 return Ok(parts.join(" "));
2801 }
2802 if std::io::stdin().is_terminal() {
2803 return Ok(String::new());
2804 }
2805 let mut input = String::new();
2806 use std::io::Read as _;
2807 std::io::stdin().read_to_string(&mut input)?;
2808 Ok(input.trim().to_string())
2809 }
2810
2811 fn print_session_intro(mode: &str, session: &Session, prompt: Option<&str>) {
2812 if crate::ui::is_quiet() {
2813 return;
2814 }
2815 crate::ui::section(mode);
2816 crate::ui::kv("workspace", session.root.display());
2817 crate::ui::kv("model", &session.model);
2818 crate::ui::kv("mode", &session.mode);
2819 crate::ui::kv("risk", config::policy_risk_label(&session.policy));
2820 if let Some(prompt) = prompt {
2821 crate::ui::kv("prompt", crate::ui::compact_preview(prompt, 100));
2822 }
2823 }
2824
2825 fn write_workspace_file(root: &Path, requested: &Path, body: &str) -> Result<()> {
2826 let path = config::resolve_workspace_output_path(root, requested)?;
2827 let mut out = body.trim_end().to_string();
2828 out.push('\n');
2829 config::write_workspace_file(&path, out.as_bytes())
2830 }
2831
2832 #[cfg(test)]
2833 mod audit_tests {
2834 use super::*;
2835
2836 #[test]
2837 fn audit_accepts_max_chunks_flag() {
2838 let cli = parse_cli_for_test(&["oy", "audit", "--max-chunks", "240", "auth paths"]);
2839 let Some(Command::Audit {
2840 max_chunks, focus, ..
2841 }) = cli.command
2842 else {
2843 panic!("expected audit command");
2844 };
2845 assert_eq!(max_chunks, 240);
2846 assert_eq!(focus, vec!["auth paths"]);
2847 }
2848
2849 #[test]
2850 fn help_documents_audit_options() {
2851 let help = command_help_for_test("audit");
2852 assert!(help.contains("--max-chunks <N>"));
2853 assert!(help.contains("--format <FORMAT>"));
2854 }
2855
2856 #[test]
2857 fn audit_accepts_sarif_format() {
2858 let cli = parse_cli_for_test(&["oy", "audit", "--format", "sarif", "auth paths"]);
2859 let Some(Command::Audit { format, out, .. }) = cli.command else {
2860 panic!("expected audit command");
2861 };
2862 assert_eq!(format, AuditFormat::Sarif);
2863 assert_eq!(out, None);
2864 }
2865
2866 #[test]
2867 fn exact_model_specs_are_endpoint_qualified_or_provider_ids() {
2868 assert!(is_exact_model_spec("copilot::gpt-4.1-mini"));
2869 assert!(is_exact_model_spec("openai/gpt-4.1-mini"));
2870 assert!(is_exact_model_spec(
2871 "bedrock::global.amazon.nova-2-lite-v1:0"
2872 ));
2873 assert!(!is_exact_model_spec("gpt"));
2874 assert!(!is_exact_model_spec("nova"));
2875 }
2876 }
2877}