Skip to main content

oy/
cli.rs

1// === config ===
2pub(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
797// === ui ===
798pub(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    /// Controls how much user-facing output `oy` writes while it runs.
812    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
813    pub enum OutputMode {
814        /// Suppress normal progress output.
815        Quiet = 0,
816        /// Show standard human-readable progress output.
817        Normal = 1,
818        /// Show fuller tool previews and diagnostic context.
819        Verbose = 2,
820        /// Prefer machine-readable JSON where a command supports it.
821        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    /// Sets the process-wide output mode used by CLI rendering helpers.
843    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
1563// === chat ===
1564pub(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
2096// === app ===
2097pub(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 one task in the current workspace; prompt can be args or stdin.
2146        Run(RunArgs),
2147        /// Start an interactive chat session with slash commands and history.
2148        Chat(ChatArgs),
2149        /// List, choose, and save model ids/routing shims.
2150        Model(ModelArgs),
2151        /// Check setup, auth, paths, and safety-relevant defaults.
2152        Doctor(DoctorArgs),
2153        /// Audit the current workspace and write findings.
2154        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}