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
+4
View File
@@ -34,6 +34,7 @@ import {
chooseRunOption,
interruptRun,
readRunMenu,
stepRunMenu,
submitToRun,
type PaneMenu,
} from "../../lib/panePaste";
@@ -505,6 +506,9 @@ export function PetChat({
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
onStep={(dir) => {
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
}}
onCancel={() => {
if (interruptRun(chat.runId)) setMenu(null);
else setError(t("sagi.chat.notSent"));
@@ -96,6 +96,9 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
// at most this often: the first event still fetches at once, the last is never
// lost, and a long transcript is not re-read for every single frame.
const EVENT_FETCH_MS = 1500;
// The server saw this session's transcript grow (`transcript_changed`, itself
// at most every 250 ms): the new lines are on disk, so read them nearly at once.
const PUSH_FETCH_MS = 300;
interface ConversationViewProps {
sessionId: string;
@@ -108,6 +111,10 @@ interface ConversationViewProps {
compact?: boolean;
/** Opens a file named in a turn's "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** Opens an artifact card's file in the chat's artifact panel. */
onOpenArtifact?: (path: string) => void;
/** The messages on screen (for an artifact's versions), as they change. */
onMessages?: (messages: TranscriptMessage[]) => void;
/** The session runs in a pane: show the step in progress. */
live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */
@@ -133,6 +140,8 @@ function ConversationViewPane({
fill = false,
compact = false,
onOpenFile,
onOpenArtifact,
onMessages,
live = false,
follow = 0,
waiting = false,
@@ -144,19 +153,21 @@ function ConversationViewPane({
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const shown = messages;
// A live pane's session has no row until its first hook — and Claude Code
// runs no hook before the folder is trusted, so a trust prompt (or any
// other question before the session starts) waits unseen in the terminal.
const [notStarted, setNotStarted] = useState(false);
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
useEffect(() => onMessages?.(shown), [shown, onMessages]);
useEffect(() => {
if (!onContext) return;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
for (let i = shown.length - 1; i >= 0; i--) {
const m = shown[i]!;
if (m.type === "assistant" && m.usage)
return onContext(promptTokens(m.usage), m.model ?? null);
}
}, [messages, onContext]);
}, [shown, onContext]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingHistory, setLoadingHistory] = useState(false);
@@ -209,12 +220,13 @@ function ConversationViewPane({
const agentLinks = useMemo<AgentLinks>(() => {
const ids = new Map<string, string>();
for (const tr of transcripts) if (tr.tool_use_id) ids.set(tr.tool_use_id, tr.id);
return { ids, notices: agentNotices(messages), live, render: renderAgent };
}, [transcripts, messages, live, renderAgent]);
return { ids, notices: agentNotices(shown), live, render: renderAgent };
}, [transcripts, shown, live, renderAgent]);
// Load available transcript list (also rescanned on a short interval so
// newly-spawned subagents appear in the dropdown without a page reload).
useEffect(() => {
if (!sessionId) return;
let cancelled = false;
async function loadTranscripts() {
try {
@@ -254,6 +266,12 @@ function ConversationViewPane({
firstLineRef.current = 0;
async function load() {
// No session yet: no history.
if (!sessionId) {
setMessages([]);
setLoading(false);
return;
}
try {
setError(null);
setLoading(true);
@@ -297,6 +315,7 @@ function ConversationViewPane({
// parsed message we've seen. The server already de-overlaps via
// afterLine, so we can safely append.
const fetchNewMessages = useCallback(async () => {
if (!sessionId) return;
if (fetchingRef.current) {
// Coalesce: remember a trigger arrived during this fetch and re-run
// exactly once when the in-flight request settles.
@@ -358,7 +377,12 @@ function ConversationViewPane({
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
[fetchNewMessages]
);
const fetchPushed = useMemo(
() => throttle(() => void fetchNewMessages(), PUSH_FETCH_MS),
[fetchNewMessages]
);
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
useEffect(() => () => fetchPushed.cancel(), [fetchPushed]);
useEffect(() => {
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
// Hidden tab or hidden pane: catching up once on show is enough.
@@ -367,13 +391,14 @@ function ConversationViewPane({
fetchSoon();
return;
}
if (msg.type !== "new_event") return;
if (msg.type !== "new_event" && msg.type !== "transcript_changed") return;
const data = msg.data as { session_id?: string };
if (data.session_id !== sessionId) return;
fetchSoon();
if (msg.type === "transcript_changed") fetchPushed();
else fetchSoon();
});
return unsubscribe;
}, [sessionId, fetchSoon, onScreen]);
}, [sessionId, fetchSoon, fetchPushed, onScreen]);
onShowRef.current = () => {
if (document.visibilityState === "visible") void fetchNewMessages();
@@ -535,7 +560,7 @@ function ConversationViewPane({
// Auto-scroll to bottom after initial load
useEffect(() => {
if (!loading && messages.length > 0) {
if (!loading && shown.length > 0) {
scrollToBottom();
}
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -626,7 +651,7 @@ function ConversationViewPane({
>
{t("conversation.loading")}
</div>
) : messages.length === 0 && notStarted ? (
) : shown.length === 0 && notStarted ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
@@ -642,7 +667,7 @@ function ConversationViewPane({
</button>
)}
</div>
) : messages.length === 0 ? (
) : shown.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
@@ -651,9 +676,10 @@ function ConversationViewPane({
</div>
) : (
<MessageList
messages={messages}
messages={shown}
loading={false}
onOpenFile={onOpenFile}
onOpenArtifact={onOpenArtifact}
live={live}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
@@ -112,7 +112,11 @@ import {
Search,
Globe,
Wrench,
Image as ImageIcon,
Code2,
Table2,
} from "lucide-react";
import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
import { api } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
@@ -148,6 +152,8 @@ interface MessageListProps {
loading: boolean;
/** Opens a file named in the "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** Opens an artifact card's file in the chat's artifact panel. */
onOpenArtifact?: (path: string) => void;
/** The session is running in a pane: show the step in progress. */
live?: boolean;
/** The CLI is asking the user something (a permission prompt): the live
@@ -604,30 +610,91 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
/** Claude Desktop's end-of-turn card: the files the turn changed, each with
* the lines added and removed. A call still running or failed is left out. */
const ARTIFACT_ICON: Partial<Record<ArtifactKind, typeof FileText>> = {
html: Globe,
svg: ImageIcon,
markdown: FileText,
mermaid: Workflow,
react: Code2,
csv: Table2,
};
/** Claude Desktop's artifact card: what the model made, opened beside the chat. */
function ArtifactCard({ path, onOpen }: { path: string; onOpen?: (path: string) => void }) {
const { t } = useTranslation("sessions");
const kind = artifactKind(path);
const Icon = ARTIFACT_ICON[kind] ?? FileText;
const name = path.split(/[\\/]/).pop() || path;
return (
<button
type="button"
data-testid="artifact-card"
title={path}
disabled={!onOpen}
onClick={() => onOpen?.(path)}
draggable
onDragStart={(e) => dragAttachments(e, [projectAttachment(path)])}
className="flex w-full max-w-md items-center gap-3 rounded-xl border border-border bg-surface-1 px-3 py-2.5 text-left transition-colors enabled:hover:border-border-light enabled:hover:bg-surface-2"
>
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-surface-2">
<Icon className="h-4 w-4 text-fg-secondary" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-fg-primary">
{name.replace(/\.[A-Za-z0-9]+$/, "")}
</span>
<span className="block truncate text-xs text-fg-muted">
{t(`conversation.artifact.${kind}`, { defaultValue: kind })}
{onOpen ? ` · ${t("conversation.artifact.open")}` : ""}
</span>
</span>
</button>
);
}
function EditedFilesCard({
blocks,
toolResultMap,
onOpenFile,
onOpenArtifact,
pending = false,
}: {
blocks: TranscriptContent[];
toolResultMap: Map<string, TranscriptContent>;
onOpenFile?: (path: string) => void;
/** Opens an artifact (a page, document, diagram…) in the chat's own panel. */
onOpenArtifact?: (path: string) => void;
/** The turn is still working: only its artifact cards so far. */
pending?: boolean;
}) {
const { t } = useTranslation("sessions");
// `counted`: a result carried its +/- lines (an older server sends none).
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
// Written whole in this turn (a Write): a component made here is an artifact.
const created = new Set<string>();
for (const b of blocks) {
const input = (b.input ?? {}) as Record<string, unknown>;
const p = input.file_path ?? input.notebook_path;
const r = b.id ? toolResultMap.get(b.id) : undefined;
if (typeof p !== "string" || !r || r.is_error) continue;
if (b.name === "Write") created.add(p);
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += r.diff?.added ?? 0;
f.removed += r.diff?.removed ?? 0;
f.counted ||= !!r.diff;
files.set(p, f);
}
if (files.size === 0) return null;
const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p)));
const cards = artifacts.length > 0 && (
<div data-testid="artifact-cards" className="flex flex-col gap-2">
{artifacts.map((p) => (
<ArtifactCard key={p} path={p} onOpen={onOpenArtifact ?? onOpenFile} />
))}
</div>
);
if (pending) return cards || null;
for (const p of artifacts) files.delete(p);
if (files.size === 0) return cards || null;
let added = 0;
let removed = 0;
let counted = false;
@@ -643,7 +710,7 @@ function EditedFilesCard({
<span className="text-status-danger">−{r}</span>
</span>
);
return (
const list = (
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
<div
draggable
@@ -673,11 +740,21 @@ function EditedFilesCard({
))}
</div>
);
return cards ? (
<div className="flex flex-col gap-2">
{cards}
{list}
</div>
) : (
list
);
}
const EditedFiles = memo(
EditedFilesCard,
(p, n) =>
p.onOpenFile === n.onOpenFile &&
p.onOpenArtifact === n.onOpenArtifact &&
p.pending === n.pending &&
sameBlocks(p.blocks, n.blocks) &&
sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
);
@@ -688,6 +765,7 @@ function MessageListView({
messages,
loading,
onOpenFile,
onOpenArtifact,
live = false,
waiting = false,
onOpenTerminal,
@@ -780,14 +858,16 @@ function MessageListView({
/>
);
case "edits":
// The card closes a finished turn; one still working has none yet.
if (pending && item === items[items.length - 1]) return null;
// The card closes a finished turn; one still working shows
// only the artifacts it made so far, as Claude Desktop does.
return (
<EditedFiles
key={item.key}
blocks={item.blocks}
toolResultMap={toolResultMap}
onOpenFile={onOpenFile}
onOpenArtifact={onOpenArtifact}
pending={!!pending && item === items[items.length - 1]}
/>
);
case "done":
@@ -0,0 +1,74 @@
/**
* @file ConversationView.pushed.test.tsx
* @description The server says a session's transcript grew
* (`transcript_changed`): the chat showing that session reads the new lines
* at once, and again within a short window after a burst — not at the hook
* throttle's 1.5 s pace — while another session's notice fetches nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
import { api } from "../../../lib/api";
import { eventBus } from "../../../lib/eventBus";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
sessions: {
transcripts: vi.fn(async () => ({ transcripts: [] })),
transcript: vi.fn(async () => ({
messages: [
{
type: "user",
content: [{ type: "text", text: "hello" }],
timestamp: "2026-10-21T00:00:00Z",
},
],
total: 1,
has_more: false,
first_line: 1,
last_line: 1,
})),
},
},
};
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
const pushed = (session_id: string) =>
act(() => eventBus.publish({ type: "transcript_changed", data: { session_id } } as never));
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
describe("ConversationView on transcript_changed", () => {
it("reads its own session's new lines at once, and a burst within 300 ms", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
vi.useFakeTimers();
const before = calls();
pushed("s1");
expect(calls()).toBe(before + 1);
pushed("s1");
pushed("s1");
await act(async () => {
await vi.advanceTimersByTimeAsync(350);
});
expect(calls()).toBe(before + 2);
});
it("ignores another session's transcript", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
const before = calls();
pushed("s2");
await act(async () => {});
expect(calls()).toBe(before);
});
});
@@ -46,7 +46,7 @@ describe("the Edited N files card", () => {
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 3, removed: 1 }),
call("2", "Write", "/w/b.md"),
call("2", "Write", "/w/b.json"),
result("2", { added: 10, removed: 0 }),
call("3", "Edit", "/w/a.ts"),
result("3", { added: 2, removed: 2 }),
@@ -65,8 +65,35 @@ describe("the Edited N files card", () => {
expect(within(card).getByText("+15")).toBeTruthy();
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
fireEvent.click(within(card).getByTitle("/w/b.md"));
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
fireEvent.click(within(card).getByTitle("/w/b.json"));
expect(onOpenFile).toHaveBeenCalledWith("/w/b.json");
});
it("shows a page or document the turn made as an artifact card, opened in the chat's panel", () => {
const onOpenFile = vi.fn();
const onOpenArtifact = vi.fn();
render(
<MessageList
loading={false}
onOpenFile={onOpenFile}
onOpenArtifact={onOpenArtifact}
messages={[
user("make a page"),
call("1", "Write", "/w/page.html"),
result("1", { added: 20, removed: 0 }),
call("2", "Edit", "/w/App.tsx"),
result("2", { added: 1, removed: 1 }),
user("thanks"),
]}
/>
);
const cards = screen.getAllByTestId("artifact-card");
// A .tsx it only edited is project code, not an artifact.
expect(cards).toHaveLength(1);
expect(cards[0]!.textContent).toContain("page");
fireEvent.click(cards[0]!);
expect(onOpenArtifact).toHaveBeenCalledWith("/w/page.html");
expect(within(screen.getByTestId("edited-files")).getByTitle("/w/App.tsx")).toBeTruthy();
});
it("drags a row as its @path attachment, and the header as every file", () => {
@@ -77,7 +104,7 @@ describe("the Edited N files card", () => {
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 1, removed: 0 }),
call("2", "Write", "/w/b.md"),
call("2", "Write", "/w/b.json"),
result("2", { added: 2, removed: 0 }),
]}
/>
@@ -90,14 +117,14 @@ describe("the Edited N files card", () => {
});
return data;
};
const one = drag(within(card).getByTitle("/w/b.md"));
const one = drag(within(card).getByTitle("/w/b.json"));
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
{ path: "@/w/b.md", name: "b.md" },
{ path: "@/w/b.json", name: "b.json" },
]);
expect(one["text/plain"]).toBe("/w/b.md");
expect(one["text/plain"]).toBe("/w/b.json");
const all = drag(within(card).getByText("Edited 2 files"));
expect(
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
).toEqual(["@/w/a.ts", "@/w/b.md"]);
).toEqual(["@/w/a.ts", "@/w/b.json"]);
});
});
+295 -39
View File
@@ -18,15 +18,40 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type DragEvent } from "react";
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Copy, FileText, Loader2, X } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
Copy,
Download,
FileText,
Loader2,
Maximize2,
Minimize2,
Share2,
X,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import { useDialog } from "../../hooks/useDialog";
import { dragSkill } from "../../lib/promptAttach";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { MarkdownPreview } from "../MarkdownPreview";
import { CodeBlock } from "../conversation/CodeBlock";
import { useTheme } from "../../hooks/useTheme";
import { toast } from "../../lib/toast";
import type { TranscriptMessage } from "../../lib/types";
import {
RENDERED,
artifactDocument,
artifactKind,
artifactLang,
artifactVersions,
frameSandbox,
parseCsv,
type ArtifactKind,
} from "../../lib/artifactView";
/** Where a Chat pane's files can be read from. */
export interface PreviewScope {
@@ -146,33 +171,89 @@ function useContent(path: string, scope: PreviewScope): Content {
return content;
}
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
/** A CSV/TSV as a table (the first rows; a file is not a spreadsheet). */
function CsvTable({ text, tab }: { text: string; tab: boolean }) {
const rows = useMemo(() => parseCsv(text, tab ? "\t" : ",").slice(0, 1000), [text, tab]);
const [head, ...body] = rows;
return (
<div className="min-h-0 flex-1 overflow-auto p-3">
<table className="w-full border-collapse text-xs">
{head && (
<thead className="sticky top-0 bg-surface-2">
<tr>
{head.map((h, i) => (
<th
key={i}
className="border border-border px-2 py-1 text-left font-medium text-fg-primary"
>
{h}
</th>
))}
</tr>
</thead>
)}
<tbody>
{body.map((r, i) => (
<tr key={i} className="odd:bg-surface-1 even:bg-surface-2/40">
{r.map((c, j) => (
<td key={j} className="border border-border px-2 py-1 text-fg-secondary">
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** An HTML, SVG, Mermaid or React artifact, running in its own sandbox:
* scripts (none for an SVG) but never the dashboard's origin. */
function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string; title: string }) {
const { theme } = useTheme();
const doc = useMemo(() => artifactDocument(kind, text, theme === "dark"), [kind, text, theme]);
return (
<iframe
data-testid="artifact-frame"
title={title}
srcDoc={doc ?? ""}
sandbox={frameSandbox(kind)}
referrerPolicy="no-referrer"
className={`min-h-0 w-full flex-1 border-0 ${kind === "html" || kind === "react" ? "bg-white" : ""}`}
/>
);
}
type View = "preview" | "code";
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
const { t } = useTranslation("run");
const loaded = useContent(path, scope);
const kind = artifactKind(path);
// An image the route refused (gone, server mode) shows like no preview.
const [broken, setBroken] = useState<string | null>(null);
const content: Content =
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
if (content.kind === "loading") {
const shown: Content =
content.kind === "image" && content.src === broken ? { kind: "none" } : content;
if (shown.kind === "loading") {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
</div>
);
}
if (content.kind === "image") {
if (shown.kind === "image") {
return (
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
<img
src={content.src}
src={shown.src}
alt={baseName(path)}
onError={() => setBroken(content.src)}
onError={() => setBroken(shown.src)}
className="max-h-full max-w-full object-contain"
/>
</div>
);
}
if (content.kind === "none") {
if (shown.kind === "none") {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
<FileText className="h-8 w-8" />
@@ -181,69 +262,244 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
</div>
);
}
return (
<div className="min-h-0 flex-1 overflow-auto">
{isMarkdown(path) ? (
<MarkdownPreview text={content.text} className="p-4" />
) : (
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
{content.text}
</pre>
)}
{content.truncated && (
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
)}
</div>
const note = shown.truncated && (
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
);
if (view === "code" || !RENDERED.has(kind)) {
return (
<div className="min-h-0 flex-1 overflow-auto p-3">
<CodeBlock code={shown.text} lang={artifactLang(path)} maxHeight="none" showLineNumbers />
{note}
</div>
);
}
if (kind === "markdown") {
return (
<div className="min-h-0 flex-1 overflow-auto">
<MarkdownPreview text={shown.text} className="p-4" />
{note}
</div>
);
}
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
}
/** One file: header (name · EXT, Copy path, Close) over its content. */
const iconBtn =
"flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40";
/** Saves text (or an image's URL) as a file through the browser. */
function download(name: string, content: Content) {
const a = document.createElement("a");
if (content.kind === "image") a.href = content.src;
else if (content.kind === "text")
a.href = URL.createObjectURL(new Blob([content.text], { type: "text/plain;charset=utf-8" }));
else return;
a.download = name;
a.click();
if (content.kind === "text") setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
/** One file, Claude Desktop's artifact view: name · EXT, Preview | Code, the
* versions the conversation made of it, Copy, Download, Share to the
* Artifacts Hub, full screen, Close. */
export function FilePreview({
path,
scope,
onClose,
className = "",
messages,
}: {
path: string;
scope: PreviewScope;
onClose: () => void;
className?: string;
/** The conversation, for the versions its Write/Edit calls left. */
messages?: TranscriptMessage[];
}) {
const { t } = useTranslation("run");
return (
const kind = artifactKind(path);
const loaded = useContent(path, scope);
const [view, setView] = useState<View>(RENDERED.has(kind) ? "preview" : "code");
const [full, setFull] = useState(false);
const [sharing, setSharing] = useState(false);
const versions = useMemo(
() => (messages ? artifactVersions(messages, path) : []),
[messages, path]
);
// null: the newest — the file as it is now.
const [picked, setPicked] = useState<number | null>(null);
// Another file opens on its own default tab, at its newest version.
useEffect(() => {
setPicked(null);
setView(RENDERED.has(artifactKind(path)) ? "preview" : "code");
}, [path]);
const last = Math.max(versions.length - 1, 0);
const at = picked ?? last;
const older = picked !== null && picked < last ? versions[picked] : null;
const content: Content =
older && older.content !== null
? { kind: "text", text: older.content, truncated: false }
: loaded;
const known = (i: number) => i === last || versions[i]?.content != null;
const step = (dir: -1 | 1) => {
for (let i = at + dir; i >= 0 && i <= last; i += dir) {
if (known(i)) return setPicked(i === last ? null : i);
}
};
const share = async () => {
if (sharing) return;
setSharing(true);
try {
const file = slashed(path);
const base = slashed(scope.laneCwd).replace(/\/+$/, "");
if (scope.laneId !== null && base && file.startsWith(`${base}/`) && picked === null) {
await api.artifacts.pinFile({
laneId: scope.laneId,
path: file.slice(base.length + 1),
title: displayName(path),
...(scope.sessionId ? { sessionId: scope.sessionId } : {}),
});
} else if (content.kind === "text") {
await api.artifacts.uploadFile(new File([content.text], displayName(path)), {
title: displayName(path),
});
} else throw new Error(t("chat.artifact.cannotShare"));
toast.success(t("chat.artifact.shared"));
} catch (err) {
toast.error(err);
} finally {
setSharing(false);
}
};
const panel = (
<section
data-testid="chat-file-preview"
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
className={
full
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
}
>
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
<button
type="button"
onClick={() => void copyText(path)}
title={t("chat.outline.copyPath", { path })}
className="min-w-0 truncate text-left text-sm text-fg-primary hover:underline"
>
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
</span>
<span className="text-xs text-fg-muted">{extOf(path)}</span>
<div className="ml-auto flex items-center gap-1">
</button>
<span className="flex-shrink-0 text-xs text-fg-muted">{extOf(path)}</span>
{versions.length > 1 && (
<span
data-testid="artifact-versions"
className="flex flex-shrink-0 items-center text-xs text-fg-muted"
>
<button
type="button"
onClick={() => step(-1)}
disabled={![...Array(at).keys()].some(known)}
aria-label={t("chat.artifact.older")}
title={t("chat.artifact.older")}
className={iconBtn}
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span className="tabular-nums">
{t("chat.artifact.version", { n: at + 1, total: versions.length })}
</span>
<button
type="button"
onClick={() => step(1)}
disabled={at >= last}
aria-label={t("chat.artifact.newer")}
title={t("chat.artifact.newer")}
className={iconBtn}
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</span>
)}
<div className="ml-auto flex flex-shrink-0 items-center gap-1">
{RENDERED.has(kind) && kind !== "image" && (
<div role="tablist" className="mr-1 flex rounded-md border border-border p-0.5 text-xs">
{(["preview", "code"] as const).map((v) => (
<button
key={v}
type="button"
role="tab"
aria-selected={view === v}
data-testid={`artifact-tab-${v}`}
onClick={() => setView(v)}
className={`rounded px-2 py-0.5 ${view === v ? "bg-surface-3 text-fg-primary" : "text-fg-muted hover:text-fg-primary"}`}
>
{t(`chat.artifact.${v}`)}
</button>
))}
</div>
)}
<button
type="button"
onClick={() => void copyText(path)}
title={t("chat.outline.copyPath", { path })}
aria-label={t("chat.outline.copyPath", { path })}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
data-testid="artifact-copy"
onClick={() => void copyText(content.kind === "text" ? content.text : path)}
title={t("chat.artifact.copy")}
aria-label={t("chat.artifact.copy")}
className={iconBtn}
>
<Copy className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => download(displayName(path), content)}
disabled={content.kind !== "text" && content.kind !== "image"}
title={t("chat.artifact.download")}
aria-label={t("chat.artifact.download")}
className={iconBtn}
>
<Download className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="artifact-share"
onClick={() => void share()}
disabled={sharing || content.kind === "loading" || content.kind === "none"}
title={t("chat.artifact.share")}
aria-label={t("chat.artifact.share")}
className={iconBtn}
>
{sharing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Share2 className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
data-testid="artifact-expand"
onClick={() => setFull((f) => !f)}
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
className={iconBtn}
>
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={onClose}
title={t("chat.outline.close")}
aria-label={t("chat.outline.close")}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
className={iconBtn}
>
<X className="h-4 w-4" />
</button>
</div>
</header>
<Body path={path} scope={scope} />
<Body path={path} content={content} view={view} />
</section>
);
// Full screen: over the whole page, where a pane's edges cannot clip it.
return full ? createPortal(panel, document.body) : panel;
}
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
@@ -12,7 +12,8 @@
* new lane's own live session for its slot, or an empty pane — never the
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
* tab) attaches to that tmux session only; nothing is started or resumed.
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
* A pane of a `CHAT_TOOLS` tool (Claude, Codex, omp, Cursor, Antigravity) also
* has a Chat view (`PaneChat`): the session's
* conversation laid OVER the terminal, which stays mounted and attached under
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
* left of the pane's split buttons); the Chat UI setting (Settings →
@@ -29,9 +30,7 @@ import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView";
import { PaneChat } from "./PaneChat";
import { useSessionChat } from "../../lib/chatUi";
const CHAT_TOOLS = new Set(["claude", "codex"]);
import { CHAT_TOOLS, useSessionChat } from "../../lib/chatUi";
export interface LaneConsolePaneProps {
lanes: Lane[];
+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"
+3 -3
View File
@@ -70,7 +70,7 @@ import {
} from "../../lib/paneLayout";
import { AiToolIcon } from "../AiToolIcon";
import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
import { CHAT_TOOLS, setSessionChat, useSessionChat } from "../../lib/chatUi";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
import { promptForPane } from "./RunSetup";
@@ -238,7 +238,7 @@ function PaneMenu({
const ITEM =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
/** Chat UI ⇄ terminal for a `CHAT_TOOLS` pane — this session's own view
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
* unsent prompt goes along: the terminal's input box into the chat's draft,
* the draft (its files as paths) back into the input box, never sent. */
@@ -254,7 +254,7 @@ export function showTerminal(runId: string, sessionId: string, tool?: string) {
function ChatToggle({ run }: { run?: RunHandle }) {
const { t } = useTranslation("lanes");
const chat = useSessionChat(run?.sessionId);
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null;
if (!run?.sessionId || !CHAT_TOOLS.has(run.tool)) return null;
const sessionId = run.sessionId;
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
return (
+18 -1
View File
@@ -65,7 +65,12 @@ import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard";
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
import {
MENU_CURSOR_ROW,
notePaneFocus,
notePaneOutput,
registerPane,
} from "../../lib/panePaste";
import {
claudePastes,
droppedFilePaths,
@@ -617,6 +622,7 @@ export const TerminalView = memo(function TerminalView({
}
}
term.write(data);
notePaneOutput(runId);
}
};
sock.onclose = (e?: CloseEvent) => {
@@ -779,6 +785,17 @@ export const TerminalView = memo(function TerminalView({
rows.push(row);
}
return { rows, cols: term.cols };
},
// Plain text of the rows on screen, dim cells kept (a status line's
// `esc to interrupt` is dim): the chat's busy read.
() => {
const term = termRef.current;
if (!term) return [];
const buf = term.buffer.active;
const rows: string[] = [];
for (let y = 0; y < term.rows; y++)
rows.push(buf.getLine(buf.baseY + y)?.translateToString(true) ?? "");
return rows;
}
),
[runId]
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", async (importOriginal) => {
}),
gitRawUrl: () => "",
},
artifacts: {
pinFile: vi.fn().mockResolvedValue({ item: { id: 1 } }),
uploadFile: vi.fn().mockResolvedValue({ item: { id: 2 } }),
},
sessions: {
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
readFile: vi.fn((_id: string, p: string) =>
@@ -75,7 +79,11 @@ const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
describe("FilePreview", () => {
it("reads a workspace file through the lane", async () => {
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
expect(
await screen.findByText(
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
)
).toBeTruthy();
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
});
@@ -89,7 +97,11 @@ describe("FilePreview", () => {
it("reads Windows paths the same way", async () => {
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
expect(
await screen.findByText(
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
)
).toBeTruthy();
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
cleanup();
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
@@ -144,7 +156,11 @@ describe("FileListModal", () => {
);
expect(screen.getByText("No preview for this file here")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
expect(
await screen.findByText(
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
)
).toBeTruthy();
});
});
@@ -172,3 +188,77 @@ describe("SkillListModal", () => {
expect(screen.getByText("No preview for this file here")).toBeTruthy();
});
});
describe("the artifact view", () => {
const html = (lines: string[]) =>
vi.mocked(api.lanes.gitFile).mockResolvedValueOnce({
available: true,
path: "page.html",
rev: "work",
binary: false,
truncated: false,
lines,
} as never);
it("runs a page in a sandbox with scripts but no same origin, and shows its code", async () => {
html(["<button>go</button>"]);
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
const frame = (await screen.findByTestId("artifact-frame")) as HTMLIFrameElement;
expect(frame.getAttribute("sandbox")).toContain("allow-scripts");
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
expect(frame.getAttribute("srcdoc")).toContain("<button>go</button>");
fireEvent.click(screen.getByTestId("artifact-tab-code"));
expect(screen.queryByTestId("artifact-frame")).toBeNull();
expect(
await screen.findByText(
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("<button>go</button>")
)
).toBeTruthy();
cleanup();
});
it("steps back to an older version the conversation wrote", async () => {
html(["<p>two</p>"]);
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
type: "assistant" as const,
timestamp: null,
content: [{ type: "tool_use" as const, id, name, input }],
});
const messages = [
tool("1", { file_path: "/w/page.html", content: "<p>one</p>" }),
tool("2", { file_path: "/w/page.html", old_string: "one", new_string: "two" }, "Edit"),
];
render(
<FilePreview
path="/w/page.html"
scope={scope}
onClose={() => {}}
messages={messages as never}
/>
);
const versions = await screen.findByTestId("artifact-versions");
expect(versions.textContent).toContain("2");
expect(
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
).toContain("two");
fireEvent.click(versions.querySelector("button")!);
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>");
cleanup();
});
it("saves a workspace file to the Artifacts library by its path", async () => {
html(["<p>x</p>"]);
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
await screen.findByTestId("artifact-frame");
fireEvent.click(screen.getByTestId("artifact-share"));
await vi.waitFor(() =>
expect(api.artifacts.pinFile).toHaveBeenCalledWith({
laneId: 4,
path: "page.html",
title: "page.html",
sessionId: "s1",
})
);
cleanup();
});
});
@@ -7,9 +7,11 @@
* activity panel, collapsed into a file button with the output count, whose
* files open a preview in its place; a file in a turn's "Edited N files" card
* opens in the Workspace's Changes panel when it is in the pane's folder; and
* Stop in Send's place while the main agent works, pressing Esc in the pane;
* Stop in Send's place while the main agent works (its hooks, or the pane's
* own busy line / fresh output), pressing Esc in the pane;
* and the menu the CLI asks with, shown as buttons that press its keys, a
* message sent meanwhile typed as its "Type something" answer.
* message sent meanwhile typed as its "Type something" answer; a Codex
* terminal's Model / Permission mode buttons typing its own pickers.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -22,11 +24,14 @@ import {
answerRunMenu,
chooseRunOption,
interruptRun,
onPaneOutput,
readRunBusy,
readRunMenu,
submitToRun,
type PaneMenu,
} from "../../../lib/panePaste";
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
import { eventBus } from "../../../lib/eventBus";
vi.mock("../../conversation/ConversationView", () => ({
// Stands in for a turn's "Edited N files" card: a click names a file.
@@ -51,8 +56,12 @@ vi.mock("../../../lib/panePaste", () => ({
submitToRun: vi.fn(() => true),
interruptRun: vi.fn(() => true),
readRunMenu: vi.fn(() => null),
readRunBusy: vi.fn(() => false),
chooseRunOption: vi.fn(() => true),
answerRunMenu: vi.fn(() => true),
stepRunMenu: vi.fn(() => true),
notePaneFocus: vi.fn(),
onPaneOutput: vi.fn(() => () => {}),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -212,6 +221,54 @@ describe("PaneChat — Stop", () => {
}
});
it("shows Stop while the pane's own busy line shows, with no hook saying so", async () => {
// omp, Cursor or Codex without its hooks: only the terminal knows.
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.mocked(readRunBusy).mockReturnValue(true);
try {
renderChat();
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
expect(readRunBusy).toHaveBeenCalledWith("agenthub-lane-1", "claude");
vi.mocked(readRunBusy).mockReturnValue(false);
await act(async () => {
await vi.advanceTimersByTimeAsync(800);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
vi.mocked(readRunBusy).mockReturnValue(false);
}
});
it("counts a tool with no known busy line as working while its pane draws", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.mocked(readRunBusy).mockReturnValue(null);
const fns: (() => void)[] = [];
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
fns.push(fn);
return () => {};
});
try {
renderChat();
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
fns.forEach((fn) => fn());
await act(async () => {
await vi.advanceTimersByTimeAsync(800);
});
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
// Quiet for longer than OUTPUT_BUSY_MS: at rest again.
await act(async () => {
await vi.advanceTimersByTimeAsync(2800);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
vi.mocked(readRunBusy).mockReturnValue(false);
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
}
});
it("keeps Send for a session at rest", async () => {
// With session_id the server answers EVERY agent of the session (its
// status filter does not apply): the main agent is there, waiting.
@@ -553,4 +610,103 @@ describe("PaneChat — a menu the CLI asks with", () => {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
it("re-reads its transcript when the pane draws, at most once a second", async () => {
// Every listener the chat put on the pane (the re-read, the busy clock).
const fns: (() => void)[] = [];
const drew = () => fns.forEach((fn) => fn());
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
fns.push(fn);
return () => {};
});
const seen: unknown[] = [];
const off = eventBus.subscribe((m) => {
if (m.type === "transcript_changed") seen.push(m.data);
});
try {
renderChat();
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
vi.useFakeTimers();
drew();
drew();
drew();
expect(seen).toEqual([{ session_id: "s1" }]);
await act(async () => {
await vi.advanceTimersByTimeAsync(1100);
});
expect(seen).toHaveLength(2);
} finally {
vi.useRealTimers();
off();
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
}
});
const PERMISSION: PaneMenu = {
question: "Do you want to proceed?",
detail: ["Bash command", "npm test", "Run the tests"],
options: [
{ n: 1, label: "Yes", hint: "", checked: null },
{ n: 2, label: "Yes, and don't ask again for npm test", hint: "", checked: null },
{ n: 3, label: "No, and tell Claude what to do differently (esc)", hint: "", checked: null },
],
cursor: 0,
multi: false,
typeAt: -1,
};
it("lays a permission prompt out as a card, each answer its own key", async () => {
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
vi.mocked(chooseRunOption).mockClear();
try {
renderChat();
const card = await screen.findByTestId("pane-chat-permission");
expect(card).toHaveTextContent("Bash command");
expect(card).toHaveTextContent("npm test");
expect(card).toHaveTextContent("Do you want to proceed?");
const options = screen.getAllByTestId("pane-chat-permission-option");
// The key hint is dropped from the label.
expect(options.map((o) => o.textContent)).toEqual([
"Yes",
"Yes, and don't ask again for npm test",
"No, and tell Claude what to do differently",
]);
fireEvent.click(options[1]!);
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 1);
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
it("denies with a note: picks the No row, then sends the note as the next message", async () => {
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
vi.mocked(chooseRunOption).mockClear();
vi.mocked(submitToRun).mockClear();
try {
renderChat();
fireEvent.click(await screen.findByTestId("pane-chat-permission-tell"));
const field = screen.getByTestId("pane-chat-permission-note");
fireEvent.change(field, { target: { value: "use pnpm" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 2);
await waitFor(() => expect(submitToRun).toHaveBeenCalled(), { timeout: 2000 });
expect(vi.mocked(submitToRun).mock.calls[0]![1]).toContain("use pnpm");
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
});
describe("PaneChat — a Codex terminal", () => {
it("types /model and /permissions, whose pickers come back as menu cards", () => {
renderChat({ tool: "codex" });
fireEvent.click(screen.getByTestId("pane-chat-codex-model"));
fireEvent.click(screen.getByTestId("pane-chat-codex-permissions"));
expect(vi.mocked(submitToRun).mock.calls.map((c) => c[1])).toEqual(["/model", "/permissions"]);
});
it("offers neither to a Claude pane", () => {
renderChat();
expect(screen.queryByTestId("pane-chat-codex-model")).toBeNull();
});
});
+23 -1
View File
@@ -89,6 +89,7 @@
"findClose": "Close find"
},
"chat": {
"codexPicker": "Opens Codex's own picker — answer it in the chat",
"placeholder": "Write a message…",
"send": "Send",
"notSent": "The terminal is not connected — the message was not sent.",
@@ -139,7 +140,28 @@
},
"menu": {
"placeholder": "Type your answer…",
"cancel": "Cancel (Esc)"
"cancel": "Cancel (Esc)",
"prev": "Previous question",
"next": "Next question"
},
"request": {
"notePlaceholder": "Tell the model what to do instead…",
"send": "Send",
"denyWithNote": "Deny with a note"
},
"artifact": {
"preview": "Preview",
"code": "Code",
"copy": "Copy",
"download": "Download",
"share": "Save to Artifacts",
"shared": "Saved to your Artifacts library — share it from the Artifacts page.",
"cannotShare": "This file cannot be saved to Artifacts.",
"expand": "Full screen",
"collapse": "Exit full screen",
"older": "Older version",
"newer": "Newer version",
"version": "Version {{n}} of {{total}}"
}
},
"interrupted": {
+9
View File
@@ -230,6 +230,15 @@
"stopped": "stopped"
},
"hide": "Hide conversation"
},
"artifact": {
"html": "Web page",
"svg": "Image · SVG",
"markdown": "Document",
"mermaid": "Diagram",
"react": "React component",
"csv": "Table",
"open": "Click to open"
}
},
"overview": {
+2 -2
View File
@@ -1302,9 +1302,9 @@
},
"chatUi": {
"title": "Chat UI",
"description": "How a new Claude or Codex session opens in its workspace. Saved for your account only.",
"description": "How a new Claude, Codex, omp, Cursor or Antigravity session opens in its workspace. Saved for your account only.",
"label": "Open new sessions as a chat",
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
"labelDesc": "A new session first shows its conversation like Claude Desktop, with a message box, instead of its terminal (the terminal stays underneath). Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and questions are answered from cards in the chat.",
"saveFailed": "Could not save: {{message}}"
},
"sessionDefaults": {
+23 -1
View File
@@ -89,6 +89,7 @@
"findClose": "Đóng tìm kiếm"
},
"chat": {
"codexPicker": "Mở bộ chọn của chính Codex — trả lời ngay trong chat",
"placeholder": "Viết tin nhắn…",
"send": "Gửi",
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
@@ -139,7 +140,28 @@
},
"menu": {
"placeholder": "Nhập câu trả lời của bạn…",
"cancel": "Huỷ (Esc)"
"cancel": "Huỷ (Esc)",
"prev": "Câu trước",
"next": "Câu tiếp"
},
"request": {
"notePlaceholder": "Nói cho model biết nên làm gì thay vào đó…",
"send": "Gửi",
"denyWithNote": "Từ chối kèm lời nhắn"
},
"artifact": {
"preview": "Xem trước",
"code": "Mã",
"copy": "Sao chép",
"download": "Tải xuống",
"share": "Lưu vào Artifacts",
"shared": "Đã lưu vào thư viện Artifacts của bạn — chia sẻ từ trang Artifacts.",
"cannotShare": "Không lưu được file này vào Artifacts.",
"expand": "Toàn màn hình",
"collapse": "Thoát toàn màn hình",
"older": "Phiên bản cũ hơn",
"newer": "Phiên bản mới hơn",
"version": "Phiên bản {{n}}/{{total}}"
}
},
"interrupted": {
+9
View File
@@ -230,6 +230,15 @@
"stopped": "đã dừng"
},
"hide": "Ẩn hội thoại"
},
"artifact": {
"html": "Trang web",
"svg": "Ảnh · SVG",
"markdown": "Tài liệu",
"mermaid": "Sơ đồ",
"react": "Component React",
"csv": "Bảng",
"open": "Bấm để mở"
}
},
"overview": {
+2 -2
View File
@@ -1302,9 +1302,9 @@
},
"chatUi": {
"title": "Giao diện chat",
"description": "Cách một session Claude hoặc Codex mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
"description": "Cách một session Claude, Codex, omp, Cursor hoặc Antigravity mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
"label": "Mở session mới dạng chat",
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
"labelDesc": "Session mới sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal (terminal vẫn chạy bên dưới). Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và câu hỏi được trả lời bằng thẻ ngay trong chat.",
"saveFailed": "Không lưu được: {{message}}"
},
"sessionDefaults": {
@@ -0,0 +1,102 @@
/**
* @file artifactView.test.ts
* @description What counts as an artifact and how it is shown: kinds by
* name, a React file only when written whole, a file's versions rebuilt from
* the conversation's Write/Edit/MultiEdit inputs (a failed call skipped, an
* edit to unseen text unknown), CSV rows, and the sandboxed documents.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import {
artifactDocument,
artifactKind,
artifactVersions,
frameSandbox,
isArtifact,
parseCsv,
} from "../artifactView";
import type { TranscriptMessage } from "../types";
const use = (id: string, name: string, input: Record<string, unknown>): TranscriptMessage => ({
type: "assistant",
timestamp: `2026-10-21T00:00:0${id}.000Z`,
content: [{ type: "tool_use", id, name, input } as TranscriptMessage["content"][number]],
});
const result = (id: string, isError = false): TranscriptMessage => ({
type: "user",
sender: "tool",
timestamp: null,
content: [
{
type: "tool_result",
id,
output: "",
is_error: isError,
} as TranscriptMessage["content"][number],
],
});
describe("artifactView", () => {
it("knows an artifact by its name", () => {
expect(artifactKind("/a/page.HTML")).toBe("html");
expect(artifactKind("d.mmd")).toBe("mermaid");
expect(artifactKind("App.tsx")).toBe("react");
expect(artifactKind("x.go")).toBe("code");
expect(isArtifact("report.md", false)).toBe(true);
expect(isArtifact("App.tsx", false)).toBe(false);
expect(isArtifact("App.tsx", true)).toBe(true);
expect(isArtifact("server.js", true)).toBe(false);
});
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
const msgs = [
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
result("1"),
use("2", "Edit", { file_path: "/w/a.md", old_string: "old", new_string: "new" }),
result("2"),
use("3", "Edit", { file_path: "/w/a.md", old_string: "missing", new_string: "x" }),
result("3", true),
use("4", "MultiEdit", {
file_path: "/w/a.md",
edits: [{ old_string: "# A", new_string: "# B" }],
}),
result("4"),
use("5", "Write", { file_path: "/w/other.md", content: "no" }),
];
const v = artifactVersions(msgs, "/w/a.md");
expect(v.map((x) => x.content)).toEqual([
"# A\nold line\n",
"# A\nnew line\n",
"# B\nnew line\n",
]);
});
it("says an edit to text it never saw is of unknown content", () => {
const v = artifactVersions(
[use("1", "Edit", { file_path: "a.md", old_string: "a", new_string: "b" })],
"a.md"
);
expect(v).toHaveLength(1);
expect(v[0]!.content).toBeNull();
});
it("reads CSV with quotes and newlines inside them", () => {
expect(parseCsv('a,b\n"x, y","say ""hi""\nthere"\n')).toEqual([
["a", "b"],
["x, y", 'say "hi"\nthere'],
]);
});
it("builds sandboxed documents that keep the source inert inside a script", () => {
const doc = artifactDocument("react", "export default () => <p>a</script>b</p>", false)!;
expect(doc).toContain("babel");
expect(doc).not.toContain("a</script>b");
expect(artifactDocument("mermaid", "graph TD; A-->B<img>", true)).toContain(
"A--&gt;B&lt;img&gt;"
);
expect(artifactDocument("code", "x", false)).toBeNull();
expect(frameSandbox("svg")).toBe("");
expect(frameSandbox("html")).not.toContain("allow-same-origin");
});
});
@@ -21,6 +21,7 @@ import {
chooseRunOption,
parsePaneMenu,
pasteToLane,
readRunBusy,
readRunMenu,
registerPane,
takeRunInput,
@@ -160,6 +161,33 @@ describe("panePaste", () => {
expect(m.typeAt).toBe(2);
});
// Several questions: the unnumbered row is Next until the last one, and the
// tab bar says which questions have an answer.
it("reads a multi-question multiSelect with its Next row and question tabs", () => {
const m = parsePaneMenu(MULTI_Q)!;
expect(m.options.map((o) => [o.n, o.label, o.checked])).toEqual([
[1, "JWT HS256 + default secret", true],
[2, "Product code issue 2 lần", true],
[3, "Type something", false],
[null, "Next", null],
[4, "Chat about this", null],
]);
expect(m.options[2]!.hint).toBe("");
expect(m.cursor).toBe(0);
expect(m.tabs).toEqual([
{ label: "Critical", done: true },
{ label: "High 1/3", done: false },
{ label: "High 2/3", done: false },
{ label: "High 3/3", done: false },
]);
expect(m.tab).toBe(0);
expect(MENU_CURSOR_ROW.test("❯ Next")).toBe(true);
});
it("reads no tabs for a single question", () => {
expect(parsePaneMenu(ASK)!.tabs).toBeUndefined();
});
// The "Type something" row once something is typed in it: its label is the
// text. It is still the row right above "Chat about this".
it("finds Type something by its place once text is typed in it, and replaces that text", () => {
@@ -188,8 +216,11 @@ describe("panePaste", () => {
const c = parsePaneMenu(PERMISSION)!;
expect(c.question).toBe("Do you want to proceed?");
expect(c.options.map((o) => o.label)).toEqual(["Yes", "Yes, and always allow", "No"]);
// The tool and its command, drawn over the dashed rule.
expect(c.detail).toEqual(["Bash command", "touch probe-file.txt"]);
const x = parsePaneMenu(CODEX_APPROVAL)!;
expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt");
expect(x.detail).toBeUndefined();
expect(x.options).toHaveLength(3);
expect(x.cursor).toBe(0);
});
@@ -317,6 +348,23 @@ const MULTI = [
"",
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
];
const MULTI_Q = [
RULE,
"← ☒ Critical □ High 1/3 □ High 2/3 □ High 3/3 ✔ Submit →",
"",
"Critical (đã verify trong code). Chọn issue để log BUG:",
"",
"❯ 1. [✔] JWT HS256 + default secret",
" pkg/middleware/auth.go:156,184 — HMAC only",
" 2. [✔] Product code issue 2 lần",
" gift product_code_repository.go:57 — không FOR UPDATE",
" 3. [ ] Type something",
" Next",
RULE,
" 4. Chat about this",
"",
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
];
const PERMISSION = [
" Bash command",
" touch probe-file.txt",
@@ -378,3 +426,51 @@ describe("MENU_CURSOR_ROW", () => {
expect(has(CODEX)).toBe(false);
});
});
describe("readRunBusy", () => {
it("reads each tool's own busy line, and nothing for a tool it does not know", () => {
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
// Claude's permission footer is not a turn at work.
rows = ["Esc to cancel · Tab to amend"];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(false);
rows = ["Thinking… (3s · esc to cancel)"];
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(true);
expect(readRunBusy("agenthub-lane-8", "omp")).toBeNull();
off();
expect(readRunBusy("agenthub-lane-8", "claude")).toBeNull();
});
});
describe("Codex's pickers", () => {
it("reads /permissions with each row's description column as its hint", () => {
const menu = parsePaneMenu([
" >_ OpenAI Codex (v0.163.0)",
"",
" Update Model Permissions",
"",
"› 1. Ask for approval (current) Read and edit workspace files and run commands, with approval req",
" access or edits outside the workspace",
" 2. Approve for me Only ask for actions detected as potentially unsafe",
" 3. Full Access Use with caution: Codex can edit files",
" without approval",
" 4. Read Only Read workspace files",
"",
" enter select · esc back",
]);
expect(menu?.question).toBe("Update Model Permissions");
expect(menu?.cursor).toBe(0);
expect(menu?.options.map((o) => [o.n, o.label, o.hint])).toEqual([
[
1,
"Ask for approval (current)",
"Read and edit workspace files and run commands, with approval req access or edits outside the workspace",
],
[2, "Approve for me", "Only ask for actions detected as potentially unsafe"],
[3, "Full Access", "Use with caution: Codex can edit files without approval"],
[4, "Read Only", "Read workspace files"],
]);
});
});
+227
View File
@@ -0,0 +1,227 @@
/**
* @file artifactView.ts
* @description What makes a file an artifact, Claude Desktop's way, and how
* one is shown: its kind from its name (a page, an image, a diagram, a React
* component, a document, a table, code), the sandboxed document an HTML,
* SVG, Mermaid or React artifact runs in (an iframe `srcdoc` with no
* same-origin access, its libraries from a pinned CDN build, as Claude's own
* artifacts load theirs), a CSV's rows, and the versions a conversation's
* Write/Edit calls left of one file — rebuilt from their own inputs, so no
* server keeps copies.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { TranscriptMessage } from "./types";
export type ArtifactKind =
"html" | "svg" | "markdown" | "mermaid" | "react" | "csv" | "image" | "code";
const EXT = (p: string) =>
(/\.([A-Za-z0-9]+)$/.exec(p.split(/[\\/]/).pop() || "")?.[1] || "").toLowerCase();
/** The kind a file shows as. */
export function artifactKind(path: string): ArtifactKind {
const e = EXT(path);
if (e === "html" || e === "htm") return "html";
if (e === "svg") return "svg";
if (e === "md" || e === "markdown" || e === "mdx") return "markdown";
if (e === "mmd" || e === "mermaid") return "mermaid";
if (e === "jsx" || e === "tsx") return "react";
if (e === "csv" || e === "tsv") return "csv";
if (["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico"].includes(e)) return "image";
return "code";
}
/** Kinds with a rendered Preview (the rest show their code only). */
export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
"html",
"svg",
"markdown",
"mermaid",
"react",
"csv",
"image",
]);
/** An edit worth a card in the conversation: a page, a document, a diagram, a
* table — and a React component the model wrote whole (a `.tsx` it merely
* edited is project code, not an artifact). */
export function isArtifact(path: string, created: boolean): boolean {
const k = artifactKind(path);
if (k === "react") return created;
return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
}
/** A code fence language for the Code tab. */
export function artifactLang(path: string): string {
const e = EXT(path);
const map: Record<string, string> = {
htm: "html",
mmd: "text",
mermaid: "text",
md: "markdown",
mdx: "markdown",
jsx: "jsx",
tsx: "tsx",
js: "javascript",
mjs: "javascript",
cjs: "javascript",
ts: "typescript",
py: "python",
sh: "bash",
yml: "yaml",
};
return map[e] || e;
}
// Pinned builds (all on npm for well over two weeks), as Claude's own
// artifacts load React, Tailwind and Mermaid from a CDN.
const CDN = {
react: "https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js",
reactDom: "https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js",
babel: "https://cdn.jsdelivr.net/npm/@babel/standalone@7.26.4/babel.min.js",
tailwind: "https://cdn.tailwindcss.com/3.4.16",
mermaid: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs",
};
/** Text safe inside a `<script>` element. */
const scriptSafe = (s: string) => s.replace(/<\/(script)/gi, "<\\/$1");
const htmlEscape = (s: string) =>
s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
/** Shown inside the frame when the artifact itself fails. */
const ERROR_BOX = `<pre id="__err" style="display:none;margin:16px;padding:12px;border-radius:8px;background:#fee2e2;color:#991b1b;font:12px/1.5 ui-monospace,monospace;white-space:pre-wrap"></pre>
<script>window.addEventListener("error",function(e){var b=document.getElementById("__err");if(b){b.style.display="block";b.textContent=String(e.message||e.error||e);}});</script>`;
/** The document a rendered artifact runs in (null: it is not a frame kind). */
export function artifactDocument(kind: ArtifactKind, source: string, dark: boolean): string | null {
switch (kind) {
case "html":
return /<html[\s>]|<!doctype/i.test(source)
? source
: `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${source}</body></html>`;
case "svg":
return `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}body{display:flex;align-items:center;justify-content:center;background:${dark ? "#1f1f1f" : "#fff"}}svg{max-width:100%;max-height:100vh;height:auto}</style></head><body>${source}</body></html>`;
case "mermaid":
return `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;padding:16px;background:${dark ? "#1f1f1f" : "#fff"};display:flex;justify-content:center}</style></head><body>${ERROR_BOX}<pre class="mermaid">${htmlEscape(source)}</pre>
<script type="module">import mermaid from "${CDN.mermaid}";mermaid.initialize({startOnLoad:false,theme:${dark ? '"dark"' : '"default"'},securityLevel:"strict"});mermaid.run().catch(function(e){var b=document.getElementById("__err");b.style.display="block";b.textContent=String(e&&e.message||e);});</script></body></html>`;
case "react":
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<script src="${CDN.react}"></script><script src="${CDN.reactDom}"></script><script src="${CDN.babel}"></script><script src="${CDN.tailwind}"></script></head>
<body><div id="root"></div>${ERROR_BOX}
<script>
(function(){
var src = ${JSON.stringify(scriptSafe(source))};
var mods = { react: window.React, "react-dom": window.ReactDOM, "react-dom/client": window.ReactDOM };
function req(name){ if (mods[name]) return mods[name]; throw new Error("This preview does not provide \\"" + name + "\\" — only react and react-dom are available."); }
try {
var out = Babel.transform(src, { filename: "Artifact.tsx", presets: [["typescript", { isTSX: true, allExtensions: true }], "react"], plugins: ["transform-modules-commonjs"] }).code;
var module = { exports: {} };
new Function("require", "module", "exports", "React", out)(req, module, module.exports, window.React);
var C = module.exports.default || module.exports.App || Object.values(module.exports).find(function(v){ return typeof v === "function"; });
if (!C) throw new Error("No component exported: export a default React component.");
ReactDOM.createRoot(document.getElementById("root")).render(React.createElement(C));
} catch (e) { var b = document.getElementById("__err"); b.style.display = "block"; b.textContent = String(e && e.message || e); }
})();
</script></body></html>`;
default:
return null;
}
}
/** The sandbox a frame kind gets: scripts but never the dashboard's origin
* (no `allow-same-origin`), and none at all for an SVG. */
export const frameSandbox = (kind: ArtifactKind) =>
kind === "svg" ? "" : "allow-scripts allow-forms allow-modals allow-popups allow-downloads";
/** A CSV/TSV's rows (quoted fields, doubled quotes, newlines inside quotes). */
export function parseCsv(text: string, sep = ","): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (quoted) {
if (c === '"' && text[i + 1] === '"') {
field += '"';
i++;
} else if (c === '"') quoted = false;
else field += c;
} else if (c === '"' && field === "") quoted = true;
else if (c === sep) {
row.push(field);
field = "";
} else if (c === "\n" || c === "\r") {
if (c === "\r" && text[i + 1] === "\n") i++;
row.push(field);
rows.push(row);
row = [];
field = "";
} else field += c;
}
if (field !== "" || row.length) {
row.push(field);
rows.push(row);
}
return rows;
}
/** One version of a file, as a Write/Edit call left it. */
export interface ArtifactVersion {
/** The call that made it. */
toolUseId: string | null;
timestamp: string | null;
/** null: the call is known, its result text is not (an edit to a file
* whose earlier text the conversation never showed). */
content: string | null;
}
const samePath = (a: unknown, b: string) =>
typeof a === "string" && a.replace(/\\/g, "/") === b.replace(/\\/g, "/");
/** Applies one Edit (old → new, once or everywhere) to known text. */
function applyEdit(text: string, oldS: unknown, newS: unknown, all: unknown): string | null {
if (typeof oldS !== "string" || typeof newS !== "string") return null;
if (!text.includes(oldS)) return null;
return all ? text.split(oldS).join(newS) : text.replace(oldS, () => newS);
}
/**
* The versions of `path` in a conversation, oldest first: each successful
* Write gives its whole text, and each Edit/MultiEdit after a known text gives
* that text with its edits applied. A call whose input the transcript cut, or
* an edit to text never shown, is a version of unknown content (the current
* file on disk stands in for the newest one).
*/
export function artifactVersions(messages: TranscriptMessage[], path: string): ArtifactVersion[] {
const failed = new Set<string>();
for (const m of messages)
for (const c of m.content) if (c.type === "tool_result" && c.is_error && c.id) failed.add(c.id);
const out: ArtifactVersion[] = [];
let text: string | null = null;
for (const m of messages) {
if (m.type !== "assistant") continue;
for (const c of m.content) {
if (c.type !== "tool_use" || (c.id && failed.has(c.id))) continue;
const input = (c.input || {}) as Record<string, unknown>;
if (!samePath(input.file_path, path)) continue;
if (c.name === "Write") {
text = typeof input.content === "string" ? input.content : null;
} else if (c.name === "Edit") {
text =
text === null
? null
: applyEdit(text, input.old_string, input.new_string, input.replace_all);
} else if (c.name === "MultiEdit") {
const edits = Array.isArray(input.edits) ? (input.edits as Record<string, unknown>[]) : [];
for (const e of edits) {
if (text === null) break;
text = applyEdit(text, e.old_string, e.new_string, e.replace_all);
}
} else continue;
out.push({ toolUseId: c.id ?? null, timestamp: m.timestamp, content: text });
}
}
return out;
}
+16 -2
View File
@@ -1,6 +1,7 @@
/**
* @file chatUi.ts — whether a Claude/Codex session shows as a chat
* (`PaneChat`, like Claude Desktop) or as its terminal. Each SESSION has its
* @file chatUi.ts — which tools' terminal panes have a Chat view
* (`CHAT_TOOLS`), and whether such a session shows as a chat (`PaneChat`,
* like Claude Desktop) or as its terminal. Each SESSION has its
* own view (`useSessionChat`, toggled by `setSessionChat` from the pane's
* chat/terminal button), kept in this browser's localStorage
* (`agenthub.chatUi.sessions`, the newest `MAX_VIEWS`). The account's Chat UI
@@ -18,6 +19,19 @@ import { api } from "./api";
export const CHAT_UI_KEY = "agenthub.chatUi";
/** The tools whose terminal pane can show a chat: each one's pane learns its
* session id (Claude's `--session-id`, the others' pollers through
* `claimToolSessions`) and the server reads that session's conversation
* (`GET /api/sessions/:id/transcript`). omp and Cursor send no hooks, so
* their chat has no Stop button and follows at the pollers' pace. */
export const CHAT_TOOLS: ReadonlySet<string> = new Set([
"claude",
"codex",
"omp",
"cursor",
"antigravity",
]);
export const VIEWS_KEY = "agenthub.chatUi.sessions";
const MAX_VIEWS = 200;
+115 -11
View File
@@ -34,20 +34,24 @@ interface Pane {
key?: (data: string) => void;
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
screen?: (menuOnly?: boolean) => PaneScreen | null;
/** The rows on screen as plain text, dim cells kept. */
lines?: () => string[];
}
const panes = new Map<string, Pane>();
let lastFocused: string | null = null;
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
* raw input, as a key press; `screen`: its rows now); returns its unregister. */
* raw input, as a key press; `screen`: its rows now; `lines`: the same as
* plain text); returns its unregister. */
export function registerPane(
runId: string,
paste: (text: string, submit?: boolean) => void,
visible: () => boolean = () => true,
key?: (data: string) => void,
screen?: (menuOnly?: boolean) => PaneScreen | null
screen?: (menuOnly?: boolean) => PaneScreen | null,
lines?: () => string[]
): () => void {
const entry = { paste, visible, key, screen };
const entry = { paste, visible, key, screen, lines };
panes.set(runId, entry);
return () => {
if (panes.get(runId) === entry) panes.delete(runId);
@@ -59,6 +63,26 @@ export function notePaneFocus(runId: string): void {
lastFocused = runId;
}
/** Who wants to hear that a pane drew something (its chat: the CLI is doing
* something, so its transcript may have grown). */
const outputListeners = new Map<string, Set<() => void>>();
/** A terminal received output for `runId` (TerminalView, every PTY frame). */
export function notePaneOutput(runId: string): void {
outputListeners.get(runId)?.forEach((fn) => fn());
}
/** Calls `fn` on every output `runId`'s terminal receives; returns its unregister. */
export function onPaneOutput(runId: string, fn: () => void): () => void {
let set = outputListeners.get(runId);
if (!set) outputListeners.set(runId, (set = new Set()));
set.add(fn);
return () => {
set!.delete(fn);
if (!set!.size) outputListeners.delete(runId);
};
}
/** The tmux names of workspace `laneId`'s panes: `agenthub-lane-<id>[-slot]`. */
function ofLane(runId: string, laneId: number): boolean {
const base = `agenthub-lane-${laneId}`;
@@ -162,32 +186,47 @@ export function takeRunInput(runId: string): string {
}
/** One choice of a menu the CLI shows: `n` its number (null for Claude's
* unnumbered Submit row), `checked` a multiSelect box's state. */
* unnumbered Submit / Next row), `checked` a multiSelect box's state. */
export type PaneMenuOption = {
n: number | null;
label: string;
hint: string;
checked: boolean | null;
};
/** One question tab of an AskUserQuestion that asks several: `done` once it
* has an answer (`☒`). */
export type PaneMenuTab = { label: string; done: boolean };
/** A menu the CLI asks with (AskUserQuestion, a permission or trust prompt,
* Codex's approval): `cursor` the row its `❯`/`›` is on, `typeAt` the
* "Type something" row (-1: none). */
* "Type something" row (-1: none). `tabs` only when it asks several
* questions (`← ☒ A □ B ✔ Submit →`), `tab` the one shown (-1: unknown).
* `detail` what a permission prompt asks about (its tool, then the command),
* when the screen shows it apart from the question. */
export type PaneMenu = {
question: string;
options: PaneMenuOption[];
cursor: number;
multi: boolean;
typeAt: number;
tabs?: PaneMenuTab[];
tab?: number;
detail?: string[];
};
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
/** A row only a menu shows: its cursor on a numbered option or Submit, or the
* "Enter to confirm" footer of Claude's unnumbered menus (the trust prompt). */
export const MENU_CURSOR_ROW = /^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)|Enter to confirm)/;
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
export const MENU_CURSOR_ROW =
/^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |(?:Submit|Next)\s*$)|Enter to confirm)/;
// The unnumbered button under a multiSelect's rows: Next while more questions
// follow, Submit on the last one.
const SUBMIT = /^ *([❯›])?[ \u00a0]*(Submit|Next)$/;
const TAB_BAR = /^ *← +(.+?) +→ *$/;
const RULE_ROW = /^ *[─╌]{3,} *$/;
const DASHED_ROW = /^ *╌{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/;
const PLACEHOLDER = /^Type something\.?$/;
const COLUMN = /^(\S.*?\S) {2,}(\S.*)$/;
/** The numbered menu at the bottom of `rows`, or null. A menu has two
* numbered rows or more from 1, the cursor on one of them, and no rule right
@@ -207,10 +246,13 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
const m = OPTION.exec(row);
const s = m ? null : SUBMIT.exec(row);
if (m || s) {
const text = m ? (m[3] ?? "") : "Submit";
const text = m ? (m[3] ?? "") : (s?.[2] ?? "Submit");
const c = m ? CHECK.exec(text) : null;
const label = c ? (c[2] ?? "") : text;
const h = hint.join(" ");
// Codex's pickers (`/model`, `/permissions`) draw each row's
// description in a column after the name: split it off as the hint.
const col = COLUMN.exec(c ? (c[2] ?? "") : text);
const label = col ? col[1]! : c ? (c[2] ?? "") : text;
const h = [col?.[2], ...hint].filter(Boolean).join(" ");
options.unshift({
n: m ? Number(m[2]) : null,
label,
@@ -239,15 +281,47 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
const lines: string[] = [];
for (; q >= 0 && r[q] && !RULE_ROW.test(r[q]!) && lines.length < 4; q--)
lines.unshift(r[q]!.trim());
const question = lines.join("\n");
const tabs = tabBar(r.slice(Math.max(0, q - 4), Math.max(0, q + 1)));
const low = question.toLowerCase();
// What a permission prompt asks about: Claude draws the tool and its
// command over a dashed rule above the question, up to the dialog's top rule.
const detail: string[] = [];
let d = q;
while (d >= 0 && !r[d]) d--;
if (d >= 0 && DASHED_ROW.test(r[d]!))
for (d--; d >= 0 && !RULE_ROW.test(r[d]!) && detail.length < 12; d--)
if (r[d]) detail.unshift(r[d]!.trim());
return {
question: lines.join("\n"),
question,
options,
cursor: options.length - cursor,
multi: options.some((o) => o.checked !== null),
typeAt: typeRow(options),
...(tabs.length > 1
? { tabs, tab: tabs.findIndex((t) => low.startsWith(t.label.toLowerCase())) }
: {}),
...(detail.length ? { detail } : {}),
};
}
/** The question tabs of a multi-question AskUserQuestion, read off its bar
* (`← ☒ Critical □ High 1/3 ✔ Submit →`) among `rows`; the Submit tab is
* no question. [] when there is no bar. */
function tabBar(rows: string[]): PaneMenuTab[] {
for (let i = rows.length - 1; i >= 0; i--) {
const m = TAB_BAR.exec(rows[i] ?? "");
if (!m) continue;
return (m[1] ?? "")
.split(/(?=[☒☐□✔☑■] )/)
.map((x) => x.trim())
.filter(Boolean)
.map((x) => ({ label: x.slice(1).trim(), done: /^[☒✔☑■]/.test(x) }))
.filter((t) => t.label && t.label !== "Submit");
}
return [];
}
const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/;
/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt:
@@ -291,6 +365,26 @@ function typeRow(options: PaneMenuOption[]): number {
return -1;
}
/** The status line a tool's TUI shows only while a turn runs, read off its
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A
* tool missing here (omp, Cursor) has no line we know. */
export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
claude: /\besc to interrupt\b/i,
codex: /\besc to interrupt\b/i,
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
};
/** Whether `runId`'s pane shows `tool` at work now (its BUSY_LINE), or null
* when the tool's line is unknown or the pane is not mounted. */
export function readRunBusy(runId: string, tool: string): boolean | null {
const re = BUSY_LINE[tool];
const p = panes.get(runId);
if (!re || !p?.lines) return null;
return p.lines().some((l) => re.test(l));
}
/** The menu `runId`'s visible pane shows now, or null. */
export function readRunMenu(runId: string): PaneMenu | null {
const p = panes.get(runId);
@@ -322,6 +416,16 @@ export function chooseRunOption(runId: string, menu: PaneMenu, index: number): b
return true;
}
/** Moves a multi-question AskUserQuestion to its previous (-1) or next (1)
* question tab, the arrows Claude's footer names. False when the pane is not
* visible or not attached. */
export function stepRunMenu(runId: string, dir: -1 | 1): boolean {
const p = panes.get(runId);
if (!p?.visible() || !p.key) return false;
p.key(dir < 0 ? "\x1b[D" : "\x1b[C");
return true;
}
/** Answers `menu` with typed text: the arrows to its "Type something" row,
* Ctrl+U over what is typed there already (one key clears it, however long
* it is), the text as a bracketed
+4 -1
View File
@@ -1930,7 +1930,10 @@ export interface WSMessage {
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
| "runs_interrupted"
/** An artifact was added, changed, shared or deleted; refetch the list. */
| "artifact_changed";
| "artifact_changed"
/** A session's transcript grew on disk (`{session_id}`); a chat showing
* it reads the new lines now. */
| "transcript_changed";
/** The message body, whose concrete shape is selected by `type` above. */
data:
| Session