From e10db813935833a1cbf3bd6fbfa6fe981879a860 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 5 Jun 2026 01:16:13 -0700 Subject: [PATCH] feat(dashboard): cli-agent terminal UI, task-card states, and notification banner support (U11) Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/cli-agent-terminal-ui.md | 15 + packages/dashboard/app/api/legacy.ts | 30 +- .../components/BackgroundTasksIndicator.tsx | 4 +- .../components/SessionNotificationBanner.tsx | 137 +++++- .../app/components/SessionTerminal.css | 165 +++++++ .../app/components/SessionTerminal.tsx | 429 ++++++++++++++++++ .../dashboard/app/components/TaskCard.tsx | 42 ++ .../app/components/TaskDetailModal.tsx | 219 ++++++++- .../SessionNotificationBanner.test.tsx | 105 +++++ .../__tests__/SessionTerminal.test.tsx | 173 +++++++ .../__tests__/TaskCard.cli-states.test.tsx | 103 +++++ .../TaskDetailModal.terminal-tab.test.tsx | 66 +++ packages/dashboard/package.json | 1 + packages/i18n/locales/en/app.json | 43 +- pnpm-lock.yaml | 12 + 15 files changed, 1528 insertions(+), 16 deletions(-) create mode 100644 .changeset/cli-agent-terminal-ui.md create mode 100644 packages/dashboard/app/components/SessionTerminal.css create mode 100644 packages/dashboard/app/components/SessionTerminal.tsx create mode 100644 packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/TaskCard.cli-states.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/TaskDetailModal.terminal-tab.test.tsx diff --git a/.changeset/cli-agent-terminal-ui.md b/.changeset/cli-agent-terminal-ui.md new file mode 100644 index 0000000000..a49ec422ad --- /dev/null +++ b/.changeset/cli-agent-terminal-ui.md @@ -0,0 +1,15 @@ +--- +"@runfusion/fusion": minor +--- + +CLI agent terminal UI (U11): a shared `SessionTerminal` component (lazy-loaded +xterm + fit/webgl/unicode11) that attaches to the U10 cli-sessions WebSocket +with ACK flow control, a posture chip (baseline vs elevated), a read-only +badge, session-idle/ended replay states, and a generic-tier confirm-advance +strip. Adds a `terminal` tab to the task detail view driven by the lifecycle +visibility matrix (live / read-only live / replay-idle / replay-ended / hidden) +with live `cli:session:state` SSE merging, waiting-on-input and needs-attention +task-card badges (distinct from staleness/stall badges), and extends +`SessionNotificationBanner` with a `cli-agent` session type plus the pinned +needs-attention variants (userExited / authFailed / resume-exhausted) and their +actions. All new strings flow through the i18n catalogs. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 8c4b40e8a6..c674af47bf 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -8394,10 +8394,36 @@ export function reorderTodoItems(listId: string, itemIds: string[], projectId?: // ── AI Sessions (Background Tasks) ───────────────────────────────────────── +/** + * Needs-attention variants for a CLI agent session (CLI Agent Executor, U11). + * Each carries pinned banner copy + action verbs: + * - userExited → Advance / Retry / Cancel task + * - authFailed → Re-authenticate / Retry + * - resume-exhausted → Relaunch fresh / Cancel task + */ +export type CliNeedsAttentionVariant = "userExited" | "authFailed" | "resume-exhausted"; + export interface AiSessionSummary { id: string; - type: "planning" | "subtask" | "mission_interview" | "milestone_interview" | "slice_interview"; - status: "draft" | "generating" | "awaiting_input" | "complete" | "error"; + type: + | "planning" + | "subtask" + | "mission_interview" + | "milestone_interview" + | "slice_interview" + | "cli-agent"; + status: + | "draft" + | "generating" + | "awaiting_input" + | "complete" + | "error" + | "waiting_on_input" + | "needs_attention"; + /** For cli-agent sessions: which needs-attention variant (drives pinned copy/actions). */ + cliVariant?: CliNeedsAttentionVariant; + /** Underlying CLI session id, for action wiring (confirm-advance / re-auth / etc.). */ + cliSessionId?: string; title: string; /** Server-derived preview of the in-progress initialPlan; only set for draft planning sessions. */ preview?: string; diff --git a/packages/dashboard/app/components/BackgroundTasksIndicator.tsx b/packages/dashboard/app/components/BackgroundTasksIndicator.tsx index 51297fcd27..8a61885001 100644 --- a/packages/dashboard/app/components/BackgroundTasksIndicator.tsx +++ b/packages/dashboard/app/components/BackgroundTasksIndicator.tsx @@ -1,6 +1,6 @@ import "./BackgroundTasksIndicator.css"; import { useState, useRef, useEffect, useMemo } from "react"; -import { Lightbulb, Layers, Target, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react"; +import { Lightbulb, Layers, Target, Terminal, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { AiSessionSummary } from "../api"; import { useAiSessionSync } from "../hooks/useAiSessionSync"; @@ -21,6 +21,7 @@ const TYPE_ICONS = { mission_interview: Target, milestone_interview: Target, slice_interview: Target, + "cli-agent": Terminal, } as const; export function BackgroundTasksIndicator({ @@ -49,6 +50,7 @@ export function BackgroundTasksIndicator({ mission_interview: t("backgroundTasks.typeLabel.missionInterview", "Mission Interview"), milestone_interview: t("backgroundTasks.typeLabel.milestoneInterview", "Milestone Interview"), slice_interview: t("backgroundTasks.typeLabel.sliceInterview", "Slice Interview"), + "cli-agent": t("backgroundTasks.typeLabel.cliAgent", "CLI Agent"), }), [t], ); diff --git a/packages/dashboard/app/components/SessionNotificationBanner.tsx b/packages/dashboard/app/components/SessionNotificationBanner.tsx index 8ede2738e5..8943419b3a 100644 --- a/packages/dashboard/app/components/SessionNotificationBanner.tsx +++ b/packages/dashboard/app/components/SessionNotificationBanner.tsx @@ -1,22 +1,35 @@ import "./SessionNotificationBanner.css"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { AlertCircle, Lightbulb, Layers, Target, X } from "lucide-react"; -import type { AiSessionSummary } from "../api"; +import { AlertCircle, Lightbulb, Layers, Target, Terminal, X } from "lucide-react"; +import type { AiSessionSummary, CliNeedsAttentionVariant } from "../api"; + +type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch"; interface SessionNotificationBannerProps { sessions: AiSessionSummary[]; onResumeSession: (session: AiSessionSummary) => void; onDismissSession: (id: string) => void; onDismissAll: () => void; + /** + * CLI agent needs-attention / confirm-advance actions (CLI Agent Executor, + * U11). `advance` wires the userExited "Advance" verb + generic-tier + * confirm-advance; the others map to existing endpoints where present, else + * are no-op callbacks marked TODO-wire by the caller. + */ + onCliAction?: (session: AiSessionSummary, action: CliActionId) => void; } +// `cli-agent` extends the previously-closed union: a SINGLE Terminal icon for +// all adapters (reusing the banner without this entry crashes on the unknown +// type — the union-regression the U11 tests guard). const TYPE_ICONS = { planning: Lightbulb, subtask: Layers, mission_interview: Target, milestone_interview: Target, slice_interview: Target, + "cli-agent": Terminal, } as const; const TYPE_LABEL_KEYS: Record = { @@ -25,6 +38,38 @@ const TYPE_LABEL_KEYS: Record = { + advance: { key: "sessionBanner.cli.advance", defaultVal: "Advance" }, + retry: { key: "sessionBanner.cli.retry", defaultVal: "Retry" }, + cancel: { key: "sessionBanner.cli.cancelTask", defaultVal: "Cancel task" }, + reauthenticate: { key: "sessionBanner.cli.reauthenticate", defaultVal: "Re-authenticate" }, + relaunch: { key: "sessionBanner.cli.relaunch", defaultVal: "Relaunch fresh" }, +}; + +/** Pinned copy + ordered actions per needs-attention variant (U11). */ +const CLI_VARIANT_SPEC: Record< + CliNeedsAttentionVariant, + { messageKey: string; messageDefault: string; actions: CliActionId[] } +> = { + userExited: { + messageKey: "sessionBanner.cli.userExited", + messageDefault: "Agent exited before completing", + actions: ["advance", "retry", "cancel"], + }, + authFailed: { + messageKey: "sessionBanner.cli.authFailed", + messageDefault: "CLI authentication failed", + actions: ["reauthenticate", "retry"], + }, + "resume-exhausted": { + messageKey: "sessionBanner.cli.resumeExhausted", + messageDefault: "Couldn't resume the session", + actions: ["relaunch", "cancel"], + }, }; const STORAGE_KEY = "fusion:session-banner-dismissed"; @@ -66,6 +111,21 @@ function persistDismissed(map: Map): void { } } +/** + * Statuses that warrant a banner entry. Extended for CLI agent sessions: + * `waiting_on_input` (F2) and `needs_attention` (pinned variants) join the + * existing `awaiting_input` / `error`. A CLI session returning to `busy` + * (no longer in this set) clears the banner entry — covering F2. + */ +function isNotifyingStatus(status: AiSessionSummary["status"]): boolean { + return ( + status === "awaiting_input" || + status === "error" || + status === "waiting_on_input" || + status === "needs_attention" + ); +} + // Map of sessionId → epoch-ms timestamp at which the user dismissed the // banner for that session. The banner re-shows the session only when the // session's `updatedAt` advances strictly past the recorded dismissal time @@ -78,6 +138,7 @@ export function SessionNotificationBanner({ onResumeSession, onDismissSession, onDismissAll, + onCliAction, }: SessionNotificationBannerProps) { const { t } = useTranslation("app"); const [dismissRevision, setDismissRevision] = useState(0); @@ -98,7 +159,7 @@ export function SessionNotificationBanner({ for (const [id, dismissedAtMs] of dismissedIds) { const session = sessionById.get(id); if (!session) continue; - const stillNotifying = session.status === "awaiting_input" || session.status === "error"; + const stillNotifying = isNotifyingStatus(session.status); if (!stillNotifying) { dismissedIds.delete(id); pruned = true; @@ -117,7 +178,7 @@ export function SessionNotificationBanner({ const sessionsNeedingInput = useMemo( () => sessions.filter((session) => { - if (session.status !== "awaiting_input" && session.status !== "error") return false; + if (!isNotifyingStatus(session.status)) return false; const dismissedAtMs = dismissedIds.get(session.id); if (dismissedAtMs === undefined) return true; return parseUpdatedAtMs(session.updatedAt) > dismissedAtMs; @@ -129,8 +190,14 @@ export function SessionNotificationBanner({ return null; } - const awaitingInputCount = sessionsNeedingInput.filter((s) => s.status === "awaiting_input").length; - const errorCount = sessionsNeedingInput.filter((s) => s.status === "error").length; + // CLI `waiting_on_input` rolls into the "needs input" count; `needs_attention` + // rolls into the "failed" count for the summary header. + const awaitingInputCount = sessionsNeedingInput.filter( + (s) => s.status === "awaiting_input" || s.status === "waiting_on_input", + ).length; + const errorCount = sessionsNeedingInput.filter( + (s) => s.status === "error" || s.status === "needs_attention", + ).length; let headerText = ""; if (awaitingInputCount > 0 && errorCount > 0) { @@ -207,6 +274,64 @@ export function SessionNotificationBanner({ {sessionsNeedingInput.map((session) => { const Icon = TYPE_ICONS[session.type]; const isError = session.status === "error"; + const variantSpec = + session.type === "cli-agent" && session.cliVariant + ? CLI_VARIANT_SPEC[session.cliVariant] + : null; + + // Pinned needs-attention variant: per-variant copy + ordered actions. + if (variantSpec) { + return ( +
+
+
+ +
+ {variantSpec.actions.map((action) => ( + + ))} + +
+
+ ); + } return (
void | Promise; + /** Whether the confirm-advance strip is offered (generic-tier idle). */ + showConfirmAdvance?: boolean; + /** Settings deep link for the posture chip tooltip. */ + onOpenAdapterSettings?: () => void; +} + +interface AttachTicketResponse { + ticket: string; + expiresAt: string; + readOnly: boolean; +} + +/** Build the WS URL for the cli-sessions attach channel (mirrors useTerminal). */ +function buildCliWsUrl(sessionId: string, ticket: string): string { + if (typeof window === "undefined") return ""; + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const base = + `${protocol}//${window.location.host}/api/cli-sessions/ws` + + `?sessionId=${encodeURIComponent(sessionId)}&ticket=${encodeURIComponent(ticket)}`; + return appendTokenQuery(base); +} + +function decodeBase64ToString(b64: string): string { + if (typeof window !== "undefined" && typeof window.atob === "function") { + // atob → binary string → UTF-8 decode. + const binary = window.atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder("utf-8").decode(bytes); + } + return Buffer.from(b64, "base64").toString("utf8"); +} + +export function SessionTerminal({ + sessionId, + readOnly = false, + posture, + mode = "live", + projectId, + onConfirmAdvance, + showConfirmAdvance = false, + onOpenAdapterSettings, +}: SessionTerminalProps) { + const { t } = useTranslation("app"); + const containerRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const wsRef = useRef(null); + + const [postureTooltipOpen, setPostureTooltipOpen] = useState(false); + const [advanceDismissed, setAdvanceDismissed] = useState(false); + const [advancePending, setAdvancePending] = useState(false); + + // Re-arm the strip whenever a fresh idle window is offered. + useEffect(() => { + if (showConfirmAdvance) setAdvanceDismissed(false); + }, [showConfirmAdvance, sessionId]); + + // ── xterm lifecycle + WS bridge ────────────────────────────────────────── + useEffect(() => { + if (!sessionId || typeof window === "undefined") return; + let disposed = false; + let resizeObserver: ResizeObserver | null = null; + let resizeTimer: ReturnType | null = null; + let unackedBytes = 0; + + const sendResize = (cols: number, rows: number) => { + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + }; + + const ackBytes = (n: number) => { + unackedBytes += n; + if (unackedBytes < ACK_THRESHOLD_BYTES) return; + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "ack", bytes: unackedBytes })); + } + unackedBytes = 0; + }; + + const init = async () => { + // 1. Mint a single-use attach ticket via the app API helper. + let ticketRes: AttachTicketResponse; + try { + ticketRes = await api( + `/cli-sessions/${encodeURIComponent(sessionId)}/attach-ticket`, + { method: "POST", body: JSON.stringify(projectId ? { projectId } : {}) }, + ); + } catch { + return; // surfaced via the "disconnected" state header below + } + if (disposed) return; + + // 2. Lazy-load xterm + addons (out of the main bundle). + const [{ Terminal }, { FitAddon }, { Unicode11Addon }] = await Promise.all([ + import("@xterm/xterm"), + import("@xterm/addon-fit"), + import("@xterm/addon-unicode11"), + ]); + if (disposed || !containerRef.current) return; + + const term = new Terminal({ + convertEol: false, + cursorBlink: !readOnly && mode === "live", + disableStdin: readOnly, + scrollback: 10000, + // Defensive: do NOT register an OSC 52 (clipboard-write) handler. The + // server-side neutralizer (U10) strips it; we add no client handling. + fontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + fontSize: 13, + }); + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + const unicode11 = new Unicode11Addon(); + term.loadAddon(unicode11); + term.unicode.activeVersion = "11"; + + term.open(containerRef.current); + xtermRef.current = term; + fitAddonRef.current = fitAddon as unknown as ITerminalAddon; + + // WebGL renderer with context-loss fallback to the DOM renderer. + try { + const { WebglAddon } = await import("@xterm/addon-webgl"); + if (!disposed) { + const webgl = new WebglAddon(); + webgl.onContextLoss(() => { + try { + webgl.dispose(); + } catch { + /* fall back to DOM renderer */ + } + }); + term.loadAddon(webgl); + } + } catch { + /* WebGL unavailable — DOM renderer is the default fallback */ + } + + try { + (fitAddon as unknown as { fit: () => void }).fit(); + } catch { + /* container not measurable yet */ + } + + // term.onData → input frames (skip entirely when read-only). + if (!readOnly) { + term.onData((data: string) => { + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "input", data })); + } + }); + } + + // Debounced ResizeObserver → resize frames. + resizeObserver = new ResizeObserver(() => { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + try { + (fitAddon as unknown as { fit: () => void }).fit(); + sendResize(term.cols, term.rows); + } catch { + /* ignore transient measure failures */ + } + }, RESIZE_DEBOUNCE_MS); + }); + resizeObserver.observe(containerRef.current); + + // 3. Open the WS attach channel. + const ws = new WebSocket(buildCliWsUrl(sessionId, ticketRes.ticket)); + wsRef.current = ws; + + ws.onopen = () => { + sendResize(term.cols, term.rows); + }; + + ws.onmessage = (event) => { + let msg: { type?: string; data?: string }; + try { + msg = JSON.parse(typeof event.data === "string" ? event.data : ""); + } catch { + return; + } + switch (msg.type) { + case "scrollback": + case "data": { + if (typeof msg.data !== "string") return; + const text = decodeBase64ToString(msg.data); + const byteLen = text.length; + // ACK once xterm has flushed the chunk to the screen. + term.write(text, () => ackBytes(byteLen)); + break; + } + // state / error / exit frames are advisory; the SSE channel and the + // mode prop drive header copy. We intentionally do not mutate the + // viewport on them. + default: + break; + } + }; + }; + + void init(); + + return () => { + disposed = true; + if (resizeTimer) clearTimeout(resizeTimer); + if (resizeObserver) resizeObserver.disconnect(); + const ws = wsRef.current; + if (ws) { + ws.onopen = null; + ws.onmessage = null; + ws.onclose = null; + ws.onerror = null; + try { + ws.close(); + } catch { + /* already closing */ + } + wsRef.current = null; + } + const term = xtermRef.current; + if (term) { + try { + term.dispose(); + } catch { + /* ignore */ + } + xtermRef.current = null; + } + fitAddonRef.current = null; + }; + }, [sessionId, readOnly, mode, projectId]); + + const replayLabel = useMemo(() => { + if (mode === "idle") return t("cliTerminal.replayIdle", "Session idle"); + if (mode === "ended") return t("cliTerminal.replayEnded", "Session ended"); + return null; + }, [mode, t]); + + const handleAdvance = useCallback(async () => { + if (!onConfirmAdvance) return; + setAdvancePending(true); + try { + await onConfirmAdvance("advance"); + setAdvanceDismissed(true); + } finally { + setAdvancePending(false); + } + }, [onConfirmAdvance]); + + const handleNotYet = useCallback(async () => { + if (onConfirmAdvance) await onConfirmAdvance("not-yet"); + // "Not yet" stays in execute and re-arms the idle timer (server-side); the + // strip hides until the next idle window re-offers it. + setAdvanceDismissed(true); + }, [onConfirmAdvance]); + + const elevated = Boolean(posture?.elevated); + const flagSummary = posture?.elevatedFlags?.join(", "); + + return ( +
+
+ {posture && ( +
+ + {postureTooltipOpen && ( +
+

+ {t("cliTerminal.postureResolved", "Resolved posture")} +

+
    + {(posture.resolved ?? []).map((line, i) => ( +
  • {line}
  • + ))} + {(posture.resolved ?? []).length === 0 && ( +
  • {posture.mode ?? t("cliTerminal.postureBaseline", "Baseline")}
  • + )} +
+ {onOpenAdapterSettings && ( + + )} +
+ )} +
+ )} + {readOnly && ( + + + )} + {replayLabel && ( + + {replayLabel} + + )} +
+ +
+ + {showConfirmAdvance && !advanceDismissed && ( +
+ + {t( + "cliTerminal.advancePrompt", + "This session looks idle — advance to review?", + )} + +
+ + +
+
+ )} +
+ ); +} diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 56037e61ce..2b485c75f3 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -407,6 +407,25 @@ interface TaskCardProps { /** Card-placed custom field definitions for this task's workflow (U13/KTD-14). * Empty/undefined → no field badges render (card byte-identical to today). */ cardFieldDefs?: WorkflowFieldDefinition[]; + /** + * CLI agent session state for this task's session (CLI Agent Executor, U11). + * Drives the waiting-on-input / needs-attention card badges, which are + * DISTINCT from staleness/stall badges (which U8 suppresses in these states). + * Undefined when the task has no CLI session → no badge (card unchanged). + */ + cliSessionState?: CliCardState; +} + +/** Minimal CLI session shape the card needs for its badges (U11). */ +export interface CliCardState { + agentState: + | "starting" + | "ready" + | "busy" + | "waitingOnInput" + | "done" + | "dead" + | "needsAttention"; } function getTaskPrimaryPrInfo(task: Pick): PrInfo | undefined { @@ -540,6 +559,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs && previous.prAuthAvailable === next.prAuthAvailable && previous.autoMergeEnabled === next.autoMergeEnabled && + previous.cliSessionState?.agentState === next.cliSessionState?.agentState && previous.cardFieldDefs === next.cardFieldDefs && (previous.cardFieldDefs == null && next.cardFieldDefs == null ? true @@ -658,6 +678,7 @@ function TaskCardComponent({ prAuthAvailable, autoMergeEnabled = false, cardFieldDefs, + cliSessionState, }: TaskCardProps) { const { t } = useTranslation("app"); const columnLabel = useColumnLabel(); @@ -924,6 +945,9 @@ function TaskCardComponent({ const stalledReview = getStalledReviewSignal(task); const showStalledReview = Boolean(stalledReview && task.column === "in-review" && !isPaused); const hasInReviewStall = shouldShowInReviewStallBadge(task); + // CLI agent session badges (U11) — distinct from staleness/stall badges. + const cliWaitingOnInput = cliSessionState?.agentState === "waitingOnInput"; + const cliNeedsAttention = cliSessionState?.agentState === "needsAttention"; const stallCopy = task.inReviewStall ? getInReviewStallCopy(task.inReviewStall, { mergeRetries: task.mergeRetries, @@ -1811,6 +1835,24 @@ function TaskCardComponent({ {stallCopy.badgeLabel}{stallCopy.counter ? ` ${stallCopy.counter}` : ""} )} + {cliWaitingOnInput && ( + + {t("tasks.cliWaitingOnInput", "Waiting on input")} + + )} + {cliNeedsAttention && ( + + {t("tasks.cliNeedsAttention", "Needs attention")} + + )} {hasStalePausedReview && stalePausedReviewCopy && ( + import("./SessionTerminal").then((m) => ({ default: m.SessionTerminal })), +); + +/** CLI session record fields the terminal tab needs (mirrors @fusion/core CliSession). */ +export interface CliSessionSummaryRecord { + id: string; + taskId: string | null; + projectId: string; + adapterId: string; + agentState: + | "starting" + | "ready" + | "busy" + | "waitingOnInput" + | "done" + | "dead" + | "needsAttention"; + terminationReason: string | null; + autonomyPosture?: Record | null; +} + +type CliTabVisibility = + | { kind: "hidden" } + | { kind: "live"; readOnly: boolean; mode: SessionTerminalMode; showConfirmAdvance: boolean } + | { kind: "replay"; mode: SessionTerminalMode }; + +/** + * Tab visibility matrix (U11): + * - starting/ready/busy/waitingOnInput → live terminal + * - one-shot (planning/validator) live → read-only live + badge + * - done (resumable) → replay "session idle" + * - dead/needsAttention (PTY reaped) → replay "session ended" + * - no recorded session → hidden + */ +export function deriveCliTabVisibility( + session: CliSessionSummaryRecord | null, + opts: { oneShot?: boolean; genericIdle?: boolean } = {}, +): CliTabVisibility { + if (!session) return { kind: "hidden" }; + const live = + session.agentState === "starting" || + session.agentState === "ready" || + session.agentState === "busy" || + session.agentState === "waitingOnInput"; + if (live) { + return { + kind: "live", + readOnly: Boolean(opts.oneShot), + mode: "live", + showConfirmAdvance: Boolean(opts.genericIdle), + }; + } + if (session.agentState === "done") { + // execute-done but resumable → scrollback replay with a "session idle" header. + return { kind: "replay", mode: "idle" }; + } + // dead / needsAttention → PTY reaped → "session ended". + return { kind: "replay", mode: "ended" }; +} export interface TaskDetailModalProps { task: Task | TaskDetail; @@ -494,6 +557,9 @@ export function TaskDetailContent({ const columnLabel = useColumnLabel(); const [activeTab, setActiveTab] = useState(initialTab === "retries" ? "definition" : initialTab); + // ── CLI agent session (U11) ──────────────────────────────────────────────── + const [cliSession, setCliSession] = useState(null); + // ── Async detail loading ────────────────────────────────────────────────── // When opened optimistically with a Task (no prompt), fetch the full // TaskDetail in the background. The modal renders immediately with the @@ -757,6 +823,56 @@ export function TaskDetailContent({ ? pluginTabs.find((tab) => tab.tabId === activeTab) ?? null : null; + // ── CLI terminal tab visibility + posture (U11) ──────────────────────────── + const cliOneShot = + cliSession?.adapterId != null && + (cliSession?.autonomyPosture?.purpose === "planning" || + cliSession?.autonomyPosture?.purpose === "validator" || + cliSession?.autonomyPosture?.readOnly === true); + const cliGenericIdle = cliSession?.autonomyPosture?.genericIdle === true; + const cliTabVisibility = useMemo( + () => + deriveCliTabVisibility(cliSession, { + oneShot: cliOneShot, + genericIdle: cliGenericIdle, + }), + [cliSession, cliOneShot, cliGenericIdle], + ); + const showCliTab = cliTabVisibility.kind !== "hidden"; + const cliPosture: SessionTerminalPosture | undefined = useMemo(() => { + if (!cliSession) return undefined; + const p = cliSession.autonomyPosture ?? {}; + const flags = Array.isArray(p.elevatedFlags) ? (p.elevatedFlags as string[]) : undefined; + return { + adapterName: (p.adapterName as string) ?? cliSession.adapterId, + mode: (p.mode as string) ?? (p.autoApprove ? "auto-approve" : undefined), + elevated: p.elevated === true, + elevatedFlags: flags, + resolved: Array.isArray(p.resolved) ? (p.resolved as string[]) : undefined, + }; + }, [cliSession]); + + // Confirm-advance handler — POST /api/cli-sessions/:id/confirm-advance. + const handleConfirmAdvance = useCallback( + async (decision: "advance" | "not-yet") => { + if (!cliSession) return; + try { + await api(`/cli-sessions/${encodeURIComponent(cliSession.id)}/confirm-advance`, { + method: "POST", + body: JSON.stringify({ decision, ...(projectId ? { projectId } : {}) }), + }); + } catch { + /* surfaced via the strip's disabled state reset */ + } + }, + [cliSession, projectId], + ); + + // If the terminal tab is active but the session disappears, fall back. + useEffect(() => { + if (activeTab === "terminal" && !showCliTab) setActiveTab("definition"); + }, [activeTab, showCliTab]); + // Track mount state to avoid setting state on unmounted component useEffect(() => { mountedRef.current = true; @@ -886,6 +1002,76 @@ export function TaskDetailContent({ }); }, [activeTab, task.id, projectId]); + // Load the CLI agent session for this task (drives the terminal tab + matrix). + useEffect(() => { + let cancelled = false; + const search = new URLSearchParams({ taskId: task.id }); + if (projectId) search.set("projectId", projectId); + void api<{ sessions: CliSessionSummaryRecord[] }>(`/cli-sessions?${search.toString()}`) + .then((res) => { + if (cancelled) return; + // Most-recent session for the task (the list is store-ordered). + const sessions = res.sessions ?? []; + setCliSession(sessions.length > 0 ? sessions[sessions.length - 1] : null); + }) + .catch(() => { + if (!cancelled) setCliSession(null); + }); + return () => { + cancelled = true; + }; + }, [task.id, projectId]); + + // Live CLI session state via SSE — MERGE payload fields onto the record + // (never wholesale-replace: the list fetch carries enriched fields the SSE + // payload omits, e.g. adapterId / autonomyPosture). + useEffect(() => { + const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; + const handleCliState = (e: MessageEvent) => { + try { + const payload = JSON.parse(e.data) as { + sessionId: string; + taskId: string | null; + state: string; + terminationReason?: string | null; + }; + if (payload.taskId !== task.id) return; + setCliSession((prev) => { + if (!prev || prev.id !== payload.sessionId) { + // Unknown/new session for this task — keep the enriched record from + // the list fetch as the source of truth; ignore until it loads. + if (!prev) return prev; + } + // The machine "idle"/"resuming" states map onto persisted enums; the + // card/tab only need the persisted set, so coerce here. + const next = { ...prev } as CliSessionSummaryRecord; + if ( + payload.state === "starting" || + payload.state === "ready" || + payload.state === "busy" || + payload.state === "waitingOnInput" || + payload.state === "done" || + payload.state === "dead" || + payload.state === "needsAttention" + ) { + next.agentState = payload.state; + } else if (payload.state === "idle" || payload.state === "resuming") { + next.agentState = "busy"; + } + if (payload.terminationReason !== undefined) { + next.terminationReason = payload.terminationReason ?? null; + } + return next; + }); + } catch { + /* skip malformed events */ + } + }; + return subscribeSse(`/api/events${query}`, { + events: { "cli:session:state": handleCliState }, + }); + }, [task.id, projectId]); + // Reset dependency search when dropdown closes useEffect(() => { if (!showDepDropdown) { @@ -2827,6 +3013,14 @@ export function TaskDetailContent({ > {t("taskDetail.tabs.routing", "Routing")} + {showCliTab && ( + + )} {/* Plugin tabs */} {pluginTabs.map(({ entry, tabId }) => { return ( @@ -3118,6 +3312,27 @@ export function TaskDetailContent({ onTaskUpdated={onTaskUpdated} />
+ ) : activeTab === "terminal" ? ( +
+ {cliSession && cliTabVisibility.kind !== "hidden" ? ( + {t("taskDetail.terminal.loading", "Loading terminal…")}
}> + + + ) : null} + ) : ( <> {/* Summary section - only for done tasks with summary */} diff --git a/packages/dashboard/app/components/__tests__/SessionNotificationBanner.test.tsx b/packages/dashboard/app/components/__tests__/SessionNotificationBanner.test.tsx index 7bae695381..c669237cda 100644 --- a/packages/dashboard/app/components/__tests__/SessionNotificationBanner.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionNotificationBanner.test.tsx @@ -315,3 +315,108 @@ describe("SessionNotificationBanner", () => { expect(screen.queryByText("Error Session")).not.toBeInTheDocument(); }); }); + +// ── CLI agent extensions (CLI Agent Executor, U11) ────────────────────────── +function buildCliSession(overrides: Partial): AiSessionSummary { + return { + id: overrides.id ?? "cli-1", + type: "cli-agent", + status: overrides.status ?? "waiting_on_input", + title: overrides.title ?? "Implement FN-1", + projectId: overrides.projectId ?? "proj-1", + lockedByTab: null, + updatedAt: overrides.updatedAt ?? new Date().toISOString(), + cliVariant: overrides.cliVariant, + cliSessionId: overrides.cliSessionId ?? "cli-1", + }; +} + +describe("SessionNotificationBanner — cli-agent (U11)", () => { + beforeEach(() => dismissedIds.clear()); + + it("renders the cli-agent type without crashing (union regression)", () => { + expect(() => + render( + , + ), + ).not.toThrow(); + expect(screen.getByText("Implement FN-1")).toBeInTheDocument(); + }); + + it("waiting_on_input surfaces a banner entry; busy clears it (F2)", () => { + const { rerender, container } = render( + , + ); + expect(container.querySelector(".session-notification-banner")).toBeTruthy(); + + rerender( + , + ); + expect(container.querySelector(".session-notification-banner")).toBeFalsy(); + }); + + it("userExited needs-attention renders pinned copy + Advance/Retry/Cancel task", () => { + const onCliAction = vi.fn(); + render( + , + ); + expect(screen.getByText("Agent exited before completing")).toBeInTheDocument(); + // All three pinned actions render before any action removes the item. + expect(screen.getByText("Advance")).toBeInTheDocument(); + expect(screen.getByText("Retry")).toBeInTheDocument(); + expect(screen.getByText("Cancel task")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Advance")); + expect(onCliAction).toHaveBeenCalledWith(expect.objectContaining({ id: "cli-1" }), "advance"); + }); + + it("authFailed renders Re-authenticate / Retry", () => { + render( + , + ); + expect(screen.getByText("CLI authentication failed")).toBeInTheDocument(); + expect(screen.getByText("Re-authenticate")).toBeInTheDocument(); + expect(screen.getByText("Retry")).toBeInTheDocument(); + }); + + it("resume-exhausted renders Relaunch fresh / Cancel task", () => { + render( + , + ); + expect(screen.getByText("Couldn't resume the session")).toBeInTheDocument(); + expect(screen.getByText("Relaunch fresh")).toBeInTheDocument(); + expect(screen.getByText("Cancel task")).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx new file mode 100644 index 0000000000..c49cb82ee4 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; + +// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ────────── +const mockTerm = { + loadAddon: vi.fn(), + open: vi.fn(), + onData: vi.fn(), + write: vi.fn((_data: string, cb?: () => void) => cb?.()), + dispose: vi.fn(), + unicode: { activeVersion: "6" }, + cols: 80, + rows: 24, +}; +vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(() => mockTerm) })); +vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(() => ({ fit: vi.fn() })) })); +vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: vi.fn(() => ({})) })); +vi.mock("@xterm/addon-webgl", () => ({ + WebglAddon: vi.fn(() => ({ onContextLoss: vi.fn(), dispose: vi.fn() })), +})); + +const apiMock = vi.fn(); +vi.mock("../../api", () => ({ api: (...args: unknown[]) => apiMock(...args) })); +vi.mock("../../auth", () => ({ appendTokenQuery: (u: string) => u })); + +// ── Minimal WebSocket stub ────────────────────────────────────────────────── +class FakeWS { + static instances: FakeWS[] = []; + static OPEN = 1; + readyState = 1; + onopen: (() => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + sent: string[] = []; + constructor(public url: string) { + FakeWS.instances.push(this); + } + send(d: string) { + this.sent.push(d); + } + close() { + this.readyState = 3; + } +} +(globalThis as unknown as { WebSocket: typeof FakeWS }).WebSocket = FakeWS; +(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + disconnect() {} +}; + +import { SessionTerminal } from "../SessionTerminal"; + +beforeEach(() => { + FakeWS.instances = []; + mockTerm.onData.mockReset(); + mockTerm.write.mockClear(); + apiMock.mockReset(); + apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("SessionTerminal", () => { + it("mints an attach ticket and opens the WS attach channel", async () => { + render(); + await waitFor(() => + expect(apiMock).toHaveBeenCalledWith( + "/cli-sessions/s1/attach-ticket", + expect.objectContaining({ method: "POST" }), + ), + ); + await waitFor(() => expect(FakeWS.instances.length).toBe(1)); + expect(FakeWS.instances[0].url).toContain("sessionId=s1"); + expect(FakeWS.instances[0].url).toContain("ticket=tkt-1"); + }); + + it("decodes base64 scrollback/data into term.write and ACKs", async () => { + render(); + await waitFor(() => expect(FakeWS.instances.length).toBe(1)); + const ws = FakeWS.instances[0]; + const b64 = Buffer.from("hello", "utf8").toString("base64"); + ws.onmessage?.({ data: JSON.stringify({ type: "scrollback", data: b64 }) }); + await waitFor(() => expect(mockTerm.write).toHaveBeenCalledWith("hello", expect.any(Function))); + }); + + it("read-only: never registers term.onData (input suppressed)", async () => { + render(); + await waitFor(() => expect(FakeWS.instances.length).toBe(1)); + expect(mockTerm.onData).not.toHaveBeenCalled(); + }); + + it("renders the Read-only badge when readOnly", async () => { + render(); + expect(await screen.findByText("Read-only")).toBeTruthy(); + }); + + it("renders the session-ended replay state", () => { + render(); + expect(screen.getByText("Session ended")).toBeTruthy(); + }); + + it("renders the session-idle replay state", () => { + render(); + expect(screen.getByText("Session idle")).toBeTruthy(); + }); + + it("posture chip: baseline shows adapter name without elevated styling", () => { + render( + , + ); + const chip = screen.getByRole("button", { name: /Claude Code/ }); + expect(chip.getAttribute("data-elevated")).toBe("false"); + expect(chip.className).not.toContain("cli-posture-chip--elevated"); + }); + + it("posture chip: elevated shows warning styling, the flag, and a tooltip", () => { + render( + , + ); + const chip = screen.getByRole("button", { name: /Codex/ }); + expect(chip.getAttribute("data-elevated")).toBe("true"); + expect(chip.className).toContain("cli-posture-chip--elevated"); + expect(screen.getByText("--dangerously-skip-permissions")).toBeTruthy(); + fireEvent.click(chip); + expect(screen.getByRole("tooltip")).toBeTruthy(); + expect(screen.getByText("autonomy: full-auto")).toBeTruthy(); + }); + + it("confirm-advance strip: Advance posts advance and hides the strip", async () => { + const onConfirmAdvance = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const advance = screen.getByText("Advance"); + fireEvent.click(advance); + await waitFor(() => expect(onConfirmAdvance).toHaveBeenCalledWith("advance")); + await waitFor(() => expect(screen.queryByText("Advance")).toBeNull()); + }); + + it("confirm-advance strip: Not yet re-arms (calls callback, hides strip)", async () => { + const onConfirmAdvance = vi.fn().mockResolvedValue(undefined); + render( + , + ); + fireEvent.click(screen.getByText("Not yet")); + await waitFor(() => expect(onConfirmAdvance).toHaveBeenCalledWith("not-yet")); + await waitFor(() => expect(screen.queryByText("Not yet")).toBeNull()); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.cli-states.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.cli-states.test.tsx new file mode 100644 index 0000000000..343bbfa53b --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskCard.cli-states.test.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { afterEach, describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { TaskCard, type CliCardState } from "../TaskCard"; +import type { Task } from "@fusion/core"; + +vi.mock("lucide-react", () => { + const Stub = () => null; + return new Proxy({}, { get: () => Stub }); +}); + +vi.mock("../ProviderIcon", () => ({ + ProviderIcon: ({ provider }: { provider: string }) => , +})); + +vi.mock("../../hooks/useTaskDiffStats", () => ({ + useTaskDiffStats: () => ({ stats: null, loading: false }), +})); + +const badgeUpdatesMock = new Map(); +vi.mock("../../hooks/useBadgeWebSocket", () => ({ + useBadgeWebSocket: () => ({ + badgeUpdates: badgeUpdatesMock, + isConnected: true, + subscribeToBadge: vi.fn(), + unsubscribeFromBadge: vi.fn(), + }), +})); + +vi.mock("../../hooks/useBatchBadgeFetch", () => ({ + getFreshBatchData: vi.fn(() => null), +})); + +vi.mock("../../api", () => ({ + fetchTaskDetail: vi.fn(), + uploadAttachment: vi.fn(), + fetchMission: vi.fn(), + fetchAgent: vi.fn(), + fetchAgents: vi.fn(), +})); + +vi.mock("../../hooks/useConfirm", () => ({ + useConfirm: () => ({ confirm: vi.fn(), confirmWithChoice: vi.fn() }), +})); + +function makeTask(overrides: Partial = {}): Task { + return { + id: "FN-001", + title: "Test task", + column: "in-progress", + status: undefined as never, + steps: [], + dependencies: [], + description: "", + ...overrides, + } as Task; +} + +const noop = () => {}; + +function renderCard(cliSessionState?: CliCardState) { + return render( + , + ); +} + +afterEach(() => { + badgeUpdatesMock.clear(); + vi.clearAllMocks(); +}); + +describe("TaskCard CLI agent state badges (U11)", () => { + it("renders the waiting-on-input badge when the session is waitingOnInput", () => { + renderCard({ agentState: "waitingOnInput" }); + const badge = screen.getByText("Waiting on input"); + expect(badge).toBeTruthy(); + expect(badge.getAttribute("data-cli-state")).toBe("waitingOnInput"); + }); + + it("renders the needs-attention badge when the session needsAttention", () => { + renderCard({ agentState: "needsAttention" }); + const badge = screen.getByText("Needs attention"); + expect(badge).toBeTruthy(); + expect(badge.getAttribute("data-cli-state")).toBe("needsAttention"); + }); + + it("busy clears both CLI badges (F2 — answering re-arms to busy)", () => { + renderCard({ agentState: "busy" }); + expect(screen.queryByText("Waiting on input")).toBeNull(); + expect(screen.queryByText("Needs attention")).toBeNull(); + }); + + it("no cli session → no CLI badges (card unchanged)", () => { + renderCard(undefined); + expect(screen.queryByText("Waiting on input")).toBeNull(); + expect(screen.queryByText("Needs attention")).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.terminal-tab.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.terminal-tab.test.tsx new file mode 100644 index 0000000000..37ab987634 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.terminal-tab.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { + deriveCliTabVisibility, + type CliSessionSummaryRecord, +} from "../TaskDetailModal"; + +function session( + agentState: CliSessionSummaryRecord["agentState"], +): CliSessionSummaryRecord { + return { + id: "cli-1", + taskId: "FN-1", + projectId: "p1", + adapterId: "claude-local", + agentState, + terminationReason: null, + autonomyPosture: null, + }; +} + +describe("TaskDetailModal terminal-tab visibility matrix (U11)", () => { + it("no recorded session → tab hidden", () => { + expect(deriveCliTabVisibility(null)).toEqual({ kind: "hidden" }); + }); + + it("starting / busy / waitingOnInput → live terminal", () => { + for (const s of ["starting", "ready", "busy", "waitingOnInput"] as const) { + const v = deriveCliTabVisibility(session(s)); + expect(v.kind).toBe("live"); + if (v.kind === "live") { + expect(v.mode).toBe("live"); + expect(v.readOnly).toBe(false); + } + } + }); + + it("one-shot (planning/validator) live → read-only live terminal", () => { + const v = deriveCliTabVisibility(session("busy"), { oneShot: true }); + expect(v.kind).toBe("live"); + if (v.kind === "live") expect(v.readOnly).toBe(true); + }); + + it("generic-tier idle → confirm-advance strip offered on the live terminal", () => { + const v = deriveCliTabVisibility(session("busy"), { genericIdle: true }); + expect(v.kind).toBe("live"); + if (v.kind === "live") expect(v.showConfirmAdvance).toBe(true); + }); + + it("execute-done resumable → replay 'session idle'", () => { + expect(deriveCliTabVisibility(session("done"))).toEqual({ + kind: "replay", + mode: "idle", + }); + }); + + it("reaped (dead / needsAttention) → replay 'session ended'", () => { + expect(deriveCliTabVisibility(session("dead"))).toEqual({ + kind: "replay", + mode: "ended", + }); + expect(deriveCliTabVisibility(session("needsAttention"))).toEqual({ + kind: "replay", + mode: "ended", + }); + }); +}); diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index cdbac8846c..6b0d70f4df 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -112,6 +112,7 @@ "@types/multer": "^2.1.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", + "@xterm/addon-unicode11": "^0.8.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index c25519f09d..dd787d1dda 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -1135,7 +1135,8 @@ "missionInterview": "Mission Interview", "planning": "Planning", "sliceInterview": "Slice Interview", - "subtask": "Subtask Breakdown" + "subtask": "Subtask Breakdown", + "cliAgent": "CLI Agent" } }, "board": { @@ -4890,7 +4891,20 @@ "headerErrorSingular_other": "", "regionLabel": "AI sessions needing input or failed", "resume": "Resume", - "retry": "Retry" + "retry": "Retry", + "typeLabel": { + "cliAgent": "CLI Agent" + }, + "cli": { + "advance": "Advance", + "retry": "Retry", + "cancelTask": "Cancel task", + "reauthenticate": "Re-authenticate", + "relaunch": "Relaunch fresh", + "userExited": "Agent exited before completing", + "authFailed": "CLI authentication failed", + "resumeExhausted": "Couldn't resume the session" + } }, "settings": { "actions": { @@ -6074,7 +6088,8 @@ "review": "Review", "routing": "Routing", "stats": "Stats", - "workflow": "Workflow" + "workflow": "Workflow", + "terminal": "Terminal" }, "timedDuration": "Timed duration", "timestamps": { @@ -6097,7 +6112,10 @@ }, "workflowRuntime": "Workflow runtime", "workflowTimedSteps": "Workflow timed steps", - "yes": "Yes" + "yes": "Yes", + "terminal": { + "loading": "Loading terminal…" + } }, "taskDocuments": { "cancel": "Cancel", @@ -6471,7 +6489,11 @@ "usingDefault": "Using default", "viewDependency": "Click to view {{depId}}", "workflow": "workflow", - "workflowCheck": "Workflow check" + "workflowCheck": "Workflow check", + "cliWaitingOnInput": "Waiting on input", + "cliWaitingOnInputTitle": "The CLI agent is waiting for your input", + "cliNeedsAttention": "Needs attention", + "cliNeedsAttentionTitle": "The CLI agent needs your attention" }, "terminal": { "clear": "Clear", @@ -6882,5 +6904,16 @@ }, "notifyNote": "How you are alerted when the agent pauses waiting for input on this step." } + }, + "cliTerminal": { + "replayIdle": "Session idle", + "replayEnded": "Session ended", + "readOnly": "Read-only", + "postureResolved": "Resolved posture", + "postureBaseline": "Baseline", + "adapterSettings": "Adapter settings", + "advancePrompt": "This session looks idle — advance to review?", + "advance": "Advance", + "notYet": "Not yet" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 862d162e24..8d8530d88e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,6 +269,9 @@ importers: '@xterm/addon-search': specifier: ^0.15.0 version: 0.15.0(@xterm/xterm@5.5.0) + '@xterm/addon-unicode11': + specifier: ^0.8.0 + version: 0.8.0(@xterm/xterm@5.5.0) '@xterm/addon-web-links': specifier: ^0.11.0 version: 0.11.0(@xterm/xterm@5.5.0) @@ -3189,6 +3192,11 @@ packages: peerDependencies: '@xterm/xterm': ^5.0.0 + '@xterm/addon-unicode11@0.8.0': + resolution: {integrity: sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + '@xterm/addon-web-links@0.11.0': resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==} peerDependencies: @@ -9811,6 +9819,10 @@ snapshots: dependencies: '@xterm/xterm': 5.5.0 + '@xterm/addon-unicode11@0.8.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + '@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)': dependencies: '@xterm/xterm': 5.5.0