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::{Context as _, Result};
1566 use dialoguer::{Confirm, 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 const MAX_CONTEXT_RECOVERY_ATTEMPTS: usize = 3;
1581
1582 fn chat_line_editor(history_path: PathBuf) -> Result<Reedline> {
1583 let mut keybindings = default_emacs_keybindings();
1584 keybindings.add_binding(KeyModifiers::NONE, KeyCode::Enter, ReedlineEvent::Submit);
1585 let insert_newline = ReedlineEvent::Edit(vec![EditCommand::InsertNewline]);
1586 keybindings.add_binding(KeyModifiers::SHIFT, KeyCode::Enter, insert_newline.clone());
1587 keybindings.add_binding(KeyModifiers::ALT, KeyCode::Enter, insert_newline);
1588
1589 Ok(Reedline::create()
1590 .with_history(Box::new(FileBackedHistory::with_file(
1591 HISTORY_SIZE,
1592 history_path,
1593 )?))
1594 .with_edit_mode(Box::new(Emacs::new(keybindings)))
1595 .use_bracketed_paste(true))
1596 }
1597
1598 pub async fn run_chat(session: &mut Session) -> Result<i32> {
1599 crate::ui::section("oy chat");
1600 crate::ui::kv("keys", "Enter sends · Alt/Shift+Enter newline · /? help");
1601 let history_path = history_path("chat")?;
1602 let mut line_editor = chat_line_editor(history_path.clone())?;
1603 let prompt = DefaultPrompt::new(
1604 DefaultPromptSegment::Basic("oy".to_string()),
1605 DefaultPromptSegment::Empty,
1606 );
1607
1608 loop {
1609 let signal = match line_editor.read_line(&prompt) {
1610 Ok(signal) => signal,
1611 Err(err) if is_cursor_position_timeout(&err) => {
1612 crate::ui::warn("terminal cursor position timed out; resetting prompt");
1613 line_editor = chat_line_editor(history_path.clone())?;
1614 continue;
1615 }
1616 Err(err) => return Err(err.into()),
1617 };
1618
1619 match signal {
1620 Signal::Success(line) => {
1621 line_editor.sync_history()?;
1622 if !handle_chat_line(session, line.trim()).await? {
1623 break;
1624 }
1625 }
1626 Signal::CtrlD => break,
1627 Signal::CtrlC => {
1628 line_editor.sync_history()?;
1629 break;
1630 }
1631 }
1632 }
1633 prompt_update_todo_on_quit(session);
1634 Ok(0)
1635 }
1636
1637 fn is_cursor_position_timeout(err: &impl Display) -> bool {
1638 let text = err.to_string();
1639 text.contains("cursor position") && text.contains("could not be read")
1640 }
1641
1642 fn prompt_update_todo_on_quit(session: &Session) {
1643 if crate::config::can_prompt() && !session.todos.is_empty() {
1644 let active = session
1645 .todos
1646 .iter()
1647 .filter(|item| item.status != "done")
1648 .count();
1649 crate::ui::line(format_args!(
1650 "todo summary: {active}/{} active in memory; use the todo tool with persist=true to write TODO.md",
1651 session.todos.len()
1652 ));
1653 }
1654 }
1655
1656 async fn handle_chat_line(session: &mut Session, line: &str) -> Result<bool> {
1657 if line.is_empty() {
1658 return Ok(true);
1659 }
1660 if let Some(command) = line.strip_prefix('/') {
1661 return handle_slash_command(session, command.trim()).await;
1662 }
1663 run_prompt_with_context_recovery(session, line).await?;
1664 Ok(true)
1665 }
1666
1667 async fn handle_slash_command(session: &mut Session, command: &str) -> Result<bool> {
1668 let mut parts = command.split_whitespace();
1669 let raw_name = parts.next().unwrap_or_default();
1670 let name = normalize_chat_command(raw_name);
1671 match name {
1672 "" => Ok(true),
1673 "help" => {
1674 crate::ui::markdown(&format!("{}\n", chat_help_text()));
1675 Ok(true)
1676 }
1677 "tokens" => tokens_command(session),
1678 "compact" => compact_command(parts.next(), session).await,
1679 "model" => model_command(parts.next(), session).await,
1680 "thinking" => thinking_command(parts.next()),
1681 "debug" | "status" => status_command(session),
1682 "ask" => {
1683 let prompt = parts.collect::<Vec<_>>().join(" ");
1684 ask_command(session, &prompt).await
1685 }
1686 "save" => save_command(parts.next(), session),
1687 "load" => load_command(parts.next(), session),
1688 "undo" => undo_command(session),
1689 "clear" => clear_command(session),
1690 "quit" | "exit" => Ok(false),
1691 other => {
1692 crate::ui::warn(format_args!("unknown command /{other}"));
1693 Ok(true)
1694 }
1695 }
1696 }
1697
1698 fn normalize_chat_command(command: &str) -> &str {
1699 match command {
1700 "h" | "?" => "help",
1701 "t" => "tokens",
1702 "k" => "compact",
1703 "m" => "model",
1704 "d" => "debug",
1705 "s" => "status",
1706 "u" => "undo",
1707 "c" => "clear",
1708 "q" => "quit",
1709 other => other,
1710 }
1711 }
1712
1713 pub(crate) fn chat_help_text() -> String {
1714 [
1715 "Enter sends; Alt/Shift+Enter inserts newline",
1716 "/help (/h, /?) -- show help",
1717 "/status (/s), /debug (/d) -- show model, mode, context, and todos",
1718 "/model [value] (/m) -- show or switch model",
1719 "/ask <question> -- research-only query",
1720 "/save [name], /load [name] -- save or load a session",
1721 "/undo (/u), /clear (/c) -- repair conversation state",
1722 "/quit (/q), /exit -- end session",
1723 "Advanced: /tokens, /compact [llm|deterministic], /thinking [auto|off|low|medium|high]",
1724 ]
1725 .join("\n")
1726 }
1727
1728 async fn ask_command(session: &mut Session, prompt: &str) -> Result<bool> {
1729 if prompt.is_empty() {
1730 anyhow::bail!("Usage: /ask <question>");
1731 }
1732 let answer =
1733 session::run_prompt_read_only(session, &config::ask_system_prompt(prompt)).await?;
1734 if !answer.is_empty() {
1735 crate::ui::markdown(&format!("{answer}\n"));
1736 }
1737 Ok(true)
1738 }
1739
1740 fn tokens_command(session: &Session) -> Result<bool> {
1741 let status = session.context_status();
1742 crate::ui::section("Context");
1743 crate::ui::kv("messages", status.estimate.messages);
1744 crate::ui::kv(
1745 "system",
1746 format_args!("~{} tokens", status.estimate.system_tokens),
1747 );
1748 crate::ui::kv(
1749 "messages",
1750 format_args!("~{} tokens", status.estimate.message_tokens),
1751 );
1752 crate::ui::kv(
1753 "total",
1754 format_args!("~{} tokens", status.estimate.total_tokens),
1755 );
1756 crate::ui::kv("limit", format_args!("{} tokens", status.limit_tokens));
1757 crate::ui::kv(
1758 "input budget",
1759 format_args!("{} tokens", status.input_budget_tokens),
1760 );
1761 crate::ui::kv("trigger", format_args!("{} tokens", status.trigger_tokens));
1762 crate::ui::kv("summary", crate::ui::bool_text(status.summary_present));
1763 Ok(true)
1764 }
1765
1766 async fn compact_command(mode: Option<&str>, session: &mut Session) -> Result<bool> {
1767 let before = session.context_status().estimate.total_tokens;
1768 let stats = match mode.unwrap_or("llm") {
1769 "" | "llm" | "smart" => session.compact_llm().await?,
1770 "deterministic" | "det" | "fast" => session.compact_deterministic(),
1771 other => anyhow::bail!("compact mode must be llm or deterministic; got {other}"),
1772 };
1773 let after = session.context_status().estimate.total_tokens;
1774 crate::ui::section("Compaction");
1775 if let Some(stats) = stats {
1776 crate::ui::kv(
1777 "tokens",
1778 format_args!("{} -> {}", stats.before_tokens, stats.after_tokens),
1779 );
1780 crate::ui::kv("removed messages", stats.removed_messages);
1781 crate::ui::kv("tool outputs", stats.compacted_tools);
1782 crate::ui::kv("summarized", stats.summarized);
1783 } else {
1784 crate::ui::kv("tokens", format_args!("{before} -> {after}"));
1785 crate::ui::line("nothing to compact");
1786 }
1787 Ok(true)
1788 }
1789
1790 async fn model_command(value: Option<&str>, session: &mut Session) -> Result<bool> {
1791 if let Some(value) = value {
1792 config::save_model_config(value)?;
1793 session.model = model::resolve_model(Some(value))?;
1794 }
1795 crate::ui::line(format_args!("model: {}", session.model));
1796 Ok(true)
1797 }
1798
1799 fn thinking_command(value: Option<&str>) -> Result<bool> {
1800 if let Some(value) = value {
1801 match value {
1802 "" | "auto" => unsafe { std::env::remove_var("OY_THINKING") },
1803 "off" | "none" => unsafe { std::env::set_var("OY_THINKING", "none") },
1804 "minimal" | "low" | "medium" | "high" => unsafe {
1805 std::env::set_var("OY_THINKING", value)
1806 },
1807 other => anyhow::bail!(
1808 "thinking must be auto, off, minimal, low, medium, or high; got {other}"
1809 ),
1810 }
1811 }
1812 crate::ui::line(format_args!(
1813 "thinking: {}",
1814 std::env::var("OY_THINKING").unwrap_or_else(|_| "auto".to_string())
1815 ));
1816 Ok(true)
1817 }
1818
1819 fn status_command(session: &Session) -> Result<bool> {
1820 crate::ui::section("Status");
1821 crate::ui::kv("workspace", session.root.display());
1822 crate::ui::kv("model", &session.model);
1823 crate::ui::kv("genai", model::to_genai_model_spec(&session.model));
1824 crate::ui::kv(
1825 "thinking",
1826 model::default_reasoning_effort(&session.model).unwrap_or("auto/off"),
1827 );
1828 crate::ui::kv("mode", &session.mode);
1829 crate::ui::kv("interactive", crate::ui::bool_text(session.interactive));
1830 crate::ui::kv(
1831 "files-write",
1832 format_args!("{:?}", session.policy.files_write),
1833 );
1834 crate::ui::kv("shell", format_args!("{:?}", session.policy.shell));
1835 crate::ui::kv("network", crate::ui::bool_text(session.policy.network));
1836 crate::ui::kv("risk", config::policy_risk_label(&session.policy));
1837 crate::ui::kv("messages", session.transcript.messages.len());
1838 crate::ui::kv("todos", session.todos.len());
1839 let status = session.context_status();
1840 crate::ui::kv(
1841 "context",
1842 format_args!(
1843 "~{} / {} tokens",
1844 status.estimate.total_tokens, status.input_budget_tokens
1845 ),
1846 );
1847 crate::ui::kv("summary", crate::ui::bool_text(status.summary_present));
1848 Ok(true)
1849 }
1850
1851 fn save_command(name: Option<&str>, session: &mut Session) -> Result<bool> {
1852 let path = session.save(name)?;
1853 crate::ui::success(format_args!("saved session {}", path.display()));
1854 Ok(true)
1855 }
1856
1857 fn load_command(name: Option<&str>, session: &mut Session) -> Result<bool> {
1858 if let Some(new_session) =
1859 session::load_saved(name, true, session.mode.clone(), session.policy)?
1860 {
1861 *session = new_session;
1862 crate::ui::success("loaded session");
1863 } else {
1864 crate::ui::warn("no saved sessions found");
1865 }
1866 Ok(true)
1867 }
1868
1869 fn undo_command(session: &mut Session) -> Result<bool> {
1870 if session.transcript.undo_last_turn() {
1871 crate::ui::success("undid last turn");
1872 } else {
1873 crate::ui::warn("nothing to undo");
1874 }
1875 Ok(true)
1876 }
1877
1878 fn clear_command(session: &mut Session) -> Result<bool> {
1879 session.transcript.messages.clear();
1880 crate::ui::success("conversation cleared");
1881 Ok(true)
1882 }
1883
1884 async fn run_prompt_with_context_recovery(session: &mut Session, prompt: &str) -> Result<()> {
1885 let mut recovery_attempts = 0usize;
1886 loop {
1887 match session::run_prompt(session, prompt).await {
1888 Ok(answer) => {
1889 if !answer.is_empty() {
1890 crate::ui::markdown(&format!("{answer}\n"));
1891 }
1892 return Ok(());
1893 }
1894 Err(err) => {
1895 let Some(budget_err) = err
1896 .downcast_ref::<session::ContextBudgetExceeded>()
1897 .copied()
1898 else {
1899 return Err(err);
1900 };
1901 recovery_attempts += 1;
1902 crate::ui::err_line(format_args!("model call failed: {err:#}"));
1903 session.transcript.undo_last_turn();
1904 if recovery_attempts >= MAX_CONTEXT_RECOVERY_ATTEMPTS {
1905 offer_save_after_context_failures(session)?;
1906 return Ok(());
1907 }
1908 if !recover_context_budget(session, recovery_attempts, budget_err)? {
1909 return Ok(());
1910 }
1911 }
1912 }
1913 }
1914 }
1915
1916 fn recover_context_budget(
1917 session: &mut Session,
1918 attempt: usize,
1919 budget_err: session::ContextBudgetExceeded,
1920 ) -> Result<bool> {
1921 if config::can_prompt() {
1922 let raised_limit =
1923 config::context_config().input_budget_tokens() >= budget_err.estimated_tokens;
1924 let choices = vec![
1925 format!(
1926 "Retry with current OY_CONTEXT_LIMIT={}{}",
1927 config::context_config().limit_tokens,
1928 if raised_limit {
1929 " (now sufficient)"
1930 } else {
1931 ""
1932 }
1933 ),
1934 "Force-truncate oldest history and retry".to_string(),
1935 "Save session and stop".to_string(),
1936 "Stop without saving".to_string(),
1937 ];
1938 let choice = ask("Context is over budget. Choose recovery", Some(&choices))?;
1939 if choice.starts_with("Retry with current OY_CONTEXT_LIMIT=") {
1940 return Ok(true);
1941 }
1942 match choice.as_str() {
1943 "Force-truncate oldest history and retry" => {}
1944 "Save session and stop" => {
1945 let path = session.save(None)?;
1946 crate::ui::success(format_args!("saved session {}", path.display()));
1947 crate::ui::line(
1948 "Try `/load` later, or switch models with `/model` after reloading.",
1949 );
1950 return Ok(false);
1951 }
1952 _ => return Ok(false),
1953 }
1954 }
1955
1956 let before = session.context_status().estimate.total_tokens;
1957 let removed = session.transcript.force_truncate_oldest_turns();
1958 let after = session.context_status().estimate.total_tokens;
1959 if removed == 0 || after >= before {
1960 if attempt + 1 >= MAX_CONTEXT_RECOVERY_ATTEMPTS {
1961 offer_save_after_context_failures(session)?;
1962 return Ok(false);
1963 }
1964 anyhow::bail!(
1965 "context remains over budget and no more history can be truncated; save the session and try a different model later"
1966 );
1967 }
1968 crate::ui::warn(format_args!(
1969 "force-truncated {removed} old messages: {before} -> {after} tokens"
1970 ));
1971 Ok(true)
1972 }
1973
1974 fn offer_save_after_context_failures(session: &Session) -> Result<()> {
1975 crate::ui::warn(format_args!(
1976 "context is still over budget after {MAX_CONTEXT_RECOVERY_ATTEMPTS} recovery attempts"
1977 ));
1978 if config::can_prompt()
1979 && Confirm::with_theme(&ColorfulTheme::default())
1980 .with_prompt("Save this session so you can resume later?")
1981 .default(true)
1982 .interact()?
1983 {
1984 let path = session
1985 .save(None)
1986 .context("failed to save over-budget session")?;
1987 crate::ui::success(format_args!("saved session {}", path.display()));
1988 }
1989 crate::ui::line(
1990 "Try `/load` later, then raise OY_CONTEXT_LIMIT, use `/compact`, or switch models with `/model`.",
1991 );
1992 Ok(())
1993 }
1994
1995 pub fn choose_model(current: Option<&str>, items: &[String]) -> Result<Option<String>> {
1996 choose_model_with_initial_list(current, items, true)
1997 }
1998
1999 pub fn choose_model_with_initial_list(
2000 current: Option<&str>,
2001 items: &[String],
2002 _print_initial_list: bool,
2003 ) -> Result<Option<String>> {
2004 if items.is_empty() || !config::can_prompt() {
2005 return Ok(None);
2006 }
2007 let theme = ColorfulTheme::default();
2008 let default = current.and_then(|value| items.iter().position(|item| item == value));
2009 let mut prompt = Select::with_theme(&theme)
2010 .with_prompt("Models")
2011 .items(items)
2012 .default(default.unwrap_or(0));
2013 if current.is_some() {
2014 prompt = prompt.with_prompt("Models (Esc keeps current)");
2015 }
2016 Ok(prompt.interact_opt()?.map(|index| items[index].clone()))
2017 }
2018
2019 pub fn ask(question: &str, choices: Option<&[String]>) -> Result<String> {
2020 if let Some(choices) = choices {
2021 if choices.is_empty() {
2022 return Ok(String::new());
2023 }
2024 let index = Select::with_theme(&ColorfulTheme::default())
2025 .with_prompt(question)
2026 .items(choices)
2027 .default(0)
2028 .interact_opt()?;
2029 return Ok(index
2030 .map(|index| choices[index].clone())
2031 .unwrap_or_default());
2032 }
2033 Ok(Input::<String>::with_theme(&ColorfulTheme::default())
2034 .with_prompt(question)
2035 .interact_text()?)
2036 }
2037
2038 fn history_path(name: &str) -> Result<PathBuf> {
2039 history_path_in(config::config_dir_path(), name)
2040 }
2041
2042 fn history_path_in(config_dir: PathBuf, name: &str) -> Result<PathBuf> {
2043 let history = config_dir.join("history");
2044 config::create_private_dir_all(&history)?;
2045 let path = history.join(format!("{name}.txt"));
2046 if !path.exists() {
2047 config::write_private_file(&path, b"")?;
2048 }
2049 Ok(path)
2050 }
2051
2052 #[cfg(test)]
2053 mod tests {
2054 use super::*;
2055
2056 #[test]
2057 fn history_path_uses_named_private_history_file() {
2058 let dir = tempfile::tempdir().unwrap();
2059 let path = history_path_in(dir.path().to_path_buf(), "chat").unwrap();
2060 assert!(path.ends_with("history/chat.txt"));
2061 assert!(path.exists());
2062
2063 #[cfg(unix)]
2064 {
2065 use std::os::unix::fs::PermissionsExt as _;
2066 let history_dir_mode = std::fs::metadata(path.parent().unwrap())
2067 .unwrap()
2068 .permissions()
2069 .mode()
2070 & 0o777;
2071 let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2072 assert_eq!(history_dir_mode, 0o700);
2073 assert_eq!(file_mode, 0o600);
2074 }
2075 }
2076
2077 #[test]
2078 fn normalize_chat_command_maps_slash_aliases() {
2079 assert_eq!(normalize_chat_command("q"), "quit");
2080 assert_eq!(normalize_chat_command("tokens"), "tokens");
2081 assert_eq!(normalize_chat_command("k"), "compact");
2082 assert_eq!(normalize_chat_command("s"), "status");
2083 }
2084
2085 #[test]
2086 fn chat_help_uses_slash_commands() {
2087 let help = chat_help_text();
2088 assert!(help.contains("/help"));
2089 assert!(help.contains("/quit"));
2090 assert!(help.contains("/compact"));
2091 assert!(help.contains("/status"));
2092 }
2093 }
2094}
2095
2096pub(crate) mod app {
2098 use anyhow::{Result, bail};
2099 use clap::{Args, Parser, Subcommand, ValueEnum};
2100 use std::io::IsTerminal as _;
2101 use std::path::{Path, PathBuf};
2102
2103 use crate::audit;
2104 use crate::config;
2105 use crate::model;
2106 use crate::session::{self, Session};
2107
2108 const MODEL_LIST_LIMIT: usize = 30;
2109
2110 #[derive(Debug, Parser)]
2111 #[command(
2112 name = "oy",
2113 version,
2114 about = "Small local AI coding assistant for your shell.",
2115 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."
2116 )]
2117 struct Cli {
2118 #[arg(long, global = true, conflicts_with_all = ["verbose", "json"], help = "Suppress normal progress output")]
2119 quiet: bool,
2120 #[arg(long, global = true, conflicts_with_all = ["quiet", "json"], help = "Show fuller tool previews")]
2121 verbose: bool,
2122 #[arg(long, global = true, conflicts_with_all = ["quiet", "verbose"], help = "Print machine-readable JSON where supported")]
2123 json: bool,
2124 #[command(subcommand)]
2125 command: Option<Command>,
2126 }
2127
2128 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
2129 enum AuditFormat {
2130 Markdown,
2131 Sarif,
2132 }
2133
2134 impl From<AuditFormat> for audit::AuditOutputFormat {
2135 fn from(format: AuditFormat) -> Self {
2136 match format {
2137 AuditFormat::Markdown => Self::Markdown,
2138 AuditFormat::Sarif => Self::Sarif,
2139 }
2140 }
2141 }
2142
2143 #[derive(Debug, Subcommand)]
2144 enum Command {
2145 Run(RunArgs),
2147 Chat(ChatArgs),
2149 Model(ModelArgs),
2151 Doctor(DoctorArgs),
2153 Audit {
2155 #[arg(
2156 long,
2157 value_enum,
2158 default_value_t = AuditFormat::Markdown,
2159 help = "Output format: markdown or sarif"
2160 )]
2161 format: AuditFormat,
2162 #[arg(
2163 long,
2164 value_name = "PATH",
2165 help = "Write findings to a workspace file (default: ISSUES.md or oy.sarif)"
2166 )]
2167 out: Option<PathBuf>,
2168 #[arg(
2169 long,
2170 value_name = "N",
2171 default_value_t = audit::DEFAULT_MAX_REVIEW_CHUNKS,
2172 help = "Maximum audit chunks to review before failing closed"
2173 )]
2174 max_chunks: usize,
2175 #[arg(value_name = "FOCUS", help = "Optional audit focus text")]
2176 focus: Vec<String>,
2177 },
2178 }
2179
2180 #[derive(Debug, Args, Clone)]
2181 struct SharedModeArgs {
2182 #[arg(
2183 long,
2184 alias = "agent",
2185 default_value = "default",
2186 help = "Safety mode (default: balanced): plan, ask, edit, or auto"
2187 )]
2188 mode: String,
2189 #[arg(
2190 long = "continue-session",
2191 default_value_t = false,
2192 help = "Resume the most recent saved session"
2193 )]
2194 continue_session: bool,
2195 #[arg(
2196 long,
2197 default_value = "",
2198 value_name = "NAME_OR_NUMBER",
2199 help = "Resume a named or numbered saved session"
2200 )]
2201 resume: String,
2202 }
2203
2204 #[derive(Debug, Args, Clone)]
2205 struct RunArgs {
2206 #[command(flatten)]
2207 shared: SharedModeArgs,
2208 #[arg(
2209 long,
2210 value_name = "PATH",
2211 help = "Write the final answer to a workspace file"
2212 )]
2213 out: Option<PathBuf>,
2214 #[arg(
2215 value_name = "PROMPT",
2216 help = "Task prompt; omitted means read stdin or start chat in a TTY"
2217 )]
2218 task: Vec<String>,
2219 }
2220
2221 #[derive(Debug, Args, Clone)]
2222 struct ChatArgs {
2223 #[command(flatten)]
2224 shared: SharedModeArgs,
2225 }
2226
2227 #[derive(Debug, Args, Clone)]
2228 struct ModelArgs {
2229 #[arg(
2230 value_name = "MODEL",
2231 help = "Model id or routing shim selection, e.g. copilot::gpt-4.1-mini"
2232 )]
2233 model: Option<String>,
2234 }
2235
2236 #[derive(Debug, Args, Clone)]
2237 struct DoctorArgs {
2238 #[arg(
2239 long,
2240 alias = "agent",
2241 default_value = "default",
2242 help = "Safety mode to inspect (default: balanced): plan, ask, edit, or auto"
2243 )]
2244 mode: String,
2245 }
2246
2247 pub async fn run(argv: Vec<String>) -> Result<i32> {
2248 let normalized = normalize_args(argv);
2249 let mut cli = Cli::parse_from(std::iter::once("oy".to_string()).chain(normalized.clone()));
2250 restore_trailing_audit_options(&mut cli);
2251 crate::ui::init_output_mode(cli_output_mode(&cli));
2252 match cli.command.unwrap_or(Command::Run(RunArgs {
2253 shared: SharedModeArgs {
2254 mode: "default".to_string(),
2255 continue_session: false,
2256 resume: String::new(),
2257 },
2258 out: None,
2259 task: Vec::new(),
2260 })) {
2261 Command::Run(args) => run_command(args).await,
2262 Command::Chat(args) => chat_command(args).await,
2263 Command::Model(args) => model_command(args).await,
2264 Command::Doctor(args) => doctor_command(args).await,
2265 Command::Audit {
2266 format,
2267 out,
2268 max_chunks,
2269 focus,
2270 } => {
2271 audit_command(AuditArgs {
2272 focus,
2273 out: out.unwrap_or_else(|| audit::default_output_path(format.into())),
2274 max_chunks,
2275 format: format.into(),
2276 })
2277 .await
2278 }
2279 }
2280 }
2281
2282 fn restore_trailing_audit_options(cli: &mut Cli) {
2283 let Some(Command::Audit {
2284 format: _,
2285 out: _,
2286 max_chunks,
2287 focus,
2288 }) = &mut cli.command
2289 else {
2290 return;
2291 };
2292 let mut filtered_focus = Vec::new();
2293 let mut i = 0usize;
2294 while i < focus.len() {
2295 match focus[i].as_str() {
2296 "--max-chunks" => {
2297 if let Some(value) = focus.get(i + 1)
2298 && let Ok(parsed) = value.parse::<usize>()
2299 {
2300 *max_chunks = parsed;
2301 i += 2;
2302 continue;
2303 }
2304 }
2305 raw if raw.starts_with("--max-chunks=") => {
2306 if let Some((_, value)) = raw.split_once('=')
2307 && let Ok(parsed) = value.parse::<usize>()
2308 {
2309 *max_chunks = parsed;
2310 i += 1;
2311 continue;
2312 }
2313 }
2314 _ => {}
2315 }
2316 filtered_focus.push(focus[i].clone());
2317 i += 1;
2318 }
2319 *focus = filtered_focus;
2320 }
2321
2322 fn cli_output_mode(cli: &Cli) -> Option<crate::ui::OutputMode> {
2323 if cli.quiet {
2324 Some(crate::ui::OutputMode::Quiet)
2325 } else if cli.verbose {
2326 Some(crate::ui::OutputMode::Verbose)
2327 } else if cli.json {
2328 Some(crate::ui::OutputMode::Json)
2329 } else {
2330 None
2331 }
2332 }
2333
2334 #[cfg(test)]
2335 fn parse_cli_for_test(args: &[&str]) -> Cli {
2336 let mut cli = Cli::parse_from(args);
2337 restore_trailing_audit_options(&mut cli);
2338 cli
2339 }
2340
2341 #[cfg(test)]
2342 fn command_help_for_test(command: &str) -> String {
2343 let mut cmd = <Cli as clap::CommandFactory>::command();
2344 let Some(subcommand) = cmd.find_subcommand_mut(command) else {
2345 panic!("unknown command: {command}");
2346 };
2347 let mut help = Vec::new();
2348 subcommand.write_long_help(&mut help).expect("write help");
2349 String::from_utf8(help).expect("utf8 help")
2350 }
2351
2352 fn normalize_args(mut args: Vec<String>) -> Vec<String> {
2353 if args.is_empty() {
2354 return if config::can_prompt() {
2355 vec!["--help".to_string()]
2356 } else {
2357 vec!["run".to_string()]
2358 };
2359 }
2360 if matches!(
2361 args.first().map(String::as_str),
2362 Some("--continue") | Some("-c")
2363 ) {
2364 return std::iter::once("run".to_string())
2365 .chain(std::iter::once("--continue-session".to_string()))
2366 .chain(args.drain(1..))
2367 .collect();
2368 }
2369 if args.first().map(String::as_str) == Some("--resume") {
2370 return std::iter::once("run".to_string()).chain(args).collect();
2371 }
2372 let commands = ["run", "chat", "model", "doctor", "audit", "-h", "--help"];
2373 if args
2374 .first()
2375 .is_some_and(|arg| !arg.starts_with('-') && !commands.contains(&arg.as_str()))
2376 {
2377 let mut out = vec!["run".to_string()];
2378 out.extend(args);
2379 return out;
2380 }
2381 args
2382 }
2383
2384 async fn run_command(args: RunArgs) -> Result<i32> {
2385 let task = collect_task(&args.task)?;
2386 if task.trim().is_empty() {
2387 return chat_command(ChatArgs {
2388 shared: args.shared,
2389 })
2390 .await;
2391 }
2392 let mut session = load_or_new(
2393 false,
2394 &args.shared.mode,
2395 args.shared.continue_session,
2396 &args.shared.resume,
2397 )?;
2398 print_session_intro("run", &session, Some(&task));
2399 let answer = session::run_prompt(&mut session, &task).await?;
2400 if crate::ui::is_json() {
2401 print_run_json(&session, &answer)?;
2402 } else if let Some(path) = args.out {
2403 write_workspace_file(&session.root, &path, &answer)?;
2404 crate::ui::success(format_args!("wrote {}", path.display()));
2405 } else if !answer.is_empty() {
2406 crate::ui::markdown(&format!("{answer}\n"));
2407 }
2408 Ok(0)
2409 }
2410
2411 fn print_run_json(session: &Session, answer: &str) -> Result<()> {
2412 let status = session.context_status();
2413 let payload = serde_json::json!({
2414 "answer": answer,
2415 "model": session.model,
2416 "mode": session.mode,
2417 "workspace": session.root,
2418 "tokens": status.estimate,
2419 "context": status,
2420 "messages": status.estimate.messages,
2421 "todos": session.todos,
2422 });
2423 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2424 Ok(())
2425 }
2426
2427 async fn chat_command(args: ChatArgs) -> Result<i32> {
2428 let mut session = load_or_new(
2429 true,
2430 &args.shared.mode,
2431 args.shared.continue_session,
2432 &args.shared.resume,
2433 )?;
2434 print_session_intro("chat", &session, None);
2435 crate::chat::run_chat(&mut session).await
2436 }
2437
2438 async fn model_command(args: ModelArgs) -> Result<i32> {
2439 if let Some(model_spec) = args
2440 .model
2441 .as_deref()
2442 .filter(|value| is_exact_model_spec(value))
2443 {
2444 let normalized = model::canonical_model_spec(model_spec);
2445 config::save_model_config(&normalized)?;
2446 if crate::ui::is_json() {
2447 print_saved_model_json(&normalized)?;
2448 } else {
2449 print_saved_model(&normalized);
2450 }
2451 return Ok(0);
2452 }
2453
2454 let listing = model::inspect_models().await?;
2455 if let Some(model_spec) = args.model {
2456 let normalized = resolve_model_choice(&listing, &model_spec)?;
2457 config::save_model_config(&normalized)?;
2458 if crate::ui::is_json() {
2459 print_model_json(&listing, Some(&normalized))?;
2460 } else {
2461 print_saved_model(&normalized);
2462 }
2463 return Ok(0);
2464 }
2465 if crate::ui::is_json() {
2466 print_model_json(&listing, None)?;
2467 return Ok(0);
2468 }
2469 print_model_listing(&listing);
2470 if config::can_prompt()
2471 && !listing.all_models.is_empty()
2472 && let Some(chosen) = crate::chat::choose_model_with_initial_list(
2473 listing.current.as_deref(),
2474 &listing.all_models,
2475 false,
2476 )?
2477 {
2478 config::save_model_config(&chosen)?;
2479 print_saved_model(&chosen);
2480 }
2481 Ok(0)
2482 }
2483
2484 fn is_exact_model_spec(value: &str) -> bool {
2485 let value = value.trim();
2486 value.contains("::") || value.contains('/') || value.contains(':') || value.contains('.')
2487 }
2488
2489 fn print_saved_model_json(saved: &str) -> Result<()> {
2490 let payload = serde_json::json!({ "saved": saved });
2491 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2492 Ok(())
2493 }
2494
2495 fn print_model_json(listing: &model::ModelListing, saved: Option<&str>) -> Result<()> {
2496 let payload = serde_json::json!({
2497 "current": listing.current,
2498 "current_shim": listing.current_shim,
2499 "saved": saved,
2500 "auth": listing.auth,
2501 "recommended": listing.recommended,
2502 "dynamic": listing.dynamic,
2503 "hints": listing.hints,
2504 "all_models": listing.all_models,
2505 });
2506 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2507 Ok(())
2508 }
2509
2510 fn print_model_listing(listing: &model::ModelListing) {
2511 crate::ui::section("Models");
2512 crate::ui::kv(
2513 "current",
2514 current_model_text(
2515 listing.current.as_deref().unwrap_or("<unset>"),
2516 listing.current_shim.as_deref(),
2517 ),
2518 );
2519 crate::ui::kv("selectable", listing.all_models.len());
2520 if !listing.recommended.is_empty() {
2521 crate::ui::kv("recommended", listing.recommended.join(", "));
2522 if listing.current.is_none() {
2523 crate::ui::line(format_args!(" Try: oy model {}", listing.recommended[0]));
2524 }
2525 }
2526
2527 if !listing.auth.is_empty() {
2528 crate::ui::line("");
2529 crate::ui::section("Auth / shims");
2530 for item in &listing.auth {
2531 let env_var = item.env_var.as_deref().unwrap_or("-");
2532 let active = if listing.current_shim.as_deref() == Some(item.adapter.as_str()) {
2533 " *"
2534 } else {
2535 ""
2536 };
2537 crate::ui::line(format_args!(
2538 " {}{} {} ({})",
2539 item.adapter, active, env_var, item.source
2540 ));
2541 crate::ui::line(format_args!(" {}", item.detail));
2542 }
2543 }
2544
2545 crate::ui::line("");
2546 crate::ui::section("Introspected endpoint models");
2547 if listing.dynamic.is_empty() {
2548 crate::ui::line(" none found from configured OpenAI-compatible endpoints");
2549 } else {
2550 for item in &listing.dynamic {
2551 if !item.ok {
2552 crate::ui::line(format_args!(
2553 " {} failed via {}",
2554 item.adapter, item.source
2555 ));
2556 if let Some(error) = item.error.as_deref() {
2557 crate::ui::line(format_args!(
2558 " {}",
2559 crate::ui::truncate_chars(error, 140)
2560 ));
2561 }
2562 continue;
2563 }
2564 crate::ui::line(format_args!(
2565 " {} {} models via {}",
2566 item.adapter, item.count, item.source
2567 ));
2568 for model_name in item.models.iter().take(MODEL_LIST_LIMIT) {
2569 let marker = if listing.current.as_deref() == Some(model_name.as_str()) {
2570 "*"
2571 } else {
2572 " "
2573 };
2574 crate::ui::line(format_args!(" {marker} {model_name}"));
2575 }
2576 if item.models.len() > MODEL_LIST_LIMIT {
2577 crate::ui::line(format_args!(
2578 " … {} more; use `oy model <filter>` or interactive selection",
2579 item.models.len() - MODEL_LIST_LIMIT
2580 ));
2581 }
2582 }
2583 }
2584
2585 let hinted = listing
2586 .hints
2587 .iter()
2588 .filter(|hint| {
2589 !listing
2590 .dynamic
2591 .iter()
2592 .any(|group| group.models.iter().any(|model| model == *hint))
2593 })
2594 .collect::<Vec<_>>();
2595 if !hinted.is_empty() {
2596 crate::ui::line("");
2597 crate::ui::section("Built-in selectable hints");
2598 for hint in hinted.iter().take(MODEL_LIST_LIMIT) {
2599 crate::ui::line(format_args!(" {hint}"));
2600 }
2601 if hinted.len() > MODEL_LIST_LIMIT {
2602 crate::ui::line(format_args!(
2603 " … {} more hints",
2604 hinted.len() - MODEL_LIST_LIMIT
2605 ));
2606 }
2607 }
2608 }
2609
2610 fn current_model_text(model_spec: &str, shim: Option<&str>) -> String {
2611 match shim.filter(|value| !value.is_empty()) {
2612 Some(shim) => format!("{model_spec} (shim: {shim})"),
2613 None => model_spec.to_string(),
2614 }
2615 }
2616
2617 fn print_saved_model(selection: &str) {
2618 let saved = config::saved_model_config_from_selection(selection);
2619 crate::ui::success(format_args!(
2620 "saved model {}",
2621 saved.model.as_deref().unwrap_or(selection)
2622 ));
2623 if let Some(shim) = saved.shim {
2624 crate::ui::kv("shim", shim);
2625 }
2626 }
2627
2628 fn resolve_model_choice(listing: &model::ModelListing, query: &str) -> Result<String> {
2629 let normalized = model::canonical_model_spec(query);
2630 if listing.all_models.iter().any(|item| item == &normalized) {
2631 return Ok(normalized);
2632 }
2633 if !config::can_prompt() {
2634 bail!(
2635 "No exact model match for `{}`. Re-run in a TTY to choose interactively.",
2636 query
2637 );
2638 }
2639 let matches = listing
2640 .all_models
2641 .iter()
2642 .filter(|item| {
2643 item.to_ascii_lowercase()
2644 .contains(&query.to_ascii_lowercase())
2645 })
2646 .cloned()
2647 .collect::<Vec<_>>();
2648 if matches.is_empty() {
2649 bail!("No matching model for `{}`", query);
2650 }
2651 crate::chat::choose_model(listing.current.as_deref(), &matches)
2652 .map(|value| value.unwrap_or(normalized))
2653 }
2654
2655 async fn doctor_command(args: DoctorArgs) -> Result<i32> {
2656 let root = config::oy_root()?;
2657 let listing = model::inspect_models().await?;
2658 let mode = config::safety_mode(&args.mode)?;
2659 let policy = config::tool_policy(mode.name());
2660 let config_file = config::config_root();
2661 let config_dir = config::config_dir_path();
2662 let sessions_dir = config::sessions_dir().unwrap_or_else(|_| config_dir.join("sessions"));
2663 let history_dir = config_dir.join("history");
2664 let bash_ok = std::process::Command::new("bash")
2665 .arg("--version")
2666 .stdout(std::process::Stdio::null())
2667 .stderr(std::process::Stdio::null())
2668 .status()
2669 .map(|status| status.success())
2670 .unwrap_or(false);
2671
2672 if crate::ui::is_json() {
2673 let payload = serde_json::json!({
2674 "workspace": root,
2675 "model": listing.current,
2676 "shim": listing.current_shim,
2677 "auth": listing.auth,
2678 "mode": mode.name(),
2679 "policy": policy,
2680 "interactive": config::can_prompt(),
2681 "non_interactive": config::non_interactive(),
2682 "config_file": config_file,
2683 "config_dir": config_dir,
2684 "sessions_dir": sessions_dir,
2685 "history_dir": history_dir,
2686 "bash": bash_ok,
2687 "recommended": listing.recommended,
2688 "next_step": recommended_next_step(&listing),
2689 });
2690 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2691 return Ok(0);
2692 }
2693
2694 crate::ui::section("Doctor");
2695 crate::ui::kv("workspace", root.display());
2696 crate::ui::kv("model", listing.current.as_deref().unwrap_or("<unset>"));
2697 crate::ui::kv("shim", listing.current_shim.as_deref().unwrap_or("<none>"));
2698 crate::ui::kv("mode", mode.name());
2699 crate::ui::kv("files-write", format_args!("{:?}", policy.files_write));
2700 crate::ui::kv("shell", format_args!("{:?}", policy.shell));
2701 crate::ui::kv("network", crate::ui::bool_text(policy.network));
2702 crate::ui::kv("risk", config::policy_risk_label(&policy));
2703 crate::ui::kv("interactive", crate::ui::bool_text(config::can_prompt()));
2704 crate::ui::kv(
2705 "bash",
2706 crate::ui::status_text(bash_ok, if bash_ok { "ok" } else { "missing" }),
2707 );
2708 crate::ui::line("");
2709 crate::ui::section("Local state");
2710 crate::ui::kv("config", config_file.display());
2711 crate::ui::kv("sessions", sessions_dir.display());
2712 crate::ui::kv("history", history_dir.display());
2713 crate::ui::line(
2714 " Treat local state as sensitive: prompts, source snippets, tool output, and command output may be saved.",
2715 );
2716 crate::ui::line("");
2717 crate::ui::section("Auth / shims");
2718 if listing.auth.is_empty() {
2719 crate::ui::warn("no provider auth detected");
2720 } else {
2721 for item in &listing.auth {
2722 crate::ui::line(format_args!(
2723 " {} {} ({})",
2724 item.adapter,
2725 item.env_var.as_deref().unwrap_or("-"),
2726 item.source
2727 ));
2728 crate::ui::line(format_args!(" {}", item.detail));
2729 }
2730 }
2731 if listing.current.is_none() {
2732 crate::ui::line("");
2733 crate::ui::warn("no model configured");
2734 crate::ui::line(format_args!(" {}", recommended_next_step(&listing)));
2735 }
2736 crate::ui::line("");
2737 crate::ui::section("Recommended next steps");
2738 crate::ui::line(format_args!(" 1. {}", recommended_next_step(&listing)));
2739 crate::ui::line(" 2. For untrusted repos: `oy chat --mode plan`");
2740 crate::ui::line(format_args!(
2741 " • Read-only container: {}",
2742 safe_container_command(&root, true)
2743 ));
2744 crate::ui::line("");
2745 crate::ui::section("Safety");
2746 crate::ui::line(
2747 " oy is not a sandbox. Use `oy chat --mode plan` or a disposable container/VM for untrusted repos.",
2748 );
2749 crate::ui::line(
2750 " Mount only needed credentials/env vars. Do not mount the host Docker socket into AI-assisted containers.",
2751 );
2752 Ok(0)
2753 }
2754
2755 fn recommended_next_step(listing: &model::ModelListing) -> String {
2756 if listing.current.is_some() {
2757 return "Run `oy \"inspect this repo\"` or `oy chat`.".to_string();
2758 }
2759 if let Some(choice) = listing.recommended.first() {
2760 return format!("Configure a model: `oy model {choice}`.");
2761 }
2762 "Configure provider auth, then run `oy model`; see `oy doctor` output.".to_string()
2763 }
2764
2765 fn safe_container_command(root: &Path, read_only: bool) -> String {
2766 let mode = if read_only { "ro" } else { "rw" };
2767 format!(
2768 "docker run --rm -it -v \"{}:/workspace:{mode}\" -w /workspace oy-image oy chat --mode plan",
2769 root.display()
2770 )
2771 }
2772
2773 #[derive(Debug, Clone)]
2774 struct AuditArgs {
2775 focus: Vec<String>,
2776 out: PathBuf,
2777 max_chunks: usize,
2778 format: audit::AuditOutputFormat,
2779 }
2780
2781 async fn audit_command(args: AuditArgs) -> Result<i32> {
2782 let started = std::time::Instant::now();
2783 let focus = args.focus.join(" ");
2784 let root = config::oy_root()?;
2785 let model = model::resolve_model(None)?;
2786 if !crate::ui::is_quiet() {
2787 crate::ui::section("audit");
2788 crate::ui::kv("workspace", root.display());
2789 crate::ui::kv("model", &model);
2790 crate::ui::kv("mode", "no-tools");
2791 crate::ui::kv("format", args.format.name());
2792 crate::ui::kv("out", args.out.display());
2793 crate::ui::kv("max chunks", args.max_chunks);
2794 if !focus.trim().is_empty() {
2795 crate::ui::kv("focus", crate::ui::compact_preview(&focus, 100));
2796 }
2797 }
2798 let result = audit::run(audit::AuditOptions {
2799 root,
2800 model,
2801 focus,
2802 out: args.out,
2803 max_chunks: args.max_chunks,
2804 format: args.format,
2805 })
2806 .await?;
2807 if crate::ui::is_json() {
2808 let payload = serde_json::json!({
2809 "output": result.output_path,
2810 "files": result.file_count,
2811 "chunks": result.chunk_count,
2812 "format": args.format.name(),
2813 "elapsed_ms": started.elapsed().as_millis(),
2814 });
2815 crate::ui::line(serde_json::to_string_pretty(&payload)?);
2816 } else {
2817 crate::ui::success(format_args!(
2818 "wrote {} ({} files, {} chunks, {})",
2819 result.output_path.display(),
2820 result.file_count,
2821 result.chunk_count,
2822 crate::ui::format_duration(started.elapsed())
2823 ));
2824 }
2825 Ok(0)
2826 }
2827
2828 fn load_or_new(
2829 interactive: bool,
2830 mode_name: &str,
2831 continue_session: bool,
2832 resume: &str,
2833 ) -> Result<Session> {
2834 let mode = config::safety_mode(mode_name)?;
2835 let policy = config::tool_policy(mode.name());
2836 if continue_session || !resume.is_empty() {
2837 let name = if continue_session { None } else { Some(resume) };
2838 if let Some(session) =
2839 session::load_saved(name, interactive, mode.name().to_string(), policy)?
2840 {
2841 return Ok(session);
2842 }
2843 }
2844 let root = config::oy_root()?;
2845 let model = model::resolve_model(None)?;
2846 Ok(Session::new(
2847 root,
2848 model,
2849 interactive,
2850 mode.name().to_string(),
2851 policy,
2852 ))
2853 }
2854
2855 fn collect_task(parts: &[String]) -> Result<String> {
2856 if !parts.is_empty() {
2857 return Ok(parts.join(" "));
2858 }
2859 if std::io::stdin().is_terminal() {
2860 return Ok(String::new());
2861 }
2862 let mut input = String::new();
2863 use std::io::Read as _;
2864 std::io::stdin().read_to_string(&mut input)?;
2865 Ok(input.trim().to_string())
2866 }
2867
2868 fn print_session_intro(mode: &str, session: &Session, prompt: Option<&str>) {
2869 if crate::ui::is_quiet() {
2870 return;
2871 }
2872 crate::ui::section(mode);
2873 crate::ui::kv("workspace", session.root.display());
2874 crate::ui::kv("model", &session.model);
2875 crate::ui::kv("mode", &session.mode);
2876 crate::ui::kv("risk", config::policy_risk_label(&session.policy));
2877 if let Some(prompt) = prompt {
2878 crate::ui::kv("prompt", crate::ui::compact_preview(prompt, 100));
2879 }
2880 }
2881
2882 fn write_workspace_file(root: &Path, requested: &Path, body: &str) -> Result<()> {
2883 let path = config::resolve_workspace_output_path(root, requested)?;
2884 let mut out = body.trim_end().to_string();
2885 out.push('\n');
2886 config::write_workspace_file(&path, out.as_bytes())
2887 }
2888
2889 #[cfg(test)]
2890 mod audit_tests {
2891 use super::*;
2892
2893 #[test]
2894 fn audit_accepts_max_chunks_flag() {
2895 let cli = parse_cli_for_test(&["oy", "audit", "--max-chunks", "240", "auth paths"]);
2896 let Some(Command::Audit {
2897 max_chunks, focus, ..
2898 }) = cli.command
2899 else {
2900 panic!("expected audit command");
2901 };
2902 assert_eq!(max_chunks, 240);
2903 assert_eq!(focus, vec!["auth paths"]);
2904 }
2905
2906 #[test]
2907 fn help_documents_audit_options() {
2908 let help = command_help_for_test("audit");
2909 assert!(help.contains("--max-chunks <N>"));
2910 assert!(help.contains("--format <FORMAT>"));
2911 }
2912
2913 #[test]
2914 fn audit_accepts_sarif_format() {
2915 let cli = parse_cli_for_test(&["oy", "audit", "--format", "sarif", "auth paths"]);
2916 let Some(Command::Audit { format, out, .. }) = cli.command else {
2917 panic!("expected audit command");
2918 };
2919 assert_eq!(format, AuditFormat::Sarif);
2920 assert_eq!(out, None);
2921 }
2922
2923 #[test]
2924 fn exact_model_specs_are_endpoint_qualified_or_provider_ids() {
2925 assert!(is_exact_model_spec("copilot::gpt-4.1-mini"));
2926 assert!(is_exact_model_spec("openai/gpt-4.1-mini"));
2927 assert!(is_exact_model_spec(
2928 "bedrock::global.amazon.nova-2-lite-v1:0"
2929 ));
2930 assert!(!is_exact_model_spec("gpt"));
2931 assert!(!is_exact_model_spec("nova"));
2932 }
2933 }
2934}