git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commitf1d004945ddbf8d35959e852f02352f8fc3b1eb8
parent88c974294d
authorLucas Galante <[email protected]>
date2026-08-18 10:07
feat: per-row kill button on the Processes page

Each row gets a red ✕ in a KILL column at content offset 624 — inside the existing 650px content width, so it pans with the other columns and needs no wider table. Clicking it SIGTERMs the pid with the app's own privileges; no confirm dialog, the small target is the confirmation.

The pid is parsed and required > 0 before reaching kill: the value comes from ps but `kill 0` signals the entire process group (this app included) and negative pids kill groups too, so the guard is cheap insurance against ever passing a malformed row through.

Feedback is the killing set: a clicked row dims and drops its ✕ until the next 3s watcher refresh, which clears the set — a dead process is gone from the fresh list, and a survivor (EPERM, ignored TERM) un-dims, which is an honest "didn't die" with no extra error channel.

Ordering subtlety, now pinned by a test: the ✕ overlaps the full-width row hover button, and input_handler's collect loop dispatches the LAST take_click among overlapping page buttons — so the ✕ is emitted after the row button on purpose, beating the row's no-op action exactly because it comes later.

Verified live in a shadow session: a spawned busy-loop hit the top row at 99.8% CPU, its ✕ dimmed the row on click, the next refresh dropped it from the list, and an environ-scoped sweep confirmed no shadow sh survived.

Co-Authored-By: Claude <[email protected]>

 src/pages/processes.rs | 112 ++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 107 insertions(+), 5 deletions(-)

diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index ae137d7..9d0ef33 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -16,6 +16,10 @@ pub struct ProcessesState {
     pub loaded: bool,
     pub processes: Vec<ProcessRow>,
     pub cpu_list: ScrollRegion,
+    /// Pids a kill was requested for, dimmed until the next refresh. The
+    /// refresh clears it: a killed process is gone from the new list, and a
+    /// survivor (EPERM, ignored TERM) un-dims — an honest "didn't die".
+    pub killing: std::collections::HashSet<String>,
 }
 
 impl Default for ProcessesState {
@@ -24,6 +28,7 @@ impl Default for ProcessesState {
             loaded: false,
             processes: Vec::new(),
             cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
+            killing: std::collections::HashSet::new(),
         }
     }
 }
@@ -31,6 +36,8 @@ impl Default for ProcessesState {
 #[derive(Debug, Clone)]
 pub enum ProcessesMessage {
     Refreshed(ProcessesState),
+    /// The row's ✕ button: SIGTERM this pid.
+    Kill(String),
     None,
 }
 
@@ -88,6 +95,7 @@ pub async fn fetch_processes_state() -> ProcessesState {
         loaded: true,
         processes,
         cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
+        killing: std::collections::HashSet::new(),
     }
 }
 
@@ -125,6 +133,7 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
             const COL_RSS: f32 = 420.0;
             const COL_MEM: f32 = 510.0;
             const COL_CPU: f32 = 580.0;
+            const COL_KILL: f32 = 624.0;
             const CONTENT_W: f32 = 650.0;
 
             let header_h = 22.0;
@@ -172,11 +181,37 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
                         AppAction::Processes(ProcessesMessage::None),
                     );
 
-                    sec.pc.text(&p.pid, list_box_x + COL_PID - ox, draw_y + 6.0, 12.0, [0.80, 0.80, 0.85, 1.0]);
-                    sec.pc.text(&p.command, list_box_x + COL_COMMAND - ox, draw_y + 6.0, 12.0, [0.80, 0.80, 0.85, 1.0]);
-                    sec.pc.text(&format_rss(p.rss_kb), list_box_x + COL_RSS - ox, draw_y + 6.0, 12.0, [0.62, 0.72, 0.88, 1.0]);
-                    sec.pc.text(&format!("{}%", p.mem_pct), list_box_x + COL_MEM - ox, draw_y + 6.0, 12.0, [0.62, 0.72, 0.88, 1.0]);
-                    sec.pc.text(&format!("{}%", p.cpu), list_box_x + COL_CPU - ox, draw_y + 6.0, 12.0, [0.56, 0.83, 0.56, 1.0]);
+                    // A pending kill dims the row until the next refresh
+                    // settles it (gone, or alive again = the kill didn't take).
+                    let dim = state.killing.contains(&p.pid);
+                    let fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.80, 0.80, 0.85, 1.0] };
+                    let mem_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.62, 0.72, 0.88, 1.0] };
+                    let cpu_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.56, 0.83, 0.56, 1.0] };
+
+                    sec.pc.text(&p.pid, list_box_x + COL_PID - ox, draw_y + 6.0, 12.0, fg);
+                    sec.pc.text(&p.command, list_box_x + COL_COMMAND - ox, draw_y + 6.0, 12.0, fg);
+                    sec.pc.text(&format_rss(p.rss_kb), list_box_x + COL_RSS - ox, draw_y + 6.0, 12.0, mem_fg);
+                    sec.pc.text(&format!("{}%", p.mem_pct), list_box_x + COL_MEM - ox, draw_y + 6.0, 12.0, mem_fg);
+                    sec.pc.text(&format!("{}%", p.cpu), list_box_x + COL_CPU - ox, draw_y + 6.0, 12.0, cpu_fg);
+
+                    // Kill button, in content space like the columns. Emitted
+                    // AFTER the row button on purpose: overlapping page
+                    // buttons all see the click and the LAST take_click wins
+                    // the dispatched action (input_handler's collect loop), so
+                    // ✕ beats the row's no-op exactly because it comes later.
+                    if !dim {
+                        sec.pc.button(
+                            "\u{00d7}",
+                            list_box_x + COL_KILL - ox,
+                            draw_y + 3.0,
+                            20.0,
+                            row_h - 6.0,
+                            [0.0, 0.0, 0.0, 0.0],
+                            [0.75, 0.30, 0.30, 0.45],
+                            [0.85, 0.55, 0.55, 1.0],
+                            AppAction::Processes(ProcessesMessage::Kill(p.pid.clone())),
+                        );
+                    }
                 }
             }
             sec.pc.pop_clip_rect();
@@ -199,6 +234,20 @@ pub fn update(state: &mut ProcessesState, msg: ProcessesMessage) {
         ProcessesMessage::Refreshed(new) => {
             state.loaded = new.loaded;
             state.processes = new.processes;
+            // Fresh list = every pending kill has resolved one way or the
+            // other; rows that survived un-dim (the kill didn't take).
+            state.killing.clear();
+        }
+        ProcessesMessage::Kill(pid) => {
+            // Plain SIGTERM, same privileges as the app. No confirm dialog:
+            // the target is a small ✕ the pointer has to mean. Failure needs
+            // no channel — a survivor un-dims on the next 3s refresh.
+            // Parsed, not passed through: `kill 0` signals the whole process
+            // group (this app included), and negative pids kill groups too.
+            if pid.parse::<u32>().is_ok_and(|n| n > 0) {
+                let _ = std::process::Command::new("kill").arg(&pid).spawn();
+                state.killing.insert(pid);
+            }
         }
         ProcessesMessage::None => {}
     }
@@ -292,6 +341,59 @@ mod tests {
         }
     }
 
+    #[test]
+    fn kill_buttons_emitted_after_row_buttons_and_skip_pending() {
+        let mut state = ProcessesState { loaded: true, ..Default::default() };
+        state.processes = (1..=3).map(|i| row(&i.to_string())).collect();
+        state.killing.insert("2".to_string());
+        let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
+        let sec_focused = vec![false];
+        let mut ctx = cce_ui::context::UiContext::new();
+        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
+
+        let kills: Vec<usize> = pc
+            .buttons
+            .iter()
+            .enumerate()
+            .filter(|(_, (_, a, _))| matches!(a, AppAction::Processes(ProcessesMessage::Kill(_))))
+            .map(|(i, _)| i)
+            .collect();
+        // One ✕ per row except the pending one (pid 2).
+        assert_eq!(kills.len(), 2, "{:?}", pc.buttons.iter().map(|(_, a, _)| a).collect::<Vec<_>>());
+        // Ordering invariant the dispatch relies on: each ✕ comes after its
+        // row's hover button — last take_click wins, so ✕ must be later.
+        let rows: Vec<usize> = pc
+            .buttons
+            .iter()
+            .enumerate()
+            .filter(|(_, (_, a, _))| matches!(a, AppAction::Processes(ProcessesMessage::None)))
+            .map(|(i, _)| i)
+            .collect();
+        assert!(kills[0] > rows[0]);
+    }
+
+    #[test]
+    fn kill_guards_and_dim_lifecycle() {
+        let mut state = ProcessesState { loaded: true, ..Default::default() };
+        // Group-signal and garbage pids are refused outright.
+        update(&mut state, ProcessesMessage::Kill("0".to_string()));
+        update(&mut state, ProcessesMessage::Kill("-1".to_string()));
+        update(&mut state, ProcessesMessage::Kill("abc".to_string()));
+        update(&mut state, ProcessesMessage::Kill(String::new()));
+        assert!(state.killing.is_empty());
+
+        // A real (nonexistent, > pid_max) pid marks the row...
+        update(&mut state, ProcessesMessage::Kill("99999999".to_string()));
+        assert!(state.killing.contains("99999999"));
+
+        // ...and the next refresh clears every pending mark.
+        update(
+            &mut state,
+            ProcessesMessage::Refreshed(ProcessesState { loaded: true, ..Default::default() }),
+        );
+        assert!(state.killing.is_empty());
+    }
+
     #[test]
     fn columns_pan_with_horizontal_scroll() {
         let mut state = ProcessesState { loaded: true, ..Default::default() };