diff --git a/config.example.toml b/config.example.toml index ec2e3a359..6e81f08a0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -262,6 +262,16 @@ tokenEnv = "MEDULLA_TOKEN" # accent = "magenta" # inline overlay borders (prompt / resume picker) # selectionFg = "white" # text drawn on top of the primary selection background # dimBorder = "darkgray" # panel borders +# +# A harness that needs you is drawn in `attention` and pulses: bright for half a +# cycle, dim for the other half, at `attentionBlinkSeconds`. The pulse is driven +# by Medulla's own frame clock rather than the terminal's blink attribute, which +# most terminals and every multiplexer ignore — so it looks the same everywhere. +# A harness that *failed* pulses red instead, whatever `attention` is set to, so +# a question and a breakage are never the same colour. +# attention = "yellow" # ⚠ rows, the tab badge, "N waiting on you" +# attentionBlink = true # false holds those cues steady and bright +# attentionBlinkSeconds = 1.0 # one full pulse; clamped to 0.2–10.0 # --------------------------------------------------------------------------- # appearance — how the Agents sidebar arranges itself, plus the legacy @@ -310,7 +320,12 @@ tokenEnv = "MEDULLA_TOKEN" # Ship the status line # --------------------------------------------------------------------------- # [statusLine] -# state = "line1" # State glyph: the ●/✓/✕ symbol, running, finished, or failed +# state = "line1" # State glyph: what the session is doing, one of +# # ⠋ spinning — a turn is in flight +# # ● — alive, idle at its composer +# # ⚠ — waiting on you (pulses) +# # ✕ — failed or exited non-zero (pulses red) +# # ✓ — exited cleanly, or finished and held # stateWhen = "always" # harness = "line1" # Harness name: which CLI is driving the session # harnessWhen = "always" diff --git a/gitbooks/developers/configuration.md b/gitbooks/developers/configuration.md index c858ed798..fb7bc0906 100644 --- a/gitbooks/developers/configuration.md +++ b/gitbooks/developers/configuration.md @@ -81,7 +81,7 @@ has no config for them. | `budget` | Operator-declared per-provider budgets. Absent leaves every harness advertising an estimate. | | `onboarding` | Welcome-flow completion state. | | `update` | `check = true`/`false` for the background release check. `MEDULLA_NO_UPDATE_CHECK` is the env kill-switch. | -| `theme` | TUI colors: `primary`, `accent`, `selectionFg`, and `dimBorder`, as [ratatui](https://ratatui.rs/) color names or `#rrggbb`. The Settings › Appearance subpage edits and persists these. | +| `theme` | TUI colors: `primary`, `accent`, `selectionFg`, `dimBorder`, and `attention`, as [ratatui](https://ratatui.rs/) color names or `#rrggbb`. `attentionBlink` and `attentionBlinkSeconds` control whether and how quickly attention cues pulse. The Settings › Appearance subpage edits and persists these. | | `statusLine` | How a harness row on the Sessions rail is laid out. Each of `state`, `harness`, `control`, `thread`, `branch`, and `path` takes a `line1`/`line2`/`line3`/`hidden` placement, a `*When` visibility of `always`/`active`/`alert`, and a `*Style` spelling for where it applies. The Settings › Status line subpage edits these with a live preview, and lists each field's description and the full set of values a row can take. The older `appearance.showHarnessBranch`/`showHarnessPath` booleans are read only when this section is absent. | | `appearance` | The Sessions sidebar layout, alongside the resource-indicator keys. `sidebarGrouping` of `host`/`path`/`harness`/`none` picks the sidebar's section headers, and `sidebarSort` of `created`/`recent`/`name` orders the agents in a section and the sessions under an agent. The Settings › Appearance subpage edits these live. | | `medulla.contextWindowTokens` | The Context tab usage hint. The orchestration limits section also carries pass, step, depth, task, and token bounds. | diff --git a/gitbooks/developers/the-tui.md b/gitbooks/developers/the-tui.md index 7304dad31..c58c4f3be 100644 --- a/gitbooks/developers/the-tui.md +++ b/gitbooks/developers/the-tui.md @@ -122,25 +122,74 @@ the prompt), or cancel a task with `Alt`+`X`. The operation absorbs the change and keeps running; it does not restart. `Ctrl`+`]` attaches the live harness pane for the selected lane. +### What a harness row says it is doing + +The glyph at the head of a harness row is the whole state in one character, and +three of the five move: + +| Glyph | State | Animation | +| --- | --- | --- | +| `⠋` | a turn is in flight | spins | +| `●` | alive, idle at its composer | still | +| `⚠` | waiting on you | pulses in the attention colour | +| `✕` | failed, or exited non-zero | pulses red | +| `✓` | exited cleanly, or finished and held for you to read | still | + +The spinner is the answer to a question the rail could not previously answer. +A harness thinking hard writes nothing, so a busy session and an idle one had +the same dot, the same liveness timestamp, and the same `busy` flag; the only +way to find out was to open the pane. Medulla now reads the harness's own +progress line and spins the row for exactly as long as the turn lasts. + +The pulse is Medulla's own, counted off the render clock rather than delegated +to the terminal's blink attribute — most terminals, and every multiplexer, +ignore that attribute, so a cue that depended on it was invisible to most of the +people it was for. Its colour and rate are yours: + +```toml +[theme] +attention = "yellow" +attentionBlink = true +attentionBlinkSeconds = 1.0 # one full bright→dim cycle; clamped to 0.2–10.0 +``` + +Settings › Appearance edits all three live under **Attention cues**. A failure +pulses red regardless, so "it is asking you something" and "it broke" are never +the same colour. + ### When a harness needs you A harness stopped on its own permission prompt looks exactly like one that is thinking hard: still running, still holding its session, saying nothing. Medulla -watches each harness's screen for that state and marks it. The row turns yellow -and blinks with a `⚠`, says what it is waiting for and for how long +watches each harness's screen for that state and marks it — the row turns yellow +and pulses with a `⚠`, says what it is waiting for and for how long ("codex is asking permission · 42s"), and the Sessions tab carries a `⚠2` badge so a stuck pane is visible from whatever tab you are on. -It is recognised from what the harness paints, in order of specificity: - -1. Startup dialogs: trust and permissions that gate the whole session. -2. Named prompts: distinctive phrases that each CLI writes when it is asking - (for example, `claude` shows "No, and tell Claude what to do differently"; - `codex` shows "Allow Codex to…"). -3. Numbered menus: a caret resting on a numbered option, or `(y/n)`. -4. The terminal bell: the universal fallback, in case a prompt is worded +Most of it is recognised from what the harness paints, in order of specificity: + +1. **Startup dialogs** — trust and permissions that gate the whole session. +2. **Named prompts** — distinctive phrases that each CLI writes when it is asking + (e.g. `claude` shows "No, and tell Claude what to do differently"; `codex` + shows "Allow Codex to…"). Claude's plan-mode exit menu is named as such, so + the row says it finished planning rather than merely that it is asking. +3. **Numbered menus** — a caret resting on a numbered option, or `(y/n)`. +4. **Blocking errors** — a usage limit, an expired sign-in, a rejected + credential. These are printed *instead of* a completed turn, so the work did + not happen; the row says which, and the count includes it. +5. **The terminal bell** — the universal fallback, in case a prompt is worded differently or not recognised. +Two states cannot be read off a screen at all, and are taken from the session +itself. A harness that **died** leaves its terminal frozen on whatever it last +painted, which is often an ordinary composer — the row now goes red and says +what happened (`codex exited with 137`), where before it went quiet. And a +dispatched turn that **finished** leaves the session standing for you to read, +which looks identical to a session nobody has used; that row shows `✓ … finished +— read and release`. It is shown but not counted in the `⚠` badge: nothing is +held up while it waits, and a badge that ticks up on every successful task is a +badge you learn to ignore. + Two things clear the mark: attaching to the pane, and the orchestrator injecting a prompt into that session. Both mean somebody is now dealing with it. A named prompt comes straight back on the next sample if the harness is in fact still diff --git a/src/sdk/src/config/types/presentation.rs b/src/sdk/src/config/types/presentation.rs index 0581aafe5..8a2d7f240 100644 --- a/src/sdk/src/config/types/presentation.rs +++ b/src/sdk/src/config/types/presentation.rs @@ -26,6 +26,15 @@ pub struct ThemeConfig { /// Whether operator-attention cues blink. #[serde(skip_serializing_if = "Option::is_none")] pub attention_blink: Option, + /// How long one attention pulse takes, in seconds. + /// + /// A full cycle: the cue is bright for half of it and dim for the other + /// half. Expressed in seconds because that is the unit the effect is judged + /// in — "blink about once a second" is a thing an operator can ask for, + /// "eleven frames" is not. Values outside the sane range are clamped rather + /// than rejected, so a typo slows the pulse instead of disabling the config. + #[serde(skip_serializing_if = "Option::is_none")] + pub attention_blink_seconds: Option, } /// Onboarding state: what the welcome flow has already shown this user. diff --git a/src/tui/src/event_loop/mod.rs b/src/tui/src/event_loop/mod.rs index 134366c5c..3e856f96c 100644 --- a/src/tui/src/event_loop/mod.rs +++ b/src/tui/src/event_loop/mod.rs @@ -78,7 +78,10 @@ pub(crate) async fn run( } let mut sub = runtime.subscribe(); let mut reader = EventStream::new(); - let mut tick = tokio::time::interval(Duration::from_millis(90)); + // The one animation clock. Every frame-driven effect — spinners, the + // attention pulse — counts in this unit, so the period is declared beside + // the theme that converts a configured duration into frames. + let mut tick = tokio::time::interval(Duration::from_millis(medulla_tui::ui::theme::FRAME_MS)); let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::(); let mut mouse_on = true; diff --git a/src/tui/src/ui/app/appearance.rs b/src/tui/src/ui/app/appearance.rs index 030724496..d0a844190 100644 --- a/src/tui/src/ui/app/appearance.rs +++ b/src/tui/src/ui/app/appearance.rs @@ -13,7 +13,7 @@ //! that move: see //! [`StatusLineConfig::from_appearance`](medulla::config::StatusLineConfig::from_appearance). -use crate::ui::theme::{color_to_string, THEME_ROLES}; +use crate::ui::theme::{blink_ms_from_seconds, blink_seconds, color_to_string, THEME_ROLES}; use super::types::App; @@ -27,8 +27,18 @@ pub(super) const SIDEBAR_GROUPING_OPTION: usize = 7; /// The option offset of the sidebar sort row. pub(super) const SIDEBAR_SORT_OPTION: usize = 8; -/// Number of behavior controls shown after the editable theme colors. -pub(super) const ATTENTION_ROWS: usize = 1; +/// Number of behavior controls shown after the editable theme colors: the blink +/// toggle and the rate it blinks at. +pub(super) const ATTENTION_ROWS: usize = 2; + +/// The pulse lengths the Appearance editor offers, in seconds. +/// +/// A short list rather than a free-text field, because the useful range is +/// narrow and every value in it is a judgement about how insistent a stuck +/// harness should be. Anything else remains configurable by hand — the config +/// key takes any number and clamps it — and a hand-set value that is not on this +/// list steps into it from the nearest end rather than being lost. +const BLINK_SECONDS: [f64; 6] = [0.3, 0.5, 1.0, 1.5, 2.0, 3.0]; /// Number of selectable rows: theme colors, attention behavior, and options. pub(super) const APPEARANCE_ROWS: usize = @@ -63,6 +73,8 @@ impl App { "off" }; self.persist_theme_value_now("Attention blink", value.into()); + } else if index == THEME_ROLES.len() + 1 { + self.cycle_blink_rate(forward); } else { let option = index - THEME_ROLES.len() - ATTENTION_ROWS; match option { @@ -77,6 +89,27 @@ impl App { } } + /// Step the attention pulse to the next offered length and persist it. + /// + /// A hand-configured value that is not one of the offered steps enters the + /// list from whichever end the operator moved towards, so a first press + /// changes the rate by a predictable amount instead of jumping to whatever + /// happens to be nearest. + fn cycle_blink_rate(&mut self, forward: bool) { + let current = blink_seconds(self.theme.attention_blink_ms); + let position = BLINK_SECONDS + .iter() + .position(|choice| (choice - current).abs() < f64::EPSILON); + let next = match position { + Some(index) if forward => BLINK_SECONDS[(index + 1) % BLINK_SECONDS.len()], + Some(index) => BLINK_SECONDS[(index + BLINK_SECONDS.len() - 1) % BLINK_SECONDS.len()], + None if forward => BLINK_SECONDS[0], + None => BLINK_SECONDS[BLINK_SECONDS.len() - 1], + }; + self.theme.attention_blink_ms = blink_ms_from_seconds(next); + self.persist_theme_value_now("Attention blink rate", format!("{next:.1}s")); + } + /// Toggle extracted harness titles on orchestrator-managed agent rows. fn toggle_session_titles(&mut self) { let appearance = &mut self.loaded.config.appearance; diff --git a/src/tui/src/ui/app/changes/baseline_tests.rs b/src/tui/src/ui/app/changes/baseline_tests.rs index 3322c89c2..bd85d3c6c 100644 --- a/src/tui/src/ui/app/changes/baseline_tests.rs +++ b/src/tui/src/ui/app/changes/baseline_tests.rs @@ -292,8 +292,10 @@ fn row( control: SessionControl::User, origin: crate::worker::pty::SessionOrigin::User, retained: false, + closed_by_request: false, name: None, attention: None, + working: false, } } diff --git a/src/tui/src/ui/app/rail/cleanup.rs b/src/tui/src/ui/app/rail/cleanup.rs index d0547d11e..e68b16c02 100644 --- a/src/tui/src/ui/app/rail/cleanup.rs +++ b/src/tui/src/ui/app/rail/cleanup.rs @@ -7,8 +7,8 @@ //! dozen entries is still doing something. //! //! So a session whose child is gone leaves the rail, and with it the screen and -//! scrollback it was holding. Two things pin one anyway, because both would be -//! taken away from someone mid-use: +//! scrollback it was holding. Three things pin one anyway, because each needs +//! to remain visible to an operator: //! //! - the session the keyboard is attached to — the operator is reading that //! screen right now, exit code and all, and it is often *why* they attached; @@ -16,6 +16,8 @@ //! parent harness by design ([`medulla::control_socket::runs`]), and its rows //! are drawn under the session that started it, so sweeping the session would //! take the run's own progress off screen with it. +//! - a failed child, whose exit cue explains why the dispatch stopped and lets +//! the operator inspect or dismiss its retained record. //! //! Settled runs are dropped from under a session the same way, by //! [`run_rows_under`](super::run_rows_under). The durable account of every run @@ -29,12 +31,20 @@ use super::super::types::App; impl App { /// Whether a session whose child has exited still belongs on the rail. /// - /// See the module doc for the two cases. Everything else is history the + /// See the module doc for the three cases. Everything else is history the /// rail no longer shows. pub(in crate::ui::app) fn keeps_finished_session(&self, row: &SessionRow) -> bool { if self.harness_focus.is_attached_to(&row.id) { return true; } + // A failed child has lifecycle information the operator needs to see. + // Keeping it also prevents the frame sweep from deleting its cue before + // the rail has rendered even once. + if crate::worker::pty::lifecycle_cue(row, medulla::clock::now_millis()) + .is_some_and(|cue| cue.kind.is_failure()) + { + return true; + } row.mcp_grant_session .as_deref() .is_some_and(|grant| self.harness_runs.any_active_for_session(grant)) diff --git a/src/tui/src/ui/app/rail/cleanup_tests.rs b/src/tui/src/ui/app/rail/cleanup_tests.rs index 32af89481..8ddc0c26f 100644 --- a/src/tui/src/ui/app/rail/cleanup_tests.rs +++ b/src/tui/src/ui/app/rail/cleanup_tests.rs @@ -38,16 +38,40 @@ fn rail_row(local: crate::worker::pty::SessionRow) -> SessionRailRow { } #[test] -fn a_finished_session_nothing_is_waiting_on_leaves_the_rail() { +fn a_failed_session_stays_visible_for_the_operator() { let app = hosting_app(); assert!( - !app.keeps_finished_session(&exited("w_1", 1, None)), - "an exited harness is history: it stops being listed, and the record \ - it was holding is swept" + app.keeps_finished_session(&exited("w_1", 1, None)), + "the lifecycle cue must survive the next frame sweep" ); } +#[test] +fn a_requested_close_is_not_kept_as_a_failure() { + // The operator confirmed the close: the kill's signal-derived exit status is + // the harness obeying, not a lifecycle failure, so the row leaves rather than + // turning red forever under a "Closed the harness" status. + let app = hosting_app(); + let mut row = exited("w_1", 1, None); + row.closed_by_request = true; + + assert!(!app.keeps_finished_session(&row)); +} + +#[test] +fn a_write_error_unblocked_by_the_close_is_not_kept_as_a_failure() { + // Killing a child whose stdin the pty writer is blocked on unblocks that + // writer with an I/O error, which it records. That error is the close's own + // doing, so it must not pin the dismissed row to the rail as a failure. + let app = hosting_app(); + let mut row = exited("w_1", 1, None); + row.closed_by_request = true; + row.last_error = Some("w_1: Input/output error".into()); + + assert!(!app.keeps_finished_session(&row)); +} + #[test] fn the_attached_session_stays_listed_after_it_exits() { // The operator is reading that screen — often *because* it exited. Sweeping @@ -56,10 +80,8 @@ fn the_attached_session_stays_listed_after_it_exits() { app.harness_focus = HarnessFocus::Attached("w_1".to_string()); assert!(app.keeps_finished_session(&exited("w_1", 1, None))); - assert!( - !app.keeps_finished_session(&exited("w_2", 1, None)), - "only the attached one is pinned" - ); + // A clean exit is kept only while it is the screen being read. + assert!(!app.keeps_finished_session(&exited("w_2", 0, None))); } #[test] @@ -87,6 +109,13 @@ fn a_finished_session_whose_runs_have_all_settled_leaves() { ); } +#[test] +fn a_cleanly_exited_session_without_runs_leaves() { + let app = hosting_app(); + + assert!(!app.keeps_finished_session(&exited("w_1", 0, None))); +} + #[test] fn only_the_runs_still_executing_get_rows() { let app = hosting_app(); diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 905753de6..9f73adcbe 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -409,6 +409,12 @@ impl App { row.origin.is_user() || row.control == crate::worker::pty::SessionControl::User || row.retained + // A lifecycle failure is deliberately retained by the + // frame sweep. It must also enter this projection or the + // manager keeps an unreachable record whose failure cue + // and waiting badge can never be acted on. + || crate::worker::pty::lifecycle_cue(row, medulla::clock::now_millis()) + .is_some_and(|cue| cue.kind.is_failure()) // Only while a run is *still going*: the rows a settled one // would contribute are dropped by `run_rows_under`, so a // session held open by finished runs would be a row with diff --git a/src/tui/src/ui/app/rail/resolve_tests.rs b/src/tui/src/ui/app/rail/resolve_tests.rs index 6c1a28cd4..778893e6a 100644 --- a/src/tui/src/ui/app/rail/resolve_tests.rs +++ b/src/tui/src/ui/app/rail/resolve_tests.rs @@ -28,8 +28,10 @@ fn session(provider: HarnessProvider, cwd: &str) -> SessionRow { control: SessionControl::User, origin: SessionOrigin::User, retained: false, + closed_by_request: false, name: None, attention: None, + working: false, } } diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index 8985be0ee..1d7d08c68 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -78,8 +78,10 @@ pub(in crate::ui::app) fn stub_session(id: &str) -> crate::worker::pty::SessionR control: crate::worker::pty::SessionControl::Orchestrator, origin: crate::worker::pty::SessionOrigin::Orchestrator, retained: false, + closed_by_request: false, name: None, attention: None, + working: false, mcp_grant_session: None, } } diff --git a/src/tui/src/ui/app/render/mod.rs b/src/tui/src/ui/app/render/mod.rs index c0e175338..f3b185670 100644 --- a/src/tui/src/ui/app/render/mod.rs +++ b/src/tui/src/ui/app/render/mod.rs @@ -232,10 +232,7 @@ impl App { // Only when the tab is not the one you are on: the selection // style is how "you are here" is said, and blinking over it // would trade a fact for a nag. - style = style.fg(self.theme.attention).add_modifier(Modifier::BOLD); - if self.theme.attention_blink { - style = style.add_modifier(Modifier::SLOW_BLINK); - } + style = self.theme.pulse(self.theme.attention, self.frame); } spans.push(Span::styled(label, style)); spans.push(Span::raw(gap)); diff --git a/src/tui/src/ui/app/render/sessions/rail/harness_line/layout.rs b/src/tui/src/ui/app/render/sessions/rail/harness_line/layout.rs index dabee6cbb..3b8181e8a 100644 --- a/src/tui/src/ui/app/render/sessions/rail/harness_line/layout.rs +++ b/src/tui/src/ui/app/render/sessions/rail/harness_line/layout.rs @@ -173,7 +173,7 @@ pub(in crate::ui::app::render::sessions::rail) fn harness_lines( index, render.width, indent, - render.state_glyph, + &render.state_glyph, render.primary, render.detail, ); @@ -207,7 +207,7 @@ fn layout_line( line: usize, width: usize, indent: &str, - state_glyph: char, + state_glyph: &str, style: Style, detail_style: Style, ) -> Vec> { @@ -236,7 +236,7 @@ fn layout_line( let room = width.saturating_sub(used + separator.width()); let text = match field { - Field::State => fit(&state_glyph.to_string(), room), + Field::State => fit(state_glyph, room), Field::Harness => fit(&harness_text(row, cfg.harness_style), room), Field::Control => fit(&control_text(row.control, cfg.control_style), room), // The harness advertises a sentence; the status line, like the diff --git a/src/tui/src/ui/app/render/sessions/rail/harness_line/types.rs b/src/tui/src/ui/app/render/sessions/rail/harness_line/types.rs index 1c5ce5036..80ab9dc95 100644 --- a/src/tui/src/ui/app/render/sessions/rail/harness_line/types.rs +++ b/src/tui/src/ui/app/render/sessions/rail/harness_line/types.rs @@ -11,7 +11,10 @@ pub(in crate::ui::app::render::sessions::rail) struct HarnessLineStyle { /// Whether the harness currently needs operator attention. pub(in crate::ui::app::render::sessions::rail) alerting: bool, /// State or attention glyph drawn by the state field. - pub(in crate::ui::app::render::sessions::rail) state_glyph: char, + /// + /// A string rather than a `char` because the working state can animate: + /// the glyph is one frame of a spinner, chosen by the caller. + pub(in crate::ui::app::render::sessions::rail) state_glyph: String, /// Style for primary status fields. pub(in crate::ui::app::render::sessions::rail) primary: Style, /// Style for secondary branch and path fields. diff --git a/src/tui/src/ui/app/render/sessions/rail/mod.rs b/src/tui/src/ui/app/render/sessions/rail/mod.rs index 76e2cf343..836ff47a4 100644 --- a/src/tui/src/ui/app/render/sessions/rail/mod.rs +++ b/src/tui/src/ui/app/render/sessions/rail/mod.rs @@ -23,6 +23,8 @@ use super::types::{Selection, SessionsPanes}; mod device_footer; mod harness_line; mod rows; +#[cfg(test)] +mod rows_tests; mod state; #[cfg(test)] mod status_line_tests; diff --git a/src/tui/src/ui/app/render/sessions/rail/rows.rs b/src/tui/src/ui/app/render/sessions/rail/rows.rs index 90864e8d6..571fa7049 100644 --- a/src/tui/src/ui/app/render/sessions/rail/rows.rs +++ b/src/tui/src/ui/app/render/sessions/rail/rows.rs @@ -7,8 +7,10 @@ use ratatui::text::{Line as TLine, Span}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::ui::agents::AgentLane; -use crate::ui::util::slug; -use crate::worker::pty::{HarnessAttention, SessionControl, SessionRow, ATTENTION_GLYPH}; +use crate::ui::util::{slug, SPINNER}; +use crate::worker::pty::{ + AttentionKind, HarnessAttention, PtyState, SessionControl, SessionRow, ATTENTION_GLYPH, +}; use super::super::super::super::rail::{RailRow, NEW_SESSION_LABEL}; use super::super::super::super::types::App; @@ -95,7 +97,7 @@ impl App { RailRow::WorkflowRun(run) => self.workflow_run_line(run, active, now), RailRow::Session(session) => match (&session.task, &session.local) { (Some(task), _) => { - self.task_session_line(task, session.last, active, waiting_sessions) + self.task_session_line(task, session.last, active, waiting_sessions, now) } (None, Some(local)) => self .own_session_lines(local, active, super::RAIL_MAX_CONTENT, now) @@ -126,9 +128,8 @@ impl App { impl App { /// Format one operator-started harness using the configured status-line layout. /// - /// PTY attention overrides the ordinary state glyph and adds a textual cue, - /// while the operator's field placement and visibility choices remain in - /// force for the status line itself. + /// Lifecycle state chooses the glyph and colour, while the operator's field + /// placement and visibility choices remain in force for the status line. pub(in crate::ui::app::render) fn own_session_lines( &self, row: &SessionRow, @@ -136,30 +137,42 @@ impl App { width: usize, now: i64, ) -> Vec> { - let waiting = self.harness_attention(row); - let alerting = waiting.is_some(); - let style = if waiting.is_some() { - let mut attention = Style::default() - .fg(self.theme.attention) - .add_modifier(Modifier::BOLD); - if self.theme.attention_blink { - attention = attention.add_modifier(Modifier::SLOW_BLINK); - } + let cue = self.harness_attention(row, now); + let failed = cue.as_ref().is_some_and(|cue| cue.kind.is_failure()); + let completed = cue + .as_ref() + .is_some_and(|cue| cue.kind == AttentionKind::Completed); + let alerting = cue.is_some() && !completed; + let style = if failed || (cue.is_some() && !completed) { + let colour = if failed { + color("red") + } else { + self.theme.attention + }; + let pulse = self.theme.pulse(colour, self.frame); if active { - attention.add_modifier(Modifier::REVERSED) + pulse.add_modifier(Modifier::REVERSED) } else { - attention + pulse } } else if active { self.theme.selection() + } else if row.working { + Style::default().fg(color("green")) } else if row.control == SessionControl::User { Style::default().fg(color("cyan")) } else { Style::default() }; - let glyph = match &waiting { - Some(_) => ATTENTION_GLYPH, - None => row.state.glyph(), + let glyph = match cue.as_ref() { + Some(cue) if cue.kind.is_failure() => "✕".to_string(), + Some(cue) if cue.kind == AttentionKind::Completed => "✓".to_string(), + Some(_) => ATTENTION_GLYPH.to_string(), + None if row.working => SPINNER[self.frame % SPINNER.len()].to_string(), + // Attached sessions suppress lifecycle cues, but a failed exit + // must still retain its state glyph. + None if matches!(row.state, PtyState::Exited { .. }) => row.state.glyph().to_string(), + None => row.state.glyph().to_string(), }; let detail_style = if active { style @@ -170,7 +183,7 @@ impl App { active, width, alerting, - state_glyph: glyph, + state_glyph: glyph.clone(), primary: style, detail: detail_style, }; @@ -180,8 +193,8 @@ impl App { home_dir().as_deref(), render, ); - if let Some(cue) = waiting { - let text = format!(" {ATTENTION_GLYPH} {}", cue.label(now)); + if let Some(cue) = cue { + let text = format!(" {glyph} {}", cue.label(now)); lines.extend(wrap_line( &TLine::from(Span::styled(text, style)), width, @@ -191,12 +204,12 @@ impl App { lines } - /// The cue a harness row should blink about, if it should. - pub(super) fn harness_attention(&self, row: &SessionRow) -> Option { - if !row.state.is_running() || self.harness_focus.is_attached_to(&row.id) { + /// The cue a harness row should draw, including lifecycle failures. + pub(super) fn harness_attention(&self, row: &SessionRow, now: i64) -> Option { + if self.harness_focus.is_attached_to(&row.id) { return None; } - row.attention.clone() + crate::worker::pty::row_cue(row, now) } /// Format the `+N more` paging control for a lane the fold has paged. @@ -230,32 +243,56 @@ impl App { last: bool, active: bool, waiting_sessions: &HashSet, + now: i64, ) -> TLine<'static> { let branch = if last { "└" } else { "├" }; - let needs_input = self.task_attention(&task.task_id, waiting_sessions); + let cue = self.task_attention_cue(&task.task_id, waiting_sessions, now); + let failed = cue.as_ref().is_some_and(|cue| cue.kind.is_failure()); + // Only a cue that blocks counts as waiting. The waiting-set was built + // from blocking cues, but the recomputed cue can have shifted to a + // non-blocking one (a prompt answered, a turn settled) by the time the + // row is drawn; a `Completed` retention is not "needs input". + let waiting = cue.as_ref().is_some_and(|cue| cue.kind.blocks()); let mut style = if active { self.theme.selection() } else { Style::default() }; - if needs_input { - style = style.fg(self.theme.attention); - if self.theme.attention_blink { - style = style.add_modifier(Modifier::SLOW_BLINK); - } + if waiting { + // A harness that died is bad news and draws red, exactly as every + // other failed-session surface draws it; anything else the harness + // is waiting on keeps the configured attention colour. The two are + // told apart before either is read. + let colour = if failed { + color("red") + } else { + self.theme.attention + }; + let pulse = self.theme.pulse(colour, self.frame); + style = if active { + // Preserve the selected-row background while letting the + // waiting state pulse its foreground and cadence. + self.theme.selection().patch(pulse) + } else { + pulse + }; } - let status_style = if active || needs_input { + let status_style = if active || waiting { style } else { style.fg(color(task.status.color())) }; - let status = if needs_input { + let status = match &cue { + // A harness that died is not "needs input": nothing a person can + // type will bring it back, and the generic waiting label would hide + // the explanation this row is the only surface left to give. Say + // what failed and how long ago, like the operator-owned rows do. + Some(cue) if failed => cue.label(now), // The one state the task's own status cannot express: the harness // has stopped on something only a person can answer, which the // backend never sees and so never reports. - NEEDS_INPUT_LABEL - } else { - task.status.label() + Some(_) => NEEDS_INPUT_LABEL.to_string(), + None => task.status.label().to_string(), }; let chip = task .work @@ -274,7 +311,7 @@ impl App { .unwrap_or_default(); TLine::from(vec![ Span::styled(format!(" {branch} {} · ", task.task_id), style), - Span::styled(status.to_string(), status_style), + Span::styled(status, status_style), Span::styled(format!("{chip}{title}"), style), ]) } diff --git a/src/tui/src/ui/app/render/sessions/rail/rows_tests.rs b/src/tui/src/ui/app/render/sessions/rail/rows_tests.rs new file mode 100644 index 000000000..1e92ffbf5 --- /dev/null +++ b/src/tui/src/ui/app/render/sessions/rail/rows_tests.rs @@ -0,0 +1,223 @@ +//! Tests for Sessions rail task rows: a dispatched task folded onto its live +//! harness, and what the row says when that harness fails. +//! +//! Unix-only: standing a real session up needs `/bin/sh` on a real pty, which +//! Windows has no equivalent of. The row model under test is portable; only the +//! way this file puts a session on the manager is not. + +#![cfg(unix)] + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use medulla::daemon::{DaemonConfig, DaemonRuntime}; +use medulla::protocol::{HarnessProvider, TaskFrameKind}; +use medulla::ui::agents::{TaskState, TaskStatus}; + +use super::super::super::super::rail::{RailRow, SessionRailRow}; +use super::super::super::color; +use super::tests::{app, lane, NOW}; +use crate::ui::harness_pane::LocalSessions; +use crate::worker::pty::{LaunchSpec, PtyManager, PtyState, SessionControl, SessionOrigin}; + +/// How long to allow a real child to exit and the daemon to record its session. +/// +/// Generous on purpose: real children on real ptys are at the mercy of machine +/// load, and a tight deadline turns "the box was busy" into a red test. +const PATIENCE: Duration = Duration::from_secs(20); + +/// A spec that runs `sh -c