use std::{env, io, path::PathBuf}; use vamp::{session::EditorSession, tui}; mod review_cli; // Only the first argument can be this process-to-process terminal handoff marker. const INHERITED_SCREEN: &str = "--vamp-inherit-alternate-screen"; const HELP: &str = "vamp — modal editor with isolated Ruby plugins Usage: vamp [--config PATH | --no-config] [FILE] vamp [--config PATH | --no-config] --review list [--repo PATH] [--json] vamp [--config PATH | --no-config] --review show ID [--repo PATH] [--json] [--all] Review CLI: read-only local storage; show defaults to unresolved comments; --all includes resolved. Use vamp -- --review to edit a file named --review. Default keys (configurable with config.bind): All modes: Ctrl+p command palette; type to filter, Ctrl+j/k or arrows select, Enter accept, Esc cancel Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix Normal leader: Space w saves; Space b n/p/l/d next/previous/pick/close buffer Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by default) tree NORMAL: hjkl navigate; INSERT: type to filter, Ctrl+hjkl navigate; Enter toggles/opens i filters; Esc retains filter and returns NORMAL, then editor; Ctrl+u clears in either mode Ctrl+t view, Ctrl+w close, Ctrl+r refresh Normal: i/a insert, I/A insert at first nonblank/end, o/O new line h/j/k/l move, 0/$ line ends, x/Delete cut hovered graphemes, J join lines w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs Normal/Visual: f/F seek next/previous on current line; 2fx seeks second x gf follows Markdown file links/reference links and heading fragments (no LSP) Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g Normal/Visual: g jumps to that one-based source line at first nonblank, clamped at EOF Ctrl+o older jump location; Ctrl+i newer (Tab too outside comparisons; counts supported) _ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count Normal/Visual: Ctrl+u/d scroll up/down without moving carets; configurable distance (default half viewport), counts supported u undo, U redo, : command, / search, * search word, n/N next/previous match yy copies whole lines; dd deletes them; cc changes them and enters Insert; counts: 3yy, 2dd, 2cc dw/db cut forward/backward words on the current line; cw/cb cut the same spans and enter Insert Word counts before/after the operator multiply: d2w, 2cw, 2c3b; newlines are preserved Linewise p/P paste below/above the current row; characterwise p/P paste after/before the grapheme Normal/Visual: . repeats last native edit at current targets (one undo group) v characterwise select, V linewise select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below y/d/x/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it Esc keeps only the primary caret Visual: motions extend selections, y copy, d/x/Delete cut, c change, p/P replace selections : opens command input with the selection preserved; Esc returns to that selection r replaces every selected grapheme, preserving newlines; one undo action I/A insert at each selected line's first nonblank/end Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all v/V switches selection shape; same selector or Esc cancels retaining carets Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+j/k next/previous Candidates replace text live; Original text restores it; other keys close and act normally Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends Outside snippets Enter inserts newline, Tab inserts a tab or configured spaces; Esc returns to Normal Insert/Command/Search: Ctrl+u deletes backward to the logical line start Command/Search: Up/Ctrl+k older history; Down/Ctrl+j newer history or unfinished input; Enter submits Search: Enter accepts current/next literal match, Esc cancels (case-sensitive, wraps) Commands: :save [PATH], :force_save [PATH], :quit, :force_quit, :save_and_quit [PATH], :force_save_and_quit [PATH], :plugins, :PLUGIN [ARGS] Aliases: :w=save, :w!=force_save, :q=quit, :q!=force_quit, :wq=save_and_quit, :wq!=force_save_and_quit Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_and_quit Normal saves recheck LF-normalized disk text; external changes/deletion require force Force never bypasses snapshot read-only, symlink, or another-buffer path protection Clean live buffers reload on 250ms metadata checks, including inactive tabs/panes Reload preserves/clamps views and clears stale undo/redo; snapshots never reload Dirty buffers keep edits; missing/unreadable/invalid changed files keep text and warn Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N] :buffers / Space b l searches IDs/paths; Enter switches, Esc/Ctrl+p cancels, Ctrl+u clears Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged :quit refuses any dirty buffer; both save-and-quit commands require all buffers clean Restart: :restart closes clean buffers; unsaved buffers remain open and block restart with a warning :force_restart / :restart! discards unsaved text without writing Actions: core.restart, core.force_restart (no default binding) Relaunches the executable with original CLI args/config/startup file; tabs/views reset Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits Markdown links: :follow_link (primary caret; local files only) Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus All text panes use native motions, selections and copy; read-only panes reject edits/saves Switching Before/After preserves the aligned cursor row and display column Selector queries own printable keys, including :; text navigation skips omitted lines Normal [/] explicitly expands context; Insert brackets remain literal text Normal Space v cycles split/unified; Space s flips left/right vs above/below split Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"vertical\" Working After follows descendant checkouts; config.diff_follow_working_copy = false disables it Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly Remote refs/history load into temporary comparison repos without changing active refs/checkout Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the current line Selected comments highlight their precise text :diff.review.edit/delete [ID], :diff.review.next/previous, :diff.review.overview :diff.review.summary; :diff.review.verdict looks_good|changes_requested|none :w saves comment/summary text, including hidden drafts; verdicts/deletions commit immediately :diff.review.save PATH exports JSON; :diff.review.open [ID|PATH] selects/imports; :diff.review.new Normal Esc or :pane.close discards unsaved text and dismisses the floating editor Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base config.resolve_history_layout = \"columns\" (left/base/right) or \"base_below\" (left/right above base) :resolve.next/previous navigates differing hunks; :resolve.use_source [N] takes a hunk :resolve.source N selects Alternative; :resolve.base N selects History base :resolve.draft returns to Draft; :resolve.view.toggle switches Resolution/History :w saves and checks jj; :resolve.files toggles the explorer; :resolve.close closes cleanly Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb Set VAMP_RUBY to choose the Ruby executable. Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset Presets: rust -> rust-analyzer, python -> ty server + ruff server, ruby -> solargraph stdio Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] } Server stacks: config.lsp[\"python\"] = { servers: [{ command: [...] }, { command: [...] }] } LSP commands: :lsp.definition, :lsp.diagnostics, :lsp.hover Semantic highlighting: automatic when the first enabled server supports semantic tokens Hover binding: config.bind \"K\", config.core.lsp.hover (no default key)"; struct Options { file: Option, config: Option, review: Option, } fn options(inherited_screen: bool) -> Result, String> { parse_options(env::args_os().skip(1 + usize::from(inherited_screen))) } fn parse_options( args: impl IntoIterator, ) -> Result, String> { let mut args = args.into_iter(); let mut file = None; let mut config = None; let mut config_selected = false; let mut positional = false; let mut review = None; while let Some(arg) = args.next() { if !positional && (arg == "--help" || arg == "-h") { println!("{HELP}"); return Ok(None); } else if !positional && arg == "--" { if review.is_some() { return Err("unexpected review argument: --".into()); } positional = true; } else if !positional && arg == "--review" && file.is_none() && review.is_none() { review = Some(review_cli::Request::parse(&mut args)?); } else if !positional && arg == "--config" { if config_selected { return Err("use only one of --config and --no-config".into()); } config = Some(PathBuf::from( args.next().ok_or("--config requires a path")?, )); config_selected = true; } else if !positional && arg == "--no-config" { if config_selected { return Err("use only one of --config and --no-config".into()); } config_selected = true; } else if !positional && review.is_some() { review.as_mut().unwrap().option(&arg, &mut args)?; } else if !positional && arg.to_string_lossy().starts_with('-') { return Err(format!("unknown option: {}", arg.to_string_lossy())); } else if file.replace(PathBuf::from(arg)).is_some() { return Err("only one file can be opened".into()); } } if !config_selected { let home = env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))); config = home .map(|home| home.join("vamp/config.rb")) .filter(|path| path.exists()); } Ok(Some(Options { file, config, review, })) } fn run() -> Result<(), String> { let inherited_screen = env::args_os() .nth(1) .is_some_and(|arg| arg == INHERITED_SCREEN); let options = options(inherited_screen)?; if let Some(options) = &options { if let Some(request) = &options.review { return review_cli::run(request, options.config.as_deref()); } } if options.is_none() { return Ok(()); } tui::TerminalSession::run(inherited_screen, || run_editor(options, inherited_screen)) .map_err(|error| error.to_string()) } fn run_editor(options: Option, inherited_screen: bool) -> io::Result<()> { let Some(options) = options else { return Ok(()); }; let mut session = EditorSession::open( options.file, tui::CAPABILITIES.iter().map(|s| (*s).to_owned()).collect(), options.config.as_deref(), ) .map_err(|error| io::Error::other(format!("cannot open file: {error}")))?; tui::run(&mut session, inherited_screen)?; let restart = session.editor().should_restart(); drop(session); if restart { tui::TerminalSession::prepare_restart()?; restart_editor(inherited_screen) .map_err(|error| io::Error::other(format!("Restart failed: {error}")))?; } Ok(()) } fn restart_editor(inherited_screen: bool) -> io::Result<()> { let executable = env::current_exe()?; let mut command = std::process::Command::new(executable); command.arg(INHERITED_SCREEN); command.args(env::args_os().skip(1 + usize::from(inherited_screen))); #[cfg(unix)] { use std::os::unix::process::CommandExt; Err(command.exec()) } #[cfg(not(unix))] { let status = command.status()?; if status.success() { Ok(()) } else { Err(io::Error::other(format!( "Restarted editor exited with {status}" ))) } } } fn main() { if let Err(error) = run() { eprintln!("vamp: {error}"); std::process::exit(1); } } #[cfg(test)] #[path = "../tests/unit/cli_args.rs"] mod tests;