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}