feat(chat): terminal chat parity for every tool; Desktop-like artifacts

- The chat over a terminal pane now covers Claude, Codex, omp, Cursor and
  Antigravity: Cursor's agent transcript and agy's step log
  (brain/<id>/.system_generated/logs/transcript_full.jsonl) are read as
  conversations; Cursor sessions are claimed by their workspace
- Replies appear as soon as they are written: the projects-tree watcher
  broadcasts `transcript_changed` (at most every 250 ms per session) and the
  chat re-reads the transcript while the terminal underneath draws
- Stop shows from the pane's own busy line (Claude/Codex `esc to interrupt`,
  agy `… esc to cancel`), or fresh output for omp/Cursor — no hooks needed
- Permission prompts read off the terminal become a card with the tool, its
  command and "Deny with a note"; Codex gets Permission mode / Model buttons
  that open its own numbered pickers, answered from the menu card
- Artifacts: cards for pages/documents/diagrams/tables/React components, a
  Preview | Code panel with versions, copy, download, save to the Artifacts
  Hub, full screen; sandboxed iframes without allow-same-origin
- Claude panes get CLAUDE_CODE_ENABLE_TODO_TOOLS=1 so the Progress panel has a
  plan; multi-question pane menus read their Next row and question tabs
- The realtime chat bridge (structured stdio, /api/chat-bridge, /ws-chat)
  from the unreleased ad9d475 is not kept
This commit is contained in:
2026-10-21 15:44:17 +07:00
parent aee4fc190e
commit 383b9ae231
41 changed files with 2582 additions and 426 deletions
+321 -15
View File
@@ -15,7 +15,8 @@
* in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
* permission or trust prompt, Codex's approval — is read off the terminal
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons
* that press its keys there (`PaneMenuCard`); its "Type something" row opens
* that press its keys there (`PaneMenuCard`) — a permission prompt with its tool, command and a deny-with-note
* (`PermissionMenuCard`); its "Type something" row opens
* a field in the card, whose text is typed there as the answer
* (`answerRunMenu`).
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
@@ -56,7 +57,17 @@
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import {
ArrowUp,
Check,
ChevronLeft,
ChevronRight,
FileText,
MessageCircleQuestion,
ShieldQuestion,
Square,
SquareCheck,
} from "lucide-react";
import { isSubmitEnter } from "../../lib/keys";
import { ConversationView } from "../conversation/ConversationView";
import {
@@ -77,7 +88,11 @@ import { Divider, showTerminal } from "./PaneGrid";
import {
answerRunMenu,
chooseRunOption,
notePaneFocus,
onPaneOutput,
stepRunMenu,
interruptRun,
readRunBusy,
readRunMenu,
submitToRun,
type PaneMenu,
@@ -89,7 +104,9 @@ import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { setTabBadge } from "../../lib/tabBadge";
import { fmt } from "../../lib/format";
import { agentAwaitingReason } from "../../lib/types";
import { agentAwaitingReason, type TranscriptMessage, type WSMessage } from "../../lib/types";
import { eventBus } from "../../lib/eventBus";
import { throttle } from "../../lib/throttle";
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
const MODES: [PermissionMode, string][] = [
@@ -100,6 +117,11 @@ const MODES: [PermissionMode, string][] = [
["bypassPermissions", "Bypass"],
];
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
// Pane output re-reads the transcript at most this often: a TUI spinner
// redraws several times a second, and each read walks the transcript file.
const OUTPUT_FETCH_MS = 1000;
/** A tool with no known busy line counts as at work this long after its pane last drew. */
const OUTPUT_BUSY_MS = 2000;
const SIDE_KEY = "agenthub.chat.panelWidth";
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
@@ -117,6 +139,7 @@ const storedSide = () => {
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
export function contextWindow(model: string | null): number | null {
// A Codex/omp chat reports its own window (ContextMeter's `window`).
if (!model?.startsWith("claude-")) return null;
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
? 200_000
@@ -125,7 +148,13 @@ export function contextWindow(model: string | null): number | null {
/** How full the context is: a ring and a percentage, amber then red as
* Claude Code's auto-compact comes near. */
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
function ContextMeter({
tokens,
model,
}: {
tokens: number;
model: string | null;
}) {
const { t } = useTranslation("run");
const size = contextWindow(model);
if (!size || !tokens) return null;
@@ -212,33 +241,222 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
);
}
/** A permission prompt (Claude's tool approval, Codex's command approval,
* the folder-trust question): it offers a Yes and a No. */
export function isPermissionMenu(menu: PaneMenu): boolean {
if (menu.multi || menu.tabs) return false;
const labels = menu.options.map((o) => o.label);
return labels.some((l) => /^Yes\b/i.test(l)) && labels.some((l) => /^No\b/i.test(l));
}
/** A menu row's label without its key hint (`Yes, proceed (y)`). */
const bareLabel = (label: string) => label.replace(/\s+\((?:[a-z]|esc|shift\+tab)\)$/i, "");
/** A permission prompt read off the terminal: the tool, what it will run, and the CLI's own
* answers as buttons — each still presses its key in the pane. "No, and
* tell … differently" also takes a note, sent as the next message once the
* CLI is back at its prompt. */
function PermissionMenuCard({
menu,
onChoose,
onDenyNote,
onCancel,
}: {
menu: PaneMenu;
onChoose: (index: number) => void;
onDenyNote: (index: number, note: string) => void;
onCancel: () => void;
}) {
const { t } = useTranslation("run");
const [noting, setNoting] = useState(false);
const [note, setNote] = useState("");
const q = menu.question.split("\n");
const title = menu.detail?.[0] ?? q[0] ?? "";
const body = (menu.detail ? menu.detail.slice(1) : q.slice(1)).join("\n");
const ask = menu.detail ? menu.question : "";
const yes = menu.options.findIndex((o) => /^Yes\b/i.test(o.label));
const tell = menu.options.findIndex((o) => /^No, and tell\b/i.test(o.label));
const sendNote = () => {
if (note.trim() && tell >= 0) onDenyNote(tell, note.trim());
};
return (
<div data-testid="pane-chat-permission" className="flex flex-col gap-2">
<div className="flex items-start gap-2">
<ShieldQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-status-warning" />
<div className="min-w-0 flex-1">
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
{title}
</p>
{ask && <p className="text-xs text-fg-muted">{ask}</p>}
</div>
</div>
{body && (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-fg-secondary">
{body}
</pre>
)}
{noting ? (
<div className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1">
<input
autoFocus
data-testid="pane-chat-permission-note"
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
sendNote();
} else if (e.key === "Escape") setNoting(false);
}}
placeholder={t("chat.request.notePlaceholder")}
aria-label={t("chat.request.notePlaceholder")}
className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted"
/>
<button
type="button"
onClick={sendNote}
disabled={!note.trim()}
className="btn-secondary text-xs disabled:opacity-40"
>
{t("chat.request.send")}
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{menu.options.map((o, i) => (
<button
key={`${i}-${o.label}`}
type="button"
data-testid="pane-chat-permission-option"
autoFocus={i === yes}
onClick={() => onChoose(i)}
title={o.hint || undefined}
className={`${i === yes ? "btn-primary" : "btn-secondary"} max-w-full break-words text-left text-xs`}
>
{bareLabel(o.label)}
</button>
))}
{tell >= 0 && (
<button
type="button"
data-testid="pane-chat-permission-tell"
onClick={() => setNoting(true)}
className="text-xs text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
>
{t("chat.request.denyWithNote")}
</button>
)}
<button
type="button"
onClick={onCancel}
className="ml-auto rounded-md border border-border px-2 py-0.5 text-xs text-fg-muted hover:bg-surface-3"
>
{t("chat.menu.cancel")}
</button>
</div>
)}
</div>
);
}
/** The menu the CLI asks with, as buttons: a pick presses its key in the
* pane; its "Type something" row opens a field right there instead —
* pressing that row's digit again would type the digit into the CLI's own. */
* pressing that row's digit again would type the digit into the CLI's own.
* A permission prompt takes its own layout (`PermissionMenuCard`), any
* other menu the question layout. */
export function PaneMenuCard({
menu,
onChoose,
onAnswer,
onCancel,
onStep,
onDenyNote,
}: {
menu: PaneMenu;
onChoose: (index: number) => void;
onAnswer: (text: string) => void;
onCancel: () => void;
/** Previous (-1) / next (1) question of a multi-question AskUserQuestion. */
onStep?: (dir: -1 | 1) => void;
/** A permission prompt's "No, and tell …", with the note to send after it. */
onDenyNote?: (index: number, note: string) => void;
}) {
const { t } = useTranslation("run");
const [typing, setTyping] = useState(false);
const [draft, setDraft] = useState("");
const submit = () => draft.trim() && onAnswer(draft);
if (isPermissionMenu(menu))
return (
<div
data-testid="pane-chat-menu"
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
>
<PermissionMenuCard
menu={menu}
onChoose={onChoose}
onDenyNote={onDenyNote ?? ((i) => onChoose(i))}
onCancel={onCancel}
/>
</div>
);
return (
<div
data-testid="pane-chat-menu"
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
>
{menu.tabs && (
<div
data-testid="pane-chat-menu-tabs"
className="mb-2 flex flex-wrap items-center gap-1 text-xs"
>
{onStep && (
<button
type="button"
onClick={() => onStep(-1)}
aria-label={t("chat.menu.prev")}
title={t("chat.menu.prev")}
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
)}
{menu.tabs.map((tab, i) => (
<span
key={`${i}-${tab.label}`}
className={`flex items-center gap-1 rounded-md border px-1.5 py-0.5 ${
i === menu.tab
? "border-accent/50 bg-surface-2 text-fg-primary"
: "border-border text-fg-muted"
}`}
>
{tab.done ? (
<Check className="h-3 w-3 text-accent" />
) : (
<Square className="h-3 w-3" />
)}
{tab.label}
</span>
))}
{onStep && (
<button
type="button"
onClick={() => onStep(1)}
aria-label={t("chat.menu.next")}
title={t("chat.menu.next")}
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
{menu.question && (
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
{menu.question}
</p>
<div className="mb-2 flex items-start gap-2">
<MessageCircleQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
{menu.question}
</p>
</div>
)}
<div className="flex flex-col gap-1">
{menu.options.map((o, i) =>
@@ -338,9 +556,10 @@ export const PaneChat = memo(function PaneChat({
const runId = run.id;
const claude = run.tool === "claude";
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt);
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images);
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]);
const draftKey = sessionId;
const [prompt, setPrompt] = useState(() => readChatDraft(draftKey).prompt);
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(draftKey).images);
useEffect(() => writeChatDraft(draftKey, prompt, images), [draftKey, prompt, images]);
const [uploading, setUploading] = useState(false);
const [follow, setFollow] = useState(0);
const [model, setModel] = useState(run.model ?? "");
@@ -416,6 +635,46 @@ export const PaneChat = memo(function PaneChat({
useEffect(() => {
void readWorking();
}, [readWorking]);
// The terminal underneath drew something: the CLI is at work, so its
// transcript may have grown — read it now, for any tool, hooks or not. A
// spinner redraws all the time, hence the throttle.
useEffect(() => {
if (!sessionId) return;
const poke = throttle(
() =>
eventBus.publish({
type: "transcript_changed",
data: { session_id: sessionId },
} as unknown as WSMessage),
OUTPUT_FETCH_MS
);
const off = onPaneOutput(runId, poke);
return () => {
off();
poke.cancel();
};
}, [runId, sessionId]);
// The terminal's own word on a turn: its busy line (BUSY_LINE) while it
// shows, else — a tool whose line we do not know — output in the last
// OUTPUT_BUSY_MS. Hooks miss a tool that sends none (omp, Cursor, Codex
// without its opt-in hooks), and arrive late on a dashboard they skip.
const [screenBusy, setScreenBusy] = useState(false);
useEffect(() => {
let last = 0;
const off = onPaneOutput(runId, () => (last = Date.now()));
const read = () => {
const known = readRunBusy(runId, run.tool);
setScreenBusy(known ?? (last > 0 && Date.now() - last < OUTPUT_BUSY_MS));
};
read();
const timer = window.setInterval(() => {
if (isShown(onScreen)) read();
}, 700);
return () => {
off();
window.clearInterval(timer);
};
}, [runId, run.tool, onScreen]);
useLiveRefresh(readWorking, {
throttleMs: 500,
// While Stop shows, also poll: a missed agent_updated (the hook reached
@@ -444,6 +703,7 @@ export const PaneChat = memo(function PaneChat({
}, 700);
return () => window.clearInterval(timer);
}, [runId, onScreen]);
const busy = working || screenBusy;
const asking = waiting || menu !== null;
// Gone until the pane redraws: no second press on a stale menu.
const choose = (index: number) => {
@@ -456,15 +716,22 @@ export const PaneChat = memo(function PaneChat({
setFollow((n) => n + 1);
} else toast.error(t("chat.notSent"));
};
// "No, and tell … differently": the CLI drops back to its prompt, where
// the note is what the user would type next.
const denyWithNote = (index: number, note: string) => {
if (!menu || !chooseRunOption(runId, menu, index)) return void toast.error(t("chat.notSent"));
setMenu(null);
window.setTimeout(() => void command(note), 800);
};
// A hidden tab says when this session waits on the user or its turn ended.
const was = useRef({ working: false, asking: false });
useEffect(() => {
if (asking) setTabBadge(runId, "waiting");
else if (working) setTabBadge(runId, null);
else if (busy) setTabBadge(runId, null);
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
was.current = { working, asking };
}, [runId, working, asking]);
was.current = { working: busy, asking };
}, [runId, busy, asking]);
useEffect(() => () => setTabBadge(runId, null), [runId]);
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
tokens: 0,
@@ -484,6 +751,16 @@ export const PaneChat = memo(function PaneChat({
() => showTerminal(runId, sessionId, run.tool),
[runId, sessionId, run.tool]
);
// The conversation on screen: an artifact's versions are rebuilt from it.
const [convMessages, setConvMessages] = useState<TranscriptMessage[]>([]);
// An artifact card opens in this chat's own panel, like Claude Desktop's —
// not the workspace's Changes panel a plain file link goes to.
const openArtifact = useCallback(
(named: string) => {
setPreview(resolveIn(cwd, named));
},
[cwd]
);
const openFile = useCallback(
(named: string) => {
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
@@ -533,6 +810,8 @@ export const PaneChat = memo(function PaneChat({
<div
ref={rootRef}
data-prompt-drop
// The last chat focused is where the Changes panel's comments go.
onFocusCapture={() => notePaneFocus(runId)}
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
>
@@ -549,6 +828,8 @@ export const PaneChat = memo(function PaneChat({
onContext={onContext}
onNotStarted={setNotStarted}
onOpenFile={openFile}
onOpenArtifact={openArtifact}
onMessages={setConvMessages}
/>
{!outlineOpen && !preview && (
<button
@@ -573,6 +854,10 @@ export const PaneChat = memo(function PaneChat({
menu={menu}
onChoose={choose}
onAnswer={answer}
onDenyNote={denyWithNote}
onStep={(dir) => {
if (!stepRunMenu(runId, dir)) toast.error(t("chat.notSent"));
}}
onCancel={() => {
if (interruptRun(runId)) setMenu(null);
else toast.error(t("chat.notSent"));
@@ -609,6 +894,26 @@ export const PaneChat = memo(function PaneChat({
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
/>
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
{run.tool === "codex" &&
// Codex's `/model` (then its effort) and `/permissions` are
// numbered pickers: typed here, answered on the menu card.
(
[
["/permissions", t("fields.permissionMode")],
["/model", t("fields.model")],
] as const
).map(([line, label]) => (
<button
key={line}
type="button"
data-testid={`pane-chat-codex-${line.slice(1)}`}
onClick={() => void command(line)}
title={t("chat.codexPicker")}
className="rounded-md px-2 py-1 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
{label}
</button>
))}
<div className="ml-auto flex items-center gap-1">
<ContextMeter tokens={context.tokens} model={context.model} />
{claude && (
@@ -624,7 +929,7 @@ export const PaneChat = memo(function PaneChat({
}}
/>
)}
{working && !text ? (
{busy && !text ? (
<button
type="button"
data-testid="pane-chat-stop"
@@ -669,6 +974,7 @@ export const PaneChat = memo(function PaneChat({
<FilePreview
path={preview}
scope={scope}
messages={convMessages}
onClose={() => setPreview(null)}
// Beside the chat when the pane has room, over it when not.
className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"