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)]
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};
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, Subcommand)]
2072 enum Command {
2073 Run(RunArgs),
2075 Chat(ChatArgs),
2077 Model(ModelArgs),
2079 Doctor(DoctorArgs),
2081 Audit {
2083 #[arg(
2084 long,
2085 value_name = "PATH",
2086 default_value = "ISSUES.md",
2087 help = "Write findings to a workspace file (default: ISSUES.md)"
2088 )]
2089 out: PathBuf,
2090 #[arg(
2091 long,
2092 value_name = "N",
2093 default_value_t = audit::DEFAULT_MAX_REVIEW_CHUNKS,
2094 help = "Maximum audit chunks to review before failing closed"
2095 )]
2096 max_chunks: usize,
2097 #[arg(value_name = "FOCUS", help = "Optional audit focus text")]
2098 focus: Vec<String>,
2099 },
2100 }
2101
2102 #[derive(Debug, Args, Clone)]
2103 struct SharedModeArgs {
2104 #[arg(
2105 long,
2106 alias = "agent",
2107 default_value = "default",
2108 help = "Safety mode (default: balanced): plan, ask, edit, or auto"
2109 )]
2110 mode: String,
2111 #[arg(
2112 long = "continue-session",
2113 default_value_t = false,
2114 help = "Resume the most recent saved session"
2115 )]
2116 continue_session: bool,
2117 #[arg(
2118 long,
2119 default_value = "",
2120 value_name = "NAME_OR_NUMBER",
2121 help = "Resume a named or numbered saved session"
2122 )]
2123 resume: String,
2124 }
2125
2126 #[derive(Debug, Args, Clone)]
2127 struct RunArgs {
2128 #[command(flatten)]
2129 shared: SharedModeArgs,
2130 #[arg(
2131 long,
2132 value_name = "PATH",
2133 help = "Write the final answer to a workspace file"
2134 )]
2135 out: Option<PathBuf>,
2136 #[arg(
2137 value_name = "PROMPT",
2138 help = "Task prompt; omitted means read stdin or start chat in a TTY"
2139 )]
2140 task: Vec<String>,
2141 }
2142
2143 #[derive(Debug, Args, Clone)]
2144 struct ChatArgs {
2145 #[command(flatten)]
2146 shared: SharedModeArgs,
2147 }
2148
2149 #[derive(Debug, Args, Clone)]
2150 struct ModelArgs {
2151 #[arg(
2152 value_name = "MODEL",
2153 help = "Model id or routing shim selection, e.g. copilot::gpt-4.1-mini"
2154 )]
2155 model: Option<String>,
2156 }
2157
2158 #[derive(Debug, Args, Clone)]
2159 struct DoctorArgs {
2160 #[arg(
2161 long,
2162 alias = "agent",
2163 default_value = "default",
2164 help = "Safety mode to inspect (default: balanced): plan, ask, edit, or auto"
2165 )]
2166 mode: String,
2167 }
2168
2169 pub async fn run(argv: Vec<String>) -> Result<i32> {
2170 let normalized = normalize_args(argv);
2171 let mut cli = Cli::parse_from(std::iter::once("oy".to_string()).chain(normalized.clone()));
2172 restore_trailing_audit_options(&mut cli);
2173 crate::ui::init_output_mode(cli_output_mode(&cli));
2174 match cli.command.unwrap_or(Command::Run(RunArgs {
2175 shared: SharedModeArgs {
2176 mode: "default".to_string(),
2177 continue_session: false,
2178 resume: String::new(),
2179 },
2180 out: None,
2181 task: Vec::new(),
2182 })) {
2183 Command::Run(args) => run_command(args).await,
2184 Command::Chat(args) => chat_command(args).await,
2185 Command::Model(args) => model_command(args).await,
2186 Command::Doctor(args) => doctor_command(args).await,
2187 Command::Audit {
2188 out,
2189 max_chunks,
2190 focus,
2191 } => {
2192 audit_command(AuditArgs {
2193 out,
2194 max_chunks,
2195 focus,
2196 })
2197 .await
2198 }
2199 }
2200 }
2201
2202 fn restore_trailing_audit_options(cli: &mut Cli) {
2203 let Some(Command::Audit {
2204 out: _,
2205 max_chunks,
2206 focus,
2207 }) = &mut cli.command
2208 else {
2209 return;
2210 };
2211 let mut filtered_focus = Vec::new();
2212 let mut i = 0usize;
2213 while i < focus.len() {
2214 match focus[i].as_str() {
2215 "--max-chunks" => {
2216 if let Some(value) = focus.get(i + 1)
2217 && let Ok(parsed) = value.parse::<usize>()
2218 {
2219 *max_chunks = parsed;
2220 i += 2;
2221 continue;
2222 }
2223 }
2224 raw if raw.starts_with("--max-chunks=") => {
2225 if let Some((_, value)) = raw.split_once('=')
2226 && let Ok(parsed) = value.parse::<usize>()
2227 {
2228 *max_chunks = parsed;
2229 i += 1;
2230 continue;
2231 }
2232 }
2233 _ => {}
2234 }
2235 filtered_focus.push(focus[i].clone());
2236 i += 1;
2237 }
2238 *focus = filtered_focus;
2239 }
2240
2241 fn cli_output_mode(cli: &Cli) -> Option<crate::ui::OutputMode> {
2242 if cli.quiet {
2243 Some(crate::ui::OutputMode::Quiet)
2244 } else if cli.verbose {
2245 Some(crate::ui::OutputMode::Verbose)
2246 } else if cli.json {
2247 Some(crate::ui::OutputMode::Json)
2248 } else {
2249 None
2250 }
2251 }
2252
2253 #[cfg(test)]
2254 fn parse_cli_for_test(args: &[&str]) -> Cli {
2255 let mut cli = Cli::parse_from(args);
2256 restore_trailing_audit_options(&mut cli);
2257 cli
2258 }
2259
2260 #[cfg(test)]
2261 fn command_help_for_test(command: &str) -> String {
2262 let mut cmd = <Cli as clap::CommandFactory>::command();
2263 let Some(subcommand) = cmd.find_subcommand_mut(command) else {
2264 panic!("unknown command: {command}");
2265 };
2266 let mut help = Vec::new();
2267 subcommand.write_long_help(&mut help).expect("write help");
2268 String::from_utf8(help).expect("utf8 help")
2269 }
2270
2271 fn normalize_args(mut args: Vec<String>) -> Vec<String> {
2272 if args.is_empty() {
2273 return if config::can_prompt() {
2274 vec!["--help".to_string()]
2275 } else {
2276 vec!["run".to_string()]
2277 };
2278 }
2279 if matches!(
2280 args.first().map(String::as_str),
2281 Some("--continue") | Some("-c")
2282 ) {
2283 return std::iter::once("run".to_string())
2284 .chain(std::iter::once("--continue-session".to_string()))
2285 .chain(args.drain(1..))
2286 .collect();
2287 }
2288 if args.first().map(String::as_str) == Some("--resume") {
2289 return std::iter::once("run".to_string()).chain(args).collect();
2290 }
2291 let commands = ["run", "chat", "model", "doctor", "audit", "-h", "--help"];
2292 if args
2293 .first()
2294 .is_some_and(|arg| !arg.starts_with('-') && !commands.contains(&arg.as_str()))
2295 {
2296 let mut out = vec!["run".to_string()];
2297 out.extend(args);
2298 return out;
2299 }
2300 args
2301 }
2302
2303 async fn run_command(args: RunArgs) -> Result<i32> {
2304 let task = collect_task(&args.task)?;
2305 if task.trim().is_empty() {
2306 return chat_command(ChatArgs {
2307 shared: args.shared,
2308 })
2309 .await;
2310 }
2311 let mut session = load_or_new(
2312 false,
2313 &args.shared.mode,
2314 args.shared.continue_session,
2315 &args.shared.resume,
2316 )?;
2317 print_session_intro("run", &session, Some(&task));
2318 let answer = session::run_prompt(&mut session, &task).await?;
2319 if crate::ui::is_json() {
2320 print_run_json(&session, &answer)?;
2321 } else if let Some(path) = args.out {
2322 write_workspace_file(&session.root, &path, &answer)?;
2323 crate::ui::success(format_args!("wrote {}", path.display()));
2324 } else if !answer.is_empty() {
2325 crate::ui::markdown(&format!("{answer}\n"));
2326 }
2327 Ok(0)
2328 }
2329
2330 fn print_run_json(session: &Session, answer: &str) -> Result<()> {
2331 let status = session.context_status();
2332 let payload = serde_json::json!({
2333 "answer": answer,
2334 "model": session.model,
2335 "mode": session.mode,
2336 "workspace": session.root,
2337 "tokens": status.estimate,
2338 "context": status,
2339 "messages": status.estimate.messages,
2340 "todos": session.todos,
2341 });
2342 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2343 Ok(())
2344 }
2345
2346 async fn chat_command(args: ChatArgs) -> Result<i32> {
2347 let mut session = load_or_new(
2348 true,
2349 &args.shared.mode,
2350 args.shared.continue_session,
2351 &args.shared.resume,
2352 )?;
2353 print_session_intro("chat", &session, None);
2354 crate::chat::run_chat(&mut session).await
2355 }
2356
2357 async fn model_command(args: ModelArgs) -> Result<i32> {
2358 if let Some(model_spec) = args
2359 .model
2360 .as_deref()
2361 .filter(|value| is_exact_model_spec(value))
2362 {
2363 let normalized = model::canonical_model_spec(model_spec);
2364 config::save_model_config(&normalized)?;
2365 if crate::ui::is_json() {
2366 print_saved_model_json(&normalized)?;
2367 } else {
2368 print_saved_model(&normalized);
2369 }
2370 return Ok(0);
2371 }
2372
2373 let listing = model::inspect_models().await?;
2374 if let Some(model_spec) = args.model {
2375 let normalized = resolve_model_choice(&listing, &model_spec)?;
2376 config::save_model_config(&normalized)?;
2377 if crate::ui::is_json() {
2378 print_model_json(&listing, Some(&normalized))?;
2379 } else {
2380 print_saved_model(&normalized);
2381 }
2382 return Ok(0);
2383 }
2384 if crate::ui::is_json() {
2385 print_model_json(&listing, None)?;
2386 return Ok(0);
2387 }
2388 print_model_listing(&listing);
2389 if config::can_prompt()
2390 && !listing.all_models.is_empty()
2391 && let Some(chosen) = crate::chat::choose_model_with_initial_list(
2392 listing.current.as_deref(),
2393 &listing.all_models,
2394 false,
2395 )?
2396 {
2397 config::save_model_config(&chosen)?;
2398 print_saved_model(&chosen);
2399 }
2400 Ok(0)
2401 }
2402
2403 fn is_exact_model_spec(value: &str) -> bool {
2404 let value = value.trim();
2405 value.contains("::") || value.contains('/') || value.contains(':') || value.contains('.')
2406 }
2407
2408 fn print_saved_model_json(saved: &str) -> Result<()> {
2409 let payload = serde_json::json!({ "saved": saved });
2410 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2411 Ok(())
2412 }
2413
2414 fn print_model_json(listing: &model::ModelListing, saved: Option<&str>) -> Result<()> {
2415 let payload = serde_json::json!({
2416 "current": listing.current,
2417 "current_shim": listing.current_shim,
2418 "saved": saved,
2419 "auth": listing.auth,
2420 "recommended": listing.recommended,
2421 "dynamic": listing.dynamic,
2422 "hints": listing.hints,
2423 "all_models": listing.all_models,
2424 });
2425 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2426 Ok(())
2427 }
2428
2429 fn print_model_listing(listing: &model::ModelListing) {
2430 crate::ui::section("Models");
2431 crate::ui::kv(
2432 "current",
2433 current_model_text(
2434 listing.current.as_deref().unwrap_or("<unset>"),
2435 listing.current_shim.as_deref(),
2436 ),
2437 );
2438 crate::ui::kv("selectable", listing.all_models.len());
2439 if !listing.recommended.is_empty() {
2440 crate::ui::kv("recommended", listing.recommended.join(", "));
2441 if listing.current.is_none() {
2442 crate::ui::line(format_args!(" Try: oy model {}", listing.recommended[0]));
2443 }
2444 }
2445
2446 if !listing.auth.is_empty() {
2447 crate::ui::line("");
2448 crate::ui::section("Auth / shims");
2449 for item in &listing.auth {
2450 let env_var = item.env_var.as_deref().unwrap_or("-");
2451 let active = if listing.current_shim.as_deref() == Some(item.adapter.as_str()) {
2452 " *"
2453 } else {
2454 ""
2455 };
2456 crate::ui::line(format_args!(
2457 " {}{} {} ({})",
2458 item.adapter, active, env_var, item.source
2459 ));
2460 crate::ui::line(format_args!(" {}", item.detail));
2461 }
2462 }
2463
2464 crate::ui::line("");
2465 crate::ui::section("Introspected endpoint models");
2466 if listing.dynamic.is_empty() {
2467 crate::ui::line(" none found from configured OpenAI-compatible endpoints");
2468 } else {
2469 for item in &listing.dynamic {
2470 if !item.ok {
2471 crate::ui::line(format_args!(
2472 " {} failed via {}",
2473 item.adapter, item.source
2474 ));
2475 if let Some(error) = item.error.as_deref() {
2476 crate::ui::line(format_args!(
2477 " {}",
2478 crate::ui::truncate_chars(error, 140)
2479 ));
2480 }
2481 continue;
2482 }
2483 crate::ui::line(format_args!(
2484 " {} {} models via {}",
2485 item.adapter, item.count, item.source
2486 ));
2487 for model_name in item.models.iter().take(MODEL_LIST_LIMIT) {
2488 let marker = if listing.current.as_deref() == Some(model_name.as_str()) {
2489 "*"
2490 } else {
2491 " "
2492 };
2493 crate::ui::line(format_args!(" {marker} {model_name}"));
2494 }
2495 if item.models.len() > MODEL_LIST_LIMIT {
2496 crate::ui::line(format_args!(
2497 " … {} more; use `oy model <filter>` or interactive selection",
2498 item.models.len() - MODEL_LIST_LIMIT
2499 ));
2500 }
2501 }
2502 }
2503
2504 let hinted = listing
2505 .hints
2506 .iter()
2507 .filter(|hint| {
2508 !listing
2509 .dynamic
2510 .iter()
2511 .any(|group| group.models.iter().any(|model| model == *hint))
2512 })
2513 .collect::<Vec<_>>();
2514 if !hinted.is_empty() {
2515 crate::ui::line("");
2516 crate::ui::section("Built-in selectable hints");
2517 for hint in hinted.iter().take(MODEL_LIST_LIMIT) {
2518 crate::ui::line(format_args!(" {hint}"));
2519 }
2520 if hinted.len() > MODEL_LIST_LIMIT {
2521 crate::ui::line(format_args!(
2522 " … {} more hints",
2523 hinted.len() - MODEL_LIST_LIMIT
2524 ));
2525 }
2526 }
2527 }
2528
2529 fn current_model_text(model_spec: &str, shim: Option<&str>) -> String {
2530 match shim.filter(|value| !value.is_empty()) {
2531 Some(shim) => format!("{model_spec} (shim: {shim})"),
2532 None => model_spec.to_string(),
2533 }
2534 }
2535
2536 fn print_saved_model(selection: &str) {
2537 let saved = config::saved_model_config_from_selection(selection);
2538 crate::ui::success(format_args!(
2539 "saved model {}",
2540 saved.model.as_deref().unwrap_or(selection)
2541 ));
2542 if let Some(shim) = saved.shim {
2543 crate::ui::kv("shim", shim);
2544 }
2545 }
2546
2547 fn resolve_model_choice(listing: &model::ModelListing, query: &str) -> Result<String> {
2548 let normalized = model::canonical_model_spec(query);
2549 if listing.all_models.iter().any(|item| item == &normalized) {
2550 return Ok(normalized);
2551 }
2552 if !config::can_prompt() {
2553 bail!(
2554 "No exact model match for `{}`. Re-run in a TTY to choose interactively.",
2555 query
2556 );
2557 }
2558 let matches = listing
2559 .all_models
2560 .iter()
2561 .filter(|item| {
2562 item.to_ascii_lowercase()
2563 .contains(&query.to_ascii_lowercase())
2564 })
2565 .cloned()
2566 .collect::<Vec<_>>();
2567 if matches.is_empty() {
2568 bail!("No matching model for `{}`", query);
2569 }
2570 crate::chat::choose_model(listing.current.as_deref(), &matches)
2571 .map(|value| value.unwrap_or(normalized))
2572 }
2573
2574 async fn doctor_command(args: DoctorArgs) -> Result<i32> {
2575 let root = config::oy_root()?;
2576 let listing = model::inspect_models().await?;
2577 let mode = config::safety_mode(&args.mode)?;
2578 let policy = config::tool_policy(mode.name());
2579 let config_file = config::config_root();
2580 let config_dir = config::config_dir_path();
2581 let sessions_dir = config::sessions_dir().unwrap_or_else(|_| config_dir.join("sessions"));
2582 let history_dir = config_dir.join("history");
2583 let bash_ok = std::process::Command::new("bash")
2584 .arg("--version")
2585 .stdout(std::process::Stdio::null())
2586 .stderr(std::process::Stdio::null())
2587 .status()
2588 .map(|status| status.success())
2589 .unwrap_or(false);
2590
2591 if crate::ui::is_json() {
2592 let payload = serde_json::json!({
2593 "workspace": root,
2594 "model": listing.current,
2595 "shim": listing.current_shim,
2596 "auth": listing.auth,
2597 "mode": mode.name(),
2598 "policy": policy,
2599 "interactive": config::can_prompt(),
2600 "non_interactive": config::non_interactive(),
2601 "config_file": config_file,
2602 "config_dir": config_dir,
2603 "sessions_dir": sessions_dir,
2604 "history_dir": history_dir,
2605 "bash": bash_ok,
2606 "recommended": listing.recommended,
2607 "next_step": recommended_next_step(&listing),
2608 });
2609 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2610 return Ok(0);
2611 }
2612
2613 crate::ui::section("Doctor");
2614 crate::ui::kv("workspace", root.display());
2615 crate::ui::kv("model", listing.current.as_deref().unwrap_or("<unset>"));
2616 crate::ui::kv("shim", listing.current_shim.as_deref().unwrap_or("<none>"));
2617 crate::ui::kv("mode", mode.name());
2618 crate::ui::kv("files-write", format_args!("{:?}", policy.files_write));
2619 crate::ui::kv("shell", format_args!("{:?}", policy.shell));
2620 crate::ui::kv("network", crate::ui::bool_text(policy.network));
2621 crate::ui::kv("risk", config::policy_risk_label(&policy));
2622 crate::ui::kv("interactive", crate::ui::bool_text(config::can_prompt()));
2623 crate::ui::kv(
2624 "bash",
2625 crate::ui::status_text(bash_ok, if bash_ok { "ok" } else { "missing" }),
2626 );
2627 crate::ui::line("");
2628 crate::ui::section("Local state");
2629 crate::ui::kv("config", config_file.display());
2630 crate::ui::kv("sessions", sessions_dir.display());
2631 crate::ui::kv("history", history_dir.display());
2632 crate::ui::line(
2633 " Treat local state as sensitive: prompts, source snippets, tool output, and command output may be saved.",
2634 );
2635 crate::ui::line("");
2636 crate::ui::section("Auth / shims");
2637 if listing.auth.is_empty() {
2638 crate::ui::warn("no provider auth detected");
2639 } else {
2640 for item in &listing.auth {
2641 crate::ui::line(format_args!(
2642 " {} {} ({})",
2643 item.adapter,
2644 item.env_var.as_deref().unwrap_or("-"),
2645 item.source
2646 ));
2647 crate::ui::line(format_args!(" {}", item.detail));
2648 }
2649 }
2650 if listing.current.is_none() {
2651 crate::ui::line("");
2652 crate::ui::warn("no model configured");
2653 crate::ui::line(format_args!(" {}", recommended_next_step(&listing)));
2654 }
2655 crate::ui::line("");
2656 crate::ui::section("Recommended next steps");
2657 crate::ui::line(format_args!(" 1. {}", recommended_next_step(&listing)));
2658 crate::ui::line(" 2. For untrusted repos: `oy chat --mode plan`");
2659 crate::ui::line(format_args!(
2660 " • Read-only container: {}",
2661 safe_container_command(&root, true)
2662 ));
2663 crate::ui::line("");
2664 crate::ui::section("Safety");
2665 crate::ui::line(
2666 " oy is not a sandbox. Use `oy chat --mode plan` or a disposable container/VM for untrusted repos.",
2667 );
2668 crate::ui::line(
2669 " Mount only needed credentials/env vars. Do not mount the host Docker socket into AI-assisted containers.",
2670 );
2671 Ok(0)
2672 }
2673
2674 fn recommended_next_step(listing: &model::ModelListing) -> String {
2675 if listing.current.is_some() {
2676 return "Run `oy \"inspect this repo\"` or `oy chat`.".to_string();
2677 }
2678 if let Some(choice) = listing.recommended.first() {
2679 return format!("Configure a model: `oy model {choice}`.");
2680 }
2681 "Configure provider auth, then run `oy model`; see `oy doctor` output.".to_string()
2682 }
2683
2684 fn safe_container_command(root: &Path, read_only: bool) -> String {
2685 let mode = if read_only { "ro" } else { "rw" };
2686 format!(
2687 "docker run --rm -it -v \"{}:/workspace:{mode}\" -w /workspace oy-image oy chat --mode plan",
2688 root.display()
2689 )
2690 }
2691
2692 #[derive(Debug, Clone)]
2693 struct AuditArgs {
2694 focus: Vec<String>,
2695 out: PathBuf,
2696 max_chunks: usize,
2697 }
2698
2699 async fn audit_command(args: AuditArgs) -> Result<i32> {
2700 let started = std::time::Instant::now();
2701 let focus = args.focus.join(" ");
2702 let root = config::oy_root()?;
2703 let model = model::resolve_model(None)?;
2704 if !crate::ui::is_quiet() {
2705 crate::ui::section("audit");
2706 crate::ui::kv("workspace", root.display());
2707 crate::ui::kv("model", &model);
2708 crate::ui::kv("mode", "no-tools");
2709 crate::ui::kv("out", args.out.display());
2710 crate::ui::kv("max chunks", args.max_chunks);
2711 if !focus.trim().is_empty() {
2712 crate::ui::kv("focus", crate::ui::compact_preview(&focus, 100));
2713 }
2714 }
2715 let result = audit::run(audit::AuditOptions {
2716 root,
2717 model,
2718 focus,
2719 out: args.out,
2720 max_chunks: args.max_chunks,
2721 })
2722 .await?;
2723 if crate::ui::is_json() {
2724 let payload = serde_json::json!({
2725 "output": result.output_path,
2726 "files": result.file_count,
2727 "chunks": result.chunk_count,
2728 "elapsed_ms": started.elapsed().as_millis(),
2729 });
2730 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2731 } else {
2732 crate::ui::success(format_args!(
2733 "wrote {} ({} files, {} chunks, {})",
2734 result.output_path.display(),
2735 result.file_count,
2736 result.chunk_count,
2737 crate::ui::format_duration(started.elapsed())
2738 ));
2739 }
2740 Ok(0)
2741 }
2742
2743 fn load_or_new(
2744 interactive: bool,
2745 mode_name: &str,
2746 continue_session: bool,
2747 resume: &str,
2748 ) -> Result<Session> {
2749 let mode = config::safety_mode(mode_name)?;
2750 let policy = config::tool_policy(mode.name());
2751 if continue_session || !resume.is_empty() {
2752 let name = if continue_session { None } else { Some(resume) };
2753 if let Some(session) =
2754 session::load_saved(name, interactive, mode.name().to_string(), policy)?
2755 {
2756 return Ok(session);
2757 }
2758 }
2759 let root = config::oy_root()?;
2760 let model = model::resolve_model(None)?;
2761 Ok(Session::new(
2762 root,
2763 model,
2764 interactive,
2765 mode.name().to_string(),
2766 policy,
2767 ))
2768 }
2769
2770 fn collect_task(parts: &[String]) -> Result<String> {
2771 if !parts.is_empty() {
2772 return Ok(parts.join(" "));
2773 }
2774 if std::io::stdin().is_terminal() {
2775 return Ok(String::new());
2776 }
2777 let mut input = String::new();
2778 use std::io::Read as _;
2779 std::io::stdin().read_to_string(&mut input)?;
2780 Ok(input.trim().to_string())
2781 }
2782
2783 fn print_session_intro(mode: &str, session: &Session, prompt: Option<&str>) {
2784 if crate::ui::is_quiet() {
2785 return;
2786 }
2787 crate::ui::section(mode);
2788 crate::ui::kv("workspace", session.root.display());
2789 crate::ui::kv("model", &session.model);
2790 crate::ui::kv("mode", &session.mode);
2791 crate::ui::kv("risk", config::policy_risk_label(&session.policy));
2792 if let Some(prompt) = prompt {
2793 crate::ui::kv("prompt", crate::ui::compact_preview(prompt, 100));
2794 }
2795 }
2796
2797 fn write_workspace_file(root: &Path, requested: &Path, body: &str) -> Result<()> {
2798 let path = config::resolve_workspace_output_path(root, requested)?;
2799 let mut out = body.trim_end().to_string();
2800 out.push('\n');
2801 config::write_workspace_file(&path, out.as_bytes())
2802 }
2803
2804 #[cfg(test)]
2805 mod audit_tests {
2806 use super::*;
2807
2808 #[test]
2809 fn audit_accepts_max_chunks_flag() {
2810 let cli = parse_cli_for_test(&["oy", "audit", "--max-chunks", "240", "auth paths"]);
2811 let Some(Command::Audit {
2812 max_chunks, focus, ..
2813 }) = cli.command
2814 else {
2815 panic!("expected audit command");
2816 };
2817 assert_eq!(max_chunks, 240);
2818 assert_eq!(focus, vec!["auth paths"]);
2819 }
2820
2821 #[test]
2822 fn help_documents_audit_max_chunks() {
2823 let help = command_help_for_test("audit");
2824 assert!(help.contains("--max-chunks <N>"));
2825 }
2826
2827 #[test]
2828 fn exact_model_specs_are_endpoint_qualified_or_provider_ids() {
2829 assert!(is_exact_model_spec("copilot::gpt-4.1-mini"));
2830 assert!(is_exact_model_spec("openai/gpt-4.1-mini"));
2831 assert!(is_exact_model_spec(
2832 "bedrock::global.amazon.nova-2-lite-v1:0"
2833 ));
2834 assert!(!is_exact_model_spec("gpt"));
2835 assert!(!is_exact_model_spec("nova"));
2836 }
2837 }
2838}