feat: add TUI command mode

This commit is contained in:
Keisuke Hirata 2026-05-24 08:32:21 +09:00
parent 83cab17f1f
commit 14381b8ba5
5 changed files with 724 additions and 25 deletions

View File

@ -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
View 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"));
}
}

View File

@ -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');
}

View File

@ -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())
}

View File

@ -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));