feat(tui): workbar under the composer replaces the in-chat workflow cards

Live workflow progress moves out of the transcript and out of the card
above the composer into a workbar directly under the status row:

- One row per run, many runs at once. `App.workflow_panel: Option` becomes
  `App.workflow_runs: Vec`, keyed by run id; a newer run no longer
  replaces one that is still going (D11).
- Row: status mark, name, 20-cell bar, settled/total "so far" (the total
  grows live as a script admits agents), elapsed, ↓tokens, and only-true
  chips: a failure count, "⚠ Large workflow" at 25+ agents, and an amber
  "· N queued" from the runtime's real follow-up counts for the run's busy
  agents. More than six runs fold into "+N more · ↓ to manage".
- Marks differ by shape, so state reads without colour; nothing animates,
  so reduced motion needs no second path; ASCII terminals use the
  backend's per-cell fallbacks.
- Transcript: the launching call is one "started" line; a settled run
  gets one finish line (state, agents, elapsed, tokens, and the result or
  reason). A foreground `run` card carries its own finish; a detached run's
  line waits until its start card leaves the active group. Written once.
- Engine `run_completed` gains an optional `result_preview`
  (`workflow_result_preview`, shared with the TUI summarizer; names empty
  slots instead of "N field(s)"). Event names are unchanged.
- Status row keeps "Esc to interrupt · ← for agents · ↓ to manage" while
  a turn runs; ↓ (and a click on the workbar) opens the existing
  /workflows view whenever runs are showing.
- Coalescing: progress events update state but repaint at the
  AgentProgress pace and never touch the transcript; only run start/end
  repaint at once. Turn interrupt no longer marks a detached run cancelled
  client-side (D12).
- Deleted: the above-composer panel renderer, its key focus and mouse
  toggle/cancel, and their tests. Dock agent rows' "· N queued" is amber.

Not done: the composer keeps its existing enclosure (top and bottom rule)
rather than a new two-rule shape.

Tests (scripts/dev-cargo.sh test -p codewhale-tui --lib, focused filters):
123 passed, 3 failed on the first run; the 3 were live-panel row
assertions for the deleted renderer and were removed. The re-run could
not build: /Volumes/VIXinSSD ran out of space ("No space left on device").
`scripts/dev-cargo.sh check -p codewhale-tui --locked`: clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-25 05:52:26 -07:00
1 parent 8a835d7c4d
commit d4ea059c05
35 files changed
+1772 -1601

No files matched your search

+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "agents",
"FooterHintToManage": "gestionar",
"AgentRailQueuedCount": "{count} a la cua",
"WorkbarSoFar": "{done}/{total} fins ara",
"WorkbarLargeWorkflow": "Flux de treball gran",
"WorkbarMoreRuns": "+{count} més",
"WorkflowLineStarted": "iniciat",
"WorkflowLineFinished": "acabat",
"WorkflowLineFinishedWithGaps": "acabat amb buits",
"WorkflowLineFailed": "ha fallat",
"WorkflowLineStopped": "aturat",
"WorkflowLineAgents": "{done}/{total} agents",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "escriu",
"AgentFocusPostureReadOnly": "només lectura",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "Agenten",
"FooterHintToManage": "verwalten",
"AgentRailQueuedCount": "{count} eingereiht",
"WorkbarSoFar": "{done}/{total} bisher",
"WorkbarLargeWorkflow": "Großer Workflow",
"WorkbarMoreRuns": "+{count} weitere",
"WorkflowLineStarted": "gestartet",
"WorkflowLineFinished": "fertig",
"WorkflowLineFinishedWithGaps": "fertig mit Lücken",
"WorkflowLineFailed": "fehlgeschlagen",
"WorkflowLineStopped": "gestoppt",
"WorkflowLineAgents": "{done}/{total} Agenten",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "schreibt",
"AgentFocusPostureReadOnly": "nur lesen",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "for agents",
"FooterHintToManage": "to manage",
"AgentRailQueuedCount": "{count} queued",
"WorkbarSoFar": "{done}/{total} so far",
"WorkbarLargeWorkflow": "Large workflow",
"WorkbarMoreRuns": "+{count} more",
"WorkflowLineStarted": "started",
"WorkflowLineFinished": "finished",
"WorkflowLineFinishedWithGaps": "finished with gaps",
"WorkflowLineFailed": "failed",
"WorkflowLineStopped": "stopped",
"WorkflowLineAgents": "{done}/{total} agents",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "writes",
"AgentFocusPostureReadOnly": "read-only",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "agentes",
"FooterHintToManage": "gestionar",
"AgentRailQueuedCount": "{count} en cola",
"WorkbarSoFar": "{done}/{total} hasta ahora",
"WorkbarLargeWorkflow": "Flujo de trabajo grande",
"WorkbarMoreRuns": "+{count} más",
"WorkflowLineStarted": "iniciado",
"WorkflowLineFinished": "terminado",
"WorkflowLineFinishedWithGaps": "terminado con huecos",
"WorkflowLineFailed": "falló",
"WorkflowLineStopped": "detenido",
"WorkflowLineAgents": "{done}/{total} agentes",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "escribe",
"AgentFocusPostureReadOnly": "solo lectura",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "agents",
"FooterHintToManage": "gérer",
"AgentRailQueuedCount": "{count} en file",
"WorkbarSoFar": "{done}/{total} pour l’instant",
"WorkbarLargeWorkflow": "Workflow volumineux",
"WorkbarMoreRuns": "+{count} de plus",
"WorkflowLineStarted": "lancé",
"WorkflowLineFinished": "terminé",
"WorkflowLineFinishedWithGaps": "terminé avec des lacunes",
"WorkflowLineFailed": "échec",
"WorkflowLineStopped": "arrêté",
"WorkflowLineAgents": "{done}/{total} agents",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "écrit",
"AgentFocusPostureReadOnly": "lecture seule",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "एजेंट",
"FooterHintToManage": "प्रबंधन",
"AgentRailQueuedCount": "{count} कतार में",
"WorkbarSoFar": "अब तक {done}/{total}",
"WorkbarLargeWorkflow": "बड़ा वर्कफ़्लो",
"WorkbarMoreRuns": "+{count} और",
"WorkflowLineStarted": "शुरू हुआ",
"WorkflowLineFinished": "पूरा हुआ",
"WorkflowLineFinishedWithGaps": "कमियों के साथ पूरा हुआ",
"WorkflowLineFailed": "विफल",
"WorkflowLineStopped": "रोका गया",
"WorkflowLineAgents": "{done}/{total} एजेंट",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "लिख सकता है",
"AgentFocusPostureReadOnly": "केवल-पढ़ने",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "agen",
"FooterHintToManage": "kelola",
"AgentRailQueuedCount": "{count} antre",
"WorkbarSoFar": "{done}/{total} sejauh ini",
"WorkbarLargeWorkflow": "Alur kerja besar",
"WorkbarMoreRuns": "+{count} lagi",
"WorkflowLineStarted": "dimulai",
"WorkflowLineFinished": "selesai",
"WorkflowLineFinishedWithGaps": "selesai dengan celah",
"WorkflowLineFailed": "gagal",
"WorkflowLineStopped": "dihentikan",
"WorkflowLineAgents": "{done}/{total} agen",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "menulis",
"AgentFocusPostureReadOnly": "hanya-baca",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "エージェント",
"FooterHintToManage": "管理",
"AgentRailQueuedCount": "{count} 件キュー",
"WorkbarSoFar": "現在 {done}/{total}",
"WorkbarLargeWorkflow": "大規模なワークフロー",
"WorkbarMoreRuns": "ほか {count} 件",
"WorkflowLineStarted": "開始",
"WorkflowLineFinished": "完了",
"WorkflowLineFinishedWithGaps": "一部欠けて完了",
"WorkflowLineFailed": "失敗",
"WorkflowLineStopped": "停止",
"WorkflowLineAgents": "{done}/{total} エージェント",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "書き込み可",
"AgentFocusPostureReadOnly": "読み取り専用",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "에이전트",
"FooterHintToManage": "관리",
"AgentRailQueuedCount": "{count}개 대기",
"WorkbarSoFar": "지금까지 {done}/{total}",
"WorkbarLargeWorkflow": "대규모 워크플로",
"WorkbarMoreRuns": "+{count}개 더",
"WorkflowLineStarted": "시작됨",
"WorkflowLineFinished": "완료됨",
"WorkflowLineFinishedWithGaps": "일부 누락된 채 완료됨",
"WorkflowLineFailed": "실패",
"WorkflowLineStopped": "중지됨",
"WorkflowLineAgents": "에이전트 {done}/{total}",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "쓰기 가능",
"AgentFocusPostureReadOnly": "읽기 전용",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "agentes",
"FooterHintToManage": "gerenciar",
"AgentRailQueuedCount": "{count} na fila",
"WorkbarSoFar": "{done}/{total} até agora",
"WorkbarLargeWorkflow": "Fluxo de trabalho grande",
"WorkbarMoreRuns": "+{count} a mais",
"WorkflowLineStarted": "iniciado",
"WorkflowLineFinished": "concluído",
"WorkflowLineFinishedWithGaps": "concluído com lacunas",
"WorkflowLineFailed": "falhou",
"WorkflowLineStopped": "interrompido",
"WorkflowLineAgents": "{done}/{total} agentes",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "escreve",
"AgentFocusPostureReadOnly": "somente leitura",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "агенты",
"FooterHintToManage": "управление",
"AgentRailQueuedCount": "{count} в очереди",
"WorkbarSoFar": "пока {done}/{total}",
"WorkbarLargeWorkflow": "Крупный рабочий процесс",
"WorkbarMoreRuns": "ещё {count}",
"WorkflowLineStarted": "запущен",
"WorkflowLineFinished": "завершён",
"WorkflowLineFinishedWithGaps": "завершён с пробелами",
"WorkflowLineFailed": "сбой",
"WorkflowLineStopped": "остановлен",
"WorkflowLineAgents": "агентов: {done}/{total}",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "пишет",
"AgentFocusPostureReadOnly": "только чтение",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "агенти",
"FooterHintToManage": "керування",
"AgentRailQueuedCount": "{count} у черзі",
"WorkbarSoFar": "поки {done}/{total}",
"WorkbarLargeWorkflow": "Великий робочий процес",
"WorkbarMoreRuns": "ще {count}",
"WorkflowLineStarted": "запущено",
"WorkflowLineFinished": "завершено",
"WorkflowLineFinishedWithGaps": "завершено з прогалинами",
"WorkflowLineFailed": "збій",
"WorkflowLineStopped": "зупинено",
"WorkflowLineAgents": "агентів: {done}/{total}",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "пише",
"AgentFocusPostureReadOnly": "лише читання",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "agent",
"FooterHintToManage": "quản lý",
"AgentRailQueuedCount": "{count} đang chờ",
"WorkbarSoFar": "{done}/{total} cho đến nay",
"WorkbarLargeWorkflow": "Quy trình lớn",
"WorkbarMoreRuns": "+{count} nữa",
"WorkflowLineStarted": "đã bắt đầu",
"WorkflowLineFinished": "đã xong",
"WorkflowLineFinishedWithGaps": "xong nhưng còn thiếu",
"WorkflowLineFailed": "thất bại",
"WorkflowLineStopped": "đã dừng",
"WorkflowLineAgents": "{done}/{total} tác tử",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "được ghi",
"AgentFocusPostureReadOnly": "chỉ đọc",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "智能体",
"FooterHintToManage": "管理",
"AgentRailQueuedCount": "{count} 条排队",
"WorkbarSoFar": "目前 {done}/{total}",
"WorkbarLargeWorkflow": "大型工作流",
"WorkbarMoreRuns": "还有 {count} 个",
"WorkflowLineStarted": "已开始",
"WorkflowLineFinished": "已完成",
"WorkflowLineFinishedWithGaps": "已完成但有缺口",
"WorkflowLineFailed": "失败",
"WorkflowLineStopped": "已停止",
"WorkflowLineAgents": "{done}/{total} 个智能体",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "可写入",
"AgentFocusPostureReadOnly": "只读",
+9
View File
@@ -1994,6 +1994,15 @@
"FooterHintForAgents": "代理",
"FooterHintToManage": "管理",
"AgentRailQueuedCount": "{count} 則排隊",
"WorkbarSoFar": "目前 {done}/{total}",
"WorkbarLargeWorkflow": "大型工作流程",
"WorkbarMoreRuns": "還有 {count} 個",
"WorkflowLineStarted": "已開始",
"WorkflowLineFinished": "已完成",
"WorkflowLineFinishedWithGaps": "已完成但有缺口",
"WorkflowLineFailed": "失敗",
"WorkflowLineStopped": "已停止",
"WorkflowLineAgents": "{done}/{total} 個代理",
"AgentFocusPosture": "{role} · {write} · {network} · {shell}",
"AgentFocusPostureWrites": "可寫入",
"AgentFocusPostureReadOnly": "唯讀",
+18
View File
@@ -2353,6 +2353,15 @@ pub enum MessageId {
FooterHintForAgents,
FooterHintToManage,
AgentRailQueuedCount,
WorkbarSoFar,
WorkbarLargeWorkflow,
WorkbarMoreRuns,
WorkflowLineStarted,
WorkflowLineFinished,
WorkflowLineFinishedWithGaps,
WorkflowLineFailed,
WorkflowLineStopped,
WorkflowLineAgents,
PickerActionTestConnection,
ProviderCustomFormBaseUrl,
ProviderCustomFormModel,
@@ -4711,6 +4720,15 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::FooterHintForAgents,
MessageId::FooterHintToManage,
MessageId::AgentRailQueuedCount,
MessageId::WorkbarSoFar,
MessageId::WorkbarLargeWorkflow,
MessageId::WorkbarMoreRuns,
MessageId::WorkflowLineStarted,
MessageId::WorkflowLineFinished,
MessageId::WorkflowLineFinishedWithGaps,
MessageId::WorkflowLineFailed,
MessageId::WorkflowLineStopped,
MessageId::WorkflowLineAgents,
MessageId::PickerActionTestConnection,
MessageId::ProviderCustomFormBaseUrl,
MessageId::ProviderCustomFormModel,
+60
View File
@@ -531,6 +531,10 @@ enum WorkflowUiEventKind {
/// Run-wide usage totals reconciled from per-task telemetry (#2974).
#[serde(default, skip_serializing_if = "Option::is_none")]
usage: Option<WorkflowRunUsage>,
/// One readable line naming what the run returned, so the transcript's
/// finish line can state the result without the full record.
#[serde(default, skip_serializing_if = "Option::is_none")]
result_preview: Option<String>,
},
RunCancelled {
reason: String,
@@ -2196,6 +2200,7 @@ async fn run_workflow_vm(
status: record.status,
error: record.error.clone(),
usage: run_usage.clone(),
result_preview: record.result.as_ref().and_then(workflow_result_preview),
},
);
record.push_event(budget_event.clone());
@@ -2246,6 +2251,7 @@ async fn run_workflow_vm(
status: record.status,
error: record.error.clone(),
usage: record.usage.clone(),
result_preview: record.result.as_ref().and_then(workflow_result_preview),
}
},
);
@@ -2556,6 +2562,59 @@ fn bounded_run_record_value(
(value, bounds)
}
/// One readable line for what a run returned: the text itself, an object's
/// own `summary`/`message`/`text`, or the slot names with the empty ones
/// named — never raw JSON and never a bare "N field(s)". Bounded.
pub(crate) fn workflow_result_preview(value: &Value) -> Option<String> {
const MAX: usize = 200;
let preview = match value {
Value::Null => return None,
Value::String(text) => text.split_whitespace().collect::<Vec<_>>().join(" "),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Array(items) => {
let empty = items.iter().filter(|item| item.is_null()).count();
let noun = if items.len() == 1 { "item" } else { "items" };
if empty > 0 {
format!("{} {noun}, {empty} empty", items.len())
} else {
format!("{} {noun}", items.len())
}
}
Value::Object(map) => {
if let Some(text) = ["summary", "message", "text"]
.iter()
.find_map(|key| map.get(*key).and_then(Value::as_str))
.map(|text| text.split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|text| !text.is_empty())
{
text
} else {
let filled: Vec<&str> = map
.iter()
.filter(|(_, v)| !v.is_null())
.map(|(k, _)| k.as_str())
.collect();
let empty: Vec<&str> = map
.iter()
.filter(|(_, v)| v.is_null())
.map(|(k, _)| k.as_str())
.collect();
match (filled.is_empty(), empty.is_empty()) {
(true, true) => return None,
(false, true) => filled.join(", "),
(true, false) => format!("empty: {}", empty.join(", ")),
(false, false) => {
format!("{} · empty: {}", filled.join(", "), empty.join(", "))
}
}
}
}
};
let preview = preview.trim();
(!preview.is_empty()).then(|| truncate_chars(preview, MAX))
}
/// Char-boundary-safe truncation with an ellipsis (precedent:
/// `cargo_failure_summary::truncate_chars`).
fn truncate_chars(text: &str, max_chars: usize) -> String {
@@ -11414,6 +11473,7 @@ FINAL RECEIPT
status: WorkflowRunStatus::Completed,
error: None,
usage: None,
result_preview: None,
},
))
.expect("serialize plain run_completed");
+4 -5
View File
@@ -439,17 +439,16 @@ pub(crate) enum AgentShellShortcut {
/// surface owns that same arrow. Rendering and dispatch both consume this
/// predicate so the footer cannot promise an action that another owner will
/// swallow.
///
/// A workflow run on the workbar counts as agents to manage: `↓` then opens
/// `/workflows` instead of the agent register.
pub(crate) fn shell_shortcuts_available(app: &App, completion_menu_open: bool) -> bool {
agents_exist(app)
(agents_exist(app) || !app.workflow_runs.is_empty())
&& !completion_menu_open
&& app.input.is_empty()
&& app.view_stack.is_empty()
&& app.selected_composer_attachment_index().is_none()
&& !app.work_surface.focused
&& !app
.workflow_panel
.as_ref()
.is_some_and(|panel| panel.keyboard_focus)
}
/// Resolve a key only while the agent shortcut contract is actually active.
+183 -56
View File
@@ -958,9 +958,9 @@ pub struct ViewportState {
/// Painted band occupied by the active approval or question sheet. Stored
/// so wheel routing can prefer the prompt over side surfaces underneath it.
pub last_prompt_area: Option<Rect>,
/// WorkflowPanel rect above the composer (#4121), for mouse toggle/cancel.
pub last_workflow_panel_area: Option<Rect>,
pub last_workflow_cancel_area: Option<Rect>,
/// The workbar's painted rows under the posture bar; a click there opens
/// `/workflows`.
pub last_workbar_area: Option<Rect>,
/// Info-line segment rects (Tideline shell, spec §6), recorded at render so
/// hover and — in a follow-up slice — click routing can hit-test the
/// painted cells. Mirrors the workflow-panel cancel-area storage pattern.
@@ -1006,8 +1006,7 @@ impl Default for ViewportState {
interaction_targets: crate::tui::tideline::InteractionRegistry::default(),
composer_click_trace: None,
last_prompt_area: None,
last_workflow_panel_area: None,
last_workflow_cancel_area: None,
last_workbar_area: None,
last_infoline_hitboxes: Vec::new(),
last_plugin_cta_area: None,
last_plugin_cta_review_area: None,
@@ -2414,10 +2413,11 @@ pub struct App {
/// every frame, so the counts live here rather than behind a
/// settings-file read.
pub footer_hint_uses: std::collections::BTreeMap<String, u8>,
/// Unified Workflow activity surface (#4121). Lives above the composer so
/// phase/row progress does not flood the chat transcript. Preserved after
/// completion until the next `RunStarted` replaces it.
pub workflow_panel: Option<crate::tui::widgets::workflow_panel::WorkflowPanel>,
/// Every workflow run this session is showing, in start order, one state
/// per run id (#4121). The workbar under the composer paints one row per
/// run; the transcript gets only each run's start and finish lines.
/// Settled runs stay until a later turn starts with nothing still running.
pub workflow_runs: Vec<crate::tui::widgets::workflow_panel::WorkflowPanel>,
/// Wall-clock time when this TUI session started. Used by the Work
/// sidebar projection to hide completed durable tasks that finished
/// before the current session (bug #1913).
@@ -2631,12 +2631,7 @@ impl App {
if self.launch.visible {
return Focus::Launch;
}
if self.work_surface.focused
|| self
.workflow_panel
.as_ref()
.is_some_and(|panel| panel.keyboard_focus)
{
if self.work_surface.focused {
return Focus::Panel;
}
Focus::Composer
@@ -5322,6 +5317,9 @@ impl App {
{
self.scroll_to_bottom();
}
// A foreground workflow's finish line waits for its start card to
// leave the active group, so the transcript reads started → finished.
self.announce_settled_workflows();
}
/// Mark every still-running entry in the active cell as interrupted, then
@@ -5330,23 +5328,19 @@ impl App {
if let Some(active) = self.active_cell.as_mut() {
active.mark_in_progress_as_interrupted();
}
// A detached workflow outlives the turn that started it, and a
// foreground one is cancelled by the engine, which says so with its
// own `run_cancelled`. Neither is marked here.
self.flush_active_cell();
// #4121: interrupt finalizes running workflow children as cancelled
// and preserves the completed panel until the next run starts.
if let Some(panel) = self.workflow_panel.as_mut() {
panel.finalize_interrupt();
self.needs_redraw = true;
}
}
/// Apply a workflow panel event for one immutable workflow run, creating
/// the panel on first `RunStarted`.
/// Apply one event to the run it names, creating that run's state on
/// first sight. Runs are independent: a newer run never replaces one that
/// is still going, so ten concurrent workflows are ten rows.
///
/// Returns whether the event belonged to the displayed run and was
/// applied. Budget-only updates still return `true`, but leave repaint to
/// the caller so high-frequency fan-out budget ticks can be paced (#4095).
/// A `RunStarted` event may select a different run only when its start is
/// strictly newer; every other cross-run event fails closed.
/// Returns whether the event was applied. Only a run's start and end ask
/// for a repaint here; progress leaves it to the caller, which paces a
/// fan-out's event stream (#4095).
pub fn apply_workflow_panel_event(
&mut self,
event_run_id: &str,
@@ -5363,17 +5357,14 @@ impl App {
{
return false;
}
if let Some(panel) = self.workflow_panel.as_ref()
&& panel.run_id != event_run_id
{
match &event {
WorkflowPanelEvent::RunStarted { at_ms, .. } if *at_ms > panel.started_at_ms => {}
_ => return false,
}
}
let budget_only = matches!(&event, WorkflowPanelEvent::BudgetUpdated { .. });
// #5528: a failed run must be loud, not just a panel row. Capture the
let lifecycle = matches!(
&event,
WorkflowPanelEvent::RunStarted { .. }
| WorkflowPanelEvent::RunCompleted { .. }
| WorkflowPanelEvent::RunCancelled { .. }
);
// #5528: a failed run must be loud, not just a row. Capture the
// failure before the event is consumed below; the sticky notice fires
// once per run because the live stream and the tool-complete hydration
// can both deliver the same terminal event.
@@ -5385,10 +5376,15 @@ impl App {
} => Some(error.clone()),
_ => None,
};
let already_failed = self.workflow_panel.as_ref().is_some_and(|panel| {
panel.run_id == event_run_id && panel.lifecycle == WorkflowPanelLifecycle::Failed
let existing = self
.workflow_runs
.iter()
.position(|panel| panel.run_id == event_run_id);
let already_failed = existing.is_some_and(|index| {
self.workflow_runs[index].lifecycle == WorkflowPanelLifecycle::Failed
});
match (&mut self.workflow_panel, &event) {
match (existing, &event) {
(Some(index), _) => self.workflow_runs[index].apply_event(event),
(
None,
WorkflowPanelEvent::RunStarted {
@@ -5408,21 +5404,18 @@ impl App {
panel.locale = self.ui_locale;
panel.budget_total = *token_budget;
panel.budget_remaining = *token_budget;
self.workflow_panel = Some(panel);
self.push_workflow_run(panel);
}
(None, _) => {
// No panel yet and event is not a start — seed a shell panel
// so late events still surface rather than being dropped.
// A late event for a run this view never saw start still
// surfaces, under its id, rather than being dropped.
let mut panel = WorkflowPanel::new(event_run_id, event_run_id, 0);
panel.locale = self.ui_locale;
panel.apply_event(event);
self.workflow_panel = Some(panel);
}
(Some(panel), _) => {
panel.apply_event(event);
self.push_workflow_run(panel);
}
}
if !budget_only {
if lifecycle {
self.needs_redraw = true;
}
if let Some(error) = run_failure
@@ -5446,18 +5439,152 @@ impl App {
Some(Self::STICKY_ERROR_TTL_MS),
);
}
self.announce_settled_workflows();
true
}
/// Toggle the workflow panel expand/collapse state. Returns true when a
/// panel was present and toggled.
pub fn toggle_workflow_panel(&mut self) -> bool {
let Some(panel) = self.workflow_panel.as_mut() else {
return false;
/// Most runs kept at once. Past it the oldest settled run goes first; a
/// live run is never evicted.
const MAX_WORKFLOW_RUNS: usize = 64;
pub(crate) fn push_workflow_run(
&mut self,
panel: crate::tui::widgets::workflow_panel::WorkflowPanel,
) {
self.workflow_runs.push(panel);
while self.workflow_runs.len() > Self::MAX_WORKFLOW_RUNS {
let Some(oldest_settled) = self
.workflow_runs
.iter()
.position(|run| run.lifecycle.is_terminal() && run.finish_announced)
else {
break;
};
self.workflow_runs.remove(oldest_settled);
}
}
/// The state of one run, by id.
pub(crate) fn workflow_run(
&self,
run_id: &str,
) -> Option<&crate::tui::widgets::workflow_panel::WorkflowPanel> {
self.workflow_runs.iter().find(|run| run.run_id == run_id)
}
pub(crate) fn workflow_run_mut(
&mut self,
run_id: &str,
) -> Option<&mut crate::tui::widgets::workflow_panel::WorkflowPanel> {
self.workflow_runs
.iter_mut()
.find(|run| run.run_id == run_id)
}
/// Whether any workflow run is still going.
pub(crate) fn workflow_run_live(&self) -> bool {
self.workflow_runs
.iter()
.any(|run| run.lifecycle.is_running())
}
/// A new turn clears settled rows from the workbar once nothing is still
/// running — their finish lines are already in the transcript. While any
/// run is live the settled ones stay, so a batch reads as one batch.
pub(crate) fn prune_settled_workflow_runs(&mut self) {
if self.workflow_run_live() {
return;
}
let before = self.workflow_runs.len();
self.workflow_runs
.retain(|run| !(run.lifecycle.is_terminal() && run.finish_announced));
if self.workflow_runs.len() != before {
self.needs_redraw = true;
}
}
/// Write each settled run's finish line into the transcript, once. The
/// line is a `workflow` card marked `transcript_line: finished`, so it
/// reuses the card renderer and expands in Transcript mode.
///
/// While a `workflow` card is still in the active group (a foreground
/// `run`, or a `start` whose turn has not flushed) the line waits: pushed
/// now it would land above the card that started it. `flush_active_cell`
/// calls back here once the card is in history.
pub(crate) fn announce_settled_workflows(&mut self) {
use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus};
use crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle;
if !self
.workflow_runs
.iter()
.any(|run| run.lifecycle.is_terminal() && !run.finish_announced)
{
return;
}
let card_in_flight = self.active_cell.as_ref().is_some_and(|active| {
active.entries().iter().any(|cell| {
matches!(cell, HistoryCell::Tool(ToolCell::Generic(tool)) if tool.name == "workflow")
})
});
if card_in_flight {
return;
}
// A foreground `run` card that returned its settled record already
// shows the finish (history.rs); writing another would say it twice.
let card_owns_finish = |history: &[HistoryCell], run_id: &str| {
history.iter().rev().any(|cell| {
let HistoryCell::Tool(ToolCell::Generic(tool)) = cell else {
return false;
};
if tool.name != "workflow" || tool.status == ToolStatus::Running {
return false;
}
let Some(value) = tool
.output
.as_deref()
.and_then(|out| serde_json::from_str::<serde_json::Value>(out).ok())
else {
return false;
};
value.get("run_id").and_then(serde_json::Value::as_str) == Some(run_id)
&& value.get("transcript_line").is_none()
&& matches!(
value.get("status").and_then(serde_json::Value::as_str),
Some("completed" | "succeeded" | "degraded" | "failed" | "cancelled")
)
})
};
let _ = panel.toggle_expanded();
let mut lines = Vec::new();
for run in &mut self.workflow_runs {
if !run.lifecycle.is_terminal() || run.finish_announced {
continue;
}
run.finish_announced = true;
if card_owns_finish(&self.history, &run.run_id) {
continue;
}
let mut output = run.to_run_json();
output["transcript_line"] = serde_json::Value::from("finished");
let status = match run.lifecycle {
WorkflowPanelLifecycle::Succeeded => ToolStatus::Success,
WorkflowPanelLifecycle::Degraded => ToolStatus::Warning,
_ => ToolStatus::Failed,
};
lines.push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "workflow".to_string(),
status,
input_summary: None,
output: Some(output.to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})));
}
for line in lines {
self.add_message(line);
}
self.needs_redraw = true;
true
}
/// How long the "press Ctrl+C again to quit" prompt stays armed before it
+1 -1
View File
@@ -1115,7 +1115,7 @@ impl App {
settings.contextual_tips,
),
footer_hint_uses: settings.footer_hint_uses.clone(),
workflow_panel: None,
workflow_runs: Vec::new(),
session_started_at: chrono::Utc::now(),
needs_redraw: true,
fleet_roster_stale: false,
+60 -30
View File
@@ -1793,7 +1793,7 @@ impl GenericToolCell {
// #4038 / #4122: purpose-built workflow run card (compact in live,
// expanded in transcript) shared with the WorkflowPanel state machine.
if let Some(lines) = self.try_render_as_workflow(width, low_motion, mode) {
if let Some(lines) = self.try_render_as_workflow(width, low_motion, mode, locale) {
return lines;
}
@@ -2018,16 +2018,19 @@ impl GenericToolCell {
))
}
/// Render the `workflow` tool via the shared WorkflowPanel history-card
/// renderer (#4122). Live mode stays compact (lifecycle, children, phases,
/// failures, elapsed); transcript mode expands phase/child summaries,
/// artifact/transcript links, final result, and failure details.
/// Status-list payloads keep a multi-run summary card.
/// Render the `workflow` tool as the transcript's two lines per run
/// (#4122): the call that launched it is one `started` line naming the
/// run, and the card `App::announce_settled_workflows` writes when the run
/// settles is one finish line — state, agents, elapsed, tokens — with the
/// result or reason under it. Live progress is the workbar's, not the
/// transcript's. Transcript mode expands the finish card's phase/child
/// detail. Status-list payloads keep a multi-run summary card.
fn try_render_as_workflow(
&self,
width: u16,
low_motion: bool,
mode: RenderMode,
locale: Locale,
) -> Option<Vec<Line<'static>>> {
if self.name != "workflow" {
return None;
@@ -2088,41 +2091,68 @@ impl GenericToolCell {
}
use crate::tui::widgets::workflow_panel::{WorkflowHistoryExtras, WorkflowPanel};
let panel = WorkflowPanel::from_run_json(&value)?;
// Prefer the panel's lifecycle-aware status label when the tool cell
// is still marked running but the snapshot already terminal (or vice
// versa during live streaming).
let header_status = match panel.lifecycle {
crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Failed
| crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Cancelled => {
ToolStatus::Failed
}
let mut panel = WorkflowPanel::from_run_json(&value)?;
panel.locale = locale;
let finish_card = value
.get("transcript_line")
.and_then(serde_json::Value::as_str)
== Some("finished");
// A foreground `run` card returns the settled record itself, so it
// carries its own finish; a detached start is finished by the card
// `App::announce_settled_workflows` writes later.
let owns_finish =
finish_card || (self.status != ToolStatus::Running && panel.lifecycle.is_terminal());
if !finish_card {
// The start line: the run's name. Its progress lives in the
// workbar, never here.
let name = panel.label.split_whitespace().collect::<Vec<_>>().join(" ");
lines.push(render_tool_header_with_family_and_summary(
family,
Some(name.as_str()),
codewhale_localization::tr(
locale,
codewhale_localization::MessageId::WorkflowLineStarted,
)
.as_ref(),
if owns_finish {
ToolStatus::Success
} else {
self.status
},
None,
low_motion,
));
}
let finish = owns_finish.then(|| panel.finish_line()).flatten();
let Some((state, facts, detail)) = finish else {
return Some(wrap_card_rail(lines, self.status));
};
let finish_status = match panel.lifecycle {
crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Succeeded => {
ToolStatus::Success
}
crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Degraded => {
ToolStatus::Warning
}
crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Pending
| crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Running => {
if self.status == ToolStatus::Failed {
ToolStatus::Failed
} else if self.status == ToolStatus::Success {
ToolStatus::Success
} else {
ToolStatus::Running
}
}
_ => ToolStatus::Failed,
};
let summary = panel.history_header_summary(usize::from(width).saturating_sub(18));
lines.push(render_tool_header_with_family_and_summary(
family,
Some(summary.as_str()),
tool_status_label(header_status),
header_status,
Some(facts.as_str()),
&state,
finish_status,
None,
low_motion,
));
if let Some(detail) = detail {
let room = usize::from(width).saturating_sub(4).max(8);
lines.extend(render_card_detail_line(
None,
&crate::tui::ui_text::truncate_line_to_width(&detail, room),
tool_value_style(),
width,
));
}
let expanded = matches!(mode, RenderMode::Transcript);
if expanded {
let extras = WorkflowHistoryExtras {
@@ -2152,7 +2182,7 @@ impl GenericToolCell {
));
}
}
Some(wrap_card_rail(lines, self.status))
Some(wrap_card_rail(lines, finish_status))
}
}
+1 -1
View File
@@ -403,7 +403,7 @@ fn chrome(theme: &UiTheme, ink: ChromeInk) -> Style {
}
/// Recorded hitboxes for one rendered row. Mirrors the
/// `viewport.last_workflow_cancel_area` storage pattern: render computes the
/// `viewport.last_workbar_area` storage pattern: render computes the
/// rects, the caller stores them, `mouse_ui` hit-tests against them.
#[derive(Debug, Clone)]
pub struct InfoLineHitbox {
+22 -84
View File
@@ -268,60 +268,16 @@ fn move_composer_cursor_by_wrapped_rows(app: &mut App, text_area: Rect, rows: is
true
}
/// The WorkflowPanel's clickable affordance: its header row only (#6503).
/// Hover registration (`frame.rs`) uses the same rect, so every row that
/// glows acts and phase/child rows are never invisible click targets.
pub(crate) fn workflow_panel_header_area(app: &App) -> Option<Rect> {
app.viewport.last_workflow_panel_area.map(|area| Rect {
height: area.height.min(1),
..area
})
}
/// Click the WorkflowPanel header to toggle expand/collapse, or the trailing
/// cancel affordance while a run is active (#4121).
fn handle_workflow_panel_mouse(app: &mut App, mouse: MouseEvent) -> bool {
if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
/// A click on the workbar opens `/workflows`, the view that lists every run
/// with its agents and cancels one.
fn handle_workbar_mouse(app: &mut App, mouse: MouseEvent) -> bool {
if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
|| !mouse_hits_rect(mouse, app.viewport.last_workbar_area)
|| app.workflow_runs.is_empty()
{
return false;
}
let Some(header) = workflow_panel_header_area(app) else {
return false;
};
if !mouse_hits_rect(mouse, Some(header)) {
return false;
}
if app.workflow_panel.is_none() {
return false;
}
if let Some(panel) = app.workflow_panel.as_mut() {
panel.keyboard_focus = true;
}
let in_cancel_zone = mouse_hits_rect(mouse, app.viewport.last_workflow_cancel_area);
let running = app
.workflow_panel
.as_ref()
.is_some_and(|panel| panel.lifecycle.is_running());
if in_cancel_zone && running {
let run_id = app
.workflow_panel
.as_ref()
.map(|panel| panel.run_id.clone())
.expect("running panel has an id");
app.input = format!("/workflow cancel {run_id}");
app.cursor_position = app.input.chars().count();
app.status_message = Some(app.tr(MessageId::SidebarDestructiveArmed).into_owned());
if let Some(panel) = app.workflow_panel.as_mut() {
panel.keyboard_focus = false;
}
app.needs_redraw = true;
return true;
}
// Any other click on the header toggles expand/collapse.
app.toggle_workflow_panel();
crate::tui::views::workflows_manager::open(app);
true
}
@@ -722,9 +678,7 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
return Vec::new();
}
// WorkflowPanel toggle / cancel (#4121) before composer so the strip
// above the input remains clickable.
if handle_workflow_panel_mouse(app, mouse) {
if handle_workbar_mouse(app, mouse) {
return Vec::new();
}
@@ -2153,38 +2107,22 @@ mod tests {
app
}
/// #6520 review: only the header row — the row that shows the hover
/// glow — toggles the workflow card; phase and child rows are not
/// invisible click targets.
#[test]
fn workflow_card_click_target_is_the_header_row_only() {
fn workbar_click_opens_the_workflows_view() {
let mut app = create_test_app();
app.workflow_panel = Some(crate::tui::widgets::workflow_panel::WorkflowPanel::new(
"workflow_1",
"audit",
1,
));
app.viewport.last_workflow_panel_area = Some(Rect::new(0, 5, 80, 6));
let expanded = |app: &App| app.workflow_panel.as_ref().is_some_and(|p| p.expanded);
assert!(expanded(&app));
assert!(!super::handle_workflow_panel_mouse(
&mut app,
left_click(10, 7)
));
assert!(
expanded(&app),
"a body-row click must not collapse the card"
);
assert!(super::handle_workflow_panel_mouse(
&mut app,
left_click(10, 5)
));
assert!(!expanded(&app), "the header click toggles");
app.workflow_runs
.push(crate::tui::widgets::workflow_panel::WorkflowPanel::new(
"workflow_1",
"audit",
1,
));
app.viewport.last_workbar_area = Some(Rect::new(0, 20, 80, 1));
assert!(!super::handle_workbar_mouse(&mut app, left_click(10, 5)));
assert!(app.view_stack.is_empty());
assert!(super::handle_workbar_mouse(&mut app, left_click(10, 20)));
assert_eq!(
super::workflow_panel_header_area(&app),
Some(Rect::new(0, 5, 80, 1))
app.view_stack.top_kind(),
Some(crate::tui::views::ModalKind::WorkflowsManager)
);
}
+18
View File
@@ -1376,6 +1376,24 @@ pub(crate) fn tideline_footer_from_app(app: &mut App, width: u16) -> TidelineFoo
let hint = hint
.filter(|(_, _, key)| !crate::tui::footer_hints::retired(&app.footer_hint_uses, key))
.map(|(text, ink, _)| (text, ink));
// While a turn runs, the arrow keys stay live beside Esc, so the row reads
// `Esc to interrupt · ← for agents · ↓ to manage` — the running agents and
// workflows are what a user wants to reach mid-turn.
let hint = if matches!(phase, ShellPhase::Working | ShellPhase::Verifying)
&& !app.double_tap_window_open()
&& crate::tui::agent_focus::shell_shortcuts_available(app, false)
&& !crate::tui::footer_hints::retired(
&app.footer_hint_uses,
crate::tui::footer_hints::AGENT_ARROWS,
) {
let arrows = crate::tui::agent_focus::footer_agent_hints(app);
Some(match hint {
Some((text, ink)) => (format!("{text} · {arrows}"), ink),
None => (arrows, ChromeInk::MetadataHint),
})
} else {
hint
};
// The right slot: the live status toast if one is owed, else the compact
// MCP or plugin boot chip, else the remote-control state when it is on.
+339 -218
View File
@@ -1116,32 +1116,27 @@ fn degraded_reason_label(reason: &serde_json::Value) -> Option<String> {
})
}
/// Hydrate or advance the WorkflowPanel from a workflow tool JSON payload.
/// Accepts a single run record (with optional `events` array) or a status
/// list. Log-only events are filtered by the panel itself so the transcript
/// stays free of progress spam (#4121). Also keeps the matching history card
/// snapshot aligned (#4122).
/// Hydrate or advance a workflow run from a workflow tool JSON payload: a
/// run record (with its retained `events` tail) or a status envelope. A
/// status envelope only refreshes runs this view already holds — polling an
/// old run must not bring it back, or its finish line would be written twice.
fn apply_workflow_output_to_panel(app: &mut App, output: &str) {
let Ok(value) = serde_json::from_str::<serde_json::Value>(output) else {
return;
};
// A status response is an envelope rather than a run. Route only its
// selected record through the same identity checks as direct results.
if value.get("action").and_then(|v| v.as_str()) == Some("status") {
if let Some(runs) = value.get("runs").and_then(|r| r.as_array())
&& let Some(run) = runs.last()
{
apply_workflow_output_to_panel(app, &run.to_string());
if let Some(runs) = value.get("runs").and_then(|r| r.as_array()) {
for run in runs {
apply_workflow_run_record(app, run, false);
}
}
return;
} else {
apply_workflow_run_record(app, &value, true);
}
app.announce_settled_workflows();
}
// Tool completions can arrive after a newer run has already selected the
// shared panel. Bind the entire payload to one run before replaying any of
// its retained events. A different run may replace a settled panel only
// when its recorded start is strictly newer; missing/older provenance
// fails closed instead of contaminating the displayed run.
fn apply_workflow_run_record(app: &mut App, value: &serde_json::Value, may_create: bool) {
let Some(run_id) = value
.get("run_id")
.and_then(|v| v.as_str())
@@ -1164,48 +1159,37 @@ fn apply_workflow_output_to_panel(app: &mut App, output: &str) {
else {
return;
};
if let Some(panel) = app.workflow_panel.as_ref()
&& panel.run_id != run_id
{
let incoming_started_at = value.get("started_at_ms").and_then(|v| v.as_u64());
if panel.lifecycle.is_running()
|| incoming_started_at.is_none_or(|at_ms| at_ms <= panel.started_at_ms)
{
return;
}
if app.workflow_run(&run_id).is_none() && !may_create {
return;
}
let label = value
.get("workflow_goal")
.and_then(|v| v.as_str())
.or_else(|| value.get("workflow_id").and_then(|v| v.as_str()))
.unwrap_or(&run_id)
.to_string();
let at_ms = value
.get("started_at_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0);
// Prefer the typed event stream when present.
if let Some(events) = value.get("events").and_then(|e| e.as_array()) {
// Ensure the selected panel belongs to this payload before applying.
// A newer settled run can reach this branch without a retained
// run_started event, so replace it with a correctly identified shell.
if app
.workflow_panel
.as_ref()
.is_none_or(|panel| panel.run_id != run_id)
{
let label = value
.get("workflow_goal")
.and_then(|v| v.as_str())
.or_else(|| value.get("workflow_id").and_then(|v| v.as_str()))
.unwrap_or(&run_id)
.to_string();
let at_ms = value
.get("started_at_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if app.workflow_run(&run_id).is_none() {
let mut panel = crate::tui::widgets::workflow_panel::WorkflowPanel::new(
run_id.clone(),
label,
at_ms,
);
panel.locale = app.ui_locale;
app.workflow_panel = Some(panel);
app.push_workflow_run(panel);
}
if let Some(panel) = app.workflow_panel.as_mut() {
let mut injected = Vec::with_capacity(events.len());
for event in events {
let Some(panel) = app.workflow_run_mut(&run_id) else {
return;
};
let injected: Vec<serde_json::Value> = events
.iter()
.map(|event| {
let mut event = event.clone();
if let Some(obj) = event.as_object_mut() {
// The top-level run record is authoritative. Do not let a
@@ -1215,60 +1199,58 @@ fn apply_workflow_output_to_panel(app: &mut App, output: &str) {
serde_json::Value::String(run_id.clone()),
);
}
injected.push(event);
}
panel.apply_json_events(&injected);
// Completion/status payloads replay a retained event tail. Merge
// the authoritative exact count + bounded structured ledger after
// replay so live dispatch failures are neither duplicated nor
// lost when older events have fallen out of the tail (#5528).
panel.merge_dispatch_failures_from_run_json(&value);
// Carry final result / source into panel for expanded history card.
if let Some(summary) = value
.get("result")
.map(|v| v.to_string())
.filter(|s| s != "null")
{
panel.result_summary = Some(summary);
}
if let Some(path) = value.get("source_path").and_then(|v| v.as_str()) {
panel.source_path = Some(PathBuf::from(path));
}
app.needs_redraw = true;
event
})
.collect();
// A replayed tail can repeat this run's `run_started`, which resets
// its state; whether the finish line was written survives that.
let announced = panel.finish_announced;
panel.apply_json_events(&injected);
panel.finish_announced = announced || panel.finish_announced;
// Completion/status payloads replay a retained event tail. Merge
// the authoritative exact count + bounded structured ledger after
// replay so live dispatch failures are neither duplicated nor
// lost when older events have fallen out of the tail (#5528).
panel.merge_dispatch_failures_from_run_json(value);
if let Some(summary) = value
.get("result")
.and_then(crate::tools::workflow::workflow_result_preview)
{
panel.result_summary = Some(summary);
}
sync_workflow_history_card_from_panel(app);
if let Some(path) = value.get("source_path").and_then(|v| v.as_str()) {
panel.source_path = Some(PathBuf::from(path));
}
app.needs_redraw = true;
sync_workflow_history_card(app, &run_id);
return;
}
// Prefer full panel hydration from summary/phases snapshot when present.
// A summary/phases snapshot hydrates the whole run.
if let Some(mut panel) =
crate::tui::widgets::workflow_panel::WorkflowPanel::from_run_json(&value)
crate::tui::widgets::workflow_panel::WorkflowPanel::from_run_json(value)
{
panel.locale = app.ui_locale;
app.workflow_panel = Some(panel);
match app.workflow_run_mut(&run_id) {
Some(existing) => {
panel.finish_announced = existing.finish_announced;
*existing = panel;
}
None => app.push_workflow_run(panel),
}
app.needs_redraw = true;
sync_workflow_history_card_from_panel(app);
sync_workflow_history_card(app, &run_id);
return;
}
// Fallback: bare run record without events — at least surface header state.
if value.get("run_id").and_then(|v| v.as_str()).is_some() {
use crate::tui::widgets::workflow_panel::{WorkflowPanelEvent, WorkflowPanelLifecycle};
let label = value
.get("workflow_goal")
.and_then(|v| v.as_str())
.or_else(|| value.get("workflow_id").and_then(|v| v.as_str()))
.unwrap_or(&run_id)
.to_string();
let at_ms = value
.get("started_at_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let status = value
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("running");
let started_applied = app.apply_workflow_panel_event(
// Fallback: bare run record without events — at least surface its state.
use crate::tui::widgets::workflow_panel::{WorkflowPanelEvent, WorkflowPanelLifecycle};
let status = value
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("running");
if app.workflow_run(&run_id).is_none() {
app.apply_workflow_panel_event(
&run_id,
WorkflowPanelEvent::RunStarted {
run_id: run_id.clone(),
@@ -1285,62 +1267,85 @@ fn apply_workflow_output_to_panel(app: &mut App, output: &str) {
at_ms,
},
);
if !started_applied {
return;
}
if status != "running" {
let life = match status {
"completed" | "succeeded" => WorkflowPanelLifecycle::Succeeded,
"degraded" => WorkflowPanelLifecycle::Degraded,
"failed" => WorkflowPanelLifecycle::Failed,
"cancelled" | "canceled" => WorkflowPanelLifecycle::Cancelled,
_ => WorkflowPanelLifecycle::Running,
};
if life != WorkflowPanelLifecycle::Running {
app.apply_workflow_panel_event(
&run_id,
WorkflowPanelEvent::RunCompleted {
status: life,
error: value
.get("error")
.and_then(|v| v.as_str())
.map(str::to_string),
at_ms: value
.get("completed_at_ms")
.and_then(|v| v.as_u64())
.unwrap_or(at_ms),
},
);
}
}
sync_workflow_history_card_from_panel(app);
}
let life = match status {
"completed" | "succeeded" => WorkflowPanelLifecycle::Succeeded,
"degraded" => WorkflowPanelLifecycle::Degraded,
"failed" => WorkflowPanelLifecycle::Failed,
"cancelled" | "canceled" => WorkflowPanelLifecycle::Cancelled,
_ => WorkflowPanelLifecycle::Running,
};
if life != WorkflowPanelLifecycle::Running
&& app
.workflow_run(&run_id)
.is_some_and(|run| !run.lifecycle.is_terminal())
{
app.apply_workflow_panel_event(
&run_id,
WorkflowPanelEvent::RunCompleted {
status: life,
error: value
.get("error")
.and_then(|v| v.as_str())
.map(str::to_string),
at_ms: value
.get("completed_at_ms")
.and_then(|v| v.as_u64())
.unwrap_or(at_ms),
},
);
}
sync_workflow_history_card(app, &run_id);
}
/// Apply one live `WorkflowUi` engine event to the panel and history card.
/// Apply one live `WorkflowUi` engine event to its run.
///
/// Progress is applied to state at once but costs nothing else: only a run's
/// start and end touch the transcript, so a 75-agent fan-out's stream of
/// task and budget events never rescans history or reserializes a card.
pub(super) fn apply_workflow_ui_event(app: &mut App, run_id: &str, event: &serde_json::Value) {
use crate::tui::widgets::workflow_panel::WorkflowPanelEvent;
let mut event = event.clone();
if let Some(obj) = event.as_object_mut() {
// The engine envelope owns route identity. An embedded stale id must
// not move this event onto another run's panel.
// not move this event onto another run's state.
obj.insert(
"run_id".to_string(),
serde_json::Value::String(run_id.to_string()),
);
}
if let Some(panel_event) = WorkflowPanelEvent::from_json_value(&event)
&& !app.apply_workflow_panel_event(run_id, panel_event)
let event_type = event
.get("type")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
// The engine names what the run returned; keep it for the finish line.
// Set before the event applies, so the line written on this very event
// already carries it.
if event_type == "run_completed"
&& let Some(preview) = event
.get("result_preview")
.and_then(|v| v.as_str())
.map(str::to_string)
&& let Some(run) = app.workflow_run_mut(run_id)
{
run.result_summary = Some(preview);
}
let Some(panel_event) = WorkflowPanelEvent::from_json_value(&event) else {
return;
};
if !app.apply_workflow_panel_event(run_id, panel_event) {
return;
}
sync_workflow_history_card_from_panel(app);
if event_type == "run_started" {
sync_workflow_history_card(app, run_id);
}
}
/// Apply a live workflow event only when its immutable owner is the active
/// conversation. This check deliberately sits in the mutation helper so every
/// caller fails closed before touching the panel or transcript history.
/// caller fails closed before touching the run or transcript history.
pub(super) fn apply_owned_workflow_ui_event(
app: &mut App,
owner_session_id: &str,
@@ -1354,23 +1359,17 @@ pub(super) fn apply_owned_workflow_ui_event(
true
}
/// Mirror the live WorkflowPanel snapshot into the in-flight (or most recent)
/// workflow history tool cell so compact/expanded cards stay current.
fn sync_workflow_history_card_from_panel(app: &mut App) {
let Some(panel) = app.workflow_panel.as_ref() else {
/// A foreground `run` card has no output until the tool returns, but its
/// start line names the run. Give the newest still-running `workflow` card —
/// the one this run belongs to, or one that has no run yet — the run's
/// snapshot. A card whose tool has returned keeps its own output.
fn sync_workflow_history_card(app: &mut App, run_id: &str) {
let Some(snapshot) = app
.workflow_run(run_id)
.map(|panel| panel.to_run_json().to_string())
else {
return;
};
let run_id = panel.run_id.clone();
let snapshot = panel.to_run_json().to_string();
let degraded = matches!(
panel.lifecycle,
crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Degraded
);
// Prefer an in-flight Generic(workflow) cell whose output already carries
// this run_id, else the newest running workflow cell, else any workflow
// cell (tool-complete path already wrote the final output).
let mut target: Option<usize> = None;
let history_len = app.history.len();
let total = history_len
+ app
@@ -1378,75 +1377,41 @@ fn sync_workflow_history_card_from_panel(app: &mut App) {
.as_ref()
.map(|a| a.entries().len())
.unwrap_or(0);
let mut target = None;
for idx in (0..total).rev() {
let Some(cell) = app.cell_at_virtual_index(idx) else {
let Some(HistoryCell::Tool(ToolCell::Generic(generic))) = app.cell_at_virtual_index(idx)
else {
continue;
};
let HistoryCell::Tool(ToolCell::Generic(generic)) = cell else {
continue;
};
if generic.name != "workflow" {
if generic.name != "workflow" || generic.status != ToolStatus::Running {
continue;
}
let matches_run = generic
let card_run = generic
.output
.as_deref()
.and_then(|out| serde_json::from_str::<serde_json::Value>(out).ok())
.and_then(|v| {
v.get("run_id")
.and_then(|id| id.as_str())
.map(|id| id == run_id)
})
.unwrap_or(false);
let is_running = generic.status == ToolStatus::Running;
if matches_run || (is_running && target.is_none()) {
target = Some(idx);
if matches_run {
.map(str::to_string)
});
match card_run.as_deref() {
Some(id) if id == run_id => {
target = Some(idx);
break;
}
None if target.is_none() => target = Some(idx),
_ => {}
}
}
let Some(idx) = target else {
return;
};
if let Some(HistoryCell::Tool(ToolCell::Generic(generic))) = app.cell_at_virtual_index_mut(idx)
{
// Preserve a richer final output if the tool completion already wrote
// a full run record with an events array longer than the snapshot.
let replace = match generic.output.as_deref() {
None => true,
Some(existing) => {
let Ok(value) = serde_json::from_str::<serde_json::Value>(existing) else {
return;
};
let existing_run = value.get("run_id").and_then(|v| v.as_str()).unwrap_or("");
if !existing_run.is_empty() && existing_run != run_id {
return;
}
// Prefer full event-bearing records when the tool has completed.
if generic.status == ToolStatus::Running {
true
} else {
value
.get("events")
.and_then(|e| e.as_array())
.is_none_or(|e| e.is_empty())
}
}
};
let status_changed = degraded && generic.status != ToolStatus::Warning;
if status_changed {
generic.status = ToolStatus::Warning;
}
if replace {
generic.output = Some(snapshot);
generic.output_summary = Some(format!("workflow {}", run_id));
}
if replace || status_changed {
app.mark_history_updated();
}
generic.output = Some(snapshot);
generic.output_summary = Some(format!("workflow {run_id}"));
app.mark_history_updated();
}
}
@@ -1957,7 +1922,7 @@ mod tests {
use serde_json::json;
#[test]
fn late_live_event_from_prior_run_does_not_mutate_active_run() {
fn concurrent_runs_keep_their_own_events() {
let mut app = crate::test_support::test_app_with_options(
crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
);
@@ -1984,14 +1949,9 @@ mod tests {
"run-b",
&json!({"type": "phase_started", "title": "Build", "at_ms": 2_100}),
);
let before = app
.workflow_panel
.as_ref()
.expect("run B panel")
.to_run_json();
let before = app.workflow_run("run-b").expect("run B").to_run_json();
// Even a delayed start cannot rewind the selected panel to an older
// run. A genuinely newer run B was already accepted above.
// Both runs are rows of their own; a delayed start for A touches A.
apply_workflow_ui_event(
&mut app,
"run-a",
@@ -2025,13 +1985,21 @@ mod tests {
}),
);
let panel = app.workflow_panel.as_ref().expect("run B remains active");
assert_eq!(panel.run_id, "run-b");
assert_eq!(panel.to_run_json(), before);
assert_eq!(app.workflow_runs.len(), 2, "two runs, two rows");
assert_eq!(
app.workflow_run("run-b").expect("run B").to_run_json(),
before
);
let run_a = app.workflow_run("run-a").expect("run A");
assert_eq!(
run_a.lifecycle,
crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Failed
);
assert_eq!(run_a.dispatch_failure_count, 1);
}
#[test]
fn prior_run_completion_replay_does_not_replace_active_run() {
fn a_completion_replay_for_another_run_never_touches_this_one() {
let mut app = crate::test_support::test_app_with_options(
crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
);
@@ -2049,14 +2017,10 @@ mod tests {
"run-b",
&json!({"type": "phase_started", "title": "Verify", "at_ms": 2_100}),
);
let before = app
.workflow_panel
.as_ref()
.expect("run B panel")
.to_run_json();
let before = app.workflow_run("run-b").expect("run B").to_run_json();
// A retained completion tail can contain run_started. The top-level
// run identity and timestamp keep the whole replay off run B.
// run identity keeps the whole replay on run A.
apply_workflow_output_to_panel(
&mut app,
&json!({
@@ -2094,9 +2058,11 @@ mod tests {
.to_string(),
);
let panel = app.workflow_panel.as_ref().expect("run B remains active");
assert_eq!(panel.run_id, "run-b");
assert_eq!(panel.to_run_json(), before);
assert_eq!(
app.workflow_run("run-b").expect("run B").to_run_json(),
before
);
assert!(app.workflow_run("run-a").is_some());
}
#[test]
@@ -2122,9 +2088,8 @@ mod tests {
);
apply_workflow_ui_event(&mut app, "run-1", &failure);
assert_eq!(
app.workflow_panel
.as_ref()
.expect("live panel")
app.workflow_run("run-1")
.expect("live run")
.dispatch_failure_count,
1
);
@@ -2149,14 +2114,14 @@ mod tests {
.to_string(),
);
let panel = app.workflow_panel.as_ref().expect("completed panel");
let panel = app.workflow_run("run-1").expect("completed run");
assert_eq!(panel.dispatch_failure_count, 1);
assert_eq!(panel.dispatch_failures.len(), 1);
assert_eq!(panel.failure_cancel_counts(), (1, 0));
}
#[test]
fn degraded_workflow_snapshot_marks_history_receipt_as_warning() {
fn degraded_run_writes_one_warning_finish_line_after_its_start_card() {
let mut app = crate::test_support::test_app_with_options(
crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
);
@@ -2192,12 +2157,168 @@ mod tests {
let HistoryCell::Tool(ToolCell::Generic(receipt)) = app.history.last().expect("receipt")
else {
panic!("workflow receipt must remain generic")
panic!("the finish line is a workflow card")
};
assert_eq!(receipt.status, ToolStatus::Warning);
let output: serde_json::Value =
serde_json::from_str(receipt.output.as_deref().expect("snapshot")).expect("json");
assert_eq!(output["transcript_line"], "finished");
assert_eq!(output["run_id"], "run-partial");
assert!(!history_cell_has_running_tool(
app.history.last().expect("receipt")
));
// The live stream delivering the same terminal event later does not
// write the line again.
let cells = app.history.len();
apply_workflow_ui_event(
&mut app,
"run-partial",
&json!({"type": "run_completed", "status": "degraded", "at_ms": 2_000}),
);
assert_eq!(app.history.len(), cells);
}
#[test]
fn a_foreground_run_card_carries_its_own_finish_line() {
let mut app = crate::test_support::test_app_with_options(
crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
);
let mut active = crate::tui::active_cell::ActiveCell::new();
active.push_untracked(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "workflow".to_string(),
status: ToolStatus::Running,
input_summary: None,
output: None,
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})));
app.active_cell = Some(active);
apply_workflow_ui_event(
&mut app,
"run-fg",
&json!({"type": "run_started", "workflow_goal": "survey", "at_ms": 1_000}),
);
apply_workflow_ui_event(
&mut app,
"run-fg",
&json!({"type": "run_completed", "status": "completed", "result_preview": "3 findings", "at_ms": 5_000}),
);
// The tool returns the settled record into its own card.
if let Some(HistoryCell::Tool(ToolCell::Generic(card))) = app
.active_cell
.as_mut()
.and_then(|active| active.entry_mut(0))
{
card.status = ToolStatus::Success;
card.output = Some(
json!({
"run_id": "run-fg",
"workflow_goal": "survey",
"status": "completed",
"started_at_ms": 1_000,
"completed_at_ms": 5_000,
"result": {"summary": "3 findings"},
})
.to_string(),
);
}
app.flush_active_cell();
assert_eq!(
app.history.len(),
1,
"the card owns the finish; no second line"
);
let rendered = app.history[0]
.lines(100)
.iter()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("started"), "{rendered}");
assert!(rendered.contains("finished"), "{rendered}");
assert!(rendered.contains("3 findings"), "{rendered}");
}
#[test]
fn a_detached_finish_waits_for_its_start_card_to_leave_the_active_group() {
let mut app = crate::test_support::test_app_with_options(
crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
);
let start_card = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "workflow".to_string(),
status: ToolStatus::Success,
input_summary: None,
output: Some(
json!({"run_id": "run-fast", "workflow_goal": "quick", "status": "running"})
.to_string(),
),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
}));
let mut active = crate::tui::active_cell::ActiveCell::new();
active.push_untracked(start_card);
app.active_cell = Some(active);
for event in [
json!({"type": "run_started", "workflow_goal": "quick", "at_ms": 1_000}),
json!({"type": "run_completed", "status": "failed", "error": "script error, line 3", "at_ms": 1_400}),
] {
apply_workflow_ui_event(&mut app, "run-fast", &event);
}
assert!(
app.history.is_empty(),
"the finish line must not jump above its start"
);
app.flush_active_cell();
assert_eq!(app.history.len(), 2, "start card, then finish line");
let HistoryCell::Tool(ToolCell::Generic(finish)) = &app.history[1] else {
panic!("finish line is a workflow card");
};
assert_eq!(finish.status, ToolStatus::Failed);
let rendered = app.history[1]
.lines(100)
.iter()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("failed"), "{rendered}");
assert!(rendered.contains("quick"), "{rendered}");
assert!(rendered.contains("script error, line 3"), "{rendered}");
let start = app.history[0]
.lines(100)
.iter()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
start.contains("started") && start.contains("quick"),
"{start}"
);
assert_eq!(
start.lines().count(),
1,
"the start card is one line: {start}"
);
}
#[cfg(unix)]
+1 -4
View File
@@ -2186,10 +2186,7 @@ pub(crate) async fn apply_command_result(
}
}
AppAction::OpenWorkflowsManager => {
if app.view_stack.top_kind() != Some(ModalKind::WorkflowsManager) {
app.view_stack
.push(crate::tui::views::workflows_manager::WorkflowsManagerView::new(app));
}
crate::tui::views::workflows_manager::open(app);
}
AppAction::OpenExtensions { tab } => {
if app.view_stack.top_kind() != Some(ModalKind::Extensions) {
+25 -25
View File
@@ -2448,6 +2448,7 @@ pub(crate) async fn run_event_loop(
}
}
EngineEvent::TurnStarted { turn_id, route, .. } => {
app.prune_settled_workflow_runs();
// A prior turn that died without its `TurnComplete`
// must not leak its provisional estimate into this one.
app.clear_pending_turn_cost();
@@ -3740,24 +3741,26 @@ pub(crate) async fn run_event_loop(
received_engine_event = redraw_requested_before_event;
continue;
}
// #4095 residual: budget_updated is high-frequency under
// multi-agent fan-out. Data is already applied; pace the
// repaint like AgentProgress so the panel does not churn.
let is_budget = event
.get("type")
.and_then(|v| v.as_str())
.is_some_and(|t| t == "budget_updated");
if is_budget {
if workflow_budget_redraw_permitted(
// Coalesce progress (#4095): a 75-agent fan-out streams
// task and budget events far faster than a frame. The
// state is already applied; only a run's start and end
// paint at once, the rest share the AgentProgress pace.
// The transcript is not marked here — the only cells a
// workflow event touches mark it themselves.
let lifecycle =
event.get("type").and_then(|v| v.as_str()).is_some_and(|t| {
matches!(t, "run_started" | "run_completed" | "run_cancelled")
});
if lifecycle
|| workflow_budget_redraw_permitted(
&mut app.last_workflow_budget_redraw,
Instant::now(),
) {
app.needs_redraw = true;
} else {
received_engine_event = redraw_requested_before_event;
}
)
{
app.needs_redraw = true;
} else {
received_engine_event = redraw_requested_before_event;
}
transcript_batch_updated = true;
}
EngineEvent::ApprovalRequired {
id,
@@ -5016,15 +5019,6 @@ pub(crate) async fn run_event_loop(
continue;
}
// Clicking the WorkflowPanel gives its non-text controls focus,
// but ordinary characters always return directly to the composer.
// This keeps the panel keyboard-accessible without stealing the
// first t/c/j/k (or any other letter) of a new chat.
if app.view_stack.is_empty() && handle_workflow_panel_key(app, &key) {
submit_initial_input_if_ready(app, config, &engine_handle).await?;
continue;
}
// The Ocean work surface is a real focus owner. Route its keys
// before global transcript/composer navigation so PageUp/Down,
// Home/End, arrows, and row actions stay panel-local.
@@ -5807,8 +5801,14 @@ pub(crate) async fn run_event_loop(
open_agents_register(app, &engine_handle).await;
}
}
// `↓ to manage` opens the workflows view while the workbar
// shows runs, else the agent register.
crate::tui::agent_focus::AgentShellShortcut::ManageAgents => {
open_agents_register(app, &engine_handle).await;
if app.workflow_runs.is_empty() {
open_agents_register(app, &engine_handle).await;
} else {
crate::tui::views::workflows_manager::open(app);
}
}
}
continue;
+80 -45
View File
@@ -525,6 +525,49 @@ fn render_info_row(
interaction_hitboxes
}
/// Paint the workbar: live runs first (in start order), then settled ones,
/// each with the runtime's count of follow-ups queued on its busy agents.
fn render_workbar(f: &mut Frame, app: &App, area: Rect) {
let queued_for = |panel: &crate::tui::widgets::workflow_panel::WorkflowPanel| {
panel
.phases
.iter()
.flat_map(|phase| phase.rows.iter())
.filter(|row| row.status.is_running())
.filter_map(|row| app.agent_queued_follow_ups.get(&row.task_id))
.sum::<usize>()
};
let (live, settled): (Vec<_>, Vec<_>) = app
.workflow_runs
.iter()
.partition(|panel| panel.lifecycle.is_running());
let runs: Vec<crate::tui::widgets::workbar::WorkbarRun<'_>> = live
.into_iter()
.chain(settled)
.map(|panel| crate::tui::widgets::workbar::WorkbarRun {
panel,
queued: queued_for(panel),
})
.collect();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
.unwrap_or_default();
let lines = crate::tui::widgets::workbar::lines(
&runs,
area.width,
usize::from(area.height),
now_ms,
&app.ui_theme,
app.ui_locale,
);
let buf = f.buffer_mut();
Block::default()
.style(Style::default().bg(app.ui_theme.footer_bg))
.render(area, buf);
ratatui::widgets::Paragraph::new(lines).render(area, buf);
}
/// Register the chrome that already answers a click, so it also answers the
/// pointer.
///
@@ -553,12 +596,8 @@ fn register_clickable_chrome_for_hover(app: &App) {
MessageId::KbCloseMenu,
),
(
// Only the header row is the toggle/cancel affordance. Registering
// the whole panel painted the link glow (accent fg + underline on
// every cell) across the entire card whenever the pointer rested
// on it, and a pointer left there when the terminal lost focus
// kept it lit (#6503).
crate::tui::mouse_ui::workflow_panel_header_area(app),
// A workbar row opens `/workflows`.
app.viewport.last_workbar_area,
MessageId::CmdWorkflowDescription,
),
];
@@ -1604,18 +1643,14 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
// flight" (one owner per fact), and nothing sits between the transcript
// and the composer that is not a queued draft or an expanded panel.
// WorkflowPanel unified activity surface (#4121). Expanded while running
// (interactive drill-in above the composer); when collapsed the panel
// takes no rows — its persistent status lives in the top status bar as a
// header chip instead (#5040). Zero height when no panel.
let desired_workflow_panel_height = if mini {
// The workbar (#4121): one row per workflow run, directly under the
// posture bar, so live progress sits beside the controls that act on it
// and never between the transcript and the composer. Zero rows when no
// run is showing.
let desired_workbar_height = if mini {
0
} else {
app.workflow_panel
.as_ref()
.filter(|panel| panel.expanded)
.map(|panel| panel.desired_height(shell_area.width))
.unwrap_or(0)
crate::tui::widgets::workbar::desired_rows(app.workflow_runs.len())
};
let plugin_cta_height = if mini && !mini_cfg.keep_input {
0
@@ -1635,8 +1670,8 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
// up to three compact rows at the release floor.
let preview_cap = if size.height >= 20 { 4 } else { 3 };
let preview_height = desired_preview_height.min(auxiliary_budget.min(preview_cap));
let workflow_panel_height =
desired_workflow_panel_height.min(auxiliary_budget.saturating_sub(preview_height));
let workbar_height =
desired_workbar_height.min(auxiliary_budget.saturating_sub(preview_height));
// Two pinned rows bracket the composer from below (SHELL-DESIGN-20260901
// §2.0 item 3, §2.3b): the posture bar — permission · mode · live counts
@@ -1667,19 +1702,21 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
.constraints([
Constraint::Length(strip_above_height), // Tasks + To-do above transcript (`top`)
Constraint::Min(1), // Chat area
Constraint::Length(workflow_panel_height), // Workflow panel (#4121)
Constraint::Length(preview_height), // Pending input preview (0 if empty)
Constraint::Length(plugin_cta_height), // Live plugin CTA (0 unless matched)
Constraint::Length(composer_height), // Composer
Constraint::Length(footer_height), // Posture bar
Constraint::Length(workbar_height), // Workbar: one row per workflow run
Constraint::Length(info_height), // Metrics line
Constraint::Length(strip_below_height), // Roster + To-do under the chrome (`bottom`)
])
.split(body_area);
let strip_slot = if strip_below { 8 } else { 0 };
let plugin_cta_slot = 4;
let composer_slot = 5;
let footer_slot = 6;
let preview_slot = 2;
let plugin_cta_slot = 3;
let composer_slot = 4;
let footer_slot = 5;
let workbar_slot = 6;
let info_slot = 7;
if matches!(
@@ -1804,30 +1841,10 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
}
}
// Workflow panel between chat and pending-input preview (#4121).
if workflow_panel_height > 0 {
if let Some(panel) = app.workflow_panel.as_ref() {
let area = body_chunks[2];
app.viewport.last_workflow_panel_area = Some(area);
app.viewport.last_workflow_cancel_area =
panel.cancel_hint_span(area.width).map(|(start, end)| Rect {
x: area.x.saturating_add(start),
y: area.y,
width: end.saturating_sub(start),
height: 1,
});
let buf = f.buffer_mut();
panel.render(area, buf);
}
} else {
app.viewport.last_workflow_panel_area = None;
app.viewport.last_workflow_cancel_area = None;
}
// Render pending-input preview (queued/steered messages, if any).
if preview_height > 0 {
let buf = f.buffer_mut();
pending_preview.render(body_chunks[3], buf);
pending_preview.render(body_chunks[preview_slot], buf);
}
if plugin_cta_height > 0 {
@@ -1924,6 +1941,14 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
register_footer_count_targets(app, &facts, &count_rects);
}
if workbar_height > 0 {
let area = body_chunks[workbar_slot];
render_workbar(f, app, area);
app.viewport.last_workbar_area = Some(area);
} else {
app.viewport.last_workbar_area = None;
}
// The metrics line sits directly under the posture bar: model · ctx ·
// cost · ttft · tok/s · ↓ tokens, with the help hint pinned right.
let mut info_interactions = InfoLineInteractionHitboxes::default();
@@ -1957,8 +1982,11 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
column.paint_matching(side_area, f.buffer_mut(), app.ui_theme.surface_bg);
}
column.paint_matching(work_chat_area, f.buffer_mut(), app.ui_theme.surface_bg);
column.paint_matching(body_chunks[2], f.buffer_mut(), app.ui_theme.surface_bg);
column.paint_matching(body_chunks[3], f.buffer_mut(), app.ui_theme.surface_bg);
column.paint_matching(
body_chunks[preview_slot],
f.buffer_mut(),
app.ui_theme.surface_bg,
);
if plugin_cta_height > 0 {
column.paint_matching(
body_chunks[plugin_cta_slot],
@@ -1978,6 +2006,13 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
app.ui_theme.footer_bg,
);
}
if workbar_height > 0 {
column.paint_matching(
body_chunks[workbar_slot],
f.buffer_mut(),
app.ui_theme.footer_bg,
);
}
}
register_clickable_chrome_for_hover(app);
crate::tui::hover_layer::apply_resolved_effects(
-77
View File
@@ -312,83 +312,6 @@ pub(crate) fn handle_prompt_transcript_key(app: &mut App, key: &event::KeyEvent)
true
}
/// Route only non-text controls to a focused workflow panel.
///
/// Returning `false` for every character is deliberate: the caller then lets
/// the normal composer path insert it. A prior bare-letter contract used
/// t/c/j/k here, which made the first matching letter of a new chat disappear
/// after the user clicked the workflow card.
pub(crate) fn handle_workflow_panel_key(app: &mut App, key: &event::KeyEvent) -> bool {
if !app
.workflow_panel
.as_ref()
.is_some_and(|panel| panel.keyboard_focus)
{
return false;
}
if matches!(key.code, KeyCode::Char(_)) {
if let Some(panel) = app.workflow_panel.as_mut() {
panel.keyboard_focus = false;
}
app.needs_redraw = true;
return false;
}
if !key.modifiers.is_empty() && key.code != KeyCode::Esc {
return false;
}
match key.code {
KeyCode::Esc => {
if let Some(panel) = app.workflow_panel.as_mut() {
panel.keyboard_focus = false;
}
app.needs_redraw = true;
true
}
KeyCode::Enter => {
if let Some(panel) = app.workflow_panel.as_mut() {
let _ = panel.toggle_expanded();
}
app.needs_redraw = true;
true
}
KeyCode::Down => {
if let Some(panel) = app.workflow_panel.as_mut() {
panel.select_next_phase();
}
app.needs_redraw = true;
true
}
KeyCode::Up => {
if let Some(panel) = app.workflow_panel.as_mut() {
panel.select_prev_phase();
}
app.needs_redraw = true;
true
}
KeyCode::Delete => {
let Some(run_id) = app
.workflow_panel
.as_ref()
.and_then(|panel| panel.lifecycle.is_running().then(|| panel.run_id.clone()))
else {
return false;
};
app.input = format!("/workflow cancel {run_id}");
app.cursor_position = app.input.chars().count();
app.status_message = Some(app.tr(MessageId::SidebarDestructiveArmed).into_owned());
if let Some(panel) = app.workflow_panel.as_mut() {
panel.keyboard_focus = false;
}
app.needs_redraw = true;
true
}
_ => false,
}
}
/// One-shot "draft my constitution" call against the user's first configured
/// model, requested by `A` on the setup Constitution card. Runs inline in the
/// event loop like [`fetch_available_models`] (the wizard modal stays open
+98 -69
View File
@@ -225,6 +225,98 @@ fn frame_cursor_is_hidden_during_diff_then_positioned_before_reveal() {
.assert_cursor_position(Position::new(17, 9));
}
#[test]
fn workbar_rows_sit_under_the_status_row_one_per_workflow() {
let mut app = crate::test_support::test_app_with_options(crate::tui::app::TuiOptions {
model: "deepseek-v4-flash".to_string(),
start_in_agent_mode: true,
..crate::test_support::test_tui_options(PathBuf::from("."))
});
app.onboarding = crate::tui::app::OnboardingState::None;
app.launch.visible = false;
app.ui_locale = codewhale_localization::Locale::En;
app.onboarding_needs_api_key = false;
app.current_session_id = Some("session-wb".to_string());
app.history.push(HistoryCell::User {
content: "Established conversation".to_string(),
});
for (run, goal) in [
("run-a", "Audit the parser"),
("run-b", "Port the fixtures"),
] {
assert!(apply_owned_workflow_ui_event(
&mut app,
"session-wb",
run,
&serde_json::json!({"type": "run_started", "workflow_goal": goal, "at_ms": 1}),
));
for index in 0..3 {
apply_owned_workflow_ui_event(
&mut app,
"session-wb",
run,
&serde_json::json!({
"type": "task_started",
"task_id": format!("{run}-{index}"),
"workflow_task_label": format!("agent {index}"),
"at_ms": 2,
}),
);
}
}
apply_owned_workflow_ui_event(
&mut app,
"session-wb",
"run-a",
&serde_json::json!({"type": "task_completed", "task_id": "run-a-0", "status": "succeeded", "at_ms": 3}),
);
app.is_loading = true;
app.turn_started_at = Some(Instant::now());
let config = Config::default();
let (width, height) = (100u16, 30u16);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
let _ = super::frame::render(frame, &mut app, &config);
})
.unwrap();
let buf = terminal.backend().buffer();
let rows: Vec<String> = (0..height)
.map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
.collect();
// No progress card sits between the transcript and the composer, and the
// transcript carries no run rows.
let composer = app.viewport.last_composer_area.expect("composer area");
let status = usize::from(composer.y + composer.height);
assert!(
rows[status].contains("Esc to interrupt") && rows[status].contains("to manage"),
"the status row keeps Esc and names the manage key: {:?}",
rows[status]
);
assert!(
rows[status + 1].contains("Audit the parser") && rows[status + 1].contains("1/3 so far"),
"first workbar row: {:?}",
rows[status + 1]
);
assert!(
rows[status + 2].contains("Port the fixtures") && rows[status + 2].contains("0/3 so far"),
"second workbar row: {:?}",
rows[status + 2]
);
assert!(
rows[..usize::from(composer.y)]
.iter()
.all(|row| !row.contains("so far")),
"progress stays out of the transcript"
);
assert_eq!(
app.viewport.last_workbar_area.map(|area| area.height),
Some(2)
);
}
#[test]
fn composer_rows_stay_pinned_across_turn_state_transitions() {
// The Tideline shell: the stage, one merged footer row, and the info line
@@ -721,7 +813,7 @@ fn focus_test_app() -> App {
app.onboarding = crate::tui::app::OnboardingState::None;
app.launch.visible = false;
app.work_surface.focused = false;
app.workflow_panel = None;
app.workflow_runs.clear();
app
}
@@ -2299,7 +2391,7 @@ fn workflow_ui_events_apply_only_to_the_active_session_owner() {
&a_started,
));
assert!(
app.workflow_panel.is_none(),
app.workflow_runs.is_empty(),
"foreign event must not mutate B"
);
@@ -2314,12 +2406,7 @@ fn workflow_ui_events_apply_only_to_the_active_session_owner() {
"workflow-b",
&b_started,
));
assert_eq!(
app.workflow_panel
.as_ref()
.map(|panel| panel.run_id.as_str()),
Some("workflow-b")
);
assert!(app.workflow_run("workflow-b").is_some());
// A -> B -> A restores A's event lane with a new chronological start; it
// never replays an older start through B.
@@ -2336,10 +2423,9 @@ fn workflow_ui_events_apply_only_to_the_active_session_owner() {
&a_resumed,
));
assert_eq!(
app.workflow_panel
.as_ref()
.map(|panel| panel.run_id.as_str()),
Some("workflow-a")
app.workflow_run("workflow-a")
.map(|panel| panel.label.as_str()),
Some("A workflow resumed")
);
}
@@ -2424,63 +2510,6 @@ fn successful_workflow_run_raises_no_failure_toast() {
);
}
#[test]
fn workflow_panel_plain_letters_return_to_composer() {
let mut app = create_test_app();
app.workflow_panel = Some(crate::tui::widgets::workflow_panel::WorkflowPanel::new(
"workflow_typing",
"typing regression",
0,
));
for ch in ['t', 'c', 'j', 'k'] {
app.workflow_panel
.as_mut()
.expect("workflow panel")
.keyboard_focus = true;
let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE);
assert!(
!handle_workflow_panel_key(&mut app, &key),
"plain {ch:?} must fall through to the composer"
);
assert!(
!app.workflow_panel
.as_ref()
.expect("workflow panel")
.keyboard_focus,
"typing releases panel focus"
);
app.insert_char(ch);
}
assert_eq!(app.input, "tcjk");
}
#[test]
fn workflow_panel_uses_non_text_keys_for_controls() {
let mut app = create_test_app();
let mut panel = crate::tui::widgets::workflow_panel::WorkflowPanel::new(
"workflow_keys",
"keyboard controls",
0,
);
panel.keyboard_focus = true;
let was_expanded = panel.expanded;
app.workflow_panel = Some(panel);
assert!(handle_workflow_panel_key(
&mut app,
&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
));
assert_ne!(
app.workflow_panel
.as_ref()
.expect("workflow panel")
.expanded,
was_expanded
);
}
struct ConfigPathEnvGuard {
_codewhale_home: crate::test_support::EnvVarGuard,
_codewhale_config_path: crate::test_support::EnvVarGuard,
@@ -66,6 +66,16 @@ fn child_state_glyph(state: &str) -> &'static str {
}
}
/// Open `/workflows` unless it is already on top. `/workflows`, the
/// workbar's `↓ to manage`, and a click on a workbar row all land here.
pub(crate) fn open(app: &mut App) {
if app.view_stack.top_kind() != Some(ModalKind::WorkflowsManager) {
let view = WorkflowsManagerView::new(app);
app.view_stack.push(view);
}
app.needs_redraw = true;
}
pub struct WorkflowsManagerView {
/// Newest first, so the live run is the first thing read.
runs: Vec<HostWorkflowRunDetail>,
+1
View File
@@ -3,6 +3,7 @@ pub mod key_hint;
pub mod pending_input_preview;
mod renderable;
pub mod tool_card;
pub(crate) mod workbar;
pub mod workflow_panel;
pub use renderable::Renderable;
+622
View File
@@ -0,0 +1,622 @@
//! The workbar: live workflow progress under the composer.
//!
//! The transcript carries one line when a workflow starts and one when it
//! finishes (`history.rs`); everything in between lives here, one row per
//! run, directly under the posture bar:
//!
//! ```text
//! • Compare Cline with Codewhale ████████░░░░░░░░░░░░ 4/10 so far 2m14s ↓1.2M ⚠ 1 failed · 2 queued
//! ```
//!
//! Row grammar: status mark · name · 20-cell bar · settled/total · elapsed ·
//! ↓tokens · chips. The total is what the run has admitted *so far* — a script
//! can keep spawning agents — so a running row says "so far" and the bar may
//! move backwards when the total grows. Chips are only ever true: a failure
//! count when an agent failed, "Large workflow" only past
//! [`LARGE_WORKFLOW_AGENTS`], and `· N queued` only when the runtime reports
//! follow-ups waiting on a busy agent of this run.
//!
//! Every state reads without colour (a distinct mark, and a word for
//! anything that needs you). Nothing animates, so reduced motion needs no
//! second path; ASCII-safe terminals get the backend's per-cell fallbacks.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::tui::ui_text::truncate_line_to_width;
use crate::tui::widgets::workflow_panel::{WorkflowPanel, WorkflowPanelLifecycle};
use codewhale_localization::{Locale, MessageId, tr};
use codewhale_palette::UiTheme;
/// Cells in one progress bar.
pub(crate) const BAR_CELLS: usize = 20;
/// Agents a run must have admitted before the workbar calls it large. At this
/// size a run is no longer something to watch row by row, and its token use
/// is the thing worth a glance.
pub(crate) const LARGE_WORKFLOW_AGENTS: usize = 25;
/// Most run rows painted at once; the rest fold into one `+N more` row.
pub(crate) const MAX_RUN_ROWS: usize = 6;
/// The name column never shrinks below this before other columns go.
const MIN_NAME_COLS: usize = 12;
/// Below this width the bar gives its columns to the name.
const BAR_MIN_WIDTH: usize = 72;
const GAP: &str = " ";
/// One run as the workbar paints it: the run's own state plus the runtime's
/// count of follow-ups waiting on its busy agents.
pub(crate) struct WorkbarRun<'a> {
pub panel: &'a WorkflowPanel,
pub queued: usize,
}
/// Rows the workbar wants for `runs` runs, before the frame's budget.
#[must_use]
pub(crate) fn desired_rows(runs: usize) -> u16 {
let rows = if runs > MAX_RUN_ROWS {
MAX_RUN_ROWS + 1
} else {
runs
};
u16::try_from(rows).unwrap_or(u16::MAX)
}
/// Paint `runs` into at most `max_rows` lines of `width` columns. When the
/// runs do not all fit, the last row says how many are hidden and names the
/// key that lists them.
#[must_use]
pub(crate) fn lines(
runs: &[WorkbarRun<'_>],
width: u16,
max_rows: usize,
now_ms: u64,
theme: &UiTheme,
locale: Locale,
) -> Vec<Line<'static>> {
let width = usize::from(width);
if runs.is_empty() || max_rows == 0 || width < 8 {
return Vec::new();
}
let shown = if runs.len() > max_rows {
max_rows.saturating_sub(1)
} else {
runs.len()
};
let cells: Vec<RowCells> = runs[..shown]
.iter()
.map(|run| RowCells::new(run, now_ms, locale))
.collect();
let layout = Layout::fit(&cells, width.saturating_sub(2));
let mut out: Vec<Line<'static>> = cells
.iter()
.map(|cells| cells.line(&layout, width, theme))
.collect();
let hidden = runs.len() - shown;
if hidden > 0 {
let arrow = if crate::tui::color_compat::ascii_safe_enabled() {
"v"
} else {
"↓"
};
let text = format!(
" {} · {arrow} {}",
tr(locale, MessageId::WorkbarMoreRuns).replace("{count}", &hidden.to_string()),
tr(locale, MessageId::FooterHintToManage),
);
out.push(Line::from(Span::styled(
truncate_line_to_width(&text, width),
Style::default().fg(theme.text_hint),
)));
}
out
}
/// The text of one row, before layout.
struct RowCells {
lifecycle: WorkflowPanelLifecycle,
mark: &'static str,
name: String,
/// Filled cells of [`BAR_CELLS`].
filled: usize,
progress: String,
elapsed: String,
tokens: Option<String>,
/// `(text, needs attention)`: attention chips paint in warning ink.
chips: Vec<(String, bool)>,
}
impl RowCells {
fn new(run: &WorkbarRun<'_>, now_ms: u64, locale: Locale) -> Self {
let panel = run.panel;
let (settled, total) = panel.done_total();
let (failed, _) = panel.failure_cancel_counts();
let lifecycle = panel.lifecycle;
let filled = if total == 0 {
0
} else {
(settled * BAR_CELLS + total / 2) / total
}
.min(BAR_CELLS);
let progress = if total == 0 {
tr(locale, MessageId::WorkflowNoTasksYet).into_owned()
} else if lifecycle.is_running() {
tr(locale, MessageId::WorkbarSoFar)
.replace("{done}", &settled.to_string())
.replace("{total}", &total.to_string())
} else {
format!("{settled}/{total}")
};
let end = panel.completed_at_ms.unwrap_or(now_ms);
let elapsed = if panel.started_at_ms == 0 {
String::new()
} else {
crate::elapsed::format_elapsed_secs(end.saturating_sub(panel.started_at_ms) / 1_000)
};
let tokens = panel.tokens_so_far().map(|tokens| {
format!(
"↓{}",
crate::tui::footer_ui::format_token_count_compact(tokens)
)
});
let mut chips = Vec::new();
match lifecycle {
WorkflowPanelLifecycle::Failed => {
chips.push((tr(locale, MessageId::WorkflowLineFailed).into_owned(), true))
}
WorkflowPanelLifecycle::Cancelled => chips.push((
tr(locale, MessageId::WorkflowLineStopped).into_owned(),
false,
)),
WorkflowPanelLifecycle::Degraded => chips.push((
tr(locale, MessageId::WorkflowLineFinishedWithGaps).into_owned(),
true,
)),
_ if failed > 0 => chips.push((
format!(
"⚠ {}",
tr(locale, MessageId::WorkflowCountFailed)
.replace("{count}", &failed.to_string())
),
true,
)),
_ => {}
}
if total >= LARGE_WORKFLOW_AGENTS {
chips.push((
format!("⚠ {}", tr(locale, MessageId::WorkbarLargeWorkflow)),
true,
));
}
if run.queued > 0 {
chips.push((
format!(
"· {}",
tr(locale, MessageId::AgentRailQueuedCount)
.replace("{count}", &run.queued.to_string())
),
true,
));
}
let name = panel.label.split_whitespace().collect::<Vec<_>>().join(" ");
Self {
lifecycle,
mark: lifecycle_mark(lifecycle),
name: if name.is_empty() {
"workflow".to_string()
} else {
name
},
filled,
progress,
elapsed,
tokens,
chips,
}
}
fn line(&self, layout: &Layout, width: usize, theme: &UiTheme) -> Line<'static> {
let state_ink = lifecycle_ink(self.lifecycle, theme);
let quiet = Style::default().fg(theme.text_muted);
let mut spans = vec![
Span::raw(" "),
Span::styled(format!("{} ", self.mark), Style::default().fg(state_ink)),
Span::styled(
pad(
&crate::tui::ui_text::semantic_truncate(&self.name, layout.name_cols),
layout.name_cols,
),
Style::default()
.fg(theme.text_body)
.add_modifier(Modifier::BOLD),
),
];
if layout.bar {
spans.push(Span::raw(GAP));
spans.push(Span::styled(
"█".repeat(self.filled),
Style::default().fg(state_ink),
));
spans.push(Span::styled(
"░".repeat(BAR_CELLS - self.filled),
Style::default().fg(theme.text_hint),
));
}
spans.push(Span::raw(GAP));
spans.push(Span::styled(
pad(&self.progress, layout.progress_cols),
quiet,
));
if layout.elapsed_cols > 0 {
spans.push(Span::raw(GAP));
spans.push(Span::styled(pad(&self.elapsed, layout.elapsed_cols), quiet));
}
if layout.tokens_cols > 0 {
spans.push(Span::raw(GAP));
spans.push(Span::styled(
pad(self.tokens.as_deref().unwrap_or(""), layout.tokens_cols),
quiet,
));
}
for (index, (chip, attention)) in self.chips.iter().enumerate() {
spans.push(Span::raw(if index == 0 { GAP } else { " " }));
let ink = if *attention {
theme.warning
} else {
theme.text_muted
};
spans.push(Span::styled(chip.clone(), Style::default().fg(ink)));
}
clip_line(spans, width)
}
}
/// Column widths shared by every visible row, so the columns line up.
struct Layout {
name_cols: usize,
bar: bool,
progress_cols: usize,
elapsed_cols: usize,
tokens_cols: usize,
}
impl Layout {
/// Fit the shared columns into `width`. The name and the progress count
/// always stay; the bar goes first, then tokens, then elapsed, so a narrow
/// terminal keeps the facts and loses the picture.
fn fit(rows: &[RowCells], width: usize) -> Self {
let widest = |f: &dyn Fn(&RowCells) -> usize| rows.iter().map(f).max().unwrap_or(0);
let name_want = widest(&|row| row.name.width());
let chips_want = widest(&|row| {
row.chips
.iter()
.map(|(chip, _)| chip.width() + 1)
.sum::<usize>()
.saturating_add(1)
})
.min(24);
let mut layout = Self {
name_cols: 0,
bar: width >= BAR_MIN_WIDTH,
progress_cols: widest(&|row| row.progress.width()),
elapsed_cols: widest(&|row| row.elapsed.width()),
tokens_cols: widest(&|row| row.tokens.as_deref().map_or(0, UnicodeWidthStr::width)),
};
loop {
let fixed = 2 // mark + space
+ if layout.bar { BAR_CELLS + GAP.len() } else { 0 }
+ GAP.len() + layout.progress_cols
+ if layout.elapsed_cols > 0 { GAP.len() + layout.elapsed_cols } else { 0 }
+ if layout.tokens_cols > 0 { GAP.len() + layout.tokens_cols } else { 0 };
let room = width.saturating_sub(fixed + chips_want);
if room >= MIN_NAME_COLS.min(name_want) {
layout.name_cols = name_want.min(room.max(MIN_NAME_COLS.min(name_want)));
return layout;
}
if layout.bar {
layout.bar = false;
} else if layout.tokens_cols > 0 {
layout.tokens_cols = 0;
} else if layout.elapsed_cols > 0 {
layout.elapsed_cols = 0;
} else {
layout.name_cols = width.saturating_sub(fixed).max(1).min(name_want.max(1));
return layout;
}
}
}
}
/// The run's mark: distinct shapes, so the state reads without colour.
fn lifecycle_mark(lifecycle: WorkflowPanelLifecycle) -> &'static str {
match lifecycle {
WorkflowPanelLifecycle::Pending => crate::tui::glyphs::AVAILABLE,
WorkflowPanelLifecycle::Running => "•",
WorkflowPanelLifecycle::Succeeded => crate::tui::glyphs::DONE,
WorkflowPanelLifecycle::Degraded => crate::tui::glyphs::ATTENTION,
WorkflowPanelLifecycle::Failed => crate::tui::glyphs::FAILED,
WorkflowPanelLifecycle::Cancelled => "⊘",
}
}
/// Running is working ink, not attention; only trouble spends warning/error.
fn lifecycle_ink(lifecycle: WorkflowPanelLifecycle, theme: &UiTheme) -> ratatui::style::Color {
match lifecycle {
WorkflowPanelLifecycle::Pending | WorkflowPanelLifecycle::Cancelled => theme.text_muted,
WorkflowPanelLifecycle::Running => theme.accent_action,
WorkflowPanelLifecycle::Succeeded => theme.success,
WorkflowPanelLifecycle::Degraded => theme.warning,
WorkflowPanelLifecycle::Failed => theme.error_fg,
}
}
fn pad(text: &str, cols: usize) -> String {
let width = text.width();
if width >= cols {
text.to_string()
} else {
format!("{text}{}", " ".repeat(cols - width))
}
}
/// Clip a styled row at `width` columns without splitting a wide glyph.
fn clip_line(spans: Vec<Span<'static>>, width: usize) -> Line<'static> {
let mut used = 0usize;
let mut out = Vec::with_capacity(spans.len());
for span in spans {
let span_width = span.content.width();
if used + span_width <= width {
used += span_width;
out.push(span);
continue;
}
let room = width.saturating_sub(used);
if room > 0 {
let text = truncate_line_to_width(&span.content, room);
out.push(Span::styled(text, span.style));
}
break;
}
Line::from(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::widgets::workflow_panel::{WorkflowPanelEvent, WorkflowRowStatus};
use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph};
const NOW: u64 = 1_000_000;
fn run(label: &str, started: u64, agents: usize, settled: usize) -> WorkflowPanel {
let mut panel = WorkflowPanel::new(format!("run-{label}"), label, started);
panel.apply_event(WorkflowPanelEvent::PhaseStarted {
title: "Survey".to_string(),
at_ms: started,
});
for index in 0..agents {
let value = serde_json::json!({
"type": "task_started",
"task_id": format!("{label}-{index}"),
"workflow_task_label": format!("agent-{index}"),
"at_ms": started,
});
panel.apply_event(WorkflowPanelEvent::from_json_value(&value).expect("task_started"));
}
for index in 0..settled {
panel.apply_event(WorkflowPanelEvent::TaskCompleted {
task_id: format!("{label}-{index}"),
status: WorkflowRowStatus::Succeeded,
usage: None,
at_ms: started + 1_000,
});
}
panel
}
fn render(runs: &[WorkbarRun<'_>], width: u16, rows: u16) -> String {
let theme = codewhale_palette::UI_THEME;
let lines = lines(runs, width, usize::from(rows), NOW, &theme, Locale::En);
let mut terminal = Terminal::new(TestBackend::new(width, rows)).expect("terminal");
terminal
.draw(|frame| frame.render_widget(Paragraph::new(lines), frame.area()))
.expect("draw");
let buffer = terminal.backend().buffer().clone();
(0..rows)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn running_row_shows_bar_growing_total_elapsed_and_tokens() {
let mut panel = run("Compare Cline with Codewhale", NOW - 134_000, 10, 4);
panel.budget_spent = 1_234_567;
let snapshot = render(
&[WorkbarRun {
panel: &panel,
queued: 0,
}],
100,
1,
);
assert_eq!(
snapshot,
" • Compare Cline with Codewhale ████████░░░░░░░░░░░░ 4/10 so far 2m 14s ↓1.2M"
);
}
#[test]
fn settled_rows_read_without_colour_and_drop_so_far() {
let mut done = run("audit", NOW - 60_000, 3, 3);
done.apply_event(WorkflowPanelEvent::RunCompleted {
status: WorkflowPanelLifecycle::Succeeded,
error: None,
at_ms: NOW - 1_000,
});
let mut failed = run("migrate", NOW - 60_000, 2, 0);
failed.apply_event(WorkflowPanelEvent::RunCompleted {
status: WorkflowPanelLifecycle::Failed,
error: Some("script error".to_string()),
at_ms: NOW - 1_000,
});
let mut gaps = run("review", NOW - 60_000, 2, 1);
gaps.apply_event(WorkflowPanelEvent::RunCompleted {
status: WorkflowPanelLifecycle::Degraded,
error: None,
at_ms: NOW - 1_000,
});
let snapshot = render(
&[
WorkbarRun {
panel: &done,
queued: 0,
},
WorkbarRun {
panel: &failed,
queued: 0,
},
WorkbarRun {
panel: &gaps,
queued: 0,
},
],
80,
3,
);
assert_eq!(
snapshot,
[
" ✓ audit ████████████████████ 3/3 59s",
" ✕ migrate ░░░░░░░░░░░░░░░░░░░░ 0/2 59s failed",
" ◆ review ██████████░░░░░░░░░░ 1/2 59s finished with gaps",
]
.join("\n")
);
}
#[test]
fn chips_are_only_ever_true() {
// Small and healthy: no chip at all.
let small = run("small", NOW - 5_000, 3, 1);
let plain = render(
&[WorkbarRun {
panel: &small,
queued: 0,
}],
100,
1,
);
assert!(!plain.contains('⚠') && !plain.contains("queued"), "{plain}");
// Large only at the threshold, a failure count only after a failure,
// queued only when the runtime reports waiting follow-ups.
let mut large = run("large", NOW - 5_000, LARGE_WORKFLOW_AGENTS, 0);
large.apply_event(WorkflowPanelEvent::TaskCompleted {
task_id: "large-0".to_string(),
status: WorkflowRowStatus::Failed,
usage: None,
at_ms: NOW,
});
let busy = render(
&[WorkbarRun {
panel: &large,
queued: 2,
}],
120,
1,
);
assert!(busy.contains("⚠ 1 failed"), "{busy}");
assert!(busy.contains("⚠ Large workflow"), "{busy}");
assert!(busy.ends_with("· 2 queued"), "{busy}");
let under = run("under", NOW - 5_000, LARGE_WORKFLOW_AGENTS - 1, 0);
let under = render(
&[WorkbarRun {
panel: &under,
queued: 0,
}],
120,
1,
);
assert!(!under.contains("Large"), "{under}");
}
#[test]
fn narrow_rows_shed_the_bar_before_the_facts() {
let panel = run(
"Compare Cline with Codewhale on five axes",
NOW - 10_000,
8,
2,
);
let narrow = render(
&[WorkbarRun {
panel: &panel,
queued: 0,
}],
60,
1,
);
assert!(!narrow.contains('█') && !narrow.contains('░'), "{narrow}");
assert!(narrow.contains("2/8 so far"), "{narrow}");
assert!(narrow.width() <= 60, "{narrow}");
let tiny = render(
&[WorkbarRun {
panel: &panel,
queued: 0,
}],
30,
1,
);
assert!(tiny.contains("2/8 so far"), "{tiny}");
}
#[test]
fn many_runs_fold_into_a_more_row_that_names_the_key() {
let panels: Vec<WorkflowPanel> = (0..12)
.map(|index| run(&format!("wf-{index:02}"), NOW - 10_000, 8, index % 8))
.collect();
let runs: Vec<WorkbarRun<'_>> = panels
.iter()
.map(|panel| WorkbarRun { panel, queued: 0 })
.collect();
assert_eq!(desired_rows(runs.len()), MAX_RUN_ROWS as u16 + 1);
let snapshot = render(&runs, 100, desired_rows(runs.len()));
let rows: Vec<&str> = snapshot.lines().collect();
assert_eq!(rows.len(), MAX_RUN_ROWS + 1);
assert_eq!(rows[MAX_RUN_ROWS], " +6 more · ↓ to manage");
}
#[test]
fn seventy_five_agents_across_ten_runs_render_in_one_pass() {
let panels: Vec<WorkflowPanel> = (0..10)
.map(|index| run(&format!("wf-{index}"), NOW - 10_000, 8, 3))
.collect();
let runs: Vec<WorkbarRun<'_>> = panels
.iter()
.map(|panel| WorkbarRun { panel, queued: 0 })
.collect();
let started = std::time::Instant::now();
for _ in 0..100 {
let _ = lines(
&runs,
120,
MAX_RUN_ROWS + 1,
NOW,
&codewhale_palette::UI_THEME,
Locale::En,
);
}
assert!(
started.elapsed() < std::time::Duration::from_secs(1),
"100 frames of 10 runs × 8 agents took {:?}",
started.elapsed()
);
}
}
File diff suppressed because it is too large. Load diff
@@ -320,12 +320,12 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
));
if let Some(queued) = queued.as_deref() {
// Truthful `· N queued`: follow-ups the running child has not
// yet folded into its next round. Accent so it reads as live
// pending work, not as part of the receipt.
// yet folded into its next round. Amber: a message is waiting
// on a busy agent, the one thing on this row that needs you.
spans.push(Span::styled(
queued.to_string(),
Style::default()
.fg(app.ui_theme.accent_action)
.fg(app.ui_theme.warning)
.bg(normal.bg.unwrap_or(app.ui_theme.panel_bg)),
));
}