mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
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:
1 parent
8a835d7c4d
commit
d4ea059c05
35 files changed
+1772
-1601
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "केवल-पढ़ने",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "読み取り専用",
|
||||
|
||||
@@ -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": "읽기 전용",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "только чтение",
|
||||
|
||||
@@ -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": "лише читання",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "只读",
|
||||
|
||||
@@ -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": "唯讀",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user