fix(tui): repair the UI views audit findings (ui-views lane bc872cd3, 98b1339b, fd54c004)

These are the ui-views lane's three WIP checkpoints, squashed with their authored
dispositions. The old lane merge e977f624 was not carried; this applies only the
lane's own changes onto the current integration.

- U06-06: aplay/ffplay resolve only from fixed absolute prefixes, never PATH,
  with the exec bit checked; the macOS companion launcher is /usr/bin/open.
- U05-01: a settled workflow run is never reopened by a late TaskStarted, and a
  late new row in a cancelled run is finalized Cancelled.
- U05-02: a run_completed/task_completed without `status`, or still saying
  running/pending, fails closed instead of reading as success.
- U05-04: delegate and fanout cards never let later envelopes change a terminal
  card or slot.
- U05-05: a follow-up receipt is appended only to the focus on its named agent
  (or its fork target).
- U05-m1/m2/m4/m6: phase routing destination hardening; a settled fanout
  reports its worst outcome; only bare Left/Right switch dock tabs; the fanout
  grid wraps within the render width.
- U06-03: pet live batches are bounded by the owner's request byte limit as
  well as the 64-event count.
- U06-m4 (partial): the export wait is aligned with the owner's work bound.
- U09-03/05/m2/m3/m5/m6: fleet model drafts install only unchanged answers;
  extension tabs re-anchor selection by entity key; settings Tab/BackTab
  preview; automations and agent projections keep recorded errors and reasons;
  fleet list and wrapped detail views scroll by painted rows.
- U09-01 (Refs #6555): fleet detail Save and rename refuse a source changed on
  disk since it was opened.
- U08-09 (Refs #6555): the session picker lists the store off the UI thread and
  shows list errors, never an empty store.
- A late Started for a recovery card names it without reopening it.

Lane receipts, dated: an earlier broad run gave 763 passed / 1 failed (a
completion-before-started recovery case). The fd54 correction then passed a
52/0 subset. No whole-set exact-head pass is claimed; this integration's
qualification is the proof.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-30 07:25:03 -07:00
1 parent b42e145e46
commit 10be0bdb2f
19 files changed
+1384 -168

No files matched your search

+108 -5
View File
@@ -2,9 +2,8 @@
//! bell for a missing/unsupported WAV. Tests inject a sink and never call an OS
//! player. A single in-flight WAV keeps repeated categories from stacking audio.
use super::sound_policy::SoundCue;
#[cfg(not(test))]
use std::io;
use std::io::Write;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioOutcome {
@@ -30,6 +29,85 @@ pub fn emit_terminal(cue: &SoundCue, out: &mut dyn Write) -> AudioOutcome {
pub const WHALE_WAV: &[u8] = include_bytes!("../../assets/audio/codewhale-whale-call.wav");
/// Resolve an external audio player (`aplay`, the pet's `ffplay`) from fixed
/// install prefixes. The ambient `PATH` is never consulted: an empty (`::`),
/// relative (`.`) or repository-local entry would let a checked-out workspace
/// plant a player that Codewhale then runs with the user's authority.
///
/// Known limitation: a player installed only outside these prefixes (a
/// custom `~/bin`, a version manager shim) is refused and the caller reports
/// audio as unavailable. That is deliberate — there is no `PATH` fallback.
pub(crate) fn trusted_player(name: &str) -> io::Result<PathBuf> {
trusted_player_in(name, &trusted_player_dirs())
}
fn trusted_player_in(name: &str, dirs: &[PathBuf]) -> io::Result<PathBuf> {
let file = if cfg!(windows) {
format!("{name}.exe")
} else {
name.to_owned()
};
dirs.iter()
.filter(|dir| dir.is_absolute())
.map(|dir| dir.join(&file))
.find(|candidate| is_executable_file(candidate))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"audio player is not installed in a trusted system location",
)
})
}
fn trusted_player_dirs() -> Vec<PathBuf> {
#[cfg(unix)]
{
[
"/usr/bin",
"/bin",
"/usr/local/bin",
"/opt/homebrew/bin",
"/home/linuxbrew/.linuxbrew/bin",
"/run/current-system/sw/bin",
]
.into_iter()
.map(PathBuf::from)
.collect()
}
#[cfg(windows)]
{
// Package-manager shim directories from known folders, never PATH.
let mut found = Vec::new();
if let Some(local) = dirs::data_local_dir() {
found.push(local.join("Microsoft").join("WinGet").join("Links"));
}
if let Some(home) = dirs::home_dir() {
found.push(home.join("scoop").join("shims"));
}
if let Some(data) = std::env::var_os("ProgramData") {
found.push(PathBuf::from(data).join("chocolatey").join("bin"));
}
found
}
#[cfg(not(any(unix, windows)))]
{
Vec::new()
}
}
fn is_executable_file(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
path.metadata()
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
path.is_file()
}
}
#[cfg(not(test))]
pub fn dispatch(cue: &SoundCue, out: &mut dyn Write) -> AudioOutcome {
#[cfg(target_os = "windows")]
@@ -140,9 +218,9 @@ fn play_file(path: &std::path::Path) -> io::Result<()> {
#[cfg(all(not(test), any(target_os = "macos", target_os = "linux")))]
fn play_file(path: &std::path::Path) -> io::Result<()> {
#[cfg(target_os = "macos")]
let player = "/usr/bin/afplay";
let player = PathBuf::from("/usr/bin/afplay");
#[cfg(target_os = "linux")]
let player = "aplay";
let player = trusted_player("aplay")?;
let status = std::process::Command::new(player)
.arg(path)
.stdin(std::process::Stdio::null())
@@ -219,6 +297,31 @@ mod tests {
}
}
#[test]
fn trusted_player_needs_an_executable_file_under_an_absolute_prefix() {
assert!(trusted_player_dirs().iter().all(|dir| dir.is_absolute()));
let dir = tempfile::tempdir().unwrap();
let name = "codewhale-test-player";
let file = dir.path().join(if cfg!(windows) {
format!("{name}.exe")
} else {
name.to_string()
});
let prefixes = [dir.path().to_path_buf()];
assert!(trusted_player_in(name, &prefixes).is_err(), "absent");
std::fs::write(&file, b"").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
assert!(
trusted_player_in(name, &prefixes).is_err(),
"a non-executable file is not a player"
);
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755)).unwrap();
}
assert_eq!(trusted_player_in(name, &prefixes).unwrap(), file);
}
#[test]
fn production_audio_entry_is_silent_in_library_tests() {
let mut out = Vec::new();
+37 -1
View File
@@ -503,7 +503,18 @@ pub(crate) fn apply_follow_up_receipt(
} else {
crate::tui::app::StatusToastLevel::Warning
};
if let Some(focus) = app.agent_focus.as_mut() {
// The receipt belongs to the agent it names (or the fork focus followed
// above). A delayed receipt for another agent must not land in whichever
// transcript happens to be focused now; the toast still reports it.
let receipt_target = outcome
.as_ref()
.ok()
.map(|receipt| receipt.target_agent_id.as_str());
if let Some(focus) = app
.agent_focus
.as_mut()
.filter(|focus| focus.is(agent_id) || receipt_target.is_some_and(|id| focus.is(id)))
{
focus.local_cells.push(HistoryCell::System {
content: note.clone(),
});
@@ -1275,6 +1286,31 @@ mod tests {
);
}
/// U05-05: a delayed follow-up receipt for another agent must not be
/// appended to whichever transcript happens to be focused when it lands.
#[test]
fn delayed_follow_up_receipt_stays_out_of_another_agents_focus() {
let tmp = tempdir().expect("tempdir");
let mut app = test_app(tmp.path().to_path_buf());
focus_agent(&mut app, "agent_b");
let outcome = Ok(crate::tools::subagent::UserFollowUpOutcome {
agent_id: "agent_a".to_string(),
target_agent_id: "agent_a".to_string(),
delivered: true,
resumed: false,
note: "delivered".to_string(),
});
apply_follow_up_receipt(&mut app, "agent_a", &outcome);
let focus = app.agent_focus.as_ref().expect("focus kept");
assert_eq!(focus.agent_id, "agent_b");
assert!(
focus.local_cells.is_empty(),
"another agent's receipt leaked into this transcript: {:?}",
focus.local_cells
);
assert!(app.status_message.is_some(), "the toast still reports it");
}
#[test]
fn scroll_to_bottom_returns_the_focused_transcript_to_its_tail() {
let tmp = tempdir().expect("tempdir");
+52 -25
View File
@@ -46,6 +46,10 @@ impl Target {
self.requested.elapsed() <= Duration::from_millis(500)
}
/// `Ok` means the samples were valid and the stream is live, not that they
/// were played: a full queue or a stale request drops them. Live audio is
/// a bounded real-time sink — late samples never queue behind the world
/// clock — so an overrun is a dropout, not a stream failure.
pub fn send(&self, channels: [Vec<f32>; 2]) -> Result<(), ()> {
let length = channels[0].len();
if length > MAX_FRAMES
@@ -104,31 +108,7 @@ impl Output {
#[cfg(not(test))]
pub fn start() -> io::Result<Self> {
let mut command = Command::new("ffplay");
command.args([
"-nodisp",
"-autoexit",
"-loglevel",
"error",
"-probesize",
"32",
"-analyzeduration",
"0",
"-f",
"f32le",
"-sample_rate",
"48000",
"-ch_layout",
"stereo",
"-i",
"pipe:0",
]);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000); // CREATE_NO_WINDOW
}
Self::spawn(command)
Self::spawn(player_command()?)
}
#[cfg(test)]
@@ -194,6 +174,38 @@ impl Drop for Output {
}
}
/// The pet's PCM player: `ffplay` from a fixed install prefix, never from the
/// ambient `PATH` (see [`crate::notify::audio::trusted_player`]). A player
/// that is not installed there fails the start and Watch reports audio as
/// unavailable.
fn player_command() -> io::Result<Command> {
let mut command = Command::new(crate::notify::audio::trusted_player("ffplay")?);
command.args([
"-nodisp",
"-autoexit",
"-loglevel",
"error",
"-probesize",
"32",
"-analyzeduration",
"0",
"-f",
"f32le",
"-sample_rate",
"48000",
"-ch_layout",
"stereo",
"-i",
"pipe:0",
]);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000); // CREATE_NO_WINDOW
}
Ok(command)
}
fn play(command: &mut Command, rx: mpsc::Receiver<Packet>, control: &Control) -> io::Result<()> {
if control.cancelled.load(Ordering::Acquire) {
return Ok(());
@@ -354,6 +366,21 @@ mod tests {
}
}
/// U06-06: the player is an absolute path from a trusted prefix or it is
/// refused — never a bare name the ambient `PATH` resolves, where an empty
/// or repository-local entry would run a workspace-planted `ffplay`.
#[test]
fn pet_player_is_never_resolved_through_path() {
match player_command() {
Ok(command) => assert!(
std::path::Path::new(command.get_program()).is_absolute(),
"player resolved through PATH: {:?}",
command.get_program()
),
Err(error) => assert_eq!(error.kind(), io::ErrorKind::NotFound),
}
}
#[test]
fn missing_player_fails_without_opening_a_device_or_blocking_the_caller() {
assert!(Output::start().is_err());
+77 -7
View File
@@ -131,6 +131,40 @@ pub enum Notice {
/// fetch failed and it is reconnecting. Only this marks the pet offline.
Unreachable(String),
}
/// Producer events queued for the next `/v1/producer` post. The owner caps a
/// batch at 64 events *and* its request body at [`owner::MAX_REQUEST_BYTES`];
/// a batch that exceeds either is a coverage gap, never a post the owner must
/// refuse (a 64-event batch of 16 KiB events is far over the body limit).
#[derive(Default)]
struct Batch {
events: Vec<Value>,
bytes: usize,
}
impl Batch {
const MAX_EVENTS: usize = 64;
/// Room left for the post's envelope (identity, epoch, source, sequence).
const MAX_EVENT_BYTES: usize = owner::MAX_REQUEST_BYTES - 4 * 1024;
/// Queue one event. `Ok(false)` means it does not fit: the caller drops
/// the batch and restarts the lease rather than posting a partial one.
fn push(&mut self, text: &str) -> io::Result<bool> {
// `+ 1` counts the separating comma in the posted JSON array.
let bytes = self.bytes + text.len() + 1;
if self.events.len() >= Self::MAX_EVENTS || bytes > Self::MAX_EVENT_BYTES {
return Ok(false);
}
self.events.push(serde_json::from_str(text)?);
self.bytes = bytes;
Ok(true)
}
fn clear(&mut self) {
self.events.clear();
self.bytes = 0;
}
}
pub struct Worker {
pub tx: mpsc::SyncSender<Command>,
pub latest: Arc<Mutex<Option<Presentation>>>,
@@ -214,11 +248,17 @@ impl Client {
}
fn request(&self, path: &str, body: Option<&Value>) -> io::Result<Value> {
let url = format!("http://127.0.0.1:{}{path}", self.descriptor.port);
let request = if let Some(body) = body {
let mut request = if let Some(body) = body {
self.http.post(url).json(body)
} else {
self.http.get(url)
};
if path == "/v1/export" {
// The owner serializes the recording on its world thread for up
// to its own work bound; giving up sooner abandons an export the
// owner still completes. Other calls keep the 2 s client bound.
request = request.timeout(owner::WORK_REPLY_TIMEOUT + Duration::from_secs(1));
}
let response = request
.bearer_auth(&self.descriptor.token)
.send()
@@ -262,7 +302,9 @@ impl Client {
#[cfg(target_os = "macos")]
{
use std::process::{Command as Process, Stdio};
let mut process = Process::new("open");
// The system launcher by absolute path: a bare `open` resolves
// through `PATH`, where a workspace entry could shadow it.
let mut process = Process::new("/usr/bin/open");
if let Some(path) = std::env::var_os("CODEWHALE_PET_APP") {
process.arg(path);
} else {
@@ -341,7 +383,7 @@ fn run(
let mut changed = Instant::now();
let mut fetched = Instant::now() - Duration::from_secs(1);
let mut encoded = Instant::now() - Duration::from_secs(1);
let mut events = Vec::new();
let mut events = Batch::default();
let mut producer_seq = None;
let mut sequence = 0u64;
let mut action: Option<Value> = None;
@@ -357,9 +399,9 @@ fn run(
match rx.recv_timeout(Duration::from_millis(2)) {
Ok(command) => match command {
Command::Observe(text) => {
if events.len() < 64 {
events.push(serde_json::from_str::<Value>(&text)?)
} else {
if !events.push(&text)? {
// Overflow is a coverage gap, never a truncated batch:
// restart the lease from sequence zero.
events.clear();
producer_seq = None;
}
@@ -489,7 +531,7 @@ fn run(
if seq == 0 {
events.clear();
}
let body = json!({"identity":s.identity,"epoch":s.epoch,"client":id,"source":s.source,"source_revision":s.source_revision,"seq":seq,"waiting":view.waiting,"events":events});
let body = json!({"identity":s.identity,"epoch":s.epoch,"client":id,"source":s.source,"source_revision":s.source_revision,"seq":seq,"waiting":view.waiting,"events":events.events});
producer_seq = client.post("/v1/producer", &body).ok().map(|_| seq);
events.clear();
last_produce = Instant::now();
@@ -556,3 +598,31 @@ fn run(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// U06-03: a producer batch is bounded by the owner's request body limit,
/// not only by its event count, so the worker never posts a body the
/// owner must refuse (64 events of 16 KiB each is ~1 MiB).
#[test]
fn producer_batch_fits_the_owner_body_limit() {
let mut batch = Batch::default();
let large = format!("{{\"event\":\"x\",\"id\":\"{}\"}}", "a".repeat(16_000));
let mut accepted = 0;
while batch.push(&large).unwrap() {
accepted += 1;
}
assert!(accepted > 0 && accepted < Batch::MAX_EVENTS, "{accepted}");
let posted = serde_json::to_vec(&json!({"seq": u64::MAX, "events": batch.events})).unwrap();
assert!(posted.len() <= owner::MAX_REQUEST_BYTES, "{}", posted.len());
batch.clear();
let small = "{\"event\":\"tool_call_heartbeat\"}";
for _ in 0..Batch::MAX_EVENTS {
assert!(batch.push(small).unwrap());
}
assert!(!batch.push(small).unwrap(), "the event count still bounds");
}
}
+6 -2
View File
@@ -23,6 +23,10 @@ use crate::fleet::files::{WorkspaceFile, same_file};
const LEASE: Duration = Duration::from_secs(2);
const MAX_CLIENTS: usize = 4096;
/// Request body limit for every owner route. Clients size batches to it.
pub(super) const MAX_REQUEST_BYTES: usize = 64 * 1024;
/// How long a route waits for the world thread's reply before answering 503.
pub(super) const WORK_REPLY_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -190,7 +194,7 @@ async fn submit(
if state.tx.try_send(make(tx)).is_err() {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
match tokio::time::timeout(Duration::from_secs(5), rx).await {
match tokio::time::timeout(WORK_REPLY_TIMEOUT, rx).await {
Ok(Ok(result)) => answer(result),
_ => StatusCode::SERVICE_UNAVAILABLE.into_response(),
}
@@ -374,7 +378,7 @@ pub fn serve(root: PathBuf, requested_port: u16) -> anyhow::Result<()> {
.route("/v1/frame", get(frame)).route("/v1/action", post(action))
.route("/v1/producer", post(producer)).route("/v1/audio", post(audio))
.route("/v1/export", get(export)).route("/v1/attach", post(attach))
.layer(DefaultBodyLimit::max(64 * 1024))
.layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES))
.layer(axum::middleware::from_fn(|request: axum::extract::Request, next: axum::middleware::Next| async move {
let mut response = next.run(request).await;
for (key, value) in [("cache-control", "no-store"), ("referrer-policy", "no-referrer"), ("x-content-type-options", "nosniff"), ("content-security-policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' blob:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")] {
+200 -9
View File
@@ -64,6 +64,10 @@ impl PreviewCache {
self.entries.back().map(|(_, lines)| lines)
}
fn remove(&mut self, id: &str) {
self.entries.retain(|(key, _)| key != id);
}
fn insert(&mut self, id: String, lines: Vec<String>) {
self.entries.retain(|(key, _)| *key != id);
self.entries.push_back((id, lines));
@@ -93,9 +97,30 @@ struct PendingPreview {
cell: Arc<Mutex<Option<PreviewLoad>>>,
}
/// The session-store listing running off the event loop (U08-09). The
/// store's metadata scan grows with every saved session, so the picker opens
/// at once and fills in when the listing lands.
struct PendingSessionList {
cell: Arc<Mutex<Option<Result<Vec<SessionMetadata>, String>>>>,
/// Row to land on once the list arrives (`new_selecting`).
select: Option<String>,
}
/// What the list pane says in place of rows it does not have yet.
#[derive(Debug, Clone, Copy)]
enum ListNotice<'a> {
Loading,
Failed(&'a str),
}
pub struct SessionPickerView {
/// Every session loaded from disk. The picker filters from this set.
sessions: Vec<SessionMetadata>,
/// The store listing still in flight, if any.
pending_list: Option<PendingSessionList>,
/// The store could not be listed. Shown instead of "no saved sessions",
/// which would claim an empty store nobody observed.
load_error: Option<String>,
filtered: Vec<SessionMetadata>,
selected: usize,
list_scroll: Cell<usize>,
@@ -148,10 +173,67 @@ impl SessionPickerView {
/// other workspaces are hidden by default — press `a` inside the
/// picker to expand to all workspaces (#1395).
pub fn new(workspace: &Path, locale: Locale) -> Self {
let sessions = SessionManager::default_location()
.and_then(|manager| manager.list_sessions())
.unwrap_or_default();
Self::from_session_list(workspace, locale, sessions)
Self::load(workspace, locale, None)
}
/// Open over the session store. Listing it is blocking disk I/O that
/// grows with the store, so inside a runtime it runs on the blocking pool
/// and lands through `tick`; outside one (unit tests, headless callers)
/// there is no loop to stall, so it loads inline.
fn load(workspace: &Path, locale: Locale, select: Option<String>) -> Self {
let mut view = Self::from_session_list(workspace, locale, Vec::new());
if tokio::runtime::Handle::try_current().is_err() {
view.apply_session_list(list_session_store(), select);
return view;
}
let cell = Arc::new(Mutex::new(None));
let slot = Arc::clone(&cell);
crate::utils::spawn_blocking_supervised("session-picker-list", move || {
let listed = list_session_store();
if let Ok(mut guard) = slot.lock() {
*guard = Some(listed);
}
});
view.pending_list = Some(PendingSessionList { cell, select });
view
}
/// Install a finished store listing. A failure is shown as a failure —
/// never as an empty store — and the list stays empty.
fn apply_session_list(
&mut self,
listed: Result<Vec<SessionMetadata>, String>,
select: Option<String>,
) {
match listed {
Ok(sessions) => {
self.sessions = sessions;
self.load_error = None;
self.apply_sort_and_filter();
if let Some(session_id) = select {
self.select_with_fallback(&session_id);
}
}
Err(error) => {
self.sessions.clear();
self.load_error =
Some(tr(self.locale, MessageId::SessionsOpenFailed).replace("{error}", &error));
self.apply_sort_and_filter();
}
}
}
/// Apply the store listing once it lands. Returns whether the view changed.
fn poll_session_list(&mut self) -> bool {
let Some(pending) = self.pending_list.as_ref() else {
return false;
};
let Some(listed) = pending.cell.lock().ok().and_then(|mut guard| guard.take()) else {
return false;
};
let select = self.pending_list.take().and_then(|pending| pending.select);
self.apply_session_list(listed, select);
true
}
/// Construct a picker scoped to `workspace` over an explicit session list.
@@ -177,6 +259,8 @@ impl SessionPickerView {
fn from_session_list(workspace: &Path, locale: Locale, sessions: Vec<SessionMetadata>) -> Self {
let mut view = Self {
sessions,
pending_list: None,
load_error: None,
filtered: Vec::new(),
selected: 0,
list_scroll: Cell::new(0),
@@ -218,9 +302,7 @@ impl SessionPickerView {
/// anything, so widening the view cannot cross a workspace boundary
/// behind the user's back.
pub fn new_selecting(workspace: &Path, locale: Locale, session_id: &str) -> Self {
let mut view = Self::new(workspace, locale);
view.select_with_fallback(session_id);
view
Self::load(workspace, locale, Some(session_id.to_string()))
}
/// Land the selection on `session_id`, widening one browse filter at a
@@ -486,6 +568,16 @@ impl SessionPickerView {
);
}
/// What the list pane shows in place of rows it has not got: the listing
/// is still in flight, or the store could not be listed.
fn list_notice(&self) -> Option<ListNotice<'_>> {
if self.pending_list.is_some() {
Some(ListNotice::Loading)
} else {
self.load_error.as_deref().map(ListNotice::Failed)
}
}
fn sort_label(&self) -> String {
match self.sort_mode {
SessionSortMode::Recent => tr(self.locale, MessageId::SessionsSortRecent),
@@ -649,10 +741,12 @@ impl SessionPickerView {
);
return ViewAction::None;
}
// Update our local metadata cache.
// Update our local metadata cache. The cached preview renders the old
// title, so it is dropped rather than shown again (U08-09).
if let Some(meta) = self.sessions.iter_mut().find(|s| s.id == session.id) {
meta.title = new_title.to_string();
}
self.preview_cache.remove(&session.id);
self.apply_sort_and_filter();
self.refresh_preview();
self.status =
@@ -749,6 +843,14 @@ impl SessionPickerView {
}
}
/// List the saved-session store. Blocking; runs on the blocking pool when a
/// runtime is available.
fn list_session_store() -> Result<Vec<SessionMetadata>, String> {
SessionManager::default_location()
.and_then(|manager| manager.list_sessions())
.map_err(|error| error.to_string())
}
/// Read one saved session and render its preview. Blocking; runs on the
/// blocking pool when a runtime is available.
fn load_preview(session_id: &str, locale: Locale) -> PreviewLoad {
@@ -791,7 +893,8 @@ impl ModalView for SessionPickerView {
}
fn tick(&mut self) -> ViewAction {
if self.poll_preview() {
let listed = self.poll_session_list();
if self.poll_preview() || listed {
ViewAction::Redraw
} else {
ViewAction::None
@@ -1041,6 +1144,7 @@ impl ModalView for SessionPickerView {
self.rename_mode,
&self.rename_input,
self.status.as_deref(),
self.list_notice(),
self.current_session_id.as_deref(),
self.locale,
);
@@ -1116,6 +1220,7 @@ impl ModalView for SessionPickerView {
self.rename_mode,
&self.rename_input,
self.status.as_deref(),
self.list_notice(),
self.current_session_id.as_deref(),
self.locale,
);
@@ -1174,6 +1279,7 @@ fn build_list_lines(
rename_mode: bool,
rename_input: &str,
status: Option<&str>,
notice: Option<ListNotice<'_>>,
current_session_id: Option<&str>,
locale: Locale,
) -> Vec<Line<'static>> {
@@ -1208,6 +1314,23 @@ fn build_list_lines(
}
if sessions.is_empty() {
match notice {
Some(ListNotice::Loading) => {
lines.push(Line::from(Span::styled(
"\u{2026}",
Style::default().fg(palette::TEXT_MUTED),
)));
return lines;
}
Some(ListNotice::Failed(error)) => {
lines.push(Line::from(Span::styled(
truncate(error, width),
Style::default().fg(palette::STATUS_ERROR),
)));
return lines;
}
None => {}
}
lines.push(Line::from(Span::styled(
tr(locale, MessageId::SessionsEmptyTitle),
Style::default().fg(palette::TEXT_MUTED),
@@ -1616,6 +1739,8 @@ mod tests {
let workspace_scope = scope.map(PathBuf::from);
let mut view = SessionPickerView {
sessions: sessions.clone(),
pending_list: None,
load_error: None,
filtered: sessions,
selected: 0,
list_scroll: Cell::new(0),
@@ -1710,8 +1835,24 @@ mod tests {
saved.metadata.title = "Before".to_string();
manager.save_session(&saved).expect("save session");
let mut view = picker_with(vec![saved.metadata.clone()], None);
assert!(
view.current_preview
.iter()
.any(|line| line == "Title: Before"),
"{:?}",
view.current_preview
);
let action = view.rename_selected("After");
// U08-09: the cached preview rendered the old title; it must not be
// shown again after the rename.
assert!(
view.current_preview
.iter()
.any(|line| line == "Title: After"),
"{:?}",
view.current_preview
);
let ViewAction::Emit(ViewEvent::SessionRenamed { metadata }) = action else {
panic!("expected SessionRenamed event");
@@ -1899,6 +2040,7 @@ mod tests {
false,
"",
None,
None,
Some("session-01"),
Locale::En,
);
@@ -2171,6 +2313,7 @@ mod tests {
"",
None,
None,
None,
Locale::En,
);
@@ -2203,6 +2346,7 @@ mod tests {
"",
None,
None,
None,
Locale::En,
);
@@ -2409,6 +2553,7 @@ mod tests {
"",
None,
None,
None,
Locale::En,
);
@@ -2443,6 +2588,7 @@ mod tests {
"",
None,
None,
None,
Locale::En,
);
@@ -2476,6 +2622,7 @@ mod tests {
"",
None,
None,
None,
Locale::En,
);
@@ -2608,6 +2755,8 @@ mod tests {
let mut view = SessionPickerView {
sessions: sessions.clone(),
pending_list: None,
load_error: None,
filtered: sessions,
selected: 0,
list_scroll: Cell::new(0),
@@ -2848,4 +2997,46 @@ mod tests {
"an idle tick after the preview landed must not keep repainting"
);
}
/// U08-09: a session store that cannot be listed is reported as such,
/// never painted as "No saved sessions yet".
#[test]
fn unreadable_session_store_is_reported_not_shown_empty() {
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
// A regular file where the store directory belongs cannot be listed.
std::fs::write(tmp.path().join("sessions"), b"not a directory").expect("file");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
let view = SessionPickerView::new(tmp.path(), Locale::En);
assert!(view.load_error.is_some(), "the listing failure was dropped");
let lines = build_list_lines(
&view.filtered,
0,
120,
0,
5,
false,
"",
"recent",
false,
false,
"",
None,
view.list_notice(),
None,
Locale::En,
);
let text = lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Could not open sessions"), "{text}");
assert!(!text.contains("No saved sessions yet"), "{text}");
}
}
@@ -205,20 +205,25 @@ impl SettingsPickerController {
self.recompute_filter(keep.as_deref());
}
pub fn next_tab(&mut self) {
/// Switch to the next tab. Previews the row it lands on only when that
/// row is available — a disabled row is shown, never previewed.
pub fn next_tab(&mut self) -> PickerNavResult {
if self.tabs.is_empty() {
return;
return PickerNavResult::None;
}
let next = (self.active_tab + 1) % self.tabs.len();
self.set_active_tab(next);
self.preview_if_available()
}
pub fn prev_tab(&mut self) {
/// Switch to the previous tab; same preview rule as [`Self::next_tab`].
pub fn prev_tab(&mut self) -> PickerNavResult {
if self.tabs.is_empty() {
return;
return PickerNavResult::None;
}
let prev = (self.active_tab + self.tabs.len() - 1) % self.tabs.len();
self.set_active_tab(prev);
self.preview_if_available()
}
/// Select by source index when that option is currently visible.
+24 -13
View File
@@ -56,18 +56,11 @@ pub fn handle_nav_key(
match key.code {
KeyCode::Esc => controller.request_cancel(),
KeyCode::Enter => controller.request_commit(),
KeyCode::BackTab => {
controller.prev_tab();
PickerNavResult::Preview
}
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
controller.prev_tab();
PickerNavResult::Preview
}
KeyCode::Tab => {
controller.next_tab();
PickerNavResult::Preview
}
// A tab switch lands on whatever row the new tab keeps selected; it
// previews that row only when it is available, like ↑/↓ do.
KeyCode::BackTab => controller.prev_tab(),
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => controller.prev_tab(),
KeyCode::Tab => controller.next_tab(),
KeyCode::Up | KeyCode::Char('k')
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
@@ -336,8 +329,26 @@ mod tests {
false,
);
assert_eq!(result, PickerNavResult::Preview);
// U09-m2: the last ("extra") tab lands on its disabled first row,
// which must not preview.
assert_eq!(controller.active_tab(), controller.tabs().len() - 1);
assert_eq!(controller.selected_id(), Some("locked"));
assert_eq!(result, PickerNavResult::None);
assert_eq!(controller.move_down(), PickerNavResult::Preview);
assert_eq!(controller.selected_id(), Some("dracula"));
let result = handle_nav_key(
&mut controller,
KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE),
false,
);
assert_eq!(controller.active_tab_name(), "core");
assert_eq!(controller.selected_id(), Some("terminal"));
assert_eq!(
result,
PickerNavResult::Preview,
"an available row previews"
);
}
#[test]
+1 -1
View File
@@ -653,7 +653,7 @@ fn deliver_fleet_draft_result(
let installed = boxed
.as_any_mut()
.downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>()
.map(|wizard| {
.and_then(|wizard| {
wizard.install_model_draft(
draft,
model_label.clone(),
+95 -11
View File
@@ -54,6 +54,9 @@ pub(crate) struct AutomationRow {
pub(crate) record: AutomationRecord,
/// Newest first.
pub(crate) runs: Vec<AutomationRunRecord>,
/// The run history could not be read. Painted instead of "no recorded
/// runs", which would claim an empty history nobody observed.
pub(crate) runs_error: Option<String>,
}
impl AutomationRow {
@@ -73,6 +76,9 @@ pub struct AutomationsView {
row: usize,
detail_open: bool,
detail_scroll: usize,
/// Largest useful detail scroll in rendered (wrapped) rows, from the last
/// paint; ↓ stops there instead of piling up invisible scroll.
detail_max_scroll: Cell<usize>,
locale: Locale,
manager: Option<SharedAutomationManager>,
/// Refreshes ride the modal tick, not every frame.
@@ -99,6 +105,7 @@ impl AutomationsView {
row: 0,
detail_open: false,
detail_scroll: 0,
detail_max_scroll: Cell::new(usize::MAX),
locale: app.ui_locale,
manager: app.runtime_services.automations.clone(),
last_refresh_at: Instant::now(),
@@ -128,6 +135,7 @@ impl AutomationsView {
row: 0,
detail_open: false,
detail_scroll: 0,
detail_max_scroll: Cell::new(usize::MAX),
locale,
manager: None,
last_refresh_at: Instant::now(),
@@ -237,10 +245,15 @@ impl AutomationsView {
self.rows = records
.into_iter()
.map(|record| {
let runs = manager
.list_runs(&record.id, Some(RECENT_RUNS))
.unwrap_or_default();
AutomationRow { record, runs }
let (runs, runs_error) = match manager.list_runs(&record.id, Some(RECENT_RUNS)) {
Ok(runs) => (runs, None),
Err(error) => (Vec::new(), Some(error.to_string())),
};
AutomationRow {
record,
runs,
runs_error,
}
})
.collect();
self.problem = None;
@@ -546,7 +559,16 @@ impl AutomationsView {
format!(" {}", tr(locale, MessageId::AutomationRecentRunsLabel)),
Style::default().fg(palette::TEXT_PRIMARY).bold(),
)));
if row.runs.is_empty() {
if let Some(error) = row.runs_error.as_deref() {
lines.push(Line::from(Span::styled(
format!(
" {} · {}",
tr(locale, MessageId::AutomationRunsUnavailable),
display_text(error)
),
Style::default().fg(palette::STATUS_ERROR),
)));
} else if row.runs.is_empty() {
lines.push(Line::from(Span::styled(
format!(" {}", tr(locale, MessageId::AutomationNoRuns)),
Style::default().fg(palette::TEXT_DIM),
@@ -593,11 +615,12 @@ impl AutomationsView {
return;
};
let lines = self.detail_lines(row);
let visible = usize::from(area.height).max(1);
let scroll = self.detail_scroll.min(lines.len().saturating_sub(visible));
Paragraph::new(lines.into_iter().skip(scroll).collect::<Vec<_>>())
.wrap(Wrap { trim: false })
.render(area, buf);
self.detail_max_scroll.set(super::render_wrapped_detail(
lines,
area,
buf,
self.detail_scroll,
));
}
}
@@ -668,7 +691,10 @@ impl ModalView for AutomationsView {
}
KeyCode::Down | KeyCode::Char('j') => {
if self.detail_open {
self.detail_scroll = self.detail_scroll.saturating_add(1);
self.detail_scroll = self
.detail_scroll
.saturating_add(1)
.min(self.detail_max_scroll.get());
} else {
self.move_row(1);
}
@@ -883,10 +909,12 @@ mod tests {
AutomationRow {
record: record("vision", AutomationStatus::Active),
runs: vec![run("vision", AutomationRunStatus::Running)],
runs_error: None,
},
AutomationRow {
record: record("infra", AutomationStatus::Paused),
runs: Vec::new(),
runs_error: None,
},
],
Locale::En,
@@ -1093,4 +1121,60 @@ mod tests {
assert!(text.contains("p pause"), "{text}");
assert!(text.contains("follow you into every repository"), "{text}");
}
/// U09-m3: a run history that could not be read is reported as such,
/// never painted as "no recorded runs".
#[test]
fn unreadable_run_history_is_not_shown_as_empty() {
let view = AutomationsView::from_rows(
vec![AutomationRow {
record: record("vision", AutomationStatus::Active),
runs: Vec::new(),
runs_error: Some("run store unreadable".to_string()),
}],
Locale::En,
);
let text = view
.detail_lines(&view.rows[0])
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("runs unavailable"), "{text}");
assert!(text.contains("run store unreadable"), "{text}");
assert!(!text.contains("no recorded runs"), "{text}");
}
/// U09-m6: detail scroll counts wrapped rows, so a long prompt's tail is
/// reachable and ↓ stops at the real end instead of piling up scroll.
#[test]
fn detail_scroll_reaches_the_wrapped_tail() {
let mut long = record("long", AutomationStatus::Active);
long.prompt = format!("{}TAILMARK", "word ".repeat(80));
let mut view = AutomationsView::from_rows(
vec![AutomationRow {
record: long,
runs: Vec::new(),
runs_error: None,
}],
Locale::En,
);
view.detail_open = true;
let area = Rect::new(0, 0, 30, 8);
let mut buf = Buffer::empty(area);
view.render_detail(area, &mut buf);
for _ in 0..200 {
view.handle_key(key(KeyCode::Down));
}
assert_eq!(view.detail_scroll, view.detail_max_scroll.get());
let mut buf = Buffer::empty(area);
view.render_detail(area, &mut buf);
let text = rendered_text(area, &buf);
assert!(text.contains("TAILMARK"), "{text}");
}
}
+108 -3
View File
@@ -1572,6 +1572,26 @@ enum VisibleEntry<'a> {
Empty,
}
/// Stable identity of a visible row across snapshot refreshes. An item is
/// keyed by its own id alone: a refresh may regroup it (MCP login-first).
#[derive(Debug, Clone, PartialEq, Eq)]
enum EntryKey {
Group(String),
Item(String),
Problem,
}
impl EntryKey {
fn of(entry: VisibleEntry<'_>) -> Option<Self> {
match entry {
VisibleEntry::Group(group) => Some(Self::Group(group.id.clone())),
VisibleEntry::Item(_, item) => Some(Self::Item(item.id.clone())),
VisibleEntry::Problem(_) => Some(Self::Problem),
VisibleEntry::Empty => None,
}
}
}
#[derive(Default)]
struct HitAreas {
tabs: Vec<(Rect, ExtensionsTab)>,
@@ -1893,11 +1913,33 @@ impl ExtensionsView {
/// Swap in a fresh read model while keeping everything the user is doing:
/// active tab, focus, search query, selection, scroll, and folded groups
/// all survive; the selection only moves when the refreshed list no
/// longer reaches it.
/// all survive. The selection follows its entity, not its row number: a
/// refresh that reorders the list (a server logs in, a plugin is
/// installed) keeps the same item selected, so a later `e`/`d` can never
/// act on whatever row slid into the old index. It only moves when the
/// selected entity is gone.
pub fn refresh_snapshot(&mut self, snapshot: ExtensionsSnapshot) {
let active = self.active_tab;
let anchors = ExtensionsTab::ALL.map(|tab| {
self.active_tab = tab;
self.visible_entries()
.get(self.selected[tab.index()])
.copied()
.and_then(EntryKey::of)
});
self.snapshot = snapshot;
self.clamp_selection();
for (tab, anchor) in ExtensionsTab::ALL.into_iter().zip(anchors) {
self.active_tab = tab;
if let Some(index) = anchor.and_then(|anchor| {
self.visible_entries()
.iter()
.position(|entry| EntryKey::of(*entry).as_ref() == Some(&anchor))
}) {
self.selected[tab.index()] = index;
}
self.clamp_selection();
}
self.active_tab = active;
}
fn set_tab(&mut self, tab: ExtensionsTab) {
@@ -3324,4 +3366,67 @@ mod tests {
other => panic!("expected the refreshed item, got {other:?}"),
}
}
/// U09-05: a refresh that reorders the list keeps the selection on the
/// same entity, so a later toggle acts on the row the user chose — never
/// on whatever slid into the old index.
#[test]
fn refresh_keeps_selection_on_the_same_entity_when_rows_reorder() {
fn row(id: &str) -> ExtensionItem {
ExtensionItem {
id: id.into(),
label: id.into(),
toggle: Some(ExtensionAction::Command {
label: "disable".into(),
command: format!("/plugin disable {id}"),
disposition: RowActionDisposition::InPlace,
}),
..item_with_action(ExtensionAction::Status {
label: "enabled".into(),
})
}
}
fn snapshot(order: &[&str]) -> ExtensionsSnapshot {
let mut snapshot = ExtensionsSnapshot::default();
snapshot.tabs[ExtensionsTab::Plugins.index()] = ExtensionsTabModel {
groups: vec![ExtensionGroup {
id: "g".into(),
label: "g".into(),
items: order.iter().map(|id| row(id)).collect(),
}],
problem: None,
};
snapshot
}
let mut view = ExtensionsView::from_snapshot_with_locale(
snapshot(&["alpha", "beta"]),
ExtensionsTab::Plugins,
Locale::En,
);
// Group heading, alpha, beta: select beta.
view.selected[ExtensionsTab::Plugins.index()] = 2;
assert_eq!(
view.selected_item().map(|item| item.id.as_str()),
Some("beta")
);
view.refresh_snapshot(snapshot(&["beta", "alpha"]));
assert_eq!(
view.selected_item().map(|item| item.id.as_str()),
Some("beta")
);
match view.toggle_selected() {
ViewAction::Emit(ViewEvent::ExecutePanelCommand { command, .. }) => {
assert_eq!(command, "/plugin disable beta");
}
other => panic!("expected the toggle command, got {other:?}"),
}
// A selected entity that is gone falls back to the clamped row.
view.refresh_snapshot(snapshot(&["alpha"]));
assert_eq!(
view.selected_item().map(|item| item.id.as_str()),
Some("alpha")
);
}
}
+88 -4
View File
@@ -249,6 +249,32 @@ impl FleetDetailView {
.and_then(|idx| self.fleet.members.get(idx))
}
/// Whether the file on disk is still exactly what this editor loaded (or
/// last wrote). Every write from the editor checks it first, so a save
/// never replaces a newer version written by another session or by hand,
/// and never recreates a file that was removed meanwhile.
///
/// Known limitation: this is check-then-write, not a lock; a write that
/// lands between the check and the atomic rename is not detected.
fn source_unchanged(&self) -> bool {
self.saved_source.is_some()
&& std::fs::read_to_string(&self.source).ok() == self.saved_source
}
/// The pager shown when the source moved underneath the editor.
fn stale_source_action(&self, title: &str) -> ViewAction {
ViewAction::Emit(ViewEvent::OpenTextPager {
title: title.to_string(),
content: format!(
"Nothing was written.\n\n{} changed on disk after this editor opened it \
(another session or an external edit). Saving now would overwrite \
that newer version.\n\nClose this editor and reopen the team to load \
the current file, then make the change again.",
self.source.display()
),
})
}
fn start_rename(&mut self) {
self.rename_mode = true;
self.rename_input = self.fleet.name.clone();
@@ -264,6 +290,10 @@ impl FleetDetailView {
self.rename_mode = false;
return Some(ViewAction::None);
}
if !self.source_unchanged() {
self.rename_mode = false;
return Some(self.stale_source_action("Rename refused"));
}
// The rename must not collide with a different Fleet of the same slug
// in this scope (the store refuses that at save).
let old_name = self.fleet.name.clone();
@@ -479,10 +509,7 @@ impl FleetDetailView {
) -> Result<String, String> {
// A picker belongs to one editor instance and the exact saved file
// it opened. Never recreate a removed file or overwrite newer edits.
if editor_id != self.editor_id
|| self.saved_source.is_none()
|| std::fs::read_to_string(&self.source).ok() != self.saved_source
{
if editor_id != self.editor_id || !self.source_unchanged() {
return Err(tr(self.locale, MessageId::FleetRoutePickUnavailable).into_owned());
}
if let FleetRouteTarget::Member(idx) = target
@@ -643,6 +670,9 @@ impl FleetDetailView {
}
fn save(&self) -> Option<ViewAction> {
if !self.source_unchanged() {
return Some(self.stale_source_action("Save refused"));
}
match save_fleet(&self.fleet, self.scope, &self.workspace) {
Ok(path) => Some(ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged {
message: format!(
@@ -2120,4 +2150,58 @@ mod tests {
view.handle_key(key(KeyCode::Char('y')));
assert_eq!(view.fleet.members, fleet.members);
}
/// U09-01: an ordinary Save or rename never overwrites a Fleet file that
/// changed on disk after the editor opened it; the newer version survives
/// and the editor says to reload.
#[test]
fn save_and_rename_refuse_an_externally_newer_source() {
let ws = tempfile::TempDir::new().unwrap();
let fleet = sample_fleet("Fleet S");
let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
let mut view = FleetDetailView::open(
&app_in(ws.path().to_path_buf()),
&Config::default(),
"Fleet S",
FleetScope::Workspace,
)
.expect("open");
// Another session saves a newer version of the same team.
let mut newer = fleet.clone();
newer.operator = Some(FleetOperator {
provider: "openai".to_string(),
model: "gpt-5.6".to_string(),
reasoning: None,
});
save_fleet(&newer, FleetScope::Workspace, ws.path()).unwrap();
let on_disk = std::fs::read(&path).unwrap();
view.fleet.operator = Some(FleetOperator {
provider: "deepseek".to_string(),
model: "deepseek-v4-pro".to_string(),
reasoning: None,
});
let action = view.handle_key(key(KeyCode::Char('s')));
assert!(
matches!(&action, ViewAction::Emit(ViewEvent::OpenTextPager { title, .. }) if title == "Save refused"),
"{action:?}"
);
assert_eq!(std::fs::read(&path).unwrap(), on_disk);
view.handle_key(key(KeyCode::Char('r')));
for _ in "Fleet S".chars() {
view.handle_key(key(KeyCode::Backspace));
}
for ch in "Fleet T".chars() {
view.handle_key(key(KeyCode::Char(ch)));
}
let action = view.handle_key(key(KeyCode::Enter));
assert!(
matches!(&action, ViewAction::Emit(ViewEvent::OpenTextPager { title, .. }) if title == "Rename refused"),
"{action:?}"
);
assert_eq!(view.fleet.name, "Fleet S");
assert_eq!(std::fs::read(&path).unwrap(), on_disk);
}
}
+54 -5
View File
@@ -453,14 +453,30 @@ impl FleetListView {
return;
}
let rows_visible = usize::from(area.height).max(1);
let scroll = self.row.saturating_sub(rows_visible.saturating_sub(1));
// Entries paint two rows (name + summary), one while a delete is
// armed. Scroll and fit by painted rows, not entry count, so the
// selected entry is always fully on screen.
let height = usize::from(area.height).max(1);
let entry_height = |idx: usize| {
if self.pending_delete == Some(idx) {
1
} else {
2
}
};
let row = self.row.min(self.entries.len() - 1);
let mut scroll = row;
let mut used = entry_height(row);
while scroll > 0 && used + entry_height(scroll - 1) <= height {
scroll -= 1;
used += entry_height(scroll);
}
let mut lines = Vec::new();
let mut hitboxes = Vec::new();
for (idx, entry) in self.entries.iter().enumerate() {
if idx < scroll || idx >= scroll + rows_visible {
continue;
for (idx, entry) in self.entries.iter().enumerate().skip(scroll) {
if !lines.is_empty() && lines.len() + entry_height(idx) > height {
break;
}
let selected = idx == self.row;
let is_selected_fleet = self
@@ -1081,4 +1097,37 @@ provider = "deepseek"
"hovered entry must show the shared hover band"
);
}
/// U09-m5: entries paint two rows (name + summary). Scrolling by entry
/// count pushed the selected bottom entry below the list; every painted
/// entry, the selected one included, must fit inside the list area.
#[test]
fn selected_entry_stays_on_screen_when_entries_are_two_rows() {
let _lock = crate::test_support::lock_test_env();
let home = tempfile::TempDir::new().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let ws = tempfile::TempDir::new().unwrap();
for name in ["Alpha", "Bravo", "Charlie", "Delta", "Echo"] {
save_in(ws.path(), FleetScope::Workspace, name);
}
let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
assert_eq!(view.entries.len(), 5);
let area = Rect::new(0, 0, 80, 4);
for row in 0..view.entries.len() {
view.row = row;
let mut buf = Buffer::empty(area);
view.render_rows(area, &mut buf);
let hitboxes = view.row_hitboxes.borrow();
assert!(
hitboxes.iter().any(|(_, idx)| *idx == row),
"row {row} not painted: {hitboxes:?}"
);
assert!(
hitboxes
.iter()
.all(|(rect, _)| rect.bottom() <= area.bottom()),
"row {row}: {hitboxes:?}"
);
}
}
}
+118 -22
View File
@@ -719,6 +719,14 @@ struct RouteAssignment {
reasoning: Option<String>,
}
/// The wizard answers a model draft was requested against (U09-03).
#[derive(Debug, Clone, PartialEq, Eq)]
struct ModelDraftRequest {
role: String,
route: Option<(String, String)>,
reasoning_effort: Option<String>,
}
fn assignment_source(path: &Path) -> Result<Option<String>, String> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
@@ -765,6 +773,11 @@ pub struct FleetSetupView {
/// pressing Esc. Keeping the preview and the save control in the same
/// view means the footer's `g`/Enter hints are never a lie.
model_draft_preview: Option<String>,
/// The answers the in-flight model draft (`m` on Review) was requested
/// against. Cleared with the draft whenever an answer changes, so a
/// draft that lands late is refused instead of being installed onto
/// answers it was never written for.
model_draft_request: Option<ModelDraftRequest>,
/// Model-step rows: `inherit` followed by one row per concrete model from
/// every configured provider (#4093).
model_choices: Vec<Choice>,
@@ -812,6 +825,7 @@ impl FleetSetupView {
let old_review_scroll = self.review_scroll;
let old_model_draft = self.model_draft.clone();
let old_model_draft_preview = self.model_draft_preview.clone();
let old_model_draft_request = self.model_draft_request.clone();
*self = Self::from_snapshot(snapshot);
@@ -828,6 +842,7 @@ impl FleetSetupView {
self.review_scroll = old_review_scroll;
self.model_draft = old_model_draft;
self.model_draft_preview = old_model_draft_preview;
self.model_draft_request = old_model_draft_request;
if self.step == Step::Composition && !self.has_composition_for_selected_role() {
self.step = Step::Model;
}
@@ -1209,6 +1224,7 @@ impl FleetSetupView {
review_scroll: 0,
model_draft: None,
model_draft_preview: None,
model_draft_request: None,
model_choices,
model_routes,
model_row_states,
@@ -1224,21 +1240,34 @@ impl FleetSetupView {
/// (returned here for the caller's status message) renders inline on the
/// Review step — not in a separate pager — so the footer's `g`/Enter
/// ratify hints stay true the instant the draft lands (#4093).
///
/// `None` refuses a draft that no longer matches the wizard: an answer
/// changed (or the draft was discarded) while it was in flight. The
/// request generation only orders requests; it cannot see an edit made
/// after the last `m`, so the wizard checks its own answers (U09-03).
pub fn install_model_draft(
&mut self,
mut draft: Box<crate::fleet::profile::FleetProfileDraft>,
model_label: String,
picked_route: Option<(String, String)>,
reasoning_effort: Option<String>,
) -> (String, String) {
) -> Option<(String, String)> {
let current = self.current_draft_request();
if self.model_draft_request.as_ref() != Some(&current)
|| current.route != picked_route
|| current.reasoning_effort != reasoning_effort
{
return None;
}
self.model_draft_request = None;
// Re-inject the route the operator picked at `m`-press time (#4093). A
// model draft comes from `from_untrusted_json`, which hard-sets
// `provider: None` and echoes whatever `model` the model happened to
// emit — so ratifying it verbatim would drop a concrete cross-provider
// pick and persist the ambiguous, provider-scoped profile #4093 exists
// to prevent. Pinning BOTH fields from the CARRIED route keeps the route
// the user actually chose (the model only authored the prose), and is
// immune to the selection changing while the async draft is in flight.
// the user actually chose (the model only authored the prose); the
// check above already refused a draft whose answers changed in flight.
// `inherit` (a `None` route) leaves `model`/`provider` untouched,
// matching the deterministic Enter path.
if let Some((provider, model)) = picked_route {
@@ -1261,7 +1290,7 @@ impl FleetSetupView {
self.model_draft = Some(draft);
self.model_draft_preview = Some(content.clone());
self.review_scroll = 0;
(title, content)
Some((title, content))
}
/// The planner role chosen (drives the profile file name and `role_hint`).
@@ -1569,10 +1598,20 @@ impl FleetSetupView {
}
}
/// A draft is only valid for the answers it was requested against.
/// A draft is only valid for the answers it was requested against —
/// including one still in flight.
fn discard_model_draft(&mut self) {
self.model_draft = None;
self.model_draft_preview = None;
self.model_draft_request = None;
}
fn current_draft_request(&self) -> ModelDraftRequest {
ModelDraftRequest {
role: self.selected_role(),
route: self.selected_route(),
reasoning_effort: self.selected_reasoning_effort(),
}
}
fn move_down(&mut self) {
@@ -2107,6 +2146,7 @@ impl ModalView for FleetSetupView {
}
KeyCode::Char('m') if self.step == Step::Review && self.snapshot.provider_ready => {
let route = self.selected_route();
self.model_draft_request = Some(self.current_draft_request());
ViewAction::Emit(ViewEvent::FleetProfileModelDraftRequested {
role: self.selected_role(),
model: route
@@ -2115,8 +2155,8 @@ impl ModalView for FleetSetupView {
.unwrap_or_else(|| "inherit".to_string()),
// Carry the picked provider so the redrafted profile keeps
// the cross-provider route (#4093). `install_model_draft`
// re-injects it authoritatively from the wizard's current
// selection, but the event stays self-describing.
// re-injects it and refuses a draft whose answers changed
// while it was in flight; the event stays self-describing.
provider: route.map(|(provider, _)| provider),
reasoning_effort: self.selected_reasoning_effort(),
locale: self.snapshot.locale,
@@ -3858,7 +3898,7 @@ approval_required = true
// The host reconstructs the picked route from the event exactly as
// `handle_fleet_profile_model_draft` does, and carries it to
// `install_model_draft` (immune to the selection changing mid-draft).
// `install_model_draft`, which refuses it if the answers changed.
let picked_route = provider.map(|provider| (provider, model.clone()));
// The model returns a draft that (as always) has provider: None — the
@@ -3869,12 +3909,14 @@ approval_required = true
// Installing it re-injects the picked route, so the ratified draft keeps
// BOTH the provider and the model the user actually chose, plus the
// captured thinking tier.
let (_title, content) = view.install_model_draft(
drafted,
"GLM-5.2".to_string(),
picked_route,
reasoning_effort,
);
let (_title, content) = view
.install_model_draft(
drafted,
"GLM-5.2".to_string(),
picked_route,
reasoning_effort,
)
.expect("answers unchanged since `m`");
let ratified = view.model_draft.as_deref().expect("draft installed");
assert_eq!(ratified.provider.as_deref(), Some("zai"));
assert_eq!(ratified.model.as_deref(), Some("glm-5.2"));
@@ -4019,8 +4061,10 @@ approval_required = true
let mut view = FleetSetupView::from_snapshot(snapshot());
to_review(&mut view);
let (title, content) =
view.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None);
view.handle_key(key(KeyCode::Char('m')));
let (title, content) = view
.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None)
.expect("answers unchanged since `m`");
assert!(title.contains("GLM-5.2"));
assert!(content.contains("id = \"reviewer\""), "{content}");
assert!(content.contains("Nothing is saved until"), "{content}");
@@ -4039,7 +4083,11 @@ approval_required = true
fn changing_answers_discards_a_stale_draft() {
let mut view = FleetSetupView::from_snapshot(snapshot());
to_review(&mut view);
let _ = view.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None);
view.handle_key(key(KeyCode::Char('m')));
assert!(
view.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None)
.is_some()
);
assert!(view.model_draft.is_some());
// Back to the role step and change the selection: the draft no
@@ -4061,6 +4109,49 @@ approval_required = true
assert_eq!(draft.id, "explore");
}
/// U09-03: the request generation orders `m` presses but cannot see an
/// answer changed after the last one. A draft that lands after the user
/// changed the thinking tier (or role, or route) mid-flight is refused,
/// never installed onto answers it was not written for.
#[test]
fn late_model_draft_is_refused_after_answers_change_in_flight() {
let mut view = FleetSetupView::from_snapshot(snapshot());
to_review(&mut view);
let ViewAction::Emit(ViewEvent::FleetProfileModelDraftRequested {
reasoning_effort, ..
}) = view.handle_key(key(KeyCode::Char('m')))
else {
panic!("expected model draft request");
};
// While the draft is in flight the user cycles the thinking tier.
view.handle_key(key(KeyCode::Char('t')));
assert!(
view.install_model_draft(
sample_draft(),
"GLM-5.2".to_string(),
None,
reasoning_effort.clone()
)
.is_none()
);
assert!(view.model_draft.is_none());
let action = view.handle_key(key(KeyCode::Char('g')));
let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, .. }) =
action
else {
panic!("expected the deterministic starter for the current answers");
};
assert_eq!(draft.id, "manager");
// A draft nobody requested (no `m`) is refused too.
let mut view = FleetSetupView::from_snapshot(snapshot());
to_review(&mut view);
assert!(
view.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None)
.is_none()
);
}
#[test]
fn arrows_move_within_step_and_enter_advances() {
let mut view = FleetSetupView::from_snapshot(snapshot());
@@ -4603,15 +4694,20 @@ approval_required = true
model: "model-a".to_string(),
..snapshot()
});
let route = view
let route_idx = view
.model_routes
.iter()
.find(|(provider, model)| provider == "custom-b" && model == "model-b")
.cloned()
.position(|(provider, model)| provider == "custom-b" && model == "model-b")
.expect("custom B route selectable while A is active");
let route = view.model_routes[route_idx].clone();
// Request the draft against the custom B answer, as `m` on Review does.
view.model_idx = route_idx;
view.step = Step::Review;
view.handle_key(key(KeyCode::Char('m')));
let draft = sample_draft();
let (_, rendered) =
view.install_model_draft(draft, "model-b".to_string(), Some(route), None);
let (_, rendered) = view
.install_model_draft(draft, "model-b".to_string(), Some(route), None)
.expect("answers unchanged since `m`");
assert!(rendered.contains("provider = \"custom-b\""), "{rendered}");
}
+36 -5
View File
@@ -169,6 +169,24 @@ pub(crate) fn render_underwater_surface(
inner
}
/// Render wrapped detail text scrolled by *rendered* rows and return the
/// largest useful scroll. Clamping by logical lines before wrapping left the
/// wrapped tail unreachable and made each step jump a whole wrapped line.
pub(crate) fn render_wrapped_detail(
lines: Vec<Line<'static>>,
area: Rect,
buf: &mut Buffer,
requested_scroll: usize,
) -> usize {
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
let max_scroll = paragraph
.line_count(area.width.max(1))
.saturating_sub(usize::from(area.height).max(1));
let scroll = u16::try_from(requested_scroll.min(max_scroll)).unwrap_or(u16::MAX);
paragraph.scroll((scroll, 0)).render(area, buf);
max_scroll
}
/// Paint a scrollbar on the exact right edge of the panel it controls and
/// return the content rect with that rail reserved. Nothing is drawn when all
/// rows fit, so narrow surfaces do not spend a column on a fictional control.
@@ -5754,7 +5772,7 @@ pub(crate) fn subagent_view_agents(
agents.push(live_subagent_result(
&card.agent_id,
agent_type,
lifecycle_to_subagent_status(card.status),
lifecycle_to_subagent_status(card.status, card.summary.as_deref()),
card.summary.as_deref().unwrap_or(card.agent_type.as_str()),
Some("transcript"),
None, // transcript-derived rows get nickname from manager on render
@@ -5771,7 +5789,7 @@ pub(crate) fn subagent_view_agents(
agents.push(live_subagent_result(
&worker.agent_id,
FleetRole::Worker,
lifecycle_to_subagent_status(worker.status),
lifecycle_to_subagent_status(worker.status, None),
&objective,
Some(card.kind.as_str()),
None, // fanout worker rows get nickname from manager on render
@@ -5814,14 +5832,27 @@ pub(crate) fn subagent_view_agents(
agents
}
fn lifecycle_to_subagent_status(status: AgentLifecycle) -> SubAgentStatus {
/// Project a transcript card's lifecycle onto the manager status vocabulary.
/// A failure or interruption keeps the reason the card recorded from its
/// terminal envelope; only a card that recorded none gets the generic line.
///
/// Known limitation: `SubAgentStatus` has no pending state, so a spawned but
/// not yet started worker reads as running here.
fn lifecycle_to_subagent_status(status: AgentLifecycle, reason: Option<&str>) -> SubAgentStatus {
let reason = |fallback: &str| {
reason
.map(str::trim)
.filter(|reason| !reason.is_empty())
.unwrap_or(fallback)
.to_string()
};
match status {
AgentLifecycle::Pending | AgentLifecycle::Running => SubAgentStatus::Running,
AgentLifecycle::Completed => SubAgentStatus::Completed,
AgentLifecycle::Failed => SubAgentStatus::Failed("failed in transcript".to_string()),
AgentLifecycle::Failed => SubAgentStatus::Failed(reason("failed in transcript")),
AgentLifecycle::Cancelled => SubAgentStatus::Cancelled,
AgentLifecycle::Interrupted => {
SubAgentStatus::Interrupted("interrupted in transcript".to_string())
SubAgentStatus::Interrupted(reason("interrupted in transcript"))
}
}
}
+66 -14
View File
@@ -56,11 +56,26 @@ fn status_style(status: &str) -> Style {
}
}
/// One glyph per projected child state. Only a real failure reads as `✗`:
/// a cancellation is not a failure, and a state this view does not know
/// must not borrow the failure mark (U09-m3).
fn child_state_glyph(state: &str) -> &'static str {
match state {
"running" | "pending" => "•",
"succeeded" => "✓",
_ => "✗",
"failed" | "budget_exceeded" => "✗",
"cancelled" => "–",
_ => "?",
}
}
/// A child with no completion receipt in a settled run did not finish as far
/// as this record shows; it is not still running (U09-m3).
fn child_display_state(child: &HostWorkflowChildRow, run_active: bool) -> &'static str {
if !run_active && matches!(child.state, "running" | "pending") {
"unknown"
} else {
child.state
}
}
@@ -80,6 +95,8 @@ pub struct WorkflowsManagerView {
row: usize,
detail_open: bool,
detail_scroll: usize,
/// Largest useful detail scroll in rendered rows, from the last paint.
detail_max_scroll: Cell<usize>,
/// Receipt line for the last host action (cancel), shown under the header.
status: Option<String>,
workspace: PathBuf,
@@ -99,6 +116,7 @@ impl WorkflowsManagerView {
row: 0,
detail_open: false,
detail_scroll: 0,
detail_max_scroll: Cell::new(usize::MAX),
status: None,
workspace: app.workspace.clone(),
owner_session_id: app.current_session_id.clone(),
@@ -334,7 +352,7 @@ impl WorkflowsManagerView {
Style::default().fg(palette::TEXT_PRIMARY).bold(),
)));
for child in &detail.children {
lines.push(child_row_line(child));
lines.push(child_row_line(child, detail.line.active));
}
lines.push(Line::from(""));
}
@@ -377,24 +395,25 @@ impl WorkflowsManagerView {
return;
};
let lines = self.detail_lines(detail);
let visible = usize::from(area.height).max(1);
let max_scroll = lines.len().saturating_sub(visible);
let scroll = self.detail_scroll.min(max_scroll);
Paragraph::new(lines.iter().skip(scroll).cloned().collect::<Vec<_>>())
.wrap(Wrap { trim: false })
.render(area, buf);
self.detail_max_scroll.set(super::render_wrapped_detail(
lines,
area,
buf,
self.detail_scroll,
));
}
}
fn child_row_line(child: &HostWorkflowChildRow) -> Line<'static> {
fn child_row_line(child: &HostWorkflowChildRow, run_active: bool) -> Line<'static> {
let name = child.label.clone().unwrap_or_else(|| child.task_id.clone());
let state = child_display_state(child, run_active);
let mut spans = vec![
Span::styled(
format!(" {} ", child_state_glyph(child.state)),
status_style(child.state),
format!(" {} ", child_state_glyph(state)),
status_style(state),
),
Span::styled(name, Style::default().fg(palette::TEXT_SECONDARY)),
Span::styled(format!(" · {}", child.state), status_style(child.state)),
Span::styled(format!(" · {state}"), status_style(state)),
];
let mut meta = Vec::new();
if let Some(role) = child.role.as_deref() {
@@ -446,8 +465,11 @@ impl ModalView for WorkflowsManagerView {
}
KeyCode::Down | KeyCode::Char('j') => {
if self.detail_open {
// The render path clamps to the last scrollable line.
self.detail_scroll = self.detail_scroll.saturating_add(1);
// Stop at the last rendered (wrapped) row of the detail.
self.detail_scroll = self
.detail_scroll
.saturating_add(1)
.min(self.detail_max_scroll.get());
} else {
self.move_row(1);
}
@@ -551,3 +573,33 @@ impl ModalView for WorkflowsManagerView {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn child(state: &'static str) -> HostWorkflowChildRow {
HostWorkflowChildRow {
task_id: "t1".to_string(),
label: None,
role: None,
model: None,
phase: None,
state,
}
}
/// U09-m3: a child with no completion receipt in a settled run is not
/// "running", a cancellation is not a failure, and an unknown state never
/// borrows the failure mark.
#[test]
fn child_rows_keep_their_source_of_truth_state() {
assert_eq!(child_display_state(&child("running"), true), "running");
assert_eq!(child_display_state(&child("running"), false), "unknown");
assert_eq!(child_display_state(&child("succeeded"), false), "succeeded");
assert_eq!(child_state_glyph("unknown"), "?");
assert_eq!(child_state_glyph("cancelled"), "–");
assert_eq!(child_state_glyph("failed"), "✗");
assert_eq!(child_state_glyph("budget_exceeded"), "✗");
}
}
+190 -23
View File
@@ -365,14 +365,15 @@ impl FanoutCard {
}
/// Update or insert a worker by id. Returns whether the visible state
/// changed and the card should be redrawn.
/// changed and the card should be redrawn. A settled worker is final: a
/// delayed envelope for it never reopens or re-settles the slot.
pub fn upsert_worker(&mut self, agent_id: &str, status: AgentLifecycle) -> bool {
if let Some(slot) = self
.workers
.iter_mut()
.find(|s| s.agent_id == agent_id || s.worker_id == agent_id)
{
if slot.agent_id == agent_id && slot.status == status {
if slot.status.is_terminal() || (slot.agent_id == agent_id && slot.status == status) {
return false;
}
slot.agent_id = agent_id.to_string();
@@ -390,7 +391,7 @@ impl FanoutCard {
/// circle for the same unit of work.
pub fn claim_pending_worker(&mut self, agent_id: &str, status: AgentLifecycle) -> bool {
if let Some(slot) = self.workers.iter_mut().find(|s| s.agent_id == agent_id) {
if slot.status == status {
if slot.status.is_terminal() || slot.status == status {
return false;
}
slot.status = status;
@@ -443,33 +444,51 @@ impl FanoutCard {
s
}
/// Header plus dot grid, one glyph per worker. The grid never runs past
/// `width`: when it does not fit beside the header it wraps onto its own
/// rows, so no worker's state is clipped off the transcript edge.
#[must_use]
pub fn render_lines(&self, _width: u16, theme: &palette::UiTheme) -> Vec<Line<'static>> {
pub fn render_lines(&self, width: u16, theme: &palette::UiTheme) -> Vec<Line<'static>> {
let header_status = self.aggregate_status();
let count = self.workers.len();
let count_label = if count == 1 { "agent" } else { "agents" };
vec![Line::from(vec![
let glyph = family_glyph(ToolFamily::Fanout);
let count_text = format!("{count} {count_label}");
let grid_style = Style::default()
.fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme))
.add_modifier(Modifier::BOLD);
let mut header = vec![
Span::styled(
family_glyph(ToolFamily::Fanout),
glyph,
Style::default()
.fg(header_status.color(theme))
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("{count} {count_label}"),
truncate_action(
&count_text,
usize::from(width).saturating_sub(UnicodeWidthStr::width(glyph) + 1),
),
Style::default()
.fg(codewhale_palette::grammar::ChromeInk::Identity.color(theme))
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
self.dot_grid(),
Style::default()
.fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme))
.add_modifier(Modifier::BOLD),
),
])]
];
let grid: Vec<char> = self.dot_grid().chars().collect();
let header_width = UnicodeWidthStr::width(glyph) + 1 + count_text.width() + 1;
let width = usize::from(width).max(1);
if header_width + grid.len() <= width {
header.push(Span::raw(" "));
header.push(Span::styled(grid.iter().collect::<String>(), grid_style));
return vec![Line::from(header)];
}
let mut lines = vec![Line::from(header)];
lines.extend(
grid.chunks(width)
.map(|row| Line::from(Span::styled(row.iter().collect::<String>(), grid_style))),
);
lines
}
fn aggregate_status(&self) -> AgentLifecycle {
@@ -477,22 +496,25 @@ impl FanoutCard {
}
/// Public aggregate lifecycle for the activity shelf and other projectors.
///
/// A settled fanout reports its worst outcome: one failed worker among
/// finished ones is a failed fanout, not a completed one; an
/// interrupted or cancelled worker likewise outranks the completions.
#[must_use]
pub fn aggregate_status_public(&self) -> AgentLifecycle {
let (done, running, failed, pending) = self.counts();
let (done, running, _, pending) = self.counts();
let any = |status: AgentLifecycle| self.workers.iter().any(|slot| slot.status == status);
if running > 0 {
AgentLifecycle::Running
} else if pending > 0 {
// Pending workers wait — amber attention, not "running" teal.
AgentLifecycle::Pending
} else if self
.workers
.iter()
.any(|slot| matches!(slot.status, AgentLifecycle::Interrupted))
{
AgentLifecycle::Interrupted
} else if failed > 0 && done == 0 {
} else if any(AgentLifecycle::Failed) {
AgentLifecycle::Failed
} else if any(AgentLifecycle::Interrupted) {
AgentLifecycle::Interrupted
} else if any(AgentLifecycle::Cancelled) {
AgentLifecycle::Cancelled
} else if done > 0 {
AgentLifecycle::Completed
} else {
@@ -559,6 +581,25 @@ pub fn apply_to_delegate(card: &mut DelegateCard, msg: &MailboxMessage) -> bool
return false;
}
let was_terminal = card.status.is_terminal();
// A settled agent is final. Mailbox producers are concurrent (a tool
// task can outlive its agent), so a Started/Progress/tool envelope may
// land after the terminal one; it must not reopen the card, stretch its
// elapsed time, or rewrite its outcome. Only the child's own Work
// snapshot still applies (it keeps the terminal status), and a late
// `Started` may still name the role of a card that a completion-first
// delivery opened with the `unknown` placeholder — identity, not state.
if was_terminal {
if let MailboxMessage::Started { agent_type, .. } = msg
&& card.agent_type == "unknown"
&& agent_type != "unknown"
{
card.agent_type = agent_type.clone();
return true;
}
if !matches!(msg, MailboxMessage::WorkState { .. }) {
return false;
}
}
if card.started_at.is_none()
&& matches!(
msg,
@@ -1392,4 +1433,130 @@ mod tests {
assert!(joined.contains("result:"), "{joined}");
assert!(joined.contains("mapped 4 call sites"), "{joined}");
}
/// U05-04: mailbox producers are concurrent, so a Started/Progress/tool
/// envelope can land after an agent's terminal one. It must not reopen
/// the card or its fanout slot, and must not move a settled count.
#[test]
fn late_envelopes_never_reopen_a_settled_agent() {
let role = || crate::tools::subagent::FleetRole::Worker;
let mut card = DelegateCard::new("agent_late", "general");
apply_to_delegate(&mut card, &MailboxMessage::started("agent_late", role()));
assert!(apply_to_delegate(
&mut card,
&MailboxMessage::Completed {
agent_id: "agent_late".into(),
summary: "done".into(),
},
));
let finished_at = card.finished_at;
for late in [
MailboxMessage::started("agent_late", role()),
MailboxMessage::progress("agent_late", "step 4: reading files"),
MailboxMessage::ToolCallStarted {
agent_id: "agent_late".into(),
tool_name: "read_file".into(),
step: 4,
},
MailboxMessage::Failed {
agent_id: "agent_late".into(),
error: "late".into(),
},
] {
assert!(!apply_to_delegate(&mut card, &late), "{late:?}");
}
assert_eq!(card.status, AgentLifecycle::Completed);
assert_eq!(card.summary.as_deref(), Some("done"));
assert_eq!(card.finished_at, finished_at);
// A completion-first recovery card may still learn its role from a
// late `Started`, but it stays settled.
let mut recovered = DelegateCard::new("agent_r", "unknown");
apply_to_delegate(
&mut recovered,
&MailboxMessage::Completed {
agent_id: "agent_r".into(),
summary: "done".into(),
},
);
assert!(apply_to_delegate(
&mut recovered,
&MailboxMessage::started("agent_r", role())
));
assert_ne!(recovered.agent_type, "unknown");
assert_eq!(recovered.status, AgentLifecycle::Completed);
let mut fanout = FanoutCard::new("rlm");
for id in ["w_1", "w_2"] {
apply_to_fanout(
&mut fanout,
&MailboxMessage::ChildSpawned {
parent_id: "root".into(),
child_id: id.into(),
},
);
apply_to_fanout(&mut fanout, &MailboxMessage::started(id, role()));
apply_to_fanout(
&mut fanout,
&MailboxMessage::Completed {
agent_id: id.into(),
summary: "ok".into(),
},
);
}
assert!(!apply_to_fanout(
&mut fanout,
&MailboxMessage::progress("w_1", "step 9: late")
));
assert!(!apply_to_fanout(
&mut fanout,
&MailboxMessage::started("w_2", role())
));
assert_eq!(fanout.worker_count(), 2);
assert_eq!(fanout.counts(), (2, 0, 0, 0));
assert_eq!(fanout.aggregate_status_public(), AgentLifecycle::Completed);
}
/// U05-m2: a settled fanout headlines its worst outcome — one failure
/// among completions is a failed fanout, not a completed one.
#[test]
fn mixed_fanout_outcomes_never_aggregate_to_completed() {
let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2", "w_3"]);
card.upsert_worker("w_1", AgentLifecycle::Completed);
card.upsert_worker("w_2", AgentLifecycle::Completed);
card.upsert_worker("w_3", AgentLifecycle::Failed);
assert_eq!(card.aggregate_status_public(), AgentLifecycle::Failed);
let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2"]);
card.upsert_worker("w_1", AgentLifecycle::Completed);
card.upsert_worker("w_2", AgentLifecycle::Cancelled);
assert_eq!(card.aggregate_status_public(), AgentLifecycle::Cancelled);
let mut card = FanoutCard::new("rlm").with_workers(["w_1"]);
card.upsert_worker("w_1", AgentLifecycle::Completed);
assert_eq!(card.aggregate_status_public(), AgentLifecycle::Completed);
}
/// U05-m6: a wide fanout wraps its dot grid instead of running past the
/// transcript width, and every worker keeps its glyph.
#[test]
fn fanout_grid_wraps_within_the_render_width() {
let ids: Vec<String> = (0..40).map(|i| format!("w_{i}")).collect();
let card = FanoutCard::new("rlm").with_workers(ids.iter().cloned());
for width in [12_u16, 20, 80] {
let lines = render_to_strings(&card.render_lines(width, &codewhale_palette::UI_THEME));
for line in &lines {
assert!(
UnicodeWidthStr::width(line.as_str()) <= usize::from(width),
"{width}: {line:?}"
);
}
let glyphs = lines
.join("")
.chars()
.filter(|ch| *ch == '\u{25CB}')
.count();
assert_eq!(glyphs, 40, "{width}: {lines:?}");
}
}
}
+100 -8
View File
@@ -558,11 +558,13 @@ impl WorkflowPanelEvent {
at_ms,
}),
"run_completed" => {
// A terminal receipt without a readable status is not
// evidence of success; it fails closed like an unknown one.
let status = value
.get("status")
.and_then(Value::as_str)
.map(lifecycle_from_status)
.unwrap_or(WorkflowPanelLifecycle::Succeeded);
.unwrap_or(WorkflowPanelLifecycle::Failed);
Some(Self::RunCompleted {
status,
error: opt_str(value, "error"),
@@ -599,7 +601,7 @@ impl WorkflowPanelEvent {
.get("status")
.and_then(Value::as_str)
.map(WorkflowRowStatus::from_ir_status)
.unwrap_or(WorkflowRowStatus::Succeeded);
.unwrap_or(WorkflowRowStatus::Failed);
Some(Self::TaskCompleted {
task_id: opt_str(value, "task_id")?,
status,
@@ -1409,8 +1411,10 @@ impl WorkflowPanel {
if self.lifecycle == WorkflowPanelLifecycle::Cancelled {
return;
}
self.lifecycle = if matches!(status, WorkflowPanelLifecycle::Running) {
WorkflowPanelLifecycle::Succeeded
// A completion receipt that still says running/pending is
// contradictory, not a success.
self.lifecycle = if status.is_running() {
WorkflowPanelLifecycle::Failed
} else {
status
};
@@ -1442,13 +1446,22 @@ impl WorkflowPanel {
route,
at_ms,
} => {
// A settled run is final. A child launch that raced the
// cancel (or any late same-run start) is recorded, but it
// never reopens the run or resets a row that already settled.
let settled = self.lifecycle.is_terminal();
if settled && self.find_row_mut(&task_id).is_some() {
return;
}
if self.phases.is_empty() {
self.phases.push(WorkflowPanelPhase::new("Work"));
self.selected_phase = 0;
}
let phase_idx = self.selected_phase.min(self.phases.len().saturating_sub(1));
// Tasks join the runtime's newest phase. `selected_phase` is
// the render cursor and never routes an arriving task.
let phase_idx = self.phases.len().saturating_sub(1);
let display_model = resolved_model.or(model);
let row = WorkflowPanelRow {
let mut row = WorkflowPanelRow {
task_id: task_id.clone(),
label: label
.filter(|s| !s.trim().is_empty())
@@ -1466,12 +1479,20 @@ impl WorkflowPanel {
route: *route,
usage: None,
};
if self.lifecycle == WorkflowPanelLifecycle::Cancelled {
// Same finalization the cancel applied to running rows.
row.status = WorkflowRowStatus::Cancelled;
row.completed_at_ms = Some(at_ms);
row.usage = Some(WorkflowRowUsage::default());
}
if let Some(existing) = self.find_row_mut(&task_id) {
*existing = row;
} else if let Some(phase) = self.phases.get_mut(phase_idx) {
phase.rows.push(row);
}
self.lifecycle = WorkflowPanelLifecycle::Running;
if !settled {
self.lifecycle = WorkflowPanelLifecycle::Running;
}
}
WorkflowPanelEvent::TaskCompleted {
task_id,
@@ -1525,7 +1546,7 @@ impl WorkflowPanel {
if self.phases.is_empty() {
self.phases.push(WorkflowPanelPhase::new("Work"));
}
let phase_idx = self.selected_phase.min(self.phases.len().saturating_sub(1));
let phase_idx = self.phases.len().saturating_sub(1);
if let Some(phase) = self.phases.get_mut(phase_idx) {
phase.rows.push(WorkflowPanelRow {
task_id,
@@ -2707,6 +2728,77 @@ mod tests {
);
}
/// U05-01: a child launch that raced the cancel (or any late same-run
/// `task_started`) lands after the run settled. It is recorded, but it
/// never reopens the run, and a settled row is never reset to running.
#[test]
fn late_task_started_never_reopens_a_settled_run() {
let mut panel = started_panel();
panel.apply_json_event(&json!({
"type": "run_cancelled", "run_id": "workflow_abc",
"reason": "stopped by you", "at_ms": 2_000,
}));
panel.apply_json_event(&task_started_json("t2", "deepseek", "flash"));
panel.apply_json_event(&task_started_json("t1", "deepseek", "flash"));
assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Cancelled);
assert_eq!(
panel.find_row_mut("t1").expect("t1").status,
WorkflowRowStatus::Cancelled
);
assert_eq!(
panel
.find_row_mut("t2")
.expect("late t2 is recorded")
.status,
WorkflowRowStatus::Cancelled
);
let mut panel = started_panel();
panel.apply_json_event(&json!({
"type": "task_completed", "run_id": "workflow_abc",
"task_id": "t1", "status": "succeeded", "at_ms": 1_500,
}));
panel.apply_json_event(&json!({
"type": "run_completed", "run_id": "workflow_abc",
"status": "completed", "at_ms": 1_600,
}));
panel.apply_json_event(&task_started_json("t1", "deepseek", "flash"));
assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Succeeded);
assert_eq!(
panel.find_row_mut("t1").expect("t1").status,
WorkflowRowStatus::Succeeded
);
}
/// U05-02: a terminal receipt with a missing or contradictory status is
/// not evidence of success; it fails closed like an unknown status.
#[test]
fn malformed_completion_status_never_reads_as_success() {
for status in [None, Some("running"), Some("pending")] {
let mut panel = started_panel();
let mut completed = json!({
"type": "run_completed", "run_id": "workflow_abc", "at_ms": 2_000,
});
if let Some(status) = status {
completed["status"] = json!(status);
}
panel.apply_json_event(&json!({
"type": "task_completed", "run_id": "workflow_abc",
"task_id": "t1", "at_ms": 1_900,
}));
panel.apply_json_event(&completed);
assert_eq!(
panel.lifecycle,
WorkflowPanelLifecycle::Failed,
"{status:?}"
);
assert_eq!(
panel.find_row_mut("t1").expect("row").status,
WorkflowRowStatus::Failed
);
}
}
/// #4208: every decorative glyph the run map emits — expand marks, role
/// marks, lane glyphs, gates, status marks across running, waiting,
/// failed, cancelled, and completed members — must narrow to an
+15 -6
View File
@@ -116,9 +116,15 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> Option<Option<SidebarRowActio
.iter()
.position(|panel| *panel == app.work_surface.panel)
.unwrap_or(0);
// Only a bare ←/→ walks the dock tabs. Ctrl/Alt/Super+←/→ are the
// composer's word-navigation chords; they fall through below and
// return ownership to the composer like printable input.
let bare = !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
let next = match key.code {
KeyCode::Left => Some((current + tabs.len() - 1) % tabs.len()),
KeyCode::Right => Some((current + 1) % tabs.len()),
KeyCode::Left if bare => Some((current + tabs.len() - 1) % tabs.len()),
KeyCode::Right if bare => Some((current + 1) % tabs.len()),
_ => None,
};
if let Some(next) = next {
@@ -128,10 +134,13 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> Option<Option<SidebarRowActio
}
}
if matches!(key.code, KeyCode::Char(_))
&& !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
let navigation_chord = key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
if (matches!(key.code, KeyCode::Char(_)) && !navigation_chord)
|| (app.work_surface.focused
&& navigation_chord
&& matches!(key.code, KeyCode::Left | KeyCode::Right))
{
release_focus(app);
return None;