Skip to content
Merged
Show file tree
Hide file tree
Changes from 76 commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
f7e1891
feat(tui): animate harness states and add error detection
senamakel Aug 7, 2026
4e37344
test: add new fields to test fixtures
senamakel Aug 7, 2026
821d5e8
fix(tui): remove unused ATTENTION_GLYPH import
senamakel Aug 7, 2026
93320bd
fix(event_loop): use public path for frame interval constant
senamakel Aug 7, 2026
9397b4b
chore(settings): clarify appearance usage text
senamakel Aug 7, 2026
e348174
fix(settings): show blink duration in appearance usage
senamakel Aug 7, 2026
fd0541d
refactor(tui): simplify appearance state handling
senamakel Aug 7, 2026
d83f15f
refactor(tui): simplify appearance state handling
senamakel Aug 7, 2026
8e73032
refactor(tui): simplify appearance state handling
senamakel Aug 7, 2026
494b1de
chore(tui): import blink timing helpers
senamakel Aug 7, 2026
c467ea3
fix(attention): handle session close during attention wait
senamakel Aug 7, 2026
1881680
test(rail): add attention tests
senamakel Aug 7, 2026
d1a2f4c
fix(rail): show agent status when rail is selected
senamakel Aug 7, 2026
518dd6a
fix(test): correct idle harness spinner assertion
senamakel Aug 7, 2026
1017882
fix(attention): handle pty resize events in detect loop
senamakel Aug 7, 2026
68d2710
test(tui): update settings navigation for extra attention control
senamakel Aug 7, 2026
e7e0d88
test(feature_settings): add tests for feature settings
senamakel Aug 7, 2026
f8e86a8
test(theme): cover blink rate parsing and pulsing edge cases
senamakel Aug 7, 2026
fa0d22a
test(attention): cover blocking error detection and cue precedence
senamakel Aug 7, 2026
d1e8b10
test(session): add tests for attention session handling
senamakel Aug 7, 2026
a4f2961
test: add session_tests module to attention test suite
senamakel Aug 7, 2026
09e1179
style: format long expressions for readability
senamakel Aug 7, 2026
f7bf3f2
refactor(theme): use is_multiple_of for blink phase check
senamakel Aug 7, 2026
710789f
fix(pty): handle attention requests without active sessions
senamakel Aug 7, 2026
480944d
fix(pty): handle attention types without panicking
senamakel Aug 7, 2026
283033a
fix(pty): handle attention requests without a session
senamakel Aug 7, 2026
949d703
fix(pty): handle attention detection edge case
senamakel Aug 7, 2026
f282870
fix(attention): handle pty resize events in detect loop
senamakel Aug 7, 2026
b41f823
test(attention): cover Claude live progress lines in working detection
senamakel Aug 7, 2026
ad461cb
docs(config): document attention and attentionBlink options
senamakel Aug 7, 2026
c6272c6
docs(config): document status line run-state glyphs
senamakel Aug 7, 2026
4f18c0e
fix(pty): handle attention requests without a pty
senamakel Aug 7, 2026
1d5a5f9
docs(developers): document the TUI in the developer guide
senamakel Aug 7, 2026
451081b
docs(configuration): document theme attention color options
senamakel Aug 7, 2026
767d2cf
chore(tui): reformat attention matcher lines
senamakel Aug 7, 2026
8e31c74
fix(attention): remove stray comment marker in Claude permission markers
senamakel Aug 7, 2026
66a95fd
Merge remote-tracking branch 'refs/remotes/upstream/main' into pr/235
senamakel Aug 7, 2026
9184552
fix(worker): render activity and attention pulses
senamakel Aug 7, 2026
905faf8
fix(worker): honor lifecycle attention cues
senamakel Aug 7, 2026
fc904da
fix(ui): preserve attention state
senamakel Aug 7, 2026
aac994d
fix(worker): distinguish idle composer state
senamakel Aug 7, 2026
4356c5d
fix(tui): refine harness attention rendering
senamakel Aug 7, 2026
72d7940
chore(tui): rename state module to app_state
senamakel Aug 8, 2026
176f80f
test(tui): use setter for attention blink rate in feature settings test
senamakel Aug 8, 2026
6ba777b
Merge remote-tracking branch 'refs/remotes/upstream/main' into pr/235
senamakel Aug 8, 2026
b95d120
fix(tui): harden attention cues
senamakel Aug 8, 2026
8779fdf
Merge remote-tracking branch 'refs/remotes/upstream/main' into pr/235
senamakel Aug 8, 2026
34de2be
chore: files changed src/tui/src/worker/pty/attention/tests.rs
senamakel Aug 8, 2026
17b4475
fix(tui): validate attention cues
senamakel Aug 8, 2026
db9b70b
chore: files changed src/tui/src/worker/pty/attention/detect.rs
senamakel Aug 8, 2026
90d9190
chore: files changed src/tui/src/worker/pty/attention/lifecycle_tests.rs
senamakel Aug 8, 2026
114c654
chore: files changed src/tui/src/worker/pty/attention/detect.rs,src/t…
senamakel Aug 8, 2026
2affb90
fix(tui): keep attention cues accurate after wrapped errors and answe…
senamakel Aug 8, 2026
ada24b9
fix(tui): keep wrapped error continuations from reading as recovery
senamakel Aug 8, 2026
bd391bf
fix(tui): validate the matched error line itself as terminal
senamakel Aug 8, 2026
cb8e7cc
fix(tui): do not treat a requested close as a lifecycle failure
senamakel Aug 8, 2026
94e79f2
fix(tui): stamp a write failure with the moment it happened
senamakel Aug 8, 2026
6eab483
fix(tui): suppress close-induced writer errors as failures
senamakel Aug 8, 2026
d8ca2d8
fix(tui): draw a dispatched task row's failed harness in red
senamakel Aug 8, 2026
75c5822
test(tui): move task-row tests to the rows sibling module
senamakel Aug 8, 2026
9aeb37f
chore: files changed src/tui/src/worker/pty/attention/detect.rs
senamakel Aug 8, 2026
e88b4f1
chore: files changed src/tui/src/worker/pty/attention/detect.rs
senamakel Aug 8, 2026
fe500a0
chore: files changed src/tui/src/worker/pty/attention/tests.rs
senamakel Aug 8, 2026
15c9f1e
chore: files changed gitbooks/developers/the-tui.md
senamakel Aug 8, 2026
3cf38d8
chore: files changed src/tui/src/worker/pty/attention/detect.rs
senamakel Aug 8, 2026
68f2946
chore: files changed src/tui/src/worker/pty/attention/tests.rs
senamakel Aug 8, 2026
f032629
chore: files changed src/tui/src/worker/pty/attention/detect.rs
senamakel Aug 8, 2026
125986b
chore: files changed src/tui/src/worker/pty/attention/tests.rs
senamakel Aug 8, 2026
674d799
chore: files changed src/tui/src/worker/pty/attention/detect.rs
senamakel Aug 8, 2026
ac89d39
chore: files changed src/tui/src/worker/pty/attention/tests.rs
senamakel Aug 8, 2026
799c752
chore: files changed src/tui/src/worker/pty/attention/tests.rs
senamakel Aug 8, 2026
64c025c
chore: files changed src/tui/src/worker/pty/attention/tests.rs
senamakel Aug 8, 2026
089a0bd
chore: files changed src/tui/src/worker/pty/attention/detect.rs
senamakel Aug 8, 2026
735f041
chore: files changed src/tui/src/worker/pty/attention/tests.rs
senamakel Aug 8, 2026
6890342
chore: files changed src/tui/src/worker/pty/attention/tests.rs
senamakel Aug 8, 2026
2ca2358
chore: files changed src/tui/src/worker/pty/attention/tests.rs
senamakel Aug 8, 2026
0d1c7cc
chore: files changed src/tui/src/worker/pty/attention/detect.rs
senamakel Aug 8, 2026
608cfbe
chore: files changed src/tui/src/worker/pty/attention/detect.rs
senamakel Aug 8, 2026
770696b
chore: files changed src/tui/src/worker/pty/attention/tests.rs
senamakel Aug 8, 2026
684a2c0
Merge remote-tracking branch 'refs/remotes/upstream/main' into pr/235
senamakel Aug 8, 2026
3b6beff
chore: files changed src/sdk/src/codex_overrides/tests.rs
senamakel Aug 8, 2026
e630a04
merge: resolve codex_overrides conflict with unescape_toml_string helper
senamakel Aug 9, 2026
d28c378
refactor(tests): split monolithic attention test file into per-catego…
senamakel Aug 9, 2026
8fe35f5
fix(tests): correct indentation of pairs.insert call
senamakel Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion gitbooks/developers/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
69 changes: 59 additions & 10 deletions gitbooks/developers/the-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/sdk/src/config/types/presentation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ pub struct ThemeConfig {
/// Whether operator-attention cues blink.
#[serde(skip_serializing_if = "Option::is_none")]
pub attention_blink: Option<bool>,
/// 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<f64>,
}

/// Onboarding state: what the welcome flow has already shown this user.
Expand Down
5 changes: 4 additions & 1 deletion src/tui/src/event_loop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<AppMsg>();
let mut mouse_on = true;

Expand Down
39 changes: 36 additions & 3 deletions src/tui/src/ui/app/appearance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 =
Expand Down Expand Up @@ -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 {
Expand All @@ -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"));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Toggle extracted harness titles on orchestrator-managed agent rows.
fn toggle_session_titles(&mut self) {
let appearance = &mut self.loaded.config.appearance;
Expand Down
2 changes: 2 additions & 0 deletions src/tui/src/ui/app/changes/baseline_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
16 changes: 13 additions & 3 deletions src/tui/src/ui/app/rail/cleanup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@
//! 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;
//! - a session with a workflow run still executing. A detached run outlives its
//! 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
Expand All @@ -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;
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
}
row.mcp_grant_session
.as_deref()
.is_some_and(|grant| self.harness_runs.any_active_for_session(grant))
Expand Down
45 changes: 37 additions & 8 deletions src/tui/src/ui/app/rail/cleanup_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions src/tui/src/ui/app/rail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/tui/src/ui/app/rail/resolve_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/tui/src/ui/app/rail/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
5 changes: 1 addition & 4 deletions src/tui/src/ui/app/render/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading
Loading