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