Remove unshipped rusty-claude-cli prototype modules

The shipped CLI surface lives in `src/main.rs`, which only wires `init`,
`input`, and `render`. The legacy `app.rs` and `args.rs` prototypes were
not in the module tree and had no inbound references, so this change deletes
those orphaned files instead of widening scope into a larger refactor.

It also aligns the TUI enhancement plan with that reality so the document no
longer describes the removed prototypes as current tracked structure.

Constraint: Must preserve shipped CLI parsing and slash-command behavior
Rejected: Refactor main.rs into smaller modules now | widens scope beyond behavior-safe cleanup
Rejected: Leave TUI plan wording untouched | leaves low-risk stale documentation behind
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep this slice deletion-first; do not reintroduce alternate CLI surfaces without wiring them into main.rs and its tests
Tested: cargo test -p rusty-claude-cli defaults_to_repl_when_no_args
Tested: cargo test -p rusty-claude-cli parses_login_and_logout_subcommands
Tested: cargo test -p rusty-claude-cli parses_direct_agents_mcp_and_skills_slash_commands
Tested: cargo test -p rusty-claude-cli direct_slash_commands_surface_shared_validation_errors
Tested: cargo test -p rusty-claude-cli parses_resume_flag_with_multiple_slash_commands -- --nocapture
Tested: cargo test -p rusty-claude-cli resumed_binary_accepts_slash_commands_with_arguments -- --nocapture
Tested: cargo check -p rusty-claude-cli
Tested: git diff --check
Not-tested: cargo clippy -p rusty-claude-cli --all-targets -- -D warnings (pre-existing failures in rust/crates/runtime/* and existing warnings outside this diff)
This commit is contained in:
Yeachan-Heo
2026-04-05 17:40:43 +00:00
parent 31163be347
commit c3b0e12164
3 changed files with 7 additions and 680 deletions

View File

@@ -20,12 +20,14 @@ This plan covers a comprehensive analysis of the current terminal user interface
### Current TUI Components
> Note: The legacy prototype files `app.rs` and `args.rs` were removed on 2026-04-05.
> References below describe future extraction targets, not current tracked source files.
| Component | File | What It Does Today | Quality |
|---|---|---|---|
| **Input** | `input.rs` (269 lines) | `rustyline`-based line editor with slash-command tab completion, Shift+Enter newline, history | ✅ Solid |
| **Rendering** | `render.rs` (641 lines) | Markdown→terminal rendering (headings, lists, tables, code blocks with syntect highlighting, blockquotes), spinner widget | ✅ Good |
| **App/REPL loop** | `main.rs` (3,159 lines) | The monolithic `LiveCli` struct: REPL loop, all slash command handlers, streaming output, tool call display, permission prompting, session management | ⚠️ Monolithic |
| **Alt App** | `app.rs` (398 lines) | An earlier `CliApp` prototype with `ConversationClient`, stream event handling, `TerminalRenderer`, output format support | ⚠️ Appears unused/legacy |
### Key Dependencies
@@ -56,7 +58,7 @@ This plan covers a comprehensive analysis of the current terminal user interface
8. **Streaming is char-by-char with artificial delay**`stream_markdown` sleeps 8ms per whitespace-delimited chunk
9. **No color theme customization** — hardcoded `ColorTheme::default()`
10. **No resize handling** — no terminal size awareness for wrapping, truncation, or layout
11. **Dual app structs**`app.rs` has a separate `CliApp` that duplicates `LiveCli` from `main.rs`
11. **Historical dual app split**the repo previously carried a separate `CliApp` prototype alongside `LiveCli`; the prototype is gone, but the monolithic `main.rs` still needs extraction
12. **No pager for long outputs**`/status`, `/config`, `/memory` can overflow the viewport
13. **Tool results not collapsible** — large bash outputs flood the screen
14. **No thinking/reasoning indicator** — when the model is in "thinking" mode, no visual distinction
@@ -73,8 +75,8 @@ This plan covers a comprehensive analysis of the current terminal user interface
| Task | Description | Effort |
|---|---|---|
| 0.1 | **Extract `LiveCli` into `app.rs`** — Move the entire `LiveCli` struct, its impl, and helpers (`format_*`, `render_*`, session management) out of `main.rs` into focused modules: `app.rs` (core), `format.rs` (report formatting), `session_manager.rs` (session CRUD) | M |
| 0.2 | **Remove or merge the legacy `CliApp`** — The existing `app.rs` has an unused `CliApp` with its own `ConversationClient`-based rendering. Either delete it or merge its unique features (stream event handler pattern) into the active `LiveCli` | S |
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is a hand-rolled parser that duplicates the clap-based `args.rs`. Consolidate on the hand-rolled parser (it's more feature-complete) and move it to `args.rs`, or adopt clap fully | S |
| 0.2 | **Keep the legacy `CliApp` removed** — The old `CliApp` prototype has already been deleted; if any unique ideas remain valuable (for example stream event handler patterns), reintroduce them intentionally inside the active `LiveCli` extraction rather than restoring the old file wholesale | S |
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is still a hand-rolled parser in `main.rs`. If parsing is extracted later, do it into a newly-introduced module intentionally rather than reviving the removed prototype `args.rs` by accident | S |
| 0.4 | **Create a `tui/` module** — Introduce `crates/rusty-claude-cli/src/tui/mod.rs` as the namespace for all new TUI components: `status_bar.rs`, `layout.rs`, `tool_panel.rs`, etc. | S |
### Phase 1: Status Bar & Live HUD
@@ -214,7 +216,7 @@ crates/rusty-claude-cli/src/
| Terminal compatibility issues (tmux, SSH, Windows) | Rely on crossterm's abstraction; test in degraded environments |
| Performance regression with rich rendering | Profile before/after; keep the fast path (raw streaming) always available |
| Scope creep into Phase 6 | Ship Phases 03 as a coherent release before starting Phase 6 |
| `app.rs` vs `main.rs` confusion | Phase 0.2 explicitly resolves this by removing the legacy `CliApp` |
| Historical `app.rs` vs `main.rs` confusion | Keep the legacy prototype removed and avoid reintroducing a second app surface accidentally during extraction |
---

View File

@@ -1,567 +0,0 @@
use std::io::{self, Write};
use std::path::PathBuf;
use crate::args::{OutputFormat, PermissionMode};
use crate::input::{LineEditor, ReadOutcome};
use crate::render::{Spinner, TerminalRenderer};
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionConfig {
pub model: String,
pub permission_mode: PermissionMode,
pub config: Option<PathBuf>,
pub output_format: OutputFormat,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionState {
pub turns: usize,
pub compacted_messages: usize,
pub last_model: String,
pub last_usage: UsageSummary,
}
impl SessionState {
#[must_use]
pub fn new(model: impl Into<String>) -> Self {
Self {
turns: 0,
compacted_messages: 0,
last_model: model.into(),
last_usage: UsageSummary::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandResult {
Continue,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SlashCommand {
Help,
Status,
Compact,
Model { model: Option<String> },
Permissions { mode: Option<String> },
Config { section: Option<String> },
Memory,
Clear { confirm: bool },
Unknown(String),
}
impl SlashCommand {
#[must_use]
pub fn parse(input: &str) -> Option<Self> {
let trimmed = input.trim();
if !trimmed.starts_with('/') {
return None;
}
let mut parts = trimmed.trim_start_matches('/').split_whitespace();
let command = parts.next().unwrap_or_default();
Some(match command {
"help" => Self::Help,
"status" => Self::Status,
"compact" => Self::Compact,
"model" => Self::Model {
model: parts.next().map(ToOwned::to_owned),
},
"permissions" => Self::Permissions {
mode: parts.next().map(ToOwned::to_owned),
},
"config" => Self::Config {
section: parts.next().map(ToOwned::to_owned),
},
"memory" => Self::Memory,
"clear" => Self::Clear {
confirm: parts.next() == Some("--confirm"),
},
other => Self::Unknown(other.to_string()),
})
}
}
struct SlashCommandHandler {
command: SlashCommand,
summary: &'static str,
}
const SLASH_COMMAND_HANDLERS: &[SlashCommandHandler] = &[
SlashCommandHandler {
command: SlashCommand::Help,
summary: "Show command help",
},
SlashCommandHandler {
command: SlashCommand::Status,
summary: "Show current session status",
},
SlashCommandHandler {
command: SlashCommand::Compact,
summary: "Compact local session history",
},
SlashCommandHandler {
command: SlashCommand::Model { model: None },
summary: "Show or switch the active model",
},
SlashCommandHandler {
command: SlashCommand::Permissions { mode: None },
summary: "Show or switch the active permission mode",
},
SlashCommandHandler {
command: SlashCommand::Config { section: None },
summary: "Inspect current config path or section",
},
SlashCommandHandler {
command: SlashCommand::Memory,
summary: "Inspect loaded memory/instruction files",
},
SlashCommandHandler {
command: SlashCommand::Clear { confirm: false },
summary: "Start a fresh local session",
},
];
pub struct CliApp {
config: SessionConfig,
renderer: TerminalRenderer,
state: SessionState,
conversation_client: ConversationClient,
conversation_history: Vec<ConversationMessage>,
}
impl CliApp {
pub fn new(config: SessionConfig) -> Result<Self, RuntimeError> {
let state = SessionState::new(config.model.clone());
let conversation_client = ConversationClient::from_env(config.model.clone())?;
Ok(Self {
config,
renderer: TerminalRenderer::new(),
state,
conversation_client,
conversation_history: Vec::new(),
})
}
pub fn run_repl(&mut self) -> io::Result<()> {
let mut editor = LineEditor::new(" ", Vec::new());
println!("Rusty Claude CLI interactive mode");
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
loop {
match editor.read_line()? {
ReadOutcome::Submit(input) => {
if input.trim().is_empty() {
continue;
}
self.handle_submission(&input, &mut io::stdout())?;
}
ReadOutcome::Cancel => continue,
ReadOutcome::Exit => break,
}
}
Ok(())
}
pub fn run_prompt(&mut self, prompt: &str, out: &mut impl Write) -> io::Result<()> {
self.render_response(prompt, out)
}
pub fn handle_submission(
&mut self,
input: &str,
out: &mut impl Write,
) -> io::Result<CommandResult> {
if let Some(command) = SlashCommand::parse(input) {
return self.dispatch_slash_command(command, out);
}
self.state.turns += 1;
self.render_response(input, out)?;
Ok(CommandResult::Continue)
}
fn dispatch_slash_command(
&mut self,
command: SlashCommand,
out: &mut impl Write,
) -> io::Result<CommandResult> {
match command {
SlashCommand::Help => Self::handle_help(out),
SlashCommand::Status => self.handle_status(out),
SlashCommand::Compact => self.handle_compact(out),
SlashCommand::Model { model } => self.handle_model(model.as_deref(), out),
SlashCommand::Permissions { mode } => self.handle_permissions(mode.as_deref(), out),
SlashCommand::Config { section } => self.handle_config(section.as_deref(), out),
SlashCommand::Memory => self.handle_memory(out),
SlashCommand::Clear { confirm } => self.handle_clear(confirm, out),
SlashCommand::Unknown(name) => {
writeln!(out, "Unknown slash command: /{name}")?;
Ok(CommandResult::Continue)
}
}
}
fn handle_help(out: &mut impl Write) -> io::Result<CommandResult> {
writeln!(out, "Available commands:")?;
for handler in SLASH_COMMAND_HANDLERS {
let name = match handler.command {
SlashCommand::Help => "/help",
SlashCommand::Status => "/status",
SlashCommand::Compact => "/compact",
SlashCommand::Model { .. } => "/model [model]",
SlashCommand::Permissions { .. } => "/permissions [mode]",
SlashCommand::Config { .. } => "/config [section]",
SlashCommand::Memory => "/memory",
SlashCommand::Clear { .. } => "/clear [--confirm]",
SlashCommand::Unknown(_) => continue,
};
writeln!(out, " {name:<9} {}", handler.summary)?;
}
Ok(CommandResult::Continue)
}
fn handle_status(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
writeln!(
out,
"status: turns={} model={} permission-mode={:?} output-format={:?} last-usage={} in/{} out config={}",
self.state.turns,
self.state.last_model,
self.config.permission_mode,
self.config.output_format,
self.state.last_usage.input_tokens,
self.state.last_usage.output_tokens,
self.config
.config
.as_ref()
.map_or_else(|| String::from("<none>"), |path| path.display().to_string())
)?;
Ok(CommandResult::Continue)
}
fn handle_compact(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
self.state.compacted_messages += self.state.turns;
self.state.turns = 0;
self.conversation_history.clear();
writeln!(
out,
"Compacted session history into a local summary ({} messages total compacted).",
self.state.compacted_messages
)?;
Ok(CommandResult::Continue)
}
fn handle_model(
&mut self,
model: Option<&str>,
out: &mut impl Write,
) -> io::Result<CommandResult> {
match model {
Some(model) => {
self.config.model = model.to_string();
self.state.last_model = model.to_string();
writeln!(out, "Active model set to {model}")?;
}
None => {
writeln!(out, "Active model: {}", self.config.model)?;
}
}
Ok(CommandResult::Continue)
}
fn handle_permissions(
&mut self,
mode: Option<&str>,
out: &mut impl Write,
) -> io::Result<CommandResult> {
match mode {
None => writeln!(out, "Permission mode: {:?}", self.config.permission_mode)?,
Some("read-only") => {
self.config.permission_mode = PermissionMode::ReadOnly;
writeln!(out, "Permission mode set to read-only")?;
}
Some("workspace-write") => {
self.config.permission_mode = PermissionMode::WorkspaceWrite;
writeln!(out, "Permission mode set to workspace-write")?;
}
Some("danger-full-access") => {
self.config.permission_mode = PermissionMode::DangerFullAccess;
writeln!(out, "Permission mode set to danger-full-access")?;
}
Some(other) => {
writeln!(out, "Unknown permission mode: {other}")?;
}
}
Ok(CommandResult::Continue)
}
fn handle_config(
&mut self,
section: Option<&str>,
out: &mut impl Write,
) -> io::Result<CommandResult> {
match section {
None => writeln!(
out,
"Config path: {}",
self.config
.config
.as_ref()
.map_or_else(|| String::from("<none>"), |path| path.display().to_string())
)?,
Some(section) => writeln!(
out,
"Config section `{section}` is not fully implemented yet; current config path is {}",
self.config
.config
.as_ref()
.map_or_else(|| String::from("<none>"), |path| path.display().to_string())
)?,
}
Ok(CommandResult::Continue)
}
fn handle_memory(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
writeln!(
out,
"Loaded memory/config file: {}",
self.config
.config
.as_ref()
.map_or_else(|| String::from("<none>"), |path| path.display().to_string())
)?;
Ok(CommandResult::Continue)
}
fn handle_clear(&mut self, confirm: bool, out: &mut impl Write) -> io::Result<CommandResult> {
if !confirm {
writeln!(out, "Refusing to clear without confirmation. Re-run as /clear --confirm")?;
return Ok(CommandResult::Continue);
}
self.state.turns = 0;
self.state.compacted_messages = 0;
self.state.last_usage = UsageSummary::default();
self.conversation_history.clear();
writeln!(out, "Started a fresh local session.")?;
Ok(CommandResult::Continue)
}
fn handle_stream_event(
renderer: &TerminalRenderer,
event: StreamEvent,
stream_spinner: &mut Spinner,
tool_spinner: &mut Spinner,
saw_text: &mut bool,
turn_usage: &mut UsageSummary,
out: &mut impl Write,
) {
match event {
StreamEvent::TextDelta(delta) => {
if !*saw_text {
let _ =
stream_spinner.finish("Streaming response", renderer.color_theme(), out);
*saw_text = true;
}
let _ = write!(out, "{delta}");
let _ = out.flush();
}
StreamEvent::ToolCallStart { name, input } => {
if *saw_text {
let _ = writeln!(out);
}
let _ = tool_spinner.tick(
&format!("Running tool `{name}` with {input}"),
renderer.color_theme(),
out,
);
}
StreamEvent::ToolCallResult {
name,
output,
is_error,
} => {
let label = if is_error {
format!("Tool `{name}` failed")
} else {
format!("Tool `{name}` completed")
};
let _ = tool_spinner.finish(&label, renderer.color_theme(), out);
let rendered_output = format!("### Tool `{name}`\n\n```text\n{output}\n```\n");
let _ = renderer.stream_markdown(&rendered_output, out);
}
StreamEvent::Usage(usage) => {
*turn_usage = usage;
}
}
}
fn write_turn_output(
&self,
summary: &runtime::TurnSummary,
out: &mut impl Write,
) -> io::Result<()> {
match self.config.output_format {
OutputFormat::Text => {
writeln!(
out,
"\nToken usage: {} input / {} output",
self.state.last_usage.input_tokens, self.state.last_usage.output_tokens
)?;
}
OutputFormat::Json => {
writeln!(
out,
"{}",
serde_json::json!({
"message": summary.assistant_text,
"usage": {
"input_tokens": self.state.last_usage.input_tokens,
"output_tokens": self.state.last_usage.output_tokens,
}
})
)?;
}
OutputFormat::Ndjson => {
writeln!(
out,
"{}",
serde_json::json!({
"type": "message",
"text": summary.assistant_text,
"usage": {
"input_tokens": self.state.last_usage.input_tokens,
"output_tokens": self.state.last_usage.output_tokens,
}
})
)?;
}
}
Ok(())
}
fn render_response(&mut self, input: &str, out: &mut impl Write) -> io::Result<()> {
let mut stream_spinner = Spinner::new();
stream_spinner.tick(
"Opening conversation stream",
self.renderer.color_theme(),
out,
)?;
let mut turn_usage = UsageSummary::default();
let mut tool_spinner = Spinner::new();
let mut saw_text = false;
let renderer = &self.renderer;
let result =
self.conversation_client
.run_turn(&mut self.conversation_history, input, |event| {
Self::handle_stream_event(
renderer,
event,
&mut stream_spinner,
&mut tool_spinner,
&mut saw_text,
&mut turn_usage,
out,
);
});
let summary = match result {
Ok(summary) => summary,
Err(error) => {
stream_spinner.fail(
"Streaming response failed",
self.renderer.color_theme(),
out,
)?;
return Err(io::Error::other(error));
}
};
self.state.last_usage = summary.usage.clone();
if saw_text {
writeln!(out)?;
} else {
stream_spinner.finish("Streaming response", self.renderer.color_theme(), out)?;
}
self.write_turn_output(&summary, out)?;
let _ = turn_usage;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::args::{OutputFormat, PermissionMode};
use super::{CommandResult, SessionConfig, SlashCommand};
#[test]
fn parses_required_slash_commands() {
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
assert_eq!(
SlashCommand::parse("/compact now"),
Some(SlashCommand::Compact)
);
assert_eq!(
SlashCommand::parse("/model claude-sonnet"),
Some(SlashCommand::Model {
model: Some("claude-sonnet".into()),
})
);
assert_eq!(
SlashCommand::parse("/permissions workspace-write"),
Some(SlashCommand::Permissions {
mode: Some("workspace-write".into()),
})
);
assert_eq!(
SlashCommand::parse("/config hooks"),
Some(SlashCommand::Config {
section: Some("hooks".into()),
})
);
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
assert_eq!(
SlashCommand::parse("/clear --confirm"),
Some(SlashCommand::Clear { confirm: true })
);
}
#[test]
fn help_output_lists_commands() {
let mut out = Vec::new();
let result = super::CliApp::handle_help(&mut out).expect("help succeeds");
assert_eq!(result, CommandResult::Continue);
let output = String::from_utf8_lossy(&out);
assert!(output.contains("/help"));
assert!(output.contains("/status"));
assert!(output.contains("/compact"));
assert!(output.contains("/model [model]"));
assert!(output.contains("/permissions [mode]"));
assert!(output.contains("/config [section]"));
assert!(output.contains("/memory"));
assert!(output.contains("/clear [--confirm]"));
}
#[test]
fn session_state_tracks_config_values() {
let config = SessionConfig {
model: "claude".into(),
permission_mode: PermissionMode::DangerFullAccess,
config: Some(PathBuf::from("settings.toml")),
output_format: OutputFormat::Text,
};
assert_eq!(config.model, "claude");
assert_eq!(config.permission_mode, PermissionMode::DangerFullAccess);
assert_eq!(config.config, Some(PathBuf::from("settings.toml")));
}
}

View File

@@ -1,108 +0,0 @@
use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Debug, Clone, Parser, PartialEq, Eq)]
#[command(
name = "rusty-claude-cli",
version,
about = "Rust Claude CLI prototype"
)]
pub struct Cli {
#[arg(long, default_value = "claude-opus-4-6")]
pub model: String,
#[arg(long, value_enum, default_value_t = PermissionMode::DangerFullAccess)]
pub permission_mode: PermissionMode,
#[arg(long)]
pub config: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
pub output_format: OutputFormat,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
pub enum Command {
/// Read upstream TS sources and print extracted counts
DumpManifests,
/// Print the current bootstrap phase skeleton
BootstrapPlan,
/// Start the OAuth login flow
Login,
/// Clear saved OAuth credentials
Logout,
/// Run a non-interactive prompt and exit
Prompt { prompt: Vec<String> },
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
pub enum PermissionMode {
ReadOnly,
WorkspaceWrite,
DangerFullAccess,
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Json,
Ndjson,
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::{Cli, Command, OutputFormat, PermissionMode};
#[test]
fn parses_requested_flags() {
let cli = Cli::parse_from([
"rusty-claude-cli",
"--model",
"claude-3-5-haiku",
"--permission-mode",
"read-only",
"--config",
"/tmp/config.toml",
"--output-format",
"ndjson",
"prompt",
"hello",
"world",
]);
assert_eq!(cli.model, "claude-3-5-haiku");
assert_eq!(cli.permission_mode, PermissionMode::ReadOnly);
assert_eq!(
cli.config.as_deref(),
Some(std::path::Path::new("/tmp/config.toml"))
);
assert_eq!(cli.output_format, OutputFormat::Ndjson);
assert_eq!(
cli.command,
Some(Command::Prompt {
prompt: vec!["hello".into(), "world".into()]
})
);
}
#[test]
fn parses_login_and_logout_commands() {
let login = Cli::parse_from(["rusty-claude-cli", "login"]);
assert_eq!(login.command, Some(Command::Login));
let logout = Cli::parse_from(["rusty-claude-cli", "logout"]);
assert_eq!(logout.command, Some(Command::Logout));
}
#[test]
fn defaults_to_danger_full_access_permission_mode() {
let cli = Cli::parse_from(["rusty-claude-cli"]);
assert_eq!(cli.permission_mode, PermissionMode::DangerFullAccess);
}
}