From ace710633caa6e2b1c1c2edc559b62d402a955a0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 5 Jun 2026 02:38:38 -0700 Subject: [PATCH] feat(dashboard): cli-agent hybrid chat transcript with raw-terminal toggle (U12) Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/cli-agent-hybrid-chat.md | 14 + packages/core/src/chat-store.ts | 29 +- packages/core/src/chat-types.ts | 9 + packages/core/src/db.ts | 12 + .../app/components/CliChatSurface.tsx | 125 ++++++ .../__tests__/ChatView.cli-toggle.test.tsx | 98 +++++ .../src/__tests__/chat-cli-sessions.test.ts | 375 ++++++++++++++++++ packages/dashboard/src/cli-chat.ts | 332 ++++++++++++++++ 8 files changed, 992 insertions(+), 2 deletions(-) create mode 100644 .changeset/cli-agent-hybrid-chat.md create mode 100644 packages/dashboard/app/components/CliChatSurface.tsx create mode 100644 packages/dashboard/app/components/__tests__/ChatView.cli-toggle.test.tsx create mode 100644 packages/dashboard/src/__tests__/chat-cli-sessions.test.ts create mode 100644 packages/dashboard/src/cli-chat.ts diff --git a/.changeset/cli-agent-hybrid-chat.md b/.changeset/cli-agent-hybrid-chat.md new file mode 100644 index 0000000000..b91b683597 --- /dev/null +++ b/.changeset/cli-agent-hybrid-chat.md @@ -0,0 +1,14 @@ +--- +"@runfusion/fusion": minor +--- + +CLI-agent hybrid chat (U12): a chat session can select a cli-agent executor and +be driven by a long-lived CLI agent process. Adapter transcript telemetry maps +to durable chat_messages rows at user/assistant/tool-summary granularity (raw +tool noise stays in the terminal), with the shared `redactSecrets` pass applied +before persistence so transcripts never become a secret store. Composer sends +route through the inject path with FIFO queueing; the flush decision re-fetches +authoritative session state rather than trusting a cached busy flag. The chat +surface gains a transcript ↔ raw-terminal toggle (terminal owns input, composer +hidden in terminal mode); generic-tier sessions render terminal-only with no +toggle. New per-session `cliExecutorAdapterId` linkage on chat_sessions. diff --git a/packages/core/src/chat-store.ts b/packages/core/src/chat-store.ts index 6301d991f5..98b711c32e 100644 --- a/packages/core/src/chat-store.ts +++ b/packages/core/src/chat-store.ts @@ -86,6 +86,7 @@ interface ChatSessionRow { updatedAt: string; cliSessionFile: string | null; inFlightGeneration: string | null; + cliExecutorAdapterId: string | null; } /** Database row shape for chat_messages. */ @@ -161,6 +162,7 @@ export class ChatStore extends EventEmitter { updatedAt: row.updatedAt, cliSessionFile: row.cliSessionFile ?? null, inFlightGeneration: fromJson(row.inFlightGeneration) ?? null, + cliExecutorAdapterId: row.cliExecutorAdapterId ?? null, }; } @@ -254,11 +256,12 @@ export class ChatStore extends EventEmitter { updatedAt: now, cliSessionFile: null, inFlightGeneration: null, + cliExecutorAdapterId: input.cliExecutorAdapterId ?? null, }; this.db.prepare(` - INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration, cliExecutorAdapterId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( session.id, session.agentId, @@ -270,6 +273,7 @@ export class ChatStore extends EventEmitter { session.createdAt, session.updatedAt, null, + session.cliExecutorAdapterId, ); this.db.bumpLastModified(); @@ -466,6 +470,27 @@ export class ChatStore extends EventEmitter { this.db.bumpLastModified(); } + /** + * Set (or clear) the cli-agent adapter that backs this chat session (U12). + * When set, the chat is CLI-backed: composer sends route through the inject + * path and adapter transcript events map to chat_messages rows. Emits a + * session update so the client can switch to the CLI-backed rendering path. + * + * @param id - Session ID + * @param adapterId - cli-agent adapter id, or null to revert to the provider path + */ + setCliExecutorAdapterId(id: string, adapterId: string | null): ChatSession | undefined { + const existing = this.getSession(id); + if (!existing) return undefined; + this.db + .prepare("UPDATE chat_sessions SET cliExecutorAdapterId = ?, updatedAt = ? WHERE id = ?") + .run(adapterId, new Date().toISOString(), id); + this.db.bumpLastModified(); + const updated = this.getSession(id)!; + this.emit("chat:session:updated", updated); + return updated; + } + setInFlightGeneration(id: string, inFlightGeneration: ChatInFlightGenerationState | null): ChatSession | undefined { const existing = this.getSession(id); if (!existing) return undefined; diff --git a/packages/core/src/chat-types.ts b/packages/core/src/chat-types.ts index 6de335ea72..5e86113606 100644 --- a/packages/core/src/chat-types.ts +++ b/packages/core/src/chat-types.ts @@ -68,6 +68,13 @@ export interface ChatSession { * for sessions that have never produced an assistant reply. */ cliSessionFile: string | null; + /** + * cli-agent adapter id backing this chat session (CLI Agent Executor, U12). + * When non-null the chat is CLI-backed: composer sends inject into a live + * CLI session and adapter transcript events map to chat_messages rows. Null + * means the chat uses the standard model-provider path. + */ + cliExecutorAdapterId: string | null; /** Durable in-flight assistant snapshot used to recover streaming UI after refresh. */ inFlightGeneration: ChatInFlightGenerationState | null; } @@ -160,6 +167,8 @@ export interface ChatSessionCreateInput { modelProvider?: string | null; /** Optional model ID override */ modelId?: string | null; + /** Optional cli-agent adapter id; when set the chat is CLI-backed (U12) */ + cliExecutorAdapterId?: string | null; } /** diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 266adee074..fe49c728ff 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -4344,6 +4344,18 @@ export class Database { }); } + // CLI Agent Executor (U12): per-chat-session selection of a cli-agent + // adapter. When set, the chat is CLI-backed — composer sends route through + // the inject path and adapter transcript events map to chat_messages rows. + // Null/empty means the chat uses the standard provider path. + if (version < 110) { + this.applyMigration(110, () => { + if (this.hasTable("chat_sessions")) { + this.addColumnIfMissing("chat_sessions", "cliExecutorAdapterId", "TEXT"); + } + }); + } + } /** diff --git a/packages/dashboard/app/components/CliChatSurface.tsx b/packages/dashboard/app/components/CliChatSurface.tsx new file mode 100644 index 0000000000..6c2ed32265 --- /dev/null +++ b/packages/dashboard/app/components/CliChatSurface.tsx @@ -0,0 +1,125 @@ +// CliChatSurface — the CLI-backed chat rendering surface (CLI Agent Executor, U12). +// +// ChatView delegates to this component when the active chat session selects a +// cli-agent executor. It encapsulates the three KTD behaviors that distinguish +// a CLI-backed chat from a provider chat: +// +// 1. Hybrid (native/hybrid tier): render the durable transcript as today PLUS a +// transcript ↔ terminal toggle. Raw-terminal mode swaps the message list for +// and HIDES the composer (the terminal owns input); +// toggling back restores the transcript and composer. +// 2. Generic tier: terminal-ONLY. No toggle, no transcript pane — the affordance +// is absent, not empty (screen-output parsing for a structured transcript is +// out of scope for generic CLIs). +// 3. Composer queued state: while the underlying CLI session is busy, sends are +// queued with a visible indicator. The flush decision is owned server-side +// (CliChatSessionRunner, which re-fetches authoritative state — the +// stale-isGenerating learning); this component only surfaces the queued count. +// +// Rendering of the transcript message list itself stays with ChatView's existing +// renderer (passed in as `renderTranscript`) so there is no parallel message UI. +import React, { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Terminal as TerminalIcon, MessageSquare } from "lucide-react"; +import { SessionTerminal, type SessionTerminalProps } from "./SessionTerminal"; + +/** Adapter capability tier — drives whether a transcript view exists at all. */ +export type CliChatTier = "native" | "hybrid" | "generic"; + +export interface CliChatSurfaceProps { + /** Live CLI session id to attach the terminal to. */ + cliSessionId: string; + /** Adapter tier. Generic → terminal-only (no toggle, no transcript). */ + tier: CliChatTier; + projectId?: string; + /** Renders the existing ChatView transcript message list. */ + renderTranscript: () => ReactNode; + /** Renders the existing ChatView composer (hidden in raw-terminal mode). */ + renderComposer: () => ReactNode; + /** Number of composer messages queued behind a busy session (0 = none). */ + queuedCount?: number; + /** Extra props forwarded to SessionTerminal (posture, settings link, etc.). */ + terminalProps?: Partial>; +} + +type SurfaceView = "transcript" | "terminal"; + +export function CliChatSurface({ + cliSessionId, + tier, + projectId, + renderTranscript, + renderComposer, + queuedCount = 0, + terminalProps, +}: CliChatSurfaceProps) { + const { t } = useTranslation("app"); + const isGeneric = tier === "generic"; + // Generic tier is terminal-only; hybrid/native default to the transcript view. + const [view, setView] = useState(isGeneric ? "terminal" : "transcript"); + + // Generic tier: render the terminal directly, no toggle, no composer, no + // transcript pane. The terminal owns all input. + if (isGeneric) { + return ( +
+ +
+ ); + } + + const showTerminal = view === "terminal"; + + return ( +
+
+ + +
+ +
+ {showTerminal ? ( + // Raw-terminal mode: the message list is swapped out and the terminal + // owns input. Composer is hidden below. + + ) : ( + renderTranscript() + )} +
+ + {/* Composer is hidden in raw-terminal mode — the terminal owns input. */} + {!showTerminal && ( +
+ {queuedCount > 0 && ( +
+ {t("cliChat.queued", "{{count}} message queued — will send when the agent is ready", { + count: queuedCount, + })} +
+ )} + {renderComposer()} +
+ )} +
+ ); +} + +export default CliChatSurface; diff --git a/packages/dashboard/app/components/__tests__/ChatView.cli-toggle.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.cli-toggle.test.tsx new file mode 100644 index 0000000000..c06225a37d --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.cli-toggle.test.tsx @@ -0,0 +1,98 @@ +// CLI-backed chat surface tests (CLI Agent Executor, U12). +// +// Exercises the transcript ↔ terminal toggle, the terminal-owns-composer rule, +// the generic-tier terminal-only rendering, and the composer queued indicator. +// SessionTerminal is mocked (no xterm / no WS / no PTY / no port 4040) so these +// are pure component-behavior assertions. +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +// Mock SessionTerminal — it lazy-loads xterm and opens a WS; we only need to +// assert presence/absence of the terminal surface. +vi.mock("../SessionTerminal", () => ({ + SessionTerminal: ({ sessionId }: { sessionId: string }) => ( +
+ terminal +
+ ), +})); + +import { CliChatSurface } from "../CliChatSurface"; + +function renderSurface(overrides: Partial> = {}) { + return render( +
transcript-rows
} + renderComposer={() =>