fix: place command status left of battery as a separate segment

This commit is contained in:
2026-10-18 01:35:52 +02:00
parent e310b76794
commit d8cfd27ee6
10 changed files with 165 additions and 58 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
- [x] command autocompletion via `tab` (multiple presses cycles candidates)
- [x] fix ruby linting for the vamp config file (see attached image)
- [x] execute terminal commands asynchronously using the `!` prefix (e.g. `!pwd`) and display the output in a floating window.
- while waiting on the result of the executed command, show a spinner in a new element right of the battery status in the top bar (top-right corner)
- while waiting on executed commands, show their delayed labels/spinners in one independently styled element just left of the battery status in the top bar (top-right corner)
- indicate also the command that we're waiting on, maybe something like the first three characters (followed by ellipses if truncated), e.g. `pwd <spinner>` or if the command was `echo $PATH`, then `ech... <spinner>`.
- [ ] diff view: GitHub code review integration after the local review UX is usable.
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
+1 -1
View File
@@ -11,7 +11,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
- `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks.
- `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured.
- `shell`: runs explicit `:!` commands off the input thread with inherited environment, closed stdin, and bounded stdout/stderr capture. A private Unix process group is killed and reaped on worker drop or shell completion. Session owns one running worker and its spinner; Editor owns the read-only output popup and modal scrolling/dismissal state. No repository-command environment overrides or timeouts apply.
- `shell`: runs explicit `:!` commands off the input thread with inherited environment, closed stdin, and bounded stdout/stderr capture. Each command has a private Unix process group, killed and reaped on worker drop or shell completion. Session owns concurrent workers and combines their delayed spinners in one shared status segment; Editor owns the read-only output popup, queued completed results, and modal scrolling/dismissal state. No repository-command environment overrides or timeouts apply.
- `blame`: owns local current-line Git attribution against the supplied in-memory text and a cancellable background worker. Git-backed jj uses its working-copy parent's commit through an isolated temporary Git store; active refs/checkouts remain untouched.
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog.
+5 -5
View File
@@ -119,7 +119,7 @@ config.bind "leader s", config.core.diff.orientation.toggle
Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported.
Defaults put `left: [:tabs]` and `right: [:battery, :shell]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count]` and `right: [:language, :lsp, :position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus live battery charge below:
Defaults put `left: [:tabs]` and `right: [:shell, :battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count]` and `right: [:language, :lsp, :position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus live battery charge below:
```ruby
Vamp.configure do |config|
@@ -130,7 +130,7 @@ Vamp.configure do |config|
position: { padding: 1, leading_margin: 1, trailing_margin: 0, leading_edge: "░▓", trailing_edge: "" }
}
config.bar.bottom = {
left: [:tabs], center: [], right: [:battery, :shell],
left: [:tabs], center: [], right: [:shell, :battery],
tabs: {
title: "{index} {view}{name}{modified}", padding: 1,
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
@@ -150,9 +150,9 @@ Each segment's `title` is a literal template supporting only `{index}`, `{view}`
`:lsp` displays only ready servers assigned to the focused buffer, in configured stack order joined with ` + `. Disabled, initializing, failed, or unassigned servers do not appear. Names come from the initialization reply's `serverInfo.name`, falling back to the configured executable basename; no arbitrary named label can make an inactive server appear active.
`margin` adds unstyled space outside both edges of a segment, unlike the styled internal `padding`. `leading_margin` and `trailing_margin` override their respective sides. All accept integers from `0` through `64`. Margins default to zero except the bottom bar's `:position`, which has one leading column and no trailing margin. LSP has no default margins. For example, `config.bar.bottom = { position: { leading_margin: 0 } }` removes position's external spacing without changing its padding. Margins count toward anchor placement and collision handling; empty segments consume no margin, and margins yield along with padding and edges when only clipped content fits.
`margin` adds unstyled space outside both edges of a segment, unlike the styled internal `padding`. `leading_margin` and `trailing_margin` override their respective sides. All accept integers from `0` through `64`. Margins default to zero except the bottom bar's `:position`, which has one leading column, and `:shell`, which has one trailing column separating it from the battery. LSP has no default margins. For example, `config.bar.bottom = { position: { leading_margin: 0 } }` removes position's external spacing without changing its padding. Margins count toward anchor placement and collision handling; empty segments consume no margin, and margins yield along with padding and edges when only clipped content fits.
`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64` (default `1`). `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs default to `"░▓"` / `"▓░"` edges; mode has only the trailing `"▓░"` edge, position and battery only the leading `"░▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration.
`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64` (default `1`). `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs and shell status default to `"░▓"` / `"▓░"` edges; mode has only the trailing `"▓░"` edge, position and battery only the leading `"░▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration.
Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, margins, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar.
@@ -160,7 +160,7 @@ Any other segment name is a custom named value, such as `:build_status`. Set ini
Move `:battery` to any anchor group or remove it from both bars to stop battery collection. A nonempty `config.labels[:battery]` or plugin value explicitly overrides the native text and pauses collection; clearing that value restores native readings. Other custom live values use [`editor.label(name, text)` or asynchronous plugin updates](plugins.md#named-label-updates). Configuration runs once at startup; there are no render callbacks.
`:shell` shows the pending `:!COMMAND` abbreviation and animated spinner only after the command has run for 200 ms, avoiding flashes for short commands. Completion output appears immediately, without waiting for that delay. The segment hides when idle. It defaults to the right of `:battery`; move it to any anchor group or omit it without affecting command execution. A nonempty label named `shell` overrides the builtin display; clearing it restores the running indicator.
`:shell` is one shared segment containing the abbreviations and animated spinners for all running `:!COMMAND` processes. Each entry appears only after its command has run for 200 ms, avoiding flashes for short commands. Completed output is shown immediately or queued behind the current result window. The segment hides when idle. It defaults immediately left of `:battery`, with its own styled edges and a separating margin; move it to any anchor group or omit it without affecting command execution. A nonempty label named `shell` overrides the builtin display; clearing it restores the running indicators.
### Command and search popup anchors
+2 -2
View File
@@ -198,9 +198,9 @@ Tab replaces the command-name prefix with the first case-sensitive match in alph
Command input works in both Normal and Visual modes, including read-only diff panes. The original selection stays highlighted while typing. Enter dispatches with its ranges, direction, and linewise shape intact; individual commands decide whether to consume or retain them. Escape returns to the original Visual selection without executing. For example, select lines with `V`, then type `:diff.comment` and press Enter—no command palette is needed. `config.core.command` can also be bound in Visual mode.
Use `:!COMMAND` (for example `:!pwd` or `:!echo "$PATH"`) to run a shell command asynchronously while continuing to edit. Vamp runs `$SHELL -c COMMAND`, falling back to `/bin/sh`, in the editor's project directory with the inherited environment and closed stdin. Shell quoting, pipes, and redirection work normally; interactive terminal programs are not supported. Commands have the same permissions as Vamp, including file and network access. One command can run at a time; another `:!` is rejected until it finishes.
Use `:!COMMAND` (for example `:!pwd` or `:!echo "$PATH"`) to run shell commands asynchronously while continuing to edit. Vamp runs `$SHELL -c COMMAND`, falling back to `/bin/sh`, in the editor's project directory with the inherited environment and closed stdin. Shell quoting, pipes, and redirection work normally; interactive terminal programs are not supported. Commands have the same permissions as Vamp, including file and network access. Submit additional `:!` commands while others are running to execute them concurrently.
While running, the default top bar shows the first three command graphemes, `...` when truncated, and an animated spinner to the right of the battery. Completion opens a read-only floating window with the command, exit status (or launch error), stdout, and separately marked stderr. Arrows/Ctrl+h/j/k/l and the mouse wheel scroll; Home resets, End goes to the last line, and Enter/Escape dismisses it and resumes the underlying editing mode. Output is decoded as UTF-8 with replacements; controls are displayed as text, never interpreted as terminal escapes. Capture retains up to 4 MiB per stream with an explicit truncation notice. There is no command timeout; exiting Vamp kills the running process group, and background jobs in that group are also stopped when the command exits. Shell execution currently requires Unix.
While running, the default top bar uses one independently styled segment immediately left of the battery for all pending commands. Each entry shows the first three command graphemes, `...` when truncated, and an animated spinner after a 200 ms delay. Completion opens a read-only floating window with the command, exit status (or launch error), stdout, and separately marked stderr. Further completed results queue without replacing the visible window; Enter/Escape dismisses the current result and opens the next, or resumes the underlying editing mode when none remain. Arrows/Ctrl+h/j/k/l and the mouse wheel scroll; Home resets and End goes to the last line. Output is decoded as UTF-8 with replacements; controls are displayed as text, never interpreted as terminal escapes. Capture retains up to 4 MiB per stream with an explicit truncation notice. There is no command timeout; exiting Vamp kills all running command process groups, and background jobs in each group are also stopped when their command exits. Shell execution currently requires Unix.
In the shell output window, your configured `config.core.word_wrap.toggle` binding toggles soft wrapping, including leader and multi-key sequences. Bindings use Normal mode while the popup is focused; there is no additional default shortcut. This does not change output bytes or the source buffer's wrap setting. Wrapping starts off for each command; toggling resets scrolling to the start. With wrapping on, vertical scrolling follows visual rows and horizontal scrolling is disabled.
+1 -1
View File
@@ -29,7 +29,7 @@ Vamp.configure do |config|
position: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
}
config.bar.bottom = {
left: [:tabs], center: [], right: [:battery, :shell],
left: [:tabs], center: [], right: [:shell, :battery],
tabs: {
title: "{index} {view}{name}{modified}", padding: 1,
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
+6 -3
View File
@@ -130,7 +130,7 @@ impl ChromeLine {
Self {
left: vec!["tabs".into()],
center: Vec::new(),
right: vec!["battery".into(), "shell".into()],
right: vec!["shell".into(), "battery".into()],
segments: BTreeMap::from([
("tabs".into(), default_segment("tabs")),
("battery".into(), default_segment("battery")),
@@ -213,12 +213,15 @@ impl ChromeLine {
fn default_segment(name: &str) -> SegmentConfig {
let mut segment = SegmentConfig::default();
if matches!(name, "tabs" | "position" | "battery") {
if matches!(name, "tabs" | "position" | "battery" | "shell") {
segment.leading_edge = "░▓".into();
}
if matches!(name, "tabs" | "mode") {
if matches!(name, "tabs" | "mode" | "shell") {
segment.trailing_edge = "▓░".into();
}
if name == "shell" {
segment.margin[1] = 1;
}
if name == "tabs" {
segment.title = "{index} {view}{name}{modified}".into();
segment.first_leading_edge = Some(String::new());
+12 -3
View File
@@ -2233,6 +2233,7 @@ pub struct Editor {
battery_label: String,
shell_label: String,
command_output: Option<ShellOutput>,
queued_command_outputs: VecDeque<ShellOutput>,
git_blame: bool,
gutter_style: crate::gutter::GutterStyle,
line_blame: Option<(BlameSource, Arc<crate::blame::LineBlame>, String)>,
@@ -2351,6 +2352,7 @@ impl Editor {
battery_label: String::new(),
shell_label: String::new(),
command_output: None,
queued_command_outputs: VecDeque::new(),
git_blame: true,
gutter_style: crate::gutter::GutterStyle::default(),
line_blame: None,
@@ -6996,7 +6998,7 @@ impl Editor {
.chars()
.map(|ch| if ch.is_control() { '�' } else { ch })
.collect();
self.command_output = Some(ShellOutput {
let output = ShellOutput {
title: format!("{status} · !{command}"),
wrapping: false,
wrapped_rows: Cell::new(1),
@@ -7007,7 +7009,12 @@ impl Editor {
left: 0,
max_column,
},
});
};
if self.command_output.is_some() {
self.queued_command_outputs.push_back(output);
} else {
self.command_output = Some(output);
}
}
fn handle_command_output(&mut self, input: &Input) -> bool {
@@ -7041,7 +7048,9 @@ impl Editor {
};
let content = &mut output.content;
match input {
Input::Escape | Input::Enter => self.command_output = None,
Input::Escape | Input::Enter => {
self.command_output = self.queued_command_outputs.pop_front()
}
Input::Up | Input::Ctrl('k') => content.top = content.top.saturating_sub(1),
Input::Down | Input::Ctrl('j') => {
content.top = content.top.saturating_add(1).min(rows.saturating_sub(1));
+55 -34
View File
@@ -439,6 +439,13 @@ fn text_fits(lines: &[String], limit: usize) -> bool {
.is_some()
}
struct ShellCommand {
command: String,
worker: crate::shell::Worker,
started: Instant,
label: String,
}
/// Owns command eligibility and execution hosts together. Frontends can inspect
/// the editor, but cannot independently mutate its plugin registry.
pub struct EditorSession {
@@ -468,7 +475,7 @@ pub struct EditorSession {
picker_source: Option<PathBuf>,
battery: Option<crate::battery::Battery>,
battery_enabled: bool,
shell: Option<(String, crate::shell::Worker, Instant)>,
shell: Vec<ShellCommand>,
last_file_refresh: Instant,
last_diff_refresh: Instant,
pub(crate) diff_follow_working_copy: bool,
@@ -596,7 +603,7 @@ impl EditorSession {
picker_source: None,
battery: None,
battery_enabled: false,
shell: None,
shell: Vec::new(),
last_file_refresh: Instant::now(),
last_diff_refresh: Instant::now(),
diff_follow_working_copy: config.diff_follow_working_copy,
@@ -830,18 +837,18 @@ impl EditorSession {
fn dispatch(&mut self, invocation: Invocation) {
if invocation.plugin == "core" {
if invocation.command == "shell" {
if self.shell.is_some() {
self.editor
.set_message("A shell command is already running");
return;
}
let command = invocation.args;
match crate::shell::Worker::start(
command.clone(),
self.editor.project_root().to_path_buf(),
) {
Ok(worker) => {
self.shell = Some((command, worker, Instant::now()));
self.shell.push(ShellCommand {
command,
worker,
started: Instant::now(),
label: String::new(),
});
self.poll_shell_at(Instant::now());
}
Err(error) => self.editor.show_command_output(command, Err(error)),
@@ -1772,34 +1779,48 @@ impl EditorSession {
}
fn poll_shell_at(&mut self, now: Instant) -> bool {
let Some((command, worker, started)) = &mut self.shell else {
return false;
};
if let Some(result) = worker.poll() {
let command = command.clone();
self.shell = None;
self.editor.set_shell_label(String::new());
self.editor.show_command_output(command, result);
return true;
}
let elapsed = now.saturating_duration_since(*started);
if elapsed < Duration::from_millis(200) {
return false;
}
const FRAMES: [char; 4] = ['|', '/', '-', '\\'];
let frame = FRAMES[((elapsed.as_millis() / 100) % 4) as usize];
if self.editor.shell_label().ends_with(frame) {
return false;
}
use unicode_segmentation::UnicodeSegmentation;
let mut graphemes = command.trim().graphemes(true);
let mut label: String = graphemes.by_ref().take(3).collect();
if graphemes.next().is_some() {
label.push_str("...");
const FRAMES: [char; 4] = ['|', '/', '-', '\\'];
let mut changed = false;
self.shell.retain_mut(|running| {
if let Some(result) = running.worker.poll() {
self.editor
.show_command_output(std::mem::take(&mut running.command), result);
changed = true;
return false;
}
label.push(' ');
label.push(frame);
self.editor.set_shell_label(label)
let elapsed = now.saturating_duration_since(running.started);
if elapsed >= Duration::from_millis(200) {
let frame = FRAMES[((elapsed.as_millis() / 100) % 4) as usize];
if !running.label.ends_with(frame) {
running.label.clear();
let mut graphemes = running.command.trim().graphemes(true);
running.label.extend(graphemes.by_ref().take(3));
if graphemes.next().is_some() {
running.label.push_str("...");
}
running.label.push(' ');
running.label.push(frame);
changed = true;
}
}
true
});
if changed {
let mut label = String::new();
for running in self
.shell
.iter()
.filter(|running| !running.label.is_empty())
{
if !label.is_empty() {
label.push_str(" ");
}
label.push_str(&running.label);
}
self.editor.set_shell_label(label);
}
changed
}
fn refresh_files_at(&mut self, now: Instant) -> bool {
+61 -8
View File
@@ -4,6 +4,65 @@ use std::{
sync::atomic::{AtomicU64, Ordering},
};
#[cfg(unix)]
#[test]
fn concurrent_shell_commands_share_status_and_queue_every_completed_output() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "source");
let mut session = fixture.open("");
session.editor.set_project_root(fixture.0.clone());
session.execute("!printf FIRST; while [ ! -f first ]; do sleep 0.01; done");
session.execute("!echo SECOND; while [ ! -f second ]; do sleep 0.01; done; exit 9");
assert_eq!(session.shell.len(), 2);
let fixed = Instant::now();
for running in &mut session.shell {
running.started = fixed;
}
session.poll_shell_at(fixed + Duration::from_millis(199));
assert_eq!(session.editor.shell_label(), "");
session.poll_shell_at(fixed + Duration::from_millis(200));
assert_eq!(session.editor.shell_label(), "pri... - ech... -");
fixture.file("second", "");
drain_until(&mut session, |session| session.shell.len() == 1);
assert_eq!(
session.editor.command_output().unwrap().content.lines[0],
"SECOND"
);
assert!(
session
.editor
.command_output()
.unwrap()
.title
.contains("exit 9")
);
assert!(session.editor.shell_label().starts_with("pri... "));
assert!(!session.editor.shell_label().contains("ech..."));
fixture.file("first", "");
drain_until(&mut session, |session| session.shell.is_empty());
assert_eq!(session.editor.shell_label(), "");
assert_eq!(
session.editor.command_output().unwrap().content.lines[0],
"SECOND"
);
session.handle(Input::Enter);
assert_eq!(
session.editor.command_output().unwrap().content.lines,
["FIRST"]
);
assert!(
session
.editor
.command_output()
.unwrap()
.title
.contains("exit 0")
);
session.handle(Input::Escape);
assert!(session.editor.command_output().is_none());
assert_eq!(session.editor.lines(), ["source"]);
}
#[cfg(unix)]
#[test]
fn shell_commands_leave_editing_live_and_present_both_streams_and_exit_status() {
@@ -19,17 +78,11 @@ fn shell_commands_leave_editing_live_and_present_both_streams_and_exit_status()
}
session.handle(Input::Enter);
let fixed = Instant::now();
session.shell.as_mut().unwrap().2 = fixed;
session.shell[0].started = fixed;
assert!(!session.poll_shell_at(fixed + Duration::from_millis(199)));
assert_eq!(session.editor.shell_label(), "");
assert!(session.poll_shell_at(fixed + Duration::from_millis(200)));
assert_eq!(session.editor.shell_label(), "whi... -");
session.execute("!printf should-not-run > unexpected");
assert_eq!(
session.editor.message(),
"A shell command is already running"
);
assert!(!fixture.0.join("unexpected").exists());
session.handle(Input::Char('i'));
session.handle(Input::Char('X'));
assert_eq!(session.editor.lines(), ["Xsource"]);
@@ -63,7 +116,7 @@ fn shell_commands_reject_empty_input_and_show_empty_success_without_editing() {
let mut session = fixture.open("");
session.execute(":!");
assert_eq!(session.editor.message(), "Shell command is empty");
assert!(session.shell.is_none());
assert!(session.shell.is_empty());
session.execute(":!true");
drain_until(&mut session, |session| {
session.editor.command_output().is_some()
+21
View File
@@ -15528,3 +15528,24 @@ fn shell_output_wrap_bindings_respect_remaps_disabled_keys_and_sequence_cancella
editor.handle(Input::Escape);
assert!(editor.command_output().is_none());
}
#[test]
fn shared_shell_status_is_a_separate_segment_left_of_the_battery() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_battery_charge(Some(27));
editor.set_shell_label("sle... | pwd /".into());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let top = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
assert!(top.contains("░▓ sle... | pwd / ▓░ ░▓ bat 27%"), "{top}");
editor.set_shell_label(String::new());
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let top = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
assert!(!top.contains("sle..."));
assert!(top.trim_end().ends_with("bat 27%"), "{top}");
}