feat: add TUI command mode
This commit is contained in:
parent
0fd995c85e
commit
6e8aa92e38
|
|
@ -10,6 +10,7 @@ use crate::block::{
|
||||||
Block, CompactEvent, ThinkingBlock, ThinkingState, ToolCallBlock, ToolCallState,
|
Block, CompactEvent, ThinkingBlock, ThinkingState, ToolCallBlock, ToolCallState,
|
||||||
};
|
};
|
||||||
use crate::cache::FileCache;
|
use crate::cache::FileCache;
|
||||||
|
use crate::command::{CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry};
|
||||||
use crate::input::InputBuffer;
|
use crate::input::InputBuffer;
|
||||||
use crate::scroll::Scroll;
|
use crate::scroll::Scroll;
|
||||||
use crate::task::TaskStore;
|
use crate::task::TaskStore;
|
||||||
|
|
@ -88,7 +89,12 @@ pub struct App {
|
||||||
pub context_window: u64,
|
pub context_window: u64,
|
||||||
pub turn_index: usize,
|
pub turn_index: usize,
|
||||||
pub current_tool: Option<String>,
|
pub current_tool: Option<String>,
|
||||||
|
/// Normal composer input that is submitted as `Method::Run`.
|
||||||
pub input: InputBuffer,
|
pub input: InputBuffer,
|
||||||
|
/// Separate command-line input. It is never submitted as a user message.
|
||||||
|
pub command_input: InputBuffer,
|
||||||
|
pub input_mode: CommandInputMode,
|
||||||
|
pub command_registry: CommandRegistry,
|
||||||
pub quit: bool,
|
pub quit: bool,
|
||||||
/// 2-tap guard for `Ctrl-C` when the Pod is not running. First press
|
/// 2-tap guard for `Ctrl-C` when the Pod is not running. First press
|
||||||
/// records the instant; a second press within the timeout exits the
|
/// records the instant; a second press within the timeout exits the
|
||||||
|
|
@ -143,6 +149,9 @@ impl App {
|
||||||
turn_index: 0,
|
turn_index: 0,
|
||||||
current_tool: None,
|
current_tool: None,
|
||||||
input: InputBuffer::new(),
|
input: InputBuffer::new(),
|
||||||
|
command_input: InputBuffer::new(),
|
||||||
|
input_mode: CommandInputMode::Composer,
|
||||||
|
command_registry: CommandRegistry::default(),
|
||||||
quit: false,
|
quit: false,
|
||||||
quit_confirm: None,
|
quit_confirm: None,
|
||||||
blocks: Vec::new(),
|
blocks: Vec::new(),
|
||||||
|
|
@ -190,6 +199,10 @@ impl App {
|
||||||
/// Callers should invoke this after every input mutation that could
|
/// Callers should invoke this after every input mutation that could
|
||||||
/// move the cursor or change atoms.
|
/// move the cursor or change atoms.
|
||||||
pub fn refresh_completion(&mut self) -> Option<Method> {
|
pub fn refresh_completion(&mut self) -> Option<Method> {
|
||||||
|
if self.is_command_mode() {
|
||||||
|
self.completion = None;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
match self.input.pending_completion_prefix() {
|
match self.input.pending_completion_prefix() {
|
||||||
Some((kind, start, prefix)) => {
|
Some((kind, start, prefix)) => {
|
||||||
let need_query = match &self.completion {
|
let need_query = match &self.completion {
|
||||||
|
|
@ -1013,43 +1026,119 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_command_mode(&self) -> bool {
|
||||||
|
self.input_mode == CommandInputMode::Command
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enter_command_mode(&mut self) {
|
||||||
|
self.input_mode = CommandInputMode::Command;
|
||||||
|
self.completion = None;
|
||||||
|
self.quit_confirm = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exit_command_mode(&mut self) {
|
||||||
|
self.input_mode = CommandInputMode::Composer;
|
||||||
|
self.command_input.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_command_input(&mut self) {
|
||||||
|
self.command_input.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn command_text(&self) -> String {
|
||||||
|
self.command_input.plain_text()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn command_suggestions(&self) -> Vec<crate::command::CommandCandidate> {
|
||||||
|
self.command_registry.suggest(&self.command_text())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_environment(&self) -> CommandEnvironment {
|
||||||
|
CommandEnvironment {
|
||||||
|
connected: self.connected,
|
||||||
|
running: self.running,
|
||||||
|
paused: self.paused,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn submit_command(&mut self) -> Option<Method> {
|
||||||
|
let command_line = self.command_text();
|
||||||
|
let environment = self.command_environment();
|
||||||
|
let result = self.command_registry.dispatch(&command_line, &environment);
|
||||||
|
self.apply_command_execution(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_command_execution(&mut self, result: CommandExecution) -> Option<Method> {
|
||||||
|
for diagnostic in result.diagnostics {
|
||||||
|
self.push_command_diagnostic(diagnostic.message);
|
||||||
|
}
|
||||||
|
if result.clear_input {
|
||||||
|
self.command_input.clear();
|
||||||
|
}
|
||||||
|
if result.exit_command_mode {
|
||||||
|
self.input_mode = CommandInputMode::Composer;
|
||||||
|
}
|
||||||
|
result.method
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_command_diagnostic(&mut self, message: impl Into<String>) {
|
||||||
|
self.blocks.push(Block::Alert {
|
||||||
|
level: AlertLevel::Warn,
|
||||||
|
source: AlertSource::Pod,
|
||||||
|
message: format!("TUI command: {}", message.into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_input_mut(&mut self) -> &mut InputBuffer {
|
||||||
|
if self.is_command_mode() {
|
||||||
|
&mut self.command_input
|
||||||
|
} else {
|
||||||
|
&mut self.input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Input manipulation — thin forwarders so call sites in main.rs
|
// Input manipulation — thin forwarders so call sites in main.rs
|
||||||
// stay readable.
|
// stay readable. In command mode these operate on the command line,
|
||||||
|
// keeping the normal composer buffer intact.
|
||||||
pub fn insert_char(&mut self, c: char) {
|
pub fn insert_char(&mut self, c: char) {
|
||||||
self.input.insert_char(c);
|
self.active_input_mut().insert_char(c);
|
||||||
}
|
}
|
||||||
pub fn insert_newline(&mut self) {
|
pub fn insert_newline(&mut self) {
|
||||||
self.input.insert_newline();
|
self.active_input_mut().insert_newline();
|
||||||
}
|
}
|
||||||
pub fn insert_paste(&mut self, content: String) {
|
pub fn insert_paste(&mut self, content: String) {
|
||||||
self.input.insert_paste(content);
|
if self.is_command_mode() {
|
||||||
|
self.command_input.insert_str(&content);
|
||||||
|
} else {
|
||||||
|
self.input.insert_paste(content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pub fn delete_char_before(&mut self) {
|
pub fn delete_char_before(&mut self) {
|
||||||
self.input.delete_before();
|
self.active_input_mut().delete_before();
|
||||||
}
|
}
|
||||||
pub fn delete_char_after(&mut self) {
|
pub fn delete_char_after(&mut self) {
|
||||||
self.input.delete_after();
|
self.active_input_mut().delete_after();
|
||||||
}
|
}
|
||||||
pub fn move_cursor_left(&mut self) {
|
pub fn move_cursor_left(&mut self) {
|
||||||
self.input.move_left();
|
self.active_input_mut().move_left();
|
||||||
}
|
}
|
||||||
pub fn move_cursor_right(&mut self) {
|
pub fn move_cursor_right(&mut self) {
|
||||||
self.input.move_right();
|
self.active_input_mut().move_right();
|
||||||
}
|
}
|
||||||
pub fn move_cursor_start(&mut self) {
|
pub fn move_cursor_start(&mut self) {
|
||||||
self.input.move_start();
|
self.active_input_mut().move_start();
|
||||||
}
|
}
|
||||||
pub fn move_cursor_home(&mut self) {
|
pub fn move_cursor_home(&mut self) {
|
||||||
self.input.move_home();
|
self.active_input_mut().move_home();
|
||||||
}
|
}
|
||||||
pub fn move_cursor_end(&mut self) {
|
pub fn move_cursor_end(&mut self) {
|
||||||
self.input.move_end();
|
self.active_input_mut().move_end();
|
||||||
}
|
}
|
||||||
pub fn move_cursor_up(&mut self) {
|
pub fn move_cursor_up(&mut self) {
|
||||||
self.input.move_up();
|
self.active_input_mut().move_up();
|
||||||
}
|
}
|
||||||
pub fn move_cursor_down(&mut self) {
|
pub fn move_cursor_down(&mut self) {
|
||||||
self.input.move_down();
|
self.active_input_mut().move_down();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset the block list and replay a connect-time `Event::Snapshot`.
|
/// Reset the block list and replay a connect-time `Event::Snapshot`.
|
||||||
|
|
|
||||||
340
crates/tui/src/command.rs
Normal file
340
crates/tui/src/command.rs
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
use protocol::Method;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CommandInputMode {
|
||||||
|
Composer,
|
||||||
|
Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CommandArgs {
|
||||||
|
raw: String,
|
||||||
|
argv: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandArgs {
|
||||||
|
pub fn parse_whitespace(raw: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
raw: raw.to_owned(),
|
||||||
|
argv: raw.split_whitespace().map(str::to_owned).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn raw(&self) -> &str {
|
||||||
|
&self.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn argv(&self) -> &[String] {
|
||||||
|
&self.argv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CommandDiagnostic {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandDiagnostic {
|
||||||
|
pub fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CommandEnvironment {
|
||||||
|
pub connected: bool,
|
||||||
|
pub running: bool,
|
||||||
|
pub paused: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CommandExecution {
|
||||||
|
pub method: Option<Method>,
|
||||||
|
pub diagnostics: Vec<CommandDiagnostic>,
|
||||||
|
pub exit_command_mode: bool,
|
||||||
|
pub clear_input: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandExecution {
|
||||||
|
pub fn diagnostic(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
method: None,
|
||||||
|
diagnostics: vec![CommandDiagnostic::new(message)],
|
||||||
|
exit_command_mode: false,
|
||||||
|
clear_input: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn notice(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
method: None,
|
||||||
|
diagnostics: vec![CommandDiagnostic::new(message)],
|
||||||
|
exit_command_mode: true,
|
||||||
|
clear_input: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ArgumentParser = fn(&str) -> Result<CommandArgs, CommandDiagnostic>;
|
||||||
|
pub type AvailabilityCheck = fn(&CommandEnvironment) -> Result<(), CommandDiagnostic>;
|
||||||
|
pub type CommandExecutor = fn(CommandInvocation<'_>) -> CommandExecution;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CommandSpec {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub aliases: &'static [&'static str],
|
||||||
|
pub usage: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub argument_parser: ArgumentParser,
|
||||||
|
pub can_execute: AvailabilityCheck,
|
||||||
|
pub executor: CommandExecutor,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CommandInvocation<'a> {
|
||||||
|
pub registry: &'a CommandRegistry,
|
||||||
|
pub command: &'a CommandSpec,
|
||||||
|
pub args: CommandArgs,
|
||||||
|
pub environment: &'a CommandEnvironment,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CommandCandidate {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub usage: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CommandRegistry {
|
||||||
|
commands: Vec<CommandSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
commands: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builtins() -> Self {
|
||||||
|
let mut registry = Self::new();
|
||||||
|
registry.register(CommandSpec {
|
||||||
|
name: "help",
|
||||||
|
aliases: &["?"],
|
||||||
|
usage: "help [command]",
|
||||||
|
description: "Show available TUI commands or details for one command.",
|
||||||
|
argument_parser: help_args,
|
||||||
|
can_execute: always_available,
|
||||||
|
executor: help_command,
|
||||||
|
});
|
||||||
|
registry.register(CommandSpec {
|
||||||
|
name: "noop",
|
||||||
|
aliases: &["nop"],
|
||||||
|
usage: "noop",
|
||||||
|
description: "Validate command dispatch without side effects.",
|
||||||
|
argument_parser: no_args,
|
||||||
|
can_execute: always_available,
|
||||||
|
executor: noop_command,
|
||||||
|
});
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register(&mut self, spec: CommandSpec) {
|
||||||
|
debug_assert!(!self.commands.iter().any(|c| c.name == spec.name));
|
||||||
|
self.commands.push(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commands(&self) -> &[CommandSpec] {
|
||||||
|
&self.commands
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find(&self, name_or_alias: &str) -> Option<&CommandSpec> {
|
||||||
|
self.commands.iter().find(|command| {
|
||||||
|
command.name == name_or_alias
|
||||||
|
|| command.aliases.iter().any(|alias| *alias == name_or_alias)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn suggest(&self, command_line: &str) -> Vec<CommandCandidate> {
|
||||||
|
let prefix = command_prefix(command_line);
|
||||||
|
if prefix.is_empty() {
|
||||||
|
return self.commands.iter().map(CommandCandidate::from).collect();
|
||||||
|
}
|
||||||
|
self.commands
|
||||||
|
.iter()
|
||||||
|
.filter(|command| {
|
||||||
|
command.name.starts_with(prefix)
|
||||||
|
|| command
|
||||||
|
.aliases
|
||||||
|
.iter()
|
||||||
|
.any(|alias| alias.starts_with(prefix))
|
||||||
|
})
|
||||||
|
.map(CommandCandidate::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch(
|
||||||
|
&self,
|
||||||
|
command_line: &str,
|
||||||
|
environment: &CommandEnvironment,
|
||||||
|
) -> CommandExecution {
|
||||||
|
let trimmed = command_line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return CommandExecution::diagnostic(
|
||||||
|
"Empty command. Type :help for available commands.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (name, raw_args) = split_command(trimmed);
|
||||||
|
let Some(command) = self.find(name) else {
|
||||||
|
return CommandExecution::diagnostic(format!(
|
||||||
|
"Unknown command: {name}. Type :help for available commands."
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let args = match (command.argument_parser)(raw_args) {
|
||||||
|
Ok(args) => args,
|
||||||
|
Err(err) => return CommandExecution::diagnostic(err.message),
|
||||||
|
};
|
||||||
|
if let Err(err) = (command.can_execute)(environment) {
|
||||||
|
return CommandExecution::diagnostic(err.message);
|
||||||
|
}
|
||||||
|
(command.executor)(CommandInvocation {
|
||||||
|
registry: self,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
environment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CommandRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::builtins()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&CommandSpec> for CommandCandidate {
|
||||||
|
fn from(command: &CommandSpec) -> Self {
|
||||||
|
Self {
|
||||||
|
name: command.name,
|
||||||
|
usage: command.usage,
|
||||||
|
description: command.description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_command(trimmed: &str) -> (&str, &str) {
|
||||||
|
match trimmed.find(char::is_whitespace) {
|
||||||
|
Some(idx) => {
|
||||||
|
let (name, rest) = trimmed.split_at(idx);
|
||||||
|
(name, rest.trim_start())
|
||||||
|
}
|
||||||
|
None => (trimmed, ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_prefix(command_line: &str) -> &str {
|
||||||
|
let trimmed = command_line.trim_start();
|
||||||
|
match trimmed.find(char::is_whitespace) {
|
||||||
|
Some(idx) => &trimmed[..idx],
|
||||||
|
None => trimmed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn always_available(_environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
|
||||||
|
let args = CommandArgs::parse_whitespace(raw);
|
||||||
|
if args.argv().is_empty() {
|
||||||
|
Ok(args)
|
||||||
|
} else {
|
||||||
|
Err(CommandDiagnostic::new("Invalid arguments. Usage: noop"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn help_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
|
||||||
|
let args = CommandArgs::parse_whitespace(raw);
|
||||||
|
if args.argv().len() <= 1 {
|
||||||
|
Ok(args)
|
||||||
|
} else {
|
||||||
|
Err(CommandDiagnostic::new(
|
||||||
|
"Invalid arguments. Usage: help [command]",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn help_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||||
|
if let Some(name) = invocation.args.argv().first() {
|
||||||
|
let Some(command) = invocation.registry.find(name) else {
|
||||||
|
return CommandExecution::diagnostic(format!(
|
||||||
|
"Unknown command: {name}. Type :help for available commands."
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let aliases = if command.aliases.is_empty() {
|
||||||
|
"".to_owned()
|
||||||
|
} else {
|
||||||
|
format!(" aliases: {}.", command.aliases.join(", "))
|
||||||
|
};
|
||||||
|
return CommandExecution::notice(format!(
|
||||||
|
"command: {} — usage: {}.{} {}",
|
||||||
|
command.name, command.usage, aliases, command.description
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = invocation
|
||||||
|
.registry
|
||||||
|
.commands()
|
||||||
|
.iter()
|
||||||
|
.map(|command| format!("{} ({})", command.name, command.usage))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
CommandExecution::notice(format!("available commands: {list}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn noop_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||||
|
let _ = invocation.command;
|
||||||
|
let _ = invocation.environment;
|
||||||
|
let _ = invocation.args.raw();
|
||||||
|
CommandExecution::notice("noop: no action")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn env() -> CommandEnvironment {
|
||||||
|
CommandEnvironment {
|
||||||
|
connected: true,
|
||||||
|
running: false,
|
||||||
|
paused: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtins_suggest_by_prefix() {
|
||||||
|
let registry = CommandRegistry::builtins();
|
||||||
|
assert_eq!(registry.suggest("he")[0].name, "help");
|
||||||
|
assert_eq!(registry.suggest("n")[0].name, "noop");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_command_is_local_diagnostic() {
|
||||||
|
let registry = CommandRegistry::builtins();
|
||||||
|
let result = registry.dispatch("wat", &env());
|
||||||
|
assert!(result.method.is_none());
|
||||||
|
assert!(!result.exit_command_mode);
|
||||||
|
assert!(result.diagnostics[0].message.contains("Unknown command"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_arguments_are_local_diagnostic() {
|
||||||
|
let registry = CommandRegistry::builtins();
|
||||||
|
let result = registry.dispatch("noop extra", &env());
|
||||||
|
assert!(result.method.is_none());
|
||||||
|
assert!(!result.exit_command_mode);
|
||||||
|
assert!(result.diagnostics[0].message.contains("Invalid arguments"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -223,6 +223,26 @@ impl InputBuffer {
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn insert_str(&mut self, text: &str) {
|
||||||
|
for c in text.chars() {
|
||||||
|
self.insert_char(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plain_text(&self) -> String {
|
||||||
|
let mut text = String::new();
|
||||||
|
for atom in &self.atoms {
|
||||||
|
match atom {
|
||||||
|
Atom::Char(c) => text.push(*c),
|
||||||
|
Atom::Paste(paste) => text.push_str(&paste.content),
|
||||||
|
Atom::FileRef(file) => text.push_str(&file.path),
|
||||||
|
Atom::KnowledgeRef(knowledge) => text.push_str(&knowledge.slug),
|
||||||
|
Atom::WorkflowInvoke(workflow) => text.push_str(&workflow.slug),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
pub fn insert_newline(&mut self) {
|
pub fn insert_newline(&mut self) {
|
||||||
self.insert_char('\n');
|
self.insert_char('\n');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
mod app;
|
mod app;
|
||||||
mod block;
|
mod block;
|
||||||
mod cache;
|
mod cache;
|
||||||
|
mod command;
|
||||||
mod input;
|
mod input;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
mod picker;
|
mod picker;
|
||||||
|
|
@ -644,7 +645,13 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
app.move_cursor_start();
|
app.move_cursor_start();
|
||||||
Some(app.refresh_completion())
|
Some(app.refresh_completion())
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'q') && alt && !ctrl => {
|
KeyCode::Char('u') if ctrl && app.is_command_mode() => {
|
||||||
|
app.clear_command_input();
|
||||||
|
Some(None)
|
||||||
|
}
|
||||||
|
KeyCode::Char(c)
|
||||||
|
if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() =>
|
||||||
|
{
|
||||||
if app.restore_next_queued_input_to_composer() {
|
if app.restore_next_queued_input_to_composer() {
|
||||||
Some(app.refresh_completion())
|
Some(app.refresh_completion())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -668,8 +675,12 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Enter if alt => {
|
KeyCode::Enter if alt => {
|
||||||
app.insert_newline();
|
if app.is_command_mode() {
|
||||||
Some(app.refresh_completion())
|
Some(None)
|
||||||
|
} else {
|
||||||
|
app.insert_newline();
|
||||||
|
Some(app.refresh_completion())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
} {
|
} {
|
||||||
|
|
@ -705,6 +716,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if app.is_command_mode() {
|
||||||
|
return handle_command_key(app, key);
|
||||||
|
}
|
||||||
|
|
||||||
// Completion popup overrides — only when there's something to
|
// Completion popup overrides — only when there's something to
|
||||||
// navigate / commit. An empty popup (request in flight) falls
|
// navigate / commit. An empty popup (request in flight) falls
|
||||||
// through to the default behaviour.
|
// through to the default behaviour.
|
||||||
|
|
@ -790,6 +805,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
app.move_cursor_end();
|
app.move_cursor_end();
|
||||||
app.refresh_completion()
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
|
KeyCode::Char(':') if !alt && app.input.is_empty() => {
|
||||||
|
app.enter_command_mode();
|
||||||
|
None
|
||||||
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
// Whitespace ends an in-flight completion token. Try the
|
// Whitespace ends an in-flight completion token. Try the
|
||||||
// auto-confirm path first so an exact match (e.g. typed
|
// auto-confirm path first so an exact match (e.g. typed
|
||||||
|
|
@ -807,6 +826,60 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.exit_command_mode();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
KeyCode::Enter => app.submit_command(),
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.delete_char_before();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
KeyCode::Delete => {
|
||||||
|
app.delete_char_after();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
app.move_cursor_left();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
app.move_cursor_right();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
app.move_cursor_up();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
app.move_cursor_down();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
KeyCode::Home => {
|
||||||
|
app.move_cursor_home();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
KeyCode::End => {
|
||||||
|
app.move_cursor_end();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
KeyCode::Tab => None,
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
if key
|
||||||
|
.modifiers
|
||||||
|
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
app.insert_char(c);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||||
|
|
||||||
/// Running → send `Method::Pause`.
|
/// Running → send `Method::Pause`.
|
||||||
|
|
@ -1056,6 +1129,138 @@ mod tests {
|
||||||
assert_eq!(app.queued_input_count(), 0);
|
assert_eq!(app.queued_input_count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_mode_enters_with_colon_and_esc_restores_composer() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
app.insert_char('d');
|
||||||
|
app.insert_char('r');
|
||||||
|
app.insert_char('a');
|
||||||
|
app.insert_char('f');
|
||||||
|
app.insert_char('t');
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(!app.is_command_mode());
|
||||||
|
assert_eq!(input_text(&app), "draft:");
|
||||||
|
|
||||||
|
app.input.clear();
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(app.is_command_mode());
|
||||||
|
for c in "help".chars() {
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(input_text(&app), "");
|
||||||
|
assert_eq!(app.command_text(), "help");
|
||||||
|
|
||||||
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).is_none());
|
||||||
|
assert!(!app.is_command_mode());
|
||||||
|
assert_eq!(input_text(&app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_command_is_not_sent_as_user_message() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
for c in "does-not-exist".chars() {
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
assert!(method.is_none());
|
||||||
|
assert!(app.is_command_mode());
|
||||||
|
assert_eq!(input_text(&app), "");
|
||||||
|
assert_eq!(app.queued_input_count(), 0);
|
||||||
|
assert!(app.blocks.iter().any(|block| match block {
|
||||||
|
crate::block::Block::Alert { message, .. } => message.contains("Unknown command"),
|
||||||
|
_ => false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_enter_dispatches_registry_without_run() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
for c in "noop".chars() {
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
assert!(method.is_none());
|
||||||
|
assert!(!app.is_command_mode());
|
||||||
|
assert_eq!(input_text(&app), "");
|
||||||
|
assert!(app.blocks.iter().any(|block| match block {
|
||||||
|
crate::block::Block::Alert { message, .. } => message.contains("noop: no action"),
|
||||||
|
_ => false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_registry_suggestions_are_available() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.command_suggestions()
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate.name == "help")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let suggestions = app.command_suggestions();
|
||||||
|
assert_eq!(suggestions.len(), 1);
|
||||||
|
assert_eq!(suggestions[0].name, "noop");
|
||||||
|
}
|
||||||
|
|
||||||
fn input_text(app: &App) -> String {
|
fn input_text(app: &App) -> String {
|
||||||
protocol::Segment::flatten_to_text(&app.input.submit_segments())
|
protocol::Segment::flatten_to_text(&app.input.submit_segments())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,14 @@ impl Mode {
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, app: &mut App) {
|
pub fn draw(frame: &mut Frame, app: &mut App) {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
// Input content starts after the "> " / " " prompt, so the width
|
// Input content starts after the prompt (`> ` or `: `), so the width
|
||||||
// available for wrapping is two columns narrower than the frame.
|
// available for wrapping is two columns narrower than the frame.
|
||||||
let input_content_width = area.width.saturating_sub(2).max(1);
|
let input_content_width = area.width.saturating_sub(2).max(1);
|
||||||
let input_render = app.input.render(input_content_width);
|
let input_render = if app.is_command_mode() {
|
||||||
|
app.command_input.render(input_content_width)
|
||||||
|
} else {
|
||||||
|
app.input.render(input_content_width)
|
||||||
|
};
|
||||||
let input_height = input_area_height(&input_render, area.height);
|
let input_height = input_area_height(&input_render, area.height);
|
||||||
let mini_view_h = task_mini_view_height(&app.task_store);
|
let mini_view_h = task_mini_view_height(&app.task_store);
|
||||||
// One blank row separates the history tail from the mini-view so
|
// One blank row separates the history tail from the mini-view so
|
||||||
|
|
@ -89,9 +93,11 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
|
||||||
}
|
}
|
||||||
draw_separator(frame, chunks[3]);
|
draw_separator(frame, chunks[3]);
|
||||||
draw_status(frame, app, chunks[4]);
|
draw_status(frame, app, chunks[4]);
|
||||||
draw_input(frame, &input_render, chunks[5]);
|
draw_input(frame, app, &input_render, chunks[5]);
|
||||||
draw_actionbar(frame, app, chunks[6]);
|
draw_actionbar(frame, app, chunks[6]);
|
||||||
if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) {
|
if !app.is_command_mode()
|
||||||
|
&& let Some(state) = app.completion.as_ref().filter(|c| c.is_active())
|
||||||
|
{
|
||||||
draw_completion_popup(frame, state, chunks[5]);
|
draw_completion_popup(frame, state, chunks[5]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1146,6 +1152,16 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
spans.push(Span::styled(queue, Style::default().fg(Color::Magenta)));
|
spans.push(Span::styled(queue, Style::default().fg(Color::Magenta)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if app.is_command_mode() {
|
||||||
|
spans.push(Span::raw(" | "));
|
||||||
|
spans.push(Span::styled(
|
||||||
|
"command",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let right_text = context_usage_text(app);
|
let right_text = context_usage_text(app);
|
||||||
let right_line = Line::from(Span::styled(right_text, Style::default().fg(Color::Gray)))
|
let right_line = Line::from(Span::styled(right_text, Style::default().fg(Color::Gray)))
|
||||||
.alignment(ratatui::layout::Alignment::Right);
|
.alignment(ratatui::layout::Alignment::Right);
|
||||||
|
|
@ -1156,7 +1172,28 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
|
||||||
fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) {
|
fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
let mut left: Vec<Span<'static>> = Vec::new();
|
let mut left: Vec<Span<'static>> = Vec::new();
|
||||||
if app.queued_input_count() > 0 {
|
if app.is_command_mode() {
|
||||||
|
left.push(Span::styled(
|
||||||
|
"COMMAND Esc cancel Enter dispatch",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
let suggestions = app.command_suggestions();
|
||||||
|
if !suggestions.is_empty() {
|
||||||
|
let suggestion_text = suggestions
|
||||||
|
.iter()
|
||||||
|
.take(4)
|
||||||
|
.map(|candidate| format!("{} — {}", candidate.name, candidate.description))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" | ");
|
||||||
|
left.push(Span::styled(" ", Style::default()));
|
||||||
|
left.push(Span::styled(
|
||||||
|
truncate_with_ellipsis(&suggestion_text, area.width.saturating_sub(34) as usize),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if app.queued_input_count() > 0 {
|
||||||
left.push(Span::styled(
|
left.push(Span::styled(
|
||||||
"Alt-q edit queued Alt-c clear queued",
|
"Alt-q edit queued Alt-c clear queued",
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
|
|
@ -1196,13 +1233,21 @@ fn queue_status_text(app: &App) -> Option<String> {
|
||||||
Some(text)
|
Some(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_input(frame: &mut Frame, render: &crate::input::InputRender, area: Rect) {
|
fn draw_input(frame: &mut Frame, app: &App, render: &crate::input::InputRender, area: Rect) {
|
||||||
// Prefix "> " on the first row, two-space gutter for continuation
|
// Prefix prompt on the first row, matching-width gutter for continuation
|
||||||
// rows so multi-line input aligns visually.
|
// rows so multi-line input aligns visually.
|
||||||
let prompt_style = Style::default().fg(Color::DarkGray);
|
let prompt = if app.is_command_mode() { ": " } else { "> " };
|
||||||
|
let continuation = if app.is_command_mode() { ": " } else { " " };
|
||||||
|
let prompt_style = if app.is_command_mode() {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
let mut lines: Vec<Line<'static>> = Vec::with_capacity(render.lines.len());
|
let mut lines: Vec<Line<'static>> = Vec::with_capacity(render.lines.len());
|
||||||
for (i, src) in render.lines.iter().enumerate() {
|
for (i, src) in render.lines.iter().enumerate() {
|
||||||
let prefix = if i == 0 { "> " } else { " " };
|
let prefix = if i == 0 { prompt } else { continuation };
|
||||||
let mut spans = vec![Span::styled(prefix.to_owned(), prompt_style)];
|
let mut spans = vec![Span::styled(prefix.to_owned(), prompt_style)];
|
||||||
spans.extend(src.spans.iter().cloned());
|
spans.extend(src.spans.iter().cloned());
|
||||||
lines.push(Line::from(spans));
|
lines.push(Line::from(spans));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user