diff --git a/Cargo.lock b/Cargo.lock
index edb38139..c0e991e7 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"
@@ -686,14 +713,12 @@ dependencies = [
name = "chitin"
version = "0.1.0"
dependencies = [
- "chitin-bio",
"chitin-command",
"chitin-databases",
"clap",
"clap_complete",
"console",
"indicatif",
- "serde_json",
"thiserror 2.0.20",
"tokio",
]
@@ -724,9 +749,29 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "chitin-builtin-shell"
+version = "0.1.0"
+dependencies = [
+ "chitin-command",
+ "chitin-databases",
+ "chitin-terminal",
+ "clap",
+ "thiserror 2.0.20",
+ "tokio",
+]
+
[[package]]
name = "chitin-command"
version = "0.1.0"
+dependencies = [
+ "chitin-bio",
+ "chitin-databases",
+ "clap",
+ "serde_json",
+ "thiserror 2.0.20",
+ "tokio",
+]
[[package]]
name = "chitin-databases"
@@ -753,9 +798,11 @@ version = "0.1.0"
dependencies = [
"bytemuck",
"chitin-bio",
+ "chitin-builtin-shell",
"chitin-command",
"chitin-databases",
"chitin-molecule-renderer",
+ "chitin-terminal",
"chitin-ui",
"chitin-utils",
"chitin-wgpu",
@@ -791,10 +838,21 @@ dependencies = [
"wgpu",
]
+[[package]]
+name = "chitin-terminal"
+version = "0.1.0"
+dependencies = [
+ "alacritty_terminal",
+ "async-channel 2.5.0",
+ "portable-pty",
+ "thiserror 2.0.20",
+]
+
[[package]]
name = "chitin-ui"
version = "0.1.0"
dependencies = [
+ "chitin-terminal",
"criterion",
"env_logger",
"gpui-ce",
@@ -1706,6 +1764,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"
@@ -2765,6 +2834,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"
@@ -3132,6 +3210,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"
@@ -3188,6 +3275,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"
@@ -3272,6 +3368,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"
@@ -3954,6 +4064,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"
@@ -4526,6 +4657,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"
@@ -4839,6 +4981,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"
@@ -4867,6 +5051,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"
@@ -4888,6 +5088,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"
@@ -5391,6 +5601,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"
@@ -5714,7 +5933,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",
]
@@ -5923,6 +6142,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"
@@ -6782,6 +7015,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 14c2a669..75c86f1e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,6 @@
[workspace]
members = [
+ "crates/chitin-builtin-shell",
"crates/chitin-command",
"crates/chitin-cli",
"crates/chitin-bio",
@@ -8,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 00000000..d540de92
Binary files /dev/null and b/assets/fonts/cascadia-mono/CascadiaMono-Bold.ttf differ
diff --git a/assets/fonts/cascadia-mono/CascadiaMono-Regular.ttf b/assets/fonts/cascadia-mono/CascadiaMono-Regular.ttf
new file mode 100644
index 00000000..e697d012
Binary files /dev/null and b/assets/fonts/cascadia-mono/CascadiaMono-Regular.ttf differ
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/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-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-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/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-builtin-shell/Cargo.toml b/crates/chitin-builtin-shell/Cargo.toml
new file mode 100644
index 00000000..0af70fcd
--- /dev/null
+++ b/crates/chitin-builtin-shell/Cargo.toml
@@ -0,0 +1,19 @@
+[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-databases = { path = "../chitin-databases" }
+chitin-terminal = { path = "../chitin-terminal" }
+clap = { version = "4.6", features = ["derive"] }
+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/grammar.rs b/crates/chitin-builtin-shell/src/grammar.rs
new file mode 100644
index 00000000..1dd0af80
--- /dev/null
+++ b/crates/chitin-builtin-shell/src/grammar.rs
@@ -0,0 +1,523 @@
+//! Built-in shell grammar composed around shared portable command fragments.
+
+use std::ffi::OsString;
+
+use chitin_command::{
+ CommandExecutionDomain, CommandId, CommandSpec, FrontendCommand, PortableCommand, command_spec_by_name,
+ command_specs,
+ grammar::{PortableCommandArgs, PortableCommandLineError},
+};
+use clap::{ColorChoice, CommandFactory, Parser, Subcommand, error::ErrorKind};
+
+/// Result of parsing one built-in shell line.
+#[derive(Debug)]
+pub enum BuiltinCommandLine {
+ /// 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 {
+ /// 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,
+ },
+ /// 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),
+}
+
+/// 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,
+}
+
+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),
+ };
+ 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
+///
+/// * `input` is one complete line without a trailing newline.
+///
+/// # Returns
+///
+/// A portable command, frontend command, shell builtin, or display-only help.
+///
+/// # Errors
+///
+/// Returns [`CommandLineParseError`] when tokenization, Clap validation, or
+/// domain conversion fails.
+pub fn parse_builtin_command_line(input: &str) -> Result {
+ let tokens = tokenize(input)?;
+ if tokens.is_empty() {
+ return Err(CommandLineParseError::Empty);
+ }
+ if let Some(line) = parse_registered_frontend_command(&tokens)? {
+ return Ok(line);
+ }
+ let include_frontend_help = tokens.len() == 1 && matches!(tokens[0].as_str(), "help" | "-h" | "--help");
+ parse_grammar(tokens, include_frontend_help)
+}
+
+/// 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;
+ }
+ }
+
+ 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())
+ .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}"))),
+ )
+ .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::>();
+ candidates.sort();
+ candidates.dedup();
+ candidates
+}
+
+/// 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 syntax through Clap without terminating the host process.
+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(),
+ Err(error)
+ if matches!(
+ error.kind(),
+ ErrorKind::DisplayHelp | ErrorKind::DisplayVersion | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
+ ) =>
+ {
+ 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