feat: interactive CLI with REPL, markdown rendering, and project init

This commit is contained in:
Yeachan-Heo
2026-04-01 20:36:06 +09:00
parent 498f62823e
commit 9e26dcec1d
7 changed files with 6796 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
[package]
name = "claw-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[[bin]]
name = "claw"
path = "src/main.rs"
[dependencies]
api = { path = "../api" }
commands = { path = "../commands" }
compat-harness = { path = "../compat-harness" }
crossterm = "0.28"
pulldown-cmark = "0.13"
rustyline = "15"
runtime = { path = "../runtime" }
plugins = { path = "../plugins" }
serde_json.workspace = true
syntect = "5"
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
tools = { path = "../tools" }
[lints]
workspace = true

View File

@@ -0,0 +1,398 @@
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,
Unknown(String),
}
impl SlashCommand {
#[must_use]
pub fn parse(input: &str) -> Option<Self> {
let trimmed = input.trim();
if !trimmed.starts_with('/') {
return None;
}
let command = trimmed
.trim_start_matches('/')
.split_whitespace()
.next()
.unwrap_or_default();
Some(match command {
"help" => Self::Help,
"status" => Self::Status,
"compact" => Self::Compact,
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",
},
];
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!("Claw Code 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::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::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_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)
);
}
#[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"));
}
#[test]
fn session_state_tracks_config_values() {
let config = SessionConfig {
model: "sonnet".into(),
permission_mode: PermissionMode::DangerFullAccess,
config: Some(PathBuf::from("settings.toml")),
output_format: OutputFormat::Text,
};
assert_eq!(config.model, "sonnet");
assert_eq!(config.permission_mode, PermissionMode::DangerFullAccess);
assert_eq!(config.config, Some(PathBuf::from("settings.toml")));
}
}

View File

@@ -0,0 +1,104 @@
use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Debug, Clone, Parser, PartialEq, Eq)]
#[command(name = "claw-cli", version, about = "Claw Code CLI")]
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([
"claw-cli",
"--model",
"claude-haiku-4-5-20251213",
"--permission-mode",
"read-only",
"--config",
"/tmp/config.toml",
"--output-format",
"ndjson",
"prompt",
"hello",
"world",
]);
assert_eq!(cli.model, "claude-haiku-4-5-20251213");
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(["claw-cli", "login"]);
assert_eq!(login.command, Some(Command::Login));
let logout = Cli::parse_from(["claw-cli", "logout"]);
assert_eq!(logout.command, Some(Command::Logout));
}
#[test]
fn defaults_to_danger_full_access_permission_mode() {
let cli = Cli::parse_from(["claw-cli"]);
assert_eq!(cli.permission_mode, PermissionMode::DangerFullAccess);
}
}

View File

@@ -0,0 +1,433 @@
use std::fs;
use std::path::{Path, PathBuf};
const STARTER_CLAW_JSON: &str = concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"dontAsk\"\n",
" }\n",
"}\n",
);
const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
const GITIGNORE_ENTRIES: [&str; 2] = [".claw/settings.local.json", ".claw/sessions/"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InitStatus {
Created,
Updated,
Skipped,
}
impl InitStatus {
#[must_use]
pub(crate) fn label(self) -> &'static str {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Skipped => "skipped (already exists)",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InitArtifact {
pub(crate) name: &'static str,
pub(crate) status: InitStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InitReport {
pub(crate) project_root: PathBuf,
pub(crate) artifacts: Vec<InitArtifact>,
}
impl InitReport {
#[must_use]
pub(crate) fn render(&self) -> String {
let mut lines = vec![
"Init".to_string(),
format!(" Project {}", self.project_root.display()),
];
for artifact in &self.artifacts {
lines.push(format!(
" {:<16} {}",
artifact.name,
artifact.status.label()
));
}
lines.push(" Next step Review and tailor the generated guidance".to_string());
lines.join("\n")
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[allow(clippy::struct_excessive_bools)]
struct RepoDetection {
rust_workspace: bool,
rust_root: bool,
python: bool,
package_json: bool,
typescript: bool,
nextjs: bool,
react: bool,
vite: bool,
nest: bool,
src_dir: bool,
tests_dir: bool,
rust_dir: bool,
}
pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::error::Error>> {
let mut artifacts = Vec::new();
let claw_dir = cwd.join(".claw");
artifacts.push(InitArtifact {
name: ".claw/",
status: ensure_dir(&claw_dir)?,
});
let claw_json = cwd.join(".claw.json");
artifacts.push(InitArtifact {
name: ".claw.json",
status: write_file_if_missing(&claw_json, STARTER_CLAW_JSON)?,
});
let gitignore = cwd.join(".gitignore");
artifacts.push(InitArtifact {
name: ".gitignore",
status: ensure_gitignore_entries(&gitignore)?,
});
let claw_md = cwd.join("CLAW.md");
let content = render_init_claw_md(cwd);
artifacts.push(InitArtifact {
name: "CLAW.md",
status: write_file_if_missing(&claw_md, &content)?,
});
Ok(InitReport {
project_root: cwd.to_path_buf(),
artifacts,
})
}
fn ensure_dir(path: &Path) -> Result<InitStatus, std::io::Error> {
if path.is_dir() {
return Ok(InitStatus::Skipped);
}
fs::create_dir_all(path)?;
Ok(InitStatus::Created)
}
fn write_file_if_missing(path: &Path, content: &str) -> Result<InitStatus, std::io::Error> {
if path.exists() {
return Ok(InitStatus::Skipped);
}
fs::write(path, content)?;
Ok(InitStatus::Created)
}
fn ensure_gitignore_entries(path: &Path) -> Result<InitStatus, std::io::Error> {
if !path.exists() {
let mut lines = vec![GITIGNORE_COMMENT.to_string()];
lines.extend(GITIGNORE_ENTRIES.iter().map(|entry| (*entry).to_string()));
fs::write(path, format!("{}\n", lines.join("\n")))?;
return Ok(InitStatus::Created);
}
let existing = fs::read_to_string(path)?;
let mut lines = existing.lines().map(ToOwned::to_owned).collect::<Vec<_>>();
let mut changed = false;
if !lines.iter().any(|line| line == GITIGNORE_COMMENT) {
lines.push(GITIGNORE_COMMENT.to_string());
changed = true;
}
for entry in GITIGNORE_ENTRIES {
if !lines.iter().any(|line| line == entry) {
lines.push(entry.to_string());
changed = true;
}
}
if !changed {
return Ok(InitStatus::Skipped);
}
fs::write(path, format!("{}\n", lines.join("\n")))?;
Ok(InitStatus::Updated)
}
pub(crate) fn render_init_claw_md(cwd: &Path) -> String {
let detection = detect_repo(cwd);
let mut lines = vec![
"# CLAW.md".to_string(),
String::new(),
"This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.".to_string(),
String::new(),
];
let detected_languages = detected_languages(&detection);
let detected_frameworks = detected_frameworks(&detection);
lines.push("## Detected stack".to_string());
if detected_languages.is_empty() {
lines.push("- No specific language markers were detected yet; document the primary language and verification commands once the project structure settles.".to_string());
} else {
lines.push(format!("- Languages: {}.", detected_languages.join(", ")));
}
if detected_frameworks.is_empty() {
lines.push("- Frameworks: none detected from the supported starter markers.".to_string());
} else {
lines.push(format!(
"- Frameworks/tooling markers: {}.",
detected_frameworks.join(", ")
));
}
lines.push(String::new());
let verification_lines = verification_lines(cwd, &detection);
if !verification_lines.is_empty() {
lines.push("## Verification".to_string());
lines.extend(verification_lines);
lines.push(String::new());
}
let structure_lines = repository_shape_lines(&detection);
if !structure_lines.is_empty() {
lines.push("## Repository shape".to_string());
lines.extend(structure_lines);
lines.push(String::new());
}
let framework_lines = framework_notes(&detection);
if !framework_lines.is_empty() {
lines.push("## Framework notes".to_string());
lines.extend(framework_lines);
lines.push(String::new());
}
lines.push("## Working agreement".to_string());
lines.push("- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.".to_string());
lines.push("- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides.".to_string());
lines.push("- Do not overwrite existing `CLAW.md` content automatically; update it intentionally when repo workflows change.".to_string());
lines.push(String::new());
lines.join("\n")
}
fn detect_repo(cwd: &Path) -> RepoDetection {
let package_json_contents = fs::read_to_string(cwd.join("package.json"))
.unwrap_or_default()
.to_ascii_lowercase();
RepoDetection {
rust_workspace: cwd.join("rust").join("Cargo.toml").is_file(),
rust_root: cwd.join("Cargo.toml").is_file(),
python: cwd.join("pyproject.toml").is_file()
|| cwd.join("requirements.txt").is_file()
|| cwd.join("setup.py").is_file(),
package_json: cwd.join("package.json").is_file(),
typescript: cwd.join("tsconfig.json").is_file()
|| package_json_contents.contains("typescript"),
nextjs: package_json_contents.contains("\"next\""),
react: package_json_contents.contains("\"react\""),
vite: package_json_contents.contains("\"vite\""),
nest: package_json_contents.contains("@nestjs"),
src_dir: cwd.join("src").is_dir(),
tests_dir: cwd.join("tests").is_dir(),
rust_dir: cwd.join("rust").is_dir(),
}
}
fn detected_languages(detection: &RepoDetection) -> Vec<&'static str> {
let mut languages = Vec::new();
if detection.rust_workspace || detection.rust_root {
languages.push("Rust");
}
if detection.python {
languages.push("Python");
}
if detection.typescript {
languages.push("TypeScript");
} else if detection.package_json {
languages.push("JavaScript/Node.js");
}
languages
}
fn detected_frameworks(detection: &RepoDetection) -> Vec<&'static str> {
let mut frameworks = Vec::new();
if detection.nextjs {
frameworks.push("Next.js");
}
if detection.react {
frameworks.push("React");
}
if detection.vite {
frameworks.push("Vite");
}
if detection.nest {
frameworks.push("NestJS");
}
frameworks
}
fn verification_lines(cwd: &Path, detection: &RepoDetection) -> Vec<String> {
let mut lines = Vec::new();
if detection.rust_workspace {
lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
} else if detection.rust_root {
lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
}
if detection.python {
if cwd.join("pyproject.toml").is_file() {
lines.push("- Run the Python project checks declared in `pyproject.toml` (for example: `pytest`, `ruff check`, and `mypy` when configured).".to_string());
} else {
lines.push(
"- Run the repo's Python test/lint commands before shipping changes.".to_string(),
);
}
}
if detection.package_json {
lines.push("- Run the JavaScript/TypeScript checks from `package.json` before shipping changes (`npm test`, `npm run lint`, `npm run build`, or the repo equivalent).".to_string());
}
if detection.tests_dir && detection.src_dir {
lines.push("- `src/` and `tests/` are both present; update both surfaces together when behavior changes.".to_string());
}
lines
}
fn repository_shape_lines(detection: &RepoDetection) -> Vec<String> {
let mut lines = Vec::new();
if detection.rust_dir {
lines.push(
"- `rust/` contains the Rust workspace and active CLI/runtime implementation."
.to_string(),
);
}
if detection.src_dir {
lines.push("- `src/` contains source files that should stay consistent with generated guidance and tests.".to_string());
}
if detection.tests_dir {
lines.push("- `tests/` contains validation surfaces that should be reviewed alongside code changes.".to_string());
}
lines
}
fn framework_notes(detection: &RepoDetection) -> Vec<String> {
let mut lines = Vec::new();
if detection.nextjs {
lines.push("- Next.js detected: preserve routing/data-fetching conventions and verify production builds after changing app structure.".to_string());
}
if detection.react && !detection.nextjs {
lines.push("- React detected: keep component behavior covered with focused tests and avoid unnecessary prop/API churn.".to_string());
}
if detection.vite {
lines.push("- Vite detected: validate the production bundle after changing build-sensitive configuration or imports.".to_string());
}
if detection.nest {
lines.push("- NestJS detected: keep module/provider boundaries explicit and verify controller/service wiring after refactors.".to_string());
}
lines
}
#[cfg(test)]
mod tests {
use super::{initialize_repo, render_init_claw_md};
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("claw-init-{nanos}"))
}
#[test]
fn initialize_repo_creates_expected_files_and_gitignore_entries() {
let root = temp_dir();
fs::create_dir_all(root.join("rust")).expect("create rust dir");
fs::write(root.join("rust").join("Cargo.toml"), "[workspace]\n").expect("write cargo");
let report = initialize_repo(&root).expect("init should succeed");
let rendered = report.render();
assert!(rendered.contains(".claw/ created"));
assert!(rendered.contains(".claw.json created"));
assert!(rendered.contains(".gitignore created"));
assert!(rendered.contains("CLAW.md created"));
assert!(root.join(".claw").is_dir());
assert!(root.join(".claw.json").is_file());
assert!(root.join("CLAW.md").is_file());
assert_eq!(
fs::read_to_string(root.join(".claw.json")).expect("read claw json"),
concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"dontAsk\"\n",
" }\n",
"}\n",
)
);
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
assert!(gitignore.contains(".claw/settings.local.json"));
assert!(gitignore.contains(".claw/sessions/"));
let claw_md = fs::read_to_string(root.join("CLAW.md")).expect("read claw md");
assert!(claw_md.contains("Languages: Rust."));
assert!(claw_md.contains("cargo clippy --workspace --all-targets -- -D warnings"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn initialize_repo_is_idempotent_and_preserves_existing_files() {
let root = temp_dir();
fs::create_dir_all(&root).expect("create root");
fs::write(root.join("CLAW.md"), "custom guidance\n").expect("write existing claw md");
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n")
.expect("write gitignore");
let first = initialize_repo(&root).expect("first init should succeed");
assert!(first
.render()
.contains("CLAW.md skipped (already exists)"));
let second = initialize_repo(&root).expect("second init should succeed");
let second_rendered = second.render();
assert!(second_rendered.contains(".claw/ skipped (already exists)"));
assert!(second_rendered.contains(".claw.json skipped (already exists)"));
assert!(second_rendered.contains(".gitignore skipped (already exists)"));
assert!(second_rendered.contains("CLAW.md skipped (already exists)"));
assert_eq!(
fs::read_to_string(root.join("CLAW.md")).expect("read existing claw md"),
"custom guidance\n"
);
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
assert_eq!(gitignore.matches(".claw/settings.local.json").count(), 1);
assert_eq!(gitignore.matches(".claw/sessions/").count(), 1);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn render_init_template_mentions_detected_python_and_nextjs_markers() {
let root = temp_dir();
fs::create_dir_all(&root).expect("create root");
fs::write(root.join("pyproject.toml"), "[project]\nname = \"demo\"\n")
.expect("write pyproject");
fs::write(
root.join("package.json"),
r#"{"dependencies":{"next":"14.0.0","react":"18.0.0"},"devDependencies":{"typescript":"5.0.0"}}"#,
)
.expect("write package json");
let rendered = render_init_claw_md(Path::new(&root));
assert!(rendered.contains("Languages: Python, TypeScript."));
assert!(rendered.contains("Frameworks/tooling markers: Next.js, React."));
assert!(rendered.contains("pyproject.toml"));
assert!(rendered.contains("Next.js detected"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
}

View File

@@ -0,0 +1,269 @@
use std::borrow::Cow;
use std::cell::RefCell;
use std::io::{self, IsTerminal, Write};
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::{CmdKind, Highlighter};
use rustyline::hint::Hinter;
use rustyline::history::DefaultHistory;
use rustyline::validate::Validator;
use rustyline::{
Cmd, CompletionType, Config, Context, EditMode, Editor, Helper, KeyCode, KeyEvent, Modifiers,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadOutcome {
Submit(String),
Cancel,
Exit,
}
struct SlashCommandHelper {
completions: Vec<String>,
current_line: RefCell<String>,
}
impl SlashCommandHelper {
fn new(completions: Vec<String>) -> Self {
Self {
completions,
current_line: RefCell::new(String::new()),
}
}
fn reset_current_line(&self) {
self.current_line.borrow_mut().clear();
}
fn current_line(&self) -> String {
self.current_line.borrow().clone()
}
fn set_current_line(&self, line: &str) {
let mut current = self.current_line.borrow_mut();
current.clear();
current.push_str(line);
}
}
impl Completer for SlashCommandHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
let Some(prefix) = slash_command_prefix(line, pos) else {
return Ok((0, Vec::new()));
};
let matches = self
.completions
.iter()
.filter(|candidate| candidate.starts_with(prefix))
.map(|candidate| Pair {
display: candidate.clone(),
replacement: candidate.clone(),
})
.collect();
Ok((0, matches))
}
}
impl Hinter for SlashCommandHelper {
type Hint = String;
}
impl Highlighter for SlashCommandHelper {
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
self.set_current_line(line);
Cow::Borrowed(line)
}
fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
self.set_current_line(line);
false
}
}
impl Validator for SlashCommandHelper {}
impl Helper for SlashCommandHelper {}
pub struct LineEditor {
prompt: String,
editor: Editor<SlashCommandHelper, DefaultHistory>,
}
impl LineEditor {
#[must_use]
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
let config = Config::builder()
.completion_type(CompletionType::List)
.edit_mode(EditMode::Emacs)
.build();
let mut editor = Editor::<SlashCommandHelper, DefaultHistory>::with_config(config)
.expect("rustyline editor should initialize");
editor.set_helper(Some(SlashCommandHelper::new(completions)));
editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline);
editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline);
Self {
prompt: prompt.into(),
editor,
}
}
pub fn push_history(&mut self, entry: impl Into<String>) {
let entry = entry.into();
if entry.trim().is_empty() {
return;
}
let _ = self.editor.add_history_entry(entry);
}
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return self.read_line_fallback();
}
if let Some(helper) = self.editor.helper_mut() {
helper.reset_current_line();
}
match self.editor.readline(&self.prompt) {
Ok(line) => Ok(ReadOutcome::Submit(line)),
Err(ReadlineError::Interrupted) => {
let has_input = !self.current_line().is_empty();
self.finish_interrupted_read()?;
if has_input {
Ok(ReadOutcome::Cancel)
} else {
Ok(ReadOutcome::Exit)
}
}
Err(ReadlineError::Eof) => {
self.finish_interrupted_read()?;
Ok(ReadOutcome::Exit)
}
Err(error) => Err(io::Error::other(error)),
}
}
fn current_line(&self) -> String {
self.editor
.helper()
.map_or_else(String::new, SlashCommandHelper::current_line)
}
fn finish_interrupted_read(&mut self) -> io::Result<()> {
if let Some(helper) = self.editor.helper_mut() {
helper.reset_current_line();
}
let mut stdout = io::stdout();
writeln!(stdout)
}
fn read_line_fallback(&self) -> io::Result<ReadOutcome> {
let mut stdout = io::stdout();
write!(stdout, "{}", self.prompt)?;
stdout.flush()?;
let mut buffer = String::new();
let bytes_read = io::stdin().read_line(&mut buffer)?;
if bytes_read == 0 {
return Ok(ReadOutcome::Exit);
}
while matches!(buffer.chars().last(), Some('\n' | '\r')) {
buffer.pop();
}
Ok(ReadOutcome::Submit(buffer))
}
}
fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> {
if pos != line.len() {
return None;
}
let prefix = &line[..pos];
if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
return None;
}
Some(prefix)
}
#[cfg(test)]
mod tests {
use super::{slash_command_prefix, LineEditor, SlashCommandHelper};
use rustyline::completion::Completer;
use rustyline::highlight::Highlighter;
use rustyline::history::{DefaultHistory, History};
use rustyline::Context;
#[test]
fn extracts_only_terminal_slash_command_prefixes() {
assert_eq!(slash_command_prefix("/he", 3), Some("/he"));
assert_eq!(slash_command_prefix("/help me", 5), None);
assert_eq!(slash_command_prefix("hello", 5), None);
assert_eq!(slash_command_prefix("/help", 2), None);
}
#[test]
fn completes_matching_slash_commands() {
let helper = SlashCommandHelper::new(vec![
"/help".to_string(),
"/hello".to_string(),
"/status".to_string(),
]);
let history = DefaultHistory::new();
let ctx = Context::new(&history);
let (start, matches) = helper
.complete("/he", 3, &ctx)
.expect("completion should work");
assert_eq!(start, 0);
assert_eq!(
matches
.into_iter()
.map(|candidate| candidate.replacement)
.collect::<Vec<_>>(),
vec!["/help".to_string(), "/hello".to_string()]
);
}
#[test]
fn ignores_non_slash_command_completion_requests() {
let helper = SlashCommandHelper::new(vec!["/help".to_string()]);
let history = DefaultHistory::new();
let ctx = Context::new(&history);
let (_, matches) = helper
.complete("hello", 5, &ctx)
.expect("completion should work");
assert!(matches.is_empty());
}
#[test]
fn tracks_current_buffer_through_highlighter() {
let helper = SlashCommandHelper::new(Vec::new());
let _ = helper.highlight("draft", 5);
assert_eq!(helper.current_line(), "draft");
}
#[test]
fn push_history_ignores_blank_entries() {
let mut editor = LineEditor::new("> ", vec!["/help".to_string()]);
editor.push_history(" ");
editor.push_history("/help");
assert_eq!(editor.editor.history().len(), 1);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,797 @@
use std::fmt::Write as FmtWrite;
use std::io::{self, Write};
use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition};
use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize};
use crossterm::terminal::{Clear, ClearType};
use crossterm::{execute, queue};
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
use syntect::easy::HighlightLines;
use syntect::highlighting::{Theme, ThemeSet};
use syntect::parsing::SyntaxSet;
use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ColorTheme {
heading: Color,
emphasis: Color,
strong: Color,
inline_code: Color,
link: Color,
quote: Color,
table_border: Color,
code_block_border: Color,
spinner_active: Color,
spinner_done: Color,
spinner_failed: Color,
}
impl Default for ColorTheme {
fn default() -> Self {
Self {
heading: Color::Cyan,
emphasis: Color::Magenta,
strong: Color::Yellow,
inline_code: Color::Green,
link: Color::Blue,
quote: Color::DarkGrey,
table_border: Color::DarkCyan,
code_block_border: Color::DarkGrey,
spinner_active: Color::Blue,
spinner_done: Color::Green,
spinner_failed: Color::Red,
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Spinner {
frame_index: usize,
}
impl Spinner {
const FRAMES: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn tick(
&mut self,
label: &str,
theme: &ColorTheme,
out: &mut impl Write,
) -> io::Result<()> {
let frame = Self::FRAMES[self.frame_index % Self::FRAMES.len()];
self.frame_index += 1;
queue!(
out,
SavePosition,
MoveToColumn(0),
Clear(ClearType::CurrentLine),
SetForegroundColor(theme.spinner_active),
Print(format!("{frame} {label}")),
ResetColor,
RestorePosition
)?;
out.flush()
}
pub fn finish(
&mut self,
label: &str,
theme: &ColorTheme,
out: &mut impl Write,
) -> io::Result<()> {
self.frame_index = 0;
execute!(
out,
MoveToColumn(0),
Clear(ClearType::CurrentLine),
SetForegroundColor(theme.spinner_done),
Print(format!("{label}\n")),
ResetColor
)?;
out.flush()
}
pub fn fail(
&mut self,
label: &str,
theme: &ColorTheme,
out: &mut impl Write,
) -> io::Result<()> {
self.frame_index = 0;
execute!(
out,
MoveToColumn(0),
Clear(ClearType::CurrentLine),
SetForegroundColor(theme.spinner_failed),
Print(format!("{label}\n")),
ResetColor
)?;
out.flush()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ListKind {
Unordered,
Ordered { next_index: u64 },
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct TableState {
headers: Vec<String>,
rows: Vec<Vec<String>>,
current_row: Vec<String>,
current_cell: String,
in_head: bool,
}
impl TableState {
fn push_cell(&mut self) {
let cell = self.current_cell.trim().to_string();
self.current_row.push(cell);
self.current_cell.clear();
}
fn finish_row(&mut self) {
if self.current_row.is_empty() {
return;
}
let row = std::mem::take(&mut self.current_row);
if self.in_head {
self.headers = row;
} else {
self.rows.push(row);
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct RenderState {
emphasis: usize,
strong: usize,
heading_level: Option<u8>,
quote: usize,
list_stack: Vec<ListKind>,
link_stack: Vec<LinkState>,
table: Option<TableState>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LinkState {
destination: String,
text: String,
}
impl RenderState {
fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
let mut style = text.stylize();
if matches!(self.heading_level, Some(1 | 2)) || self.strong > 0 {
style = style.bold();
}
if self.emphasis > 0 {
style = style.italic();
}
if let Some(level) = self.heading_level {
style = match level {
1 => style.with(theme.heading),
2 => style.white(),
3 => style.with(Color::Blue),
_ => style.with(Color::Grey),
};
} else if self.strong > 0 {
style = style.with(theme.strong);
} else if self.emphasis > 0 {
style = style.with(theme.emphasis);
}
if self.quote > 0 {
style = style.with(theme.quote);
}
format!("{style}")
}
fn append_raw(&mut self, output: &mut String, text: &str) {
if let Some(link) = self.link_stack.last_mut() {
link.text.push_str(text);
} else if let Some(table) = self.table.as_mut() {
table.current_cell.push_str(text);
} else {
output.push_str(text);
}
}
fn append_styled(&mut self, output: &mut String, text: &str, theme: &ColorTheme) {
let styled = self.style_text(text, theme);
self.append_raw(output, &styled);
}
}
#[derive(Debug)]
pub struct TerminalRenderer {
syntax_set: SyntaxSet,
syntax_theme: Theme,
color_theme: ColorTheme,
}
impl Default for TerminalRenderer {
fn default() -> Self {
let syntax_set = SyntaxSet::load_defaults_newlines();
let syntax_theme = ThemeSet::load_defaults()
.themes
.remove("base16-ocean.dark")
.unwrap_or_default();
Self {
syntax_set,
syntax_theme,
color_theme: ColorTheme::default(),
}
}
}
impl TerminalRenderer {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn color_theme(&self) -> &ColorTheme {
&self.color_theme
}
#[must_use]
pub fn render_markdown(&self, markdown: &str) -> String {
let mut output = String::new();
let mut state = RenderState::default();
let mut code_language = String::new();
let mut code_buffer = String::new();
let mut in_code_block = false;
for event in Parser::new_ext(markdown, Options::all()) {
self.render_event(
event,
&mut state,
&mut output,
&mut code_buffer,
&mut code_language,
&mut in_code_block,
);
}
output.trim_end().to_string()
}
#[must_use]
pub fn markdown_to_ansi(&self, markdown: &str) -> String {
self.render_markdown(markdown)
}
#[allow(clippy::too_many_lines)]
fn render_event(
&self,
event: Event<'_>,
state: &mut RenderState,
output: &mut String,
code_buffer: &mut String,
code_language: &mut String,
in_code_block: &mut bool,
) {
match event {
Event::Start(Tag::Heading { level, .. }) => {
self.start_heading(state, level as u8, output);
}
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
Event::End(TagEnd::BlockQuote(..)) => {
state.quote = state.quote.saturating_sub(1);
output.push('\n');
}
Event::End(TagEnd::Heading(..)) => {
state.heading_level = None;
output.push_str("\n\n");
}
Event::End(TagEnd::Item) | Event::SoftBreak | Event::HardBreak => {
state.append_raw(output, "\n");
}
Event::Start(Tag::List(first_item)) => {
let kind = match first_item {
Some(index) => ListKind::Ordered { next_index: index },
None => ListKind::Unordered,
};
state.list_stack.push(kind);
}
Event::End(TagEnd::List(..)) => {
state.list_stack.pop();
output.push('\n');
}
Event::Start(Tag::Item) => Self::start_item(state, output),
Event::Start(Tag::CodeBlock(kind)) => {
*in_code_block = true;
*code_language = match kind {
CodeBlockKind::Indented => String::from("text"),
CodeBlockKind::Fenced(lang) => lang.to_string(),
};
code_buffer.clear();
self.start_code_block(code_language, output);
}
Event::End(TagEnd::CodeBlock) => {
self.finish_code_block(code_buffer, code_language, output);
*in_code_block = false;
code_language.clear();
code_buffer.clear();
}
Event::Start(Tag::Emphasis) => state.emphasis += 1,
Event::End(TagEnd::Emphasis) => state.emphasis = state.emphasis.saturating_sub(1),
Event::Start(Tag::Strong) => state.strong += 1,
Event::End(TagEnd::Strong) => state.strong = state.strong.saturating_sub(1),
Event::Code(code) => {
let rendered =
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
state.append_raw(output, &rendered);
}
Event::Rule => output.push_str("---\n"),
Event::Text(text) => {
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
}
Event::Html(html) | Event::InlineHtml(html) => {
state.append_raw(output, &html);
}
Event::FootnoteReference(reference) => {
state.append_raw(output, &format!("[{reference}]"));
}
Event::TaskListMarker(done) => {
state.append_raw(output, if done { "[x] " } else { "[ ] " });
}
Event::InlineMath(math) | Event::DisplayMath(math) => {
state.append_raw(output, &math);
}
Event::Start(Tag::Link { dest_url, .. }) => {
state.link_stack.push(LinkState {
destination: dest_url.to_string(),
text: String::new(),
});
}
Event::End(TagEnd::Link) => {
if let Some(link) = state.link_stack.pop() {
let label = if link.text.is_empty() {
link.destination.clone()
} else {
link.text
};
let rendered = format!(
"{}",
format!("[{label}]({})", link.destination)
.underlined()
.with(self.color_theme.link)
);
state.append_raw(output, &rendered);
}
}
Event::Start(Tag::Image { dest_url, .. }) => {
let rendered = format!(
"{}",
format!("[image:{dest_url}]").with(self.color_theme.link)
);
state.append_raw(output, &rendered);
}
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
Event::End(TagEnd::Table) => {
if let Some(table) = state.table.take() {
output.push_str(&self.render_table(&table));
output.push_str("\n\n");
}
}
Event::Start(Tag::TableHead) => {
if let Some(table) = state.table.as_mut() {
table.in_head = true;
}
}
Event::End(TagEnd::TableHead) => {
if let Some(table) = state.table.as_mut() {
table.finish_row();
table.in_head = false;
}
}
Event::Start(Tag::TableRow) => {
if let Some(table) = state.table.as_mut() {
table.current_row.clear();
table.current_cell.clear();
}
}
Event::End(TagEnd::TableRow) => {
if let Some(table) = state.table.as_mut() {
table.finish_row();
}
}
Event::Start(Tag::TableCell) => {
if let Some(table) = state.table.as_mut() {
table.current_cell.clear();
}
}
Event::End(TagEnd::TableCell) => {
if let Some(table) = state.table.as_mut() {
table.push_cell();
}
}
Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _)
| Event::End(TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {}
}
}
#[allow(clippy::unused_self)]
fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) {
state.heading_level = Some(level);
if !output.is_empty() {
output.push('\n');
}
}
fn start_quote(&self, state: &mut RenderState, output: &mut String) {
state.quote += 1;
let _ = write!(output, "{}", "".with(self.color_theme.quote));
}
fn start_item(state: &mut RenderState, output: &mut String) {
let depth = state.list_stack.len().saturating_sub(1);
output.push_str(&" ".repeat(depth));
let marker = match state.list_stack.last_mut() {
Some(ListKind::Ordered { next_index }) => {
let value = *next_index;
*next_index += 1;
format!("{value}. ")
}
_ => "".to_string(),
};
output.push_str(&marker);
}
fn start_code_block(&self, code_language: &str, output: &mut String) {
let label = if code_language.is_empty() {
"code".to_string()
} else {
code_language.to_string()
};
let _ = writeln!(
output,
"{}",
format!("╭─ {label}")
.bold()
.with(self.color_theme.code_block_border)
);
}
fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) {
output.push_str(&self.highlight_code(code_buffer, code_language));
let _ = write!(
output,
"{}",
"╰─".bold().with(self.color_theme.code_block_border)
);
output.push_str("\n\n");
}
fn push_text(
&self,
text: &str,
state: &mut RenderState,
output: &mut String,
code_buffer: &mut String,
in_code_block: bool,
) {
if in_code_block {
code_buffer.push_str(text);
} else {
state.append_styled(output, text, &self.color_theme);
}
}
fn render_table(&self, table: &TableState) -> String {
let mut rows = Vec::new();
if !table.headers.is_empty() {
rows.push(table.headers.clone());
}
rows.extend(table.rows.iter().cloned());
if rows.is_empty() {
return String::new();
}
let column_count = rows.iter().map(Vec::len).max().unwrap_or(0);
let widths = (0..column_count)
.map(|column| {
rows.iter()
.filter_map(|row| row.get(column))
.map(|cell| visible_width(cell))
.max()
.unwrap_or(0)
})
.collect::<Vec<_>>();
let border = format!("{}", "".with(self.color_theme.table_border));
let separator = widths
.iter()
.map(|width| "".repeat(*width + 2))
.collect::<Vec<_>>()
.join(&format!("{}", "".with(self.color_theme.table_border)));
let separator = format!("{border}{separator}{border}");
let mut output = String::new();
if !table.headers.is_empty() {
output.push_str(&self.render_table_row(&table.headers, &widths, true));
output.push('\n');
output.push_str(&separator);
if !table.rows.is_empty() {
output.push('\n');
}
}
for (index, row) in table.rows.iter().enumerate() {
output.push_str(&self.render_table_row(row, &widths, false));
if index + 1 < table.rows.len() {
output.push('\n');
}
}
output
}
fn render_table_row(&self, row: &[String], widths: &[usize], is_header: bool) -> String {
let border = format!("{}", "".with(self.color_theme.table_border));
let mut line = String::new();
line.push_str(&border);
for (index, width) in widths.iter().enumerate() {
let cell = row.get(index).map_or("", String::as_str);
line.push(' ');
if is_header {
let _ = write!(line, "{}", cell.bold().with(self.color_theme.heading));
} else {
line.push_str(cell);
}
let padding = width.saturating_sub(visible_width(cell));
line.push_str(&" ".repeat(padding + 1));
line.push_str(&border);
}
line
}
#[must_use]
pub fn highlight_code(&self, code: &str, language: &str) -> String {
let syntax = self
.syntax_set
.find_syntax_by_token(language)
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
let mut syntax_highlighter = HighlightLines::new(syntax, &self.syntax_theme);
let mut colored_output = String::new();
for line in LinesWithEndings::from(code) {
match syntax_highlighter.highlight_line(line, &self.syntax_set) {
Ok(ranges) => {
let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
colored_output.push_str(&apply_code_block_background(&escaped));
}
Err(_) => colored_output.push_str(&apply_code_block_background(line)),
}
}
colored_output
}
pub fn stream_markdown(&self, markdown: &str, out: &mut impl Write) -> io::Result<()> {
let rendered_markdown = self.markdown_to_ansi(markdown);
write!(out, "{rendered_markdown}")?;
if !rendered_markdown.ends_with('\n') {
writeln!(out)?;
}
out.flush()
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct MarkdownStreamState {
pending: String,
}
impl MarkdownStreamState {
#[must_use]
pub fn push(&mut self, renderer: &TerminalRenderer, delta: &str) -> Option<String> {
self.pending.push_str(delta);
let split = find_stream_safe_boundary(&self.pending)?;
let ready = self.pending[..split].to_string();
self.pending.drain(..split);
Some(renderer.markdown_to_ansi(&ready))
}
#[must_use]
pub fn flush(&mut self, renderer: &TerminalRenderer) -> Option<String> {
if self.pending.trim().is_empty() {
self.pending.clear();
None
} else {
let pending = std::mem::take(&mut self.pending);
Some(renderer.markdown_to_ansi(&pending))
}
}
}
fn apply_code_block_background(line: &str) -> String {
let trimmed = line.trim_end_matches('\n');
let trailing_newline = if trimmed.len() == line.len() {
""
} else {
"\n"
};
let with_background = trimmed.replace("\u{1b}[0m", "\u{1b}[0;48;5;236m");
format!("\u{1b}[48;5;236m{with_background}\u{1b}[0m{trailing_newline}")
}
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
let mut in_fence = false;
let mut last_boundary = None;
for (offset, line) in markdown.split_inclusive('\n').scan(0usize, |cursor, line| {
let start = *cursor;
*cursor += line.len();
Some((start, line))
}) {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
if !in_fence {
last_boundary = Some(offset + line.len());
}
continue;
}
if in_fence {
continue;
}
if trimmed.is_empty() {
last_boundary = Some(offset + line.len());
}
}
last_boundary
}
fn visible_width(input: &str) -> usize {
strip_ansi(input).chars().count()
}
fn strip_ansi(input: &str) -> String {
let mut output = String::new();
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
for next in chars.by_ref() {
if next.is_ascii_alphabetic() {
break;
}
}
}
} else {
output.push(ch);
}
}
output
}
#[cfg(test)]
mod tests {
use super::{strip_ansi, MarkdownStreamState, Spinner, TerminalRenderer};
#[test]
fn renders_markdown_with_styling_and_lists() {
let terminal_renderer = TerminalRenderer::new();
let markdown_output = terminal_renderer
.render_markdown("# Heading\n\nThis is **bold** and *italic*.\n\n- item\n\n`code`");
assert!(markdown_output.contains("Heading"));
assert!(markdown_output.contains("• item"));
assert!(markdown_output.contains("code"));
assert!(markdown_output.contains('\u{1b}'));
}
#[test]
fn renders_links_as_colored_markdown_labels() {
let terminal_renderer = TerminalRenderer::new();
let markdown_output =
terminal_renderer.render_markdown("See [Claw](https://example.com/docs) now.");
let plain_text = strip_ansi(&markdown_output);
assert!(plain_text.contains("[Claw](https://example.com/docs)"));
assert!(markdown_output.contains('\u{1b}'));
}
#[test]
fn highlights_fenced_code_blocks() {
let terminal_renderer = TerminalRenderer::new();
let markdown_output =
terminal_renderer.markdown_to_ansi("```rust\nfn hi() { println!(\"hi\"); }\n```");
let plain_text = strip_ansi(&markdown_output);
assert!(plain_text.contains("╭─ rust"));
assert!(plain_text.contains("fn hi"));
assert!(markdown_output.contains('\u{1b}'));
assert!(markdown_output.contains("[48;5;236m"));
}
#[test]
fn renders_ordered_and_nested_lists() {
let terminal_renderer = TerminalRenderer::new();
let markdown_output =
terminal_renderer.render_markdown("1. first\n2. second\n - nested\n - child");
let plain_text = strip_ansi(&markdown_output);
assert!(plain_text.contains("1. first"));
assert!(plain_text.contains("2. second"));
assert!(plain_text.contains(" • nested"));
assert!(plain_text.contains(" • child"));
}
#[test]
fn renders_tables_with_alignment() {
let terminal_renderer = TerminalRenderer::new();
let markdown_output = terminal_renderer
.render_markdown("| Name | Value |\n| ---- | ----- |\n| alpha | 1 |\n| beta | 22 |");
let plain_text = strip_ansi(&markdown_output);
let lines = plain_text.lines().collect::<Vec<_>>();
assert_eq!(lines[0], "│ Name │ Value │");
assert_eq!(lines[1], "│───────┼───────│");
assert_eq!(lines[2], "│ alpha │ 1 │");
assert_eq!(lines[3], "│ beta │ 22 │");
assert!(markdown_output.contains('\u{1b}'));
}
#[test]
fn streaming_state_waits_for_complete_blocks() {
let renderer = TerminalRenderer::new();
let mut state = MarkdownStreamState::default();
assert_eq!(state.push(&renderer, "# Heading"), None);
let flushed = state
.push(&renderer, "\n\nParagraph\n\n")
.expect("completed block");
let plain_text = strip_ansi(&flushed);
assert!(plain_text.contains("Heading"));
assert!(plain_text.contains("Paragraph"));
assert_eq!(state.push(&renderer, "```rust\nfn main() {}\n"), None);
let code = state
.push(&renderer, "```\n")
.expect("closed code fence flushes");
assert!(strip_ansi(&code).contains("fn main()"));
}
#[test]
fn spinner_advances_frames() {
let terminal_renderer = TerminalRenderer::new();
let mut spinner = Spinner::new();
let mut out = Vec::new();
spinner
.tick("Working", terminal_renderer.color_theme(), &mut out)
.expect("tick succeeds");
spinner
.tick("Working", terminal_renderer.color_theme(), &mut out)
.expect("tick succeeds");
let output = String::from_utf8_lossy(&out);
assert!(output.contains("Working"));
}
}