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,
|
||||
};
|
||||
use crate::cache::FileCache;
|
||||
use crate::command::{CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry};
|
||||
use crate::input::InputBuffer;
|
||||
use crate::scroll::Scroll;
|
||||
use crate::task::TaskStore;
|
||||
|
|
@ -88,7 +89,12 @@ pub struct App {
|
|||
pub context_window: u64,
|
||||
pub turn_index: usize,
|
||||
pub current_tool: Option<String>,
|
||||
/// Normal composer input that is submitted as `Method::Run`.
|
||||
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,
|
||||
/// 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
|
||||
|
|
@ -143,6 +149,9 @@ impl App {
|
|||
turn_index: 0,
|
||||
current_tool: None,
|
||||
input: InputBuffer::new(),
|
||||
command_input: InputBuffer::new(),
|
||||
input_mode: CommandInputMode::Composer,
|
||||
command_registry: CommandRegistry::default(),
|
||||
quit: false,
|
||||
quit_confirm: None,
|
||||
blocks: Vec::new(),
|
||||
|
|
@ -190,6 +199,10 @@ impl App {
|
|||
/// Callers should invoke this after every input mutation that could
|
||||
/// move the cursor or change atoms.
|
||||
pub fn refresh_completion(&mut self) -> Option<Method> {
|
||||
if self.is_command_mode() {
|
||||
self.completion = None;
|
||||
return None;
|
||||
}
|
||||
match self.input.pending_completion_prefix() {
|
||||
Some((kind, start, prefix)) => {
|
||||
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
|
||||
// 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) {
|
||||
self.input.insert_char(c);
|
||||
self.active_input_mut().insert_char(c);
|
||||
}
|
||||
pub fn insert_newline(&mut self) {
|
||||
self.input.insert_newline();
|
||||
self.active_input_mut().insert_newline();
|
||||
}
|
||||
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) {
|
||||
self.input.delete_before();
|
||||
self.active_input_mut().delete_before();
|
||||
}
|
||||
pub fn delete_char_after(&mut self) {
|
||||
self.input.delete_after();
|
||||
self.active_input_mut().delete_after();
|
||||
}
|
||||
pub fn move_cursor_left(&mut self) {
|
||||
self.input.move_left();
|
||||
self.active_input_mut().move_left();
|
||||
}
|
||||
pub fn move_cursor_right(&mut self) {
|
||||
self.input.move_right();
|
||||
self.active_input_mut().move_right();
|
||||
}
|
||||
pub fn move_cursor_start(&mut self) {
|
||||
self.input.move_start();
|
||||
self.active_input_mut().move_start();
|
||||
}
|
||||
pub fn move_cursor_home(&mut self) {
|
||||
self.input.move_home();
|
||||
self.active_input_mut().move_home();
|
||||
}
|
||||
pub fn move_cursor_end(&mut self) {
|
||||
self.input.move_end();
|
||||
self.active_input_mut().move_end();
|
||||
}
|
||||
pub fn move_cursor_up(&mut self) {
|
||||
self.input.move_up();
|
||||
self.active_input_mut().move_up();
|
||||
}
|
||||
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`.
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
self.insert_char('\n');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
mod app;
|
||||
mod block;
|
||||
mod cache;
|
||||
mod command;
|
||||
mod input;
|
||||
mod markdown;
|
||||
mod picker;
|
||||
|
|
@ -644,7 +645,13 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
app.move_cursor_start();
|
||||
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() {
|
||||
Some(app.refresh_completion())
|
||||
} else {
|
||||
|
|
@ -668,8 +675,12 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
Some(None)
|
||||
}
|
||||
KeyCode::Enter if alt => {
|
||||
app.insert_newline();
|
||||
Some(app.refresh_completion())
|
||||
if app.is_command_mode() {
|
||||
Some(None)
|
||||
} else {
|
||||
app.insert_newline();
|
||||
Some(app.refresh_completion())
|
||||
}
|
||||
}
|
||||
_ => 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
|
||||
// navigate / commit. An empty popup (request in flight) falls
|
||||
// through to the default behaviour.
|
||||
|
|
@ -790,6 +805,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
app.move_cursor_end();
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Char(':') if !alt && app.input.is_empty() => {
|
||||
app.enter_command_mode();
|
||||
None
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// Whitespace ends an in-flight completion token. Try the
|
||||
// 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);
|
||||
|
||||
/// Running → send `Method::Pause`.
|
||||
|
|
@ -1056,6 +1129,138 @@ mod tests {
|
|||
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 {
|
||||
protocol::Segment::flatten_to_text(&app.input.submit_segments())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,10 +61,14 @@ impl Mode {
|
|||
|
||||
pub fn draw(frame: &mut Frame, app: &mut App) {
|
||||
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.
|
||||
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 mini_view_h = task_mini_view_height(&app.task_store);
|
||||
// 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_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]);
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1146,6 +1152,16 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
|
|||
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_line = Line::from(Span::styled(right_text, Style::default().fg(Color::Gray)))
|
||||
.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) {
|
||||
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(
|
||||
"Alt-q edit queued Alt-c clear queued",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
|
|
@ -1196,13 +1233,21 @@ fn queue_status_text(app: &App) -> Option<String> {
|
|||
Some(text)
|
||||
}
|
||||
|
||||
fn draw_input(frame: &mut Frame, render: &crate::input::InputRender, area: Rect) {
|
||||
// Prefix "> " on the first row, two-space gutter for continuation
|
||||
fn draw_input(frame: &mut Frame, app: &App, render: &crate::input::InputRender, area: Rect) {
|
||||
// Prefix prompt on the first row, matching-width gutter for continuation
|
||||
// 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());
|
||||
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)];
|
||||
spans.extend(src.spans.iter().cloned());
|
||||
lines.push(Line::from(spans));
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user