From 21bfc41b4b553a2530ae3a12f3b94ac9ef71dbb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Sun, 20 Sep 2026 10:14:12 +0800 Subject: [PATCH 01/32] refactor(command): introduce fully typed command invocations - separate stable command identities from executable command payloads - move RCSB download and structure arguments into chitin-command - distinguish command descriptors from search and presentation metadata - route CLI and desktop forms through the shared typed command model - remove form-specific invocation semantics from the core command layer - add coverage for parameterized commands and validated RCSB arguments --- Cargo.lock | 3 + crates/chitin-cli/src/cli.rs | 82 ++++---- crates/chitin-cli/src/download.rs | 21 +- crates/chitin-cli/src/structure.rs | 63 +++--- crates/chitin-command/Cargo.toml | 3 + crates/chitin-command/src/application.rs | 19 +- crates/chitin-command/src/database.rs | 42 ++-- crates/chitin-command/src/lib.rs | 199 ++++++++++++++---- crates/chitin-command/src/panel_tab.rs | 23 +- crates/chitin-command/src/structure.rs | 57 ++++- crates/chitin-command/src/workspace.rs | 29 +-- .../src/components/command_panel.rs | 126 +++++++---- .../components/command_panel/controller.rs | 25 ++- .../src/keybindings/dispatch.rs | 33 +-- 14 files changed, 480 insertions(+), 245 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index edb38139..1b9020f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -727,6 +727,9 @@ dependencies = [ [[package]] name = "chitin-command" version = "0.1.0" +dependencies = [ + "chitin-databases", +] [[package]] name = "chitin-databases" diff --git a/crates/chitin-cli/src/cli.rs b/crates/chitin-cli/src/cli.rs index 71009013..8df93f74 100644 --- a/crates/chitin-cli/src/cli.rs +++ b/crates/chitin-cli/src/cli.rs @@ -2,8 +2,11 @@ use std::path::PathBuf; -use chitin_command::{ChitinCommand, DatabaseCommand, StructureCommand}; -use chitin_databases::providers::rcsb::StructureFormat; +use chitin_command::{ + ChitinCommand, CommandOutputFormat, DatabaseCommand, RcsbDownloadArguments, StructureCommand, + StructureInputArguments, StructureInspectArguments, StructureValidateArguments, +}; +use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; @@ -136,6 +139,25 @@ impl FormatArg { } } +impl From for CommandOutputFormat { + fn from(output: OutputArg) -> Self { + match output { + OutputArg::Text => Self::Text, + OutputArg::Json => Self::Json, + } + } +} + +impl StructureInputArgs { + /// Converts parsed CLI input into frontend-independent command arguments. + fn into_command_arguments(self) -> StructureInputArguments { + StructureInputArguments { + input: self.input, + format: self.format.map(FormatArg::structure_format), + } + } +} + /// Dispatches a parsed CLI workflow. /// /// # Parameters @@ -163,26 +185,18 @@ pub(crate) async fn dispatch(command: CliCommand) -> Result<(), CliError> { /// Dispatches a structure command through the shared command bus. async fn dispatch_structure_command(command: StructureSubcommand) -> Result<(), CliError> { - match command { - StructureSubcommand::Inspect(args) => { - crate::structure::dispatch( - ChitinCommand::from(StructureCommand::Inspect), - args.input, - Some(args.output), - args.verbose, - ) - .await - } - StructureSubcommand::Validate(args) => { - crate::structure::dispatch( - ChitinCommand::from(StructureCommand::Validate), - args.input, - Some(args.output), - false, - ) - .await - } - } + let command = match command { + StructureSubcommand::Inspect(args) => StructureCommand::Inspect(StructureInspectArguments { + input: args.input.into_command_arguments(), + output: args.output.into(), + verbose: args.verbose, + }), + StructureSubcommand::Validate(args) => StructureCommand::Validate(StructureValidateArguments { + input: args.input.into_command_arguments(), + output: args.output.into(), + }), + }; + dispatch_command(command.into()).await } /// Dispatches a database command to its provider-specific workflow. @@ -220,8 +234,12 @@ async fn dispatch_database_command(command: DatabaseSubcommand) -> Result<(), Cl async fn dispatch_rcsb_command(command: RcsbSubcommand) -> Result<(), CliError> { match command { RcsbSubcommand::Download { id, format, output } => { - let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure); - dispatch_command(command, id, format.structure_format(), output).await + let command = DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ids: PdbId::parse_many(&id)?, + format: format.structure_format(), + output, + }); + dispatch_command(command.into()).await } } } @@ -231,10 +249,6 @@ async fn dispatch_rcsb_command(command: RcsbSubcommand) -> Result<(), CliError> /// # Parameters /// /// * `command` identifies the typed command to dispatch. -/// * `raw_id` contains one or more comma-separated PDB identifiers. -/// * `format` selects the structure file format. -/// * `output` optionally overrides the generated output path. -/// /// # Returns /// /// Returns `Ok(())` after the command completes successfully. @@ -243,14 +257,10 @@ async fn dispatch_rcsb_command(command: RcsbSubcommand) -> Result<(), CliError> /// /// Returns [`CliError`] when the command is unsupported or the RCSB download /// fails. -async fn dispatch_command( - command: ChitinCommand, - raw_id: String, - format: StructureFormat, - output: Option, -) -> Result<(), CliError> { +async fn dispatch_command(command: ChitinCommand) -> Result<(), CliError> { match command { - ChitinCommand::Database(DatabaseCommand::DownloadRcsbStructure) => download_rcsb(raw_id, format, output).await, - other => Err(CliError::UnsupportedCommand(other.id())), + ChitinCommand::Database(DatabaseCommand::DownloadRcsbStructure(arguments)) => download_rcsb(arguments).await, + command @ ChitinCommand::Structure(_) => crate::structure::dispatch(command).await, + other => Err(CliError::UnsupportedCommand(other.id().as_str())), } } diff --git a/crates/chitin-cli/src/download.rs b/crates/chitin-cli/src/download.rs index ac9fdbd8..2d3cc1e8 100644 --- a/crates/chitin-cli/src/download.rs +++ b/crates/chitin-cli/src/download.rs @@ -1,40 +1,31 @@ //! RCSB download execution and terminal progress reporting. use std::{ - path::PathBuf, sync::{Arc, Mutex}, time::Duration, }; use chitin_databases::{ Client, ClientConfig, - providers::rcsb::{ - PdbId, RcsbBatchDownloadEvent, RcsbBatchDownloadRequest, RcsbDownloadError, RcsbError, StructureFormat, - }, + providers::rcsb::{RcsbBatchDownloadEvent, RcsbBatchDownloadRequest, RcsbDownloadError, RcsbError}, }; use indicatif::{ProgressBar, ProgressStyle}; +use chitin_command::RcsbDownloadArguments; + use crate::{error::CliError, output::resolve_output_path}; /// Downloads an RCSB artifact and writes it to the resolved output path. /// /// # Parameters /// -/// * `raw_id` is the user-provided comma-separated list of four-character PDB -/// identifiers such as 4HHB,1YTH. -/// * `format` selects PDB or mmCIF content. -/// * `output` is an optional file or directory override. +/// * `arguments` contains validated identifiers, format, and output override. /// /// # Returns /// /// `Ok(())` after the artifact has been written and reported to the terminal. -pub(crate) async fn download_rcsb( - raw_ids: String, - format: StructureFormat, - output: Option, -) -> Result<(), CliError> { - // we use `,` as the splitter of different PDB IDs. - let ids = PdbId::parse_many(&raw_ids)?; +pub(crate) async fn download_rcsb(arguments: RcsbDownloadArguments) -> Result<(), CliError> { + let RcsbDownloadArguments { ids, format, output } = arguments; let multiple = ids.len() > 1; let client = Client::new(ClientConfig::default()) .map_err(|error| CliError::Rcsb(RcsbDownloadError::Provider(RcsbError::Transport(error))))?; diff --git a/crates/chitin-cli/src/structure.rs b/crates/chitin-cli/src/structure.rs index 6aed3d3e..4ec46b62 100644 --- a/crates/chitin-cli/src/structure.rs +++ b/crates/chitin-cli/src/structure.rs @@ -7,45 +7,42 @@ use std::{ }; use chitin_bio::structure::{MmcifParser, PdbParser, Structure, StructureParseResult}; -use chitin_command::{ChitinCommand, StructureCommand}; +use chitin_command::{ChitinCommand, CommandOutputFormat, StructureCommand, StructureInputArguments}; use chitin_databases::providers::rcsb::StructureFormat; use console::Style; use serde_json::{Value, json}; -use crate::{ - cli::{FormatArg, OutputArg, StructureInputArgs}, - error::CliError, -}; +use crate::error::CliError; /// Executes a structure inspection or validation command. -pub(crate) async fn dispatch( - command: ChitinCommand, - input: StructureInputArgs, - output: Option, - verbose: bool, -) -> Result<(), CliError> { - let (format, bytes) = read_input(&input.input, input.format)?; - let parsed = parse_structure(format, &input.input, &bytes)?; +pub(crate) async fn dispatch(command: ChitinCommand) -> Result<(), CliError> { match command { - ChitinCommand::Structure(StructureCommand::Inspect) => print_inspection( - &input.input, - format, - bytes.len(), - &parsed, - output.unwrap_or(OutputArg::Text), - verbose, - ), - ChitinCommand::Structure(StructureCommand::Validate) => { - validate_structure(&input.input, format, &parsed, output.unwrap_or(OutputArg::Text)) + ChitinCommand::Structure(StructureCommand::Inspect(arguments)) => { + let (format, bytes) = read_input(&arguments.input)?; + let parsed = parse_structure(format, &arguments.input.input, &bytes)?; + print_inspection( + &arguments.input.input, + format, + bytes.len(), + &parsed, + arguments.output, + arguments.verbose, + ) + } + ChitinCommand::Structure(StructureCommand::Validate(arguments)) => { + let (format, bytes) = read_input(&arguments.input)?; + let parsed = parse_structure(format, &arguments.input.input, &bytes)?; + validate_structure(&arguments.input.input, format, &parsed, arguments.output) } - other => Err(CliError::UnsupportedCommand(other.id())), + other => Err(CliError::UnsupportedCommand(other.id().as_str())), } } /// Reads a local file or stdin and resolves its structure format. -fn read_input(path: &Path, requested: Option) -> Result<(StructureFormat, Vec), CliError> { - let format = requested - .map(FormatArg::structure_format) +fn read_input(input: &StructureInputArguments) -> Result<(StructureFormat, Vec), CliError> { + let path = &input.input; + let format = input + .format .or_else(|| detect_format(path)) .ok_or_else(|| CliError::StructureFormat(path.to_owned()))?; let mut bytes = Vec::new(); @@ -87,12 +84,12 @@ fn print_inspection( format: StructureFormat, byte_count: usize, parsed: &StructureParseResult, - output: OutputArg, + output: CommandOutputFormat, verbose: bool, ) -> Result<(), CliError> { match output { - OutputArg::Text => print_text_summary(path, format, byte_count, parsed, verbose), - OutputArg::Json => print_json_summary(path, format, byte_count, parsed, verbose), + CommandOutputFormat::Text => print_text_summary(path, format, byte_count, parsed, verbose), + CommandOutputFormat::Json => print_json_summary(path, format, byte_count, parsed, verbose), } } @@ -101,7 +98,7 @@ fn validate_structure( path: &Path, format: StructureFormat, parsed: &StructureParseResult, - output: OutputArg, + output: CommandOutputFormat, ) -> Result<(), CliError> { let validation = parsed .structure @@ -109,7 +106,7 @@ fn validate_structure( .map_err(|error| error.to_string()) .and_then(|()| validate_content(&parsed.structure)); match output { - OutputArg::Text => { + CommandOutputFormat::Text => { if let Err(message) = validation { let error = Style::new().red().bold(); eprintln!("{} {}", error.apply_to("✗ Invalid structure:"), message); @@ -126,7 +123,7 @@ fn validate_structure( format.label() ); } - OutputArg::Json => { + CommandOutputFormat::Json => { let valid = validation.is_ok(); let value = json!({ "path": path.display().to_string(), diff --git a/crates/chitin-command/Cargo.toml b/crates/chitin-command/Cargo.toml index 1306d249..50f743e6 100644 --- a/crates/chitin-command/Cargo.toml +++ b/crates/chitin-command/Cargo.toml @@ -5,5 +5,8 @@ edition.workspace = true license.workspace = true repository.workspace = true +[dependencies] +chitin-databases = { path = "../chitin-databases" } + [lints] workspace = true diff --git a/crates/chitin-command/src/application.rs b/crates/chitin-command/src/application.rs index f8328f2e..37dac1b4 100644 --- a/crates/chitin-command/src/application.rs +++ b/crates/chitin-command/src/application.rs @@ -7,22 +7,23 @@ pub enum ApplicationCommand { impl ApplicationCommand { /// Returns the stable command identifier. - pub fn id(&self) -> &'static str { + pub fn id(&self) -> crate::CommandId { match self { - Self::ToggleCommandPanel => "application.toggle_command_panel", + Self::ToggleCommandPanel => crate::CommandId::ApplicationToggleCommandPanel, } } } -/// Returns the application command descriptors. -pub fn command_descriptors() -> Vec { - vec![crate::CommandDescriptor { - id: ApplicationCommand::ToggleCommandPanel.id(), - title: "Toggle Command Panel", +/// Returns the application command registrations. +pub fn command_registrations() -> Vec { + vec![crate::CommandRegistration { + descriptor: crate::CommandDescriptor { + id: ApplicationCommand::ToggleCommandPanel.id(), + title: "Toggle Command Panel", + requires_arguments: false, + }, category: crate::CommandCategory::Application, keywords: &["quick pick", "palette", "commands"], shortcut: Some("Ctrl/Cmd+Shift+P"), - invocation: crate::CommandInvocationKind::Immediate, - command: ApplicationCommand::ToggleCommandPanel.into(), }] } diff --git a/crates/chitin-command/src/database.rs b/crates/chitin-command/src/database.rs index 24a434bf..c567ea91 100644 --- a/crates/chitin-command/src/database.rs +++ b/crates/chitin-command/src/database.rs @@ -1,28 +1,46 @@ -/// Database-provider commands. +use std::path::PathBuf; + +use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; + +use crate::CommandId; + +/// Arguments for downloading one or more structures from RCSB. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RcsbDownloadArguments { + /// Canonical RCSB identifiers to download in input order. + pub ids: Vec, + /// Structure file format requested from RCSB. + pub format: StructureFormat, + /// Optional output file or directory override. + pub output: Option, +} + +/// Executable database-provider commands. #[derive(Clone, Debug, PartialEq, Eq)] pub enum DatabaseCommand { - /// Open the RCSB structure download workflow. - DownloadRcsbStructure, + /// Download one or more RCSB structure files. + DownloadRcsbStructure(RcsbDownloadArguments), } impl DatabaseCommand { /// Returns the stable command identifier. - pub fn id(&self) -> &'static str { + pub fn id(&self) -> CommandId { match self { - Self::DownloadRcsbStructure => "database.rcsb.download_structure", + Self::DownloadRcsbStructure(_) => CommandId::DatabaseDownloadRcsbStructure, } } } -/// Returns the database command descriptors. -pub fn command_descriptors() -> Vec { - vec![crate::CommandDescriptor { - id: DatabaseCommand::DownloadRcsbStructure.id(), - title: "Download RCSB Structure", +/// Returns the database command registrations. +pub fn command_registrations() -> Vec { + vec![crate::CommandRegistration { + descriptor: crate::CommandDescriptor { + id: CommandId::DatabaseDownloadRcsbStructure, + title: "Download RCSB Structure", + requires_arguments: true, + }, category: crate::CommandCategory::Database, keywords: &["pdb", "rcsb", "mmcif", "structure"], shortcut: None, - invocation: crate::CommandInvocationKind::Form, - command: DatabaseCommand::DownloadRcsbStructure.into(), }] } diff --git a/crates/chitin-command/src/lib.rs b/crates/chitin-command/src/lib.rs index e4919031..e8cb5b13 100644 --- a/crates/chitin-command/src/lib.rs +++ b/crates/chitin-command/src/lib.rs @@ -7,11 +7,89 @@ mod structure; mod workspace; pub use application::ApplicationCommand; -pub use database::DatabaseCommand; +pub use database::{DatabaseCommand, RcsbDownloadArguments}; pub use panel_tab::PanelTabCommand; -pub use structure::StructureCommand; +pub use structure::{ + CommandOutputFormat, StructureCommand, StructureInputArguments, StructureInspectArguments, StructureValidateArguments, +}; pub use workspace::WorkspaceCommand; +/// Stable identity of a command independent of its invocation arguments. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CommandId { + /// Focus the previous project entry. + WorkspaceFocusPrevious, + /// Focus the next project entry. + WorkspaceFocusNext, + /// Activate the focused project entry. + WorkspaceActivateFocused, + /// Focus the first project entry. + WorkspaceFocusFirst, + /// Focus the last project entry. + WorkspaceFocusLast, + /// Show or hide the workspace sidebar. + WorkspaceToggle, + /// Focus the previous document tab. + PanelTabFocusPrevious, + /// Focus the next document tab. + PanelTabFocusNext, + /// Close the active document tab. + PanelTabClose, + /// Download one or more RCSB structures. + DatabaseDownloadRcsbStructure, + /// Show or hide the command panel. + ApplicationToggleCommandPanel, + /// Inspect a local structure file. + StructureInspect, + /// Validate a local structure file. + StructureValidate, +} + +impl CommandId { + /// Returns the stable dotted identifier. + pub const fn as_str(self) -> &'static str { + match self { + Self::WorkspaceFocusPrevious => "workspace.focus_previous_entry", + Self::WorkspaceFocusNext => "workspace.focus_next_entry", + Self::WorkspaceActivateFocused => "workspace.activate_focused_entry", + Self::WorkspaceFocusFirst => "workspace.focus_first_entry", + Self::WorkspaceFocusLast => "workspace.focus_last_entry", + Self::WorkspaceToggle => "workspace.toggle_workspace", + Self::PanelTabFocusPrevious => "tab.focus_previous", + Self::PanelTabFocusNext => "tab.focus_next", + Self::PanelTabClose => "tab.close", + Self::DatabaseDownloadRcsbStructure => "database.rcsb.download_structure", + Self::ApplicationToggleCommandPanel => "application.toggle_command_panel", + Self::StructureInspect => "structure.inspect", + Self::StructureValidate => "structure.validate", + } + } + + /// Builds an executable command when this identity needs no arguments. + pub fn command_without_arguments(self) -> Option { + match self { + Self::WorkspaceFocusPrevious => Some(WorkspaceCommand::FocusPrevious.into()), + Self::WorkspaceFocusNext => Some(WorkspaceCommand::FocusNext.into()), + Self::WorkspaceActivateFocused => Some(WorkspaceCommand::ActivateFocused.into()), + Self::WorkspaceFocusFirst => Some(WorkspaceCommand::FocusFirst.into()), + Self::WorkspaceFocusLast => Some(WorkspaceCommand::FocusLast.into()), + Self::WorkspaceToggle => Some(WorkspaceCommand::ToggleWorkspace.into()), + Self::PanelTabFocusPrevious => Some(PanelTabCommand::FocusPrevious.into()), + Self::PanelTabFocusNext => Some(PanelTabCommand::FocusNext.into()), + Self::PanelTabClose => Some(PanelTabCommand::Close.into()), + Self::ApplicationToggleCommandPanel => Some(ApplicationCommand::ToggleCommandPanel.into()), + Self::DatabaseDownloadRcsbStructure | Self::StructureInspect | Self::StructureValidate => None, + } + } +} + +impl std::fmt::Display for CommandId { + /// Formats the stable dotted identifier. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + /// Top-level command hierarchy shared by desktop, terminal, and CLI inputs. #[derive(Clone, Debug, PartialEq, Eq)] pub enum ChitinCommand { @@ -26,8 +104,8 @@ pub enum ChitinCommand { } impl ChitinCommand { - /// Returns the stable dotted identifier for this command. - pub fn id(&self) -> &'static str { + /// Returns the stable identity for this command. + pub fn id(&self) -> CommandId { match self { Self::Workspace(command) => command.id(), Self::Database(command) => command.id(), @@ -89,32 +167,28 @@ impl CommandCategory { } } -/// Describes what happens after a command is selected. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CommandInvocationKind { - /// Execute immediately. - Immediate, - /// Request a command-specific interactive workflow. - Form, -} - -/// Searchable metadata for a command. +/// Frontend-independent description of one available command. #[derive(Clone, Debug, PartialEq, Eq)] pub struct CommandDescriptor { /// Stable command identifier. - pub id: &'static str, + pub id: CommandId, /// User-facing title. pub title: &'static str, - /// Search category. + /// Whether an input adapter must collect arguments before execution. + pub requires_arguments: bool, +} + +/// Search and presentation metadata attached to a command registration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandRegistration { + /// Frontend-independent command description. + pub descriptor: CommandDescriptor, + /// Search result category. pub category: CommandCategory, - /// Search keywords. + /// Additional search keywords. pub keywords: &'static [&'static str], - /// Optional shortcut label. + /// Optional shortcut label shown by search frontends. pub shortcut: Option<&'static str>, - /// Invocation behavior. - pub invocation: CommandInvocationKind, - /// Typed command payload. - pub command: ChitinCommand, } /// A ranked command result. @@ -124,25 +198,31 @@ pub struct CommandSearchResult<'a> { pub score: i32, /// Matching descriptor. pub descriptor: &'a CommandDescriptor, + /// Optional shortcut label associated with the registration. + pub shortcut: Option<&'static str>, } /// Registry and deterministic search implementation for command descriptors. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CommandRegistry { - commands: Vec, + commands: Vec, } impl CommandRegistry { /// Creates a registry from command descriptors. - pub fn new(commands: impl IntoIterator) -> Self { + pub fn new(commands: impl IntoIterator) -> Self { Self { commands: commands.into_iter().collect(), } } - /// Finds a descriptor by its typed command. - pub fn descriptor_for(&self, command: &ChitinCommand) -> Option<&CommandDescriptor> { - self.commands.iter().find(|descriptor| &descriptor.command == command) + /// Finds a descriptor by stable command identity. + pub fn descriptor_for(&self, id: CommandId) -> Option<&CommandDescriptor> { + self + .commands + .iter() + .map(|registration| ®istration.descriptor) + .find(|descriptor| descriptor.id == id) } /// Searches commands using deterministic scoring. @@ -151,8 +231,12 @@ impl CommandRegistry { let mut results = self .commands .iter() - .filter_map(|descriptor| { - score_descriptor(descriptor, &query).map(|score| CommandSearchResult { score, descriptor }) + .filter_map(|registration| { + score_registration(registration, &query).map(|score| CommandSearchResult { + score, + descriptor: ®istration.descriptor, + shortcut: registration.shortcut, + }) }) .collect::>(); results.sort_by(|left, right| { @@ -168,15 +252,15 @@ impl CommandRegistry { /// Creates the built-in registry from all shared command descriptors. pub fn default_registry() -> CommandRegistry { CommandRegistry::new( - workspace::command_descriptors() + workspace::command_registrations() .into_iter() - .chain(panel_tab::command_descriptors()) - .chain(database::command_descriptors()) - .chain(application::command_descriptors()), + .chain(panel_tab::command_registrations()) + .chain(database::command_registrations()) + .chain(application::command_registrations()), ) } -/// Scores a command descriptor against a normalized search query. +/// Scores a command registration against a normalized search query. /// /// Exact command identifiers receive the highest score, followed by title, /// identifier-prefix, title-prefix, and category matches. The ordering keeps @@ -185,19 +269,20 @@ pub fn default_registry() -> CommandRegistry { /// /// # Parameters /// -/// * `descriptor` is the command metadata being matched. +/// * `registration` is the command and search metadata being matched. /// * `query` is a trimmed, lowercase search query. /// /// # Returns /// /// A score when the descriptor matches, or `None` when it should be omitted. -fn score_descriptor(descriptor: &CommandDescriptor, query: &str) -> Option { +fn score_registration(registration: &CommandRegistration, query: &str) -> Option { if query.is_empty() { return Some(1); } - let id = normalize(descriptor.id); + let descriptor = ®istration.descriptor; + let id = normalize(descriptor.id.as_str()); let title = normalize(descriptor.title); - let category = normalize(descriptor.category.label()); + let category = normalize(registration.category.label()); // Check stronger matches first so a broad prefix cannot hide an exact hit. if id == query { return Some(1000); @@ -211,10 +296,10 @@ fn score_descriptor(descriptor: &CommandDescriptor, query: &str) -> Option if title.contains(query) { return Some(500); } - if descriptor.keywords.iter().any(|keyword| normalize(keyword) == query) { + if registration.keywords.iter().any(|keyword| normalize(keyword) == query) { return Some(420); } - if descriptor + if registration .keywords .iter() .any(|keyword| normalize(keyword).contains(query)) @@ -242,13 +327,45 @@ fn normalize(value: &str) -> String { #[cfg(test)] mod tests { + use std::path::{Path, PathBuf}; + + use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; + use super::*; #[test] fn command_ids_should_include_scope() { assert_eq!( ChitinCommand::from(WorkspaceCommand::FocusNext).id(), - "workspace.focus_next_entry" + CommandId::WorkspaceFocusNext ); } + + #[test] + fn parameterized_command_id_should_not_create_an_incomplete_command() { + assert_eq!( + CommandId::DatabaseDownloadRcsbStructure.command_without_arguments(), + None + ); + } + + #[test] + fn rcsb_download_command_should_retain_validated_arguments() -> Result<(), Box> { + let id = PdbId::new("4hhb")?; + let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ids: vec![id.clone()], + format: StructureFormat::Mmcif, + output: Some(PathBuf::from("structures")), + })); + + assert!(matches!( + command, + ChitinCommand::Database(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ids, + format: StructureFormat::Mmcif, + output: Some(output), + })) if ids == vec![id] && output == Path::new("structures") + )); + Ok(()) + } } diff --git a/crates/chitin-command/src/panel_tab.rs b/crates/chitin-command/src/panel_tab.rs index bde6a133..a0226382 100644 --- a/crates/chitin-command/src/panel_tab.rs +++ b/crates/chitin-command/src/panel_tab.rs @@ -11,17 +11,17 @@ pub enum PanelTabCommand { impl PanelTabCommand { /// Returns the stable command identifier. - pub fn id(&self) -> &'static str { + pub fn id(&self) -> crate::CommandId { match self { - Self::FocusPrevious => "tab.focus_previous", - Self::FocusNext => "tab.focus_next", - Self::Close => "tab.close", + Self::FocusPrevious => crate::CommandId::PanelTabFocusPrevious, + Self::FocusNext => crate::CommandId::PanelTabFocusNext, + Self::Close => crate::CommandId::PanelTabClose, } } } -/// Returns the document-panel tab command descriptors. -pub fn command_descriptors() -> Vec { +/// Returns the document-panel tab command registrations. +pub fn command_registrations() -> Vec { [ ( PanelTabCommand::FocusPrevious, @@ -43,14 +43,15 @@ pub fn command_descriptors() -> Vec { ), ] .into_iter() - .map(|(command, title, keywords, shortcut)| crate::CommandDescriptor { - id: command.id(), - title, + .map(|(command, title, keywords, shortcut)| crate::CommandRegistration { + descriptor: crate::CommandDescriptor { + id: command.id(), + title, + requires_arguments: false, + }, category: crate::CommandCategory::Workspace, keywords, shortcut, - invocation: crate::CommandInvocationKind::Immediate, - command: crate::ChitinCommand::from(crate::WorkspaceCommand::PanelTab(command)), }) .collect() } diff --git a/crates/chitin-command/src/structure.rs b/crates/chitin-command/src/structure.rs index 4525ba01..4406abde 100644 --- a/crates/chitin-command/src/structure.rs +++ b/crates/chitin-command/src/structure.rs @@ -1,18 +1,63 @@ -/// Structure parsing and inspection commands. +use std::path::PathBuf; + +use chitin_databases::providers::rcsb::StructureFormat; + +use crate::CommandId; + +/// Output representation requested by a command frontend. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CommandOutputFormat { + /// Human-readable text. + #[default] + Text, + /// Stable machine-readable JSON. + Json, +} + +/// Shared input arguments for local structure commands. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StructureInputArguments { + /// Structure file path, or `-` when the frontend supports standard input. + pub input: PathBuf, + /// Explicit input format, or `None` to infer it from the path. + pub format: Option, +} + +/// Arguments for printing a parsed structure summary. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StructureInspectArguments { + /// Local structure input. + pub input: StructureInputArguments, + /// Output representation. + pub output: CommandOutputFormat, + /// Whether detailed metadata and diagnostics should be included. + pub verbose: bool, +} + +/// Arguments for validating a local structure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StructureValidateArguments { + /// Local structure input. + pub input: StructureInputArguments, + /// Output representation. + pub output: CommandOutputFormat, +} + +/// Executable structure parsing and inspection commands. #[derive(Clone, Debug, PartialEq, Eq)] pub enum StructureCommand { /// Print a parsed structure summary. - Inspect, + Inspect(StructureInspectArguments), /// Parse a structure and verify its cross-table invariants. - Validate, + Validate(StructureValidateArguments), } impl StructureCommand { /// Returns the stable command identifier. - pub fn id(&self) -> &'static str { + pub fn id(&self) -> CommandId { match self { - Self::Inspect => "structure.inspect", - Self::Validate => "structure.validate", + Self::Inspect(_) => CommandId::StructureInspect, + Self::Validate(_) => CommandId::StructureValidate, } } } diff --git a/crates/chitin-command/src/workspace.rs b/crates/chitin-command/src/workspace.rs index be5f52dd..d494c37d 100644 --- a/crates/chitin-command/src/workspace.rs +++ b/crates/chitin-command/src/workspace.rs @@ -21,21 +21,21 @@ pub enum WorkspaceCommand { impl WorkspaceCommand { /// Returns the stable command identifier. - pub fn id(&self) -> &'static str { + pub fn id(&self) -> crate::CommandId { match self { - Self::FocusPrevious => "workspace.focus_previous_entry", - Self::FocusNext => "workspace.focus_next_entry", - Self::ActivateFocused => "workspace.activate_focused_entry", - Self::FocusFirst => "workspace.focus_first_entry", - Self::FocusLast => "workspace.focus_last_entry", - Self::ToggleWorkspace => "workspace.toggle_workspace", + Self::FocusPrevious => crate::CommandId::WorkspaceFocusPrevious, + Self::FocusNext => crate::CommandId::WorkspaceFocusNext, + Self::ActivateFocused => crate::CommandId::WorkspaceActivateFocused, + Self::FocusFirst => crate::CommandId::WorkspaceFocusFirst, + Self::FocusLast => crate::CommandId::WorkspaceFocusLast, + Self::ToggleWorkspace => crate::CommandId::WorkspaceToggle, Self::PanelTab(command) => command.id(), } } } -/// Returns the project-workspace command descriptors. -pub fn command_descriptors() -> Vec { +/// Returns the project-workspace command registrations. +pub fn command_registrations() -> Vec { [ ( WorkspaceCommand::ToggleWorkspace, @@ -75,14 +75,15 @@ pub fn command_descriptors() -> Vec { ), ] .into_iter() - .map(|(command, title, keywords, shortcut)| crate::CommandDescriptor { - id: command.id(), - title, + .map(|(command, title, keywords, shortcut)| crate::CommandRegistration { + descriptor: crate::CommandDescriptor { + id: command.id(), + title, + requires_arguments: false, + }, category: crate::CommandCategory::Workspace, keywords, shortcut, - invocation: crate::CommandInvocationKind::Immediate, - command: command.into(), }) .collect() } diff --git a/crates/chitin-desktop/src/components/command_panel.rs b/crates/chitin-desktop/src/components/command_panel.rs index eb8d9e5a..137f4a26 100644 --- a/crates/chitin-desktop/src/components/command_panel.rs +++ b/crates/chitin-desktop/src/components/command_panel.rs @@ -9,7 +9,7 @@ pub(crate) use controller::CommandPanelController; use controller::{CommandPanelEvent, CommandPanelMode}; use form::rcsb::{RcsbDownloadState, RcsbFormPanel, download_path}; -use chitin_command::{ApplicationCommand, ChitinCommand, CommandInvocationKind}; +use chitin_command::{CommandId, DatabaseCommand, RcsbDownloadArguments}; use chitin_databases::providers::rcsb::{PdbId, RcsbBatchDownloadRequest}; use chitin_ui::{ composite::{ @@ -57,7 +57,11 @@ pub(crate) fn render_command_panel( cx: &mut Context, search_input: Entity, ) -> Div { - let rcsb_form = matches!(controller.mode(), CommandPanelMode::Form(_)).then(|| controller.rcsb_form(cx)); + let rcsb_form = matches!( + controller.mode(), + CommandPanelMode::Form(CommandId::DatabaseDownloadRcsbStructure) + ) + .then(|| controller.rcsb_form(cx)); let focus_handle = rcsb_form .as_ref() .map(|form| form.pdb_id.read(cx).focus_handle().clone()) @@ -84,28 +88,25 @@ pub(crate) fn render_command_panel( } let results = controller.registry().search(controller.query()); - let selected_commands = results - .iter() - .map(|result| result.descriptor.command.clone()) - .collect::>(); + let selected_ids = results.iter().map(|result| result.descriptor.id).collect::>(); let overlay = QuickPickOverlay { query: controller.query().into(), placeholder: "Type a command".into(), search_input: Some(QuickPickSearchInput::new(search_input)), items: results .iter() - .map(|result| QuickPickItem::new(result.descriptor.title, result.descriptor.shortcut)) + .map(|result| QuickPickItem::new(result.descriptor.title, result.shortcut)) .collect(), selected_index: controller.selected_index(), scroll_handle: Some(controller.result_scroll_handle()), empty_message: "No commands found".into(), }; let on_select: Rc = Rc::new(move |index, window, cx: &mut App| { - let Some(command) = selected_commands.get(index).cloned() else { + let Some(id) = selected_ids.get(index).copied() else { return; }; let _ = app.update(cx, |app, cx| { - app.invoke_command_from_panel(command, window, cx); + app.invoke_command_from_panel(id, window, cx); }); }); @@ -150,7 +151,10 @@ impl ChitinApp { window: &mut Window, cx: &mut Context, ) -> Option { - if !matches!(self.command_panel.mode(), CommandPanelMode::Form(_)) { + if !matches!( + self.command_panel.mode(), + CommandPanelMode::Form(CommandId::DatabaseDownloadRcsbStructure) + ) { return None; } let form = self.command_panel.rcsb_form(cx); @@ -189,7 +193,7 @@ impl ChitinApp { Some(form) } - /// Submits the current RCSB form and closes its modal panel. + /// Validates the current RCSB form and dispatches a typed download command. fn submit_rcsb_form(&mut self, window: &mut Window, cx: &mut Context) { let Some(form) = self.command_panel.rcsb_form_if_created() else { return; @@ -198,10 +202,6 @@ impl ChitinApp { log::debug!("RCSB download submission ignored because a batch is already active"); return; } - let Some(workspace_root) = self.workspace.as_ref().map(|workspace| workspace.root.clone()) else { - log::warn!("RCSB download ignored because no workspace is open"); - return; - }; let raw_pdb_ids = form.pdb_id.read(cx).text().trim().to_owned(); let ids = match PdbId::parse_many(&raw_pdb_ids) { Ok(ids) => ids, @@ -215,12 +215,61 @@ impl ChitinApp { } }; let format = form.selected_format(cx); + self.dispatch_command_with_window( + DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ids, + format, + output: None, + }) + .into(), + window, + cx, + ); + } + + /// Executes a typed RCSB download against the active desktop workspace. + /// + /// # Parameters + /// + /// * `arguments` contains validated identifiers, format, and output override. + /// * `window` identifies the window that receives completed documents. + /// * `cx` submits the background task and updates form presentation state. + /// + /// # Returns + /// + /// This function returns after the background task has been submitted. + pub(crate) fn execute_rcsb_download( + &mut self, + arguments: RcsbDownloadArguments, + window: &mut Window, + cx: &mut Context, + ) { + let Some(form) = self.command_panel.rcsb_form_if_created() else { + return; + }; + let Some(workspace_root) = self.workspace.as_ref().map(|workspace| workspace.root.clone()) else { + let error = "RCSB download requires an open workspace".to_string(); + log::warn!("{error}"); + form.download.update(cx, |state, cx| state.fail(error, cx)); + return; + }; + let RcsbDownloadArguments { ids, format, output } = arguments; + let multiple = ids.len() > 1; let mut unique_ids = BTreeSet::new(); let requests = ids .into_iter() .filter(|pdb_id| unique_ids.insert(pdb_id.clone())) .map(|pdb_id| RcsbBatchDownloadRequest { - destination: download_path(&workspace_root, &pdb_id, format), + destination: output + .as_ref() + .map(|path| { + if path.is_dir() || multiple { + path.join(format.filename(&pdb_id)) + } else { + path.clone() + } + }) + .unwrap_or_else(|| download_path(&workspace_root, &pdb_id, format)), id: pdb_id, format, }) @@ -317,45 +366,36 @@ impl ChitinApp { self.command_panel.close(window, cx); cx.notify(); } - CommandPanelEvent::Invoke(command) => self.invoke_command_from_panel(command, window, cx), + CommandPanelEvent::Invoke(id) => self.invoke_command_from_panel(id, window, cx), } } /// Invokes one command selected from the command panel. /// # Parameters /// - /// * `command` is the typed command selected by the controller. + /// * `id` is the command identity selected by the controller. /// * `window` receives focus restoration when an immediate command closes the panel. /// * `cx` is used to dispatch or notify state changes. - pub(crate) fn invoke_command_from_panel( - &mut self, - command: ChitinCommand, - window: &mut Window, - cx: &mut Context, - ) { - let Some(invocation) = self - .command_panel - .registry() - .descriptor_for(&command) - .map(|descriptor| descriptor.invocation) - else { + pub(crate) fn invoke_command_from_panel(&mut self, id: CommandId, window: &mut Window, cx: &mut Context) { + let Some(descriptor) = self.command_panel.registry().descriptor_for(id) else { return; }; - match invocation { - CommandInvocationKind::Form => { - if self.command_panel.open_form(command) { - cx.notify(); - } - } - CommandInvocationKind::Immediate => { - self.command_panel.close(window, cx); - if command == ChitinCommand::Application(ApplicationCommand::ToggleCommandPanel) { - cx.notify(); - } else { - self.dispatch_command_with_window(command, window, cx); - } + if descriptor.requires_arguments { + if self.command_panel.open_form(id) { + cx.notify(); } + return; + } + + let Some(command) = id.command_without_arguments() else { + return; + }; + self.command_panel.close(window, cx); + if id == CommandId::ApplicationToggleCommandPanel { + cx.notify(); + } else { + self.dispatch_command_with_window(command, window, cx); } } } diff --git a/crates/chitin-desktop/src/components/command_panel/controller.rs b/crates/chitin-desktop/src/components/command_panel/controller.rs index 66404356..13ba0f4a 100644 --- a/crates/chitin-desktop/src/components/command_panel/controller.rs +++ b/crates/chitin-desktop/src/components/command_panel/controller.rs @@ -1,6 +1,6 @@ //! Desktop-owned command-panel state and focus management. -use chitin_command::{ChitinCommand, CommandRegistry}; +use chitin_command::{CommandId, CommandRegistry}; use chitin_ui::{composite::quickpick::QuickPickState, primitive::input::text::TextInputState}; use gpui::{AppContext, Context, Entity, FocusHandle, KeyDownEvent, ScrollStrategy, UniformListScrollHandle, Window}; @@ -12,7 +12,7 @@ pub(crate) enum CommandPanelMode { /// Search mode listing matching commands. Search, /// Form mode for one command, identified without duplicating its descriptor. - Form(ChitinCommand), + Form(CommandId), } /// Result of handling one command-panel key event. @@ -23,7 +23,7 @@ pub(crate) enum CommandPanelEvent { /// The panel should close and restore its prior focus target. Close, /// A command should be invoked from the current search result. - Invoke(ChitinCommand), + Invoke(CommandId), } /// State and focus owner for the desktop command panel. @@ -215,17 +215,20 @@ impl CommandPanelController { /// /// # Parameters /// - /// * `command` identifies the command that owns the form metadata. + /// * `id` identifies the command that owns the form metadata. /// /// # Returns /// /// `true` when the command is registered and the panel entered form mode. - pub(crate) fn open_form(&mut self, command: ChitinCommand) -> bool { - if self.registry.descriptor_for(&command).is_none() { + pub(crate) fn open_form(&mut self, id: CommandId) -> bool { + let Some(descriptor) = self.registry.descriptor_for(id) else { + return false; + }; + if !descriptor.requires_arguments { return false; } - self.mode = CommandPanelMode::Form(command); + self.mode = CommandPanelMode::Form(id); self.rcsb_focus_pending = true; self.quickpick.reset(); true @@ -319,7 +322,7 @@ impl CommandPanelController { .registry .search(self.quickpick.query()) .get(self.quickpick.selected_index()) - .map(|result| CommandPanelEvent::Invoke(result.descriptor.command.clone())) + .map(|result| CommandPanelEvent::Invoke(result.descriptor.id)) .unwrap_or(CommandPanelEvent::StateChanged) } } @@ -364,10 +367,10 @@ mod tests { #[test] fn open_form_should_store_only_command_identity() { let mut controller = CommandPanelController::new(); - let command = ChitinCommand::from(chitin_command::DatabaseCommand::DownloadRcsbStructure); + let id = CommandId::DatabaseDownloadRcsbStructure; - assert!(controller.open_form(command.clone())); - assert_eq!(controller.mode, CommandPanelMode::Form(command)); + assert!(controller.open_form(id)); + assert_eq!(controller.mode, CommandPanelMode::Form(id)); } #[test] diff --git a/crates/chitin-desktop/src/keybindings/dispatch.rs b/crates/chitin-desktop/src/keybindings/dispatch.rs index bfb17038..a8e0ee24 100644 --- a/crates/chitin-desktop/src/keybindings/dispatch.rs +++ b/crates/chitin-desktop/src/keybindings/dispatch.rs @@ -37,10 +37,12 @@ impl ChitinApp { window: &mut Window, cx: &mut Context, ) { - if matches!(command, ChitinCommand::Workspace(WorkspaceCommand::ActivateFocused)) { - self.activate_focused_project_tree_entry_with_window(window, cx); - } else { - self.dispatch_command(command, cx); + match command { + ChitinCommand::Workspace(WorkspaceCommand::ActivateFocused) => { + self.activate_focused_project_tree_entry_with_window(window, cx); + } + ChitinCommand::Database(command) => self.dispatch_database_command(command, window, cx), + command => self.dispatch_command(command, cx), } } @@ -59,7 +61,9 @@ impl ChitinApp { match command { ChitinCommand::Workspace(command) => self.dispatch_workspace_command(command, cx), - ChitinCommand::Database(command) => self.dispatch_database_command(command, cx), + ChitinCommand::Database(command) => { + log::warn!("Command {} requires a window execution context", command.id()); + } ChitinCommand::Application(command) => self.dispatch_application_command(command, cx), ChitinCommand::Structure(command) => self.dispatch_structure_command(command), } @@ -91,19 +95,20 @@ impl ChitinApp { /// # Parameters /// /// * `command` identifies the database workflow to start. - /// * `cx` is the GPUI context notified when the form is opened. + /// * `window` identifies the window that receives completed documents. + /// * `cx` submits the background task and updates desktop state. /// /// # Returns /// - /// This function returns `()` after opening the corresponding form. - pub(crate) fn dispatch_database_command(&mut self, command: DatabaseCommand, cx: &mut Context) { + /// This function returns `()` after routing the executable database command. + pub(crate) fn dispatch_database_command( + &mut self, + command: DatabaseCommand, + window: &mut Window, + cx: &mut Context, + ) { match command { - DatabaseCommand::DownloadRcsbStructure => { - let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure); - if self.command_panel.open_form(command) { - cx.notify(); - } - } + DatabaseCommand::DownloadRcsbStructure(arguments) => self.execute_rcsb_download(arguments, window, cx), } } From 27b7ac892b333851a74a02afae74ce630ab8e89f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Sun, 20 Sep 2026 16:26:37 +0800 Subject: [PATCH 02/32] feat(command): add built-in command language parser - parse RCSB download and structure inspection commands into typed commands - support quoted arguments, escape sequences, and inline option values - resolve argumentless commands from their stable dotted identifiers - report malformed commands through structured parse errors - add unit and documentation tests for command parsing --- Cargo.lock | 1 + crates/chitin-command/Cargo.toml | 1 + crates/chitin-command/src/lib.rs | 2 + crates/chitin-command/src/parser.rs | 572 ++++++++++++++++++++++++++++ 4 files changed, 576 insertions(+) create mode 100644 crates/chitin-command/src/parser.rs diff --git a/Cargo.lock b/Cargo.lock index 1b9020f2..81e4adb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -729,6 +729,7 @@ name = "chitin-command" version = "0.1.0" dependencies = [ "chitin-databases", + "thiserror 2.0.20", ] [[package]] diff --git a/crates/chitin-command/Cargo.toml b/crates/chitin-command/Cargo.toml index 50f743e6..edbb6baf 100644 --- a/crates/chitin-command/Cargo.toml +++ b/crates/chitin-command/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true [dependencies] chitin-databases = { path = "../chitin-databases" } +thiserror = "2.0" [lints] workspace = true diff --git a/crates/chitin-command/src/lib.rs b/crates/chitin-command/src/lib.rs index e8cb5b13..086ee0f5 100644 --- a/crates/chitin-command/src/lib.rs +++ b/crates/chitin-command/src/lib.rs @@ -3,12 +3,14 @@ mod application; mod database; mod panel_tab; +mod parser; mod structure; mod workspace; pub use application::ApplicationCommand; pub use database::{DatabaseCommand, RcsbDownloadArguments}; pub use panel_tab::PanelTabCommand; +pub use parser::{CommandParseError, parse_command_line}; pub use structure::{ CommandOutputFormat, StructureCommand, StructureInputArguments, StructureInspectArguments, StructureValidateArguments, }; diff --git a/crates/chitin-command/src/parser.rs b/crates/chitin-command/src/parser.rs new file mode 100644 index 00000000..48680a13 --- /dev/null +++ b/crates/chitin-command/src/parser.rs @@ -0,0 +1,572 @@ +//! Frontend-independent parsing for Chitin's built-in command language. + +use std::path::PathBuf; + +use chitin_databases::providers::rcsb::{PdbId, PdbIdListError, StructureFormat}; + +use crate::{ + ChitinCommand, CommandId, CommandOutputFormat, DatabaseCommand, RcsbDownloadArguments, StructureCommand, + StructureInputArguments, StructureInspectArguments, StructureValidateArguments, +}; + +/// Failure produced while tokenizing or parsing a built-in command line. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum CommandParseError { + /// No command was present after whitespace was removed. + #[error("command line is empty")] + Empty, + /// A quoted token reached the end of the input without a closing quote. + #[error("unterminated {quote} quote starting at byte {position}")] + UnterminatedQuote { + /// Quote character that was not closed. + quote: char, + /// Byte offset of the opening quote. + position: usize, + }, + /// A trailing escape character had no following character. + #[error("trailing escape character at byte {position}")] + TrailingEscape { + /// Byte offset of the escape character. + position: usize, + }, + /// The top-level command name is not registered. + #[error("unknown command '{command}'")] + UnknownCommand { + /// Unrecognized command name. + command: String, + }, + /// A nested command path ended before a required word. + #[error("'{parent}' requires a {expected} subcommand")] + MissingSubcommand { + /// Command path parsed before the missing word. + parent: String, + /// Human-readable set of accepted words. + expected: &'static str, + }, + /// A nested command word was not recognized. + #[error("unknown subcommand '{subcommand}' for '{parent}'; expected {expected}")] + UnknownSubcommand { + /// Parent command path. + parent: String, + /// Unrecognized nested command word. + subcommand: String, + /// Human-readable set of accepted words. + expected: &'static str, + }, + /// A required positional argument or option was absent. + #[error("missing required argument {argument}")] + MissingArgument { + /// Argument spelling shown to the user. + argument: &'static str, + }, + /// An option that requires a value was the final token. + #[error("option '{option}' requires a value")] + MissingOptionValue { + /// Option spelling that lacked a value. + option: String, + }, + /// The command line contains an option that its command does not accept. + #[error("unknown option '{option}' for '{command}'")] + UnknownOption { + /// Command path receiving the option. + command: &'static str, + /// Unrecognized option spelling. + option: String, + }, + /// A single-valued option occurred more than once. + #[error("option '{option}' may only be specified once")] + DuplicateOption { + /// Repeated option spelling. + option: String, + }, + /// A value is outside the accepted vocabulary for its option. + #[error("invalid value '{value}' for '{option}'; expected {expected}")] + InvalidValue { + /// Option whose value failed validation. + option: &'static str, + /// Original invalid value. + value: String, + /// Human-readable set of accepted values. + expected: &'static str, + }, + /// A command received an extra positional argument. + #[error("unexpected argument '{value}' for '{command}'")] + UnexpectedArgument { + /// Command path receiving the extra value. + command: &'static str, + /// Unexpected value. + value: String, + }, + /// A comma-separated PDB identifier list contains an invalid item. + #[error(transparent)] + InvalidPdbIds(#[from] PdbIdListError), +} + +/// Parses built-in shell text into a complete typed command. +/// +/// The grammar deliberately mirrors the public CLI for portable workflows. +/// Commands without arguments may also use their stable dotted identifier, +/// such as `workspace.toggle_workspace`. +/// +/// # Parameters +/// +/// * `input` is one complete command line without a trailing newline. +/// +/// # Returns +/// +/// A frontend-independent [`ChitinCommand`] containing all validated arguments. +/// +/// # Errors +/// +/// Returns [`CommandParseError`] when quoting is incomplete, the command path +/// is unknown, an option is malformed, or a domain value fails validation. +/// +/// # Examples +/// +/// ``` +/// use chitin_command::{ChitinCommand, DatabaseCommand, parse_command_line}; +/// +/// let command = parse_command_line("db rcsb download --id 4hhb --format pdb")?; +/// assert!(matches!( +/// command, +/// ChitinCommand::Database(DatabaseCommand::DownloadRcsbStructure(_)) +/// )); +/// # Ok::<(), chitin_command::CommandParseError>(()) +/// ``` +pub fn parse_command_line(input: &str) -> Result { + let tokens = tokenize(input)?; + let Some(command) = tokens.first().map(String::as_str) else { + return Err(CommandParseError::Empty); + }; + + match command { + "db" => parse_database(&tokens[1..]), + "structure" => parse_structure(&tokens[1..]), + stable_id => parse_argumentless_command(stable_id, &tokens[1..]), + } +} + +/// Splits command text while preserving quoted whitespace and escaped characters. +/// +/// # Parameters +/// +/// * `input` is the original command line and may contain single quotes, +/// double quotes, or backslash escapes. +/// +/// # Returns +/// +/// Tokens with syntax characters removed while retaining empty quoted values. +fn tokenize(input: &str) -> Result, CommandParseError> { + let mut tokens = Vec::new(); + let mut token = String::new(); + let mut token_started = false; + let mut quote = None; + let mut characters = input.char_indices().peekable(); + + while let Some((position, character)) = characters.next() { + match quote { + Some((active, _)) if character == active => quote = None, + Some(('\'', _)) => { + token.push(character); + token_started = true; + } + Some(_) | None if character == '\\' => { + let Some((_, escaped)) = characters.next() else { + return Err(CommandParseError::TrailingEscape { position }); + }; + token.push(escaped); + token_started = true; + } + None if character == '\'' || character == '"' => { + quote = Some((character, position)); + token_started = true; + } + None if character.is_whitespace() => { + if token_started { + tokens.push(std::mem::take(&mut token)); + token_started = false; + } + } + Some(_) | None => { + token.push(character); + token_started = true; + } + } + } + + if let Some((quote, position)) = quote { + return Err(CommandParseError::UnterminatedQuote { quote, position }); + } + if token_started { + tokens.push(token); + } + Ok(tokens) +} + +/// Parses the `db rcsb download` command and its provider arguments. +/// +/// # Parameters +/// +/// * `tokens` starts after the top-level `db` word. +/// +/// # Returns +/// +/// A download command containing canonical identifiers and resolved options. +fn parse_database(tokens: &[String]) -> Result { + expect_subcommand(tokens.first(), "db", "rcsb", "rcsb")?; + expect_subcommand(tokens.get(1), "db rcsb", "download", "download")?; + + let mut ids = None; + let mut format = None; + let mut output = None; + let mut cursor = 2; + while cursor < tokens.len() { + let token = &tokens[cursor]; + let (option, inline_value) = parse_option(token).ok_or_else(|| CommandParseError::UnexpectedArgument { + command: "db rcsb download", + value: token.clone(), + })?; + match option { + "--id" => { + reject_duplicate(&ids, option)?; + ids = Some(option_value(tokens, &mut cursor, option, inline_value)?.to_owned()); + } + "--format" => { + reject_duplicate(&format, option)?; + let value = option_value(tokens, &mut cursor, option, inline_value)?; + format = Some(parse_structure_format(value)?); + } + "--output" => { + reject_duplicate(&output, option)?; + output = Some(PathBuf::from(option_value(tokens, &mut cursor, option, inline_value)?)); + } + _ => { + return Err(CommandParseError::UnknownOption { + command: "db rcsb download", + option: option.to_owned(), + }); + } + } + cursor += 1; + } + + let ids = ids.ok_or(CommandParseError::MissingArgument { argument: "--id" })?; + let format = format.ok_or(CommandParseError::MissingArgument { argument: "--format" })?; + Ok( + DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ids: PdbId::parse_many(&ids)?, + format, + output, + }) + .into(), + ) +} + +/// Parses structure inspection and validation commands. +/// +/// # Parameters +/// +/// * `tokens` starts with `inspect` or `validate` and contains its arguments. +/// +/// # Returns +/// +/// A complete structure command with its input and output preferences. +fn parse_structure(tokens: &[String]) -> Result { + let Some(subcommand) = tokens.first().map(String::as_str) else { + return Err(CommandParseError::MissingSubcommand { + parent: "structure".to_owned(), + expected: "inspect or validate", + }); + }; + if subcommand != "inspect" && subcommand != "validate" { + return Err(CommandParseError::UnknownSubcommand { + parent: "structure".to_owned(), + subcommand: subcommand.to_owned(), + expected: "inspect or validate", + }); + } + + let command_name = if subcommand == "inspect" { + "structure inspect" + } else { + "structure validate" + }; + let mut input = None; + let mut format = None; + let mut output = None; + let mut verbose = false; + let mut cursor = 1; + while cursor < tokens.len() { + let token = &tokens[cursor]; + if let Some((option, inline_value)) = parse_option(token) { + match option { + "--format" => { + reject_duplicate(&format, option)?; + let value = option_value(tokens, &mut cursor, option, inline_value)?; + format = Some(parse_structure_format(value)?); + } + "--output" => { + reject_duplicate(&output, option)?; + let value = option_value(tokens, &mut cursor, option, inline_value)?; + output = Some(parse_output_format(value)?); + } + "--verbose" if subcommand == "inspect" && inline_value.is_none() => { + if verbose { + return Err(CommandParseError::DuplicateOption { + option: option.to_owned(), + }); + } + verbose = true; + } + _ => { + return Err(CommandParseError::UnknownOption { + command: command_name, + option: option.to_owned(), + }); + } + } + } else if input.is_some() { + return Err(CommandParseError::UnexpectedArgument { + command: command_name, + value: token.clone(), + }); + } else { + input = Some(PathBuf::from(token)); + } + cursor += 1; + } + + let input = StructureInputArguments { + input: input.ok_or(CommandParseError::MissingArgument { argument: "" })?, + format, + }; + let output = output.unwrap_or_default(); + match subcommand { + "inspect" => Ok(StructureCommand::Inspect(StructureInspectArguments { input, output, verbose }).into()), + "validate" => Ok(StructureCommand::Validate(StructureValidateArguments { input, output }).into()), + _ => Err(CommandParseError::UnknownSubcommand { + parent: "structure".to_owned(), + subcommand: subcommand.to_owned(), + expected: "inspect or validate", + }), + } +} + +/// Resolves a stable dotted identifier for a command without arguments. +/// +/// # Parameters +/// +/// * `stable_id` is the canonical command identifier. +/// * `trailing` contains tokens that must be absent for argumentless commands. +/// +/// # Returns +/// +/// The executable command represented by the stable identifier. +fn parse_argumentless_command(stable_id: &str, trailing: &[String]) -> Result { + let id = match stable_id { + "workspace.focus_previous_entry" => CommandId::WorkspaceFocusPrevious, + "workspace.focus_next_entry" => CommandId::WorkspaceFocusNext, + "workspace.activate_focused_entry" => CommandId::WorkspaceActivateFocused, + "workspace.focus_first_entry" => CommandId::WorkspaceFocusFirst, + "workspace.focus_last_entry" => CommandId::WorkspaceFocusLast, + "workspace.toggle_workspace" => CommandId::WorkspaceToggle, + "tab.focus_previous" => CommandId::PanelTabFocusPrevious, + "tab.focus_next" => CommandId::PanelTabFocusNext, + "tab.close" => CommandId::PanelTabClose, + "application.toggle_command_panel" => CommandId::ApplicationToggleCommandPanel, + _ => { + return Err(CommandParseError::UnknownCommand { + command: stable_id.to_owned(), + }); + } + }; + if let Some(value) = trailing.first() { + return Err(CommandParseError::UnexpectedArgument { + command: id.as_str(), + value: value.clone(), + }); + } + id.command_without_arguments() + .ok_or_else(|| CommandParseError::UnknownCommand { + command: stable_id.to_owned(), + }) +} + +/// Requires one exact nested command word at the requested position. +fn expect_subcommand( + token: Option<&String>, + parent: &str, + required: &str, + expected: &'static str, +) -> Result<(), CommandParseError> { + match token { + Some(token) if token == required => Ok(()), + Some(token) => Err(CommandParseError::UnknownSubcommand { + parent: parent.to_owned(), + subcommand: token.clone(), + expected, + }), + None => Err(CommandParseError::MissingSubcommand { + parent: parent.to_owned(), + expected, + }), + } +} + +/// Splits a long option into its name and optional inline value. +fn parse_option(token: &str) -> Option<(&str, Option<&str>)> { + token.strip_prefix("--").map(|option| { + let (name, value) = option + .split_once('=') + .map_or((option, None), |(name, value)| (name, Some(value))); + (&token[..name.len() + 2], value) + }) +} + +/// Returns an option value from `--name=value` or the following token. +fn option_value<'a>( + tokens: &'a [String], + cursor: &mut usize, + option: &str, + inline_value: Option<&'a str>, +) -> Result<&'a str, CommandParseError> { + if let Some(value) = inline_value { + return if value.is_empty() { + Err(CommandParseError::MissingOptionValue { + option: option.to_owned(), + }) + } else { + Ok(value) + }; + } + *cursor += 1; + tokens + .get(*cursor) + .filter(|value| !value.starts_with("--")) + .map(String::as_str) + .ok_or_else(|| CommandParseError::MissingOptionValue { + option: option.to_owned(), + }) +} + +/// Rejects a second occurrence of a single-valued option. +fn reject_duplicate(value: &Option, option: &str) -> Result<(), CommandParseError> { + if value.is_some() { + Err(CommandParseError::DuplicateOption { + option: option.to_owned(), + }) + } else { + Ok(()) + } +} + +/// Parses a structure format shared by local and RCSB commands. +fn parse_structure_format(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "pdb" => Ok(StructureFormat::Pdb), + "mmcif" | "cif" => Ok(StructureFormat::Mmcif), + _ => Err(CommandParseError::InvalidValue { + option: "--format", + value: value.to_owned(), + expected: "pdb or mmcif", + }), + } +} + +/// Parses the requested command output representation. +fn parse_output_format(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "text" => Ok(CommandOutputFormat::Text), + "json" => Ok(CommandOutputFormat::Json), + _ => Err(CommandParseError::InvalidValue { + option: "--output", + value: value.to_owned(), + expected: "text or json", + }), + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + + #[test] + fn database_download_should_parse_complete_typed_arguments() -> Result<(), CommandParseError> { + let command = parse_command_line("db rcsb download --id 4hhb,1yth --format mmcif --output 'saved structures'")?; + + assert!(matches!( + command, + ChitinCommand::Database(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ids, + format: StructureFormat::Mmcif, + output: Some(output), + })) if ids.iter().map(PdbId::as_str).collect::>() == ["4HHB", "1YTH"] + && output == Path::new("saved structures") + )); + Ok(()) + } + + #[test] + fn structure_inspect_should_parse_options_in_any_order() -> Result<(), CommandParseError> { + let command = parse_command_line("structure inspect --verbose --output=json 'models/one.cif' --format cif")?; + + assert!(matches!( + command, + ChitinCommand::Structure(StructureCommand::Inspect(StructureInspectArguments { + input: StructureInputArguments { input, format: Some(StructureFormat::Mmcif) }, + output: CommandOutputFormat::Json, + verbose: true, + })) if input == Path::new("models/one.cif") + )); + Ok(()) + } + + #[test] + fn stable_identifier_should_create_argumentless_command() -> Result<(), CommandParseError> { + let command = parse_command_line("tab.close")?; + + assert_eq!(command.id(), CommandId::PanelTabClose); + Ok(()) + } + + #[test] + fn invalid_pdb_list_should_preserve_item_details() { + let result = parse_command_line("db rcsb download --id 4hhb,invalid --format pdb"); + + assert!(matches!( + result, + Err(CommandParseError::InvalidPdbIds(PdbIdListError { + index: 2, + value, + .. + })) if value == "invalid" + )); + } + + #[test] + fn unterminated_quote_should_report_opening_byte() { + let result = parse_command_line("structure inspect 'model.cif"); + + assert_eq!( + result, + Err(CommandParseError::UnterminatedQuote { + quote: '\'', + position: 18, + }) + ); + } + + #[test] + fn duplicate_option_should_be_rejected() { + let result = parse_command_line("structure validate one.pdb --format pdb --format mmcif"); + + assert_eq!( + result, + Err(CommandParseError::DuplicateOption { + option: "--format".to_owned(), + }) + ); + } +} From 144999d5b291ff0e08989b7bcb5c3f75c6da8a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Sun, 20 Sep 2026 17:21:04 +0800 Subject: [PATCH 03/32] refactor(command): centralize portable command execution - add frontend-neutral execution contexts, events, progress, and outcomes - introduce chitin-command-runtime for database and structure workflows - route CLI downloads, inspection, and validation through CommandExecutor - reuse the shared executor for desktop RCSB download tasks - remove duplicated CLI and desktop execution and output-path logic - preserve frontend-specific rendering and task-center integration --- Cargo.lock | 13 + Cargo.toml | 1 + crates/chitin-cli/Cargo.toml | 1 + crates/chitin-cli/src/cli.rs | 72 ++++- crates/chitin-cli/src/download.rs | 153 +++++----- crates/chitin-cli/src/error.rs | 32 +- crates/chitin-cli/src/main.rs | 1 - crates/chitin-cli/src/output.rs | 86 ------ crates/chitin-cli/src/structure.rs | 182 +++--------- crates/chitin-command-runtime/Cargo.toml | 18 ++ crates/chitin-command-runtime/src/database.rs | 279 ++++++++++++++++++ crates/chitin-command-runtime/src/lib.rs | 229 ++++++++++++++ .../chitin-command-runtime/src/structure.rs | 188 ++++++++++++ crates/chitin-command/src/execution.rs | 138 +++++++++ crates/chitin-command/src/lib.rs | 5 + crates/chitin-desktop/Cargo.toml | 1 + crates/chitin-desktop/src/app.rs | 5 + .../src/components/command_panel.rs | 52 ++-- .../src/components/command_panel/form/rcsb.rs | 42 +-- crates/chitin-desktop/src/tasks/rcsb.rs | 151 ++++++---- 20 files changed, 1173 insertions(+), 476 deletions(-) delete mode 100644 crates/chitin-cli/src/output.rs create mode 100644 crates/chitin-command-runtime/Cargo.toml create mode 100644 crates/chitin-command-runtime/src/database.rs create mode 100644 crates/chitin-command-runtime/src/lib.rs create mode 100644 crates/chitin-command-runtime/src/structure.rs create mode 100644 crates/chitin-command/src/execution.rs diff --git a/Cargo.lock b/Cargo.lock index 81e4adb5..b32a7cb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -688,6 +688,7 @@ version = "0.1.0" dependencies = [ "chitin-bio", "chitin-command", + "chitin-command-runtime", "chitin-databases", "clap", "clap_complete", @@ -732,6 +733,17 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "chitin-command-runtime" +version = "0.1.0" +dependencies = [ + "chitin-bio", + "chitin-command", + "chitin-databases", + "thiserror 2.0.20", + "tokio", +] + [[package]] name = "chitin-databases" version = "0.1.0" @@ -758,6 +770,7 @@ dependencies = [ "bytemuck", "chitin-bio", "chitin-command", + "chitin-command-runtime", "chitin-databases", "chitin-molecule-renderer", "chitin-ui", diff --git a/Cargo.toml b/Cargo.toml index 14c2a669..3b0241b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/chitin-command", + "crates/chitin-command-runtime", "crates/chitin-cli", "crates/chitin-bio", "crates/chitin-bio-macros", diff --git a/crates/chitin-cli/Cargo.toml b/crates/chitin-cli/Cargo.toml index 7ef73898..e1d57c02 100644 --- a/crates/chitin-cli/Cargo.toml +++ b/crates/chitin-cli/Cargo.toml @@ -25,6 +25,7 @@ path = "src/main.rs" [dependencies] chitin-command = { version = "0.1.0", path = "../chitin-command" } +chitin-command-runtime = { version = "0.1.0", path = "../chitin-command-runtime" } chitin-bio = { version = "0.1.0", path = "../chitin-bio" } chitin-databases = { version = "0.1.0", path = "../chitin-databases" } clap = { version = "4.6", features = ["derive"] } diff --git a/crates/chitin-cli/src/cli.rs b/crates/chitin-cli/src/cli.rs index 8df93f74..e1be16eb 100644 --- a/crates/chitin-cli/src/cli.rs +++ b/crates/chitin-cli/src/cli.rs @@ -1,16 +1,20 @@ //! Command-line schema and command-bus routing. -use std::path::PathBuf; +use std::{io::Read, path::PathBuf}; use chitin_command::{ - ChitinCommand, CommandOutputFormat, DatabaseCommand, RcsbDownloadArguments, StructureCommand, - StructureInputArguments, StructureInspectArguments, StructureValidateArguments, + ChitinCommand, CommandExecutionContext, CommandOutputFormat, DatabaseCommand, RcsbDownloadArguments, + StructureCommand, StructureInputArguments, StructureInspectArguments, StructureValidateArguments, +}; +use chitin_command_runtime::CommandExecutor; +use chitin_databases::{ + ClientConfig, + providers::rcsb::{PdbId, StructureFormat}, }; -use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; -use crate::{download::download_rcsb, error::CliError}; +use crate::{download::terminal_event_sink, error::CliError}; /// Root command parsed by the `chitin` binary. #[derive(Debug, Parser)] @@ -258,9 +262,61 @@ async fn dispatch_rcsb_command(command: RcsbSubcommand) -> Result<(), CliError> /// Returns [`CliError`] when the command is unsupported or the RCSB download /// fails. async fn dispatch_command(command: ChitinCommand) -> Result<(), CliError> { + let context = execution_context(&command)?; + let executor = CommandExecutor::new(ClientConfig::default()); + let outcome = executor.execute(command, context, terminal_event_sink()).await?; + crate::structure::render_outcome(outcome) +} + +/// Builds frontend-neutral execution context from the current process state. +/// +/// # Parameters +/// +/// * `command` identifies which optional process resources are required. +/// +/// # Returns +/// +/// Working-directory, download-root, and standard-input data for the executor. +fn execution_context(command: &ChitinCommand) -> Result { + let working_directory = std::env::current_dir().map_err(CliError::WorkingDirectory)?; + let mut context = CommandExecutionContext::new(working_directory); + if requires_default_download_root(command) { + context = context.with_default_download_root(home_directory()?.join(".chitin").join("download")); + } + if requires_standard_input(command) { + let mut bytes = Vec::new(); + std::io::stdin() + .read_to_end(&mut bytes) + .map_err(CliError::StandardInput)?; + context = context.with_standard_input(bytes); + } + Ok(context) +} + +/// Returns whether a database command needs the process default download root. +fn requires_default_download_root(command: &ChitinCommand) -> bool { + matches!( + command, + ChitinCommand::Database(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + output: None, + .. + })) + ) +} + +/// Returns whether a structure command reads bytes from standard input. +fn requires_standard_input(command: &ChitinCommand) -> bool { match command { - ChitinCommand::Database(DatabaseCommand::DownloadRcsbStructure(arguments)) => download_rcsb(arguments).await, - command @ ChitinCommand::Structure(_) => crate::structure::dispatch(command).await, - other => Err(CliError::UnsupportedCommand(other.id().as_str())), + ChitinCommand::Structure(StructureCommand::Inspect(arguments)) => arguments.input.input.as_os_str() == "-", + ChitinCommand::Structure(StructureCommand::Validate(arguments)) => arguments.input.input.as_os_str() == "-", + _ => false, } } + +/// Finds the platform home directory used by default downloads. +fn home_directory() -> Result { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or(CliError::HomeDirectory) +} diff --git a/crates/chitin-cli/src/download.rs b/crates/chitin-cli/src/download.rs index 2d3cc1e8..0e251503 100644 --- a/crates/chitin-cli/src/download.rs +++ b/crates/chitin-cli/src/download.rs @@ -1,90 +1,87 @@ -//! RCSB download execution and terminal progress reporting. +//! Terminal presentation for portable command execution events. use std::{ sync::{Arc, Mutex}, time::Duration, }; -use chitin_databases::{ - Client, ClientConfig, - providers::rcsb::{RcsbBatchDownloadEvent, RcsbBatchDownloadRequest, RcsbDownloadError, RcsbError}, -}; +use chitin_command::{CommandEventSink, CommandExecutionEvent, CommandMessageLevel, CommandProgress}; use indicatif::{ProgressBar, ProgressStyle}; -use chitin_command::RcsbDownloadArguments; +/// Mutable terminal progress retained across command event callbacks. +#[derive(Default)] +struct DownloadProgressState { + /// Active progress bar for the current artifact. + bar: Option, + /// One-based stage currently represented by the progress bar. + stage_index: Option, +} -use crate::{error::CliError, output::resolve_output_path}; +/// Creates a command-event sink that renders download progress in the terminal. +pub(crate) fn terminal_event_sink() -> CommandEventSink { + let state = Arc::new(Mutex::new(DownloadProgressState::default())); + CommandEventSink::new(move |event| { + let Ok(mut state) = state.lock() else { + return; + }; + render_event(&mut state, event); + }) +} -/// Downloads an RCSB artifact and writes it to the resolved output path. -/// -/// # Parameters -/// -/// * `arguments` contains validated identifiers, format, and output override. -/// -/// # Returns -/// -/// `Ok(())` after the artifact has been written and reported to the terminal. -pub(crate) async fn download_rcsb(arguments: RcsbDownloadArguments) -> Result<(), CliError> { - let RcsbDownloadArguments { ids, format, output } = arguments; - let multiple = ids.len() > 1; - let client = Client::new(ClientConfig::default()) - .map_err(|error| CliError::Rcsb(RcsbDownloadError::Provider(RcsbError::Transport(error))))?; - let requests = ids - .into_iter() - .map(|id| { - let destination = resolve_output_path(output.clone(), &id, format, multiple)?; - Ok(RcsbBatchDownloadRequest { - id, - format, - destination, - }) - }) - .collect::, CliError>>()?; - let progress: Arc>> = Arc::new(Mutex::new(None)); - let callback_progress = progress.clone(); - client - .rcsb() - .download_structures_to_paths(&requests, move |event| match event { - RcsbBatchDownloadEvent::Started { index, total, id } => { - let bar = ProgressBar::new_spinner(); - bar.enable_steady_tick(Duration::from_millis(100)); - if let Ok(style) = ProgressStyle::with_template(" {spinner} Downloading {bytes} ({elapsed})") { - bar.set_style(style); - } - eprintln!(" Downloading {index}/{total}: {id}"); - if let Ok(mut current) = callback_progress.lock() { - *current = Some(bar); - } - } - RcsbBatchDownloadEvent::Progress { - received, total_bytes, .. - } => { - if let Ok(current) = callback_progress.lock() - && let Some(bar) = current.as_ref() - { - if let Some(total_bytes) = total_bytes { - if bar.length().is_none() { - if let Ok(style) = - ProgressStyle::with_template(" Downloading [{bar:32.cyan/blue}] {bytes}/{total_bytes} ({eta})") - { - bar.set_style(style.progress_chars("##-")); - } - bar.set_length(total_bytes); - } - } - bar.set_position(received); - } - } - RcsbBatchDownloadEvent::Completed { id, path, .. } => { - if let Ok(mut current) = callback_progress.lock() - && let Some(bar) = current.take() - { - bar.finish_and_clear(); - } - println!(" ✓ Downloaded {id} ({})", format.label()); - println!(" ✓ Saved to {}", path.display()); +/// Projects one frontend-neutral event into terminal progress and messages. +fn render_event(state: &mut DownloadProgressState, event: CommandExecutionEvent) { + match event { + CommandExecutionEvent::Progress(progress) => render_progress(state, progress), + CommandExecutionEvent::Artifact(artifact) => { + finish_progress_bar(state); + println!(" ✓ Saved to {}", artifact.path.display()); + } + CommandExecutionEvent::Message(message) => match message.level { + CommandMessageLevel::Info => println!(" ✓ {}", message.text), + CommandMessageLevel::Warning => eprintln!(" ! {}", message.text), + }, + CommandExecutionEvent::Completed { .. } => finish_progress_bar(state), + CommandExecutionEvent::Started { .. } => {} + } +} + +/// Updates or replaces the progress bar for the active download stage. +fn render_progress(state: &mut DownloadProgressState, progress: CommandProgress) { + if state.stage_index != Some(progress.stage_index) { + finish_progress_bar(state); + let bar = ProgressBar::new_spinner(); + bar.enable_steady_tick(Duration::from_millis(100)); + if let Ok(style) = ProgressStyle::with_template(" {spinner} Downloading {bytes} ({elapsed})") { + bar.set_style(style); + } + let label = progress.stage_label.as_deref().unwrap_or("structure"); + eprintln!( + " Downloading {}/{}: {label}", + progress.stage_index, progress.stage_count + ); + state.stage_index = Some(progress.stage_index); + state.bar = Some(bar); + } + + let Some(bar) = state.bar.as_ref() else { + return; + }; + if let Some(total) = progress.total { + if bar.length().is_none() { + if let Ok(style) = + ProgressStyle::with_template(" Downloading [{bar:32.cyan/blue}] {bytes}/{total_bytes} ({eta})") + { + bar.set_style(style.progress_chars("##-")); } - }) - .await - .map_err(CliError::Rcsb) + bar.set_length(total); + } + } + bar.set_position(progress.completed); +} + +/// Clears the current progress bar after an artifact or command completes. +fn finish_progress_bar(state: &mut DownloadProgressState) { + if let Some(bar) = state.bar.take() { + bar.finish_and_clear(); + } } diff --git a/crates/chitin-cli/src/error.rs b/crates/chitin-cli/src/error.rs index 515158cf..c692908b 100644 --- a/crates/chitin-cli/src/error.rs +++ b/crates/chitin-cli/src/error.rs @@ -1,6 +1,6 @@ //! Errors produced while parsing and executing CLI workflows. -use chitin_databases::providers::rcsb::{PdbIdError, PdbIdListError, RcsbDownloadError}; +use chitin_databases::providers::rcsb::PdbIdListError; use std::path::PathBuf; /// Error returned by the Chitin CLI command handlers. @@ -9,30 +9,18 @@ pub(crate) enum CliError { /// A comma-separated identifier list contains an invalid element. #[error("{0}")] InvalidPdbIdList(#[from] PdbIdListError), - /// The supplied PDB identifier failed provider validation. - #[error("invalid PDB ID: {0}")] - InvalidPdbId(#[from] PdbIdError), - /// The RCSB provider or local persistence step failed. - #[error("RCSB download failed: {0}")] - Rcsb(#[from] RcsbDownloadError), + /// A portable typed command could not be executed. + #[error(transparent)] + CommandExecution(#[from] chitin_command_runtime::CommandExecutionError), /// No platform home directory variable was available. #[error("could not determine the home directory; set HOME or USERPROFILE")] HomeDirectory, - /// Multiple downloads were given one explicit file path. - #[error("--output must be a directory when downloading multiple PDB IDs: {0}")] - MultipleOutputFile(std::path::PathBuf), - /// A command was not implemented by this CLI entry point. - #[error("unsupported CLI command: {0}")] - UnsupportedCommand(&'static str), - /// The structure input could not be read. - #[error("failed to read structure `{path}`: {source}")] - StructureRead { path: PathBuf, source: std::io::Error }, - /// The structure format could not be determined from the input. - #[error("could not determine structure format for `{0}`; use --format pdb or --format mmcif")] - StructureFormat(PathBuf), - /// The structure parser rejected the input. - #[error("failed to parse structure `{path}`: {message}")] - StructureParse { path: PathBuf, message: String }, + /// The process working directory could not be read. + #[error("could not determine the current working directory: {0}")] + WorkingDirectory(std::io::Error), + /// Standard input could not be read for a structure command. + #[error("failed to read structure data from standard input: {0}")] + StandardInput(std::io::Error), /// The parsed structure violated a model invariant. #[error("structure validation failed for `{path}`: {message}")] StructureValidation { path: PathBuf, message: String }, diff --git a/crates/chitin-cli/src/main.rs b/crates/chitin-cli/src/main.rs index 2bc32b99..fe9c7898 100644 --- a/crates/chitin-cli/src/main.rs +++ b/crates/chitin-cli/src/main.rs @@ -4,7 +4,6 @@ mod cli; mod download; mod error; -mod output; mod structure; use std::process::ExitCode; diff --git a/crates/chitin-cli/src/output.rs b/crates/chitin-cli/src/output.rs deleted file mode 100644 index e76a2975..00000000 --- a/crates/chitin-cli/src/output.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Output path resolution for downloaded structures. - -use std::path::PathBuf; - -use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; - -use crate::error::CliError; - -/// Resolves an optional CLI output into the final file path. -/// -/// # Parameters -/// -/// * `output` is an optional explicit file or directory. -/// * `id` supplies the generated filename when a directory is selected. -/// * `format` supplies the generated file extension and default directory. -/// -/// # Returns -/// -/// The path where the downloaded bytes should be written. -pub(crate) fn resolve_output_path( - output: Option, - id: &PdbId, - format: StructureFormat, - multiple: bool, -) -> Result { - let Some(output) = output else { - return Ok( - home_directory()? - .join(".chitin") - .join("download") - .join(format.id()) - .join(format.filename(id)), - ); - }; - if output.is_dir() { - return Ok(output.join(format.filename(id))); - } - if multiple && (output.extension().is_some() || output.exists()) { - return Err(CliError::MultipleOutputFile(output)); - } - if multiple { - return Ok(output.join(format.filename(id))); - } - Ok(output) -} - -/// Finds the platform home directory used by the default output path. -fn home_directory() -> Result { - std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) - .ok_or(CliError::HomeDirectory) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_id() -> PdbId { - match PdbId::new("4hhb") { - Ok(id) => id, - Err(_) => panic!("test identifier must be valid"), - } - } - - #[test] - fn existing_directory_should_receive_generated_filename() { - let directory = std::env::temp_dir(); - let resolved = resolve_output_path(Some(directory.clone()), &test_id(), StructureFormat::Pdb, false); - assert!(matches!(resolved, Ok(path) if path == directory.join("4HHB.pdb"))); - } - - #[test] - fn nonexistent_extensionless_output_should_be_used_as_file() { - let path = PathBuf::from("custom/structure"); - let resolved = resolve_output_path(Some(path.clone()), &test_id(), StructureFormat::Pdb, false); - assert!(matches!(resolved, Ok(resolved) if resolved == path)); - } - - #[test] - fn path_with_extension_should_be_used_as_file() { - let path = PathBuf::from("custom/output.cif"); - let resolved = resolve_output_path(Some(path.clone()), &test_id(), StructureFormat::Mmcif, false); - assert!(matches!(resolved, Ok(resolved) if resolved == path)); - } -} diff --git a/crates/chitin-cli/src/structure.rs b/crates/chitin-cli/src/structure.rs index 4ec46b62..bf292d35 100644 --- a/crates/chitin-cli/src/structure.rs +++ b/crates/chitin-cli/src/structure.rs @@ -1,140 +1,76 @@ -//! Local PDB and mmCIF inspection workflows. +//! Terminal rendering for local structure command outcomes. -use std::{ - fs::File, - io::{self, Read}, - path::Path, -}; +use std::path::Path; -use chitin_bio::structure::{MmcifParser, PdbParser, Structure, StructureParseResult}; -use chitin_command::{ChitinCommand, CommandOutputFormat, StructureCommand, StructureInputArguments}; +use chitin_bio::structure::{Structure, StructureParseResult}; +use chitin_command::CommandOutputFormat; +use chitin_command_runtime::{CommandOutcome, StructureInspection, StructureValidation}; use chitin_databases::providers::rcsb::StructureFormat; use console::Style; use serde_json::{Value, json}; use crate::error::CliError; -/// Executes a structure inspection or validation command. -pub(crate) async fn dispatch(command: ChitinCommand) -> Result<(), CliError> { - match command { - ChitinCommand::Structure(StructureCommand::Inspect(arguments)) => { - let (format, bytes) = read_input(&arguments.input)?; - let parsed = parse_structure(format, &arguments.input.input, &bytes)?; - print_inspection( - &arguments.input.input, - format, - bytes.len(), - &parsed, - arguments.output, - arguments.verbose, - ) - } - ChitinCommand::Structure(StructureCommand::Validate(arguments)) => { - let (format, bytes) = read_input(&arguments.input)?; - let parsed = parse_structure(format, &arguments.input.input, &bytes)?; - validate_structure(&arguments.input.input, format, &parsed, arguments.output) - } - other => Err(CliError::UnsupportedCommand(other.id().as_str())), +/// Renders a portable command outcome for the CLI frontend. +pub(crate) fn render_outcome(outcome: CommandOutcome) -> Result<(), CliError> { + match outcome { + CommandOutcome::DatabaseDownload { .. } => Ok(()), + CommandOutcome::StructureInspection(inspection) => print_inspection(&inspection), + CommandOutcome::StructureValidation(validation) => print_validation(&validation), } } -/// Reads a local file or stdin and resolves its structure format. -fn read_input(input: &StructureInputArguments) -> Result<(StructureFormat, Vec), CliError> { - let path = &input.input; - let format = input - .format - .or_else(|| detect_format(path)) - .ok_or_else(|| CliError::StructureFormat(path.to_owned()))?; - let mut bytes = Vec::new(); - if path == Path::new("-") { - io::stdin() - .read_to_end(&mut bytes) - .map_err(|source| CliError::StructureRead { - path: path.to_owned(), - source, - })?; - } else { - File::open(path) - .and_then(|mut file| file.read_to_end(&mut bytes)) - .map_err(|source| CliError::StructureRead { - path: path.to_owned(), - source, - })?; - } - Ok((format, bytes)) -} - -/// Parses bytes with the reader selected by the resolved format. -fn parse_structure(format: StructureFormat, path: &Path, bytes: &[u8]) -> Result { - let result = match format { - StructureFormat::Pdb => PdbParser::new().parse_bytes(bytes).map_err(|source| source.to_string()), - StructureFormat::Mmcif => MmcifParser::new() - .parse_bytes(bytes) - .map_err(|source| source.to_string()), - }; - result.map_err(|message| CliError::StructureParse { - path: path.to_owned(), - message, - }) -} - /// Prints a structure summary in text or JSON form. -fn print_inspection( - path: &Path, - format: StructureFormat, - byte_count: usize, - parsed: &StructureParseResult, - output: CommandOutputFormat, - verbose: bool, -) -> Result<(), CliError> { - match output { - CommandOutputFormat::Text => print_text_summary(path, format, byte_count, parsed, verbose), - CommandOutputFormat::Json => print_json_summary(path, format, byte_count, parsed, verbose), +fn print_inspection(inspection: &StructureInspection) -> Result<(), CliError> { + match inspection.output { + CommandOutputFormat::Text => print_text_summary( + &inspection.path, + inspection.format, + inspection.byte_count, + &inspection.parsed, + inspection.verbose, + ), + CommandOutputFormat::Json => print_json_summary( + &inspection.path, + inspection.format, + inspection.byte_count, + &inspection.parsed, + inspection.verbose, + ), } } /// Verifies structure invariants and reports the result. -fn validate_structure( - path: &Path, - format: StructureFormat, - parsed: &StructureParseResult, - output: CommandOutputFormat, -) -> Result<(), CliError> { - let validation = parsed - .structure - .validate_invariants() - .map_err(|error| error.to_string()) - .and_then(|()| validate_content(&parsed.structure)); - match output { +fn print_validation(validation: &StructureValidation) -> Result<(), CliError> { + match validation.output { CommandOutputFormat::Text => { - if let Err(message) = validation { + if let Some(message) = validation.error.as_ref() { let error = Style::new().red().bold(); eprintln!("{} {}", error.apply_to("✗ Invalid structure:"), message); return Err(CliError::StructureValidation { - path: path.to_owned(), - message, + path: validation.path.clone(), + message: message.clone(), }); } let success = Style::new().green().bold(); println!( "{} {} ({})", success.apply_to("✓ Valid structure:"), - path.display(), - format.label() + validation.path.display(), + validation.format.label() ); } CommandOutputFormat::Json => { - let valid = validation.is_ok(); let value = json!({ - "path": path.display().to_string(), - "format": format.id(), - "valid": valid, - "error": validation.err(), + "path": validation.path.display().to_string(), + "format": validation.format.id(), + "valid": validation.is_valid(), + "error": validation.error, }); println!("{}", serde_json::to_string_pretty(&value)?); if let Some(message) = value.get("error").and_then(Value::as_str) { return Err(CliError::StructureValidation { - path: path.to_owned(), + path: validation.path.clone(), message: message.to_owned(), }); } @@ -143,36 +79,6 @@ fn validate_structure( Ok(()) } -/// Verifies that the parsed value contains an actual coordinate-bearing -/// structure rather than only metadata or ignored records. -/// -/// # Parameters -/// -/// * `structure` is the parsed, index-validated structure snapshot. -/// -/// # Returns -/// -/// `Ok(())` when at least one atom and one finite coordinate are present; -/// otherwise returns the reason the input cannot be considered a valid -/// molecular structure. -fn validate_content(structure: &Structure) -> Result<(), String> { - if structure.atoms().is_empty() { - return Err("no atom records were found".to_owned()); - } - if structure.models().is_empty() { - return Err("no coordinate models were found".to_owned()); - } - if !structure - .coordinates() - .iter() - .flat_map(|coordinates| coordinates.positions.iter()) - .any(|position| position.iter().all(|coordinate| coordinate.is_finite())) - { - return Err("no finite atom coordinates were found".to_owned()); - } - Ok(()) -} - /// Prints the human-readable inspection summary with terminal color accents. fn print_text_summary( path: &Path, @@ -313,13 +219,3 @@ fn summary_value( } value } - -/// Infers a structure format from a supported filename extension. -fn detect_format(path: &Path) -> Option { - let extension = path.extension()?.to_str()?.to_ascii_lowercase(); - match extension.as_str() { - "pdb" | "ent" => Some(StructureFormat::Pdb), - "cif" | "mmcif" => Some(StructureFormat::Mmcif), - _ => None, - } -} diff --git a/crates/chitin-command-runtime/Cargo.toml b/crates/chitin-command-runtime/Cargo.toml new file mode 100644 index 00000000..638a4ecc --- /dev/null +++ b/crates/chitin-command-runtime/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "chitin-command-runtime" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +chitin-bio = { path = "../chitin-bio" } +chitin-command = { path = "../chitin-command" } +chitin-databases = { path = "../chitin-databases" } +thiserror = "2.0" + +[dev-dependencies] +tokio = { version = "1.52", features = ["macros", "rt"] } + +[lints] +workspace = true diff --git a/crates/chitin-command-runtime/src/database.rs b/crates/chitin-command-runtime/src/database.rs new file mode 100644 index 00000000..ae819289 --- /dev/null +++ b/crates/chitin-command-runtime/src/database.rs @@ -0,0 +1,279 @@ +//! Portable database-command execution. + +use std::{collections::BTreeSet, path::PathBuf}; + +use chitin_command::{ + CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandMessage, CommandMessageLevel, + CommandProgress, DatabaseCommand, RcsbDownloadArguments, +}; +use chitin_databases::{ + Client, + providers::rcsb::{RcsbBatchDownloadEvent, RcsbBatchDownloadRequest}, +}; + +use crate::{CommandExecutionError, CommandOutcome}; + +/// Executes a portable database command through the shared provider client. +/// +/// # Parameters +/// +/// * `client` owns shared transport, retry, and concurrency policy. +/// * `command` contains validated provider-specific arguments. +/// * `context` supplies path resolution and cancellation resources. +/// * `events` receives frontend-neutral progress and artifacts. +/// +/// # Returns +/// +/// The typed outcome of the selected database workflow. +pub(super) async fn execute( + client: &Client, + command: DatabaseCommand, + context: &CommandExecutionContext, + events: &CommandEventSink, +) -> Result { + match command { + DatabaseCommand::DownloadRcsbStructure(arguments) => { + execute_rcsb_download(client, arguments, context, events).await + } + } +} + +/// Resolves the unique destination paths produced by an RCSB download. +/// +/// # Parameters +/// +/// * `arguments` contains identifiers, format, and an optional output override. +/// * `context` supplies the working directory and default download root. +/// +/// # Returns +/// +/// Final artifact paths in input order with duplicate destinations removed. +pub fn resolve_rcsb_download_paths( + arguments: &RcsbDownloadArguments, + context: &CommandExecutionContext, +) -> Result, CommandExecutionError> { + let multiple = arguments.ids.len() > 1; + let mut destinations = BTreeSet::new(); + arguments + .ids + .iter() + .map(|id| { + let destination = match arguments.output.as_ref() { + Some(output) => { + let output = resolve_from_working_directory(output, &context.working_directory); + if output.is_dir() { + output.join(arguments.format.filename(id)) + } else if multiple && (output.extension().is_some() || output.exists()) { + return Err(CommandExecutionError::MultipleOutputFile(output)); + } else if multiple { + output.join(arguments.format.filename(id)) + } else { + output + } + } + None => context + .default_download_root + .as_ref() + .ok_or(CommandExecutionError::MissingDownloadRoot { + command_id: chitin_command::CommandId::DatabaseDownloadRcsbStructure, + }) + .map(|root| resolve_from_working_directory(root, &context.working_directory))? + .join(arguments.format.id()) + .join(arguments.format.filename(id)), + }; + Ok(destination) + }) + .filter_map(|result| match result { + Ok(path) if destinations.insert(path.clone()) => Some(Ok(path)), + Ok(_) => None, + Err(error) => Some(Err(error)), + }) + .collect() +} + +/// Downloads all unique RCSB artifacts and forwards provider progress. +/// +/// # Parameters +/// +/// * `client` provides the configured RCSB provider. +/// * `arguments` contains validated identifiers and output preferences. +/// * `context` supplies path defaults and cooperative cancellation. +/// * `events` receives normalized progress, messages, and artifacts. +/// +/// # Returns +/// +/// A database outcome containing the persisted artifact count. +async fn execute_rcsb_download( + client: &Client, + arguments: RcsbDownloadArguments, + context: &CommandExecutionContext, + events: &CommandEventSink, +) -> Result { + let destinations = resolve_rcsb_download_paths(&arguments, context)?; + let mut seen = BTreeSet::new(); + let requests = arguments + .ids + .into_iter() + .filter(|id| seen.insert(id.clone())) + .zip(destinations) + .map(|(id, destination)| RcsbBatchDownloadRequest { + id, + format: arguments.format, + destination, + }) + .collect::>(); + let artifact_count = requests.len(); + let event_sink = events.clone(); + client + .rcsb() + .download_structures_to_paths_with_cancellation( + &requests, + move |event| emit_download_event(&event_sink, event), + context.cancellation.clone(), + ) + .await?; + Ok(CommandOutcome::DatabaseDownload { artifact_count }) +} + +/// Translates provider-specific download events into the command protocol. +/// +/// # Parameters +/// +/// * `events` is the frontend observer receiving normalized events. +/// * `event` is the provider-specific update being projected. +/// +/// # Returns +/// +/// This function returns after synchronously forwarding the event. +fn emit_download_event(events: &CommandEventSink, event: RcsbBatchDownloadEvent) { + match event { + RcsbBatchDownloadEvent::Started { index, total, id } => { + events.emit(CommandExecutionEvent::Progress(CommandProgress { + completed: 0, + total: None, + stage_index: index, + stage_count: total, + stage_label: Some(id.to_string()), + })); + } + RcsbBatchDownloadEvent::Progress { + index, + total, + id, + received, + total_bytes, + } => { + events.emit(CommandExecutionEvent::Progress(CommandProgress { + completed: received, + total: total_bytes, + stage_index: index, + stage_count: total, + stage_label: Some(id.to_string()), + })); + } + RcsbBatchDownloadEvent::Completed { id, artifact, .. } => { + events.emit(CommandExecutionEvent::Message(CommandMessage { + level: CommandMessageLevel::Info, + text: format!("downloaded RCSB structure {id}"), + })); + events.emit(CommandExecutionEvent::Artifact(artifact)); + } + } +} + +/// Resolves a relative path against the frontend working directory. +fn resolve_from_working_directory(path: &std::path::Path, working_directory: &std::path::Path) -> PathBuf { + if path.is_absolute() { + path.to_owned() + } else { + working_directory.join(path) + } +} + +#[cfg(test)] +mod tests { + use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; + + use super::*; + + fn id(value: &str) -> Result> { + Ok(PdbId::new(value)?) + } + + #[test] + fn default_download_paths_should_use_format_directory() -> Result<(), Box> { + let arguments = RcsbDownloadArguments { + ids: vec![id("4hhb")?], + format: StructureFormat::Mmcif, + output: None, + }; + let context = CommandExecutionContext::new("/workspace").with_default_download_root("/workspace/.chitin/download"); + + let paths = resolve_rcsb_download_paths(&arguments, &context)?; + + assert_eq!(paths, [PathBuf::from("/workspace/.chitin/download/mmcif/4HHB.cif")]); + Ok(()) + } + + #[test] + fn repeated_ids_should_produce_one_destination() -> Result<(), Box> { + let repeated = id("4hhb")?; + let arguments = RcsbDownloadArguments { + ids: vec![repeated.clone(), repeated], + format: StructureFormat::Pdb, + output: None, + }; + let context = CommandExecutionContext::new("/workspace").with_default_download_root("/workspace/.chitin/download"); + + let paths = resolve_rcsb_download_paths(&arguments, &context)?; + + assert_eq!(paths.len(), 1); + Ok(()) + } + + #[test] + fn existing_directory_should_receive_generated_filename() -> Result<(), Box> { + let directory = std::env::temp_dir(); + let arguments = RcsbDownloadArguments { + ids: vec![id("1yth")?], + format: StructureFormat::Pdb, + output: Some(directory.clone()), + }; + let context = CommandExecutionContext::new("/workspace"); + + let paths = resolve_rcsb_download_paths(&arguments, &context)?; + + assert_eq!(paths, [directory.join("1YTH.pdb")]); + Ok(()) + } + + #[test] + fn nonexistent_extensionless_single_output_should_remain_a_file() -> Result<(), Box> { + let arguments = RcsbDownloadArguments { + ids: vec![id("1yth")?], + format: StructureFormat::Pdb, + output: Some(PathBuf::from("custom/structure")), + }; + let context = CommandExecutionContext::new("/workspace"); + + let paths = resolve_rcsb_download_paths(&arguments, &context)?; + + assert_eq!(paths, [PathBuf::from("/workspace/custom/structure")]); + Ok(()) + } + + #[test] + fn multiple_downloads_should_reject_an_explicit_file() -> Result<(), Box> { + let arguments = RcsbDownloadArguments { + ids: vec![id("1yth")?, id("4hhb")?], + format: StructureFormat::Mmcif, + output: Some(PathBuf::from("structures/output.cif")), + }; + let context = CommandExecutionContext::new("/workspace"); + + let result = resolve_rcsb_download_paths(&arguments, &context); + + assert!(matches!(result, Err(CommandExecutionError::MultipleOutputFile(_)))); + Ok(()) + } +} diff --git a/crates/chitin-command-runtime/src/lib.rs b/crates/chitin-command-runtime/src/lib.rs new file mode 100644 index 00000000..5dbdc0d6 --- /dev/null +++ b/crates/chitin-command-runtime/src/lib.rs @@ -0,0 +1,229 @@ +#![forbid(unsafe_code)] +//! Frontend-independent execution for portable Chitin commands. +//! +//! This crate performs database and structure workflows without printing to a +//! terminal or accessing GPUI. Frontends supply an execution context, observe +//! structured events, and render the returned outcome in their own language. + +mod database; +mod structure; + +use std::{ + path::PathBuf, + sync::{Arc, OnceLock}, +}; + +use chitin_bio::structure::StructureParseResult; +use chitin_command::{ + ChitinCommand, CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandId, CommandOutputFormat, +}; +use chitin_databases::{Client, ClientConfig, TransportError, providers::rcsb::StructureFormat}; + +pub use database::resolve_rcsb_download_paths; + +/// Successful result returned by a portable command. +#[derive(Clone, Debug, PartialEq)] +pub enum CommandOutcome { + /// One or more RCSB artifacts were downloaded. + DatabaseDownload { + /// Number of unique persisted artifacts. + artifact_count: usize, + }, + /// A structure was parsed for inspection. + StructureInspection(Box), + /// A structure was parsed and checked for model validity. + StructureValidation(StructureValidation), +} + +/// Parsed structure data and presentation preferences for inspection output. +#[derive(Clone, Debug, PartialEq)] +pub struct StructureInspection { + /// User-provided source path, or `-` for standard input. + pub path: PathBuf, + /// Resolved source format. + pub format: StructureFormat, + /// Number of source bytes parsed. + pub byte_count: usize, + /// Shared structure model and recoverable diagnostics. + pub parsed: StructureParseResult, + /// Output representation requested by the frontend. + pub output: CommandOutputFormat, + /// Whether detailed metadata and diagnostics were requested. + pub verbose: bool, +} + +/// Result of parsing and validating one local structure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StructureValidation { + /// User-provided source path, or `-` for standard input. + pub path: PathBuf, + /// Resolved source format. + pub format: StructureFormat, + /// Output representation requested by the frontend. + pub output: CommandOutputFormat, + /// Validation failure, or `None` when the structure is valid. + pub error: Option, +} + +impl StructureValidation { + /// Returns whether parsing and all model invariants succeeded. + pub fn is_valid(&self) -> bool { + self.error.is_none() + } +} + +/// Failure while preparing or executing a portable command. +#[derive(Debug, thiserror::Error)] +pub enum CommandExecutionError { + /// The selected command belongs to a frontend-specific domain. + #[error("command '{0}' requires a frontend-specific executor")] + UnsupportedCommand(CommandId), + /// No default download directory was supplied for a command without output. + #[error("command '{command_id}' requires a default download directory")] + MissingDownloadRoot { + /// Command requiring the missing directory. + command_id: CommandId, + }, + /// Multiple downloads cannot target one explicit file. + #[error("--output must be a directory when downloading multiple PDB IDs: {0}")] + MultipleOutputFile(PathBuf), + /// Standard input was requested but the frontend supplied no bytes. + #[error("structure input '-' requires standard-input bytes from the frontend")] + MissingStandardInput, + /// The structure format could not be inferred. + #[error("could not determine structure format for '{0}'; use --format pdb or --format mmcif")] + UnknownStructureFormat(PathBuf), + /// A local structure file could not be read. + #[error("failed to read structure '{path}': {source}")] + StructureRead { + /// User-provided structure path. + path: PathBuf, + /// Filesystem failure. + source: std::io::Error, + }, + /// A structure parser rejected the source bytes. + #[error("failed to parse structure '{path}': {message}")] + StructureParse { + /// User-provided structure path. + path: PathBuf, + /// Parser diagnostic. + message: String, + }, + /// The shared database client could not be initialized. + #[error("failed to initialize database client: {0}")] + DatabaseClient(#[source] TransportError), + /// An RCSB provider or persistence operation failed. + #[error("RCSB download failed: {0}")] + RcsbDownload(#[from] chitin_databases::providers::rcsb::RcsbDownloadError), +} + +/// Executor for commands whose behavior is shared by every frontend. +#[derive(Clone)] +pub struct CommandExecutor { + config: ClientConfig, + client: Arc>>, +} + +impl CommandExecutor { + /// Creates an executor that initializes its database client on first use. + pub fn new(config: ClientConfig) -> Self { + Self { + config, + client: Arc::new(OnceLock::new()), + } + } + + /// Executes a complete typed command and emits frontend-neutral events. + /// + /// # Parameters + /// + /// * `command` contains the validated command identity and arguments. + /// * `context` supplies paths, standard input, and cancellation. + /// * `events` receives progress, diagnostics, and persisted artifacts. + /// + /// # Returns + /// + /// A typed outcome that the calling frontend can render or consume. + /// + /// # Errors + /// + /// Returns [`CommandExecutionError`] when the command is frontend-specific, + /// required context is missing, parsing fails, or provider execution fails. + pub async fn execute( + &self, + command: ChitinCommand, + context: CommandExecutionContext, + events: CommandEventSink, + ) -> Result { + let command_id = command.id(); + events.emit(CommandExecutionEvent::Started { command_id }); + let outcome = match command { + ChitinCommand::Database(command) => database::execute(self.database_client()?, command, &context, &events).await, + ChitinCommand::Structure(command) => structure::execute(command, &context), + ChitinCommand::Workspace(_) | ChitinCommand::Application(_) => { + Err(CommandExecutionError::UnsupportedCommand(command_id)) + } + }?; + events.emit(CommandExecutionEvent::Completed { command_id }); + Ok(outcome) + } + + /// Returns the lazily initialized shared database client. + fn database_client(&self) -> Result<&Client, CommandExecutionError> { + match self.client.get_or_init(|| Client::new(self.config.clone())) { + Ok(client) => Ok(client), + Err(error) => Err(CommandExecutionError::DatabaseClient(error.clone())), + } + } +} + +#[cfg(test)] +mod tests { + use std::{path::PathBuf, sync::Mutex}; + + use chitin_command::{CommandOutputFormat, StructureCommand, StructureInputArguments, StructureInspectArguments}; + + use super::*; + + #[tokio::test] + async fn executor_should_emit_lifecycle_events_around_structure_inspection() -> Result<(), CommandExecutionError> { + let observed = Arc::new(Mutex::new(Vec::new())); + let callback_observed = observed.clone(); + let events = CommandEventSink::new(move |event| { + if let Ok(mut observed) = callback_observed.lock() { + observed.push(event); + } + }); + let command = StructureCommand::Inspect(StructureInspectArguments { + input: StructureInputArguments { + input: PathBuf::from("-"), + format: Some(StructureFormat::Pdb), + }, + output: CommandOutputFormat::Text, + verbose: false, + }); + let context = CommandExecutionContext::new(".").with_standard_input( + b"ATOM 1 CA GLY A 1 1.000 2.000 3.000 1.00 20.00 C \nEND\n".to_vec(), + ); + let executor = CommandExecutor::new(ClientConfig::default()); + + let _ = executor.execute(command.into(), context, events).await?; + let observed = match observed.lock() { + Ok(observed) => observed.clone(), + Err(_) => Vec::new(), + }; + + assert!(matches!( + observed.as_slice(), + [ + CommandExecutionEvent::Started { + command_id: CommandId::StructureInspect + }, + CommandExecutionEvent::Completed { + command_id: CommandId::StructureInspect + } + ] + )); + Ok(()) + } +} diff --git a/crates/chitin-command-runtime/src/structure.rs b/crates/chitin-command-runtime/src/structure.rs new file mode 100644 index 00000000..18919870 --- /dev/null +++ b/crates/chitin-command-runtime/src/structure.rs @@ -0,0 +1,188 @@ +//! Portable local-structure inspection and validation. + +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use chitin_bio::structure::{MmcifParser, PdbParser, Structure, StructureParseResult}; +use chitin_command::{CommandExecutionContext, StructureCommand, StructureInputArguments}; +use chitin_databases::providers::rcsb::StructureFormat; + +use crate::{CommandExecutionError, CommandOutcome, StructureInspection, StructureValidation}; + +/// Executes a local structure command against frontend-provided resources. +/// +/// # Parameters +/// +/// * `command` contains the input, output, and validation preferences. +/// * `context` supplies the working directory and optional standard input. +/// +/// # Returns +/// +/// Parsed inspection data or a structured validation result. +pub(super) fn execute( + command: StructureCommand, + context: &CommandExecutionContext, +) -> Result { + match command { + StructureCommand::Inspect(arguments) => { + let (format, bytes) = read_input(&arguments.input, context)?; + let parsed = parse_structure(format, &arguments.input.input, &bytes)?; + Ok(CommandOutcome::StructureInspection(Box::new(StructureInspection { + path: arguments.input.input, + format, + byte_count: bytes.len(), + parsed, + output: arguments.output, + verbose: arguments.verbose, + }))) + } + StructureCommand::Validate(arguments) => { + let (format, bytes) = read_input(&arguments.input, context)?; + let parsed = parse_structure(format, &arguments.input.input, &bytes)?; + let error = parsed + .structure + .validate_invariants() + .map_err(|error| error.to_string()) + .and_then(|()| validate_content(&parsed.structure)) + .err(); + Ok(CommandOutcome::StructureValidation(StructureValidation { + path: arguments.input.input, + format, + output: arguments.output, + error, + })) + } + } +} + +/// Reads a local file or frontend-provided standard input. +/// +/// # Parameters +/// +/// * `input` identifies the source path and optional explicit format. +/// * `context` resolves relative paths and supplies standard-input bytes. +/// +/// # Returns +/// +/// The resolved structure format and complete source bytes. +fn read_input( + input: &StructureInputArguments, + context: &CommandExecutionContext, +) -> Result<(StructureFormat, Arc<[u8]>), CommandExecutionError> { + let path = &input.input; + let format = input + .format + .or_else(|| detect_format(path)) + .ok_or_else(|| CommandExecutionError::UnknownStructureFormat(path.to_owned()))?; + if path == Path::new("-") { + let bytes = context + .standard_input + .as_ref() + .ok_or(CommandExecutionError::MissingStandardInput)? + .clone(); + return Ok((format, bytes)); + } + let resolved = resolve_from_working_directory(path, &context.working_directory); + let bytes = std::fs::read(resolved).map_err(|source| CommandExecutionError::StructureRead { + path: path.to_owned(), + source, + })?; + Ok((format, bytes.into())) +} + +/// Parses bytes with the reader selected by the resolved format. +/// +/// # Parameters +/// +/// * `format` selects the PDB or mmCIF parser. +/// * `path` identifies the input in diagnostics. +/// * `bytes` contains the complete source document. +/// +/// # Returns +/// +/// The shared molecular structure and recoverable parser diagnostics. +fn parse_structure( + format: StructureFormat, + path: &Path, + bytes: &[u8], +) -> Result { + let result = match format { + StructureFormat::Pdb => PdbParser::new().parse_bytes(bytes).map_err(|source| source.to_string()), + StructureFormat::Mmcif => MmcifParser::new() + .parse_bytes(bytes) + .map_err(|source| source.to_string()), + }; + result.map_err(|message| CommandExecutionError::StructureParse { + path: path.to_owned(), + message, + }) +} + +/// Verifies that parsed data contains a coordinate-bearing structure. +fn validate_content(structure: &Structure) -> Result<(), String> { + if structure.atoms().is_empty() { + return Err("no atom records were found".to_owned()); + } + if structure.models().is_empty() { + return Err("no coordinate models were found".to_owned()); + } + if !structure + .coordinates() + .iter() + .flat_map(|coordinates| coordinates.positions.iter()) + .any(|position| position.iter().all(|coordinate| coordinate.is_finite())) + { + return Err("no finite atom coordinates were found".to_owned()); + } + Ok(()) +} + +/// Infers a structure format from a supported filename extension. +fn detect_format(path: &Path) -> Option { + let extension = path.extension()?.to_str()?.to_ascii_lowercase(); + match extension.as_str() { + "pdb" | "ent" => Some(StructureFormat::Pdb), + "cif" | "mmcif" => Some(StructureFormat::Mmcif), + _ => None, + } +} + +/// Resolves a relative input path against the frontend working directory. +fn resolve_from_working_directory(path: &Path, working_directory: &Path) -> PathBuf { + if path.is_absolute() { + path.to_owned() + } else { + working_directory.join(path) + } +} + +#[cfg(test)] +mod tests { + use chitin_command::{CommandOutputFormat, StructureInspectArguments}; + + use super::*; + + #[test] + fn inspect_should_parse_frontend_standard_input() -> Result<(), CommandExecutionError> { + let command = StructureCommand::Inspect(StructureInspectArguments { + input: StructureInputArguments { + input: PathBuf::from("-"), + format: Some(StructureFormat::Pdb), + }, + output: CommandOutputFormat::Text, + verbose: false, + }); + let context = CommandExecutionContext::new(".").with_standard_input( + b"ATOM 1 CA GLY A 1 1.000 2.000 3.000 1.00 20.00 C \nEND\n".to_vec(), + ); + + let outcome = execute(command, &context)?; + + assert!( + matches!(outcome, CommandOutcome::StructureInspection(inspection) if inspection.parsed.structure.atoms().len() == 1) + ); + Ok(()) + } +} diff --git a/crates/chitin-command/src/execution.rs b/crates/chitin-command/src/execution.rs new file mode 100644 index 00000000..4afb6b21 --- /dev/null +++ b/crates/chitin-command/src/execution.rs @@ -0,0 +1,138 @@ +//! Frontend-neutral execution context and observable command events. + +use std::{path::PathBuf, sync::Arc}; + +use chitin_databases::{CancellationToken, PersistedArtifact}; + +use crate::CommandId; + +/// Runtime resources supplied by the frontend executing a command. +#[derive(Clone, Debug)] +pub struct CommandExecutionContext { + /// Directory used to resolve relative input and output paths. + pub working_directory: PathBuf, + /// Active project root when the command runs inside a workspace. + pub workspace_root: Option, + /// Default `.chitin/download` directory for downloads without `--output`. + pub default_download_root: Option, + /// Bytes supplied for an input path of `-`. + pub standard_input: Option>, + /// Cooperative cancellation shared with the frontend task owner. + pub cancellation: CancellationToken, +} + +impl CommandExecutionContext { + /// Creates an execution context rooted at the supplied working directory. + pub fn new(working_directory: impl Into) -> Self { + Self { + working_directory: working_directory.into(), + workspace_root: None, + default_download_root: None, + standard_input: None, + cancellation: CancellationToken::new(), + } + } + + /// Sets the active workspace root. + pub fn with_workspace_root(mut self, workspace_root: impl Into) -> Self { + self.workspace_root = Some(workspace_root.into()); + self + } + + /// Sets the default directory containing format-specific download folders. + pub fn with_default_download_root(mut self, root: impl Into) -> Self { + self.default_download_root = Some(root.into()); + self + } + + /// Supplies standard-input bytes for commands whose input path is `-`. + pub fn with_standard_input(mut self, bytes: impl Into>) -> Self { + self.standard_input = Some(bytes.into()); + self + } + + /// Replaces the cooperative cancellation token. + pub fn with_cancellation(mut self, cancellation: CancellationToken) -> Self { + self.cancellation = cancellation; + self + } +} + +/// Progress for the active stage of a command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandProgress { + /// Completed units for the active stage. + pub completed: u64, + /// Total units when the executor can determine them. + pub total: Option, + /// One-based active stage index. + pub stage_index: usize, + /// Number of stages in the command. + pub stage_count: usize, + /// Human-readable active stage label. + pub stage_label: Option, +} + +/// Severity attached to a command diagnostic message. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandMessageLevel { + /// Normal execution information. + Info, + /// Recoverable condition worth presenting to the user. + Warning, +} + +/// Structured diagnostic emitted during command execution. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandMessage { + /// Diagnostic severity. + pub level: CommandMessageLevel, + /// User-facing message without terminal or GPUI styling. + pub text: String, +} + +/// Observable event emitted by the frontend-independent executor. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CommandExecutionEvent { + /// Command execution has started. + Started { + /// Stable identity of the executing command. + command_id: CommandId, + }, + /// Progress changed for the active stage. + Progress(CommandProgress), + /// A diagnostic message was produced. + Message(CommandMessage), + /// A downloaded artifact was durably persisted. + Artifact(Box), + /// Command execution completed successfully. + Completed { + /// Stable identity of the completed command. + command_id: CommandId, + }, +} + +/// Cloneable event callback shared with asynchronous provider operations. +#[derive(Clone)] +pub struct CommandEventSink { + callback: Arc, +} + +impl CommandEventSink { + /// Creates an event sink from a thread-safe callback. + pub fn new(callback: impl Fn(CommandExecutionEvent) + Send + Sync + 'static) -> Self { + Self { + callback: Arc::new(callback), + } + } + + /// Emits one event to the frontend adapter. + pub fn emit(&self, event: CommandExecutionEvent) { + (self.callback)(event); + } + + /// Creates a sink that deliberately discards all events. + pub fn silent() -> Self { + Self::new(|_| {}) + } +} diff --git a/crates/chitin-command/src/lib.rs b/crates/chitin-command/src/lib.rs index 086ee0f5..5f3415a9 100644 --- a/crates/chitin-command/src/lib.rs +++ b/crates/chitin-command/src/lib.rs @@ -2,6 +2,7 @@ mod application; mod database; +mod execution; mod panel_tab; mod parser; mod structure; @@ -9,6 +10,10 @@ mod workspace; pub use application::ApplicationCommand; pub use database::{DatabaseCommand, RcsbDownloadArguments}; +pub use execution::{ + CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandMessage, CommandMessageLevel, + CommandProgress, +}; pub use panel_tab::PanelTabCommand; pub use parser::{CommandParseError, parse_command_line}; pub use structure::{ diff --git a/crates/chitin-desktop/Cargo.toml b/crates/chitin-desktop/Cargo.toml index 1545471c..ab7e1fb8 100644 --- a/crates/chitin-desktop/Cargo.toml +++ b/crates/chitin-desktop/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true [dependencies] chitin-command = { path = "../chitin-command" } +chitin-command-runtime = { path = "../chitin-command-runtime" } chitin-bio = { path = "../chitin-bio" } log = "0.4.33" env_logger = "0.10" diff --git a/crates/chitin-desktop/src/app.rs b/crates/chitin-desktop/src/app.rs index 8f0a4630..7ead9fff 100644 --- a/crates/chitin-desktop/src/app.rs +++ b/crates/chitin-desktop/src/app.rs @@ -5,6 +5,8 @@ use std::{collections::BTreeSet, path::PathBuf}; +use chitin_command_runtime::CommandExecutor; +use chitin_databases::ClientConfig; use chitin_ui::{ composite::{ activity_bar::DEFAULT_ACTIVITY_BAR_WIDTH, @@ -55,6 +57,8 @@ pub struct ChitinApp { pub(crate) command_panel: CommandPanelController, /// Application-wide executor and registry for background work. pub(crate) tasks: BackgroundTaskCenter, + /// Shared executor for frontend-independent typed commands. + pub(crate) command_executor: CommandExecutor, /// Structure paths currently being read and parsed off the UI thread. pub(crate) pending_structure_loads: BTreeSet, /// Primitive button state for platform window actions. @@ -135,6 +139,7 @@ impl ChitinApp { project_sidebar_visible: true, command_panel: CommandPanelController::new(), tasks: BackgroundTaskCenter::new(), + command_executor: CommandExecutor::new(ClientConfig::default()), pending_structure_loads: BTreeSet::new(), window_bar_controls: None, activity_bar_controls: None, diff --git a/crates/chitin-desktop/src/components/command_panel.rs b/crates/chitin-desktop/src/components/command_panel.rs index 137f4a26..d7219371 100644 --- a/crates/chitin-desktop/src/components/command_panel.rs +++ b/crates/chitin-desktop/src/components/command_panel.rs @@ -1,16 +1,16 @@ //! Desktop command panel rendering and input handling. -use std::{collections::BTreeSet, rc::Rc}; +use std::rc::Rc; mod controller; mod form; pub(crate) use controller::CommandPanelController; use controller::{CommandPanelEvent, CommandPanelMode}; -use form::rcsb::{RcsbDownloadState, RcsbFormPanel, download_path}; +use form::rcsb::{RcsbDownloadState, RcsbFormPanel}; -use chitin_command::{CommandId, DatabaseCommand, RcsbDownloadArguments}; -use chitin_databases::providers::rcsb::{PdbId, RcsbBatchDownloadRequest}; +use chitin_command::{CommandExecutionContext, CommandId, DatabaseCommand, RcsbDownloadArguments}; +use chitin_databases::providers::rcsb::PdbId; use chitin_ui::{ composite::{ quickpick::{QuickPickItem, QuickPickOverlay, QuickPickSearchInput, render_quick_pick_overlay}, @@ -33,7 +33,7 @@ use gpui::{ use crate::{ app::ChitinApp, components::document_area::state::OpenedProjectDocument, - tasks::{TaskOutput, TaskState, rcsb::submit_downloads}, + tasks::{TaskOutput, TaskState, rcsb::submit_download}, }; /// Callback invoked when a rendered command row is selected. @@ -253,36 +253,18 @@ impl ChitinApp { form.download.update(cx, |state, cx| state.fail(error, cx)); return; }; - let RcsbDownloadArguments { ids, format, output } = arguments; - let multiple = ids.len() > 1; - let mut unique_ids = BTreeSet::new(); - let requests = ids - .into_iter() - .filter(|pdb_id| unique_ids.insert(pdb_id.clone())) - .map(|pdb_id| RcsbBatchDownloadRequest { - destination: output - .as_ref() - .map(|path| { - if path.is_dir() || multiple { - path.join(format.filename(&pdb_id)) - } else { - path.clone() - } - }) - .unwrap_or_else(|| download_path(&workspace_root, &pdb_id, format)), - id: pdb_id, - format, - }) - .collect::>(); - let total_items = requests.len(); - let mut task = match submit_downloads(&self.tasks, requests) { - Ok(task) => task, - Err(error) => { - log::error!("failed to submit RCSB download task: {error}"); - form.download.update(cx, |state, cx| state.fail(error.to_string(), cx)); - return; - } - }; + let execution_context = CommandExecutionContext::new(&workspace_root) + .with_workspace_root(&workspace_root) + .with_default_download_root(workspace_root.join(".chitin").join("download")); + let (mut task, total_items) = + match submit_download(&self.tasks, self.command_executor.clone(), arguments, execution_context) { + Ok(submission) => submission, + Err(error) => { + log::error!("failed to submit RCSB download task: {error}"); + form.download.update(cx, |state, cx| state.fail(error.to_string(), cx)); + return; + } + }; log::info!( "RCSB batch download task submitted: id={}, items={total_items}", task.id() diff --git a/crates/chitin-desktop/src/components/command_panel/form/rcsb.rs b/crates/chitin-desktop/src/components/command_panel/form/rcsb.rs index 7a731547..eccff69b 100644 --- a/crates/chitin-desktop/src/components/command_panel/form/rcsb.rs +++ b/crates/chitin-desktop/src/components/command_panel/form/rcsb.rs @@ -1,8 +1,6 @@ //! RCSB structure-download form rendered inside the command panel. -use std::path::{Path, PathBuf}; - -use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; +use chitin_databases::providers::rcsb::StructureFormat; use chitin_ui::{ primitive::{ @@ -24,18 +22,6 @@ use gpui::{ use crate::tasks::{TaskSnapshot, TaskState}; -/// Builds the final workspace download path for an RCSB structure. -pub(crate) fn download_path(workspace_root: &Path, pdb_id: &PdbId, format: StructureFormat) -> PathBuf { - workspace_root - .join(".chitin") - .join("download") - .join(match format { - StructureFormat::Pdb => "pdb", - StructureFormat::Mmcif => "mmcif", - }) - .join(format.filename(pdb_id)) -} - /// Persistent primitive state for the RCSB download form. #[derive(Clone)] pub(crate) struct RcsbFormPanel { @@ -398,31 +384,7 @@ impl RcsbFormPanel { #[cfg(test)] mod tests { - use std::path::Path; - - use chitin_databases::providers::rcsb::PdbId; - - use super::{RcsbDownloadState, StructureFormat, download_path, download_progress_label}; - - #[test] - fn pdb_download_path_should_use_pdb_directory_and_extension() -> Result<(), Box> { - let id = PdbId::new("1yth")?; - - let path = download_path(Path::new("/workspace"), &id, StructureFormat::Pdb); - - assert_eq!(path, Path::new("/workspace/.chitin/download/pdb/1YTH.pdb")); - Ok(()) - } - - #[test] - fn mmcif_download_path_should_use_mmcif_directory_and_cif_extension() -> Result<(), Box> { - let id = PdbId::new("1yth")?; - - let path = download_path(Path::new("/workspace"), &id, StructureFormat::Mmcif); - - assert_eq!(path, Path::new("/workspace/.chitin/download/mmcif/1YTH.cif")); - Ok(()) - } + use super::{RcsbDownloadState, download_progress_label}; #[test] fn cancelled_download_should_render_cancelled_label() { diff --git a/crates/chitin-desktop/src/tasks/rcsb.rs b/crates/chitin-desktop/src/tasks/rcsb.rs index 8dd31b46..b1dfdbfa 100644 --- a/crates/chitin-desktop/src/tasks/rcsb.rs +++ b/crates/chitin-desktop/src/tasks/rcsb.rs @@ -1,85 +1,110 @@ -//! RCSB database adapter for the application task center. - -use std::collections::BTreeSet; - -use chitin_databases::{ - Client, ClientConfig, - providers::rcsb::{RcsbBatchDownloadEvent, RcsbBatchDownloadRequest, RcsbDownloadError, RcsbError}, -}; +//! RCSB command adapter for the application task center. use super::{ BackgroundTaskCenter, TaskCenterError, TaskContext, TaskFailure, TaskHandle, TaskKind, TaskOutput, TaskProgress, TaskTarget, }; +use chitin_command::{ + ChitinCommand, CommandEventSink, CommandExecutionContext, CommandExecutionEvent, DatabaseCommand, + RcsbDownloadArguments, +}; +use chitin_command_runtime::{CommandExecutionError, CommandExecutor, CommandOutcome, resolve_rcsb_download_paths}; -/// Submits a batch of RCSB downloads, coalescing repeated destination paths. -pub(crate) fn submit_downloads( +/// Failure while preparing or submitting a database command task. +#[derive(Debug, thiserror::Error)] +pub(crate) enum SubmitRcsbCommandError { + /// Portable command preparation failed before task submission. + #[error(transparent)] + Execution(#[from] CommandExecutionError), + /// The desktop task center rejected the prepared task. + #[error(transparent)] + TaskCenter(#[from] TaskCenterError), +} + +/// Submits a typed RCSB download through the shared command executor. +/// +/// # Parameters +/// +/// * `center` schedules and tracks the asynchronous desktop task. +/// * `executor` is the application-wide portable command executor. +/// * `arguments` contains validated download inputs. +/// * `execution_context` supplies workspace paths and cancellation defaults. +/// +/// # Returns +/// +/// The task subscription and number of unique output artifacts. +pub(crate) fn submit_download( center: &BackgroundTaskCenter, - mut requests: Vec, -) -> Result { - let mut destinations = BTreeSet::new(); - requests.retain(|request| destinations.insert(request.destination.clone())); - let targets = requests - .iter() - .map(|request| TaskTarget::File(request.destination.clone())) - .collect(); - center.submit( + executor: CommandExecutor, + arguments: RcsbDownloadArguments, + execution_context: CommandExecutionContext, +) -> Result<(TaskHandle, usize), SubmitRcsbCommandError> { + let paths = resolve_rcsb_download_paths(&arguments, &execution_context)?; + let total_items = paths.len(); + let targets = paths.into_iter().map(TaskTarget::File).collect(); + let handle = center.submit( TaskKind::DatabaseDownload { provider: "RCSB".to_string(), }, targets, - move |context| run_downloads(context, requests), - ) + move |context| run_download(context, executor, arguments, execution_context), + )?; + Ok((handle, total_items)) } -/// Runs the provider batch inside the task center's shared Tokio runtime. -async fn run_downloads(context: TaskContext, requests: Vec) -> Result<(), TaskFailure> { - context.log(format!("downloading {} RCSB structure artifacts", requests.len())); - let client = Client::new(ClientConfig::default()) - .map_err(|error| TaskFailure::new(RcsbDownloadError::Provider(RcsbError::Transport(error)).to_string()))?; +/// Runs the portable executor inside the desktop task center. +/// +/// # Parameters +/// +/// * `context` reports observable state to the desktop task center. +/// * `executor` executes the typed command without GPUI dependencies. +/// * `arguments` contains the validated download request. +/// * `execution_context` supplies workspace paths and cancellation defaults. +/// +/// # Returns +/// +/// `Ok(())` after all artifacts are persisted and reported. +async fn run_download( + context: TaskContext, + executor: CommandExecutor, + arguments: RcsbDownloadArguments, + execution_context: CommandExecutionContext, +) -> Result<(), TaskFailure> { let event_context = context.clone(); - client - .rcsb() - .download_structures_to_paths_with_cancellation( - &requests, - move |event| report_download_event(&event_context, event), - context.cancellation_token(), - ) + let events = CommandEventSink::new(move |event| report_command_event(&event_context, event)); + let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure(arguments)); + let execution_context = execution_context.with_cancellation(context.cancellation_token()); + let outcome = executor + .execute(command, execution_context, events) .await .map_err(|error| TaskFailure::new(error.to_string()))?; + if !matches!(outcome, CommandOutcome::DatabaseDownload { .. }) { + return Err(TaskFailure::new("RCSB task produced an unexpected command outcome")); + } Ok(()) } -/// Translates provider-specific progress into task-center events. -fn report_download_event(context: &TaskContext, event: RcsbBatchDownloadEvent) { +/// Projects command events into the existing desktop task-center protocol. +/// +/// # Parameters +/// +/// * `context` receives translated progress, logs, and outputs. +/// * `event` is the frontend-independent command event to translate. +/// +/// # Returns +/// +/// This function returns after synchronously updating the task snapshot. +fn report_command_event(context: &TaskContext, event: CommandExecutionEvent) { match event { - RcsbBatchDownloadEvent::Started { index, total, id } => { - context.report_progress(TaskProgress { - completed: 0, - total: None, - stage_index: index, - stage_count: total, - stage_label: Some(id.to_string()), - }); - } - RcsbBatchDownloadEvent::Progress { - index, - total, - id, - received, - total_bytes, - } => { - context.report_progress(TaskProgress { - completed: received, - total: total_bytes, - stage_index: index, - stage_count: total, - stage_label: Some(id.to_string()), - }); - } - RcsbBatchDownloadEvent::Completed { id, artifact, .. } => { - context.log(format!("persisted RCSB structure {id} to {}", artifact.path.display())); - context.output(TaskOutput::PersistedArtifact(artifact)); - } + CommandExecutionEvent::Progress(progress) => context.report_progress(TaskProgress { + completed: progress.completed, + total: progress.total, + stage_index: progress.stage_index, + stage_count: progress.stage_count, + stage_label: progress.stage_label, + }), + CommandExecutionEvent::Message(message) => context.log(message.text), + CommandExecutionEvent::Artifact(artifact) => context.output(TaskOutput::PersistedArtifact(artifact)), + CommandExecutionEvent::Started { .. } | CommandExecutionEvent::Completed { .. } => {} } } From 65407017b0619f62898c997b963a4736ba62e2ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Sun, 20 Sep 2026 18:23:18 +0800 Subject: [PATCH 04/32] feat(shell): add frontend-independent built-in command sessions - add the chitin-builtin-shell workspace crate - support interactive, agent, and system command invocations - accept both command-line text and structured typed commands - route portable commands through the shared command executor - expose frontend commands for desktop-side dispatch - retain correlated execution history, progress, artifacts, and transcript state - support history navigation and cooperative command cancellation - add tests for routing, agent submissions, execution, history, and cancellation --- Cargo.lock | 11 + Cargo.toml | 1 + crates/chitin-builtin-shell/Cargo.toml | 18 + crates/chitin-builtin-shell/src/event.rs | 59 ++ crates/chitin-builtin-shell/src/lib.rs | 17 + crates/chitin-builtin-shell/src/session.rs | 811 +++++++++++++++++++++ 6 files changed, 917 insertions(+) create mode 100644 crates/chitin-builtin-shell/Cargo.toml create mode 100644 crates/chitin-builtin-shell/src/event.rs create mode 100644 crates/chitin-builtin-shell/src/lib.rs create mode 100644 crates/chitin-builtin-shell/src/session.rs diff --git a/Cargo.lock b/Cargo.lock index b32a7cb3..ba0a8282 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -725,6 +725,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "chitin-builtin-shell" +version = "0.1.0" +dependencies = [ + "chitin-command", + "chitin-command-runtime", + "chitin-databases", + "thiserror 2.0.20", + "tokio", +] + [[package]] name = "chitin-command" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3b0241b5..2dea4c13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/chitin-builtin-shell", "crates/chitin-command", "crates/chitin-command-runtime", "crates/chitin-cli", diff --git a/crates/chitin-builtin-shell/Cargo.toml b/crates/chitin-builtin-shell/Cargo.toml new file mode 100644 index 00000000..c40ec870 --- /dev/null +++ b/crates/chitin-builtin-shell/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "chitin-builtin-shell" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +chitin-command = { path = "../chitin-command" } +chitin-command-runtime = { path = "../chitin-command-runtime" } +chitin-databases = { path = "../chitin-databases" } +thiserror = "2.0" + +[dev-dependencies] +tokio = { version = "1.52", features = ["macros", "rt"] } + +[lints] +workspace = true diff --git a/crates/chitin-builtin-shell/src/event.rs b/crates/chitin-builtin-shell/src/event.rs new file mode 100644 index 00000000..37fbbd17 --- /dev/null +++ b/crates/chitin-builtin-shell/src/event.rs @@ -0,0 +1,59 @@ +//! Observable events emitted by a built-in shell session. + +use std::sync::Arc; + +use chitin_command::CommandExecutionEvent; + +use crate::{ShellCommandId, ShellExecutionRecord, ShellExecutionStatus}; + +/// A state transition emitted by a built-in shell session. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ShellEvent { + /// A parsed command was accepted as the active submission. + Submitted(ShellExecutionRecord), + /// A portable executor emitted progress, a message, or an artifact. + CommandExecution { + /// Shell-local identity of the active submission. + id: ShellCommandId, + /// Frontend-neutral event emitted by the command executor. + event: CommandExecutionEvent, + }, + /// The active command entered a new shell lifecycle state. + StateChanged { + /// Shell-local identity of the affected submission. + id: ShellCommandId, + /// Current lifecycle state. + status: ShellExecutionStatus, + }, +} + +/// Cloneable callback receiving built-in shell events. +#[derive(Clone)] +pub struct ShellEventSink { + callback: Arc, +} + +impl ShellEventSink { + /// Creates a shell event sink from a thread-safe callback. + pub fn new(callback: impl Fn(ShellEvent) + Send + Sync + 'static) -> Self { + Self { + callback: Arc::new(callback), + } + } + + /// Emits one event to the shell host. + pub fn emit(&self, event: ShellEvent) { + (self.callback)(event); + } + + /// Creates a sink that deliberately discards every event. + pub fn silent() -> Self { + Self::new(|_| {}) + } +} + +impl Default for ShellEventSink { + fn default() -> Self { + Self::silent() + } +} diff --git a/crates/chitin-builtin-shell/src/lib.rs b/crates/chitin-builtin-shell/src/lib.rs new file mode 100644 index 00000000..9c6f31f2 --- /dev/null +++ b/crates/chitin-builtin-shell/src/lib.rs @@ -0,0 +1,17 @@ +#![forbid(unsafe_code)] +//! Session state and execution routing for Chitin's built-in command shell. +//! +//! This crate is independent of GPUI and terminal emulation. It parses one +//! command line at a time, retains navigation and execution history, forwards +//! portable commands to `chitin-command-runtime`, and returns frontend commands +//! to the host application for execution. + +mod event; +mod session; + +pub use event::{ShellEvent, ShellEventSink}; +pub use session::{ + BuiltinShell, BuiltinShellError, BuiltinShellSnapshot, ShellActiveCommand, ShellCommandId, ShellCommandTarget, + ShellExecutionRecord, ShellExecutionResult, ShellExecutionStatus, ShellInvocationSource, ShellSubmission, + ShellTranscriptContent, ShellTranscriptEntry, +}; diff --git a/crates/chitin-builtin-shell/src/session.rs b/crates/chitin-builtin-shell/src/session.rs new file mode 100644 index 00000000..cf091516 --- /dev/null +++ b/crates/chitin-builtin-shell/src/session.rs @@ -0,0 +1,811 @@ +//! Built-in shell session state and command routing. + +use std::{ + path::PathBuf, + sync::{Arc, Mutex, MutexGuard}, +}; + +use chitin_command::{ + ChitinCommand, CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandId, CommandMessage, + CommandProgress, parse_command_line, +}; +use chitin_command_runtime::{CommandExecutionError, CommandExecutor, CommandOutcome}; +use chitin_databases::{CancellationToken, PersistedArtifact}; + +use crate::{ShellEvent, ShellEventSink}; + +/// Monotonic identity assigned to one shell submission. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ShellCommandId(u64); + +impl ShellCommandId { + /// Returns the numeric identity used inside this shell session. + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Executor boundary selected for a parsed command. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShellCommandTarget { + /// The command can execute without a graphical frontend. + Portable, + /// The command requires application or workspace state from the host. + Frontend, +} + +/// Origin of a command invocation crossing the built-in shell bridge. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ShellInvocationSource { + /// A person entered text in the built-in terminal. + Interactive, + /// An application agent submitted a structured command. + Agent { + /// Stable agent identity used for attribution and diagnostics. + name: String, + }, + /// Internal application automation submitted the command. + System { + /// Stable subsystem identity used for attribution and diagnostics. + name: String, + }, +} + +/// Lifecycle state retained for a submitted shell command. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShellExecutionStatus { + /// The command has been accepted and is executing. + Running, + /// Cancellation was requested and execution is winding down. + Cancelling, + /// Execution completed successfully. + Succeeded, + /// Execution returned an error. + Failed, + /// Execution stopped after cooperative cancellation. + Cancelled, +} + +impl ShellExecutionStatus { + /// Returns whether no further execution events are expected. + pub const fn is_terminal(self) -> bool { + matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled) + } +} + +/// Persistent history for one accepted shell command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShellExecutionRecord { + /// Shell-local submission identity. + pub id: ShellCommandId, + /// Original command text entered by the user. + pub input: String, + /// Typed request retained for audit, replay, and agent/kernel correlation. + pub command: ChitinCommand, + /// Stable command identity resolved by the parser. + pub command_id: CommandId, + /// Actor or subsystem that submitted the command. + pub source: ShellInvocationSource, + /// Executor boundary selected for the command. + pub target: ShellCommandTarget, + /// Current lifecycle state. + pub status: ShellExecutionStatus, +} + +/// Active command information exposed in a shell snapshot. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShellActiveCommand { + /// Shell-local submission identity. + pub id: ShellCommandId, + /// Stable identity of the parsed command. + pub command_id: CommandId, + /// Executor boundary selected for the command. + pub target: ShellCommandTarget, + /// Current non-terminal lifecycle state. + pub status: ShellExecutionStatus, +} + +/// Content retained in the built-in shell scrollback model. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ShellTranscriptContent { + /// Original input shown beside the shell prompt. + Input(String), + /// Most recent progress for a portable command. + Progress(CommandProgress), + /// Structured informational or warning message. + Message(CommandMessage), + /// Artifact persisted by a command. + Artifact(Box), + /// Successful terminal marker. + Completed, + /// Failed terminal marker and its user-facing message. + Failed(String), + /// Cooperative cancellation marker. + Cancelled, +} + +/// One ordered item in shell scrollback. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShellTranscriptEntry { + /// Submission that produced this content. + pub command_id: ShellCommandId, + /// Render-neutral content interpreted by the eventual terminal view. + pub content: ShellTranscriptContent, +} + +/// Read-only copy of session state suitable for presentation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BuiltinShellSnapshot { + /// Directory used to resolve relative command paths. + pub working_directory: PathBuf, + /// Currently executing command, when present. + pub active: Option, + /// Accepted commands in submission order. + pub executions: Vec, + /// Ordered render-neutral shell scrollback. + pub transcript: Vec, +} + +/// Parsed command and per-invocation resources prepared by a shell session. +#[derive(Clone, Debug)] +pub struct ShellSubmission { + id: ShellCommandId, + command: ChitinCommand, + target: ShellCommandTarget, + source: ShellInvocationSource, + context: CommandExecutionContext, +} + +impl ShellSubmission { + /// Returns the shell-local submission identity. + pub const fn id(&self) -> ShellCommandId { + self.id + } + + /// Returns the parsed typed command. + pub const fn command(&self) -> &ChitinCommand { + &self.command + } + + /// Returns the executor boundary selected for this command. + pub const fn target(&self) -> ShellCommandTarget { + self.target + } + + /// Returns the actor or subsystem that initiated this command. + pub const fn source(&self) -> &ShellInvocationSource { + &self.source + } + + /// Returns the resources prepared for this invocation. + pub const fn context(&self) -> &CommandExecutionContext { + &self.context + } +} + +/// Correlated structured result returned by the portable command kernel. +#[derive(Clone, Debug, PartialEq)] +pub struct ShellExecutionResult { + /// Shell-local identity shared with the originating submission and events. + pub id: ShellCommandId, + /// Domain result returned without terminal-specific string conversion. + pub outcome: CommandOutcome, +} + +/// Failure while parsing, routing, or updating a built-in shell command. +#[derive(Debug, thiserror::Error)] +pub enum BuiltinShellError { + /// The submitted command line is syntactically or semantically invalid. + #[error(transparent)] + Parse(#[from] chitin_command::CommandParseError), + /// Another foreground command is still active. + #[error("shell command {} is still active", active_id.get())] + Busy { + /// Identity of the active command preventing submission. + active_id: ShellCommandId, + }, + /// A portable executor was requested for a frontend command. + #[error("shell command {} requires the frontend executor", id.get())] + FrontendCommand { + /// Identity of the incorrectly routed submission. + id: ShellCommandId, + }, + /// A completion or failure referred to a command other than the active one. + #[error("shell command {} is not the active command", id.get())] + InactiveCommand { + /// Identity supplied by the caller. + id: ShellCommandId, + }, + /// The shared command executor rejected or failed the command. + #[error(transparent)] + Execution(#[from] CommandExecutionError), + /// Session state could not be accessed after a panic poisoned its mutex. + #[error("built-in shell session state is unavailable")] + StateUnavailable, +} + +#[derive(Debug)] +struct ActiveCommand { + id: ShellCommandId, + command_id: CommandId, + target: ShellCommandTarget, + status: ShellExecutionStatus, + cancellation: CancellationToken, +} + +#[derive(Debug)] +struct ShellState { + context: CommandExecutionContext, + next_id: u64, + active: Option, + history: Vec, + history_cursor: Option, + navigation_draft: String, + executions: Vec, + transcript: Vec, +} + +/// Thread-safe session for Chitin's built-in command language. +/// +/// A session accepts one foreground command at a time. Portable commands are +/// executed through [`CommandExecutor`], while frontend commands are returned +/// to the host and must be finalized with [`BuiltinShell::complete_frontend`] +/// or [`BuiltinShell::fail_frontend`]. +#[derive(Clone)] +pub struct BuiltinShell { + state: Arc>, + events: ShellEventSink, +} + +impl BuiltinShell { + /// Creates a session rooted at the supplied working directory. + pub fn new(working_directory: impl Into) -> Self { + Self::with_event_sink(working_directory, ShellEventSink::silent()) + } + + /// Creates a session that reports lifecycle changes to an event sink. + pub fn with_event_sink(working_directory: impl Into, events: ShellEventSink) -> Self { + Self { + state: Arc::new(Mutex::new(ShellState { + context: CommandExecutionContext::new(working_directory), + next_id: 1, + active: None, + history: Vec::new(), + history_cursor: None, + navigation_draft: String::new(), + executions: Vec::new(), + transcript: Vec::new(), + })), + events, + } + } + + /// Sets the workspace root supplied to subsequent commands. + pub fn set_workspace_root(&self, workspace_root: Option) -> Result<(), BuiltinShellError> { + self.lock_state()?.context.workspace_root = workspace_root; + Ok(()) + } + + /// Sets the default download root supplied to subsequent commands. + pub fn set_default_download_root(&self, root: Option) -> Result<(), BuiltinShellError> { + self.lock_state()?.context.default_download_root = root; + Ok(()) + } + + /// Sets standard-input bytes supplied to the next command submission. + pub fn set_standard_input(&self, bytes: Option>) -> Result<(), BuiltinShellError> { + self.lock_state()?.context.standard_input = bytes; + Ok(()) + } + + /// Parses a command line and reserves it as the active foreground command. + /// + /// # Parameters + /// + /// * `input` is one complete built-in shell command line. + /// + /// # Returns + /// + /// A typed submission carrying its execution target and isolated cancellation + /// token. + /// + /// # Errors + /// + /// Returns [`BuiltinShellError::Busy`] while another command is active, or a + /// parse error when the command line is invalid. + pub fn submit(&self, input: impl Into) -> Result { + let input = input.into(); + let command = parse_command_line(&input)?; + self.submit_typed(input, command, ShellInvocationSource::Interactive) + } + + /// Submits a command line attributed to a specific invocation source. + /// + /// # Parameters + /// + /// * `input` is the command text used for history and human-readable audit. + /// * `source` identifies the interactive, agent, or system caller. + /// + /// # Returns + /// + /// A parsed typed submission correlated with its shell-local identity. + pub fn submit_line( + &self, + input: impl Into, + source: ShellInvocationSource, + ) -> Result { + let input = input.into(); + let command = parse_command_line(&input)?; + self.submit_typed(input, command, source) + } + + /// Submits an already typed command from an agent or application subsystem. + /// + /// This is the primary non-interactive bridge API. It prevents agents from + /// serializing validated arguments back into shell text merely to invoke a + /// computation kernel. + /// + /// # Parameters + /// + /// * `input` is a human-readable audit representation of the request. + /// * `command` contains the validated command identity and arguments. + /// * `source` identifies the agent or subsystem initiating the request. + /// + /// # Returns + /// + /// A correlated submission ready for the portable or frontend executor. + pub fn submit_typed( + &self, + input: impl Into, + command: ChitinCommand, + source: ShellInvocationSource, + ) -> Result { + let input = input.into(); + let target = command_target(&command); + let cancellation = CancellationToken::new(); + let (submission, record) = { + let mut state = self.lock_state()?; + if let Some(active) = state.active.as_ref() { + return Err(BuiltinShellError::Busy { active_id: active.id }); + } + + let id = ShellCommandId(state.next_id); + state.next_id = state.next_id.saturating_add(1); + let command_id = command.id(); + let context = state.context.clone().with_cancellation(cancellation.clone()); + let record = ShellExecutionRecord { + id, + input: input.clone(), + command: command.clone(), + command_id, + source: source.clone(), + target, + status: ShellExecutionStatus::Running, + }; + state.history.push(input.clone()); + state.history_cursor = None; + state.navigation_draft.clear(); + state.executions.push(record.clone()); + state.transcript.push(ShellTranscriptEntry { + command_id: id, + content: ShellTranscriptContent::Input(input), + }); + state.active = Some(ActiveCommand { + id, + command_id, + target, + status: ShellExecutionStatus::Running, + cancellation, + }); + ( + ShellSubmission { + id, + command, + target, + source, + context, + }, + record, + ) + }; + self.events.emit(ShellEvent::Submitted(record)); + Ok(submission) + } + + /// Executes a portable submission and records its events and terminal state. + /// + /// # Parameters + /// + /// * `submission` is the active value returned by [`BuiltinShell::submit`]. + /// * `executor` runs database and structure commands without frontend state. + /// + /// # Returns + /// + /// The typed portable-command outcome for presentation by the shell host. + /// + /// # Errors + /// + /// Returns an error when the submission is stale, requires the frontend, or + /// fails in the shared command executor. Execution failures are retained in + /// the transcript before being returned. + pub async fn execute_portable( + &self, + submission: ShellSubmission, + executor: &CommandExecutor, + ) -> Result { + self.validate_active_submission(&submission)?; + if submission.target != ShellCommandTarget::Portable { + return Err(BuiltinShellError::FrontendCommand { id: submission.id }); + } + + let shell = self.clone(); + let id = submission.id; + let events = CommandEventSink::new(move |event| shell.record_command_event(id, event)); + match executor.execute(submission.command, submission.context, events).await { + Ok(outcome) => { + self.finish(id, ShellExecutionStatus::Succeeded, ShellTranscriptContent::Completed)?; + Ok(ShellExecutionResult { id, outcome }) + } + Err(error) => { + let cancelled = self.active_is_cancelled(id)?; + let status = if cancelled { + ShellExecutionStatus::Cancelled + } else { + ShellExecutionStatus::Failed + }; + let content = if cancelled { + ShellTranscriptContent::Cancelled + } else { + ShellTranscriptContent::Failed(error.to_string()) + }; + self.finish(id, status, content)?; + Err(BuiltinShellError::Execution(error)) + } + } + } + + /// Marks a frontend-routed command as successfully completed. + pub fn complete_frontend(&self, id: ShellCommandId) -> Result<(), BuiltinShellError> { + self.finish(id, ShellExecutionStatus::Succeeded, ShellTranscriptContent::Completed) + } + + /// Marks a frontend-routed command as failed with a user-facing message. + pub fn fail_frontend(&self, id: ShellCommandId, message: impl Into) -> Result<(), BuiltinShellError> { + self.finish( + id, + ShellExecutionStatus::Failed, + ShellTranscriptContent::Failed(message.into()), + ) + } + + /// Requests cooperative cancellation of the active command. + pub fn cancel_active(&self) -> Result, BuiltinShellError> { + let id = { + let mut state = self.lock_state()?; + let Some(active) = state.active.as_mut() else { + return Ok(None); + }; + active.cancellation.cancel(); + active.status = ShellExecutionStatus::Cancelling; + let id = active.id; + if let Some(record) = state.executions.iter_mut().find(|record| record.id == id) { + record.status = ShellExecutionStatus::Cancelling; + } + id + }; + self.events.emit(ShellEvent::StateChanged { + id, + status: ShellExecutionStatus::Cancelling, + }); + Ok(Some(id)) + } + + /// Selects the previous command-line history entry. + pub fn previous_history(&self, current_input: &str) -> Result, BuiltinShellError> { + let mut state = self.lock_state()?; + if state.history.is_empty() { + return Ok(None); + } + let index = match state.history_cursor { + Some(index) => index.saturating_sub(1), + None => { + state.navigation_draft = current_input.to_owned(); + state.history.len() - 1 + } + }; + state.history_cursor = Some(index); + Ok(state.history.get(index).cloned()) + } + + /// Selects the next command-line history entry or restores the input draft. + pub fn next_history(&self) -> Result, BuiltinShellError> { + let mut state = self.lock_state()?; + let Some(index) = state.history_cursor else { + return Ok(None); + }; + if index + 1 < state.history.len() { + let next = index + 1; + state.history_cursor = Some(next); + return Ok(state.history.get(next).cloned()); + } + state.history_cursor = None; + Ok(Some(state.navigation_draft.clone())) + } + + /// Returns a presentation-safe copy of the current session state. + pub fn snapshot(&self) -> Result { + let state = self.lock_state()?; + Ok(BuiltinShellSnapshot { + working_directory: state.context.working_directory.clone(), + active: state.active.as_ref().map(|active| ShellActiveCommand { + id: active.id, + command_id: active.command_id, + target: active.target, + status: active.status, + }), + executions: state.executions.clone(), + transcript: state.transcript.clone(), + }) + } + + /// Validates that a prepared submission still owns the foreground slot. + fn validate_active_submission(&self, submission: &ShellSubmission) -> Result<(), BuiltinShellError> { + let state = self.lock_state()?; + match state.active.as_ref() { + Some(active) if active.id == submission.id => Ok(()), + _ => Err(BuiltinShellError::InactiveCommand { id: submission.id }), + } + } + + /// Returns whether cancellation was requested for the active submission. + fn active_is_cancelled(&self, id: ShellCommandId) -> Result { + let state = self.lock_state()?; + match state.active.as_ref() { + Some(active) if active.id == id => Ok(active.cancellation.is_cancelled()), + _ => Err(BuiltinShellError::InactiveCommand { id }), + } + } + + /// Records one frontend-neutral event without holding the lock during notification. + fn record_command_event(&self, id: ShellCommandId, event: CommandExecutionEvent) { + let recorded = self + .lock_state() + .map(|mut state| { + if !matches!(state.active.as_ref(), Some(active) if active.id == id) { + return false; + } + match &event { + CommandExecutionEvent::Progress(progress) => update_progress(&mut state.transcript, id, progress.clone()), + CommandExecutionEvent::Message(message) => state.transcript.push(ShellTranscriptEntry { + command_id: id, + content: ShellTranscriptContent::Message(message.clone()), + }), + CommandExecutionEvent::Artifact(artifact) => state.transcript.push(ShellTranscriptEntry { + command_id: id, + content: ShellTranscriptContent::Artifact(artifact.clone()), + }), + CommandExecutionEvent::Started { .. } | CommandExecutionEvent::Completed { .. } => {} + } + true + }) + .unwrap_or(false); + if recorded { + self.events.emit(ShellEvent::CommandExecution { id, event }); + } + } + + /// Moves the active command into a terminal state and appends its marker. + fn finish( + &self, + id: ShellCommandId, + status: ShellExecutionStatus, + content: ShellTranscriptContent, + ) -> Result<(), BuiltinShellError> { + { + let mut state = self.lock_state()?; + if !matches!(state.active.as_ref(), Some(active) if active.id == id) { + return Err(BuiltinShellError::InactiveCommand { id }); + } + if let Some(record) = state.executions.iter_mut().find(|record| record.id == id) { + record.status = status; + } + state.transcript.push(ShellTranscriptEntry { + command_id: id, + content, + }); + state.active = None; + } + self.events.emit(ShellEvent::StateChanged { id, status }); + Ok(()) + } + + /// Locks the shared session state and maps mutex poisoning into a typed error. + fn lock_state(&self) -> Result, BuiltinShellError> { + self.state.lock().map_err(|_| BuiltinShellError::StateUnavailable) + } +} + +/// Classifies whether a command is portable or needs its host frontend. +fn command_target(command: &ChitinCommand) -> ShellCommandTarget { + match command { + ChitinCommand::Database(_) | ChitinCommand::Structure(_) => ShellCommandTarget::Portable, + ChitinCommand::Workspace(_) | ChitinCommand::Application(_) => ShellCommandTarget::Frontend, + } +} + +/// Replaces the active progress row so frequent updates do not grow scrollback. +fn update_progress(transcript: &mut Vec, id: ShellCommandId, progress: CommandProgress) { + if let Some(entry) = transcript + .iter_mut() + .rev() + .find(|entry| entry.command_id == id && matches!(entry.content, ShellTranscriptContent::Progress(_))) + { + entry.content = ShellTranscriptContent::Progress(progress); + } else { + transcript.push(ShellTranscriptEntry { + command_id: id, + content: ShellTranscriptContent::Progress(progress), + }); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use chitin_command::CommandOutputFormat; + use chitin_databases::{ClientConfig, providers::rcsb::StructureFormat}; + + use super::*; + + #[test] + fn submit_should_route_portable_and_frontend_commands() -> Result<(), BuiltinShellError> { + let portable_shell = BuiltinShell::new("."); + let portable = portable_shell.submit("structure validate model.pdb")?; + let frontend_shell = BuiltinShell::new("."); + let frontend = frontend_shell.submit("tab.close")?; + + assert_eq!(portable.target(), ShellCommandTarget::Portable); + assert_eq!(frontend.target(), ShellCommandTarget::Frontend); + Ok(()) + } + + #[test] + fn submit_should_reject_a_second_foreground_command() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + let first = shell.submit("tab.close")?; + + let result = shell.submit("tab.focus_next"); + + assert!(matches!(result, Err(BuiltinShellError::Busy { active_id }) if active_id == first.id())); + Ok(()) + } + + #[test] + fn history_navigation_should_restore_the_current_draft() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + let first = shell.submit("tab.close")?; + shell.complete_frontend(first.id())?; + let second = shell.submit("workspace.toggle_workspace")?; + shell.complete_frontend(second.id())?; + + let newest = shell.previous_history("structure inspect draft.pdb")?; + let oldest = shell.previous_history("")?; + let forward = shell.next_history()?; + let draft = shell.next_history()?; + + assert_eq!(newest.as_deref(), Some("workspace.toggle_workspace")); + assert_eq!(oldest.as_deref(), Some("tab.close")); + assert_eq!(forward.as_deref(), Some("workspace.toggle_workspace")); + assert_eq!(draft.as_deref(), Some("structure inspect draft.pdb")); + Ok(()) + } + + #[test] + fn cancel_should_update_the_active_command_and_token() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + let submission = shell.submit("structure validate model.pdb")?; + + let cancelled = shell.cancel_active()?; + let snapshot = shell.snapshot()?; + + assert_eq!(cancelled, Some(submission.id())); + assert!(submission.context().cancellation.is_cancelled()); + assert_eq!( + snapshot.active.map(|active| active.status), + Some(ShellExecutionStatus::Cancelling) + ); + Ok(()) + } + + #[tokio::test] + async fn portable_execution_should_record_validation_completion() -> Result<(), BuiltinShellError> { + let observed = Arc::new(Mutex::new(Vec::new())); + let callback_observed = observed.clone(); + let shell = BuiltinShell::with_event_sink( + ".", + ShellEventSink::new(move |event| { + if let Ok(mut events) = callback_observed.lock() { + events.push(event); + } + }), + ); + shell.set_standard_input(Some(Arc::from( + b"ATOM 1 CA GLY A 1 1.000 2.000 3.000 1.00 20.00 C \nEND\n".as_slice(), + )))?; + let submission = shell.submit("structure validate - --format pdb")?; + let executor = CommandExecutor::new(ClientConfig::default()); + + let result = shell.execute_portable(submission, &executor).await?; + let snapshot = shell.snapshot()?; + + assert!(matches!( + result.outcome, + CommandOutcome::StructureValidation(validation) + if validation.format == StructureFormat::Pdb + && validation.output == CommandOutputFormat::Text + && validation.is_valid() + )); + assert!(snapshot.active.is_none()); + assert!(matches!( + snapshot.executions.as_slice(), + [ShellExecutionRecord { + status: ShellExecutionStatus::Succeeded, + .. + }] + )); + assert!(observed.lock().map(|events| !events.is_empty()).unwrap_or(false)); + Ok(()) + } + + #[test] + fn frontend_completion_should_append_a_terminal_marker() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + let submission = shell.submit("tab.close")?; + + shell.complete_frontend(submission.id())?; + let snapshot = shell.snapshot()?; + + assert!(snapshot.active.is_none()); + assert!(matches!( + snapshot.transcript.last(), + Some(ShellTranscriptEntry { + content: ShellTranscriptContent::Completed, + .. + }) + )); + Ok(()) + } + + #[test] + fn typed_agent_submission_should_preserve_source_and_arguments() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + let parsed = parse_command_line("structure validate model.pdb --format pdb")?; + + let submission = shell.submit_typed( + "agent validation request", + parsed.clone(), + ShellInvocationSource::Agent { + name: "structure-agent".to_owned(), + }, + )?; + let snapshot = shell.snapshot()?; + + assert_eq!(submission.command(), &parsed); + assert_eq!( + submission.source(), + &ShellInvocationSource::Agent { + name: "structure-agent".to_owned() + } + ); + assert!(matches!( + snapshot.executions.as_slice(), + [ShellExecutionRecord { + command, + source: ShellInvocationSource::Agent { name }, + .. + }] if command == &parsed && name == "structure-agent" + )); + Ok(()) + } +} From ca385b79239da185fa66bb75e9b7c8b745a5efda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Sun, 20 Sep 2026 19:09:12 +0800 Subject: [PATCH 05/32] feat(desktop): host built-in shell command execution - attach a shared built-in shell session to ChitinApp - route frontend commands through the existing desktop dispatcher - execute portable shell commands through the background task center - return correlated structured results to terminal and agent callers - share cancellation tokens between shell submissions and background tasks - forward command progress, messages, and artifacts into task snapshots - reserve RCSB output paths against overlapping shell downloads - notify GPUI safely as background shell tasks change state - reuse generic command-event projection for existing RCSB tasks# --- Cargo.lock | 1 + crates/chitin-builtin-shell/src/session.rs | 51 ++- crates/chitin-desktop/Cargo.toml | 1 + crates/chitin-desktop/src/app.rs | 5 + crates/chitin-desktop/src/builtin_shell.rs | 381 +++++++++++++++++++++ crates/chitin-desktop/src/lib.rs | 1 + crates/chitin-desktop/src/tasks/mod.rs | 47 ++- crates/chitin-desktop/src/tasks/rcsb.rs | 35 +- 8 files changed, 486 insertions(+), 36 deletions(-) create mode 100644 crates/chitin-desktop/src/builtin_shell.rs diff --git a/Cargo.lock b/Cargo.lock index ba0a8282..0a0542b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -780,6 +780,7 @@ version = "0.1.0" dependencies = [ "bytemuck", "chitin-bio", + "chitin-builtin-shell", "chitin-command", "chitin-command-runtime", "chitin-databases", diff --git a/crates/chitin-builtin-shell/src/session.rs b/crates/chitin-builtin-shell/src/session.rs index cf091516..df6e1f57 100644 --- a/crates/chitin-builtin-shell/src/session.rs +++ b/crates/chitin-builtin-shell/src/session.rs @@ -260,14 +260,24 @@ pub struct BuiltinShell { impl BuiltinShell { /// Creates a session rooted at the supplied working directory. pub fn new(working_directory: impl Into) -> Self { - Self::with_event_sink(working_directory, ShellEventSink::silent()) + Self::from_context(CommandExecutionContext::new(working_directory)) } /// Creates a session that reports lifecycle changes to an event sink. pub fn with_event_sink(working_directory: impl Into, events: ShellEventSink) -> Self { + Self::from_context_and_event_sink(CommandExecutionContext::new(working_directory), events) + } + + /// Creates a session from frontend-supplied execution defaults. + pub fn from_context(context: CommandExecutionContext) -> Self { + Self::from_context_and_event_sink(context, ShellEventSink::silent()) + } + + /// Creates a configured session that reports lifecycle changes to an event sink. + pub fn from_context_and_event_sink(context: CommandExecutionContext, events: ShellEventSink) -> Self { Self { state: Arc::new(Mutex::new(ShellState { - context: CommandExecutionContext::new(working_directory), + context, next_id: 1, active: None, history: Vec::new(), @@ -432,6 +442,33 @@ impl BuiltinShell { &self, submission: ShellSubmission, executor: &CommandExecutor, + ) -> Result { + self + .execute_portable_with_events(submission, executor, CommandEventSink::silent()) + .await + } + + /// Executes a portable submission while forwarding command events to a host. + /// + /// # Parameters + /// + /// * `submission` is the active value returned by [`BuiltinShell::submit`]. + /// * `executor` runs database and structure commands without frontend state. + /// * `host_events` receives the same structured events recorded by the shell. + /// + /// # Returns + /// + /// The correlated typed outcome produced by the portable command kernel. + /// + /// # Errors + /// + /// Returns an error when routing is invalid or command execution fails. The + /// shell records the terminal state before returning an execution failure. + pub async fn execute_portable_with_events( + &self, + submission: ShellSubmission, + executor: &CommandExecutor, + host_events: CommandEventSink, ) -> Result { self.validate_active_submission(&submission)?; if submission.target != ShellCommandTarget::Portable { @@ -440,7 +477,10 @@ impl BuiltinShell { let shell = self.clone(); let id = submission.id; - let events = CommandEventSink::new(move |event| shell.record_command_event(id, event)); + let events = CommandEventSink::new(move |event| { + shell.record_command_event(id, event.clone()); + host_events.emit(event); + }); match executor.execute(submission.command, submission.context, events).await { Ok(outcome) => { self.finish(id, ShellExecutionStatus::Succeeded, ShellTranscriptContent::Completed)?; @@ -471,6 +511,11 @@ impl BuiltinShell { /// Marks a frontend-routed command as failed with a user-facing message. pub fn fail_frontend(&self, id: ShellCommandId, message: impl Into) -> Result<(), BuiltinShellError> { + self.fail_submission(id, message) + } + + /// Marks an active submission as failed before or during host routing. + pub fn fail_submission(&self, id: ShellCommandId, message: impl Into) -> Result<(), BuiltinShellError> { self.finish( id, ShellExecutionStatus::Failed, diff --git a/crates/chitin-desktop/Cargo.toml b/crates/chitin-desktop/Cargo.toml index ab7e1fb8..448a4947 100644 --- a/crates/chitin-desktop/Cargo.toml +++ b/crates/chitin-desktop/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true repository.workspace = true [dependencies] +chitin-builtin-shell = { path = "../chitin-builtin-shell" } chitin-command = { path = "../chitin-command" } chitin-command-runtime = { path = "../chitin-command-runtime" } chitin-bio = { path = "../chitin-bio" } diff --git a/crates/chitin-desktop/src/app.rs b/crates/chitin-desktop/src/app.rs index 7ead9fff..90486d5c 100644 --- a/crates/chitin-desktop/src/app.rs +++ b/crates/chitin-desktop/src/app.rs @@ -22,6 +22,7 @@ use gpui::{ }; use crate::{ + builtin_shell::{DesktopShellHost, desktop_shell_context}, components::{ activity_bar::{ActiveActivity, ActivityBarControls, render_activity_bar}, command_panel::{CommandPanelController, render_command_panel}, @@ -59,6 +60,8 @@ pub struct ChitinApp { pub(crate) tasks: BackgroundTaskCenter, /// Shared executor for frontend-independent typed commands. pub(crate) command_executor: CommandExecutor, + /// Shared command bridge for terminal, agent, and system invocations. + pub(crate) builtin_shell: DesktopShellHost, /// Structure paths currently being read and parsed off the UI thread. pub(crate) pending_structure_loads: BTreeSet, /// Primitive button state for platform window actions. @@ -128,6 +131,7 @@ impl ChitinApp { let project_sidebar_state = ProjectSidebarState::with_workspace_root(workspace.as_ref().map(|workspace| workspace.tree.root.path.as_path())); + let shell_workspace_root = workspace.as_ref().map(|workspace| workspace.root.clone()); Self { workspace, project_sidebar_state, @@ -140,6 +144,7 @@ impl ChitinApp { command_panel: CommandPanelController::new(), tasks: BackgroundTaskCenter::new(), command_executor: CommandExecutor::new(ClientConfig::default()), + builtin_shell: DesktopShellHost::new(desktop_shell_context(shell_workspace_root)), pending_structure_loads: BTreeSet::new(), window_bar_controls: None, activity_bar_controls: None, diff --git a/crates/chitin-desktop/src/builtin_shell.rs b/crates/chitin-desktop/src/builtin_shell.rs new file mode 100644 index 00000000..abb3a867 --- /dev/null +++ b/crates/chitin-desktop/src/builtin_shell.rs @@ -0,0 +1,381 @@ +//! Desktop host adapter for the frontend-independent built-in shell. + +use std::path::PathBuf; + +use chitin_builtin_shell::{ + BuiltinShell, BuiltinShellError, ShellCommandId, ShellCommandTarget, ShellExecutionResult, ShellInvocationSource, + ShellSubmission, +}; +use chitin_command::{ChitinCommand, CommandEventSink, CommandExecutionContext, DatabaseCommand}; +use chitin_command_runtime::{CommandExecutionError, CommandExecutor, resolve_rcsb_download_paths}; +use gpui::{AppContext, AsyncApp, Context, WeakEntity, Window}; +use tokio::sync::oneshot; + +use crate::{ + app::ChitinApp, + tasks::{BackgroundTaskCenter, TaskCenterError, TaskFailure, TaskHandle, TaskKind, TaskTarget}, +}; + +/// Desktop-owned shell session and adapters for application background work. +#[derive(Clone)] +pub struct DesktopShellHost { + session: BuiltinShell, +} + +impl DesktopShellHost { + /// Creates a desktop shell from workspace-aware execution defaults. + pub fn new(context: CommandExecutionContext) -> Self { + Self { + session: BuiltinShell::from_context(context), + } + } + + /// Returns the session shared with terminal and agent callers. + pub fn session(&self) -> &BuiltinShell { + &self.session + } + + /// Parses and reserves an attributed command-line submission. + pub fn submit_line( + &self, + input: impl Into, + source: ShellInvocationSource, + ) -> Result { + Ok(self.session.submit_line(input, source)?) + } + + /// Reserves an already typed command for an agent or internal subsystem. + /// + /// # Parameters + /// + /// * `input` is a human-readable audit representation of the request. + /// * `command` is the validated command sent across the shell/kernel bridge. + /// * `source` attributes the invocation to its agent or subsystem. + /// + /// # Returns + /// + /// A correlated submission ready for desktop routing. + pub fn submit_typed( + &self, + input: impl Into, + command: ChitinCommand, + source: ShellInvocationSource, + ) -> Result { + Ok(self.session.submit_typed(input, command, source)?) + } + + /// Runs a portable shell submission through the application task center. + /// + /// # Parameters + /// + /// * `submission` contains the correlated typed command and execution context. + /// * `tasks` owns the shared Tokio executor and durable task state. + /// * `executor` dispatches portable work into database and scientific crates. + /// + /// # Returns + /// + /// A task handle for UI observation and a structured result receiver for + /// terminal or agent callers. + pub fn submit_portable( + &self, + submission: ShellSubmission, + tasks: &BackgroundTaskCenter, + executor: CommandExecutor, + ) -> Result { + if submission.target() != ShellCommandTarget::Portable { + return Err(DesktopShellHostError::WrongTarget { + id: submission.id(), + target: submission.target(), + }); + } + + let id = submission.id(); + let command_id = submission.command().id(); + let targets = match submission_targets(&submission) { + Ok(targets) => targets, + Err(error) => { + let _ = self.session.fail_submission(id, error.to_string()); + return Err(error.into()); + } + }; + let cancellation = submission.context().cancellation.clone(); + let execution_session = self.session.clone(); + let failure_session = self.session.clone(); + let (result_sender, result_receiver) = oneshot::channel(); + let task = tasks.submit_with_cancellation( + TaskKind::BuiltinShell { command_id }, + targets, + cancellation, + move |context| async move { + let event_context = context.clone(); + let events = CommandEventSink::new(move |event| event_context.report_command_event(event)); + match execution_session + .execute_portable_with_events(submission, &executor, events) + .await + { + Ok(result) => { + let _ = result_sender.send(Ok(result)); + Ok(()) + } + Err(error) => { + let failure = TaskFailure::new(error.to_string()); + let _ = result_sender.send(Err(error)); + Err(failure) + } + } + }, + ); + match task { + Ok(task) => Ok(DesktopShellTask { + command_id: id, + task, + result: result_receiver, + }), + Err(error) => { + let _ = failure_session.fail_submission(id, error.to_string()); + Err(error.into()) + } + } + } + + /// Completes a command after the desktop frontend applies its UI mutation. + pub fn complete_frontend(&self, id: ShellCommandId) -> Result<(), DesktopShellHostError> { + Ok(self.session.complete_frontend(id)?) + } + + /// Records a desktop routing failure for a frontend command. + pub fn fail_frontend(&self, id: ShellCommandId, message: impl Into) -> Result<(), DesktopShellHostError> { + Ok(self.session.fail_frontend(id, message)?) + } +} + +/// Running portable shell command exposed to desktop and agent consumers. +pub struct DesktopShellTask { + command_id: ShellCommandId, + task: TaskHandle, + result: oneshot::Receiver>, +} + +impl DesktopShellTask { + /// Returns the shell-local command identity. + pub const fn command_id(&self) -> ShellCommandId { + self.command_id + } + + /// Returns the background task observation handle. + pub const fn task(&self) -> &TaskHandle { + &self.task + } + + /// Waits for the structured portable-command result. + pub async fn result(self) -> Result { + Ok( + self + .result + .await + .map_err(|_| DesktopShellHostError::ResultChannelClosed)??, + ) + } +} + +/// Result of routing one shell submission through the desktop host. +pub enum DesktopShellDispatch { + /// The command mutated desktop state synchronously. + Frontend { command_id: ShellCommandId }, + /// The command is running through the shared background executor. + Portable(DesktopShellTask), +} + +/// Failure while preparing or executing a desktop-hosted shell command. +#[derive(Debug, thiserror::Error)] +pub enum DesktopShellHostError { + /// The frontend-independent shell rejected the request or execution. + #[error(transparent)] + Shell(#[from] BuiltinShellError), + /// Portable command preparation failed before task submission. + #[error(transparent)] + Execution(#[from] CommandExecutionError), + /// The application task center rejected the command. + #[error(transparent)] + TaskCenter(#[from] TaskCenterError), + /// A submission was sent to an incompatible host route. + #[error("shell command {} has target {target:?}, which cannot use this route", id.get())] + WrongTarget { + /// Shell-local identity of the incorrectly routed command. + id: ShellCommandId, + /// Target selected by the frontend-independent shell. + target: ShellCommandTarget, + }, + /// The background task ended without publishing its structured result. + #[error("built-in shell result channel closed before completion")] + ResultChannelClosed, +} + +/// Resolves resources protected while a portable command is active. +fn submission_targets(submission: &ShellSubmission) -> Result, CommandExecutionError> { + match submission.command() { + ChitinCommand::Database(DatabaseCommand::DownloadRcsbStructure(arguments)) => { + resolve_rcsb_download_paths(arguments, submission.context()) + .map(|paths| paths.into_iter().map(TaskTarget::File).collect()) + } + _ => Ok(vec![TaskTarget::Resource(format!( + "builtin-shell:{}", + submission.id().get() + ))]), + } +} + +/// Builds workspace-aware defaults for a desktop shell session. +pub(crate) fn desktop_shell_context(workspace_root: Option) -> CommandExecutionContext { + match workspace_root { + Some(root) => CommandExecutionContext::new(&root) + .with_workspace_root(&root) + .with_default_download_root(root.join(".chitin").join("download")), + None => CommandExecutionContext::new(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))), + } +} + +impl ChitinApp { + /// Returns the shared built-in shell session used by desktop and agent callers. + pub fn builtin_shell(&self) -> &BuiltinShell { + self.builtin_shell.session() + } + + /// Parses and routes one textual built-in shell command through the desktop host. + /// + /// # Parameters + /// + /// * `input` is one complete command line. + /// * `source` identifies the interactive, agent, or system caller. + /// * `window` provides the context required by frontend UI commands. + /// * `cx` schedules portable task observation on the GPUI main thread. + /// + /// # Returns + /// + /// A completed frontend dispatch or a handle to the running portable command. + pub fn submit_builtin_shell_line( + &mut self, + input: impl Into, + source: ShellInvocationSource, + window: &mut Window, + cx: &mut Context, + ) -> Result { + let submission = self.builtin_shell.submit_line(input, source)?; + self.route_builtin_shell_submission(submission, window, cx) + } + + /// Routes an already typed command from an agent or application subsystem. + /// + /// # Parameters + /// + /// * `input` is a human-readable audit representation of the request. + /// * `command` contains the validated command identity and arguments. + /// * `source` identifies the agent or subsystem initiating the request. + /// * `window` provides the context required by frontend UI commands. + /// * `cx` schedules portable task observation on the GPUI main thread. + /// + /// # Returns + /// + /// A completed frontend dispatch or a handle to the running portable command. + pub fn submit_builtin_shell_command( + &mut self, + input: impl Into, + command: ChitinCommand, + source: ShellInvocationSource, + window: &mut Window, + cx: &mut Context, + ) -> Result { + let submission = self.builtin_shell.submit_typed(input, command, source)?; + self.route_builtin_shell_submission(submission, window, cx) + } + + /// Routes one prepared submission without reparsing its typed command. + fn route_builtin_shell_submission( + &mut self, + submission: ShellSubmission, + window: &mut Window, + cx: &mut Context, + ) -> Result { + match submission.target() { + ShellCommandTarget::Frontend => { + let command_id = submission.id(); + self.dispatch_command_with_window(submission.command().clone(), window, cx); + self.builtin_shell.complete_frontend(command_id)?; + Ok(DesktopShellDispatch::Frontend { command_id }) + } + ShellCommandTarget::Portable => { + let running = self + .builtin_shell + .submit_portable(submission, &self.tasks, self.command_executor.clone())?; + observe_shell_task(running.task().clone(), window, cx); + Ok(DesktopShellDispatch::Portable(running)) + } + } + } +} + +/// Notifies GPUI while a portable shell command changes task state. +fn observe_shell_task(mut task: TaskHandle, window: &Window, cx: &mut Context) { + let window_handle = window.window_handle(); + cx.spawn(async move |app: WeakEntity, async_cx: &mut AsyncApp| { + while let Some(snapshot) = task.changed().await { + let terminal = snapshot.state.is_terminal(); + let _ = async_cx.update_window(window_handle, |_, _, cx| { + let _ = app.update(cx, |_, cx| cx.notify()); + }); + if terminal { + break; + } + } + }) + .detach(); +} + +#[cfg(test)] +mod tests { + use chitin_command::CommandOutputFormat; + use chitin_databases::{ClientConfig, providers::rcsb::StructureFormat}; + + use super::*; + + #[test] + fn portable_submission_should_return_a_correlated_kernel_result() -> Result<(), Box> { + let host = DesktopShellHost::new(CommandExecutionContext::new(".").with_standard_input( + b"ATOM 1 CA GLY A 1 1.000 2.000 3.000 1.00 20.00 C \nEND\n".to_vec(), + )); + let submission = host.submit_line( + "structure validate - --format pdb", + ShellInvocationSource::Agent { + name: "validation-agent".to_owned(), + }, + )?; + let command_id = submission.id(); + let tasks = BackgroundTaskCenter::new(); + let running = host.submit_portable(submission, &tasks, CommandExecutor::new(ClientConfig::default()))?; + let result_runtime = tokio::runtime::Builder::new_current_thread().build()?; + + let result = result_runtime.block_on(running.result())?; + + assert_eq!(result.id, command_id); + assert!(matches!( + result.outcome, + chitin_command_runtime::CommandOutcome::StructureValidation(validation) + if validation.format == StructureFormat::Pdb + && validation.output == CommandOutputFormat::Text + && validation.is_valid() + )); + Ok(()) + } + + #[test] + fn frontend_submission_should_remain_available_for_desktop_dispatch() -> Result<(), DesktopShellHostError> { + let host = DesktopShellHost::new(CommandExecutionContext::new(".")); + + let submission = host.submit_line("tab.close", ShellInvocationSource::Interactive)?; + + assert_eq!(submission.target(), ShellCommandTarget::Frontend); + host.complete_frontend(submission.id())?; + assert!(host.session().snapshot()?.active.is_none()); + Ok(()) + } +} diff --git a/crates/chitin-desktop/src/lib.rs b/crates/chitin-desktop/src/lib.rs index fa7230d1..6c5689d7 100644 --- a/crates/chitin-desktop/src/lib.rs +++ b/crates/chitin-desktop/src/lib.rs @@ -5,6 +5,7 @@ //! validate the real desktop shell without `#[path]`-based source inclusion. pub mod app; +pub mod builtin_shell; pub(crate) mod components; pub mod keybindings; pub mod tasks; diff --git a/crates/chitin-desktop/src/tasks/mod.rs b/crates/chitin-desktop/src/tasks/mod.rs index 63c7cc54..a370966d 100644 --- a/crates/chitin-desktop/src/tasks/mod.rs +++ b/crates/chitin-desktop/src/tasks/mod.rs @@ -9,6 +9,7 @@ use std::{ sync::{Arc, Mutex}, }; +use chitin_command::{CommandExecutionEvent, CommandId}; use chitin_databases::{CancellationToken, PersistedArtifact}; use futures_util::FutureExt; use tokio::{ @@ -33,6 +34,8 @@ impl std::fmt::Display for TaskId { pub enum TaskKind { /// An artifact download from an external database provider. DatabaseDownload { provider: String }, + /// A portable command submitted through the built-in shell bridge. + BuiltinShell { command_id: CommandId }, } /// Lifecycle state shared by all background tasks. @@ -191,6 +194,7 @@ pub enum TaskCenterError { } /// Subscription returned to the UI for one submitted task. +#[derive(Clone)] pub struct TaskHandle { /// Stable identity of the submitted task. id: TaskId, @@ -284,6 +288,22 @@ impl TaskContext { pub fn is_cancelled(&self) -> bool { self.cancellation.is_cancelled() } + + /// Projects one frontend-neutral command event into task-center state. + pub fn report_command_event(&self, event: CommandExecutionEvent) { + match event { + CommandExecutionEvent::Progress(progress) => self.report_progress(TaskProgress { + completed: progress.completed, + total: progress.total, + stage_index: progress.stage_index, + stage_count: progress.stage_count, + stage_label: progress.stage_label, + }), + CommandExecutionEvent::Message(message) => self.log(message.text), + CommandExecutionEvent::Artifact(artifact) => self.output(TaskOutput::PersistedArtifact(artifact)), + CommandExecutionEvent::Started { .. } | CommandExecutionEvent::Completed { .. } => {} + } + } } /// Registry-owned state and notification channel for one task. @@ -413,6 +433,32 @@ impl BackgroundTaskCenter { targets: Vec, task: Task, ) -> Result + where + Task: FnOnce(TaskContext) -> TaskFuture + Send + 'static, + TaskFuture: Future> + Send + 'static, + { + self.submit_with_cancellation(kind, targets, CancellationToken::new(), task) + } + + /// Submits work using a cancellation token owned by an upstream protocol. + /// + /// # Parameters + /// + /// * `kind` identifies the task for global task-center presentation. + /// * `targets` reserve resources against overlapping active work. + /// * `cancellation` is shared with the upstream shell submission. + /// * `task` creates the asynchronous adapter from its reporting context. + /// + /// # Returns + /// + /// A handle observing the task registered with the shared cancellation token. + pub(crate) fn submit_with_cancellation( + &self, + kind: TaskKind, + targets: Vec, + cancellation: CancellationToken, + task: Task, + ) -> Result where Task: FnOnce(TaskContext) -> TaskFuture + Send + 'static, TaskFuture: Future> + Send + 'static, @@ -426,7 +472,6 @@ impl BackgroundTaskCenter { } }; let id = TaskId(self.next_id.fetch_add(1, Ordering::Relaxed)); - let cancellation = CancellationToken::new(); let snapshot = TaskSnapshot { id, kind, diff --git a/crates/chitin-desktop/src/tasks/rcsb.rs b/crates/chitin-desktop/src/tasks/rcsb.rs index b1dfdbfa..d766d638 100644 --- a/crates/chitin-desktop/src/tasks/rcsb.rs +++ b/crates/chitin-desktop/src/tasks/rcsb.rs @@ -1,12 +1,8 @@ //! RCSB command adapter for the application task center. -use super::{ - BackgroundTaskCenter, TaskCenterError, TaskContext, TaskFailure, TaskHandle, TaskKind, TaskOutput, TaskProgress, - TaskTarget, -}; +use super::{BackgroundTaskCenter, TaskCenterError, TaskContext, TaskFailure, TaskHandle, TaskKind, TaskTarget}; use chitin_command::{ - ChitinCommand, CommandEventSink, CommandExecutionContext, CommandExecutionEvent, DatabaseCommand, - RcsbDownloadArguments, + ChitinCommand, CommandEventSink, CommandExecutionContext, DatabaseCommand, RcsbDownloadArguments, }; use chitin_command_runtime::{CommandExecutionError, CommandExecutor, CommandOutcome, resolve_rcsb_download_paths}; @@ -71,7 +67,7 @@ async fn run_download( execution_context: CommandExecutionContext, ) -> Result<(), TaskFailure> { let event_context = context.clone(); - let events = CommandEventSink::new(move |event| report_command_event(&event_context, event)); + let events = CommandEventSink::new(move |event| event_context.report_command_event(event)); let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure(arguments)); let execution_context = execution_context.with_cancellation(context.cancellation_token()); let outcome = executor @@ -83,28 +79,3 @@ async fn run_download( } Ok(()) } - -/// Projects command events into the existing desktop task-center protocol. -/// -/// # Parameters -/// -/// * `context` receives translated progress, logs, and outputs. -/// * `event` is the frontend-independent command event to translate. -/// -/// # Returns -/// -/// This function returns after synchronously updating the task snapshot. -fn report_command_event(context: &TaskContext, event: CommandExecutionEvent) { - match event { - CommandExecutionEvent::Progress(progress) => context.report_progress(TaskProgress { - completed: progress.completed, - total: progress.total, - stage_index: progress.stage_index, - stage_count: progress.stage_count, - stage_label: progress.stage_label, - }), - CommandExecutionEvent::Message(message) => context.log(message.text), - CommandExecutionEvent::Artifact(artifact) => context.output(TaskOutput::PersistedArtifact(artifact)), - CommandExecutionEvent::Started { .. } | CommandExecutionEvent::Completed { .. } => {} - } -} From 223aff107ab0e0e1985fabdecaf70c40f77626b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 21 Sep 2026 00:05:16 +0800 Subject: [PATCH 06/32] feat(terminal): add structured resizable built-in terminal - add reusable terminal viewport and command-block composite components - render command output, progress, errors, and results beneath each invocation - preserve rejected commands in shell history - add Shift+T terminal toggle command and scoped keybinding - support history navigation and foreground command cancellation - add a resizable bottom terminal panel with persistent height - reuse the shared close icon in the terminal header --- crates/chitin-builtin-shell/src/session.rs | 34 ++- crates/chitin-command/src/application.rs | 33 ++- crates/chitin-command/src/lib.rs | 4 + crates/chitin-command/src/parser.rs | 1 + crates/chitin-desktop/src/app.rs | 79 +++++- crates/chitin-desktop/src/components/mod.rs | 1 + .../src/components/terminal/controller.rs | 152 +++++++++++ .../src/components/terminal/mod.rs | 244 ++++++++++++++++++ .../src/components/terminal/presenter.rs | 166 ++++++++++++ .../src/components/terminal/render.rs | 88 +++++++ .../src/keybindings/application.rs | 13 +- .../src/keybindings/dispatch.rs | 1 + crates/chitin-desktop/src/keybindings/mod.rs | 2 +- .../src/composite/command_terminal/mod.rs | 7 + .../src/composite/command_terminal/model.rs | 229 ++++++++++++++++ .../src/composite/command_terminal/render.rs | 106 ++++++++ crates/chitin-ui/src/composite/mod.rs | 3 + crates/chitin-ui/src/primitive/mod.rs | 2 + .../chitin-ui/src/primitive/terminal/mod.rs | 12 + .../chitin-ui/src/primitive/terminal/model.rs | 141 ++++++++++ .../src/primitive/terminal/render.rs | 95 +++++++ .../chitin-ui/src/primitive/terminal/state.rs | 48 ++++ 22 files changed, 1433 insertions(+), 28 deletions(-) create mode 100644 crates/chitin-desktop/src/components/terminal/controller.rs create mode 100644 crates/chitin-desktop/src/components/terminal/mod.rs create mode 100644 crates/chitin-desktop/src/components/terminal/presenter.rs create mode 100644 crates/chitin-desktop/src/components/terminal/render.rs create mode 100644 crates/chitin-ui/src/composite/command_terminal/mod.rs create mode 100644 crates/chitin-ui/src/composite/command_terminal/model.rs create mode 100644 crates/chitin-ui/src/composite/command_terminal/render.rs create mode 100644 crates/chitin-ui/src/primitive/terminal/mod.rs create mode 100644 crates/chitin-ui/src/primitive/terminal/model.rs create mode 100644 crates/chitin-ui/src/primitive/terminal/render.rs create mode 100644 crates/chitin-ui/src/primitive/terminal/state.rs diff --git a/crates/chitin-builtin-shell/src/session.rs b/crates/chitin-builtin-shell/src/session.rs index df6e1f57..30126cc7 100644 --- a/crates/chitin-builtin-shell/src/session.rs +++ b/crates/chitin-builtin-shell/src/session.rs @@ -324,9 +324,7 @@ impl BuiltinShell { /// Returns [`BuiltinShellError::Busy`] while another command is active, or a /// parse error when the command line is invalid. pub fn submit(&self, input: impl Into) -> Result { - let input = input.into(); - let command = parse_command_line(&input)?; - self.submit_typed(input, command, ShellInvocationSource::Interactive) + self.submit_line(input, ShellInvocationSource::Interactive) } /// Submits a command line attributed to a specific invocation source. @@ -345,7 +343,13 @@ impl BuiltinShell { source: ShellInvocationSource, ) -> Result { let input = input.into(); - let command = parse_command_line(&input)?; + let command = match parse_command_line(&input) { + Ok(command) => command, + Err(error) => { + self.remember_rejected_input(input.clone())?; + return Err(error.into()); + } + }; self.submit_typed(input, command, source) } @@ -668,6 +672,15 @@ impl BuiltinShell { fn lock_state(&self) -> Result, BuiltinShellError> { self.state.lock().map_err(|_| BuiltinShellError::StateUnavailable) } + + /// Retains a rejected interactive line for conventional shell history. + fn remember_rejected_input(&self, input: String) -> Result<(), BuiltinShellError> { + let mut state = self.lock_state()?; + state.history.push(input); + state.history_cursor = None; + state.navigation_draft.clear(); + Ok(()) + } } /// Classifies whether a command is portable or needs its host frontend. @@ -746,6 +759,19 @@ mod tests { Ok(()) } + #[test] + fn rejected_input_should_remain_in_history() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + + assert!( + shell + .submit_line("not-a-command", ShellInvocationSource::Interactive) + .is_err() + ); + assert_eq!(shell.previous_history("")?, Some("not-a-command".to_owned())); + Ok(()) + } + #[test] fn cancel_should_update_the_active_command_and_token() -> Result<(), BuiltinShellError> { let shell = BuiltinShell::new("."); diff --git a/crates/chitin-command/src/application.rs b/crates/chitin-command/src/application.rs index 37dac1b4..cf7d1c57 100644 --- a/crates/chitin-command/src/application.rs +++ b/crates/chitin-command/src/application.rs @@ -3,6 +3,8 @@ pub enum ApplicationCommand { /// Show or hide the command panel. ToggleCommandPanel, + /// Show or hide the built-in terminal panel. + ToggleTerminal, } impl ApplicationCommand { @@ -10,20 +12,33 @@ impl ApplicationCommand { pub fn id(&self) -> crate::CommandId { match self { Self::ToggleCommandPanel => crate::CommandId::ApplicationToggleCommandPanel, + Self::ToggleTerminal => crate::CommandId::ApplicationToggleTerminal, } } } /// Returns the application command registrations. pub fn command_registrations() -> Vec { - vec![crate::CommandRegistration { - descriptor: crate::CommandDescriptor { - id: ApplicationCommand::ToggleCommandPanel.id(), - title: "Toggle Command Panel", - requires_arguments: false, + vec![ + crate::CommandRegistration { + descriptor: crate::CommandDescriptor { + id: ApplicationCommand::ToggleCommandPanel.id(), + title: "Toggle Command Panel", + requires_arguments: false, + }, + category: crate::CommandCategory::Application, + keywords: &["quick pick", "palette", "commands"], + shortcut: Some("Ctrl/Cmd+Shift+P"), }, - category: crate::CommandCategory::Application, - keywords: &["quick pick", "palette", "commands"], - shortcut: Some("Ctrl/Cmd+Shift+P"), - }] + crate::CommandRegistration { + descriptor: crate::CommandDescriptor { + id: ApplicationCommand::ToggleTerminal.id(), + title: "Toggle Terminal", + requires_arguments: false, + }, + category: crate::CommandCategory::Application, + keywords: &["built-in shell", "console", "command line"], + shortcut: Some("Shift+T"), + }, + ] } diff --git a/crates/chitin-command/src/lib.rs b/crates/chitin-command/src/lib.rs index 5f3415a9..05013d5f 100644 --- a/crates/chitin-command/src/lib.rs +++ b/crates/chitin-command/src/lib.rs @@ -46,6 +46,8 @@ pub enum CommandId { DatabaseDownloadRcsbStructure, /// Show or hide the command panel. ApplicationToggleCommandPanel, + /// Show or hide the built-in terminal panel. + ApplicationToggleTerminal, /// Inspect a local structure file. StructureInspect, /// Validate a local structure file. @@ -67,6 +69,7 @@ impl CommandId { Self::PanelTabClose => "tab.close", Self::DatabaseDownloadRcsbStructure => "database.rcsb.download_structure", Self::ApplicationToggleCommandPanel => "application.toggle_command_panel", + Self::ApplicationToggleTerminal => "application.toggle_terminal", Self::StructureInspect => "structure.inspect", Self::StructureValidate => "structure.validate", } @@ -85,6 +88,7 @@ impl CommandId { Self::PanelTabFocusNext => Some(PanelTabCommand::FocusNext.into()), Self::PanelTabClose => Some(PanelTabCommand::Close.into()), Self::ApplicationToggleCommandPanel => Some(ApplicationCommand::ToggleCommandPanel.into()), + Self::ApplicationToggleTerminal => Some(ApplicationCommand::ToggleTerminal.into()), Self::DatabaseDownloadRcsbStructure | Self::StructureInspect | Self::StructureValidate => None, } } diff --git a/crates/chitin-command/src/parser.rs b/crates/chitin-command/src/parser.rs index 48680a13..0d4796cf 100644 --- a/crates/chitin-command/src/parser.rs +++ b/crates/chitin-command/src/parser.rs @@ -374,6 +374,7 @@ fn parse_argumentless_command(stable_id: &str, trailing: &[String]) -> Result CommandId::PanelTabFocusNext, "tab.close" => CommandId::PanelTabClose, "application.toggle_command_panel" => CommandId::ApplicationToggleCommandPanel, + "application.toggle_terminal" => CommandId::ApplicationToggleTerminal, _ => { return Err(CommandParseError::UnknownCommand { command: stable_id.to_owned(), diff --git a/crates/chitin-desktop/src/app.rs b/crates/chitin-desktop/src/app.rs index 90486d5c..5a9aa973 100644 --- a/crates/chitin-desktop/src/app.rs +++ b/crates/chitin-desktop/src/app.rs @@ -28,9 +28,10 @@ use crate::{ command_panel::{CommandPanelController, render_command_panel}, document_area::{DocumentOptionsControls, DocumentPanelState, render_document_area, state::DocumentPanelContent}, project_sidebar::{ProjectSidebarState, render_project_sidebar}, + terminal::{TerminalPanelControls, TerminalPanelState, render_terminal_panel}, window_bar::{WindowBarControls, render_window_bar}, }, - keybindings::{ToggleCommandPanel, ToggleWorkspace}, + keybindings::{ToggleCommandPanel, ToggleTerminal, ToggleWorkspace, WORKBENCH_KEY_CONTEXT}, tasks::BackgroundTaskCenter, }; @@ -62,6 +63,10 @@ pub struct ChitinApp { pub(crate) command_executor: CommandExecutor, /// Shared command bridge for terminal, agent, and system invocations. pub(crate) builtin_shell: DesktopShellHost, + /// Visibility, scrollback presentation, and focus state for the bottom terminal. + pub(crate) terminal_panel: TerminalPanelState, + /// Persistent primitive controls for the bottom terminal. + pub(crate) terminal_panel_controls: Option, /// Structure paths currently being read and parsed off the UI thread. pub(crate) pending_structure_loads: BTreeSet, /// Primitive button state for platform window actions. @@ -145,6 +150,8 @@ impl ChitinApp { tasks: BackgroundTaskCenter::new(), command_executor: CommandExecutor::new(ClientConfig::default()), builtin_shell: DesktopShellHost::new(desktop_shell_context(shell_workspace_root)), + terminal_panel: TerminalPanelState::new(), + terminal_panel_controls: None, pending_structure_loads: BTreeSet::new(), window_bar_controls: None, activity_bar_controls: None, @@ -459,19 +466,34 @@ impl Render for ChitinApp { self.cancel_document_panel_tab_drag(); } - let theme = builtins::dark(); + let theme = builtins::light(); let window_bar_controls = self.window_bar_controls(window, cx); let activity_bar_controls = self.activity_bar_controls(window, cx); let document_options_controls = self.document_options_controls(window, cx); let toast_viewport = self.toast_viewport(cx); let command_panel_search_input = self.command_panel_search_input(window, cx); self.command_panel_rcsb_form(window, cx); + let terminal_controls = self + .terminal_panel + .is_visible() + .then(|| self.terminal_panel_controls(window, cx)); + if terminal_controls.is_some() { + self.sync_terminal_output(cx); + } + if self.terminal_panel.take_focus_request() + && let Some(controls) = terminal_controls.as_ref() + { + let focus = controls.input(cx).read(cx).focus_handle().clone(); + window.focus(&focus, cx); + } let app = cx.weak_entity(); let workbench_focus = self.workbench_focus(cx); let project_sidebar_focus = self.project_sidebar_focus(cx); let document_panel_focus = self.document_panel_focus(cx); let project_sidebar_is_resizing = self.project_sidebar_state.is_resizing(); let document_panel_resize_axis = self.document_panel_resize_axis(); + let terminal_panel_is_resizing = self.terminal_panel.is_resizing(); + let terminal_panel_height = self.terminal_panel.height(); let visible_sidebar_width = if self.active_activity == ActiveActivity::Workspace && self.project_sidebar_visible { self.project_sidebar_state.resize.width() } else { @@ -483,6 +505,7 @@ impl Render for ChitinApp { .flex_col() .size_full() .relative() + .key_context(WORKBENCH_KEY_CONTEXT) .track_focus(&workbench_focus) .bg(theme.background.primary) .text_color(theme.text.primary) @@ -494,6 +517,9 @@ impl Render for ChitinApp { cx.notify(); } })) + .on_action(cx.listener(|this, _: &ToggleTerminal, _, cx| { + this.toggle_terminal(cx); + })) .capture_key_down({ let app = app.clone(); move |event, window, cx| { @@ -512,10 +538,13 @@ impl Render for ChitinApp { .when(document_panel_resize_axis == Some(PanelSplitAxis::Vertical), |layout| { layout.cursor(CursorStyle::ResizeUpDown) }) + .when(terminal_panel_is_resizing, |layout| { + layout.cursor(CursorStyle::ResizeUpDown) + }) .on_mouse_move({ let app = app.clone(); move |event, window, cx| { - if !project_sidebar_is_resizing && document_panel_resize_axis.is_none() { + if !project_sidebar_is_resizing && document_panel_resize_axis.is_none() && !terminal_panel_is_resizing { return; } @@ -542,6 +571,15 @@ impl Render for ChitinApp { } }); } + + if terminal_panel_is_resizing { + let available_height = Self::document_panel_root_height(window.bounds().size.height); + let _ = app.update(cx, |this, cx| { + if this.terminal_panel.drag_resize(event.position.y, available_height) { + cx.notify(); + } + }); + } } }) .on_drag_move::({ @@ -560,9 +598,10 @@ impl Render for ChitinApp { let _ = app.update(cx, |this, cx| { let sidebar_stopped = this.project_sidebar_state.stop_resize(); let document_panel_stopped = this.stop_document_panel_resize(); + let terminal_panel_stopped = this.terminal_panel.stop_resize(); let document_tab_drag_cancelled = this.cancel_document_panel_tab_drag(); - if sidebar_stopped || document_panel_stopped || document_tab_drag_cancelled { + if sidebar_stopped || document_panel_stopped || terminal_panel_stopped || document_tab_drag_cancelled { cx.notify(); } }); @@ -587,14 +626,30 @@ impl Render for ChitinApp { )) }, ) - .child(render_document_area( - &self.document_panels, - theme, - &document_panel_focus, - app.clone(), - document_options_controls, - cx, - )), + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .min_h_0() + .child(render_document_area( + &self.document_panels, + theme, + &document_panel_focus, + app.clone(), + document_options_controls, + cx, + )) + .when_some(terminal_controls, |layout, controls| { + layout.child(render_terminal_panel( + controls, + terminal_panel_height, + theme, + app.clone(), + )) + }), + ), ) .when(self.command_panel.is_open(), |layout| { layout.child(render_command_panel( diff --git a/crates/chitin-desktop/src/components/mod.rs b/crates/chitin-desktop/src/components/mod.rs index f6d23fc5..638d90b9 100644 --- a/crates/chitin-desktop/src/components/mod.rs +++ b/crates/chitin-desktop/src/components/mod.rs @@ -4,6 +4,7 @@ pub mod activity_bar; pub mod command_panel; pub mod document_area; pub mod project_sidebar; +pub mod terminal; pub mod wgpu_panel; pub mod window_bar; pub mod workspace_tree; diff --git a/crates/chitin-desktop/src/components/terminal/controller.rs b/crates/chitin-desktop/src/components/terminal/controller.rs new file mode 100644 index 00000000..9c6d5801 --- /dev/null +++ b/crates/chitin-desktop/src/components/terminal/controller.rs @@ -0,0 +1,152 @@ +//! Keyboard and command-execution control for the desktop terminal. + +use chitin_builtin_shell::ShellInvocationSource; +use chitin_ui::composite::command_terminal::CommandTerminalStatus; +use gpui::{AppContext, AsyncApp, Context, KeyDownEvent, WeakEntity, Window}; + +use super::presenter::{shell_output_lines, shell_terminal_status, terminal_outcome}; +use crate::{app::ChitinApp, builtin_shell::DesktopShellDispatch}; + +impl ChitinApp { + /// Submits one live-prompt line and binds all resulting output to its block. + /// + /// # Parameters + /// + /// * `line` is the complete command line emitted by the text input. + /// * `window` supplies the frontend context required by UI commands. + /// * `cx` creates the immutable command block and schedules portable work. + pub(super) fn submit_terminal_line(&mut self, line: String, window: &mut Window, cx: &mut Context) { + let line = line.trim().to_owned(); + if line.is_empty() { + return; + } + let Some(controls) = self.terminal_panel_controls.as_ref().cloned() else { + return; + }; + if controls.terminal.read(cx).is_busy() { + return; + } + let terminal_id = controls + .terminal + .update(cx, |terminal, cx| terminal.begin_submission(line.clone(), cx)); + + match self.submit_builtin_shell_line(line, ShellInvocationSource::Interactive, window, cx) { + Ok(DesktopShellDispatch::Frontend { command_id }) => { + self.terminal_panel.bind(command_id, terminal_id); + controls.terminal.update(cx, |terminal, cx| { + terminal.finish(terminal_id, CommandTerminalStatus::Succeeded, Vec::new(), cx); + }); + } + Ok(DesktopShellDispatch::Portable(task)) => { + self.terminal_panel.bind(task.command_id(), terminal_id); + let viewport = controls.terminal.read(cx).viewport().clone(); + let focus = viewport.read(cx).focus_handle().clone(); + window.focus(&focus, cx); + let window_handle = window.window_handle(); + cx.spawn(async move |app: WeakEntity, async_cx: &mut AsyncApp| { + let result = task.result().await; + let _ = async_cx.update_window(window_handle, |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.sync_terminal_output(cx); + if let Some(controls) = this.terminal_panel_controls.as_ref() { + let (status, lines) = match result { + Ok(result) => terminal_outcome(result.outcome), + Err(_) => { + let status = this + .builtin_shell() + .snapshot() + .ok() + .and_then(|snapshot| shell_terminal_status(&snapshot, terminal_id, &this.terminal_panel)) + .unwrap_or(CommandTerminalStatus::Failed); + (status, Vec::new()) + } + }; + controls.terminal.update(cx, |terminal, cx| { + terminal.finish(terminal_id, status, lines, cx); + }); + if this.terminal_panel.is_visible() { + let input = controls.input(cx); + let focus = input.read(cx).focus_handle().clone(); + window.focus(&focus, cx); + } + } + cx.notify(); + }); + }); + }) + .detach(); + } + Err(error) => { + controls + .terminal + .update(cx, |terminal, cx| terminal.reject(terminal_id, error.to_string(), cx)); + } + } + cx.notify(); + } + + /// Synchronizes running command events into their existing terminal blocks. + pub(crate) fn sync_terminal_output(&mut self, cx: &mut Context) { + let Some(controls) = self.terminal_panel_controls.as_ref() else { + return; + }; + let Ok(snapshot) = self.builtin_shell().snapshot() else { + return; + }; + + for binding in &self.terminal_panel.bindings { + let output = shell_output_lines(&snapshot, binding.shell_id); + controls.terminal.update(cx, |terminal, cx| { + terminal.set_output(binding.terminal_id, output, cx); + }); + } + } + + /// Applies history navigation and cancellation while the terminal owns focus. + /// + /// # Parameters + /// + /// * `event` is the focused GPUI keyboard event. + /// * `window` verifies ownership of keyboard focus. + /// * `cx` updates input/history state and stops consumed events. + pub(crate) fn handle_terminal_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + let Some(controls) = self.terminal_panel_controls.as_ref() else { + return; + }; + let input = controls.input(cx); + let key = event.keystroke.key.as_str(); + if event.keystroke.modifiers.control && key == "c" { + let viewport = controls.terminal.read(cx).viewport().clone(); + let terminal_focused = + input.read(cx).focus_handle().is_focused(window) || viewport.read(cx).focus_handle().is_focused(window); + if terminal_focused + && self + .builtin_shell() + .snapshot() + .is_ok_and(|snapshot| snapshot.active.is_some()) + { + let _ = self.builtin_shell().cancel_active(); + cx.stop_propagation(); + cx.notify(); + } + return; + } + + if !input.read(cx).focus_handle().is_focused(window) { + return; + } + + let history = match key { + "up" => self.builtin_shell().previous_history(input.read(cx).text()), + "down" => self.builtin_shell().next_history(), + _ => return, + }; + if let Ok(Some(line)) = history { + input.update(cx, |input, cx| { + input.set_text(line, cx); + }); + } + cx.stop_propagation(); + cx.notify(); + } +} diff --git a/crates/chitin-desktop/src/components/terminal/mod.rs b/crates/chitin-desktop/src/components/terminal/mod.rs new file mode 100644 index 00000000..01324390 --- /dev/null +++ b/crates/chitin-desktop/src/components/terminal/mod.rs @@ -0,0 +1,244 @@ +//! Desktop adapter for the reusable structured command terminal. + +mod controller; +mod presenter; +mod render; + +use std::path::Path; + +use chitin_builtin_shell::ShellCommandId; +use chitin_ui::{ + composite::command_terminal::{CommandTerminalId, CommandTerminalState}, + primitive::{ + button::{ButtonEvent, ButtonState}, + input::text::{TextInputEvent, TextInputState}, + resize::ResizeGesture, + }, +}; +use gpui::{AppContext, Context, Entity, Pixels, Subscription, Window, px}; + +use crate::app::ChitinApp; + +pub(crate) use render::render_terminal_panel; + +const DEFAULT_TERMINAL_HEIGHT: Pixels = px(260.0); +const MIN_TERMINAL_HEIGHT: Pixels = px(120.0); +const MIN_DOCUMENT_AREA_HEIGHT: Pixels = px(160.0); +pub(super) const TERMINAL_FONT_FAMILY: &str = "Cascadia Code"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct TerminalShellBinding { + pub(super) shell_id: ShellCommandId, + pub(super) terminal_id: CommandTerminalId, +} + +/// Visibility, focus, and shell-to-view identity mapping for the bottom panel. +pub(crate) struct TerminalPanelState { + visible: bool, + focus_requested: bool, + height: Pixels, + resize_drag: Option>, + pub(super) bindings: Vec, +} + +impl TerminalPanelState { + /// Creates a closed terminal panel. + pub(crate) fn new() -> Self { + Self { + visible: false, + focus_requested: false, + height: DEFAULT_TERMINAL_HEIGHT, + resize_drag: None, + bindings: Vec::new(), + } + } + + /// Returns whether the terminal panel is visible. + pub(crate) const fn is_visible(&self) -> bool { + self.visible + } + + /// Toggles panel visibility and requests input focus when opening. + pub(crate) fn toggle(&mut self) { + self.visible = !self.visible; + self.focus_requested = self.visible; + if !self.visible { + self.resize_drag = None; + } + } + + /// Hides the terminal panel. + pub(crate) fn close(&mut self) { + self.visible = false; + self.focus_requested = false; + } + + /// Takes the pending input-focus request. + pub(crate) fn take_focus_request(&mut self) -> bool { + std::mem::take(&mut self.focus_requested) + } + + /// Returns the current terminal panel height. + pub(crate) const fn height(&self) -> Pixels { + self.height + } + + /// Starts resizing from the terminal's top edge. + pub(crate) fn start_resize(&mut self, start_y: Pixels) { + self.resize_drag = Some(ResizeGesture::new((), start_y, self.height)); + } + + /// Updates terminal height while preserving minimum document space. + /// + /// # Parameters + /// + /// * `current_y` is the latest vertical pointer position. + /// * `available_height` is the workbench height below the window bar. + /// + /// # Returns + /// + /// `true` when an active drag changed or reaffirmed the terminal height. + pub(crate) fn drag_resize(&mut self, current_y: Pixels, available_height: Pixels) -> bool { + let Some(resize_drag) = self.resize_drag else { + return false; + }; + let maximum_height = + px((f32::from(available_height) - f32::from(MIN_DOCUMENT_AREA_HEIGHT)).max(f32::from(MIN_TERMINAL_HEIGHT))); + let desired_height = f32::from(resize_drag.start_value()) - resize_drag.delta(current_y); + self.height = px(desired_height.clamp(f32::from(MIN_TERMINAL_HEIGHT), f32::from(maximum_height))); + true + } + + /// Stops the active terminal resize gesture. + pub(crate) fn stop_resize(&mut self) -> bool { + self.resize_drag.take().is_some() + } + + /// Returns whether the terminal top edge is being dragged. + pub(crate) const fn is_resizing(&self) -> bool { + self.resize_drag.is_some() + } + + /// Associates one shell execution with its visible command block. + pub(super) fn bind(&mut self, shell_id: ShellCommandId, terminal_id: CommandTerminalId) { + self.bindings.push(TerminalShellBinding { shell_id, terminal_id }); + } +} + +impl Default for TerminalPanelState { + fn default() -> Self { + Self::new() + } +} + +/// Persistent primitive and composite controls for the bottom terminal panel. +#[derive(Clone)] +pub(crate) struct TerminalPanelControls { + pub(super) terminal: Entity, + pub(super) close: Entity, +} + +impl TerminalPanelControls { + /// Creates terminal state and panel controls for one working directory. + fn new(working_directory: &Path, cx: &mut Context) -> Self { + let prompt = presenter::terminal_prompt(working_directory); + Self { + terminal: cx.new(|cx| CommandTerminalState::new(prompt, cx)), + close: cx.new(ButtonState::new), + } + } + + /// Connects primitive semantic events to the desktop shell adapter. + fn subscribe(&self, window: &mut Window, cx: &mut Context) { + let input = self.input(cx); + let input_subscription: Subscription = cx.subscribe_in(&input, window, |this, _, event, window, cx| match event { + TextInputEvent::Submit { value } => this.submit_terminal_line(value.to_string(), window, cx), + TextInputEvent::Cancel => this.close_terminal(cx), + _ => {} + }); + input_subscription.detach(); + + let close_subscription: Subscription = cx.subscribe_in(&self.close, window, |this, _, event, _, cx| { + if matches!(event, ButtonEvent::Click) { + this.close_terminal(cx); + } + }); + close_subscription.detach(); + } + + /// Returns the live prompt's editable input. + pub(crate) fn input(&self, cx: &gpui::App) -> Entity { + self.terminal.read(cx).input().clone() + } +} + +impl ChitinApp { + /// Returns terminal controls, creating and subscribing them on first use. + pub(crate) fn terminal_panel_controls( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> TerminalPanelControls { + if let Some(controls) = self.terminal_panel_controls.as_ref() { + return controls.clone(); + } + + let working_directory = self + .builtin_shell() + .snapshot() + .map(|snapshot| snapshot.working_directory) + .unwrap_or_else(|_| std::path::PathBuf::from(".")); + let controls = TerminalPanelControls::new(&working_directory, cx); + controls.subscribe(window, cx); + self.terminal_panel_controls = Some(controls.clone()); + controls + } + + /// Shows or hides the bottom terminal panel. + pub(crate) fn toggle_terminal(&mut self, cx: &mut Context) { + self.terminal_panel.toggle(); + cx.notify(); + } + + /// Hides the bottom terminal panel. + fn close_terminal(&mut self, cx: &mut Context) { + self.terminal_panel.stop_resize(); + self.terminal_panel.close(); + cx.notify(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verifies that dragging the top edge upward increases terminal height. + #[test] + fn drag_resize_should_increase_height_when_pointer_moves_up() { + let mut state = TerminalPanelState::new(); + state.start_resize(px(400.0)); + + assert!(state.drag_resize(px(350.0), px(800.0))); + assert_eq!(state.height(), px(310.0)); + } + + /// Verifies that terminal resizing reserves space for the document area. + #[test] + fn drag_resize_should_preserve_minimum_document_height() { + let mut state = TerminalPanelState::new(); + state.start_resize(px(400.0)); + + assert!(state.drag_resize(px(0.0), px(600.0))); + assert_eq!(state.height(), px(440.0)); + } + + /// Verifies that dragging downward cannot collapse the terminal completely. + #[test] + fn drag_resize_should_preserve_minimum_terminal_height() { + let mut state = TerminalPanelState::new(); + state.start_resize(px(400.0)); + + assert!(state.drag_resize(px(800.0), px(600.0))); + assert_eq!(state.height(), MIN_TERMINAL_HEIGHT); + } +} diff --git a/crates/chitin-desktop/src/components/terminal/presenter.rs b/crates/chitin-desktop/src/components/terminal/presenter.rs new file mode 100644 index 00000000..b25d5eb5 --- /dev/null +++ b/crates/chitin-desktop/src/components/terminal/presenter.rs @@ -0,0 +1,166 @@ +//! Translation from frontend-neutral shell records to terminal presentation. + +use std::path::Path; + +use chitin_builtin_shell::{BuiltinShellSnapshot, ShellCommandId, ShellExecutionStatus, ShellTranscriptContent}; +use chitin_command::CommandMessageLevel; +use chitin_command_runtime::CommandOutcome; +use chitin_ui::{ + composite::command_terminal::{CommandTerminalId, CommandTerminalStatus}, + primitive::terminal::{TerminalLine, TerminalSpan, TerminalTone}, +}; + +use super::TerminalPanelState; + +/// Builds the two-tone prompt shared by frozen and live command lines. +pub(super) fn terminal_prompt(working_directory: &Path) -> TerminalLine { + TerminalLine::new([ + TerminalSpan::accent("chitin "), + TerminalSpan::secondary(format!("{} ", working_directory.display())), + TerminalSpan::accent("❯ "), + ]) +} + +/// Converts one shell command's event stream into terminal output rows. +/// +/// # Parameters +/// +/// * `snapshot` supplies the frontend-neutral session transcript. +/// * `shell_id` selects entries belonging to one submitted command. +/// +/// # Returns +/// +/// Ordered progress, diagnostic, artifact, failure, and cancellation rows. +pub(super) fn shell_output_lines(snapshot: &BuiltinShellSnapshot, shell_id: ShellCommandId) -> Vec { + snapshot + .transcript + .iter() + .filter(|entry| entry.command_id == shell_id) + .filter_map(|entry| match &entry.content { + ShellTranscriptContent::Input(_) | ShellTranscriptContent::Completed => None, + ShellTranscriptContent::Progress(progress) => { + let stage = progress + .stage_label + .clone() + .unwrap_or_else(|| format!("Stage {}/{}", progress.stage_index, progress.stage_count)); + let amount = progress.total.map_or_else( + || progress.completed.to_string(), + |total| format!("{} / {}", progress.completed, total), + ); + Some(TerminalLine::new([TerminalSpan::info(format!("{stage} {amount}"))])) + } + ShellTranscriptContent::Message(message) => Some(TerminalLine::new([TerminalSpan::new( + message.text.clone(), + match message.level { + CommandMessageLevel::Info => TerminalTone::Info, + CommandMessageLevel::Warning => TerminalTone::Warning, + }, + )])), + ShellTranscriptContent::Artifact(artifact) => Some(TerminalLine::new([ + TerminalSpan::secondary("→ "), + TerminalSpan::primary(format!("{} ({} bytes)", artifact.path.display(), artifact.bytes)), + ])), + ShellTranscriptContent::Failed(message) => { + Some(TerminalLine::new([TerminalSpan::error(format!("error: {message}"))])) + } + ShellTranscriptContent::Cancelled => Some(TerminalLine::new([TerminalSpan::warning("^C cancelled")])), + }) + .collect() +} + +/// Converts a portable command result into rows placed before the next prompt. +/// +/// # Parameters +/// +/// * `outcome` is the structured result returned by the shared command runtime. +/// +/// # Returns +/// +/// The terminal status and final rows for the corresponding command block. +pub(super) fn terminal_outcome(outcome: CommandOutcome) -> (CommandTerminalStatus, Vec) { + match outcome { + CommandOutcome::DatabaseDownload { artifact_count } => ( + CommandTerminalStatus::Succeeded, + vec![TerminalLine::new([TerminalSpan::success(format!( + "Downloaded {artifact_count} structure artifact(s)." + ))])], + ), + CommandOutcome::StructureValidation(validation) => match validation.error { + Some(error) => ( + CommandTerminalStatus::Failed, + vec![TerminalLine::new([TerminalSpan::error(format!( + "Invalid structure {}: {error}", + validation.path.display() + ))])], + ), + None => ( + CommandTerminalStatus::Succeeded, + vec![TerminalLine::new([TerminalSpan::success(format!( + "Valid structure: {} ({})", + validation.path.display(), + validation.format.label() + ))])], + ), + }, + CommandOutcome::StructureInspection(inspection) => { + let structure = &inspection.parsed.structure; + ( + CommandTerminalStatus::Succeeded, + vec![ + TerminalLine::new([ + TerminalSpan::secondary("Structure: "), + TerminalSpan::primary(inspection.path.display().to_string()), + ]), + TerminalLine::new([ + TerminalSpan::secondary("Format: "), + TerminalSpan::primary(inspection.format.label()), + TerminalSpan::secondary(" | Bytes: "), + TerminalSpan::primary(inspection.byte_count.to_string()), + TerminalSpan::secondary(" | Diagnostics: "), + TerminalSpan::primary(inspection.parsed.diagnostics.len().to_string()), + ]), + TerminalLine::plain(format!( + "Models: {} | Chains: {} | Residues: {} | Atoms: {} | Bonds: {}", + structure.models().len(), + structure.chains().len(), + structure.residues().len(), + structure.atoms().len(), + structure.bonds().len() + )), + ], + ) + } + } +} + +/// Resolves cancellation or failure state for one terminal command mapping. +/// +/// # Parameters +/// +/// * `snapshot` supplies completed shell execution records. +/// * `terminal_id` identifies the visible command block. +/// * `panel` maps terminal blocks back to shell-local identities. +/// +/// # Returns +/// +/// The final presentation status when both mapping and execution still exist. +pub(super) fn shell_terminal_status( + snapshot: &BuiltinShellSnapshot, + terminal_id: CommandTerminalId, + panel: &TerminalPanelState, +) -> Option { + let shell_id = panel + .bindings + .iter() + .find_map(|binding| (binding.terminal_id == terminal_id).then_some(binding.shell_id))?; + snapshot + .executions + .iter() + .find(|record| record.id == shell_id) + .map(|record| match record.status { + ShellExecutionStatus::Cancelled => CommandTerminalStatus::Cancelled, + ShellExecutionStatus::Failed => CommandTerminalStatus::Failed, + ShellExecutionStatus::Succeeded => CommandTerminalStatus::Succeeded, + ShellExecutionStatus::Running | ShellExecutionStatus::Cancelling => CommandTerminalStatus::Failed, + }) +} diff --git a/crates/chitin-desktop/src/components/terminal/render.rs b/crates/chitin-desktop/src/components/terminal/render.rs new file mode 100644 index 00000000..e3b4b107 --- /dev/null +++ b/crates/chitin-desktop/src/components/terminal/render.rs @@ -0,0 +1,88 @@ +//! Bottom-panel chrome around the reusable command terminal. + +use chitin_ui::{ + composite::command_terminal::CommandTerminal, + primitive::{ + button::{Button, ButtonSize, ButtonStyle, ButtonVariant}, + icon::Icon, + }, + themes::UIThemes, +}; +use gpui::{CursorStyle, InteractiveElement, MouseButton, ParentElement, Pixels, WeakEntity, div, prelude::*, px}; + +use super::{TERMINAL_FONT_FAMILY, TerminalPanelControls}; +use crate::{app::ChitinApp, keybindings::COMMAND_TERMINAL_KEY_CONTEXT}; + +/// Renders the bottom panel chrome around the reusable command terminal. +pub(crate) fn render_terminal_panel( + controls: TerminalPanelControls, + height: Pixels, + theme: UIThemes, + app: WeakEntity, +) -> impl gpui::IntoElement { + let resize_app = app.clone(); + div() + .relative() + .flex() + .flex_col() + .h(height) + .min_h(height) + .border_t_1() + .border_color(theme.border.primary) + .bg(theme.background.primary) + .key_context(COMMAND_TERMINAL_KEY_CONTEXT) + .capture_key_down(move |event, window, cx| { + let _ = app.update(cx, |this, cx| this.handle_terminal_key(event, window, cx)); + }) + .child( + div() + .flex() + .items_center() + .justify_between() + .h(px(34.0)) + .min_h(px(34.0)) + .px_3() + .border_b_1() + .border_color(theme.border.muted) + .child( + div() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child("TERMINAL"), + ) + .child( + Button::new(controls.close) + .theme(theme) + .variant(ButtonVariant::Transparent) + .size(ButtonSize::Small) + .style( + ButtonStyle::new() + .width(px(26.0)) + .height(px(24.0)) + .horizontal_padding(px(0.0)), + ) + .child(Icon::new("icons/window-close.svg").size(px(14.0)).theme(theme)), + ), + ) + .child( + CommandTerminal::new(controls.terminal) + .theme(theme) + .font_family(TERMINAL_FONT_FAMILY), + ) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(5.0)) + .cursor(CursorStyle::ResizeUpDown) + .hover(move |style| style.bg(theme.border.focus)) + .on_mouse_down(MouseButton::Left, move |event, _, cx| { + let _ = resize_app.update(cx, |this, cx| { + this.terminal_panel.start_resize(event.position.y); + cx.notify(); + }); + }), + ) +} diff --git a/crates/chitin-desktop/src/keybindings/application.rs b/crates/chitin-desktop/src/keybindings/application.rs index e7581dd5..2b6d9427 100644 --- a/crates/chitin-desktop/src/keybindings/application.rs +++ b/crates/chitin-desktop/src/keybindings/application.rs @@ -2,17 +2,26 @@ use gpui::{KeyBinding, actions}; use super::CommandShortcut; +pub(crate) const COMMAND_TERMINAL_KEY_CONTEXT: &str = "CommandTerminal"; +pub(crate) const WORKBENCH_KEY_CONTEXT: &str = "Workbench"; const TOGGLE_COMMAND_PANEL_SHORTCUTS: [CommandShortcut; 1] = [CommandShortcut::new("secondary-shift-p", "Ctrl/Cmd+Shift+P", None)]; +const TOGGLE_TERMINAL_SHORTCUTS: [CommandShortcut; 1] = + [CommandShortcut::new("shift-t", "Shift+T", Some("!CommandTerminal"))]; actions!( application, [ /// Show or hide the command panel. ToggleCommandPanel, + /// Show or hide the built-in terminal panel. + ToggleTerminal, ] ); -pub(super) fn default_key_bindings() -> [KeyBinding; 1] { - [TOGGLE_COMMAND_PANEL_SHORTCUTS[0].binding(ToggleCommandPanel)] +pub(super) fn default_key_bindings() -> [KeyBinding; 2] { + [ + TOGGLE_COMMAND_PANEL_SHORTCUTS[0].binding(ToggleCommandPanel), + TOGGLE_TERMINAL_SHORTCUTS[0].binding(ToggleTerminal), + ] } diff --git a/crates/chitin-desktop/src/keybindings/dispatch.rs b/crates/chitin-desktop/src/keybindings/dispatch.rs index a8e0ee24..bc910237 100644 --- a/crates/chitin-desktop/src/keybindings/dispatch.rs +++ b/crates/chitin-desktop/src/keybindings/dispatch.rs @@ -87,6 +87,7 @@ impl ChitinApp { pub(crate) fn dispatch_application_command(&mut self, command: ApplicationCommand, cx: &mut Context) { match command { ApplicationCommand::ToggleCommandPanel => self.toggle_command_panel(cx), + ApplicationCommand::ToggleTerminal => self.toggle_terminal(cx), } } diff --git a/crates/chitin-desktop/src/keybindings/mod.rs b/crates/chitin-desktop/src/keybindings/mod.rs index a11d04da..9d30471e 100644 --- a/crates/chitin-desktop/src/keybindings/mod.rs +++ b/crates/chitin-desktop/src/keybindings/mod.rs @@ -5,7 +5,7 @@ mod dispatch; mod tab; mod workspace; -pub(crate) use application::ToggleCommandPanel; +pub(crate) use application::{COMMAND_TERMINAL_KEY_CONTEXT, ToggleCommandPanel, ToggleTerminal, WORKBENCH_KEY_CONTEXT}; pub(crate) use tab::{CloseTab, FocusNextPanelTab, FocusPreviousPanelTab, PANEL_CONTAINER_KEY_CONTEXT}; pub(crate) use workspace::{ ActivateFocusedEntry, FocusFirstEntry, FocusLastEntry, FocusNextEntry, FocusPreviousEntry, PROJECT_TREE_KEY_CONTEXT, diff --git a/crates/chitin-ui/src/composite/command_terminal/mod.rs b/crates/chitin-ui/src/composite/command_terminal/mod.rs new file mode 100644 index 00000000..704e4a84 --- /dev/null +++ b/crates/chitin-ui/src/composite/command_terminal/mod.rs @@ -0,0 +1,7 @@ +//! Structured command terminal built from terminal and text-input primitives. + +mod model; +mod render; + +pub use model::{CommandTerminalBlock, CommandTerminalId, CommandTerminalState, CommandTerminalStatus}; +pub use render::CommandTerminal; diff --git a/crates/chitin-ui/src/composite/command_terminal/model.rs b/crates/chitin-ui/src/composite/command_terminal/model.rs new file mode 100644 index 00000000..c0728b10 --- /dev/null +++ b/crates/chitin-ui/src/composite/command_terminal/model.rs @@ -0,0 +1,229 @@ +//! Persistent command-block and live-prompt state. + +use std::collections::VecDeque; + +use gpui::{AppContext, Context, Entity}; + +use crate::primitive::{ + input::text::TextInputState, + terminal::{TerminalLine, TerminalViewportState}, +}; + +const MAX_COMMAND_BLOCKS: usize = 512; + +/// Stable identity for one submitted terminal command block. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CommandTerminalId(u64); + +impl CommandTerminalId { + /// Returns the terminal-local numeric identity. + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Presentation lifecycle of one terminal command block. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandTerminalStatus { + /// The command is still executing and owns the foreground prompt. + Running, + /// The command completed successfully. + Succeeded, + /// The command execution failed. + Failed, + /// The command stopped through cooperative cancellation. + Cancelled, + /// The submitted line was rejected before execution. + Rejected, +} + +impl CommandTerminalStatus { + /// Returns whether a new live prompt may be rendered. + pub const fn is_terminal(self) -> bool { + !matches!(self, Self::Running) + } +} + +/// One immutable submitted command and its ordered output. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandTerminalBlock { + /// Terminal-local block identity. + pub id: CommandTerminalId, + /// Prompt captured when this command was submitted. + pub prompt: TerminalLine, + /// Exact command text submitted by the user. + pub input: String, + /// Replaceable executor output such as progress and artifacts. + pub output: Vec, + /// Final structured result lines appended after executor output. + pub result: Vec, + /// Current presentation lifecycle. + pub status: CommandTerminalStatus, +} + +/// Persistent command history, draft input, and viewport state. +pub struct CommandTerminalState { + input: Entity, + viewport: Entity, + prompt: TerminalLine, + blocks: VecDeque, + active: Option, + next_id: u64, +} + +impl CommandTerminalState { + /// Creates an empty command terminal with one live prompt. + pub fn new(prompt: TerminalLine, cx: &mut Context) -> Self { + Self { + input: cx.new(TextInputState::new), + viewport: cx.new(TerminalViewportState::new), + prompt, + blocks: VecDeque::new(), + active: None, + next_id: 1, + } + } + + /// Returns the editable live-prompt input. + pub fn input(&self) -> &Entity { + &self.input + } + + /// Returns the terminal viewport state. + pub fn viewport(&self) -> &Entity { + &self.viewport + } + + /// Returns submitted command blocks in display order. + pub fn blocks(&self) -> &VecDeque { + &self.blocks + } + + /// Returns the prompt used for the next submission. + pub fn prompt(&self) -> &TerminalLine { + &self.prompt + } + + /// Returns whether a command currently owns the foreground slot. + pub const fn is_busy(&self) -> bool { + self.active.is_some() + } + + /// Freezes the live prompt into a running command block. + /// + /// # Parameters + /// + /// * `input` is the exact submitted command line. + /// * `cx` updates the input primitive and schedules viewport repainting. + /// + /// # Returns + /// + /// A terminal-local identity used to attach subsequent output and results. + pub fn begin_submission(&mut self, input: String, cx: &mut Context) -> CommandTerminalId { + let id = CommandTerminalId(self.next_id); + self.next_id = self.next_id.saturating_add(1); + if self.blocks.len() == MAX_COMMAND_BLOCKS { + self.blocks.pop_front(); + } + self.blocks.push_back(CommandTerminalBlock { + id, + prompt: self.prompt.clone(), + input, + output: Vec::new(), + result: Vec::new(), + status: CommandTerminalStatus::Running, + }); + self.active = Some(id); + self.input.update(cx, |input, cx| { + input.clear(cx); + input.set_disabled(true, cx); + }); + self.reveal_tail(cx); + id + } + + /// Replaces transient output for one command without growing scrollback. + /// + /// # Parameters + /// + /// * `id` identifies the command block receiving executor output. + /// * `output` contains the current progress, messages, and artifacts. + /// * `cx` reveals changed output and schedules repainting. + /// + /// # Returns + /// + /// This function returns `()` and leaves unknown command identities unchanged. + pub fn set_output(&mut self, id: CommandTerminalId, output: Vec, cx: &mut Context) { + if let Some(block) = self.blocks.iter_mut().find(|block| block.id == id) { + if block.output == output { + return; + } + block.output = output; + self.reveal_tail(cx); + } + } + + /// Finishes one block and restores the editable live prompt. + /// + /// # Parameters + /// + /// * `id` identifies the running command block. + /// * `status` is its terminal presentation state. + /// * `result` contains final frontend-formatted result lines. + /// * `cx` re-enables input and schedules tail repainting. + /// + /// # Returns + /// + /// This function returns `()` and leaves unknown command identities unchanged. + pub fn finish( + &mut self, + id: CommandTerminalId, + status: CommandTerminalStatus, + result: Vec, + cx: &mut Context, + ) { + let Some(block) = self.blocks.iter_mut().find(|block| block.id == id) else { + return; + }; + block.status = status; + block.result = result; + if self.active == Some(id) { + self.active = None; + self.input.update(cx, |input, cx| input.set_disabled(false, cx)); + } + self.reveal_tail(cx); + } + + /// Rejects one frozen input line and displays its error below the command. + /// + /// # Parameters + /// + /// * `id` identifies the command block created for the attempted input. + /// * `message` describes why parsing or routing rejected the command. + /// * `cx` restores input and schedules terminal repainting. + /// + /// # Returns + /// + /// This function returns `()` after converting the message into an error row. + pub fn reject(&mut self, id: CommandTerminalId, message: String, cx: &mut Context) { + self.finish( + id, + CommandTerminalStatus::Rejected, + vec![TerminalLine::new([crate::primitive::terminal::TerminalSpan::error( + format!("error: {message}"), + )])], + cx, + ); + } + + /// Replaces the prompt used by subsequent commands. + pub fn set_prompt(&mut self, prompt: TerminalLine) { + self.prompt = prompt; + } + + /// Requests that the newest command and live prompt remain visible. + fn reveal_tail(&self, cx: &mut Context) { + self.viewport.update(cx, |viewport, _| viewport.reveal_tail()); + cx.notify(); + } +} diff --git a/crates/chitin-ui/src/composite/command_terminal/render.rs b/crates/chitin-ui/src/composite/command_terminal/render.rs new file mode 100644 index 00000000..47b127dd --- /dev/null +++ b/crates/chitin-ui/src/composite/command_terminal/render.rs @@ -0,0 +1,106 @@ +//! Rendering for structured command blocks and the live prompt. + +use gpui::{App, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Window, div, prelude::*}; + +use super::CommandTerminalState; +use crate::{ + primitive::{ + input::text::{TextInput, TextInputSize, TextInputStyle, TextInputVariant}, + terminal::{TerminalLine, TerminalSpan, TerminalViewport}, + }, + themes::{UIThemes, builtins}, +}; + +/// A structured shell transcript whose editable prompt lives at its tail. +#[derive(IntoElement)] +pub struct CommandTerminal { + state: Entity, + theme: UIThemes, + font_family: SharedString, +} + +impl CommandTerminal { + /// Creates a command terminal bound to persistent command-block state. + pub fn new(state: Entity) -> Self { + Self { + state, + theme: builtins::dark(), + font_family: "monospace".into(), + } + } + + /// Sets the semantic UI theme. + pub fn theme(mut self, theme: UIThemes) -> Self { + self.theme = theme; + self + } + + /// Sets the monospace font family used by transcript and input. + pub fn font_family(mut self, font_family: impl Into) -> Self { + self.font_family = font_family.into(); + self + } +} + +impl RenderOnce for CommandTerminal { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let state = self.state.read(cx); + let input = state.input().clone(); + let viewport = state.viewport().clone(); + let prompt = state.prompt().clone(); + let busy = state.is_busy(); + let lines = state.blocks().iter().flat_map(block_lines).collect::>(); + let theme = self.theme; + let tail = (!busy).then(|| render_live_prompt(prompt, input, theme)); + + TerminalViewport::new(viewport) + .theme(theme) + .font_family(self.font_family) + .lines(lines) + .when_some(tail, TerminalViewport::tail) + } +} + +/// Flattens one command block while preserving command-local output ordering. +fn block_lines(block: &super::CommandTerminalBlock) -> Vec { + let mut command = block.prompt.clone(); + command.push(TerminalSpan::primary(block.input.clone())); + let mut lines = Vec::with_capacity(block.output.len() + block.result.len() + 2); + lines.push(command); + lines.extend(block.output.iter().cloned()); + lines.extend(block.result.iter().cloned()); + lines.push(TerminalLine::default()); + lines +} + +/// Renders the only editable line at the end of terminal scrollback. +fn render_live_prompt( + prompt: TerminalLine, + input: Entity, + theme: UIThemes, +) -> impl IntoElement { + div() + .flex() + .items_center() + .min_h(gpui::px(26.0)) + .children( + prompt + .spans() + .iter() + .map(|span| div().text_color(span.tone.color(theme)).child(span.text.clone())), + ) + .child( + TextInput::new(input) + .theme(theme) + .variant(TextInputVariant::Transparent) + .size(TextInputSize::Small) + .style( + TextInputStyle::new() + .background(theme.background.primary) + .border(theme.background.primary) + .focus_border(theme.background.primary) + .horizontal_padding(gpui::px(0.0)), + ) + .full_width(true), + ) +} diff --git a/crates/chitin-ui/src/composite/mod.rs b/crates/chitin-ui/src/composite/mod.rs index d6727cac..ef5c3939 100644 --- a/crates/chitin-ui/src/composite/mod.rs +++ b/crates/chitin-ui/src/composite/mod.rs @@ -3,6 +3,9 @@ /// Vertical activity-bar composition. pub mod activity_bar; +/// Structured command terminal assembled from terminal and input primitives. +pub mod command_terminal; + /// Independently single-selectable groups composed from select primitives. pub mod grouped_select; diff --git a/crates/chitin-ui/src/primitive/mod.rs b/crates/chitin-ui/src/primitive/mod.rs index 80477095..cc94770f 100644 --- a/crates/chitin-ui/src/primitive/mod.rs +++ b/crates/chitin-ui/src/primitive/mod.rs @@ -16,6 +16,8 @@ pub mod progress; pub mod resize; /// Sidebar layout and resize controls. pub mod sidebar; +/// Monospace terminal rows and tail-following scroll viewport. +pub mod terminal; /// Text display controls with optional clipboard support. pub mod text; /// Virtualized tree layout and scroll state. diff --git a/crates/chitin-ui/src/primitive/terminal/mod.rs b/crates/chitin-ui/src/primitive/terminal/mod.rs new file mode 100644 index 00000000..b85ab112 --- /dev/null +++ b/crates/chitin-ui/src/primitive/terminal/mod.rs @@ -0,0 +1,12 @@ +//! Monospace terminal content and a tail-following scroll viewport. +//! +//! This primitive has no shell or command semantics. Callers provide styled +//! lines and may attach one interactive tail element, such as a live prompt. + +mod model; +mod render; +mod state; + +pub use model::{TerminalLine, TerminalSpan, TerminalTone}; +pub use render::TerminalViewport; +pub use state::TerminalViewportState; diff --git a/crates/chitin-ui/src/primitive/terminal/model.rs b/crates/chitin-ui/src/primitive/terminal/model.rs new file mode 100644 index 00000000..9eef4cc0 --- /dev/null +++ b/crates/chitin-ui/src/primitive/terminal/model.rs @@ -0,0 +1,141 @@ +//! Render-neutral terminal row content. + +use gpui::SharedString; + +use crate::themes::UIThemes; + +/// Semantic foreground tone for one terminal span. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TerminalTone { + /// Primary terminal content. + #[default] + Primary, + /// De-emphasized paths, metadata, and hints. + Secondary, + /// Prompt and other interactive accents. + Accent, + /// Successful result content. + Success, + /// Recoverable warning content. + Warning, + /// Failed or rejected command content. + Error, + /// Informational and progress content. + Info, +} + +impl TerminalTone { + /// Resolves this semantic tone through a UI theme. + pub(crate) const fn color(self, theme: UIThemes) -> gpui::Rgba { + match self { + Self::Primary => theme.text.primary, + Self::Secondary => theme.text.secondary, + Self::Accent => theme.accent.primary, + Self::Success => theme.text.success, + Self::Warning => theme.text.warning, + Self::Error => theme.text.error, + Self::Info => theme.text.info, + } + } +} + +/// One styled, indivisible text span inside a terminal line. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalSpan { + /// Text rendered by this span. + pub text: SharedString, + /// Semantic foreground tone. + pub tone: TerminalTone, +} + +impl TerminalSpan { + /// Creates a span with a semantic foreground tone. + pub fn new(text: impl Into, tone: TerminalTone) -> Self { + Self { + text: text.into(), + tone, + } + } + + /// Creates primary terminal content. + pub fn primary(text: impl Into) -> Self { + Self::new(text, TerminalTone::Primary) + } + + /// Creates de-emphasized terminal content. + pub fn secondary(text: impl Into) -> Self { + Self::new(text, TerminalTone::Secondary) + } + + /// Creates prompt or interactive accent content. + pub fn accent(text: impl Into) -> Self { + Self::new(text, TerminalTone::Accent) + } + + /// Creates successful terminal content. + pub fn success(text: impl Into) -> Self { + Self::new(text, TerminalTone::Success) + } + + /// Creates warning terminal content. + pub fn warning(text: impl Into) -> Self { + Self::new(text, TerminalTone::Warning) + } + + /// Creates failed terminal content. + pub fn error(text: impl Into) -> Self { + Self::new(text, TerminalTone::Error) + } + + /// Creates informational terminal content. + pub fn info(text: impl Into) -> Self { + Self::new(text, TerminalTone::Info) + } +} + +/// One soft-wrapping row of terminal spans. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TerminalLine { + spans: Vec, +} + +impl TerminalLine { + /// Creates a line from ordered styled spans. + pub fn new(spans: impl IntoIterator) -> Self { + Self { + spans: spans.into_iter().collect(), + } + } + + /// Creates a line containing primary text. + pub fn plain(text: impl Into) -> Self { + Self::new([TerminalSpan::primary(text)]) + } + + /// Returns the ordered styled spans. + pub fn spans(&self) -> &[TerminalSpan] { + &self.spans + } + + /// Appends one styled span. + pub fn push(&mut self, span: TerminalSpan) { + self.spans.push(span); + } + + /// Returns whether the line contains no text spans. + pub fn is_empty(&self) -> bool { + self.spans.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn line_should_preserve_span_order() { + let line = TerminalLine::new([TerminalSpan::accent("❯ "), TerminalSpan::primary("help")]); + + assert_eq!(line.spans()[1].text, "help"); + } +} diff --git a/crates/chitin-ui/src/primitive/terminal/render.rs b/crates/chitin-ui/src/primitive/terminal/render.rs new file mode 100644 index 00000000..d739efa2 --- /dev/null +++ b/crates/chitin-ui/src/primitive/terminal/render.rs @@ -0,0 +1,95 @@ +//! Rendering for terminal lines and their scroll viewport. + +use gpui::{ + AnyElement, App, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, + Window, div, prelude::*, +}; + +use super::{TerminalLine, TerminalViewportState}; +use crate::themes::{UIThemes, builtins}; + +/// Scrollable monospace output surface with an optional interactive tail. +#[derive(IntoElement)] +pub struct TerminalViewport { + state: Entity, + lines: Vec, + tail: Option, + theme: UIThemes, + font_family: SharedString, +} + +impl TerminalViewport { + /// Creates an empty terminal viewport bound to persistent scroll state. + pub fn new(state: Entity) -> Self { + Self { + state, + lines: Vec::new(), + tail: None, + theme: builtins::dark(), + font_family: "monospace".into(), + } + } + + /// Replaces the ordered terminal lines. + pub fn lines(mut self, lines: impl IntoIterator) -> Self { + self.lines = lines.into_iter().collect(); + self + } + + /// Sets an interactive element rendered after all immutable lines. + pub fn tail(mut self, tail: impl IntoElement) -> Self { + self.tail = Some(tail.into_any_element()); + self + } + + /// Sets the semantic UI theme. + pub fn theme(mut self, theme: UIThemes) -> Self { + self.theme = theme; + self + } + + /// Sets the monospace font family used by all viewport content. + pub fn font_family(mut self, font_family: impl Into) -> Self { + self.font_family = font_family.into(); + self + } +} + +impl RenderOnce for TerminalViewport { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let scroll_handle = self.state.read(cx).scroll_handle().clone(); + let focus_handle = self.state.read(cx).focus_handle().clone(); + let state_for_mouse = self.state.clone(); + let theme = self.theme; + + div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(&scroll_handle) + .track_focus(&focus_handle) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + let focus = state_for_mouse.read(cx).focus_handle().clone(); + window.focus(&focus, cx); + }) + .px_3() + .py_2() + .gap_1() + .font_family(self.font_family) + .text_sm() + .children(self.lines.into_iter().map(|line| render_line(line, theme))) + .when_some(self.tail, |viewport, tail| viewport.child(tail)) + } +} + +/// Renders one soft-wrapping terminal line. +fn render_line(line: TerminalLine, theme: UIThemes) -> impl IntoElement { + div().flex().flex_wrap().min_h(gpui::px(18.0)).children( + line + .spans() + .iter() + .map(|span| div().text_color(span.tone.color(theme)).child(span.text.clone())), + ) +} diff --git a/crates/chitin-ui/src/primitive/terminal/state.rs b/crates/chitin-ui/src/primitive/terminal/state.rs new file mode 100644 index 00000000..3cb92fdd --- /dev/null +++ b/crates/chitin-ui/src/primitive/terminal/state.rs @@ -0,0 +1,48 @@ +//! Persistent terminal viewport scroll state. + +use gpui::{Context, FocusHandle, ScrollHandle}; + +/// Persistent scrolling behavior for a terminal viewport. +pub struct TerminalViewportState { + scroll_handle: ScrollHandle, + focus_handle: FocusHandle, + follow_tail: bool, +} + +impl TerminalViewportState { + /// Creates a viewport that follows new output. + pub fn new(cx: &mut Context) -> Self { + Self { + scroll_handle: ScrollHandle::new(), + focus_handle: cx.focus_handle(), + follow_tail: true, + } + } + + /// Returns the GPUI scroll handle used by the viewport. + pub fn scroll_handle(&self) -> &ScrollHandle { + &self.scroll_handle + } + + /// Returns the focus handle used while a command is running. + pub fn focus_handle(&self) -> &FocusHandle { + &self.focus_handle + } + + /// Returns whether new output should remain visible. + pub const fn follows_tail(&self) -> bool { + self.follow_tail + } + + /// Enables or disables automatic tail following. + pub fn set_follow_tail(&mut self, follow_tail: bool) { + self.follow_tail = follow_tail; + } + + /// Scrolls to the newest line when tail following is enabled. + pub fn reveal_tail(&self) { + if self.follow_tail { + self.scroll_handle.scroll_to_bottom(); + } + } +} From 9e16d33c6a697fa34d958c020bba29fea906fbab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 21 Sep 2026 00:09:27 +0800 Subject: [PATCH 07/32] chore(theme): revert default theme --- crates/chitin-desktop/src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/chitin-desktop/src/app.rs b/crates/chitin-desktop/src/app.rs index 5a9aa973..92e0dcc2 100644 --- a/crates/chitin-desktop/src/app.rs +++ b/crates/chitin-desktop/src/app.rs @@ -466,7 +466,7 @@ impl Render for ChitinApp { self.cancel_document_panel_tab_drag(); } - let theme = builtins::light(); + let theme = builtins::dark(); let window_bar_controls = self.window_bar_controls(window, cx); let activity_bar_controls = self.activity_bar_controls(window, cx); let document_options_controls = self.document_options_controls(window, cx); From f39ce2ae07cddd2b39abf8f91c4433e68c1abdfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 21 Sep 2026 09:59:04 +0800 Subject: [PATCH 08/32] refactor(desktop): host terminal in an overlay bottom dock - add reusable bottom dock state, controls, and rendering - move terminal visibility, resizing, and close behavior into the dock - render the dock as an overlay without resizing the document area - support downward and upward resizing across dock boundaries - keep the dock extensible for future workbench tools --- crates/chitin-desktop/src/app.rs | 52 +++-- .../src/components/bottom_dock.rs | 50 +++++ crates/chitin-desktop/src/components/mod.rs | 1 + .../src/components/terminal/controller.rs | 7 +- .../src/components/terminal/mod.rs | 135 +----------- .../src/components/terminal/render.rs | 113 +++++----- .../src/composite/bottom_dock/mod.rs | 15 ++ .../src/composite/bottom_dock/model.rs | 201 ++++++++++++++++++ .../src/composite/bottom_dock/render.rs | 188 ++++++++++++++++ crates/chitin-ui/src/composite/mod.rs | 3 + 10 files changed, 559 insertions(+), 206 deletions(-) create mode 100644 crates/chitin-desktop/src/components/bottom_dock.rs create mode 100644 crates/chitin-ui/src/composite/bottom_dock/mod.rs create mode 100644 crates/chitin-ui/src/composite/bottom_dock/model.rs create mode 100644 crates/chitin-ui/src/composite/bottom_dock/render.rs diff --git a/crates/chitin-desktop/src/app.rs b/crates/chitin-desktop/src/app.rs index 92e0dcc2..f2d1b0bc 100644 --- a/crates/chitin-desktop/src/app.rs +++ b/crates/chitin-desktop/src/app.rs @@ -10,6 +10,7 @@ use chitin_databases::ClientConfig; use chitin_ui::{ composite::{ activity_bar::DEFAULT_ACTIVITY_BAR_WIDTH, + bottom_dock::BottomDockState, panel::{PanelSplitAxis, PanelTabDrag}, toast::{Toast, ToastHost, ToastId, ToastViewport}, window_bar::DEFAULT_WINDOW_BAR_HEIGHT, @@ -25,10 +26,11 @@ use crate::{ builtin_shell::{DesktopShellHost, desktop_shell_context}, components::{ activity_bar::{ActiveActivity, ActivityBarControls, render_activity_bar}, + bottom_dock::BottomDockControls, command_panel::{CommandPanelController, render_command_panel}, document_area::{DocumentOptionsControls, DocumentPanelState, render_document_area, state::DocumentPanelContent}, project_sidebar::{ProjectSidebarState, render_project_sidebar}, - terminal::{TerminalPanelControls, TerminalPanelState, render_terminal_panel}, + terminal::{TERMINAL_DOCK_ITEM_ID, TerminalPanelControls, TerminalPanelState, render_terminal_bottom_dock}, window_bar::{WindowBarControls, render_window_bar}, }, keybindings::{ToggleCommandPanel, ToggleTerminal, ToggleWorkspace, WORKBENCH_KEY_CONTEXT}, @@ -63,7 +65,11 @@ pub struct ChitinApp { pub(crate) command_executor: CommandExecutor, /// Shared command bridge for terminal, agent, and system invocations. pub(crate) builtin_shell: DesktopShellHost, - /// Visibility, scrollback presentation, and focus state for the bottom terminal. + /// Active tool and geometry of the workbench-level bottom dock. + pub(crate) bottom_dock: BottomDockState, + /// Persistent controls shared by all bottom-dock tools. + pub(crate) bottom_dock_controls: Option, + /// Scrollback presentation, shell mapping, and focus state for the terminal. pub(crate) terminal_panel: TerminalPanelState, /// Persistent primitive controls for the bottom terminal. pub(crate) terminal_panel_controls: Option, @@ -150,6 +156,8 @@ impl ChitinApp { tasks: BackgroundTaskCenter::new(), command_executor: CommandExecutor::new(ClientConfig::default()), builtin_shell: DesktopShellHost::new(desktop_shell_context(shell_workspace_root)), + bottom_dock: BottomDockState::new(), + bottom_dock_controls: None, terminal_panel: TerminalPanelState::new(), terminal_panel_controls: None, pending_structure_loads: BTreeSet::new(), @@ -389,7 +397,7 @@ impl ChitinApp { /// /// The approximate height available to the document panel root after removing /// the window bar. - fn document_panel_root_height(window_height: gpui::Pixels) -> gpui::Pixels { + pub(crate) fn document_panel_root_height(window_height: gpui::Pixels) -> gpui::Pixels { gpui::px((f32::from(window_height) - f32::from(DEFAULT_WINDOW_BAR_HEIGHT)).max(0.0)) } @@ -473,15 +481,17 @@ impl Render for ChitinApp { let toast_viewport = self.toast_viewport(cx); let command_panel_search_input = self.command_panel_search_input(window, cx); self.command_panel_rcsb_form(window, cx); - let terminal_controls = self - .terminal_panel - .is_visible() - .then(|| self.terminal_panel_controls(window, cx)); - if terminal_controls.is_some() { + let terminal_dock_controls = self.bottom_dock.is_active(TERMINAL_DOCK_ITEM_ID).then(|| { + ( + self.terminal_panel_controls(window, cx), + self.bottom_dock_controls(window, cx), + ) + }); + if terminal_dock_controls.is_some() { self.sync_terminal_output(cx); } if self.terminal_panel.take_focus_request() - && let Some(controls) = terminal_controls.as_ref() + && let Some((controls, _)) = terminal_dock_controls.as_ref() { let focus = controls.input(cx).read(cx).focus_handle().clone(); window.focus(&focus, cx); @@ -492,8 +502,8 @@ impl Render for ChitinApp { let document_panel_focus = self.document_panel_focus(cx); let project_sidebar_is_resizing = self.project_sidebar_state.is_resizing(); let document_panel_resize_axis = self.document_panel_resize_axis(); - let terminal_panel_is_resizing = self.terminal_panel.is_resizing(); - let terminal_panel_height = self.terminal_panel.height(); + let bottom_dock_is_resizing = self.bottom_dock.is_resizing(); + let bottom_dock_height = self.bottom_dock.height(); let visible_sidebar_width = if self.active_activity == ActiveActivity::Workspace && self.project_sidebar_visible { self.project_sidebar_state.resize.width() } else { @@ -538,13 +548,13 @@ impl Render for ChitinApp { .when(document_panel_resize_axis == Some(PanelSplitAxis::Vertical), |layout| { layout.cursor(CursorStyle::ResizeUpDown) }) - .when(terminal_panel_is_resizing, |layout| { + .when(bottom_dock_is_resizing, |layout| { layout.cursor(CursorStyle::ResizeUpDown) }) .on_mouse_move({ let app = app.clone(); move |event, window, cx| { - if !project_sidebar_is_resizing && document_panel_resize_axis.is_none() && !terminal_panel_is_resizing { + if !project_sidebar_is_resizing && document_panel_resize_axis.is_none() && !bottom_dock_is_resizing { return; } @@ -572,10 +582,10 @@ impl Render for ChitinApp { }); } - if terminal_panel_is_resizing { + if bottom_dock_is_resizing { let available_height = Self::document_panel_root_height(window.bounds().size.height); let _ = app.update(cx, |this, cx| { - if this.terminal_panel.drag_resize(event.position.y, available_height) { + if this.bottom_dock.drag_resize(event.position.y, available_height) { cx.notify(); } }); @@ -598,10 +608,10 @@ impl Render for ChitinApp { let _ = app.update(cx, |this, cx| { let sidebar_stopped = this.project_sidebar_state.stop_resize(); let document_panel_stopped = this.stop_document_panel_resize(); - let terminal_panel_stopped = this.terminal_panel.stop_resize(); + let bottom_dock_stopped = this.bottom_dock.stop_resize(); let document_tab_drag_cancelled = this.cancel_document_panel_tab_drag(); - if sidebar_stopped || document_panel_stopped || terminal_panel_stopped || document_tab_drag_cancelled { + if sidebar_stopped || document_panel_stopped || bottom_dock_stopped || document_tab_drag_cancelled { cx.notify(); } }); @@ -628,6 +638,7 @@ impl Render for ChitinApp { ) .child( div() + .relative() .flex() .flex_col() .flex_1() @@ -641,10 +652,11 @@ impl Render for ChitinApp { document_options_controls, cx, )) - .when_some(terminal_controls, |layout, controls| { - layout.child(render_terminal_panel( + .when_some(terminal_dock_controls, |layout, (controls, dock_controls)| { + layout.child(render_terminal_bottom_dock( controls, - terminal_panel_height, + dock_controls.close(), + bottom_dock_height, theme, app.clone(), )) diff --git a/crates/chitin-desktop/src/components/bottom_dock.rs b/crates/chitin-desktop/src/components/bottom_dock.rs new file mode 100644 index 00000000..22748c42 --- /dev/null +++ b/crates/chitin-desktop/src/components/bottom_dock.rs @@ -0,0 +1,50 @@ +//! Desktop-owned controls for the reusable workbench bottom dock. + +use chitin_ui::primitive::button::{ButtonEvent, ButtonState}; +use gpui::{AppContext, Context, Entity, Subscription, Window}; + +use crate::app::ChitinApp; + +/// Persistent controls shared by every bottom-dock item. +#[derive(Clone)] +pub(crate) struct BottomDockControls { + close: Entity, +} + +impl BottomDockControls { + /// Creates and subscribes the generic dock controls. + fn new(window: &mut Window, cx: &mut Context) -> Self { + let close = cx.new(ButtonState::new); + let subscription: Subscription = cx.subscribe_in(&close, window, |this, _, event, _, cx| { + if matches!(event, ButtonEvent::Click) { + this.close_bottom_dock(cx); + } + }); + subscription.detach(); + Self { close } + } + + /// Returns the shared dock close-button state. + pub(crate) fn close(&self) -> Entity { + self.close.clone() + } +} + +impl ChitinApp { + /// Returns generic bottom-dock controls, creating them on first use. + pub(crate) fn bottom_dock_controls(&mut self, window: &mut Window, cx: &mut Context) -> BottomDockControls { + if let Some(controls) = self.bottom_dock_controls.as_ref() { + return controls.clone(); + } + let controls = BottomDockControls::new(window, cx); + self.bottom_dock_controls = Some(controls.clone()); + controls + } + + /// Closes whichever tool is active in the bottom dock. + pub(crate) fn close_bottom_dock(&mut self, cx: &mut Context) { + self.bottom_dock.close(); + self.terminal_panel.request_focus(false); + cx.notify(); + } +} diff --git a/crates/chitin-desktop/src/components/mod.rs b/crates/chitin-desktop/src/components/mod.rs index 638d90b9..a891b0bb 100644 --- a/crates/chitin-desktop/src/components/mod.rs +++ b/crates/chitin-desktop/src/components/mod.rs @@ -1,6 +1,7 @@ //! Desktop-specific views and application wiring components. pub mod activity_bar; +pub mod bottom_dock; pub mod command_panel; pub mod document_area; pub mod project_sidebar; diff --git a/crates/chitin-desktop/src/components/terminal/controller.rs b/crates/chitin-desktop/src/components/terminal/controller.rs index 9c6d5801..d25142a2 100644 --- a/crates/chitin-desktop/src/components/terminal/controller.rs +++ b/crates/chitin-desktop/src/components/terminal/controller.rs @@ -4,7 +4,10 @@ use chitin_builtin_shell::ShellInvocationSource; use chitin_ui::composite::command_terminal::CommandTerminalStatus; use gpui::{AppContext, AsyncApp, Context, KeyDownEvent, WeakEntity, Window}; -use super::presenter::{shell_output_lines, shell_terminal_status, terminal_outcome}; +use super::{ + TERMINAL_DOCK_ITEM_ID, + presenter::{shell_output_lines, shell_terminal_status, terminal_outcome}, +}; use crate::{app::ChitinApp, builtin_shell::DesktopShellDispatch}; impl ChitinApp { @@ -64,7 +67,7 @@ impl ChitinApp { controls.terminal.update(cx, |terminal, cx| { terminal.finish(terminal_id, status, lines, cx); }); - if this.terminal_panel.is_visible() { + if this.bottom_dock.is_active(TERMINAL_DOCK_ITEM_ID) { let input = controls.input(cx); let focus = input.read(cx).focus_handle().clone(); window.focus(&focus, cx); diff --git a/crates/chitin-desktop/src/components/terminal/mod.rs b/crates/chitin-desktop/src/components/terminal/mod.rs index 01324390..73193ecc 100644 --- a/crates/chitin-desktop/src/components/terminal/mod.rs +++ b/crates/chitin-desktop/src/components/terminal/mod.rs @@ -9,21 +9,15 @@ use std::path::Path; use chitin_builtin_shell::ShellCommandId; use chitin_ui::{ composite::command_terminal::{CommandTerminalId, CommandTerminalState}, - primitive::{ - button::{ButtonEvent, ButtonState}, - input::text::{TextInputEvent, TextInputState}, - resize::ResizeGesture, - }, + primitive::input::text::{TextInputEvent, TextInputState}, }; -use gpui::{AppContext, Context, Entity, Pixels, Subscription, Window, px}; +use gpui::{AppContext, Context, Entity, Subscription, Window}; use crate::app::ChitinApp; -pub(crate) use render::render_terminal_panel; +pub(crate) use render::render_terminal_bottom_dock; -const DEFAULT_TERMINAL_HEIGHT: Pixels = px(260.0); -const MIN_TERMINAL_HEIGHT: Pixels = px(120.0); -const MIN_DOCUMENT_AREA_HEIGHT: Pixels = px(160.0); +pub(crate) const TERMINAL_DOCK_ITEM_ID: &str = "terminal"; pub(super) const TERMINAL_FONT_FAMILY: &str = "Cascadia Code"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -32,12 +26,9 @@ pub(super) struct TerminalShellBinding { pub(super) terminal_id: CommandTerminalId, } -/// Visibility, focus, and shell-to-view identity mapping for the bottom panel. +/// Terminal focus and shell-to-view identity mapping. pub(crate) struct TerminalPanelState { - visible: bool, focus_requested: bool, - height: Pixels, - resize_drag: Option>, pub(super) bindings: Vec, } @@ -45,32 +36,14 @@ impl TerminalPanelState { /// Creates a closed terminal panel. pub(crate) fn new() -> Self { Self { - visible: false, focus_requested: false, - height: DEFAULT_TERMINAL_HEIGHT, - resize_drag: None, bindings: Vec::new(), } } - /// Returns whether the terminal panel is visible. - pub(crate) const fn is_visible(&self) -> bool { - self.visible - } - - /// Toggles panel visibility and requests input focus when opening. - pub(crate) fn toggle(&mut self) { - self.visible = !self.visible; - self.focus_requested = self.visible; - if !self.visible { - self.resize_drag = None; - } - } - - /// Hides the terminal panel. - pub(crate) fn close(&mut self) { - self.visible = false; - self.focus_requested = false; + /// Updates whether terminal input should receive focus on the next render. + pub(crate) fn request_focus(&mut self, requested: bool) { + self.focus_requested = requested; } /// Takes the pending input-focus request. @@ -78,47 +51,6 @@ impl TerminalPanelState { std::mem::take(&mut self.focus_requested) } - /// Returns the current terminal panel height. - pub(crate) const fn height(&self) -> Pixels { - self.height - } - - /// Starts resizing from the terminal's top edge. - pub(crate) fn start_resize(&mut self, start_y: Pixels) { - self.resize_drag = Some(ResizeGesture::new((), start_y, self.height)); - } - - /// Updates terminal height while preserving minimum document space. - /// - /// # Parameters - /// - /// * `current_y` is the latest vertical pointer position. - /// * `available_height` is the workbench height below the window bar. - /// - /// # Returns - /// - /// `true` when an active drag changed or reaffirmed the terminal height. - pub(crate) fn drag_resize(&mut self, current_y: Pixels, available_height: Pixels) -> bool { - let Some(resize_drag) = self.resize_drag else { - return false; - }; - let maximum_height = - px((f32::from(available_height) - f32::from(MIN_DOCUMENT_AREA_HEIGHT)).max(f32::from(MIN_TERMINAL_HEIGHT))); - let desired_height = f32::from(resize_drag.start_value()) - resize_drag.delta(current_y); - self.height = px(desired_height.clamp(f32::from(MIN_TERMINAL_HEIGHT), f32::from(maximum_height))); - true - } - - /// Stops the active terminal resize gesture. - pub(crate) fn stop_resize(&mut self) -> bool { - self.resize_drag.take().is_some() - } - - /// Returns whether the terminal top edge is being dragged. - pub(crate) const fn is_resizing(&self) -> bool { - self.resize_drag.is_some() - } - /// Associates one shell execution with its visible command block. pub(super) fn bind(&mut self, shell_id: ShellCommandId, terminal_id: CommandTerminalId) { self.bindings.push(TerminalShellBinding { shell_id, terminal_id }); @@ -135,7 +67,6 @@ impl Default for TerminalPanelState { #[derive(Clone)] pub(crate) struct TerminalPanelControls { pub(super) terminal: Entity, - pub(super) close: Entity, } impl TerminalPanelControls { @@ -144,7 +75,6 @@ impl TerminalPanelControls { let prompt = presenter::terminal_prompt(working_directory); Self { terminal: cx.new(|cx| CommandTerminalState::new(prompt, cx)), - close: cx.new(ButtonState::new), } } @@ -157,13 +87,6 @@ impl TerminalPanelControls { _ => {} }); input_subscription.detach(); - - let close_subscription: Subscription = cx.subscribe_in(&self.close, window, |this, _, event, _, cx| { - if matches!(event, ButtonEvent::Click) { - this.close_terminal(cx); - } - }); - close_subscription.detach(); } /// Returns the live prompt's editable input. @@ -196,49 +119,13 @@ impl ChitinApp { /// Shows or hides the bottom terminal panel. pub(crate) fn toggle_terminal(&mut self, cx: &mut Context) { - self.terminal_panel.toggle(); + let visible = self.bottom_dock.toggle(TERMINAL_DOCK_ITEM_ID); + self.terminal_panel.request_focus(visible); cx.notify(); } /// Hides the bottom terminal panel. fn close_terminal(&mut self, cx: &mut Context) { - self.terminal_panel.stop_resize(); - self.terminal_panel.close(); - cx.notify(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Verifies that dragging the top edge upward increases terminal height. - #[test] - fn drag_resize_should_increase_height_when_pointer_moves_up() { - let mut state = TerminalPanelState::new(); - state.start_resize(px(400.0)); - - assert!(state.drag_resize(px(350.0), px(800.0))); - assert_eq!(state.height(), px(310.0)); - } - - /// Verifies that terminal resizing reserves space for the document area. - #[test] - fn drag_resize_should_preserve_minimum_document_height() { - let mut state = TerminalPanelState::new(); - state.start_resize(px(400.0)); - - assert!(state.drag_resize(px(0.0), px(600.0))); - assert_eq!(state.height(), px(440.0)); - } - - /// Verifies that dragging downward cannot collapse the terminal completely. - #[test] - fn drag_resize_should_preserve_minimum_terminal_height() { - let mut state = TerminalPanelState::new(); - state.start_resize(px(400.0)); - - assert!(state.drag_resize(px(800.0), px(600.0))); - assert_eq!(state.height(), MIN_TERMINAL_HEIGHT); + self.close_bottom_dock(cx); } } diff --git a/crates/chitin-desktop/src/components/terminal/render.rs b/crates/chitin-desktop/src/components/terminal/render.rs index e3b4b107..55a1ff46 100644 --- a/crates/chitin-desktop/src/components/terminal/render.rs +++ b/crates/chitin-desktop/src/components/terminal/render.rs @@ -1,88 +1,81 @@ -//! Bottom-panel chrome around the reusable command terminal. +//! Terminal content hosted by the reusable workbench bottom dock. use chitin_ui::{ - composite::command_terminal::CommandTerminal, - primitive::{ - button::{Button, ButtonSize, ButtonStyle, ButtonVariant}, - icon::Icon, + composite::{ + bottom_dock::{BottomDock, BottomDockResizeConfig}, + command_terminal::CommandTerminal, }, + primitive::button::ButtonState, themes::UIThemes, }; -use gpui::{CursorStyle, InteractiveElement, MouseButton, ParentElement, Pixels, WeakEntity, div, prelude::*, px}; +use gpui::{Entity, InteractiveElement, ParentElement, Pixels, Styled, WeakEntity, div}; use super::{TERMINAL_FONT_FAMILY, TerminalPanelControls}; use crate::{app::ChitinApp, keybindings::COMMAND_TERMINAL_KEY_CONTEXT}; -/// Renders the bottom panel chrome around the reusable command terminal. -pub(crate) fn render_terminal_panel( +/// Renders the command terminal as the active bottom-dock item. +/// +/// # Parameters +/// +/// * `controls` contains the persistent terminal transcript and input state. +/// * `close` is the bottom dock's shared close-button state. +/// * `height` is the current workbench-level dock height. +/// * `theme` supplies semantic colors for the dock and terminal. +/// * `app` receives resize and keyboard events from the rendered content. +/// +/// # Returns +/// +/// A bottom-dock element containing the structured command terminal. +pub(crate) fn render_terminal_bottom_dock( controls: TerminalPanelControls, + close: Entity, height: Pixels, theme: UIThemes, app: WeakEntity, ) -> impl gpui::IntoElement { let resize_app = app.clone(); - div() - .relative() + let resize_move_app = app.clone(); + let resize_end_app = app.clone(); + let terminal = div() .flex() .flex_col() - .h(height) - .min_h(height) - .border_t_1() - .border_color(theme.border.primary) - .bg(theme.background.primary) + .flex_1() + .min_h_0() .key_context(COMMAND_TERMINAL_KEY_CONTEXT) .capture_key_down(move |event, window, cx| { let _ = app.update(cx, |this, cx| this.handle_terminal_key(event, window, cx)); }) - .child( - div() - .flex() - .items_center() - .justify_between() - .h(px(34.0)) - .min_h(px(34.0)) - .px_3() - .border_b_1() - .border_color(theme.border.muted) - .child( - div() - .text_xs() - .font_weight(gpui::FontWeight::SEMIBOLD) - .child("TERMINAL"), - ) - .child( - Button::new(controls.close) - .theme(theme) - .variant(ButtonVariant::Transparent) - .size(ButtonSize::Small) - .style( - ButtonStyle::new() - .width(px(26.0)) - .height(px(24.0)) - .horizontal_padding(px(0.0)), - ) - .child(Icon::new("icons/window-close.svg").size(px(14.0)).theme(theme)), - ), - ) .child( CommandTerminal::new(controls.terminal) .theme(theme) .font_family(TERMINAL_FONT_FAMILY), - ) - .child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .h(px(5.0)) - .cursor(CursorStyle::ResizeUpDown) - .hover(move |style| style.bg(theme.border.focus)) - .on_mouse_down(MouseButton::Left, move |event, _, cx| { - let _ = resize_app.update(cx, |this, cx| { - this.terminal_panel.start_resize(event.position.y); + ); + + BottomDock::new("TERMINAL", close) + .height(height) + .theme(theme) + .resizable( + BottomDockResizeConfig::new(move |start_y, _, cx| { + let _ = resize_app.update(cx, |this, cx| { + this.bottom_dock.start_resize(start_y); + cx.notify(); + }); + }) + .on_resize(move |current_y, window, cx| { + let available_height = ChitinApp::document_panel_root_height(window.bounds().size.height); + let _ = resize_move_app.update(cx, |this, cx| { + if this.bottom_dock.drag_resize(current_y, available_height) { + cx.notify(); + } + }); + }) + .on_resize_end(move |_, cx| { + let _ = resize_end_app.update(cx, |this, cx| { + if this.bottom_dock.stop_resize() { cx.notify(); - }); - }), + } + }); + }), ) + .child(terminal) } diff --git a/crates/chitin-ui/src/composite/bottom_dock/mod.rs b/crates/chitin-ui/src/composite/bottom_dock/mod.rs new file mode 100644 index 00000000..5dcd2821 --- /dev/null +++ b/crates/chitin-ui/src/composite/bottom_dock/mod.rs @@ -0,0 +1,15 @@ +//! Resizable bottom workbench overlay dock. +//! +//! The dock is independent from document tabs and split panels. Applications +//! select an active dock item, then supply that item's content to [`BottomDock`]. +//! The rendered dock is absolutely positioned over its containing workbench +//! layer, so it does not participate in document-area size allocation. + +mod model; +mod render; + +pub use model::{ + BottomDockItemId, BottomDockState, DEFAULT_BOTTOM_DOCK_HEIGHT, DEFAULT_BOTTOM_DOCK_MIN_HEIGHT, + DEFAULT_CENTER_AREA_MIN_HEIGHT, +}; +pub use render::{BottomDock, BottomDockResizeConfig}; diff --git a/crates/chitin-ui/src/composite/bottom_dock/model.rs b/crates/chitin-ui/src/composite/bottom_dock/model.rs new file mode 100644 index 00000000..f81ebaab --- /dev/null +++ b/crates/chitin-ui/src/composite/bottom_dock/model.rs @@ -0,0 +1,201 @@ +//! Active-item and resize state for the bottom dock. + +use gpui::{Pixels, SharedString, px}; + +use crate::primitive::resize::ResizeGesture; + +/// Default bottom-dock height. +pub const DEFAULT_BOTTOM_DOCK_HEIGHT: Pixels = px(260.0); +/// Default minimum height retained by an open bottom dock. +pub const DEFAULT_BOTTOM_DOCK_MIN_HEIGHT: Pixels = px(120.0); +/// Default minimum height reserved for the workbench center area. +pub const DEFAULT_CENTER_AREA_MIN_HEIGHT: Pixels = px(160.0); + +/// Stable semantic identity of one bottom-dock tool. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct BottomDockItemId(SharedString); + +impl BottomDockItemId { + /// Creates a dock-item identity from an application-defined stable string. + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + /// Returns the stable string representation. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Active tool and geometry state for a reusable bottom workbench dock. +#[derive(Clone, Debug)] +pub struct BottomDockState { + active_item: Option, + height: Pixels, + min_height: Pixels, + min_center_height: Pixels, + resize_drag: Option>, +} + +impl BottomDockState { + /// Creates a closed dock with the default height constraints. + pub fn new() -> Self { + Self { + active_item: None, + height: DEFAULT_BOTTOM_DOCK_HEIGHT, + min_height: DEFAULT_BOTTOM_DOCK_MIN_HEIGHT, + min_center_height: DEFAULT_CENTER_AREA_MIN_HEIGHT, + resize_drag: None, + } + } + + /// Returns the active dock item, or `None` while the dock is closed. + pub fn active_item(&self) -> Option<&BottomDockItemId> { + self.active_item.as_ref() + } + + /// Returns whether any dock item is visible. + pub const fn is_open(&self) -> bool { + self.active_item.is_some() + } + + /// Returns whether the identified item is currently visible. + pub fn is_active(&self, item_id: &str) -> bool { + self + .active_item + .as_ref() + .is_some_and(|active| active.as_str() == item_id) + } + + /// Shows the identified item, replacing the previously active dock tool. + pub fn show(&mut self, item_id: impl Into) { + self.active_item = Some(BottomDockItemId::new(item_id)); + } + + /// Toggles one item and returns whether it is visible afterward. + /// + /// An inactive item replaces the current dock content instead of closing the + /// dock. Toggling the already-active item closes the dock. + /// + /// # Parameters + /// + /// * `item_id` is the stable identity of the tool being toggled. + /// + /// # Returns + /// + /// `true` when the requested item is visible afterward; otherwise `false`. + pub fn toggle(&mut self, item_id: impl Into) -> bool { + let item_id = BottomDockItemId::new(item_id); + if self.active_item.as_ref() == Some(&item_id) { + self.close(); + false + } else { + self.active_item = Some(item_id); + true + } + } + + /// Closes the dock and stops any active resize gesture. + pub fn close(&mut self) { + self.active_item = None; + self.resize_drag = None; + } + + /// Returns the current dock height. + pub const fn height(&self) -> Pixels { + self.height + } + + /// Starts resizing from the dock's top edge. + pub fn start_resize(&mut self, start_y: Pixels) { + self.resize_drag = Some(ResizeGesture::new((), start_y, self.height)); + } + + /// Updates dock height while preserving minimum center-area space. + /// + /// # Parameters + /// + /// * `current_y` is the latest vertical pointer position. + /// * `available_height` is the workbench height available to the center area + /// and bottom dock together. + /// + /// # Returns + /// + /// `true` when an active resize gesture updated the dock; otherwise `false`. + pub fn drag_resize(&mut self, current_y: Pixels, available_height: Pixels) -> bool { + let Some(resize_drag) = self.resize_drag else { + return false; + }; + let maximum_height = + px((f32::from(available_height) - f32::from(self.min_center_height)).max(f32::from(self.min_height))); + let desired_height = f32::from(resize_drag.start_value()) - resize_drag.delta(current_y); + self.height = px(desired_height.clamp(f32::from(self.min_height), f32::from(maximum_height))); + true + } + + /// Stops the active dock resize gesture. + pub fn stop_resize(&mut self) -> bool { + self.resize_drag.take().is_some() + } + + /// Returns whether the dock top edge is being dragged. + pub const fn is_resizing(&self) -> bool { + self.resize_drag.is_some() + } +} + +impl Default for BottomDockState { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toggling_active_item_should_close_dock() { + let mut state = BottomDockState::new(); + state.show("terminal"); + + assert!(!state.toggle("terminal")); + assert!(!state.is_open()); + } + + #[test] + fn toggling_inactive_item_should_replace_content() { + let mut state = BottomDockState::new(); + state.show("terminal"); + + assert!(state.toggle("tasks")); + assert!(state.is_active("tasks")); + } + + #[test] + fn drag_resize_should_increase_height_when_pointer_moves_up() { + let mut state = BottomDockState::new(); + state.start_resize(px(400.0)); + + assert!(state.drag_resize(px(350.0), px(800.0))); + assert_eq!(state.height(), px(310.0)); + } + + #[test] + fn drag_resize_should_preserve_minimum_center_height() { + let mut state = BottomDockState::new(); + state.start_resize(px(400.0)); + + assert!(state.drag_resize(px(0.0), px(600.0))); + assert_eq!(state.height(), px(440.0)); + } + + #[test] + fn drag_resize_should_preserve_minimum_dock_height() { + let mut state = BottomDockState::new(); + state.start_resize(px(400.0)); + + assert!(state.drag_resize(px(800.0), px(600.0))); + assert_eq!(state.height(), DEFAULT_BOTTOM_DOCK_MIN_HEIGHT); + } +} diff --git a/crates/chitin-ui/src/composite/bottom_dock/render.rs b/crates/chitin-ui/src/composite/bottom_dock/render.rs new file mode 100644 index 00000000..63e01e21 --- /dev/null +++ b/crates/chitin-ui/src/composite/bottom_dock/render.rs @@ -0,0 +1,188 @@ +//! Generic bottom-dock layout and chrome. + +use std::rc::Rc; + +use gpui::{ + AnyElement, App, CursorStyle, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, + RenderOnce, SharedString, Window, div, prelude::*, px, +}; + +use crate::{ + primitive::{ + button::{Button, ButtonSize, ButtonState, ButtonStyle, ButtonVariant}, + icon::Icon, + }, + themes::{UIThemes, builtins}, +}; + +type BottomDockResizeStartHandler = dyn Fn(Pixels, &mut Window, &mut App); +type BottomDockResizeHandler = dyn Fn(Pixels, &mut Window, &mut App); +type BottomDockResizeEndHandler = dyn Fn(&mut Window, &mut App); + +/// Configuration for the dock's top-edge resize handle. +#[derive(Clone)] +pub struct BottomDockResizeConfig { + handle_height: Pixels, + on_resize_start: Rc, + on_resize: Option>, + on_resize_end: Option>, +} + +impl BottomDockResizeConfig { + /// Creates resize configuration with the standard interactive handle height. + pub fn new(on_resize_start: impl Fn(Pixels, &mut Window, &mut App) + 'static) -> Self { + Self { + handle_height: px(5.0), + on_resize_start: Rc::new(on_resize_start), + on_resize: None, + on_resize_end: None, + } + } + + /// Sets the interactive resize-handle height. + pub fn handle_height(mut self, handle_height: Pixels) -> Self { + self.handle_height = handle_height; + self + } + + /// Sets the callback invoked while the pointer moves inside the dock. + pub fn on_resize(mut self, on_resize: impl Fn(Pixels, &mut Window, &mut App) + 'static) -> Self { + self.on_resize = Some(Rc::new(on_resize)); + self + } + + /// Sets the callback invoked when a resize ends inside the dock. + pub fn on_resize_end(mut self, on_resize_end: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_resize_end = Some(Rc::new(on_resize_end)); + self + } +} + +/// Workbench-level overlay dock around one active tool's arbitrary content. +#[derive(IntoElement)] +pub struct BottomDock { + title: SharedString, + close: Entity, + height: Pixels, + theme: UIThemes, + resize: Option, + child: Option, +} + +impl BottomDock { + /// Creates an empty dock for the active tool represented by `title`. + pub fn new(title: impl Into, close: Entity) -> Self { + Self { + title: title.into(), + close, + height: super::DEFAULT_BOTTOM_DOCK_HEIGHT, + theme: builtins::dark(), + resize: None, + child: None, + } + } + + /// Sets the dock height. + pub fn height(mut self, height: Pixels) -> Self { + self.height = height; + self + } + + /// Sets the semantic UI theme. + pub fn theme(mut self, theme: UIThemes) -> Self { + self.theme = theme; + self + } + + /// Enables top-edge resizing with the supplied callback configuration. + pub fn resizable(mut self, resize: BottomDockResizeConfig) -> Self { + self.resize = Some(resize); + self + } + + /// Sets the active dock item's content. + pub fn child(mut self, child: impl IntoElement) -> Self { + self.child = Some(child.into_any_element()); + self + } +} + +impl RenderOnce for BottomDock { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let theme = self.theme; + div() + .absolute() + .left_0() + .right_0() + .bottom_0() + .flex() + .flex_col() + .h(self.height) + .min_h(self.height) + .border_t_1() + .border_color(theme.border.primary) + .bg(theme.background.primary) + .occlude() + .on_scroll_wheel(|_, _, cx| { + cx.stop_propagation(); + }) + .child( + div() + .flex() + .items_center() + .justify_between() + .h(px(34.0)) + .min_h(px(34.0)) + .px_3() + .border_b_1() + .border_color(theme.border.muted) + .child( + div() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(self.title), + ) + .child( + Button::new(self.close) + .theme(theme) + .variant(ButtonVariant::Transparent) + .size(ButtonSize::Small) + .style( + ButtonStyle::new() + .width(px(26.0)) + .height(px(24.0)) + .horizontal_padding(px(0.0)), + ) + .child(Icon::new("icons/window-close.svg").size(px(14.0)).theme(theme)), + ), + ) + .when_some(self.child, |dock, child| dock.child(child)) + .when_some(self.resize, |dock, resize| { + let on_resize_start = resize.on_resize_start.clone(); + dock + .when_some(resize.on_resize, |dock, on_resize| { + dock.on_mouse_move(move |event, window, cx| { + on_resize(event.position.y, window, cx); + }) + }) + .when_some(resize.on_resize_end, |dock, on_resize_end| { + dock.on_mouse_up(MouseButton::Left, move |_, window, cx| { + on_resize_end(window, cx); + }) + }) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(resize.handle_height) + .cursor(CursorStyle::ResizeUpDown) + .hover(move |style| style.bg(theme.border.focus)) + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + on_resize_start(event.position.y, window, cx); + }), + ) + }) + } +} diff --git a/crates/chitin-ui/src/composite/mod.rs b/crates/chitin-ui/src/composite/mod.rs index ef5c3939..fb239a6a 100644 --- a/crates/chitin-ui/src/composite/mod.rs +++ b/crates/chitin-ui/src/composite/mod.rs @@ -3,6 +3,9 @@ /// Vertical activity-bar composition. pub mod activity_bar; +/// Resizable workbench dock for terminal, tasks, output, and similar tools. +pub mod bottom_dock; + /// Structured command terminal assembled from terminal and input primitives. pub mod command_terminal; From e984fc8ec0d00757341f7e0d374c1d0718881fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 21 Sep 2026 10:23:07 +0800 Subject: [PATCH 09/32] refactor(desktop): remove unused blank WGPU panel scene Remove the unreachable ClearScene scaffolding and the unused ChitinWgpuDocumentPanel::new constructor. Require callers to provide an explicit WgpuPanelScene through new_with_scene, while retaining ClearRenderer for the browser WASM background renderer.# --- .../src/components/wgpu_panel.rs | 64 +------------------ 1 file changed, 2 insertions(+), 62 deletions(-) diff --git a/crates/chitin-desktop/src/components/wgpu_panel.rs b/crates/chitin-desktop/src/components/wgpu_panel.rs index d3298e4d..63ee79ad 100644 --- a/crates/chitin-desktop/src/components/wgpu_panel.rs +++ b/crates/chitin-desktop/src/components/wgpu_panel.rs @@ -1,13 +1,10 @@ //! Experimental WGPU document panel component. -use std::{ - sync::Arc, - time::{Duration, Instant}, -}; +use std::time::{Duration, Instant}; use chitin_bio::surface::MolecularSurfaceArtifact; use chitin_molecule_renderer::{DragMode, RepresentationLayers, ViewerCamera, ViewportDrag}; -use chitin_wgpu::{ClearRenderer, RenderTargetSize}; +use chitin_wgpu::RenderTargetSize; use gpui::{ Context, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Render, ScrollWheelEvent, WgpuSurfaceHandle, Window, div, prelude::*, px, rgb, wgpu_surface, @@ -73,49 +70,6 @@ pub trait WgpuPanelScene { } } -/// Default scene used when no specialized renderer is supplied. -struct ClearScene { - /// Lazy renderer using the surface's device and queue. - renderer: Option, -} - -impl ClearScene { - /// Creates a scene that clears the WGPU surface each frame. - fn new() -> Self { - Self { renderer: None } - } -} - -impl WgpuPanelScene for ClearScene { - /// Clears the current frame using the shared WGPU helper. - /// - /// # Parameters - /// - /// * `frame` contains the GPUI surface resources and back-buffer target. - /// - /// # Returns - /// - /// The queue submission index for synchronized presentation. - fn render_frame(&mut self, frame: WgpuPanelFrame<'_>) -> wgpu::SubmissionIndex { - let renderer = self.renderer.get_or_insert_with(|| { - ClearRenderer::new( - Arc::new(frame.device.clone()), - Arc::new(frame.queue.clone()), - frame.size, - wgpu::Color { - r: 0.025, - g: 0.030, - b: 0.045, - a: 1.0, - }, - ) - }); - - renderer.resize_if_needed(frame.size); - renderer.render(frame.view) - } -} - /// Experimental interactive WGPU panel suitable for a document-area tab. pub struct ChitinWgpuDocumentPanel { /// GPUI surface handle, absent only if the backend cannot create WGPU surfaces. @@ -137,20 +91,6 @@ pub struct ChitinWgpuDocumentPanel { } impl ChitinWgpuDocumentPanel { - /// Creates an interactive WGPU document panel. - /// - /// # Parameters - /// - /// * `surface` is the GPUI-owned WGPU surface created by the desktop window. - /// - /// # Returns - /// - /// A panel ready to render when GPUI schedules its first frame. - #[allow(dead_code)] - pub fn new(surface: Option) -> Self { - Self::new_with_scene(surface, ClearScene::new()) - } - /// Creates an interactive WGPU document panel with a custom scene. /// /// # Parameters From 1bb226e0e33ca60ac48325c277a625a6343afc50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 21 Sep 2026 11:09:01 +0800 Subject: [PATCH 10/32] refactor(command): separate portable and frontend execution - introduce explicit portable and frontend command types - classify commands through a shared execution domain - restrict CommandExecutor to portable commands - restrict desktop dispatch to frontend commands - remove duplicate shell target classification - eliminate the no-op desktop structure command path - update CLI, RCSB tasks, parser, and shell routing --- crates/chitin-builtin-shell/src/session.rs | 29 ++-- crates/chitin-cli/src/cli.rs | 16 +- crates/chitin-command-runtime/src/lib.rs | 18 +-- crates/chitin-command/src/lib.rs | 149 +++++++++++++++--- crates/chitin-command/src/parser.rs | 20 ++- crates/chitin-desktop/src/builtin_shell.rs | 12 +- .../src/components/command_panel.rs | 11 +- .../src/keybindings/dispatch.rs | 46 +----- crates/chitin-desktop/src/tasks/rcsb.rs | 4 +- 9 files changed, 189 insertions(+), 116 deletions(-) diff --git a/crates/chitin-builtin-shell/src/session.rs b/crates/chitin-builtin-shell/src/session.rs index 30126cc7..22f041be 100644 --- a/crates/chitin-builtin-shell/src/session.rs +++ b/crates/chitin-builtin-shell/src/session.rs @@ -6,8 +6,8 @@ use std::{ }; use chitin_command::{ - ChitinCommand, CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandId, CommandMessage, - CommandProgress, parse_command_line, + ChitinCommand, CommandEventSink, CommandExecutionContext, CommandExecutionDomain, CommandExecutionEvent, CommandId, + CommandMessage, CommandProgress, parse_command_line, }; use chitin_command_runtime::{CommandExecutionError, CommandExecutor, CommandOutcome}; use chitin_databases::{CancellationToken, PersistedArtifact}; @@ -25,14 +25,8 @@ impl ShellCommandId { } } -/// Executor boundary selected for a parsed command. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ShellCommandTarget { - /// The command can execute without a graphical frontend. - Portable, - /// The command requires application or workspace state from the host. - Frontend, -} +/// Execution boundary selected for a parsed shell command. +pub type ShellCommandTarget = CommandExecutionDomain; /// Origin of a command invocation crossing the built-in shell bridge. #[derive(Clone, Debug, PartialEq, Eq)] @@ -375,7 +369,7 @@ impl BuiltinShell { source: ShellInvocationSource, ) -> Result { let input = input.into(); - let target = command_target(&command); + let target = command.execution_domain(); let cancellation = CancellationToken::new(); let (submission, record) = { let mut state = self.lock_state()?; @@ -485,7 +479,10 @@ impl BuiltinShell { shell.record_command_event(id, event.clone()); host_events.emit(event); }); - match executor.execute(submission.command, submission.context, events).await { + let ChitinCommand::Portable(command) = submission.command else { + return Err(BuiltinShellError::FrontendCommand { id: submission.id }); + }; + match executor.execute(command, submission.context, events).await { Ok(outcome) => { self.finish(id, ShellExecutionStatus::Succeeded, ShellTranscriptContent::Completed)?; Ok(ShellExecutionResult { id, outcome }) @@ -683,14 +680,6 @@ impl BuiltinShell { } } -/// Classifies whether a command is portable or needs its host frontend. -fn command_target(command: &ChitinCommand) -> ShellCommandTarget { - match command { - ChitinCommand::Database(_) | ChitinCommand::Structure(_) => ShellCommandTarget::Portable, - ChitinCommand::Workspace(_) | ChitinCommand::Application(_) => ShellCommandTarget::Frontend, - } -} - /// Replaces the active progress row so frequent updates do not grow scrollback. fn update_progress(transcript: &mut Vec, id: ShellCommandId, progress: CommandProgress) { if let Some(entry) = transcript diff --git a/crates/chitin-cli/src/cli.rs b/crates/chitin-cli/src/cli.rs index e1be16eb..3153b030 100644 --- a/crates/chitin-cli/src/cli.rs +++ b/crates/chitin-cli/src/cli.rs @@ -3,7 +3,7 @@ use std::{io::Read, path::PathBuf}; use chitin_command::{ - ChitinCommand, CommandExecutionContext, CommandOutputFormat, DatabaseCommand, RcsbDownloadArguments, + CommandExecutionContext, CommandOutputFormat, DatabaseCommand, PortableCommand, RcsbDownloadArguments, StructureCommand, StructureInputArguments, StructureInspectArguments, StructureValidateArguments, }; use chitin_command_runtime::CommandExecutor; @@ -261,7 +261,7 @@ async fn dispatch_rcsb_command(command: RcsbSubcommand) -> Result<(), CliError> /// /// Returns [`CliError`] when the command is unsupported or the RCSB download /// fails. -async fn dispatch_command(command: ChitinCommand) -> Result<(), CliError> { +async fn dispatch_command(command: PortableCommand) -> Result<(), CliError> { let context = execution_context(&command)?; let executor = CommandExecutor::new(ClientConfig::default()); let outcome = executor.execute(command, context, terminal_event_sink()).await?; @@ -277,7 +277,7 @@ async fn dispatch_command(command: ChitinCommand) -> Result<(), CliError> { /// # Returns /// /// Working-directory, download-root, and standard-input data for the executor. -fn execution_context(command: &ChitinCommand) -> Result { +fn execution_context(command: &PortableCommand) -> Result { let working_directory = std::env::current_dir().map_err(CliError::WorkingDirectory)?; let mut context = CommandExecutionContext::new(working_directory); if requires_default_download_root(command) { @@ -294,10 +294,10 @@ fn execution_context(command: &ChitinCommand) -> Result bool { +fn requires_default_download_root(command: &PortableCommand) -> bool { matches!( command, - ChitinCommand::Database(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { output: None, .. })) @@ -305,10 +305,10 @@ fn requires_default_download_root(command: &ChitinCommand) -> bool { } /// Returns whether a structure command reads bytes from standard input. -fn requires_standard_input(command: &ChitinCommand) -> bool { +fn requires_standard_input(command: &PortableCommand) -> bool { match command { - ChitinCommand::Structure(StructureCommand::Inspect(arguments)) => arguments.input.input.as_os_str() == "-", - ChitinCommand::Structure(StructureCommand::Validate(arguments)) => arguments.input.input.as_os_str() == "-", + PortableCommand::Structure(StructureCommand::Inspect(arguments)) => arguments.input.input.as_os_str() == "-", + PortableCommand::Structure(StructureCommand::Validate(arguments)) => arguments.input.input.as_os_str() == "-", _ => false, } } diff --git a/crates/chitin-command-runtime/src/lib.rs b/crates/chitin-command-runtime/src/lib.rs index 5dbdc0d6..fc078d4f 100644 --- a/crates/chitin-command-runtime/src/lib.rs +++ b/crates/chitin-command-runtime/src/lib.rs @@ -15,7 +15,7 @@ use std::{ use chitin_bio::structure::StructureParseResult; use chitin_command::{ - ChitinCommand, CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandId, CommandOutputFormat, + CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandId, CommandOutputFormat, PortableCommand, }; use chitin_databases::{Client, ClientConfig, TransportError, providers::rcsb::StructureFormat}; @@ -75,9 +75,6 @@ impl StructureValidation { /// Failure while preparing or executing a portable command. #[derive(Debug, thiserror::Error)] pub enum CommandExecutionError { - /// The selected command belongs to a frontend-specific domain. - #[error("command '{0}' requires a frontend-specific executor")] - UnsupportedCommand(CommandId), /// No default download directory was supplied for a command without output. #[error("command '{command_id}' requires a default download directory")] MissingDownloadRoot { @@ -147,22 +144,21 @@ impl CommandExecutor { /// /// # Errors /// - /// Returns [`CommandExecutionError`] when the command is frontend-specific, - /// required context is missing, parsing fails, or provider execution fails. + /// Returns [`CommandExecutionError`] when required context is missing, + /// parsing fails, or provider execution fails. pub async fn execute( &self, - command: ChitinCommand, + command: PortableCommand, context: CommandExecutionContext, events: CommandEventSink, ) -> Result { let command_id = command.id(); events.emit(CommandExecutionEvent::Started { command_id }); let outcome = match command { - ChitinCommand::Database(command) => database::execute(self.database_client()?, command, &context, &events).await, - ChitinCommand::Structure(command) => structure::execute(command, &context), - ChitinCommand::Workspace(_) | ChitinCommand::Application(_) => { - Err(CommandExecutionError::UnsupportedCommand(command_id)) + PortableCommand::Database(command) => { + database::execute(self.database_client()?, command, &context, &events).await } + PortableCommand::Structure(command) => structure::execute(command, &context), }?; events.emit(CommandExecutionEvent::Completed { command_id }); Ok(outcome) diff --git a/crates/chitin-command/src/lib.rs b/crates/chitin-command/src/lib.rs index 05013d5f..fd440910 100644 --- a/crates/chitin-command/src/lib.rs +++ b/crates/chitin-command/src/lib.rs @@ -21,6 +21,15 @@ pub use structure::{ }; pub use workspace::WorkspaceCommand; +/// Execution boundary required by a typed command. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CommandExecutionDomain { + /// The command can execute without application or window state. + Portable, + /// The command requires state owned by its graphical frontend. + Frontend, +} + /// Stable identity of a command independent of its invocation arguments. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum CommandId { @@ -75,8 +84,8 @@ impl CommandId { } } - /// Builds an executable command when this identity needs no arguments. - pub fn command_without_arguments(self) -> Option { + /// Builds a frontend command when this identity needs no arguments. + pub fn frontend_command_without_arguments(self) -> Option { match self { Self::WorkspaceFocusPrevious => Some(WorkspaceCommand::FocusPrevious.into()), Self::WorkspaceFocusNext => Some(WorkspaceCommand::FocusNext.into()), @@ -101,61 +110,143 @@ impl std::fmt::Display for CommandId { } } -/// Top-level command hierarchy shared by desktop, terminal, and CLI inputs. +/// Commands executable without application or window state. #[derive(Clone, Debug, PartialEq, Eq)] -pub enum ChitinCommand { - /// Workspace and document-panel commands. - Workspace(WorkspaceCommand), +pub enum PortableCommand { /// Database-provider commands. Database(DatabaseCommand), - /// Application-shell commands. - Application(ApplicationCommand), /// Structure parsing and inspection commands. Structure(StructureCommand), } -impl ChitinCommand { +impl PortableCommand { /// Returns the stable identity for this command. pub fn id(&self) -> CommandId { match self { - Self::Workspace(command) => command.id(), Self::Database(command) => command.id(), - Self::Application(command) => command.id(), Self::Structure(command) => command.id(), } } } -impl From for ChitinCommand { +/// Commands that require state owned by a graphical frontend. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FrontendCommand { + /// Workspace and document-panel commands. + Workspace(WorkspaceCommand), + /// Application-shell commands. + Application(ApplicationCommand), +} + +impl FrontendCommand { + /// Returns the stable identity for this command. + pub fn id(&self) -> CommandId { + match self { + Self::Workspace(command) => command.id(), + Self::Application(command) => command.id(), + } + } +} + +/// Top-level command hierarchy shared by desktop, terminal, and CLI inputs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ChitinCommand { + /// A command executable by the frontend-independent runtime. + Portable(PortableCommand), + /// A command requiring state owned by its host frontend. + Frontend(FrontendCommand), +} + +impl ChitinCommand { + /// Returns the stable identity for this command. + pub fn id(&self) -> CommandId { + match self { + Self::Portable(command) => command.id(), + Self::Frontend(command) => command.id(), + } + } + + /// Returns the execution boundary required by this command. + pub const fn execution_domain(&self) -> CommandExecutionDomain { + match self { + Self::Portable(_) => CommandExecutionDomain::Portable, + Self::Frontend(_) => CommandExecutionDomain::Frontend, + } + } +} + +impl From for FrontendCommand { fn from(command: WorkspaceCommand) -> Self { Self::Workspace(command) } } -impl From for ChitinCommand { +impl From for PortableCommand { fn from(command: DatabaseCommand) -> Self { Self::Database(command) } } -impl From for ChitinCommand { +impl From for FrontendCommand { fn from(command: ApplicationCommand) -> Self { Self::Application(command) } } -impl From for ChitinCommand { +impl From for PortableCommand { fn from(command: StructureCommand) -> Self { Self::Structure(command) } } -impl From for ChitinCommand { +impl From for FrontendCommand { fn from(command: PanelTabCommand) -> Self { Self::Workspace(WorkspaceCommand::PanelTab(command)) } } +impl From for ChitinCommand { + fn from(command: PortableCommand) -> Self { + Self::Portable(command) + } +} + +impl From for ChitinCommand { + fn from(command: FrontendCommand) -> Self { + Self::Frontend(command) + } +} + +impl From for ChitinCommand { + fn from(command: WorkspaceCommand) -> Self { + FrontendCommand::from(command).into() + } +} + +impl From for ChitinCommand { + fn from(command: DatabaseCommand) -> Self { + PortableCommand::from(command).into() + } +} + +impl From for ChitinCommand { + fn from(command: ApplicationCommand) -> Self { + FrontendCommand::from(command).into() + } +} + +impl From for ChitinCommand { + fn from(command: StructureCommand) -> Self { + PortableCommand::from(command).into() + } +} + +impl From for ChitinCommand { + fn from(command: PanelTabCommand) -> Self { + FrontendCommand::from(command).into() + } +} + /// Command category used for search grouping and presentation. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CommandCategory { @@ -352,10 +443,29 @@ mod tests { ); } + #[test] + fn database_commands_should_use_the_portable_execution_domain() -> Result<(), Box> { + let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ids: vec![PdbId::new("4hhb")?], + format: StructureFormat::Pdb, + output: None, + })); + + assert_eq!(command.execution_domain(), CommandExecutionDomain::Portable); + Ok(()) + } + + #[test] + fn workspace_commands_should_use_the_frontend_execution_domain() { + let command = ChitinCommand::from(WorkspaceCommand::ToggleWorkspace); + + assert_eq!(command.execution_domain(), CommandExecutionDomain::Frontend); + } + #[test] fn parameterized_command_id_should_not_create_an_incomplete_command() { assert_eq!( - CommandId::DatabaseDownloadRcsbStructure.command_without_arguments(), + CommandId::DatabaseDownloadRcsbStructure.frontend_command_without_arguments(), None ); } @@ -371,11 +481,12 @@ mod tests { assert!(matches!( command, - ChitinCommand::Database(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ChitinCommand::Portable(PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure( + RcsbDownloadArguments { ids, format: StructureFormat::Mmcif, output: Some(output), - })) if ids == vec![id] && output == Path::new("structures") + }))) if ids == vec![id] && output == Path::new("structures") )); Ok(()) } diff --git a/crates/chitin-command/src/parser.rs b/crates/chitin-command/src/parser.rs index 0d4796cf..4d907d63 100644 --- a/crates/chitin-command/src/parser.rs +++ b/crates/chitin-command/src/parser.rs @@ -124,12 +124,14 @@ pub enum CommandParseError { /// # Examples /// /// ``` -/// use chitin_command::{ChitinCommand, DatabaseCommand, parse_command_line}; +/// use chitin_command::{ChitinCommand, DatabaseCommand, PortableCommand, parse_command_line}; /// /// let command = parse_command_line("db rcsb download --id 4hhb --format pdb")?; /// assert!(matches!( /// command, -/// ChitinCommand::Database(DatabaseCommand::DownloadRcsbStructure(_)) +/// ChitinCommand::Portable(PortableCommand::Database( +/// DatabaseCommand::DownloadRcsbStructure(_) +/// )) /// )); /// # Ok::<(), chitin_command::CommandParseError>(()) /// ``` @@ -387,7 +389,8 @@ fn parse_argumentless_command(stable_id: &str, trailing: &[String]) -> Result Result>() == ["4HHB", "1YTH"] + }))) if ids.iter().map(PdbId::as_str).collect::>() == ["4HHB", "1YTH"] && output == Path::new("saved structures") )); Ok(()) @@ -515,11 +521,11 @@ mod tests { assert!(matches!( command, - ChitinCommand::Structure(StructureCommand::Inspect(StructureInspectArguments { + ChitinCommand::Portable(PortableCommand::Structure(StructureCommand::Inspect(StructureInspectArguments { input: StructureInputArguments { input, format: Some(StructureFormat::Mmcif) }, output: CommandOutputFormat::Json, verbose: true, - })) if input == Path::new("models/one.cif") + }))) if input == Path::new("models/one.cif") )); Ok(()) } diff --git a/crates/chitin-desktop/src/builtin_shell.rs b/crates/chitin-desktop/src/builtin_shell.rs index abb3a867..07055baa 100644 --- a/crates/chitin-desktop/src/builtin_shell.rs +++ b/crates/chitin-desktop/src/builtin_shell.rs @@ -6,7 +6,7 @@ use chitin_builtin_shell::{ BuiltinShell, BuiltinShellError, ShellCommandId, ShellCommandTarget, ShellExecutionResult, ShellInvocationSource, ShellSubmission, }; -use chitin_command::{ChitinCommand, CommandEventSink, CommandExecutionContext, DatabaseCommand}; +use chitin_command::{ChitinCommand, CommandEventSink, CommandExecutionContext, DatabaseCommand, PortableCommand}; use chitin_command_runtime::{CommandExecutionError, CommandExecutor, resolve_rcsb_download_paths}; use gpui::{AppContext, AsyncApp, Context, WeakEntity, Window}; use tokio::sync::oneshot; @@ -214,7 +214,7 @@ pub enum DesktopShellHostError { /// Resolves resources protected while a portable command is active. fn submission_targets(submission: &ShellSubmission) -> Result, CommandExecutionError> { match submission.command() { - ChitinCommand::Database(DatabaseCommand::DownloadRcsbStructure(arguments)) => { + ChitinCommand::Portable(PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure(arguments))) => { resolve_rcsb_download_paths(arguments, submission.context()) .map(|paths| paths.into_iter().map(TaskTarget::File).collect()) } @@ -299,7 +299,13 @@ impl ChitinApp { match submission.target() { ShellCommandTarget::Frontend => { let command_id = submission.id(); - self.dispatch_command_with_window(submission.command().clone(), window, cx); + let ChitinCommand::Frontend(command) = submission.command() else { + return Err(DesktopShellHostError::WrongTarget { + id: command_id, + target: submission.target(), + }); + }; + self.dispatch_command_with_window(command.clone(), window, cx); self.builtin_shell.complete_frontend(command_id)?; Ok(DesktopShellDispatch::Frontend { command_id }) } diff --git a/crates/chitin-desktop/src/components/command_panel.rs b/crates/chitin-desktop/src/components/command_panel.rs index d7219371..0a584280 100644 --- a/crates/chitin-desktop/src/components/command_panel.rs +++ b/crates/chitin-desktop/src/components/command_panel.rs @@ -9,7 +9,7 @@ pub(crate) use controller::CommandPanelController; use controller::{CommandPanelEvent, CommandPanelMode}; use form::rcsb::{RcsbDownloadState, RcsbFormPanel}; -use chitin_command::{CommandExecutionContext, CommandId, DatabaseCommand, RcsbDownloadArguments}; +use chitin_command::{CommandExecutionContext, CommandId, RcsbDownloadArguments}; use chitin_databases::providers::rcsb::PdbId; use chitin_ui::{ composite::{ @@ -215,13 +215,12 @@ impl ChitinApp { } }; let format = form.selected_format(cx); - self.dispatch_command_with_window( - DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + self.execute_rcsb_download( + RcsbDownloadArguments { ids, format, output: None, - }) - .into(), + }, window, cx, ); @@ -370,7 +369,7 @@ impl ChitinApp { return; } - let Some(command) = id.command_without_arguments() else { + let Some(command) = id.frontend_command_without_arguments() else { return; }; self.command_panel.close(window, cx); diff --git a/crates/chitin-desktop/src/keybindings/dispatch.rs b/crates/chitin-desktop/src/keybindings/dispatch.rs index bc910237..798ff754 100644 --- a/crates/chitin-desktop/src/keybindings/dispatch.rs +++ b/crates/chitin-desktop/src/keybindings/dispatch.rs @@ -1,8 +1,6 @@ //! Desktop execution adapters for shared commands. -use chitin_command::{ - ApplicationCommand, ChitinCommand, DatabaseCommand, PanelTabCommand, StructureCommand, WorkspaceCommand, -}; +use chitin_command::{ApplicationCommand, FrontendCommand, PanelTabCommand, WorkspaceCommand}; use gpui::{Context, Window}; use crate::{app::ChitinApp, components::workspace_tree::WorkspaceTreeNavigation}; @@ -33,15 +31,14 @@ impl ChitinApp { /// the regular context-only dispatch path. pub(crate) fn dispatch_command_with_window( &mut self, - command: ChitinCommand, + command: FrontendCommand, window: &mut Window, cx: &mut Context, ) { match command { - ChitinCommand::Workspace(WorkspaceCommand::ActivateFocused) => { + FrontendCommand::Workspace(WorkspaceCommand::ActivateFocused) => { self.activate_focused_project_tree_entry_with_window(window, cx); } - ChitinCommand::Database(command) => self.dispatch_database_command(command, window, cx), command => self.dispatch_command(command, cx), } } @@ -56,24 +53,15 @@ impl ChitinApp { /// # Returns /// /// This function returns `()` after routing the command to its feature handler. - pub(crate) fn dispatch_command(&mut self, command: ChitinCommand, cx: &mut Context) { + pub(crate) fn dispatch_command(&mut self, command: FrontendCommand, cx: &mut Context) { log::debug!("Dispatch command {}", command.id()); match command { - ChitinCommand::Workspace(command) => self.dispatch_workspace_command(command, cx), - ChitinCommand::Database(command) => { - log::warn!("Command {} requires a window execution context", command.id()); - } - ChitinCommand::Application(command) => self.dispatch_application_command(command, cx), - ChitinCommand::Structure(command) => self.dispatch_structure_command(command), + FrontendCommand::Workspace(command) => self.dispatch_workspace_command(command, cx), + FrontendCommand::Application(command) => self.dispatch_application_command(command, cx), } } - /// Reports structure commands that are currently CLI-only. - pub(crate) fn dispatch_structure_command(&mut self, command: StructureCommand) { - log::debug!("Structure command {} is not a desktop action yet", command.id()); - } - /// Executes an application command. /// /// # Parameters @@ -91,28 +79,6 @@ impl ChitinApp { } } - /// Executes a database command. - /// - /// # Parameters - /// - /// * `command` identifies the database workflow to start. - /// * `window` identifies the window that receives completed documents. - /// * `cx` submits the background task and updates desktop state. - /// - /// # Returns - /// - /// This function returns `()` after routing the executable database command. - pub(crate) fn dispatch_database_command( - &mut self, - command: DatabaseCommand, - window: &mut Window, - cx: &mut Context, - ) { - match command { - DatabaseCommand::DownloadRcsbStructure(arguments) => self.execute_rcsb_download(arguments, window, cx), - } - } - /// Executes a document panel-tab command. /// /// # Parameters diff --git a/crates/chitin-desktop/src/tasks/rcsb.rs b/crates/chitin-desktop/src/tasks/rcsb.rs index d766d638..9bc7e019 100644 --- a/crates/chitin-desktop/src/tasks/rcsb.rs +++ b/crates/chitin-desktop/src/tasks/rcsb.rs @@ -2,7 +2,7 @@ use super::{BackgroundTaskCenter, TaskCenterError, TaskContext, TaskFailure, TaskHandle, TaskKind, TaskTarget}; use chitin_command::{ - ChitinCommand, CommandEventSink, CommandExecutionContext, DatabaseCommand, RcsbDownloadArguments, + CommandEventSink, CommandExecutionContext, DatabaseCommand, PortableCommand, RcsbDownloadArguments, }; use chitin_command_runtime::{CommandExecutionError, CommandExecutor, CommandOutcome, resolve_rcsb_download_paths}; @@ -68,7 +68,7 @@ async fn run_download( ) -> Result<(), TaskFailure> { let event_context = context.clone(); let events = CommandEventSink::new(move |event| event_context.report_command_event(event)); - let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure(arguments)); + let command = PortableCommand::from(DatabaseCommand::DownloadRcsbStructure(arguments)); let execution_context = execution_context.with_cancellation(context.cancellation_token()); let outcome = executor .execute(command, execution_context, events) From 9ddd3578377847ff4f53dbe5f50286881f81c320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 21 Sep 2026 11:31:47 +0800 Subject: [PATCH 11/32] refactor(command): share CLI grammar with built-in shell - add chitin-command-line for shared portable command definitions - replace the hand-written shell parser with the shared Clap grammar - keep CLI-only completions and desktop-only commands frontend-specific - render command help directly in the built-in terminal - prevent display-only help from occupying the shell execution slot - remove the duplicated parser from chitin-command --- Cargo.lock | 12 + Cargo.toml | 1 + crates/chitin-builtin-shell/Cargo.toml | 1 + crates/chitin-builtin-shell/src/lib.rs | 4 +- crates/chitin-builtin-shell/src/session.rs | 95 ++- crates/chitin-cli/Cargo.toml | 1 + crates/chitin-cli/src/cli.rs | 234 +------ crates/chitin-cli/src/error.rs | 7 +- crates/chitin-command-line/Cargo.toml | 15 + crates/chitin-command-line/src/lib.rs | 13 + crates/chitin-command-line/src/portable.rs | 220 +++++++ crates/chitin-command-line/src/shell.rs | 243 ++++++++ crates/chitin-command/src/lib.rs | 2 - crates/chitin-command/src/parser.rs | 579 ------------------ crates/chitin-desktop/src/builtin_shell.rs | 30 +- .../src/components/terminal/controller.rs | 10 + .../src/components/terminal/presenter.rs | 5 + 17 files changed, 644 insertions(+), 828 deletions(-) create mode 100644 crates/chitin-command-line/Cargo.toml create mode 100644 crates/chitin-command-line/src/lib.rs create mode 100644 crates/chitin-command-line/src/portable.rs create mode 100644 crates/chitin-command-line/src/shell.rs delete mode 100644 crates/chitin-command/src/parser.rs diff --git a/Cargo.lock b/Cargo.lock index 0a0542b8..19a9889e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -688,6 +688,7 @@ version = "0.1.0" dependencies = [ "chitin-bio", "chitin-command", + "chitin-command-line", "chitin-command-runtime", "chitin-databases", "clap", @@ -730,6 +731,7 @@ name = "chitin-builtin-shell" version = "0.1.0" dependencies = [ "chitin-command", + "chitin-command-line", "chitin-command-runtime", "chitin-databases", "thiserror 2.0.20", @@ -744,6 +746,16 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "chitin-command-line" +version = "0.1.0" +dependencies = [ + "chitin-command", + "chitin-databases", + "clap", + "thiserror 2.0.20", +] + [[package]] name = "chitin-command-runtime" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2dea4c13..f4168b39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/chitin-builtin-shell", "crates/chitin-command", + "crates/chitin-command-line", "crates/chitin-command-runtime", "crates/chitin-cli", "crates/chitin-bio", diff --git a/crates/chitin-builtin-shell/Cargo.toml b/crates/chitin-builtin-shell/Cargo.toml index c40ec870..957fb333 100644 --- a/crates/chitin-builtin-shell/Cargo.toml +++ b/crates/chitin-builtin-shell/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true [dependencies] chitin-command = { path = "../chitin-command" } +chitin-command-line = { path = "../chitin-command-line" } chitin-command-runtime = { path = "../chitin-command-runtime" } chitin-databases = { path = "../chitin-databases" } thiserror = "2.0" diff --git a/crates/chitin-builtin-shell/src/lib.rs b/crates/chitin-builtin-shell/src/lib.rs index 9c6f31f2..716af7d5 100644 --- a/crates/chitin-builtin-shell/src/lib.rs +++ b/crates/chitin-builtin-shell/src/lib.rs @@ -12,6 +12,6 @@ mod session; pub use event::{ShellEvent, ShellEventSink}; pub use session::{ BuiltinShell, BuiltinShellError, BuiltinShellSnapshot, ShellActiveCommand, ShellCommandId, ShellCommandTarget, - ShellExecutionRecord, ShellExecutionResult, ShellExecutionStatus, ShellInvocationSource, ShellSubmission, - ShellTranscriptContent, ShellTranscriptEntry, + ShellExecutionRecord, ShellExecutionResult, ShellExecutionStatus, ShellInvocationSource, ShellLineSubmission, + ShellSubmission, ShellTranscriptContent, ShellTranscriptEntry, }; diff --git a/crates/chitin-builtin-shell/src/session.rs b/crates/chitin-builtin-shell/src/session.rs index 22f041be..a621dbc3 100644 --- a/crates/chitin-builtin-shell/src/session.rs +++ b/crates/chitin-builtin-shell/src/session.rs @@ -7,8 +7,9 @@ use std::{ use chitin_command::{ ChitinCommand, CommandEventSink, CommandExecutionContext, CommandExecutionDomain, CommandExecutionEvent, CommandId, - CommandMessage, CommandProgress, parse_command_line, + CommandMessage, CommandProgress, }; +use chitin_command_line::{BuiltinCommandLine, parse_builtin_command_line}; use chitin_command_runtime::{CommandExecutionError, CommandExecutor, CommandOutcome}; use chitin_databases::{CancellationToken, PersistedArtifact}; @@ -150,6 +151,25 @@ pub struct ShellSubmission { context: CommandExecutionContext, } +/// Result of evaluating one textual built-in shell line. +#[derive(Clone, Debug)] +pub enum ShellLineSubmission { + /// A typed command was reserved for execution. + Command(ShellSubmission), + /// Help text should be displayed without invoking an executor. + Display(String), +} + +impl ShellLineSubmission { + /// Extracts an executable submission from a command-producing line. + pub fn into_command(self) -> Result { + match self { + Self::Command(submission) => Ok(submission), + Self::Display(output) => Err(BuiltinShellError::DisplayOnly { output }), + } + } +} + impl ShellSubmission { /// Returns the shell-local submission identity. pub const fn id(&self) -> ShellCommandId { @@ -191,7 +211,13 @@ pub struct ShellExecutionResult { pub enum BuiltinShellError { /// The submitted command line is syntactically or semantically invalid. #[error(transparent)] - Parse(#[from] chitin_command::CommandParseError), + Parse(#[from] chitin_command_line::CommandLineParseError), + /// A caller requiring execution received display-only help text. + #[error("shell line produced display output instead of an executable command")] + DisplayOnly { + /// Help text produced by the command-line grammar. + output: String, + }, /// Another foreground command is still active. #[error("shell command {} is still active", active_id.get())] Busy { @@ -317,7 +343,7 @@ impl BuiltinShell { /// /// Returns [`BuiltinShellError::Busy`] while another command is active, or a /// parse error when the command line is invalid. - pub fn submit(&self, input: impl Into) -> Result { + pub fn submit(&self, input: impl Into) -> Result { self.submit_line(input, ShellInvocationSource::Interactive) } @@ -335,16 +361,24 @@ impl BuiltinShell { &self, input: impl Into, source: ShellInvocationSource, - ) -> Result { + ) -> Result { let input = input.into(); - let command = match parse_command_line(&input) { - Ok(command) => command, + let parsed = match parse_builtin_command_line(&input) { + Ok(parsed) => parsed, Err(error) => { - self.remember_rejected_input(input.clone())?; + self.remember_unsubmitted_input(input.clone())?; return Err(error.into()); } }; - self.submit_typed(input, command, source) + match parsed { + BuiltinCommandLine::Command(command) => self + .submit_typed(input, command, source) + .map(ShellLineSubmission::Command), + BuiltinCommandLine::Display(output) => { + self.remember_unsubmitted_input(input)?; + Ok(ShellLineSubmission::Display(output)) + } + } } /// Submits an already typed command from an agent or application subsystem. @@ -671,7 +705,7 @@ impl BuiltinShell { } /// Retains a rejected interactive line for conventional shell history. - fn remember_rejected_input(&self, input: String) -> Result<(), BuiltinShellError> { + fn remember_unsubmitted_input(&self, input: String) -> Result<(), BuiltinShellError> { let mut state = self.lock_state()?; state.history.push(input); state.history_cursor = None; @@ -708,9 +742,9 @@ mod tests { #[test] fn submit_should_route_portable_and_frontend_commands() -> Result<(), BuiltinShellError> { let portable_shell = BuiltinShell::new("."); - let portable = portable_shell.submit("structure validate model.pdb")?; + let portable = portable_shell.submit("structure validate model.pdb")?.into_command()?; let frontend_shell = BuiltinShell::new("."); - let frontend = frontend_shell.submit("tab.close")?; + let frontend = frontend_shell.submit("tab.close")?.into_command()?; assert_eq!(portable.target(), ShellCommandTarget::Portable); assert_eq!(frontend.target(), ShellCommandTarget::Frontend); @@ -720,7 +754,7 @@ mod tests { #[test] fn submit_should_reject_a_second_foreground_command() -> Result<(), BuiltinShellError> { let shell = BuiltinShell::new("."); - let first = shell.submit("tab.close")?; + let first = shell.submit("tab.close")?.into_command()?; let result = shell.submit("tab.focus_next"); @@ -731,9 +765,9 @@ mod tests { #[test] fn history_navigation_should_restore_the_current_draft() -> Result<(), BuiltinShellError> { let shell = BuiltinShell::new("."); - let first = shell.submit("tab.close")?; + let first = shell.submit("tab.close")?.into_command()?; shell.complete_frontend(first.id())?; - let second = shell.submit("workspace.toggle_workspace")?; + let second = shell.submit("workspace.toggle_workspace")?.into_command()?; shell.complete_frontend(second.id())?; let newest = shell.previous_history("structure inspect draft.pdb")?; @@ -761,10 +795,30 @@ mod tests { Ok(()) } + #[test] + fn help_line_should_return_display_output() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + + let result = shell.submit("db")?; + + assert!(matches!(result, ShellLineSubmission::Display(help) if help.contains("Usage: chitin db "))); + Ok(()) + } + + #[test] + fn help_line_should_not_reserve_the_execution_slot() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + + let _ = shell.submit("db")?; + + assert!(shell.snapshot()?.active.is_none()); + Ok(()) + } + #[test] fn cancel_should_update_the_active_command_and_token() -> Result<(), BuiltinShellError> { let shell = BuiltinShell::new("."); - let submission = shell.submit("structure validate model.pdb")?; + let submission = shell.submit("structure validate model.pdb")?.into_command()?; let cancelled = shell.cancel_active()?; let snapshot = shell.snapshot()?; @@ -793,7 +847,7 @@ mod tests { shell.set_standard_input(Some(Arc::from( b"ATOM 1 CA GLY A 1 1.000 2.000 3.000 1.00 20.00 C \nEND\n".as_slice(), )))?; - let submission = shell.submit("structure validate - --format pdb")?; + let submission = shell.submit("structure validate - --format pdb")?.into_command()?; let executor = CommandExecutor::new(ClientConfig::default()); let result = shell.execute_portable(submission, &executor).await?; @@ -821,7 +875,7 @@ mod tests { #[test] fn frontend_completion_should_append_a_terminal_marker() -> Result<(), BuiltinShellError> { let shell = BuiltinShell::new("."); - let submission = shell.submit("tab.close")?; + let submission = shell.submit("tab.close")?.into_command()?; shell.complete_frontend(submission.id())?; let snapshot = shell.snapshot()?; @@ -840,7 +894,12 @@ mod tests { #[test] fn typed_agent_submission_should_preserve_source_and_arguments() -> Result<(), BuiltinShellError> { let shell = BuiltinShell::new("."); - let parsed = parse_command_line("structure validate model.pdb --format pdb")?; + let parsed = parse_builtin_command_line("structure validate model.pdb --format pdb")?; + let BuiltinCommandLine::Command(parsed) = parsed else { + return Err(BuiltinShellError::DisplayOnly { + output: "expected an executable structure command".to_owned(), + }); + }; let submission = shell.submit_typed( "agent validation request", diff --git a/crates/chitin-cli/Cargo.toml b/crates/chitin-cli/Cargo.toml index e1d57c02..2c8d705e 100644 --- a/crates/chitin-cli/Cargo.toml +++ b/crates/chitin-cli/Cargo.toml @@ -25,6 +25,7 @@ path = "src/main.rs" [dependencies] chitin-command = { version = "0.1.0", path = "../chitin-command" } +chitin-command-line = { version = "0.1.0", path = "../chitin-command-line" } chitin-command-runtime = { version = "0.1.0", path = "../chitin-command-runtime" } chitin-bio = { version = "0.1.0", path = "../chitin-bio" } chitin-databases = { version = "0.1.0", path = "../chitin-databases" } diff --git a/crates/chitin-cli/src/cli.rs b/crates/chitin-cli/src/cli.rs index 3153b030..080140c6 100644 --- a/crates/chitin-cli/src/cli.rs +++ b/crates/chitin-cli/src/cli.rs @@ -1,17 +1,14 @@ -//! Command-line schema and command-bus routing. +//! Process CLI schema and command-bus routing. use std::{io::Read, path::PathBuf}; use chitin_command::{ - CommandExecutionContext, CommandOutputFormat, DatabaseCommand, PortableCommand, RcsbDownloadArguments, - StructureCommand, StructureInputArguments, StructureInspectArguments, StructureValidateArguments, + CommandExecutionContext, DatabaseCommand, PortableCommand, RcsbDownloadArguments, StructureCommand, }; +use chitin_command_line::PortableCommandArgs; use chitin_command_runtime::CommandExecutor; -use chitin_databases::{ - ClientConfig, - providers::rcsb::{PdbId, StructureFormat}, -}; -use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use chitin_databases::ClientConfig; +use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{Shell, generate}; use crate::{download::terminal_event_sink, error::CliError}; @@ -20,161 +17,35 @@ use crate::{download::terminal_event_sink, error::CliError}; #[derive(Debug, Parser)] #[command(name = "chitin", version, about = "Chitin structural biology tools")] pub(crate) struct Cli { - /// Selected top-level workflow. + /// Selected command-line workflow. #[command(subcommand)] pub(crate) command: CliCommand, } -/// Top-level CLI workflows. +/// CLI-specific actions composed with the shared portable command tree. #[derive(Debug, Subcommand)] pub(crate) enum CliCommand { - /// Work with external biological databases. - #[command(name = "db", visible_alias = "databases")] - Database(DatabaseCommandArgs), - /// Inspect or validate a local PDB/mmCIF structure file. - Structure(StructureCommandArgs), + /// Execute a portable Chitin workflow. + #[command(flatten)] + Portable(PortableCommandArgs), /// Generate shell completion scripts. Completions { shell: Shell }, } -#[derive(Debug, Args)] -pub(crate) struct StructureCommandArgs { - #[command(subcommand)] - command: StructureSubcommand, -} - -#[derive(Debug, Subcommand)] -enum StructureSubcommand { - /// Print a human-readable or JSON structure summary. - Inspect(StructureInspectArgs), - /// Parse a structure and verify its indexed model invariants. - Validate(StructureValidateArgs), -} - -#[derive(Debug, Args)] -struct StructureInspectArgs { - #[command(flatten)] - input: StructureInputArgs, - /// Output representation. - #[arg(long, value_enum, default_value_t = OutputArg::Text)] - output: OutputArg, - /// Include chains, metadata, assembly, and diagnostics. - #[arg(long)] - verbose: bool, -} - -#[derive(Debug, Args)] -struct StructureValidateArgs { - #[command(flatten)] - input: StructureInputArgs, - /// Output representation. - #[arg(long, value_enum, default_value_t = OutputArg::Text)] - output: OutputArg, -} - -#[derive(Debug, Args)] -pub(crate) struct StructureInputArgs { - /// Structure file path, or `-` to read stdin. - #[arg(value_name = "FILE")] - pub(crate) input: PathBuf, - /// Input format; inferred from the extension when omitted. - #[arg(long, value_enum)] - pub(crate) format: Option, -} - -#[derive(Clone, Copy, Debug, ValueEnum)] -pub(crate) enum OutputArg { - /// Human-readable terminal output. - Text, - /// Stable machine-readable JSON output. - Json, -} - -#[derive(Debug, Args)] -pub(crate) struct DatabaseCommandArgs { - #[command(subcommand)] - command: DatabaseSubcommand, -} - -#[derive(Debug, Subcommand)] -enum DatabaseSubcommand { - /// Download an RCSB structure file. - Rcsb(RcsbCommandArgs), -} - -#[derive(Debug, Args)] -struct RcsbCommandArgs { - #[command(subcommand)] - command: RcsbSubcommand, -} - -#[derive(Debug, Subcommand)] -enum RcsbSubcommand { - /// Download a PDB or mmCIF structure. - Download { - /// Comma-separated list of four-character PDB identifiers, such as 4HHB,1YTH. - #[arg(long, value_name = "PDB_ID")] - id: String, - /// Structure format to download. - #[arg(long, value_enum, default_value_t = FormatArg::Pdb)] - format: FormatArg, - /// Output file or directory. Existing directories receive a generated filename. - #[arg(short, long, value_name = "PATH")] - output: Option, - }, -} - -/// CLI spelling for the two RCSB structure formats. -#[derive(Clone, Copy, Debug, ValueEnum)] -pub(crate) enum FormatArg { - /// Legacy PDB format. - Pdb, - /// PDBx/mmCIF format. - Mmcif, -} - -impl FormatArg { - /// Converts the CLI value into the shared provider format. - pub(crate) fn structure_format(self) -> StructureFormat { - match self { - Self::Pdb => StructureFormat::Pdb, - Self::Mmcif => StructureFormat::Mmcif, - } - } -} - -impl From for CommandOutputFormat { - fn from(output: OutputArg) -> Self { - match output { - OutputArg::Text => Self::Text, - OutputArg::Json => Self::Json, - } - } -} - -impl StructureInputArgs { - /// Converts parsed CLI input into frontend-independent command arguments. - fn into_command_arguments(self) -> StructureInputArguments { - StructureInputArguments { - input: self.input, - format: self.format.map(FormatArg::structure_format), - } - } -} - /// Dispatches a parsed CLI workflow. /// /// # Parameters /// -/// * `command` is the validated top-level CLI command. +/// * `command` is the validated top-level CLI action. /// /// # Returns /// -/// Returns `Ok(())` after the selected workflow completes. +/// Returns `Ok(())` after execution or completion generation finishes. /// /// # Errors /// -/// Returns [`CliError`] when command execution or output generation fails. +/// Returns [`CliError`] when typed conversion, execution, or output rendering +/// fails. pub(crate) async fn dispatch(command: CliCommand) -> Result<(), CliError> { match command { CliCommand::Completions { shell } => { @@ -182,85 +53,24 @@ pub(crate) async fn dispatch(command: CliCommand) -> Result<(), CliError> { generate(shell, &mut command, "chitin", &mut std::io::stdout()); Ok(()) } - CliCommand::Database(database) => dispatch_database_command(database.command).await, - CliCommand::Structure(structure) => dispatch_structure_command(structure.command).await, + CliCommand::Portable(arguments) => dispatch_command(arguments.into_command()?).await, } } -/// Dispatches a structure command through the shared command bus. -async fn dispatch_structure_command(command: StructureSubcommand) -> Result<(), CliError> { - let command = match command { - StructureSubcommand::Inspect(args) => StructureCommand::Inspect(StructureInspectArguments { - input: args.input.into_command_arguments(), - output: args.output.into(), - verbose: args.verbose, - }), - StructureSubcommand::Validate(args) => StructureCommand::Validate(StructureValidateArguments { - input: args.input.into_command_arguments(), - output: args.output.into(), - }), - }; - dispatch_command(command.into()).await -} - -/// Dispatches a database command to its provider-specific workflow. +/// Routes one CLI request through the shared portable command executor. /// /// # Parameters /// -/// * `command` is the parsed database subcommand. +/// * `command` contains the validated portable command and arguments. /// /// # Returns /// -/// Returns `Ok(())` after the database workflow completes. +/// Returns `Ok(())` after execution and frontend-specific rendering complete. /// /// # Errors /// -/// Returns [`CliError`] when the selected workflow fails. -async fn dispatch_database_command(command: DatabaseSubcommand) -> Result<(), CliError> { - match command { - DatabaseSubcommand::Rcsb(command) => dispatch_rcsb_command(command.command).await, - } -} - -/// Dispatches an RCSB subcommand to the shared command bus. -/// -/// # Parameters -/// -/// * `command` is the parsed RCSB subcommand and its download options. -/// -/// # Returns -/// -/// Returns `Ok(())` after the RCSB workflow completes. -/// -/// # Errors -/// -/// Returns [`CliError`] when the RCSB download or output resolution fails. -async fn dispatch_rcsb_command(command: RcsbSubcommand) -> Result<(), CliError> { - match command { - RcsbSubcommand::Download { id, format, output } => { - let command = DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { - ids: PdbId::parse_many(&id)?, - format: format.structure_format(), - output, - }); - dispatch_command(command.into()).await - } - } -} - -/// Routes the CLI request through the shared typed command bus. -/// -/// # Parameters -/// -/// * `command` identifies the typed command to dispatch. -/// # Returns -/// -/// Returns `Ok(())` after the command completes successfully. -/// -/// # Errors -/// -/// Returns [`CliError`] when the command is unsupported or the RCSB download -/// fails. +/// Returns [`CliError`] when process context, command execution, or output +/// rendering fails. async fn dispatch_command(command: PortableCommand) -> Result<(), CliError> { let context = execution_context(&command)?; let executor = CommandExecutor::new(ClientConfig::default()); @@ -293,7 +103,7 @@ fn execution_context(command: &PortableCommand) -> Result bool { matches!( command, @@ -309,7 +119,7 @@ fn requires_standard_input(command: &PortableCommand) -> bool { match command { PortableCommand::Structure(StructureCommand::Inspect(arguments)) => arguments.input.input.as_os_str() == "-", PortableCommand::Structure(StructureCommand::Validate(arguments)) => arguments.input.input.as_os_str() == "-", - _ => false, + PortableCommand::Database(_) => false, } } diff --git a/crates/chitin-cli/src/error.rs b/crates/chitin-cli/src/error.rs index c692908b..02c445a2 100644 --- a/crates/chitin-cli/src/error.rs +++ b/crates/chitin-cli/src/error.rs @@ -1,14 +1,13 @@ //! Errors produced while parsing and executing CLI workflows. -use chitin_databases::providers::rcsb::PdbIdListError; use std::path::PathBuf; /// Error returned by the Chitin CLI command handlers. #[derive(Debug, thiserror::Error)] pub(crate) enum CliError { - /// A comma-separated identifier list contains an invalid element. - #[error("{0}")] - InvalidPdbIdList(#[from] PdbIdListError), + /// Shared command-line arguments could not be converted into a typed command. + #[error(transparent)] + CommandLine(#[from] chitin_command_line::PortableCommandLineError), /// A portable typed command could not be executed. #[error(transparent)] CommandExecution(#[from] chitin_command_runtime::CommandExecutionError), diff --git a/crates/chitin-command-line/Cargo.toml b/crates/chitin-command-line/Cargo.toml new file mode 100644 index 00000000..797cf49e --- /dev/null +++ b/crates/chitin-command-line/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "chitin-command-line" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +chitin-command = { path = "../chitin-command" } +chitin-databases = { path = "../chitin-databases" } +clap = { version = "4.6", features = ["derive"] } +thiserror = "2.0" + +[lints] +workspace = true diff --git a/crates/chitin-command-line/src/lib.rs b/crates/chitin-command-line/src/lib.rs new file mode 100644 index 00000000..1e0592b3 --- /dev/null +++ b/crates/chitin-command-line/src/lib.rs @@ -0,0 +1,13 @@ +#![forbid(unsafe_code)] +//! Shared command-line grammar for Chitin's textual frontends. +//! +//! This crate owns the portable `db` and `structure` command fragments used by +//! both the process CLI and the built-in shell. Each frontend composes these +//! fragments into its own root grammar, so frontend-specific commands do not +//! need to appear in every textual interface. + +mod portable; +mod shell; + +pub use portable::{PortableCommandArgs, PortableCommandLineError}; +pub use shell::{BuiltinCommandLine, CommandLineParseError, parse_builtin_command_line}; diff --git a/crates/chitin-command-line/src/portable.rs b/crates/chitin-command-line/src/portable.rs new file mode 100644 index 00000000..e59ab634 --- /dev/null +++ b/crates/chitin-command-line/src/portable.rs @@ -0,0 +1,220 @@ +//! Portable command fragments shared by CLI-style frontends. + +use std::path::PathBuf; + +use chitin_command::{ + CommandOutputFormat, DatabaseCommand, PortableCommand, RcsbDownloadArguments, StructureCommand, + StructureInputArguments, StructureInspectArguments, StructureValidateArguments, +}; +use chitin_databases::providers::rcsb::{PdbId, PdbIdListError, StructureFormat}; +use clap::{Args, Subcommand, ValueEnum}; + +/// Portable command tree embedded by the process CLI and built-in shell. +#[derive(Debug, Subcommand)] +pub enum PortableCommandArgs { + /// Work with external biological databases. + #[command(name = "db", visible_alias = "databases")] + Database(DatabaseCommandArgs), + /// Inspect or validate a local PDB/mmCIF structure file. + Structure(StructureCommandArgs), +} + +impl PortableCommandArgs { + /// Converts validated command-line arguments into a portable typed command. + pub fn into_command(self) -> Result { + match self { + Self::Database(arguments) => arguments.into_command(), + Self::Structure(arguments) => Ok(arguments.into_command()), + } + } +} + +/// Command-line conversion failure after Clap has validated the grammar. +#[derive(Debug, thiserror::Error)] +pub enum PortableCommandLineError { + /// A comma-separated identifier list contains an invalid element. + #[error(transparent)] + InvalidPdbIds(#[from] PdbIdListError), +} + +/// Database-provider command group. +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct DatabaseCommandArgs { + #[command(subcommand)] + command: DatabaseSubcommand, +} + +impl DatabaseCommandArgs { + /// Converts the selected database workflow into a portable command. + fn into_command(self) -> Result { + match self.command { + DatabaseSubcommand::Rcsb(arguments) => arguments.into_command(), + } + } +} + +/// Supported database providers. +#[derive(Debug, Subcommand)] +enum DatabaseSubcommand { + /// Download an RCSB structure file. + Rcsb(RcsbCommandArgs), +} + +/// RCSB command group. +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +struct RcsbCommandArgs { + #[command(subcommand)] + command: RcsbSubcommand, +} + +impl RcsbCommandArgs { + /// Converts the selected RCSB workflow into a portable command. + fn into_command(self) -> Result { + match self.command { + RcsbSubcommand::Download { id, format, output } => Ok( + DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ids: PdbId::parse_many(&id)?, + format: format.into(), + output, + }) + .into(), + ), + } + } +} + +/// RCSB provider workflows. +#[derive(Debug, Subcommand)] +enum RcsbSubcommand { + /// Download a PDB or mmCIF structure. + Download { + /// Comma-separated list of four-character PDB identifiers, such as 4HHB,1YTH. + #[arg(long, value_name = "PDB_ID")] + id: String, + /// Structure format to download. + #[arg(long, value_enum, default_value_t = StructureFormatArg::Pdb)] + format: StructureFormatArg, + /// Output file or directory. Existing directories receive a generated filename. + #[arg(short, long, value_name = "PATH")] + output: Option, + }, +} + +/// Structure command group. +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct StructureCommandArgs { + #[command(subcommand)] + command: StructureSubcommand, +} + +impl StructureCommandArgs { + /// Converts the selected structure workflow into a portable command. + fn into_command(self) -> PortableCommand { + match self.command { + StructureSubcommand::Inspect(arguments) => StructureCommand::Inspect(StructureInspectArguments { + input: arguments.input.into_command_arguments(), + output: arguments.output.into(), + verbose: arguments.verbose, + }) + .into(), + StructureSubcommand::Validate(arguments) => StructureCommand::Validate(StructureValidateArguments { + input: arguments.input.into_command_arguments(), + output: arguments.output.into(), + }) + .into(), + } + } +} + +/// Structure parsing workflows. +#[derive(Debug, Subcommand)] +enum StructureSubcommand { + /// Print a human-readable or JSON structure summary. + Inspect(StructureInspectArgs), + /// Parse a structure and verify its indexed model invariants. + Validate(StructureValidateArgs), +} + +/// Structure inspection arguments. +#[derive(Debug, Args)] +struct StructureInspectArgs { + #[command(flatten)] + input: StructureInputArgs, + /// Output representation. + #[arg(long, value_enum, default_value_t = OutputFormatArg::Text)] + output: OutputFormatArg, + /// Include chains, metadata, assembly, and diagnostics. + #[arg(long)] + verbose: bool, +} + +/// Structure validation arguments. +#[derive(Debug, Args)] +struct StructureValidateArgs { + #[command(flatten)] + input: StructureInputArgs, + /// Output representation. + #[arg(long, value_enum, default_value_t = OutputFormatArg::Text)] + output: OutputFormatArg, +} + +/// Input path and optional format shared by structure workflows. +#[derive(Debug, Args)] +struct StructureInputArgs { + /// Structure file path, or `-` to read stdin. + #[arg(value_name = "FILE")] + input: PathBuf, + /// Input format; inferred from the extension when omitted. + #[arg(long, value_enum)] + format: Option, +} + +impl StructureInputArgs { + /// Converts parsed input options into frontend-independent arguments. + fn into_command_arguments(self) -> StructureInputArguments { + StructureInputArguments { + input: self.input, + format: self.format.map(StructureFormat::from), + } + } +} + +/// Command-line spelling for supported structure formats. +#[derive(Clone, Copy, Debug, ValueEnum)] +enum StructureFormatArg { + /// Legacy PDB format. + Pdb, + /// PDBx/mmCIF format. + #[value(alias = "cif")] + Mmcif, +} + +impl From for StructureFormat { + fn from(format: StructureFormatArg) -> Self { + match format { + StructureFormatArg::Pdb => Self::Pdb, + StructureFormatArg::Mmcif => Self::Mmcif, + } + } +} + +/// Command-line spelling for structure command output formats. +#[derive(Clone, Copy, Debug, ValueEnum)] +enum OutputFormatArg { + /// Human-readable output. + Text, + /// Stable machine-readable JSON output. + Json, +} + +impl From for CommandOutputFormat { + fn from(output: OutputFormatArg) -> Self { + match output { + OutputFormatArg::Text => Self::Text, + OutputFormatArg::Json => Self::Json, + } + } +} diff --git a/crates/chitin-command-line/src/shell.rs b/crates/chitin-command-line/src/shell.rs new file mode 100644 index 00000000..3b2a9042 --- /dev/null +++ b/crates/chitin-command-line/src/shell.rs @@ -0,0 +1,243 @@ +//! Built-in shell parsing around the shared portable command fragments. + +use std::ffi::OsString; + +use chitin_command::{ChitinCommand, CommandId}; +use clap::{ColorChoice, Parser, error::ErrorKind}; + +use crate::{PortableCommandArgs, PortableCommandLineError}; + +/// Result of parsing one built-in shell line. +#[derive(Debug)] +pub enum BuiltinCommandLine { + /// A typed command ready for execution routing. + Command(ChitinCommand), + /// Help or version text that should be displayed without execution. + Display(String), +} + +/// Failure while tokenizing or parsing built-in shell text. +#[derive(Debug, thiserror::Error)] +pub enum CommandLineParseError { + /// No command was present after whitespace was removed. + #[error("command line is empty")] + Empty, + /// A quoted token reached the end of the input without a closing quote. + #[error("unterminated {quote} quote starting at byte {position}")] + UnterminatedQuote { + /// Quote character that was not closed. + quote: char, + /// Byte offset of the opening quote. + position: usize, + }, + /// A trailing escape character had no following character. + #[error("trailing escape character at byte {position}")] + TrailingEscape { + /// Byte offset of the escape character. + position: usize, + }, + /// Clap rejected the portable command grammar. + #[error("{0}")] + Grammar(String), + /// A shared command-line value failed domain validation. + #[error(transparent)] + Portable(#[from] PortableCommandLineError), + /// A stable frontend command identifier was not registered. + #[error("unknown command '{0}'")] + UnknownCommand(String), + /// A frontend command without arguments received trailing values. + #[error("unexpected argument '{value}' for '{command}'")] + UnexpectedArgument { + /// Stable command identifier receiving the extra value. + command: &'static str, + /// Unexpected argument value. + value: String, + }, +} + +/// Parses one built-in shell line using shared portable command grammar. +/// +/// # Parameters +/// +/// * `input` is one complete line without a trailing newline. +/// +/// # Returns +/// +/// A typed command or display-only help text. +/// +/// # Errors +/// +/// Returns [`CommandLineParseError`] when tokenization, Clap validation, or +/// domain conversion fails. +pub fn parse_builtin_command_line(input: &str) -> Result { + let tokens = tokenize(input)?; + let Some(command) = tokens.first().map(String::as_str) else { + return Err(CommandLineParseError::Empty); + }; + + if matches!(command, "db" | "databases" | "structure" | "help") || command.starts_with('-') { + return parse_portable(tokens); + } + parse_frontend_identifier(command, &tokens[1..]).map(BuiltinCommandLine::Command) +} + +#[derive(Debug, Parser)] +#[command( + name = "chitin", + about = "Chitin structural biology tools", + color = ColorChoice::Never, + arg_required_else_help = true +)] +struct BuiltinPortableRoot { + #[command(subcommand)] + command: PortableCommandArgs, +} + +/// Parses tokenized portable syntax through Clap without terminating the host. +fn parse_portable(tokens: Vec) -> Result { + let arguments = std::iter::once(OsString::from("chitin")).chain(tokens.into_iter().map(OsString::from)); + match BuiltinPortableRoot::try_parse_from(arguments) { + Ok(parsed) => parsed + .command + .into_command() + .map(ChitinCommand::from) + .map(BuiltinCommandLine::Command) + .map_err(CommandLineParseError::from), + Err(error) + if matches!( + error.kind(), + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + ) => + { + Ok(BuiltinCommandLine::Display(error.to_string())) + } + Err(error) => Err(CommandLineParseError::Grammar(normalize_clap_error(error.to_string()))), + } +} + +/// Resolves a stable identifier for a frontend command without arguments. +fn parse_frontend_identifier(command: &str, trailing: &[String]) -> Result { + let id = match command { + "workspace.focus_previous_entry" => CommandId::WorkspaceFocusPrevious, + "workspace.focus_next_entry" => CommandId::WorkspaceFocusNext, + "workspace.activate_focused_entry" => CommandId::WorkspaceActivateFocused, + "workspace.focus_first_entry" => CommandId::WorkspaceFocusFirst, + "workspace.focus_last_entry" => CommandId::WorkspaceFocusLast, + "workspace.toggle_workspace" => CommandId::WorkspaceToggle, + "tab.focus_previous" => CommandId::PanelTabFocusPrevious, + "tab.focus_next" => CommandId::PanelTabFocusNext, + "tab.close" => CommandId::PanelTabClose, + "application.toggle_command_panel" => CommandId::ApplicationToggleCommandPanel, + "application.toggle_terminal" => CommandId::ApplicationToggleTerminal, + _ => return Err(CommandLineParseError::UnknownCommand(command.to_owned())), + }; + if let Some(value) = trailing.first() { + return Err(CommandLineParseError::UnexpectedArgument { + command: id.as_str(), + value: value.clone(), + }); + } + id.frontend_command_without_arguments() + .map(ChitinCommand::from) + .ok_or_else(|| CommandLineParseError::UnknownCommand(command.to_owned())) +} + +/// Splits command text while preserving quoted whitespace and escapes. +fn tokenize(input: &str) -> Result, CommandLineParseError> { + let mut tokens = Vec::new(); + let mut token = String::new(); + let mut token_started = false; + let mut quote = None; + let mut characters = input.char_indices().peekable(); + + while let Some((position, character)) = characters.next() { + match quote { + Some((active, _)) if character == active => quote = None, + Some(('\'', _)) => { + token.push(character); + token_started = true; + } + Some(_) | None if character == '\\' => { + let Some((_, escaped)) = characters.next() else { + return Err(CommandLineParseError::TrailingEscape { position }); + }; + token.push(escaped); + token_started = true; + } + None if character == '\'' || character == '"' => { + quote = Some((character, position)); + token_started = true; + } + None if character.is_whitespace() => { + if token_started { + tokens.push(std::mem::take(&mut token)); + token_started = false; + } + } + Some(_) | None => { + token.push(character); + token_started = true; + } + } + } + + if let Some((quote, position)) = quote { + return Err(CommandLineParseError::UnterminatedQuote { quote, position }); + } + if token_started { + tokens.push(token); + } + Ok(tokens) +} + +/// Removes Clap's own prefix because terminal presenters supply one. +fn normalize_clap_error(message: String) -> String { + message + .trim() + .strip_prefix("error: ") + .unwrap_or(message.trim()) + .to_owned() +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use chitin_command::{DatabaseCommand, PortableCommand, RcsbDownloadArguments}; + use chitin_databases::providers::rcsb::StructureFormat; + + use super::*; + + #[test] + fn database_download_should_use_cli_defaults_and_short_options() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("db rcsb download --id 4hhb -o 'saved structure'")?; + + assert!(matches!( + parsed, + BuiltinCommandLine::Command(ChitinCommand::Portable(PortableCommand::Database( + DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + format: StructureFormat::Pdb, + output: Some(output), + .. + }) + ))) if output == Path::new("saved structure") + )); + Ok(()) + } + + #[test] + fn missing_database_subcommand_should_return_clap_help() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("db")?; + + assert!(matches!(parsed, BuiltinCommandLine::Display(help) if help.contains("Usage: chitin db "))); + Ok(()) + } + + #[test] + fn stable_frontend_identifier_should_still_parse() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("tab.close")?; + + assert!(matches!(parsed, BuiltinCommandLine::Command(command) if command.id() == CommandId::PanelTabClose)); + Ok(()) + } +} diff --git a/crates/chitin-command/src/lib.rs b/crates/chitin-command/src/lib.rs index fd440910..ebb88101 100644 --- a/crates/chitin-command/src/lib.rs +++ b/crates/chitin-command/src/lib.rs @@ -4,7 +4,6 @@ mod application; mod database; mod execution; mod panel_tab; -mod parser; mod structure; mod workspace; @@ -15,7 +14,6 @@ pub use execution::{ CommandProgress, }; pub use panel_tab::PanelTabCommand; -pub use parser::{CommandParseError, parse_command_line}; pub use structure::{ CommandOutputFormat, StructureCommand, StructureInputArguments, StructureInspectArguments, StructureValidateArguments, }; diff --git a/crates/chitin-command/src/parser.rs b/crates/chitin-command/src/parser.rs deleted file mode 100644 index 4d907d63..00000000 --- a/crates/chitin-command/src/parser.rs +++ /dev/null @@ -1,579 +0,0 @@ -//! Frontend-independent parsing for Chitin's built-in command language. - -use std::path::PathBuf; - -use chitin_databases::providers::rcsb::{PdbId, PdbIdListError, StructureFormat}; - -use crate::{ - ChitinCommand, CommandId, CommandOutputFormat, DatabaseCommand, RcsbDownloadArguments, StructureCommand, - StructureInputArguments, StructureInspectArguments, StructureValidateArguments, -}; - -/// Failure produced while tokenizing or parsing a built-in command line. -#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] -pub enum CommandParseError { - /// No command was present after whitespace was removed. - #[error("command line is empty")] - Empty, - /// A quoted token reached the end of the input without a closing quote. - #[error("unterminated {quote} quote starting at byte {position}")] - UnterminatedQuote { - /// Quote character that was not closed. - quote: char, - /// Byte offset of the opening quote. - position: usize, - }, - /// A trailing escape character had no following character. - #[error("trailing escape character at byte {position}")] - TrailingEscape { - /// Byte offset of the escape character. - position: usize, - }, - /// The top-level command name is not registered. - #[error("unknown command '{command}'")] - UnknownCommand { - /// Unrecognized command name. - command: String, - }, - /// A nested command path ended before a required word. - #[error("'{parent}' requires a {expected} subcommand")] - MissingSubcommand { - /// Command path parsed before the missing word. - parent: String, - /// Human-readable set of accepted words. - expected: &'static str, - }, - /// A nested command word was not recognized. - #[error("unknown subcommand '{subcommand}' for '{parent}'; expected {expected}")] - UnknownSubcommand { - /// Parent command path. - parent: String, - /// Unrecognized nested command word. - subcommand: String, - /// Human-readable set of accepted words. - expected: &'static str, - }, - /// A required positional argument or option was absent. - #[error("missing required argument {argument}")] - MissingArgument { - /// Argument spelling shown to the user. - argument: &'static str, - }, - /// An option that requires a value was the final token. - #[error("option '{option}' requires a value")] - MissingOptionValue { - /// Option spelling that lacked a value. - option: String, - }, - /// The command line contains an option that its command does not accept. - #[error("unknown option '{option}' for '{command}'")] - UnknownOption { - /// Command path receiving the option. - command: &'static str, - /// Unrecognized option spelling. - option: String, - }, - /// A single-valued option occurred more than once. - #[error("option '{option}' may only be specified once")] - DuplicateOption { - /// Repeated option spelling. - option: String, - }, - /// A value is outside the accepted vocabulary for its option. - #[error("invalid value '{value}' for '{option}'; expected {expected}")] - InvalidValue { - /// Option whose value failed validation. - option: &'static str, - /// Original invalid value. - value: String, - /// Human-readable set of accepted values. - expected: &'static str, - }, - /// A command received an extra positional argument. - #[error("unexpected argument '{value}' for '{command}'")] - UnexpectedArgument { - /// Command path receiving the extra value. - command: &'static str, - /// Unexpected value. - value: String, - }, - /// A comma-separated PDB identifier list contains an invalid item. - #[error(transparent)] - InvalidPdbIds(#[from] PdbIdListError), -} - -/// Parses built-in shell text into a complete typed command. -/// -/// The grammar deliberately mirrors the public CLI for portable workflows. -/// Commands without arguments may also use their stable dotted identifier, -/// such as `workspace.toggle_workspace`. -/// -/// # Parameters -/// -/// * `input` is one complete command line without a trailing newline. -/// -/// # Returns -/// -/// A frontend-independent [`ChitinCommand`] containing all validated arguments. -/// -/// # Errors -/// -/// Returns [`CommandParseError`] when quoting is incomplete, the command path -/// is unknown, an option is malformed, or a domain value fails validation. -/// -/// # Examples -/// -/// ``` -/// use chitin_command::{ChitinCommand, DatabaseCommand, PortableCommand, parse_command_line}; -/// -/// let command = parse_command_line("db rcsb download --id 4hhb --format pdb")?; -/// assert!(matches!( -/// command, -/// ChitinCommand::Portable(PortableCommand::Database( -/// DatabaseCommand::DownloadRcsbStructure(_) -/// )) -/// )); -/// # Ok::<(), chitin_command::CommandParseError>(()) -/// ``` -pub fn parse_command_line(input: &str) -> Result { - let tokens = tokenize(input)?; - let Some(command) = tokens.first().map(String::as_str) else { - return Err(CommandParseError::Empty); - }; - - match command { - "db" => parse_database(&tokens[1..]), - "structure" => parse_structure(&tokens[1..]), - stable_id => parse_argumentless_command(stable_id, &tokens[1..]), - } -} - -/// Splits command text while preserving quoted whitespace and escaped characters. -/// -/// # Parameters -/// -/// * `input` is the original command line and may contain single quotes, -/// double quotes, or backslash escapes. -/// -/// # Returns -/// -/// Tokens with syntax characters removed while retaining empty quoted values. -fn tokenize(input: &str) -> Result, CommandParseError> { - let mut tokens = Vec::new(); - let mut token = String::new(); - let mut token_started = false; - let mut quote = None; - let mut characters = input.char_indices().peekable(); - - while let Some((position, character)) = characters.next() { - match quote { - Some((active, _)) if character == active => quote = None, - Some(('\'', _)) => { - token.push(character); - token_started = true; - } - Some(_) | None if character == '\\' => { - let Some((_, escaped)) = characters.next() else { - return Err(CommandParseError::TrailingEscape { position }); - }; - token.push(escaped); - token_started = true; - } - None if character == '\'' || character == '"' => { - quote = Some((character, position)); - token_started = true; - } - None if character.is_whitespace() => { - if token_started { - tokens.push(std::mem::take(&mut token)); - token_started = false; - } - } - Some(_) | None => { - token.push(character); - token_started = true; - } - } - } - - if let Some((quote, position)) = quote { - return Err(CommandParseError::UnterminatedQuote { quote, position }); - } - if token_started { - tokens.push(token); - } - Ok(tokens) -} - -/// Parses the `db rcsb download` command and its provider arguments. -/// -/// # Parameters -/// -/// * `tokens` starts after the top-level `db` word. -/// -/// # Returns -/// -/// A download command containing canonical identifiers and resolved options. -fn parse_database(tokens: &[String]) -> Result { - expect_subcommand(tokens.first(), "db", "rcsb", "rcsb")?; - expect_subcommand(tokens.get(1), "db rcsb", "download", "download")?; - - let mut ids = None; - let mut format = None; - let mut output = None; - let mut cursor = 2; - while cursor < tokens.len() { - let token = &tokens[cursor]; - let (option, inline_value) = parse_option(token).ok_or_else(|| CommandParseError::UnexpectedArgument { - command: "db rcsb download", - value: token.clone(), - })?; - match option { - "--id" => { - reject_duplicate(&ids, option)?; - ids = Some(option_value(tokens, &mut cursor, option, inline_value)?.to_owned()); - } - "--format" => { - reject_duplicate(&format, option)?; - let value = option_value(tokens, &mut cursor, option, inline_value)?; - format = Some(parse_structure_format(value)?); - } - "--output" => { - reject_duplicate(&output, option)?; - output = Some(PathBuf::from(option_value(tokens, &mut cursor, option, inline_value)?)); - } - _ => { - return Err(CommandParseError::UnknownOption { - command: "db rcsb download", - option: option.to_owned(), - }); - } - } - cursor += 1; - } - - let ids = ids.ok_or(CommandParseError::MissingArgument { argument: "--id" })?; - let format = format.ok_or(CommandParseError::MissingArgument { argument: "--format" })?; - Ok( - DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { - ids: PdbId::parse_many(&ids)?, - format, - output, - }) - .into(), - ) -} - -/// Parses structure inspection and validation commands. -/// -/// # Parameters -/// -/// * `tokens` starts with `inspect` or `validate` and contains its arguments. -/// -/// # Returns -/// -/// A complete structure command with its input and output preferences. -fn parse_structure(tokens: &[String]) -> Result { - let Some(subcommand) = tokens.first().map(String::as_str) else { - return Err(CommandParseError::MissingSubcommand { - parent: "structure".to_owned(), - expected: "inspect or validate", - }); - }; - if subcommand != "inspect" && subcommand != "validate" { - return Err(CommandParseError::UnknownSubcommand { - parent: "structure".to_owned(), - subcommand: subcommand.to_owned(), - expected: "inspect or validate", - }); - } - - let command_name = if subcommand == "inspect" { - "structure inspect" - } else { - "structure validate" - }; - let mut input = None; - let mut format = None; - let mut output = None; - let mut verbose = false; - let mut cursor = 1; - while cursor < tokens.len() { - let token = &tokens[cursor]; - if let Some((option, inline_value)) = parse_option(token) { - match option { - "--format" => { - reject_duplicate(&format, option)?; - let value = option_value(tokens, &mut cursor, option, inline_value)?; - format = Some(parse_structure_format(value)?); - } - "--output" => { - reject_duplicate(&output, option)?; - let value = option_value(tokens, &mut cursor, option, inline_value)?; - output = Some(parse_output_format(value)?); - } - "--verbose" if subcommand == "inspect" && inline_value.is_none() => { - if verbose { - return Err(CommandParseError::DuplicateOption { - option: option.to_owned(), - }); - } - verbose = true; - } - _ => { - return Err(CommandParseError::UnknownOption { - command: command_name, - option: option.to_owned(), - }); - } - } - } else if input.is_some() { - return Err(CommandParseError::UnexpectedArgument { - command: command_name, - value: token.clone(), - }); - } else { - input = Some(PathBuf::from(token)); - } - cursor += 1; - } - - let input = StructureInputArguments { - input: input.ok_or(CommandParseError::MissingArgument { argument: "" })?, - format, - }; - let output = output.unwrap_or_default(); - match subcommand { - "inspect" => Ok(StructureCommand::Inspect(StructureInspectArguments { input, output, verbose }).into()), - "validate" => Ok(StructureCommand::Validate(StructureValidateArguments { input, output }).into()), - _ => Err(CommandParseError::UnknownSubcommand { - parent: "structure".to_owned(), - subcommand: subcommand.to_owned(), - expected: "inspect or validate", - }), - } -} - -/// Resolves a stable dotted identifier for a command without arguments. -/// -/// # Parameters -/// -/// * `stable_id` is the canonical command identifier. -/// * `trailing` contains tokens that must be absent for argumentless commands. -/// -/// # Returns -/// -/// The executable command represented by the stable identifier. -fn parse_argumentless_command(stable_id: &str, trailing: &[String]) -> Result { - let id = match stable_id { - "workspace.focus_previous_entry" => CommandId::WorkspaceFocusPrevious, - "workspace.focus_next_entry" => CommandId::WorkspaceFocusNext, - "workspace.activate_focused_entry" => CommandId::WorkspaceActivateFocused, - "workspace.focus_first_entry" => CommandId::WorkspaceFocusFirst, - "workspace.focus_last_entry" => CommandId::WorkspaceFocusLast, - "workspace.toggle_workspace" => CommandId::WorkspaceToggle, - "tab.focus_previous" => CommandId::PanelTabFocusPrevious, - "tab.focus_next" => CommandId::PanelTabFocusNext, - "tab.close" => CommandId::PanelTabClose, - "application.toggle_command_panel" => CommandId::ApplicationToggleCommandPanel, - "application.toggle_terminal" => CommandId::ApplicationToggleTerminal, - _ => { - return Err(CommandParseError::UnknownCommand { - command: stable_id.to_owned(), - }); - } - }; - if let Some(value) = trailing.first() { - return Err(CommandParseError::UnexpectedArgument { - command: id.as_str(), - value: value.clone(), - }); - } - id.frontend_command_without_arguments() - .map(ChitinCommand::from) - .ok_or_else(|| CommandParseError::UnknownCommand { - command: stable_id.to_owned(), - }) -} - -/// Requires one exact nested command word at the requested position. -fn expect_subcommand( - token: Option<&String>, - parent: &str, - required: &str, - expected: &'static str, -) -> Result<(), CommandParseError> { - match token { - Some(token) if token == required => Ok(()), - Some(token) => Err(CommandParseError::UnknownSubcommand { - parent: parent.to_owned(), - subcommand: token.clone(), - expected, - }), - None => Err(CommandParseError::MissingSubcommand { - parent: parent.to_owned(), - expected, - }), - } -} - -/// Splits a long option into its name and optional inline value. -fn parse_option(token: &str) -> Option<(&str, Option<&str>)> { - token.strip_prefix("--").map(|option| { - let (name, value) = option - .split_once('=') - .map_or((option, None), |(name, value)| (name, Some(value))); - (&token[..name.len() + 2], value) - }) -} - -/// Returns an option value from `--name=value` or the following token. -fn option_value<'a>( - tokens: &'a [String], - cursor: &mut usize, - option: &str, - inline_value: Option<&'a str>, -) -> Result<&'a str, CommandParseError> { - if let Some(value) = inline_value { - return if value.is_empty() { - Err(CommandParseError::MissingOptionValue { - option: option.to_owned(), - }) - } else { - Ok(value) - }; - } - *cursor += 1; - tokens - .get(*cursor) - .filter(|value| !value.starts_with("--")) - .map(String::as_str) - .ok_or_else(|| CommandParseError::MissingOptionValue { - option: option.to_owned(), - }) -} - -/// Rejects a second occurrence of a single-valued option. -fn reject_duplicate(value: &Option, option: &str) -> Result<(), CommandParseError> { - if value.is_some() { - Err(CommandParseError::DuplicateOption { - option: option.to_owned(), - }) - } else { - Ok(()) - } -} - -/// Parses a structure format shared by local and RCSB commands. -fn parse_structure_format(value: &str) -> Result { - match value.to_ascii_lowercase().as_str() { - "pdb" => Ok(StructureFormat::Pdb), - "mmcif" | "cif" => Ok(StructureFormat::Mmcif), - _ => Err(CommandParseError::InvalidValue { - option: "--format", - value: value.to_owned(), - expected: "pdb or mmcif", - }), - } -} - -/// Parses the requested command output representation. -fn parse_output_format(value: &str) -> Result { - match value.to_ascii_lowercase().as_str() { - "text" => Ok(CommandOutputFormat::Text), - "json" => Ok(CommandOutputFormat::Json), - _ => Err(CommandParseError::InvalidValue { - option: "--output", - value: value.to_owned(), - expected: "text or json", - }), - } -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use crate::PortableCommand; - - use super::*; - - #[test] - fn database_download_should_parse_complete_typed_arguments() -> Result<(), CommandParseError> { - let command = parse_command_line("db rcsb download --id 4hhb,1yth --format mmcif --output 'saved structures'")?; - - assert!(matches!( - command, - ChitinCommand::Portable(PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure( - RcsbDownloadArguments { - ids, - format: StructureFormat::Mmcif, - output: Some(output), - }))) if ids.iter().map(PdbId::as_str).collect::>() == ["4HHB", "1YTH"] - && output == Path::new("saved structures") - )); - Ok(()) - } - - #[test] - fn structure_inspect_should_parse_options_in_any_order() -> Result<(), CommandParseError> { - let command = parse_command_line("structure inspect --verbose --output=json 'models/one.cif' --format cif")?; - - assert!(matches!( - command, - ChitinCommand::Portable(PortableCommand::Structure(StructureCommand::Inspect(StructureInspectArguments { - input: StructureInputArguments { input, format: Some(StructureFormat::Mmcif) }, - output: CommandOutputFormat::Json, - verbose: true, - }))) if input == Path::new("models/one.cif") - )); - Ok(()) - } - - #[test] - fn stable_identifier_should_create_argumentless_command() -> Result<(), CommandParseError> { - let command = parse_command_line("tab.close")?; - - assert_eq!(command.id(), CommandId::PanelTabClose); - Ok(()) - } - - #[test] - fn invalid_pdb_list_should_preserve_item_details() { - let result = parse_command_line("db rcsb download --id 4hhb,invalid --format pdb"); - - assert!(matches!( - result, - Err(CommandParseError::InvalidPdbIds(PdbIdListError { - index: 2, - value, - .. - })) if value == "invalid" - )); - } - - #[test] - fn unterminated_quote_should_report_opening_byte() { - let result = parse_command_line("structure inspect 'model.cif"); - - assert_eq!( - result, - Err(CommandParseError::UnterminatedQuote { - quote: '\'', - position: 18, - }) - ); - } - - #[test] - fn duplicate_option_should_be_rejected() { - let result = parse_command_line("structure validate one.pdb --format pdb --format mmcif"); - - assert_eq!( - result, - Err(CommandParseError::DuplicateOption { - option: "--format".to_owned(), - }) - ); - } -} diff --git a/crates/chitin-desktop/src/builtin_shell.rs b/crates/chitin-desktop/src/builtin_shell.rs index 07055baa..ba9ffba6 100644 --- a/crates/chitin-desktop/src/builtin_shell.rs +++ b/crates/chitin-desktop/src/builtin_shell.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use chitin_builtin_shell::{ BuiltinShell, BuiltinShellError, ShellCommandId, ShellCommandTarget, ShellExecutionResult, ShellInvocationSource, - ShellSubmission, + ShellLineSubmission, ShellSubmission, }; use chitin_command::{ChitinCommand, CommandEventSink, CommandExecutionContext, DatabaseCommand, PortableCommand}; use chitin_command_runtime::{CommandExecutionError, CommandExecutor, resolve_rcsb_download_paths}; @@ -40,7 +40,7 @@ impl DesktopShellHost { &self, input: impl Into, source: ShellInvocationSource, - ) -> Result { + ) -> Result { Ok(self.session.submit_line(input, source)?) } @@ -180,6 +180,8 @@ impl DesktopShellTask { /// Result of routing one shell submission through the desktop host. pub enum DesktopShellDispatch { + /// Clap produced help text without scheduling a command. + Display { output: String }, /// The command mutated desktop state synchronously. Frontend { command_id: ShellCommandId }, /// The command is running through the shared background executor. @@ -260,8 +262,10 @@ impl ChitinApp { window: &mut Window, cx: &mut Context, ) -> Result { - let submission = self.builtin_shell.submit_line(input, source)?; - self.route_builtin_shell_submission(submission, window, cx) + match self.builtin_shell.submit_line(input, source)? { + ShellLineSubmission::Command(submission) => self.route_builtin_shell_submission(submission, window, cx), + ShellLineSubmission::Display(output) => Ok(DesktopShellDispatch::Display { output }), + } } /// Routes an already typed command from an agent or application subsystem. @@ -349,12 +353,14 @@ mod tests { let host = DesktopShellHost::new(CommandExecutionContext::new(".").with_standard_input( b"ATOM 1 CA GLY A 1 1.000 2.000 3.000 1.00 20.00 C \nEND\n".to_vec(), )); - let submission = host.submit_line( - "structure validate - --format pdb", - ShellInvocationSource::Agent { - name: "validation-agent".to_owned(), - }, - )?; + let submission = host + .submit_line( + "structure validate - --format pdb", + ShellInvocationSource::Agent { + name: "validation-agent".to_owned(), + }, + )? + .into_command()?; let command_id = submission.id(); let tasks = BackgroundTaskCenter::new(); let running = host.submit_portable(submission, &tasks, CommandExecutor::new(ClientConfig::default()))?; @@ -377,7 +383,9 @@ mod tests { fn frontend_submission_should_remain_available_for_desktop_dispatch() -> Result<(), DesktopShellHostError> { let host = DesktopShellHost::new(CommandExecutionContext::new(".")); - let submission = host.submit_line("tab.close", ShellInvocationSource::Interactive)?; + let submission = host + .submit_line("tab.close", ShellInvocationSource::Interactive)? + .into_command()?; assert_eq!(submission.target(), ShellCommandTarget::Frontend); host.complete_frontend(submission.id())?; diff --git a/crates/chitin-desktop/src/components/terminal/controller.rs b/crates/chitin-desktop/src/components/terminal/controller.rs index d25142a2..cb3f57be 100644 --- a/crates/chitin-desktop/src/components/terminal/controller.rs +++ b/crates/chitin-desktop/src/components/terminal/controller.rs @@ -34,6 +34,16 @@ impl ChitinApp { .update(cx, |terminal, cx| terminal.begin_submission(line.clone(), cx)); match self.submit_builtin_shell_line(line, ShellInvocationSource::Interactive, window, cx) { + Ok(DesktopShellDispatch::Display { output }) => { + controls.terminal.update(cx, |terminal, cx| { + terminal.finish( + terminal_id, + CommandTerminalStatus::Succeeded, + super::presenter::terminal_text_lines(&output), + cx, + ); + }); + } Ok(DesktopShellDispatch::Frontend { command_id }) => { self.terminal_panel.bind(command_id, terminal_id); controls.terminal.update(cx, |terminal, cx| { diff --git a/crates/chitin-desktop/src/components/terminal/presenter.rs b/crates/chitin-desktop/src/components/terminal/presenter.rs index b25d5eb5..8bd123f1 100644 --- a/crates/chitin-desktop/src/components/terminal/presenter.rs +++ b/crates/chitin-desktop/src/components/terminal/presenter.rs @@ -21,6 +21,11 @@ pub(super) fn terminal_prompt(working_directory: &Path) -> TerminalLine { ]) } +/// Converts preformatted command help into terminal rows. +pub(super) fn terminal_text_lines(text: &str) -> Vec { + text.lines().map(TerminalLine::plain).collect() +} + /// Converts one shell command's event stream into terminal output rows. /// /// # Parameters From 1212cd35e03cb796ba21f89f3160dcb9dc090cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 21 Sep 2026 11:49:06 +0800 Subject: [PATCH 12/32] refactor(desktop): unify portable command task execution - add a shared desktop runner for portable commands - route command panel and built-in shell tasks through one adapter - centralize target resolution, event projection, cancellation, and outcomes - keep shell transcript lifecycle separate from task execution - remove the RCSB-specific background task adapter --- crates/chitin-builtin-shell/src/session.rs | 53 +++- crates/chitin-desktop/src/app.rs | 8 +- crates/chitin-desktop/src/builtin_shell.rs | 120 +++----- .../src/components/command_panel.rs | 34 ++- crates/chitin-desktop/src/lib.rs | 1 + crates/chitin-desktop/src/portable_command.rs | 286 ++++++++++++++++++ crates/chitin-desktop/src/tasks/mod.rs | 2 - crates/chitin-desktop/src/tasks/rcsb.rs | 81 ----- 8 files changed, 405 insertions(+), 180 deletions(-) create mode 100644 crates/chitin-desktop/src/portable_command.rs delete mode 100644 crates/chitin-desktop/src/tasks/rcsb.rs diff --git a/crates/chitin-builtin-shell/src/session.rs b/crates/chitin-builtin-shell/src/session.rs index a621dbc3..db8423ae 100644 --- a/crates/chitin-builtin-shell/src/session.rs +++ b/crates/chitin-builtin-shell/src/session.rs @@ -502,17 +502,8 @@ impl BuiltinShell { executor: &CommandExecutor, host_events: CommandEventSink, ) -> Result { - self.validate_active_submission(&submission)?; - if submission.target != ShellCommandTarget::Portable { - return Err(BuiltinShellError::FrontendCommand { id: submission.id }); - } - - let shell = self.clone(); let id = submission.id; - let events = CommandEventSink::new(move |event| { - shell.record_command_event(id, event.clone()); - host_events.emit(event); - }); + let events = self.portable_event_sink(&submission, host_events)?; let ChitinCommand::Portable(command) = submission.command else { return Err(BuiltinShellError::FrontendCommand { id: submission.id }); }; @@ -539,6 +530,48 @@ impl BuiltinShell { } } + /// Creates an event sink that records portable executor events in this session. + /// + /// # Parameters + /// + /// * `submission` identifies the active portable command receiving events. + /// * `host_events` receives a copy for task-center or protocol projection. + /// + /// # Returns + /// + /// A sink that updates the shell transcript before forwarding each event. + /// + /// # Errors + /// + /// Returns [`BuiltinShellError`] when the submission is no longer active or + /// targets the desktop frontend instead of the portable executor. + pub fn portable_event_sink( + &self, + submission: &ShellSubmission, + host_events: CommandEventSink, + ) -> Result { + self.validate_active_submission(submission)?; + if submission.target != ShellCommandTarget::Portable { + return Err(BuiltinShellError::FrontendCommand { id: submission.id }); + } + let shell = self.clone(); + let id = submission.id; + Ok(CommandEventSink::new(move |event| { + shell.record_command_event(id, event.clone()); + host_events.emit(event); + })) + } + + /// Marks a portable command as successfully completed by its host executor. + pub fn complete_portable(&self, id: ShellCommandId) -> Result<(), BuiltinShellError> { + self.finish(id, ShellExecutionStatus::Succeeded, ShellTranscriptContent::Completed) + } + + /// Marks a portable command as cooperatively cancelled by its host executor. + pub fn cancel_portable(&self, id: ShellCommandId) -> Result<(), BuiltinShellError> { + self.finish(id, ShellExecutionStatus::Cancelled, ShellTranscriptContent::Cancelled) + } + /// Marks a frontend-routed command as successfully completed. pub fn complete_frontend(&self, id: ShellCommandId) -> Result<(), BuiltinShellError> { self.finish(id, ShellExecutionStatus::Succeeded, ShellTranscriptContent::Completed) diff --git a/crates/chitin-desktop/src/app.rs b/crates/chitin-desktop/src/app.rs index f2d1b0bc..5eb77603 100644 --- a/crates/chitin-desktop/src/app.rs +++ b/crates/chitin-desktop/src/app.rs @@ -34,6 +34,7 @@ use crate::{ window_bar::{WindowBarControls, render_window_bar}, }, keybindings::{ToggleCommandPanel, ToggleTerminal, ToggleWorkspace, WORKBENCH_KEY_CONTEXT}, + portable_command::DesktopPortableCommandRunner, tasks::BackgroundTaskCenter, }; @@ -61,8 +62,8 @@ pub struct ChitinApp { pub(crate) command_panel: CommandPanelController, /// Application-wide executor and registry for background work. pub(crate) tasks: BackgroundTaskCenter, - /// Shared executor for frontend-independent typed commands. - pub(crate) command_executor: CommandExecutor, + /// Shared adapter for frontend-independent commands submitted by desktop views. + pub(crate) portable_commands: DesktopPortableCommandRunner, /// Shared command bridge for terminal, agent, and system invocations. pub(crate) builtin_shell: DesktopShellHost, /// Active tool and geometry of the workbench-level bottom dock. @@ -143,6 +144,7 @@ impl ChitinApp { ProjectSidebarState::with_workspace_root(workspace.as_ref().map(|workspace| workspace.tree.root.path.as_path())); let shell_workspace_root = workspace.as_ref().map(|workspace| workspace.root.clone()); + let command_executor = CommandExecutor::new(ClientConfig::default()); Self { workspace, project_sidebar_state, @@ -154,7 +156,7 @@ impl ChitinApp { project_sidebar_visible: true, command_panel: CommandPanelController::new(), tasks: BackgroundTaskCenter::new(), - command_executor: CommandExecutor::new(ClientConfig::default()), + portable_commands: DesktopPortableCommandRunner::new(command_executor), builtin_shell: DesktopShellHost::new(desktop_shell_context(shell_workspace_root)), bottom_dock: BottomDockState::new(), bottom_dock_controls: None, diff --git a/crates/chitin-desktop/src/builtin_shell.rs b/crates/chitin-desktop/src/builtin_shell.rs index ba9ffba6..74281487 100644 --- a/crates/chitin-desktop/src/builtin_shell.rs +++ b/crates/chitin-desktop/src/builtin_shell.rs @@ -6,14 +6,16 @@ use chitin_builtin_shell::{ BuiltinShell, BuiltinShellError, ShellCommandId, ShellCommandTarget, ShellExecutionResult, ShellInvocationSource, ShellLineSubmission, ShellSubmission, }; -use chitin_command::{ChitinCommand, CommandEventSink, CommandExecutionContext, DatabaseCommand, PortableCommand}; -use chitin_command_runtime::{CommandExecutionError, CommandExecutor, resolve_rcsb_download_paths}; +use chitin_command::{ChitinCommand, CommandEventSink, CommandExecutionContext}; use gpui::{AppContext, AsyncApp, Context, WeakEntity, Window}; -use tokio::sync::oneshot; use crate::{ app::ChitinApp, - tasks::{BackgroundTaskCenter, TaskCenterError, TaskFailure, TaskHandle, TaskKind, TaskTarget}, + portable_command::{ + DesktopPortableCommandError, DesktopPortableCommandRunner, DesktopPortableCommandTask, PortableCommandCompletion, + PortableCommandSubmission, + }, + tasks::{BackgroundTaskCenter, TaskHandle, TaskKind}, }; /// Desktop-owned shell session and adapters for application background work. @@ -70,7 +72,8 @@ impl DesktopShellHost { /// /// * `submission` contains the correlated typed command and execution context. /// * `tasks` owns the shared Tokio executor and durable task state. - /// * `executor` dispatches portable work into database and scientific crates. + /// * `runner` resolves targets and dispatches portable work through the + /// application-wide command executor. /// /// # Returns /// @@ -80,7 +83,7 @@ impl DesktopShellHost { &self, submission: ShellSubmission, tasks: &BackgroundTaskCenter, - executor: CommandExecutor, + runner: &DesktopPortableCommandRunner, ) -> Result { if submission.target() != ShellCommandTarget::Portable { return Err(DesktopShellHostError::WrongTarget { @@ -91,46 +94,37 @@ impl DesktopShellHost { let id = submission.id(); let command_id = submission.command().id(); - let targets = match submission_targets(&submission) { - Ok(targets) => targets, - Err(error) => { - let _ = self.session.fail_submission(id, error.to_string()); - return Err(error.into()); - } + let ChitinCommand::Portable(command) = submission.command().clone() else { + return Err(DesktopShellHostError::WrongTarget { + id, + target: submission.target(), + }); }; let cancellation = submission.context().cancellation.clone(); - let execution_session = self.session.clone(); + let context = submission.context().clone(); + let events = self + .session + .portable_event_sink(&submission, CommandEventSink::silent())?; + let completion_session = self.session.clone(); let failure_session = self.session.clone(); - let (result_sender, result_receiver) = oneshot::channel(); - let task = tasks.submit_with_cancellation( - TaskKind::BuiltinShell { command_id }, - targets, - cancellation, - move |context| async move { - let event_context = context.clone(); - let events = CommandEventSink::new(move |event| event_context.report_command_event(event)); - match execution_session - .execute_portable_with_events(submission, &executor, events) - .await - { - Ok(result) => { - let _ = result_sender.send(Ok(result)); - Ok(()) + let task = runner.submit( + tasks, + PortableCommandSubmission::new(TaskKind::BuiltinShell { command_id }, command, context) + .with_cancellation(cancellation) + .with_events(events) + .on_completion(move |completion| { + let result = match completion { + PortableCommandCompletion::Succeeded => completion_session.complete_portable(id), + PortableCommandCompletion::Cancelled => completion_session.cancel_portable(id), + PortableCommandCompletion::Failed(message) => completion_session.fail_submission(id, message), + }; + if let Err(error) = result { + log::error!("failed to update built-in shell completion state: {error}"); } - Err(error) => { - let failure = TaskFailure::new(error.to_string()); - let _ = result_sender.send(Err(error)); - Err(failure) - } - } - }, + }), ); match task { - Ok(task) => Ok(DesktopShellTask { - command_id: id, - task, - result: result_receiver, - }), + Ok(task) => Ok(DesktopShellTask { command_id: id, task }), Err(error) => { let _ = failure_session.fail_submission(id, error.to_string()); Err(error.into()) @@ -152,8 +146,7 @@ impl DesktopShellHost { /// Running portable shell command exposed to desktop and agent consumers. pub struct DesktopShellTask { command_id: ShellCommandId, - task: TaskHandle, - result: oneshot::Receiver>, + task: DesktopPortableCommandTask, } impl DesktopShellTask { @@ -164,17 +157,16 @@ impl DesktopShellTask { /// Returns the background task observation handle. pub const fn task(&self) -> &TaskHandle { - &self.task + self.task.task() } /// Waits for the structured portable-command result. pub async fn result(self) -> Result { - Ok( - self - .result - .await - .map_err(|_| DesktopShellHostError::ResultChannelClosed)??, - ) + let outcome = self.task.result().await?; + Ok(ShellExecutionResult { + id: self.command_id, + outcome, + }) } } @@ -194,12 +186,9 @@ pub enum DesktopShellHostError { /// The frontend-independent shell rejected the request or execution. #[error(transparent)] Shell(#[from] BuiltinShellError), - /// Portable command preparation failed before task submission. - #[error(transparent)] - Execution(#[from] CommandExecutionError), - /// The application task center rejected the command. + /// Portable command preparation, submission, or execution failed. #[error(transparent)] - TaskCenter(#[from] TaskCenterError), + Portable(#[from] DesktopPortableCommandError), /// A submission was sent to an incompatible host route. #[error("shell command {} has target {target:?}, which cannot use this route", id.get())] WrongTarget { @@ -208,23 +197,6 @@ pub enum DesktopShellHostError { /// Target selected by the frontend-independent shell. target: ShellCommandTarget, }, - /// The background task ended without publishing its structured result. - #[error("built-in shell result channel closed before completion")] - ResultChannelClosed, -} - -/// Resolves resources protected while a portable command is active. -fn submission_targets(submission: &ShellSubmission) -> Result, CommandExecutionError> { - match submission.command() { - ChitinCommand::Portable(PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure(arguments))) => { - resolve_rcsb_download_paths(arguments, submission.context()) - .map(|paths| paths.into_iter().map(TaskTarget::File).collect()) - } - _ => Ok(vec![TaskTarget::Resource(format!( - "builtin-shell:{}", - submission.id().get() - ))]), - } } /// Builds workspace-aware defaults for a desktop shell session. @@ -316,7 +288,7 @@ impl ChitinApp { ShellCommandTarget::Portable => { let running = self .builtin_shell - .submit_portable(submission, &self.tasks, self.command_executor.clone())?; + .submit_portable(submission, &self.tasks, &self.portable_commands)?; observe_shell_task(running.task().clone(), window, cx); Ok(DesktopShellDispatch::Portable(running)) } @@ -344,6 +316,7 @@ fn observe_shell_task(mut task: TaskHandle, window: &Window, cx: &mut Context submission, - Err(error) => { - log::error!("failed to submit RCSB download task: {error}"); - form.download.update(cx, |state, cx| state.fail(error.to_string(), cx)); - return; - } - }; + let command = PortableCommand::from(DatabaseCommand::DownloadRcsbStructure(arguments)); + let running = match self.portable_commands.submit( + &self.tasks, + PortableCommandSubmission::new( + TaskKind::DatabaseDownload { + provider: "RCSB".to_string(), + }, + command, + execution_context, + ), + ) { + Ok(submission) => submission, + Err(error) => { + log::error!("failed to submit RCSB download task: {error}"); + form.download.update(cx, |state, cx| state.fail(error.to_string(), cx)); + return; + } + }; + let total_items = running.target_count(); + let mut task = running.into_task_handle(); log::info!( "RCSB batch download task submitted: id={}, items={total_items}", task.id() diff --git a/crates/chitin-desktop/src/lib.rs b/crates/chitin-desktop/src/lib.rs index 6c5689d7..4533725f 100644 --- a/crates/chitin-desktop/src/lib.rs +++ b/crates/chitin-desktop/src/lib.rs @@ -8,6 +8,7 @@ pub mod app; pub mod builtin_shell; pub(crate) mod components; pub mod keybindings; +pub mod portable_command; pub mod tasks; /// GPUI adapter for the experimental WGPU document panel. diff --git a/crates/chitin-desktop/src/portable_command.rs b/crates/chitin-desktop/src/portable_command.rs new file mode 100644 index 00000000..44a52e7b --- /dev/null +++ b/crates/chitin-desktop/src/portable_command.rs @@ -0,0 +1,286 @@ +//! Desktop adapter for executing portable commands as background tasks. + +use chitin_command::{CommandEventSink, CommandExecutionContext, DatabaseCommand, PortableCommand, StructureCommand}; +use chitin_command_runtime::{CommandExecutionError, CommandExecutor, CommandOutcome, resolve_rcsb_download_paths}; +use chitin_databases::CancellationToken; +use tokio::sync::oneshot; + +use crate::tasks::{BackgroundTaskCenter, TaskCenterError, TaskFailure, TaskHandle, TaskKind, TaskTarget}; + +/// Terminal state reported to the frontend that originated a portable task. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PortableCommandCompletion { + /// The command completed and produced a typed outcome. + Succeeded, + /// Cooperative cancellation was requested before execution finished. + Cancelled, + /// Execution failed without a cancellation request. + Failed(String), +} + +/// Complete desktop submission parameters for one portable command. +pub struct PortableCommandSubmission { + kind: TaskKind, + command: PortableCommand, + context: CommandExecutionContext, + cancellation: CancellationToken, + events: CommandEventSink, + completion: Option>, +} + +impl PortableCommandSubmission { + /// Creates a submission with silent frontend events and context cancellation. + pub fn new(kind: TaskKind, command: PortableCommand, context: CommandExecutionContext) -> Self { + let cancellation = context.cancellation.clone(); + Self { + kind, + command, + context, + cancellation, + events: CommandEventSink::silent(), + completion: None, + } + } + + /// Replaces the cancellation token shared with the task center. + pub fn with_cancellation(mut self, cancellation: CancellationToken) -> Self { + self.cancellation = cancellation; + self + } + + /// Forwards executor events to an additional frontend observer. + pub fn with_events(mut self, events: CommandEventSink) -> Self { + self.events = events; + self + } + + /// Installs a callback invoked exactly once when execution terminates. + pub fn on_completion(mut self, completion: impl FnOnce(PortableCommandCompletion) + Send + 'static) -> Self { + self.completion = Some(Box::new(completion)); + self + } +} + +/// Application-wide adapter from typed portable commands to background tasks. +#[derive(Clone)] +pub struct DesktopPortableCommandRunner { + executor: CommandExecutor, +} + +impl DesktopPortableCommandRunner { + /// Creates a runner around the shared frontend-independent executor. + pub fn new(executor: CommandExecutor) -> Self { + Self { executor } + } + + /// Resolves protected targets and submits a portable command. + /// + /// # Parameters + /// + /// * `center` owns the application background runtime and task registry. + /// * `submission` contains the command, execution context, task metadata, + /// event observer, and optional lifecycle callback. + /// + /// # Returns + /// + /// A running task with its observation handle and typed outcome receiver. + /// + /// # Errors + /// + /// Returns [`DesktopPortableCommandError`] when command targets cannot be + /// resolved or the task center rejects the submission. + pub fn submit( + &self, + center: &BackgroundTaskCenter, + submission: PortableCommandSubmission, + ) -> Result { + let PortableCommandSubmission { + kind, + command, + context, + cancellation, + events, + completion, + } = submission; + let targets = command_targets(&command, &context)?; + let target_count = targets.len(); + let executor = self.executor.clone(); + let execution_cancellation = cancellation.clone(); + let (result_sender, result_receiver) = oneshot::channel(); + let task = center.submit_with_cancellation(kind, targets, cancellation, move |task_context| async move { + let event_context = task_context.clone(); + let task_events = CommandEventSink::new(move |event| { + event_context.report_command_event(event.clone()); + events.emit(event); + }); + let execution_context = context.with_cancellation(task_context.cancellation_token()); + let result = executor.execute(command, execution_context, task_events).await; + match result { + Ok(_) if execution_cancellation.is_cancelled() => { + finish_submission(completion, PortableCommandCompletion::Cancelled); + let _ = result_sender.send(PortableCommandResult::Cancelled); + Ok(()) + } + Ok(outcome) => { + finish_submission(completion, PortableCommandCompletion::Succeeded); + let _ = result_sender.send(PortableCommandResult::Succeeded(outcome)); + Ok(()) + } + Err(error) if execution_cancellation.is_cancelled() => { + let failure = TaskFailure::new(error.to_string()); + finish_submission(completion, PortableCommandCompletion::Cancelled); + let _ = result_sender.send(PortableCommandResult::Cancelled); + Err(failure) + } + Err(error) => { + let message = error.to_string(); + finish_submission(completion, PortableCommandCompletion::Failed(message.clone())); + let _ = result_sender.send(PortableCommandResult::Failed(error)); + Err(TaskFailure::new(message)) + } + } + })?; + Ok(DesktopPortableCommandTask { + task, + target_count, + result: result_receiver, + }) + } +} + +/// Running portable command exposed to desktop presenters and protocol hosts. +pub struct DesktopPortableCommandTask { + task: TaskHandle, + target_count: usize, + result: oneshot::Receiver, +} + +impl DesktopPortableCommandTask { + /// Returns the background task observation handle. + pub const fn task(&self) -> &TaskHandle { + &self.task + } + + /// Returns the number of resources reserved by the task. + pub const fn target_count(&self) -> usize { + self.target_count + } + + /// Consumes the result receiver and returns the task observation handle. + pub fn into_task_handle(self) -> TaskHandle { + self.task + } + + /// Waits for and returns the typed executor outcome. + pub async fn result(self) -> Result { + match self + .result + .await + .map_err(|_| DesktopPortableCommandError::ResultChannelClosed)? + { + PortableCommandResult::Succeeded(outcome) => Ok(outcome), + PortableCommandResult::Cancelled => Err(DesktopPortableCommandError::Cancelled), + PortableCommandResult::Failed(error) => Err(DesktopPortableCommandError::Execution(error)), + } + } +} + +/// Typed terminal result transferred from the task runtime to its caller. +enum PortableCommandResult { + /// Execution completed with a domain outcome. + Succeeded(CommandOutcome), + /// Cooperative cancellation won the terminal-state race. + Cancelled, + /// Execution failed before producing an outcome. + Failed(CommandExecutionError), +} + +/// Failure while preparing, submitting, or observing a portable desktop task. +#[derive(Debug, thiserror::Error)] +pub enum DesktopPortableCommandError { + /// Portable command preparation or execution failed. + #[error(transparent)] + Execution(#[from] CommandExecutionError), + /// The application task center rejected the command. + #[error(transparent)] + TaskCenter(#[from] TaskCenterError), + /// The task ended without publishing its typed outcome. + #[error("portable command result channel closed before completion")] + ResultChannelClosed, + /// Cooperative cancellation completed before a result was presented. + #[error("portable command was cancelled")] + Cancelled, +} + +/// Notifies an optional origin-specific lifecycle observer. +fn finish_submission( + completion: Option>, + state: PortableCommandCompletion, +) { + if let Some(completion) = completion { + completion(state); + } +} + +/// Resolves resources protected while a portable command is active. +fn command_targets( + command: &PortableCommand, + context: &CommandExecutionContext, +) -> Result, CommandExecutionError> { + match command { + PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure(arguments)) => { + resolve_rcsb_download_paths(arguments, context).map(|paths| paths.into_iter().map(TaskTarget::File).collect()) + } + PortableCommand::Structure(StructureCommand::Inspect(arguments)) => { + Ok(vec![TaskTarget::File(arguments.input.input.clone())]) + } + PortableCommand::Structure(StructureCommand::Validate(arguments)) => { + Ok(vec![TaskTarget::File(arguments.input.input.clone())]) + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use chitin_command::{CommandOutputFormat, StructureInputArguments, StructureValidateArguments}; + use chitin_databases::{ClientConfig, providers::rcsb::StructureFormat}; + + use super::*; + + #[test] + fn runner_should_execute_structure_validation_and_publish_result() -> Result<(), Box> { + let center = BackgroundTaskCenter::new(); + let runner = DesktopPortableCommandRunner::new(CommandExecutor::new(ClientConfig::default())); + let command = StructureCommand::Validate(StructureValidateArguments { + input: StructureInputArguments { + input: PathBuf::from("-"), + format: Some(StructureFormat::Pdb), + }, + output: CommandOutputFormat::Text, + }); + let context = CommandExecutionContext::new(".").with_standard_input( + b"ATOM 1 CA GLY A 1 1.000 2.000 3.000 1.00 20.00 C \nEND\n".to_vec(), + ); + let task = runner.submit( + ¢er, + PortableCommandSubmission::new( + TaskKind::BuiltinShell { + command_id: command.id(), + }, + command.into(), + context, + ), + )?; + let runtime = tokio::runtime::Builder::new_current_thread().build()?; + + let outcome = runtime.block_on(task.result())?; + + assert!(matches!( + outcome, + CommandOutcome::StructureValidation(validation) if validation.is_valid() + )); + Ok(()) + } +} diff --git a/crates/chitin-desktop/src/tasks/mod.rs b/crates/chitin-desktop/src/tasks/mod.rs index a370966d..490741dc 100644 --- a/crates/chitin-desktop/src/tasks/mod.rs +++ b/crates/chitin-desktop/src/tasks/mod.rs @@ -17,8 +17,6 @@ use tokio::{ sync::{broadcast, watch}, }; -pub(crate) mod rcsb; - /// Stable identity assigned to one submitted task. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct TaskId(u64); diff --git a/crates/chitin-desktop/src/tasks/rcsb.rs b/crates/chitin-desktop/src/tasks/rcsb.rs deleted file mode 100644 index 9bc7e019..00000000 --- a/crates/chitin-desktop/src/tasks/rcsb.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! RCSB command adapter for the application task center. - -use super::{BackgroundTaskCenter, TaskCenterError, TaskContext, TaskFailure, TaskHandle, TaskKind, TaskTarget}; -use chitin_command::{ - CommandEventSink, CommandExecutionContext, DatabaseCommand, PortableCommand, RcsbDownloadArguments, -}; -use chitin_command_runtime::{CommandExecutionError, CommandExecutor, CommandOutcome, resolve_rcsb_download_paths}; - -/// Failure while preparing or submitting a database command task. -#[derive(Debug, thiserror::Error)] -pub(crate) enum SubmitRcsbCommandError { - /// Portable command preparation failed before task submission. - #[error(transparent)] - Execution(#[from] CommandExecutionError), - /// The desktop task center rejected the prepared task. - #[error(transparent)] - TaskCenter(#[from] TaskCenterError), -} - -/// Submits a typed RCSB download through the shared command executor. -/// -/// # Parameters -/// -/// * `center` schedules and tracks the asynchronous desktop task. -/// * `executor` is the application-wide portable command executor. -/// * `arguments` contains validated download inputs. -/// * `execution_context` supplies workspace paths and cancellation defaults. -/// -/// # Returns -/// -/// The task subscription and number of unique output artifacts. -pub(crate) fn submit_download( - center: &BackgroundTaskCenter, - executor: CommandExecutor, - arguments: RcsbDownloadArguments, - execution_context: CommandExecutionContext, -) -> Result<(TaskHandle, usize), SubmitRcsbCommandError> { - let paths = resolve_rcsb_download_paths(&arguments, &execution_context)?; - let total_items = paths.len(); - let targets = paths.into_iter().map(TaskTarget::File).collect(); - let handle = center.submit( - TaskKind::DatabaseDownload { - provider: "RCSB".to_string(), - }, - targets, - move |context| run_download(context, executor, arguments, execution_context), - )?; - Ok((handle, total_items)) -} - -/// Runs the portable executor inside the desktop task center. -/// -/// # Parameters -/// -/// * `context` reports observable state to the desktop task center. -/// * `executor` executes the typed command without GPUI dependencies. -/// * `arguments` contains the validated download request. -/// * `execution_context` supplies workspace paths and cancellation defaults. -/// -/// # Returns -/// -/// `Ok(())` after all artifacts are persisted and reported. -async fn run_download( - context: TaskContext, - executor: CommandExecutor, - arguments: RcsbDownloadArguments, - execution_context: CommandExecutionContext, -) -> Result<(), TaskFailure> { - let event_context = context.clone(); - let events = CommandEventSink::new(move |event| event_context.report_command_event(event)); - let command = PortableCommand::from(DatabaseCommand::DownloadRcsbStructure(arguments)); - let execution_context = execution_context.with_cancellation(context.cancellation_token()); - let outcome = executor - .execute(command, execution_context, events) - .await - .map_err(|error| TaskFailure::new(error.to_string()))?; - if !matches!(outcome, CommandOutcome::DatabaseDownload { .. }) { - return Err(TaskFailure::new("RCSB task produced an unexpected command outcome")); - } - Ok(()) -} From 4b5c204d155d96631aa88c9b8185132421f1cba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 21 Sep 2026 12:12:00 +0800 Subject: [PATCH 13/32] fix(ui): render multiline terminal spans as separate rows Split embedded newline characters in TerminalLine spans before layout so command output preserves row boundaries and span styling. Normalize trailing carriage returns and add regression coverage for multiline styled output. --- .../chitin-ui/src/primitive/terminal/model.rs | 39 +++++++++++++++++++ .../src/primitive/terminal/render.rs | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/chitin-ui/src/primitive/terminal/model.rs b/crates/chitin-ui/src/primitive/terminal/model.rs index 9eef4cc0..60032fc1 100644 --- a/crates/chitin-ui/src/primitive/terminal/model.rs +++ b/crates/chitin-ui/src/primitive/terminal/model.rs @@ -126,6 +126,31 @@ impl TerminalLine { pub fn is_empty(&self) -> bool { self.spans.is_empty() } + + /// Splits embedded newline characters into independently laid-out rows. + pub(super) fn into_rows(self) -> Vec { + if self.spans.iter().all(|span| !span.text.contains('\n')) { + return vec![self]; + } + + let mut rows = Vec::new(); + let mut current = Vec::new(); + for span in self.spans { + for (index, text) in span.text.split('\n').enumerate() { + if index > 0 { + rows.push(Self { + spans: std::mem::take(&mut current), + }); + } + let text = text.strip_suffix('\r').unwrap_or(text); + if !text.is_empty() { + current.push(TerminalSpan::new(text.to_owned(), span.tone)); + } + } + } + rows.push(Self { spans: current }); + rows + } } #[cfg(test)] @@ -138,4 +163,18 @@ mod tests { assert_eq!(line.spans()[1].text, "help"); } + + #[test] + fn multiline_span_should_split_into_independent_styled_rows() { + let line = TerminalLine::new([ + TerminalSpan::error("error: missing argument\n"), + TerminalSpan::secondary(" --id "), + ]); + + let rows = line.into_rows(); + + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].spans()[0], TerminalSpan::error("error: missing argument")); + assert_eq!(rows[1].spans()[0], TerminalSpan::secondary(" --id ")); + } } diff --git a/crates/chitin-ui/src/primitive/terminal/render.rs b/crates/chitin-ui/src/primitive/terminal/render.rs index d739efa2..53942ad0 100644 --- a/crates/chitin-ui/src/primitive/terminal/render.rs +++ b/crates/chitin-ui/src/primitive/terminal/render.rs @@ -32,7 +32,7 @@ impl TerminalViewport { /// Replaces the ordered terminal lines. pub fn lines(mut self, lines: impl IntoIterator) -> Self { - self.lines = lines.into_iter().collect(); + self.lines = lines.into_iter().flat_map(TerminalLine::into_rows).collect(); self } From ca4d8ea679b49a1b1e3173114d2a8a34ba9b0f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 21 Sep 2026 14:54:16 +0800 Subject: [PATCH 14/32] refactor(command): route built-in shell lines by execution domain - route shell builtins to the session instead of the desktop host - resolve frontend grammar branches through stable command ids - list every command identity and guard grammar coverage with drift tests - expose line completion from the built-in shell boundary - add a terminal block that reports output without claiming the prompt - complete the live prompt on tab and list candidates when ambiguous - cover nested subcommand help rendered from the shared grammar --- crates/chitin-builtin-shell/src/lib.rs | 6 +- crates/chitin-builtin-shell/src/session.rs | 104 ++++- crates/chitin-command-line/src/lib.rs | 5 +- crates/chitin-command-line/src/shell.rs | 388 ++++++++++++++---- crates/chitin-command/src/lib.rs | 47 +++ crates/chitin-desktop/src/builtin_shell.rs | 7 +- .../src/components/terminal/completion.rs | 59 +++ .../src/components/terminal/controller.rs | 36 +- .../src/components/terminal/mod.rs | 6 + .../src/composite/command_terminal/model.rs | 54 ++- 10 files changed, 620 insertions(+), 92 deletions(-) create mode 100644 crates/chitin-desktop/src/components/terminal/completion.rs diff --git a/crates/chitin-builtin-shell/src/lib.rs b/crates/chitin-builtin-shell/src/lib.rs index 716af7d5..0b2d8806 100644 --- a/crates/chitin-builtin-shell/src/lib.rs +++ b/crates/chitin-builtin-shell/src/lib.rs @@ -11,7 +11,7 @@ mod session; pub use event::{ShellEvent, ShellEventSink}; pub use session::{ - BuiltinShell, BuiltinShellError, BuiltinShellSnapshot, ShellActiveCommand, ShellCommandId, ShellCommandTarget, - ShellExecutionRecord, ShellExecutionResult, ShellExecutionStatus, ShellInvocationSource, ShellLineSubmission, - ShellSubmission, ShellTranscriptContent, ShellTranscriptEntry, + BuiltinShell, BuiltinShellError, BuiltinShellSnapshot, ShellActiveCommand, ShellBuiltinEffect, ShellCommandId, + ShellCommandTarget, ShellExecutionRecord, ShellExecutionResult, ShellExecutionStatus, ShellInvocationSource, + ShellLineSubmission, ShellSubmission, ShellTranscriptContent, ShellTranscriptEntry, }; diff --git a/crates/chitin-builtin-shell/src/session.rs b/crates/chitin-builtin-shell/src/session.rs index db8423ae..ddaa07c5 100644 --- a/crates/chitin-builtin-shell/src/session.rs +++ b/crates/chitin-builtin-shell/src/session.rs @@ -9,7 +9,7 @@ use chitin_command::{ ChitinCommand, CommandEventSink, CommandExecutionContext, CommandExecutionDomain, CommandExecutionEvent, CommandId, CommandMessage, CommandProgress, }; -use chitin_command_line::{BuiltinCommandLine, parse_builtin_command_line}; +use chitin_command_line::{BuiltinCommandLine, ShellBuiltin, parse_builtin_command_line}; use chitin_command_runtime::{CommandExecutionError, CommandExecutor, CommandOutcome}; use chitin_databases::{CancellationToken, PersistedArtifact}; @@ -156,16 +156,26 @@ pub struct ShellSubmission { pub enum ShellLineSubmission { /// A typed command was reserved for execution. Command(ShellSubmission), + /// The shell session applied a built-in command synchronously. + ShellBuiltin(ShellBuiltinEffect), /// Help text should be displayed without invoking an executor. Display(String), } +/// Presentation effect produced by a command owned by the shell session. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShellBuiltinEffect { + /// Remove visible command blocks while preserving navigable history. + ClearScrollback, +} + impl ShellLineSubmission { /// Extracts an executable submission from a command-producing line. pub fn into_command(self) -> Result { match self { Self::Command(submission) => Ok(submission), Self::Display(output) => Err(BuiltinShellError::DisplayOnly { output }), + Self::ShellBuiltin(effect) => Err(BuiltinShellError::BuiltinOnly { effect }), } } } @@ -218,6 +228,12 @@ pub enum BuiltinShellError { /// Help text produced by the command-line grammar. output: String, }, + /// A caller requiring execution received a synchronous shell builtin. + #[error("shell line produced a built-in effect instead of an executable command")] + BuiltinOnly { + /// Effect already applied to the shell session. + effect: ShellBuiltinEffect, + }, /// Another foreground command is still active. #[error("shell command {} is still active", active_id.get())] Busy { @@ -371,9 +387,15 @@ impl BuiltinShell { } }; match parsed { - BuiltinCommandLine::Command(command) => self - .submit_typed(input, command, source) + BuiltinCommandLine::Portable(command) => self + .submit_typed(input, command.into(), source) + .map(ShellLineSubmission::Command), + BuiltinCommandLine::Frontend(command) => self + .submit_typed(input, command.into(), source) .map(ShellLineSubmission::Command), + BuiltinCommandLine::ShellBuiltin(command) => self + .execute_builtin(input, command) + .map(ShellLineSubmission::ShellBuiltin), BuiltinCommandLine::Display(output) => { self.remember_unsubmitted_input(input)?; Ok(ShellLineSubmission::Display(output)) @@ -381,6 +403,37 @@ impl BuiltinShell { } } + /// Applies a command whose state and lifecycle belong to the shell session. + /// + /// # Parameters + /// + /// * `input` is retained in navigable history after successful execution. + /// * `command` identifies the synchronous session mutation to apply. + /// + /// # Returns + /// + /// A presentation effect for the host terminal to mirror. + /// + /// # Errors + /// + /// Returns [`BuiltinShellError::Busy`] while a foreground command is active, + /// or [`BuiltinShellError::StateUnavailable`] when session state is poisoned. + fn execute_builtin(&self, input: String, command: ShellBuiltin) -> Result { + let mut state = self.lock_state()?; + if let Some(active) = state.active.as_ref() { + return Err(BuiltinShellError::Busy { active_id: active.id }); + } + state.history.push(input); + state.history_cursor = None; + state.navigation_draft.clear(); + match command { + ShellBuiltin::Clear => { + state.transcript.clear(); + Ok(ShellBuiltinEffect::ClearScrollback) + } + } + } + /// Submits an already typed command from an agent or application subsystem. /// /// This is the primary non-interactive bridge API. It prevents agents from @@ -645,6 +698,25 @@ impl BuiltinShell { Ok(Some(state.navigation_draft.clone())) } + /// Returns full replacement lines matching the unfinished shell token. + /// + /// # Parameters + /// + /// * `input` is the current editable command line up to the caret. + /// + /// # Returns + /// + /// Sorted, de-duplicated replacement lines, or an empty list when the input + /// is not a completable prefix. + /// + /// # Notes + /// + /// Completion is derived from the built-in grammar alone and never reads + /// session state, so it stays an associated function rather than a method. + pub fn complete(input: &str) -> Vec { + chitin_command_line::complete_builtin_shell_line(input) + } + /// Returns a presentation-safe copy of the current session state. pub fn snapshot(&self) -> Result { let state = self.lock_state()?; @@ -815,6 +887,11 @@ mod tests { Ok(()) } + #[test] + fn completion_should_not_require_session_state() { + assert!(BuiltinShell::complete("cl").contains(&"clear".to_owned())); + } + #[test] fn rejected_input_should_remain_in_history() -> Result<(), BuiltinShellError> { let shell = BuiltinShell::new("."); @@ -848,6 +925,24 @@ mod tests { Ok(()) } + #[test] + fn clear_should_reset_transcript_without_erasing_history() -> Result<(), BuiltinShellError> { + let shell = BuiltinShell::new("."); + let submission = shell.submit("tab.close")?.into_command()?; + shell.complete_frontend(submission.id())?; + + let result = shell.submit("clear")?; + let snapshot = shell.snapshot()?; + + assert!(matches!( + result, + ShellLineSubmission::ShellBuiltin(ShellBuiltinEffect::ClearScrollback) + )); + assert!(snapshot.transcript.is_empty()); + assert_eq!(shell.previous_history("")?.as_deref(), Some("clear")); + Ok(()) + } + #[test] fn cancel_should_update_the_active_command_and_token() -> Result<(), BuiltinShellError> { let shell = BuiltinShell::new("."); @@ -928,11 +1023,12 @@ mod tests { fn typed_agent_submission_should_preserve_source_and_arguments() -> Result<(), BuiltinShellError> { let shell = BuiltinShell::new("."); let parsed = parse_builtin_command_line("structure validate model.pdb --format pdb")?; - let BuiltinCommandLine::Command(parsed) = parsed else { + let BuiltinCommandLine::Portable(parsed) = parsed else { return Err(BuiltinShellError::DisplayOnly { output: "expected an executable structure command".to_owned(), }); }; + let parsed = ChitinCommand::from(parsed); let submission = shell.submit_typed( "agent validation request", diff --git a/crates/chitin-command-line/src/lib.rs b/crates/chitin-command-line/src/lib.rs index 1e0592b3..f8363db3 100644 --- a/crates/chitin-command-line/src/lib.rs +++ b/crates/chitin-command-line/src/lib.rs @@ -10,4 +10,7 @@ mod portable; mod shell; pub use portable::{PortableCommandArgs, PortableCommandLineError}; -pub use shell::{BuiltinCommandLine, CommandLineParseError, parse_builtin_command_line}; +pub use shell::{ + BuiltinCommandLine, BuiltinShellGrammar, CommandLineParseError, ShellBuiltin, complete_builtin_shell_line, + parse_builtin_command_line, +}; diff --git a/crates/chitin-command-line/src/shell.rs b/crates/chitin-command-line/src/shell.rs index 3b2a9042..7bba0990 100644 --- a/crates/chitin-command-line/src/shell.rs +++ b/crates/chitin-command-line/src/shell.rs @@ -1,21 +1,32 @@ -//! Built-in shell parsing around the shared portable command fragments. +//! Built-in shell grammar composed around shared portable command fragments. use std::ffi::OsString; -use chitin_command::{ChitinCommand, CommandId}; -use clap::{ColorChoice, Parser, error::ErrorKind}; +use chitin_command::{CommandId, FrontendCommand, PortableCommand}; +use clap::{ColorChoice, CommandFactory, Parser, Subcommand, error::ErrorKind}; use crate::{PortableCommandArgs, PortableCommandLineError}; /// Result of parsing one built-in shell line. #[derive(Debug)] pub enum BuiltinCommandLine { - /// A typed command ready for execution routing. - Command(ChitinCommand), + /// A typed command executable without desktop state. + Portable(PortableCommand), + /// A typed command requiring desktop application state. + Frontend(FrontendCommand), + /// A command implemented by the shell session itself. + ShellBuiltin(ShellBuiltin), /// Help or version text that should be displayed without execution. Display(String), } +/// Commands whose semantics belong to the built-in shell session. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShellBuiltin { + /// Clear visible scrollback without changing command history. + Clear, +} + /// Failure while tokenizing or parsing built-in shell text. #[derive(Debug, thiserror::Error)] pub enum CommandLineParseError { @@ -36,26 +47,133 @@ pub enum CommandLineParseError { /// Byte offset of the escape character. position: usize, }, - /// Clap rejected the portable command grammar. + /// Clap rejected the composed built-in shell grammar. #[error("{0}")] Grammar(String), + /// A frontend grammar branch has no argument-free command to invoke. + /// + /// This is unreachable while every frontend branch stays argument-free; it + /// exists so the conversion reports a typed failure instead of panicking. + #[error("command '{command}' has no argument-free frontend form")] + MissingFrontendCommand { + /// Stable identity of the frontend command without an invocation form. + command: CommandId, + }, /// A shared command-line value failed domain validation. #[error(transparent)] Portable(#[from] PortableCommandLineError), - /// A stable frontend command identifier was not registered. - #[error("unknown command '{0}'")] - UnknownCommand(String), - /// A frontend command without arguments received trailing values. - #[error("unexpected argument '{value}' for '{command}'")] - UnexpectedArgument { - /// Stable command identifier receiving the extra value. - command: &'static str, - /// Unexpected argument value. - value: String, - }, } -/// Parses one built-in shell line using shared portable command grammar. +/// Complete grammar available inside Chitin's built-in shell. +#[derive(Debug, Parser)] +#[command( + name = "chitin", + about = "Chitin structural biology tools", + color = ColorChoice::Never, + arg_required_else_help = true +)] +pub struct BuiltinShellGrammar { + #[command(subcommand)] + command: BuiltinShellCommandArgs, +} + +/// Top-level commands composed specifically for the built-in shell. +#[derive(Debug, Subcommand)] +enum BuiltinShellCommandArgs { + /// Execute a command shared with non-GUI frontends. + #[command(flatten)] + Portable(PortableCommandArgs), + /// Clear visible terminal scrollback. + Clear, + /// Focus the previous project entry. + #[command(name = "workspace.focus_previous_entry")] + WorkspaceFocusPrevious, + /// Focus the next project entry. + #[command(name = "workspace.focus_next_entry")] + WorkspaceFocusNext, + /// Activate the focused project entry. + #[command(name = "workspace.activate_focused_entry")] + WorkspaceActivateFocused, + /// Focus the first project entry. + #[command(name = "workspace.focus_first_entry")] + WorkspaceFocusFirst, + /// Focus the last project entry. + #[command(name = "workspace.focus_last_entry")] + WorkspaceFocusLast, + /// Show or hide the workspace sidebar. + #[command(name = "workspace.toggle_workspace")] + WorkspaceToggle, + /// Focus the previous document tab. + #[command(name = "tab.focus_previous")] + PanelTabFocusPrevious, + /// Focus the next document tab. + #[command(name = "tab.focus_next")] + PanelTabFocusNext, + /// Close the active document tab. + #[command(name = "tab.close")] + PanelTabClose, + /// Show or hide the command panel. + #[command(name = "application.toggle_command_panel")] + ApplicationToggleCommandPanel, + /// Show or hide the built-in terminal panel. + #[command(name = "application.toggle_terminal")] + ApplicationToggleTerminal, +} + +impl BuiltinShellCommandArgs { + /// Converts the selected grammar branch into its execution-domain result. + fn into_line(self) -> Result { + let line = match self { + Self::Portable(arguments) => BuiltinCommandLine::Portable(arguments.into_command()?), + Self::Clear => BuiltinCommandLine::ShellBuiltin(ShellBuiltin::Clear), + // Frontend branches name only their stable identity. The mapping from + // identity to command stays in `CommandId`, so a grammar branch cannot + // drift away from the command the rest of the application dispatches. + Self::WorkspaceFocusPrevious => { + BuiltinCommandLine::Frontend(frontend_command(CommandId::WorkspaceFocusPrevious)?) + } + Self::WorkspaceFocusNext => BuiltinCommandLine::Frontend(frontend_command(CommandId::WorkspaceFocusNext)?), + Self::WorkspaceActivateFocused => { + BuiltinCommandLine::Frontend(frontend_command(CommandId::WorkspaceActivateFocused)?) + } + Self::WorkspaceFocusFirst => BuiltinCommandLine::Frontend(frontend_command(CommandId::WorkspaceFocusFirst)?), + Self::WorkspaceFocusLast => BuiltinCommandLine::Frontend(frontend_command(CommandId::WorkspaceFocusLast)?), + Self::WorkspaceToggle => BuiltinCommandLine::Frontend(frontend_command(CommandId::WorkspaceToggle)?), + Self::PanelTabFocusPrevious => BuiltinCommandLine::Frontend(frontend_command(CommandId::PanelTabFocusPrevious)?), + Self::PanelTabFocusNext => BuiltinCommandLine::Frontend(frontend_command(CommandId::PanelTabFocusNext)?), + Self::PanelTabClose => BuiltinCommandLine::Frontend(frontend_command(CommandId::PanelTabClose)?), + Self::ApplicationToggleCommandPanel => { + BuiltinCommandLine::Frontend(frontend_command(CommandId::ApplicationToggleCommandPanel)?) + } + Self::ApplicationToggleTerminal => { + BuiltinCommandLine::Frontend(frontend_command(CommandId::ApplicationToggleTerminal)?) + } + }; + Ok(line) + } +} + +/// Resolves a stable identity into the argument-free frontend command to run. +/// +/// # Parameters +/// +/// * `id` is the stable identity named by a built-in shell grammar branch. +/// +/// # Returns +/// +/// The frontend command registered for that identity. +/// +/// # Errors +/// +/// Returns [`CommandLineParseError::MissingFrontendCommand`] when the identity +/// carries required arguments and therefore cannot be invoked from a grammar +/// branch that accepts none. +fn frontend_command(id: CommandId) -> Result { + id.frontend_command_without_arguments() + .ok_or(CommandLineParseError::MissingFrontendCommand { command: id }) +} + +/// Parses one built-in shell line using the composed shell grammar. /// /// # Parameters /// @@ -63,7 +181,7 @@ pub enum CommandLineParseError { /// /// # Returns /// -/// A typed command or display-only help text. +/// A portable command, frontend command, shell builtin, or display-only help. /// /// # Errors /// @@ -71,38 +189,78 @@ pub enum CommandLineParseError { /// domain conversion fails. pub fn parse_builtin_command_line(input: &str) -> Result { let tokens = tokenize(input)?; - let Some(command) = tokens.first().map(String::as_str) else { + if tokens.is_empty() { return Err(CommandLineParseError::Empty); - }; + } + parse_grammar(tokens) +} - if matches!(command, "db" | "databases" | "structure" | "help") || command.starts_with('-') { - return parse_portable(tokens); +/// Generates full-line completion candidates from the built-in shell grammar. +/// +/// # Parameters +/// +/// * `input` is the current editable command line up to the caret. +/// +/// # Returns +/// +/// Complete replacement lines whose current command or option starts with the +/// unfinished token. Invalid or quoted prefixes return no candidates. +pub fn complete_builtin_shell_line(input: &str) -> Vec { + let (head, prefix) = completion_prefix(input); + let Ok(tokens) = tokenize(head) else { + return Vec::new(); + }; + let mut root = BuiltinShellGrammar::command(); + root.build(); + let mut command = &root; + for token in &tokens { + if token.starts_with('-') { + continue; + } + if let Some(subcommand) = command.get_subcommands().find(|subcommand| { + subcommand.get_name() == token || subcommand.get_all_aliases().any(|alias| alias == token.as_str()) + }) { + command = subcommand; + } } - parse_frontend_identifier(command, &tokens[1..]).map(BuiltinCommandLine::Command) + + let mut candidates = command + .get_subcommands() + .map(|subcommand| subcommand.get_name().to_owned()) + .chain( + command + .get_arguments() + .filter_map(|argument| argument.get_long().map(|long| format!("--{long}"))), + ) + .chain( + command + .get_arguments() + .filter_map(|argument| argument.get_short().map(|short| format!("-{short}"))), + ) + .filter(|candidate| candidate.starts_with(prefix)) + .map(|candidate| format!("{head}{candidate}")) + .collect::>(); + candidates.sort(); + candidates.dedup(); + candidates } -#[derive(Debug, Parser)] -#[command( - name = "chitin", - about = "Chitin structural biology tools", - color = ColorChoice::Never, - arg_required_else_help = true -)] -struct BuiltinPortableRoot { - #[command(subcommand)] - command: PortableCommandArgs, +/// Separates completed input from the token currently being completed. +fn completion_prefix(input: &str) -> (&str, &str) { + input + .char_indices() + .rfind(|(_, character)| character.is_whitespace()) + .map_or(("", input), |(index, character)| { + let next = index + character.len_utf8(); + (&input[..next], &input[next..]) + }) } -/// Parses tokenized portable syntax through Clap without terminating the host. -fn parse_portable(tokens: Vec) -> Result { +/// Parses tokenized syntax through Clap without terminating the host process. +fn parse_grammar(tokens: Vec) -> Result { let arguments = std::iter::once(OsString::from("chitin")).chain(tokens.into_iter().map(OsString::from)); - match BuiltinPortableRoot::try_parse_from(arguments) { - Ok(parsed) => parsed - .command - .into_command() - .map(ChitinCommand::from) - .map(BuiltinCommandLine::Command) - .map_err(CommandLineParseError::from), + match BuiltinShellGrammar::try_parse_from(arguments) { + Ok(parsed) => parsed.command.into_line(), Err(error) if matches!( error.kind(), @@ -115,33 +273,6 @@ fn parse_portable(tokens: Vec) -> Result Result { - let id = match command { - "workspace.focus_previous_entry" => CommandId::WorkspaceFocusPrevious, - "workspace.focus_next_entry" => CommandId::WorkspaceFocusNext, - "workspace.activate_focused_entry" => CommandId::WorkspaceActivateFocused, - "workspace.focus_first_entry" => CommandId::WorkspaceFocusFirst, - "workspace.focus_last_entry" => CommandId::WorkspaceFocusLast, - "workspace.toggle_workspace" => CommandId::WorkspaceToggle, - "tab.focus_previous" => CommandId::PanelTabFocusPrevious, - "tab.focus_next" => CommandId::PanelTabFocusNext, - "tab.close" => CommandId::PanelTabClose, - "application.toggle_command_panel" => CommandId::ApplicationToggleCommandPanel, - "application.toggle_terminal" => CommandId::ApplicationToggleTerminal, - _ => return Err(CommandLineParseError::UnknownCommand(command.to_owned())), - }; - if let Some(value) = trailing.first() { - return Err(CommandLineParseError::UnexpectedArgument { - command: id.as_str(), - value: value.clone(), - }); - } - id.frontend_command_without_arguments() - .map(ChitinCommand::from) - .ok_or_else(|| CommandLineParseError::UnknownCommand(command.to_owned())) -} - /// Splits command text while preserving quoted whitespace and escapes. fn tokenize(input: &str) -> Result, CommandLineParseError> { let mut tokens = Vec::new(); @@ -203,7 +334,7 @@ fn normalize_clap_error(message: String) -> String { mod tests { use std::path::Path; - use chitin_command::{DatabaseCommand, PortableCommand, RcsbDownloadArguments}; + use chitin_command::{CommandId, DatabaseCommand, RcsbDownloadArguments}; use chitin_databases::providers::rcsb::StructureFormat; use super::*; @@ -214,12 +345,12 @@ mod tests { assert!(matches!( parsed, - BuiltinCommandLine::Command(ChitinCommand::Portable(PortableCommand::Database( - DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + BuiltinCommandLine::Portable(PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure( + RcsbDownloadArguments { format: StructureFormat::Pdb, output: Some(output), .. - }) + } ))) if output == Path::new("saved structure") )); Ok(()) @@ -237,7 +368,114 @@ mod tests { fn stable_frontend_identifier_should_still_parse() -> Result<(), CommandLineParseError> { let parsed = parse_builtin_command_line("tab.close")?; - assert!(matches!(parsed, BuiltinCommandLine::Command(command) if command.id() == CommandId::PanelTabClose)); + assert!(matches!(parsed, BuiltinCommandLine::Frontend(command) if command.id() == CommandId::PanelTabClose)); + Ok(()) + } + + #[test] + fn clear_should_parse_as_a_shell_builtin() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("clear")?; + + assert!(matches!(parsed, BuiltinCommandLine::ShellBuiltin(ShellBuiltin::Clear))); + Ok(()) + } + + #[test] + fn root_help_should_include_all_execution_domains() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("help")?; + + assert!(matches!(parsed, BuiltinCommandLine::Display(help) + if help.contains("db") && help.contains("tab.close") && help.contains("clear"))); + Ok(()) + } + + #[test] + fn completion_should_follow_the_composed_shell_grammar() { + assert_eq!(complete_builtin_shell_line("db r"), vec!["db rcsb"]); + assert!(complete_builtin_shell_line("tab.").contains(&"tab.close".to_owned())); + assert!(complete_builtin_shell_line("cl").contains(&"clear".to_owned())); + } + + #[test] + fn completion_should_list_every_option_of_a_nested_subcommand() { + // Clap contributes its own `--help` flag, so it is listed alongside the + // options declared by the shared portable fragment. + assert_eq!( + complete_builtin_shell_line("db rcsb download --"), + vec![ + "db rcsb download --format", + "db rcsb download --help", + "db rcsb download --id", + "db rcsb download --output", + ] + ); + } + + #[test] + fn subcommand_help_should_render_from_the_builtin_grammar() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("help structure")?; + + assert!(matches!(parsed, BuiltinCommandLine::Display(help) + if help.contains("inspect") && help.contains("validate"))); + Ok(()) + } + + #[test] + fn nested_subcommand_help_should_render_from_the_builtin_grammar() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("help db rcsb download")?; + + assert!(matches!(parsed, BuiltinCommandLine::Display(help) + if help.contains("--id") && help.contains("--format") && help.contains("--output"))); + Ok(()) + } + + #[test] + fn frontend_grammar_names_should_round_trip_through_command_ids() -> Result<(), CommandLineParseError> { + let names = grammar_subcommand_names(); + let mut frontend_names = 0_usize; + for name in &names { + // Only frontend branches carry a stable identity to compare against; + // portable and built-in names are covered by their own tests. + if let Ok(BuiltinCommandLine::Frontend(command)) = parse_builtin_command_line(name) { + assert_eq!(command.id().as_str(), name); + frontend_names += 1; + } + } + // Guards against the loop going vacuous if every branch stopped parsing. + assert_eq!(frontend_names, frontend_command_id_count()); Ok(()) } + + #[test] + fn builtin_grammar_should_cover_every_frontend_command_id() { + let names = grammar_subcommand_names(); + + for id in CommandId::ALL { + if id.frontend_command_without_arguments().is_some() { + assert!( + names.iter().any(|name| name == id.as_str()), + "built-in grammar is missing a branch for {}", + id.as_str() + ); + } + } + } + + /// Returns every top-level built-in grammar subcommand name, aliases excluded. + fn grammar_subcommand_names() -> Vec { + let mut root = BuiltinShellGrammar::command(); + root.build(); + root + .get_subcommands() + .map(|subcommand| subcommand.get_name().to_owned()) + .collect() + } + + /// Counts the stable identities that have an argument-free frontend command. + fn frontend_command_id_count() -> usize { + CommandId::ALL + .iter() + .filter(|id| id.frontend_command_without_arguments().is_some()) + .count() + } } diff --git a/crates/chitin-command/src/lib.rs b/crates/chitin-command/src/lib.rs index ebb88101..0f80bfef 100644 --- a/crates/chitin-command/src/lib.rs +++ b/crates/chitin-command/src/lib.rs @@ -62,6 +62,27 @@ pub enum CommandId { } impl CommandId { + /// Every stable command identity, in declaration order. + /// + /// Frontends that mirror the command set, such as the built-in shell grammar, + /// iterate this list to prove their own coverage instead of restating it. + pub const ALL: &[Self] = &[ + Self::WorkspaceFocusPrevious, + Self::WorkspaceFocusNext, + Self::WorkspaceActivateFocused, + Self::WorkspaceFocusFirst, + Self::WorkspaceFocusLast, + Self::WorkspaceToggle, + Self::PanelTabFocusPrevious, + Self::PanelTabFocusNext, + Self::PanelTabClose, + Self::DatabaseDownloadRcsbStructure, + Self::ApplicationToggleCommandPanel, + Self::ApplicationToggleTerminal, + Self::StructureInspect, + Self::StructureValidate, + ]; + /// Returns the stable dotted identifier. pub const fn as_str(self) -> &'static str { match self { @@ -441,6 +462,32 @@ mod tests { ); } + #[test] + fn command_id_all_should_cover_every_variant() { + // The or-pattern lists every variant on purpose. Adding a `CommandId` + // without extending `ALL` leaves this match non-exhaustive, so the test + // stops compiling rather than silently skipping the new identity. + for id in CommandId::ALL { + match id { + CommandId::WorkspaceFocusPrevious + | CommandId::WorkspaceFocusNext + | CommandId::WorkspaceActivateFocused + | CommandId::WorkspaceFocusFirst + | CommandId::WorkspaceFocusLast + | CommandId::WorkspaceToggle + | CommandId::PanelTabFocusPrevious + | CommandId::PanelTabFocusNext + | CommandId::PanelTabClose + | CommandId::DatabaseDownloadRcsbStructure + | CommandId::ApplicationToggleCommandPanel + | CommandId::ApplicationToggleTerminal + | CommandId::StructureInspect + | CommandId::StructureValidate => {} + } + } + assert_eq!(CommandId::ALL.len(), 14); + } + #[test] fn database_commands_should_use_the_portable_execution_domain() -> Result<(), Box> { let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { diff --git a/crates/chitin-desktop/src/builtin_shell.rs b/crates/chitin-desktop/src/builtin_shell.rs index 74281487..29d305f2 100644 --- a/crates/chitin-desktop/src/builtin_shell.rs +++ b/crates/chitin-desktop/src/builtin_shell.rs @@ -3,8 +3,8 @@ use std::path::PathBuf; use chitin_builtin_shell::{ - BuiltinShell, BuiltinShellError, ShellCommandId, ShellCommandTarget, ShellExecutionResult, ShellInvocationSource, - ShellLineSubmission, ShellSubmission, + BuiltinShell, BuiltinShellError, ShellBuiltinEffect, ShellCommandId, ShellCommandTarget, ShellExecutionResult, + ShellInvocationSource, ShellLineSubmission, ShellSubmission, }; use chitin_command::{ChitinCommand, CommandEventSink, CommandExecutionContext}; use gpui::{AppContext, AsyncApp, Context, WeakEntity, Window}; @@ -174,6 +174,8 @@ impl DesktopShellTask { pub enum DesktopShellDispatch { /// Clap produced help text without scheduling a command. Display { output: String }, + /// The shell session synchronously produced a presentation effect. + ShellBuiltin { effect: ShellBuiltinEffect }, /// The command mutated desktop state synchronously. Frontend { command_id: ShellCommandId }, /// The command is running through the shared background executor. @@ -236,6 +238,7 @@ impl ChitinApp { ) -> Result { match self.builtin_shell.submit_line(input, source)? { ShellLineSubmission::Command(submission) => self.route_builtin_shell_submission(submission, window, cx), + ShellLineSubmission::ShellBuiltin(effect) => Ok(DesktopShellDispatch::ShellBuiltin { effect }), ShellLineSubmission::Display(output) => Ok(DesktopShellDispatch::Display { output }), } } diff --git a/crates/chitin-desktop/src/components/terminal/completion.rs b/crates/chitin-desktop/src/components/terminal/completion.rs new file mode 100644 index 00000000..4ef2bbdb --- /dev/null +++ b/crates/chitin-desktop/src/components/terminal/completion.rs @@ -0,0 +1,59 @@ +//! Completion-candidate handling for the desktop terminal prompt. + +/// Action the live prompt takes for one set of completion candidates. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum CompletionAction { + /// Replace the line with the only match, followed by a word separator. + Insert(String), + /// List candidates without changing the editable line. + List(Vec), + /// Leave the line untouched. + None, +} + +/// Decides what one completion result does to the live prompt. +/// +/// # Parameters +/// +/// * `candidates` are the full replacement lines reported by shell completion. +/// +/// # Returns +/// +/// A single candidate is inserted so repeated Tab presses converge on one +/// command. Several candidates are listed instead, because the grammar offers +/// no unambiguous choice and overwriting the line would discard user input. +pub(super) fn completion_action(candidates: Vec) -> CompletionAction { + match candidates.as_slice() { + [] => CompletionAction::None, + [line] => CompletionAction::Insert(format!("{line} ")), + _ => CompletionAction::List(candidates), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_empty_completion_should_leave_the_line_untouched() { + assert_eq!(completion_action(Vec::new()), CompletionAction::None); + } + + #[test] + fn a_unique_completion_should_be_inserted_with_a_separator() { + assert_eq!( + completion_action(vec!["clear".to_owned()]), + CompletionAction::Insert("clear ".to_owned()) + ); + } + + #[test] + fn ambiguous_completion_should_list_candidates_without_editing() { + let candidates = vec!["tab.close".to_owned(), "tab.focus_next".to_owned()]; + + assert_eq!( + completion_action(candidates.clone()), + CompletionAction::List(candidates) + ); + } +} diff --git a/crates/chitin-desktop/src/components/terminal/controller.rs b/crates/chitin-desktop/src/components/terminal/controller.rs index cb3f57be..0c1b1d96 100644 --- a/crates/chitin-desktop/src/components/terminal/controller.rs +++ b/crates/chitin-desktop/src/components/terminal/controller.rs @@ -1,11 +1,12 @@ //! Keyboard and command-execution control for the desktop terminal. -use chitin_builtin_shell::ShellInvocationSource; -use chitin_ui::composite::command_terminal::CommandTerminalStatus; +use chitin_builtin_shell::{BuiltinShell, ShellBuiltinEffect, ShellInvocationSource}; +use chitin_ui::{composite::command_terminal::CommandTerminalStatus, primitive::terminal::TerminalLine}; use gpui::{AppContext, AsyncApp, Context, KeyDownEvent, WeakEntity, Window}; use super::{ TERMINAL_DOCK_ITEM_ID, + completion::{CompletionAction, completion_action}, presenter::{shell_output_lines, shell_terminal_status, terminal_outcome}, }; use crate::{app::ChitinApp, builtin_shell::DesktopShellDispatch}; @@ -44,6 +45,12 @@ impl ChitinApp { ); }); } + Ok(DesktopShellDispatch::ShellBuiltin { + effect: ShellBuiltinEffect::ClearScrollback, + }) => { + self.terminal_panel.clear_bindings(); + controls.terminal.update(cx, |terminal, cx| terminal.clear(cx)); + } Ok(DesktopShellDispatch::Frontend { command_id }) => { self.terminal_panel.bind(command_id, terminal_id); controls.terminal.update(cx, |terminal, cx| { @@ -149,6 +156,31 @@ impl ChitinApp { return; } + if key == "tab" { + let modifiers = &event.keystroke.modifiers; + if modifiers.control || modifiers.alt || modifiers.platform { + return; + } + let line = input.read(cx).text().to_owned(); + match completion_action(BuiltinShell::complete(&line)) { + CompletionAction::Insert(completed) => { + input.update(cx, |input, cx| { + input.set_text(completed, cx); + }); + } + CompletionAction::List(candidates) => { + let lines = candidates.into_iter().map(TerminalLine::plain).collect::>(); + controls + .terminal + .update(cx, |terminal, cx| terminal.push_notice(line, lines, cx)); + } + CompletionAction::None => {} + } + cx.stop_propagation(); + cx.notify(); + return; + } + let history = match key { "up" => self.builtin_shell().previous_history(input.read(cx).text()), "down" => self.builtin_shell().next_history(), diff --git a/crates/chitin-desktop/src/components/terminal/mod.rs b/crates/chitin-desktop/src/components/terminal/mod.rs index 73193ecc..5f02ea4d 100644 --- a/crates/chitin-desktop/src/components/terminal/mod.rs +++ b/crates/chitin-desktop/src/components/terminal/mod.rs @@ -1,5 +1,6 @@ //! Desktop adapter for the reusable structured command terminal. +mod completion; mod controller; mod presenter; mod render; @@ -55,6 +56,11 @@ impl TerminalPanelState { pub(super) fn bind(&mut self, shell_id: ShellCommandId, terminal_id: CommandTerminalId) { self.bindings.push(TerminalShellBinding { shell_id, terminal_id }); } + + /// Removes view bindings for command blocks cleared from scrollback. + pub(super) fn clear_bindings(&mut self) { + self.bindings.clear(); + } } impl Default for TerminalPanelState { diff --git a/crates/chitin-ui/src/composite/command_terminal/model.rs b/crates/chitin-ui/src/composite/command_terminal/model.rs index c0728b10..af961991 100644 --- a/crates/chitin-ui/src/composite/command_terminal/model.rs +++ b/crates/chitin-ui/src/composite/command_terminal/model.rs @@ -120,11 +120,7 @@ impl CommandTerminalState { /// /// A terminal-local identity used to attach subsequent output and results. pub fn begin_submission(&mut self, input: String, cx: &mut Context) -> CommandTerminalId { - let id = CommandTerminalId(self.next_id); - self.next_id = self.next_id.saturating_add(1); - if self.blocks.len() == MAX_COMMAND_BLOCKS { - self.blocks.pop_front(); - } + let id = self.reserve_block(); self.blocks.push_back(CommandTerminalBlock { id, prompt: self.prompt.clone(), @@ -142,6 +138,33 @@ impl CommandTerminalState { id } + /// Appends a finished block for output that no command produced. + /// + /// The live prompt keeps its text and stays editable, so completion + /// candidates can be listed above the line still being edited. + /// + /// # Parameters + /// + /// * `input` is the unfinished line the output was produced for. + /// * `lines` are the output rows appended below the echoed line. + /// * `cx` reveals the new tail and schedules viewport repainting. + /// + /// # Returns + /// + /// This function returns `()` and never claims the foreground slot. + pub fn push_notice(&mut self, input: String, lines: Vec, cx: &mut Context) { + let id = self.reserve_block(); + self.blocks.push_back(CommandTerminalBlock { + id, + prompt: self.prompt.clone(), + input, + output: Vec::new(), + result: lines, + status: CommandTerminalStatus::Succeeded, + }); + self.reveal_tail(cx); + } + /// Replaces transient output for one command without growing scrollback. /// /// # Parameters @@ -216,11 +239,32 @@ impl CommandTerminalState { ); } + /// Clears submitted blocks and restores the editable live prompt. + pub fn clear(&mut self, cx: &mut Context) { + self.blocks.clear(); + self.active = None; + self.input.update(cx, |input, cx| { + input.clear(cx); + input.set_disabled(false, cx); + }); + self.reveal_tail(cx); + } + /// Replaces the prompt used by subsequent commands. pub fn set_prompt(&mut self, prompt: TerminalLine) { self.prompt = prompt; } + /// Reserves the next block identity, evicting the oldest block at capacity. + fn reserve_block(&mut self) -> CommandTerminalId { + let id = CommandTerminalId(self.next_id); + self.next_id = self.next_id.saturating_add(1); + if self.blocks.len() == MAX_COMMAND_BLOCKS { + self.blocks.pop_front(); + } + id + } + /// Requests that the newest command and live prompt remain visible. fn reveal_tail(&self, cx: &mut Context) { self.viewport.update(cx, |viewport, _| viewport.reveal_tail()); From b8d12848bd37df7899b57b2d95b734e3613a58a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 21 Sep 2026 19:29:39 +0800 Subject: [PATCH 15/32] refactor(command): consolidate command models and execution modules Merge the command-line and command-runtime crates into chitin-command, and organize its implementation into model, execution, grammar, and output modules. Move the built-in shell grammar into chitin-builtin-shell, then update the CLI and desktop integrations to use the new command architecture and output presentation flow. --- Cargo.lock | 29 +- Cargo.toml | 2 - crates/chitin-builtin-shell/Cargo.toml | 3 +- .../src/grammar.rs} | 7 +- crates/chitin-builtin-shell/src/lib.rs | 7 +- crates/chitin-builtin-shell/src/session.rs | 8 +- crates/chitin-cli/Cargo.toml | 4 - crates/chitin-cli/src/cli.rs | 17 +- crates/chitin-cli/src/error.rs | 12 +- crates/chitin-cli/src/main.rs | 4 +- crates/chitin-cli/src/structure.rs | 239 +------- crates/chitin-command-line/Cargo.toml | 15 - crates/chitin-command-line/src/lib.rs | 16 - crates/chitin-command-runtime/Cargo.toml | 18 - crates/chitin-command/Cargo.toml | 6 + .../{execution.rs => execution/context.rs} | 0 .../src/execution}/database.rs | 8 +- .../src/execution/mod.rs} | 20 +- .../src/execution}/structure.rs | 6 +- crates/chitin-command/src/grammar/mod.rs | 5 + .../src/grammar}/portable.rs | 2 +- crates/chitin-command/src/lib.rs | 554 +----------------- .../src/{ => model}/application.rs | 0 .../src/{ => model}/database.rs | 0 crates/chitin-command/src/model/mod.rs | 533 +++++++++++++++++ .../src/{ => model}/panel_tab.rs | 0 .../src/{ => model}/structure.rs | 0 .../src/{ => model}/workspace.rs | 0 crates/chitin-command/src/output/mod.rs | 460 +++++++++++++++ crates/chitin-desktop/Cargo.toml | 1 - crates/chitin-desktop/src/app.rs | 2 +- crates/chitin-desktop/src/builtin_shell.rs | 5 +- .../src/components/terminal/presenter.rs | 104 ++-- crates/chitin-desktop/src/portable_command.rs | 6 +- 34 files changed, 1161 insertions(+), 932 deletions(-) rename crates/{chitin-command-line/src/shell.rs => chitin-builtin-shell/src/grammar.rs} (99%) delete mode 100644 crates/chitin-command-line/Cargo.toml delete mode 100644 crates/chitin-command-line/src/lib.rs delete mode 100644 crates/chitin-command-runtime/Cargo.toml rename crates/chitin-command/src/{execution.rs => execution/context.rs} (100%) rename crates/{chitin-command-runtime/src => chitin-command/src/execution}/database.rs (97%) rename crates/{chitin-command-runtime/src/lib.rs => chitin-command/src/execution/mod.rs} (93%) rename crates/{chitin-command-runtime/src => chitin-command/src/execution}/structure.rs (96%) create mode 100644 crates/chitin-command/src/grammar/mod.rs rename crates/{chitin-command-line/src => chitin-command/src/grammar}/portable.rs (99%) rename crates/chitin-command/src/{ => model}/application.rs (100%) rename crates/chitin-command/src/{ => model}/database.rs (100%) create mode 100644 crates/chitin-command/src/model/mod.rs rename crates/chitin-command/src/{ => model}/panel_tab.rs (100%) rename crates/chitin-command/src/{ => model}/structure.rs (100%) rename crates/chitin-command/src/{ => model}/workspace.rs (100%) create mode 100644 crates/chitin-command/src/output/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 19a9889e..265a764d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -686,16 +686,12 @@ dependencies = [ name = "chitin" version = "0.1.0" dependencies = [ - "chitin-bio", "chitin-command", - "chitin-command-line", - "chitin-command-runtime", "chitin-databases", "clap", "clap_complete", "console", "indicatif", - "serde_json", "thiserror 2.0.20", "tokio", ] @@ -731,9 +727,8 @@ name = "chitin-builtin-shell" version = "0.1.0" dependencies = [ "chitin-command", - "chitin-command-line", - "chitin-command-runtime", "chitin-databases", + "clap", "thiserror 2.0.20", "tokio", ] @@ -741,28 +736,11 @@ dependencies = [ [[package]] name = "chitin-command" version = "0.1.0" -dependencies = [ - "chitin-databases", - "thiserror 2.0.20", -] - -[[package]] -name = "chitin-command-line" -version = "0.1.0" -dependencies = [ - "chitin-command", - "chitin-databases", - "clap", - "thiserror 2.0.20", -] - -[[package]] -name = "chitin-command-runtime" -version = "0.1.0" dependencies = [ "chitin-bio", - "chitin-command", "chitin-databases", + "clap", + "serde_json", "thiserror 2.0.20", "tokio", ] @@ -794,7 +772,6 @@ dependencies = [ "chitin-bio", "chitin-builtin-shell", "chitin-command", - "chitin-command-runtime", "chitin-databases", "chitin-molecule-renderer", "chitin-ui", diff --git a/Cargo.toml b/Cargo.toml index f4168b39..95a0af8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,6 @@ members = [ "crates/chitin-builtin-shell", "crates/chitin-command", - "crates/chitin-command-line", - "crates/chitin-command-runtime", "crates/chitin-cli", "crates/chitin-bio", "crates/chitin-bio-macros", diff --git a/crates/chitin-builtin-shell/Cargo.toml b/crates/chitin-builtin-shell/Cargo.toml index 957fb333..0ca847ae 100644 --- a/crates/chitin-builtin-shell/Cargo.toml +++ b/crates/chitin-builtin-shell/Cargo.toml @@ -7,9 +7,8 @@ repository.workspace = true [dependencies] chitin-command = { path = "../chitin-command" } -chitin-command-line = { path = "../chitin-command-line" } -chitin-command-runtime = { path = "../chitin-command-runtime" } chitin-databases = { path = "../chitin-databases" } +clap = { version = "4.6", features = ["derive"] } thiserror = "2.0" [dev-dependencies] diff --git a/crates/chitin-command-line/src/shell.rs b/crates/chitin-builtin-shell/src/grammar.rs similarity index 99% rename from crates/chitin-command-line/src/shell.rs rename to crates/chitin-builtin-shell/src/grammar.rs index 7bba0990..e3db7ac1 100644 --- a/crates/chitin-command-line/src/shell.rs +++ b/crates/chitin-builtin-shell/src/grammar.rs @@ -2,11 +2,12 @@ use std::ffi::OsString; -use chitin_command::{CommandId, FrontendCommand, PortableCommand}; +use chitin_command::{ + CommandId, FrontendCommand, PortableCommand, + grammar::{PortableCommandArgs, PortableCommandLineError}, +}; use clap::{ColorChoice, CommandFactory, Parser, Subcommand, error::ErrorKind}; -use crate::{PortableCommandArgs, PortableCommandLineError}; - /// Result of parsing one built-in shell line. #[derive(Debug)] pub enum BuiltinCommandLine { diff --git a/crates/chitin-builtin-shell/src/lib.rs b/crates/chitin-builtin-shell/src/lib.rs index 0b2d8806..ad6f01e7 100644 --- a/crates/chitin-builtin-shell/src/lib.rs +++ b/crates/chitin-builtin-shell/src/lib.rs @@ -3,13 +3,18 @@ //! //! This crate is independent of GPUI and terminal emulation. It parses one //! command line at a time, retains navigation and execution history, forwards -//! portable commands to `chitin-command-runtime`, and returns frontend commands +//! portable commands to `chitin-command::execution`, and returns frontend commands //! to the host application for execution. mod event; +mod grammar; mod session; pub use event::{ShellEvent, ShellEventSink}; +pub use grammar::{ + BuiltinCommandLine, BuiltinShellGrammar, CommandLineParseError, ShellBuiltin, complete_builtin_shell_line, + parse_builtin_command_line, +}; pub use session::{ BuiltinShell, BuiltinShellError, BuiltinShellSnapshot, ShellActiveCommand, ShellBuiltinEffect, ShellCommandId, ShellCommandTarget, ShellExecutionRecord, ShellExecutionResult, ShellExecutionStatus, ShellInvocationSource, diff --git a/crates/chitin-builtin-shell/src/session.rs b/crates/chitin-builtin-shell/src/session.rs index ddaa07c5..46564bd0 100644 --- a/crates/chitin-builtin-shell/src/session.rs +++ b/crates/chitin-builtin-shell/src/session.rs @@ -5,12 +5,12 @@ use std::{ sync::{Arc, Mutex, MutexGuard}, }; +use crate::grammar::{BuiltinCommandLine, ShellBuiltin, parse_builtin_command_line}; use chitin_command::{ ChitinCommand, CommandEventSink, CommandExecutionContext, CommandExecutionDomain, CommandExecutionEvent, CommandId, CommandMessage, CommandProgress, }; -use chitin_command_line::{BuiltinCommandLine, ShellBuiltin, parse_builtin_command_line}; -use chitin_command_runtime::{CommandExecutionError, CommandExecutor, CommandOutcome}; +use chitin_command::{CommandExecutionError, CommandExecutor, CommandOutcome}; use chitin_databases::{CancellationToken, PersistedArtifact}; use crate::{ShellEvent, ShellEventSink}; @@ -221,7 +221,7 @@ pub struct ShellExecutionResult { pub enum BuiltinShellError { /// The submitted command line is syntactically or semantically invalid. #[error(transparent)] - Parse(#[from] chitin_command_line::CommandLineParseError), + Parse(#[from] crate::grammar::CommandLineParseError), /// A caller requiring execution received display-only help text. #[error("shell line produced display output instead of an executable command")] DisplayOnly { @@ -714,7 +714,7 @@ impl BuiltinShell { /// Completion is derived from the built-in grammar alone and never reads /// session state, so it stays an associated function rather than a method. pub fn complete(input: &str) -> Vec { - chitin_command_line::complete_builtin_shell_line(input) + crate::grammar::complete_builtin_shell_line(input) } /// Returns a presentation-safe copy of the current session state. diff --git a/crates/chitin-cli/Cargo.toml b/crates/chitin-cli/Cargo.toml index 2c8d705e..56d859c5 100644 --- a/crates/chitin-cli/Cargo.toml +++ b/crates/chitin-cli/Cargo.toml @@ -25,15 +25,11 @@ path = "src/main.rs" [dependencies] chitin-command = { version = "0.1.0", path = "../chitin-command" } -chitin-command-line = { version = "0.1.0", path = "../chitin-command-line" } -chitin-command-runtime = { version = "0.1.0", path = "../chitin-command-runtime" } -chitin-bio = { version = "0.1.0", path = "../chitin-bio" } chitin-databases = { version = "0.1.0", path = "../chitin-databases" } clap = { version = "4.6", features = ["derive"] } clap_complete = "4.6" console = "0.16" indicatif = "0.18" -serde_json = "1.0" thiserror = "2.0" tokio = { version = "1.52", features = ["macros", "rt-multi-thread"] } diff --git a/crates/chitin-cli/src/cli.rs b/crates/chitin-cli/src/cli.rs index 080140c6..a6e8941f 100644 --- a/crates/chitin-cli/src/cli.rs +++ b/crates/chitin-cli/src/cli.rs @@ -3,10 +3,9 @@ use std::{io::Read, path::PathBuf}; use chitin_command::{ - CommandExecutionContext, DatabaseCommand, PortableCommand, RcsbDownloadArguments, StructureCommand, + CommandExecutionContext, CommandExecutor, CommandReportStatus, DatabaseCommand, PortableCommand, PortableCommandArgs, + RcsbDownloadArguments, StructureCommand, }; -use chitin_command_line::PortableCommandArgs; -use chitin_command_runtime::CommandExecutor; use chitin_databases::ClientConfig; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{Shell, generate}; @@ -40,18 +39,18 @@ pub(crate) enum CliCommand { /// /// # Returns /// -/// Returns `Ok(())` after execution or completion generation finishes. +/// The report status after execution or completion generation finishes. /// /// # Errors /// /// Returns [`CliError`] when typed conversion, execution, or output rendering /// fails. -pub(crate) async fn dispatch(command: CliCommand) -> Result<(), CliError> { +pub(crate) async fn dispatch(command: CliCommand) -> Result { match command { CliCommand::Completions { shell } => { let mut command = Cli::command(); generate(shell, &mut command, "chitin", &mut std::io::stdout()); - Ok(()) + Ok(CommandReportStatus::Succeeded) } CliCommand::Portable(arguments) => dispatch_command(arguments.into_command()?).await, } @@ -65,17 +64,17 @@ pub(crate) async fn dispatch(command: CliCommand) -> Result<(), CliError> { /// /// # Returns /// -/// Returns `Ok(())` after execution and frontend-specific rendering complete. +/// The rendered command's domain-level completion status. /// /// # Errors /// /// Returns [`CliError`] when process context, command execution, or output /// rendering fails. -async fn dispatch_command(command: PortableCommand) -> Result<(), CliError> { +async fn dispatch_command(command: PortableCommand) -> Result { let context = execution_context(&command)?; let executor = CommandExecutor::new(ClientConfig::default()); let outcome = executor.execute(command, context, terminal_event_sink()).await?; - crate::structure::render_outcome(outcome) + Ok(crate::structure::render_outcome(&outcome)) } /// Builds frontend-neutral execution context from the current process state. diff --git a/crates/chitin-cli/src/error.rs b/crates/chitin-cli/src/error.rs index 02c445a2..1f8d137b 100644 --- a/crates/chitin-cli/src/error.rs +++ b/crates/chitin-cli/src/error.rs @@ -1,16 +1,14 @@ //! Errors produced while parsing and executing CLI workflows. -use std::path::PathBuf; - /// Error returned by the Chitin CLI command handlers. #[derive(Debug, thiserror::Error)] pub(crate) enum CliError { /// Shared command-line arguments could not be converted into a typed command. #[error(transparent)] - CommandLine(#[from] chitin_command_line::PortableCommandLineError), + CommandLine(#[from] chitin_command::PortableCommandLineError), /// A portable typed command could not be executed. #[error(transparent)] - CommandExecution(#[from] chitin_command_runtime::CommandExecutionError), + CommandExecution(#[from] chitin_command::CommandExecutionError), /// No platform home directory variable was available. #[error("could not determine the home directory; set HOME or USERPROFILE")] HomeDirectory, @@ -20,10 +18,4 @@ pub(crate) enum CliError { /// Standard input could not be read for a structure command. #[error("failed to read structure data from standard input: {0}")] StandardInput(std::io::Error), - /// The parsed structure violated a model invariant. - #[error("structure validation failed for `{path}`: {message}")] - StructureValidation { path: PathBuf, message: String }, - /// JSON output could not be serialized. - #[error("failed to serialize structure output: {0}")] - Json(#[from] serde_json::Error), } diff --git a/crates/chitin-cli/src/main.rs b/crates/chitin-cli/src/main.rs index fe9c7898..c038a61c 100644 --- a/crates/chitin-cli/src/main.rs +++ b/crates/chitin-cli/src/main.rs @@ -8,6 +8,7 @@ mod structure; use std::process::ExitCode; +use chitin_command::CommandReportStatus; use clap::Parser; use console::Style; @@ -18,7 +19,8 @@ use crate::{cli::Cli, error::CliError}; async fn main() -> ExitCode { let cli = Cli::parse(); match cli::dispatch(cli.command).await { - Ok(()) => ExitCode::SUCCESS, + Ok(CommandReportStatus::Succeeded) => ExitCode::SUCCESS, + Ok(CommandReportStatus::Failed) => ExitCode::FAILURE, Err(error) => report_error(error), } } diff --git a/crates/chitin-cli/src/structure.rs b/crates/chitin-cli/src/structure.rs index bf292d35..7f208d3e 100644 --- a/crates/chitin-cli/src/structure.rs +++ b/crates/chitin-cli/src/structure.rs @@ -1,221 +1,38 @@ -//! Terminal rendering for local structure command outcomes. +//! ANSI presentation adapter for shared command reports. -use std::path::Path; - -use chitin_bio::structure::{Structure, StructureParseResult}; -use chitin_command::CommandOutputFormat; -use chitin_command_runtime::{CommandOutcome, StructureInspection, StructureValidation}; -use chitin_databases::providers::rcsb::StructureFormat; +use chitin_command::{ + CommandOutcome, CommandOutputTone, CommandReportStatus, render_outcome as render_command_outcome, +}; use console::Style; -use serde_json::{Value, json}; - -use crate::error::CliError; - -/// Renders a portable command outcome for the CLI frontend. -pub(crate) fn render_outcome(outcome: CommandOutcome) -> Result<(), CliError> { - match outcome { - CommandOutcome::DatabaseDownload { .. } => Ok(()), - CommandOutcome::StructureInspection(inspection) => print_inspection(&inspection), - CommandOutcome::StructureValidation(validation) => print_validation(&validation), - } -} -/// Prints a structure summary in text or JSON form. -fn print_inspection(inspection: &StructureInspection) -> Result<(), CliError> { - match inspection.output { - CommandOutputFormat::Text => print_text_summary( - &inspection.path, - inspection.format, - inspection.byte_count, - &inspection.parsed, - inspection.verbose, - ), - CommandOutputFormat::Json => print_json_summary( - &inspection.path, - inspection.format, - inspection.byte_count, - &inspection.parsed, - inspection.verbose, - ), - } -} - -/// Verifies structure invariants and reports the result. -fn print_validation(validation: &StructureValidation) -> Result<(), CliError> { - match validation.output { - CommandOutputFormat::Text => { - if let Some(message) = validation.error.as_ref() { - let error = Style::new().red().bold(); - eprintln!("{} {}", error.apply_to("✗ Invalid structure:"), message); - return Err(CliError::StructureValidation { - path: validation.path.clone(), - message: message.clone(), - }); - } - let success = Style::new().green().bold(); - println!( - "{} {} ({})", - success.apply_to("✓ Valid structure:"), - validation.path.display(), - validation.format.label() - ); - } - CommandOutputFormat::Json => { - let value = json!({ - "path": validation.path.display().to_string(), - "format": validation.format.id(), - "valid": validation.is_valid(), - "error": validation.error, - }); - println!("{}", serde_json::to_string_pretty(&value)?); - if let Some(message) = value.get("error").and_then(Value::as_str) { - return Err(CliError::StructureValidation { - path: validation.path.clone(), - message: message.to_owned(), - }); - } +/// Writes a shared command outcome with process-terminal styling. +/// +/// # Parameters +/// +/// * `outcome` is the frontend-independent result returned by the executor. +/// +/// # Returns +/// +/// The domain-level completion status used as the process exit status. +pub(crate) fn render_outcome(outcome: &CommandOutcome) -> CommandReportStatus { + let report = render_command_outcome(outcome); + for line in &report.lines { + for span in &line.spans { + print!("{}", style(span.tone).apply_to(&span.text)); } - } - Ok(()) -} - -/// Prints the human-readable inspection summary with terminal color accents. -fn print_text_summary( - path: &Path, - format: StructureFormat, - byte_count: usize, - parsed: &StructureParseResult, - verbose: bool, -) -> Result<(), CliError> { - let heading = Style::new().cyan().bold(); - let label = Style::new().dim(); - let value = Style::new().green().bold(); - let warning = Style::new().yellow(); - println!("{} {}", heading.apply_to("Structure"), path.display()); - println!("{} {}", label.apply_to("Format:"), value.apply_to(format.label())); - println!("{} {}", label.apply_to("Bytes:"), byte_count); - println!(); - for (name, count) in summary_counts(&parsed.structure) { - println!("{} {}", label.apply_to(format!("{name}:")), value.apply_to(count)); - } - if !parsed.diagnostics.is_empty() { - println!("{} {}", warning.apply_to("Diagnostics:"), parsed.diagnostics.len()); - } - if verbose { println!(); - println!("{}", heading.apply_to("Details")); - println!("{} {:#?}", label.apply_to("Metadata:"), parsed.structure.metadata()); - println!( - "{} {:?}", - label.apply_to("Chains:"), - parsed - .structure - .chains() - .iter() - .map(|chain| chain.auth_id.as_deref().or(chain.label_id.as_deref())) - .collect::>() - ); - if !parsed.diagnostics.is_empty() { - println!("{} {:#?}", warning.apply_to("Diagnostics:"), parsed.diagnostics); - } } - Ok(()) -} - -/// Prints the stable machine-readable inspection summary. -fn print_json_summary( - path: &Path, - format: StructureFormat, - byte_count: usize, - parsed: &StructureParseResult, - verbose: bool, -) -> Result<(), CliError> { - let mut value = summary_value(path, format, byte_count, &parsed.structure, parsed.diagnostics.len()); - if verbose { - value["metadata"] = metadata_value(&parsed.structure); - value["diagnostics_detail"] = json!( - parsed - .diagnostics - .iter() - .map(|diagnostic| json!({ - "code": diagnostic.code, - "line": diagnostic.line, - "severity": format!("{:?}", diagnostic.severity), - "message": diagnostic.message, - })) - .collect::>() - ); - } - println!("{}", serde_json::to_string_pretty(&value)?); - Ok(()) -} - -/// Builds a stable JSON representation of the currently parsed metadata. -fn metadata_value(structure: &Structure) -> Value { - json!({ - "classification": structure.metadata().classification, - "identifier": structure.metadata().identifier, - "unit_cell": structure.metadata().unit_cell.as_ref().map(|cell| json!({ - "lengths": cell.lengths, - "angles": cell.angles, - })), - "symmetry": structure.metadata().symmetry.as_ref().map(|symmetry| json!({ - "space_group_name": symmetry.space_group_name, - "international_tables_number": symmetry.international_tables_number, - })), - "assembly": { - "operations": structure.metadata().assembly.operations.iter().map(|operation| json!({ - "id": operation.id, - "rotation": operation.rotation, - "translation": operation.translation, - })).collect::>(), - "assemblies": structure.metadata().assembly.assemblies.iter().map(|assembly| json!({ - "id": assembly.id, - "details": assembly.details, - "generations": assembly.generations.iter().map(|generation| json!({ - "asym_ids": generation.asym_ids, - "auth_asym_ids": generation.auth_asym_ids, - "entity_instance_ids": generation.entity_instance_ids, - "operator_expression": generation.operator_expression, - })).collect::>(), - })).collect::>(), - }, - }) -} - -/// Returns the stable count fields shared by text and JSON summaries. -fn summary_counts(structure: &Structure) -> Vec<(&'static str, usize)> { - vec![ - ("Models", structure.models().len()), - ("Chains", structure.chains().len()), - ("Residues", structure.residues().len()), - ("Atoms", structure.atoms().len()), - ("Bonds", structure.bonds().len()), - ("Polymer entities", structure.polymer_entities().len()), - ("Missing polymer residues", structure.missing_polymer_residues().len()), - ("Secondary ranges", structure.secondary_ranges().len()), - ("Assembly operations", structure.metadata().assembly.operations.len()), - ("Biological assemblies", structure.metadata().assembly.assemblies.len()), - ] + report.status } -/// Builds the JSON object used by `inspect --output json`. -fn summary_value( - path: &Path, - format: StructureFormat, - byte_count: usize, - structure: &Structure, - diagnostics: usize, -) -> Value { - let counts = summary_counts(structure); - let mut value = json!({ - "path": path.display().to_string(), - "format": format.id(), - "bytes": byte_count, - "diagnostics": diagnostics, - }); - for (name, count) in counts { - value[name.to_ascii_lowercase().replace(' ', "_")] = json!(count); +/// Maps a frontend-neutral output tone to ANSI terminal styling. +fn style(tone: CommandOutputTone) -> Style { + match tone { + CommandOutputTone::Primary => Style::new(), + CommandOutputTone::Secondary => Style::new().dim(), + CommandOutputTone::Heading => Style::new().cyan().bold(), + CommandOutputTone::Success => Style::new().green().bold(), + CommandOutputTone::Warning => Style::new().yellow(), + CommandOutputTone::Error => Style::new().red().bold(), } - value } diff --git a/crates/chitin-command-line/Cargo.toml b/crates/chitin-command-line/Cargo.toml deleted file mode 100644 index 797cf49e..00000000 --- a/crates/chitin-command-line/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "chitin-command-line" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -chitin-command = { path = "../chitin-command" } -chitin-databases = { path = "../chitin-databases" } -clap = { version = "4.6", features = ["derive"] } -thiserror = "2.0" - -[lints] -workspace = true diff --git a/crates/chitin-command-line/src/lib.rs b/crates/chitin-command-line/src/lib.rs deleted file mode 100644 index f8363db3..00000000 --- a/crates/chitin-command-line/src/lib.rs +++ /dev/null @@ -1,16 +0,0 @@ -#![forbid(unsafe_code)] -//! Shared command-line grammar for Chitin's textual frontends. -//! -//! This crate owns the portable `db` and `structure` command fragments used by -//! both the process CLI and the built-in shell. Each frontend composes these -//! fragments into its own root grammar, so frontend-specific commands do not -//! need to appear in every textual interface. - -mod portable; -mod shell; - -pub use portable::{PortableCommandArgs, PortableCommandLineError}; -pub use shell::{ - BuiltinCommandLine, BuiltinShellGrammar, CommandLineParseError, ShellBuiltin, complete_builtin_shell_line, - parse_builtin_command_line, -}; diff --git a/crates/chitin-command-runtime/Cargo.toml b/crates/chitin-command-runtime/Cargo.toml deleted file mode 100644 index 638a4ecc..00000000 --- a/crates/chitin-command-runtime/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "chitin-command-runtime" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -chitin-bio = { path = "../chitin-bio" } -chitin-command = { path = "../chitin-command" } -chitin-databases = { path = "../chitin-databases" } -thiserror = "2.0" - -[dev-dependencies] -tokio = { version = "1.52", features = ["macros", "rt"] } - -[lints] -workspace = true diff --git a/crates/chitin-command/Cargo.toml b/crates/chitin-command/Cargo.toml index edbb6baf..cf373a2f 100644 --- a/crates/chitin-command/Cargo.toml +++ b/crates/chitin-command/Cargo.toml @@ -6,8 +6,14 @@ license.workspace = true repository.workspace = true [dependencies] +chitin-bio = { path = "../chitin-bio" } chitin-databases = { path = "../chitin-databases" } +clap = { version = "4.6", features = ["derive"] } +serde_json = "1.0" thiserror = "2.0" +[dev-dependencies] +tokio = { version = "1.52", features = ["macros", "rt"] } + [lints] workspace = true diff --git a/crates/chitin-command/src/execution.rs b/crates/chitin-command/src/execution/context.rs similarity index 100% rename from crates/chitin-command/src/execution.rs rename to crates/chitin-command/src/execution/context.rs diff --git a/crates/chitin-command-runtime/src/database.rs b/crates/chitin-command/src/execution/database.rs similarity index 97% rename from crates/chitin-command-runtime/src/database.rs rename to crates/chitin-command/src/execution/database.rs index ae819289..ff3c17ea 100644 --- a/crates/chitin-command-runtime/src/database.rs +++ b/crates/chitin-command/src/execution/database.rs @@ -2,8 +2,8 @@ use std::{collections::BTreeSet, path::PathBuf}; -use chitin_command::{ - CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandMessage, CommandMessageLevel, +use crate::{ + CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandId, CommandMessage, CommandMessageLevel, CommandProgress, DatabaseCommand, RcsbDownloadArguments, }; use chitin_databases::{ @@ -11,7 +11,7 @@ use chitin_databases::{ providers::rcsb::{RcsbBatchDownloadEvent, RcsbBatchDownloadRequest}, }; -use crate::{CommandExecutionError, CommandOutcome}; +use super::{CommandExecutionError, CommandOutcome}; /// Executes a portable database command through the shared provider client. /// @@ -75,7 +75,7 @@ pub fn resolve_rcsb_download_paths( .default_download_root .as_ref() .ok_or(CommandExecutionError::MissingDownloadRoot { - command_id: chitin_command::CommandId::DatabaseDownloadRcsbStructure, + command_id: CommandId::DatabaseDownloadRcsbStructure, }) .map(|root| resolve_from_working_directory(root, &context.working_directory))? .join(arguments.format.id()) diff --git a/crates/chitin-command-runtime/src/lib.rs b/crates/chitin-command/src/execution/mod.rs similarity index 93% rename from crates/chitin-command-runtime/src/lib.rs rename to crates/chitin-command/src/execution/mod.rs index fc078d4f..c294d7b2 100644 --- a/crates/chitin-command-runtime/src/lib.rs +++ b/crates/chitin-command/src/execution/mod.rs @@ -1,10 +1,10 @@ -#![forbid(unsafe_code)] //! Frontend-independent execution for portable Chitin commands. //! -//! This crate performs database and structure workflows without printing to a -//! terminal or accessing GPUI. Frontends supply an execution context, observe -//! structured events, and render the returned outcome in their own language. +//! Frontends supply an execution context, observe structured events, and +//! convert the returned outcome through [`crate::output`] without embedding +//! CLI or GPUI behavior in the executor. +mod context; mod database; mod structure; @@ -14,13 +14,16 @@ use std::{ }; use chitin_bio::structure::StructureParseResult; -use chitin_command::{ - CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandId, CommandOutputFormat, PortableCommand, -}; use chitin_databases::{Client, ClientConfig, TransportError, providers::rcsb::StructureFormat}; +pub use context::{ + CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandMessage, CommandMessageLevel, + CommandProgress, +}; pub use database::resolve_rcsb_download_paths; +use crate::{CommandId, CommandOutputFormat, PortableCommand}; + /// Successful result returned by a portable command. #[derive(Clone, Debug, PartialEq)] pub enum CommandOutcome { @@ -177,9 +180,10 @@ impl CommandExecutor { mod tests { use std::{path::PathBuf, sync::Mutex}; - use chitin_command::{CommandOutputFormat, StructureCommand, StructureInputArguments, StructureInspectArguments}; + use chitin_databases::providers::rcsb::StructureFormat; use super::*; + use crate::{StructureCommand, StructureInputArguments, StructureInspectArguments}; #[tokio::test] async fn executor_should_emit_lifecycle_events_around_structure_inspection() -> Result<(), CommandExecutionError> { diff --git a/crates/chitin-command-runtime/src/structure.rs b/crates/chitin-command/src/execution/structure.rs similarity index 96% rename from crates/chitin-command-runtime/src/structure.rs rename to crates/chitin-command/src/execution/structure.rs index 18919870..51804435 100644 --- a/crates/chitin-command-runtime/src/structure.rs +++ b/crates/chitin-command/src/execution/structure.rs @@ -6,10 +6,10 @@ use std::{ }; use chitin_bio::structure::{MmcifParser, PdbParser, Structure, StructureParseResult}; -use chitin_command::{CommandExecutionContext, StructureCommand, StructureInputArguments}; use chitin_databases::providers::rcsb::StructureFormat; -use crate::{CommandExecutionError, CommandOutcome, StructureInspection, StructureValidation}; +use super::{CommandExecutionError, CommandOutcome, StructureInspection, StructureValidation}; +use crate::{CommandExecutionContext, StructureCommand, StructureInputArguments}; /// Executes a local structure command against frontend-provided resources. /// @@ -160,7 +160,7 @@ fn resolve_from_working_directory(path: &Path, working_directory: &Path) -> Path #[cfg(test)] mod tests { - use chitin_command::{CommandOutputFormat, StructureInspectArguments}; + use crate::{CommandOutputFormat, StructureInspectArguments}; use super::*; diff --git a/crates/chitin-command/src/grammar/mod.rs b/crates/chitin-command/src/grammar/mod.rs new file mode 100644 index 00000000..f3f09eb3 --- /dev/null +++ b/crates/chitin-command/src/grammar/mod.rs @@ -0,0 +1,5 @@ +//! Clap grammar fragments shared by textual Chitin frontends. + +mod portable; + +pub use portable::{PortableCommandArgs, PortableCommandLineError}; diff --git a/crates/chitin-command-line/src/portable.rs b/crates/chitin-command/src/grammar/portable.rs similarity index 99% rename from crates/chitin-command-line/src/portable.rs rename to crates/chitin-command/src/grammar/portable.rs index e59ab634..29b269cb 100644 --- a/crates/chitin-command-line/src/portable.rs +++ b/crates/chitin-command/src/grammar/portable.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; -use chitin_command::{ +use crate::{ CommandOutputFormat, DatabaseCommand, PortableCommand, RcsbDownloadArguments, StructureCommand, StructureInputArguments, StructureInspectArguments, StructureValidateArguments, }; diff --git a/crates/chitin-command/src/lib.rs b/crates/chitin-command/src/lib.rs index 0f80bfef..db05b67e 100644 --- a/crates/chitin-command/src/lib.rs +++ b/crates/chitin-command/src/lib.rs @@ -1,538 +1,24 @@ -//! Shared command definitions and search behavior. +#![forbid(unsafe_code)] +//! Typed commands, textual grammar, portable execution, and shared output. +//! +//! Chitin frontends construct the same typed commands, execute portable work +//! through [`execution`], and render canonical reports from [`output`]. The +//! crate deliberately contains no process-terminal, GPUI, or shell-session +//! state. + +pub mod execution; +pub mod grammar; +pub mod model; +pub mod output; -mod application; -mod database; -mod execution; -mod panel_tab; -mod structure; -mod workspace; - -pub use application::ApplicationCommand; -pub use database::{DatabaseCommand, RcsbDownloadArguments}; pub use execution::{ - CommandEventSink, CommandExecutionContext, CommandExecutionEvent, CommandMessage, CommandMessageLevel, - CommandProgress, + CommandEventSink, CommandExecutionContext, CommandExecutionError, CommandExecutionEvent, CommandExecutor, + CommandMessage, CommandMessageLevel, CommandOutcome, CommandProgress, StructureInspection, StructureValidation, + resolve_rcsb_download_paths, }; -pub use panel_tab::PanelTabCommand; -pub use structure::{ - CommandOutputFormat, StructureCommand, StructureInputArguments, StructureInspectArguments, StructureValidateArguments, +pub use grammar::{PortableCommandArgs, PortableCommandLineError}; +pub use model::*; +pub use output::{ + CommandOutputKind, CommandOutputLine, CommandOutputSpan, CommandOutputTone, CommandReport, CommandReportStatus, + render_outcome, }; -pub use workspace::WorkspaceCommand; - -/// Execution boundary required by a typed command. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum CommandExecutionDomain { - /// The command can execute without application or window state. - Portable, - /// The command requires state owned by its graphical frontend. - Frontend, -} - -/// Stable identity of a command independent of its invocation arguments. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum CommandId { - /// Focus the previous project entry. - WorkspaceFocusPrevious, - /// Focus the next project entry. - WorkspaceFocusNext, - /// Activate the focused project entry. - WorkspaceActivateFocused, - /// Focus the first project entry. - WorkspaceFocusFirst, - /// Focus the last project entry. - WorkspaceFocusLast, - /// Show or hide the workspace sidebar. - WorkspaceToggle, - /// Focus the previous document tab. - PanelTabFocusPrevious, - /// Focus the next document tab. - PanelTabFocusNext, - /// Close the active document tab. - PanelTabClose, - /// Download one or more RCSB structures. - DatabaseDownloadRcsbStructure, - /// Show or hide the command panel. - ApplicationToggleCommandPanel, - /// Show or hide the built-in terminal panel. - ApplicationToggleTerminal, - /// Inspect a local structure file. - StructureInspect, - /// Validate a local structure file. - StructureValidate, -} - -impl CommandId { - /// Every stable command identity, in declaration order. - /// - /// Frontends that mirror the command set, such as the built-in shell grammar, - /// iterate this list to prove their own coverage instead of restating it. - pub const ALL: &[Self] = &[ - Self::WorkspaceFocusPrevious, - Self::WorkspaceFocusNext, - Self::WorkspaceActivateFocused, - Self::WorkspaceFocusFirst, - Self::WorkspaceFocusLast, - Self::WorkspaceToggle, - Self::PanelTabFocusPrevious, - Self::PanelTabFocusNext, - Self::PanelTabClose, - Self::DatabaseDownloadRcsbStructure, - Self::ApplicationToggleCommandPanel, - Self::ApplicationToggleTerminal, - Self::StructureInspect, - Self::StructureValidate, - ]; - - /// Returns the stable dotted identifier. - pub const fn as_str(self) -> &'static str { - match self { - Self::WorkspaceFocusPrevious => "workspace.focus_previous_entry", - Self::WorkspaceFocusNext => "workspace.focus_next_entry", - Self::WorkspaceActivateFocused => "workspace.activate_focused_entry", - Self::WorkspaceFocusFirst => "workspace.focus_first_entry", - Self::WorkspaceFocusLast => "workspace.focus_last_entry", - Self::WorkspaceToggle => "workspace.toggle_workspace", - Self::PanelTabFocusPrevious => "tab.focus_previous", - Self::PanelTabFocusNext => "tab.focus_next", - Self::PanelTabClose => "tab.close", - Self::DatabaseDownloadRcsbStructure => "database.rcsb.download_structure", - Self::ApplicationToggleCommandPanel => "application.toggle_command_panel", - Self::ApplicationToggleTerminal => "application.toggle_terminal", - Self::StructureInspect => "structure.inspect", - Self::StructureValidate => "structure.validate", - } - } - - /// Builds a frontend command when this identity needs no arguments. - pub fn frontend_command_without_arguments(self) -> Option { - match self { - Self::WorkspaceFocusPrevious => Some(WorkspaceCommand::FocusPrevious.into()), - Self::WorkspaceFocusNext => Some(WorkspaceCommand::FocusNext.into()), - Self::WorkspaceActivateFocused => Some(WorkspaceCommand::ActivateFocused.into()), - Self::WorkspaceFocusFirst => Some(WorkspaceCommand::FocusFirst.into()), - Self::WorkspaceFocusLast => Some(WorkspaceCommand::FocusLast.into()), - Self::WorkspaceToggle => Some(WorkspaceCommand::ToggleWorkspace.into()), - Self::PanelTabFocusPrevious => Some(PanelTabCommand::FocusPrevious.into()), - Self::PanelTabFocusNext => Some(PanelTabCommand::FocusNext.into()), - Self::PanelTabClose => Some(PanelTabCommand::Close.into()), - Self::ApplicationToggleCommandPanel => Some(ApplicationCommand::ToggleCommandPanel.into()), - Self::ApplicationToggleTerminal => Some(ApplicationCommand::ToggleTerminal.into()), - Self::DatabaseDownloadRcsbStructure | Self::StructureInspect | Self::StructureValidate => None, - } - } -} - -impl std::fmt::Display for CommandId { - /// Formats the stable dotted identifier. - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(self.as_str()) - } -} - -/// Commands executable without application or window state. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PortableCommand { - /// Database-provider commands. - Database(DatabaseCommand), - /// Structure parsing and inspection commands. - Structure(StructureCommand), -} - -impl PortableCommand { - /// Returns the stable identity for this command. - pub fn id(&self) -> CommandId { - match self { - Self::Database(command) => command.id(), - Self::Structure(command) => command.id(), - } - } -} - -/// Commands that require state owned by a graphical frontend. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum FrontendCommand { - /// Workspace and document-panel commands. - Workspace(WorkspaceCommand), - /// Application-shell commands. - Application(ApplicationCommand), -} - -impl FrontendCommand { - /// Returns the stable identity for this command. - pub fn id(&self) -> CommandId { - match self { - Self::Workspace(command) => command.id(), - Self::Application(command) => command.id(), - } - } -} - -/// Top-level command hierarchy shared by desktop, terminal, and CLI inputs. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ChitinCommand { - /// A command executable by the frontend-independent runtime. - Portable(PortableCommand), - /// A command requiring state owned by its host frontend. - Frontend(FrontendCommand), -} - -impl ChitinCommand { - /// Returns the stable identity for this command. - pub fn id(&self) -> CommandId { - match self { - Self::Portable(command) => command.id(), - Self::Frontend(command) => command.id(), - } - } - - /// Returns the execution boundary required by this command. - pub const fn execution_domain(&self) -> CommandExecutionDomain { - match self { - Self::Portable(_) => CommandExecutionDomain::Portable, - Self::Frontend(_) => CommandExecutionDomain::Frontend, - } - } -} - -impl From for FrontendCommand { - fn from(command: WorkspaceCommand) -> Self { - Self::Workspace(command) - } -} - -impl From for PortableCommand { - fn from(command: DatabaseCommand) -> Self { - Self::Database(command) - } -} - -impl From for FrontendCommand { - fn from(command: ApplicationCommand) -> Self { - Self::Application(command) - } -} - -impl From for PortableCommand { - fn from(command: StructureCommand) -> Self { - Self::Structure(command) - } -} - -impl From for FrontendCommand { - fn from(command: PanelTabCommand) -> Self { - Self::Workspace(WorkspaceCommand::PanelTab(command)) - } -} - -impl From for ChitinCommand { - fn from(command: PortableCommand) -> Self { - Self::Portable(command) - } -} - -impl From for ChitinCommand { - fn from(command: FrontendCommand) -> Self { - Self::Frontend(command) - } -} - -impl From for ChitinCommand { - fn from(command: WorkspaceCommand) -> Self { - FrontendCommand::from(command).into() - } -} - -impl From for ChitinCommand { - fn from(command: DatabaseCommand) -> Self { - PortableCommand::from(command).into() - } -} - -impl From for ChitinCommand { - fn from(command: ApplicationCommand) -> Self { - FrontendCommand::from(command).into() - } -} - -impl From for ChitinCommand { - fn from(command: StructureCommand) -> Self { - PortableCommand::from(command).into() - } -} - -impl From for ChitinCommand { - fn from(command: PanelTabCommand) -> Self { - FrontendCommand::from(command).into() - } -} - -/// Command category used for search grouping and presentation. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CommandCategory { - /// Workspace and document-panel commands. - Workspace, - /// External database commands. - Database, - /// Application-shell commands. - Application, -} - -impl CommandCategory { - /// Returns the display label for this category. - pub fn label(self) -> &'static str { - match self { - Self::Workspace => "Workspace", - Self::Database => "Database", - Self::Application => "Application", - } - } -} - -/// Frontend-independent description of one available command. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CommandDescriptor { - /// Stable command identifier. - pub id: CommandId, - /// User-facing title. - pub title: &'static str, - /// Whether an input adapter must collect arguments before execution. - pub requires_arguments: bool, -} - -/// Search and presentation metadata attached to a command registration. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CommandRegistration { - /// Frontend-independent command description. - pub descriptor: CommandDescriptor, - /// Search result category. - pub category: CommandCategory, - /// Additional search keywords. - pub keywords: &'static [&'static str], - /// Optional shortcut label shown by search frontends. - pub shortcut: Option<&'static str>, -} - -/// A ranked command result. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct CommandSearchResult<'a> { - /// Match score, where larger values rank first. - pub score: i32, - /// Matching descriptor. - pub descriptor: &'a CommandDescriptor, - /// Optional shortcut label associated with the registration. - pub shortcut: Option<&'static str>, -} - -/// Registry and deterministic search implementation for command descriptors. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct CommandRegistry { - commands: Vec, -} - -impl CommandRegistry { - /// Creates a registry from command descriptors. - pub fn new(commands: impl IntoIterator) -> Self { - Self { - commands: commands.into_iter().collect(), - } - } - - /// Finds a descriptor by stable command identity. - pub fn descriptor_for(&self, id: CommandId) -> Option<&CommandDescriptor> { - self - .commands - .iter() - .map(|registration| ®istration.descriptor) - .find(|descriptor| descriptor.id == id) - } - - /// Searches commands using deterministic scoring. - pub fn search(&self, query: &str) -> Vec> { - let query = normalize(query); - let mut results = self - .commands - .iter() - .filter_map(|registration| { - score_registration(registration, &query).map(|score| CommandSearchResult { - score, - descriptor: ®istration.descriptor, - shortcut: registration.shortcut, - }) - }) - .collect::>(); - results.sort_by(|left, right| { - right - .score - .cmp(&left.score) - .then_with(|| left.descriptor.title.cmp(right.descriptor.title)) - }); - results - } -} - -/// Creates the built-in registry from all shared command descriptors. -pub fn default_registry() -> CommandRegistry { - CommandRegistry::new( - workspace::command_registrations() - .into_iter() - .chain(panel_tab::command_registrations()) - .chain(database::command_registrations()) - .chain(application::command_registrations()), - ) -} - -/// Scores a command registration against a normalized search query. -/// -/// Exact command identifiers receive the highest score, followed by title, -/// identifier-prefix, title-prefix, and category matches. The ordering keeps -/// command IDs predictable while still allowing users to search by the label -/// shown in the command panel. -/// -/// # Parameters -/// -/// * `registration` is the command and search metadata being matched. -/// * `query` is a trimmed, lowercase search query. -/// -/// # Returns -/// -/// A score when the descriptor matches, or `None` when it should be omitted. -fn score_registration(registration: &CommandRegistration, query: &str) -> Option { - if query.is_empty() { - return Some(1); - } - let descriptor = ®istration.descriptor; - let id = normalize(descriptor.id.as_str()); - let title = normalize(descriptor.title); - let category = normalize(registration.category.label()); - // Check stronger matches first so a broad prefix cannot hide an exact hit. - if id == query { - return Some(1000); - } - if id.starts_with(query) { - return Some(850); - } - if title.starts_with(query) { - return Some(700); - } - if title.contains(query) { - return Some(500); - } - if registration.keywords.iter().any(|keyword| normalize(keyword) == query) { - return Some(420); - } - if registration - .keywords - .iter() - .any(|keyword| normalize(keyword).contains(query)) - { - return Some(360); - } - if category == query || category.starts_with(query) { - return Some(280); - } - None -} - -/// Trims surrounding whitespace and lowercases a command-search value. -/// -/// # Parameters -/// -/// * `value` is the raw command identifier, title, category, or user query. -/// -/// # Returns -/// -/// A canonical value suitable for case-insensitive matching. -fn normalize(value: &str) -> String { - value.trim().to_ascii_lowercase() -} - -#[cfg(test)] -mod tests { - use std::path::{Path, PathBuf}; - - use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; - - use super::*; - - #[test] - fn command_ids_should_include_scope() { - assert_eq!( - ChitinCommand::from(WorkspaceCommand::FocusNext).id(), - CommandId::WorkspaceFocusNext - ); - } - - #[test] - fn command_id_all_should_cover_every_variant() { - // The or-pattern lists every variant on purpose. Adding a `CommandId` - // without extending `ALL` leaves this match non-exhaustive, so the test - // stops compiling rather than silently skipping the new identity. - for id in CommandId::ALL { - match id { - CommandId::WorkspaceFocusPrevious - | CommandId::WorkspaceFocusNext - | CommandId::WorkspaceActivateFocused - | CommandId::WorkspaceFocusFirst - | CommandId::WorkspaceFocusLast - | CommandId::WorkspaceToggle - | CommandId::PanelTabFocusPrevious - | CommandId::PanelTabFocusNext - | CommandId::PanelTabClose - | CommandId::DatabaseDownloadRcsbStructure - | CommandId::ApplicationToggleCommandPanel - | CommandId::ApplicationToggleTerminal - | CommandId::StructureInspect - | CommandId::StructureValidate => {} - } - } - assert_eq!(CommandId::ALL.len(), 14); - } - - #[test] - fn database_commands_should_use_the_portable_execution_domain() -> Result<(), Box> { - let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { - ids: vec![PdbId::new("4hhb")?], - format: StructureFormat::Pdb, - output: None, - })); - - assert_eq!(command.execution_domain(), CommandExecutionDomain::Portable); - Ok(()) - } - - #[test] - fn workspace_commands_should_use_the_frontend_execution_domain() { - let command = ChitinCommand::from(WorkspaceCommand::ToggleWorkspace); - - assert_eq!(command.execution_domain(), CommandExecutionDomain::Frontend); - } - - #[test] - fn parameterized_command_id_should_not_create_an_incomplete_command() { - assert_eq!( - CommandId::DatabaseDownloadRcsbStructure.frontend_command_without_arguments(), - None - ); - } - - #[test] - fn rcsb_download_command_should_retain_validated_arguments() -> Result<(), Box> { - let id = PdbId::new("4hhb")?; - let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { - ids: vec![id.clone()], - format: StructureFormat::Mmcif, - output: Some(PathBuf::from("structures")), - })); - - assert!(matches!( - command, - ChitinCommand::Portable(PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure( - RcsbDownloadArguments { - ids, - format: StructureFormat::Mmcif, - output: Some(output), - }))) if ids == vec![id] && output == Path::new("structures") - )); - Ok(()) - } -} diff --git a/crates/chitin-command/src/application.rs b/crates/chitin-command/src/model/application.rs similarity index 100% rename from crates/chitin-command/src/application.rs rename to crates/chitin-command/src/model/application.rs diff --git a/crates/chitin-command/src/database.rs b/crates/chitin-command/src/model/database.rs similarity index 100% rename from crates/chitin-command/src/database.rs rename to crates/chitin-command/src/model/database.rs diff --git a/crates/chitin-command/src/model/mod.rs b/crates/chitin-command/src/model/mod.rs new file mode 100644 index 00000000..2e6f62cd --- /dev/null +++ b/crates/chitin-command/src/model/mod.rs @@ -0,0 +1,533 @@ +//! Typed command identities, arguments, registrations, and search behavior. + +mod application; +mod database; +mod panel_tab; +mod structure; +mod workspace; + +pub use application::ApplicationCommand; +pub use database::{DatabaseCommand, RcsbDownloadArguments}; +pub use panel_tab::PanelTabCommand; +pub use structure::{ + CommandOutputFormat, StructureCommand, StructureInputArguments, StructureInspectArguments, StructureValidateArguments, +}; +pub use workspace::WorkspaceCommand; + +/// Execution boundary required by a typed command. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CommandExecutionDomain { + /// The command can execute without application or window state. + Portable, + /// The command requires state owned by its graphical frontend. + Frontend, +} + +/// Stable identity of a command independent of its invocation arguments. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CommandId { + /// Focus the previous project entry. + WorkspaceFocusPrevious, + /// Focus the next project entry. + WorkspaceFocusNext, + /// Activate the focused project entry. + WorkspaceActivateFocused, + /// Focus the first project entry. + WorkspaceFocusFirst, + /// Focus the last project entry. + WorkspaceFocusLast, + /// Show or hide the workspace sidebar. + WorkspaceToggle, + /// Focus the previous document tab. + PanelTabFocusPrevious, + /// Focus the next document tab. + PanelTabFocusNext, + /// Close the active document tab. + PanelTabClose, + /// Download one or more RCSB structures. + DatabaseDownloadRcsbStructure, + /// Show or hide the command panel. + ApplicationToggleCommandPanel, + /// Show or hide the built-in terminal panel. + ApplicationToggleTerminal, + /// Inspect a local structure file. + StructureInspect, + /// Validate a local structure file. + StructureValidate, +} + +impl CommandId { + /// Every stable command identity, in declaration order. + /// + /// Frontends that mirror the command set, such as the built-in shell grammar, + /// iterate this list to prove their own coverage instead of restating it. + pub const ALL: &[Self] = &[ + Self::WorkspaceFocusPrevious, + Self::WorkspaceFocusNext, + Self::WorkspaceActivateFocused, + Self::WorkspaceFocusFirst, + Self::WorkspaceFocusLast, + Self::WorkspaceToggle, + Self::PanelTabFocusPrevious, + Self::PanelTabFocusNext, + Self::PanelTabClose, + Self::DatabaseDownloadRcsbStructure, + Self::ApplicationToggleCommandPanel, + Self::ApplicationToggleTerminal, + Self::StructureInspect, + Self::StructureValidate, + ]; + + /// Returns the stable dotted identifier. + pub const fn as_str(self) -> &'static str { + match self { + Self::WorkspaceFocusPrevious => "workspace.focus_previous_entry", + Self::WorkspaceFocusNext => "workspace.focus_next_entry", + Self::WorkspaceActivateFocused => "workspace.activate_focused_entry", + Self::WorkspaceFocusFirst => "workspace.focus_first_entry", + Self::WorkspaceFocusLast => "workspace.focus_last_entry", + Self::WorkspaceToggle => "workspace.toggle_workspace", + Self::PanelTabFocusPrevious => "tab.focus_previous", + Self::PanelTabFocusNext => "tab.focus_next", + Self::PanelTabClose => "tab.close", + Self::DatabaseDownloadRcsbStructure => "database.rcsb.download_structure", + Self::ApplicationToggleCommandPanel => "application.toggle_command_panel", + Self::ApplicationToggleTerminal => "application.toggle_terminal", + Self::StructureInspect => "structure.inspect", + Self::StructureValidate => "structure.validate", + } + } + + /// Builds a frontend command when this identity needs no arguments. + pub fn frontend_command_without_arguments(self) -> Option { + match self { + Self::WorkspaceFocusPrevious => Some(WorkspaceCommand::FocusPrevious.into()), + Self::WorkspaceFocusNext => Some(WorkspaceCommand::FocusNext.into()), + Self::WorkspaceActivateFocused => Some(WorkspaceCommand::ActivateFocused.into()), + Self::WorkspaceFocusFirst => Some(WorkspaceCommand::FocusFirst.into()), + Self::WorkspaceFocusLast => Some(WorkspaceCommand::FocusLast.into()), + Self::WorkspaceToggle => Some(WorkspaceCommand::ToggleWorkspace.into()), + Self::PanelTabFocusPrevious => Some(PanelTabCommand::FocusPrevious.into()), + Self::PanelTabFocusNext => Some(PanelTabCommand::FocusNext.into()), + Self::PanelTabClose => Some(PanelTabCommand::Close.into()), + Self::ApplicationToggleCommandPanel => Some(ApplicationCommand::ToggleCommandPanel.into()), + Self::ApplicationToggleTerminal => Some(ApplicationCommand::ToggleTerminal.into()), + Self::DatabaseDownloadRcsbStructure | Self::StructureInspect | Self::StructureValidate => None, + } + } +} + +impl std::fmt::Display for CommandId { + /// Formats the stable dotted identifier. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Commands executable without application or window state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PortableCommand { + /// Database-provider commands. + Database(DatabaseCommand), + /// Structure parsing and inspection commands. + Structure(StructureCommand), +} + +impl PortableCommand { + /// Returns the stable identity for this command. + pub fn id(&self) -> CommandId { + match self { + Self::Database(command) => command.id(), + Self::Structure(command) => command.id(), + } + } +} + +/// Commands that require state owned by a graphical frontend. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FrontendCommand { + /// Workspace and document-panel commands. + Workspace(WorkspaceCommand), + /// Application-shell commands. + Application(ApplicationCommand), +} + +impl FrontendCommand { + /// Returns the stable identity for this command. + pub fn id(&self) -> CommandId { + match self { + Self::Workspace(command) => command.id(), + Self::Application(command) => command.id(), + } + } +} + +/// Top-level command hierarchy shared by desktop, terminal, and CLI inputs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ChitinCommand { + /// A command executable by the frontend-independent runtime. + Portable(PortableCommand), + /// A command requiring state owned by its host frontend. + Frontend(FrontendCommand), +} + +impl ChitinCommand { + /// Returns the stable identity for this command. + pub fn id(&self) -> CommandId { + match self { + Self::Portable(command) => command.id(), + Self::Frontend(command) => command.id(), + } + } + + /// Returns the execution boundary required by this command. + pub const fn execution_domain(&self) -> CommandExecutionDomain { + match self { + Self::Portable(_) => CommandExecutionDomain::Portable, + Self::Frontend(_) => CommandExecutionDomain::Frontend, + } + } +} + +impl From for FrontendCommand { + fn from(command: WorkspaceCommand) -> Self { + Self::Workspace(command) + } +} + +impl From for PortableCommand { + fn from(command: DatabaseCommand) -> Self { + Self::Database(command) + } +} + +impl From for FrontendCommand { + fn from(command: ApplicationCommand) -> Self { + Self::Application(command) + } +} + +impl From for PortableCommand { + fn from(command: StructureCommand) -> Self { + Self::Structure(command) + } +} + +impl From for FrontendCommand { + fn from(command: PanelTabCommand) -> Self { + Self::Workspace(WorkspaceCommand::PanelTab(command)) + } +} + +impl From for ChitinCommand { + fn from(command: PortableCommand) -> Self { + Self::Portable(command) + } +} + +impl From for ChitinCommand { + fn from(command: FrontendCommand) -> Self { + Self::Frontend(command) + } +} + +impl From for ChitinCommand { + fn from(command: WorkspaceCommand) -> Self { + FrontendCommand::from(command).into() + } +} + +impl From for ChitinCommand { + fn from(command: DatabaseCommand) -> Self { + PortableCommand::from(command).into() + } +} + +impl From for ChitinCommand { + fn from(command: ApplicationCommand) -> Self { + FrontendCommand::from(command).into() + } +} + +impl From for ChitinCommand { + fn from(command: StructureCommand) -> Self { + PortableCommand::from(command).into() + } +} + +impl From for ChitinCommand { + fn from(command: PanelTabCommand) -> Self { + FrontendCommand::from(command).into() + } +} + +/// Command category used for search grouping and presentation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandCategory { + /// Workspace and document-panel commands. + Workspace, + /// External database commands. + Database, + /// Application-shell commands. + Application, +} + +impl CommandCategory { + /// Returns the display label for this category. + pub fn label(self) -> &'static str { + match self { + Self::Workspace => "Workspace", + Self::Database => "Database", + Self::Application => "Application", + } + } +} + +/// Frontend-independent description of one available command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandDescriptor { + /// Stable command identifier. + pub id: CommandId, + /// User-facing title. + pub title: &'static str, + /// Whether an input adapter must collect arguments before execution. + pub requires_arguments: bool, +} + +/// Search and presentation metadata attached to a command registration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandRegistration { + /// Frontend-independent command description. + pub descriptor: CommandDescriptor, + /// Search result category. + pub category: CommandCategory, + /// Additional search keywords. + pub keywords: &'static [&'static str], + /// Optional shortcut label shown by search frontends. + pub shortcut: Option<&'static str>, +} + +/// A ranked command result. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CommandSearchResult<'a> { + /// Match score, where larger values rank first. + pub score: i32, + /// Matching descriptor. + pub descriptor: &'a CommandDescriptor, + /// Optional shortcut label associated with the registration. + pub shortcut: Option<&'static str>, +} + +/// Registry and deterministic search implementation for command descriptors. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CommandRegistry { + commands: Vec, +} + +impl CommandRegistry { + /// Creates a registry from command descriptors. + pub fn new(commands: impl IntoIterator) -> Self { + Self { + commands: commands.into_iter().collect(), + } + } + + /// Finds a descriptor by stable command identity. + pub fn descriptor_for(&self, id: CommandId) -> Option<&CommandDescriptor> { + self + .commands + .iter() + .map(|registration| ®istration.descriptor) + .find(|descriptor| descriptor.id == id) + } + + /// Searches commands using deterministic scoring. + pub fn search(&self, query: &str) -> Vec> { + let query = normalize(query); + let mut results = self + .commands + .iter() + .filter_map(|registration| { + score_registration(registration, &query).map(|score| CommandSearchResult { + score, + descriptor: ®istration.descriptor, + shortcut: registration.shortcut, + }) + }) + .collect::>(); + results.sort_by(|left, right| { + right + .score + .cmp(&left.score) + .then_with(|| left.descriptor.title.cmp(right.descriptor.title)) + }); + results + } +} + +/// Creates the built-in registry from all shared command descriptors. +pub fn default_registry() -> CommandRegistry { + CommandRegistry::new( + workspace::command_registrations() + .into_iter() + .chain(panel_tab::command_registrations()) + .chain(database::command_registrations()) + .chain(application::command_registrations()), + ) +} + +/// Scores a command registration against a normalized search query. +/// +/// Exact command identifiers receive the highest score, followed by title, +/// identifier-prefix, title-prefix, and category matches. The ordering keeps +/// command IDs predictable while still allowing users to search by the label +/// shown in the command panel. +/// +/// # Parameters +/// +/// * `registration` is the command and search metadata being matched. +/// * `query` is a trimmed, lowercase search query. +/// +/// # Returns +/// +/// A score when the descriptor matches, or `None` when it should be omitted. +fn score_registration(registration: &CommandRegistration, query: &str) -> Option { + if query.is_empty() { + return Some(1); + } + let descriptor = ®istration.descriptor; + let id = normalize(descriptor.id.as_str()); + let title = normalize(descriptor.title); + let category = normalize(registration.category.label()); + // Check stronger matches first so a broad prefix cannot hide an exact hit. + if id == query { + return Some(1000); + } + if id.starts_with(query) { + return Some(850); + } + if title.starts_with(query) { + return Some(700); + } + if title.contains(query) { + return Some(500); + } + if registration.keywords.iter().any(|keyword| normalize(keyword) == query) { + return Some(420); + } + if registration + .keywords + .iter() + .any(|keyword| normalize(keyword).contains(query)) + { + return Some(360); + } + if category == query || category.starts_with(query) { + return Some(280); + } + None +} + +/// Trims surrounding whitespace and lowercases a command-search value. +/// +/// # Parameters +/// +/// * `value` is the raw command identifier, title, category, or user query. +/// +/// # Returns +/// +/// A canonical value suitable for case-insensitive matching. +fn normalize(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use chitin_databases::providers::rcsb::{PdbId, StructureFormat}; + + use super::*; + + #[test] + fn command_ids_should_include_scope() { + assert_eq!( + ChitinCommand::from(WorkspaceCommand::FocusNext).id(), + CommandId::WorkspaceFocusNext + ); + } + + #[test] + fn command_id_all_should_cover_every_variant() { + // The or-pattern lists every variant on purpose. Adding a `CommandId` + // without extending `ALL` leaves this match non-exhaustive, so the test + // stops compiling rather than silently skipping the new identity. + for id in CommandId::ALL { + match id { + CommandId::WorkspaceFocusPrevious + | CommandId::WorkspaceFocusNext + | CommandId::WorkspaceActivateFocused + | CommandId::WorkspaceFocusFirst + | CommandId::WorkspaceFocusLast + | CommandId::WorkspaceToggle + | CommandId::PanelTabFocusPrevious + | CommandId::PanelTabFocusNext + | CommandId::PanelTabClose + | CommandId::DatabaseDownloadRcsbStructure + | CommandId::ApplicationToggleCommandPanel + | CommandId::ApplicationToggleTerminal + | CommandId::StructureInspect + | CommandId::StructureValidate => {} + } + } + assert_eq!(CommandId::ALL.len(), 14); + } + + #[test] + fn database_commands_should_use_the_portable_execution_domain() -> Result<(), Box> { + let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ids: vec![PdbId::new("4hhb")?], + format: StructureFormat::Pdb, + output: None, + })); + + assert_eq!(command.execution_domain(), CommandExecutionDomain::Portable); + Ok(()) + } + + #[test] + fn workspace_commands_should_use_the_frontend_execution_domain() { + let command = ChitinCommand::from(WorkspaceCommand::ToggleWorkspace); + + assert_eq!(command.execution_domain(), CommandExecutionDomain::Frontend); + } + + #[test] + fn parameterized_command_id_should_not_create_an_incomplete_command() { + assert_eq!( + CommandId::DatabaseDownloadRcsbStructure.frontend_command_without_arguments(), + None + ); + } + + #[test] + fn rcsb_download_command_should_retain_validated_arguments() -> Result<(), Box> { + let id = PdbId::new("4hhb")?; + let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { + ids: vec![id.clone()], + format: StructureFormat::Mmcif, + output: Some(PathBuf::from("structures")), + })); + + assert!(matches!( + command, + ChitinCommand::Portable(PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure( + RcsbDownloadArguments { + ids, + format: StructureFormat::Mmcif, + output: Some(output), + }))) if ids == vec![id] && output == Path::new("structures") + )); + Ok(()) + } +} diff --git a/crates/chitin-command/src/panel_tab.rs b/crates/chitin-command/src/model/panel_tab.rs similarity index 100% rename from crates/chitin-command/src/panel_tab.rs rename to crates/chitin-command/src/model/panel_tab.rs diff --git a/crates/chitin-command/src/structure.rs b/crates/chitin-command/src/model/structure.rs similarity index 100% rename from crates/chitin-command/src/structure.rs rename to crates/chitin-command/src/model/structure.rs diff --git a/crates/chitin-command/src/workspace.rs b/crates/chitin-command/src/model/workspace.rs similarity index 100% rename from crates/chitin-command/src/workspace.rs rename to crates/chitin-command/src/model/workspace.rs diff --git a/crates/chitin-command/src/output/mod.rs b/crates/chitin-command/src/output/mod.rs new file mode 100644 index 00000000..7dc865b6 --- /dev/null +++ b/crates/chitin-command/src/output/mod.rs @@ -0,0 +1,460 @@ +//! Frontend-neutral presentation of completed command outcomes. +//! +//! This module owns wording, field order, line breaks, output format, and +//! success semantics. Process terminals and GPUI terminals only translate the +//! semantic tones into their native styling systems. + +use chitin_bio::structure::Structure; +use serde_json::{Value, json}; + +use crate::{CommandOutcome, CommandOutputFormat, StructureInspection, StructureValidation}; + +/// Completion state attached to a rendered command report. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandReportStatus { + /// The command completed successfully. + Succeeded, + /// The command completed with a domain-level failure already described by the report. + Failed, +} + +/// Serialization form selected by the command arguments. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandOutputKind { + /// Human-readable terminal output. + Text, + /// Stable machine-readable JSON output. + Json, +} + +/// Semantic visual role for one output span. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandOutputTone { + /// Normal result content. + Primary, + /// Labels and supporting text. + Secondary, + /// Section or document headings. + Heading, + /// Successful result content. + Success, + /// Recoverable diagnostic content. + Warning, + /// Failed result content. + Error, +} + +/// Styled text fragment independent of ANSI and GPUI. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandOutputSpan { + /// Human-readable content. + pub text: String, + /// Semantic role interpreted by the frontend adapter. + pub tone: CommandOutputTone, +} + +impl CommandOutputSpan { + /// Creates a semantic output span. + pub fn new(text: impl Into, tone: CommandOutputTone) -> Self { + Self { + text: text.into(), + tone, + } + } +} + +/// One physical row in a command report. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandOutputLine { + /// Ordered fragments forming the row. + pub spans: Vec, +} + +impl CommandOutputLine { + /// Creates a row from ordered semantic spans. + pub fn new(spans: impl IntoIterator) -> Self { + Self { + spans: spans.into_iter().collect(), + } + } + + /// Creates an unstyled output row. + pub fn plain(text: impl Into) -> Self { + Self::new([CommandOutputSpan::new(text, CommandOutputTone::Primary)]) + } + + /// Returns the row without frontend styling. + pub fn plain_text(&self) -> String { + self.spans.iter().map(|span| span.text.as_str()).collect() + } +} + +/// Complete portable-command output shared by textual frontends. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandReport { + /// Domain-level completion state. + pub status: CommandReportStatus, + /// Text or JSON representation selected by the command. + pub kind: CommandOutputKind, + /// Ordered physical rows without trailing newline characters. + pub lines: Vec, +} + +impl CommandReport { + /// Returns the complete report without frontend styling. + pub fn plain_text(&self) -> String { + self + .lines + .iter() + .map(CommandOutputLine::plain_text) + .collect::>() + .join("\n") + } +} + +/// Converts a completed command outcome into its canonical report. +/// +/// # Parameters +/// +/// * `outcome` contains frontend-independent command results and presentation preferences. +/// +/// # Returns +/// +/// A semantic report whose wording and line structure are identical for every frontend. +pub fn render_outcome(outcome: &CommandOutcome) -> CommandReport { + match outcome { + CommandOutcome::DatabaseDownload { artifact_count } => CommandReport { + status: CommandReportStatus::Succeeded, + kind: CommandOutputKind::Text, + lines: vec![semantic_line( + CommandOutputTone::Success, + format!("Downloaded {artifact_count} structure artifact(s)."), + )], + }, + CommandOutcome::StructureInspection(inspection) => render_inspection(inspection), + CommandOutcome::StructureValidation(validation) => render_validation(validation), + } +} + +/// Renders a structure inspection according to its requested output format. +/// +/// # Parameters +/// +/// * `inspection` contains parsed structure data and text/JSON preferences. +/// +/// # Returns +/// +/// The canonical text or JSON report for the inspection. +fn render_inspection(inspection: &StructureInspection) -> CommandReport { + match inspection.output { + CommandOutputFormat::Text => render_text_inspection(inspection), + CommandOutputFormat::Json => render_json_inspection(inspection), + } +} + +/// Renders the complete human-readable structure summary. +/// +/// # Parameters +/// +/// * `inspection` contains the parsed structure and verbosity preference. +/// +/// # Returns +/// +/// A successful report with stable labels, ordering, and optional details. +fn render_text_inspection(inspection: &StructureInspection) -> CommandReport { + let structure = &inspection.parsed.structure; + let mut lines = vec![ + paired_line( + "Structure ", + inspection.path.display().to_string(), + CommandOutputTone::Heading, + ), + paired_line("Format: ", inspection.format.label(), CommandOutputTone::Success), + paired_line("Bytes: ", inspection.byte_count.to_string(), CommandOutputTone::Primary), + CommandOutputLine::plain(""), + ]; + lines.extend( + summary_counts(structure) + .into_iter() + .map(|(label, count)| paired_line(format!("{label}: "), count.to_string(), CommandOutputTone::Success)), + ); + if !inspection.parsed.diagnostics.is_empty() { + lines.push(paired_line( + "Diagnostics: ", + inspection.parsed.diagnostics.len().to_string(), + CommandOutputTone::Warning, + )); + } + if inspection.verbose { + lines.push(CommandOutputLine::plain("")); + lines.push(semantic_line(CommandOutputTone::Heading, "Details")); + extend_multiline( + &mut lines, + &format!("Metadata: {:#?}", structure.metadata()), + CommandOutputTone::Secondary, + ); + lines.push(paired_line( + "Chains: ", + format!( + "{:?}", + structure + .chains() + .iter() + .map(|chain| chain.auth_id.as_deref().or(chain.label_id.as_deref())) + .collect::>() + ), + CommandOutputTone::Primary, + )); + if !inspection.parsed.diagnostics.is_empty() { + extend_multiline( + &mut lines, + &format!("Diagnostics: {:#?}", inspection.parsed.diagnostics), + CommandOutputTone::Warning, + ); + } + } + CommandReport { + status: CommandReportStatus::Succeeded, + kind: CommandOutputKind::Text, + lines, + } +} + +/// Renders the stable JSON structure summary. +/// +/// # Parameters +/// +/// * `inspection` contains the parsed structure and verbosity preference. +/// +/// # Returns +/// +/// A successful report whose rows contain canonical pretty-printed JSON. +fn render_json_inspection(inspection: &StructureInspection) -> CommandReport { + let mut value = summary_value(inspection); + if inspection.verbose { + value["metadata"] = metadata_value(&inspection.parsed.structure); + value["diagnostics_detail"] = json!( + inspection + .parsed + .diagnostics + .iter() + .map(|diagnostic| json!({ + "code": diagnostic.code, + "line": diagnostic.line, + "severity": format!("{:?}", diagnostic.severity), + "message": diagnostic.message, + })) + .collect::>() + ); + } + json_report(CommandReportStatus::Succeeded, value) +} + +/// Renders a structure validation result and preserves its failure status. +/// +/// # Parameters +/// +/// * `validation` contains the requested format and optional invariant failure. +/// +/// # Returns +/// +/// A report whose status matches the validation result. +fn render_validation(validation: &StructureValidation) -> CommandReport { + match validation.output { + CommandOutputFormat::Text => { + let (status, tone, text) = match validation.error.as_deref() { + Some(error) => ( + CommandReportStatus::Failed, + CommandOutputTone::Error, + format!("✗ Invalid structure: {error}"), + ), + None => ( + CommandReportStatus::Succeeded, + CommandOutputTone::Success, + format!( + "✓ Valid structure: {} ({})", + validation.path.display(), + validation.format.label() + ), + ), + }; + CommandReport { + status, + kind: CommandOutputKind::Text, + lines: vec![semantic_line(tone, text)], + } + } + CommandOutputFormat::Json => json_report( + if validation.is_valid() { + CommandReportStatus::Succeeded + } else { + CommandReportStatus::Failed + }, + json!({ + "path": validation.path.display().to_string(), + "format": validation.format.id(), + "valid": validation.is_valid(), + "error": validation.error, + }), + ), + } +} + +/// Builds one label-value row with independent semantic tones. +fn paired_line(label: impl Into, value: impl Into, value_tone: CommandOutputTone) -> CommandOutputLine { + CommandOutputLine::new([ + CommandOutputSpan::new(label, CommandOutputTone::Secondary), + CommandOutputSpan::new(value, value_tone), + ]) +} + +/// Builds one row with a uniform semantic tone. +fn semantic_line(tone: CommandOutputTone, text: impl Into) -> CommandOutputLine { + CommandOutputLine::new([CommandOutputSpan::new(text, tone)]) +} + +/// Appends every physical row from a formatted multi-line value. +fn extend_multiline(lines: &mut Vec, text: &str, tone: CommandOutputTone) { + lines.extend(text.lines().map(|line| semantic_line(tone, line))); +} + +/// Returns stable count labels shared by text and JSON reports. +fn summary_counts(structure: &Structure) -> [(&'static str, usize); 10] { + [ + ("Models", structure.models().len()), + ("Chains", structure.chains().len()), + ("Residues", structure.residues().len()), + ("Atoms", structure.atoms().len()), + ("Bonds", structure.bonds().len()), + ("Polymer entities", structure.polymer_entities().len()), + ("Missing polymer residues", structure.missing_polymer_residues().len()), + ("Secondary ranges", structure.secondary_ranges().len()), + ("Assembly operations", structure.metadata().assembly.operations.len()), + ("Biological assemblies", structure.metadata().assembly.assemblies.len()), + ] +} + +/// Builds the stable JSON inspection summary. +/// +/// # Parameters +/// +/// * `inspection` contains source metadata, diagnostics, and parsed structure data. +/// +/// # Returns +/// +/// A JSON object containing stable scalar metadata and structure counts. +fn summary_value(inspection: &StructureInspection) -> Value { + let mut value = json!({ + "path": inspection.path.display().to_string(), + "format": inspection.format.id(), + "bytes": inspection.byte_count, + "diagnostics": inspection.parsed.diagnostics.len(), + }); + for (name, count) in summary_counts(&inspection.parsed.structure) { + value[name.to_ascii_lowercase().replace(' ', "_")] = json!(count); + } + value +} + +/// Builds stable JSON metadata for verbose inspection output. +/// +/// # Parameters +/// +/// * `structure` supplies crystallographic and biological assembly metadata. +/// +/// # Returns +/// +/// A JSON object suitable for the verbose inspection report. +fn metadata_value(structure: &Structure) -> Value { + json!({ + "classification": structure.metadata().classification, + "identifier": structure.metadata().identifier, + "unit_cell": structure.metadata().unit_cell.as_ref().map(|cell| json!({ + "lengths": cell.lengths, + "angles": cell.angles, + })), + "symmetry": structure.metadata().symmetry.as_ref().map(|symmetry| json!({ + "space_group_name": symmetry.space_group_name, + "international_tables_number": symmetry.international_tables_number, + })), + "assembly": { + "operations": structure.metadata().assembly.operations.iter().map(|operation| json!({ + "id": operation.id, + "rotation": operation.rotation, + "translation": operation.translation, + })).collect::>(), + "assemblies": structure.metadata().assembly.assemblies.iter().map(|assembly| json!({ + "id": assembly.id, + "details": assembly.details, + "generations": assembly.generations.iter().map(|generation| json!({ + "asym_ids": generation.asym_ids, + "auth_asym_ids": generation.auth_asym_ids, + "entity_instance_ids": generation.entity_instance_ids, + "operator_expression": generation.operator_expression, + })).collect::>(), + })).collect::>(), + }, + }) +} + +/// Converts a JSON value into canonical pretty-printed physical rows. +/// +/// # Parameters +/// +/// * `status` is the domain-level completion state. +/// * `value` is the complete machine-readable result. +/// +/// # Returns +/// +/// A JSON report split into physical rows for consistent frontend rendering. +fn json_report(status: CommandReportStatus, value: Value) -> CommandReport { + let text = format!("{value:#}"); + CommandReport { + status, + kind: CommandOutputKind::Json, + lines: text.lines().map(CommandOutputLine::plain).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn report_plain_text_should_preserve_semantic_span_order() { + let report = CommandReport { + status: CommandReportStatus::Succeeded, + kind: CommandOutputKind::Text, + lines: vec![paired_line("Format: ", "mmCIF", CommandOutputTone::Success)], + }; + + assert_eq!(report.plain_text(), "Format: mmCIF"); + } + + #[test] + fn database_report_should_use_the_shared_success_message() { + let report = render_outcome(&CommandOutcome::DatabaseDownload { artifact_count: 2 }); + + assert_eq!(report.plain_text(), "Downloaded 2 structure artifact(s)."); + } + + #[test] + fn invalid_structure_report_should_carry_failure_without_a_second_error() { + let outcome = CommandOutcome::StructureValidation(StructureValidation { + path: "invalid.pdb".into(), + format: chitin_databases::providers::rcsb::StructureFormat::Pdb, + output: CommandOutputFormat::Text, + error: Some("no atom records were found".to_owned()), + }); + + let report = render_outcome(&outcome); + + assert_eq!( + (report.status, report.plain_text()), + ( + CommandReportStatus::Failed, + "✗ Invalid structure: no atom records were found".to_owned() + ) + ); + } +} diff --git a/crates/chitin-desktop/Cargo.toml b/crates/chitin-desktop/Cargo.toml index 448a4947..28130cae 100644 --- a/crates/chitin-desktop/Cargo.toml +++ b/crates/chitin-desktop/Cargo.toml @@ -8,7 +8,6 @@ repository.workspace = true [dependencies] chitin-builtin-shell = { path = "../chitin-builtin-shell" } chitin-command = { path = "../chitin-command" } -chitin-command-runtime = { path = "../chitin-command-runtime" } chitin-bio = { path = "../chitin-bio" } log = "0.4.33" env_logger = "0.10" diff --git a/crates/chitin-desktop/src/app.rs b/crates/chitin-desktop/src/app.rs index 5eb77603..33803a73 100644 --- a/crates/chitin-desktop/src/app.rs +++ b/crates/chitin-desktop/src/app.rs @@ -5,7 +5,7 @@ use std::{collections::BTreeSet, path::PathBuf}; -use chitin_command_runtime::CommandExecutor; +use chitin_command::CommandExecutor; use chitin_databases::ClientConfig; use chitin_ui::{ composite::{ diff --git a/crates/chitin-desktop/src/builtin_shell.rs b/crates/chitin-desktop/src/builtin_shell.rs index 29d305f2..79281a0b 100644 --- a/crates/chitin-desktop/src/builtin_shell.rs +++ b/crates/chitin-desktop/src/builtin_shell.rs @@ -318,8 +318,7 @@ fn observe_shell_task(mut task: TaskHandle, window: &Window, cx: &mut Context (CommandTerminalStatus, Vec) { - match outcome { - CommandOutcome::DatabaseDownload { artifact_count } => ( - CommandTerminalStatus::Succeeded, - vec![TerminalLine::new([TerminalSpan::success(format!( - "Downloaded {artifact_count} structure artifact(s)." - ))])], - ), - CommandOutcome::StructureValidation(validation) => match validation.error { - Some(error) => ( - CommandTerminalStatus::Failed, - vec![TerminalLine::new([TerminalSpan::error(format!( - "Invalid structure {}: {error}", - validation.path.display() - ))])], - ), - None => ( - CommandTerminalStatus::Succeeded, - vec![TerminalLine::new([TerminalSpan::success(format!( - "Valid structure: {} ({})", - validation.path.display(), - validation.format.label() - ))])], - ), - }, - CommandOutcome::StructureInspection(inspection) => { - let structure = &inspection.parsed.structure; - ( - CommandTerminalStatus::Succeeded, - vec![ - TerminalLine::new([ - TerminalSpan::secondary("Structure: "), - TerminalSpan::primary(inspection.path.display().to_string()), - ]), - TerminalLine::new([ - TerminalSpan::secondary("Format: "), - TerminalSpan::primary(inspection.format.label()), - TerminalSpan::secondary(" | Bytes: "), - TerminalSpan::primary(inspection.byte_count.to_string()), - TerminalSpan::secondary(" | Diagnostics: "), - TerminalSpan::primary(inspection.parsed.diagnostics.len().to_string()), - ]), - TerminalLine::plain(format!( - "Models: {} | Chains: {} | Residues: {} | Atoms: {} | Bonds: {}", - structure.models().len(), - structure.chains().len(), - structure.residues().len(), - structure.atoms().len(), - structure.bonds().len() - )), - ], + let report = render_command_outcome(&outcome); + let status = match report.status { + CommandReportStatus::Succeeded => CommandTerminalStatus::Succeeded, + CommandReportStatus::Failed => CommandTerminalStatus::Failed, + }; + let lines = report + .lines + .into_iter() + .map(|line| { + TerminalLine::new( + line + .spans + .into_iter() + .map(|span| TerminalSpan::new(span.text, terminal_tone(span.tone))), ) - } + }) + .collect(); + (status, lines) +} + +/// Maps a shared output tone to the command terminal theme. +fn terminal_tone(tone: CommandOutputTone) -> TerminalTone { + match tone { + CommandOutputTone::Primary => TerminalTone::Primary, + CommandOutputTone::Secondary => TerminalTone::Secondary, + CommandOutputTone::Heading => TerminalTone::Accent, + CommandOutputTone::Success => TerminalTone::Success, + CommandOutputTone::Warning => TerminalTone::Warning, + CommandOutputTone::Error => TerminalTone::Error, } } @@ -169,3 +147,23 @@ pub(super) fn shell_terminal_status( ShellExecutionStatus::Running | ShellExecutionStatus::Cancelling => CommandTerminalStatus::Failed, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_adapter_should_preserve_shared_report_text() { + let outcome = CommandOutcome::DatabaseDownload { artifact_count: 2 }; + let expected = render_command_outcome(&outcome).plain_text(); + + let (_, lines) = terminal_outcome(outcome); + let actual = lines + .iter() + .map(|line| line.spans().iter().map(|span| span.text.as_ref()).collect::()) + .collect::>() + .join("\n"); + + assert_eq!(actual, expected); + } +} diff --git a/crates/chitin-desktop/src/portable_command.rs b/crates/chitin-desktop/src/portable_command.rs index 44a52e7b..11bfd97f 100644 --- a/crates/chitin-desktop/src/portable_command.rs +++ b/crates/chitin-desktop/src/portable_command.rs @@ -1,7 +1,9 @@ //! Desktop adapter for executing portable commands as background tasks. -use chitin_command::{CommandEventSink, CommandExecutionContext, DatabaseCommand, PortableCommand, StructureCommand}; -use chitin_command_runtime::{CommandExecutionError, CommandExecutor, CommandOutcome, resolve_rcsb_download_paths}; +use chitin_command::{ + CommandEventSink, CommandExecutionContext, CommandExecutionError, CommandExecutor, CommandOutcome, DatabaseCommand, + PortableCommand, StructureCommand, resolve_rcsb_download_paths, +}; use chitin_databases::CancellationToken; use tokio::sync::oneshot; From 68eaf3f22e2d24db50057b6bc6d234d056d9c8d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 21 Sep 2026 21:11:00 +0800 Subject: [PATCH 16/32] feat(command): add canonical command catalog Introduce shared command metadata for names, titles, categories, execution domains, arguments, keywords, shortcuts, and frontend visibility. Use the catalog to drive shell parsing, help generation, completion, and command-panel discovery instead of duplicating frontend command definitions across modules. Refactor the desktop command panel and built-in shell grammar to consume the canonical registry, and add catalog-based frontend command coverage tests. --- crates/chitin-builtin-shell/src/grammar.rs | 195 ++++---- .../chitin-command/src/model/application.rs | 26 - crates/chitin-command/src/model/catalog.rs | 454 ++++++++++++++++++ crates/chitin-command/src/model/database.rs | 14 - crates/chitin-command/src/model/mod.rs | 325 +------------ crates/chitin-command/src/model/panel_tab.rs | 36 -- crates/chitin-command/src/model/workspace.rs | 54 --- .../src/components/command_panel.rs | 8 +- .../components/command_panel/controller.rs | 6 +- 9 files changed, 560 insertions(+), 558 deletions(-) create mode 100644 crates/chitin-command/src/model/catalog.rs diff --git a/crates/chitin-builtin-shell/src/grammar.rs b/crates/chitin-builtin-shell/src/grammar.rs index e3db7ac1..f5dd6471 100644 --- a/crates/chitin-builtin-shell/src/grammar.rs +++ b/crates/chitin-builtin-shell/src/grammar.rs @@ -3,7 +3,8 @@ use std::ffi::OsString; use chitin_command::{ - CommandId, FrontendCommand, PortableCommand, + CommandExecutionDomain, CommandId, CommandSpec, FrontendCommand, PortableCommand, command_spec_by_name, + command_specs, grammar::{PortableCommandArgs, PortableCommandLineError}, }; use clap::{ColorChoice, CommandFactory, Parser, Subcommand, error::ErrorKind}; @@ -86,39 +87,6 @@ enum BuiltinShellCommandArgs { Portable(PortableCommandArgs), /// Clear visible terminal scrollback. Clear, - /// Focus the previous project entry. - #[command(name = "workspace.focus_previous_entry")] - WorkspaceFocusPrevious, - /// Focus the next project entry. - #[command(name = "workspace.focus_next_entry")] - WorkspaceFocusNext, - /// Activate the focused project entry. - #[command(name = "workspace.activate_focused_entry")] - WorkspaceActivateFocused, - /// Focus the first project entry. - #[command(name = "workspace.focus_first_entry")] - WorkspaceFocusFirst, - /// Focus the last project entry. - #[command(name = "workspace.focus_last_entry")] - WorkspaceFocusLast, - /// Show or hide the workspace sidebar. - #[command(name = "workspace.toggle_workspace")] - WorkspaceToggle, - /// Focus the previous document tab. - #[command(name = "tab.focus_previous")] - PanelTabFocusPrevious, - /// Focus the next document tab. - #[command(name = "tab.focus_next")] - PanelTabFocusNext, - /// Close the active document tab. - #[command(name = "tab.close")] - PanelTabClose, - /// Show or hide the command panel. - #[command(name = "application.toggle_command_panel")] - ApplicationToggleCommandPanel, - /// Show or hide the built-in terminal panel. - #[command(name = "application.toggle_terminal")] - ApplicationToggleTerminal, } impl BuiltinShellCommandArgs { @@ -127,28 +95,6 @@ impl BuiltinShellCommandArgs { let line = match self { Self::Portable(arguments) => BuiltinCommandLine::Portable(arguments.into_command()?), Self::Clear => BuiltinCommandLine::ShellBuiltin(ShellBuiltin::Clear), - // Frontend branches name only their stable identity. The mapping from - // identity to command stays in `CommandId`, so a grammar branch cannot - // drift away from the command the rest of the application dispatches. - Self::WorkspaceFocusPrevious => { - BuiltinCommandLine::Frontend(frontend_command(CommandId::WorkspaceFocusPrevious)?) - } - Self::WorkspaceFocusNext => BuiltinCommandLine::Frontend(frontend_command(CommandId::WorkspaceFocusNext)?), - Self::WorkspaceActivateFocused => { - BuiltinCommandLine::Frontend(frontend_command(CommandId::WorkspaceActivateFocused)?) - } - Self::WorkspaceFocusFirst => BuiltinCommandLine::Frontend(frontend_command(CommandId::WorkspaceFocusFirst)?), - Self::WorkspaceFocusLast => BuiltinCommandLine::Frontend(frontend_command(CommandId::WorkspaceFocusLast)?), - Self::WorkspaceToggle => BuiltinCommandLine::Frontend(frontend_command(CommandId::WorkspaceToggle)?), - Self::PanelTabFocusPrevious => BuiltinCommandLine::Frontend(frontend_command(CommandId::PanelTabFocusPrevious)?), - Self::PanelTabFocusNext => BuiltinCommandLine::Frontend(frontend_command(CommandId::PanelTabFocusNext)?), - Self::PanelTabClose => BuiltinCommandLine::Frontend(frontend_command(CommandId::PanelTabClose)?), - Self::ApplicationToggleCommandPanel => { - BuiltinCommandLine::Frontend(frontend_command(CommandId::ApplicationToggleCommandPanel)?) - } - Self::ApplicationToggleTerminal => { - BuiltinCommandLine::Frontend(frontend_command(CommandId::ApplicationToggleTerminal)?) - } }; Ok(line) } @@ -193,7 +139,11 @@ pub fn parse_builtin_command_line(input: &str) -> Result Vec { } } + if tokens.len() == 1 && command_spec_by_name(&tokens[0]).is_some() { + return Vec::new(); + } + let mut candidates = command .get_subcommands() .map(|subcommand| subcommand.get_name().to_owned()) @@ -238,6 +192,14 @@ pub fn complete_builtin_shell_line(input: &str) -> Vec { .get_arguments() .filter_map(|argument| argument.get_short().map(|short| format!("-{short}"))), ) + .chain( + tokens + .is_empty() + .then(frontend_specs) + .into_iter() + .flatten() + .map(|spec| spec.name.to_owned()), + ) .filter(|candidate| candidate.starts_with(prefix)) .map(|candidate| format!("{head}{candidate}")) .collect::>(); @@ -258,7 +220,10 @@ fn completion_prefix(input: &str) -> (&str, &str) { } /// Parses tokenized syntax through Clap without terminating the host process. -fn parse_grammar(tokens: Vec) -> Result { +fn parse_grammar( + tokens: Vec, + include_frontend_help: bool, +) -> Result { let arguments = std::iter::once(OsString::from("chitin")).chain(tokens.into_iter().map(OsString::from)); match BuiltinShellGrammar::try_parse_from(arguments) { Ok(parsed) => parsed.command.into_line(), @@ -268,12 +233,74 @@ fn parse_grammar(tokens: Vec) -> Result { - Ok(BuiltinCommandLine::Display(error.to_string())) + let mut help = error.to_string(); + if include_frontend_help { + append_frontend_help(&mut help); + } + Ok(BuiltinCommandLine::Display(help)) } Err(error) => Err(CommandLineParseError::Grammar(normalize_clap_error(error.to_string()))), } } +/// Parses registry-driven frontend names before delegating portable syntax to Clap. +/// +/// # Parameters +/// +/// * `tokens` is the tokenized built-in shell input. +/// +/// # Returns +/// +/// A frontend command or its help text when the first token is a registered +/// frontend name; otherwise `None` so portable and shell grammar can continue. +/// +/// # Errors +/// +/// Returns [`CommandLineParseError`] when a frontend command receives arguments +/// or its catalog entry cannot construct its argument-free command value. +fn parse_registered_frontend_command(tokens: &[String]) -> Result, CommandLineParseError> { + let (name, help_requested) = match tokens { + [help, name] if help == "help" => (name.as_str(), true), + [name, flag] if flag == "-h" || flag == "--help" => (name.as_str(), true), + [name, ..] => (name.as_str(), false), + [] => return Ok(None), + }; + let Some(spec) = command_spec_by_name(name) else { + return Ok(None); + }; + if spec.execution_domain != CommandExecutionDomain::Frontend { + return Ok(None); + } + if help_requested { + return Ok(Some(BuiltinCommandLine::Display(frontend_help(spec)))); + } + if tokens.len() != 1 { + return Err(CommandLineParseError::Grammar(format!( + "unexpected argument '{}'\n\nUsage: chitin {}", + tokens[1], spec.name + ))); + } + Ok(Some(BuiltinCommandLine::Frontend(frontend_command(spec.id)?))) +} + +/// Iterates over argument-free frontend specifications exposed by the shell. +fn frontend_specs() -> impl Iterator { + command_specs().filter(|spec| spec.execution_domain == CommandExecutionDomain::Frontend && !spec.requires_arguments) +} + +/// Formats help for one registry-driven frontend command. +fn frontend_help(spec: &CommandSpec) -> String { + format!("{}\n\nUsage: chitin {}\n", spec.title, spec.name) +} + +/// Appends registry-driven frontend commands to Clap's root help. +fn append_frontend_help(help: &mut String) { + help.push_str("\nFrontend commands:\n"); + for spec in frontend_specs() { + help.push_str(&format!(" {:<42} {}\n", spec.name, spec.title)); + } +} + /// Splits command text while preserving quoted whitespace and escapes. fn tokenize(input: &str) -> Result, CommandLineParseError> { let mut tokens = Vec::new(); @@ -431,52 +458,20 @@ mod tests { } #[test] - fn frontend_grammar_names_should_round_trip_through_command_ids() -> Result<(), CommandLineParseError> { - let names = grammar_subcommand_names(); - let mut frontend_names = 0_usize; - for name in &names { - // Only frontend branches carry a stable identity to compare against; - // portable and built-in names are covered by their own tests. - if let Ok(BuiltinCommandLine::Frontend(command)) = parse_builtin_command_line(name) { - assert_eq!(command.id().as_str(), name); - frontend_names += 1; - } + fn frontend_catalog_names_should_round_trip_through_the_shell() -> Result<(), CommandLineParseError> { + for spec in frontend_specs() { + let parsed = parse_builtin_command_line(spec.name)?; + assert!(matches!(parsed, BuiltinCommandLine::Frontend(command) if command.id() == spec.id)); } - // Guards against the loop going vacuous if every branch stopped parsing. - assert_eq!(frontend_names, frontend_command_id_count()); Ok(()) } #[test] - fn builtin_grammar_should_cover_every_frontend_command_id() { - let names = grammar_subcommand_names(); - - for id in CommandId::ALL { - if id.frontend_command_without_arguments().is_some() { - assert!( - names.iter().any(|name| name == id.as_str()), - "built-in grammar is missing a branch for {}", - id.as_str() - ); - } - } - } - - /// Returns every top-level built-in grammar subcommand name, aliases excluded. - fn grammar_subcommand_names() -> Vec { - let mut root = BuiltinShellGrammar::command(); - root.build(); - root - .get_subcommands() - .map(|subcommand| subcommand.get_name().to_owned()) - .collect() - } + fn frontend_help_should_come_from_the_canonical_spec() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line("help tab.close")?; - /// Counts the stable identities that have an argument-free frontend command. - fn frontend_command_id_count() -> usize { - CommandId::ALL - .iter() - .filter(|id| id.frontend_command_without_arguments().is_some()) - .count() + assert!(matches!(parsed, BuiltinCommandLine::Display(help) + if help.contains("Close Active Tab") && help.contains("Usage: chitin tab.close"))); + Ok(()) } } diff --git a/crates/chitin-command/src/model/application.rs b/crates/chitin-command/src/model/application.rs index cf7d1c57..7cf9d30e 100644 --- a/crates/chitin-command/src/model/application.rs +++ b/crates/chitin-command/src/model/application.rs @@ -16,29 +16,3 @@ impl ApplicationCommand { } } } - -/// Returns the application command registrations. -pub fn command_registrations() -> Vec { - vec![ - crate::CommandRegistration { - descriptor: crate::CommandDescriptor { - id: ApplicationCommand::ToggleCommandPanel.id(), - title: "Toggle Command Panel", - requires_arguments: false, - }, - category: crate::CommandCategory::Application, - keywords: &["quick pick", "palette", "commands"], - shortcut: Some("Ctrl/Cmd+Shift+P"), - }, - crate::CommandRegistration { - descriptor: crate::CommandDescriptor { - id: ApplicationCommand::ToggleTerminal.id(), - title: "Toggle Terminal", - requires_arguments: false, - }, - category: crate::CommandCategory::Application, - keywords: &["built-in shell", "console", "command line"], - shortcut: Some("Shift+T"), - }, - ] -} diff --git a/crates/chitin-command/src/model/catalog.rs b/crates/chitin-command/src/model/catalog.rs new file mode 100644 index 00000000..20e81921 --- /dev/null +++ b/crates/chitin-command/src/model/catalog.rs @@ -0,0 +1,454 @@ +//! Canonical command metadata and command-panel search. + +use super::{ApplicationCommand, FrontendCommand, PanelTabCommand, WorkspaceCommand}; + +/// Execution boundary required by a typed command. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CommandExecutionDomain { + /// The command can execute without application or window state. + Portable, + /// The command requires state owned by its graphical frontend. + Frontend, +} + +/// Command category used for discovery, search grouping, and presentation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandCategory { + /// Workspace and document-panel commands. + Workspace, + /// External database commands. + Database, + /// Structure parsing and analysis commands. + Structure, + /// Application-shell commands. + Application, +} + +impl CommandCategory { + /// Returns the display label for this category. + pub const fn label(self) -> &'static str { + match self { + Self::Workspace => "Workspace", + Self::Database => "Database", + Self::Structure => "Structure", + Self::Application => "Application", + } + } +} + +/// Canonical metadata for one typed command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandSpec { + /// Stable command identity. + pub id: CommandId, + /// Stable dotted name accepted by registry-driven textual frontends. + pub name: &'static str, + /// User-facing command title. + pub title: &'static str, + /// Discovery and presentation category. + pub category: CommandCategory, + /// Execution boundary required by the command. + pub execution_domain: CommandExecutionDomain, + /// Whether an input adapter must collect arguments before execution. + pub requires_arguments: bool, + /// Additional search keywords. + pub keywords: &'static [&'static str], + /// Optional shortcut label shown by discovery frontends. + pub shortcut: Option<&'static str>, + /// Whether the desktop command panel can currently invoke the command. + pub visible_in_command_panel: bool, +} + +macro_rules! frontend_command { + () => { + None + }; + ($command:expr) => { + Some(FrontendCommand::from($command)) + }; +} + +macro_rules! define_commands { + ( + $( + $(#[$variant_meta:meta])* + $variant:ident { + name: $name:literal, + title: $title:literal, + category: $category:expr, + domain: $domain:expr, + requires_arguments: $requires_arguments:literal, + keywords: $keywords:expr, + shortcut: $shortcut:expr, + command_panel: $command_panel:literal, + frontend: [$($frontend:expr)?] + } + ),* $(,)? + ) => { + /// Stable identity of a command independent of its invocation arguments. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub enum CommandId { + $( + $(#[$variant_meta])* + $variant, + )* + } + + impl CommandId { + /// Every stable command identity, in declaration order. + pub const ALL: &'static [Self] = &[$(Self::$variant),*]; + + /// Returns the stable dotted identifier. + pub const fn as_str(self) -> &'static str { + match self { + $(Self::$variant => $name),* + } + } + + /// Returns the canonical metadata for this identity. + pub const fn spec(self) -> &'static CommandSpec { + match self { + $( + Self::$variant => &CommandSpec { + id: Self::$variant, + name: $name, + title: $title, + category: $category, + execution_domain: $domain, + requires_arguments: $requires_arguments, + keywords: $keywords, + shortcut: $shortcut, + visible_in_command_panel: $command_panel, + }, + )* + } + } + + /// Builds a frontend command when this identity needs no arguments. + pub fn frontend_command_without_arguments(self) -> Option { + match self { + $(Self::$variant => frontend_command!($($frontend)?)),* + } + } + } + }; +} + +define_commands! { + /// Focus the previous project entry. + WorkspaceFocusPrevious { + name: "workspace.focus_previous_entry", + title: "Focus Previous Project Entry", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["tree", "up", "previous"], + shortcut: Some("Up"), + command_panel: true, + frontend: [WorkspaceCommand::FocusPrevious] + }, + /// Focus the next project entry. + WorkspaceFocusNext { + name: "workspace.focus_next_entry", + title: "Focus Next Project Entry", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["tree", "down", "next"], + shortcut: Some("Down"), + command_panel: true, + frontend: [WorkspaceCommand::FocusNext] + }, + /// Activate the focused project entry. + WorkspaceActivateFocused { + name: "workspace.activate_focused_entry", + title: "Activate Focused Project Entry", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["open", "tree", "file", "directory"], + shortcut: Some("Enter"), + command_panel: true, + frontend: [WorkspaceCommand::ActivateFocused] + }, + /// Focus the first project entry. + WorkspaceFocusFirst { + name: "workspace.focus_first_entry", + title: "Focus First Project Entry", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["tree", "home", "first"], + shortcut: Some("Home"), + command_panel: true, + frontend: [WorkspaceCommand::FocusFirst] + }, + /// Focus the last project entry. + WorkspaceFocusLast { + name: "workspace.focus_last_entry", + title: "Focus Last Project Entry", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["tree", "end", "last"], + shortcut: Some("End"), + command_panel: true, + frontend: [WorkspaceCommand::FocusLast] + }, + /// Show or hide the workspace sidebar. + WorkspaceToggle { + name: "workspace.toggle_workspace", + title: "Toggle Workspace Sidebar", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["files", "project", "sidebar", "explorer"], + shortcut: Some("Shift+E"), + command_panel: true, + frontend: [WorkspaceCommand::ToggleWorkspace] + }, + /// Focus the previous document tab. + PanelTabFocusPrevious { + name: "tab.focus_previous", + title: "Focus Previous Tab", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["document", "panel", "tab", "previous"], + shortcut: Some("Shift+J"), + command_panel: true, + frontend: [PanelTabCommand::FocusPrevious] + }, + /// Focus the next document tab. + PanelTabFocusNext { + name: "tab.focus_next", + title: "Focus Next Tab", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["document", "panel", "tab", "next"], + shortcut: Some("Shift+K"), + command_panel: true, + frontend: [PanelTabCommand::FocusNext] + }, + /// Close the active document tab. + PanelTabClose { + name: "tab.close", + title: "Close Active Tab", + category: CommandCategory::Workspace, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["document", "panel", "tab", "close"], + shortcut: Some("Shift+X"), + command_panel: true, + frontend: [PanelTabCommand::Close] + }, + /// Download one or more RCSB structures. + DatabaseDownloadRcsbStructure { + name: "database.rcsb.download_structure", + title: "Download RCSB Structure", + category: CommandCategory::Database, + domain: CommandExecutionDomain::Portable, + requires_arguments: true, + keywords: &["pdb", "rcsb", "mmcif", "structure"], + shortcut: None, + command_panel: true, + frontend: [] + }, + /// Show or hide the command panel. + ApplicationToggleCommandPanel { + name: "application.toggle_command_panel", + title: "Toggle Command Panel", + category: CommandCategory::Application, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["quick pick", "palette", "commands"], + shortcut: Some("Ctrl/Cmd+Shift+P"), + command_panel: true, + frontend: [ApplicationCommand::ToggleCommandPanel] + }, + /// Show or hide the built-in terminal panel. + ApplicationToggleTerminal { + name: "application.toggle_terminal", + title: "Toggle Terminal", + category: CommandCategory::Application, + domain: CommandExecutionDomain::Frontend, + requires_arguments: false, + keywords: &["built-in shell", "console", "command line"], + shortcut: Some("Shift+T"), + command_panel: true, + frontend: [ApplicationCommand::ToggleTerminal] + }, + /// Inspect a local structure file. + StructureInspect { + name: "structure.inspect", + title: "Inspect Structure", + category: CommandCategory::Structure, + domain: CommandExecutionDomain::Portable, + requires_arguments: true, + keywords: &["pdb", "mmcif", "summary", "metadata"], + shortcut: None, + command_panel: false, + frontend: [] + }, + /// Validate a local structure file. + StructureValidate { + name: "structure.validate", + title: "Validate Structure", + category: CommandCategory::Structure, + domain: CommandExecutionDomain::Portable, + requires_arguments: true, + keywords: &["pdb", "mmcif", "check", "invariants"], + shortcut: None, + command_panel: false, + frontend: [] + }, +} + +impl std::fmt::Display for CommandId { + /// Formats the stable dotted identifier. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Iterates over every canonical command specification. +pub fn command_specs() -> impl ExactSizeIterator { + CommandId::ALL.iter().map(|id| id.spec()) +} + +/// Finds canonical command metadata by its stable dotted name. +pub fn command_spec_by_name(name: &str) -> Option<&'static CommandSpec> { + command_specs().find(|spec| spec.name == name) +} + +/// A ranked command result. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CommandSearchResult { + /// Match score, where larger values rank first. + pub score: i32, + /// Matching canonical command specification. + pub spec: &'static CommandSpec, +} + +/// Registry and deterministic search implementation for command specifications. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CommandRegistry { + commands: Vec<&'static CommandSpec>, +} + +impl CommandRegistry { + /// Creates a registry from canonical command specifications. + pub fn new(commands: impl IntoIterator) -> Self { + Self { + commands: commands.into_iter().collect(), + } + } + + /// Finds a specification by stable command identity. + pub fn spec_for(&self, id: CommandId) -> Option<&'static CommandSpec> { + self.commands.iter().copied().find(|spec| spec.id == id) + } + + /// Searches commands using deterministic scoring. + pub fn search(&self, query: &str) -> Vec { + let query = normalize(query); + let mut results = self + .commands + .iter() + .filter_map(|spec| score_spec(spec, &query).map(|score| CommandSearchResult { score, spec })) + .collect::>(); + results.sort_by(|left, right| { + right + .score + .cmp(&left.score) + .then_with(|| left.spec.title.cmp(right.spec.title)) + }); + results + } +} + +/// Creates the registry of commands currently invocable from the desktop panel. +pub fn default_registry() -> CommandRegistry { + CommandRegistry::new(command_specs().filter(|spec| spec.visible_in_command_panel)) +} + +/// Scores a command specification against a normalized search query. +/// +/// Exact command identifiers receive the highest score, followed by title, +/// identifier-prefix, title-prefix, keyword, and category matches. This keeps +/// stable names predictable while still supporting human-facing discovery. +/// +/// # Parameters +/// +/// * `spec` is the canonical command metadata being matched. +/// * `query` is a trimmed, lowercase search query. +/// +/// # Returns +/// +/// A score when the command matches, or `None` when it should be omitted. +fn score_spec(spec: &CommandSpec, query: &str) -> Option { + if query.is_empty() { + return Some(1); + } + let name = normalize(spec.name); + let title = normalize(spec.title); + let category = normalize(spec.category.label()); + if name == query { + return Some(1000); + } + if name.starts_with(query) { + return Some(850); + } + if title.starts_with(query) { + return Some(700); + } + if title.contains(query) { + return Some(500); + } + if spec.keywords.iter().any(|keyword| normalize(keyword) == query) { + return Some(420); + } + if spec.keywords.iter().any(|keyword| normalize(keyword).contains(query)) { + return Some(360); + } + if category == query || category.starts_with(query) { + return Some(280); + } + None +} + +/// Trims surrounding whitespace and lowercases a command-search value. +fn normalize(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_specs_should_cover_every_command_id() { + assert_eq!(command_specs().count(), CommandId::ALL.len()); + } + + #[test] + fn stable_names_should_resolve_to_their_original_identity() { + for id in CommandId::ALL { + assert_eq!(command_spec_by_name(id.as_str()).map(|spec| spec.id), Some(*id)); + } + } + + #[test] + fn default_registry_should_hide_commands_without_panel_argument_ui() { + assert_eq!(default_registry().spec_for(CommandId::StructureInspect), None); + } + + #[test] + fn frontend_specs_should_construct_commands_in_the_frontend_domain() { + for spec in command_specs().filter(|spec| spec.execution_domain == CommandExecutionDomain::Frontend) { + let command = spec.id.frontend_command_without_arguments(); + assert_eq!(command.as_ref().map(FrontendCommand::id), Some(spec.id)); + } + } +} diff --git a/crates/chitin-command/src/model/database.rs b/crates/chitin-command/src/model/database.rs index c567ea91..5cf95d1b 100644 --- a/crates/chitin-command/src/model/database.rs +++ b/crates/chitin-command/src/model/database.rs @@ -30,17 +30,3 @@ impl DatabaseCommand { } } } - -/// Returns the database command registrations. -pub fn command_registrations() -> Vec { - vec![crate::CommandRegistration { - descriptor: crate::CommandDescriptor { - id: CommandId::DatabaseDownloadRcsbStructure, - title: "Download RCSB Structure", - requires_arguments: true, - }, - category: crate::CommandCategory::Database, - keywords: &["pdb", "rcsb", "mmcif", "structure"], - shortcut: None, - }] -} diff --git a/crates/chitin-command/src/model/mod.rs b/crates/chitin-command/src/model/mod.rs index 2e6f62cd..56cd327d 100644 --- a/crates/chitin-command/src/model/mod.rs +++ b/crates/chitin-command/src/model/mod.rs @@ -1,12 +1,14 @@ //! Typed command identities, arguments, registrations, and search behavior. mod application; +mod catalog; mod database; mod panel_tab; mod structure; mod workspace; pub use application::ApplicationCommand; +pub use catalog::*; pub use database::{DatabaseCommand, RcsbDownloadArguments}; pub use panel_tab::PanelTabCommand; pub use structure::{ @@ -14,116 +16,6 @@ pub use structure::{ }; pub use workspace::WorkspaceCommand; -/// Execution boundary required by a typed command. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum CommandExecutionDomain { - /// The command can execute without application or window state. - Portable, - /// The command requires state owned by its graphical frontend. - Frontend, -} - -/// Stable identity of a command independent of its invocation arguments. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum CommandId { - /// Focus the previous project entry. - WorkspaceFocusPrevious, - /// Focus the next project entry. - WorkspaceFocusNext, - /// Activate the focused project entry. - WorkspaceActivateFocused, - /// Focus the first project entry. - WorkspaceFocusFirst, - /// Focus the last project entry. - WorkspaceFocusLast, - /// Show or hide the workspace sidebar. - WorkspaceToggle, - /// Focus the previous document tab. - PanelTabFocusPrevious, - /// Focus the next document tab. - PanelTabFocusNext, - /// Close the active document tab. - PanelTabClose, - /// Download one or more RCSB structures. - DatabaseDownloadRcsbStructure, - /// Show or hide the command panel. - ApplicationToggleCommandPanel, - /// Show or hide the built-in terminal panel. - ApplicationToggleTerminal, - /// Inspect a local structure file. - StructureInspect, - /// Validate a local structure file. - StructureValidate, -} - -impl CommandId { - /// Every stable command identity, in declaration order. - /// - /// Frontends that mirror the command set, such as the built-in shell grammar, - /// iterate this list to prove their own coverage instead of restating it. - pub const ALL: &[Self] = &[ - Self::WorkspaceFocusPrevious, - Self::WorkspaceFocusNext, - Self::WorkspaceActivateFocused, - Self::WorkspaceFocusFirst, - Self::WorkspaceFocusLast, - Self::WorkspaceToggle, - Self::PanelTabFocusPrevious, - Self::PanelTabFocusNext, - Self::PanelTabClose, - Self::DatabaseDownloadRcsbStructure, - Self::ApplicationToggleCommandPanel, - Self::ApplicationToggleTerminal, - Self::StructureInspect, - Self::StructureValidate, - ]; - - /// Returns the stable dotted identifier. - pub const fn as_str(self) -> &'static str { - match self { - Self::WorkspaceFocusPrevious => "workspace.focus_previous_entry", - Self::WorkspaceFocusNext => "workspace.focus_next_entry", - Self::WorkspaceActivateFocused => "workspace.activate_focused_entry", - Self::WorkspaceFocusFirst => "workspace.focus_first_entry", - Self::WorkspaceFocusLast => "workspace.focus_last_entry", - Self::WorkspaceToggle => "workspace.toggle_workspace", - Self::PanelTabFocusPrevious => "tab.focus_previous", - Self::PanelTabFocusNext => "tab.focus_next", - Self::PanelTabClose => "tab.close", - Self::DatabaseDownloadRcsbStructure => "database.rcsb.download_structure", - Self::ApplicationToggleCommandPanel => "application.toggle_command_panel", - Self::ApplicationToggleTerminal => "application.toggle_terminal", - Self::StructureInspect => "structure.inspect", - Self::StructureValidate => "structure.validate", - } - } - - /// Builds a frontend command when this identity needs no arguments. - pub fn frontend_command_without_arguments(self) -> Option { - match self { - Self::WorkspaceFocusPrevious => Some(WorkspaceCommand::FocusPrevious.into()), - Self::WorkspaceFocusNext => Some(WorkspaceCommand::FocusNext.into()), - Self::WorkspaceActivateFocused => Some(WorkspaceCommand::ActivateFocused.into()), - Self::WorkspaceFocusFirst => Some(WorkspaceCommand::FocusFirst.into()), - Self::WorkspaceFocusLast => Some(WorkspaceCommand::FocusLast.into()), - Self::WorkspaceToggle => Some(WorkspaceCommand::ToggleWorkspace.into()), - Self::PanelTabFocusPrevious => Some(PanelTabCommand::FocusPrevious.into()), - Self::PanelTabFocusNext => Some(PanelTabCommand::FocusNext.into()), - Self::PanelTabClose => Some(PanelTabCommand::Close.into()), - Self::ApplicationToggleCommandPanel => Some(ApplicationCommand::ToggleCommandPanel.into()), - Self::ApplicationToggleTerminal => Some(ApplicationCommand::ToggleTerminal.into()), - Self::DatabaseDownloadRcsbStructure | Self::StructureInspect | Self::StructureValidate => None, - } - } -} - -impl std::fmt::Display for CommandId { - /// Formats the stable dotted identifier. - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(self.as_str()) - } -} - /// Commands executable without application or window state. #[derive(Clone, Debug, PartialEq, Eq)] pub enum PortableCommand { @@ -181,11 +73,8 @@ impl ChitinCommand { } /// Returns the execution boundary required by this command. - pub const fn execution_domain(&self) -> CommandExecutionDomain { - match self { - Self::Portable(_) => CommandExecutionDomain::Portable, - Self::Frontend(_) => CommandExecutionDomain::Frontend, - } + pub fn execution_domain(&self) -> CommandExecutionDomain { + self.id().spec().execution_domain } } @@ -261,186 +150,6 @@ impl From for ChitinCommand { } } -/// Command category used for search grouping and presentation. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CommandCategory { - /// Workspace and document-panel commands. - Workspace, - /// External database commands. - Database, - /// Application-shell commands. - Application, -} - -impl CommandCategory { - /// Returns the display label for this category. - pub fn label(self) -> &'static str { - match self { - Self::Workspace => "Workspace", - Self::Database => "Database", - Self::Application => "Application", - } - } -} - -/// Frontend-independent description of one available command. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CommandDescriptor { - /// Stable command identifier. - pub id: CommandId, - /// User-facing title. - pub title: &'static str, - /// Whether an input adapter must collect arguments before execution. - pub requires_arguments: bool, -} - -/// Search and presentation metadata attached to a command registration. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CommandRegistration { - /// Frontend-independent command description. - pub descriptor: CommandDescriptor, - /// Search result category. - pub category: CommandCategory, - /// Additional search keywords. - pub keywords: &'static [&'static str], - /// Optional shortcut label shown by search frontends. - pub shortcut: Option<&'static str>, -} - -/// A ranked command result. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct CommandSearchResult<'a> { - /// Match score, where larger values rank first. - pub score: i32, - /// Matching descriptor. - pub descriptor: &'a CommandDescriptor, - /// Optional shortcut label associated with the registration. - pub shortcut: Option<&'static str>, -} - -/// Registry and deterministic search implementation for command descriptors. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct CommandRegistry { - commands: Vec, -} - -impl CommandRegistry { - /// Creates a registry from command descriptors. - pub fn new(commands: impl IntoIterator) -> Self { - Self { - commands: commands.into_iter().collect(), - } - } - - /// Finds a descriptor by stable command identity. - pub fn descriptor_for(&self, id: CommandId) -> Option<&CommandDescriptor> { - self - .commands - .iter() - .map(|registration| ®istration.descriptor) - .find(|descriptor| descriptor.id == id) - } - - /// Searches commands using deterministic scoring. - pub fn search(&self, query: &str) -> Vec> { - let query = normalize(query); - let mut results = self - .commands - .iter() - .filter_map(|registration| { - score_registration(registration, &query).map(|score| CommandSearchResult { - score, - descriptor: ®istration.descriptor, - shortcut: registration.shortcut, - }) - }) - .collect::>(); - results.sort_by(|left, right| { - right - .score - .cmp(&left.score) - .then_with(|| left.descriptor.title.cmp(right.descriptor.title)) - }); - results - } -} - -/// Creates the built-in registry from all shared command descriptors. -pub fn default_registry() -> CommandRegistry { - CommandRegistry::new( - workspace::command_registrations() - .into_iter() - .chain(panel_tab::command_registrations()) - .chain(database::command_registrations()) - .chain(application::command_registrations()), - ) -} - -/// Scores a command registration against a normalized search query. -/// -/// Exact command identifiers receive the highest score, followed by title, -/// identifier-prefix, title-prefix, and category matches. The ordering keeps -/// command IDs predictable while still allowing users to search by the label -/// shown in the command panel. -/// -/// # Parameters -/// -/// * `registration` is the command and search metadata being matched. -/// * `query` is a trimmed, lowercase search query. -/// -/// # Returns -/// -/// A score when the descriptor matches, or `None` when it should be omitted. -fn score_registration(registration: &CommandRegistration, query: &str) -> Option { - if query.is_empty() { - return Some(1); - } - let descriptor = ®istration.descriptor; - let id = normalize(descriptor.id.as_str()); - let title = normalize(descriptor.title); - let category = normalize(registration.category.label()); - // Check stronger matches first so a broad prefix cannot hide an exact hit. - if id == query { - return Some(1000); - } - if id.starts_with(query) { - return Some(850); - } - if title.starts_with(query) { - return Some(700); - } - if title.contains(query) { - return Some(500); - } - if registration.keywords.iter().any(|keyword| normalize(keyword) == query) { - return Some(420); - } - if registration - .keywords - .iter() - .any(|keyword| normalize(keyword).contains(query)) - { - return Some(360); - } - if category == query || category.starts_with(query) { - return Some(280); - } - None -} - -/// Trims surrounding whitespace and lowercases a command-search value. -/// -/// # Parameters -/// -/// * `value` is the raw command identifier, title, category, or user query. -/// -/// # Returns -/// -/// A canonical value suitable for case-insensitive matching. -fn normalize(value: &str) -> String { - value.trim().to_ascii_lowercase() -} - #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; @@ -457,32 +166,6 @@ mod tests { ); } - #[test] - fn command_id_all_should_cover_every_variant() { - // The or-pattern lists every variant on purpose. Adding a `CommandId` - // without extending `ALL` leaves this match non-exhaustive, so the test - // stops compiling rather than silently skipping the new identity. - for id in CommandId::ALL { - match id { - CommandId::WorkspaceFocusPrevious - | CommandId::WorkspaceFocusNext - | CommandId::WorkspaceActivateFocused - | CommandId::WorkspaceFocusFirst - | CommandId::WorkspaceFocusLast - | CommandId::WorkspaceToggle - | CommandId::PanelTabFocusPrevious - | CommandId::PanelTabFocusNext - | CommandId::PanelTabClose - | CommandId::DatabaseDownloadRcsbStructure - | CommandId::ApplicationToggleCommandPanel - | CommandId::ApplicationToggleTerminal - | CommandId::StructureInspect - | CommandId::StructureValidate => {} - } - } - assert_eq!(CommandId::ALL.len(), 14); - } - #[test] fn database_commands_should_use_the_portable_execution_domain() -> Result<(), Box> { let command = ChitinCommand::from(DatabaseCommand::DownloadRcsbStructure(RcsbDownloadArguments { diff --git a/crates/chitin-command/src/model/panel_tab.rs b/crates/chitin-command/src/model/panel_tab.rs index a0226382..76668b2f 100644 --- a/crates/chitin-command/src/model/panel_tab.rs +++ b/crates/chitin-command/src/model/panel_tab.rs @@ -19,39 +19,3 @@ impl PanelTabCommand { } } } - -/// Returns the document-panel tab command registrations. -pub fn command_registrations() -> Vec { - [ - ( - PanelTabCommand::FocusPrevious, - "Focus Previous Tab", - &["document", "panel", "tab", "previous"][..], - Some("Shift+J"), - ), - ( - PanelTabCommand::FocusNext, - "Focus Next Tab", - &["document", "panel", "tab", "next"][..], - Some("Shift+K"), - ), - ( - PanelTabCommand::Close, - "Close Active Tab", - &["document", "panel", "tab", "close"][..], - Some("Shift+X"), - ), - ] - .into_iter() - .map(|(command, title, keywords, shortcut)| crate::CommandRegistration { - descriptor: crate::CommandDescriptor { - id: command.id(), - title, - requires_arguments: false, - }, - category: crate::CommandCategory::Workspace, - keywords, - shortcut, - }) - .collect() -} diff --git a/crates/chitin-command/src/model/workspace.rs b/crates/chitin-command/src/model/workspace.rs index d494c37d..37e2f960 100644 --- a/crates/chitin-command/src/model/workspace.rs +++ b/crates/chitin-command/src/model/workspace.rs @@ -33,57 +33,3 @@ impl WorkspaceCommand { } } } - -/// Returns the project-workspace command registrations. -pub fn command_registrations() -> Vec { - [ - ( - WorkspaceCommand::ToggleWorkspace, - "Toggle Workspace Sidebar", - &["files", "project", "sidebar", "explorer"][..], - Some("Shift+E"), - ), - ( - WorkspaceCommand::FocusPrevious, - "Focus Previous Project Entry", - &["tree", "up", "previous"][..], - Some("Up"), - ), - ( - WorkspaceCommand::FocusNext, - "Focus Next Project Entry", - &["tree", "down", "next"][..], - Some("Down"), - ), - ( - WorkspaceCommand::ActivateFocused, - "Activate Focused Project Entry", - &["open", "tree", "file", "directory"][..], - Some("Enter"), - ), - ( - WorkspaceCommand::FocusFirst, - "Focus First Project Entry", - &["tree", "home", "first"][..], - Some("Home"), - ), - ( - WorkspaceCommand::FocusLast, - "Focus Last Project Entry", - &["tree", "end", "last"][..], - Some("End"), - ), - ] - .into_iter() - .map(|(command, title, keywords, shortcut)| crate::CommandRegistration { - descriptor: crate::CommandDescriptor { - id: command.id(), - title, - requires_arguments: false, - }, - category: crate::CommandCategory::Workspace, - keywords, - shortcut, - }) - .collect() -} diff --git a/crates/chitin-desktop/src/components/command_panel.rs b/crates/chitin-desktop/src/components/command_panel.rs index ca363a9a..4bad2532 100644 --- a/crates/chitin-desktop/src/components/command_panel.rs +++ b/crates/chitin-desktop/src/components/command_panel.rs @@ -89,14 +89,14 @@ pub(crate) fn render_command_panel( } let results = controller.registry().search(controller.query()); - let selected_ids = results.iter().map(|result| result.descriptor.id).collect::>(); + let selected_ids = results.iter().map(|result| result.spec.id).collect::>(); let overlay = QuickPickOverlay { query: controller.query().into(), placeholder: "Type a command".into(), search_input: Some(QuickPickSearchInput::new(search_input)), items: results .iter() - .map(|result| QuickPickItem::new(result.descriptor.title, result.shortcut)) + .map(|result| QuickPickItem::new(result.spec.title, result.spec.shortcut)) .collect(), selected_index: controller.selected_index(), scroll_handle: Some(controller.result_scroll_handle()), @@ -370,11 +370,11 @@ impl ChitinApp { /// * `window` receives focus restoration when an immediate command closes the panel. /// * `cx` is used to dispatch or notify state changes. pub(crate) fn invoke_command_from_panel(&mut self, id: CommandId, window: &mut Window, cx: &mut Context) { - let Some(descriptor) = self.command_panel.registry().descriptor_for(id) else { + let Some(spec) = self.command_panel.registry().spec_for(id) else { return; }; - if descriptor.requires_arguments { + if spec.requires_arguments { if self.command_panel.open_form(id) { cx.notify(); } diff --git a/crates/chitin-desktop/src/components/command_panel/controller.rs b/crates/chitin-desktop/src/components/command_panel/controller.rs index 13ba0f4a..35f7e3a0 100644 --- a/crates/chitin-desktop/src/components/command_panel/controller.rs +++ b/crates/chitin-desktop/src/components/command_panel/controller.rs @@ -221,10 +221,10 @@ impl CommandPanelController { /// /// `true` when the command is registered and the panel entered form mode. pub(crate) fn open_form(&mut self, id: CommandId) -> bool { - let Some(descriptor) = self.registry.descriptor_for(id) else { + let Some(spec) = self.registry.spec_for(id) else { return false; }; - if !descriptor.requires_arguments { + if !spec.requires_arguments { return false; } @@ -322,7 +322,7 @@ impl CommandPanelController { .registry .search(self.quickpick.query()) .get(self.quickpick.selected_index()) - .map(|result| CommandPanelEvent::Invoke(result.descriptor.id)) + .map(|result| CommandPanelEvent::Invoke(result.spec.id)) .unwrap_or(CommandPanelEvent::StateChanged) } } From 08a5bf4ca4ea742f7f3ab6ae11907854350e95a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 28 Sep 2026 15:31:11 +0800 Subject: [PATCH 17/32] feat(terminal): add native PTY terminal crate and bundled font support Introduce the frontend-independent chitin-terminal crate for PTY sessions, terminal sizing, VT snapshots, and cell attributes. Add the bundled Cascadia Mono font with GPUI registration, update desktop terminal rendering to use it, and add the terminal-debug example with a corresponding just command. --- Cargo.lock | 223 ++++++- Cargo.toml | 1 + .../fonts/cascadia-mono/CascadiaMono-Bold.ttf | Bin 0 -> 581704 bytes .../cascadia-mono/CascadiaMono-Regular.ttf | Bin 0 -> 575912 bytes assets/fonts/cascadia-mono/LICENSE | 94 +++ crates/chitin-desktop/Cargo.toml | 3 +- .../examples/chitin-wgpu-desktop.rs | 6 + .../chitin-desktop/examples/terminal-debug.rs | 612 ++++++++++++++++++ .../src/components/terminal/mod.rs | 1 - .../src/components/terminal/render.rs | 4 +- crates/chitin-desktop/src/fonts.rs | 24 + crates/chitin-desktop/src/lib.rs | 1 + crates/chitin-desktop/src/main.rs | 8 +- crates/chitin-terminal/Cargo.toml | 15 + crates/chitin-terminal/src/lib.rs | 10 + crates/chitin-terminal/src/session.rs | 315 +++++++++ crates/chitin-terminal/src/size.rs | 59 ++ crates/chitin-terminal/src/snapshot.rs | 271 ++++++++ justfile | 4 + 19 files changed, 1645 insertions(+), 6 deletions(-) create mode 100644 assets/fonts/cascadia-mono/CascadiaMono-Bold.ttf create mode 100644 assets/fonts/cascadia-mono/CascadiaMono-Regular.ttf create mode 100644 assets/fonts/cascadia-mono/LICENSE create mode 100644 crates/chitin-desktop/examples/terminal-debug.rs create mode 100644 crates/chitin-desktop/src/fonts.rs create mode 100644 crates/chitin-terminal/Cargo.toml create mode 100644 crates/chitin-terminal/src/lib.rs create mode 100644 crates/chitin-terminal/src/session.rs create mode 100644 crates/chitin-terminal/src/size.rs create mode 100644 crates/chitin-terminal/src/snapshot.rs diff --git a/Cargo.lock b/Cargo.lock index 265a764d..bb4716c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.26.1-dev" +source = "git+https://github.com/l0ng-ai/alacritty?rev=1276f128fbaa8832cbc66210675cbe8aeb570499#1276f128fbaa8832cbc66210675cbe8aeb570499" +dependencies = [ + "base64", + "bitflags 2.13.1", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix 1.1.4", + "rustix-openpty", + "serde", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -485,6 +509,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "block" @@ -774,6 +801,7 @@ dependencies = [ "chitin-command", "chitin-databases", "chitin-molecule-renderer", + "chitin-terminal", "chitin-ui", "chitin-utils", "chitin-wgpu", @@ -809,6 +837,15 @@ dependencies = [ "wgpu", ] +[[package]] +name = "chitin-terminal" +version = "0.1.0" +dependencies = [ + "alacritty_terminal", + "portable-pty", + "thiserror 2.0.20", +] + [[package]] name = "chitin-ui" version = "0.1.0" @@ -1724,6 +1761,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" @@ -2783,6 +2831,15 @@ dependencies = [ "leaky-cow", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.1" @@ -3150,6 +3207,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3206,6 +3272,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -3290,6 +3365,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nix" version = "0.29.0" @@ -3972,6 +4061,27 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "postage" version = "0.5.0" @@ -4544,6 +4654,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-openpty" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" +dependencies = [ + "errno", + "libc", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.43" @@ -4857,6 +4978,48 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -4885,6 +5048,22 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shellexpand" version = "3.1.2" @@ -4906,6 +5085,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5409,6 +5598,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5732,7 +5930,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "windows-sys 0.61.2", ] @@ -5941,6 +6139,20 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "cursor-icon", + "log", + "memchr", + "serde", +] + [[package]] name = "waker-fn" version = "1.2.0" @@ -6800,6 +7012,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winsafe" version = "0.0.19" diff --git a/Cargo.toml b/Cargo.toml index 95a0af8c..75c86f1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/chitin-databases", "crates/chitin-desktop", "crates/chitin-molecule-renderer", + "crates/chitin-terminal", "crates/chitin-ui", "crates/chitin-wgpu", "crates/chitin-wasm", diff --git a/assets/fonts/cascadia-mono/CascadiaMono-Bold.ttf b/assets/fonts/cascadia-mono/CascadiaMono-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d540de9249e9751094fea4f12079c06d324ea4ec GIT binary patch literal 581704 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%0D*? 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 literal 0 HcmV?d00001 diff --git a/assets/fonts/cascadia-mono/CascadiaMono-Regular.ttf b/assets/fonts/cascadia-mono/CascadiaMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e697d012e808786beb288c62faceae3f708e9521 GIT binary patch literal 575912 zcmeFa3!F{W`#-++T6^zv_RQYr9Ojzla^`+8m?X_iW{63XT*f7hOL8Z-gpiLUNs>yE zK< zNydHPCmZ*JpJF@!eyZ^x_=k*#!A~=$fq%q!4E#)ECiuq<8mPX&7%C?}FcKYzF_H@d5a4#y0RD z8Xtq-Y3v05iLnd(r^ct?cN_b_e{OsZe!p=L{8z?T;13xULNkRagfcZVAAF%%2y9|@ z1$Hx0dgf?zG+gg6{{eoidB2e66mu&02Ti=G`H(pq{2cRNz-P@e;3D${;EN{e+FWj~ z0lsd&0iWwkv=dj1D^4iaCKu|_wbQi|k^`;-!0%k&3C*40P6Vd7Q-l_|G7{ww`F7-X zp(1xg?g4&lBcHak>i{paF9+t@xk9t^?IQ5k*e$@9*loeLx7!16usZ@v?M}ch_RYW^ zc5h%G8{d=N&%OoxKzkr?uss+!#715FKJkrsol&jvfJV<@rHQ6 zE_QF^UlVb`d?2Nre;C&VVGyD^YVmb*t@%b|1id+cg!$?YC3x zG&|kSw6pAhUDK{**S53mI%pO3?aMDxUM2QT_RV%L)O&yX7SubHSUS}jl~*vgf0|;| zbef@^ofjM8a`Ss`#=Frw9gjQ-nI0@%v|CM>p_1Ma`Ag(!JPQ#oB1Eo`gL@1cD25Ck zIC!AwD2$@!Ejx&=J%;t@DaQ1=W7r@uv45YQ!$j9XJw^=C8i^<|LW~s?#Z)mv%n=LF zE|-fnVuRQsc8dMth&Un2HMdqBvAad2u!V!(+Kcg|8b&w?seqJnBZb0vXW-L!!Ka0s zpjWt|U6CJjw~$S*ZRZ!e+qY{9gg*+gBSd$F=-v?BAEF0C^l*qC4bkHvdIG6YJf}kR z4AS;t;HkJA_*&8y1`&%0GjLD=b9f5dI8j~D_#e7pC8#jdnrOo|0NhQ9{ z<3crVNCwZ;<5DYCjcbt+dgHo{17yf8*p9*J;obvvBpOG<))1obh%g&hM?CQyHV03> z#`y?ew{dJ^l&>(W^ioCq9Qp?FZ8xK5{*HoQ(Mi;?&$(5GLyu9=61I7zEo=mztzo-|DDh$26nt^$ zxdUitSn@}0ufbm&5AQuUB2L5nXb$E=%P{X*i#g6_%x89D?((_u6+ZCM7wORliv;9$ zDeV3#c10C_4S2G<|BYQAa^D2HHN@}u!!XH^lFNt?9S<4~H3mF|CfTGAKNXbxPlufq z;^%@cfL#Pj{>jo?P|hj0VHxF$_;CC?MB{?4g$V^?3Il9=7W8L2q+mo?Rv3VZIQJA$ z=quF86pSyJ#B^%Gp@QiJvkK-GEGSr1u(V)B!J2~g1)B=C7RFYZf*s&f3u6m*7wm`s zqXj<}oGK_Uj78lx{)=^6Wv+AnOy>{3yT3lmks=QLT)se4gQ+I2LqAttv=yB(ns$Xr z6{E$&B27FZ9u-%K$6*S^d*UO}7~|M(QLJr8A9|g(Py1Z7)DCK2iPqZB+DTEORY*;= zlZG_J4bm+w(NXr6y+x_)EBlL1a)=xvy2{~lxVTB)CC7_y=4#ArsjWAcW&bbEbS~@* zS~c?gPc$UcxNb9EEE?Q}$v^zu}ZK^g+^w*|q^Tl9of%Y#kPJ2UpLyXrxN1MDGZO<(x z$P}3(?vX99x|k^2%J$-3*-!Qplerz_AVv2lPJ}n;Lb~sh^%1`|tY!dPlF38=5 zFAB_kRLk=OeFQ?_63L%ECdyXN=Losm16xZHWU3z;s%0zmee*i!bq5a088G66}?zP~b zuFC#+9r$-vftc~+Zh_qeyFEm)!p}Vjdqju^3(+1iqUT{GKhI)Br{@OH>*Wy-+hA?T zE(Tpz@ROph_4F@n|mDgQ6Gcz}A=H&hrQ^xN~DFuDg+u`Z*Y%&-hF2nZ_g)t)rGtnrXmt4xDNH)(->R@it zTGZv)Nj;vOEem>%P&&NFW?39jl#3wRZ#)@4$4(*Y-GFR-C*UD?fXFMkD<1y)T*uw`hcl!+M zZWprdwv2VROIUaNHtTLbP+Qff;;h=Qe$Xs+LVr%XR9~XMqJ5~Z)?e2?)wk=PYM<%* z^e?oppv`q@-;g#}`^~6f^wuhje#TJQg0#7^1L<*Pchca>o}{~#y-8~;`;xv^_9soP z985Y|IgGTk@@~@0%1NY=l@E|ER!$==tbC00uW~kNUgcAybCnCAZQUyuLeF|YJ_il! zNx9fKZTu!*H_D9)`3Cf?UiqdOZRX2u=GA6b`3vb;RTODhRULDjdAG_jCz|)F2IhU{ zWYv)LqbirQp(>B`ohqL+o$4ymR;sH}vRKVR11~ga;I%hN_b%t6lwXqbNXL%ye--8# z(yPk_DDgMtLX`O1@>$ZNqbG;{93h}Nmy4sOO83yIU2O+`U|8**Iy(py8bGayZ#}SyZ$kiyWyvDH)>J28x5)4 zja({s%-m4!Mk6YBqmatoXh!936jQkyH&VG9U8&rS9#rl|Un+NFAeB2}MY$WdQ@I;s zsN9XQRPM%IRPNC0oi-|rM@h47JV9k`d_-k!d{1Rn?BM)o7G4Q zZB{2Ov>8q1ZpKi#o3T{xW*n8fnTT?SHb_H%tJT!r)H1pB-{8`pLoJ=_U|FaR=2Lq| z=~Epnq*jk|$9@LNo$6pQ*TE95gQZ*t*Kr-R95?>9^$4Z_B0MkxRb|mwtEJV=#Ks?gDC?N`DZwdgD%N^~PP)>W#ap z)f>~eCRTDyd_*;2d`CM9#&N0%<0q;K;{?@&@iWzgagu7nI7KyK{7N-poTi#Ee&d>8 zeZ5Kg!fQl^sENM#I?)RHL7K%!@GL%(``j4rbL()Qn*;6IV)WfR#3#_d(z>vb*b7s@ zv-m=u#W&_zd=ssfR#!CRxqLCty@TxQlzz@v;x*^>=f>IZ+Oi!^FMZZ%*QV^FGyE^%0Zt zRSptU)ChIEctDL+cZvtqcy+gUSWQ&-ibvFBb-#F2O;rzy$MCgI6OXGI>M=1(%~DT@ z*=nA8TFg-k)W5`B{aU?P%+s&eTZ;L58@)s<(A(=B#6ta~ep;02XP};a0SjPLEJHh~ zE>@bh>4?>+vly|)Y-P3TIJ)ix&=W~#Ed%oknl<#>j z<+wPKGv#la?m6B6LYThrGwmYg@2B~9Lk!9p_BYMwoPTe|ek_J)5hpP2Jk%(N<{ z9E@)Y-YJ;NX`GX3p?HF&L;l&H_@VB)bLL>(u-Sf&3rjZsP7-$cuh1=ZkA%#=x?!`u z?jM=3zrRO!)eW1f;VN@{1gRdW@@DW=uSPvTY!HdN&|WC<)QhfH#nwGu_hj8O^<+Kw zA9Cz+&OhR+8BM0%B$!N?Y?%5mxoisS71k?)X#rCL(-EdCOi!5pFhgKQz)Xi312Y~5 zG1c^tsW-LWRKz?TeQPQ2=?k92NuI;$yn}D@OwP@__7OC)37bYX;o&`cAMegr$H>-8 zM6154pLh=A+d%O=&+uNr_%>X;s77Ls{w0iZ{}3;$aq2GdikhJA5i8Xsb)R@uO;Hbs z*VIGmVevY~z(>U!YNmQzys2hmH~%e+hfj&O)qM4gcteKW`wP*Cl^m*E|`h3hLU*_55Ydo8L zi)WMX@NDt}o=tv4BdYclM${tAEL$2aWwg=SXd`2cwnkeSXS6pu$asu`y=4uaYbNqs zGmGb%m-Afn3Z83T$#cy-o@-vkbIm3^*Sv=3njLtq*@@?x-FU9qi|3mCd9FE_=bFQK zt~rY5ns@MAb1X*P!}2cUd*cT=!8m~#=e<1Rd=O)=k`J3E=AF}d-Z=*&a7+1?*#@)E z4`>{gpJ5!n6Eo2X<~{NZjli;;##>pzGfu%XPL1}>m5b+{ma=Ql%Z|^k9<*UTOi4~W zKM%?tojoXfSoXy1DcRG&o&QwnLh|$7sy_Xd(0?_qKTBs&mAq2==g0Yf^J#Q;=l{B? zy)b+J-?!&NEY4nCJ7{yV8`j^a$GuuEXNpX26Sz+c2q?Paw$ zz^i8$Bx_yP#;h&i&VQ`8Zc6$1;y`-TXTmuEP+`va_mZ#b+gDrDxT;C?9s;l|^O>BhhJDGeci}RWA4? z&%B81??d#>8kE&2t6>(=zFAGPa=0I<C-|ks|-KVcWe}oBL}u7_OtLDOBad{Z3U`RsQ^5bvs(~ z`I0jiJfC~7w~7o&M-%`@+rur>6ffe z*-kEmsY6nSq{XIoB$Kk8abn7puu0ufX;P;Bo<|5znh&w;Kx{Kp=A@K`%zV%#;apVa z;6e$756e<_!$>y71)o@wvOINn*jJ66Dce)FpC4o8(;sockij=5p57NGA+;-+lyxcV zLfq=$6Z_ep3X`%iWeZZ<5>ET~WqD!TDaR}Qp3hb0EG$p$NL*@H#-8A+ykpo8(aMmO zTv|k>57gjvGAX;5_D4+p*@R0mT$15u87$qDeW>AgvXtbI%#BEy!f5hw7CO#?6-STwT*!A&!K~H@euHp6}z5 z^_6dqTAPYmj83h=#-Hj>)5)Y}!q!i%kMy&H?HW(HsfAqYRP(W^MPa^@gR})=himY? zgK-fep0FtibK$aq+jQc|7t>fOTS&(Qzm-aV#H9`iKl2y2KNvgYvr@;fJ1`s*-mYgj zFYwVHt=5OQ$E1!3`%N7mj;%5jd`=2WQx^s6Is8n%!m)E|Qz`HCMpUMVeGy9;gJDpP z(~&o-{kh}=7CsU0g49K62`KwTsY~&^B6SUiR)QaGNqI%{=$cbJCh4Z-EW zFbBR7Ow;tHG~c~|W{k)f6R4l@W5%M) zkr_)f)@N+`v!{#&84Ci70!y>bWStC5%^06CDPwBJ^o&^BaV{68ajNRv+_h;6YgV#U8a_+Lna+i{({ZOEbF# zzjl^!uTkkrYmdyanIm}(5}!FCvv=lz%%NH5xXh7og?m*R_KWq(-!s)~?9GA=_jQ35 znKOdlK1oA4Cyx7rO4mw`{juxc=Q3w!&dXewxfp9}%EPMA>UA4drL>wu-$`q^V6RFm zxv&l85Rwo_?_8N*TAT3-f$L?<@7i@Etf*+kL+gs}yvCxn!nkv_h;J0DIMP6rh2lR~ zuT^TPY6?~PaL-m%R+T@mCl1ZttBf6i1?Ts&ffj+1U@mGLAQR{x7!nvkBUoSza*Q4X zW@=zMdX%{ci?6T70n=~q@-6VqO;|xTVNJq%-z49p#GzypM<$N-tzq}^37dS2z|BqA z>RX>UBRM-^NAj9vcf#((*~#w2*$Mj-4%u7CBpkK3B(G08lkj80DVTDyiF#s0vV?&= z(U%yTygo4@G1cCc7_fKQ8|{sW`H6KC8^GizHc56vwu|yW6n>gh?(n=OoD<4NQZe#S zBe7*-yW~wtCzCgkN$i$b3R`JcDd(K#`cTT8^I)tO#?L2tMwGTF_LsDmde$p!uQX?|e76OZtn)(C43rw|z)fW4v2QB#EQ!uaR zj=LSm^WnISQx;$w$l`9+MGwDduI^=d?a>wMZNgI>4Fx{R!J-KTz&7@sP z`;rbO9YF~n_sydE4z?%e@RcOXV4Fb8=Qf#~9ejqrt@MSUHG0@@Zdh*bsupTVXpPbM z5_kF5B-cpxCuhQM{mS+k43%6+l)j|oqEHOmlUpFab!~bH1hYwxPG9(wdq) zHTZ7+0HpIOUBVnnacc4`)c%m<5y@l7NAj5D@zoaE+rvJp%|q*$>QAK>g>nH_o9$bl zJS$k%$qVp|*3I!Fh2%xasHfx=!Eyrf_r>pW?#tt+t)aKU+wt?EgDPfadIin>3-@AJp{6DW1Ga=f9xfxoW50cfa1 z?Ct(0$lc<=3V(0^0Dto;*wWw5U+V8hl9cq69N1c!GidDk-BYCt`MRjjP-u~2r-&3Y zIF|rd$=m*+FeCkA{S*9?{SWz%`A@^1$fS8gZ~tun4A^-YZkiMN7yB2&F7vPQuk~;6 zZ}V@4-Ra-!Kj1%%JXFaMVyKMc^!aY*r=C(BCO$XhBK#l65Du{`Wgly8rld?u z*&f)>Iw_KcEy-9nm9ji#HFQ5g4Nu55H4_?x8mWG0MX=K3RYv#p&M8Nr7lD*rF*Qs% znQ{gi4$^0k?ja~=4MS>CYKzno=p3+i$BKPK>Ug$eQpaG;m>tL^T^n#jSeiOMa44{Y z?a{zdP4|l)5!!!kWCW z7Kk-m&~nkLn%C8-<*DV+5YVa_>*jMb6)WNZtxp4MuwHDC)&wi#8N9Ac$Qm8k9XJ&z zhbc@OkTn|X?6A+ea0|Hvb|X|cOp~u1p`DJ}!MC$2u*2X8OUJ5$Q*2>Y+6xtsqyQ$I~kE+!`|I zH?WmV)|B+)Y))3rJ;Pk)yo}6XkCBm0YslbtA9l?uqcvg39c#hhS4+DWy#6c7sGpUD zwP7LkSHX27t#hz046YA{V0}m{M6C0$S{ol)vC*1s7O%DD(%KDX5lnD>wlo84G3M82 ztj{uio3IuOuDr0`qUTUvcy-2U9nJVLm@BNlu;L1@vw|{OZH4lDE^KDq%+Lyp)=|M; zkJdm~@odA|U@X=|q^~(qnHq7GPr)37kk=O%TU+q%9o7+{eV+Kxu1_tjhI4o=OlyS; zR||t^KdiESpU02GZrQl3iQ!tUtR-4GKv%JjcXGC$)5RRY3V`+rPGWU?1{$QW9*Ne3 zq)V!S)c}SpAon3yk&*VP2b>@Ag~l>MPN;UN87+AtZWzsG+8|52>TQXRkRSFanRfh*04_yx$fEO}bT87^|9hI3Nzzrel0 zm+9&7V;eXtKr5^`1Z>P$O~<(b+BO5{zeuC}9`HA^JcCdy(Qu}SmZ?7qtghpH899mK zkqL}5C^lK0v4|z5;H7tqRu0^lfM2C}H)^MVt1B_Vt+opp#7oZ*&!`e0OYjJlU?)^h zh!=W7Y(hdpYC-^9dP1#)oCKT&Ap7Besx^5NW9y3Fz;_Zi14}DT0XtVv$&^Yzu!Dwk z22^AGI#-i7YE6LswOY*Mloq(+mkP3X#d=_4j_pRq-W51E2frktRPfs*VCM>)zyc(B zDNBm*-^!E<&N=`lF*XN2p!Y>7&DPg}AEeC%-l*>bcF}tQJ1NRxe{}#@s#6}Wlxe^l zbw5cYPF(;-0|!z3sH5S)o6BQ>S2LzDdKnwD>xuG7@So4vrQ8R;%h`tr%Lf2EpPdKn zeRd|W&)NCFK)Dxq*V)g21Iz0GJF}#71{m{CC@C)mcB-fcys^9|uuFMI zMymZTXIC&D29|Q_Mmg0^F_jP659K_Au@&Q`gm8na2vBPg(S%wJ^G?QBfu=cyd9*Pt zoq3OGfzL2eN?L~T145lO#{xe$sZF*tA3=)ICdJ&zOch$i31d6>`-vAW=F1e$Rgpu? zTNsg+ychqSkJigLp0TyD8?G>MU>w zm6cY_wsN==HVl*_8r7(u*?zNu1rBki9WVWyD* ztZv|2Kwt3@@%lT!czqXq78%qw1~GQzUZOMOy}=$sc_=n{9Qd%l2%)~=uVb*JwmL|s zGVEZWPGt*y2=O}kY^Zkz_R@=hophv&uZ;56iB2y;-BFyKbSm>s21%N7T35=M;7cXW znZh?nbx|r$0|#>6T5~^^!#?wad1Gu%sNYMj`W*dA;z^=5Qwiv~?8@(@&mlkRSC%AE z|BPRI0P#L3XFY}aI6}FH`MM!jl9aN%I-e}|3S+5EfE>R{N13d~SuF5FJo3Deu{zRv zN8wy5^)jJ+pZRr+ZCEnB0{M{-Rg?f1R%{3Ms=$}4J%ZFUZ2_SyW_}D~97~#LN8s94 z&IXRbuSPWORd&6PT?aD%7V~Wwb5$1n^w!D{TaiS4Y13IgnsJQEgSdGQW%Y0Athge(+zL`TH637;Bcl2zd`AC?W&FCl z7DAP9AK06*7yEgNP>p4N2;=jNZOh++|GMR9!#F*z1^l#P`E`uV2`gqW9t+CLS5y4N zE1fZ$j9rq zt&n0{&f5zLUybZ4skf{z(PPMlYBJ*hU~73BN_7t7NkXwoTLt+@`4eJjMm@P2$o=^f z9NVoFoA#olmhWQ8SRLbw{FNnTj3XuHKk5qA1@caePn3@ZU!6uGIg&@N8O+aM`AU|o zWXXGsZ!^ZRyeFfZu{~ochic6H$Z|j8%p}zC%V_YWx_Dr-PU;3nKdolsAC? zCB$n>Sgsh;*|jzEPcZf@?~2%Rn9rfu)Js8E_KDw3!}S@)1jg}+&DjKVc5@bJ-QY*QOMiPvD$nattF`GKjG~BYtU)usA^m_!Z+C z#?o@MSlK$n`@z$>DM%$Gq5PDV7n7fIv?4jIJPKv9m1aA#9?wjkAVg0x9PQ-$vm?P@ zOIYz3i7 z!}9N0{>s^7a9wf-tG6@+JZ16Q1-@_C+X;rVj<`kv>yc(!~A_yW#DVL8HTrwC6yr21xnGdj^?#$02RO2WfmBuk&$#^|uzq8Z}HDK2}IEIrP z_BzJKXQ=nPy1X{9AE)?ic{2Dq9QFams~E35I}!577_T7|Hpft#@qWfU##%flpTt{=q!Dw`DZ>rs9yZ-d|r_R z$>-(20gKPv4HReE0NYaQLRi8&+|sT)GXiq+OcAgVr`VL&m|vfzQoXJm{k*)1<y@`E`5IpT$?6iU6vnbd2@;xvkM?*6qKUQn9cHIEN^j^#%S{_^^L*#gTWkRh{?YT!?L}9gMoJu{;&*fZGomjG%P-{!yuS#Oq`h@CEPW_*pZYiB5 zC`;YzfIS&o8~1{*?xKEaJg=Ts29ehYD|yvZomUwnc}+2rc=fUC0HHfTq1=msBY8bC z(xjNncpje5IG*LtaM))!o@ZG848^Z*qqJ0P5Xn_@6R)|+kM<&?PN-ILiYqzAo}6N- zo7!*==Qf99?#XGbWPT81Db4n=DgkOb^R3uB{nIG7jR>9vQ??3qkG3@m#-e za!ti@t#u%j35?@eGLiXALh)fx&aI^fm)J7SXFq!I8pfMAl^L9F zH;$(#^V!UgWt`2}kg*A43&v9RS%YJUXGv$~$1~sBq%Xat83DY~9ECMzzVRH&e-fc= z$NH1GgtCoZj$Y+SLOIG94?ji5X&}x;fczJBJ!Fi7YawC9RK{OuEswB3ge88IaRZ0N zSb(s=iZeRR_B#+(%x8YA(I2kY8T5_cMu-@G;1~#hAs%aUjG-0J5+c_$p0;7hlZ?Nx zq_v{>=P;fmlqEVwmWsKIrwC;(PPit#6?mnwh0sJl03Q7SupwhSFi)Sr*c*5?%da+A z?pg#%D;L%5aAOtlK4S^+7Sch0C=}vlU=>QQ96yG`#IED)@sZeHPYLiC?e zkn|)}4!4tDEJ@HW7h3s8gep?UTbJ)({x;)k@=vH7gZjo^EcqoEzwt99zZ&Qzlp@ql z>-!<;p|=Ij)w@7)#_Y?XUIfk~ZJ2h(pm*uT?=_E0qud;T7}^=LfDH|5y>8~)vAm(N zha}A3Xx4j=_IAp)4nfd>H4OLqo#&6d``yOl#ZT)PA(7jLgd!+~dt9)K)Pr zU`YpPhBR$C<66cDLYZbf0=$G!dx7HA=5oxb2Fg%d$tlj|6#aT*_>3jgzUQ{&Fs2x2 zE!s%4CvX6veA|2z{CAAZt1O%Wtf|#h`)VQMTGuV$7ZJ)$%)i9gjPXgvwXPr74^VDk z{w2odj878EcR04j+^&Wi3m{p-*hZ(enxP|Id>0sTaK=z9u+(e-$xZrmz{^?hceA1q z=L(e!++%zPEK_d-Cn!I#lkpO8kxsS$fKD^vyL2CLv_2geV1B&O9DJrxg10_t8sJL| z`r;1a1bk}Aoq;dwcK{#9NyeIdj&@JbGF^<90yi0i)97@5d8^R}_!Rqj%9wz@>nG|} zw1EomqTR=u=g$bWR~fyGnWP)hZdO;p|7!I*aH(p8ct-1|@YdHD!+?VcwZ;l{q$SE< zftM-dMC-1Qe{ozPpW4mxYlOO6e;WQM4tWuk75Zl&Qv8rp z>_sUWHuwW9*@bfu=uII}1B}_=M=A={L`UzT`s?>XQeQU_b2se}_^XU+;BS#=QQ`yA zxPqrz9-{Ao|7(m%z`=y#2X^f(Uxw=fb{%f?hU;VG3JrO8;41p2v@|&le4!%ANd0;E zi8tziFVPnQ_v#0L6$ai8Er;Im73NLuF$Qywv50yM?Pqf?{B+{}zY~8EPnp#rKVud{ z-iyDZc?S9%)q;8hIh0%A62_%E+J?MIeFD5(eFPlEC0xWMJcw)mCc4Q$UT&Zy*k%|6R4I-4M4f69Gp|+Gz#h4yQHV|s>F<;ZX43e7M z_x{E?Ttuk7#QbTUTHSMm+EV7Hv%I~IaiQXALirE<7sOm_+y)#1MH9jD8 zeMdPlJ)EBo`bpf!@CN%iL}<7OwY7xq1?+P#p?;Q7HiLhGcM{O7j}^6or`c;hI?Y~- zrqk@TBs$Gr^9`cY?CXjE&aunYnrOwcL@UusWk5Tub=Uf9L$%S`cx|#aO`E07)5>6$ zYMXG*(mHLEtgmg8jkI0be)v5u-P$QBaJrID#!ElWRjSdW$Dk2%RxiTnUOk5Qku`et z8az~%^&WETVCnCB%a9&2v+uATz2vf6hTJkjX5)sGL9#w$E@L5M5n~I+lEHm?-y%B> zxqa|3+4a_8y@$wo!+Ih9p2G&+f^+YN-!i1H95Q@(PJKCI`0&f}*vVv!*B07 zTuvH(`_SQX>X<&mZk5xqb17tv;5qx)1@HyW*_T7;oPC@ah6`rs{CX)`;rx2FkIt{x zJ#>D(UP$NH!<|Z2tDZ^ct>Y|u#6%QlH3ezI5bZ4B6}7QAM3)5Rw1+`*-4;;W!hK#y zK0g$$b?CVfow^S9WufQ#A@^<}it&Tp{UM5)=jVhF4dn~xCkN9B$@S(Tcf?KpsRtw) zxh6=pVn!HeYqi+!V6Ct4uq1-fW(?kv;AP%n%wUXROl8bsr1Kv&oE}W*XG~#a zjkmz*oRFshxlWi~DiT5I{sk9qULY<`#EVP#c7mY)s<$(oyT1V^Oh)6R$=E8sgSn~d zyUKkJf62ca?;g4z;xGBPFX~^5EyGEdTI^b}5_k+b)neyVVC0NFfYQ-ow^yJqiJf-V zA*7wRSkyl_Eq05LvGZav{={wuHy`n6v75k6jGYYJh?`>KT`u(dGIj&XY`$1TJp-QB z3tii&y+bzmMZ}_o#dk+}3sEg7iED(o%30!uM2kHtw76PuCqF?6=8Un8LXsNn=OIYK zeu9!zNLb?ze+lfz2T9LRD3YKPitQP@91@ARsYZg5`H&!8Sh{=S_w{96v^@XVf5hMP zA9+##GPW3Zf$S2Wirr$5*sHy+t;PSXtkd3%JVm(`TFfrIWAK|bLXriLV8#enx|f6e zU|fnt>4YR&%x1_olM5_`(qf|_Sye?sp~eXrvpxoO5%NVc+(;3#gwlj89D6=wvtu@d zWHM%e@Lu%Rm|yH1+zBM%I|{esG{Y@9SL5ECeB7AR*dA;*(tpG0;VzsU9%WpDn{*1y zCX@#e#kbll$DKWEa9_`c;9Wg9ZM*^}jYr~?@i^Ss^C0d-k}>N<6h^DYWdB_rV*MgU zM#xBM%a*bgdaE|F1ZSqUlkH^(d4udIZWY^`cdag@dS*~kbbzB9mJXeM* z#dU@2GFPIjrmKnTYQ!>Kq(xj4(LCbXh~kLrB3eX5yL-C(xO=&KM_eD#GNM&P>xecH zCGN)}+PY^(w2NpT(IFzkJ<`4b#i~TC=W40TRIX~QnyXf-gX*Gs;*8OOID2$BP9FV-x=Y=o?o$t_ht;F% zahyN;lzK)zt3FU4sb5vOuIrJy&(783^+f!Hv!^=Bu#M_Qypd?67#TRfw64*>$iqpc&2U0#Yn=F1YIK!98Pknf z#$4kWqs&-hEH_pg>x_-Y7Gt}y$N0kd+Bk0fY)Ui2^qNs-oS9&znptLTv!2<&%rhIC z*O=FvCFTug7qh21&>U`#!6czOO70`2;py%lY`qlb1`gM9My{&$O-bL@J-(vRFhw3BsvHAp@FZ!T9U4LBvC(adJ zpqJ^3^`-i9eU-jOU#D--H|g)|JM>-pUVXoQP(Q36)sO2Z^iz7dp&Jo6FD=$cz}c_q zMopuRaXHR>EijrI*BY&ib~xL$i~JF1pgoSW&*mG?8ZY47vsZE6+1ohh>_ePvwjU>& zeQ%t=`DK>rF{_)g=A~wenQ7KC>zbFFxn_Y`WVXO5W*yAVW)E|KIn2C6Xf?WtccS8> zlA_Y1YDMKlHH>N$)ikO&s&&+^sQpogqmD*Ram=m2F-6Xm=x+J2j-D1hD|%jZS@hEARnhCBH$`uY-W9z+`f&8|=un8`8IVrIq6iz$m)8nY^9UCgGKZ85uI_QxEK zIUaK=R>ZnveX;Se{@6flPHb*$li1?elGxJN?y>!2hsKVM9UnV6c3SK#jEQBjOJi5X zu8Z9iyDfHC?EcupvBzUi#fdm~oG&gu&L0n6{OI`c@ss1H#m|bLcS()-b@7|ycf{|DKOFyK{FzJ4OMFq?qxwb- ziW(L*I%-_h#HcAz)1qcZ&54>HRTi}*zAS2a{L=VUQLCfYMQx1Q5{3UZtkG2*#%Z_9 za31k$oPoO@XWwqdxx*jIU2>m1B)^lt;D0G}{I^Avs-aR;ma2>YAZUdDKxl#gV<=TO zt3K)$H5jK4->&XZcdEP9z3P7Tpn60-rk+qws;AYz)N|^2^@4(q4gZ<*idv~&Rj;Yn z)f?(fyI5^d8`UPY*&d?aS0Ad6)h9T^c#rx_eXhPxU#hRv*XkSft@=)VuYOQJsgvqA zUDMsVV_&Do=rwe|o~hT->*K$U8tF~+BE48|sh8*-^iFzry`O!nK13g(kI~2DJi)2@ zBl=8zwmw&XMt@d+9w#BctgqBx)8D{3$?xdz=^yEz=zH|fabEJ*`nUS`I4k)Vtecc! z8D1mCxYS5C(u{zSW7IdU#Cgb98_kUtIAgG_(b4E^bThisxq>+TxW6#~XX_5Z*~i0h z^6@C+4&xsd4+#DA)prRL3Me{-lg z%DmIO+q}oT*PLwkL9KR{o2^DxfmLWVwwhR1TTQKIR*`j$)!e$)Dz>h(T3FXxEv;5o zYpab_Vzsr}S?#S3)(uui>qe{8>ST4cx>#MUo2+iu%~p4-ht<>SW%aiDSbeR2R)6aj zYk)P-8e|Q&hFG^+L#^AaVb*YKgmt?$(i&xrw(hXTSpTrbT6bFGth=o7*4@?w>mF;O zb+0way3d+y-EU2?99vvxHZdq!kTUU)0$&FY0b5s zvgTP&Tl1}FtOeG;tcBLIR+;sjwa9wjT5P>wEwNs-mRc`a%dD5J<<=|K3Tvgc%6ipW zZM|l#v0k^uqbJ^^Ud4de_=)y=QH)-nX_|A6VP053TLiN7fGO zV{51NiM7l6)Y@(Bu}9b=?a}rad#pXq9&b;uC)$(j$@Ua`s{N2X&7N-0uxHw{?Ai7l zd#*jto^LO(7usd^B73pD#9nGIvzOZ|>{a$^dyT!;UT3ejH{h3so9xZ_Wz<%Co4wuM zVeiB*gm&9|?S1xs`+$AWK4c%ZkJv};WA<_TNBe|*(mrLMw$Iq*j=<6Hx??(SC&IBE zkK=QyJJC+86Ytb;5}YK*@1#2EPNoxZYB||XT_?w>?=)~4I=N21)5s}wnmA3JBB!}i z?6h!NI<1`&r=8Qm>FAU?ot>^uH>bPP)9LN>b^1F4oI%bIXQ(sG8R3j{MmuAivCcSW zyfeX>=uC1ZJ5!vg&O^>LXSy@Pnd!`OW;=77xz0RizO%qt=#)8&oW;%(XQ{KyS?;WG zRynJkHO^XRowMHA;B0g@Ih&m=&Q@oev)$R@>~wZHyPdtxK4-skz&Yp~at=F3oTJV$ z=eYBubHX|4oN`V(XPk16@JNsDF+FZigva)HJU&l#PqZi26Yr_vN$@0j{5Y^9-IM7F zcxrjFJ#{@fp8B2!o`#-WT*A@FQ|M{pY3eERH1`yHT6kJ|T6;=7?K~Yk9X+L<&YrHG zZl3O*o}S*GzMlS`0iHpgA)cY0VV)75k)F|>F`luWah~y>37(0bNuJ4`DW0jGhdk3f z(>*ghGd;6BvpsV>b3OAs^F0eZ3q56?MV`f;C7z|8WuE1p6`oa|)t)t;wVrjJ^_~r$ zjh;=O&7Liut)6Y3?VcT;ot|Bu-JZRkeV+ZE1D=DPL!QH)Bc7w4W1i!lA3Y~LCq1V; zr#)vp%@oTf>{+P4fD^sor#NrZ?cN<<0ii_2zi% zdmDHgdUL(`-bUU+Zxe4*Z;`jTx7gdl+tS9`qja9`+vb9`zpc9{2v}J>fm+J>@;^J>$hcbNi%E z_nAJoFT!X0JU*YVx-Z%n>x=i*@Fne3N}sd{cc7`KI}%`)2rN`eylN`{v-6k@I}>@r&7ozB1n;-(ueq z{FZZ>Z@F)UZBJx5c;Bw+%gWshX;7q_rJZ#tS#PWk-^{uf8xe>37<^lqmXe__-Mj(H#RsKz52#xuIt z>D)+VeroPE_n3Rl&&+-1=jML%3-f^a%m0pB|Fv=JKeIw~%?PceFT4Sk*YY@T_#F9Wof=)t6VGXr zw1=6e->ZC~(awryVn31M5??SK4JpO^sd<27c2(6znT_GcRaMJk7C@qL+M0F+LgK#k z$X|qjRy0RAxE1?yXo8WtR!=L$@8I&l+ZfT8T(~NyH6%joxVd{9E{om~xkK1?J-Z&_ zsBhOt9MEe+x)W}@=vH>b6AAtPZQ>3wSA2}yr#oslYZJ9sBcZ`Z9^Q`p2tM~lekQzj zUAr#y@)CMsP0(&imS_Un6gRIl*4~r0*e$!uyEWP^iu$9RRegWTC6~b;`>d*cBA-+? zQQ{~|3IP6_sB5TM?Wr9q?fsYxo zw#=4wWIdT9uR_X^)?w=#A*{33Ss^2Q5m7=##6+YB7Y-dv6VY@`lZc6^8BtrrM%0O@ zBQA-k7tuh}h-etmQ1~PABd!uDLfS>NA80qj?*ydXR4xZzEmr}rl52qZaviXV+yHDW zQFr!W{02qZjpSBfp>aR30RQ35)~p)LJIC~Uw~$g`j>XijxdFZ8E2Xe_j- zC_(6;6BKrLpovaXDWJ8WSIq*w4BFMYpt;boUIE$|n$||3&7p5?2HFZ**A}21pnGi# z+65ZeQqZ2z!`=+q58Bv1paY?ky#@4EXl4h44u^jBHqcSf(%ugG59n&|0KE$u+dDz; zf!_9R(EFghy%+QW=y2}`eHfbD2SFc&KKBvO$D!4I4D_GS?LGne6g0d~f<6O1@6({q zLfiWpN1?Muc|qg zJs0#}&}BaZ`W!Uc&w{=Hz4r5+gYn3~l&Fpr1k~{t4)3(2S#>#J<9Q{d3T-pe6qj^c(2PzXtsd z8uM>Ke}LZnd(fYuJ^u;xGJ=-+cd8$b(>`obPYF8)hIVJD-&Kz(69qbdHgP+?c& zTKor>!rn$J1NDR*j&?>{&{F8@JA!sKy5N7fl>7<0d$d>Vd^}=23_1&Xe6&mKf;?&b z6Z9GA^wA!%Bl4`V5OfLj`zT-Rj=XHV1iBi!ev~VAO5QMD2i*w0|68D2p#6UrbUXF{ zJ^qMg?dDb_-mfUhEm5J!8kE zx>*f04*LfvC+xmlYF+}GioFDs6Lw-U%?!}m*jK0tS`WJmXxG@Ax!goKVTUHyL^)xf zrocowVYjBpYzlh4*#iITq_A_-#%v9G1NI@>fp)=e1o{K)<@CURLn`d(3^4nH4#)n) zP|z{BYh@JgX{Wo8E_SvXxUK-b1bcax2_3tD$IOG~Zg3xBC+{nB54i2v(K}@B1@{ql z_P#bh1GfV^e22|_;6BDq-#6yx;C5oi?})h{+$Y%i`_}vd+%EH^c@%R913Qf0VaD>Q zN&jClTYeyS%FpCM`K|m}o>59gsA?)+C96zTM>SAasix{W-2Hc>>V{j$`s4p8hT;aY z(YSwXJnkKvjQhr>;hwQsYL1$R+r_r1ZR&(Nts^Xd72x{x6O^_ zJLV?yU30Vfp1H+*AG0p}xYVV)43~*-!|k$M5w1v=?Q&cm%%OcWi*`l1qG?`+Ii`l4 zrRQ)X)hoD(>UG>e^)_yv+VUT*k21ALEmlj^QngGi$Nv(yK8ZTuem zUG*M+0Xx)AwM*?*d(}SNKy^SJREN}IbwnLi$JBB4()s2z^c87~k?2^$Tu(JcI8_|Lb3t zIo2GH+ovX)_t|~1KHKcR(w*zhbLYFSayN1pxC`Bl-A&wAyPLY3xr^M_xSPALbr-v@ zbGL9`?{4XCc3cXx2#;O^*N<9^+}*8PTio%>DqdiPuI4eqzy8{O}? zH@V++Z+5@u-r|1Wz197Jdz<@1_jdP3?j7!r-8pKWXH~PRTTxcD6=TI(aaO!_ ziB-e8)Jm`ttt2bi@>?lZs+DG?TNzfSm1PC2npQ2Vwv}zwvFcj&tQ_kytG;!))xf&K zYG_?)M~*jQubBS^GKrdHV(XMf)ZDW&0I-rTwb?n*F-{hW)1fmi@NLg#ev~|Pn`jM{k`|O_j&I9^M&U#pG+p5RMn{@m8v9df^-lD89~XQ zbWk=ZA5;t~2UUXXAP28Au8uF=%MWS?b%J_9gP>8+Bxo8m3(gOk2Q7kDL7U*Bpk2^D z=nz~QTpn}^t_rRWx(3~Y9zoBbchEQJ9}EZv27`jZ!H{5RFf6z&7#@rWMh2sT(ZSeY zTyS47K9~?p3?2#|2_^?qf@#4M!SrB8Ff*7H%n9ZO^Md)og5ag#m0)48I9L+A5iAXs z1ptqU~RB2SRZT%HU^u6Ex{+jXTcZ2SHU;Icfr=+$KdB+d$2Ru9qbME2M2;f z!I9u-a4h&EI35%RCxcU=3S(g+^ulCl!gLsh8DYt=bXYblA65)2hgHJtFel6ltA{ni z{IGUdC#)AX2pfe>!lq%f@cgiO*dlBdwh1o^+lB4J4&kNYpI4B$(4he^b!@}Fb;o*pIWH>4u9gYphh4+Qy!wKQU@S*ULaB?^$oEAP2 zP7h~A9m zFG@xxN=IRo5tWQeM`fe(QN^foR3*yBE9UZ|>QT)oKdK$oiRwiSqDE1ZsA<$JIzMV2 zwTN0pZK8{!c2WDNLv(3$dDJPoD!MxA8}*L{L<6Hi(coxEG&C9(-4+dxMnof{QPJpV zY&0&qFB%_Bh$co4MUO<2qbbp}=!s~0G$WcB&5GtkbEA3D{AfY+QuIo+Fj^cfiQb5o zM$4k*(L2$qXic;>S{JR4Hbfhv&CwRzKRyZPTkh+*!}j)|9}7J`KcmklfnJ|$ZNCrt zwbZiW-i_e@rhnr<&)%{ByEE?p;cSbmr@wFy65*=J#T8SEzta|1O63ym694Bjo}BMK zPw*Mdp3QJBJ0Dj7XU7i{d(igYhqm@FUg!TgYyH2SwYZ-vc@jNb8GZ@B zB>K3@_+|ZaetExwU(v7RSN1dgDt?xqjef6c=gU{r-_QJ;L$&Z)#N&k+8_MFDomyPnf>u-d z)G6;qyd#M8YwD+7cx(($5Kuf9&o~p7B$mdV zAB1O3-lsIxp{8`V8jbO%3Fc=^g@I2ErXO=U+_}=;Txbptk*;;%bXX#i)^sCp1 z)kibBphmT#cU8Z*Y&~-uNN( z5w1wAM32pS+~4sV`t?He-CgWe!%@4d(cyM+&$%u)-UVV^;`OF z{Z7!lGF4t)c~xc}-n`x8#AGs#&>N)$l^m zx>*gf&doY6>w>J-SvO~m$QqqBA!|<7b6Kxuy_WSx*4tUDvJ=@Qv&-V`&sDRlWuKjW zZT2*Lr`K!QKWA^p+mGFs769wd<-4s$R1Cfa*hQoT^nPzfS%K`5VvbbJpNOr?7G1d4(4g zwkT{}*uHQ@;p!7TPCR@!q`$K-u>0YIoCP=zr?YRGtkEILBw&YjpI^hIi7)d-^TH4bTzxN5*RDtIObtg zqyYVYccbrb62>DYW3=H#+-v(G;z;p|@m%!rwMH)<-~IY5`tP{+j^p?>`t0_^58^IV zj^k>?@#Dl%mt2E5W}*l6O2n}n;@As)q+4wq52B~@FYhuvTfeH`L>yO~iDNF}SU>aJ z%(j`AWp>N#gE)>s9A_hrQC7*UvRPSY;#fbc>6tj*ia3tRnw>Ql?*w0*^?KG@Su1QD z%bba0cf1+@PWeSM@$>AWbwov91I#UY0CSq# zzh2Hi`QItcKZ`~e%{^7MXmQbdMJw&^dyAgH+(|`Kidq!yJB8=Y(A!WMy%0zEuQ+D&GJ?ryuUi$uIBmM zUMJeVcKh7zv$xOMe$VzX+ehzczwNVaWB6}dkMmEW&&_A^-FJD)+`vCEnr@<7QHHw<5lg?YM44rElk&n*Tv7eINlY+TVkpuM#TQ{5A*p6G>W4% zPXEJLTJb;Jdw$-(mbahgdGU_C5dVyK0lmOuUz;BGr zJN%LN{pltD)86~u2i}L?x8C>O58gKZySL--rT+a%?`!=1Ywz3BOL(8#PuSnRuQkdx ziS+;HzvN#&(wHKC{!9LvIxmI#mFkwdCUq^(N%i~tC$_QGYwwNlI(d6_ZgRhOr`Of% z=3TCR?;@|iccItWYwNYs89LCB*CDwNQLl`90`l;La{=yOXh$8X2lb-3whJGGL^jC3Af6MEh{N1}omqDpr;_dQIdV91={*gRjen=iv?|ZlD zvfg#cLuOC%s4m4%fSI3lcJdeRQnS}RsMdS8+8%?Gq^eQc)#k7eYR}a#!w2k)BaXL(Y&|kRa zq_j$^lq#Vbt9q(|YN+C>hZ=~!fZl3^8lcl^IG*92qNbsxu}IyeUc)nx$Iv?2j^|i^ zM_)n_RaJOCRyjDMox=0r396}FYO2c8S<0tIswADO%291)sEx{{R;nspq^i+{DvvI~ z{T3JF&Wmf*`P5aNOIN7+)D8E)T&3#Tdv1EFmefbJRu|HBsvQkf9qD@2fo{M(88_jM zjG?MC4O3m|7S%;vO}DCUG)dh;cdA}AQ4OW3>Q0)jM$-&6mYz~$=oxh{%~9j|d`q*` zeYk(;5qd#AO!L)4v|7!h6>1JGR8wh{dXAQ>+4PQjme#A6XfuwGAJG?T8GWUe)0gUP zHIdGvdDM(rcn4H_yy^QsHHh9&Pti(Ri)VrNQd?D%TB{s-MBRpGg8#(RriJvFx`Uoo z58%1iN2$F!i#}Cv;%VVr8bB}MUXyHf9{s4UP?xB=YQB2GylYmQo#scg%j`3|%{H^% ze1PjtGscLMXp=Ehc9a0C>Z|XQ&CTZt%4^gQz_rO%b>)vbP{+C+z z4w$-5J=_J;7;Vt=oYGDiv|uuvMtBaqiF1zA6s?*DXw_6kOSQUlHr@(QLY;6fP$!+{ zs>mro3$_JrRch%dr1V+3vIMbMEu*e0PESqWiM@s=Lr#>@IO%ci(j1a^H4W<4E_h_lftZ zdB?0U%go!}&t|!|&D-dG=6&vcfg|1K#8+q^uW=S9203perlPOw%S2D-7{*%rVI*~` z?djt2qu(%YS`wqBO)ye=5k^Td28!`Yj8?imFkU(lZK~ff&RG>Bn|*mqlfQH*RoT3S z7B6{QoGtPDQ+kD`U3TV$^oD9j|@9cw}}TMrL`W_8{7Nzhb=h^vLYrBeGZF{TMRd z)ElEre~&B;z}V8F#OD~F9cV{m(c9*I=*GRTybTy#+l7(jdht%)mu6|A>%T^&AHe7{ zkG}2s=LnOG8vkTp0b0&F1ml__ubP`#D^VjEGOi zm~v)fc4A)aSB!)G5nG3G>|KdrGR7w(!SR`i0_vd7roO5T^-~woAaxnts4l15)iw06 zx|JSM!{}*s56x8*XoGr%-d8Wu2kK?Ypd)z7{wVI4+mAN<0o+Y@5KrG9!ink^T)*wW zbI-eQowgfKrtiUZ<4$UVvDhel%M3r!nd}8m0Qu{pv<~K;1+W)DU`5-Av=vV0ukGL9eUn^ro7DcmB?# zW$GDvTg{@S>S_8|y-r)y68c2FL7%Cm-eB(*Znhr4M`!8sI!A{(u9LdDE~!gsPrK;Vjoz6| z>0L>!wrGWDMhg8}(L<#+{X{ruSo9u2wQv@5QLxImtZzQL>T#JejW# zVVtf>GDmMpHq@UcYwCkZM;}R+#VF#3$qHx{exD5WuNcFtfib&^7!ll)EUAxTM6Yo& zTW?7=(0`y^emGf1e}R#_N*FIZpik*0%L!fdNoG+>SA2;IK~`##IXn?jwdk2n8x^`!noybj9Gq% z5yt={nENn(`tK3di!h4X2BWF1Fsj-Dqp31(TA;__Y3}><{rUktK|iP`>Ph+`{jh#S zkI`fGXnl_!rSH}=^&DJjFVfHGXZ0(%u6RMesAuW9xMq6|S8mVgg?fozpkLCr>D%>4 zeZ9U*chLj%b$Xz_T6fdk^)znjYeTyEZZ`DKe&H4>o`z^(l z{0;h1Jy}1dr|7AAo_=1>*Io5A{kVQYKZ&Q(@k~0NOkZxV_owTp^bGy9Ugq`n`gy&) zK3-3+x4+bX%bepcGk5sMO;i7E^Q0+ny87Rm5&mE1Tz|QF!PGPZ{e5PF@y*5lMl;=1 zFx~v`%$DW?K30nFXenxxwFW9yG?Z z_cxiROhwb(|K5!BPnh%kcg*Xik-5$P-AprOOlSWqGtX2r*ZI56{l+sF`5%~9%-QB9 z|B!jugyvHJWAluuVtV>NnbC$!fxpHqHucRd{!#OoDQP~FylO6D4WtGUZRY0mdonm0`oGu;2vJYmY2F8()WzNulZ_xG9y zOwzRTKQs$XU30U4#5`&;%;o;4X12*Tz5Q)wtZ__Bf2~|v} z`(K;qO?5NC-($ubZQA-9%&Vr38R8!{kC@0@=6_;lnJm-G|JjT&%Czv`HLsZlW|;q* znPN(rEB!CbT$5}1`8&{InfSF{{ri1^HdD>JqJ^UZcD8I;D;IHzl z`{(#I{HA)izC(}Dck1!J@-OgHersR*E&TJ6MSfMEe8+F@C;S3G>HB`moUBr`zENzgY%&Kf+mpw-wG7y!J3( zRRM1VoFjPr-tSz&!=1sn60vBWhwDxzxgV}Bcz41zKuxTtD~#(V#=9KO7c^hpoh5h| z!L;w+ zhV#p5;$?>b?ss7B0DP^1egZ)Fj3Ugft>MfYJ0X#0tyM*NLFmDfd zT&{Nux(qy9@P2^j2;L>|vx2t^o-24K;pYTz4?IuM3g+~ISA$b6PK`^|2PB%y% zgkKc!%qHQQ&f;;tUlw#(nClSmu7f$BAbAL0C>Tz|A|ZJcUM%QR@M~ZR_ETQ|b-{3b zctg} z=4}HNhmQ!V2h4c@Y9M@6P<;IRO;EkzV}j!H|6Nc6V9o>3X_)IJP{U!)Gf+>$oHw92 zMup%6rl-NY&jZC}UL>fy;8Oy6x41*tf_^SMjwNtf(8Fax-xqGA5xBfLZ#I8r;Do^G zMK72IePno4P2g?vG1?xdtHLN>3vb64xQ&3`GJ&^=a<^rl1tZT4rx#a`7H$`)uo&Kc zByd{+SD6B*1u?U6YYLYvrYu}a;I;$D-ok0&`~YeM^KuM-uAIQ_1y#Nno>xKOb_cF@ z1axuAZroin3K37?|OtJ)S2dL~~s=@p>z-z8rqpwalAFd_vxg5Qc0_Ty-1#nvdJ(fa!_-ujC`Fu{bG=u93+;-so z!NT+SGl1J7yl+^}fpN@Y`23Ha0HGz^Sm1L$dI^NqFn@;Oa^P43KIfwcRcHruIsl*Z z(Yq*gggKvp+XZ~?w{(D;3*0VH1;ubWTL`utz-eMS0gf5q^FN-K6FS2jW58_zn?6n> zmnX*(*tSAjOfy{or>K+|E488aGiegMsYIepAn!086`6wG;J#sE$$z*Cf@x)*aV%=HX# zdxVdDmhtel0?mdw_RRgDmq4@N-o@Mp_Yt_wfnFiu5xAegZ4>kw2@k_uCjhr!__%3# z2c^{`m_a3^Nj+xQ_%Aya2ehta9aR9Q^Irb%>uV6&=W7rhB;<{ z+Z5Rvl^e8+*;OjAr7FhVY2%`rUZZo1!-@?~UYO=uBK-}JCyaO;d%i0`b=?b0JjJE+-Bi*b9n)7 zyV-NwYnWb(>7@c+zbP(XKuMU(6xehBR!lRMzz%`0$JEYZYQnn&zP3}li^+la3B={J zzZlNT;bORa{t_@sLaI=(=kgPn1{gUZbyDDSxGEBe%k@+-s0$80&u|;xDKDr8;0gk_ z&7Fz@$I8CLHxtXF%$+I%AK#t&0=EI31_H)u$Y~<*G156#z~~J*%>=%VcMt=Hue+QJ z1ilV%+6dgte;Gc8I=2XXd~(JKeBO2*6x3XJBAA5b=fjT)e6GgZdC8fE`PJcB z0{sN@a)9f;gL>t>h3O6Oa>1a^J1YdU8ZO3gnY<6y`sgG3@@O(bSas<4mfMR)|2A2O4 zt_SL48hMQsfEJiW{$ec!JdaPYTLJbhvl6~r@K7GHdj&lCPqF)i6tC}o!MOt-FL=Cu z>@zX02P0s_pKB=d~>gafw$1Ihc>Q$8!Pd z2OoFi)daN{t}duOFzQacCYIR;*8=&NJ_w&Br~`0qLH!1w4eDT-<8WQj0OiK{Xb2i% z8flF;22C+t7Cu*SD#6V_E6m67A>JCa!8C9GLczhd;};1Ir==}uhh;b|7lZbg=51ag zI7mmlgW&KsJAzBG3~%Q$a5<(?UhyjgKIX!#xB(Z^W+^94_OYg2QF@sGvTECksvq_%VUcVeu&fpA+Jb3w%zC zKOs2H;im*Xzr|+=P63QM&8SoGT)}Apzbc>_Q+%P|w1gK44(fV*vEU#!@z(?gAHehQ z7KdZ?rr^Zjr2?O?;%^HM_J{a#!QptW7MujUMQ}K_I6gCu3x6Xx9OrF<PG@;OCU|A5MCh|lu2Tx;H`s^HpY7wUL%;lVE!GXP*#cc zLJH-RK+G8TLHHBFeHi{!a36&~6I{eI@wwnmg})G7#53`w;7*6X0w@O;F-)Kw821_Y zTfv_o5g8MSO4WJxd#6Ph^a1rOkPOuB}5!=LW z!9{EnC}+lf3q~0;F3K^nPjFYmN5O9>x3%~i3EoFA%E*mjK9>>d0b|~Q5qk!2{3jP5 za1mRx3`Q&&59xIgKgOW$xJK}hb{9clyp1r9FK&owl(Ab;@HWAjg7-dLMesJmSpt`_ zn=Npg!>uYfYv3HgL7BL@LShh{Cpd4w)r17%=vEh;1#k_)K|I}>LZT;JOK^_C`2wHw z-Lr&PKe)CKy9+*Bi1GUC2(cI7x&ogY-FiZ7JzQVl^Pk&5h#?+sLxImfZX>NXSNoQCrRK9;!W3kjZofsjD`ad};U_c^zqm>zHo zA$~R7QixB4TM2y3a$5`W-{Cewq5^!Okl?s+`apvJ-d0HTh1&^%x`bDok^I=`5rw!(D_F$FLjdj%8}W97~Ymy4DK}MBP*H4T8Zw;tm2gf>U6y;BA3# z5*(f$BE;{9Zx&LO;Gu%E4!%Wjc)u7XIK1!NDmYvpZWA1?^S29$zu@6Q;skt$kSKyj z2(b+KP9atf9w|6G;JXBeKRZg`wyk@&z-?i7v=FNTbNK>iKRi~5@w|HkZa=!?1a4!x z{5yzoy6zLWjp%Y&fY|l$cp=8+_<+FeKX-x0ZA14VA@&-~ zu?MlY;YWnn3iweWwi2E!#6E{{yk=rw!c&CU*YH#!_ANY3h-2!pW-vS}d%f2YYKZIWr;-A7V3vn*1SA_UJm}3Uw zTqX;J_)3^#1LE((iv>P!yRQjxE~_O%{8yOc4dU;^ZwPU&pB#ITaNwmvLc?4SKq7#7 z{{e~WFvlJw&VrWC1CVG8^F9U=T(+E6khlt7CB$xqR||=D@ERe(<Kq!kizFxE)?EB<_Ge5E6I78-&DNFqb<>41hNZiM!!VLW0Y6v%qZ) zyy3-?cnap@4oD1yKNh&X;C>?5HUY;H*mL}6m$_UuYh163z}K;S zFNN0)%Omz)cOiy+d)Elu#_@Uxw(W8)rhyy5Jq5QE+)HrF!KgP3w`IIO0=L1vz5=(q zy?z3>wLR1=hTAOObpp3XJk%A2+Zx{W0=FGJ)B}dw0^SV*pWD4bLTn#=qY(QA9xU*A z+q+4KJqZsHVo$?23w%EIh6*vn+q*?@JHW$)#QpHCLgGRAHi6sO-t9u-19-TQ_z1?n z!6Y~>BZLHR|4t!s03InMeueK6Vzc2<(xT@H@#*mMLIN?>^MwTBs9z8gyqyI?fsS36=Lh)g#us0>qSC*7ragBuA7bu6>7K&Z2sO$2ore6B#KznQ4hj6&Qq+X{qyWn$lE6!y_f z{tTdpVO|a>)XmH*1bPVWB=DF+W;cOQ=QFzt>K?d{!1sn^avp%10N*6=eH@u%1lj

!&jWhf1!-ZDHMpNV?Q;67!_Tp-Xvn9~O+gkKVP%s!LT2l#?Hlk*1X7x-0y z;xOtkLp$I_0$)64E*ALyf=tw5hCFzQK)YeoXNENVhCq8@)N6(kFzPl#J7LsqhPb{g z6L`Ej6Lp-SbKvEIDg&d=GjsurI?t$z@JfN|z^K~{kE3U<7N{P)Mo=OAu0UL`*9t0v z-xH_-yiVXT_RRMMstd0d6qor20(F78-T)rU&HPZH&hSQo$9^+63B+}3v!JerKN9F( zc#EKJfIk-KF8C8c^?^SX=x+EkLG^<_7icv6g`oPwUkWq^{z_2S!CwnB3jRh=ec^8f zx*z^d;Bmst?*)1Q#xaHAal*_W1eyT&eb_+jQYfyTqz1vMDnArRLu zt{*@>0dt)L#Py5o8BjQ$XL8*F^d`JlP&43t0=)(E{sVZ-EtBgyAg*f%1oaGjP#~^% zhXgeXJ}eOGYvvI_Jq`aV5XXo2Tc9{?zX`-KJSM0m@b3cOiF~ zvb5l#yt5*JI>>E-tdgKKrU$@f1wQ|0l>_B5{}#A{z-@sn>>CW9&$B9l%9uv^XJrc9 zM##z%l1NKdwvhY_t}1X_Eh|^xwpdm*!Mhl)A$UK*bp@RV*9Q$Saqgg*wfs58yEXb&cE2S+fNXam|_|c&Hy) zIHoclVw?4x;2njZ2lFu>G0A#C@HoE<1Qb1Gy(oAmU>rZQUdHmMQ(3PF9;fG3!8-*n z7CbJ)*8~ssDC>2>I}9V-d3_ZGodxsff#$#S=Yh_FYYLi|%@=eZ0p7Sx zxx5_8z(+mEMcrcXo^HzJ-vQpuO}VHSjDIf7c>{QkoN{?NfVX>7ZdJkOymA=bJ&@+z(P%?pO#E#KF!iXW0M7iW4hK$DXEe~;Il88Yb ze+KBCFyhH15tBT`lhM0i#FI&~5l=>MgAr3E$@6$!KyQcn^B~FFL4Fwh8H{`~dNYi8 zGWrJ?Wz8fxjff|sKY=;jKyQRmUX0!fqb!&tFN>Hm$+KX@lS!hC@(@o(?}rgjCdu1D zESV(AHV^S+k|^su#FWu{VZ@e6^12aQCV38w*fPmH7_nvaM=)Z`BoUW9#Fo*Y!-y}F z%!hLXeF*05fh6aH*9DSIU`{tk^0J68qc_1dgk(dwrl3ECIS(Mo^LQITAB4FqKoYUf zt1a+bBJ$1_l3X^}ml*vqTvyN^!u14v>on!n7xZs%13`Zeb2>m0@yp{p1N|$EeThkO zd2@b1lGDy*2a;SKO@$HiAA3UnnF|hw?5G^cQejA;~dmCnUL!TrB9{;r4<)0AC{L zQ*Z}Ce*5m!RK=dkaa9Z66`Y zb*itR*TMY+{UzLA(Cgvr1n!f_8z3aP{$4L6vG3!dEN1|i9HYLJlRx_qOc--8DW z`Z#=(p#Ox2fSXbG{(^@Jx(L2S&?n(xf<6J?DkNR_HX)gYZx@n2JX}aB_zpq;1dkB( zZum|??|?@N`aAe8A?d-Rgk%8UE$E-&(SqIwj}dfpcr17Td1(Sq5cEaxgMz*go+xOp z?~?@0`@utk=KbSgLGym|h@e}*j|#diJQ+;Ewzv*X6?6eS4a~y)cJOR47t`b5=fF#t zM*8wz2Crb6)BdWUIZq1(eGj|{EXFci2CoTvCcH%8e!INa1@1q~dqd!Uvb;A1{S3TR z;J&@Qw*-xC=PeVsFD>tFf&1<9mJ8fhm&f%FXyhmF9fA7<^HvJnSC_X+;C{Hg)q>`H zuMxQ4Fz;P~`}6YF3fyOx_nyFggn7I_0PYve!>0tj439^a7m`SOwR6FFI0tQo3xp){QLUwr zM1HCv|4b5js@7LXZio8=lw}h6tcLtlLwWefZ}pNw5_zqTa%YmrYxNt!U|geN9o26V zJgm1m%8T)kuj)4oUQZZh%6PqrAJf3=QLuj0-x`i!&Vgfsk9^gL3+4_uA^68(S1?Us zPw-JbHMC%!gp-0n+0^g_(-k&?|1F#n%m_Fw_PU@D!8C)b2>uEd7kt#W8fOXS9=Nt(IR0k~rWIUA@ZW>$3T6S!=>~?^U0>k0TGePE z`1|38f_V^bBp8l=W5KkCn+X0UnCl-fPr*$E!{vRhV7kLxrohL(Rs;JPV@AT~3;qfC z0>SX#n+raUuQdt;gX2bx7J}h)wG_;4Fy|Tgzr(EsGYxJd7_K`P3Z^rBk>Gy?w-wAh zxSe1)Ru>EAI+*heeC)q9E)mTAa0kKg{?t)0yzWZ{{{#3k!Mp-rE*Op#*8^Z~f;$QR zA^1wcJPcnY7~Y>a-N5j=uNM4|;Vy!C2JR{tuD@LGfx$7P2FC#SKf%`sW;EPGKzOOf zwSwXD=Fb3s4ctpGi{aja;kv{15|~@yzJh-g?kAYX;QoRs33E9D!+9Mb_@Beq3+7pP zpkQ)fu9v{@x(5mV4){jFjDrUYCJu8s0>kkiBKYgzn+5YCJXA3GFsB}Tfn5?+Xch%A1?Tt;X4E~10ErmO7NY6;qvCX4*ad~U4mhc68w|!-GVtE=GXv# zB|JtjZ^B~*!~5Sog5k7qSpfe}_+G(00pBN>axm9dV7R`F7yNJF2Lv-8o*$@enjv;gdY{mLU^)Z>cU(T74=^p^S%SY7=Jf)z z1fC<9hVZk3;rcRH@Q=aI35N5*#}r^n!_N!mDwxX}_+P>=2!=nmKrneQ*H>Wr!!HT` zF8F1^+y}oR7#Dt3Fc-pH*1%s6FA@xYZn0o$!>`Ugx$ zc&XrTf!`7gm*q0SWWsL?=302U;Qt7(5X{~1JA!`-UMZO7@G8My4HsiLEo%hR7=BkU zx5FGK;Qs-?Cz!|Kb%H4izb}}pVJ;Woe+_>inCIaQf~gL3-34X20kSC z9CI#HV0a%oBABz_Uj=g`d{ppxeVhkiCc(!9lZJm6Ob3|j8Spvge+q_=E5`*>8U9N! zJ>WvY=Q2Jam{IUa!7qY~F&AL^l;C4Os8vVMHHh*{2tMj(eo4VU2S)iaJ}*;R@SDPA zKzUsI42LTS8ud26qTnO$`IQ78+s#M189xPQ3Vv(2ir{M)b&K&)Px7+`|2(*=kVHQ6 za|EB~A(o6!aGu~ha5cei4s(8hk2vOY9)Mo}*95gNorLoRza@@&A3j^~ z4UBSQ{8sQf!S`U)iTw4LAA>&-d>8&u@SDIJ1wSM@tB*i~;bI77U5K(PL_XA77-=j- zI{3cALZqRvE~Y2K^#s1>w6MOw_n8(p5cuBG!iEChPg>YW;Co058w>7JFk;N`J)wn& zE5rAJ7M?5cy`P261itUH5b5?tOw=~v(i7D_LKD_SVM7{+%D14=J}D_bc22ApZ3^isHrh0@F5 zEDNQV!`T)}qyB70{Q;Cl{n?yjq4XLU^#@RTEu3ee^g0;z2T*!FT-`$H4KV5tp!7z# zriId*VbmW$=`C=+h0>@$n^AuNrBQ!2*S1g^^=C8c51=&a&*nN7N^||GYoYXaa6Jp9 zx5D);l>QNJV4?KSa6=2Fx5JGrl->#B8?gbU_rpyrls*9C8?FJR55Y|>ls*EVYoYW} zxS560$KdlUl>P%g-$Lo*Fus8sP`VIqZlUx^xWIxhM1WgZC{S=q3k5N_m4$)?+}c6` z*YP$M3bIMLIKy2t1J|7z36PAfa}8577952T`UxE z%)44B;5c`)P{6V6ZlQqVd5whvj$scA1speg4>_QKW7X3_0mrA8h41}Tn|oU*;5hWL zP{3v1*Fpi8cRvdST*mz^6mYp-XQ6=0a)5;bF2Cz76mXdhv{1n1bc2NgE}KCX3b;IO zv{1lhFxWx?=ldoL1)SF*7793j_%?Sy0q1F`g#u3REfxwmjl(PyaJp`_P{3)q%|Zch zAKw59DBx`lw@|>_xx+#MuX}`r0$$&p77BPBBP|s0=kBslz{`%ZP{4n`+d=`)8*QP0 zJ;p-8SXiK79H#HFP;ej2zvG-4jEC>FP%r`JzukxV6XE+U6g&j;a^o@o5%>WM1(RX^ z49=Os6!<|41=C>uJlZwE6YwMp1=C?(*F%^;1Af>-!AzLfi?&ZN3x3o>!5o;kfp$XA^fC;g2gbWX*%XFfuFKa z@CMB3n}PXD;ioMWEQ2|%GckWT{EUTycVJF8+F8LWc(#RtH8AI44(6|gpS4i14(9yK z#r*Z~a~29Vz??U<#e$9S^A-v=!<^6gn7;*n!9u|&Fz0yz=6?piXrbT>nDdXeTksY9 zvW0?gU@i-^aZlPc= z%w>l*V6Y#4(?Y=kn9C9E!Qc@5mW6^NFqbLXhQU$zZ3_j*U@l*@k%K?r6&4DP!(7(y zV16OI(n7&Wn9CjQ%HR~d+Crg%IR&w)R+P?!gEyg$SI>hR|l3Twg~`!6s*AO6xpVQrY}!B?1H2mabZ zVLh1Z1lr1B1Nd7Dg^ggYAKzhq6Zm@zg-v0uD_b$Y8T^BV!t-ISH$P&2bND9t93uMX$gG7K#?aEi4o*hFe-FS^~GSQ1k}e+CtG%xQ&IP zW$=X-ik8C{Stxo3Zfl`v72M83(Hi(-3q@<;_7;lP!IxMlS`T-yP_zN=XrX8$e5r+^ z&G2Ow;_tNl=yD5%-@#W{DBKEnvQYRVe5HlLpW&-46mEw*TPWNKUu~gqH{8WS;a<3_ zg~I)CHw%Ra;O-U*55d=1C_Do9uuymuzSctFF}SCN!av|%77CBUy)6_L!hI|ho`m~a zC_DxCvrweq{uYX2@O2i767T>EMIL;;g`y-p&_a=cZ?I65h6h4 z9Ttje!Xqpc<->PcD5?#Qv`|zBzRN;UJ$RIbq6YBY7K$3dqb(FQfyY=VY6_3FP}B^* z$3oHh@Hh)a&Eb136t#fwvryCuzTZMo8+g2hqKn`MEEKhaCs-(I4?k$3r~^FFLeZu0 zBnw5C!w*>~>I6S*q3A035er3E!;e}hIpP{`|f%t9frXNrYFUe8nug}k0=77BSi zk6S403O`|?usi&ug~A^2bPI(&;ioJV_J(IzDC`SAZK1F~Jkvtq0QeaTgh!ol#f77B;Jb1jtK3qNO}a40;_Lg6s@c?*TN!SgK?4u@Z`P@3m2uu#bL z=tT>KTz6iwP@0#2*+L=LomVUra(#K#Lg83=p@l-!i$bzc3TZ)KB10)`AAO1prLfJy zn1xc8)q11jj*FvcSaGr%yhu~@!N*#f#TPSrDu3@3nF}S9MQh&g;ER;G9 z=UXUM2%lx4)JeFug;J;Bvn`b7I#S0%X|5M_EtKZEP|reXj(>d%r8(veER^OrH?&Zi zW826=X^v-O3#B=RO)Qk=xSeC6G{>r`h0+|Kb1jtSm^8Ccn&WVuh07g)xegNhVgRi$xYBJ308i-@s6!-=Ur5=HKy@N3SA^1iMrN+U$jlq~d z7QV?ssrz8w-Vn?m58rH|kmvKZhvGVg=ig$Xa01Nf7>4;1;ae>f^0scXP{?Vx-9jO! zbGU^Y&%itXr zN^xFxS}4Wkw97&%E*s9nZmfsPV~>SWTyA?Ul;ZN)XQ33A;eHFHxD0=>P>R!dz(OgG z#X$?DIE{xal;Zpy2FG#D(v=((eZ@HKoYb~O}JJ>!^E zm&W5*6yI-%S5@I#P%~V&Y<`1`?2PJQOl;BTC;v=L`s>z}@d&HOda3M$jivca83FA1}a0vtv26)l;gf zdg{|2MY_kM>ao=&coV8y(ewEF)(@}}eCw;-j#tg8MrbYI)mR5HK==3j)@SBp%e=RHcv#WD4kdUR1ET)^6O6kQV^=bc_hHG}>JYyqD*z1{y<&w*3# z{deo(vPjauw)s1@{NHSom({=iYuVq(|KBa^)jgBPBmd0fKjtGKr_-FMr8Cd08}m8u zY99TGmnFp1qCb&!&YR8m>Ght@HS63GA?Q*7U^10Qc-55@@1niWxNEHQl&goCO=Wt$%slcs9&aRgGLQ9%9c)4 z&vD(78Bq?;&#vFNu~TCA?q9#ZXHdN4$J2^lO_oRxpR{Ab6jiBLpQ;trt?C-pPmRCj z`u7VPmtTnd6m6jV}f ztc*%H&LysLRC~O7KshcRl271;UJ2@t_qkrtA+8*!lhaXA#R{cMgz1#=liH*5s+>kv zRMl#Y8a8f%9XE$};HqxvQVr@iZsNJF!mQX|!$x(vly~D#WtWcKaQm$p`DIF!t#fiL z@7P6MhV;LDNGaa8c^|*xl0I>Km9i6C{a^R<8_(q9DCgsJ{&X3tj(wQuaLlE$u*M;N z(P61LG1TWbvQjDWUtJqLj%m7wcwC~@%PLsJ-* zDIJy!OJfkGePp9AOb0%NE`J6F_FuobT z1jo-1X>M82yo5sPF2Vemc47nYrUy5!-2td8_tX zDq5t<-22#*lZyWQcIc>aBQ_-_Eq~>n85ybA+w2>r+$eK zOU$Oy)TE%HM!hbX#P?m6QmD-+N=NlRy|Yqp1fTa)MJ1xN!HXG7tI`SHL9z$q$|VYANS?)m@G1(RoVii}8)qbn?^l z7PaOW+jQ9S%S5^H*=C+CSDDYpS)T8jBV1a%d{Gvs`^@~WyA{vp{Mh5VEPwd)@^(HS z7iIo0JRfzXGwS^?)O-FuTh#g>g;RT(5*cA!#meH#KUM4!gZqxK1KCTaA%ZuE;I?!ABW+yW>Hk zXkTH`7)ke};(F!c%`4Rlayf1Ps7GjxNV6erk{%9JiR^^bA;c=XdISEMGf8!E9z1#b z0}rUTirPMqm~^t*$&M@4Z~S&$ly6Gb@3@4XZIn~-@3wI zrE>$P$mv(qjT2RL_K?i~m8%=l zC(Cm^mib5iH}enwJwMUbwnKRNY6kOdJ7leG8#rg$A+vE_zr>b@EcXRI@AA^c+wAin z>o}lV{Cge9%WB&W`RBHN$9EQRo97?f;yl}S$Um3+4G$M_JH$b|@b7krs zQM-Z*E4YqU_8^>a<9IcmPAZ%(_zZ6AFKRMQ$-O<JD%XK*lgVX2pN~)6o_xZfh znUe-;j-1qSPLI7mY*e!*C%gPzgK#>}sZ-)?K8;`8uSBUc6{^c+UCfI~q;U+6QT-FM z=`6aZpaf2@gw}*Aui~)^DiM#PiB$BU!=VYU{uBRKYr)lePuVV-E7_= z$!7C{ga8R4fC38$BqYJ`CV-M4ZxIm?5dk9#BBCN9A|ggaq=*z16(dHZ6%i>$8lR1p zT5D}fEmEZl+2Q{=GxzRpLO}n&FPhiv+&eR8&N*}56N=NAs$fev;A%mR)n>?B*|MKj zcjb=NzF4MYXQg$Db4O!+HVbag&KJUp!O6w-CuXw{rm$% zuByAG?*3)$)rR-@t>K?Ftb7|b5M@8sP=mRJDFmBK_?nrSOrxoIIXZ+9bSyZVf*ftX zfsKTGDUJSSTJOb^JX5^Wsd#*;49aa8y;#+5Fd#jO5v1ejoYQhjdi5#}ut*3bnf9TT z*BbBLzk_EUb%a-sHUCKruxRI-P65+FfN71n9_Q>(-(Zq6!T?4$WT8Ze8YY6PNi&;f zwi7u5a7~Dhi`B)BWR@)9+D7b%C0(FIV1UJj!&#R{PZd8}aDUaYQ>?Mu=uG|% zJJscnh6FNpBP5W2T=bu`+t{c^|5R872`9ung+4n7^M)o{jvs+`;@`6U`^&VSG1~n{ zX}y8|pXhRkd_VC&`JU6%ANxw}`^@!V= zN#QKDQ%Z7DVhFrE%tOqootP%!RqQQ>Rux+m>xHeN1E_T_OaJ!UBP&|gzJJsY#vY z-wIJ=lRD@8BEUt;_^a{W80ajJ&rGU{ys9wGY^Rc@cExJ3TW69cGOE(XESAw0pgvH= zjs7u$IncKVM;gfi;)7)&x&HzrF>e`hyOaj)?D!K*`tPU+3RLYe$U)mQe$f_@2#2})wIS& z|6dvjzO=5dg`9OQ)(H*vVljS_MFs?g&jmci=fw1awk_FCa+qv4Y+JIO=vcNJ`z70n zE@k__@V4OlgYBn{cI+?qG0@+zr?j7sVNa3mB(KQ#3ww$&e<82Pc4>P6oZcDZ5l|Zgl1frSY+FgC6YJDQQVGE%Pe4>rmyxK#cL!06pKm9u7`P^7 z70i1o_NCC=0s}~C2%P9FmFti?y3{YTu@0wF=<5ciplZOekkb=LSZg+T7YSH|M2eH) z;1af<;v%;=JOyWWK4vi*!gw8Mm4uvYMxY@gQ^6%Jhih6G3%Fd6`(b%lmliaX9}Ci^Q` zR)5unxv5s>1>dncH#OaNbmf2(kKE6up5=cZuxiu{F|*wNC%)i5{|vQLMxKrL zCY&NUWUZKA4aNt~>bmPDoEex0U?|%^apPm^4_pgv|0vMDs7?Fl)Qzal%w~5qK}&8KHFb zb%88o1uGHSpRV|sYap8my9yN22+4YFkjpd>eo+2!?{8voZ14-hp~Mv!afGL2tCW_gJ+FBsOO8? zyi?x;JR#wgq1@#Qw_Abcof1@&RfQDq0&j*%4A3=EJ!KUZHj`O3LAoOy+HAJeK>ff; zmh9`Hpc@WSFl8I8tTuBU=B+=4W2A#;s(mDGWhfaL&KR%D8{@S*Qn3d;dyxSZ8$pt2 zF%CgkkTSC2mcW?;qfKlqo4YurXJS)Q_vF5>*38Ko;!z7*d3I*GS~;d}5o~5w|CWg8 z^2zukDx6={sD2Y!J{bcsrEdT)KsymOcs_s}Hq5#LAM1!GO#;r?7)=-fN-&SCShEssZzCK%h#((U@>nFIUQSFZB@%}ozpL{P-N>5)84WbXJZQvp%wT{@J zGDVUp(9u9{-KZDiB=iI7#T3PmpaD~)=5^MwUR$EKuwF-#wj9`!1RC9@PCdWKwBEm8 ztsvi^vJvB@U_6JAuYr~D>fnU;Jjv6Kx*TIhjtiN(`&1`pV{Wxc{o0@6?|uMYwS0fG|67`e7*Fiy z5=<<(pR%1~57}IbiD%FcI$- zww};;T~j)*g-wV0!q#J~i{@)49Z0qtGLr01{z%zw z$VjrCWF*;c$Vk+F%5tmTZgu<*em(>Krv&_0iE$0TCc#Pem+hhBhV~B~_mq{!mHpd{ zEBp?!UEo;<`~Dwey@Y>4mXq@rvYgT1Hc_8{wgc_7zwoE_)ctoo1O7V!gG>67&$L}% zoR<}l5eeTD6_0YYuTQ)M?qcvq3(idV79eJ+kgQZ?26%!b1k;G*B&MWwO7bLo;@plf zIHnRwmuBRcDk+8mn~1<1slwy~<28&}20d(8xO@RbbC3l8O^>OA3WjENbrsc0;@}ge zPuh|l-&L&_H1hh*&TL;vQh(UA)QSMP5Z~$s_{_t63(Kq>9C^nwfrgm8@`k_OoPt76!2?>Y?`2?DFc1|;hRyC;^Q39 zwKXS1CauDbf``KUg{djEcxr-h@dn1q}xGenU1oRVySurR(P-WrEr zGIGFAdgidW9G2}(ce>xZchwzg_^t)D_b#sU|M0>h=3%`$^FF0du_MFg@nfsY|GuA1 zR==*;Res`izQNyp#2hu@SY7Mi%l@v#5A`=6Ae`Qrg}X{vO2cr*82(*{O@$m* zYlVA6czETjdbjN=*e^Y$tGVJK6sWRVM_1}Eu;Z{29^xY7Q6(%)XWW2#&?0L*#%wmF zR)4Cx+Lx9P&y-Hd@ty=vi1K5D1ul-og-b0$dXS4q+#y>Ber8y53QI`#A@8zk`{O_U z@yDXF0=9*}|MSnRn9uiC7o@}{X3Sq&=VrIF@vN5J?cUGJ_VZ!;-3KhD+&KOP|KCN0 zf2+%_WRtT4lk$AIz%0UQvRLXq8C`5u6vH8s>9Gir)st?5t-|AVI;B6Po^59i7Q_F| ze>{1TRq*{M&FjAV_5%0wQgty39mVj& z#2{xSTnW$gIIU83i3QDwQDdEkS+c(ABusKC{A3Cva#8swJajE=%eDvC zZ>4c7cu&D^qCqeHeOEaka+LwhLfb#0cI@5vf6}}^)H3(Va}Pgp>JJaK z%vd+!@zFDH8D847ZHIfrv2Hz%++179KMFXk!W z@FW_7I<9OdILP+zNoNHd?t)B9v#jRfv_jTc$R6L-dOe0C zI>lN^E{FzyAUXv%vSWoRVpVa;EE86C8S7L=3fRu86KmBNnYyjVcuvbu<0LvvzE73hL^ zmQ{g|0q9D%&Xc|i1z++hd_v)EWL^=D9I$G3hS!*)@pS@r&ce3ui6YJiqp1z?9Pn=o z8*uQb5O!ej0wcU54B;Iyp|&sRYlm2O`Fi|pYEsJ^**lGm&~5#%sJabb%vs>xP7EHj z`Bni}u@>N}Z2wfp$tvJvX!|FK)~Efb;@eF%h%tzU=7jh`X9Sr}SOumICj~Q=L2M&< zP}r1E;NZZ)M#MN{r0T}V_7xLv#mS|?h#C{)WixJP8n4kZZod8cY0v-I)O6#Ge0#q7 ziiSnx=C?NbZ}Gn!qitnr7#_4$1wATsUZ0D3!DbIUA)9@alIinEBRa5GnHmW62uJh) ztZ`8+N)Pmaf=iMijw`5g>KNX;Y3a(=rqgcLqW!-0>Ic`eRQmbdcXC|ue!&A?KtoVg zk?o`($#z3OlI_GBWIHq)$s=i=LcfshhJLXQv@UJzvfYsL+mCC=`LdnleA!>f`M^!I zn_ZS$K`l5rTsR0>g#&w04(p4C_*vSNHeiX45?~3&zXZWj(yO>A+8P2JTff&2eb*L1 z_SCoeS5>WVfA<~ne;QlB=#{oG;*)7kn5%3LSzDX_A!|cBtxfhfbQt-5k_%x!hC``2w%c@NyGP9hqS;BK1ELzyKYz+JZsG2&*kDP2xZH zCZGQ?t8lT3kNJEUno><)!O(=bVqPfT>x=IVX&!VzjOvenhcRRgfkM&U~;!A zF6JT<7m8r768-(cOU>ZKf$KWQVo@h-+Sz{;zsnf%u2KDW@^gz8^%y+32QOT8$H0}P z=4-_yUMyWX@Qzh1f5{T>=(4iW0)Ggvus`v{|4A~g)wcu+1z`;i(@d``E1k?n3=&B! zXb3t}09h!)OEhn4h@@`j2754jdiAh^Q68h$#M}*iCJJ{mCWnyN}-GUUIUY zzdt*CHem18?rB|SI(>eTc75x1j4#F$^z{zL5Ar_QPCQPw8$3?7lb=bp8$7Q4dj!3M z9tUDQH)A~xvc=i0Cb&2PJ4{|Uvj1UV1U9)`*jgN_4Cga8mj2i7;~(%3zF{x(%5PW? z*5eyq$zE1t{r^(mQvcvTrKbCf{1|}tEfM29hH=6auP@z()4C(fM65xKi~I>Vvn6WM z+1%2{y9bfK4L!Kjs!_ zH+W?G_ZvJ?wiAz(?=g6!Xcs(BwhNvYm?zPyY&U3i6JUZk62Ju11r|uMRXIUR4}$~W zfz{ZMYXBUVQ*1#|v5fxk#BNeI`)6tQ_-{~OG(2kMXijZgKi}(Mm_ZPe~U{|y_jP~}j`p<6p{`T_`{lRw%@cz3@ zC6MGg&Sw~V#e9YwMBIY93fObZ%${o=$Jw}z?C#f{rjiTq{q1k;K8EEnW$eZ8O;b%n ze{CGeq)H*5W0PcbL`YmjhjAp9KwN}}A|iJCpJ(N)%l)7AoZk15iSK^N9xj>M`8=}o z{}H9;!={u`)O~kWm)CdmwKmIJjsCCg&{}jKzbO+?2=(!gg{=E)ef&dt1AP1qZ0KI~ z=-$2l-g^OfraT4sjsScOzuwqi@asL*tp3SV(t6uo>`e{$?`l&4Vw!T4yM0j+%%Ud4 z1eBPdS``gWDG$s*Ibc*cu&s6IZ&6fBAj$~?S*@(rMu8kc>&d%(PaB0W$o`V8SNmMJ zS0*DZ1!E_9GBYy}3!0oK!uY)+Aik(Do(`Ctl6Vu?etaxL9C5m^KqAseE40UkzqWDb zs~7&ZVBDjRaQDZ|dT~ErSv0*TKRspq4XXy(|G9K&{f1rpojvBM4%zjZo67<&vu|4Hru0!bv&ip zY)=Jjwl&~Gt1ZSmj9d^raoP90d-1J~-a~t2Th?yOCTx8NV~W1%?fRa$c(tg;wFYs=()7sesY>dA4G40lFz|~kgo-o#a$$Q>qnH{}{)?+=>Zpw>k4vn$FeX}SEhzRp|-1CD8|DC`|G96 zh>wFEf*$DxZ!t{L_Bz=5!c^vh3(=n;BO zBLjT3hD@AJOzG9LM?vSD%#2RSNeOOOWVlFVL2M{lRCO`2*h;xL9TPESbu$GJZJ4&T8{Ip!Mqmd-aD9yk2cxq_<}~ z&@O1&4!dgDizdV`C$z;Ii!bnw6X$3jH6<4N{g|mYE>$R2u4d1c?~BB?#FMkYh8U|Z zh_cE`L!e$dc&nMr>y#B0`mrtaCT6^NLT9?Z5a+PXMEgAu3^&H-1MxSNXmkcz8fZ z4+|A*Ql%Uj7zLcX>}!1e+P^_@IP@1l>DCA%ukd^|{`n9gc~zg1UOfv7@-j10Q<4(h zE`%~u2yQOR6`^SDkC}LcL%7J;>!ElEBxyj^5NTxyXAT59$9SM~WLQ|<^2d+w9Cp># zb=SMEXXYELUfl4Mrtw~}58l#eKvHgXXWm-lbl*22>6+YYYe)F1CcMVt`wuBCb?;jH z@1 zA`i&dX{W#p6Dl7@iqKgK{Xnn+pa5I1!bDVvh)+&uZXqkSc`Gu!!4)pxDCQ&J=*Rp3$9ebx_{w%V2W9*FWK*Pe z*z+*|K>Md8%m@0@*(UoRGy2nd#hD`8k90?WUBKZCYsGKQO*P&V~Z%Q z0GkDPSglaPZ9473XLOlvh~}1k5wt+CtM&o1$%*l?Mm7S#2;Ds_)Xf!7aVUZ{#}r3H zhlWQ|wKje7Nz-kQM!1_BV`J@)zRZ!&WYEADcE`RZH0jeh=S_ z8QmiA?lf#Foym?IMqaH7imrui2cdd)HXNHz?uXfJ0xZ%HL!RYL?VQ#blS2TajO3$y z0rE5ysX6AHEOO(3+fd`f^Ter^rzdxTkck^^++Hvo1=T!BbR=Q}+Z+k7|e|)<;zrg=`;IVt} zeT=?nUo?{C%(E=%Vqemf`{mAbrK1yZRbhxZzrq$7-c7_}{ZjwHUKzegUs_5EQ&Q4W z(o#EJx@T=%lO60Cg|b~{-&g^n+pe^E@WhFrH!1fo08S>sWAr<@^6n5A%nloHlGsK# z^8FA-rCVd{c55wg69`Id81Z+MO#o)^1+(w@tDX~3a5yZrK=x`1Vk^X}X%Ofcl$049 z84`yvSi6V7)Z-Mz>q+ZGI)e);gZM{r+^pD$;nszZKz6XxX<$1$!DckinIkZLse$+s z<4uS6&YipWLVrT_Ao8>8{rLvk0~>01p4qFztkgHeNwyX6vdd$MAWmYt<4rKB(YzDl z-7ZT01qC1v{qCtGzEQ#P|P@sRDFzpR_xdG5BZRWRbw& zChfKQ=tXNZHf*5E%mlM+J~HE;>?VU&rd%*A!lX z>}#Bs@~{+qnaQJ3-B0ZK{MVoS%i`~u^85Nhb%TW6bSb_4IFWFm9lTp_CyPw5J@}quA?+WN?jibT zJJ2q4n5R3?CssgzOAX8gIt=U_7J5P-x5p*#d6)I>=skdY9qBM_#%x0Tz@>VVSU>47 zZTg;2>>cPZqA#5{`r6;Yv0=_m8_gtg%d#dF!Y1#{vC)O6)vzxg5e9*lBseMW=j z(tW^Y;oDo+^1ok<4elxF9r|9JAiqEGkYtx!niK0J_CWvJpz#Qt&EXyTcmeh{K3<}6 zXO!IrA1@qOQXEtf{pAs&FCwaBNR=An5n`A+{NvLW^|(&mQ=Yh*MYHVlJ^kNE5;y;k zNZak&m*(Vu?J-zt!r3s?Dm!he;vlBb0))N10B)Xy!@3I#wo2Tv8R@BHjZ1WAX2f{N8W)q3 zMai5(n-3hQB}EX+%}f^|ov+&2n5Q=|xBXFZrY_qO5#c<>`s zvL0&f%m2pDhMDYAszfOp@`o3iYG5Dj$UlV@{J;CBw(J=)iEsOgb#k#zU-4~|hCI)| z5BjGDuPWF6+`3+y-|8t}HJJQU^X5ta6zEIr1=;XeI>2jj9M|J>WILTZviFc|6-l*-p*ZRI&`|;`PswWL&EAIPjap}$F zvld)G;-MRseE8AtUX#A*1C~O$tU~3`crzj~|UG(v@a|*yQ zgft(F8-laYsYVZ-+5tcb=UdzR>Cy9+_Vvx0!)A5~LG}dEhR7U@FZOgq8g7cmK;{pW znO-F^29e~eKg6^nk*NE3_xkTU{20jRvGP%pRNkK3e{6d?xuI(Ay{j$u!*)}p`o93R z5RUA^9!b5Ds!t)h5NA&vbZaP-_=FtVlR}d$U5X3wYc4MpGw?VGPmqKs^nD6(b80=O zO+4S+>~=BBt^?{z({JKU{yjpuKi=RULE}O87ULx;i=^bAhoV*%tI1La%otM%17wKC zV6~bl=X{cpf0+!UmEIeIKlMc|EU9Wh5@_33Fe4%2dy%6-{~WkO;&$lp+WLuq>#s;!&<&i+m4wgRm6?ckA)&2=fo{1I8+SeI>+^h1xSw_+_XP26>jzNn~6?k#gWL z$Jra}h{ZD^Jv)rjvt?isqEdzQBdjuROFi4jcfrXtl$Z4zKfYi8apM})eGRRf8cfH< zU;X=yr@az$051)rTo7!Jh=DSO6%lEdWP=BW3k;#ZndA)dJ|PQ}Q6p$Ok?q7EW&3g6 z7Sg`IVIvXkg5Syh@ASm$=>32tV21Z}LjzzrRBtC(hK&2JIkcT*Q2CxF>JL5UC{NeE z1?(M&UP^{ftHM_XA3p384vSr*Ita*=HczYs3MynOg)wn-K;4%}dR5^<82O z`Y$~FpeypJXS8p`5$JcbW>e;cmf-FMcahd}7B;AxEtAE1DA#$oZNb(jS{I<8kM(TUB4cax5*#|-%cNGzqQH(| zjVpz`oe96rT8Xy;KS?f^c!Mkd?eS6&TiCs|3hAES1icP$e3QpDttF_%OSWcM)>s(vas-H3rA=gx?vz`>Rg zgM&g=>4BA#s*w}{$vm3H>vh&Q_q}I#ZG20Q-?85O{Ex#%%{8~QE}C@WdA{^X|3&tU zlk0Y1$^*r?n9pF06OsoC-W1v{^k!q8LS99@N85Qyiu}LK(Ui&aJDMY6HbduPn4r9 zD`DiL?xQW*hK37`W9AfQH^k|705PU@G2m5-Z9^>6+0avzDT6^Pn;=7GDJyh`R}?JU zEGsSE86ANjSLDznK(W%`+>Gz&6H_408j^=0`eO5?<^zXhvM(v*17R%GeM97}d@aG- z*_%m$V^CvAi>0(6Sejv9Hpl1-g_ANzv&LDhnwPE3Wj7^P6eoKVic0F9$WvKyF8`=G zd2~O~va&w^`OIi7?$lq|cI~d;E*{a})(}2_qix!6YyBxx%JIjoThuX*K}-AiU(_fP zhZ(u>-~j!6eVwS)1JTN?R^aGIh_4Bpo5UY+Fk^dk83CqIJe#N_W@ZY(CnQ3d(owI@X(T%4<}?DF|Oee=;Kg{ar59s*8o` zGA2j{RNBZ4khUcQjco1hNTHN$>}|=xgbG)BF8NgO5UPJ5Xo7f16}A(+QKwIxR**~1 zu4ei0E;D$S^GWEWKHxK<&wgY)3z{Dn^3Knx9s571mRZ62Un z1~yhi4zy_wlg)@6poH^Wa=zKj3OvIagmSY*GgHF3ftA5+Pxkdf-6;h&?>ajRGh57I z5c>mG(k@%oivHnVH#0JxBhz6z28ZbHWkA>0tJGTO5~p=;d=4cj}cym=fPL zky6pU1JgnNVlJCnBcsE253Y`Knybgm zo2GS{x4!i!t^Y)PH)YKriW$G+rqZkx$@po)&p;ZZu?*BiRIP|_3ob{*wh8#>1eSwd&}4}_ zhAzwKDe#@5V**PfOInD+Z7hwF?~8kSQEI6WUKFW*8w+iHdehbYuI*aRm#8nbU9xG> zJp+Ax`n-O2`8~n4lWZl|{{A@~Cg4M`AqLt%L9^IH$YRpAIC4LdfLWQ`$;80of=dS( zh=q3weAafs^7HD~$f%Gj;BAMiI={Dwje;6GX!=&pGYgmfviwKw1^J9fr*RMY4m|m@QE1+TbPlcIX~_ zj`;@njC2OMXD6r~=b2%g90VPS@dX`G!dTGv zCfiBJm+i-Cx*hZfV+*uP9~RE%ZGd``BoX(#ICsw__ih zSdZ|rh4$^($0qs;dj<8iI1C?~n75!W*)D899l*r!rAZi)ZY=v7zBFoo+B`sSw>p~H z$iUj47I3*yj4NV(=>33~>@V9x#|`ZtI_}fLhbQ~D8CUp|WP2NbQeZzUj`wnddzveB zdpUpM^E1|K-J{Px+ktiwJ9jm!k};P|n$!aLa!KEJFaEK8U!n!r403VSP>cy`7$pBv z4TDLh5yAM9qy*HLMTAL6eo?ksEd13@lx7M#47E^Nc@kd3!ygg3Ja&KQNdu&(aIQ`` zdNBhROZm|tpFi*)3!`%eN;jc8${-}b3y0!8*jJi=BKeS<@WMr3TGId~XR@f11SXU+ zsQ*;c0L2${r7V~Sih#3#3rceStir96H}5*~>fptd>6H&ng}#@=|KT3ivp%c$ zt2kVN+<9$v&58 zLGgAH2B=Yl(^5o>7x5^X$)?Q=L)9l)*8p*g7D!|$N*0LxDWG1K_Ps>(rR*dE?g9@W zAdw7Na3y4V(o(#gyovEKh?hbrh^FXkS$c5&`t9sT<{45sbckC;J(Yt8Wt8FS4H3`V>`hILY!;iv zH}lPGyNEFaz3qZewo1r$$;yIPkl+~bIiiNNh17yN=zvNs5Eh10Xsf!Dg=z*Ox+U3% z@&Z=cf05Pz|LQDgVlNr%5jLb`(!N2F5covN zFo^cS$OCetl8_H0uyC6yN^oXyj|d;AaFJYQYR&G5M{jFCGwUw@n#)cNbE^TYNd|KQ zLlF;1@wvfxKmr^@ej^qzG=zwN9E>)?E(C#0VS}MvMLZx?{Roj>V1bv?5k zd6jS8FL@o*QBxh(6R&(Gvu4%Ia8o0=%Q_$BQ(*R_pfRbxup00^5-rR3xv}<~ zHocVd~&dVzU`9$4(l*lAaV82l4Tt6M}QqW)mJ6aNvN+IrIq%9}dqT#f1Ka zQG6L0GGwvJm?9U+gkIrkRS|XTzxwp=+5F4|1YGXecydVPy{p(!ezt+E$ove;Ja}@^ zjK32f6L1oIY(*PBCfiAdm+kKpSgAi`c)(R}w>mz#I8L@h1_*8ch}wbSTfz6-*oSP& z#f^hL3(G0g$IucG?T?&fpa_l}MA=+&{Ih{24fA`nRMwk&ymSiF%0^v_+qWbamrXX!VjptG}k z?&`YdpOEVl^A+&88x7#=6GPhx9JJKiQFG|6@jfz%R7_C%SI5 z74Qpf|A^WFziR+Lis$J=l~M`S>;NFl&M4c0<3j%fc5*{+OoJAJ0xRGusv30gDYcqa z6(>6BA(!?bmygJRDk?Ntw8Cqb^++h2pSPl{Fn2^zVPdD^vTNoqZQiow-=FM$J*``O zOxD1>ys^DsL1phlQO@~gQ*ylJH1b6h(RQ!T18I}FI@P{VPAuVO{|2Xkbot65Is)W;^Lvq z9#~Q6s>;9rgq_)YT(0Z*-f{ZEj<(d`1QL57E2}Tvm2C3t*|53})<_I<2w%;-sa68<#C!`hklC3idk0xLV z{yP->2Dk^lvJMRvhjEw4Q1F$|_D=@lW9+#d@qj1I^-7+y)fX29Ycy(^S#lAI+Zh(d zWM);&`u^vOO$V-uu!jl410BF%q|;0lk>Vt$h}e~GuysKHv5*AzYo4g~A%zoCW_nTr zl}QtK4H0_hvAh7Rz&!{lWQZCa|H!L1>sAl)0~whX%Fay<4b5yStGKpbTu#@rk*E3j z3HHV7R`6|C4Rm!*Oi1tNe&|W{`n^N}Y+2NSuNK5DcJdxwO%%-00WVV5BM%~jUP)Jtay9A;WJ#WbiDo#3o4N4=;(8(a#aP0!Oz z#_c#F=z*$_U@wr5F%=bR0|xUppNdPLsE;}oyMjA~gixR5O;1fuijSp=OeoNSRJK5T zG{VvJry)_5-o`UZVV7aAjQ&1s{|#qrw*p)R08j6kZWIM@+uyxT9g)p zL?2dx2*EZ*Rj@e7PPLdlimJqAJL9qgMOE}fE|O}BMOhWdlqh9oWU;6>IQlubf-wIHN)3L&B@Dip>>UI0{Lj#ek|GIR5{-xl`2 zX6}{mX!Fr-7-pgTS@My3K zc(iC9s7hwk{eT=6BML}C4nxHfPe=t5wP{)SGJbZ+(JI&S!^>S0Pj1qNx4w#HE~64A z+C5k*;4H=w^zs`?r-hPU+P5FmWo^+ec%bb6&QBfpfA>AKV;ys_j-gmbuzm>z&Pb~R z>X#UM6RclCl}ln`@ZHO_IqVL;>I~;+cpa-d!~H*_USI-$fGzZ&@tGbpg%%_nTOIX zubjeX6?YWK>Y|8)2uZty1+S=(Qux@P-#mQy%^7tLk$P#X+xl@!%g0FIJM=3O_y8;N z8F1gBKT!Mi0_Hy%Ix^nlruXQ&^G|K|g4!j_jsCYQ z2TdiW9SFM)$9teN14nGo1!L(B!=Nq*1_-4Eye~u>q>_C4RRFO{EJY#<3mdtrNte|{ z(R61lm1Re|MAF9}tIAE&ABC<~B%y#A|2 znS#D4q}9u=8D}jLaM&N4<>9 zdOJFF-pjIy(MI~ij@^)DD((^ z3iTyRV&deQnF}6WQ$66fuRFHyf8j>{pRD0o3&u^DpgHaxI&AvnS+_VWi#9O!(3+nk z=CcTdXbc}y0H3)EoqR9WOMcqX<@*Y;HYxx802^a_?RK zAYZF4zOmn+J8tGRcpRYl26KJiOtmJ-56VdTbB|66G@`&ZpeX1q;h#GFXV{!e5F3!E~&%b**=HcJJoiVe1 zDf{W!l0g_Si~Y%ec*YjkWWkTSf(FHRw;h+RDVTLzw)oZ;r!M}tJ`omxT@(13?vTwWVuG{$M zbd%f0FOL23+s1otzZr}YbE(H%=83sbuAsa#ZV=`o>qZ4;K=8;Dk-+jk7#rOOL*wq( z9`vKXthtK!?Z^8DS>az|6v3d^^3~rv&T<&C^|11NVS+lacs8GOOSwo&)xr9bkXl#Rx^akN2I|QsKT52j_;WZ_epQuJ{-tRH)({OAfH-Re;b@9B8io=P z#?_?~ca#8s#Sxxo!VMHiv`BP9rNsl$V6Jf}fr0o?XHkC*p??@doILo=4OuUBa!OvO zC@p72lRsY_$LB;Ozhkrjx&ACv>(^&>Jpcw6wRgH(9>y1h4aHjiYEbu-C z{uXdLDd6;PT~4dV#KfJKOty==FaeO{ix*6fnjc1~KAb7A6T*U2p@gB7Ul>E|9WkPa z74W7+u?WQ!F&ibV5fN*paOf^5uA(U6Q=QR~T6lO~IFZe0)G{m|URFAE;DG*DmlXHJ z9V;IE-#HGqGNdeulr&^)YE1mZ>g(KnS2nD?D(1TCiQIyJ4@MCp0C{0+?x=C|9 zszO+pSXR>W7}Tc zxH5eC@!Owxe9ETMyl!u=`}f7#-`q6s)ti=mOVWGdhpJ=lU(RKxMIIxK{<+ z!8RUsvm@Ueb#q)=dEum~02kU6Tp&~fj*53%QEQh`WSAwq2;YP4MAM?(Vbqme2b`P( zob0MBE8mv@n(j)j51VNLrE{C-W41O66Bb{kmON>gCB=>+q||E7qXfKe)F1m%Cmyh^ z_@yUg*l1U!Yqlo~|7Cle4&bYfktHSlRf&;Sm^QrLSP|EaFjB(OY}AZHxVP82uIcTm z!(*~z`_33&*1aGzvN-O#+e=F7X2%srW)^fGHGW3l*lgE`*>B^zpU1DAcwqXX!p82s zKbkNnWzK|;dUtOuymi`v>MH-^0xn|xf`&gQcsazKB3MHf_K512qF4!>xHz&XdK8NX zZ>j}nH#n)_Hg2cImXudgjPwLzHpFVcX0m2Yiqo>2_>J*Z&$7mmn`UTr*r*f!A$R=l z{xUIk9mc?2N8l$n%elxg@wfb3)M+%=SkKn zxM4amT92lz3hn&SsGD(qTFI(Gbt%gf?4TYR3Gt|!RCO<5*XDUQ{g z_=`F5J3G%y*?|Vuz=ygJpfCe>&^_A?)|vRiXP@ov|GWR^f@e)DT90>OGuRRY*!Jwo z@8eHkJp%zV)N2KdkI|sYL@eekU>GItnNp!a0gj@IIiP4V;S9qCi{!E*i#G~zTfm+e zF=1ivlP8$!`nIX*=G$2`%9ndS`ZxOxAHQaq`oj4|sz;2m4rk_?AYWmoF_L1*%5JWq z7|A>{9UblY3cZ~;%ep4^UDLcKFqJ~W7di#Sd+V?k;3=L%ei3k=p#4kl6aR1B%=`Xm2;NwpAa&_ zgb}in}3jDV>lB$%2SIN3PRMtOE)FYSOh zXc1Bw-d-qN@*&b7cBg9P+c)0$_Da5w#jV)9c?JKcc^$VcII(u^i3N?1+`e=JazS98 z6Lqm8K`o(jC?US&U~S}3Bzi=Ft|J0+DBa!(rb~jT#hXJy0vR#SoM*?*$-&PZq@fR@ zMt|F(L92B-p4jomc#2iPiY;bXYH&W&k`XafV>6*vAuZbBz|E}~-4W>k-lKe77%q+1 z7cW&L+{yu=#+X7~)8aKdcC2ALj`-8Bu9!Tz;%Zv_g%3WsKzvOuA22W0*B=Y-39JTa zu_`KV-GfD5uAjsKjY^y9Oe-}dAr3NotTWRSg9+fI1?v(rqm;&pjRF>dc@)$y8bN*L z7|4Ex+$ZKoavxcQI+Xhe%LHe&P&{5+xvvCHbfXYT5!IkkSc`tO)XN`5`%kD{6Hhl) zJUFc2w})BC4O9R7qOs|##Ke7lgBMHzuEItqcs@4J_*>j#KzuBCPa2)KxFZ$tzfP>r z4g8=xQ)7g#3=RcOB<@lcNm^YhQK#HE;BhCZ2ww8qfDt1100JS*Y^25s-CP@2d*iXD z7Of{=a_qC;vBS6DGli<5FC5iwRG@;JI@O&jONqiytx@GVyAZoo zjiwEJc2TV<8Qd-c1W}A6PP4wSGwjhLM;^65_xyoppFKRLynGD4+$^6}$lIdXwX7R| zn!_o;pFhA^63b#%guVZXf5T~grI2X}o|F%_QmzjQImB6Y0YHnND-f0d?U+iUVY!qE zhxK3+Y7P1$u@(>5R95#ZpMVd~<55HcU1kPGPQyi;P{$nDCZUU!z~4|*lv5z?<|Lj1 zHNzr=T%0CQu%MNddUE>5WTxL#7SDRGx~;Ocg?-ud+UNX+{V#C#yOR5KJ$~Iy>PL((Uw-@j$giL?0IC7-32{6yLxKylnY5=u zT^37KYhi&ggjfTqU}nP!>nX-nL6EWoXa!VA`DCD3?Eo)R) zHS#`<{?}B9!^Eecq~p%?hm|pi8JdB+t6`JH27!e-S(IXqnoYvo7|Q)O)#5fYlZn)3 z#v*HDT+v!&WL_l_ER@Kp`d^VmlVg<8BPyUp4;wVlr>oKVc|wndX5w`J-xX=B>9U%1 zmoA3F9F;e(VP1Zup-K1bNgu2{Qaw2`A0OmJwbi6eWmnduwJsWI(qCkxLW1jbi83wa zqBTP2TlGpN3lkB+r1}+*DtD>shYH~q>kS|hLjy#fU|1eSF?FX0I@0|v_eenb%K>ft zdgTvL{B_FTh$AB_60e~9!G@RVynU&z{t-?YGIJF9yaPD!3?k}*2`vrn`EjUS1vs(aQ^6ds`hcp@? zHc7GFsX!8Nq22@Weq69#ta~r@8}1qVPskMvXU{gVoW>;M9)_ew)K$&FdItvNZrqy^ z9%jKo3R5WsQKPi%4P+whQVIPixL_Cl!A1%5J1h@y#$r(MKbG6)(px3^JtUUi_G*cX zSU$meBF0`M_)>u{4^jnkd2!%C210=O4HV}mSd#=bcoM4E;II>mB}lhXh5+ro>-ZT% zA6h9!4bh1N$i@Bw=A_3*k?w*q$>dFOon54*T&m63SDdO|(&ELLs_!bH9Etn^9|_TUK`I!snrurY)e|8-?s@a`=wyurmA zNFy+vx{wYy54yMy_JbC{nC|?97DjPT(RQn}A7He$RsoC;GE2~`WD!8Q&S+N&Yyxp4 zqF7-S5Ouh4S3RBA?1!&UocQ|4-#+-@Z;{IvF65srTd<&NO)?wEa&gB>dCHnK$@~?5 zf`7^zlkYY8TMJXZZ~E+fYnAqg)bpQya0YNExf?a&AqV~iY1LJn3A)Wm-#8_=xUp+e z!=aj+Z%3hBKRf}?Hub~d0lVRqI>MEvD4w+BME(B8NVmr(9T!3kvc?KkHRv-bHtunJNsW8v z<;`oKd|}sSM2!p`KB{^oKgv?Y&oidX2kx4En_OTQvxQX}2EL0_zpA|7fk0}inx9lkOiL!)FV})@qo{~8(NQ);z0Cvk&6jFkah8#>3-ay zFiE-B7uKh@!)~%8B28AJ&&Jst4rpuk*(f^1)LP+Q%ty$rqJ-6=9K5E>C?nEz4S|skM_%9wRXk8FN{>uZCXOCib?u0XA%nYlay-r)Pd86gKpLcgHtALo#u19} zij8%X2ro2rvY8Z92)}f#5ZI(^Mf4D^6?7o83XU~$uDJ0u3_W@AnPd88XZIVE8J|}+ z>n2z6!u_`vyXMU-%ZtmBA7sVlmCc;zD!z69!eZA=v+Rv2X{!egGiym2yeq5IlN%>Z z-_2B9`wq{jw^&8Q-K56k^wsdLP?uo?SEr>IUC4E+cOm>#7J?UrBgZ61Dfg8oLZ(9n z7XBONTb3m5(se20H% z>A%LzD1Ci63xE2(E{@)7*W~lR44FGFaroV{|I93_?`DO()!lQ%#-3dstE}9SQk=a$ zB5c1s`tAou-tTmG{bb#4T7%HPLAwrx25$S-hWWMyeD@WeG3D_h38Gyl9trjnTJ356v-k zR`Qi=d;>2hDjQ-ourbuJf;Z3lTNlZ;=ErFmz5nk zG;zvE=Gfk2Xk>Plr|*cF!_tO0i+qov7*SnScBkaDYRx8GmW(k0Pl|!yg$T0f;HeAs}1pw2#HzxN64-w3~RzaVzCUUnv|{b zX8zEyJ&h&z+^Y5hq-XGl-u@h>a@dFQ{_f)axMKiTQxHCm31FjIM^d>m1qU}a6l$iS z#p=R&jF1??7!fZ+avX&dc%5}Db5HahmU%2``;P5NrcD1fbt=Zo_3u|JT0g~jLEI7R z!4x1Tl1wU)#xTDa;te4U@n1vuhhALT+{_L%6XgM@ATC7E5d1`Zmf(Tc12(~N+W2t5 zuAbGYcFGV~=3uLS=u)+RG<_i`p zR18n9V&ZBbeQe=)FiXGLc2TKs7>FdUfzHqF(BL z>1l}4mySvg;$&1;U+!rbt*U)qrr=hQ{LVRE4+IX>*K(3`5-=%ap;%jY?R2aAnAkm3FG{G)luUnKoDTiT?_F^xXvKSk}vG)-Q?jysUA>f0mG7HdWAW+@cB(g^+YB_>M z2wxK}=q|#|+$2+YovMpZYi?mC;Pf)K_xyPbeEvcUTSj|LI+oUTK|j+kzf-Vo%z1wE>-*n8#YIu?R|as)cVe!q1s|a} zq=26(UtUf3>-|jgoA>X2wRs{<8gG|hygyBxDKB6Q1S4SH9r&35^2_;|+J5v4ex@Jz z>jXCbI%F&`-qy9+;tMVOEa?pg0kRNt)B@awDB`hgq$2 z2Jg+L|8#od)Tt9sgA278Skr4O(KiTr%)?>frt>C2$ zrK(Rn)3kK&{{4IT0xIWq1avcxd``fHEBc(EnqI-@1Pa%EPS}P2|2`+GJt2HfMmdVm z5M0gr^j7-=@4WLs_|~V$=QLv2uo3uj|LJ?kHJNcWPwNfGy)K>CBQMo(ma$;X>*>&;| z`?z`Lz-uz1@-oY2jUC|W+%>*1eS8@xhV$`Lri^EYH{ZYcIUN79T$w2&hg9b`dNYQ1 zE9f+h`Nmy4d?djExJLEIfNL2PpN9x>gf_-8YZ-#VV66-z(GQUp;Ek{c!S7~)_ghuN zrU*|0ZEJxD^~g{%I>V+S{>iAYOE8X<>CkdyO`*tl_~Uu@b(6;cCqvQi%b)%h6@JiV zt6QlKlE{hqBBWE;c`oO#632GzP#l2hzU6Qe$9T+7#sZ`An!Vwg&e(L265+Di~9n_w1gX34A3m&t0yA6m* ztr?P?J)|ZzWdlEFohT~D ztJaHP&t(B?#BFd;^<;MI^;1MQQkq0J1bjkw2JG$;x-)KM3hK^K8p0^V5Kb#x7!w{=DMo35 z&`ULoyi|f(3Pf<3h=}ySXGD%0+@(NXD*P`_4X^VP79SjlCGc;X_wJ1_28-CszhbGF zAI6*`_Mc#OIX_kY75r417h_WPU<@2Z2WKE-@Juj;@PF?L&>?Lwm`2EV6ZkOhVvM^K z@N+6;Ly|tKS)o2EEYB{L&h@U!)#|1WfE!K$nucMQ66KJD>)v zg>?y@?Iz@Z(%FvBgZ2Z&_6PbSXI!^6eL&#CJoSFiL0s@Nl0V6W^c0f@|C+q}s|PHf zP(*4;K85Rm@>A$Crs$|}SA*WWYaz~n8d-P~aiI~E$;WoOSUN6RX1G| z*=ONU+-Mcmr+R8G|EZ+M{TnCO&rTTq2ltcf$IibdGpr~gzvnf>nE9Ytd+Ld6r}a&9 zTodWbDZ45$`L(nZ?1O&)5O6CU8>8FTab{0ap4IK^IHYy^dLQJ_z#z=R5nrJp6#)h+ z_?}FWEF8I`vw>8OTDT)Mhr?4zxs!pv9FS*)Ko19lZwiHaD7k59;N()cQ^z?x&9o z`+KRWjs2ZYJZ)j?19X(NvA-iGL$|+E7vr=;7qJ)e?yTPMeOCI08}|1sq&7fuLI@3< zWfi)k3@E1OIRNg+7-mcmHB&}XiUTb0h>r$UbY)$wz&RFjjTV{Ub<~3e9+CFKg+|^9 zJ;xzF>dez3aJ?EUkCcWh0#^{TgQ^A?U@mSTO= z4P;QZCpehJffJ{xYHYF%PmyuZs$mk6i(mg_~Y-30V9toQf3B#8p z8(eBg-}UPZ>9~2|(JL6zMG_e0^-FuK;nQZysw?@lr=aRx2AkEwPrLYOvO;J}TGs$x z(pF7=HPi^e-_mYIe~$qeG-VugmWhCyjG52$dD_Ix6Xma>LmOs7ikOPiEb$NoDEu9w zB89&@HK&Eb-+STPfB6mU$z$wEe#0AIv-Mvi7=Cvl`;L8A$aeE_h1|tmg?yY^p|^i7G79H~_!c-TalxFZs!I6ys*3QBIm} zyXMCPa#&8Z?p82!+;ea$kZ#6jY8is&oj5bgMxDC+a8+ex)!tb%5C1VHA@}0dx!Bm>Rd`}E?SxlgyGkg|#4-Qgx<-N5g)k7`3xQI_Gr?@LG^cW3>=0?mfl3s{K z(`Y{(B^VT?AX>lyN7|IKR%KwSpf#v$k5BWY;Sz0?4s(zp#w7&e5^kP32{`~Pul&b< zmCl6Px~i|K27G5XuJPO6984~(m zMq#HGxQO5bXYwU%RM@a1Zd48d9WQQ0NcAOTcrz@wA{Yqqa(_yQP{F??AtK-W!aFij zvI%KB&iMVqU%r0&Su0Onydv?%$JoCQbis=9AUwYVn?w1XbA@7;!C^U68x`N1b;FPr zF3{FQ4{)TT;a!3G`KDY;X))J>?n{exqJCkT2PS-Dq%@DXU$?Jq3vwHkbiXc@wnC|~ zsX_sEI}#Z@tAlbVvo;5a7GN`}tR%m(ppvf7mhxDhkOI*ThuZ!j5=4a#Z(58Fe+st2 zEfau4;csXi4_~qL^n!)g?rdNqo!Yg7miX%@RZP2g%S7$RH`_1WzUth|wkCf5mnYuH z^renDsmH=I>*8hC;~pipNAIe-ELX~a)9&B+-j5I8bk3Ex+;w^TmE2e8wN=5rIwA_Q=8z>RS<{(C4edw5# zTS7n##h6dOQ5`srVL=5@Fy>LCeY{w!5cOJmu~x@@a;x@X0zbc@=HU??AL|)gRL`I% z8`a7m=D%;oXVmlIGZh6#s-p{AJ@mJ%9{Nb5qfsD2x0&Ohl`Gn%*U?1>>*xxpgifJi zI-`y*@Eyza1+MWW>*aK28QsI|X|1o^&YmW`#X64g!U7wg3Ga;@&pG_F$#)s&UhsQ6 ze#a3Fz%S*Wx8pO;gyJ*2Ip_ihRq%oHJw{p}PV?s=pNq2rxBvw~xx&>0GXPVKgXQ`R zWSSs|u;6UiydWs<&he+AtE!3&Kui?c$Egl{%5}OX-5kFELUm{ z9H3I~z;y=>0PkRZBhDn^vpFB^eg63-;5~=a0eORr>GOwm=|n&70)n(q@&NzGTbYp@ z;i$McBhQ6Nbe`!$XUM%08l7Uxf1U|IogpAm7%V2ilW2>kbB2sC&V`Fex{5ub4O1=( zY(i)ei93$I+_mRN&2k;DV9c$vYn|}_e$HnjZSwqPF$Ncw!fVZCEYsF_e$K%EN zIy%;gV=+N(h($E2>q8&P>Bd4aq}k$2bohEGF)dApVdpH#iPm~f5G|(50 zn3;n8UDgJM_2(%i?84DOC(h0!tRm=>1cn+wDj4bpbLd_oPhx?`5l!cxded1*Dk^oL zv^cjGVIY0*B`rcjW~5A9KfkA){*Lx%?a$xs_cXHq$ae&$YWALq`}dDwU*kMnyp}d~ zKV%pjmALJ2V8Y_0dFWXItAh}N5Y2#7kkQg*5vl`FAV>v`4Wn>0Oc&z}`lrk`M1Zmb z=VZFjPh5K!jb@vY11aNZ5(h*I`AV)r<)$_?n%lezp+-lPG($l;KnZG5V6-iTkz z+lQ{@r54TtnsgMk)RCWZgkn#k%+uXYa9>()J4UkXi{XLVWfmo1@(%Cmwl_BYr@e zdO(|eK#k{U>v&UrGvTdU3A)#Vm#+}Adf$zPQT!ScZlnvk|J{HlMYHfY97{;9D0(L> zal#l$c-HgfBX0x6VAVTHaCI!v0@bUaTL{4FFqfH%Sv2*w)!ru2!0vw8STW{=9# zBR+6co{HYiNx)d}^o$Qbl&|^cKaczu$bydt4jlQ$WGBfi#aFhQAX9PB^QGeS5IClY z=5PIZLviEZ!HgO_OS(UggK^~pzBYc^213mjHY73t!pW%kc=AEw=0?=Y#YM*nFBEO{ zLe+Xxc<@(#Oqqd$9v*D&H5ywzfd4u&wvI|}m=19(1Cyvh1CWSY_5uE}NMP3#EL≶yHtDj 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 literal 0 HcmV?d00001 diff --git a/assets/fonts/cascadia-mono/LICENSE b/assets/fonts/cascadia-mono/LICENSE new file mode 100644 index 00000000..b7f91076 --- /dev/null +++ b/assets/fonts/cascadia-mono/LICENSE @@ -0,0 +1,94 @@ +Copyright (c) 2019 - Present, Microsoft Corporation, +with Reserved Font Name Cascadia Code. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/chitin-desktop/Cargo.toml b/crates/chitin-desktop/Cargo.toml index 28130cae..f8f18f23 100644 --- a/crates/chitin-desktop/Cargo.toml +++ b/crates/chitin-desktop/Cargo.toml @@ -20,11 +20,12 @@ chitin-wgpu = { path = "../chitin-wgpu" } gpui = { package = "gpui-ce", git = "https://github.com/chitin-dev/gpui-wgpu", rev = "d01b063d0dce9df2be24a53f59201e010712fc01" } tokio = { version = "1.52", features = ["rt-multi-thread", "sync"] } wgpu = { version = "30", default-features = false, features = ["vulkan", "wgsl"] } -rust-embed = { version = "8.12.0", features = ["debug-embed", "interpolate-folder-path"] } +rust-embed = { version = "8.12.0", features = ["debug-embed", "include-exclude", "interpolate-folder-path"] } thiserror = "2.0" [dev-dependencies] bytemuck = "1.24" +chitin-terminal = { path = "../chitin-terminal" } glam = "0.33" rayon = "1.11" diff --git a/crates/chitin-desktop/examples/chitin-wgpu-desktop.rs b/crates/chitin-desktop/examples/chitin-wgpu-desktop.rs index bdf34c88..61a07939 100644 --- a/crates/chitin-desktop/examples/chitin-wgpu-desktop.rs +++ b/crates/chitin-desktop/examples/chitin-wgpu-desktop.rs @@ -22,6 +22,7 @@ use std::{ use chitin_bio::structure::{MmcifParser, PdbParser, StructureScene}; use chitin_desktop::{ app::{ChitinApp, WgpuDocumentView, WgpuDocumentViewFactory}, + fonts::register_terminal_fonts, keybindings::default_key_bindings, wgpu_panel::ChitinWgpuDocumentPanel, }; @@ -194,6 +195,11 @@ fn main() { base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"), }) .run(move |cx: &mut App| { + if let Err(error) = register_terminal_fonts(cx) { + eprintln!("failed to register bundled terminal fonts: {error}"); + cx.quit(); + return; + } cx.bind_keys(default_key_bindings()); let bounds = Bounds::centered(None, size(px(1180.0), px(800.0)), cx); diff --git a/crates/chitin-desktop/examples/terminal-debug.rs b/crates/chitin-desktop/examples/terminal-debug.rs new file mode 100644 index 00000000..92a46145 --- /dev/null +++ b/crates/chitin-desktop/examples/terminal-debug.rs @@ -0,0 +1,612 @@ +#![forbid(unsafe_code)] +//! Native PTY and VT-emulation integration example. +//! +//! Run with `cargo run -p chitin-desktop --example terminal-debug -- [WORKING_DIRECTORY]`. + +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use chitin_desktop::fonts::{TERMINAL_FONT_FAMILY, register_terminal_fonts}; +use chitin_terminal::{ + TerminalCell, TerminalCellAttributes, TerminalColor, TerminalEvent, TerminalNamedColor, TerminalSession, + TerminalSize, TerminalSnapshot, +}; +use gpui::{ + App, AppContext, Application, AsyncApp, Bounds, Context, FocusHandle, Font, FontFallbacks, FontFeatures, FontWeight, + Hsla, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, ParentElement, Pixels, Render, SharedString, + Styled, Task, TextRun, Timer, WeakEntity, Window, WindowBounds, WindowOptions, div, font, prelude::*, px, rgb, size, +}; + +const FONT_SIZE: f32 = 15.0; +const LINE_HEIGHT_MULTIPLIER: f32 = 1.4; +const VIEWPORT_PADDING: f32 = 24.0; +const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(16); + +/// Font-derived geometry shared by layout, painting, and the native PTY. +#[derive(Clone, Copy)] +struct TerminalMetrics { + cell_width: Pixels, + cell_height: Pixels, + font_size: Pixels, +} + +/// Standalone GPUI host for a native terminal session. +struct TerminalDebugView { + session: TerminalSession, + focus: FocusHandle, + size: TerminalSize, + title: String, + status: Option, + _event_task: Task<()>, +} + +impl TerminalDebugView { + /// Spawns a native shell and starts repainting when the VT grid changes. + /// + /// # Parameters + /// + /// * `working_directory` is the initial directory for the system shell. + /// * `size` is the measured initial PTY grid, before the shell emits its prompt. + /// * `cx` creates the focus handle and terminal-event polling task. + /// + /// # Returns + /// + /// A debug view backed by a real pseudo-terminal. + fn new(working_directory: PathBuf, size: TerminalSize, cx: &mut Context) -> Result { + let session = TerminalSession::spawn_default_shell(&working_directory, size).map_err(|error| error.to_string())?; + let event_task = cx.spawn(|this: WeakEntity, async_cx: &mut AsyncApp| { + let mut async_cx = async_cx.clone(); + async move { + loop { + Timer::after(EVENT_POLL_INTERVAL).await; + let should_continue = this + .update(&mut async_cx, |view, cx| { + view.process_events(cx); + true + }) + .unwrap_or(false); + if !should_continue { + break; + } + } + } + }); + + Ok(Self { + session, + focus: cx.focus_handle(), + size, + title: "Chitin native terminal".into(), + status: None, + _event_task: event_task, + }) + } + + /// Applies pending session events and schedules a repaint when necessary. + fn process_events(&mut self, cx: &mut Context) { + let events = self.session.drain_events(); + if events.is_empty() { + return; + } + + for event in events { + match event { + TerminalEvent::Title(title) => self.title = title, + TerminalEvent::Exited(code) => self.status = Some(format!("process exited with code {code}")), + TerminalEvent::Error(error) => self.status = Some(error), + TerminalEvent::Render | TerminalEvent::Bell => {} + } + } + cx.notify(); + } + + /// Resizes the native PTY when the GPUI window's character capacity changes. + fn resize_for_window(&mut self, window: &Window, metrics: TerminalMetrics) { + let next = terminal_size_for_window(window, metrics); + if next == self.size { + return; + } + + if let Err(error) = self.session.resize(next) { + self.status = Some(error.to_string()); + } else { + self.size = next; + } + } + + /// Encodes one GPUI key event and writes it directly to the PTY. + fn handle_key(&mut self, event: &KeyDownEvent, cx: &mut Context) { + let Some(bytes) = encode_key(event) else { + return; + }; + if let Err(error) = self.session.write(&bytes) { + self.status = Some(error.to_string()); + cx.notify(); + } + cx.stop_propagation(); + } +} + +impl Render for TerminalDebugView { + /// Renders the current VT character grid and a small diagnostic status line. + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let metrics = measure_terminal_metrics(window); + self.resize_for_window(window, metrics); + let snapshot = self.session.snapshot(); + let rows = (0..snapshot.rows).map(|row| { + div().flex().h(metrics.cell_height).children( + build_row_runs(&snapshot, row) + .into_iter() + .map(move |run| render_run(run, metrics)), + ) + }); + let focus = self.focus.clone(); + + div() + .flex() + .flex_col() + .size_full() + .overflow_hidden() + .track_focus(&self.focus) + .on_mouse_down(MouseButton::Left, move |_, window, cx| window.focus(&focus, cx)) + .on_key_down(cx.listener(|this, event, _, cx| this.handle_key(event, cx))) + .bg(rgb(0x101216)) + .font(terminal_font()) + .text_size(metrics.font_size) + .line_height(metrics.cell_height) + .child( + div() + .flex() + .items_center() + .h(metrics.cell_height + px(6.0)) + .px_3() + .text_color(rgb(0x8b949e)) + .child(self.status.clone().unwrap_or_else(|| self.title.clone())), + ) + .child(div().flex().flex_col().px_3().children(rows)) + } +} + +/// Measures the bundled terminal font and derives one fixed character grid. +fn measure_terminal_metrics(window: &mut Window) -> TerminalMetrics { + let font_size = px(FONT_SIZE); + let regular_width = measure_terminal_cell_width(window, font_size, FontWeight::NORMAL); + let bold_width = measure_terminal_cell_width(window, font_size, FontWeight::BOLD); + + TerminalMetrics { + cell_width: regular_width.max(bold_width).max(px(1.0)), + cell_height: px((FONT_SIZE * LINE_HEIGHT_MULTIPLIER).round()).max(px(1.0)), + font_size, + } +} + +/// Measures one upright terminal cell at the requested font weight. +fn measure_terminal_cell_width(window: &mut Window, font_size: Pixels, weight: FontWeight) -> Pixels { + let mut terminal_font = terminal_font(); + terminal_font.weight = weight; + window + .text_system() + .shape_line( + SharedString::new_static("M"), + font_size, + &[TextRun { + len: 1, + font: terminal_font, + color: Hsla::default().into(), + ..TextRun::default() + }], + None, + ) + .width +} + +/// Derives the PTY grid from the current drawable window and font metrics. +fn terminal_size_for_window(window: &Window, metrics: TerminalMetrics) -> TerminalSize { + let bounds = window.bounds(); + let cell_width = f32::from(metrics.cell_width); + let cell_height = f32::from(metrics.cell_height); + let width = (f32::from(bounds.size.width) - VIEWPORT_PADDING).max(cell_width); + let height = (f32::from(bounds.size.height) - VIEWPORT_PADDING - cell_height).max(cell_height); + let columns = (width / cell_width).floor().max(1.0) as usize; + let rows = (height / cell_height).floor().max(1.0) as usize; + + TerminalSize::new(columns, rows, cell_width.ceil() as u16, cell_height.ceil() as u16) +} + +/// Disables contextual and standard ligatures so one glyph cannot span several cells. +fn terminal_font_features() -> FontFeatures { + FontFeatures(Arc::new(vec![ + ("calt".into(), 0), + ("liga".into(), 0), + ("clig".into(), 0), + ])) +} + +/// Builds the upright terminal font stack, including symbol and emoji fallbacks. +fn terminal_font() -> Font { + let mut terminal_font = font(TERMINAL_FONT_FAMILY); + terminal_font.features = terminal_font_features(); + terminal_font.fallbacks = Some(FontFallbacks::from_fonts(vec![ + "Symbols Nerd Font".into(), + "Noto Color Emoji".into(), + "Segoe UI Emoji".into(), + "Apple Color Emoji".into(), + "Noto Sans Symbols 2".into(), + ])); + terminal_font +} + +/// A consecutive sequence of cells that share one paint style. +struct TerminalRun { + text: String, + columns: usize, + foreground: TerminalColor, + background: TerminalColor, + attributes: TerminalCellAttributes, + cursor: bool, +} + +impl TerminalRun { + /// Starts a run with the visual state of one terminal cell. + fn new(cell: &TerminalCell, cursor: bool) -> Self { + let mut run = Self { + text: String::new(), + columns: 0, + foreground: cell.foreground, + background: cell.background, + attributes: upright_attributes(cell.attributes), + cursor, + }; + run.push(cell); + run + } + + /// Returns whether another cell can share this run's shaping and paint operation. + fn matches(&self, cell: &TerminalCell, cursor: bool) -> bool { + self.foreground == cell.foreground + && self.background == cell.background + && self.attributes == upright_attributes(cell.attributes) + && self.cursor == cursor + } + + /// Appends one cell while retaining its fixed grid-column budget. + fn push(&mut self, cell: &TerminalCell) { + self.columns += 1; + if cell.attributes.hidden || cell.attributes.wide_spacer { + self.text.push(' '); + return; + } + + self.text.push(cell.character); + self.text.extend(cell.combining_characters.iter()); + } +} + +/// Groups one row into the smallest set of consecutive equal-style text runs. +fn build_row_runs(snapshot: &TerminalSnapshot, row: usize) -> Vec { + let mut runs: Vec = Vec::new(); + let visible_columns = (0..snapshot.columns) + .rfind(|&column| { + snapshot.cursor == Some((row, column)) || snapshot.cell(row, column).is_some_and(|cell| !is_default_blank(cell)) + }) + .map_or(0, |column| column + 1); + + for column in 0..visible_columns { + let Some(cell) = snapshot.cell(row, column) else { + continue; + }; + let cursor = snapshot.cursor == Some((row, column)); + if let Some(run) = runs.last_mut().filter(|run| run.matches(cell, cursor)) { + run.push(cell); + } else { + runs.push(TerminalRun::new(cell, cursor)); + } + } + runs +} + +/// Returns whether a cell is already represented by the viewport background. +fn is_default_blank(cell: &TerminalCell) -> bool { + cell.character == ' ' + && cell.combining_characters.is_empty() + && cell.foreground == TerminalColor::Named(TerminalNamedColor::Foreground) + && cell.background == TerminalColor::Named(TerminalNamedColor::Background) + && upright_attributes(cell.attributes) == TerminalCellAttributes::default() +} + +/// Removes font slant while preserving the remaining terminal text attributes. +fn upright_attributes(mut attributes: TerminalCellAttributes) -> TerminalCellAttributes { + attributes.italic = false; + attributes +} + +/// Renders one equal-style text run using a fixed terminal-column width. +fn render_run(run: TerminalRun, metrics: TerminalMetrics) -> impl IntoElement { + let (mut foreground, mut background) = (resolve_color(run.foreground), resolve_color(run.background)); + if run.attributes.inverse { + std::mem::swap(&mut foreground, &mut background); + } + if run.attributes.dim { + foreground = foreground.opacity(0.62); + } + if run.cursor { + std::mem::swap(&mut foreground, &mut background); + if background == resolve_color(TerminalColor::Named(TerminalNamedColor::Background)) { + background = rgb(0xd0d7de).into(); + } + } + + div() + .flex_none() + .w(metrics.cell_width * run.columns as f32) + .h(metrics.cell_height) + .whitespace_nowrap() + .bg(background) + .text_color(foreground) + .when(run.attributes.bold, |element| element.font_weight(FontWeight::BOLD)) + .when(run.attributes.underline, |element| element.underline()) + .child(run.text) +} + +/// Resolves a VT color against the debug terminal's xterm-compatible palette. +fn resolve_color(color: TerminalColor) -> Hsla { + match color { + TerminalColor::Rgb { red, green, blue } => { + rgb((u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue)).into() + } + TerminalColor::Indexed(index) => indexed_color(index), + TerminalColor::Named(named) => named_color(named), + } +} + +/// Resolves semantic and ANSI color names used by the VT state machine. +fn named_color(color: TerminalNamedColor) -> Hsla { + let value = match color { + TerminalNamedColor::Black | TerminalNamedColor::DimBlack => 0x1f2329, + TerminalNamedColor::Red | TerminalNamedColor::DimRed => 0xff5f56, + TerminalNamedColor::Green | TerminalNamedColor::DimGreen => 0x63d17c, + TerminalNamedColor::Yellow | TerminalNamedColor::DimYellow => 0xe5c07b, + TerminalNamedColor::Blue | TerminalNamedColor::DimBlue => 0x61afef, + TerminalNamedColor::Magenta | TerminalNamedColor::DimMagenta => 0xc678dd, + TerminalNamedColor::Cyan | TerminalNamedColor::DimCyan => 0x56b6c2, + TerminalNamedColor::White | TerminalNamedColor::DimWhite => 0xabb2bf, + TerminalNamedColor::BrightBlack => 0x5c6370, + TerminalNamedColor::BrightRed => 0xff7b72, + TerminalNamedColor::BrightGreen => 0x7ee787, + TerminalNamedColor::BrightYellow => 0xf2cc60, + TerminalNamedColor::BrightBlue => 0x79c0ff, + TerminalNamedColor::BrightMagenta => 0xd2a8ff, + TerminalNamedColor::BrightCyan => 0x76e3ea, + TerminalNamedColor::BrightWhite | TerminalNamedColor::BrightForeground => 0xffffff, + TerminalNamedColor::Foreground => 0xd0d7de, + TerminalNamedColor::Background => 0x101216, + TerminalNamedColor::Cursor => 0xd0d7de, + TerminalNamedColor::DimForeground => 0x8b949e, + }; + rgb(value).into() +} + +/// Converts one xterm-256 palette index into an RGB color. +fn indexed_color(index: u8) -> Hsla { + const ANSI: [u32; 16] = [ + 0x1f2329, 0xff5f56, 0x63d17c, 0xe5c07b, 0x61afef, 0xc678dd, 0x56b6c2, 0xabb2bf, 0x5c6370, 0xff7b72, 0x7ee787, + 0xf2cc60, 0x79c0ff, 0xd2a8ff, 0x76e3ea, 0xffffff, + ]; + let value = match index { + 0..=15 => ANSI[index as usize], + 16..=231 => { + let cube = index - 16; + let component = |value: u8| if value == 0 { 0 } else { 55 + value * 40 }; + let red = component(cube / 36); + let green = component((cube % 36) / 6); + let blue = component(cube % 6); + (u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue) + } + 232..=255 => { + let gray = 8 + (index - 232) * 10; + (u32::from(gray) << 16) | (u32::from(gray) << 8) | u32::from(gray) + } + }; + rgb(value).into() +} + +/// Converts a GPUI keystroke into the byte sequence expected by a terminal application. +fn encode_key(event: &KeyDownEvent) -> Option> { + let key = event.keystroke.key.as_str(); + let modifiers = event.keystroke.modifiers; + let special = match key { + "enter" => Some(b"\r".as_slice()), + "backspace" => Some(b"\x7f".as_slice()), + "tab" if modifiers.shift => Some(b"\x1b[Z".as_slice()), + "tab" => Some(b"\t".as_slice()), + "escape" => Some(b"\x1b".as_slice()), + "up" => Some(b"\x1b[A".as_slice()), + "down" => Some(b"\x1b[B".as_slice()), + "right" => Some(b"\x1b[C".as_slice()), + "left" => Some(b"\x1b[D".as_slice()), + "home" => Some(b"\x1b[H".as_slice()), + "end" => Some(b"\x1b[F".as_slice()), + "pageup" => Some(b"\x1b[5~".as_slice()), + "pagedown" => Some(b"\x1b[6~".as_slice()), + "delete" => Some(b"\x1b[3~".as_slice()), + "insert" => Some(b"\x1b[2~".as_slice()), + _ => None, + }; + if let Some(special) = special { + return Some(special.to_vec()); + } + if modifiers.platform { + return None; + } + + let text = event.keystroke.key_char.as_deref()?; + let mut bytes = if modifiers.control && text.len() == 1 { + let byte = text.as_bytes()[0].to_ascii_uppercase(); + if byte.is_ascii_uppercase() { + vec![byte & 0x1f] + } else { + return None; + } + } else { + text.as_bytes().to_vec() + }; + if modifiers.alt { + bytes.insert(0, 0x1b); + } + Some(bytes) +} + +/// Opens the native terminal integration example. +fn main() { + let working_directory = std::env::args_os() + .nth(1) + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + + Application::new().run(move |cx: &mut App| { + if let Err(error) = register_terminal_fonts(cx) { + eprintln!("failed to register bundled terminal fonts: {error}"); + cx.quit(); + return; + } + let result = cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::centered( + None, + size(px(960.0), px(640.0)), + cx, + ))), + app_id: Some("dev.chitin.TerminalDebug".into()), + ..Default::default() + }, + move |window, cx| { + window.activate_window(); + let metrics = measure_terminal_metrics(window); + let terminal_size = terminal_size_for_window(window, metrics); + cx.new( + |cx| match TerminalDebugView::new(working_directory, terminal_size, cx) { + Ok(view) => view, + Err(error) => panic!("failed to start native terminal example: {error}"), + }, + ) + }, + ); + + if let Err(error) = result { + eprintln!("failed to open native terminal example: {error}"); + cx.quit(); + return; + } + cx.activate(true); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn row_builder_should_merge_equal_style_cells() { + let cell = TerminalCell { + character: 'x', + ..TerminalCell::default() + }; + let snapshot = TerminalSnapshot { + columns: 120, + rows: 1, + cells: vec![cell; 120], + cursor: None, + }; + + let runs = build_row_runs(&snapshot, 0); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].columns, 120); + } + + #[test] + fn row_builder_should_skip_trailing_default_background_cells() { + let mut cells = vec![TerminalCell::default(); 120]; + cells[0].character = 'x'; + let snapshot = TerminalSnapshot { + columns: 120, + rows: 1, + cells, + cursor: None, + }; + + let runs = build_row_runs(&snapshot, 0); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].columns, 1); + } + + #[test] + fn cursor_should_split_only_its_own_cell_from_a_run() { + let cell = TerminalCell { + character: 'x', + ..TerminalCell::default() + }; + let snapshot = TerminalSnapshot { + columns: 120, + rows: 1, + cells: vec![cell; 120], + cursor: Some((0, 60)), + }; + + let runs = build_row_runs(&snapshot, 0); + + assert_eq!(runs.len(), 3); + assert_eq!(runs.iter().map(|run| run.columns).collect::>(), [60, 1, 59]); + } + + #[test] + fn row_builder_should_render_italic_cells_upright() { + let cell = TerminalCell { + character: 'x', + attributes: TerminalCellAttributes { + italic: true, + ..TerminalCellAttributes::default() + }, + ..TerminalCell::default() + }; + let snapshot = TerminalSnapshot { + columns: 1, + rows: 1, + cells: vec![cell], + cursor: None, + }; + + let runs = build_row_runs(&snapshot, 0); + + assert_eq!(runs.len(), 1); + assert!(!runs[0].attributes.italic); + } + + #[test] + fn row_builder_should_merge_upright_and_italic_cells() { + let upright = TerminalCell { + character: 'a', + ..TerminalCell::default() + }; + let italic = TerminalCell { + character: 'b', + attributes: TerminalCellAttributes { + italic: true, + ..TerminalCellAttributes::default() + }, + ..TerminalCell::default() + }; + let snapshot = TerminalSnapshot { + columns: 2, + rows: 1, + cells: vec![upright, italic], + cursor: None, + }; + + let runs = build_row_runs(&snapshot, 0); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].text, "ab"); + } +} diff --git a/crates/chitin-desktop/src/components/terminal/mod.rs b/crates/chitin-desktop/src/components/terminal/mod.rs index 5f02ea4d..478e5a26 100644 --- a/crates/chitin-desktop/src/components/terminal/mod.rs +++ b/crates/chitin-desktop/src/components/terminal/mod.rs @@ -19,7 +19,6 @@ use crate::app::ChitinApp; pub(crate) use render::render_terminal_bottom_dock; pub(crate) const TERMINAL_DOCK_ITEM_ID: &str = "terminal"; -pub(super) const TERMINAL_FONT_FAMILY: &str = "Cascadia Code"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct TerminalShellBinding { diff --git a/crates/chitin-desktop/src/components/terminal/render.rs b/crates/chitin-desktop/src/components/terminal/render.rs index 55a1ff46..011dd461 100644 --- a/crates/chitin-desktop/src/components/terminal/render.rs +++ b/crates/chitin-desktop/src/components/terminal/render.rs @@ -10,8 +10,8 @@ use chitin_ui::{ }; use gpui::{Entity, InteractiveElement, ParentElement, Pixels, Styled, WeakEntity, div}; -use super::{TERMINAL_FONT_FAMILY, TerminalPanelControls}; -use crate::{app::ChitinApp, keybindings::COMMAND_TERMINAL_KEY_CONTEXT}; +use super::TerminalPanelControls; +use crate::{app::ChitinApp, fonts::TERMINAL_FONT_FAMILY, keybindings::COMMAND_TERMINAL_KEY_CONTEXT}; /// Renders the command terminal as the active bottom-dock item. /// diff --git a/crates/chitin-desktop/src/fonts.rs b/crates/chitin-desktop/src/fonts.rs new file mode 100644 index 00000000..31b84db4 --- /dev/null +++ b/crates/chitin-desktop/src/fonts.rs @@ -0,0 +1,24 @@ +use std::borrow::Cow; + +use gpui::{App, Result}; + +/// Font family used by native and built-in terminal surfaces. +/// +/// Cascadia Mono is the official non-ligature variant of Cascadia Code. +pub const TERMINAL_FONT_FAMILY: &str = "Cascadia Mono"; + +/// Registers the bundled terminal font faces with GPUI. +/// +/// # Parameters +/// +/// * `cx` provides the process-wide GPUI text system. +/// +/// # Returns +/// +/// `Ok(())` after the regular and bold upright faces are registered. +pub fn register_terminal_fonts(cx: &mut App) -> Result<()> { + cx.text_system().add_fonts(vec![ + Cow::Borrowed(include_bytes!("../../../assets/fonts/cascadia-mono/CascadiaMono-Regular.ttf").as_slice()), + Cow::Borrowed(include_bytes!("../../../assets/fonts/cascadia-mono/CascadiaMono-Bold.ttf").as_slice()), + ]) +} diff --git a/crates/chitin-desktop/src/lib.rs b/crates/chitin-desktop/src/lib.rs index 4533725f..46b972f4 100644 --- a/crates/chitin-desktop/src/lib.rs +++ b/crates/chitin-desktop/src/lib.rs @@ -7,6 +7,7 @@ pub mod app; pub mod builtin_shell; pub(crate) mod components; +pub mod fonts; pub mod keybindings; pub mod portable_command; pub mod tasks; diff --git a/crates/chitin-desktop/src/main.rs b/crates/chitin-desktop/src/main.rs index 98371dc5..3bb3373c 100644 --- a/crates/chitin-desktop/src/main.rs +++ b/crates/chitin-desktop/src/main.rs @@ -3,7 +3,7 @@ use std::{borrow::Cow, collections::BTreeSet, path::PathBuf}; -use chitin_desktop::{app::ChitinApp, keybindings::default_key_bindings}; +use chitin_desktop::{app::ChitinApp, fonts::register_terminal_fonts, keybindings::default_key_bindings}; use gpui::{ App, AppContext, Application, AssetSource, Bounds, Result, SharedString, WindowBounds, WindowOptions, px, size, }; @@ -12,6 +12,7 @@ use rust_embed::RustEmbed; /// Compile-time asset bundle for the desktop application. #[derive(RustEmbed)] #[folder = "$CARGO_MANIFEST_DIR/../../assets"] +#[exclude = "fonts/**"] struct EmbeddedAssets; /// GPUI asset source backed by the embedded desktop assets. @@ -68,6 +69,11 @@ fn main() { let project_path = std::env::args_os().nth(1).map(PathBuf::from); Application::new().with_assets(DesktopAssets).run(|cx: &mut App| { + if let Err(error) = register_terminal_fonts(cx) { + eprintln!("failed to register bundled terminal fonts: {error}"); + cx.quit(); + return; + } cx.bind_keys(default_key_bindings()); let bounds = Bounds::centered(None, size(px(1100.0), px(760.0)), cx); diff --git a/crates/chitin-terminal/Cargo.toml b/crates/chitin-terminal/Cargo.toml new file mode 100644 index 00000000..c8a09e1a --- /dev/null +++ b/crates/chitin-terminal/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "chitin-terminal" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Frontend-independent PTY sessions and VT terminal state for Chitin" + +[dependencies] +alacritty_terminal = { git = "https://github.com/l0ng-ai/alacritty", rev = "1276f128fbaa8832cbc66210675cbe8aeb570499" } +portable-pty = "0.8" +thiserror = "2.0" + +[lints] +workspace = true diff --git a/crates/chitin-terminal/src/lib.rs b/crates/chitin-terminal/src/lib.rs new file mode 100644 index 00000000..9c7302bf --- /dev/null +++ b/crates/chitin-terminal/src/lib.rs @@ -0,0 +1,10 @@ +#![forbid(unsafe_code)] +//! Frontend-independent PTY sessions and VT terminal state. + +mod session; +mod size; +mod snapshot; + +pub use session::{TerminalEvent, TerminalSession, TerminalSessionError}; +pub use size::TerminalSize; +pub use snapshot::{TerminalCell, TerminalCellAttributes, TerminalColor, TerminalNamedColor, TerminalSnapshot}; diff --git a/crates/chitin-terminal/src/session.rs b/crates/chitin-terminal/src/session.rs new file mode 100644 index 00000000..16cdb9e9 --- /dev/null +++ b/crates/chitin-terminal/src/session.rs @@ -0,0 +1,315 @@ +use std::{ + io::{Read, Write}, + path::Path, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, +}; + +use alacritty_terminal::{ + Term, + event::{Event as AlacrittyEvent, EventListener}, + sync::FairMutex, + term::Config, + vte::ansi, +}; +use portable_pty::{ChildKiller, CommandBuilder, MasterPty, native_pty_system}; +use thiserror::Error; + +use crate::{TerminalSize, TerminalSnapshot}; + +type SharedWriter = Arc>>; +type SharedTerminal = Arc>>; + +/// A state change emitted by a native terminal session. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TerminalEvent { + /// Visible terminal cells changed. + Render, + /// The child changed the terminal title. + Title(String), + /// The terminal emitted an audible or visual bell. + Bell, + /// The child process exited with the supplied exit code. + Exited(u32), + /// A background terminal operation failed. + Error(String), +} + +/// Errors produced while creating or controlling a terminal session. +#[derive(Debug, Error)] +pub enum TerminalSessionError { + #[error("failed to {operation}: {message}")] + Pty { operation: &'static str, message: String }, + #[error("terminal writer lock is poisoned")] + WriterPoisoned, + #[error("failed to write terminal input: {0}")] + Write(#[from] std::io::Error), +} + +/// Owns a native PTY child and its frontend-independent VT terminal state. +pub struct TerminalSession { + terminal: SharedTerminal, + writer: SharedWriter, + master: Box, + killer: Mutex>, + events: Mutex>, + render_pending: Arc, +} + +impl TerminalSession { + /// Spawns the operating system's default interactive shell. + /// + /// # Parameters + /// + /// * `working_directory` is the initial directory for the child shell. + /// * `size` is the initial character-grid and cell-pixel size. + /// + /// # Returns + /// + /// A live PTY session and VT terminal state. + pub fn spawn_default_shell( + working_directory: impl AsRef, + size: TerminalSize, + ) -> Result { + let mut command = CommandBuilder::new_default_prog(); + command.cwd(working_directory.as_ref()); + command.env("TERM", "xterm-256color"); + command.env("COLORTERM", "truecolor"); + Self::spawn(command, size) + } + + /// Spawns a command attached to a native pseudo-terminal. + /// + /// # Parameters + /// + /// * `command` configures the child program, environment, and working directory. + /// * `size` is the initial character-grid and cell-pixel size. + /// + /// # Returns + /// + /// A live PTY session and VT terminal state. + pub fn spawn(command: CommandBuilder, size: TerminalSize) -> Result { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(size.pty_size()) + .map_err(|error| pty_error("open native pseudo-terminal", error))?; + let child = pair + .slave + .spawn_command(command) + .map_err(|error| pty_error("spawn terminal child", error))?; + let killer = child.clone_killer(); + drop(pair.slave); + + let reader = pair + .master + .try_clone_reader() + .map_err(|error| pty_error("clone terminal reader", error))?; + let writer = Arc::new(Mutex::new( + pair + .master + .take_writer() + .map_err(|error| pty_error("take terminal writer", error))?, + )); + let (event_sender, event_receiver) = mpsc::channel(); + let render_pending = Arc::new(AtomicBool::new(false)); + let proxy = TerminalEventProxy { + sender: event_sender.clone(), + writer: Arc::clone(&writer), + render_pending: Arc::clone(&render_pending), + }; + let terminal = Arc::new(FairMutex::new(Term::new(Config::default(), &size, proxy))); + + spawn_reader_thread(&terminal, &event_sender, &render_pending, reader)?; + spawn_wait_thread(&event_sender, child)?; + + Ok(Self { + terminal, + writer, + master: pair.master, + killer: Mutex::new(killer), + events: Mutex::new(event_receiver), + render_pending, + }) + } + + /// Writes encoded keyboard or paste bytes to the terminal child. + pub fn write(&self, bytes: &[u8]) -> Result<(), TerminalSessionError> { + let mut writer = self.writer.lock().map_err(|_| TerminalSessionError::WriterPoisoned)?; + writer.write_all(bytes)?; + writer.flush()?; + Ok(()) + } + + /// Updates both the native PTY and the VT screen dimensions. + pub fn resize(&self, size: TerminalSize) -> Result<(), TerminalSessionError> { + self + .master + .resize(size.pty_size()) + .map_err(|error| pty_error("resize native pseudo-terminal", error))?; + self.terminal.lock().resize(size); + Ok(()) + } + + /// Copies the currently visible terminal grid. + pub fn snapshot(&self) -> TerminalSnapshot { + TerminalSnapshot::from_term(&self.terminal.lock()) + } + + /// Drains terminal events accumulated since the previous call. + pub fn drain_events(&self) -> Vec { + // Clear before draining so a concurrent parser update can enqueue the next + // repaint instead of being hidden behind the event currently in the queue. + self.render_pending.store(false, Ordering::Release); + let Ok(receiver) = self.events.lock() else { + return vec![TerminalEvent::Error("terminal event receiver lock is poisoned".into())]; + }; + receiver.try_iter().collect() + } +} + +impl Drop for TerminalSession { + fn drop(&mut self) { + if let Ok(killer) = self.killer.get_mut() { + let _ = killer.kill(); + } + } +} + +#[derive(Clone)] +struct TerminalEventProxy { + sender: mpsc::Sender, + writer: SharedWriter, + render_pending: Arc, +} + +impl EventListener for TerminalEventProxy { + fn send_event(&self, event: AlacrittyEvent) { + match event { + AlacrittyEvent::Wakeup | AlacrittyEvent::MouseCursorDirty => { + send_render_event(&self.sender, &self.render_pending); + } + AlacrittyEvent::Title(title) => { + let _ = self.sender.send(TerminalEvent::Title(title)); + } + AlacrittyEvent::Bell => { + let _ = self.sender.send(TerminalEvent::Bell); + } + AlacrittyEvent::PtyWrite(text) => match self.writer.lock() { + Ok(mut writer) => { + if let Err(error) = writer.write_all(text.as_bytes()).and_then(|()| writer.flush()) { + let _ = self.sender.send(TerminalEvent::Error(error.to_string())); + } + } + Err(_) => { + let _ = self + .sender + .send(TerminalEvent::Error("terminal writer lock is poisoned".into())); + } + }, + _ => {} + } + } +} + +/// Starts the blocking PTY reader without holding the terminal lock while waiting for bytes. +fn spawn_reader_thread( + terminal: &SharedTerminal, + sender: &mpsc::Sender, + render_pending: &Arc, + mut reader: Box, +) -> Result<(), TerminalSessionError> { + let terminal = Arc::clone(terminal); + let sender = sender.clone(); + let render_pending = Arc::clone(render_pending); + thread::Builder::new() + .name("chitin-terminal-reader".into()) + .spawn(move || { + let mut processor = ansi::Processor::::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(read) => { + processor.advance(&mut *terminal.lock(), &buffer[..read]); + send_render_event(&sender, &render_pending); + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) => { + let _ = sender.send(TerminalEvent::Error(error.to_string())); + break; + } + } + } + }) + .map(|_| ()) + .map_err(TerminalSessionError::Write) +} + +/// Coalesces repeated parser wakeups into at most one queued repaint request. +fn send_render_event(sender: &mpsc::Sender, pending: &AtomicBool) { + if !pending.swap(true, Ordering::AcqRel) { + let _ = sender.send(TerminalEvent::Render); + } +} + +/// Starts a waiter that reports the child exit code without blocking a UI executor. +fn spawn_wait_thread( + sender: &mpsc::Sender, + mut child: Box, +) -> Result<(), TerminalSessionError> { + let sender = sender.clone(); + thread::Builder::new() + .name("chitin-terminal-wait".into()) + .spawn(move || match child.wait() { + Ok(status) => { + let _ = sender.send(TerminalEvent::Exited(status.exit_code())); + } + Err(error) => { + let _ = sender.send(TerminalEvent::Error(error.to_string())); + } + }) + .map(|_| ()) + .map_err(TerminalSessionError::Write) +} + +fn pty_error(operation: &'static str, error: impl std::fmt::Display) -> TerminalSessionError { + TerminalSessionError::Pty { + operation, + message: error.to_string(), + } +} + +#[cfg(all(test, unix))] +mod tests { + use std::{thread, time::Duration}; + + use super::*; + + #[test] + fn session_should_parse_output_from_a_native_pty() { + let mut command = CommandBuilder::new("sh"); + command.args(["-c", "printf '\\033[32mnative-pty\\033[0m'"]); + let Ok(session) = TerminalSession::spawn(command, TerminalSize::new(40, 4, 8, 16)) else { + panic!("native PTY should be available during a Unix test"); + }; + + let mut visible_text = String::new(); + for _ in 0..100 { + visible_text = session.snapshot().cells.iter().map(|cell| cell.character).collect(); + if visible_text.contains("native-pty") { + break; + } + thread::sleep(Duration::from_millis(10)); + } + + assert!(visible_text.contains("native-pty")); + assert_eq!( + session.snapshot().cell(0, 0).map(|cell| cell.foreground), + Some(crate::TerminalColor::Named(crate::TerminalNamedColor::Green)), + ); + } +} diff --git a/crates/chitin-terminal/src/size.rs b/crates/chitin-terminal/src/size.rs new file mode 100644 index 00000000..8c54a502 --- /dev/null +++ b/crates/chitin-terminal/src/size.rs @@ -0,0 +1,59 @@ +use alacritty_terminal::grid::Dimensions; + +/// Character-grid and pixel dimensions reported to a terminal process. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TerminalSize { + /// Number of visible character columns. + pub columns: usize, + /// Number of visible character rows. + pub rows: usize, + /// Width of one terminal cell in device pixels. + pub cell_width: u16, + /// Height of one terminal cell in device pixels. + pub cell_height: u16, +} + +impl TerminalSize { + /// Creates a terminal size with explicit grid and cell dimensions. + pub const fn new(columns: usize, rows: usize, cell_width: u16, cell_height: u16) -> Self { + Self { + columns, + rows, + cell_width, + cell_height, + } + } + + /// Converts this size into the PTY representation. + pub(crate) fn pty_size(self) -> portable_pty::PtySize { + let rows = self.rows.min(u16::MAX as usize) as u16; + let columns = self.columns.min(u16::MAX as usize) as u16; + + portable_pty::PtySize { + rows, + cols: columns, + pixel_width: self.cell_width.saturating_mul(columns), + pixel_height: self.cell_height.saturating_mul(rows), + } + } +} + +impl Dimensions for TerminalSize { + fn total_lines(&self) -> usize { + self.rows + } + + fn screen_lines(&self) -> usize { + self.rows + } + + fn columns(&self) -> usize { + self.columns + } +} + +impl Default for TerminalSize { + fn default() -> Self { + Self::new(80, 24, 8, 16) + } +} diff --git a/crates/chitin-terminal/src/snapshot.rs b/crates/chitin-terminal/src/snapshot.rs new file mode 100644 index 00000000..b6c49e8e --- /dev/null +++ b/crates/chitin-terminal/src/snapshot.rs @@ -0,0 +1,271 @@ +use alacritty_terminal::{ + Term, + event::EventListener, + grid::Dimensions, + term::cell::Flags, + vte::ansi::{Color, NamedColor}, +}; + +/// A frontend-neutral copy of the visible terminal grid. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalSnapshot { + /// Number of visible columns. + pub columns: usize, + /// Number of visible rows. + pub rows: usize, + /// Visible cells stored in row-major order. + pub cells: Vec, + /// Visible cursor location, when the terminal requests a cursor. + pub cursor: Option<(usize, usize)>, +} + +impl TerminalSnapshot { + /// Copies the visible grid from an Alacritty terminal state machine. + pub(crate) fn from_term(term: &Term) -> Self { + let content = term.renderable_content(); + let columns = term.columns(); + let rows = term.screen_lines(); + let cursor = (!matches!(content.cursor.shape, alacritty_terminal::vte::ansi::CursorShape::Hidden)).then(|| { + ( + content.cursor.point.line.0.max(0) as usize, + content.cursor.point.column.0, + ) + }); + let mut cells = vec![TerminalCell::default(); rows.saturating_mul(columns)]; + + for indexed in content.display_iter { + let row = indexed.point.line.0.max(0) as usize; + let column = indexed.point.column.0; + let Some(cell) = cells.get_mut(row.saturating_mul(columns).saturating_add(column)) else { + continue; + }; + + *cell = TerminalCell { + character: indexed.cell.c, + combining_characters: indexed.cell.zerowidth().unwrap_or_default().to_vec(), + foreground: indexed.cell.fg.into(), + background: indexed.cell.bg.into(), + attributes: TerminalCellAttributes { + bold: indexed.cell.flags.intersects(Flags::BOLD | Flags::DIM_BOLD), + dim: indexed.cell.flags.intersects(Flags::DIM | Flags::DIM_BOLD), + italic: indexed.cell.flags.intersects(Flags::ITALIC | Flags::BOLD_ITALIC), + underline: indexed.cell.flags.intersects(Flags::ALL_UNDERLINES), + inverse: indexed.cell.flags.contains(Flags::INVERSE), + hidden: indexed.cell.flags.contains(Flags::HIDDEN), + wide_spacer: indexed + .cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER), + }, + }; + } + + Self { + columns, + rows, + cells, + cursor, + } + } + + /// Returns the cell at a visible grid coordinate. + pub fn cell(&self, row: usize, column: usize) -> Option<&TerminalCell> { + if row >= self.rows || column >= self.columns { + return None; + } + + self.cells.get(row * self.columns + column) + } +} + +/// Character and visual attributes for one terminal grid cell. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalCell { + /// Unicode scalar displayed in the cell. + pub character: char, + /// Zero-width scalars that complete the cell's displayed grapheme. + pub combining_characters: Vec, + /// Requested foreground color. + pub foreground: TerminalColor, + /// Requested background color. + pub background: TerminalColor, + /// Additional text attributes. + pub attributes: TerminalCellAttributes, +} + +impl Default for TerminalCell { + fn default() -> Self { + Self { + character: ' ', + combining_characters: Vec::new(), + foreground: TerminalColor::Named(TerminalNamedColor::Foreground), + background: TerminalColor::Named(TerminalNamedColor::Background), + attributes: TerminalCellAttributes::default(), + } + } +} + +/// Font and compositing attributes attached to one terminal cell. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TerminalCellAttributes { + pub bold: bool, + pub dim: bool, + pub italic: bool, + pub underline: bool, + pub inverse: bool, + pub hidden: bool, + pub wide_spacer: bool, +} + +/// A terminal color before it is resolved against the UI palette. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalColor { + Named(TerminalNamedColor), + Indexed(u8), + Rgb { red: u8, green: u8, blue: u8 }, +} + +impl From for TerminalColor { + fn from(value: Color) -> Self { + match value { + Color::Named(color) => Self::Named(color.into()), + Color::Indexed(index) => Self::Indexed(index), + Color::Spec(color) => Self::Rgb { + red: color.r, + green: color.g, + blue: color.b, + }, + } + } +} + +/// Semantic and ANSI named colors understood by the VT state machine. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalNamedColor { + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + White, + BrightBlack, + BrightRed, + BrightGreen, + BrightYellow, + BrightBlue, + BrightMagenta, + BrightCyan, + BrightWhite, + Foreground, + Background, + Cursor, + DimBlack, + DimRed, + DimGreen, + DimYellow, + DimBlue, + DimMagenta, + DimCyan, + DimWhite, + BrightForeground, + DimForeground, +} + +impl From for TerminalNamedColor { + fn from(value: NamedColor) -> Self { + match value { + NamedColor::Black => Self::Black, + NamedColor::Red => Self::Red, + NamedColor::Green => Self::Green, + NamedColor::Yellow => Self::Yellow, + NamedColor::Blue => Self::Blue, + NamedColor::Magenta => Self::Magenta, + NamedColor::Cyan => Self::Cyan, + NamedColor::White => Self::White, + NamedColor::BrightBlack => Self::BrightBlack, + NamedColor::BrightRed => Self::BrightRed, + NamedColor::BrightGreen => Self::BrightGreen, + NamedColor::BrightYellow => Self::BrightYellow, + NamedColor::BrightBlue => Self::BrightBlue, + NamedColor::BrightMagenta => Self::BrightMagenta, + NamedColor::BrightCyan => Self::BrightCyan, + NamedColor::BrightWhite => Self::BrightWhite, + NamedColor::Foreground => Self::Foreground, + NamedColor::Background => Self::Background, + NamedColor::Cursor => Self::Cursor, + NamedColor::DimBlack => Self::DimBlack, + NamedColor::DimRed => Self::DimRed, + NamedColor::DimGreen => Self::DimGreen, + NamedColor::DimYellow => Self::DimYellow, + NamedColor::DimBlue => Self::DimBlue, + NamedColor::DimMagenta => Self::DimMagenta, + NamedColor::DimCyan => Self::DimCyan, + NamedColor::DimWhite => Self::DimWhite, + NamedColor::BrightForeground => Self::BrightForeground, + NamedColor::DimForeground => Self::DimForeground, + } + } +} + +#[cfg(test)] +mod tests { + use alacritty_terminal::{ + Term, + event::VoidListener, + term::Config, + vte::ansi::{Processor, StdSyncHandler}, + }; + + use super::*; + use crate::TerminalSize; + + #[test] + fn snapshot_should_preserve_visible_text_and_sgr_attributes() { + let size = TerminalSize::new(12, 3, 8, 16); + let mut term = Term::new(Config::default(), &size, VoidListener); + let mut processor = Processor::::new(); + + processor.advance(&mut term, b"plain \x1b[1;31mbold\x1b[0m"); + let snapshot = TerminalSnapshot::from_term(&term); + + assert_eq!(snapshot.cell(0, 0).map(|cell| cell.character), Some('p')); + assert_eq!(snapshot.cell(0, 6).map(|cell| cell.character), Some('b')); + assert_eq!(snapshot.cell(0, 6).map(|cell| cell.attributes.bold), Some(true)); + assert_eq!( + snapshot.cell(0, 6).map(|cell| cell.foreground), + Some(TerminalColor::Named(TerminalNamedColor::Red)), + ); + } + + #[test] + fn snapshot_should_preserve_emoji_variation_selectors() { + let size = TerminalSize::new(12, 3, 8, 16); + let mut term = Term::new(Config::default(), &size, VoidListener); + let mut processor = Processor::::new(); + + processor.advance(&mut term, "☀️".as_bytes()); + let snapshot = TerminalSnapshot::from_term(&term); + + assert_eq!(snapshot.cell(0, 0).map(|cell| cell.character), Some('☀')); + assert_eq!( + snapshot.cell(0, 0).map(|cell| cell.combining_characters.as_slice()), + Some(&['\u{fe0f}'][..]) + ); + } + + #[test] + fn cell_should_reject_coordinates_outside_the_visible_grid() { + let snapshot = TerminalSnapshot { + columns: 2, + rows: 1, + cells: vec![TerminalCell::default(); 2], + cursor: None, + }; + + assert!(snapshot.cell(0, 1).is_some()); + assert!(snapshot.cell(1, 0).is_none()); + assert!(snapshot.cell(0, 2).is_none()); + } +} diff --git a/justfile b/justfile index c19e7693..15476e14 100644 --- a/justfile +++ b/justfile @@ -47,6 +47,10 @@ generate-mmcif-schema: desktop path=".": cargo run -p chitin-desktop -- "{{path}}" +# Launch the native PTY and VT-emulation integration example. +terminal-debug path=".": + cargo run -p chitin-desktop --example terminal-debug -- "{{path}}" + # Rebuild the browser WASM package before starting Vite's development server. browser-dev: pnpm --dir browser wasm:build From 7e4e1930f7043aa10a863e45074662c5a7a732fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 28 Sep 2026 20:12:17 +0800 Subject: [PATCH 18/32] refactor(terminal): unify built-in and system shell profiles Move terminal transcript models, styled lines, command blocks, and profile metadata into chitin-terminal. Adapt the GPUI command terminal to use the shared frontend-independent buffer, and identify built-in and PTY-backed system sessions through explicit terminal profiles. --- Cargo.lock | 2 + crates/chitin-builtin-shell/Cargo.toml | 1 + crates/chitin-builtin-shell/src/lib.rs | 6 +- crates/chitin-builtin-shell/src/terminal.rs | 307 ++++++++++++ crates/chitin-desktop/Cargo.toml | 2 +- crates/chitin-desktop/src/app.rs | 17 +- .../src/components/terminal/controller.rs | 313 ++++++------ .../src/components/terminal/mod.rs | 94 ++-- .../src/components/terminal/presenter.rs | 83 ++-- .../src/components/terminal/render.rs | 17 +- crates/chitin-terminal/src/lib.rs | 11 +- crates/chitin-terminal/src/model.rs | 340 +++++++++++++ crates/chitin-terminal/src/session.rs | 231 ++++++++- crates/chitin-ui/Cargo.toml | 1 + .../src/composite/command_terminal/model.rs | 228 ++------- .../src/composite/command_terminal/render.rs | 15 +- .../src/primitive/terminal/emulator.rs | 465 ++++++++++++++++++ .../chitin-ui/src/primitive/terminal/mod.rs | 2 + .../chitin-ui/src/primitive/terminal/model.rs | 181 +------ .../src/primitive/terminal/render.rs | 15 +- 20 files changed, 1694 insertions(+), 637 deletions(-) create mode 100644 crates/chitin-builtin-shell/src/terminal.rs create mode 100644 crates/chitin-terminal/src/model.rs create mode 100644 crates/chitin-ui/src/primitive/terminal/emulator.rs diff --git a/Cargo.lock b/Cargo.lock index bb4716c1..5328bfb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -755,6 +755,7 @@ version = "0.1.0" dependencies = [ "chitin-command", "chitin-databases", + "chitin-terminal", "clap", "thiserror 2.0.20", "tokio", @@ -850,6 +851,7 @@ dependencies = [ name = "chitin-ui" version = "0.1.0" dependencies = [ + "chitin-terminal", "criterion", "env_logger", "gpui-ce", diff --git a/crates/chitin-builtin-shell/Cargo.toml b/crates/chitin-builtin-shell/Cargo.toml index 0ca847ae..0af70fcd 100644 --- a/crates/chitin-builtin-shell/Cargo.toml +++ b/crates/chitin-builtin-shell/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [dependencies] chitin-command = { path = "../chitin-command" } chitin-databases = { path = "../chitin-databases" } +chitin-terminal = { path = "../chitin-terminal" } clap = { version = "4.6", features = ["derive"] } thiserror = "2.0" diff --git a/crates/chitin-builtin-shell/src/lib.rs b/crates/chitin-builtin-shell/src/lib.rs index ad6f01e7..58466cfe 100644 --- a/crates/chitin-builtin-shell/src/lib.rs +++ b/crates/chitin-builtin-shell/src/lib.rs @@ -1,14 +1,15 @@ #![forbid(unsafe_code)] //! Session state and execution routing for Chitin's built-in command shell. //! -//! This crate is independent of GPUI and terminal emulation. It parses one -//! command line at a time, retains navigation and execution history, forwards +//! This crate is independent of GPUI. It parses one command line at a time, +//! adapts terminal byte streams, retains navigation and execution history, forwards //! portable commands to `chitin-command::execution`, and returns frontend commands //! to the host application for execution. mod event; mod grammar; mod session; +mod terminal; pub use event::{ShellEvent, ShellEventSink}; pub use grammar::{ @@ -20,3 +21,4 @@ pub use session::{ ShellCommandTarget, ShellExecutionRecord, ShellExecutionResult, ShellExecutionStatus, ShellInvocationSource, ShellLineSubmission, ShellSubmission, ShellTranscriptContent, ShellTranscriptEntry, }; +pub use terminal::{BuiltinTerminalEvent, BuiltinTerminalProgram}; diff --git a/crates/chitin-builtin-shell/src/terminal.rs b/crates/chitin-builtin-shell/src/terminal.rs new file mode 100644 index 00000000..de53bdf2 --- /dev/null +++ b/crates/chitin-builtin-shell/src/terminal.rs @@ -0,0 +1,307 @@ +//! Terminal byte-stream adapter for Chitin's in-process shell. + +use chitin_terminal::{ + TerminalProfile, TerminalProgram, TerminalProgramInput, TerminalSession, TerminalSessionError, TerminalSize, +}; + +/// Semantic input produced by the built-in terminal's line discipline. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BuiltinTerminalEvent { + /// A complete command line was submitted with Enter. + Submit(String), + /// The active line or command was interrupted with Ctrl+C. + Interrupt, + /// The user requested completion with Tab. + Complete(String), + /// The user requested an older history entry. + PreviousHistory, + /// The user requested a newer history entry. + NextHistory, + /// The terminal grid changed size. + Resize(TerminalSize), + /// The terminal frontend disconnected. + Shutdown, +} + +/// Adapts terminal input bytes into shell lines and writes ANSI output back. +/// +/// This type performs the same line-discipline role that an interactive Bash +/// or Fish process performs behind a PTY. Command parsing and execution remain +/// owned by [`BuiltinShell`](crate::BuiltinShell) and its desktop host. +pub struct BuiltinTerminalProgram { + terminal: TerminalProgram, + prompt: String, + line: String, + waiting_for_command: bool, + running_output_lines: usize, + escape: Vec, +} + +impl BuiltinTerminalProgram { + /// Creates an in-process terminal session and displays its first prompt. + /// + /// # Parameters + /// + /// * `size` is the initial terminal grid and cell geometry. + /// * `prompt` is ANSI-capable text displayed before each editable line. + /// + /// # Returns + /// + /// The frontend session and its built-in shell program endpoint. + pub fn connect( + size: TerminalSize, + prompt: impl Into, + ) -> Result<(TerminalSession, Self), TerminalSessionError> { + let (session, terminal) = TerminalSession::in_process(TerminalProfile::BuiltinShell, size); + let program = Self { + terminal, + prompt: prompt.into(), + line: String::new(), + waiting_for_command: false, + running_output_lines: 0, + escape: Vec::new(), + }; + program.write_prompt()?; + Ok((session, program)) + } + + /// Processes pending emulator input into semantic shell events. + pub fn poll(&mut self) -> Result, TerminalSessionError> { + let mut events = Vec::new(); + for input in self.terminal.drain_input()? { + match input { + TerminalProgramInput::Bytes(bytes) => self.process_bytes(&bytes, &mut events)?, + TerminalProgramInput::Resize(size) => events.push(BuiltinTerminalEvent::Resize(size)), + TerminalProgramInput::Shutdown => events.push(BuiltinTerminalEvent::Shutdown), + } + } + Ok(events) + } + + /// Writes command output and restores an editable prompt. + pub fn finish_command(&mut self, output: &str) -> Result<(), TerminalSessionError> { + self.clear_running_output()?; + if !output.is_empty() { + self.write_normalized_lines(output)?; + } + self.line.clear(); + self.waiting_for_command = false; + self.write_prompt() + } + + /// Replaces transient output for the command currently holding the prompt. + pub fn update_command_output(&mut self, output: &str) -> Result<(), TerminalSessionError> { + self.clear_running_output()?; + if output.is_empty() { + return Ok(()); + } + let normalized = output.replace("\r\n", "\n").replace('\n', "\r\n"); + self.terminal.output().write(normalized.as_bytes())?; + self.running_output_lines = normalized.lines().count().max(1); + Ok(()) + } + + /// Replaces the editable line, as used by history and completion. + pub fn replace_line(&mut self, line: impl Into) -> Result<(), TerminalSessionError> { + self.line = line.into(); + self.redraw_line() + } + + /// Clears the VT screen and displays a fresh prompt. + pub fn clear_screen(&mut self) -> Result<(), TerminalSessionError> { + self.line.clear(); + self.waiting_for_command = false; + self.terminal.output().write(b"\x1b[2J\x1b[H")?; + self.write_prompt() + } + + /// Displays non-command output above the current editable line. + pub fn show_notice(&self, output: &str) -> Result<(), TerminalSessionError> { + self.terminal.output().write(b"\r\x1b[2K")?; + self.write_normalized_lines(output)?; + self.redraw_line() + } + + /// Returns the line currently owned by the in-process line discipline. + pub fn line(&self) -> &str { + &self.line + } + + fn process_bytes( + &mut self, + bytes: &[u8], + events: &mut Vec, + ) -> Result<(), TerminalSessionError> { + let mut printable = Vec::new(); + for &byte in bytes { + if !self.escape.is_empty() || byte == 0x1b { + self.flush_printable(&mut printable)?; + self.process_escape_byte(byte, events); + continue; + } + match byte { + b'\r' | b'\n' if !self.waiting_for_command => { + self.flush_printable(&mut printable)?; + self.terminal.output().write(b"\r\n")?; + self.waiting_for_command = true; + self.running_output_lines = 0; + events.push(BuiltinTerminalEvent::Submit(self.line.clone())); + } + 0x03 => { + self.flush_printable(&mut printable)?; + self.terminal.output().write(b"^C\r\n")?; + events.push(BuiltinTerminalEvent::Interrupt); + if !self.waiting_for_command { + self.line.clear(); + self.write_prompt()?; + } + } + 0x7f | 0x08 if !self.waiting_for_command => { + self.flush_printable(&mut printable)?; + self.line.pop(); + self.redraw_line()?; + } + b'\t' if !self.waiting_for_command => { + self.flush_printable(&mut printable)?; + events.push(BuiltinTerminalEvent::Complete(self.line.clone())); + } + 0x20..=0x7e if !self.waiting_for_command => printable.push(byte), + 0x80..=0xff if !self.waiting_for_command => printable.push(byte), + _ => {} + } + } + self.flush_printable(&mut printable) + } + + fn process_escape_byte(&mut self, byte: u8, events: &mut Vec) { + self.escape.push(byte); + match self.escape.as_slice() { + b"\x1b[A" => { + events.push(BuiltinTerminalEvent::PreviousHistory); + self.escape.clear(); + } + b"\x1b[B" => { + events.push(BuiltinTerminalEvent::NextHistory); + self.escape.clear(); + } + sequence if sequence.len() >= 3 || (sequence.len() == 2 && sequence[1] != b'[') => { + self.escape.clear(); + } + _ => {} + } + } + + fn flush_printable(&mut self, bytes: &mut Vec) -> Result<(), TerminalSessionError> { + if bytes.is_empty() { + return Ok(()); + } + let text = String::from_utf8_lossy(bytes); + self.line.push_str(&text); + self.terminal.output().write(text.as_bytes())?; + bytes.clear(); + Ok(()) + } + + fn redraw_line(&self) -> Result<(), TerminalSessionError> { + let redraw = format!("\r\x1b[2K{}{}", self.prompt, self.line); + self.terminal.output().write(redraw.as_bytes()) + } + + fn write_prompt(&self) -> Result<(), TerminalSessionError> { + self.terminal.output().write(self.prompt.as_bytes()) + } + + fn write_normalized_lines(&self, output: &str) -> Result<(), TerminalSessionError> { + let normalized = output.replace("\r\n", "\n").replace('\n', "\r\n"); + self.terminal.output().write(normalized.as_bytes())?; + if !normalized.ends_with("\r\n") { + self.terminal.output().write(b"\r\n")?; + } + Ok(()) + } + + fn clear_running_output(&mut self) -> Result<(), TerminalSessionError> { + if self.running_output_lines == 0 { + return Ok(()); + } + let mut clear = String::from("\r\x1b[2K"); + for _ in 1..self.running_output_lines { + clear.push_str("\x1b[1A\r\x1b[2K"); + } + self.terminal.output().write(clear.as_bytes())?; + self.running_output_lines = 0; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn connected_program() -> (TerminalSession, BuiltinTerminalProgram) { + let result = BuiltinTerminalProgram::connect(TerminalSize::new(80, 24, 8, 16), "chitin ❯ "); + let Ok(connected) = result else { + panic!("in-process terminal should connect"); + }; + connected + } + + #[test] + fn enter_should_submit_the_line_received_as_terminal_bytes() { + let (session, mut program) = connected_program(); + assert!(session.write(b"structure validate file.cif\r").is_ok()); + + let events = program.poll(); + + assert_eq!( + events.ok(), + Some(vec![BuiltinTerminalEvent::Submit("structure validate file.cif".into())]), + ); + } + + #[test] + fn backspace_should_edit_and_redraw_the_terminal_line() { + let (session, mut program) = connected_program(); + assert!(session.write(b"hellp\x7fo").is_ok()); + + let poll_result = program.poll(); + + assert!(poll_result.is_ok()); + assert_eq!(program.line(), "hello"); + } + + #[test] + fn finish_should_write_output_through_the_shared_vt_emulator() { + let (session, mut program) = connected_program(); + + let finish_result = program.finish_command("command completed"); + let text = session + .snapshot() + .cells + .iter() + .map(|cell| cell.character) + .collect::(); + + assert!(finish_result.is_ok()); + assert!(text.contains("command completed")); + } + + #[test] + fn running_output_should_be_replaced_before_final_output() { + let (session, mut program) = connected_program(); + assert!(program.update_command_output("Downloading 1 / 2").is_ok()); + assert!(program.update_command_output("Downloading 2 / 2").is_ok()); + + let finish_result = program.finish_command("Download complete"); + let text = session + .snapshot() + .cells + .iter() + .map(|cell| cell.character) + .collect::(); + + assert!(finish_result.is_ok()); + assert!(text.contains("Download complete")); + assert!(!text.contains("Downloading 1 / 2")); + } +} diff --git a/crates/chitin-desktop/Cargo.toml b/crates/chitin-desktop/Cargo.toml index f8f18f23..ece1fa67 100644 --- a/crates/chitin-desktop/Cargo.toml +++ b/crates/chitin-desktop/Cargo.toml @@ -14,6 +14,7 @@ env_logger = "0.10" futures-util = "0.3" chitin-databases = { path = "../chitin-databases" } chitin-molecule-renderer = { path = "../chitin-molecule-renderer" } +chitin-terminal = { path = "../chitin-terminal" } chitin-utils = { path = "../chitin-utils" } chitin-ui = { path = "../chitin-ui" } chitin-wgpu = { path = "../chitin-wgpu" } @@ -25,7 +26,6 @@ thiserror = "2.0" [dev-dependencies] bytemuck = "1.24" -chitin-terminal = { path = "../chitin-terminal" } glam = "0.33" rayon = "1.11" diff --git a/crates/chitin-desktop/src/app.rs b/crates/chitin-desktop/src/app.rs index 33803a73..d52818b4 100644 --- a/crates/chitin-desktop/src/app.rs +++ b/crates/chitin-desktop/src/app.rs @@ -483,19 +483,20 @@ impl Render for ChitinApp { let toast_viewport = self.toast_viewport(cx); let command_panel_search_input = self.command_panel_search_input(window, cx); self.command_panel_rcsb_form(window, cx); - let terminal_dock_controls = self.bottom_dock.is_active(TERMINAL_DOCK_ITEM_ID).then(|| { - ( - self.terminal_panel_controls(window, cx), - self.bottom_dock_controls(window, cx), - ) - }); + let terminal_dock_controls = if self.bottom_dock.is_active(TERMINAL_DOCK_ITEM_ID) { + self + .terminal_panel_controls(window, cx) + .map(|controls| (controls, self.bottom_dock_controls(window, cx))) + } else { + None + }; if terminal_dock_controls.is_some() { - self.sync_terminal_output(cx); + self.sync_terminal_output(); } if self.terminal_panel.take_focus_request() && let Some((controls, _)) = terminal_dock_controls.as_ref() { - let focus = controls.input(cx).read(cx).focus_handle().clone(); + let focus = controls.focus(cx); window.focus(&focus, cx); } let app = cx.weak_entity(); diff --git a/crates/chitin-desktop/src/components/terminal/controller.rs b/crates/chitin-desktop/src/components/terminal/controller.rs index 0c1b1d96..430e9181 100644 --- a/crates/chitin-desktop/src/components/terminal/controller.rs +++ b/crates/chitin-desktop/src/components/terminal/controller.rs @@ -1,94 +1,125 @@ -//! Keyboard and command-execution control for the desktop terminal. +//! Built-in shell routing for the VT terminal backend. -use chitin_builtin_shell::{BuiltinShell, ShellBuiltinEffect, ShellInvocationSource}; -use chitin_ui::{composite::command_terminal::CommandTerminalStatus, primitive::terminal::TerminalLine}; -use gpui::{AppContext, AsyncApp, Context, KeyDownEvent, WeakEntity, Window}; +use chitin_builtin_shell::{BuiltinShell, BuiltinTerminalEvent, ShellBuiltinEffect, ShellInvocationSource}; +use gpui::{AppContext, AsyncApp, Context, WeakEntity, Window}; use super::{ - TERMINAL_DOCK_ITEM_ID, - completion::{CompletionAction, completion_action}, - presenter::{shell_output_lines, shell_terminal_status, terminal_outcome}, + completion::completion_action, + presenter::{shell_output_lines, terminal_lines_ansi}, }; -use crate::{app::ChitinApp, builtin_shell::DesktopShellDispatch}; +use crate::{app::ChitinApp, builtin_shell::DesktopShellDispatch, components::terminal::completion::CompletionAction}; impl ChitinApp { - /// Submits one live-prompt line and binds all resulting output to its block. - /// - /// # Parameters - /// - /// * `line` is the complete command line emitted by the text input. - /// * `window` supplies the frontend context required by UI commands. - /// * `cx` creates the immutable command block and schedules portable work. - pub(super) fn submit_terminal_line(&mut self, line: String, window: &mut Window, cx: &mut Context) { - let line = line.trim().to_owned(); - if line.is_empty() { - return; - } + /// Drains byte-stream events produced by the in-process shell backend. + pub(super) fn process_builtin_terminal_input(&mut self, window: &mut Window, cx: &mut Context) { let Some(controls) = self.terminal_panel_controls.as_ref().cloned() else { return; }; - if controls.terminal.read(cx).is_busy() { - return; + let events = match controls.program.lock() { + Ok(mut program) => match program.poll() { + Ok(events) => events, + Err(error) => { + log::error!("failed to poll built-in terminal input: {error}"); + return; + } + }, + Err(_) => { + log::error!("built-in terminal program lock is poisoned"); + return; + } + }; + + for event in events { + self.handle_builtin_terminal_event(event, window, cx); } - let terminal_id = controls - .terminal - .update(cx, |terminal, cx| terminal.begin_submission(line.clone(), cx)); + } - match self.submit_builtin_shell_line(line, ShellInvocationSource::Interactive, window, cx) { - Ok(DesktopShellDispatch::Display { output }) => { - controls.terminal.update(cx, |terminal, cx| { - terminal.finish( - terminal_id, - CommandTerminalStatus::Succeeded, - super::presenter::terminal_text_lines(&output), - cx, - ); - }); + /// Routes one semantic line-discipline event to shell or terminal behavior. + fn handle_builtin_terminal_event( + &mut self, + event: BuiltinTerminalEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + BuiltinTerminalEvent::Submit(line) => self.submit_terminal_line(line, window, cx), + BuiltinTerminalEvent::Interrupt => { + if self + .builtin_shell() + .snapshot() + .is_ok_and(|snapshot| snapshot.active.is_some()) + { + let _ = self.builtin_shell().cancel_active(); + } } - Ok(DesktopShellDispatch::ShellBuiltin { - effect: ShellBuiltinEffect::ClearScrollback, - }) => { - self.terminal_panel.clear_bindings(); - controls.terminal.update(cx, |terminal, cx| terminal.clear(cx)); + BuiltinTerminalEvent::Complete(line) => self.complete_terminal_line(line), + BuiltinTerminalEvent::PreviousHistory => { + let draft = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.program.lock().ok().map(|program| program.line().to_owned())) + .unwrap_or_default(); + if let Ok(Some(line)) = self.builtin_shell().previous_history(&draft) { + self.replace_terminal_line(line); + } } - Ok(DesktopShellDispatch::Frontend { command_id }) => { - self.terminal_panel.bind(command_id, terminal_id); - controls.terminal.update(cx, |terminal, cx| { - terminal.finish(terminal_id, CommandTerminalStatus::Succeeded, Vec::new(), cx); - }); + BuiltinTerminalEvent::NextHistory => { + if let Ok(Some(line)) = self.builtin_shell().next_history() { + self.replace_terminal_line(line); + } } + BuiltinTerminalEvent::Resize(_) | BuiltinTerminalEvent::Shutdown => {} + } + cx.notify(); + } + + /// Submits one line through the existing typed built-in-shell dispatcher. + fn submit_terminal_line(&mut self, line: String, window: &mut Window, cx: &mut Context) { + let line = line.trim().to_owned(); + if line.is_empty() { + self.finish_terminal_command(""); + return; + } + match self.submit_builtin_shell_line(line, ShellInvocationSource::Interactive, window, cx) { + Ok(DesktopShellDispatch::Display { output }) => self.finish_terminal_command(&output), + Ok(DesktopShellDispatch::ShellBuiltin { + effect: ShellBuiltinEffect::ClearScrollback, + }) => self.clear_terminal_screen(), + Ok(DesktopShellDispatch::Frontend { .. }) => self.finish_terminal_command(""), Ok(DesktopShellDispatch::Portable(task)) => { - self.terminal_panel.bind(task.command_id(), terminal_id); - let viewport = controls.terminal.read(cx).viewport().clone(); - let focus = viewport.read(cx).focus_handle().clone(); - window.focus(&focus, cx); + let command_id = task.command_id(); + self.terminal_panel.start_command(command_id); let window_handle = window.window_handle(); cx.spawn(async move |app: WeakEntity, async_cx: &mut AsyncApp| { let result = task.result().await; let _ = async_cx.update_window(window_handle, |_, window, cx| { let _ = app.update(cx, |this, cx| { - this.sync_terminal_output(cx); - if let Some(controls) = this.terminal_panel_controls.as_ref() { - let (status, lines) = match result { - Ok(result) => terminal_outcome(result.outcome), - Err(_) => { - let status = this - .builtin_shell() - .snapshot() - .ok() - .and_then(|snapshot| shell_terminal_status(&snapshot, terminal_id, &this.terminal_panel)) - .unwrap_or(CommandTerminalStatus::Failed); - (status, Vec::new()) - } - }; - controls.terminal.update(cx, |terminal, cx| { - terminal.finish(terminal_id, status, lines, cx); - }); - if this.bottom_dock.is_active(TERMINAL_DOCK_ITEM_ID) { - let input = controls.input(cx); - let focus = input.read(cx).focus_handle().clone(); - window.focus(&focus, cx); + let output = match result { + Ok(result) => { + let mut lines = this + .builtin_shell() + .snapshot() + .map(|snapshot| shell_output_lines(&snapshot, command_id)) + .unwrap_or_default(); + let (_, result_lines) = super::presenter::terminal_outcome(result.outcome); + lines.extend(result_lines); + terminal_lines_ansi(&lines) } + Err(error) => match this.builtin_shell().snapshot() { + Ok(snapshot) => { + let output = terminal_lines_ansi(&shell_output_lines(&snapshot, command_id)); + if output.is_empty() { + format!("\x1b[31merror: {error}\x1b[0m") + } else { + output + } + } + Err(_) => format!("\x1b[31merror: {error}\x1b[0m"), + }, + }; + this.finish_terminal_command(&output); + if let Some(controls) = this.terminal_panel_controls.as_ref() { + window.focus(&controls.focus(cx), cx); } cx.notify(); }); @@ -96,102 +127,82 @@ impl ChitinApp { }) .detach(); } - Err(error) => { - controls - .terminal - .update(cx, |terminal, cx| terminal.reject(terminal_id, error.to_string(), cx)); + Err(error) => self.finish_terminal_command(&format!("\x1b[31merror: {error}\x1b[0m")), + } + } + + /// Applies built-in shell completion to the terminal-owned editable line. + fn complete_terminal_line(&self, line: String) { + match completion_action(BuiltinShell::complete(&line)) { + CompletionAction::Insert(completed) => self.replace_terminal_line(completed), + CompletionAction::List(candidates) => { + let output = candidates.join("\n"); + if let Some(controls) = self.terminal_panel_controls.as_ref() + && let Ok(program) = controls.program.lock() + && let Err(error) = program.show_notice(&output) + { + log::error!("failed to display terminal completions: {error}"); + } } + CompletionAction::None => {} } - cx.notify(); } - /// Synchronizes running command events into their existing terminal blocks. - pub(crate) fn sync_terminal_output(&mut self, cx: &mut Context) { - let Some(controls) = self.terminal_panel_controls.as_ref() else { - return; - }; - let Ok(snapshot) = self.builtin_shell().snapshot() else { - return; - }; + /// Replaces the line edited by the in-process terminal backend. + fn replace_terminal_line(&self, line: String) { + if let Some(controls) = self.terminal_panel_controls.as_ref() + && let Ok(mut program) = controls.program.lock() + && let Err(error) = program.replace_line(line) + { + log::error!("failed to replace built-in terminal line: {error}"); + } + } - for binding in &self.terminal_panel.bindings { - let output = shell_output_lines(&snapshot, binding.shell_id); - controls.terminal.update(cx, |terminal, cx| { - terminal.set_output(binding.terminal_id, output, cx); - }); + /// Writes final command output and restores the shell prompt. + fn finish_terminal_command(&mut self, output: &str) { + if let Some(controls) = self.terminal_panel_controls.as_ref() + && let Ok(mut program) = controls.program.lock() + && let Err(error) = program.finish_command(output) + { + log::error!("failed to finish built-in terminal command: {error}"); } + self.terminal_panel.finish_command(); } - /// Applies history navigation and cancellation while the terminal owns focus. - /// - /// # Parameters - /// - /// * `event` is the focused GPUI keyboard event. - /// * `window` verifies ownership of keyboard focus. - /// * `cx` updates input/history state and stops consumed events. - pub(crate) fn handle_terminal_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { - let Some(controls) = self.terminal_panel_controls.as_ref() else { + /// Clears the VT grid and resets the built-in line discipline. + fn clear_terminal_screen(&mut self) { + if let Some(controls) = self.terminal_panel_controls.as_ref() + && let Ok(mut program) = controls.program.lock() + && let Err(error) = program.clear_screen() + { + log::error!("failed to clear built-in terminal: {error}"); + } + self.terminal_panel.finish_command(); + } + + /// Projects the active command's replaceable transcript into the VT grid. + pub(crate) fn sync_terminal_output(&mut self) { + let Some(command_id) = self.terminal_panel.active_shell_command else { return; }; - let input = controls.input(cx); - let key = event.keystroke.key.as_str(); - if event.keystroke.modifiers.control && key == "c" { - let viewport = controls.terminal.read(cx).viewport().clone(); - let terminal_focused = - input.read(cx).focus_handle().is_focused(window) || viewport.read(cx).focus_handle().is_focused(window); - if terminal_focused - && self - .builtin_shell() - .snapshot() - .is_ok_and(|snapshot| snapshot.active.is_some()) - { - let _ = self.builtin_shell().cancel_active(); - cx.stop_propagation(); - cx.notify(); - } + let Ok(snapshot) = self.builtin_shell().snapshot() else { return; - } - - if !input.read(cx).focus_handle().is_focused(window) { + }; + let output = terminal_lines_ansi(&shell_output_lines(&snapshot, command_id)); + if output == self.terminal_panel.rendered_running_output { return; } - - if key == "tab" { - let modifiers = &event.keystroke.modifiers; - if modifiers.control || modifiers.alt || modifiers.platform { - return; - } - let line = input.read(cx).text().to_owned(); - match completion_action(BuiltinShell::complete(&line)) { - CompletionAction::Insert(completed) => { - input.update(cx, |input, cx| { - input.set_text(completed, cx); - }); - } - CompletionAction::List(candidates) => { - let lines = candidates.into_iter().map(TerminalLine::plain).collect::>(); - controls - .terminal - .update(cx, |terminal, cx| terminal.push_notice(line, lines, cx)); - } - CompletionAction::None => {} - } - cx.stop_propagation(); - cx.notify(); + let Some(controls) = self.terminal_panel_controls.as_ref() else { return; - } - - let history = match key { - "up" => self.builtin_shell().previous_history(input.read(cx).text()), - "down" => self.builtin_shell().next_history(), - _ => return, }; - if let Ok(Some(line)) = history { - input.update(cx, |input, cx| { - input.set_text(line, cx); - }); + let Ok(mut program) = controls.program.lock() else { + log::error!("built-in terminal program lock is poisoned"); + return; + }; + if let Err(error) = program.update_command_output(&output) { + log::error!("failed to update built-in terminal output: {error}"); + return; } - cx.stop_propagation(); - cx.notify(); + self.terminal_panel.rendered_running_output = output; } } diff --git a/crates/chitin-desktop/src/components/terminal/mod.rs b/crates/chitin-desktop/src/components/terminal/mod.rs index 478e5a26..aee1ce77 100644 --- a/crates/chitin-desktop/src/components/terminal/mod.rs +++ b/crates/chitin-desktop/src/components/terminal/mod.rs @@ -5,13 +5,14 @@ mod controller; mod presenter; mod render; -use std::path::Path; - -use chitin_builtin_shell::ShellCommandId; -use chitin_ui::{ - composite::command_terminal::{CommandTerminalId, CommandTerminalState}, - primitive::input::text::{TextInputEvent, TextInputState}, +use std::{ + path::Path, + sync::{Arc, Mutex}, }; + +use chitin_builtin_shell::{BuiltinTerminalProgram, ShellCommandId}; +use chitin_terminal::{TerminalSessionError, TerminalSize}; +use chitin_ui::primitive::terminal::{TerminalEmulatorEvent, TerminalEmulatorState}; use gpui::{AppContext, Context, Entity, Subscription, Window}; use crate::app::ChitinApp; @@ -20,16 +21,11 @@ pub(crate) use render::render_terminal_bottom_dock; pub(crate) const TERMINAL_DOCK_ITEM_ID: &str = "terminal"; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) struct TerminalShellBinding { - pub(super) shell_id: ShellCommandId, - pub(super) terminal_id: CommandTerminalId, -} - -/// Terminal focus and shell-to-view identity mapping. +/// Terminal focus and active built-in-shell command state. pub(crate) struct TerminalPanelState { focus_requested: bool, - pub(super) bindings: Vec, + pub(super) active_shell_command: Option, + pub(super) rendered_running_output: String, } impl TerminalPanelState { @@ -37,7 +33,8 @@ impl TerminalPanelState { pub(crate) fn new() -> Self { Self { focus_requested: false, - bindings: Vec::new(), + active_shell_command: None, + rendered_running_output: String::new(), } } @@ -51,14 +48,16 @@ impl TerminalPanelState { std::mem::take(&mut self.focus_requested) } - /// Associates one shell execution with its visible command block. - pub(super) fn bind(&mut self, shell_id: ShellCommandId, terminal_id: CommandTerminalId) { - self.bindings.push(TerminalShellBinding { shell_id, terminal_id }); + /// Tracks the portable shell command currently writing terminal output. + pub(super) fn start_command(&mut self, command_id: ShellCommandId) { + self.active_shell_command = Some(command_id); + self.rendered_running_output.clear(); } - /// Removes view bindings for command blocks cleared from scrollback. - pub(super) fn clear_bindings(&mut self) { - self.bindings.clear(); + /// Clears the active output projection after its command reaches a terminal state. + pub(super) fn finish_command(&mut self) { + self.active_shell_command = None; + self.rendered_running_output.clear(); } } @@ -71,32 +70,36 @@ impl Default for TerminalPanelState { /// Persistent primitive and composite controls for the bottom terminal panel. #[derive(Clone)] pub(crate) struct TerminalPanelControls { - pub(super) terminal: Entity, + pub(super) terminal: Entity, + pub(super) program: Arc>, } impl TerminalPanelControls { /// Creates terminal state and panel controls for one working directory. - fn new(working_directory: &Path, cx: &mut Context) -> Self { - let prompt = presenter::terminal_prompt(working_directory); - Self { - terminal: cx.new(|cx| CommandTerminalState::new(prompt, cx)), - } + fn new(working_directory: &Path, cx: &mut Context) -> Result { + let prompt = presenter::terminal_prompt_ansi(working_directory); + let size = TerminalSize::new(80, 24, 9, 21); + let (session, program) = BuiltinTerminalProgram::connect(size, prompt)?; + Ok(Self { + terminal: cx.new(|cx| TerminalEmulatorState::new(session, size, cx)), + program: Arc::new(Mutex::new(program)), + }) } /// Connects primitive semantic events to the desktop shell adapter. fn subscribe(&self, window: &mut Window, cx: &mut Context) { - let input = self.input(cx); - let input_subscription: Subscription = cx.subscribe_in(&input, window, |this, _, event, window, cx| match event { - TextInputEvent::Submit { value } => this.submit_terminal_line(value.to_string(), window, cx), - TextInputEvent::Cancel => this.close_terminal(cx), - _ => {} + let terminal = self.terminal.clone(); + let subscription: Subscription = cx.subscribe_in(&terminal, window, |this, _, event, window, cx| { + if *event == TerminalEmulatorEvent::InputWritten { + this.process_builtin_terminal_input(window, cx); + } }); - input_subscription.detach(); + subscription.detach(); } - /// Returns the live prompt's editable input. - pub(crate) fn input(&self, cx: &gpui::App) -> Entity { - self.terminal.read(cx).input().clone() + /// Returns the focus handle owned by the VT terminal surface. + pub(crate) fn focus(&self, cx: &gpui::App) -> gpui::FocusHandle { + self.terminal.read(cx).focus_handle().clone() } } @@ -106,9 +109,9 @@ impl ChitinApp { &mut self, window: &mut Window, cx: &mut Context, - ) -> TerminalPanelControls { + ) -> Option { if let Some(controls) = self.terminal_panel_controls.as_ref() { - return controls.clone(); + return Some(controls.clone()); } let working_directory = self @@ -116,10 +119,16 @@ impl ChitinApp { .snapshot() .map(|snapshot| snapshot.working_directory) .unwrap_or_else(|_| std::path::PathBuf::from(".")); - let controls = TerminalPanelControls::new(&working_directory, cx); + let controls = match TerminalPanelControls::new(&working_directory, cx) { + Ok(controls) => controls, + Err(error) => { + log::error!("failed to create built-in terminal: {error}"); + return None; + } + }; controls.subscribe(window, cx); self.terminal_panel_controls = Some(controls.clone()); - controls + Some(controls) } /// Shows or hides the bottom terminal panel. @@ -128,9 +137,4 @@ impl ChitinApp { self.terminal_panel.request_focus(visible); cx.notify(); } - - /// Hides the bottom terminal panel. - fn close_terminal(&mut self, cx: &mut Context) { - self.close_bottom_dock(cx); - } } diff --git a/crates/chitin-desktop/src/components/terminal/presenter.rs b/crates/chitin-desktop/src/components/terminal/presenter.rs index a23f4e42..c0638b96 100644 --- a/crates/chitin-desktop/src/components/terminal/presenter.rs +++ b/crates/chitin-desktop/src/components/terminal/presenter.rs @@ -2,41 +2,33 @@ use std::path::Path; -use chitin_builtin_shell::{BuiltinShellSnapshot, ShellCommandId, ShellExecutionStatus, ShellTranscriptContent}; +use chitin_builtin_shell::{BuiltinShellSnapshot, ShellCommandId, ShellTranscriptContent}; use chitin_command::{ CommandMessageLevel, CommandOutcome, CommandOutputTone, CommandReportStatus, render_outcome as render_command_outcome, }; use chitin_ui::{ - composite::command_terminal::{CommandTerminalId, CommandTerminalStatus}, + composite::command_terminal::CommandTerminalStatus, primitive::terminal::{TerminalLine, TerminalSpan, TerminalTone}, }; -use super::TerminalPanelState; - -/// Builds the two-tone prompt shared by frozen and live command lines. -pub(super) fn terminal_prompt(working_directory: &Path) -> TerminalLine { - TerminalLine::new([ - TerminalSpan::accent("chitin "), - TerminalSpan::secondary(format!("{} ", working_directory.display())), - TerminalSpan::accent("❯ "), - ]) -} - -/// Converts preformatted command help into terminal rows. -pub(super) fn terminal_text_lines(text: &str) -> Vec { - text.lines().map(TerminalLine::plain).collect() +/// Builds an ANSI-colored prompt for the in-process terminal program. +pub(super) fn terminal_prompt_ansi(working_directory: &Path) -> String { + format!( + "\x1b[38;2;0;145;220mchitin\x1b[0m \x1b[90m{}\x1b[0m \x1b[38;2;0;145;220m❯\x1b[0m ", + working_directory.display(), + ) } -/// Converts one shell command's event stream into terminal output rows. +/// Converts one command's structured event stream into terminal rows. /// /// # Parameters /// -/// * `snapshot` supplies the frontend-neutral session transcript. +/// * `snapshot` supplies the current shell transcript. /// * `shell_id` selects entries belonging to one submitted command. /// /// # Returns /// -/// Ordered progress, diagnostic, artifact, failure, and cancellation rows. +/// Ordered progress, message, artifact, failure, and cancellation rows. pub(super) fn shell_output_lines(snapshot: &BuiltinShellSnapshot, shell_id: ShellCommandId) -> Vec { snapshot .transcript @@ -116,36 +108,31 @@ fn terminal_tone(tone: CommandOutputTone) -> TerminalTone { } } -/// Resolves cancellation or failure state for one terminal command mapping. -/// -/// # Parameters -/// -/// * `snapshot` supplies completed shell execution records. -/// * `terminal_id` identifies the visible command block. -/// * `panel` maps terminal blocks back to shell-local identities. -/// -/// # Returns -/// -/// The final presentation status when both mapping and execution still exist. -pub(super) fn shell_terminal_status( - snapshot: &BuiltinShellSnapshot, - terminal_id: CommandTerminalId, - panel: &TerminalPanelState, -) -> Option { - let shell_id = panel - .bindings - .iter() - .find_map(|binding| (binding.terminal_id == terminal_id).then_some(binding.shell_id))?; - snapshot - .executions +/// Converts semantic terminal lines into ANSI-colored program output. +pub(super) fn terminal_lines_ansi(lines: &[TerminalLine]) -> String { + lines .iter() - .find(|record| record.id == shell_id) - .map(|record| match record.status { - ShellExecutionStatus::Cancelled => CommandTerminalStatus::Cancelled, - ShellExecutionStatus::Failed => CommandTerminalStatus::Failed, - ShellExecutionStatus::Succeeded => CommandTerminalStatus::Succeeded, - ShellExecutionStatus::Running | ShellExecutionStatus::Cancelling => CommandTerminalStatus::Failed, + .map(|line| { + line + .spans() + .iter() + .map(|span| format!("{}{}\x1b[0m", tone_ansi(span.tone), span.text)) + .collect::() }) + .collect::>() + .join("\n") +} + +/// Returns the ANSI SGR prefix for a semantic terminal tone. +fn tone_ansi(tone: TerminalTone) -> &'static str { + match tone { + TerminalTone::Primary => "\x1b[39m", + TerminalTone::Secondary => "\x1b[90m", + TerminalTone::Accent | TerminalTone::Info => "\x1b[36m", + TerminalTone::Success => "\x1b[32m", + TerminalTone::Warning => "\x1b[33m", + TerminalTone::Error => "\x1b[31m", + } } #[cfg(test)] @@ -160,7 +147,7 @@ mod tests { let (_, lines) = terminal_outcome(outcome); let actual = lines .iter() - .map(|line| line.spans().iter().map(|span| span.text.as_ref()).collect::()) + .map(|line| line.spans().iter().map(|span| span.text.as_str()).collect::()) .collect::>() .join("\n"); diff --git a/crates/chitin-desktop/src/components/terminal/render.rs b/crates/chitin-desktop/src/components/terminal/render.rs index 011dd461..bdf06ead 100644 --- a/crates/chitin-desktop/src/components/terminal/render.rs +++ b/crates/chitin-desktop/src/components/terminal/render.rs @@ -1,11 +1,9 @@ //! Terminal content hosted by the reusable workbench bottom dock. use chitin_ui::{ - composite::{ - bottom_dock::{BottomDock, BottomDockResizeConfig}, - command_terminal::CommandTerminal, - }, + composite::bottom_dock::{BottomDock, BottomDockResizeConfig}, primitive::button::ButtonState, + primitive::terminal::TerminalEmulator, themes::UIThemes, }; use gpui::{Entity, InteractiveElement, ParentElement, Pixels, Styled, WeakEntity, div}; @@ -13,11 +11,11 @@ use gpui::{Entity, InteractiveElement, ParentElement, Pixels, Styled, WeakEntity use super::TerminalPanelControls; use crate::{app::ChitinApp, fonts::TERMINAL_FONT_FAMILY, keybindings::COMMAND_TERMINAL_KEY_CONTEXT}; -/// Renders the command terminal as the active bottom-dock item. +/// Renders the VT terminal emulator as the active bottom-dock item. /// /// # Parameters /// -/// * `controls` contains the persistent terminal transcript and input state. +/// * `controls` contains the persistent emulator and in-process shell endpoint. /// * `close` is the bottom dock's shared close-button state. /// * `height` is the current workbench-level dock height. /// * `theme` supplies semantic colors for the dock and terminal. @@ -25,7 +23,7 @@ use crate::{app::ChitinApp, fonts::TERMINAL_FONT_FAMILY, keybindings::COMMAND_TE /// /// # Returns /// -/// A bottom-dock element containing the structured command terminal. +/// A bottom-dock element containing the shared VT terminal surface. pub(crate) fn render_terminal_bottom_dock( controls: TerminalPanelControls, close: Entity, @@ -42,11 +40,8 @@ pub(crate) fn render_terminal_bottom_dock( .flex_1() .min_h_0() .key_context(COMMAND_TERMINAL_KEY_CONTEXT) - .capture_key_down(move |event, window, cx| { - let _ = app.update(cx, |this, cx| this.handle_terminal_key(event, window, cx)); - }) .child( - CommandTerminal::new(controls.terminal) + TerminalEmulator::new(controls.terminal) .theme(theme) .font_family(TERMINAL_FONT_FAMILY), ); diff --git a/crates/chitin-terminal/src/lib.rs b/crates/chitin-terminal/src/lib.rs index 9c7302bf..c6617bc2 100644 --- a/crates/chitin-terminal/src/lib.rs +++ b/crates/chitin-terminal/src/lib.rs @@ -1,10 +1,17 @@ #![forbid(unsafe_code)] -//! Frontend-independent PTY sessions and VT terminal state. +//! Frontend-independent terminal profiles, PTY and in-process sessions, transcript state, and VT snapshots. +mod model; mod session; mod size; mod snapshot; -pub use session::{TerminalEvent, TerminalSession, TerminalSessionError}; +pub use model::{ + TerminalBlock, TerminalBlockId, TerminalBlockStatus, TerminalBuffer, TerminalLine, TerminalProfile, TerminalSpan, + TerminalTone, +}; +pub use session::{ + TerminalEvent, TerminalProgram, TerminalProgramInput, TerminalProgramOutput, TerminalSession, TerminalSessionError, +}; pub use size::TerminalSize; pub use snapshot::{TerminalCell, TerminalCellAttributes, TerminalColor, TerminalNamedColor, TerminalSnapshot}; diff --git a/crates/chitin-terminal/src/model.rs b/crates/chitin-terminal/src/model.rs new file mode 100644 index 00000000..edd0c7d7 --- /dev/null +++ b/crates/chitin-terminal/src/model.rs @@ -0,0 +1,340 @@ +//! Profile-aware, frontend-independent terminal transcript state. + +use std::collections::VecDeque; + +/// Execution profile hosted by a terminal surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum TerminalProfile { + /// Chitin's structured command language and application command executor. + BuiltinShell, + /// The operating system's interactive shell attached to a native PTY. + SystemShell, +} + +impl TerminalProfile { + /// Returns the user-facing profile name. + pub const fn label(self) -> &'static str { + match self { + Self::BuiltinShell => "Built-in shell", + Self::SystemShell => "System shell", + } + } +} + +/// Semantic foreground tone shared by terminal renderers. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TerminalTone { + /// Primary terminal content. + #[default] + Primary, + /// De-emphasized paths, metadata, and hints. + Secondary, + /// Prompt and other interactive accents. + Accent, + /// Successful result content. + Success, + /// Recoverable warning content. + Warning, + /// Failed or rejected command content. + Error, + /// Informational and progress content. + Info, +} + +/// One styled text span in a terminal line. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalSpan { + /// Text rendered by this span. + pub text: String, + /// Semantic foreground tone. + pub tone: TerminalTone, +} + +impl TerminalSpan { + /// Creates a span with a semantic foreground tone. + pub fn new(text: impl Into, tone: TerminalTone) -> Self { + Self { + text: text.into(), + tone, + } + } + + /// Creates primary terminal content. + pub fn primary(text: impl Into) -> Self { + Self::new(text, TerminalTone::Primary) + } + + /// Creates de-emphasized terminal content. + pub fn secondary(text: impl Into) -> Self { + Self::new(text, TerminalTone::Secondary) + } + + /// Creates prompt or interactive accent content. + pub fn accent(text: impl Into) -> Self { + Self::new(text, TerminalTone::Accent) + } + + /// Creates successful terminal content. + pub fn success(text: impl Into) -> Self { + Self::new(text, TerminalTone::Success) + } + + /// Creates warning terminal content. + pub fn warning(text: impl Into) -> Self { + Self::new(text, TerminalTone::Warning) + } + + /// Creates failed or rejected terminal content. + pub fn error(text: impl Into) -> Self { + Self::new(text, TerminalTone::Error) + } + + /// Creates informational and progress content. + pub fn info(text: impl Into) -> Self { + Self::new(text, TerminalTone::Info) + } +} + +/// One soft-wrapping row of terminal spans. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TerminalLine { + spans: Vec, +} + +impl TerminalLine { + /// Creates a line from ordered styled spans. + pub fn new(spans: impl IntoIterator) -> Self { + Self { + spans: spans.into_iter().collect(), + } + } + + /// Creates a line containing primary text. + pub fn plain(text: impl Into) -> Self { + Self::new([TerminalSpan::primary(text)]) + } + + /// Returns the ordered styled spans. + pub fn spans(&self) -> &[TerminalSpan] { + &self.spans + } + + /// Appends one styled span. + pub fn push(&mut self, span: TerminalSpan) { + self.spans.push(span); + } + + /// Returns whether the line contains no text spans. + pub fn is_empty(&self) -> bool { + self.spans.is_empty() + } + + /// Splits embedded newline characters into independently laid-out rows. + pub fn into_rows(self) -> Vec { + if self.spans.iter().all(|span| !span.text.contains('\n')) { + return vec![self]; + } + + let mut rows = Vec::new(); + let mut current = Vec::new(); + for span in self.spans { + for (index, text) in span.text.split('\n').enumerate() { + if index > 0 { + rows.push(Self { + spans: std::mem::take(&mut current), + }); + } + let text = text.strip_suffix('\r').unwrap_or(text); + if !text.is_empty() { + current.push(TerminalSpan::new(text.to_owned(), span.tone)); + } + } + } + rows.push(Self { spans: current }); + rows + } +} + +/// Stable identity for one submitted command block. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TerminalBlockId(u64); + +impl TerminalBlockId { + /// Returns the terminal-local numeric identity. + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Presentation lifecycle of one submitted terminal command block. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalBlockStatus { + /// The command is still executing and owns the foreground prompt. + Running, + /// The command completed successfully. + Succeeded, + /// The command execution failed. + Failed, + /// The command stopped through cooperative cancellation. + Cancelled, + /// The submitted line was rejected before execution. + Rejected, +} + +impl TerminalBlockStatus { + /// Returns whether a new live prompt may be rendered. + pub const fn is_terminal(self) -> bool { + !matches!(self, Self::Running) + } +} + +/// One immutable submitted command and its ordered output. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalBlock { + /// Terminal-local block identity. + pub id: TerminalBlockId, + /// Prompt captured when this command was submitted. + pub prompt: TerminalLine, + /// Exact command text submitted by the user. + pub input: String, + /// Replaceable executor output such as progress and artifacts. + pub output: Vec, + /// Final structured result lines appended after executor output. + pub result: Vec, + /// Current presentation lifecycle. + pub status: TerminalBlockStatus, +} + +const MAX_TERMINAL_BLOCKS: usize = 512; + +/// Profile-aware transcript model shared by built-in and system terminal hosts. +pub struct TerminalBuffer { + profile: TerminalProfile, + prompt: TerminalLine, + blocks: VecDeque, + active: Option, + next_id: u64, +} + +impl TerminalBuffer { + /// Creates an empty transcript for one terminal profile. + pub fn new(profile: TerminalProfile, prompt: TerminalLine) -> Self { + Self { + profile, + prompt, + blocks: VecDeque::new(), + active: None, + next_id: 1, + } + } + + /// Returns the profile that owns this transcript. + pub const fn profile(&self) -> TerminalProfile { + self.profile + } + + /// Returns submitted command blocks in display order. + pub fn blocks(&self) -> &VecDeque { + &self.blocks + } + + /// Returns the prompt used for the next submission. + pub fn prompt(&self) -> &TerminalLine { + &self.prompt + } + + /// Returns whether a command currently owns the foreground slot. + pub const fn is_busy(&self) -> bool { + self.active.is_some() + } + + /// Returns whether one block currently owns the foreground slot. + pub fn is_active(&self, id: TerminalBlockId) -> bool { + self.active == Some(id) + } + + /// Freezes the live prompt into a running command block. + pub fn begin_submission(&mut self, input: String) -> TerminalBlockId { + let id = self.reserve_block(); + self.blocks.push_back(TerminalBlock { + id, + prompt: self.prompt.clone(), + input, + output: Vec::new(), + result: Vec::new(), + status: TerminalBlockStatus::Running, + }); + self.active = Some(id); + id + } + + /// Appends a completed notice without claiming the foreground slot. + pub fn push_notice(&mut self, input: String, result: Vec) { + let id = self.reserve_block(); + self.blocks.push_back(TerminalBlock { + id, + prompt: self.prompt.clone(), + input, + output: Vec::new(), + result, + status: TerminalBlockStatus::Succeeded, + }); + } + + /// Replaces transient output for one command block. + pub fn set_output(&mut self, id: TerminalBlockId, output: Vec) { + if let Some(block) = self.blocks.iter_mut().find(|block| block.id == id) { + block.output = output; + } + } + + /// Finishes one command block and restores the live prompt. + pub fn finish(&mut self, id: TerminalBlockId, status: TerminalBlockStatus, result: Vec) { + let Some(block) = self.blocks.iter_mut().find(|block| block.id == id) else { + return; + }; + block.status = status; + block.result = result; + if self.active == Some(id) { + self.active = None; + } + } + + /// Clears submitted blocks while retaining the profile and prompt. + pub fn clear(&mut self) { + self.blocks.clear(); + self.active = None; + } + + /// Replaces the prompt used by subsequent command blocks. + pub fn set_prompt(&mut self, prompt: TerminalLine) { + self.prompt = prompt; + } + + /// Reserves the next block identity and evicts the oldest block at capacity. + fn reserve_block(&mut self) -> TerminalBlockId { + let id = TerminalBlockId(self.next_id); + self.next_id = self.next_id.saturating_add(1); + if self.blocks.len() == MAX_TERMINAL_BLOCKS { + self.blocks.pop_front(); + } + id + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn buffer_should_preserve_profile_and_command_lifecycle() { + let mut buffer = TerminalBuffer::new(TerminalProfile::BuiltinShell, TerminalLine::plain("chitin ❯ ")); + let id = buffer.begin_submission("help".into()); + assert!(buffer.is_busy()); + buffer.finish(id, TerminalBlockStatus::Succeeded, vec![TerminalLine::plain("ok")]); + + assert_eq!(buffer.profile(), TerminalProfile::BuiltinShell); + assert!(!buffer.is_busy()); + assert_eq!(buffer.blocks()[0].status, TerminalBlockStatus::Succeeded); + } +} diff --git a/crates/chitin-terminal/src/session.rs b/crates/chitin-terminal/src/session.rs index 16cdb9e9..23ca9456 100644 --- a/crates/chitin-terminal/src/session.rs +++ b/crates/chitin-terminal/src/session.rs @@ -19,10 +19,22 @@ use alacritty_terminal::{ use portable_pty::{ChildKiller, CommandBuilder, MasterPty, native_pty_system}; use thiserror::Error; -use crate::{TerminalSize, TerminalSnapshot}; +use crate::{TerminalProfile, TerminalSize, TerminalSnapshot}; type SharedWriter = Arc>>; type SharedTerminal = Arc>>; +type SharedProcessor = Arc>>; + +/// Input sent from a terminal emulator to an in-process terminal program. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TerminalProgramInput { + /// Keyboard, paste, or terminal-response bytes. + Bytes(Vec), + /// Updated character-grid and cell-pixel dimensions. + Resize(TerminalSize), + /// The owning terminal session is shutting down. + Shutdown, +} /// A state change emitted by a native terminal session. #[derive(Clone, Debug, PartialEq, Eq)] @@ -46,16 +58,83 @@ pub enum TerminalSessionError { Pty { operation: &'static str, message: String }, #[error("terminal writer lock is poisoned")] WriterPoisoned, + #[error("terminal output parser lock is poisoned")] + ParserPoisoned, + #[error("in-process terminal program is disconnected")] + ProgramDisconnected, #[error("failed to write terminal input: {0}")] Write(#[from] std::io::Error), } -/// Owns a native PTY child and its frontend-independent VT terminal state. +/// Output side of an in-process program connected to a VT terminal emulator. +#[derive(Clone)] +pub struct TerminalProgramOutput { + terminal: SharedTerminal, + processor: SharedProcessor, + events: mpsc::Sender, + render_pending: Arc, +} + +impl TerminalProgramOutput { + /// Parses program output as ANSI/VT bytes and schedules a terminal repaint. + pub fn write(&self, bytes: &[u8]) -> Result<(), TerminalSessionError> { + let mut processor = self + .processor + .lock() + .map_err(|_| TerminalSessionError::ParserPoisoned)?; + processor.advance(&mut *self.terminal.lock(), bytes); + send_render_event(&self.events, &self.render_pending); + Ok(()) + } +} + +/// Program-facing endpoint paired with an in-process terminal session. +pub struct TerminalProgram { + input: Mutex>, + output: TerminalProgramOutput, +} + +impl TerminalProgram { + /// Returns a cloneable handle for writing ANSI/VT output to the emulator. + pub fn output(&self) -> TerminalProgramOutput { + self.output.clone() + } + + /// Drains input currently waiting for the in-process program. + pub fn drain_input(&self) -> Result, TerminalSessionError> { + let receiver = self + .input + .lock() + .map_err(|_| TerminalSessionError::ProgramDisconnected)?; + Ok(receiver.try_iter().collect()) + } + + /// Waits for the next input event from the terminal emulator. + pub fn recv(&self) -> Result { + let receiver = self + .input + .lock() + .map_err(|_| TerminalSessionError::ProgramDisconnected)?; + receiver.recv().map_err(|_| TerminalSessionError::ProgramDisconnected) + } +} + +enum TerminalBackend { + NativePty { + master: Box, + killer: Mutex>, + }, + InProcess { + input: mpsc::Sender, + }, +} + +/// Owns one terminal backend and its frontend-independent VT terminal state. pub struct TerminalSession { + profile: TerminalProfile, terminal: SharedTerminal, writer: SharedWriter, - master: Box, - killer: Mutex>, + backend: TerminalBackend, events: Mutex>, render_pending: Arc, } @@ -127,15 +206,67 @@ impl TerminalSession { spawn_wait_thread(&event_sender, child)?; Ok(Self { + profile: TerminalProfile::SystemShell, terminal, writer, - master: pair.master, - killer: Mutex::new(killer), + backend: TerminalBackend::NativePty { + master: pair.master, + killer: Mutex::new(killer), + }, events: Mutex::new(event_receiver), render_pending, }) } + /// Creates a VT terminal connected to an in-process program endpoint. + /// + /// # Parameters + /// + /// * `profile` identifies the program hosted by the terminal surface. + /// * `size` supplies the initial character-grid and cell-pixel dimensions. + /// + /// # Returns + /// + /// A terminal session for the frontend and a program endpoint that receives + /// input bytes and writes ANSI/VT output. + pub fn in_process(profile: TerminalProfile, size: TerminalSize) -> (Self, TerminalProgram) { + let (input_sender, input_receiver) = mpsc::channel(); + let writer: SharedWriter = Arc::new(Mutex::new(Box::new(ProgramInputWriter { + sender: input_sender.clone(), + }))); + let (event_sender, event_receiver) = mpsc::channel(); + let render_pending = Arc::new(AtomicBool::new(false)); + let proxy = TerminalEventProxy { + sender: event_sender.clone(), + writer: Arc::clone(&writer), + render_pending: Arc::clone(&render_pending), + }; + let terminal = Arc::new(FairMutex::new(Term::new(Config::default(), &size, proxy))); + let program = TerminalProgram { + input: Mutex::new(input_receiver), + output: TerminalProgramOutput { + terminal: Arc::clone(&terminal), + processor: Arc::new(Mutex::new(ansi::Processor::::new())), + events: event_sender, + render_pending: Arc::clone(&render_pending), + }, + }; + let session = Self { + profile, + terminal, + writer, + backend: TerminalBackend::InProcess { input: input_sender }, + events: Mutex::new(event_receiver), + render_pending, + }; + (session, program) + } + + /// Returns the profile represented by this terminal backend. + pub const fn profile(&self) -> TerminalProfile { + self.profile + } + /// Writes encoded keyboard or paste bytes to the terminal child. pub fn write(&self, bytes: &[u8]) -> Result<(), TerminalSessionError> { let mut writer = self.writer.lock().map_err(|_| TerminalSessionError::WriterPoisoned)?; @@ -146,10 +277,14 @@ impl TerminalSession { /// Updates both the native PTY and the VT screen dimensions. pub fn resize(&self, size: TerminalSize) -> Result<(), TerminalSessionError> { - self - .master - .resize(size.pty_size()) - .map_err(|error| pty_error("resize native pseudo-terminal", error))?; + match &self.backend { + TerminalBackend::NativePty { master, .. } => master + .resize(size.pty_size()) + .map_err(|error| pty_error("resize native pseudo-terminal", error))?, + TerminalBackend::InProcess { input } => input + .send(TerminalProgramInput::Resize(size)) + .map_err(|_| TerminalSessionError::ProgramDisconnected)?, + } self.terminal.lock().resize(size); Ok(()) } @@ -173,12 +308,42 @@ impl TerminalSession { impl Drop for TerminalSession { fn drop(&mut self) { - if let Ok(killer) = self.killer.get_mut() { - let _ = killer.kill(); + match &mut self.backend { + TerminalBackend::NativePty { killer, .. } => { + if let Ok(killer) = killer.get_mut() { + let _ = killer.kill(); + } + } + TerminalBackend::InProcess { input } => { + let _ = input.send(TerminalProgramInput::Shutdown); + } } } } +struct ProgramInputWriter { + sender: mpsc::Sender, +} + +impl Write for ProgramInputWriter { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + self + .sender + .send(TerminalProgramInput::Bytes(buffer.to_vec())) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "in-process terminal program disconnected", + ) + })?; + Ok(buffer.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + #[derive(Clone)] struct TerminalEventProxy { sender: mpsc::Sender, @@ -312,4 +477,46 @@ mod tests { Some(crate::TerminalColor::Named(crate::TerminalNamedColor::Green)), ); } + + #[test] + fn in_process_program_should_receive_terminal_input_bytes() { + let (session, program) = + TerminalSession::in_process(TerminalProfile::BuiltinShell, TerminalSize::new(40, 4, 8, 16)); + + let write_result = session.write(b"help\r"); + let input = program.recv(); + + assert!(write_result.is_ok()); + assert_eq!(input.ok(), Some(TerminalProgramInput::Bytes(b"help\r".to_vec()))); + } + + #[test] + fn in_process_program_output_should_update_the_shared_vt_snapshot() { + let (session, program) = + TerminalSession::in_process(TerminalProfile::BuiltinShell, TerminalSize::new(40, 4, 8, 16)); + + let write_result = program.output().write(b"\x1b[32mbuiltin-shell\x1b[0m"); + let visible_text = session + .snapshot() + .cells + .iter() + .map(|cell| cell.character) + .collect::(); + + assert!(write_result.is_ok()); + assert!(visible_text.contains("builtin-shell")); + } + + #[test] + fn in_process_program_should_receive_terminal_resize() { + let (session, program) = + TerminalSession::in_process(TerminalProfile::BuiltinShell, TerminalSize::new(40, 4, 8, 16)); + let expected = TerminalSize::new(80, 24, 9, 18); + + let resize_result = session.resize(expected); + let input = program.recv(); + + assert!(resize_result.is_ok()); + assert_eq!(input.ok(), Some(TerminalProgramInput::Resize(expected))); + } } diff --git a/crates/chitin-ui/Cargo.toml b/crates/chitin-ui/Cargo.toml index 46c800f9..14474e27 100644 --- a/crates/chitin-ui/Cargo.toml +++ b/crates/chitin-ui/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true description = "Reusable GPUI components for chitin desktop application" [dependencies] +chitin-terminal = { path = "../chitin-terminal" } gpui = { package = "gpui-ce", git = "https://github.com/chitin-dev/gpui-wgpu", rev = "d01b063d0dce9df2be24a53f59201e010712fc01" } log = "0.4.33" diff --git a/crates/chitin-ui/src/composite/command_terminal/model.rs b/crates/chitin-ui/src/composite/command_terminal/model.rs index af961991..e3b9d495 100644 --- a/crates/chitin-ui/src/composite/command_terminal/model.rs +++ b/crates/chitin-ui/src/composite/command_terminal/model.rs @@ -1,89 +1,46 @@ -//! Persistent command-block and live-prompt state. +//! GPUI state wrapper around the shared terminal transcript model. use std::collections::VecDeque; -use gpui::{AppContext, Context, Entity}; - -use crate::primitive::{ - input::text::TextInputState, - terminal::{TerminalLine, TerminalViewportState}, +use chitin_terminal::{ + TerminalBlock, TerminalBlockId, TerminalBlockStatus, TerminalBuffer, TerminalLine, TerminalProfile, }; +use gpui::{AppContext, Context, Entity}; -const MAX_COMMAND_BLOCKS: usize = 512; - -/// Stable identity for one submitted terminal command block. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct CommandTerminalId(u64); - -impl CommandTerminalId { - /// Returns the terminal-local numeric identity. - pub const fn get(self) -> u64 { - self.0 - } -} - -/// Presentation lifecycle of one terminal command block. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CommandTerminalStatus { - /// The command is still executing and owns the foreground prompt. - Running, - /// The command completed successfully. - Succeeded, - /// The command execution failed. - Failed, - /// The command stopped through cooperative cancellation. - Cancelled, - /// The submitted line was rejected before execution. - Rejected, -} - -impl CommandTerminalStatus { - /// Returns whether a new live prompt may be rendered. - pub const fn is_terminal(self) -> bool { - !matches!(self, Self::Running) - } -} +use crate::primitive::{input::text::TextInputState, terminal::TerminalViewportState}; -/// One immutable submitted command and its ordered output. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CommandTerminalBlock { - /// Terminal-local block identity. - pub id: CommandTerminalId, - /// Prompt captured when this command was submitted. - pub prompt: TerminalLine, - /// Exact command text submitted by the user. - pub input: String, - /// Replaceable executor output such as progress and artifacts. - pub output: Vec, - /// Final structured result lines appended after executor output. - pub result: Vec, - /// Current presentation lifecycle. - pub status: CommandTerminalStatus, -} +pub use chitin_terminal::{ + TerminalBlock as CommandTerminalBlock, TerminalBlockId as CommandTerminalId, + TerminalBlockStatus as CommandTerminalStatus, +}; /// Persistent command history, draft input, and viewport state. pub struct CommandTerminalState { input: Entity, viewport: Entity, - prompt: TerminalLine, - blocks: VecDeque, - active: Option, - next_id: u64, + buffer: TerminalBuffer, } impl CommandTerminalState { - /// Creates an empty command terminal with one live prompt. + /// Creates an empty built-in-shell terminal with one live prompt. pub fn new(prompt: TerminalLine, cx: &mut Context) -> Self { + Self::with_profile(TerminalProfile::BuiltinShell, prompt, cx) + } + + /// Creates a terminal state for an explicit terminal profile. + pub fn with_profile(profile: TerminalProfile, prompt: TerminalLine, cx: &mut Context) -> Self { Self { input: cx.new(TextInputState::new), viewport: cx.new(TerminalViewportState::new), - prompt, - blocks: VecDeque::new(), - active: None, - next_id: 1, + buffer: TerminalBuffer::new(profile, prompt), } } + /// Returns the profile represented by this terminal state. + pub const fn profile(&self) -> TerminalProfile { + self.buffer.profile() + } + /// Returns the editable live-prompt input. pub fn input(&self) -> &Entity { &self.input @@ -95,41 +52,23 @@ impl CommandTerminalState { } /// Returns submitted command blocks in display order. - pub fn blocks(&self) -> &VecDeque { - &self.blocks + pub fn blocks(&self) -> &VecDeque { + self.buffer.blocks() } /// Returns the prompt used for the next submission. pub fn prompt(&self) -> &TerminalLine { - &self.prompt + self.buffer.prompt() } /// Returns whether a command currently owns the foreground slot. pub const fn is_busy(&self) -> bool { - self.active.is_some() + self.buffer.is_busy() } /// Freezes the live prompt into a running command block. - /// - /// # Parameters - /// - /// * `input` is the exact submitted command line. - /// * `cx` updates the input primitive and schedules viewport repainting. - /// - /// # Returns - /// - /// A terminal-local identity used to attach subsequent output and results. - pub fn begin_submission(&mut self, input: String, cx: &mut Context) -> CommandTerminalId { - let id = self.reserve_block(); - self.blocks.push_back(CommandTerminalBlock { - id, - prompt: self.prompt.clone(), - input, - output: Vec::new(), - result: Vec::new(), - status: CommandTerminalStatus::Running, - }); - self.active = Some(id); + pub fn begin_submission(&mut self, input: String, cx: &mut Context) -> TerminalBlockId { + let id = self.buffer.begin_submission(input); self.input.update(cx, |input, cx| { input.clear(cx); input.set_disabled(true, cx); @@ -139,110 +78,57 @@ impl CommandTerminalState { } /// Appends a finished block for output that no command produced. - /// - /// The live prompt keeps its text and stays editable, so completion - /// candidates can be listed above the line still being edited. - /// - /// # Parameters - /// - /// * `input` is the unfinished line the output was produced for. - /// * `lines` are the output rows appended below the echoed line. - /// * `cx` reveals the new tail and schedules viewport repainting. - /// - /// # Returns - /// - /// This function returns `()` and never claims the foreground slot. pub fn push_notice(&mut self, input: String, lines: Vec, cx: &mut Context) { - let id = self.reserve_block(); - self.blocks.push_back(CommandTerminalBlock { - id, - prompt: self.prompt.clone(), - input, - output: Vec::new(), - result: lines, - status: CommandTerminalStatus::Succeeded, - }); + self.buffer.push_notice(input, lines); self.reveal_tail(cx); } /// Replaces transient output for one command without growing scrollback. - /// - /// # Parameters - /// - /// * `id` identifies the command block receiving executor output. - /// * `output` contains the current progress, messages, and artifacts. - /// * `cx` reveals changed output and schedules repainting. - /// - /// # Returns - /// - /// This function returns `()` and leaves unknown command identities unchanged. - pub fn set_output(&mut self, id: CommandTerminalId, output: Vec, cx: &mut Context) { - if let Some(block) = self.blocks.iter_mut().find(|block| block.id == id) { - if block.output == output { - return; - } - block.output = output; - self.reveal_tail(cx); + pub fn set_output(&mut self, id: TerminalBlockId, output: Vec, cx: &mut Context) { + if self + .buffer + .blocks() + .iter() + .find(|block| block.id == id) + .is_some_and(|block| block.output == output) + { + return; } + self.buffer.set_output(id, output); + self.reveal_tail(cx); } /// Finishes one block and restores the editable live prompt. - /// - /// # Parameters - /// - /// * `id` identifies the running command block. - /// * `status` is its terminal presentation state. - /// * `result` contains final frontend-formatted result lines. - /// * `cx` re-enables input and schedules tail repainting. - /// - /// # Returns - /// - /// This function returns `()` and leaves unknown command identities unchanged. pub fn finish( &mut self, - id: CommandTerminalId, - status: CommandTerminalStatus, + id: TerminalBlockId, + status: TerminalBlockStatus, result: Vec, cx: &mut Context, ) { - let Some(block) = self.blocks.iter_mut().find(|block| block.id == id) else { - return; - }; - block.status = status; - block.result = result; - if self.active == Some(id) { - self.active = None; + let was_active = self.buffer.is_active(id); + self.buffer.finish(id, status, result); + if was_active { self.input.update(cx, |input, cx| input.set_disabled(false, cx)); } self.reveal_tail(cx); } /// Rejects one frozen input line and displays its error below the command. - /// - /// # Parameters - /// - /// * `id` identifies the command block created for the attempted input. - /// * `message` describes why parsing or routing rejected the command. - /// * `cx` restores input and schedules terminal repainting. - /// - /// # Returns - /// - /// This function returns `()` after converting the message into an error row. - pub fn reject(&mut self, id: CommandTerminalId, message: String, cx: &mut Context) { + pub fn reject(&mut self, id: TerminalBlockId, message: String, cx: &mut Context) { self.finish( id, - CommandTerminalStatus::Rejected, - vec![TerminalLine::new([crate::primitive::terminal::TerminalSpan::error( - format!("error: {message}"), - )])], + TerminalBlockStatus::Rejected, + vec![TerminalLine::new([chitin_terminal::TerminalSpan::error(format!( + "error: {message}" + ))])], cx, ); } /// Clears submitted blocks and restores the editable live prompt. pub fn clear(&mut self, cx: &mut Context) { - self.blocks.clear(); - self.active = None; + self.buffer.clear(); self.input.update(cx, |input, cx| { input.clear(cx); input.set_disabled(false, cx); @@ -252,17 +138,7 @@ impl CommandTerminalState { /// Replaces the prompt used by subsequent commands. pub fn set_prompt(&mut self, prompt: TerminalLine) { - self.prompt = prompt; - } - - /// Reserves the next block identity, evicting the oldest block at capacity. - fn reserve_block(&mut self) -> CommandTerminalId { - let id = CommandTerminalId(self.next_id); - self.next_id = self.next_id.saturating_add(1); - if self.blocks.len() == MAX_COMMAND_BLOCKS { - self.blocks.pop_front(); - } - id + self.buffer.set_prompt(prompt); } /// Requests that the newest command and live prompt remain visible. diff --git a/crates/chitin-ui/src/composite/command_terminal/render.rs b/crates/chitin-ui/src/composite/command_terminal/render.rs index 47b127dd..087c4053 100644 --- a/crates/chitin-ui/src/composite/command_terminal/render.rs +++ b/crates/chitin-ui/src/composite/command_terminal/render.rs @@ -87,7 +87,7 @@ fn render_live_prompt( prompt .spans() .iter() - .map(|span| div().text_color(span.tone.color(theme)).child(span.text.clone())), + .map(|span| div().text_color(tone_color(span.tone, theme)).child(span.text.clone())), ) .child( TextInput::new(input) @@ -104,3 +104,16 @@ fn render_live_prompt( .full_width(true), ) } + +/// Resolves a frontend-independent terminal tone through the active UI theme. +fn tone_color(tone: chitin_terminal::TerminalTone, theme: UIThemes) -> gpui::Rgba { + match tone { + chitin_terminal::TerminalTone::Primary => theme.text.primary, + chitin_terminal::TerminalTone::Secondary => theme.text.secondary, + chitin_terminal::TerminalTone::Accent => theme.accent.primary, + chitin_terminal::TerminalTone::Success => theme.text.success, + chitin_terminal::TerminalTone::Warning => theme.text.warning, + chitin_terminal::TerminalTone::Error => theme.text.error, + chitin_terminal::TerminalTone::Info => theme.text.info, + } +} diff --git a/crates/chitin-ui/src/primitive/terminal/emulator.rs b/crates/chitin-ui/src/primitive/terminal/emulator.rs new file mode 100644 index 00000000..991d8b3c --- /dev/null +++ b/crates/chitin-ui/src/primitive/terminal/emulator.rs @@ -0,0 +1,465 @@ +//! GPUI rendering and input handling for a VT terminal session. + +use std::{sync::Arc, time::Duration}; + +use chitin_terminal::{ + TerminalCell, TerminalCellAttributes, TerminalColor, TerminalEvent, TerminalNamedColor, TerminalSession, + TerminalSessionError, TerminalSize, TerminalSnapshot, +}; +use gpui::{ + App, AsyncApp, Bounds, Context, Entity, EventEmitter, FocusHandle, Font, FontFallbacks, FontFeatures, FontWeight, + Hsla, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, ParentElement, Pixels, RenderOnce, SharedString, + Styled, Task, TextRun, Timer, WeakEntity, Window, div, font, prelude::*, px, rgb, +}; + +use crate::themes::{UIThemes, builtins}; + +const FONT_SIZE: f32 = 15.0; +const LINE_HEIGHT_MULTIPLIER: f32 = 1.4; +const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(16); + +/// Semantic events emitted by the terminal emulator primitive. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalEmulatorEvent { + /// Input bytes were delivered to the active terminal backend. + InputWritten, +} + +/// Persistent focus, backend, and VT state for a rendered terminal emulator. +pub struct TerminalEmulatorState { + session: TerminalSession, + focus: FocusHandle, + size: TerminalSize, + status: Option, + _event_task: Task<()>, +} + +impl TerminalEmulatorState { + /// Creates state around an existing terminal session. + pub fn new(session: TerminalSession, size: TerminalSize, cx: &mut Context) -> Self { + let event_task = cx.spawn(|this: WeakEntity, async_cx: &mut AsyncApp| { + let mut async_cx = async_cx.clone(); + async move { + loop { + Timer::after(EVENT_POLL_INTERVAL).await; + let should_continue = this + .update(&mut async_cx, |state, cx| { + state.process_events(cx); + true + }) + .unwrap_or(false); + if !should_continue { + break; + } + } + } + }); + Self { + session, + focus: cx.focus_handle(), + size, + status: None, + _event_task: event_task, + } + } + + /// Returns the terminal backend profile. + pub fn profile(&self) -> chitin_terminal::TerminalProfile { + self.session.profile() + } + + /// Returns the focus handle owned by the terminal surface. + pub fn focus_handle(&self) -> &FocusHandle { + &self.focus + } + + /// Writes already encoded bytes to the active terminal backend. + pub fn write(&self, bytes: &[u8]) -> Result<(), TerminalSessionError> { + self.session.write(bytes) + } + + fn process_events(&mut self, cx: &mut Context) { + let events = self.session.drain_events(); + if events.is_empty() { + return; + } + for event in events { + match event { + TerminalEvent::Exited(code) => self.status = Some(format!("process exited with code {code}")), + TerminalEvent::Error(error) => self.status = Some(error), + TerminalEvent::Render | TerminalEvent::Title(_) | TerminalEvent::Bell => {} + } + } + cx.notify(); + } + + fn resize(&mut self, bounds: Bounds, metrics: TerminalMetrics) { + let next = terminal_size_for_bounds(bounds, metrics); + if next == self.size { + return; + } + match self.session.resize(next) { + Ok(()) => self.size = next, + Err(error) => self.status = Some(error.to_string()), + } + } + + fn handle_key(&mut self, event: &KeyDownEvent, cx: &mut Context) { + let Some(bytes) = encode_key(event) else { + return; + }; + match self.session.write(&bytes) { + Ok(()) => cx.emit(TerminalEmulatorEvent::InputWritten), + Err(error) => self.status = Some(error.to_string()), + } + cx.stop_propagation(); + cx.notify(); + } +} + +impl EventEmitter for TerminalEmulatorState {} + +/// Interactive GPUI surface for either a PTY or in-process terminal backend. +#[derive(IntoElement)] +pub struct TerminalEmulator { + state: Entity, + theme: UIThemes, + font_family: SharedString, +} + +impl TerminalEmulator { + /// Creates a terminal emulator bound to persistent session state. + pub fn new(state: Entity) -> Self { + Self { + state, + theme: builtins::dark(), + font_family: "monospace".into(), + } + } + + /// Sets the semantic UI theme. + pub fn theme(mut self, theme: UIThemes) -> Self { + self.theme = theme; + self + } + + /// Sets the upright monospace font used by the character grid. + pub fn font_family(mut self, font_family: impl Into) -> Self { + self.font_family = font_family.into(); + self + } +} + +impl RenderOnce for TerminalEmulator { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let metrics = measure_terminal_metrics(window, self.font_family.clone()); + let snapshot = self.state.read(cx).session.snapshot(); + let focus = self.state.read(cx).focus.clone(); + let state_for_mouse = self.state.clone(); + let state_for_keys = self.state.clone(); + let state_for_layout = self.state.clone(); + let theme = self.theme; + let rows = (0..snapshot.rows).map(move |row| { + div().flex().h(metrics.cell_height).whitespace_nowrap().children( + build_row_runs(&snapshot, row) + .into_iter() + .map(move |run| render_run(run, metrics, theme)), + ) + }); + + div() + .flex() + .flex_col() + .size_full() + .overflow_hidden() + .track_focus(&focus) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + let focus = state_for_mouse.read(cx).focus.clone(); + window.focus(&focus, cx); + }) + .on_key_down(move |event, _, cx| { + state_for_keys.update(cx, |state, cx| state.handle_key(event, cx)); + }) + .on_children_prepainted(move |bounds, _, cx| { + let Some(bounds) = bounds.first().copied() else { + return; + }; + state_for_layout.update(cx, |state, _| state.resize(bounds, metrics)); + }) + .bg(theme.background.primary) + .font(terminal_font(self.font_family)) + .text_size(metrics.font_size) + .line_height(metrics.cell_height) + .child(div().flex().flex_col().size_full().children(rows)) + } +} + +#[derive(Clone, Copy)] +struct TerminalMetrics { + cell_width: Pixels, + cell_height: Pixels, + font_size: Pixels, +} + +fn measure_terminal_metrics(window: &mut Window, family: SharedString) -> TerminalMetrics { + let font_size = px(FONT_SIZE); + let regular_width = measure_cell_width(window, family.clone(), font_size, FontWeight::NORMAL); + let bold_width = measure_cell_width(window, family, font_size, FontWeight::BOLD); + TerminalMetrics { + cell_width: regular_width.max(bold_width).max(px(1.0)), + cell_height: px((FONT_SIZE * LINE_HEIGHT_MULTIPLIER).round()).max(px(1.0)), + font_size, + } +} + +fn measure_cell_width(window: &mut Window, family: SharedString, font_size: Pixels, weight: FontWeight) -> Pixels { + let mut terminal_font = terminal_font(family); + terminal_font.weight = weight; + window + .text_system() + .shape_line( + SharedString::new_static("M"), + font_size, + &[TextRun { + len: 1, + font: terminal_font, + color: Hsla::default().into(), + ..TextRun::default() + }], + None, + ) + .width +} + +fn terminal_size_for_bounds(bounds: Bounds, metrics: TerminalMetrics) -> TerminalSize { + let cell_width = f32::from(metrics.cell_width); + let cell_height = f32::from(metrics.cell_height); + TerminalSize::new( + (f32::from(bounds.size.width) / cell_width).floor().max(1.0) as usize, + (f32::from(bounds.size.height) / cell_height).floor().max(1.0) as usize, + cell_width.ceil() as u16, + cell_height.ceil() as u16, + ) +} + +fn terminal_font(family: SharedString) -> Font { + let mut terminal_font = font(family); + terminal_font.features = FontFeatures(Arc::new(vec![ + ("calt".into(), 0), + ("liga".into(), 0), + ("clig".into(), 0), + ])); + terminal_font.fallbacks = Some(FontFallbacks::from_fonts(vec![ + "Symbols Nerd Font".into(), + "Noto Color Emoji".into(), + "Segoe UI Emoji".into(), + "Apple Color Emoji".into(), + "Noto Sans Symbols 2".into(), + ])); + terminal_font +} + +struct TerminalRun { + text: String, + columns: usize, + foreground: TerminalColor, + background: TerminalColor, + attributes: TerminalCellAttributes, + cursor: bool, +} + +impl TerminalRun { + fn new(cell: &TerminalCell, cursor: bool) -> Self { + let mut run = Self { + text: String::new(), + columns: 0, + foreground: cell.foreground, + background: cell.background, + attributes: upright_attributes(cell.attributes), + cursor, + }; + run.push(cell); + run + } + + fn matches(&self, cell: &TerminalCell, cursor: bool) -> bool { + self.foreground == cell.foreground + && self.background == cell.background + && self.attributes == upright_attributes(cell.attributes) + && self.cursor == cursor + } + + fn push(&mut self, cell: &TerminalCell) { + self.columns += 1; + if cell.attributes.hidden || cell.attributes.wide_spacer { + self.text.push(' '); + return; + } + self.text.push(cell.character); + self.text.extend(cell.combining_characters.iter()); + } +} + +fn build_row_runs(snapshot: &TerminalSnapshot, row: usize) -> Vec { + let mut runs: Vec = Vec::new(); + let visible_columns = (0..snapshot.columns) + .rfind(|&column| { + snapshot.cursor == Some((row, column)) || snapshot.cell(row, column).is_some_and(|cell| !is_default_blank(cell)) + }) + .map_or(0, |column| column + 1); + for column in 0..visible_columns { + let Some(cell) = snapshot.cell(row, column) else { + continue; + }; + let cursor = snapshot.cursor == Some((row, column)); + if let Some(run) = runs.last_mut().filter(|run| run.matches(cell, cursor)) { + run.push(cell); + } else { + runs.push(TerminalRun::new(cell, cursor)); + } + } + runs +} + +fn is_default_blank(cell: &TerminalCell) -> bool { + cell.character == ' ' + && cell.combining_characters.is_empty() + && cell.foreground == TerminalColor::Named(TerminalNamedColor::Foreground) + && cell.background == TerminalColor::Named(TerminalNamedColor::Background) + && upright_attributes(cell.attributes) == TerminalCellAttributes::default() +} + +fn upright_attributes(mut attributes: TerminalCellAttributes) -> TerminalCellAttributes { + attributes.italic = false; + attributes +} + +fn render_run(run: TerminalRun, metrics: TerminalMetrics, theme: UIThemes) -> impl IntoElement { + let (mut foreground, mut background) = ( + resolve_color(run.foreground, theme), + resolve_color(run.background, theme), + ); + if run.attributes.inverse { + std::mem::swap(&mut foreground, &mut background); + } + if run.attributes.dim { + foreground = foreground.opacity(0.62); + } + if run.cursor { + std::mem::swap(&mut foreground, &mut background); + } + div() + .flex_none() + .w(metrics.cell_width * run.columns as f32) + .h(metrics.cell_height) + .whitespace_nowrap() + .bg(background) + .text_color(foreground) + .when(run.attributes.bold, |element| element.font_weight(FontWeight::BOLD)) + .when(run.attributes.underline, |element| element.underline()) + .child(run.text) +} + +fn resolve_color(color: TerminalColor, theme: UIThemes) -> Hsla { + match color { + TerminalColor::Rgb { red, green, blue } => { + rgb((u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue)).into() + } + TerminalColor::Indexed(index) => indexed_color(index), + TerminalColor::Named(TerminalNamedColor::Foreground) => theme.text.primary.into(), + TerminalColor::Named(TerminalNamedColor::Background) => theme.background.primary.into(), + TerminalColor::Named(named) => named_color(named), + } +} + +fn named_color(color: TerminalNamedColor) -> Hsla { + let value = match color { + TerminalNamedColor::Black | TerminalNamedColor::DimBlack => 0x1f2329, + TerminalNamedColor::Red | TerminalNamedColor::DimRed => 0xff5f56, + TerminalNamedColor::Green | TerminalNamedColor::DimGreen => 0x63d17c, + TerminalNamedColor::Yellow | TerminalNamedColor::DimYellow => 0xe5c07b, + TerminalNamedColor::Blue | TerminalNamedColor::DimBlue => 0x61afef, + TerminalNamedColor::Magenta | TerminalNamedColor::DimMagenta => 0xc678dd, + TerminalNamedColor::Cyan | TerminalNamedColor::DimCyan => 0x56b6c2, + TerminalNamedColor::White | TerminalNamedColor::DimWhite => 0xabb2bf, + TerminalNamedColor::BrightBlack => 0x5c6370, + TerminalNamedColor::BrightRed => 0xff7b72, + TerminalNamedColor::BrightGreen => 0x7ee787, + TerminalNamedColor::BrightYellow => 0xf2cc60, + TerminalNamedColor::BrightBlue => 0x79c0ff, + TerminalNamedColor::BrightMagenta => 0xd2a8ff, + TerminalNamedColor::BrightCyan => 0x76e3ea, + TerminalNamedColor::BrightWhite | TerminalNamedColor::BrightForeground => 0xffffff, + TerminalNamedColor::Foreground | TerminalNamedColor::Cursor => 0xd0d7de, + TerminalNamedColor::Background => 0x101216, + TerminalNamedColor::DimForeground => 0x8b949e, + }; + rgb(value).into() +} + +fn indexed_color(index: u8) -> Hsla { + const ANSI: [u32; 16] = [ + 0x1f2329, 0xff5f56, 0x63d17c, 0xe5c07b, 0x61afef, 0xc678dd, 0x56b6c2, 0xabb2bf, 0x5c6370, 0xff7b72, 0x7ee787, + 0xf2cc60, 0x79c0ff, 0xd2a8ff, 0x76e3ea, 0xffffff, + ]; + let value = match index { + 0..=15 => ANSI[index as usize], + 16..=231 => { + let cube = index - 16; + let component = |value: u8| if value == 0 { 0 } else { 55 + value * 40 }; + let red = component(cube / 36); + let green = component((cube % 36) / 6); + let blue = component(cube % 6); + (u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue) + } + 232..=255 => { + let gray = 8 + (index - 232) * 10; + (u32::from(gray) << 16) | (u32::from(gray) << 8) | u32::from(gray) + } + }; + rgb(value).into() +} + +fn encode_key(event: &KeyDownEvent) -> Option> { + let key = event.keystroke.key.as_str(); + let modifiers = event.keystroke.modifiers; + let special = match key { + "enter" => Some(b"\r".as_slice()), + "backspace" => Some(b"\x7f".as_slice()), + "tab" if modifiers.shift => Some(b"\x1b[Z".as_slice()), + "tab" => Some(b"\t".as_slice()), + "escape" => Some(b"\x1b".as_slice()), + "up" => Some(b"\x1b[A".as_slice()), + "down" => Some(b"\x1b[B".as_slice()), + "right" => Some(b"\x1b[C".as_slice()), + "left" => Some(b"\x1b[D".as_slice()), + "home" => Some(b"\x1b[H".as_slice()), + "end" => Some(b"\x1b[F".as_slice()), + "pageup" => Some(b"\x1b[5~".as_slice()), + "pagedown" => Some(b"\x1b[6~".as_slice()), + "delete" => Some(b"\x1b[3~".as_slice()), + "insert" => Some(b"\x1b[2~".as_slice()), + _ => None, + }; + if let Some(special) = special { + return Some(special.to_vec()); + } + if modifiers.platform { + return None; + } + let text = event.keystroke.key_char.as_deref()?; + let mut bytes = if modifiers.control && text.len() == 1 { + let byte = text.as_bytes()[0].to_ascii_uppercase(); + if byte.is_ascii_uppercase() { + vec![byte & 0x1f] + } else { + return None; + } + } else { + text.as_bytes().to_vec() + }; + if modifiers.alt { + bytes.insert(0, 0x1b); + } + Some(bytes) +} diff --git a/crates/chitin-ui/src/primitive/terminal/mod.rs b/crates/chitin-ui/src/primitive/terminal/mod.rs index b85ab112..49b1bebf 100644 --- a/crates/chitin-ui/src/primitive/terminal/mod.rs +++ b/crates/chitin-ui/src/primitive/terminal/mod.rs @@ -3,10 +3,12 @@ //! This primitive has no shell or command semantics. Callers provide styled //! lines and may attach one interactive tail element, such as a live prompt. +mod emulator; mod model; mod render; mod state; +pub use emulator::{TerminalEmulator, TerminalEmulatorEvent, TerminalEmulatorState}; pub use model::{TerminalLine, TerminalSpan, TerminalTone}; pub use render::TerminalViewport; pub use state::TerminalViewportState; diff --git a/crates/chitin-ui/src/primitive/terminal/model.rs b/crates/chitin-ui/src/primitive/terminal/model.rs index 60032fc1..67f7cddd 100644 --- a/crates/chitin-ui/src/primitive/terminal/model.rs +++ b/crates/chitin-ui/src/primitive/terminal/model.rs @@ -1,180 +1,3 @@ -//! Render-neutral terminal row content. +//! Re-exports of the frontend-independent terminal text model. -use gpui::SharedString; - -use crate::themes::UIThemes; - -/// Semantic foreground tone for one terminal span. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum TerminalTone { - /// Primary terminal content. - #[default] - Primary, - /// De-emphasized paths, metadata, and hints. - Secondary, - /// Prompt and other interactive accents. - Accent, - /// Successful result content. - Success, - /// Recoverable warning content. - Warning, - /// Failed or rejected command content. - Error, - /// Informational and progress content. - Info, -} - -impl TerminalTone { - /// Resolves this semantic tone through a UI theme. - pub(crate) const fn color(self, theme: UIThemes) -> gpui::Rgba { - match self { - Self::Primary => theme.text.primary, - Self::Secondary => theme.text.secondary, - Self::Accent => theme.accent.primary, - Self::Success => theme.text.success, - Self::Warning => theme.text.warning, - Self::Error => theme.text.error, - Self::Info => theme.text.info, - } - } -} - -/// One styled, indivisible text span inside a terminal line. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TerminalSpan { - /// Text rendered by this span. - pub text: SharedString, - /// Semantic foreground tone. - pub tone: TerminalTone, -} - -impl TerminalSpan { - /// Creates a span with a semantic foreground tone. - pub fn new(text: impl Into, tone: TerminalTone) -> Self { - Self { - text: text.into(), - tone, - } - } - - /// Creates primary terminal content. - pub fn primary(text: impl Into) -> Self { - Self::new(text, TerminalTone::Primary) - } - - /// Creates de-emphasized terminal content. - pub fn secondary(text: impl Into) -> Self { - Self::new(text, TerminalTone::Secondary) - } - - /// Creates prompt or interactive accent content. - pub fn accent(text: impl Into) -> Self { - Self::new(text, TerminalTone::Accent) - } - - /// Creates successful terminal content. - pub fn success(text: impl Into) -> Self { - Self::new(text, TerminalTone::Success) - } - - /// Creates warning terminal content. - pub fn warning(text: impl Into) -> Self { - Self::new(text, TerminalTone::Warning) - } - - /// Creates failed terminal content. - pub fn error(text: impl Into) -> Self { - Self::new(text, TerminalTone::Error) - } - - /// Creates informational terminal content. - pub fn info(text: impl Into) -> Self { - Self::new(text, TerminalTone::Info) - } -} - -/// One soft-wrapping row of terminal spans. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct TerminalLine { - spans: Vec, -} - -impl TerminalLine { - /// Creates a line from ordered styled spans. - pub fn new(spans: impl IntoIterator) -> Self { - Self { - spans: spans.into_iter().collect(), - } - } - - /// Creates a line containing primary text. - pub fn plain(text: impl Into) -> Self { - Self::new([TerminalSpan::primary(text)]) - } - - /// Returns the ordered styled spans. - pub fn spans(&self) -> &[TerminalSpan] { - &self.spans - } - - /// Appends one styled span. - pub fn push(&mut self, span: TerminalSpan) { - self.spans.push(span); - } - - /// Returns whether the line contains no text spans. - pub fn is_empty(&self) -> bool { - self.spans.is_empty() - } - - /// Splits embedded newline characters into independently laid-out rows. - pub(super) fn into_rows(self) -> Vec { - if self.spans.iter().all(|span| !span.text.contains('\n')) { - return vec![self]; - } - - let mut rows = Vec::new(); - let mut current = Vec::new(); - for span in self.spans { - for (index, text) in span.text.split('\n').enumerate() { - if index > 0 { - rows.push(Self { - spans: std::mem::take(&mut current), - }); - } - let text = text.strip_suffix('\r').unwrap_or(text); - if !text.is_empty() { - current.push(TerminalSpan::new(text.to_owned(), span.tone)); - } - } - } - rows.push(Self { spans: current }); - rows - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn line_should_preserve_span_order() { - let line = TerminalLine::new([TerminalSpan::accent("❯ "), TerminalSpan::primary("help")]); - - assert_eq!(line.spans()[1].text, "help"); - } - - #[test] - fn multiline_span_should_split_into_independent_styled_rows() { - let line = TerminalLine::new([ - TerminalSpan::error("error: missing argument\n"), - TerminalSpan::secondary(" --id "), - ]); - - let rows = line.into_rows(); - - assert_eq!(rows.len(), 2); - assert_eq!(rows[0].spans()[0], TerminalSpan::error("error: missing argument")); - assert_eq!(rows[1].spans()[0], TerminalSpan::secondary(" --id ")); - } -} +pub use chitin_terminal::{TerminalLine, TerminalSpan, TerminalTone}; diff --git a/crates/chitin-ui/src/primitive/terminal/render.rs b/crates/chitin-ui/src/primitive/terminal/render.rs index 53942ad0..cce1b964 100644 --- a/crates/chitin-ui/src/primitive/terminal/render.rs +++ b/crates/chitin-ui/src/primitive/terminal/render.rs @@ -90,6 +90,19 @@ fn render_line(line: TerminalLine, theme: UIThemes) -> impl IntoElement { line .spans() .iter() - .map(|span| div().text_color(span.tone.color(theme)).child(span.text.clone())), + .map(|span| div().text_color(tone_color(span.tone, theme)).child(span.text.clone())), ) } + +/// Resolves a frontend-independent terminal tone through the active UI theme. +fn tone_color(tone: super::TerminalTone, theme: UIThemes) -> gpui::Rgba { + match tone { + super::TerminalTone::Primary => theme.text.primary, + super::TerminalTone::Secondary => theme.text.secondary, + super::TerminalTone::Accent => theme.accent.primary, + super::TerminalTone::Success => theme.text.success, + super::TerminalTone::Warning => theme.text.warning, + super::TerminalTone::Error => theme.text.error, + super::TerminalTone::Info => theme.text.info, + } +} From 46588b3ab8db5d22a48aff582d917abe758dd03f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 28 Sep 2026 23:57:36 +0800 Subject: [PATCH 19/32] feat(ui): extend the select and bottom dock primitives for compact triggers Add a header actions slot to the bottom dock so a tool can place its own controls on the title row ahead of the shared close button. Extend the select primitive with a trigger icon and indicator toggle, right-aligned popup anchoring, and style overrides for trigger padding, popup width, and trigger icon size, expose explicit selection clearing, and give the transparent variant a border that is actually transparent. An icon-only trigger now centers its icon rather than laying it out beside an empty label. The empty label previously cost the layout both its gap and its text node, and flex recovered that space by shrinking the icon, so an icon-only trigger rendered smaller than requested and pinned to the padding edge. --- .../src/composite/bottom_dock/render.rs | 39 ++++-- .../src/primitive/input/select/render.rs | 131 ++++++++++++++++-- .../src/primitive/input/select/state.rs | 5 + 3 files changed, 152 insertions(+), 23 deletions(-) diff --git a/crates/chitin-ui/src/composite/bottom_dock/render.rs b/crates/chitin-ui/src/composite/bottom_dock/render.rs index 63e01e21..747be268 100644 --- a/crates/chitin-ui/src/composite/bottom_dock/render.rs +++ b/crates/chitin-ui/src/composite/bottom_dock/render.rs @@ -66,6 +66,7 @@ pub struct BottomDock { height: Pixels, theme: UIThemes, resize: Option, + header_actions: Option, child: Option, } @@ -78,6 +79,7 @@ impl BottomDock { height: super::DEFAULT_BOTTOM_DOCK_HEIGHT, theme: builtins::dark(), resize: None, + header_actions: None, child: None, } } @@ -100,6 +102,12 @@ impl BottomDock { self } + /// Adds tool-specific controls before the dock's close button. + pub fn header_actions(mut self, actions: impl IntoElement) -> Self { + self.header_actions = Some(actions.into_any_element()); + self + } + /// Sets the active dock item's content. pub fn child(mut self, child: impl IntoElement) -> Self { self.child = Some(child.into_any_element()); @@ -143,17 +151,26 @@ impl RenderOnce for BottomDock { .child(self.title), ) .child( - Button::new(self.close) - .theme(theme) - .variant(ButtonVariant::Transparent) - .size(ButtonSize::Small) - .style( - ButtonStyle::new() - .width(px(26.0)) - .height(px(24.0)) - .horizontal_padding(px(0.0)), - ) - .child(Icon::new("icons/window-close.svg").size(px(14.0)).theme(theme)), + div() + .flex() + .items_center() + .gap_1() + .when_some(self.header_actions, |actions, header_actions| { + actions.child(header_actions) + }) + .child( + Button::new(self.close) + .theme(theme) + .variant(ButtonVariant::Transparent) + .size(ButtonSize::Small) + .style( + ButtonStyle::new() + .width(px(26.0)) + .height(px(24.0)) + .horizontal_padding(px(0.0)), + ) + .child(Icon::new("icons/window-close.svg").size(px(14.0)).theme(theme)), + ), ), ) .when_some(self.child, |dock, child| dock.child(child)) diff --git a/crates/chitin-ui/src/primitive/input/select/render.rs b/crates/chitin-ui/src/primitive/input/select/render.rs index 057d0af3..c6e3c98e 100644 --- a/crates/chitin-ui/src/primitive/input/select/render.rs +++ b/crates/chitin-ui/src/primitive/input/select/render.rs @@ -24,6 +24,8 @@ const DEFAULT_PADDING_X: Pixels = px(8.0); const DEFAULT_MENU_MAX_HEIGHT: Pixels = px(240.0); /// Default height of each item const ITEM_HEIGHT: Pixels = px(30.0); +/// Default rendered size of the icon shown inside a trigger. +const DEFAULT_TRIGGER_ICON_SIZE: Pixels = px(14.0); /// Default height of each label in the items, notice it's different from the /// font size of label in the items. const LABEL_HEIGHT: Pixels = px(24.0); @@ -47,8 +49,14 @@ pub enum SelectInputVariant { pub struct SelectInputStyle { /// Explicit trigger width, when visual layout requires one. width: Option, + /// Optional horizontal padding for a compact trigger. + trigger_padding_x: Option, + /// Optional popup width independent of a compact trigger. + menu_width: Option, /// Maximum popup height before its content scrolls. menu_max_height: Option, + /// Optional rendered size for the trigger's leading icon. + trigger_icon_size: Option, } impl SelectInputStyle { @@ -63,11 +71,29 @@ impl SelectInputStyle { self } + /// Sets a popup width independent of the trigger width. + pub fn menu_width(mut self, width: Pixels) -> Self { + self.menu_width = Some(width); + self + } + + /// Sets the trigger's horizontal padding. + pub fn trigger_padding_x(mut self, padding: Pixels) -> Self { + self.trigger_padding_x = Some(padding); + self + } + /// Sets the maximum visible popup height before its option list scrolls. pub fn menu_max_height(mut self, height: Pixels) -> Self { self.menu_max_height = Some(height); self } + + /// Sets the rendered size of the trigger's leading icon. + pub fn trigger_icon_size(mut self, size: Pixels) -> Self { + self.trigger_icon_size = Some(size); + self + } } /// Visual size for a [`Select`]. @@ -233,10 +259,13 @@ impl RenderOnce for Select { let popup_layout = SelectPopupLayout { trigger: metrics, configured_width: width, + menu_width: self.style.menu_width, + align_end: self.content.align_end, trigger_bounds, viewport_size, }; let value = self.trigger.value; + let show_indicator = self.trigger.show_indicator; div() .relative() @@ -255,7 +284,7 @@ impl RenderOnce for Select { .items_center() .justify_between() .h(metrics.height) - .px(DEFAULT_PADDING_X) + .px(self.style.trigger_padding_x.unwrap_or(DEFAULT_PADDING_X)) .rounded_sm() .border_1() .border_color(colors.border) @@ -286,12 +315,16 @@ impl RenderOnce for Select { .track_focus(&focus_handle) .child(render_value( value, + self.trigger.icon, selected_label, selected_icon, + self.style.trigger_icon_size.unwrap_or(DEFAULT_TRIGGER_ICON_SIZE), colors.foreground, self.theme.text.disabled, )) - .child(Icon::new("icons/tree-expand.svg").theme(self.theme)), + .when(show_indicator, |trigger| { + trigger.child(Icon::new("icons/tree-expand.svg").theme(self.theme)) + }), ), ) .when(open, |parent| { @@ -323,10 +356,24 @@ impl RenderOnce for Select { } /// Trigger popup panel for a [`Select`]. -#[derive(Clone, Default)] +#[derive(Clone)] pub struct SelectTrigger { /// Value subtree displayed before the trigger icon. value: SelectValue, + /// Optional icon shown when the selector has no selected value. + icon: Option, + /// Whether the trigger draws the default trailing chevron. + show_indicator: bool, +} + +impl Default for SelectTrigger { + fn default() -> Self { + Self { + value: SelectValue::default(), + icon: None, + show_indicator: true, + } + } } impl SelectTrigger { @@ -340,6 +387,18 @@ impl SelectTrigger { self.value = value; self } + + /// Sets the icon shown while the selector has no selected value. + pub fn icon(mut self, path: impl Into) -> Self { + self.icon = Some(path.into()); + self + } + + /// Shows or hides the default trailing chevron. + pub fn show_indicator(mut self, show: bool) -> Self { + self.show_indicator = show; + self + } } /// Displays the selected label or a placeholder inside a [`SelectTrigger`]. @@ -378,6 +437,8 @@ pub struct SelectContent { position: SelectContentPosition, /// Ordered content nodes used for rendering and selection-state flattening. children: Vec, + /// Whether the popup's right edge aligns with the trigger's right edge. + align_end: bool, } impl SelectContent { @@ -392,6 +453,12 @@ impl SelectContent { self } + /// Aligns the popup's right edge with the trigger's right edge. + pub fn align_end(mut self) -> Self { + self.align_end = true; + self + } + /// Adds one grouped section to this content. pub fn group(mut self, group: SelectGroup) -> Self { self.children.push(SelectContentChild::Group(group)); @@ -638,8 +705,11 @@ impl SelectSeparator { /// # Parameters /// /// * `value` configures the placeholder displayed for an empty selection. +/// * `trigger_icon` is the icon shown while the selector has no selection. /// * `selected_label` is the current selected item label, when available. -/// * `foreground` colors selected text. +/// * `selected_icon` is the icon owned by the selected item, when available. +/// * `icon_size` is the rendered size of whichever icon is displayed. +/// * `foreground` colors selected text and icons. /// * `placeholder` colors the empty-state text. /// /// # Returns @@ -647,12 +717,16 @@ impl SelectSeparator { /// A flexible value element that leaves room for the trigger icon. fn render_value( value: SelectValue, + trigger_icon: Option, selected_label: Option, selected_icon: Option, + icon_size: Pixels, foreground: gpui::Rgba, placeholder: gpui::Rgba, ) -> Div { let selected = selected_label.is_some(); + let icon = selected_icon.or(trigger_icon); + let label = selected_label.unwrap_or(value.placeholder); let mut value_element = div() .flex() .items_center() @@ -660,10 +734,20 @@ fn render_value( .min_w_0() .flex_1() .text_color(if selected { foreground } else { placeholder }); - if let Some(path) = selected_icon { - value_element = value_element.child(Icon::new(path).size(px(14.0)).color(foreground)); + // An icon-only trigger carries no text to lay out, so centering the icon keeps + // it off the padding edge instead of left-aligning it against an empty label. + if label.is_empty() { + return match icon { + Some(path) => value_element + .justify_center() + .child(Icon::new(path).size(icon_size).color(foreground)), + None => value_element.child(label), + }; + } + if let Some(path) = icon { + value_element = value_element.child(Icon::new(path).size(icon_size).color(foreground)); } - value_element.child(selected_label.unwrap_or(value.placeholder)) + value_element.child(label) } /// Renders one popup content tree at its selected positioning mode. @@ -757,7 +841,12 @@ fn render_content( .theme(theme) .scroll_handle(interaction.scroll_handle); let popover = if let Some(trigger_bounds) = layout.trigger_bounds { - popover.anchor_position(trigger_bounds.origin) + let origin = if layout.align_end { + point(trigger_bounds.right() - layout.popup_width(), trigger_bounds.top()) + } else { + trigger_bounds.origin + }; + popover.anchor_position(origin) } else { popover }; @@ -911,6 +1000,10 @@ struct SelectPopupLayout { trigger: SelectInputMetrics, /// Configured width used until the trigger has been measured. configured_width: Pixels, + /// Explicit menu width for compact triggers. + menu_width: Option, + /// Whether the popup aligns its right edge with the trigger. + align_end: bool, /// Trigger bounds measured during the previous prepaint pass. trigger_bounds: Option>, /// Current window size used to constrain item-aligned content. @@ -1050,9 +1143,11 @@ impl SelectPopupLayout { /// Returns the measured trigger width, falling back before the first prepaint pass. fn popup_width(self) -> Pixels { - self - .trigger_bounds - .map_or(self.configured_width, |trigger_bounds| trigger_bounds.size.width) + self.menu_width.unwrap_or_else(|| { + self + .trigger_bounds + .map_or(self.configured_width, |trigger_bounds| trigger_bounds.size.width) + }) } /// Returns the measured trigger height used by conventional anchor placement. @@ -1112,7 +1207,7 @@ impl SelectInputColors { background: theme.background.primary, hover_background: theme.background.hover, foreground: theme.text.primary, - border: theme.border.muted, + border: builtins::TRANSPARENT, focus_border: theme.border.focus, }, } @@ -1197,6 +1292,8 @@ mod tests { font_size: px(13.0), }, configured_width: px(240.0), + menu_width: None, + align_end: false, trigger_bounds: Some(Bounds { origin: point(px(0.0), px(20.0)), size: Size { @@ -1232,6 +1329,8 @@ mod tests { font_size: px(13.0), }, configured_width: px(240.0), + menu_width: None, + align_end: false, trigger_bounds: Some(Bounds { origin: point(px(0.0), px(650.0)), size: Size { @@ -1267,6 +1366,8 @@ mod tests { font_size: px(13.0), }, configured_width: px(240.0), + menu_width: None, + align_end: false, trigger_bounds: Some(Bounds { origin: point(px(0.0), px(300.0)), size: Size { @@ -1299,6 +1400,8 @@ mod tests { font_size: px(13.0), }, configured_width: px(240.0), + menu_width: None, + align_end: false, trigger_bounds: Some(Bounds { origin: point(px(0.0), px(580.0)), size: Size { @@ -1329,6 +1432,8 @@ mod tests { font_size: px(13.0), }, configured_width: px(240.0), + menu_width: None, + align_end: false, trigger_bounds: Some(Bounds { origin: point(px(0.0), px(0.0)), size: Size { @@ -1353,6 +1458,8 @@ mod tests { font_size: px(13.0), }, configured_width: px(240.0), + menu_width: None, + align_end: false, trigger_bounds: Some(Bounds { origin: point(px(0.0), px(300.0)), size: Size { diff --git a/crates/chitin-ui/src/primitive/input/select/state.rs b/crates/chitin-ui/src/primitive/input/select/state.rs index be63a2c7..3ac89e74 100644 --- a/crates/chitin-ui/src/primitive/input/select/state.rs +++ b/crates/chitin-ui/src/primitive/input/select/state.rs @@ -260,6 +260,11 @@ impl SelectInputState { true } + /// Clears the current selection without changing the available options. + pub fn clear_selection(&mut self, cx: &mut Context) { + self.set_selected_id(None, cx); + } + /// Updates selector availability, closing the popup and emitting semantic events when it changes. pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { if self.disabled == disabled { From 352af49cec0575b238f8361cf970e32e6562a68b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 29 Sep 2026 00:01:08 +0800 Subject: [PATCH 20/32] feat(terminal): add selectable shell profiles to the session model Split the system shell profile into the default shell, Bash, and Fish, and give every profile a stable selector id, a display name, and a flag distinguishing in-process shells from PTY-backed ones. TerminalSession gains a profile-aware spawn that launches the requested program and records the profile on the session, rejecting the built-in profile because it runs in process rather than behind a pseudo-terminal. --- crates/chitin-terminal/src/model.rs | 48 +++++++++++++++++++++++++-- crates/chitin-terminal/src/session.rs | 47 ++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/crates/chitin-terminal/src/model.rs b/crates/chitin-terminal/src/model.rs index edd0c7d7..d39e66ed 100644 --- a/crates/chitin-terminal/src/model.rs +++ b/crates/chitin-terminal/src/model.rs @@ -7,18 +7,53 @@ use std::collections::VecDeque; pub enum TerminalProfile { /// Chitin's structured command language and application command executor. BuiltinShell, - /// The operating system's interactive shell attached to a native PTY. + /// The operating system's default interactive shell attached to a native PTY. SystemShell, + /// An explicit interactive Bash process attached to a native PTY. + Bash, + /// An explicit interactive Fish process attached to a native PTY. + Fish, } impl TerminalProfile { + /// Profiles offered by the desktop terminal selector, in display order. + pub const ALL: [Self; 4] = [Self::BuiltinShell, Self::SystemShell, Self::Bash, Self::Fish]; + + /// Returns the stable selector identifier for this profile. + pub const fn id(self) -> &'static str { + match self { + Self::BuiltinShell => "builtin-shell", + Self::SystemShell => "system-shell", + Self::Bash => "bash", + Self::Fish => "fish", + } + } + /// Returns the user-facing profile name. pub const fn label(self) -> &'static str { match self { Self::BuiltinShell => "Built-in shell", - Self::SystemShell => "System shell", + Self::SystemShell => "Default shell", + Self::Bash => "Bash", + Self::Fish => "Fish", } } + + /// Resolves a selector identifier to its terminal profile. + pub fn from_id(id: &str) -> Option { + match id { + "builtin-shell" => Some(Self::BuiltinShell), + "system-shell" => Some(Self::SystemShell), + "bash" => Some(Self::Bash), + "fish" => Some(Self::Fish), + _ => None, + } + } + + /// Reports whether this profile requires an operating-system pseudo-terminal. + pub const fn is_system_shell(self) -> bool { + !matches!(self, Self::BuiltinShell) + } } /// Semantic foreground tone shared by terminal renderers. @@ -326,6 +361,15 @@ impl TerminalBuffer { mod tests { use super::*; + #[test] + fn terminal_profiles_distinguish_in_process_and_native_shells() { + assert!(!TerminalProfile::BuiltinShell.is_system_shell()); + assert!(TerminalProfile::SystemShell.is_system_shell()); + assert!(TerminalProfile::Bash.is_system_shell()); + assert!(TerminalProfile::Fish.is_system_shell()); + assert_eq!(TerminalProfile::SystemShell.label(), "Default shell"); + } + #[test] fn buffer_should_preserve_profile_and_command_lifecycle() { let mut buffer = TerminalBuffer::new(TerminalProfile::BuiltinShell, TerminalLine::plain("chitin ❯ ")); diff --git a/crates/chitin-terminal/src/session.rs b/crates/chitin-terminal/src/session.rs index 23ca9456..f27065a0 100644 --- a/crates/chitin-terminal/src/session.rs +++ b/crates/chitin-terminal/src/session.rs @@ -158,7 +158,41 @@ impl TerminalSession { command.cwd(working_directory.as_ref()); command.env("TERM", "xterm-256color"); command.env("COLORTERM", "truecolor"); - Self::spawn(command, size) + Self::spawn_with_profile(command, TerminalProfile::SystemShell, size) + } + + /// Spawns a selected operating-system shell profile. + /// + /// # Parameters + /// + /// * `profile` selects the default shell, Bash, or Fish. + /// * `working_directory` is the initial directory for the child shell. + /// * `size` is the initial character-grid and cell-pixel size. + /// + /// # Returns + /// + /// A live PTY session tagged with the selected profile. The built-in profile + /// is rejected because it requires an in-process program endpoint. + pub fn spawn_system_shell( + profile: TerminalProfile, + working_directory: impl AsRef, + size: TerminalSize, + ) -> Result { + let mut command = match profile { + TerminalProfile::SystemShell => CommandBuilder::new_default_prog(), + TerminalProfile::Bash => CommandBuilder::new("bash"), + TerminalProfile::Fish => CommandBuilder::new("fish"), + TerminalProfile::BuiltinShell => { + return Err(TerminalSessionError::Pty { + operation: "spawn system shell profile", + message: "the built-in shell uses an in-process terminal program".into(), + }); + } + }; + command.cwd(working_directory.as_ref()); + command.env("TERM", "xterm-256color"); + command.env("COLORTERM", "truecolor"); + Self::spawn_with_profile(command, profile, size) } /// Spawns a command attached to a native pseudo-terminal. @@ -172,6 +206,15 @@ impl TerminalSession { /// /// A live PTY session and VT terminal state. pub fn spawn(command: CommandBuilder, size: TerminalSize) -> Result { + Self::spawn_with_profile(command, TerminalProfile::SystemShell, size) + } + + /// Spawns a native PTY command and records its terminal profile. + fn spawn_with_profile( + command: CommandBuilder, + profile: TerminalProfile, + size: TerminalSize, + ) -> Result { let pty_system = native_pty_system(); let pair = pty_system .openpty(size.pty_size()) @@ -206,7 +249,7 @@ impl TerminalSession { spawn_wait_thread(&event_sender, child)?; Ok(Self { - profile: TerminalProfile::SystemShell, + profile, terminal, writer, backend: TerminalBackend::NativePty { From d699b83363dc65befc7a3a4a737cc5d7ceb2ded7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 29 Sep 2026 00:02:09 +0800 Subject: [PATCH 21/32] feat(terminal): host multiple terminal sessions in the bottom dock Replace the single terminal panel with a session manager owning a list of independent sessions, each with its own emulator, optional in-process built-in shell program, tab button, and replaceable command-output projection. Thread the session identity through every built-in shell path so submission, history, completion, and output projection reach the owning session instead of one shared global, and tear down every session when the dock closes. Add the chrome that drives that list: a profile selector opening a popup of shell profiles labelled with icon and name, a trash button closing the active session, an icon-only vertical session rail down the right edge, and an inset around the terminal grid. Redraw the profile icons so the built-in, default, Bash, and Fish glyphs carry one optical weight at 16px. --- assets/icons/terminal-add.svg | 3 + assets/icons/terminal-bash.svg | 4 + assets/icons/terminal-builtin.svg | 4 + assets/icons/terminal-fish.svg | 4 + assets/icons/terminal-shell.svg | 4 + assets/icons/terminal-trash.svg | 3 + crates/chitin-desktop/src/app.rs | 2 +- .../src/components/bottom_dock.rs | 6 + .../src/components/terminal/controller.rs | 154 ++++++--- .../src/components/terminal/mod.rs | 323 +++++++++++++++--- .../src/components/terminal/render.rs | 144 +++++++- 11 files changed, 557 insertions(+), 94 deletions(-) create mode 100644 assets/icons/terminal-add.svg create mode 100644 assets/icons/terminal-bash.svg create mode 100644 assets/icons/terminal-builtin.svg create mode 100644 assets/icons/terminal-fish.svg create mode 100644 assets/icons/terminal-shell.svg create mode 100644 assets/icons/terminal-trash.svg diff --git a/assets/icons/terminal-add.svg b/assets/icons/terminal-add.svg new file mode 100644 index 00000000..efde3add --- /dev/null +++ b/assets/icons/terminal-add.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/terminal-bash.svg b/assets/icons/terminal-bash.svg new file mode 100644 index 00000000..e2fb81e9 --- /dev/null +++ b/assets/icons/terminal-bash.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/terminal-builtin.svg b/assets/icons/terminal-builtin.svg new file mode 100644 index 00000000..c3c68c71 --- /dev/null +++ b/assets/icons/terminal-builtin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/terminal-fish.svg b/assets/icons/terminal-fish.svg new file mode 100644 index 00000000..fb23983e --- /dev/null +++ b/assets/icons/terminal-fish.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/terminal-shell.svg b/assets/icons/terminal-shell.svg new file mode 100644 index 00000000..9c3aa98a --- /dev/null +++ b/assets/icons/terminal-shell.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/terminal-trash.svg b/assets/icons/terminal-trash.svg new file mode 100644 index 00000000..75c749b6 --- /dev/null +++ b/assets/icons/terminal-trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/chitin-desktop/src/app.rs b/crates/chitin-desktop/src/app.rs index d52818b4..d6adaa75 100644 --- a/crates/chitin-desktop/src/app.rs +++ b/crates/chitin-desktop/src/app.rs @@ -495,8 +495,8 @@ impl Render for ChitinApp { } if self.terminal_panel.take_focus_request() && let Some((controls, _)) = terminal_dock_controls.as_ref() + && let Some(focus) = controls.focus(cx) { - let focus = controls.focus(cx); window.focus(&focus, cx); } let app = cx.weak_entity(); diff --git a/crates/chitin-desktop/src/components/bottom_dock.rs b/crates/chitin-desktop/src/components/bottom_dock.rs index 22748c42..1a87af9c 100644 --- a/crates/chitin-desktop/src/components/bottom_dock.rs +++ b/crates/chitin-desktop/src/components/bottom_dock.rs @@ -43,6 +43,12 @@ impl ChitinApp { /// Closes whichever tool is active in the bottom dock. pub(crate) fn close_bottom_dock(&mut self, cx: &mut Context) { + if self.bottom_dock.is_active(super::terminal::TERMINAL_DOCK_ITEM_ID) { + if let Err(error) = self.builtin_shell().cancel_active() { + log::warn!("failed to cancel active built-in terminal command: {error}"); + } + self.terminal_panel_controls = None; + } self.bottom_dock.close(); self.terminal_panel.request_focus(false); cx.notify(); diff --git a/crates/chitin-desktop/src/components/terminal/controller.rs b/crates/chitin-desktop/src/components/terminal/controller.rs index 430e9181..822f5494 100644 --- a/crates/chitin-desktop/src/components/terminal/controller.rs +++ b/crates/chitin-desktop/src/components/terminal/controller.rs @@ -4,6 +4,7 @@ use chitin_builtin_shell::{BuiltinShell, BuiltinTerminalEvent, ShellBuiltinEffec use gpui::{AppContext, AsyncApp, Context, WeakEntity, Window}; use super::{ + TerminalSessionId, completion::completion_action, presenter::{shell_output_lines, terminal_lines_ansi}, }; @@ -11,11 +12,21 @@ use crate::{app::ChitinApp, builtin_shell::DesktopShellDispatch, components::ter impl ChitinApp { /// Drains byte-stream events produced by the in-process shell backend. - pub(super) fn process_builtin_terminal_input(&mut self, window: &mut Window, cx: &mut Context) { - let Some(controls) = self.terminal_panel_controls.as_ref().cloned() else { + pub(super) fn process_builtin_terminal_input( + &mut self, + session_id: TerminalSessionId, + window: &mut Window, + cx: &mut Context, + ) { + let Some(program) = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.session(session_id)) + .and_then(|session| session.program.clone()) + else { return; }; - let events = match controls.program.lock() { + let events = match program.lock() { Ok(mut program) => match program.poll() { Ok(events) => events, Err(error) => { @@ -30,19 +41,20 @@ impl ChitinApp { }; for event in events { - self.handle_builtin_terminal_event(event, window, cx); + self.handle_builtin_terminal_event(session_id, event, window, cx); } } /// Routes one semantic line-discipline event to shell or terminal behavior. fn handle_builtin_terminal_event( &mut self, + session_id: TerminalSessionId, event: BuiltinTerminalEvent, window: &mut Window, cx: &mut Context, ) { match event { - BuiltinTerminalEvent::Submit(line) => self.submit_terminal_line(line, window, cx), + BuiltinTerminalEvent::Submit(line) => self.submit_terminal_line(session_id, line, window, cx), BuiltinTerminalEvent::Interrupt => { if self .builtin_shell() @@ -52,20 +64,22 @@ impl ChitinApp { let _ = self.builtin_shell().cancel_active(); } } - BuiltinTerminalEvent::Complete(line) => self.complete_terminal_line(line), + BuiltinTerminalEvent::Complete(line) => self.complete_terminal_line(session_id, line), BuiltinTerminalEvent::PreviousHistory => { let draft = self .terminal_panel_controls .as_ref() - .and_then(|controls| controls.program.lock().ok().map(|program| program.line().to_owned())) + .and_then(|controls| controls.session(session_id)) + .and_then(|session| session.program.as_ref()) + .and_then(|program| program.lock().ok().map(|program| program.line().to_owned())) .unwrap_or_default(); if let Ok(Some(line)) = self.builtin_shell().previous_history(&draft) { - self.replace_terminal_line(line); + self.replace_terminal_line(session_id, line); } } BuiltinTerminalEvent::NextHistory => { if let Ok(Some(line)) = self.builtin_shell().next_history() { - self.replace_terminal_line(line); + self.replace_terminal_line(session_id, line); } } BuiltinTerminalEvent::Resize(_) | BuiltinTerminalEvent::Shutdown => {} @@ -74,21 +88,33 @@ impl ChitinApp { } /// Submits one line through the existing typed built-in-shell dispatcher. - fn submit_terminal_line(&mut self, line: String, window: &mut Window, cx: &mut Context) { + fn submit_terminal_line( + &mut self, + session_id: TerminalSessionId, + line: String, + window: &mut Window, + cx: &mut Context, + ) { let line = line.trim().to_owned(); if line.is_empty() { - self.finish_terminal_command(""); + self.finish_terminal_command(session_id, ""); return; } match self.submit_builtin_shell_line(line, ShellInvocationSource::Interactive, window, cx) { - Ok(DesktopShellDispatch::Display { output }) => self.finish_terminal_command(&output), + Ok(DesktopShellDispatch::Display { output }) => self.finish_terminal_command(session_id, &output), Ok(DesktopShellDispatch::ShellBuiltin { effect: ShellBuiltinEffect::ClearScrollback, - }) => self.clear_terminal_screen(), - Ok(DesktopShellDispatch::Frontend { .. }) => self.finish_terminal_command(""), + }) => self.clear_terminal_screen(session_id), + Ok(DesktopShellDispatch::Frontend { .. }) => self.finish_terminal_command(session_id, ""), Ok(DesktopShellDispatch::Portable(task)) => { let command_id = task.command_id(); - self.terminal_panel.start_command(command_id); + if let Some(session) = self + .terminal_panel_controls + .as_mut() + .and_then(|controls| controls.session_mut(session_id)) + { + session.start_command(command_id); + } let window_handle = window.window_handle(); cx.spawn(async move |app: WeakEntity, async_cx: &mut AsyncApp| { let result = task.result().await; @@ -117,9 +143,13 @@ impl ChitinApp { Err(_) => format!("\x1b[31merror: {error}\x1b[0m"), }, }; - this.finish_terminal_command(&output); - if let Some(controls) = this.terminal_panel_controls.as_ref() { - window.focus(&controls.focus(cx), cx); + this.finish_terminal_command(session_id, &output); + if let Some(focus) = this.terminal_panel_controls.as_ref().and_then(|controls| { + (controls.active_session == session_id) + .then(|| controls.focus(cx)) + .flatten() + }) { + window.focus(&focus, cx); } cx.notify(); }); @@ -127,18 +157,22 @@ impl ChitinApp { }) .detach(); } - Err(error) => self.finish_terminal_command(&format!("\x1b[31merror: {error}\x1b[0m")), + Err(error) => self.finish_terminal_command(session_id, &format!("\x1b[31merror: {error}\x1b[0m")), } } /// Applies built-in shell completion to the terminal-owned editable line. - fn complete_terminal_line(&self, line: String) { + fn complete_terminal_line(&self, session_id: TerminalSessionId, line: String) { match completion_action(BuiltinShell::complete(&line)) { - CompletionAction::Insert(completed) => self.replace_terminal_line(completed), + CompletionAction::Insert(completed) => self.replace_terminal_line(session_id, completed), CompletionAction::List(candidates) => { let output = candidates.join("\n"); - if let Some(controls) = self.terminal_panel_controls.as_ref() - && let Ok(program) = controls.program.lock() + if let Some(program) = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.session(session_id)) + .and_then(|session| session.program.as_ref()) + && let Ok(program) = program.lock() && let Err(error) = program.show_notice(&output) { log::error!("failed to display terminal completions: {error}"); @@ -149,9 +183,13 @@ impl ChitinApp { } /// Replaces the line edited by the in-process terminal backend. - fn replace_terminal_line(&self, line: String) { - if let Some(controls) = self.terminal_panel_controls.as_ref() - && let Ok(mut program) = controls.program.lock() + fn replace_terminal_line(&self, session_id: TerminalSessionId, line: String) { + if let Some(program) = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.session(session_id)) + .and_then(|session| session.program.as_ref()) + && let Ok(mut program) = program.lock() && let Err(error) = program.replace_line(line) { log::error!("failed to replace built-in terminal line: {error}"); @@ -159,43 +197,71 @@ impl ChitinApp { } /// Writes final command output and restores the shell prompt. - fn finish_terminal_command(&mut self, output: &str) { - if let Some(controls) = self.terminal_panel_controls.as_ref() - && let Ok(mut program) = controls.program.lock() + fn finish_terminal_command(&mut self, session_id: TerminalSessionId, output: &str) { + if let Some(program) = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.session(session_id)) + .and_then(|session| session.program.as_ref()) + && let Ok(mut program) = program.lock() && let Err(error) = program.finish_command(output) { log::error!("failed to finish built-in terminal command: {error}"); } - self.terminal_panel.finish_command(); + if let Some(session) = self + .terminal_panel_controls + .as_mut() + .and_then(|controls| controls.session_mut(session_id)) + { + session.finish_command(); + } } /// Clears the VT grid and resets the built-in line discipline. - fn clear_terminal_screen(&mut self) { - if let Some(controls) = self.terminal_panel_controls.as_ref() - && let Ok(mut program) = controls.program.lock() + fn clear_terminal_screen(&mut self, session_id: TerminalSessionId) { + if let Some(program) = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.session(session_id)) + .and_then(|session| session.program.as_ref()) + && let Ok(mut program) = program.lock() && let Err(error) = program.clear_screen() { log::error!("failed to clear built-in terminal: {error}"); } - self.terminal_panel.finish_command(); + if let Some(session) = self + .terminal_panel_controls + .as_mut() + .and_then(|controls| controls.session_mut(session_id)) + { + session.finish_command(); + } } /// Projects the active command's replaceable transcript into the VT grid. pub(crate) fn sync_terminal_output(&mut self) { - let Some(command_id) = self.terminal_panel.active_shell_command else { + let Some((session_id, command_id, previous_output, program)) = + self.terminal_panel_controls.as_ref().and_then(|controls| { + controls.sessions.iter().find_map(|session| { + Some(( + session.id, + session.active_shell_command?, + session.rendered_running_output.clone(), + session.program.clone()?, + )) + }) + }) + else { return; }; let Ok(snapshot) = self.builtin_shell().snapshot() else { return; }; let output = terminal_lines_ansi(&shell_output_lines(&snapshot, command_id)); - if output == self.terminal_panel.rendered_running_output { + if output == previous_output { return; } - let Some(controls) = self.terminal_panel_controls.as_ref() else { - return; - }; - let Ok(mut program) = controls.program.lock() else { + let Ok(mut program) = program.lock() else { log::error!("built-in terminal program lock is poisoned"); return; }; @@ -203,6 +269,12 @@ impl ChitinApp { log::error!("failed to update built-in terminal output: {error}"); return; } - self.terminal_panel.rendered_running_output = output; + if let Some(session) = self + .terminal_panel_controls + .as_mut() + .and_then(|controls| controls.session_mut(session_id)) + { + session.rendered_running_output = output; + } } } diff --git a/crates/chitin-desktop/src/components/terminal/mod.rs b/crates/chitin-desktop/src/components/terminal/mod.rs index aee1ce77..ba5d02e3 100644 --- a/crates/chitin-desktop/src/components/terminal/mod.rs +++ b/crates/chitin-desktop/src/components/terminal/mod.rs @@ -1,4 +1,4 @@ -//! Desktop adapter for the reusable structured command terminal. +//! Desktop adapter for terminal sessions hosted by the workbench bottom dock. mod completion; mod controller; @@ -6,13 +6,20 @@ mod presenter; mod render; use std::{ - path::Path, + path::{Path, PathBuf}, sync::{Arc, Mutex}, }; use chitin_builtin_shell::{BuiltinTerminalProgram, ShellCommandId}; -use chitin_terminal::{TerminalSessionError, TerminalSize}; -use chitin_ui::primitive::terminal::{TerminalEmulatorEvent, TerminalEmulatorState}; +use chitin_terminal::{TerminalProfile, TerminalSession, TerminalSessionError, TerminalSize}; +use chitin_ui::{ + composite::toast::{Toast, ToastVariant}, + primitive::{ + button::{ButtonEvent, ButtonState}, + input::select::{SelectInputEvent, SelectInputState, SelectOption}, + terminal::{TerminalEmulatorEvent, TerminalEmulatorState}, + }, +}; use gpui::{AppContext, Context, Entity, Subscription, Window}; use crate::app::ChitinApp; @@ -20,25 +27,34 @@ use crate::app::ChitinApp; pub(crate) use render::render_terminal_bottom_dock; pub(crate) const TERMINAL_DOCK_ITEM_ID: &str = "terminal"; +const INITIAL_TERMINAL_SIZE: TerminalSize = TerminalSize::new(80, 24, 9, 21); + +/// Returns the desktop asset used to represent one terminal profile. +pub(super) const fn terminal_profile_icon(profile: TerminalProfile) -> &'static str { + match profile { + TerminalProfile::BuiltinShell => "icons/terminal-builtin.svg", + TerminalProfile::SystemShell => "icons/terminal-shell.svg", + TerminalProfile::Bash => "icons/terminal-bash.svg", + TerminalProfile::Fish => "icons/terminal-fish.svg", + } +} + +/// Stable identity of one terminal session in the bottom dock. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct TerminalSessionId(u64); -/// Terminal focus and active built-in-shell command state. +/// Terminal focus state that belongs to the bottom dock rather than a session. pub(crate) struct TerminalPanelState { focus_requested: bool, - pub(super) active_shell_command: Option, - pub(super) rendered_running_output: String, } impl TerminalPanelState { /// Creates a closed terminal panel. pub(crate) fn new() -> Self { - Self { - focus_requested: false, - active_shell_command: None, - rendered_running_output: String::new(), - } + Self { focus_requested: false } } - /// Updates whether terminal input should receive focus on the next render. + /// Updates whether the active session should receive focus on the next render. pub(crate) fn request_focus(&mut self, requested: bool) { self.focus_requested = requested; } @@ -47,59 +63,177 @@ impl TerminalPanelState { pub(crate) fn take_focus_request(&mut self) -> bool { std::mem::take(&mut self.focus_requested) } +} + +impl Default for TerminalPanelState { + fn default() -> Self { + Self::new() + } +} + +/// One independently rendered terminal surface and its optional in-process program. +#[derive(Clone)] +pub(super) struct ManagedTerminalSession { + pub(super) id: TerminalSessionId, + pub(super) profile: TerminalProfile, + pub(super) terminal: Entity, + pub(super) program: Option>>, + pub(super) tab: Entity, + pub(super) active_shell_command: Option, + pub(super) rendered_running_output: String, +} - /// Tracks the portable shell command currently writing terminal output. - pub(super) fn start_command(&mut self, command_id: ShellCommandId) { +impl ManagedTerminalSession { + /// Tracks the portable command currently writing into this session. + fn start_command(&mut self, command_id: ShellCommandId) { self.active_shell_command = Some(command_id); self.rendered_running_output.clear(); } - /// Clears the active output projection after its command reaches a terminal state. - pub(super) fn finish_command(&mut self) { + /// Clears this session's replaceable command-output projection. + fn finish_command(&mut self) { self.active_shell_command = None; self.rendered_running_output.clear(); } } -impl Default for TerminalPanelState { - fn default() -> Self { - Self::new() - } -} - -/// Persistent primitive and composite controls for the bottom terminal panel. +/// Persistent session manager and controls for the bottom terminal panel. #[derive(Clone)] pub(crate) struct TerminalPanelControls { - pub(super) terminal: Entity, - pub(super) program: Arc>, + pub(super) sessions: Vec, + pub(super) active_session: TerminalSessionId, + next_session_id: u64, + pub(super) close_session: Entity, + pub(super) profile_select: Entity, } impl TerminalPanelControls { - /// Creates terminal state and panel controls for one working directory. + /// Creates the session manager with an initial built-in shell. fn new(working_directory: &Path, cx: &mut Context) -> Result { - let prompt = presenter::terminal_prompt_ansi(working_directory); - let size = TerminalSize::new(80, 24, 9, 21); - let (session, program) = BuiltinTerminalProgram::connect(size, prompt)?; + let close_session = cx.new(ButtonState::new); + let profile_select = cx.new(|cx| { + SelectInputState::new( + TerminalProfile::ALL + .into_iter() + .map(|profile| SelectOption::new(profile.id(), profile.label()).icon(terminal_profile_icon(profile))), + cx, + ) + }); + let session = Self::build_session( + TerminalSessionId(1), + TerminalProfile::BuiltinShell, + working_directory, + cx, + )?; Ok(Self { - terminal: cx.new(|cx| TerminalEmulatorState::new(session, size, cx)), - program: Arc::new(Mutex::new(program)), + sessions: vec![session], + active_session: TerminalSessionId(1), + next_session_id: 2, + close_session, + profile_select, + }) + } + + /// Builds a terminal surface for one launch profile. + fn build_session( + id: TerminalSessionId, + profile: TerminalProfile, + working_directory: &Path, + cx: &mut Context, + ) -> Result { + let (session, program) = if profile == TerminalProfile::BuiltinShell { + let prompt = presenter::terminal_prompt_ansi(working_directory); + let (session, program) = BuiltinTerminalProgram::connect(INITIAL_TERMINAL_SIZE, prompt)?; + (session, Some(Arc::new(Mutex::new(program)))) + } else { + ( + TerminalSession::spawn_system_shell(profile, working_directory, INITIAL_TERMINAL_SIZE)?, + None, + ) + }; + Ok(ManagedTerminalSession { + id, + profile, + terminal: cx.new(|cx| TerminalEmulatorState::new(session, INITIAL_TERMINAL_SIZE, cx)), + program, + tab: cx.new(ButtonState::new), + active_shell_command: None, + rendered_running_output: String::new(), }) } - /// Connects primitive semantic events to the desktop shell adapter. + /// Connects session-manager and initial-session semantic controls. fn subscribe(&self, window: &mut Window, cx: &mut Context) { - let terminal = self.terminal.clone(); - let subscription: Subscription = cx.subscribe_in(&terminal, window, |this, _, event, window, cx| { + self.subscribe_session(&self.sessions[0], window, cx); + + let subscription: Subscription = cx.subscribe_in(&self.close_session, window, |this, _, event, _, cx| { + if matches!(event, ButtonEvent::Click) { + this.close_active_terminal_session(cx); + } + }); + subscription.detach(); + + let profile_select = self.profile_select.clone(); + let profile_select_for_event = profile_select.clone(); + let subscription: Subscription = cx.subscribe_in(&profile_select, window, move |this, _, event, window, cx| { + let SelectInputEvent::SelectionChange { + selected_id: Some(selected_id), + } = event + else { + return; + }; + let Some(profile) = TerminalProfile::from_id(selected_id.as_ref()) else { + return; + }; + this.create_terminal_session(profile, window, cx); + let profile_select = profile_select_for_event.clone(); + cx.defer_in(window, move |_, _, cx| { + profile_select.update(cx, |state, cx| state.clear_selection(cx)); + }); + }); + subscription.detach(); + } + + /// Connects input and tab activation for one session. + fn subscribe_session(&self, session: &ManagedTerminalSession, window: &mut Window, cx: &mut Context) { + let id = session.id; + let terminal = session.terminal.clone(); + let subscription: Subscription = cx.subscribe_in(&terminal, window, move |this, _, event, window, cx| { if *event == TerminalEmulatorEvent::InputWritten { - this.process_builtin_terminal_input(window, cx); + this.process_builtin_terminal_input(id, window, cx); + } + }); + subscription.detach(); + + let tab = session.tab.clone(); + let subscription: Subscription = cx.subscribe_in(&tab, window, move |this, _, event, window, cx| { + if matches!(event, ButtonEvent::Click) { + this.activate_terminal_session(id, window, cx); } }); subscription.detach(); } - /// Returns the focus handle owned by the VT terminal surface. - pub(crate) fn focus(&self, cx: &gpui::App) -> gpui::FocusHandle { - self.terminal.read(cx).focus_handle().clone() + /// Returns the active terminal session. + pub(super) fn active(&self) -> Option<&ManagedTerminalSession> { + self.sessions.iter().find(|session| session.id == self.active_session) + } + + /// Returns a session by stable identity. + pub(super) fn session(&self, id: TerminalSessionId) -> Option<&ManagedTerminalSession> { + self.sessions.iter().find(|session| session.id == id) + } + + /// Returns a mutable session by stable identity. + pub(super) fn session_mut(&mut self, id: TerminalSessionId) -> Option<&mut ManagedTerminalSession> { + self.sessions.iter_mut().find(|session| session.id == id) + } + + /// Returns the focus handle owned by the active terminal surface. + pub(crate) fn focus(&self, cx: &gpui::App) -> Option { + self + .active() + .map(|session| session.terminal.read(cx).focus_handle().clone()) } } @@ -114,11 +248,7 @@ impl ChitinApp { return Some(controls.clone()); } - let working_directory = self - .builtin_shell() - .snapshot() - .map(|snapshot| snapshot.working_directory) - .unwrap_or_else(|_| std::path::PathBuf::from(".")); + let working_directory = self.terminal_working_directory(); let controls = match TerminalPanelControls::new(&working_directory, cx) { Ok(controls) => controls, Err(error) => { @@ -137,4 +267,109 @@ impl ChitinApp { self.terminal_panel.request_focus(visible); cx.notify(); } + + /// Creates or activates a terminal session for the selected profile. + fn create_terminal_session(&mut self, profile: TerminalProfile, window: &mut Window, cx: &mut Context) { + if profile == TerminalProfile::BuiltinShell + && let Some(existing) = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.sessions.iter().find(|session| session.profile == profile)) + .map(|session| session.id) + { + self.activate_terminal_session(existing, window, cx); + return; + } + + let working_directory = self.terminal_working_directory(); + let Some(id) = self + .terminal_panel_controls + .as_ref() + .map(|controls| TerminalSessionId(controls.next_session_id)) + else { + return; + }; + let session = match TerminalPanelControls::build_session(id, profile, &working_directory, cx) { + Ok(session) => session, + Err(error) => { + log::error!("failed to create {} terminal session: {error}", profile.label()); + self.show_toast( + Toast::new(format!("Could not start {}", profile.label())) + .description(error.to_string()) + .variant(ToastVariant::Error), + cx, + ); + return; + } + }; + if let Some(controls) = self.terminal_panel_controls.as_ref() { + controls.subscribe_session(&session, window, cx); + } + if let Some(controls) = self.terminal_panel_controls.as_mut() { + controls.sessions.push(session); + controls.active_session = id; + controls.next_session_id += 1; + } + self.terminal_panel.request_focus(true); + cx.notify(); + } + + /// Activates one existing session and transfers keyboard focus to it. + fn activate_terminal_session(&mut self, id: TerminalSessionId, window: &mut Window, cx: &mut Context) { + let Some(controls) = self.terminal_panel_controls.as_mut() else { + return; + }; + if controls.session(id).is_none() { + return; + } + controls.active_session = id; + if let Some(focus) = controls.focus(cx) { + window.focus(&focus, cx); + } + cx.notify(); + } + + /// Closes the active session and selects its nearest remaining neighbor. + fn close_active_terminal_session(&mut self, cx: &mut Context) { + let running = self + .terminal_panel_controls + .as_ref() + .and_then(TerminalPanelControls::active) + .is_some_and(|session| session.active_shell_command.is_some()); + if running && let Err(error) = self.builtin_shell().cancel_active() { + log::warn!("failed to cancel active built-in terminal command: {error}"); + } + let Some(controls) = self.terminal_panel_controls.as_mut() else { + return; + }; + let Some(index) = controls + .sessions + .iter() + .position(|session| session.id == controls.active_session) + else { + return; + }; + controls.sessions.remove(index); + if controls.sessions.is_empty() { + self.terminal_panel_controls = None; + self.bottom_dock.close(); + self.terminal_panel.request_focus(false); + cx.notify(); + return; + } else if let Some(session) = controls.sessions.get(index.min(controls.sessions.len() - 1)) { + controls.active_session = session.id; + } + self.terminal_panel.request_focus(true); + cx.notify(); + } + + /// Resolves the directory inherited by newly created terminal sessions. + fn terminal_working_directory(&self) -> PathBuf { + self + .builtin_shell() + .snapshot() + .map(|snapshot| snapshot.working_directory) + .or_else(|_| std::env::current_dir()) + .unwrap_or_else(|_| PathBuf::from(".")) + } } diff --git a/crates/chitin-desktop/src/components/terminal/render.rs b/crates/chitin-desktop/src/components/terminal/render.rs index bdf06ead..32357012 100644 --- a/crates/chitin-desktop/src/components/terminal/render.rs +++ b/crates/chitin-desktop/src/components/terminal/render.rs @@ -2,14 +2,25 @@ use chitin_ui::{ composite::bottom_dock::{BottomDock, BottomDockResizeConfig}, - primitive::button::ButtonState, - primitive::terminal::TerminalEmulator, - themes::UIThemes, + primitive::{ + button::{Button, ButtonSize, ButtonState, ButtonStyle, ButtonVariant}, + icon::Icon, + input::select::{ + Select, SelectContent, SelectContentPosition, SelectGroup, SelectInputSize, SelectInputStyle, SelectInputVariant, + SelectItem, SelectTrigger, SelectValue, + }, + terminal::TerminalEmulator, + }, + themes::{UIThemes, builtins}, +}; +use gpui::{ + Entity, InteractiveElement, ParentElement, Pixels, StatefulInteractiveElement, Styled, WeakEntity, div, + prelude::FluentBuilder, px, }; -use gpui::{Entity, InteractiveElement, ParentElement, Pixels, Styled, WeakEntity, div}; -use super::TerminalPanelControls; +use super::{TerminalPanelControls, terminal_profile_icon}; use crate::{app::ChitinApp, fonts::TERMINAL_FONT_FAMILY, keybindings::COMMAND_TERMINAL_KEY_CONTEXT}; +use chitin_terminal::TerminalProfile; /// Renders the VT terminal emulator as the active bottom-dock item. /// @@ -34,6 +45,68 @@ pub(crate) fn render_terminal_bottom_dock( let resize_app = app.clone(); let resize_move_app = app.clone(); let resize_end_app = app.clone(); + let active_terminal = controls.active().map(|session| session.terminal.clone()); + let sessions = controls.sessions.iter().fold( + div() + .flex() + .flex_col() + .gap_1() + .w(px(38.0)) + .min_h_0() + .p_1() + .border_l_1() + .border_color(theme.border.muted) + .overflow_y_scroll(), + |sessions, session| { + let selected = session.id == controls.active_session; + sessions.child( + Button::new(session.tab.clone()) + .size(ButtonSize::Small) + .variant(ButtonVariant::Transparent) + .style( + ButtonStyle::new() + .width(px(28.0)) + .height(px(28.0)) + .horizontal_padding(px(0.0)) + .background(if selected { + theme.background.selection + } else { + builtins::TRANSPARENT + }), + ) + .theme(theme) + .child( + Icon::new(terminal_profile_icon(session.profile)) + .size(px(14.0)) + .color(if selected { + theme.text.primary + } else { + theme.text.secondary + }) + .hover_color(theme.text.primary) + .theme(theme), + ), + ) + }, + ); + let header_actions = div() + .flex() + .items_center() + .gap_1() + .child(new_terminal_select(controls.profile_select.clone(), theme)) + .child( + Button::new(controls.close_session.clone()) + .size(ButtonSize::Small) + .variant(ButtonVariant::Transparent) + .style( + ButtonStyle::new() + .width(px(26.0)) + .height(px(24.0)) + .horizontal_padding(px(0.0)), + ) + .theme(theme) + .child(Icon::new("icons/terminal-trash.svg").size(px(14.0)).theme(theme)), + ); let terminal = div() .flex() .flex_col() @@ -41,14 +114,34 @@ pub(crate) fn render_terminal_bottom_dock( .min_h_0() .key_context(COMMAND_TERMINAL_KEY_CONTEXT) .child( - TerminalEmulator::new(controls.terminal) - .theme(theme) - .font_family(TERMINAL_FONT_FAMILY), + div() + .flex() + .flex_row() + .flex_1() + .min_h_0() + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .min_h_0() + .p(px(8.0)) + .when_some(active_terminal, |terminal, active_terminal| { + terminal.child( + TerminalEmulator::new(active_terminal) + .theme(theme) + .font_family(TERMINAL_FONT_FAMILY), + ) + }), + ) + .child(sessions), ); BottomDock::new("TERMINAL", close) .height(height) .theme(theme) + .header_actions(header_actions) .resizable( BottomDockResizeConfig::new(move |start_y, _, cx| { let _ = resize_app.update(cx, |this, cx| { @@ -74,3 +167,38 @@ pub(crate) fn render_terminal_bottom_dock( ) .child(terminal) } + +/// Builds the icon-only selector used to create a terminal profile session. +fn new_terminal_select( + profile_select: Entity, + theme: UIThemes, +) -> impl gpui::IntoElement { + let group = TerminalProfile::ALL + .into_iter() + .fold(SelectGroup::new(), |group, profile| { + group.item(SelectItem::new(profile.id(), profile.label()).icon(terminal_profile_icon(profile))) + }); + Select::new(profile_select) + .trigger( + SelectTrigger::new() + .value(SelectValue::new().placeholder("")) + .icon("icons/terminal-add.svg") + .show_indicator(false), + ) + .content( + SelectContent::new() + .position(SelectContentPosition::Popper) + .align_end() + .group(group), + ) + .variant(SelectInputVariant::Transparent) + .size(SelectInputSize::Small) + .style( + SelectInputStyle::new() + .width(px(28.0)) + .trigger_padding_x(px(6.0)) + .trigger_icon_size(px(16.0)) + .menu_width(px(180.0)), + ) + .theme(theme) +} From c1ebb567dacd2c47cc53c40a17cbd181ac6b02e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 29 Sep 2026 01:25:03 +0800 Subject: [PATCH 22/32] feat(terminal): support input method composition Register the terminal surface as a text input target. GPUI turns the platform input method on only for the element that calls Window::handle_input during paint, and the terminal called it for no element, so no input method was reachable from the surface at all. A terminal owns no editable text buffer, so the parts of the protocol are mapped onto the grid: committed text goes straight to the active backend, the composition is held here and painted over its anchor cell until the input method commits it, and a live composition swallows every keystroke so Enter, Escape, and the candidate keys never reach the child program as bytes. The candidate window is pointed at the composition anchor, which is the only path GPUI offers for positioning it. The exact cell geometry is kept beside the whole-pixel cell size reported to the backend so that spot lands on the cell the composition is painted over rather than drifting as the column grows. A Div has no paint-phase hook, and on_children_prepainted runs too early to register a handler, so the grid is wrapped in a thin element that delegates layout, prepaint, and paint to it and then registers the handler against the grid's own bounds. --- .../src/primitive/terminal/emulator.rs | 435 +++++++++++++++++- 1 file changed, 414 insertions(+), 21 deletions(-) diff --git a/crates/chitin-ui/src/primitive/terminal/emulator.rs b/crates/chitin-ui/src/primitive/terminal/emulator.rs index 991d8b3c..5c818b9a 100644 --- a/crates/chitin-ui/src/primitive/terminal/emulator.rs +++ b/crates/chitin-ui/src/primitive/terminal/emulator.rs @@ -1,15 +1,17 @@ //! GPUI rendering and input handling for a VT terminal session. -use std::{sync::Arc, time::Duration}; +use std::{ops::Range, sync::Arc, time::Duration}; use chitin_terminal::{ TerminalCell, TerminalCellAttributes, TerminalColor, TerminalEvent, TerminalNamedColor, TerminalSession, TerminalSessionError, TerminalSize, TerminalSnapshot, }; use gpui::{ - App, AsyncApp, Bounds, Context, Entity, EventEmitter, FocusHandle, Font, FontFallbacks, FontFeatures, FontWeight, - Hsla, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, ParentElement, Pixels, RenderOnce, SharedString, - Styled, Task, TextRun, Timer, WeakEntity, Window, div, font, prelude::*, px, rgb, + AnyElement, App, AsyncApp, Bounds, Context, Div, Element, ElementId, ElementInputHandler, Entity, EntityInputHandler, + EventEmitter, FocusHandle, Font, FontFallbacks, FontFeatures, FontWeight, GlobalElementId, Hsla, InspectorElementId, + InteractiveElement, IntoElement, KeyDownEvent, LayoutId, MouseButton, ParentElement, Pixels, Point, RenderOnce, + SharedString, Styled, Task, TextRun, Timer, UTF16Selection, WeakEntity, Window, div, font, point, prelude::*, px, + rgb, size, }; use crate::themes::{UIThemes, builtins}; @@ -30,10 +32,28 @@ pub struct TerminalEmulatorState { session: TerminalSession, focus: FocusHandle, size: TerminalSize, + metrics: TerminalMetrics, status: Option, + preedit: Option, _event_task: Task<()>, } +/// Composition text an input method is still editing. +/// +/// The composition is deliberately kept out of the session. Writing half-finished +/// bytes to a PTY or an in-process shell would let the child program echo and +/// interpret them, so the text stays here and is painted over the grid until the +/// input method commits it. +#[derive(Clone, Debug)] +struct TerminalPreedit { + /// Composition text the input method is currently showing. + text: String, + /// Grid row the composition started on. + row: usize, + /// Grid column the composition started at. + column: usize, +} + impl TerminalEmulatorState { /// Creates state around an existing terminal session. pub fn new(session: TerminalSession, size: TerminalSize, cx: &mut Context) -> Self { @@ -58,7 +78,13 @@ impl TerminalEmulatorState { session, focus: cx.focus_handle(), size, + metrics: TerminalMetrics { + cell_width: px(1.0), + cell_height: px(1.0), + font_size: px(FONT_SIZE), + }, status: None, + preedit: None, _event_task: event_task, } } @@ -73,6 +99,11 @@ impl TerminalEmulatorState { &self.focus } + /// Returns the input method composition currently painted over the grid. + fn preedit(&self) -> Option<&TerminalPreedit> { + self.preedit.as_ref() + } + /// Writes already encoded bytes to the active terminal backend. pub fn write(&self, bytes: &[u8]) -> Result<(), TerminalSessionError> { self.session.write(bytes) @@ -94,6 +125,9 @@ impl TerminalEmulatorState { } fn resize(&mut self, bounds: Bounds, metrics: TerminalMetrics) { + // Kept so the input method can be pointed at the same cell geometry the grid is painted + // with, which is finer than the whole-pixel cell size reported to the backend. + self.metrics = metrics; let next = terminal_size_for_bounds(bounds, metrics); if next == self.size { return; @@ -105,20 +139,152 @@ impl TerminalEmulatorState { } fn handle_key(&mut self, event: &KeyDownEvent, cx: &mut Context) { + // A live composition owns every keystroke: Enter commits it, Escape cancels it, + // and the arrow keys pick a candidate. Forwarding those bytes would send them to + // the child program as well, which is what the composition has not done yet. + if self.preedit.is_some() { + cx.stop_propagation(); + return; + } let Some(bytes) = encode_key(event) else { return; }; - match self.session.write(&bytes) { + self.write_input(&bytes, cx); + cx.stop_propagation(); + } + + /// Delivers encoded input to the active backend and repaints the surface. + fn write_input(&mut self, bytes: &[u8], cx: &mut Context) { + match self.session.write(bytes) { Ok(()) => cx.emit(TerminalEmulatorEvent::InputWritten), Err(error) => self.status = Some(error.to_string()), } - cx.stop_propagation(); cx.notify(); } } impl EventEmitter for TerminalEmulatorState {} +/// Routes platform text input, input method composition included, into the session. +/// +/// A terminal owns no editable text buffer: committed text is written to the active +/// backend, and every glyph on the grid arrives back from that backend's own output. +/// The composition is the single piece of text that has to live here, because the +/// child program must not see it until the input method commits it. +impl EntityInputHandler for TerminalEmulatorState { + fn text_for_range( + &mut self, + _: Range, + _: &mut Option>, + _: &mut Window, + _: &mut Context, + ) -> Option { + // Nothing to hand back: the grid is the child program's output, not a buffer to read. + None + } + + fn selected_text_range(&mut self, _: bool, _: &mut Window, _: &mut Context) -> Option { + // A terminal holds no selection. Reporting a collapsed range still presents the + // surface as a live text input target, which is what wakes the platform input method. + Some(UTF16Selection { + range: 0..0, + reversed: false, + }) + } + + fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> { + self + .preedit + .as_ref() + .map(|preedit| 0..preedit.text.encode_utf16().count()) + } + + fn unmark_text(&mut self, _: &mut Window, cx: &mut Context) { + if self.preedit.take().is_some() { + cx.notify(); + } + } + + fn replace_text_in_range(&mut self, _: Option>, text: &str, _: &mut Window, cx: &mut Context) { + // A commit ends the composition and hands the finished text to the child program, + // exactly as if the keystrokes had been typed out one by one. + self.preedit = None; + if text.is_empty() { + cx.notify(); + return; + } + self.write_input(text.as_bytes(), cx); + } + + fn replace_and_mark_text_in_range( + &mut self, + _: Option>, + text: &str, + _: Option>, + window: &mut Window, + cx: &mut Context, + ) { + // An empty composition is how the platform reports a cancelled one. + if text.is_empty() { + if self.preedit.take().is_some() { + cx.notify(); + } + return; + } + // Keep the anchor captured when the composition started, so shell output arriving + // mid-edit cannot drag the composition to another cell. + let anchor = self + .preedit + .as_ref() + .map(|preedit| (preedit.row, preedit.column)) + .or_else(|| self.session.snapshot().cursor) + .unwrap_or((0, 0)); + self.preedit = Some(TerminalPreedit { + text: text.to_string(), + row: anchor.0, + column: anchor.1, + }); + // The platform holds no cursor of its own for this surface, so the candidate window + // stays wherever it was last told until it is handed the composition anchor again. + window.invalidate_character_coordinates(); + cx.notify(); + } + + fn bounds_for_range( + &mut self, + _: Range, + element_bounds: Bounds, + _: &mut Window, + _: &mut Context, + ) -> Option> { + // Places the input method's candidate window over the composition anchor instead of + // leaving it pinned to the corner of the window. + let (row, column) = self + .preedit + .as_ref() + .map(|preedit| (preedit.row, preedit.column)) + .or_else(|| self.session.snapshot().cursor)?; + let cell_width = self.metrics.cell_width; + let cell_height = self.metrics.cell_height; + Some(Bounds::new( + point( + element_bounds.left() + cell_width * column as f32, + element_bounds.top() + cell_height * row as f32, + ), + size(cell_width, cell_height), + )) + } + + fn character_index_for_point(&mut self, _: Point, _: &mut Window, _: &mut Context) -> Option { + // The terminal hit-tests its own pointer input, so there is no index to report. + None + } + + fn accepts_text_input(&self, _: &mut Window, _: &mut Context) -> bool { + true + } +} + /// Interactive GPUI surface for either a PTY or in-process terminal backend. #[derive(IntoElement)] pub struct TerminalEmulator { @@ -153,6 +319,7 @@ impl TerminalEmulator { impl RenderOnce for TerminalEmulator { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let metrics = measure_terminal_metrics(window, self.font_family.clone()); + let preedit = self.state.read(cx).preedit().cloned(); let snapshot = self.state.read(cx).session.snapshot(); let focus = self.state.read(cx).focus.clone(); let state_for_mouse = self.state.clone(); @@ -160,14 +327,19 @@ impl RenderOnce for TerminalEmulator { let state_for_layout = self.state.clone(); let theme = self.theme; let rows = (0..snapshot.rows).map(move |row| { - div().flex().h(metrics.cell_height).whitespace_nowrap().children( - build_row_runs(&snapshot, row) - .into_iter() - .map(move |run| render_run(run, metrics, theme)), - ) + let cells = build_row_runs(&snapshot, row) + .into_iter() + .map(move |run| render_run(run, metrics, theme)); + let grid_row = div().flex().h(metrics.cell_height).whitespace_nowrap().children(cells); + // The composition belongs to exactly one row, and only that row becomes a + // containing block for its overlay. + match preedit.as_ref().filter(|preedit| preedit.row == row) { + Some(preedit) => grid_row.relative().child(render_preedit(preedit, metrics, theme)), + None => grid_row, + } }); - div() + let grid = div() .flex() .flex_col() .size_full() @@ -190,10 +362,114 @@ impl RenderOnce for TerminalEmulator { .font(terminal_font(self.font_family)) .text_size(metrics.font_size) .line_height(metrics.cell_height) - .child(div().flex().flex_col().size_full().children(rows)) + .child(div().flex().flex_col().size_full().children(rows)); + + TerminalSurface { + content: grid.into_any_element(), + state: self.state, + } } } +/// Wraps the terminal grid so the surface can register itself as a text input target. +/// +/// GPUI enables the platform input method only for the element that calls +/// [`Window::handle_input`], and only during paint. A `Div` has no paint-phase hook, so +/// this element delegates the entire layout, prepaint, and paint pass to the grid and +/// then registers the handler against the grid's own bounds. +struct TerminalSurface { + content: AnyElement, + state: Entity, +} + +impl IntoElement for TerminalSurface { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for TerminalSurface { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + Element::request_layout(&mut self.content, id, inspector_id, window, cx) + } + + fn prepaint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + Element::prepaint(&mut self.content, id, inspector_id, bounds, request_layout, window, cx) + } + + fn paint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + Element::paint( + &mut self.content, + id, + inspector_id, + bounds, + request_layout, + prepaint, + window, + cx, + ); + // Registering a handler is also what turns the platform input method on; GPUI + // disables it for any frame that registers none. Losing focus therefore cancels a + // composition through the platform's own `unmark_text` path. + let focus = self.state.read(cx).focus.clone(); + window.handle_input(&focus, ElementInputHandler::new(bounds, self.state.clone()), cx); + } +} + +/// Paints the active input method composition over the grid at its anchor cell. +/// +/// The composition is not part of the grid, so it covers the cells underneath instead of +/// replacing them. It disappears the moment the input method commits, at which point the +/// child program echoes the committed glyphs itself. +fn render_preedit(preedit: &TerminalPreedit, metrics: TerminalMetrics, theme: UIThemes) -> Div { + div() + .absolute() + .top_0() + .left(metrics.cell_width * preedit.column as f32) + .h(metrics.cell_height) + .whitespace_nowrap() + .bg(theme.background.primary) + .text_color(theme.text.primary) + .underline() + .child(preedit.text.clone()) +} + #[derive(Clone, Copy)] struct TerminalMetrics { cell_width: Pixels, @@ -265,6 +541,8 @@ struct TerminalRun { foreground: TerminalColor, background: TerminalColor, attributes: TerminalCellAttributes, + /// Whether the run holds a single glyph that is painted across two grid columns. + wide: bool, cursor: bool, } @@ -275,7 +553,8 @@ impl TerminalRun { columns: 0, foreground: cell.foreground, background: cell.background, - attributes: upright_attributes(cell.attributes), + attributes: run_attributes(cell.attributes), + wide: cell.attributes.wide, cursor, }; run.push(cell); @@ -285,13 +564,16 @@ impl TerminalRun { fn matches(&self, cell: &TerminalCell, cursor: bool) -> bool { self.foreground == cell.foreground && self.background == cell.background - && self.attributes == upright_attributes(cell.attributes) + && self.attributes == run_attributes(cell.attributes) + && self.wide == cell.attributes.wide && self.cursor == cursor } fn push(&mut self, cell: &TerminalCell) { - self.columns += 1; - if cell.attributes.hidden || cell.attributes.wide_spacer { + // A wide cell is the only cell of its run, and it is the one that accounts for both of + // the columns the glyph covers. + self.columns += if cell.attributes.wide { 2 } else { 1 }; + if cell.attributes.hidden { self.text.push(' '); return; } @@ -311,11 +593,20 @@ fn build_row_runs(snapshot: &TerminalSnapshot, row: usize) -> Vec { let Some(cell) = snapshot.cell(row, column) else { continue; }; + // The cell that continues a wide glyph is a placeholder: the wide glyph's own run + // already covers its column, and painting it as a space would cover the right half of + // the glyph that precedes it. + if cell.attributes.wide_spacer { + continue; + } let cursor = snapshot.cursor == Some((row, column)); - if let Some(run) = runs.last_mut().filter(|run| run.matches(cell, cursor)) { - run.push(cell); - } else { - runs.push(TerminalRun::new(cell, cursor)); + // A wide glyph is placed by the grid rather than by the text flow, because its advance + // is not exactly two cell widths. Keeping it in a run of its own leaves every following + // run starting exactly where the grid says the next column starts. + let extended = runs.last_mut().filter(|run| !run.wide && run.matches(cell, cursor)); + match extended { + Some(run) => run.push(cell), + None => runs.push(TerminalRun::new(cell, cursor)), } } runs @@ -334,6 +625,17 @@ fn upright_attributes(mut attributes: TerminalCellAttributes) -> TerminalCellAtt attributes } +/// Attributes that decide where one painted run ends and the next one begins. +/// +/// Slant is dropped because the grid font ships upright only, and the wide-continuation +/// marker is dropped because it describes the cell's role in the grid rather than how the +/// cell looks. +fn run_attributes(mut attributes: TerminalCellAttributes) -> TerminalCellAttributes { + attributes.italic = false; + attributes.wide_spacer = false; + attributes +} + fn render_run(run: TerminalRun, metrics: TerminalMetrics, theme: UIThemes) -> impl IntoElement { let (mut foreground, mut background) = ( resolve_color(run.foreground, theme), @@ -463,3 +765,94 @@ fn encode_key(event: &KeyDownEvent) -> Option> { } Some(bytes) } + +#[cfg(test)] +mod tests { + use super::*; + + fn narrow(character: char) -> TerminalCell { + TerminalCell { + character, + ..TerminalCell::default() + } + } + + fn wide(character: char) -> TerminalCell { + TerminalCell { + character, + attributes: TerminalCellAttributes { + wide: true, + ..TerminalCellAttributes::default() + }, + ..TerminalCell::default() + } + } + + fn continuation() -> TerminalCell { + TerminalCell { + attributes: TerminalCellAttributes { + wide_spacer: true, + ..TerminalCellAttributes::default() + }, + ..TerminalCell::default() + } + } + + fn hidden(character: char) -> TerminalCell { + TerminalCell { + character, + attributes: TerminalCellAttributes { + hidden: true, + ..TerminalCellAttributes::default() + }, + ..TerminalCell::default() + } + } + + fn row_of(cells: Vec) -> TerminalSnapshot { + TerminalSnapshot { + columns: cells.len(), + rows: 1, + cells, + cursor: None, + } + } + + #[test] + fn wide_character_should_span_two_columns_and_hide_its_continuation() { + let runs = build_row_runs(&row_of(vec![wide('中'), continuation()]), 0); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].columns, 2); + assert_eq!(runs[0].text, "中"); + } + + #[test] + fn text_after_a_wide_character_should_start_at_its_own_grid_column() { + let runs = build_row_runs(&row_of(vec![wide('中'), continuation(), narrow('a'), narrow('b')]), 0); + + assert_eq!(runs.len(), 2); + assert_eq!((runs[0].columns, runs[0].text.as_str()), (2, "中")); + assert_eq!((runs[1].columns, runs[1].text.as_str()), (2, "ab")); + } + + #[test] + fn adjacent_wide_characters_should_not_share_a_run() { + let runs = build_row_runs(&row_of(vec![wide('中'), continuation(), wide('文'), continuation()]), 0); + + assert_eq!(runs.len(), 2); + assert!(runs.iter().all(|run| run.wide && run.columns == 2)); + assert_eq!(runs[1].text, "文"); + } + + #[test] + fn hidden_text_should_keep_its_columns_without_revealing_its_glyphs() { + let runs = build_row_runs(&row_of(vec![narrow('a'), hidden('x'), narrow('b')]), 0); + + assert_eq!( + runs.iter().map(|run| run.text.as_str()).collect::>(), + ["a", " ", "b"] + ); + assert_eq!(runs.iter().map(|run| run.columns).sum::(), 3); + } +} From 1f5fa0ad1fbe049d337858e3aea2d4faaf2d8a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 29 Sep 2026 01:28:39 +0800 Subject: [PATCH 23/32] fix(terminal): give double-width glyphs both of their grid columns A wide glyph owns two grid columns and the cell after it holds no character of its own. The run builder treated that continuation cell as an ordinary one: it split the pair across two runs, so the glyph was given a one-column box, and it painted the continuation as a space, which pushed every later column one place to the right. A Chinese character therefore showed only the part of itself that fit the narrower box, with the rest covered by the background of the run behind it. Carry the leading cell's wide flag out of the snapshot, drop continuation cells while grouping a row, and let a wide glyph account for both columns in a run of its own, so the run after it starts where the grid says the next column starts. Alacritty marks the leading cell WIDE_CHAR and the cell after it WIDE_CHAR_SPACER, which is the same split the other cell-grid terminals make. The debug example mirrors the renderer and is fixed with it. --- .../chitin-desktop/examples/terminal-debug.rs | 42 +++++++++++++++---- crates/chitin-terminal/src/snapshot.rs | 34 +++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/chitin-desktop/examples/terminal-debug.rs b/crates/chitin-desktop/examples/terminal-debug.rs index 92a46145..2784570f 100644 --- a/crates/chitin-desktop/examples/terminal-debug.rs +++ b/crates/chitin-desktop/examples/terminal-debug.rs @@ -242,6 +242,8 @@ struct TerminalRun { foreground: TerminalColor, background: TerminalColor, attributes: TerminalCellAttributes, + /// Whether the run holds a single glyph that is painted across two grid columns. + wide: bool, cursor: bool, } @@ -253,7 +255,8 @@ impl TerminalRun { columns: 0, foreground: cell.foreground, background: cell.background, - attributes: upright_attributes(cell.attributes), + attributes: run_attributes(cell.attributes), + wide: cell.attributes.wide, cursor, }; run.push(cell); @@ -264,14 +267,17 @@ impl TerminalRun { fn matches(&self, cell: &TerminalCell, cursor: bool) -> bool { self.foreground == cell.foreground && self.background == cell.background - && self.attributes == upright_attributes(cell.attributes) + && self.attributes == run_attributes(cell.attributes) + && self.wide == cell.attributes.wide && self.cursor == cursor } /// Appends one cell while retaining its fixed grid-column budget. fn push(&mut self, cell: &TerminalCell) { - self.columns += 1; - if cell.attributes.hidden || cell.attributes.wide_spacer { + // A wide cell is the only cell of its run, and it is the one that accounts for both of + // the columns the glyph covers. + self.columns += if cell.attributes.wide { 2 } else { 1 }; + if cell.attributes.hidden { self.text.push(' '); return; } @@ -294,11 +300,20 @@ fn build_row_runs(snapshot: &TerminalSnapshot, row: usize) -> Vec { let Some(cell) = snapshot.cell(row, column) else { continue; }; + // The cell that continues a wide glyph is a placeholder: the wide glyph's own run + // already covers its column, and painting it as a space would cover the right half of + // the glyph that precedes it. + if cell.attributes.wide_spacer { + continue; + } let cursor = snapshot.cursor == Some((row, column)); - if let Some(run) = runs.last_mut().filter(|run| run.matches(cell, cursor)) { - run.push(cell); - } else { - runs.push(TerminalRun::new(cell, cursor)); + // A wide glyph is placed by the grid rather than by the text flow, because its advance + // is not exactly two cell widths. Keeping it in a run of its own leaves every following + // run starting exactly where the grid says the next column starts. + let extended = runs.last_mut().filter(|run| !run.wide && run.matches(cell, cursor)); + match extended { + Some(run) => run.push(cell), + None => runs.push(TerminalRun::new(cell, cursor)), } } runs @@ -319,6 +334,17 @@ fn upright_attributes(mut attributes: TerminalCellAttributes) -> TerminalCellAtt attributes } +/// Attributes that decide where one painted run ends and the next one begins. +/// +/// Slant is dropped because the grid font ships upright only, and the wide-continuation +/// marker is dropped because it describes the cell's role in the grid rather than how the +/// cell looks. +fn run_attributes(mut attributes: TerminalCellAttributes) -> TerminalCellAttributes { + attributes.italic = false; + attributes.wide_spacer = false; + attributes +} + /// Renders one equal-style text run using a fixed terminal-column width. fn render_run(run: TerminalRun, metrics: TerminalMetrics) -> impl IntoElement { let (mut foreground, mut background) = (resolve_color(run.foreground), resolve_color(run.background)); diff --git a/crates/chitin-terminal/src/snapshot.rs b/crates/chitin-terminal/src/snapshot.rs index b6c49e8e..4861979e 100644 --- a/crates/chitin-terminal/src/snapshot.rs +++ b/crates/chitin-terminal/src/snapshot.rs @@ -52,6 +52,7 @@ impl TerminalSnapshot { underline: indexed.cell.flags.intersects(Flags::ALL_UNDERLINES), inverse: indexed.cell.flags.contains(Flags::INVERSE), hidden: indexed.cell.flags.contains(Flags::HIDDEN), + wide: indexed.cell.flags.contains(Flags::WIDE_CHAR), wide_spacer: indexed .cell .flags @@ -114,6 +115,9 @@ pub struct TerminalCellAttributes { pub underline: bool, pub inverse: bool, pub hidden: bool, + /// Whether the cell leads a glyph that occupies this column and the next one. + pub wide: bool, + /// Whether the cell continues a wide glyph instead of holding one of its own. pub wide_spacer: bool, } @@ -255,6 +259,36 @@ mod tests { ); } + #[test] + fn wide_character_should_own_two_columns() { + let size = TerminalSize::new(12, 3, 8, 16); + let mut term = Term::new(Config::default(), &size, VoidListener); + let mut processor = Processor::::new(); + + processor.advance(&mut term, "中文".as_bytes()); + let snapshot = TerminalSnapshot::from_term(&term); + + assert_eq!(snapshot.cell(0, 0).map(|cell| cell.character), Some('中')); + assert_eq!(snapshot.cell(0, 0).map(|cell| cell.attributes.wide), Some(true)); + assert_eq!(snapshot.cell(0, 1).map(|cell| cell.attributes.wide_spacer), Some(true)); + assert_eq!(snapshot.cell(0, 2).map(|cell| cell.character), Some('文')); + assert_eq!(snapshot.cell(0, 2).map(|cell| cell.attributes.wide), Some(true)); + assert_eq!(snapshot.cell(0, 3).map(|cell| cell.attributes.wide_spacer), Some(true)); + } + + #[test] + fn narrow_character_should_own_one_column() { + let size = TerminalSize::new(12, 3, 8, 16); + let mut term = Term::new(Config::default(), &size, VoidListener); + let mut processor = Processor::::new(); + + processor.advance(&mut term, b"M"); + let snapshot = TerminalSnapshot::from_term(&term); + + assert_eq!(snapshot.cell(0, 0).map(|cell| cell.attributes.wide), Some(false)); + assert_eq!(snapshot.cell(0, 1).map(|cell| cell.attributes.wide_spacer), Some(false)); + } + #[test] fn cell_should_reject_coordinates_outside_the_visible_grid() { let snapshot = TerminalSnapshot { From 6ed98993d1b57ade48000940e09f0d2b7cc0e7f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 29 Sep 2026 01:29:10 +0800 Subject: [PATCH 24/32] refactor(terminal): use the spacing scale for the dock inset The content around the terminal grid asked for eight pixels directly, which is what the spacing scale already produces one step up. Name the step instead so the inset follows the scale. --- .../src/components/terminal/render.rs | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/crates/chitin-desktop/src/components/terminal/render.rs b/crates/chitin-desktop/src/components/terminal/render.rs index 32357012..b6a5f7d1 100644 --- a/crates/chitin-desktop/src/components/terminal/render.rs +++ b/crates/chitin-desktop/src/components/terminal/render.rs @@ -119,22 +119,16 @@ pub(crate) fn render_terminal_bottom_dock( .flex_row() .flex_1() .min_h_0() - .child( - div() - .flex() - .flex_col() - .flex_1() - .min_w_0() - .min_h_0() - .p(px(8.0)) - .when_some(active_terminal, |terminal, active_terminal| { - terminal.child( - TerminalEmulator::new(active_terminal) - .theme(theme) - .font_family(TERMINAL_FONT_FAMILY), - ) - }), - ) + .child(div().flex().flex_col().flex_1().min_w_0().min_h_0().p_2().when_some( + active_terminal, + |terminal, active_terminal| { + terminal.child( + TerminalEmulator::new(active_terminal) + .theme(theme) + .font_family(TERMINAL_FONT_FAMILY), + ) + }, + )) .child(sessions), ); From 3d393f71f4be682f763c3fe9d0ad5d3f9c3a0b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 29 Sep 2026 01:58:58 +0800 Subject: [PATCH 25/32] fix(terminal): retire a session whose terminal backend exited A Bash or Fish session outlived the shell running inside it. The backend thread stopped, reaped the child, and reported the exit as TerminalEvent::Exited, but the emulator reduced that event to a status field that nothing painted and nothing emitted, so the exit reached no one. The tab stayed behind with its last screen and could not be closed by the program that had already ended. Carry the exit the rest of the way. TerminalEvent::Exited becomes TerminalEmulatorEvent::Exited { code } and leaves the emulator, because whether a stopped backend should also take its session down depends on what else the host has open. The host answers with exit code zero: a program that ended cleanly has nothing left to show, so its session is retired and the selection moves to its nearest neighbor, closing the dock when it was the last one. A non-zero code keeps the tab, shuts off input, and paints the code below the grid, because closing it would discard the last screen along with the reason for it. What the surface does after the exit is written not to disturb that record. Input is dropped rather than sent into a backend that would answer with an error; a resize is refused, since the failure it reported would replace the exit status; events that arrive after the exit are consequences of the shutdown and are ignored; and the cursor is hidden so a session that has ended reads differently from one that is merely waiting. Keystrokes are still swallowed, so they cannot fall through to a window shortcut behind the frozen grid. Two tests in chitin-terminal pin the contract the emulator now relies on: that a native PTY reports the child's exit code, and that dropping a session terminates the child it owns, which is what makes closing a tab with a shell still running stop that shell. --- .../src/components/terminal/mod.rs | 52 ++++++++--- crates/chitin-terminal/src/session.rs | 56 +++++++++++- .../src/primitive/terminal/emulator.rs | 91 +++++++++++++++++-- 3 files changed, 177 insertions(+), 22 deletions(-) diff --git a/crates/chitin-desktop/src/components/terminal/mod.rs b/crates/chitin-desktop/src/components/terminal/mod.rs index ba5d02e3..429fd045 100644 --- a/crates/chitin-desktop/src/components/terminal/mod.rs +++ b/crates/chitin-desktop/src/components/terminal/mod.rs @@ -198,11 +198,11 @@ impl TerminalPanelControls { fn subscribe_session(&self, session: &ManagedTerminalSession, window: &mut Window, cx: &mut Context) { let id = session.id; let terminal = session.terminal.clone(); - let subscription: Subscription = cx.subscribe_in(&terminal, window, move |this, _, event, window, cx| { - if *event == TerminalEmulatorEvent::InputWritten { - this.process_builtin_terminal_input(id, window, cx); - } - }); + let subscription: Subscription = + cx.subscribe_in(&terminal, window, move |this, _, event, window, cx| match *event { + TerminalEmulatorEvent::InputWritten => this.process_builtin_terminal_input(id, window, cx), + TerminalEmulatorEvent::Exited { code } => this.terminal_session_exited(id, code, cx), + }); subscription.detach(); let tab = session.tab.clone(); @@ -331,10 +331,34 @@ impl ChitinApp { /// Closes the active session and selects its nearest remaining neighbor. fn close_active_terminal_session(&mut self, cx: &mut Context) { - let running = self + let Some(id) = self .terminal_panel_controls .as_ref() .and_then(TerminalPanelControls::active) + .map(|session| session.id) + else { + return; + }; + self.close_terminal_session(id, cx); + } + + /// Retires a session whose terminal backend stopped on its own. + /// + /// A program that exited cleanly has nothing left to show, so its tab goes away with + /// it. One that failed keeps its tab, because the surface is showing the exit status + /// and closing it would discard the last screen along with the reason for it. + fn terminal_session_exited(&mut self, id: TerminalSessionId, code: u32, cx: &mut Context) { + if code == 0 { + self.close_terminal_session(id, cx); + } + } + + /// Closes one session and moves the selection when the active one is gone. + fn close_terminal_session(&mut self, id: TerminalSessionId, cx: &mut Context) { + let running = self + .terminal_panel_controls + .as_ref() + .and_then(|controls| controls.session(id)) .is_some_and(|session| session.active_shell_command.is_some()); if running && let Err(error) = self.builtin_shell().cancel_active() { log::warn!("failed to cancel active built-in terminal command: {error}"); @@ -342,13 +366,10 @@ impl ChitinApp { let Some(controls) = self.terminal_panel_controls.as_mut() else { return; }; - let Some(index) = controls - .sessions - .iter() - .position(|session| session.id == controls.active_session) - else { + let Some(index) = controls.sessions.iter().position(|session| session.id == id) else { return; }; + let was_active = controls.active_session == id; controls.sessions.remove(index); if controls.sessions.is_empty() { self.terminal_panel_controls = None; @@ -356,10 +377,13 @@ impl ChitinApp { self.terminal_panel.request_focus(false); cx.notify(); return; - } else if let Some(session) = controls.sessions.get(index.min(controls.sessions.len() - 1)) { - controls.active_session = session.id; } - self.terminal_panel.request_focus(true); + if was_active { + if let Some(session) = controls.sessions.get(index.min(controls.sessions.len() - 1)) { + controls.active_session = session.id; + } + self.terminal_panel.request_focus(true); + } cx.notify(); } diff --git a/crates/chitin-terminal/src/session.rs b/crates/chitin-terminal/src/session.rs index f27065a0..ef78a396 100644 --- a/crates/chitin-terminal/src/session.rs +++ b/crates/chitin-terminal/src/session.rs @@ -493,7 +493,7 @@ fn pty_error(operation: &'static str, error: impl std::fmt::Display) -> Terminal #[cfg(all(test, unix))] mod tests { - use std::{thread, time::Duration}; + use std::{path::PathBuf, thread, time::Duration}; use super::*; @@ -521,6 +521,60 @@ mod tests { ); } + #[test] + fn native_pty_should_report_the_child_exit_code() { + let mut command = CommandBuilder::new("sh"); + command.args(["-c", "exit 3"]); + let Ok(session) = TerminalSession::spawn(command, TerminalSize::new(40, 4, 8, 16)) else { + panic!("native PTY should be available during a Unix test"); + }; + + let mut exit_code = None; + for _ in 0..200 { + exit_code = session.drain_events().into_iter().find_map(|event| match event { + TerminalEvent::Exited(code) => Some(code), + _ => None, + }); + if exit_code.is_some() { + break; + } + thread::sleep(Duration::from_millis(10)); + } + + assert_eq!(exit_code, Some(3)); + } + + #[cfg(target_os = "linux")] + #[test] + fn dropping_a_session_should_terminate_the_pty_child() { + let pid_file = std::env::temp_dir().join("chitin-terminal-dropped-child.pid"); + let _ = std::fs::remove_file(&pid_file); + let mut command = CommandBuilder::new("sh"); + command.args(["-c", &format!("echo $$ > {}; exec sleep 300", pid_file.display())]); + let Ok(session) = TerminalSession::spawn(command, TerminalSize::new(40, 4, 8, 16)) else { + panic!("native PTY should be available during a Unix test"); + }; + let reported = (0..200) + .find_map(|_| { + thread::sleep(Duration::from_millis(10)); + std::fs::read_to_string(&pid_file).ok() + }) + .and_then(|pid| pid.trim().parse::().ok()); + let Some(child_pid) = reported else { + panic!("child should report its process id"); + }; + assert!(PathBuf::from(format!("/proc/{child_pid}")).exists()); + + drop(session); + + let exited = (0..500).any(|_| { + thread::sleep(Duration::from_millis(10)); + !PathBuf::from(format!("/proc/{child_pid}")).exists() + }); + let _ = std::fs::remove_file(&pid_file); + assert!(exited, "child {child_pid} outlived the session that owned it"); + } + #[test] fn in_process_program_should_receive_terminal_input_bytes() { let (session, program) = diff --git a/crates/chitin-ui/src/primitive/terminal/emulator.rs b/crates/chitin-ui/src/primitive/terminal/emulator.rs index 5c818b9a..cd96e96a 100644 --- a/crates/chitin-ui/src/primitive/terminal/emulator.rs +++ b/crates/chitin-ui/src/primitive/terminal/emulator.rs @@ -25,6 +25,11 @@ const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(16); pub enum TerminalEmulatorEvent { /// Input bytes were delivered to the active terminal backend. InputWritten, + /// The terminal backend stopped and reported its exit code. + Exited { + /// Code the backend reported for the program that ended. + code: u32, + }, } /// Persistent focus, backend, and VT state for a rendered terminal emulator. @@ -33,7 +38,10 @@ pub struct TerminalEmulatorState { focus: FocusHandle, size: TerminalSize, metrics: TerminalMetrics, + /// Message painted below the grid, describing a backend that failed or stopped. status: Option, + /// Exit code once the backend has stopped, which is also what ends its input. + ended: Option, preedit: Option, _event_task: Task<()>, } @@ -84,6 +92,7 @@ impl TerminalEmulatorState { font_size: px(FONT_SIZE), }, status: None, + ended: None, preedit: None, _event_task: event_task, } @@ -115,8 +124,14 @@ impl TerminalEmulatorState { return; } for event in events { + // A backend that has already stopped has nothing further to report: what arrives + // after its exit is a consequence of the shutdown, and must not overwrite the + // exit status the surface is showing. + if self.ended.is_some() { + break; + } match event { - TerminalEvent::Exited(code) => self.status = Some(format!("process exited with code {code}")), + TerminalEvent::Exited(code) => self.end(code, cx), TerminalEvent::Error(error) => self.status = Some(error), TerminalEvent::Render | TerminalEvent::Title(_) | TerminalEvent::Bell => {} } @@ -124,12 +139,25 @@ impl TerminalEmulatorState { cx.notify(); } + /// Records a backend that stopped and hands its exit code to the host. + /// + /// The session itself stays in place. Whether a stopped backend should also take its + /// session down depends on what else the host has open, so that decision belongs to + /// the host and travels there as [`TerminalEmulatorEvent::Exited`]. + fn end(&mut self, code: u32, cx: &mut Context) { + self.ended = Some(code); + self.status = Some(format!("process exited with code {code}")); + cx.emit(TerminalEmulatorEvent::Exited { code }); + } + fn resize(&mut self, bounds: Bounds, metrics: TerminalMetrics) { // Kept so the input method can be pointed at the same cell geometry the grid is painted // with, which is finer than the whole-pixel cell size reported to the backend. self.metrics = metrics; let next = terminal_size_for_bounds(bounds, metrics); - if next == self.size { + // A stopped backend has no grid left to resize, and the failure it would report for + // one would replace the exit status the surface is showing. + if next == self.size || self.ended.is_some() { return; } match self.session.resize(next) { @@ -139,6 +167,12 @@ impl TerminalEmulatorState { } fn handle_key(&mut self, event: &KeyDownEvent, cx: &mut Context) { + // A stopped backend has nowhere to put input, but the surface still swallows the + // keystroke so it cannot reach a window shortcut behind the frozen grid. + if self.ended.is_some() { + cx.stop_propagation(); + return; + } // A live composition owns every keystroke: Enter commits it, Escape cancels it, // and the arrow keys pick a candidate. Forwarding those bytes would send them to // the child program as well, which is what the composition has not done yet. @@ -155,6 +189,11 @@ impl TerminalEmulatorState { /// Delivers encoded input to the active backend and repaints the surface. fn write_input(&mut self, bytes: &[u8], cx: &mut Context) { + // Committed composition text arrives here too, and a stopped backend would answer + // both with an error rather than with the child program's own echo. + if self.ended.is_some() { + return; + } match self.session.write(bytes) { Ok(()) => cx.emit(TerminalEmulatorEvent::InputWritten), Err(error) => self.status = Some(error.to_string()), @@ -319,8 +358,15 @@ impl TerminalEmulator { impl RenderOnce for TerminalEmulator { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let metrics = measure_terminal_metrics(window, self.font_family.clone()); + let status_font = self.font_family.clone(); + let status = self.state.read(cx).status.clone(); let preedit = self.state.read(cx).preedit().cloned(); - let snapshot = self.state.read(cx).session.snapshot(); + let mut snapshot = self.state.read(cx).session.snapshot(); + // A stopped backend leaves its last frame on the grid, so the cursor is what + // separates a session that has ended from one that is merely waiting. + if self.state.read(cx).ended.is_some() { + snapshot.cursor = None; + } let focus = self.state.read(cx).focus.clone(); let state_for_mouse = self.state.clone(); let state_for_keys = self.state.clone(); @@ -364,13 +410,44 @@ impl RenderOnce for TerminalEmulator { .line_height(metrics.cell_height) .child(div().flex().flex_col().size_full().children(rows)); - TerminalSurface { - content: grid.into_any_element(), - state: self.state, - } + // The grid keeps the whole surface to itself until the backend has something to + // report, so the input method anchor and the painted grid stay on the same bounds. + div() + .flex() + .flex_col() + .size_full() + .child(div().flex_1().min_h_0().child(TerminalSurface { + content: grid.into_any_element(), + state: self.state, + })) + .when_some(status, move |surface, status| { + surface.child(render_status(&status, metrics, theme, status_font)) + }) } } +/// Paints a backend's final or failed state below the grid. +fn render_status( + status: &str, + metrics: TerminalMetrics, + theme: UIThemes, + font_family: SharedString, +) -> impl IntoElement { + div() + .flex_none() + .w_full() + .h(metrics.cell_height) + .px(metrics.cell_width) + .border_t_1() + .border_color(theme.border.muted) + .whitespace_nowrap() + .overflow_hidden() + .font(terminal_font(font_family)) + .text_size(metrics.font_size) + .text_color(theme.text.secondary) + .child(status.to_owned()) +} + /// Wraps the terminal grid so the surface can register itself as a text input target. /// /// GPUI enables the platform input method only for the element that calls From 3adbeeddb90a04aaf6389ca9e52bcf12dfd861b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 29 Sep 2026 14:25:41 +0800 Subject: [PATCH 26/32] feat(terminal): expose the scrollback viewport The backend has been retaining ten thousand lines behind every session all along, but nothing could reach them. The viewport offset stayed at zero, no call could move it, and the snapshot reported only the live screen, so a frontend had no way to see that history existed or to ask for any of it. TerminalScroll names the motions a frontend may ask for, and TerminalScrollState reports where the viewport sits: the history it sits in, how much of that history is above it, how many lines it shows, and whether the child program owns the alternate screen. The alternate screen is reported because it keeps no history of its own, so the motions mean nothing there. Alacritty only exposes relative viewport motion, so an absolute target is reached by asking for the difference from where the viewport is now, clamped to the lines that are actually retained before the cast. The snapshot gains the offset and the history size so a frontend can place the viewport without a second read of the terminal, and it stops reporting a cursor while the viewport is scrolled back: the cursor point is an absolute grid coordinate, so once the viewport has moved it names a line below the visible rows, and clamping it into range would paint it onto whichever row happened to be last. --- .../chitin-desktop/examples/terminal-debug.rs | 10 + crates/chitin-terminal/src/lib.rs | 3 +- crates/chitin-terminal/src/session.rs | 241 +++++++++++++++++- crates/chitin-terminal/src/snapshot.rs | 29 ++- 4 files changed, 279 insertions(+), 4 deletions(-) diff --git a/crates/chitin-desktop/examples/terminal-debug.rs b/crates/chitin-desktop/examples/terminal-debug.rs index 2784570f..67396c58 100644 --- a/crates/chitin-desktop/examples/terminal-debug.rs +++ b/crates/chitin-desktop/examples/terminal-debug.rs @@ -542,6 +542,8 @@ mod tests { rows: 1, cells: vec![cell; 120], cursor: None, + display_offset: 0, + history_size: 0, }; let runs = build_row_runs(&snapshot, 0); @@ -559,6 +561,8 @@ mod tests { rows: 1, cells, cursor: None, + display_offset: 0, + history_size: 0, }; let runs = build_row_runs(&snapshot, 0); @@ -578,6 +582,8 @@ mod tests { rows: 1, cells: vec![cell; 120], cursor: Some((0, 60)), + display_offset: 0, + history_size: 0, }; let runs = build_row_runs(&snapshot, 0); @@ -601,6 +607,8 @@ mod tests { rows: 1, cells: vec![cell], cursor: None, + display_offset: 0, + history_size: 0, }; let runs = build_row_runs(&snapshot, 0); @@ -628,6 +636,8 @@ mod tests { rows: 1, cells: vec![upright, italic], cursor: None, + display_offset: 0, + history_size: 0, }; let runs = build_row_runs(&snapshot, 0); diff --git a/crates/chitin-terminal/src/lib.rs b/crates/chitin-terminal/src/lib.rs index c6617bc2..e344fe28 100644 --- a/crates/chitin-terminal/src/lib.rs +++ b/crates/chitin-terminal/src/lib.rs @@ -11,7 +11,8 @@ pub use model::{ TerminalTone, }; pub use session::{ - TerminalEvent, TerminalProgram, TerminalProgramInput, TerminalProgramOutput, TerminalSession, TerminalSessionError, + TerminalEvent, TerminalProgram, TerminalProgramInput, TerminalProgramOutput, TerminalScroll, TerminalScrollState, + TerminalSession, TerminalSessionError, }; pub use size::TerminalSize; pub use snapshot::{TerminalCell, TerminalCellAttributes, TerminalColor, TerminalNamedColor, TerminalSnapshot}; diff --git a/crates/chitin-terminal/src/session.rs b/crates/chitin-terminal/src/session.rs index ef78a396..a21c6530 100644 --- a/crates/chitin-terminal/src/session.rs +++ b/crates/chitin-terminal/src/session.rs @@ -12,8 +12,9 @@ use std::{ use alacritty_terminal::{ Term, event::{Event as AlacrittyEvent, EventListener}, + grid::{Dimensions, Scroll}, sync::FairMutex, - term::Config, + term::{Config, TermMode}, vte::ansi, }; use portable_pty::{ChildKiller, CommandBuilder, MasterPty, native_pty_system}; @@ -51,6 +52,43 @@ pub enum TerminalEvent { Error(String), } +/// A viewport motion through the terminal's scrollback history. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalScroll { + /// Moves the viewport by whole lines, negative toward the live edge. + Lines(i32), + /// Moves the viewport back by one screenful. + PageUp, + /// Moves the viewport toward the live edge by one screenful. + PageDown, + /// Moves to the oldest line still retained. + Top, + /// Moves to the live edge, where new output appears. + Bottom, + /// Moves so that `lines` of history sit above the viewport. + Offset(usize), +} + +/// Where a terminal viewport sits inside its scrollback history. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TerminalScrollState { + /// Lines of scrollback retained behind the viewport. + pub history_size: usize, + /// Lines of history the viewport has been scrolled back over. + pub display_offset: usize, + /// Lines the viewport shows at once. + pub screen_lines: usize, + /// Whether the child program owns the alternate screen, which has no history. + pub alt_screen: bool, +} + +impl TerminalScrollState { + /// Returns how many lines of history sit above the viewport. + pub const fn lines_above(&self) -> usize { + self.history_size.saturating_sub(self.display_offset) + } +} + /// Errors produced while creating or controlling a terminal session. #[derive(Debug, Error)] pub enum TerminalSessionError { @@ -337,6 +375,51 @@ impl TerminalSession { TerminalSnapshot::from_term(&self.terminal.lock()) } + /// Moves the visible viewport through the scrollback history. + /// + /// # Parameters + /// + /// * `scroll` is the requested viewport motion. It is clamped to the lines + /// that are actually retained. + /// + /// # Returns + /// + /// This function does not return a value: a viewport motion cannot fail. It + /// does nothing while the child program owns the alternate screen, because + /// that screen keeps no history to move through. + pub fn scroll(&self, scroll: TerminalScroll) { + let mut terminal = self.terminal.lock(); + let scroll = match scroll { + TerminalScroll::Lines(lines) => Scroll::Delta(lines), + TerminalScroll::PageUp => Scroll::PageUp, + TerminalScroll::PageDown => Scroll::PageDown, + TerminalScroll::Top => Scroll::Top, + TerminalScroll::Bottom => Scroll::Bottom, + // Alacritty only exposes relative viewport motion, so an absolute target + // is reached by asking for the difference from where the viewport is now. + // The target is clamped before the cast so a caller asking for a line far + // past the oldest one cannot wrap the difference around into a scroll the + // wrong way. + TerminalScroll::Offset(lines) => { + let offset = terminal.grid().display_offset(); + let target = lines.min(terminal.history_size()); + Scroll::Delta(target as i32 - offset as i32) + } + }; + terminal.scroll_display(scroll); + } + + /// Returns where the viewport currently sits in the scrollback history. + pub fn scroll_state(&self) -> TerminalScrollState { + let terminal = self.terminal.lock(); + TerminalScrollState { + history_size: terminal.history_size(), + display_offset: terminal.grid().display_offset(), + screen_lines: terminal.screen_lines(), + alt_screen: terminal.mode().contains(TermMode::ALT_SCREEN), + } + } + /// Drains terminal events accumulated since the previous call. pub fn drain_events(&self) -> Vec { // Clear before draining so a concurrent parser update can enqueue the next @@ -604,6 +687,162 @@ mod tests { assert!(visible_text.contains("builtin-shell")); } + /// Writes bytes out of an in-process program, failing the test if it refuses them. + fn feed(program: &TerminalProgram, bytes: &[u8]) { + assert!( + program.output().write(bytes).is_ok(), + "an in-process program should accept output" + ); + } + + /// Runs a terminal whose scrollback holds `lines` numbered lines. + fn session_with_scrollback(lines: usize) -> TerminalSession { + let (session, program) = + TerminalSession::in_process(TerminalProfile::BuiltinShell, TerminalSize::new(20, 4, 8, 16)); + let written = (1..=lines).map(|line| format!("line-{line}\r\n")).collect::(); + feed(&program, written.as_bytes()); + session + } + + /// Returns the trimmed text of one visible row. + fn row_text(snapshot: &TerminalSnapshot, row: usize) -> String { + (0..snapshot.columns) + .filter_map(|column| snapshot.cell(row, column)) + .map(|cell| cell.character) + .collect::() + .trim_end() + .to_owned() + } + + #[test] + fn scroll_should_move_the_viewport_back_through_history() { + let session = session_with_scrollback(10); + + assert_eq!(session.scroll_state().display_offset, 0); + session.scroll(TerminalScroll::Lines(2)); + + assert_eq!(session.scroll_state().display_offset, 2); + assert_eq!(session.snapshot().display_offset, 2); + } + + #[test] + fn a_scrolled_snapshot_should_show_older_rows_than_the_live_edge() { + let session = session_with_scrollback(10); + let live = session.snapshot(); + let history_size = live.history_size; + + session.scroll(TerminalScroll::Lines(2)); + let scrolled = session.snapshot(); + + assert!( + history_size >= 2, + "the scrollback should hold the two lines being scrolled over" + ); + assert_ne!(row_text(&scrolled, 0), row_text(&live, 0)); + // The two views overlap: what the scrolled viewport shows at row `row` is + // what the live viewport shows at row `row - 2`, because scrolled content is + // older content. + for row in 2..scrolled.rows { + assert_eq!(row_text(&scrolled, row), row_text(&live, row - 2)); + } + assert_eq!(scrolled.history_size, history_size); + } + + #[test] + fn scrolling_forward_past_the_live_edge_should_stop_there() { + let session = session_with_scrollback(10); + + session.scroll(TerminalScroll::Lines(-5)); + + assert_eq!(session.scroll_state().display_offset, 0); + } + + #[test] + fn scrolling_back_past_the_oldest_line_should_stop_there() { + let session = session_with_scrollback(10); + let history_size = session.scroll_state().history_size; + + session.scroll(TerminalScroll::Lines(history_size as i32 + 50)); + + assert_eq!(session.scroll_state().display_offset, history_size); + } + + #[test] + fn the_top_motion_should_reach_the_oldest_line_and_the_bottom_motion_should_return() { + let session = session_with_scrollback(10); + let history_size = session.scroll_state().history_size; + assert!(history_size > 0, "the fixture should have pushed lines into history"); + + session.scroll(TerminalScroll::Top); + assert_eq!(session.scroll_state().display_offset, history_size); + assert_eq!(session.scroll_state().lines_above(), 0); + + session.scroll(TerminalScroll::Bottom); + assert_eq!(session.scroll_state().display_offset, 0); + assert_eq!(session.scroll_state().lines_above(), history_size); + } + + #[test] + fn page_scroll_should_move_the_viewport_by_one_screenful() { + let session = session_with_scrollback(40); + let screen_lines = session.scroll_state().screen_lines; + + session.scroll(TerminalScroll::PageUp); + assert_eq!(session.scroll_state().display_offset, screen_lines); + + session.scroll(TerminalScroll::PageDown); + assert_eq!(session.scroll_state().display_offset, 0); + } + + #[test] + fn an_offset_should_set_the_viewport_absolutely() { + let session = session_with_scrollback(40); + let history_size = session.scroll_state().history_size; + + session.scroll(TerminalScroll::Offset(3)); + assert_eq!(session.scroll_state().display_offset, 3); + assert_eq!(session.snapshot().display_offset, 3); + + // A target past the oldest retained line lands on it rather than wrapping. + session.scroll(TerminalScroll::Offset(usize::MAX)); + assert_eq!(session.scroll_state().display_offset, history_size); + } + + #[test] + fn a_snapshot_should_not_report_a_cursor_while_scrolled() { + let session = session_with_scrollback(10); + + assert!(session.snapshot().cursor.is_some()); + session.scroll(TerminalScroll::Lines(2)); + assert_eq!(session.snapshot().cursor, None); + + session.scroll(TerminalScroll::Bottom); + assert!(session.snapshot().cursor.is_some()); + } + + #[test] + fn scrolling_should_do_nothing_on_the_alternate_screen() { + let (session, program) = + TerminalSession::in_process(TerminalProfile::BuiltinShell, TerminalSize::new(20, 4, 8, 16)); + let written = (1..=10).map(|line| format!("line-{line}\r\n")).collect::(); + feed(&program, written.as_bytes()); + feed(&program, b"\x1b[?1049h"); + + let alternate = session.scroll_state(); + assert!(alternate.alt_screen, "the fixture should have switched screens"); + assert_eq!(alternate.history_size, 0); + + session.scroll(TerminalScroll::Top); + assert_eq!(session.scroll_state().display_offset, 0); + + feed(&program, b"\x1b[?1049l"); + assert!(!session.scroll_state().alt_screen); + assert!( + session.scroll_state().history_size > 0, + "the primary scrollback should return" + ); + } + #[test] fn in_process_program_should_receive_terminal_resize() { let (session, program) = diff --git a/crates/chitin-terminal/src/snapshot.rs b/crates/chitin-terminal/src/snapshot.rs index 4861979e..a389400a 100644 --- a/crates/chitin-terminal/src/snapshot.rs +++ b/crates/chitin-terminal/src/snapshot.rs @@ -16,7 +16,15 @@ pub struct TerminalSnapshot { /// Visible cells stored in row-major order. pub cells: Vec, /// Visible cursor location, when the terminal requests a cursor. + /// + /// A cursor the terminal has hidden, or one that is not inside the visible + /// viewport because the viewport has been scrolled back, is reported absent. pub cursor: Option<(usize, usize)>, + /// Lines of history the viewport has been scrolled back over, where zero is + /// the live edge. + pub display_offset: usize, + /// Lines of scrollback retained behind the viewport. + pub history_size: usize, } impl TerminalSnapshot { @@ -25,7 +33,12 @@ impl TerminalSnapshot { let content = term.renderable_content(); let columns = term.columns(); let rows = term.screen_lines(); - let cursor = (!matches!(content.cursor.shape, alacritty_terminal::vte::ansi::CursorShape::Hidden)).then(|| { + // The cursor point is an absolute grid coordinate, so once the viewport has + // been scrolled back it names a line below the visible rows. Clamping it + // into range would paint it onto whichever row happened to be last. + let cursor = (content.display_offset == 0 + && !matches!(content.cursor.shape, alacritty_terminal::vte::ansi::CursorShape::Hidden)) + .then(|| { ( content.cursor.point.line.0.max(0) as usize, content.cursor.point.column.0, @@ -34,7 +47,15 @@ impl TerminalSnapshot { let mut cells = vec![TerminalCell::default(); rows.saturating_mul(columns)]; for indexed in content.display_iter { - let row = indexed.point.line.0.max(0) as usize; + // The iterator reports absolute grid lines and starts one line above the + // viewport, so a line is negative whenever the viewport has been scrolled + // back. Adding the offset turns an absolute line into the row it occupies + // on screen, and the one line above the viewport falls out as row -1. + let row = indexed.point.line.0 + content.display_offset as i32; + if row < 0 { + continue; + } + let row = row as usize; let column = indexed.point.column.0; let Some(cell) = cells.get_mut(row.saturating_mul(columns).saturating_add(column)) else { continue; @@ -66,6 +87,8 @@ impl TerminalSnapshot { rows, cells, cursor, + display_offset: content.display_offset, + history_size: term.history_size(), } } @@ -296,6 +319,8 @@ mod tests { rows: 1, cells: vec![TerminalCell::default(); 2], cursor: None, + display_offset: 0, + history_size: 0, }; assert!(snapshot.cell(0, 1).is_some()); From 5bbf658d78d24788277dbbcfeb1dadecb20ceff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 29 Sep 2026 14:36:49 +0800 Subject: [PATCH 27/32] feat(ui): add a draggable scrollbar primitive Nothing in the library reported where a viewport sat, so a scrollable region had no way to show it and no way to be dragged. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScrollbarMetrics describes the region in the caller's own pixel space — the content, the viewport, and an offset that follows GPUI's convention of zero at the start of the content and more negative toward its end — so the geometry is free of layout types and can be tested without a window. The thumb spans the share of the track the viewport covers, with a floor so a long history does not shrink it past the point where it can be grabbed; the floor applies to the mapping as well as the paint, so the far end of the content stays reachable. ScrollbarState owns the drag and publishes ScrollbarEvent, so callers subscribe to a semantic event rather than attaching their own pointer handlers. The bar is laid out as an overlay pinned to the trailing edge, so gaining and losing scrollback cannot change the width the content underneath is laid out for, and the layer that captures the pointer beyond the narrow track exists only while a drag is underway — an idle bar inserts no hitbox over the content. That capture has to close itself. GPUI dispatches pointer events by position and offers no pointer capture, so a button released outside the bar is never delivered to it. The move reports which button is still held, and the gesture ends when it is no longer the left one; without that, the gesture stayed open and the next move across the bar jumped the viewport to wherever that move implied. --- crates/chitin-ui/src/primitive/mod.rs | 2 + .../src/primitive/scrollbar/event.rs | 13 + .../src/primitive/scrollbar/metrics.rs | 262 ++++++++++++++++++ .../chitin-ui/src/primitive/scrollbar/mod.rs | 14 + .../src/primitive/scrollbar/render.rs | 182 ++++++++++++ .../src/primitive/scrollbar/state.rs | 112 ++++++++ 6 files changed, 585 insertions(+) create mode 100644 crates/chitin-ui/src/primitive/scrollbar/event.rs create mode 100644 crates/chitin-ui/src/primitive/scrollbar/metrics.rs create mode 100644 crates/chitin-ui/src/primitive/scrollbar/mod.rs create mode 100644 crates/chitin-ui/src/primitive/scrollbar/render.rs create mode 100644 crates/chitin-ui/src/primitive/scrollbar/state.rs diff --git a/crates/chitin-ui/src/primitive/mod.rs b/crates/chitin-ui/src/primitive/mod.rs index cc94770f..36c4c9e8 100644 --- a/crates/chitin-ui/src/primitive/mod.rs +++ b/crates/chitin-ui/src/primitive/mod.rs @@ -14,6 +14,8 @@ pub mod popover; pub mod progress; /// Generic resize gesture state. pub mod resize; +/// Draggable scrollbars reporting viewport positions. +pub mod scrollbar; /// Sidebar layout and resize controls. pub mod sidebar; /// Monospace terminal rows and tail-following scroll viewport. diff --git a/crates/chitin-ui/src/primitive/scrollbar/event.rs b/crates/chitin-ui/src/primitive/scrollbar/event.rs new file mode 100644 index 00000000..c2d64143 --- /dev/null +++ b/crates/chitin-ui/src/primitive/scrollbar/event.rs @@ -0,0 +1,13 @@ +//! Semantic events published by the scrollbar primitive. + +use gpui::Pixels; + +/// A viewport motion requested by a scrollbar interaction. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ScrollbarEvent { + /// The user moved the viewport to this offset in the caller's pixel space. + /// + /// The offset follows GPUI's scroll convention: zero shows the start of the + /// content and it becomes more negative as the viewport moves toward the end. + Scrolled { offset: Pixels }, +} diff --git a/crates/chitin-ui/src/primitive/scrollbar/metrics.rs b/crates/chitin-ui/src/primitive/scrollbar/metrics.rs new file mode 100644 index 00000000..8f6a13cc --- /dev/null +++ b/crates/chitin-ui/src/primitive/scrollbar/metrics.rs @@ -0,0 +1,262 @@ +//! Scrollbar geometry, expressed in the caller's pixel space. +//! +//! The math here is deliberately free of GPUI layout types beyond [`Pixels`], +//! so thumb sizing and drag mapping can be tested without a window. + +use gpui::{Pixels, px}; + +/// Smallest fraction of the track a thumb keeps. +/// +/// A long scrollback would otherwise shrink the thumb past the point where it +/// can be grabbed. The floor is applied to the mapping as well as the paint, so +/// the far end of the content stays reachable. +const MIN_THUMB_EXTENT: f32 = 0.05; + +/// Geometry of the region a scrollbar moves a viewport through. +/// +/// The offset follows GPUI's scroll convention: zero shows the start of the +/// content and it becomes more negative as the viewport moves toward the end. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScrollbarMetrics { + /// Total height of the scrollable content. + pub content_height: Pixels, + /// Height of the visible viewport. + pub viewport_height: Pixels, + /// Distance from the top of the content to the top of the viewport. + pub offset: Pixels, +} + +impl ScrollbarMetrics { + /// Creates metrics for one viewport position. + /// + /// # Parameters + /// + /// * `content_height` is the total height of the scrollable content. + /// * `viewport_height` is the height of the visible viewport. + /// * `offset` is the distance from the top of the content to the top of the + /// viewport, which is negative once the viewport has moved down. + /// + /// # Returns + /// + /// Metrics describing the requested viewport position. + pub const fn new(content_height: Pixels, viewport_height: Pixels, offset: Pixels) -> Self { + Self { + content_height, + viewport_height, + offset, + } + } + + /// Returns whether the content is taller than the viewport. + pub fn is_scrollable(&self) -> bool { + self.travel() > 0.0 + } + + /// Returns the fraction of the track the thumb occupies. + pub fn thumb_extent_fraction(&self) -> f32 { + let content = f32::from(self.content_height); + if content <= 0.0 { + return 1.0; + } + + (f32::from(self.viewport_height) / content).clamp(MIN_THUMB_EXTENT, 1.0) + } + + /// Returns the thumb's distance from the top of the track, as a fraction of + /// the track that the thumb can travel over. + pub fn thumb_top_fraction(&self) -> f32 { + let travel = self.travel(); + if travel <= 0.0 { + return 0.0; + } + + let progress = (-f32::from(self.offset) / travel).clamp(0.0, 1.0); + progress * self.thumb_reach() + } + + /// Returns the viewport offset described by a thumb dragged to `top`. + /// + /// # Parameters + /// + /// * `top` is the thumb's distance from the top of the track, as a fraction + /// of the track. Values outside the reachable range land on its ends. + /// + /// # Returns + /// + /// The viewport offset in the caller's pixel space. + pub fn offset_for_thumb_top(&self, top: f32) -> Pixels { + let travel = self.travel(); + let reach = self.thumb_reach(); + if travel <= 0.0 || reach <= 0.0 { + return Pixels::ZERO; + } + + px(-(top / reach).clamp(0.0, 1.0) * travel) + } + + /// Returns the viewport offset for a click at a point on the track. + /// + /// The click centres the thumb on itself, which is what a scrollbar track + /// click does everywhere else. + /// + /// # Parameters + /// + /// * `fraction` is the clicked distance from the top of the track, as a + /// fraction of the track. + /// + /// # Returns + /// + /// The viewport offset in the caller's pixel space. + pub fn offset_for_track_click(&self, fraction: f32) -> Pixels { + let reach = self.thumb_reach(); + let top = (fraction - self.thumb_extent_fraction() / 2.0).clamp(0.0, reach); + self.offset_for_thumb_top(top) + } + + /// Returns the offset one viewport closer to the end of the content. + pub fn offset_for_page_toward_end(&self) -> Pixels { + self.offset_for_page(true) + } + + /// Returns the offset one viewport closer to the start of the content. + pub fn offset_for_page_toward_start(&self) -> Pixels { + self.offset_for_page(false) + } + + /// Returns the offset one viewport away from the current position. + fn offset_for_page(&self, toward_end: bool) -> Pixels { + let travel = self.travel(); + let step = if toward_end { + f32::from(self.viewport_height) + } else { + -f32::from(self.viewport_height) + }; + + px(-(-f32::from(self.offset) + step).clamp(0.0, travel)) + } + + /// Returns the distance the viewport can travel through the content. + fn travel(&self) -> f32 { + (f32::from(self.content_height) - f32::from(self.viewport_height)).max(0.0) + } + + /// Returns how far the thumb's top may travel, as a track fraction. + fn thumb_reach(&self) -> f32 { + 1.0 - self.thumb_extent_fraction() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds metrics for content that is scrolled to a given pixel offset. + fn metrics(content: f32, viewport: f32, offset: f32) -> ScrollbarMetrics { + ScrollbarMetrics::new(px(content), px(viewport), px(offset)) + } + + #[test] + fn content_that_fits_should_not_scroll() { + assert!(!metrics(80.0, 200.0, 0.0).is_scrollable()); + } + + #[test] + fn a_thumb_should_span_the_whole_track_when_nothing_scrolls() { + let metrics = metrics(80.0, 200.0, 0.0); + + assert_eq!(metrics.thumb_extent_fraction(), 1.0); + assert_eq!(metrics.thumb_top_fraction(), 0.0); + } + + #[test] + fn a_thumb_should_shrink_as_the_content_grows() { + let some_history = metrics(124.0, 24.0, 0.0).thumb_extent_fraction(); + let more_history = metrics(100_024.0, 24.0, 0.0).thumb_extent_fraction(); + + assert!((some_history - 24.0 / 124.0).abs() < 1e-6); + assert!( + more_history < some_history, + "a longer history should give a smaller thumb" + ); + assert_eq!(metrics(48.0, 24.0, 0.0).thumb_extent_fraction(), 0.5); + } + + #[test] + fn a_thumb_should_keep_a_grabbable_size_for_a_long_history() { + assert_eq!(metrics(100_024.0, 24.0, 0.0).thumb_extent_fraction(), MIN_THUMB_EXTENT); + } + + #[test] + fn a_thumb_should_rest_at_the_start_of_the_track_at_the_first_offset() { + let metrics = metrics(200.0, 100.0, 0.0); + + assert_eq!(metrics.thumb_top_fraction(), 0.0); + } + + #[test] + fn a_thumb_should_rest_at_the_end_of_the_track_at_the_last_offset() { + let metrics = metrics(200.0, 100.0, -100.0); + + // Half the content is visible, so the thumb is half the track and sits at + // the end of the half it can travel. + assert_eq!(metrics.thumb_extent_fraction(), 0.5); + assert_eq!(metrics.thumb_top_fraction(), 0.5); + } + + #[test] + fn an_offset_should_round_trip_through_the_thumb() { + let metrics = metrics(400.0, 100.0, -90.0); + let top = metrics.thumb_top_fraction(); + + assert_eq!(metrics.offset_for_thumb_top(top), px(-90.0)); + } + + #[test] + fn dragging_toward_the_end_should_ask_for_a_later_offset() { + let metrics = metrics(400.0, 100.0, 0.0); + + let earlier = metrics.offset_for_thumb_top(0.25); + let later = metrics.offset_for_thumb_top(0.75); + + assert!(later < earlier, "a lower thumb should ask for a more negative offset"); + assert!(earlier < Pixels::ZERO); + } + + #[test] + fn a_drag_past_either_end_should_land_on_it() { + let metrics = metrics(400.0, 100.0, -150.0); + + assert_eq!(metrics.offset_for_thumb_top(-5.0), Pixels::ZERO); + assert_eq!(metrics.offset_for_thumb_top(5.0), px(-300.0)); + } + + #[test] + fn a_track_click_should_centre_the_thumb_on_the_click() { + let metrics = metrics(400.0, 100.0, 0.0); + + // A click at the midpoint of the track should land the viewport halfway + // through the content, with the thumb centred under the pointer. + let offset = metrics.offset_for_track_click(0.5); + + assert_eq!(offset, px(-150.0)); + // Halfway through the content means the thumb's top is at half of its reach. + let centred = ScrollbarMetrics::new(metrics.content_height, metrics.viewport_height, offset); + assert_eq!(centred.thumb_top_fraction(), 0.375); + } + + #[test] + fn a_track_click_should_not_escape_the_content() { + let metrics = metrics(400.0, 100.0, 0.0); + + assert_eq!(metrics.offset_for_track_click(0.0), Pixels::ZERO); + assert_eq!(metrics.offset_for_track_click(1.0), px(-300.0)); + } + + #[test] + fn a_content_that_fits_should_ignore_every_motion() { + let metrics = metrics(80.0, 200.0, 0.0); + + assert_eq!(metrics.offset_for_thumb_top(0.5), Pixels::ZERO); + assert_eq!(metrics.offset_for_track_click(0.5), Pixels::ZERO); + } +} diff --git a/crates/chitin-ui/src/primitive/scrollbar/mod.rs b/crates/chitin-ui/src/primitive/scrollbar/mod.rs new file mode 100644 index 00000000..c2950e63 --- /dev/null +++ b/crates/chitin-ui/src/primitive/scrollbar/mod.rs @@ -0,0 +1,14 @@ +//! A draggable scrollbar reporting where a viewport should sit. +//! +//! [`ScrollbarState`] owns the drag. Consumers subscribe to [`ScrollbarEvent`] +//! values rather than attaching component-specific pointer handlers. + +mod event; +mod metrics; +mod render; +mod state; + +pub use event::ScrollbarEvent; +pub use metrics::ScrollbarMetrics; +pub use render::{Scrollbar, ScrollbarSize}; +pub use state::ScrollbarState; diff --git a/crates/chitin-ui/src/primitive/scrollbar/render.rs b/crates/chitin-ui/src/primitive/scrollbar/render.rs new file mode 100644 index 00000000..3f3eab60 --- /dev/null +++ b/crates/chitin-ui/src/primitive/scrollbar/render.rs @@ -0,0 +1,182 @@ +//! Rendering and visual customization for the scrollbar primitive. + +use gpui::{ + App, CursorStyle, Entity, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, + Window, div, prelude::*, px, relative, +}; + +use super::{ScrollbarMetrics, ScrollbarState}; +use crate::themes::{UIThemes, builtins}; + +/// Track width and thumb width for one visual size of a [`Scrollbar`]. +#[derive(Clone, Copy, Debug)] +struct ScrollbarSizeMetrics { + /// Width of the interactive track along the trailing edge. + lane_width: Pixels, + /// Width of the painted thumb inside the track. + thumb_width: Pixels, +} + +/// Visual size for a [`Scrollbar`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ScrollbarSize { + /// Narrow bar for dense panes. + Small, + /// Default bar. + #[default] + Medium, +} + +impl ScrollbarSize { + /// Returns the track and thumb widths for this size. + fn metrics(self) -> ScrollbarSizeMetrics { + match self { + Self::Small => ScrollbarSizeMetrics { + lane_width: px(9.0), + thumb_width: px(4.0), + }, + Self::Medium => ScrollbarSizeMetrics { + lane_width: px(13.0), + thumb_width: px(6.0), + }, + } + } +} + +/// A draggable bar that reports where a viewport should sit. +/// +/// The bar is laid out over the viewport it scrolls and pinned to the trailing +/// edge, so its height is the viewport height. It paints nothing while the +/// content fits, and while idle it only occupies its lane, leaving the rest of +/// the area free for the content underneath. +#[derive(IntoElement)] +pub struct Scrollbar { + state: Entity, + metrics: ScrollbarMetrics, + theme: UIThemes, + size: ScrollbarSize, +} + +impl Scrollbar { + /// Creates a scrollbar that reports motions through `state`. + pub fn new(state: Entity) -> Self { + Self { + state, + metrics: ScrollbarMetrics::new(Pixels::ZERO, Pixels::ZERO, Pixels::ZERO), + theme: builtins::dark(), + size: ScrollbarSize::default(), + } + } + + /// Sets the content geometry this bar describes. + pub fn metrics(mut self, metrics: ScrollbarMetrics) -> Self { + self.metrics = metrics; + self + } + + /// Sets the semantic theme used for this bar. + pub fn theme(mut self, theme: UIThemes) -> Self { + self.theme = theme; + self + } + + /// Sets the visual size. + pub fn size(mut self, size: ScrollbarSize) -> Self { + self.size = size; + self + } +} + +impl RenderOnce for Scrollbar { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let dragging = self.state.read(cx).is_dragging(); + let mut root = div().absolute().inset_0(); + if !self.metrics.is_scrollable() { + return root; + } + + let size = self.size.metrics(); + let theme = self.theme; + let metrics = self.metrics; + let thumb_top = metrics.thumb_top_fraction(); + let thumb_extent = metrics.thumb_extent_fraction(); + + let thumb_color: Hsla = if dragging { + theme.text.secondary + } else { + theme.border.tertiary + } + .into(); + + let state_for_thumb = self.state.clone(); + let state_for_before = self.state.clone(); + let state_for_after = self.state.clone(); + let state_for_move = self.state.clone(); + let state_for_up = self.state.clone(); + + // While dragging, the pointer is free to leave the narrow lane, so the whole + // area takes the move and release. This layer only exists during a drag; + // an idle bar inserts no hitbox here and the content underneath keeps its + // own pointer behaviour. + if dragging { + root = root + .on_mouse_move(move |event, _, cx| { + state_for_move.update(cx, |state, cx| { + state.drag(event.position.y, event.pressed_button, metrics, cx); + }); + }) + .on_mouse_up(MouseButton::Left, move |_, _, cx| { + state_for_up.update(cx, |state, cx| state.end_drag(cx)); + }); + } + + root.child( + div() + .absolute() + .top_0() + .bottom_0() + .right_0() + .w(size.lane_width) + .cursor(CursorStyle::Arrow) + .hover(move |style| style.bg(theme.background.hover)) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(relative(thumb_top)) + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + state_for_before.update(cx, |state, cx| state.scroll_page(metrics, false, cx)); + cx.stop_propagation(); + }), + ) + .child( + div() + .absolute() + .top(relative(thumb_top)) + .h(relative(thumb_extent)) + .right(px(3.0)) + .w(size.thumb_width) + .rounded_full() + .bg(thumb_color) + .on_mouse_down(MouseButton::Left, move |event, _, cx| { + state_for_thumb.update(cx, |state, cx| state.begin_drag(event.position.y, metrics, cx)); + cx.stop_propagation(); + }), + ) + .child( + div() + .absolute() + .top(relative(thumb_top + thumb_extent)) + .bottom_0() + .left_0() + .right_0() + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + state_for_after.update(cx, |state, cx| state.scroll_page(metrics, true, cx)); + cx.stop_propagation(); + }), + ), + ) + } +} diff --git a/crates/chitin-ui/src/primitive/scrollbar/state.rs b/crates/chitin-ui/src/primitive/scrollbar/state.rs new file mode 100644 index 00000000..60ea3a0f --- /dev/null +++ b/crates/chitin-ui/src/primitive/scrollbar/state.rs @@ -0,0 +1,112 @@ +//! Persistent interaction state for the scrollbar primitive. + +use gpui::{Context, EventEmitter, MouseButton, Pixels}; + +use super::{ScrollbarEvent, ScrollbarMetrics}; +use crate::primitive::resize::ResizeGesture; + +/// Persistent interaction state for one scrollbar. +/// +/// The bar owns its drag: callers subscribe to [`ScrollbarEvent`] instead of +/// attaching their own pointer handlers. +pub struct ScrollbarState { + drag: Option>, +} + +impl ScrollbarState { + /// Creates a scrollbar that is not being dragged. + pub fn new(_cx: &mut Context) -> Self { + Self { drag: None } + } + + /// Returns whether the user is currently dragging the thumb. + pub const fn is_dragging(&self) -> bool { + self.drag.is_some() + } + + /// Starts a drag from a press on the thumb. + /// + /// A press that arrives while a drag is already underway is ignored, so the + /// thumb cannot be handed to a second pointer mid-gesture. + /// + /// # Parameters + /// + /// * `pointer_y` is where the pointer pressed the thumb, in window pixels. + /// * `metrics` describes the content the bar is scrolling. + /// * `cx` refreshes observers of the drag state. + pub fn begin_drag(&mut self, pointer_y: Pixels, metrics: ScrollbarMetrics, cx: &mut Context) { + if self.is_dragging() { + return; + } + + // The grip is kept as a track fraction rather than a pixel delta so it + // survives the content growing underneath the drag. + self.drag = Some(ResizeGesture::new((), pointer_y, metrics.thumb_top_fraction())); + cx.notify(); + } + + /// Continues an in-progress drag from the current pointer position. + /// + /// This is a no-op when no drag is underway, which is what lets one handler be + /// attached to the whole bar without acting on idle pointer movement. + /// + /// # Parameters + /// + /// * `pointer_y` is the pointer position on the drag axis, in window pixels. + /// * `pressed_button` is the button the move reports as held, if any. + /// * `metrics` describes the content the bar is scrolling. The bar is laid out + /// over the viewport, so the viewport height is the distance the thumb + /// travels over. + /// * `cx` publishes the requested viewport offset. + pub fn drag( + &mut self, + pointer_y: Pixels, + pressed_button: Option, + metrics: ScrollbarMetrics, + cx: &mut Context, + ) { + // A release outside the bar never reaches its own handler: GPUI dispatches + // pointer events by position and offers no pointer capture, so a gesture + // would otherwise stay open and the next move across the bar would jump the + // viewport to wherever that move implied. The button the move reports is + // what closes it instead. + if pressed_button != Some(MouseButton::Left) { + self.end_drag(cx); + return; + } + let Some(gesture) = self.drag.as_ref() else { + return; + }; + + let track_height = f32::from(metrics.viewport_height).max(1.0); + let thumb_top = gesture.start_value() + gesture.delta(pointer_y) / track_height; + cx.emit(ScrollbarEvent::Scrolled { + offset: metrics.offset_for_thumb_top(thumb_top), + }); + } + + /// Ends an in-progress drag. + pub fn end_drag(&mut self, cx: &mut Context) { + if self.drag.take().is_some() { + cx.notify(); + } + } + + /// Moves the viewport by one screenful toward one end of the content. + /// + /// # Parameters + /// + /// * `metrics` describes the content the bar is scrolling. + /// * `toward_end` selects the direction of travel. + /// * `cx` publishes the requested viewport offset. + pub fn scroll_page(&self, metrics: ScrollbarMetrics, toward_end: bool, cx: &mut Context) { + let offset = if toward_end { + metrics.offset_for_page_toward_end() + } else { + metrics.offset_for_page_toward_start() + }; + cx.emit(ScrollbarEvent::Scrolled { offset }); + } +} + +impl EventEmitter for ScrollbarState {} From b928beafb2f13636d11d9067541ead00e469cae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 29 Sep 2026 16:02:10 +0800 Subject: [PATCH 28/32] feat(terminal): scroll the terminal viewport The surface had no scroll handling of any kind. The wheel reached nothing, no keystroke moved the viewport, and with no history exposed there was nothing to show where it sat. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wheel asks for whole lines and carries the sub-line part of a pixel delta to the next event, so a precision trackpad reporting a fraction of a line at a time still moves; one event is capped so a single large platform delta cannot throw the viewport across the whole scrollback. On the alternate screen, which keeps no history of its own, the wheel is forwarded to the child program as the arrow keys it reads, one per whole line. The shifted page, home and end keys move the viewport, and they are recognised before key encoding: the encoding table looks at shift only for tab, so shift with a page key would otherwise reach the program as the unshifted key it did not ask for. The wheel and a live composition are mutually exclusive in both directions — a composition is anchored to the row it captured when it started, so moving the viewport under it would paint it over different content. Any input delivered to the backend returns the viewport to the live edge. The child program's answer to a keystroke has to appear where the user is looking, and it cannot be seen from a viewport that has been scrolled away from it. The repaint is asked for rather than waited for: the backend reports a viewport motion as a cursor-dirty event and coalesces it, so a frame could otherwise be painted from a position the viewport has already left. --- .../src/primitive/terminal/emulator.rs | 453 ++++++++++++++++-- 1 file changed, 423 insertions(+), 30 deletions(-) diff --git a/crates/chitin-ui/src/primitive/terminal/emulator.rs b/crates/chitin-ui/src/primitive/terminal/emulator.rs index cd96e96a..4c81dff2 100644 --- a/crates/chitin-ui/src/primitive/terminal/emulator.rs +++ b/crates/chitin-ui/src/primitive/terminal/emulator.rs @@ -3,22 +3,30 @@ use std::{ops::Range, sync::Arc, time::Duration}; use chitin_terminal::{ - TerminalCell, TerminalCellAttributes, TerminalColor, TerminalEvent, TerminalNamedColor, TerminalSession, - TerminalSessionError, TerminalSize, TerminalSnapshot, + TerminalCell, TerminalCellAttributes, TerminalColor, TerminalEvent, TerminalNamedColor, TerminalScroll, + TerminalScrollState, TerminalSession, TerminalSessionError, TerminalSize, TerminalSnapshot, }; use gpui::{ AnyElement, App, AsyncApp, Bounds, Context, Div, Element, ElementId, ElementInputHandler, Entity, EntityInputHandler, EventEmitter, FocusHandle, Font, FontFallbacks, FontFeatures, FontWeight, GlobalElementId, Hsla, InspectorElementId, - InteractiveElement, IntoElement, KeyDownEvent, LayoutId, MouseButton, ParentElement, Pixels, Point, RenderOnce, - SharedString, Styled, Task, TextRun, Timer, UTF16Selection, WeakEntity, Window, div, font, point, prelude::*, px, - rgb, size, + InteractiveElement, IntoElement, KeyDownEvent, Keystroke, LayoutId, MouseButton, ParentElement, Pixels, Point, + RenderOnce, ScrollDelta, ScrollWheelEvent, SharedString, Styled, Subscription, Task, TextRun, Timer, UTF16Selection, + WeakEntity, Window, div, font, point, prelude::*, px, rgb, size, }; -use crate::themes::{UIThemes, builtins}; +use crate::{ + primitive::scrollbar::{Scrollbar, ScrollbarEvent, ScrollbarMetrics, ScrollbarState}, + themes::{UIThemes, builtins}, +}; const FONT_SIZE: f32 = 15.0; const LINE_HEIGHT_MULTIPLIER: f32 = 1.4; const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(16); +/// Largest viewport motion one wheel event may ask for. +/// +/// A platform is free to report a very large pixel delta, and one such event +/// should not throw the viewport across the whole scrollback. +const MAX_WHEEL_LINES_PER_EVENT: i32 = 10; /// Semantic events emitted by the terminal emulator primitive. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -43,6 +51,12 @@ pub struct TerminalEmulatorState { /// Exit code once the backend has stopped, which is also what ends its input. ended: Option, preedit: Option, + /// Where the viewport sits in the scrollback, refreshed once per poll. + scroll_state: TerminalScrollState, + /// Sub-line part of a pixel scroll, carried so a slow trackpad still moves. + scroll_remainder: f32, + scrollbar: Entity, + _scrollbar_subscription: Subscription, _event_task: Task<()>, } @@ -82,6 +96,14 @@ impl TerminalEmulatorState { } } }); + let scrollbar = cx.new(ScrollbarState::new); + // The subscription is held for as long as the state lives: dropping it would + // silently disconnect the bar from the viewport it moves. + let scrollbar_subscription = cx.subscribe(&scrollbar, |state, _, event, cx| { + let ScrollbarEvent::Scrolled { offset } = *event; + state.scroll_to_scrollbar_offset(offset, cx); + }); + let scroll_state = session.scroll_state(); Self { session, focus: cx.focus_handle(), @@ -94,6 +116,10 @@ impl TerminalEmulatorState { status: None, ended: None, preedit: None, + scroll_state, + scroll_remainder: 0.0, + scrollbar, + _scrollbar_subscription: scrollbar_subscription, _event_task: event_task, } } @@ -119,6 +145,10 @@ impl TerminalEmulatorState { } fn process_events(&mut self, cx: &mut Context) { + // Read before the events are drained: a child program entering or leaving the + // alternate screen changes what the wheel means, and that is observed here rather + // than at the next wheel event. The read is a few counters under the terminal lock. + self.scroll_state = self.session.scroll_state(); let events = self.session.drain_events(); if events.is_empty() { return; @@ -161,12 +191,33 @@ impl TerminalEmulatorState { return; } match self.session.resize(next) { - Ok(()) => self.size = next, + Ok(()) => { + self.size = next; + // A resize reflows the grid and can shorten the history the viewport sits in, + // which the backend answers by clamping the position it had. + self.refresh_scroll_state(); + // The cell height the wheel was measured against has changed, so a fraction + // carried over from before it no longer names the same distance. + self.scroll_remainder = 0.0; + } Err(error) => self.status = Some(error.to_string()), } } fn handle_key(&mut self, event: &KeyDownEvent, cx: &mut Context) { + // Scrolling reaches back through whatever a stopped program left on screen, which + // is exactly when its last screen is worth reading, and it writes no bytes for the + // guards below to withhold. A live composition is the one case it waits for: the + // composition is anchored to the row it captured when it started, so moving the + // viewport would paint it over different content. The guard below then swallows + // the keystroke, exactly as the wheel does. + if self.preedit.is_none() + && let Some(scroll) = scroll_keystroke(&event.keystroke) + { + self.scroll_viewport(scroll, cx); + cx.stop_propagation(); + return; + } // A stopped backend has nowhere to put input, but the surface still swallows the // keystroke so it cannot reach a window shortcut behind the frozen grid. if self.ended.is_some() { @@ -194,12 +245,79 @@ impl TerminalEmulatorState { if self.ended.is_some() { return; } + // Input is aimed at the live edge. Echoing into a viewport that has been scrolled + // back would put the child program's answer somewhere the user cannot see. + if self.scroll_state.display_offset != 0 { + self.session.scroll(TerminalScroll::Bottom); + self.refresh_scroll_state(); + } match self.session.write(bytes) { Ok(()) => cx.emit(TerminalEmulatorEvent::InputWritten), Err(error) => self.status = Some(error.to_string()), } cx.notify(); } + + /// Applies one wheel event to the viewport, or forwards it to a full-screen program. + fn handle_scroll_wheel(&mut self, event: &ScrollWheelEvent, cx: &mut Context) { + // A live composition anchors to a row captured when it started, and scrolling + // would paint it over different content. A stopped backend has nothing to scroll. + if self.ended.is_some() || self.preedit.is_some() { + cx.stop_propagation(); + return; + } + + let lines = wheel_lines(&event.delta, self.metrics.cell_height, &mut self.scroll_remainder); + if lines == 0 { + cx.stop_propagation(); + return; + } + // A full-screen program keeps no scrollback of its own, so the wheel means to it + // what the arrow keys mean: the only viewport motion it understands. + if self.scroll_state.alt_screen { + self.write_arrows(lines, cx); + } else { + self.scroll_viewport(TerminalScroll::Lines(lines), cx); + } + cx.stop_propagation(); + } + + /// Forwards wheel motion to a full-screen program as the arrow keys it reads. + fn write_arrows(&mut self, lines: i32, cx: &mut Context) { + let key = if lines > 0 { "up" } else { "down" }; + let Some(bytes) = encode_special_key(key, false) else { + return; + }; + // One arrow per whole line, capped so a trackpad fling cannot flood the child. + for _ in 0..lines.unsigned_abs().min(MAX_WHEEL_LINES_PER_EVENT as u32) { + self.write_input(bytes, cx); + } + } + + /// Moves the viewport and repaints the surface. + fn scroll_viewport(&mut self, scroll: TerminalScroll, cx: &mut Context) { + self.session.scroll(scroll); + self.refresh_scroll_state(); + // The backend reports a viewport motion as a cursor-dirty event and coalesces it, + // so the repaint is asked for here rather than waited for. + cx.notify(); + } + + /// Moves the viewport to the position a scrollbar drag described. + fn scroll_to_scrollbar_offset(&mut self, offset: Pixels, cx: &mut Context) { + let display_offset = + display_offset_for_scrollbar_offset(offset, self.metrics.cell_height, self.scroll_state.history_size); + self.scroll_viewport(TerminalScroll::Offset(display_offset), cx); + } + + /// Reads the viewport position back from the session. + /// + /// The wheel remainder is deliberately left alone: it is the sub-line part of a + /// gesture still in progress, and clearing it here would drop that fraction every + /// time the viewport crossed a whole line. + fn refresh_scroll_state(&mut self) { + self.scroll_state = self.session.scroll_state(); + } } impl EventEmitter for TerminalEmulatorState {} @@ -368,9 +486,12 @@ impl RenderOnce for TerminalEmulator { snapshot.cursor = None; } let focus = self.state.read(cx).focus.clone(); + let scrollbar = self.state.read(cx).scrollbar.clone(); + let scrollbar_metrics = scrollbar_metrics(self.state.read(cx).scroll_state, metrics.cell_height); let state_for_mouse = self.state.clone(); let state_for_keys = self.state.clone(); let state_for_layout = self.state.clone(); + let state_for_scroll = self.state.clone(); let theme = self.theme; let rows = (0..snapshot.rows).map(move |row| { let cells = build_row_runs(&snapshot, row) @@ -412,14 +533,28 @@ impl RenderOnce for TerminalEmulator { // The grid keeps the whole surface to itself until the backend has something to // report, so the input method anchor and the painted grid stay on the same bounds. + // The wheel listener sits on this outer surface rather than on the grid so that it + // also covers the scrollbar, whose lane is a sibling of the surface below. div() .flex() .flex_col() .size_full() - .child(div().flex_1().min_h_0().child(TerminalSurface { - content: grid.into_any_element(), - state: self.state, - })) + .on_scroll_wheel(move |event, _, cx| { + state_for_scroll.update(cx, |state, cx| state.handle_scroll_wheel(event, cx)); + }) + .child( + div() + .relative() + .flex_1() + .min_h_0() + .child(TerminalSurface { + content: grid.into_any_element(), + state: self.state, + }) + // The bar is an overlay rather than a column of its own, so gaining and + // losing scrollback cannot change the width the backend is laid out for. + .child(Scrollbar::new(scrollbar).metrics(scrollbar_metrics).theme(theme)), + ) .when_some(status, move |surface, status| { surface.child(render_status(&status, metrics, theme, status_font)) }) @@ -799,28 +934,111 @@ fn indexed_color(index: u8) -> Hsla { rgb(value).into() } +/// Returns the bytes a terminal program expects for a named special key. +/// +/// The table is shared by key encoding and by the wheel, which forwards motion to a +/// full-screen program as arrow keys, so the two cannot disagree about what an arrow is. +fn encode_special_key(key: &str, shift: bool) -> Option<&'static [u8]> { + match key { + "enter" => Some(b"\r"), + "backspace" => Some(b"\x7f"), + "tab" if shift => Some(b"\x1b[Z"), + "tab" => Some(b"\t"), + "escape" => Some(b"\x1b"), + "up" => Some(b"\x1b[A"), + "down" => Some(b"\x1b[B"), + "right" => Some(b"\x1b[C"), + "left" => Some(b"\x1b[D"), + "home" => Some(b"\x1b[H"), + "end" => Some(b"\x1b[F"), + "pageup" => Some(b"\x1b[5~"), + "pagedown" => Some(b"\x1b[6~"), + "delete" => Some(b"\x1b[3~"), + "insert" => Some(b"\x1b[2~"), + _ => None, + } +} + +/// Returns the viewport motion a keystroke asks for. +/// +/// Only the shifted forms scroll. Unmodified keys have to keep reaching the child +/// program as the bytes it expects, and the combinations that select text elsewhere +/// are left alone rather than guessed at. +fn scroll_keystroke(keystroke: &Keystroke) -> Option { + let modifiers = keystroke.modifiers; + if !modifiers.shift || modifiers.control || modifiers.alt || modifiers.platform { + return None; + } + + match keystroke.key.as_str() { + "pageup" => Some(TerminalScroll::PageUp), + "pagedown" => Some(TerminalScroll::PageDown), + "home" => Some(TerminalScroll::Top), + "end" => Some(TerminalScroll::Bottom), + _ => None, + } +} + +/// Converts one wheel event into whole lines of viewport motion. +/// +/// # Parameters +/// +/// * `delta` is the platform's scroll delta for this event. +/// * `cell_height` measures a pixel delta in lines. +/// * `remainder` carries the sub-line part of earlier events. A precision trackpad +/// reports fractions of a line per event, and discarding each one separately would +/// make a slow gesture scroll nothing at all. +/// +/// # Returns +/// +/// Whole lines to scroll, positive toward older output, capped at +/// [`MAX_WHEEL_LINES_PER_EVENT`]. Zero when this event only accumulated a fraction. +fn wheel_lines(delta: &ScrollDelta, cell_height: Pixels, remainder: &mut f32) -> i32 { + let lines = match delta { + ScrollDelta::Lines(lines) => lines.y, + ScrollDelta::Pixels(pixels) => f32::from(pixels.y) / f32::from(cell_height.max(px(1.0))), + }; + + *remainder += lines; + let whole = remainder.trunc(); + *remainder -= whole; + (whole as i32).clamp(-MAX_WHEEL_LINES_PER_EVENT, MAX_WHEEL_LINES_PER_EVENT) +} + +/// Describes a viewport position in scrollbar geometry. +/// +/// The bar puts the oldest output at the top of the content and the live edge at the +/// bottom, and it measures downward from zero the way GPUI scroll offsets do. +fn scrollbar_metrics(scroll: TerminalScrollState, cell_height: Pixels) -> ScrollbarMetrics { + let viewport_height = cell_height * scroll.screen_lines as f32; + let content_height = cell_height * (scroll.screen_lines + scroll.history_size) as f32; + let offset = -(cell_height * scroll.lines_above() as f32); + + ScrollbarMetrics::new(content_height, viewport_height, offset) +} + +/// Converts a position on the scrollbar back into a viewport display offset. +/// +/// # Parameters +/// +/// * `offset` is the bar's distance from the top of the content, negative downward. +/// * `cell_height` measures that distance in lines. +/// * `history_size` bounds the result to the lines that are actually retained. +/// +/// # Returns +/// +/// The number of lines the viewport should sit above the live edge. +fn display_offset_for_scrollbar_offset(offset: Pixels, cell_height: Pixels, history_size: usize) -> usize { + let lines = f32::from(offset) / f32::from(cell_height.max(px(1.0))); + let display_offset = history_size as f32 + lines.round(); + + display_offset.clamp(0.0, history_size as f32) as usize +} + fn encode_key(event: &KeyDownEvent) -> Option> { let key = event.keystroke.key.as_str(); let modifiers = event.keystroke.modifiers; - let special = match key { - "enter" => Some(b"\r".as_slice()), - "backspace" => Some(b"\x7f".as_slice()), - "tab" if modifiers.shift => Some(b"\x1b[Z".as_slice()), - "tab" => Some(b"\t".as_slice()), - "escape" => Some(b"\x1b".as_slice()), - "up" => Some(b"\x1b[A".as_slice()), - "down" => Some(b"\x1b[B".as_slice()), - "right" => Some(b"\x1b[C".as_slice()), - "left" => Some(b"\x1b[D".as_slice()), - "home" => Some(b"\x1b[H".as_slice()), - "end" => Some(b"\x1b[F".as_slice()), - "pageup" => Some(b"\x1b[5~".as_slice()), - "pagedown" => Some(b"\x1b[6~".as_slice()), - "delete" => Some(b"\x1b[3~".as_slice()), - "insert" => Some(b"\x1b[2~".as_slice()), - _ => None, - }; - if let Some(special) = special { + if let Some(special) = encode_special_key(key, modifiers.shift) { return Some(special.to_vec()); } if modifiers.platform { @@ -892,6 +1110,8 @@ mod tests { rows: 1, cells, cursor: None, + display_offset: 0, + history_size: 0, } } @@ -932,4 +1152,177 @@ mod tests { ); assert_eq!(runs.iter().map(|run| run.columns).sum::(), 3); } + + /// Builds a viewport position for the scrollbar helper tests. + fn scroll_state(history: usize, offset: usize, lines: usize) -> TerminalScrollState { + TerminalScrollState { + history_size: history, + display_offset: offset, + screen_lines: lines, + alt_screen: false, + } + } + + #[test] + fn a_wheel_tick_toward_the_top_should_reveal_older_lines() { + let mut remainder = 0.0; + + let lines = wheel_lines(&ScrollDelta::Lines(point(0.0, 1.0)), px(20.0), &mut remainder); + + assert_eq!(lines, 1); + } + + #[test] + fn a_wheel_tick_toward_the_bottom_should_move_toward_the_live_edge() { + let mut remainder = 0.0; + + let lines = wheel_lines(&ScrollDelta::Lines(point(0.0, -1.0)), px(20.0), &mut remainder); + + assert_eq!(lines, -1); + } + + #[test] + fn pixel_scrolling_should_accumulate_until_a_whole_line_is_reached() { + let mut remainder = 0.0; + // Quarters rather than thirds: a third of a line is not exactly representable, + // and three of them sum to just under one. + let quarter = ScrollDelta::Pixels(point(px(0.0), px(5.0))); + + let steps = (0..4) + .map(|_| wheel_lines(&quarter, px(20.0), &mut remainder)) + .collect::>(); + + // Four quarters of a line must scroll once between them, not four times zero. + assert_eq!(steps, [0, 0, 0, 1]); + } + + #[test] + fn a_pixel_delta_smaller_than_a_line_should_not_scroll_yet() { + let mut remainder = 0.0; + + let lines = wheel_lines(&ScrollDelta::Pixels(point(px(0.0), px(2.0))), px(20.0), &mut remainder); + + assert_eq!(lines, 0); + assert!( + remainder > 0.0, + "the unused part of the delta should carry to the next event" + ); + } + + #[test] + fn a_wheel_event_should_not_scroll_more_than_the_per_event_cap() { + let mut remainder = 0.0; + + let lines = wheel_lines( + &ScrollDelta::Pixels(point(px(0.0), px(10_000.0))), + px(20.0), + &mut remainder, + ); + + assert_eq!(lines, MAX_WHEEL_LINES_PER_EVENT); + } + + #[test] + fn a_scroll_keystroke_should_require_shift_and_no_other_modifier() { + let plain = Keystroke { + key: "pageup".into(), + ..Keystroke::default() + }; + let shifted = Keystroke { + modifiers: gpui::Modifiers { + shift: true, + ..gpui::Modifiers::default() + }, + key: "pageup".into(), + ..Keystroke::default() + }; + + assert_eq!(scroll_keystroke(&plain), None); + assert_eq!(scroll_keystroke(&shifted), Some(TerminalScroll::PageUp)); + } + + #[test] + fn a_scroll_keystroke_should_map_the_viewport_motions() { + let keystroke = |key: &'static str| Keystroke { + modifiers: gpui::Modifiers { + shift: true, + ..gpui::Modifiers::default() + }, + key: key.into(), + ..Keystroke::default() + }; + + assert_eq!(scroll_keystroke(&keystroke("pageup")), Some(TerminalScroll::PageUp)); + assert_eq!(scroll_keystroke(&keystroke("pagedown")), Some(TerminalScroll::PageDown)); + assert_eq!(scroll_keystroke(&keystroke("home")), Some(TerminalScroll::Top)); + assert_eq!(scroll_keystroke(&keystroke("end")), Some(TerminalScroll::Bottom)); + assert_eq!(scroll_keystroke(&keystroke("a")), None); + } + + #[test] + fn an_unmodified_page_key_should_still_reach_the_child_program() { + // The scroll branch runs before key encoding, so it must decline the keystrokes + // that the program is waiting for. + let keystroke = Keystroke { + key: "pageup".into(), + ..Keystroke::default() + }; + + assert_eq!(scroll_keystroke(&keystroke), None); + assert_eq!(encode_special_key("pageup", false), Some(b"\x1b[5~".as_slice())); + } + + #[test] + fn a_wheel_arrow_should_match_the_arrow_key_encoding() { + // The wheel forwards motion to a full-screen program as arrow keys, so the bytes + // it sends and the bytes the arrow keys send have to be the same ones. + assert_eq!(encode_special_key("up", false), Some(b"\x1b[A".as_slice())); + assert_eq!(encode_special_key("down", false), Some(b"\x1b[B".as_slice())); + } + + #[test] + fn a_shifted_tab_should_still_encode_as_a_back_tab() { + assert_eq!(encode_special_key("tab", true), Some(b"\x1b[Z".as_slice())); + assert_eq!(encode_special_key("tab", false), Some(b"\t".as_slice())); + } + + #[test] + fn the_scrollbar_should_measure_from_the_oldest_line_to_the_live_edge() { + let metrics = scrollbar_metrics(scroll_state(100, 0, 24), px(20.0)); + + // At the live edge the viewport sits at the bottom of the content. + assert_eq!(metrics.viewport_height, px(480.0)); + assert_eq!(metrics.content_height, px(2480.0)); + assert_eq!(metrics.offset, px(-2000.0)); + assert_eq!(metrics.thumb_top_fraction(), 1.0 - metrics.thumb_extent_fraction()); + } + + #[test] + fn the_scrollbar_should_sit_at_the_top_when_scrolled_all_the_way_back() { + let metrics = scrollbar_metrics(scroll_state(100, 100, 24), px(20.0)); + + assert_eq!(metrics.offset, Pixels::ZERO); + assert_eq!(metrics.thumb_top_fraction(), 0.0); + } + + #[test] + fn a_scrollbar_offset_should_round_trip_through_the_display_offset() { + for display_offset in [0, 1, 37, 100] { + let metrics = scrollbar_metrics(scroll_state(100, display_offset, 24), px(20.0)); + + assert_eq!( + display_offset_for_scrollbar_offset(metrics.offset, px(20.0), 100), + display_offset, + ); + } + } + + #[test] + fn a_display_offset_should_stay_inside_the_retained_history() { + // The bar measures downward from the oldest line, so an offset above the top + // of the content asks for more history than is retained and lands on the + // oldest line, and one below the bottom lands on the live edge. + assert_eq!(display_offset_for_scrollbar_offset(px(5_000.0), px(20.0), 100), 100); + assert_eq!(display_offset_for_scrollbar_offset(px(-5_000.0), px(20.0), 100), 0); + } } From 2438082103cf3145dc9accf73e8faf1cc906d027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 29 Sep 2026 16:05:11 +0800 Subject: [PATCH 29/32] =?UTF-8?q?feat(terminal):=20scroll=20the=20terminal?= =?UTF-8?q?=20session=20tab=20strip=20The=20strip=20asked=20for=20a=20vert?= =?UTF-8?q?ical=20overflow=20but=20named=20neither=20an=20element=20id=20n?= =?UTF-8?q?or=20a=20tracked=20scroll=20handle.=20GPUI=20takes=20the=20scro?= =?UTF-8?q?ll=20offset=20from=20a=20tracked=20handle=20or=20from=20the=20e?= =?UTF-8?q?lement's=20own=20state,=20so=20with=20neither=20it=20never=20re?= =?UTF-8?q?gistered=20the=20listener=20and=20never=20displaced=20the=20chi?= =?UTF-8?q?ldren=20=E2=80=94=20a=20silent=20failure=20with=20nothing=20to?= =?UTF-8?q?=20assert=20against.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handle is owned by the panel controls because it has to outlive a render; one created while rendering would start every frame back at the top of the strip. A new session scrolls the strip to its end, which is where the tab it just created sits, so the new session is visible without the controls having to know the strip's geometry. --- .../src/components/terminal/mod.rs | 11 ++- .../src/components/terminal/render.rs | 72 ++++++++++--------- 2 files changed, 49 insertions(+), 34 deletions(-) diff --git a/crates/chitin-desktop/src/components/terminal/mod.rs b/crates/chitin-desktop/src/components/terminal/mod.rs index 429fd045..c2bfd85f 100644 --- a/crates/chitin-desktop/src/components/terminal/mod.rs +++ b/crates/chitin-desktop/src/components/terminal/mod.rs @@ -20,7 +20,7 @@ use chitin_ui::{ terminal::{TerminalEmulatorEvent, TerminalEmulatorState}, }, }; -use gpui::{AppContext, Context, Entity, Subscription, Window}; +use gpui::{AppContext, Context, Entity, ScrollHandle, Subscription, Window}; use crate::app::ChitinApp; @@ -105,6 +105,11 @@ pub(crate) struct TerminalPanelControls { next_session_id: u64, pub(super) close_session: Entity, pub(super) profile_select: Entity, + /// Scroll position of the session tab strip. + /// + /// This has to outlive a render: a handle created while rendering would start every + /// frame at the top of the strip and the lane would never move. + pub(super) tab_scroll: ScrollHandle, } impl TerminalPanelControls { @@ -131,6 +136,7 @@ impl TerminalPanelControls { next_session_id: 2, close_session, profile_select, + tab_scroll: ScrollHandle::new(), }) } @@ -309,6 +315,9 @@ impl ChitinApp { controls.sessions.push(session); controls.active_session = id; controls.next_session_id += 1; + // The new tab is the last child, so revealing it keeps the active session in + // view without the session manager having to know the strip's geometry. + controls.tab_scroll.scroll_to_bottom(); } self.terminal_panel.request_focus(true); cx.notify(); diff --git a/crates/chitin-desktop/src/components/terminal/render.rs b/crates/chitin-desktop/src/components/terminal/render.rs index b6a5f7d1..d44120b1 100644 --- a/crates/chitin-desktop/src/components/terminal/render.rs +++ b/crates/chitin-desktop/src/components/terminal/render.rs @@ -46,8 +46,10 @@ pub(crate) fn render_terminal_bottom_dock( let resize_move_app = app.clone(); let resize_end_app = app.clone(); let active_terminal = controls.active().map(|session| session.terminal.clone()); - let sessions = controls.sessions.iter().fold( + let tab_scroll = controls.tab_scroll.clone(); + let tab_strip = Styled::scrollbar_width( div() + .id("terminal-session-tabs") .flex() .flex_col() .gap_1() @@ -56,39 +58,43 @@ pub(crate) fn render_terminal_bottom_dock( .p_1() .border_l_1() .border_color(theme.border.muted) - .overflow_y_scroll(), - |sessions, session| { - let selected = session.id == controls.active_session; - sessions.child( - Button::new(session.tab.clone()) - .size(ButtonSize::Small) - .variant(ButtonVariant::Transparent) - .style( - ButtonStyle::new() - .width(px(28.0)) - .height(px(28.0)) - .horizontal_padding(px(0.0)) - .background(if selected { - theme.background.selection - } else { - builtins::TRANSPARENT - }), - ) - .theme(theme) - .child( - Icon::new(terminal_profile_icon(session.profile)) - .size(px(14.0)) - .color(if selected { - theme.text.primary - } else { - theme.text.secondary - }) - .hover_color(theme.text.primary) - .theme(theme), - ), - ) - }, + .overflow_y_scroll() + .track_scroll(&tab_scroll), + // The lane is only wide enough for one icon column, so the native bar would + // cover the tabs themselves. + px(0.0), ); + let sessions = controls.sessions.iter().fold(tab_strip, |sessions, session| { + let selected = session.id == controls.active_session; + sessions.child( + Button::new(session.tab.clone()) + .size(ButtonSize::Small) + .variant(ButtonVariant::Transparent) + .style( + ButtonStyle::new() + .width(px(28.0)) + .height(px(28.0)) + .horizontal_padding(px(0.0)) + .background(if selected { + theme.background.selection + } else { + builtins::TRANSPARENT + }), + ) + .theme(theme) + .child( + Icon::new(terminal_profile_icon(session.profile)) + .size(px(14.0)) + .color(if selected { + theme.text.primary + } else { + theme.text.secondary + }) + .hover_color(theme.text.primary) + .theme(theme), + ), + ) + }); let header_actions = div() .flex() .items_center() From 734f201fa71ae2f4f0060c3687b51972db7785a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 29 Sep 2026 18:10:40 +0800 Subject: [PATCH 30/32] refactor(terminal): isolate shell sessions and use event-driven wakeups Give each built-in terminal session its own shell host, command context, history, and output stream so multiple sessions remain independent. Replace periodic VT event polling with coalesced asynchronous wakeups, and cancel all active built-in sessions when the terminal dock closes. Add regression coverage for session isolation, unique session IDs, and event wakeup coalescing. --- Cargo.lock | 1 + crates/chitin-desktop/src/app.rs | 2 +- crates/chitin-desktop/src/builtin_shell.rs | 49 +++++-- .../src/components/bottom_dock.rs | 10 +- .../src/components/terminal/controller.rs | 114 +++++++++------- .../src/components/terminal/mod.rs | 127 +++++++++++++----- crates/chitin-terminal/Cargo.toml | 1 + crates/chitin-terminal/src/session.rs | 115 +++++++++++++--- .../src/primitive/terminal/emulator.rs | 11 +- 9 files changed, 311 insertions(+), 119 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5328bfb7..c0e991e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -843,6 +843,7 @@ name = "chitin-terminal" version = "0.1.0" dependencies = [ "alacritty_terminal", + "async-channel 2.5.0", "portable-pty", "thiserror 2.0.20", ] diff --git a/crates/chitin-desktop/src/app.rs b/crates/chitin-desktop/src/app.rs index d6adaa75..99c1149d 100644 --- a/crates/chitin-desktop/src/app.rs +++ b/crates/chitin-desktop/src/app.rs @@ -64,7 +64,7 @@ pub struct ChitinApp { pub(crate) tasks: BackgroundTaskCenter, /// Shared adapter for frontend-independent commands submitted by desktop views. pub(crate) portable_commands: DesktopPortableCommandRunner, - /// Shared command bridge for terminal, agent, and system invocations. + /// Application-level command bridge for agent and system invocations. pub(crate) builtin_shell: DesktopShellHost, /// Active tool and geometry of the workbench-level bottom dock. pub(crate) bottom_dock: BottomDockState, diff --git a/crates/chitin-desktop/src/builtin_shell.rs b/crates/chitin-desktop/src/builtin_shell.rs index 79281a0b..c4b7de09 100644 --- a/crates/chitin-desktop/src/builtin_shell.rs +++ b/crates/chitin-desktop/src/builtin_shell.rs @@ -32,7 +32,7 @@ impl DesktopShellHost { } } - /// Returns the session shared with terminal and agent callers. + /// Returns the session owned by this desktop shell host. pub fn session(&self) -> &BuiltinShell { &self.session } @@ -212,7 +212,7 @@ pub(crate) fn desktop_shell_context(workspace_root: Option) -> CommandE } impl ChitinApp { - /// Returns the shared built-in shell session used by desktop and agent callers. + /// Returns the application-level built-in shell used by agent and system callers. pub fn builtin_shell(&self) -> &BuiltinShell { self.builtin_shell.session() } @@ -236,8 +236,21 @@ impl ChitinApp { window: &mut Window, cx: &mut Context, ) -> Result { - match self.builtin_shell.submit_line(input, source)? { - ShellLineSubmission::Command(submission) => self.route_builtin_shell_submission(submission, window, cx), + let host = self.builtin_shell.clone(); + self.submit_shell_line_with_host(&host, input, source, window, cx) + } + + /// Routes text through a specific shell session, such as one terminal tab. + pub(crate) fn submit_shell_line_with_host( + &mut self, + host: &DesktopShellHost, + input: impl Into, + source: ShellInvocationSource, + window: &mut Window, + cx: &mut Context, + ) -> Result { + match host.submit_line(input, source)? { + ShellLineSubmission::Command(submission) => self.route_builtin_shell_submission(host, submission, window, cx), ShellLineSubmission::ShellBuiltin(effect) => Ok(DesktopShellDispatch::ShellBuiltin { effect }), ShellLineSubmission::Display(output) => Ok(DesktopShellDispatch::Display { output }), } @@ -264,13 +277,15 @@ impl ChitinApp { window: &mut Window, cx: &mut Context, ) -> Result { - let submission = self.builtin_shell.submit_typed(input, command, source)?; - self.route_builtin_shell_submission(submission, window, cx) + let host = self.builtin_shell.clone(); + let submission = host.submit_typed(input, command, source)?; + self.route_builtin_shell_submission(&host, submission, window, cx) } /// Routes one prepared submission without reparsing its typed command. fn route_builtin_shell_submission( &mut self, + host: &DesktopShellHost, submission: ShellSubmission, window: &mut Window, cx: &mut Context, @@ -285,13 +300,11 @@ impl ChitinApp { }); }; self.dispatch_command_with_window(command.clone(), window, cx); - self.builtin_shell.complete_frontend(command_id)?; + host.complete_frontend(command_id)?; Ok(DesktopShellDispatch::Frontend { command_id }) } ShellCommandTarget::Portable => { - let running = self - .builtin_shell - .submit_portable(submission, &self.tasks, &self.portable_commands)?; + let running = host.submit_portable(submission, &self.tasks, &self.portable_commands)?; observe_shell_task(running.task().clone(), window, cx); Ok(DesktopShellDispatch::Portable(running)) } @@ -368,4 +381,20 @@ mod tests { assert!(host.session().snapshot()?.active.is_none()); Ok(()) } + + #[test] + fn separate_terminal_hosts_should_keep_commands_and_history_independent() -> Result<(), DesktopShellHostError> { + let first = DesktopShellHost::new(CommandExecutionContext::new(".")); + let second = DesktopShellHost::new(CommandExecutionContext::new(".")); + + let submission = first + .submit_line("tab.close", ShellInvocationSource::Interactive)? + .into_command()?; + first.complete_frontend(submission.id())?; + + assert_eq!(first.session().snapshot()?.executions.len(), 1); + assert!(second.session().snapshot()?.executions.is_empty()); + assert_eq!(second.session().previous_history("")?, None); + Ok(()) + } } diff --git a/crates/chitin-desktop/src/components/bottom_dock.rs b/crates/chitin-desktop/src/components/bottom_dock.rs index 1a87af9c..5a647b10 100644 --- a/crates/chitin-desktop/src/components/bottom_dock.rs +++ b/crates/chitin-desktop/src/components/bottom_dock.rs @@ -44,8 +44,14 @@ impl ChitinApp { /// Closes whichever tool is active in the bottom dock. pub(crate) fn close_bottom_dock(&mut self, cx: &mut Context) { if self.bottom_dock.is_active(super::terminal::TERMINAL_DOCK_ITEM_ID) { - if let Err(error) = self.builtin_shell().cancel_active() { - log::warn!("failed to cancel active built-in terminal command: {error}"); + if let Some(controls) = self.terminal_panel_controls.as_ref() { + for session in &controls.sessions { + if let Some(builtin) = session.builtin.as_ref() + && let Err(error) = builtin.host.session().cancel_active() + { + log::warn!("failed to cancel active built-in terminal command: {error}"); + } + } } self.terminal_panel_controls = None; } diff --git a/crates/chitin-desktop/src/components/terminal/controller.rs b/crates/chitin-desktop/src/components/terminal/controller.rs index 822f5494..b2c7871c 100644 --- a/crates/chitin-desktop/src/components/terminal/controller.rs +++ b/crates/chitin-desktop/src/components/terminal/controller.rs @@ -22,7 +22,8 @@ impl ChitinApp { .terminal_panel_controls .as_ref() .and_then(|controls| controls.session(session_id)) - .and_then(|session| session.program.clone()) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| builtin.program.clone()) else { return; }; @@ -53,15 +54,18 @@ impl ChitinApp { window: &mut Window, cx: &mut Context, ) { + let Some(host) = self.terminal_shell_host(session_id) else { + return; + }; match event { BuiltinTerminalEvent::Submit(line) => self.submit_terminal_line(session_id, line, window, cx), BuiltinTerminalEvent::Interrupt => { - if self - .builtin_shell() + if host + .session() .snapshot() .is_ok_and(|snapshot| snapshot.active.is_some()) { - let _ = self.builtin_shell().cancel_active(); + let _ = host.session().cancel_active(); } } BuiltinTerminalEvent::Complete(line) => self.complete_terminal_line(session_id, line), @@ -70,15 +74,16 @@ impl ChitinApp { .terminal_panel_controls .as_ref() .and_then(|controls| controls.session(session_id)) - .and_then(|session| session.program.as_ref()) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| &builtin.program) .and_then(|program| program.lock().ok().map(|program| program.line().to_owned())) .unwrap_or_default(); - if let Ok(Some(line)) = self.builtin_shell().previous_history(&draft) { + if let Ok(Some(line)) = host.session().previous_history(&draft) { self.replace_terminal_line(session_id, line); } } BuiltinTerminalEvent::NextHistory => { - if let Ok(Some(line)) = self.builtin_shell().next_history() { + if let Ok(Some(line)) = host.session().next_history() { self.replace_terminal_line(session_id, line); } } @@ -95,12 +100,15 @@ impl ChitinApp { window: &mut Window, cx: &mut Context, ) { + let Some(host) = self.terminal_shell_host(session_id) else { + return; + }; let line = line.trim().to_owned(); if line.is_empty() { self.finish_terminal_command(session_id, ""); return; } - match self.submit_builtin_shell_line(line, ShellInvocationSource::Interactive, window, cx) { + match self.submit_shell_line_with_host(&host, line, ShellInvocationSource::Interactive, window, cx) { Ok(DesktopShellDispatch::Display { output }) => self.finish_terminal_command(session_id, &output), Ok(DesktopShellDispatch::ShellBuiltin { effect: ShellBuiltinEffect::ClearScrollback, @@ -122,8 +130,8 @@ impl ChitinApp { let _ = app.update(cx, |this, cx| { let output = match result { Ok(result) => { - let mut lines = this - .builtin_shell() + let mut lines = host + .session() .snapshot() .map(|snapshot| shell_output_lines(&snapshot, command_id)) .unwrap_or_default(); @@ -131,7 +139,7 @@ impl ChitinApp { lines.extend(result_lines); terminal_lines_ansi(&lines) } - Err(error) => match this.builtin_shell().snapshot() { + Err(error) => match host.session().snapshot() { Ok(snapshot) => { let output = terminal_lines_ansi(&shell_output_lines(&snapshot, command_id)); if output.is_empty() { @@ -171,7 +179,8 @@ impl ChitinApp { .terminal_panel_controls .as_ref() .and_then(|controls| controls.session(session_id)) - .and_then(|session| session.program.as_ref()) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| &builtin.program) && let Ok(program) = program.lock() && let Err(error) = program.show_notice(&output) { @@ -188,7 +197,8 @@ impl ChitinApp { .terminal_panel_controls .as_ref() .and_then(|controls| controls.session(session_id)) - .and_then(|session| session.program.as_ref()) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| &builtin.program) && let Ok(mut program) = program.lock() && let Err(error) = program.replace_line(line) { @@ -202,7 +212,8 @@ impl ChitinApp { .terminal_panel_controls .as_ref() .and_then(|controls| controls.session(session_id)) - .and_then(|session| session.program.as_ref()) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| &builtin.program) && let Ok(mut program) = program.lock() && let Err(error) = program.finish_command(output) { @@ -223,7 +234,8 @@ impl ChitinApp { .terminal_panel_controls .as_ref() .and_then(|controls| controls.session(session_id)) - .and_then(|session| session.program.as_ref()) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| &builtin.program) && let Ok(mut program) = program.lock() && let Err(error) = program.clear_screen() { @@ -238,43 +250,47 @@ impl ChitinApp { } } - /// Projects the active command's replaceable transcript into the VT grid. + /// Projects every running session's replaceable transcript into its own VT grid. pub(crate) fn sync_terminal_output(&mut self) { - let Some((session_id, command_id, previous_output, program)) = - self.terminal_panel_controls.as_ref().and_then(|controls| { - controls.sessions.iter().find_map(|session| { - Some(( - session.id, - session.active_shell_command?, - session.rendered_running_output.clone(), - session.program.clone()?, - )) - }) - }) - else { - return; - }; - let Ok(snapshot) = self.builtin_shell().snapshot() else { - return; - }; - let output = terminal_lines_ansi(&shell_output_lines(&snapshot, command_id)); - if output == previous_output { - return; - } - let Ok(mut program) = program.lock() else { - log::error!("built-in terminal program lock is poisoned"); + let Some(controls) = self.terminal_panel_controls.as_ref() else { return; }; - if let Err(error) = program.update_command_output(&output) { - log::error!("failed to update built-in terminal output: {error}"); - return; - } - if let Some(session) = self - .terminal_panel_controls - .as_mut() - .and_then(|controls| controls.session_mut(session_id)) - { - session.rendered_running_output = output; + let running: Vec<_> = controls + .sessions + .iter() + .filter_map(|session| { + Some(( + session.id, + session.active_shell_command?, + session.rendered_running_output.clone(), + session.builtin.clone()?, + )) + }) + .collect(); + + for (session_id, command_id, previous_output, builtin) in running { + let Ok(snapshot) = builtin.host.session().snapshot() else { + continue; + }; + let output = terminal_lines_ansi(&shell_output_lines(&snapshot, command_id)); + if output == previous_output { + continue; + } + let Ok(mut program) = builtin.program.lock() else { + log::error!("built-in terminal program lock is poisoned"); + continue; + }; + if let Err(error) = program.update_command_output(&output) { + log::error!("failed to update built-in terminal output: {error}"); + continue; + } + if let Some(session) = self + .terminal_panel_controls + .as_mut() + .and_then(|controls| controls.session_mut(session_id)) + { + session.rendered_running_output = output; + } } } } diff --git a/crates/chitin-desktop/src/components/terminal/mod.rs b/crates/chitin-desktop/src/components/terminal/mod.rs index c2bfd85f..d7828002 100644 --- a/crates/chitin-desktop/src/components/terminal/mod.rs +++ b/crates/chitin-desktop/src/components/terminal/mod.rs @@ -11,6 +11,7 @@ use std::{ }; use chitin_builtin_shell::{BuiltinTerminalProgram, ShellCommandId}; +use chitin_command::CommandExecutionContext; use chitin_terminal::{TerminalProfile, TerminalSession, TerminalSessionError, TerminalSize}; use chitin_ui::{ composite::toast::{Toast, ToastVariant}, @@ -22,7 +23,10 @@ use chitin_ui::{ }; use gpui::{AppContext, Context, Entity, ScrollHandle, Subscription, Window}; -use crate::app::ChitinApp; +use crate::{ + app::ChitinApp, + builtin_shell::{DesktopShellHost, desktop_shell_context}, +}; pub(crate) use render::render_terminal_bottom_dock; @@ -46,12 +50,16 @@ pub(super) struct TerminalSessionId(u64); /// Terminal focus state that belongs to the bottom dock rather than a session. pub(crate) struct TerminalPanelState { focus_requested: bool, + next_session_id: u64, } impl TerminalPanelState { /// Creates a closed terminal panel. pub(crate) fn new() -> Self { - Self { focus_requested: false } + Self { + focus_requested: false, + next_session_id: 1, + } } /// Updates whether the active session should receive focus on the next render. @@ -63,6 +71,13 @@ impl TerminalPanelState { pub(crate) fn take_focus_request(&mut self) -> bool { std::mem::take(&mut self.focus_requested) } + + /// Allocates an identity that is not reused when the dock is closed and reopened. + fn next_session_id(&mut self) -> TerminalSessionId { + let id = TerminalSessionId(self.next_session_id); + self.next_session_id += 1; + id + } } impl Default for TerminalPanelState { @@ -77,12 +92,19 @@ pub(super) struct ManagedTerminalSession { pub(super) id: TerminalSessionId, pub(super) profile: TerminalProfile, pub(super) terminal: Entity, - pub(super) program: Option>>, + pub(super) builtin: Option, pub(super) tab: Entity, pub(super) active_shell_command: Option, pub(super) rendered_running_output: String, } +/// Command state and byte-stream program owned by one built-in terminal tab. +#[derive(Clone)] +pub(super) struct ManagedBuiltinShell { + pub(super) host: DesktopShellHost, + pub(super) program: Arc>, +} + impl ManagedTerminalSession { /// Tracks the portable command currently writing into this session. fn start_command(&mut self, command_id: ShellCommandId) { @@ -102,7 +124,6 @@ impl ManagedTerminalSession { pub(crate) struct TerminalPanelControls { pub(super) sessions: Vec, pub(super) active_session: TerminalSessionId, - next_session_id: u64, pub(super) close_session: Entity, pub(super) profile_select: Entity, /// Scroll position of the session tab strip. @@ -114,7 +135,12 @@ pub(crate) struct TerminalPanelControls { impl TerminalPanelControls { /// Creates the session manager with an initial built-in shell. - fn new(working_directory: &Path, cx: &mut Context) -> Result { + fn new( + initial_id: TerminalSessionId, + working_directory: &Path, + shell_context: CommandExecutionContext, + cx: &mut Context, + ) -> Result { let close_session = cx.new(ButtonState::new); let profile_select = cx.new(|cx| { SelectInputState::new( @@ -125,15 +151,15 @@ impl TerminalPanelControls { ) }); let session = Self::build_session( - TerminalSessionId(1), + initial_id, TerminalProfile::BuiltinShell, working_directory, + shell_context, cx, )?; Ok(Self { sessions: vec![session], - active_session: TerminalSessionId(1), - next_session_id: 2, + active_session: initial_id, close_session, profile_select, tab_scroll: ScrollHandle::new(), @@ -145,12 +171,21 @@ impl TerminalPanelControls { id: TerminalSessionId, profile: TerminalProfile, working_directory: &Path, + mut shell_context: CommandExecutionContext, cx: &mut Context, ) -> Result { - let (session, program) = if profile == TerminalProfile::BuiltinShell { + let (session, builtin) = if profile == TerminalProfile::BuiltinShell { + // The prompt and relative-path execution must start in the same directory. + shell_context.working_directory = working_directory.to_path_buf(); let prompt = presenter::terminal_prompt_ansi(working_directory); let (session, program) = BuiltinTerminalProgram::connect(INITIAL_TERMINAL_SIZE, prompt)?; - (session, Some(Arc::new(Mutex::new(program)))) + ( + session, + Some(ManagedBuiltinShell { + host: DesktopShellHost::new(shell_context), + program: Arc::new(Mutex::new(program)), + }), + ) } else { ( TerminalSession::spawn_system_shell(profile, working_directory, INITIAL_TERMINAL_SIZE)?, @@ -161,7 +196,7 @@ impl TerminalPanelControls { id, profile, terminal: cx.new(|cx| TerminalEmulatorState::new(session, INITIAL_TERMINAL_SIZE, cx)), - program, + builtin, tab: cx.new(ButtonState::new), active_shell_command: None, rendered_running_output: String::new(), @@ -244,6 +279,17 @@ impl TerminalPanelControls { } impl ChitinApp { + /// Returns the command host owned by one built-in terminal session. + fn terminal_shell_host(&self, id: TerminalSessionId) -> Option { + self + .terminal_panel_controls + .as_ref()? + .session(id)? + .builtin + .as_ref() + .map(|builtin| builtin.host.clone()) + } + /// Returns terminal controls, creating and subscribing them on first use. pub(crate) fn terminal_panel_controls( &mut self, @@ -255,7 +301,9 @@ impl ChitinApp { } let working_directory = self.terminal_working_directory(); - let controls = match TerminalPanelControls::new(&working_directory, cx) { + let shell_context = desktop_shell_context(self.workspace.as_ref().map(|workspace| workspace.root.clone())); + let initial_id = self.terminal_panel.next_session_id(); + let controls = match TerminalPanelControls::new(initial_id, &working_directory, shell_context, cx) { Ok(controls) => controls, Err(error) => { log::error!("failed to create built-in terminal: {error}"); @@ -274,28 +322,15 @@ impl ChitinApp { cx.notify(); } - /// Creates or activates a terminal session for the selected profile. + /// Creates a new terminal session for the selected profile. fn create_terminal_session(&mut self, profile: TerminalProfile, window: &mut Window, cx: &mut Context) { - if profile == TerminalProfile::BuiltinShell - && let Some(existing) = self - .terminal_panel_controls - .as_ref() - .and_then(|controls| controls.sessions.iter().find(|session| session.profile == profile)) - .map(|session| session.id) - { - self.activate_terminal_session(existing, window, cx); - return; - } - let working_directory = self.terminal_working_directory(); - let Some(id) = self - .terminal_panel_controls - .as_ref() - .map(|controls| TerminalSessionId(controls.next_session_id)) - else { + let shell_context = desktop_shell_context(self.workspace.as_ref().map(|workspace| workspace.root.clone())); + if self.terminal_panel_controls.is_none() { return; - }; - let session = match TerminalPanelControls::build_session(id, profile, &working_directory, cx) { + } + let id = self.terminal_panel.next_session_id(); + let session = match TerminalPanelControls::build_session(id, profile, &working_directory, shell_context, cx) { Ok(session) => session, Err(error) => { log::error!("failed to create {} terminal session: {error}", profile.label()); @@ -314,7 +349,6 @@ impl ChitinApp { if let Some(controls) = self.terminal_panel_controls.as_mut() { controls.sessions.push(session); controls.active_session = id; - controls.next_session_id += 1; // The new tab is the last child, so revealing it keeps the active session in // view without the session manager having to know the strip's geometry. controls.tab_scroll.scroll_to_bottom(); @@ -364,12 +398,14 @@ impl ChitinApp { /// Closes one session and moves the selection when the active one is gone. fn close_terminal_session(&mut self, id: TerminalSessionId, cx: &mut Context) { - let running = self + let builtin = self .terminal_panel_controls .as_ref() .and_then(|controls| controls.session(id)) - .is_some_and(|session| session.active_shell_command.is_some()); - if running && let Err(error) = self.builtin_shell().cancel_active() { + .and_then(|session| session.builtin.clone()); + if let Some(builtin) = builtin + && let Err(error) = builtin.host.session().cancel_active() + { log::warn!("failed to cancel active built-in terminal command: {error}"); } let Some(controls) = self.terminal_panel_controls.as_mut() else { @@ -399,10 +435,29 @@ impl ChitinApp { /// Resolves the directory inherited by newly created terminal sessions. fn terminal_working_directory(&self) -> PathBuf { self - .builtin_shell() + .terminal_panel_controls + .as_ref() + .and_then(TerminalPanelControls::active) + .and_then(|session| session.builtin.as_ref()) + .map(|builtin| builtin.host.session()) + .unwrap_or_else(|| self.builtin_shell()) .snapshot() .map(|snapshot| snapshot.working_directory) .or_else(|_| std::env::current_dir()) .unwrap_or_else(|_| PathBuf::from(".")) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_session_ids_should_not_repeat_after_a_dock_reopen() { + let mut state = TerminalPanelState::new(); + let first = state.next_session_id(); + let second = state.next_session_id(); + + assert_ne!(first, second); + } +} diff --git a/crates/chitin-terminal/Cargo.toml b/crates/chitin-terminal/Cargo.toml index c8a09e1a..7758e641 100644 --- a/crates/chitin-terminal/Cargo.toml +++ b/crates/chitin-terminal/Cargo.toml @@ -8,6 +8,7 @@ description = "Frontend-independent PTY sessions and VT terminal state for Chiti [dependencies] alacritty_terminal = { git = "https://github.com/l0ng-ai/alacritty", rev = "1276f128fbaa8832cbc66210675cbe8aeb570499" } +async-channel = "2.5" portable-pty = "0.8" thiserror = "2.0" diff --git a/crates/chitin-terminal/src/session.rs b/crates/chitin-terminal/src/session.rs index a21c6530..259e7ed7 100644 --- a/crates/chitin-terminal/src/session.rs +++ b/crates/chitin-terminal/src/session.rs @@ -26,6 +26,23 @@ type SharedWriter = Arc>>; type SharedTerminal = Arc>>; type SharedProcessor = Arc>>; +/// Sends terminal events and coalesces UI wakeups independently of event payloads. +#[derive(Clone)] +struct TerminalEventSender { + events: mpsc::Sender, + wake: async_channel::Sender<()>, +} + +impl TerminalEventSender { + /// Queues an event before waking its observer, so the observer can drain it immediately. + fn send(&self, event: TerminalEvent) { + if self.events.send(event).is_ok() { + // One pending wake is enough: the observer drains every queued event together. + let _ = self.wake.try_send(()); + } + } +} + /// Input sent from a terminal emulator to an in-process terminal program. #[derive(Clone, Debug, PartialEq, Eq)] pub enum TerminalProgramInput { @@ -109,7 +126,7 @@ pub enum TerminalSessionError { pub struct TerminalProgramOutput { terminal: SharedTerminal, processor: SharedProcessor, - events: mpsc::Sender, + events: TerminalEventSender, render_pending: Arc, } @@ -174,6 +191,7 @@ pub struct TerminalSession { writer: SharedWriter, backend: TerminalBackend, events: Mutex>, + wake: async_channel::Receiver<()>, render_pending: Arc, } @@ -274,7 +292,12 @@ impl TerminalSession { .take_writer() .map_err(|error| pty_error("take terminal writer", error))?, )); - let (event_sender, event_receiver) = mpsc::channel(); + let (events, event_receiver) = mpsc::channel(); + let (wake_sender, wake) = async_channel::bounded(1); + let event_sender = TerminalEventSender { + events, + wake: wake_sender, + }; let render_pending = Arc::new(AtomicBool::new(false)); let proxy = TerminalEventProxy { sender: event_sender.clone(), @@ -295,6 +318,7 @@ impl TerminalSession { killer: Mutex::new(killer), }, events: Mutex::new(event_receiver), + wake, render_pending, }) } @@ -315,7 +339,12 @@ impl TerminalSession { let writer: SharedWriter = Arc::new(Mutex::new(Box::new(ProgramInputWriter { sender: input_sender.clone(), }))); - let (event_sender, event_receiver) = mpsc::channel(); + let (events, event_receiver) = mpsc::channel(); + let (wake_sender, wake) = async_channel::bounded(1); + let event_sender = TerminalEventSender { + events, + wake: wake_sender, + }; let render_pending = Arc::new(AtomicBool::new(false)); let proxy = TerminalEventProxy { sender: event_sender.clone(), @@ -338,6 +367,7 @@ impl TerminalSession { writer, backend: TerminalBackend::InProcess { input: input_sender }, events: Mutex::new(event_receiver), + wake, render_pending, }; (session, program) @@ -420,6 +450,14 @@ impl TerminalSession { } } + /// Returns a wake receiver for the session's sole event observer. + /// + /// A wake carries no payload; after receiving one, the observer should call + /// [`Self::drain_events`]. Wakeups are coalesced while one is pending. + pub fn event_wakeups(&self) -> async_channel::Receiver<()> { + self.wake.clone() + } + /// Drains terminal events accumulated since the previous call. pub fn drain_events(&self) -> Vec { // Clear before draining so a concurrent parser update can enqueue the next @@ -472,7 +510,7 @@ impl Write for ProgramInputWriter { #[derive(Clone)] struct TerminalEventProxy { - sender: mpsc::Sender, + sender: TerminalEventSender, writer: SharedWriter, render_pending: Arc, } @@ -484,19 +522,19 @@ impl EventListener for TerminalEventProxy { send_render_event(&self.sender, &self.render_pending); } AlacrittyEvent::Title(title) => { - let _ = self.sender.send(TerminalEvent::Title(title)); + self.sender.send(TerminalEvent::Title(title)); } AlacrittyEvent::Bell => { - let _ = self.sender.send(TerminalEvent::Bell); + self.sender.send(TerminalEvent::Bell); } AlacrittyEvent::PtyWrite(text) => match self.writer.lock() { Ok(mut writer) => { if let Err(error) = writer.write_all(text.as_bytes()).and_then(|()| writer.flush()) { - let _ = self.sender.send(TerminalEvent::Error(error.to_string())); + self.sender.send(TerminalEvent::Error(error.to_string())); } } Err(_) => { - let _ = self + self .sender .send(TerminalEvent::Error("terminal writer lock is poisoned".into())); } @@ -509,7 +547,7 @@ impl EventListener for TerminalEventProxy { /// Starts the blocking PTY reader without holding the terminal lock while waiting for bytes. fn spawn_reader_thread( terminal: &SharedTerminal, - sender: &mpsc::Sender, + sender: &TerminalEventSender, render_pending: &Arc, mut reader: Box, ) -> Result<(), TerminalSessionError> { @@ -530,7 +568,7 @@ fn spawn_reader_thread( } Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} Err(error) => { - let _ = sender.send(TerminalEvent::Error(error.to_string())); + sender.send(TerminalEvent::Error(error.to_string())); break; } } @@ -541,15 +579,15 @@ fn spawn_reader_thread( } /// Coalesces repeated parser wakeups into at most one queued repaint request. -fn send_render_event(sender: &mpsc::Sender, pending: &AtomicBool) { +fn send_render_event(sender: &TerminalEventSender, pending: &AtomicBool) { if !pending.swap(true, Ordering::AcqRel) { - let _ = sender.send(TerminalEvent::Render); + sender.send(TerminalEvent::Render); } } /// Starts a waiter that reports the child exit code without blocking a UI executor. fn spawn_wait_thread( - sender: &mpsc::Sender, + sender: &TerminalEventSender, mut child: Box, ) -> Result<(), TerminalSessionError> { let sender = sender.clone(); @@ -557,10 +595,10 @@ fn spawn_wait_thread( .name("chitin-terminal-wait".into()) .spawn(move || match child.wait() { Ok(status) => { - let _ = sender.send(TerminalEvent::Exited(status.exit_code())); + sender.send(TerminalEvent::Exited(status.exit_code())); } Err(error) => { - let _ = sender.send(TerminalEvent::Error(error.to_string())); + sender.send(TerminalEvent::Error(error.to_string())); } }) .map(|_| ()) @@ -574,6 +612,53 @@ fn pty_error(operation: &'static str, error: impl std::fmt::Display) -> Terminal } } +#[cfg(test)] +mod event_tests { + use super::*; + + #[test] + fn event_sender_should_coalesce_wakeups_without_discarding_events() { + let (events, receiver) = mpsc::channel(); + let (wake, wakeups) = async_channel::bounded(1); + let sender = TerminalEventSender { events, wake }; + + sender.send(TerminalEvent::Title("first".into())); + sender.send(TerminalEvent::Bell); + + assert_eq!(wakeups.try_recv(), Ok(())); + assert_eq!(wakeups.try_recv(), Err(async_channel::TryRecvError::Empty)); + assert_eq!( + receiver.try_iter().collect::>(), + vec![TerminalEvent::Title("first".into()), TerminalEvent::Bell] + ); + } + + #[test] + fn event_sender_should_wake_again_after_prior_wakeup_is_consumed() { + let (events, _receiver) = mpsc::channel(); + let (wake, wakeups) = async_channel::bounded(1); + let sender = TerminalEventSender { events, wake }; + + sender.send(TerminalEvent::Bell); + assert_eq!(wakeups.try_recv(), Ok(())); + sender.send(TerminalEvent::Bell); + assert_eq!(wakeups.try_recv(), Ok(())); + } + + #[test] + fn in_process_session_should_sleep_until_program_output_arrives() { + let (session, program) = + TerminalSession::in_process(TerminalProfile::BuiltinShell, TerminalSize::new(40, 4, 8, 16)); + let wakeups = session.event_wakeups(); + assert_eq!(wakeups.try_recv(), Err(async_channel::TryRecvError::Empty)); + + assert!(program.output().write(b"output").is_ok()); + assert_eq!(wakeups.try_recv(), Ok(())); + assert!(session.drain_events().contains(&TerminalEvent::Render)); + assert_eq!(wakeups.try_recv(), Err(async_channel::TryRecvError::Empty)); + } +} + #[cfg(all(test, unix))] mod tests { use std::{path::PathBuf, thread, time::Duration}; diff --git a/crates/chitin-ui/src/primitive/terminal/emulator.rs b/crates/chitin-ui/src/primitive/terminal/emulator.rs index 4c81dff2..60e4d1a2 100644 --- a/crates/chitin-ui/src/primitive/terminal/emulator.rs +++ b/crates/chitin-ui/src/primitive/terminal/emulator.rs @@ -1,6 +1,6 @@ //! GPUI rendering and input handling for a VT terminal session. -use std::{ops::Range, sync::Arc, time::Duration}; +use std::{ops::Range, sync::Arc}; use chitin_terminal::{ TerminalCell, TerminalCellAttributes, TerminalColor, TerminalEvent, TerminalNamedColor, TerminalScroll, @@ -10,7 +10,7 @@ use gpui::{ AnyElement, App, AsyncApp, Bounds, Context, Div, Element, ElementId, ElementInputHandler, Entity, EntityInputHandler, EventEmitter, FocusHandle, Font, FontFallbacks, FontFeatures, FontWeight, GlobalElementId, Hsla, InspectorElementId, InteractiveElement, IntoElement, KeyDownEvent, Keystroke, LayoutId, MouseButton, ParentElement, Pixels, Point, - RenderOnce, ScrollDelta, ScrollWheelEvent, SharedString, Styled, Subscription, Task, TextRun, Timer, UTF16Selection, + RenderOnce, ScrollDelta, ScrollWheelEvent, SharedString, Styled, Subscription, Task, TextRun, UTF16Selection, WeakEntity, Window, div, font, point, prelude::*, px, rgb, size, }; @@ -21,7 +21,6 @@ use crate::{ const FONT_SIZE: f32 = 15.0; const LINE_HEIGHT_MULTIPLIER: f32 = 1.4; -const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(16); /// Largest viewport motion one wheel event may ask for. /// /// A platform is free to report a very large pixel delta, and one such event @@ -51,7 +50,7 @@ pub struct TerminalEmulatorState { /// Exit code once the backend has stopped, which is also what ends its input. ended: Option, preedit: Option, - /// Where the viewport sits in the scrollback, refreshed once per poll. + /// Where the viewport sits in the scrollback, refreshed when events arrive. scroll_state: TerminalScrollState, /// Sub-line part of a pixel scroll, carried so a slow trackpad still moves. scroll_remainder: f32, @@ -79,11 +78,11 @@ struct TerminalPreedit { impl TerminalEmulatorState { /// Creates state around an existing terminal session. pub fn new(session: TerminalSession, size: TerminalSize, cx: &mut Context) -> Self { + let wakeups = session.event_wakeups(); let event_task = cx.spawn(|this: WeakEntity, async_cx: &mut AsyncApp| { let mut async_cx = async_cx.clone(); async move { - loop { - Timer::after(EVENT_POLL_INTERVAL).await; + while wakeups.recv().await.is_ok() { let should_continue = this .update(&mut async_cx, |state, cx| { state.process_events(cx); From 29babbcc05784a152acb1d86747f978bce91242d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 29 Sep 2026 21:47:40 +0800 Subject: [PATCH 31/32] refactor(terminal): introduce extensible shell session management Separate shell hosts and command contexts for each terminal session, allowing independent history, execution state, and output updates. Replace periodic terminal event polling with coalesced asynchronous wakeups, and ensure active built-in shell commands are cancelled when the terminal dock closes. Add regression tests for session isolation, unique session IDs, and event wakeup behavior. --- assets/icons/terminal-nushell.svg | 4 + assets/icons/terminal-powershell.svg | 1 + assets/icons/terminal-zsh.svg | 4 + .../src/components/terminal/mod.rs | 123 +++++--- .../src/components/terminal/render.rs | 20 +- crates/chitin-terminal/src/lib.rs | 2 + crates/chitin-terminal/src/model.rs | 47 +-- crates/chitin-terminal/src/session.rs | 44 ++- crates/chitin-terminal/src/shell.rs | 269 ++++++++++++++++++ 9 files changed, 406 insertions(+), 108 deletions(-) create mode 100644 assets/icons/terminal-nushell.svg create mode 100644 assets/icons/terminal-powershell.svg create mode 100644 assets/icons/terminal-zsh.svg create mode 100644 crates/chitin-terminal/src/shell.rs diff --git a/assets/icons/terminal-nushell.svg b/assets/icons/terminal-nushell.svg new file mode 100644 index 00000000..d0e1c188 --- /dev/null +++ b/assets/icons/terminal-nushell.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/terminal-powershell.svg b/assets/icons/terminal-powershell.svg new file mode 100644 index 00000000..e95ae4f4 --- /dev/null +++ b/assets/icons/terminal-powershell.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/terminal-zsh.svg b/assets/icons/terminal-zsh.svg new file mode 100644 index 00000000..30817084 --- /dev/null +++ b/assets/icons/terminal-zsh.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/chitin-desktop/src/components/terminal/mod.rs b/crates/chitin-desktop/src/components/terminal/mod.rs index d7828002..d82b2893 100644 --- a/crates/chitin-desktop/src/components/terminal/mod.rs +++ b/crates/chitin-desktop/src/components/terminal/mod.rs @@ -12,7 +12,7 @@ use std::{ use chitin_builtin_shell::{BuiltinTerminalProgram, ShellCommandId}; use chitin_command::CommandExecutionContext; -use chitin_terminal::{TerminalProfile, TerminalSession, TerminalSessionError, TerminalSize}; +use chitin_terminal::{ShellCatalog, ShellDefinition, TerminalSession, TerminalSessionError, TerminalSize}; use chitin_ui::{ composite::toast::{Toast, ToastVariant}, primitive::{ @@ -34,12 +34,38 @@ pub(crate) const TERMINAL_DOCK_ITEM_ID: &str = "terminal"; const INITIAL_TERMINAL_SIZE: TerminalSize = TerminalSize::new(80, 24, 9, 21); /// Returns the desktop asset used to represent one terminal profile. -pub(super) const fn terminal_profile_icon(profile: TerminalProfile) -> &'static str { - match profile { - TerminalProfile::BuiltinShell => "icons/terminal-builtin.svg", - TerminalProfile::SystemShell => "icons/terminal-shell.svg", - TerminalProfile::Bash => "icons/terminal-bash.svg", - TerminalProfile::Fish => "icons/terminal-fish.svg", +pub(super) fn terminal_profile_icon(id: &str) -> &'static str { + match id { + "builtin-shell" => "icons/terminal-builtin.svg", + "bash" | "git-bash" => "icons/terminal-bash.svg", + "fish" => "icons/terminal-fish.svg", + "powershell" | "pwsh" => "icons/terminal-powershell.svg", + "zsh" => "icons/terminal-zsh.svg", + "nushell" => "icons/terminal-nushell.svg", + _ => "icons/terminal-shell.svg", + } +} + +/// One launchable desktop choice; only PTY shells come from platform discovery. +#[derive(Clone)] +enum DesktopTerminalProfile { + Builtin, + Shell(ShellDefinition), +} + +impl DesktopTerminalProfile { + fn id(&self) -> &str { + match self { + Self::Builtin => "builtin-shell", + Self::Shell(shell) => &shell.id, + } + } + + fn label(&self) -> &str { + match self { + Self::Builtin => "Built-in shell", + Self::Shell(shell) => &shell.label, + } } } @@ -90,7 +116,7 @@ impl Default for TerminalPanelState { #[derive(Clone)] pub(super) struct ManagedTerminalSession { pub(super) id: TerminalSessionId, - pub(super) profile: TerminalProfile, + profile: DesktopTerminalProfile, pub(super) terminal: Entity, pub(super) builtin: Option, pub(super) tab: Entity, @@ -122,6 +148,7 @@ impl ManagedTerminalSession { /// Persistent session manager and controls for the bottom terminal panel. #[derive(Clone)] pub(crate) struct TerminalPanelControls { + catalog: ShellCatalog, pub(super) sessions: Vec, pub(super) active_session: TerminalSessionId, pub(super) close_session: Entity, @@ -141,23 +168,31 @@ impl TerminalPanelControls { shell_context: CommandExecutionContext, cx: &mut Context, ) -> Result { + let catalog = ShellCatalog::discover(); let close_session = cx.new(ButtonState::new); let profile_select = cx.new(|cx| { SelectInputState::new( - TerminalProfile::ALL - .into_iter() - .map(|profile| SelectOption::new(profile.id(), profile.label()).icon(terminal_profile_icon(profile))), + std::iter::once( + SelectOption::new("builtin-shell", "Built-in shell").icon(terminal_profile_icon("builtin-shell")), + ) + .chain( + catalog + .available() + .iter() + .map(|shell| SelectOption::new(&shell.id, &shell.label).icon(terminal_profile_icon(&shell.id))), + ), cx, ) }); let session = Self::build_session( initial_id, - TerminalProfile::BuiltinShell, + &DesktopTerminalProfile::Builtin, working_directory, shell_context, cx, )?; Ok(Self { + catalog, sessions: vec![session], active_session: initial_id, close_session, @@ -169,32 +204,33 @@ impl TerminalPanelControls { /// Builds a terminal surface for one launch profile. fn build_session( id: TerminalSessionId, - profile: TerminalProfile, + profile: &DesktopTerminalProfile, working_directory: &Path, mut shell_context: CommandExecutionContext, cx: &mut Context, ) -> Result { - let (session, builtin) = if profile == TerminalProfile::BuiltinShell { - // The prompt and relative-path execution must start in the same directory. - shell_context.working_directory = working_directory.to_path_buf(); - let prompt = presenter::terminal_prompt_ansi(working_directory); - let (session, program) = BuiltinTerminalProgram::connect(INITIAL_TERMINAL_SIZE, prompt)?; - ( - session, - Some(ManagedBuiltinShell { - host: DesktopShellHost::new(shell_context), - program: Arc::new(Mutex::new(program)), - }), - ) - } else { - ( - TerminalSession::spawn_system_shell(profile, working_directory, INITIAL_TERMINAL_SIZE)?, + let (session, builtin) = match profile { + DesktopTerminalProfile::Builtin => { + // The prompt and relative-path execution must start in the same directory. + shell_context.working_directory = working_directory.to_path_buf(); + let prompt = presenter::terminal_prompt_ansi(working_directory); + let (session, program) = BuiltinTerminalProgram::connect(INITIAL_TERMINAL_SIZE, prompt)?; + ( + session, + Some(ManagedBuiltinShell { + host: DesktopShellHost::new(shell_context), + program: Arc::new(Mutex::new(program)), + }), + ) + } + DesktopTerminalProfile::Shell(shell) => ( + TerminalSession::spawn_shell(shell, working_directory, INITIAL_TERMINAL_SIZE)?, None, - ) + ), }; Ok(ManagedTerminalSession { id, - profile, + profile: profile.clone(), terminal: cx.new(|cx| TerminalEmulatorState::new(session, INITIAL_TERMINAL_SIZE, cx)), builtin, tab: cx.new(ButtonState::new), @@ -223,7 +259,17 @@ impl TerminalPanelControls { else { return; }; - let Some(profile) = TerminalProfile::from_id(selected_id.as_ref()) else { + let Some(profile) = this.terminal_panel_controls.as_ref().and_then(|controls| { + if selected_id.as_ref() == "builtin-shell" { + Some(DesktopTerminalProfile::Builtin) + } else { + controls + .catalog + .get(selected_id.as_ref()) + .cloned() + .map(DesktopTerminalProfile::Shell) + } + }) else { return; }; this.create_terminal_session(profile, window, cx); @@ -323,14 +369,14 @@ impl ChitinApp { } /// Creates a new terminal session for the selected profile. - fn create_terminal_session(&mut self, profile: TerminalProfile, window: &mut Window, cx: &mut Context) { + fn create_terminal_session(&mut self, profile: DesktopTerminalProfile, window: &mut Window, cx: &mut Context) { let working_directory = self.terminal_working_directory(); let shell_context = desktop_shell_context(self.workspace.as_ref().map(|workspace| workspace.root.clone())); if self.terminal_panel_controls.is_none() { return; } let id = self.terminal_panel.next_session_id(); - let session = match TerminalPanelControls::build_session(id, profile, &working_directory, shell_context, cx) { + let session = match TerminalPanelControls::build_session(id, &profile, &working_directory, shell_context, cx) { Ok(session) => session, Err(error) => { log::error!("failed to create {} terminal session: {error}", profile.label()); @@ -460,4 +506,15 @@ mod tests { assert_ne!(first, second); } + + #[test] + fn terminal_shell_icons_should_resolve_to_existing_assets() { + let assets = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + let all_exist = ["powershell", "pwsh", "zsh", "nushell"] + .into_iter() + .map(terminal_profile_icon) + .all(|icon| assets.join(icon).is_file()); + + assert!(all_exist); + } } diff --git a/crates/chitin-desktop/src/components/terminal/render.rs b/crates/chitin-desktop/src/components/terminal/render.rs index d44120b1..2db98797 100644 --- a/crates/chitin-desktop/src/components/terminal/render.rs +++ b/crates/chitin-desktop/src/components/terminal/render.rs @@ -20,7 +20,6 @@ use gpui::{ use super::{TerminalPanelControls, terminal_profile_icon}; use crate::{app::ChitinApp, fonts::TERMINAL_FONT_FAMILY, keybindings::COMMAND_TERMINAL_KEY_CONTEXT}; -use chitin_terminal::TerminalProfile; /// Renders the VT terminal emulator as the active bottom-dock item. /// @@ -83,7 +82,7 @@ pub(crate) fn render_terminal_bottom_dock( ) .theme(theme) .child( - Icon::new(terminal_profile_icon(session.profile)) + Icon::new(terminal_profile_icon(session.profile.id())) .size(px(14.0)) .color(if selected { theme.text.primary @@ -99,7 +98,11 @@ pub(crate) fn render_terminal_bottom_dock( .flex() .items_center() .gap_1() - .child(new_terminal_select(controls.profile_select.clone(), theme)) + .child(new_terminal_select( + controls.profile_select.clone(), + &controls.catalog, + theme, + )) .child( Button::new(controls.close_session.clone()) .size(ButtonSize::Small) @@ -171,13 +174,14 @@ pub(crate) fn render_terminal_bottom_dock( /// Builds the icon-only selector used to create a terminal profile session. fn new_terminal_select( profile_select: Entity, + catalog: &chitin_terminal::ShellCatalog, theme: UIThemes, ) -> impl gpui::IntoElement { - let group = TerminalProfile::ALL - .into_iter() - .fold(SelectGroup::new(), |group, profile| { - group.item(SelectItem::new(profile.id(), profile.label()).icon(terminal_profile_icon(profile))) - }); + let group = catalog.available().iter().fold( + SelectGroup::new() + .item(SelectItem::new("builtin-shell", "Built-in shell").icon(terminal_profile_icon("builtin-shell"))), + |group, shell| group.item(SelectItem::new(&shell.id, &shell.label).icon(terminal_profile_icon(&shell.id))), + ); Select::new(profile_select) .trigger( SelectTrigger::new() diff --git a/crates/chitin-terminal/src/lib.rs b/crates/chitin-terminal/src/lib.rs index e344fe28..1f0b39f9 100644 --- a/crates/chitin-terminal/src/lib.rs +++ b/crates/chitin-terminal/src/lib.rs @@ -3,6 +3,7 @@ mod model; mod session; +mod shell; mod size; mod snapshot; @@ -14,5 +15,6 @@ pub use session::{ TerminalEvent, TerminalProgram, TerminalProgramInput, TerminalProgramOutput, TerminalScroll, TerminalScrollState, TerminalSession, TerminalSessionError, }; +pub use shell::{ShellCatalog, ShellDefinition}; pub use size::TerminalSize; pub use snapshot::{TerminalCell, TerminalCellAttributes, TerminalColor, TerminalNamedColor, TerminalSnapshot}; diff --git a/crates/chitin-terminal/src/model.rs b/crates/chitin-terminal/src/model.rs index d39e66ed..39fdc76f 100644 --- a/crates/chitin-terminal/src/model.rs +++ b/crates/chitin-terminal/src/model.rs @@ -1,56 +1,18 @@ -//! Profile-aware, frontend-independent terminal transcript state. +//! Frontend-independent terminal transcript state. use std::collections::VecDeque; -/// Execution profile hosted by a terminal surface. +/// Backend category hosted by a terminal surface, independent of shell identity. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum TerminalProfile { /// Chitin's structured command language and application command executor. BuiltinShell, /// The operating system's default interactive shell attached to a native PTY. SystemShell, - /// An explicit interactive Bash process attached to a native PTY. - Bash, - /// An explicit interactive Fish process attached to a native PTY. - Fish, } impl TerminalProfile { - /// Profiles offered by the desktop terminal selector, in display order. - pub const ALL: [Self; 4] = [Self::BuiltinShell, Self::SystemShell, Self::Bash, Self::Fish]; - - /// Returns the stable selector identifier for this profile. - pub const fn id(self) -> &'static str { - match self { - Self::BuiltinShell => "builtin-shell", - Self::SystemShell => "system-shell", - Self::Bash => "bash", - Self::Fish => "fish", - } - } - - /// Returns the user-facing profile name. - pub const fn label(self) -> &'static str { - match self { - Self::BuiltinShell => "Built-in shell", - Self::SystemShell => "Default shell", - Self::Bash => "Bash", - Self::Fish => "Fish", - } - } - - /// Resolves a selector identifier to its terminal profile. - pub fn from_id(id: &str) -> Option { - match id { - "builtin-shell" => Some(Self::BuiltinShell), - "system-shell" => Some(Self::SystemShell), - "bash" => Some(Self::Bash), - "fish" => Some(Self::Fish), - _ => None, - } - } - - /// Reports whether this profile requires an operating-system pseudo-terminal. + /// Reports whether this backend requires an operating-system pseudo-terminal. pub const fn is_system_shell(self) -> bool { !matches!(self, Self::BuiltinShell) } @@ -365,9 +327,6 @@ mod tests { fn terminal_profiles_distinguish_in_process_and_native_shells() { assert!(!TerminalProfile::BuiltinShell.is_system_shell()); assert!(TerminalProfile::SystemShell.is_system_shell()); - assert!(TerminalProfile::Bash.is_system_shell()); - assert!(TerminalProfile::Fish.is_system_shell()); - assert_eq!(TerminalProfile::SystemShell.label(), "Default shell"); } #[test] diff --git a/crates/chitin-terminal/src/session.rs b/crates/chitin-terminal/src/session.rs index 259e7ed7..467b8d62 100644 --- a/crates/chitin-terminal/src/session.rs +++ b/crates/chitin-terminal/src/session.rs @@ -20,7 +20,7 @@ use alacritty_terminal::{ use portable_pty::{ChildKiller, CommandBuilder, MasterPty, native_pty_system}; use thiserror::Error; -use crate::{TerminalProfile, TerminalSize, TerminalSnapshot}; +use crate::{ShellDefinition, TerminalProfile, TerminalSize, TerminalSnapshot}; type SharedWriter = Arc>>; type SharedTerminal = Arc>>; @@ -196,59 +196,57 @@ pub struct TerminalSession { } impl TerminalSession { - /// Spawns the operating system's default interactive shell. + /// Spawns a discovered shell using its resolved executable and launch arguments. /// /// # Parameters /// + /// * `shell` is a catalog definition selected by the user. /// * `working_directory` is the initial directory for the child shell. /// * `size` is the initial character-grid and cell-pixel size. /// /// # Returns /// - /// A live PTY session and VT terminal state. - pub fn spawn_default_shell( + /// A live PTY session, or an error if the executable disappeared or spawning failed. + pub fn spawn_shell( + shell: &ShellDefinition, working_directory: impl AsRef, size: TerminalSize, ) -> Result { - let mut command = CommandBuilder::new_default_prog(); + if !crate::shell::is_executable(&shell.executable) { + return Err(TerminalSessionError::Pty { + operation: "spawn shell profile", + message: format!("{} is no longer available", shell.executable.display()), + }); + } + let mut command = CommandBuilder::new(&shell.executable); + for argument in &shell.arguments { + command.arg(argument); + } command.cwd(working_directory.as_ref()); command.env("TERM", "xterm-256color"); command.env("COLORTERM", "truecolor"); Self::spawn_with_profile(command, TerminalProfile::SystemShell, size) } - /// Spawns a selected operating-system shell profile. + /// Spawns the operating system's default interactive shell. /// /// # Parameters /// - /// * `profile` selects the default shell, Bash, or Fish. /// * `working_directory` is the initial directory for the child shell. /// * `size` is the initial character-grid and cell-pixel size. /// /// # Returns /// - /// A live PTY session tagged with the selected profile. The built-in profile - /// is rejected because it requires an in-process program endpoint. - pub fn spawn_system_shell( - profile: TerminalProfile, + /// A live PTY session and VT terminal state. + pub fn spawn_default_shell( working_directory: impl AsRef, size: TerminalSize, ) -> Result { - let mut command = match profile { - TerminalProfile::SystemShell => CommandBuilder::new_default_prog(), - TerminalProfile::Bash => CommandBuilder::new("bash"), - TerminalProfile::Fish => CommandBuilder::new("fish"), - TerminalProfile::BuiltinShell => { - return Err(TerminalSessionError::Pty { - operation: "spawn system shell profile", - message: "the built-in shell uses an in-process terminal program".into(), - }); - } - }; + let mut command = CommandBuilder::new_default_prog(); command.cwd(working_directory.as_ref()); command.env("TERM", "xterm-256color"); command.env("COLORTERM", "truecolor"); - Self::spawn_with_profile(command, profile, size) + Self::spawn_with_profile(command, TerminalProfile::SystemShell, size) } /// Spawns a command attached to a native pseudo-terminal. diff --git a/crates/chitin-terminal/src/shell.rs b/crates/chitin-terminal/src/shell.rs new file mode 100644 index 00000000..c6fdc805 --- /dev/null +++ b/crates/chitin-terminal/src/shell.rs @@ -0,0 +1,269 @@ +//! Platform-specific discovery of shells that can back native PTY sessions. + +use std::{ + env, + ffi::OsString, + fs, + path::{Path, PathBuf}, +}; + +/// A discovered shell that can be launched in a native pseudo-terminal. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShellDefinition { + /// Stable identity used by terminal profile selectors. + pub id: String, + /// Name shown to the user. + pub label: String, + /// Resolved executable, independent of later `PATH` changes. + pub executable: PathBuf, + /// Arguments passed when the shell starts. + pub arguments: Vec, +} + +impl ShellDefinition { + fn new(id: &str, label: &str, executable: PathBuf, arguments: &[&str]) -> Self { + Self { + id: id.into(), + label: label.into(), + executable, + arguments: arguments.iter().map(OsString::from).collect(), + } + } +} + +/// Shells available to the current desktop process. +#[derive(Clone, Debug, Default)] +pub struct ShellCatalog { + definitions: Vec, +} + +impl ShellCatalog { + /// Discovers launchable system shells using the current platform's conventions. + /// + /// # Returns + /// + /// A snapshot of available shells; applications should rediscover after changes to + /// installed shells or the process environment. The Chitin built-in shell is not + /// included because it does not require an executable or PTY. + pub fn discover() -> Self { + let mut catalog = Self::default(); + platform::discover(&mut catalog); + catalog + } + + /// Returns the discovered launchable definitions in display order. + pub fn available(&self) -> &[ShellDefinition] { + &self.definitions + } + + /// Looks up a discovered shell by its stable selector identity. + pub fn get(&self, id: &str) -> Option<&ShellDefinition> { + self.definitions.iter().find(|definition| definition.id == id) + } + + fn add(&mut self, id: &str, label: &str, executable: PathBuf, arguments: &[&str]) { + if self.get(id).is_none() && is_executable(&executable) { + self + .definitions + .push(ShellDefinition::new(id, label, executable, arguments)); + } + } + + #[cfg(windows)] + fn add_named(&mut self, id: &str, label: &str, name: &str, arguments: &[&str]) { + if let Some(executable) = find_on_path(name) { + self.add(id, label, executable, arguments); + } + } +} + +fn find_on_path(name: &str) -> Option { + let paths = env::var_os("PATH")?; + for directory in env::split_paths(&paths) { + for candidate in executable_names(name) { + let path = directory.join(candidate); + if is_executable(&path) { + return Some(path); + } + } + } + None +} + +#[cfg(windows)] +fn executable_names(name: &str) -> Vec { + let path = Path::new(name); + if path.extension().is_some() { + return vec![OsString::from(name)]; + } + let extensions = env::var_os("PATHEXT").unwrap_or_else(|| OsString::from(".COM;.EXE;.BAT;.CMD")); + extensions + .to_string_lossy() + .split(';') + .filter(|extension| !extension.is_empty()) + .map(|extension| OsString::from(format!("{name}{extension}"))) + .collect() +} + +#[cfg(not(windows))] +fn executable_names(name: &str) -> Vec { + vec![OsString::from(name)] +} + +pub(crate) fn is_executable(path: &Path) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +#[cfg(unix)] +mod platform { + use super::*; + + pub(super) fn discover(catalog: &mut ShellCatalog) { + if let Some(default) = env::var_os("SHELL").map(PathBuf::from) + && is_executable(&default) + { + catalog.add("system-shell", "Default shell", default, &[]); + } + + // /etc/shells records login shells that might live outside the GUI process's PATH. + let registered = fs::read_to_string("/etc/shells").unwrap_or_default(); + for (id, label, name) in [ + ("bash", "Bash", "bash"), + ("zsh", "Zsh", "zsh"), + ("fish", "Fish", "fish"), + ("nushell", "Nushell", "nu"), + ("pwsh", "PowerShell 7", "pwsh"), + ] { + let registered_path = registered + .lines() + .map(str::trim) + .filter(|line| line.starts_with('/') && !line.starts_with('#')) + .map(PathBuf::from) + .find(|path| path.file_name().is_some_and(|file| file == name) && is_executable(path)); + if let Some(path) = registered_path.or_else(|| find_on_path(name)) { + catalog.add(id, label, path, &[]); + } + } + if catalog.get("system-shell").is_none() + && let Some(path) = find_on_path("sh") + { + catalog.add("system-shell", "Default shell", path, &[]); + } + } +} + +#[cfg(windows)] +mod platform { + use super::*; + + pub(super) fn discover(catalog: &mut ShellCatalog) { + let system32 = env::var_os("SystemRoot") + .map(PathBuf::from) + .map(|root| root.join("System32")); + if let Some(path) = env::var_os("ComSpec").map(PathBuf::from) + && is_executable(&path) + { + catalog.add("system-shell", "Default shell", path, &[]); + } + if catalog.get("system-shell").is_none() { + catalog.add_named("system-shell", "Default shell", "cmd", &[]); + } + if catalog.get("system-shell").is_none() + && let Some(system32) = &system32 + { + catalog.add("system-shell", "Default shell", system32.join("cmd.exe"), &[]); + } + catalog.add_named("cmd", "Command Prompt", "cmd", &[]); + if let Some(system32) = &system32 { + catalog.add("cmd", "Command Prompt", system32.join("cmd.exe"), &[]); + } + catalog.add_named("powershell", "Windows PowerShell", "powershell", &[]); + if let Some(system32) = &system32 { + catalog.add( + "powershell", + "Windows PowerShell", + system32.join("WindowsPowerShell/v1.0/powershell.exe"), + &[], + ); + } + catalog.add_named("pwsh", "PowerShell 7", "pwsh", &[]); + for variable in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] { + if let Some(root) = env::var_os(variable).map(PathBuf::from) { + catalog.add("pwsh", "PowerShell 7", root.join("PowerShell/7/pwsh.exe"), &[]); + } + } + catalog.add_named("nushell", "Nushell", "nu", &[]); + catalog.add_named("bash", "Bash", "bash", &[]); + catalog.add_named("fish", "Fish", "fish", &[]); + + // Git for Windows places git.exe on PATH but its interactive bash.exe under bin/. + if let Some(git) = find_on_path("git") + && let Some(root) = git.parent().and_then(Path::parent) + { + catalog.add("git-bash", "Git Bash", root.join("bin/bash.exe"), &["--login", "-i"]); + } + for variable in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] { + if let Some(root) = env::var_os(variable).map(PathBuf::from) { + catalog.add( + "git-bash", + "Git Bash", + root.join("Git/bin/bash.exe"), + &["--login", "-i"], + ); + } + } + } +} + +#[cfg(not(any(unix, windows)))] +mod platform { + use super::*; + + pub(super) fn discover(catalog: &mut ShellCatalog) { + let _ = catalog; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_omits_non_executable_shells() { + let mut catalog = ShellCatalog::default(); + catalog.add("missing", "Missing", PathBuf::from("/not/a/chitin/shell"), &[]); + assert!(catalog.available().is_empty()); + } + + #[test] + fn catalog_keeps_first_definition_for_a_stable_id() -> std::io::Result<()> { + let mut catalog = ShellCatalog::default(); + let executable = env::current_exe()?; + catalog.add("test", "First", executable.clone(), &[]); + catalog.add("test", "Second", executable, &[]); + assert_eq!(catalog.get("test").map(|shell| shell.label.as_str()), Some("First")); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn catalog_discovers_pwsh_when_installed() { + if find_on_path("pwsh").is_some() { + assert!(ShellCatalog::discover().get("pwsh").is_some()); + } + } +} From 132612ff83d066612af90c71de5c6b1c3a2c3031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Wed, 30 Sep 2026 00:41:55 +0800 Subject: [PATCH 32/32] fix(shell): preserve Windows path separators during tokenization Treat backslashes as escape characters only when they precede escapable characters, while preserving ordinary Windows path separators and trailing backslashes. Add regression tests for unquoted and quoted Windows paths, escaped whitespace, and embedded quotes. --- crates/chitin-builtin-shell/src/grammar.rs | 68 ++++++++++++++++++---- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/crates/chitin-builtin-shell/src/grammar.rs b/crates/chitin-builtin-shell/src/grammar.rs index f5dd6471..1dd0af80 100644 --- a/crates/chitin-builtin-shell/src/grammar.rs +++ b/crates/chitin-builtin-shell/src/grammar.rs @@ -43,12 +43,6 @@ pub enum CommandLineParseError { /// Byte offset of the opening quote. position: usize, }, - /// A trailing escape character had no following character. - #[error("trailing escape character at byte {position}")] - TrailingEscape { - /// Byte offset of the escape character. - position: usize, - }, /// Clap rejected the composed built-in shell grammar. #[error("{0}")] Grammar(String), @@ -317,10 +311,13 @@ fn tokenize(input: &str) -> Result, CommandLineParseError> { token_started = true; } Some(_) | None if character == '\\' => { - let Some((_, escaped)) = characters.next() else { - return Err(CommandLineParseError::TrailingEscape { position }); - }; - token.push(escaped); + // Preserve path separators unless the next character is actually escapable. + let escaped = characters.next_if(|(_, next)| match quote { + Some(('"', _)) => matches!(next, '"' | '\\'), + None => matches!(next, '\'' | '"' | '\\') || next.is_whitespace(), + Some(_) => false, + }); + token.push(escaped.map_or('\\', |(_, character)| character)); token_started = true; } None if character == '\'' || character == '"' => { @@ -362,7 +359,7 @@ fn normalize_clap_error(message: String) -> String { mod tests { use std::path::Path; - use chitin_command::{CommandId, DatabaseCommand, RcsbDownloadArguments}; + use chitin_command::{CommandId, DatabaseCommand, RcsbDownloadArguments, StructureCommand}; use chitin_databases::providers::rcsb::StructureFormat; use super::*; @@ -384,6 +381,55 @@ mod tests { Ok(()) } + #[test] + fn structure_validate_should_preserve_unquoted_windows_path() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line(r"structure validate C:\data\x.pdb")?; + + assert!(matches!( + parsed, + BuiltinCommandLine::Portable(PortableCommand::Structure(StructureCommand::Validate(arguments))) + if arguments.input.input == Path::new(r"C:\data\x.pdb") + )); + Ok(()) + } + + #[test] + fn database_download_should_preserve_quoted_windows_output_path() -> Result<(), CommandLineParseError> { + let parsed = parse_builtin_command_line(r#"db rcsb download --id 4hhb -o "C:\out""#)?; + + assert!(matches!( + parsed, + BuiltinCommandLine::Portable(PortableCommand::Database(DatabaseCommand::DownloadRcsbStructure( + RcsbDownloadArguments { output: Some(output), .. } + ))) if output == Path::new(r"C:\out") + )); + Ok(()) + } + + #[test] + fn tokenize_should_preserve_trailing_unquoted_backslash() -> Result<(), CommandLineParseError> { + assert_eq!( + tokenize("structure validate C:\\")?, + vec!["structure", "validate", "C:\\"] + ); + Ok(()) + } + + #[test] + fn tokenize_should_still_escape_unquoted_whitespace() -> Result<(), CommandLineParseError> { + assert_eq!( + tokenize(r"structure validate C:\my\ file.pdb")?, + vec!["structure", "validate", r"C:\my file.pdb"] + ); + Ok(()) + } + + #[test] + fn tokenize_should_still_escape_double_quote_inside_double_quotes() -> Result<(), CommandLineParseError> { + assert_eq!(tokenize(r#""a\"b""#)?, vec!["a\"b"]); + Ok(()) + } + #[test] fn missing_database_subcommand_should_return_clap_help() -> Result<(), CommandLineParseError> { let parsed = parse_builtin_command_line("db")?;