ailloli_ui_terminal_core/
shell.rs

1//! Pure shell prompt, process, command-history, and integration-event state.
2
3use serde::{Deserialize, Serialize};
4
5/// Default maximum of 1,000 finished commands retained in shell history.
6///
7/// # Examples
8///
9/// ```
10/// use ailloli_ui_terminal_core::shell::DEFAULT_COMMAND_HISTORY_LIMIT;
11/// assert_eq!(DEFAULT_COMMAND_HISTORY_LIMIT, 1_000);
12/// ```
13pub const DEFAULT_COMMAND_HISTORY_LIMIT: usize = 1_000;
14
15/// Shell-state-local command identity.
16///
17/// Normal allocation begins at one and saturates at [`u64::MAX`], after which
18/// repeated allocations reuse `u64::MAX`. Public construction/deserialization
19/// accepts every value, so uniqueness must not be assumed for untrusted state.
20///
21/// # Examples
22///
23/// ```
24/// use ailloli_ui_terminal_core::CommandId;
25/// assert_eq!(CommandId(1).0, 1);
26/// ```
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub struct CommandId(
29    /// Raw shell-state-local numeric identity.
30    pub u64,
31);
32
33/// Supported shell family for integration-script selection.
34///
35/// # Examples
36///
37/// ```
38/// use ailloli_ui_terminal_core::ShellKind;
39/// assert_eq!(ShellKind::default(), ShellKind::Unknown);
40/// ```
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
42pub enum ShellKind {
43    /// Missing or unsupported shell name; no script is available.
44    #[default]
45    Unknown,
46    /// GNU Bash integration.
47    Bash,
48    /// Z shell integration.
49    Zsh,
50    /// Fish shell integration.
51    Fish,
52}
53
54impl ShellKind {
55    /// Parses exact `bash`, `zsh`, or `fish` after trimming and ASCII lowercasing.
56    ///
57    /// Paths such as `/bin/bash`, versioned names, empty input, and all other
58    /// values return [`Self::Unknown`].
59    ///
60    /// # Examples
61    ///
62    /// ```
63    /// use ailloli_ui_terminal_core::ShellKind;
64    /// assert_eq!(ShellKind::from_name(" ZSH "), ShellKind::Zsh);
65    /// assert_eq!(ShellKind::from_name("/bin/zsh"), ShellKind::Unknown);
66    /// ```
67    pub fn from_name(name: &str) -> Self {
68        match name.trim().to_ascii_lowercase().as_str() {
69            "bash" => Self::Bash,
70            "zsh" => Self::Zsh,
71            "fish" => Self::Fish,
72            _ => Self::Unknown,
73        }
74    }
75}
76
77/// Lifecycle outcome of one tracked command.
78///
79/// # Examples
80///
81/// ```
82/// use ailloli_ui_terminal_core::CommandStatus;
83/// assert_eq!(CommandStatus::default(), CommandStatus::Unknown);
84/// ```
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
86pub enum CommandStatus {
87    /// Known but not yet started; not emitted by current helpers.
88    Queued,
89    /// Currently executing.
90    Running,
91    /// Finished with exit code zero and no signal.
92    Succeeded,
93    /// Finished with a nonzero exit code and no signal.
94    Failed,
95    /// Finished with a signal value, regardless of exit code.
96    Interrupted,
97    /// Outcome unavailable, including a finish with no code/signal.
98    #[default]
99    Unknown,
100}
101
102/// Coarse lifecycle of the shell/PTY process.
103///
104/// Codes/signals are optional signed platform values and are not normalized.
105///
106/// # Examples
107///
108/// ```
109/// use ailloli_ui_terminal_core::TerminalProcessStatus;
110/// let status = TerminalProcessStatus::Exited { code: Some(0) };
111/// assert_ne!(status, TerminalProcessStatus::Running);
112/// ```
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
114pub enum TerminalProcessStatus {
115    /// No lifecycle information has arrived.
116    #[default]
117    Unknown,
118    /// Spawn has begun but execution is not yet confirmed.
119    Starting,
120    /// Shell/process is running.
121    Running,
122    /// Process exited normally or with an exit code unavailable.
123    Exited {
124        /// Signed exit code, or `None` when not reported.
125        code: Option<i32>,
126    },
127    /// Process terminated by a signal or equivalent platform event.
128    Signaled {
129        /// Signed signal number, or `None` when not reported.
130        signal: Option<i32>,
131    },
132}
133
134/// Inclusive global terminal line range for command output.
135///
136/// A running range has `end_line == None`; [`Self::finish`] installs an end no
137/// earlier than the start. Public fields/deserialization can bypass that rule.
138///
139/// # Examples
140///
141/// ```
142/// use ailloli_ui_terminal_core::CommandOutputRange;
143/// assert_eq!(CommandOutputRange::new(4).end_line, None);
144/// ```
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146pub struct CommandOutputRange {
147    /// Inclusive first global terminal line.
148    pub start_line: u64,
149    /// Inclusive last line, or `None` while open/unknown.
150    pub end_line: Option<u64>,
151}
152
153impl CommandOutputRange {
154    /// Creates an open range at an exact global line.
155    ///
156    /// # Examples
157    ///
158    /// ```
159    /// use ailloli_ui_terminal_core::CommandOutputRange;
160    /// assert_eq!(CommandOutputRange::new(u64::MAX).start_line, u64::MAX);
161    /// ```
162    pub const fn new(start_line: u64) -> Self {
163        Self {
164            start_line,
165            end_line: None,
166        }
167    }
168
169    /// Closes the range, clamping an earlier end up to `start_line`.
170    ///
171    /// Repeated calls replace the previous end.
172    ///
173    /// # Examples
174    ///
175    /// ```
176    /// use ailloli_ui_terminal_core::CommandOutputRange;
177    /// let mut range = CommandOutputRange::new(10); range.finish(3);
178    /// assert_eq!(range.end_line, Some(10));
179    /// ```
180    pub fn finish(&mut self, end_line: u64) {
181        self.end_line = Some(end_line.max(self.start_line));
182    }
183}
184
185impl Default for CommandOutputRange {
186    fn default() -> Self {
187        Self::new(0)
188    }
189}
190
191/// Serializable lifecycle snapshot for one shell command.
192///
193/// Times and durations are milliseconds in a caller-defined clock domain.
194/// `None` means not reported. Public fields/deserialization do not enforce
195/// lifecycle, time-order, range, or status/code consistency.
196///
197/// # Examples
198///
199/// ```
200/// use ailloli_ui_terminal_core::{CommandExecution, CommandId, CommandStatus};
201/// let command = CommandExecution::running(CommandId(1), "cargo check", None, 7, Some(100));
202/// assert_eq!((command.status, command.output_range.start_line), (CommandStatus::Running, 7));
203/// ```
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct CommandExecution {
206    /// Shell-state-local identity.
207    pub id: CommandId,
208    /// Command text stored verbatim; may be empty.
209    pub command_line: String,
210    /// Working-directory URI at start, or `None` when unknown.
211    pub cwd_uri: Option<String>,
212    /// Current/finished lifecycle status.
213    pub status: CommandStatus,
214    /// Signed exit code, or `None` when absent.
215    pub exit_code: Option<i32>,
216    /// Signed terminating signal, or `None` when absent.
217    pub signal: Option<i32>,
218    /// Start timestamp in caller-defined milliseconds.
219    pub started_at_ms: Option<u64>,
220    /// End timestamp in the same caller-defined clock domain.
221    pub ended_at_ms: Option<u64>,
222    /// Duration in milliseconds, supplied or derived with checked subtraction.
223    pub duration_ms: Option<u64>,
224    /// Inclusive combined-output global line range.
225    pub output_range: CommandOutputRange,
226    /// Optional stdout range; current helpers mirror combined output here.
227    pub stdout_range: Option<CommandOutputRange>,
228    /// Optional stderr range; current helpers leave it `None`.
229    pub stderr_range: Option<CommandOutputRange>,
230}
231
232impl CommandExecution {
233    /// Creates a running command with an open combined/stdout range.
234    ///
235    /// Exit/signal/end/duration are cleared and stderr remains untracked. Inputs
236    /// are stored verbatim, including an empty command or empty CWD string.
237    ///
238    /// # Examples
239    ///
240    /// ```
241    /// use ailloli_ui_terminal_core::{CommandExecution, CommandId, CommandStatus};
242    /// let command = CommandExecution::running(CommandId(2), "", Some("".into()), 0, None);
243    /// assert_eq!(command.status, CommandStatus::Running);
244    /// assert!(command.command_line.is_empty() && command.stderr_range.is_none());
245    /// ```
246    pub fn running(
247        id: CommandId,
248        command_line: impl Into<String>,
249        cwd_uri: Option<String>,
250        start_line: u64,
251        started_at_ms: Option<u64>,
252    ) -> Self {
253        let output_range = CommandOutputRange::new(start_line);
254        Self {
255            id,
256            command_line: command_line.into(),
257            cwd_uri,
258            status: CommandStatus::Running,
259            exit_code: None,
260            signal: None,
261            started_at_ms,
262            ended_at_ms: None,
263            duration_ms: None,
264            output_range,
265            stdout_range: Some(output_range),
266            stderr_range: None,
267        }
268    }
269
270    /// Finishes this command and closes combined/stdout output ranges.
271    ///
272    /// A signal makes status `Interrupted`; otherwise exit zero succeeds,
273    /// nonzero fails, and no code yields `Unknown`. Explicit `duration_ms` wins.
274    /// Without it, duration is `ended - started` using checked subtraction and
275    /// is `None` when either time is absent or end precedes start. The output end
276    /// clamps to its start; stderr range is not changed.
277    ///
278    /// # Examples
279    ///
280    /// ```
281    /// use ailloli_ui_terminal_core::{CommandExecution, CommandId, CommandStatus};
282    /// let mut command = CommandExecution::running(CommandId(1), "true", None, 4, Some(100));
283    /// command.finish(6, Some(0), None, Some(125), None);
284    /// assert_eq!((command.status, command.duration_ms, command.output_range.end_line), (CommandStatus::Succeeded, Some(25), Some(6)));
285    /// ```
286    pub fn finish(
287        &mut self,
288        end_line: u64,
289        exit_code: Option<i32>,
290        signal: Option<i32>,
291        ended_at_ms: Option<u64>,
292        duration_ms: Option<u64>,
293    ) {
294        self.exit_code = exit_code;
295        self.signal = signal;
296        self.ended_at_ms = ended_at_ms;
297        self.duration_ms = duration_ms.or_else(|| match (self.started_at_ms, ended_at_ms) {
298            (Some(started), Some(ended)) => ended.checked_sub(started),
299            _ => None,
300        });
301        self.status = command_status_from_exit(exit_code, signal);
302        self.output_range.finish(end_line);
303        if let Some(stdout_range) = &mut self.stdout_range {
304            stdout_range.finish(end_line);
305        }
306    }
307}
308
309/// Ordered shell-state change emitted for host/UI consumers.
310///
311/// # Examples
312///
313/// ```
314/// use ailloli_ui_terminal_core::ShellEvent;
315/// assert!(matches!(ShellEvent::PromptStart { line: 3 }, ShellEvent::PromptStart { line: 3 }));
316/// ```
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318pub enum ShellEvent {
319    /// A prompt became visible at a global terminal line.
320    PromptStart {
321        /// Global prompt line index.
322        line: u64,
323    },
324    /// A command began.
325    CommandStart {
326        /// Complete running command snapshot.
327        command: CommandExecution,
328    },
329    /// A command ended or was implicitly superseded.
330    CommandEnd {
331        /// Complete finished/superseded command snapshot.
332        command: CommandExecution,
333    },
334    /// Working-directory URI changed.
335    CwdChanged {
336        /// New URI stored verbatim.
337        cwd_uri: String,
338    },
339    /// Detected/configured shell family changed.
340    ShellKindChanged {
341        /// New shell kind.
342        shell_kind: ShellKind,
343    },
344    /// Shell/PTY process status changed.
345    ProcessStatusChanged {
346        /// New process status.
347        status: TerminalProcessStatus,
348    },
349}
350
351/// Cloned read model of current shell execution state.
352///
353/// Pending events, history limit, and next ID are deliberately omitted.
354///
355/// # Examples
356///
357/// ```
358/// use ailloli_ui_terminal_core::{ShellExecutionState, ShellKind};
359/// let snapshot = ShellExecutionState::new().snapshot();
360/// assert_eq!(snapshot.shell_kind, ShellKind::Unknown);
361/// assert!(snapshot.command_history.is_empty());
362/// ```
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
364pub struct TerminalShellSnapshot {
365    /// Current shell family.
366    pub shell_kind: ShellKind,
367    /// Current shell/PTY process status.
368    pub process_status: TerminalProcessStatus,
369    /// Current working-directory URI, or `None` when unknown.
370    pub cwd_uri: Option<String>,
371    /// Whether a prompt is currently considered visible.
372    pub prompt_visible: bool,
373    /// Running command, or `None` while idle.
374    pub current_command: Option<CommandExecution>,
375    /// Most recently finished command, retained independently of history limit.
376    pub last_command: Option<CommandExecution>,
377    /// Retained finished commands, oldest to newest.
378    pub command_history: Vec<CommandExecution>,
379    /// Most recent prompt global line, or `None` before detection.
380    pub last_prompt_line: Option<u64>,
381}
382
383/// Mutable pure state for shell integration and heuristic prompt tracking.
384///
385/// Normal methods keep history bounded and events ordered. Public fields and
386/// deserialization can bypass all invariants; notably `next_command_id` uses
387/// saturating allocation and will reuse `u64::MAX` after exhaustion.
388///
389/// # Examples
390///
391/// ```
392/// use ailloli_ui_terminal_core::ShellExecutionState;
393/// let shell = ShellExecutionState::new();
394/// assert_eq!(shell.next_command_id, 1);
395/// assert!(shell.current_command.is_none());
396/// ```
397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398pub struct ShellExecutionState {
399    /// Detected/configured shell family.
400    pub shell_kind: ShellKind,
401    /// Shell/PTY process lifecycle.
402    pub process_status: TerminalProcessStatus,
403    /// Current working-directory URI, or `None` when unknown.
404    pub cwd_uri: Option<String>,
405    /// Whether a prompt is currently considered visible.
406    pub prompt_visible: bool,
407    /// Running command, or `None` while idle.
408    pub current_command: Option<CommandExecution>,
409    /// Most recently finished command, even when history retention is disabled.
410    pub last_command: Option<CommandExecution>,
411    /// Retained finished commands, oldest to newest.
412    pub history: Vec<CommandExecution>,
413    /// Maximum retained history count; zero disables history retention.
414    #[serde(default = "default_command_history_limit")]
415    pub history_limit: usize,
416    /// Next saturating raw command ID.
417    pub next_command_id: u64,
418    /// Most recent prompt global line, or `None` before detection.
419    pub last_prompt_line: Option<u64>,
420    /// Ordered undrained changes; defaults empty in older serialized state.
421    #[serde(default)]
422    pub pending_events: Vec<ShellEvent>,
423}
424
425impl ShellExecutionState {
426    /// Creates the default unknown, idle shell state.
427    ///
428    /// # Examples
429    ///
430    /// ```
431    /// use ailloli_ui_terminal_core::{ShellExecutionState, TerminalProcessStatus};
432    /// assert_eq!(ShellExecutionState::new().process_status, TerminalProcessStatus::Unknown);
433    /// ```
434    pub fn new() -> Self {
435        Self::default()
436    }
437
438    /// Clones the consumer-facing shell snapshot.
439    ///
440    /// This is linear in retained command history and does not drain events.
441    ///
442    /// # Examples
443    ///
444    /// ```
445    /// use ailloli_ui_terminal_core::ShellExecutionState;
446    /// let mut shell = ShellExecutionState::new(); shell.set_cwd_uri("file:///tmp");
447    /// assert_eq!(shell.snapshot().cwd_uri.as_deref(), Some("file:///tmp"));
448    /// ```
449    pub fn snapshot(&self) -> TerminalShellSnapshot {
450        TerminalShellSnapshot {
451            shell_kind: self.shell_kind,
452            process_status: self.process_status,
453            cwd_uri: self.cwd_uri.clone(),
454            prompt_visible: self.prompt_visible,
455            current_command: self.current_command.clone(),
456            last_command: self.last_command.clone(),
457            command_history: self.history.clone(),
458            last_prompt_line: self.last_prompt_line,
459        }
460    }
461
462    /// Removes and returns all pending events in original order.
463    ///
464    /// A second drain returns an empty vector.
465    ///
466    /// # Examples
467    ///
468    /// ```
469    /// use ailloli_ui_terminal_core::ShellExecutionState;
470    /// let mut shell = ShellExecutionState::new(); shell.mark_prompt_start(2);
471    /// assert_eq!(shell.drain_events().len(), 1);
472    /// assert!(shell.drain_events().is_empty());
473    /// ```
474    pub fn drain_events(&mut self) -> Vec<ShellEvent> {
475        std::mem::take(&mut self.pending_events)
476    }
477
478    /// Replaces the CWD URI and emits `CwdChanged` only when unequal.
479    ///
480    /// Text is stored verbatim, including an empty string. This method cannot
481    /// restore `None`; public field mutation can.
482    ///
483    /// # Examples
484    ///
485    /// ```
486    /// use ailloli_ui_terminal_core::ShellExecutionState;
487    /// let mut shell = ShellExecutionState::new(); shell.set_cwd_uri("file:///tmp"); shell.set_cwd_uri("file:///tmp");
488    /// assert_eq!(shell.drain_events().len(), 1);
489    /// ```
490    pub fn set_cwd_uri(&mut self, cwd_uri: impl Into<String>) {
491        let cwd_uri = cwd_uri.into();
492        if self.cwd_uri.as_deref() == Some(cwd_uri.as_str()) {
493            return;
494        }
495        self.cwd_uri = Some(cwd_uri.clone());
496        self.pending_events.push(ShellEvent::CwdChanged { cwd_uri });
497    }
498
499    /// Replaces shell kind and emits an event only when unequal.
500    ///
501    /// # Examples
502    ///
503    /// ```
504    /// use ailloli_ui_terminal_core::{ShellExecutionState, ShellKind};
505    /// let mut shell = ShellExecutionState::new(); shell.set_shell_kind(ShellKind::Fish);
506    /// assert_eq!(shell.shell_kind, ShellKind::Fish);
507    /// ```
508    pub fn set_shell_kind(&mut self, shell_kind: ShellKind) {
509        if self.shell_kind == shell_kind {
510            return;
511        }
512        self.shell_kind = shell_kind;
513        self.pending_events
514            .push(ShellEvent::ShellKindChanged { shell_kind });
515    }
516
517    /// Replaces process status and emits an event only when unequal.
518    ///
519    /// # Examples
520    ///
521    /// ```
522    /// use ailloli_ui_terminal_core::{ShellExecutionState, TerminalProcessStatus};
523    /// let mut shell = ShellExecutionState::new(); shell.set_process_status(TerminalProcessStatus::Running);
524    /// assert_eq!(shell.process_status, TerminalProcessStatus::Running);
525    /// ```
526    pub fn set_process_status(&mut self, status: TerminalProcessStatus) {
527        if self.process_status == status {
528            return;
529        }
530        self.process_status = status;
531        self.pending_events
532            .push(ShellEvent::ProcessStatusChanged { status });
533    }
534
535    /// Marks a visible prompt and always emits `PromptStart`.
536    ///
537    /// Repeated calls for the same line are not deduplicated here; use
538    /// [`Self::apply_prompt_heuristic`] for heuristic deduplication.
539    ///
540    /// # Examples
541    ///
542    /// ```
543    /// use ailloli_ui_terminal_core::ShellExecutionState;
544    /// let mut shell = ShellExecutionState::new(); shell.mark_prompt_start(9);
545    /// assert!(shell.prompt_visible && shell.last_prompt_line == Some(9));
546    /// ```
547    pub fn mark_prompt_start(&mut self, line: u64) {
548        self.prompt_visible = true;
549        self.last_prompt_line = Some(line);
550        self.pending_events.push(ShellEvent::PromptStart { line });
551    }
552
553    /// Starts a command, superseding any currently running command.
554    ///
555    /// A superseded command is closed at `line`, forced to `Unknown`, retained,
556    /// and emits `CommandEnd`. The new command inherits state CWD only when the
557    /// argument is `None`, hides the prompt, sets process status to running, and
558    /// emits `CommandStart`. A status-change event precedes the start event when
559    /// status was not already running. IDs allocate with saturation.
560    ///
561    /// # Examples
562    ///
563    /// ```
564    /// use ailloli_ui_terminal_core::{CommandStatus, ShellExecutionState};
565    /// let mut shell = ShellExecutionState::new();
566    /// shell.start_command("cargo test", None, 4, Some(100));
567    /// assert_eq!(shell.current_command.as_ref().unwrap().status, CommandStatus::Running);
568    /// assert!(!shell.prompt_visible);
569    /// ```
570    pub fn start_command(
571        &mut self,
572        command_line: impl Into<String>,
573        cwd_uri: Option<String>,
574        line: u64,
575        started_at_ms: Option<u64>,
576    ) {
577        if let Some(mut previous) = self.current_command.take() {
578            previous.finish(line, None, None, started_at_ms, None);
579            previous.status = CommandStatus::Unknown;
580            self.push_finished_command(previous);
581        }
582
583        let command = CommandExecution::running(
584            self.allocate_command_id(),
585            command_line,
586            cwd_uri.or_else(|| self.cwd_uri.clone()),
587            line,
588            started_at_ms,
589        );
590        self.prompt_visible = false;
591        self.set_process_status(TerminalProcessStatus::Running);
592        self.pending_events.push(ShellEvent::CommandStart {
593            command: command.clone(),
594        });
595        self.current_command = Some(command);
596    }
597
598    /// Finishes the current command, or synthesizes an empty one when idle.
599    ///
600    /// A signal takes precedence over exit code and sets process `Signaled`;
601    /// otherwise process becomes `Exited`. The status event (when changed)
602    /// precedes `CommandEnd`. `last_command` is always replaced; history retains
603    /// the command only when `history_limit > 0` and drops oldest excess entries.
604    /// No `CommandStart` event is emitted for a synthesized idle finish.
605    ///
606    /// # Examples
607    ///
608    /// ```
609    /// use ailloli_ui_terminal_core::{CommandStatus, ShellExecutionState};
610    /// let mut shell = ShellExecutionState::new(); shell.start_command("false", None, 1, None);
611    /// shell.finish_command(Some(1), None, 2, None, None);
612    /// assert_eq!(shell.last_command.as_ref().unwrap().status, CommandStatus::Failed);
613    /// ```
614    pub fn finish_command(
615        &mut self,
616        exit_code: Option<i32>,
617        signal: Option<i32>,
618        line: u64,
619        ended_at_ms: Option<u64>,
620        duration_ms: Option<u64>,
621    ) {
622        let mut command = if let Some(command) = self.current_command.take() {
623            command
624        } else {
625            CommandExecution::running(
626                self.allocate_command_id(),
627                String::new(),
628                self.cwd_uri.clone(),
629                line,
630                None,
631            )
632        };
633        command.finish(line, exit_code, signal, ended_at_ms, duration_ms);
634
635        if signal.is_some() {
636            self.set_process_status(TerminalProcessStatus::Signaled { signal });
637        } else {
638            self.set_process_status(TerminalProcessStatus::Exited { code: exit_code });
639        }
640
641        self.push_finished_command(command);
642    }
643
644    /// Detects a prompt only while idle and suppresses a duplicate same-line prompt.
645    ///
646    /// Returns `true` exactly when state changed and a `PromptStart` event was
647    /// appended. Detection is delegated to [`PromptDetector::detect_text`].
648    ///
649    /// # Examples
650    ///
651    /// ```
652    /// use ailloli_ui_terminal_core::ShellExecutionState;
653    /// let mut shell = ShellExecutionState::new();
654    /// assert!(shell.apply_prompt_heuristic("user@host:~$ ", 3));
655    /// assert!(!shell.apply_prompt_heuristic("user@host:~$ ", 3));
656    /// ```
657    pub fn apply_prompt_heuristic(&mut self, text: &str, line: u64) -> bool {
658        if self.current_command.is_some() || !PromptDetector::detect_text(text) {
659            return false;
660        }
661        if self.prompt_visible && self.last_prompt_line == Some(line) {
662            return false;
663        }
664        self.mark_prompt_start(line);
665        true
666    }
667
668    /// Allocates the current ID and advances with saturation.
669    fn allocate_command_id(&mut self) -> CommandId {
670        let id = CommandId(self.next_command_id);
671        self.next_command_id = self.next_command_id.saturating_add(1);
672        id
673    }
674
675    /// Updates last/history state, trims oldest entries, and emits `CommandEnd`.
676    ///
677    /// Front removal makes repeated trimming linear in retained history length.
678    fn push_finished_command(&mut self, command: CommandExecution) {
679        self.last_command = Some(command.clone());
680        if self.history_limit > 0 {
681            self.history.push(command.clone());
682            while self.history.len() > self.history_limit {
683                self.history.remove(0);
684            }
685        }
686        self.pending_events.push(ShellEvent::CommandEnd { command });
687    }
688}
689
690impl Default for ShellExecutionState {
691    fn default() -> Self {
692        Self {
693            shell_kind: ShellKind::Unknown,
694            process_status: TerminalProcessStatus::Unknown,
695            cwd_uri: None,
696            prompt_visible: false,
697            current_command: None,
698            last_command: None,
699            history: Vec::new(),
700            history_limit: DEFAULT_COMMAND_HISTORY_LIMIT,
701            next_command_id: 1,
702            last_prompt_line: None,
703            pending_events: Vec::new(),
704        }
705    }
706}
707
708/// Stateless conservative detector for common visible shell prompt suffixes.
709///
710/// # Examples
711///
712/// ```
713/// use ailloli_ui_terminal_core::PromptDetector;
714/// assert!(PromptDetector::detect_text("~/repo$ "));
715/// ```
716pub struct PromptDetector;
717
718impl PromptDetector {
719    /// Heuristically recognizes prompts ending in `$`, `#`, `>`, or `❯`.
720    ///
721    /// Trailing whitespace is ignored. `❯` and a marker with an empty prefix
722    /// are accepted. Other markers require a preceding whitespace/punctuation,
723    /// or a prefix containing `@`/`/` or starting with `~`. Matching is purely
724    /// textual and can produce false positives/negatives.
725    ///
726    /// # Examples
727    ///
728    /// ```
729    /// use ailloli_ui_terminal_core::PromptDetector;
730    /// assert!(PromptDetector::detect_text("dev@host:~/repo$  "));
731    /// assert!(!PromptDetector::detect_text("price$99"));
732    /// ```
733    pub fn detect_text(text: &str) -> bool {
734        let trimmed = text.trim_end();
735        let Some(marker) = trimmed.chars().last() else {
736            return false;
737        };
738        if !matches!(marker, '$' | '#' | '>' | '❯') {
739            return false;
740        }
741        let prefix = trimmed.trim_end_matches(marker);
742        if prefix.is_empty() || marker == '❯' {
743            return true;
744        }
745        let Some(previous) = prefix.chars().last() else {
746            return true;
747        };
748        previous.is_whitespace()
749            || matches!(previous, ':' | '/' | '~' | ')' | ']' | '}')
750            || prefix.contains('@')
751            || prefix.contains('/')
752            || prefix.starts_with('~')
753    }
754}
755
756/// Returns the built-in static shell-integration script for a known shell.
757///
758/// Bash, Zsh, and Fish scripts emit private OSC 9001 events in the
759/// `ailloli_ui:` namespace. [`ShellKind::Unknown`] returns the empty string.
760/// Returning a script does not evaluate it or consult
761/// [`crate::TerminalSecurityPolicy`]; the host owns consent and installation.
762///
763/// # Examples
764///
765/// ```
766/// use ailloli_ui_terminal_core::{shell_integration_script, ShellKind};
767/// assert!(shell_integration_script(ShellKind::Bash).contains("ailloli_ui:prompt_start"));
768/// assert_eq!(shell_integration_script(ShellKind::Unknown), "");
769/// ```
770pub fn shell_integration_script(kind: ShellKind) -> &'static str {
771    match kind {
772        ShellKind::Bash => BASH_INTEGRATION_SCRIPT,
773        ShellKind::Zsh => ZSH_INTEGRATION_SCRIPT,
774        ShellKind::Fish => FISH_INTEGRATION_SCRIPT,
775        ShellKind::Unknown => "",
776    }
777}
778
779/// Derives command outcome with signal taking precedence over exit code.
780fn command_status_from_exit(exit_code: Option<i32>, signal: Option<i32>) -> CommandStatus {
781    if signal.is_some() {
782        CommandStatus::Interrupted
783    } else {
784        match exit_code {
785            Some(0) => CommandStatus::Succeeded,
786            Some(_) => CommandStatus::Failed,
787            None => CommandStatus::Unknown,
788        }
789    }
790}
791
792/// Serde default provider for command history limit.
793fn default_command_history_limit() -> usize {
794    DEFAULT_COMMAND_HISTORY_LIMIT
795}
796
797/// Bash hooks emitting escaped command, CWD, exit, and prompt OSC 9001 events.
798const BASH_INTEGRATION_SCRIPT: &str = r#"__ailloli_ui_osc_escape() {
799  local value="$1"
800  value="${value//'%'/'%25'}"
801  value="${value//';'/'%3B'}"
802  value="${value//$'\n'/'%0A'}"
803  printf '%s' "$value"
804}
805__ailloli_ui_prompt_command() {
806  local code="$?"
807  printf '\033]9001;ailloli_ui:command_end;exit=%s\007' "$code"
808  printf '\033]9001;ailloli_ui:cwd;uri=file://%s\007' "$(__ailloli_ui_osc_escape "$PWD")"
809  printf '\033]9001;ailloli_ui:prompt_start\007'
810}
811__ailloli_ui_debug_trap() {
812  printf '\033]9001;ailloli_ui:command_start;cmd=%s;cwd=file://%s\007' "$(__ailloli_ui_osc_escape "$BASH_COMMAND")" "$(__ailloli_ui_osc_escape "$PWD")"
813}
814PROMPT_COMMAND="__ailloli_ui_prompt_command${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
815trap '__ailloli_ui_debug_trap' DEBUG
816"#;
817
818/// Zsh `preexec`/`precmd` hooks emitting OSC 9001 lifecycle events.
819const ZSH_INTEGRATION_SCRIPT: &str = r#"__ailloli_ui_osc_escape() {
820  local value="$1"
821  value="${value//'%'/'%25'}"
822  value="${value//';'/'%3B'}"
823  value="${value//$'\n'/'%0A'}"
824  printf '%s' "$value"
825}
826preexec() {
827  printf '\033]9001;ailloli_ui:command_start;cmd=%s;cwd=file://%s\007' "$(__ailloli_ui_osc_escape "$1")" "$(__ailloli_ui_osc_escape "$PWD")"
828}
829precmd() {
830  local code="$?"
831  printf '\033]9001;ailloli_ui:command_end;exit=%s\007' "$code"
832  printf '\033]9001;ailloli_ui:cwd;uri=file://%s\007' "$(__ailloli_ui_osc_escape "$PWD")"
833  printf '\033]9001;ailloli_ui:prompt_start\007'
834}
835"#;
836
837/// Fish event handlers emitting OSC 9001 lifecycle events.
838const FISH_INTEGRATION_SCRIPT: &str = r#"function __ailloli_ui_osc_escape
839  string replace -a '%' '%25' -- $argv[1] | string replace -a ';' '%3B'
840end
841function __ailloli_ui_preexec --on-event fish_preexec
842  printf '\033]9001;ailloli_ui:command_start;cmd=%s;cwd=file://%s\007' (__ailloli_ui_osc_escape "$argv[1]") (__ailloli_ui_osc_escape "$PWD")
843end
844function __ailloli_ui_postexec --on-event fish_postexec
845  printf '\033]9001;ailloli_ui:command_end;exit=%s\007' $status
846end
847function __ailloli_ui_prompt --on-event fish_prompt
848  printf '\033]9001;ailloli_ui:cwd;uri=file://%s\007' (__ailloli_ui_osc_escape "$PWD")
849  printf '\033]9001;ailloli_ui:prompt_start\007'
850end
851"#;
852
853#[cfg(test)]
854mod tests {
855    //! Covers lifecycle status/ranges, bounded history, prompt heuristics, and scripts.
856    use super::*;
857
858    #[test]
859    fn shell_default_state_is_unknown_and_idle() {
860        let shell = ShellExecutionState::default();
861
862        assert_eq!(shell.shell_kind, ShellKind::Unknown);
863        assert_eq!(shell.process_status, TerminalProcessStatus::Unknown);
864        assert!(!shell.prompt_visible);
865        assert!(shell.current_command.is_none());
866        assert!(shell.history.is_empty());
867    }
868
869    #[test]
870    fn shell_command_start_end_records_status_and_range() {
871        let mut shell = ShellExecutionState::default();
872        shell.set_cwd_uri("file:///tmp/ailloli_ui");
873        shell.start_command("cargo test", None, 10, Some(100));
874        shell.finish_command(Some(0), None, 14, Some(250), None);
875
876        let command = shell.last_command.as_ref().expect("last command");
877        assert_eq!(command.command_line, "cargo test");
878        assert_eq!(command.cwd_uri.as_deref(), Some("file:///tmp/ailloli_ui"));
879        assert_eq!(command.status, CommandStatus::Succeeded);
880        assert_eq!(command.duration_ms, Some(150));
881        assert_eq!(command.output_range.start_line, 10);
882        assert_eq!(command.output_range.end_line, Some(14));
883        assert_eq!(shell.history.len(), 1);
884        assert_eq!(
885            shell.process_status,
886            TerminalProcessStatus::Exited { code: Some(0) }
887        );
888    }
889
890    #[test]
891    fn shell_history_limit_preserves_order() {
892        let mut shell = ShellExecutionState {
893            history_limit: 2,
894            ..ShellExecutionState::default()
895        };
896
897        for command in ["one", "two", "three"] {
898            shell.start_command(command, None, 1, None);
899            shell.finish_command(Some(1), None, 2, None, None);
900        }
901
902        assert_eq!(shell.history.len(), 2);
903        assert_eq!(shell.history[0].command_line, "two");
904        assert_eq!(shell.history[1].command_line, "three");
905        assert_eq!(shell.history[1].status, CommandStatus::Failed);
906    }
907
908    #[test]
909    fn shell_prompt_heuristic_detects_common_prompts_and_limits_false_positives() {
910        assert!(PromptDetector::detect_text("dev@example:~/repo$     "));
911        assert!(PromptDetector::detect_text("/tmp/project# "));
912        assert!(PromptDetector::detect_text("❯ "));
913        assert!(!PromptDetector::detect_text("total cost $99"));
914        assert!(!PromptDetector::detect_text("money$"));
915    }
916
917    #[test]
918    fn shell_snapshot_and_events_are_deterministic() {
919        let mut shell = ShellExecutionState::default();
920        shell.set_shell_kind(ShellKind::Zsh);
921        shell.mark_prompt_start(3);
922
923        let snapshot = shell.snapshot();
924        assert_eq!(snapshot.shell_kind, ShellKind::Zsh);
925        assert!(snapshot.prompt_visible);
926        assert_eq!(snapshot.last_prompt_line, Some(3));
927
928        let events = shell.drain_events();
929        assert_eq!(events.len(), 2);
930        assert!(shell.pending_events.is_empty());
931    }
932
933    #[test]
934    fn shell_integration_scripts_emit_only_ailloli_ui_namespace() {
935        for kind in [ShellKind::Bash, ShellKind::Zsh, ShellKind::Fish] {
936            let script = shell_integration_script(kind);
937            assert!(script.contains("9001;ailloli_ui:command_start"));
938            assert!(script.contains("9001;ailloli_ui:prompt_start"));
939            assert!(!script.contains("9001;octavui:"));
940        }
941        assert_eq!(shell_integration_script(ShellKind::Unknown), "");
942    }
943}