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:
@@ -17,7 +17,8 @@
|
||||
* 5 cache read.
|
||||
* The session's name and start come from its transcript
|
||||
* (`brain/<id>/.system_generated/logs/transcript_full.jsonl`, first
|
||||
* `USER_INPUT`). A file is re-read whole when its mtime (or its non-empty
|
||||
* `USER_INPUT`), and the same file is the conversation view's
|
||||
* (`transcriptPath`, read by `GET /api/sessions/:id/transcript`). A file is re-read whole when its mtime (or its non-empty
|
||||
* `-wal`'s) changes — they are small — and the mtime per file lives in
|
||||
* `antigravity_conversations`, so a session the user deleted stays deleted
|
||||
* until agy writes to it again. Opening read-only still lets SQLite leave an
|
||||
@@ -157,6 +158,21 @@ const transcriptFiles = (id) =>
|
||||
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
|
||||
});
|
||||
|
||||
/** The transcript of conversation `id` (this account's or a member's) the
|
||||
* conversation view reads, or null. Only a UUID names one. */
|
||||
function transcriptPath(id) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(id || "")))
|
||||
return null;
|
||||
for (const file of transcriptFiles(id)) {
|
||||
try {
|
||||
if (fs.statSync(file).isFile()) return file;
|
||||
} catch {
|
||||
/* try the next name */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The transcript's last write, 0 when it has none. */
|
||||
function transcriptMtime(id) {
|
||||
for (const file of transcriptFiles(id)) {
|
||||
@@ -531,4 +547,5 @@ module.exports = {
|
||||
readConversation,
|
||||
ingestAntigravityUsage,
|
||||
startAntigravityUsagePoll,
|
||||
transcriptPath,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
* is resolved only when exactly such a path still exists, else left empty.
|
||||
* A file is re-read when its mtime changes, and the mtime per file lives in
|
||||
* `cursor_transcripts`, so a session the user deleted stays deleted until
|
||||
* Cursor writes to it again. No transcript is ever stored as `transcript_path`.
|
||||
* Cursor writes to it again. A session a dashboard pane started is claimed
|
||||
* for that pane's workspace and run (`claimToolSessions`), which gives the
|
||||
* pane its session id and so its Chat view. No transcript is ever stored as
|
||||
* `transcript_path`; the conversation view finds the file through
|
||||
* `transcriptPath` instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -26,6 +30,7 @@ const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { db, stmts } = require("../db");
|
||||
const { claimToolSessions } = require("./dashboard-runs");
|
||||
|
||||
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
|
||||
const FUTURE_SLACK_MS = 1000;
|
||||
@@ -148,6 +153,14 @@ function transcriptFilesIn(cursorDir) {
|
||||
}
|
||||
|
||||
const getSeen = db.prepare("SELECT * FROM cursor_transcripts WHERE path = ?");
|
||||
const getPathOf = db.prepare(
|
||||
"SELECT path FROM cursor_transcripts WHERE session_id = ? ORDER BY mtime_ms DESC LIMIT 1"
|
||||
);
|
||||
|
||||
/** The transcript a Cursor session was read from (recorded by the poller), or null. */
|
||||
function transcriptPath(sessionId) {
|
||||
return getPathOf.get(sessionId)?.path || null;
|
||||
}
|
||||
const upsertSeen = db.prepare(
|
||||
`INSERT INTO cursor_transcripts (path, session_id, mtime_ms, updated_at)
|
||||
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
@@ -235,6 +248,7 @@ function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
|
||||
upsertSeen.run(f.path, f.id, f.mtime);
|
||||
if (type) {
|
||||
written += 1;
|
||||
claimToolSessions(f.id); // its workspace and live pane, before the broadcast
|
||||
broadcast(type, stmts.getSession.get(f.id));
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -273,4 +287,5 @@ module.exports = {
|
||||
firstPrompt,
|
||||
ingestCursorUsage,
|
||||
startCursorUsagePoll,
|
||||
transcriptPath,
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ function bindable(v) {
|
||||
*/
|
||||
/** A run row as it may leave this machine: never a pane credential's hash. */
|
||||
function exportableRun(row) {
|
||||
const { pane_token_hash: _secret, ...rest } = row;
|
||||
const { pane_token_hash: _secret, chat_token_hash: _chat, ...rest } = row;
|
||||
return rest;
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@ function importExportBundle(db, bundle) {
|
||||
events: makeInserter(db, "events", { omit: ["id"], wrap: { data: "trim_event_data" } }),
|
||||
token_usage: makeInserter(db, "token_usage"),
|
||||
workflows: makeInserter(db, "workflows"),
|
||||
dashboard_runs: makeInserter(db, "dashboard_runs", { omit: ["pane_token_hash"] }),
|
||||
dashboard_runs: makeInserter(db, "dashboard_runs", {
|
||||
omit: ["pane_token_hash", "chat_token_hash"],
|
||||
}),
|
||||
alert_rules: makeInserter(db, "alert_rules"),
|
||||
model_pricing: makeInserter(db, "model_pricing"),
|
||||
};
|
||||
|
||||
@@ -640,6 +640,11 @@ function spawnRun(args) {
|
||||
...(entry.launcher === "claude" && systemPromptFile
|
||||
? { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1" }
|
||||
: {}),
|
||||
// Claude Code hides TaskCreate/TaskUpdate from its newer models unless
|
||||
// this is set, and those calls are all the chat Progress panel draws
|
||||
// (routes/sessions.js sessionOutline). A user's settings.json `env` still
|
||||
// wins, so `"0"` there turns them back off.
|
||||
...(entry.launcher === "claude" ? { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } : {}),
|
||||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||||
// pushes lines into history its redraw cannot clear). The user's own
|
||||
|
||||
@@ -288,6 +288,7 @@ function broadcastAudience(type, data) {
|
||||
case "agent_updated":
|
||||
case "new_event":
|
||||
case "workflow_upserted":
|
||||
case "transcript_changed":
|
||||
return { owners: sessionOwners(d.session_id) };
|
||||
case "alert_triggered":
|
||||
case "alert_updated":
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* @file Turns one Claude Code transcript line (or a stream-json event shaped
|
||||
* like one) into the conversation message the chat renders: sender, text,
|
||||
* thinking, tool calls and results (with an edit's +/- counts, an
|
||||
* AskUserQuestion's answers, a subagent's facts), model, usage. Shared by the
|
||||
* transcript route and the realtime chat runtime so both draw the same thing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Classify the TRUE sender of a transcript entry. A JSONL `type:"user"` line is
|
||||
* not always the human: it also carries tool results, harness-injected
|
||||
* task-notifications, /loop re-injections (`isMeta`), and — in a subagent
|
||||
* transcript — the task prompt handed down by the orchestrator. Attributing all
|
||||
* of those to "User" is wrong; the UI styles each sender distinctly.
|
||||
*
|
||||
* Returns: "user" | "assistant" | "orchestrator" | "system" | "tool".
|
||||
* user — a real message typed by the human
|
||||
* assistant — the agent's own turn
|
||||
* orchestrator — a subagent's task, assigned by its parent/main agent
|
||||
* system — harness/tooling injection (task-notification, /loop meta, …)
|
||||
* tool — a tool_result echoed back on a `user` line
|
||||
*/
|
||||
function classifyTranscriptSender(entry, isSubagentFile) {
|
||||
if (entry.type === "assistant") return "assistant";
|
||||
// `system`/local_command lines are surfaced as the human's slash-command I/O.
|
||||
if (entry.type !== "user") return "user";
|
||||
|
||||
const content = entry.message ? entry.message.content : undefined;
|
||||
const onlyToolResults =
|
||||
Array.isArray(content) &&
|
||||
content.length > 0 &&
|
||||
content.every((b) => b && b.type === "tool_result");
|
||||
if (entry.toolUseResult !== undefined || onlyToolResults) return "tool";
|
||||
|
||||
const text =
|
||||
typeof content === "string"
|
||||
? content
|
||||
: Array.isArray(content)
|
||||
? (content.find((b) => b && b.type === "text") || {}).text || ""
|
||||
: "";
|
||||
const lead = text.replace(/^\s+/, "");
|
||||
|
||||
// Harness injections that masquerade as a user line.
|
||||
if (entry.isMeta === true) return "system";
|
||||
if (lead.startsWith("<task-notification>") || lead.startsWith("<task-notification ")) {
|
||||
return "system";
|
||||
}
|
||||
// Background-task event banner ("[SYSTEM NOTIFICATION - NOT USER INPUT] …")
|
||||
// that newer harness builds prefix ahead of the <task-notification> payload.
|
||||
if (lead.startsWith("[SYSTEM NOTIFICATION")) return "system";
|
||||
|
||||
// In a subagent transcript, a user line with no human prompt provenance is the
|
||||
// task injected by the Task/Agent tool. A real human message to the subagent
|
||||
// (rare, but allowed) carries promptSource/origin and stays "user".
|
||||
if (isSubagentFile && entry.promptSource === undefined && entry.origin === undefined) {
|
||||
return "orchestrator";
|
||||
}
|
||||
|
||||
return "user";
|
||||
}
|
||||
|
||||
/** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
|
||||
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
|
||||
* created. Null for any other result. */
|
||||
function patchCounts(r) {
|
||||
if (!r || typeof r !== "object") return null;
|
||||
if (Array.isArray(r.structuredPatch) && r.structuredPatch.length) {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const h of r.structuredPatch) {
|
||||
for (const l of Array.isArray(h?.lines) ? h.lines : []) {
|
||||
if (typeof l !== "string") continue;
|
||||
if (l.startsWith("+")) added++;
|
||||
else if (l.startsWith("-")) removed++;
|
||||
}
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
if (r.type === "create" && typeof r.content === "string") {
|
||||
return { added: r.content.split("\n").length - (r.content.endsWith("\n") ? 1 : 0), removed: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** An AskUserQuestion result's answers (`toolUseResult.answers`): question
|
||||
* text → the option picked, or what the user typed. Null for any other result. */
|
||||
function askAnswers(r) {
|
||||
if (!r || typeof r !== "object" || !r.answers || typeof r.answers !== "object") return null;
|
||||
if (Array.isArray(r.answers)) return null;
|
||||
const out = {};
|
||||
for (const [q, a] of Object.entries(r.answers)) if (typeof a === "string") out[q] = a;
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/** An Agent/Task result's facts (`toolUseResult`): the subagent's id (its
|
||||
* transcript is `agent-<id>.jsonl`), the model it ran on and, once it
|
||||
* finished in the foreground, its time, tokens and tool calls. Null for any
|
||||
* other result. */
|
||||
function agentInfo(r) {
|
||||
if (!r || typeof r !== "object" || typeof r.agentId !== "string") return null;
|
||||
if (!/^[\w-]{1,200}$/.test(r.agentId)) return null;
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
return {
|
||||
id: r.agentId,
|
||||
model: typeof r.resolvedModel === "string" ? r.resolvedModel : null,
|
||||
status: typeof r.status === "string" ? r.status : null,
|
||||
duration_ms: num(r.totalDurationMs),
|
||||
tokens: num(r.totalTokens),
|
||||
tool_uses: num(r.totalToolUseCount),
|
||||
};
|
||||
}
|
||||
|
||||
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by
|
||||
// the model's output and what the user typed. Only tool payloads and thinking
|
||||
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards
|
||||
// against a pathological line, not a long answer.
|
||||
const TEXT_MAX = 1024 * 1024;
|
||||
// The tools whose input carries a file's text (artifactVersions on the client).
|
||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit"]);
|
||||
const EDIT_INPUT_MAX = 256 * 1024;
|
||||
|
||||
function truncate(str, maxLen) {
|
||||
if (!str || str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "[truncated]";
|
||||
}
|
||||
|
||||
function truncateObj(obj, maxLen) {
|
||||
if (!obj) return obj;
|
||||
const json = JSON.stringify(obj);
|
||||
if (json.length <= maxLen) return obj;
|
||||
return { _truncated: truncate(json, maxLen) };
|
||||
}
|
||||
|
||||
/** A parser for one transcript: it remembers the last rename it surfaced, so
|
||||
* a repeated `custom-title` line shows once. `isSubagentFile` tells a
|
||||
* subagent's task (orchestrator) from a human prompt. */
|
||||
function createClaudeLineParser(isSubagentFile = false) {
|
||||
// Dedupe state for synthetic rename markers: custom-title lines can repeat
|
||||
// with the same value across a transcript, so only emit when the title
|
||||
// actually changes from the last one we surfaced.
|
||||
let lastRenameTitle = null;
|
||||
|
||||
// Helper: parse a JSONL line into a message object, or null if not a displayable message
|
||||
function parseMessage(entry, num) {
|
||||
// /rename, `claude -n`, picker Ctrl+R → custom-title metadata line. These
|
||||
// commands produce no user/assistant turn, so without this they'd be
|
||||
// invisible. Surface a compact "renamed" marker instead.
|
||||
if (entry.type === "custom-title") {
|
||||
const title = typeof entry.customTitle === "string" ? entry.customTitle.trim() : "";
|
||||
if (!title || title === lastRenameTitle) return null;
|
||||
lastRenameTitle = title;
|
||||
return {
|
||||
type: "session_event",
|
||||
event_kind: "rename",
|
||||
title,
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Local slash-command I/O. Newer Claude Code builds write the command
|
||||
// invocation and its captured output as `system`/`local_command` lines
|
||||
// with the TUI markup in a top-level `content` string (older builds used
|
||||
// `user` messages, handled below). Surface those as user-side text so the
|
||||
// client's tuiSegments parser renders the command pill + stdout/stderr
|
||||
// (e.g. /color → "/color" pill + "Session color set to: cyan"). Skip
|
||||
// every other system subtype (turn_duration, stop_hook_summary, …) and
|
||||
// empty local_command lines (e.g. /clear writes a content-less one).
|
||||
if (entry.type === "system") {
|
||||
if (entry.subtype !== "local_command") return null;
|
||||
const sysText = typeof entry.content === "string" ? entry.content : "";
|
||||
if (!sysText.trim()) return null;
|
||||
return {
|
||||
type: "user",
|
||||
sender: "user", // local slash-command I/O is the human's own action
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(sysText, TEXT_MAX) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Mid-turn queued message: journaled as a `queued_command` attachment
|
||||
// (never as a `user` line). Surface it at the position the model actually
|
||||
// received it. NOT everything in the queue is the human, though — the
|
||||
// harness delivers its own injections (task-notifications from background
|
||||
// agents, "[SYSTEM NOTIFICATION …]" banners) through the same queue, and
|
||||
// those attachments carry NO `origin` field, while a genuinely typed
|
||||
// message carries `origin.kind = "human"`. So: harness-marker text or a
|
||||
// non-human origin → "system"; everything else → "user". Other attachment
|
||||
// subtypes are harness noise → dropped.
|
||||
if (entry.type === "attachment") {
|
||||
const att = entry.attachment;
|
||||
if (!att || att.type !== "queued_command") return null;
|
||||
const prompt = typeof att.prompt === "string" ? att.prompt : "";
|
||||
if (!prompt.trim()) return null;
|
||||
const lead = prompt.replace(/^\s+/, "");
|
||||
// Same harness markers classifyTranscriptSender strips off user lines.
|
||||
const isHarnessText =
|
||||
lead.startsWith("<task-notification") || lead.startsWith("[SYSTEM NOTIFICATION");
|
||||
const kind = att.origin && typeof att.origin.kind === "string" ? att.origin.kind : null;
|
||||
const isSystem = isHarnessText || (kind !== null && kind !== "human");
|
||||
return {
|
||||
type: "user",
|
||||
sender: isSystem ? "system" : "user",
|
||||
timestamp: entry.timestamp || att.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(prompt, TEXT_MAX) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
const msg = entry.type === "assistant" ? entry.message || {} : {};
|
||||
const content = [];
|
||||
|
||||
if (entry.type === "user") {
|
||||
const msgContent = entry.message?.content;
|
||||
if (typeof msgContent === "string") {
|
||||
content.push({ type: "text", text: truncate(msgContent, TEXT_MAX) });
|
||||
} else if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||
} else if (block.type === "tool_result") {
|
||||
// A line carries one tool result; an edit's +/- counts ride on it.
|
||||
const diff = patchCounts(entry.toolUseResult);
|
||||
const answers = askAnswers(entry.toolUseResult);
|
||||
const agent = agentInfo(entry.toolUseResult);
|
||||
// A subagent's report is a reply, read as prose: its text blocks
|
||||
// whole, not a 10 KB slice of their JSON.
|
||||
const report =
|
||||
agent && Array.isArray(block.content)
|
||||
? block.content
|
||||
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text)
|
||||
.join("\n\n")
|
||||
: null;
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
id: block.tool_use_id || null,
|
||||
output:
|
||||
report !== null
|
||||
? truncate(report, TEXT_MAX)
|
||||
: truncate(
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: JSON.stringify(block.content || ""),
|
||||
10240
|
||||
),
|
||||
is_error: !!block.is_error,
|
||||
...(diff ? { diff } : {}),
|
||||
...(answers ? { answers } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msgContent === undefined || msgContent === null) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const msgContent = msg.content || [];
|
||||
if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||
} else if (block.type === "thinking" && block.thinking) {
|
||||
content.push({ type: "thinking", text: truncate(block.thinking, 10240) });
|
||||
} else if (block.type === "tool_use") {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: block.name || "unknown",
|
||||
id: block.id || null,
|
||||
// A file a Write/Edit made is an artifact the chat can show in
|
||||
// versions, rebuilt from these inputs: they keep up to 256 KB.
|
||||
input: truncateObj(block.input, EDIT_TOOLS.has(block.name) ? EDIT_INPUT_MAX : 10240),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content.length === 0) return null;
|
||||
|
||||
const message = {
|
||||
type: entry.type,
|
||||
sender: classifyTranscriptSender(entry, isSubagentFile),
|
||||
timestamp: entry.timestamp
|
||||
? typeof entry.timestamp === "number"
|
||||
? new Date(entry.timestamp).toISOString()
|
||||
: entry.timestamp
|
||||
: null,
|
||||
content,
|
||||
line: num,
|
||||
};
|
||||
|
||||
if (entry.type === "assistant") {
|
||||
if (msg.model) message.model = msg.model;
|
||||
// "end_turn" closes the turn; "tool_use" means more work follows.
|
||||
if (typeof msg.stop_reason === "string") message.stop_reason = msg.stop_reason;
|
||||
if (msg.usage) {
|
||||
message.usage = {
|
||||
input_tokens: msg.usage.input_tokens || 0,
|
||||
output_tokens: msg.usage.output_tokens || 0,
|
||||
cache_read_input_tokens: msg.usage.cache_read_input_tokens || 0,
|
||||
cache_creation_input_tokens: msg.usage.cache_creation_input_tokens || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
return parseMessage;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyTranscriptSender,
|
||||
patchCounts,
|
||||
askAnswers,
|
||||
agentInfo,
|
||||
truncate,
|
||||
truncateObj,
|
||||
TEXT_MAX,
|
||||
createClaudeLineParser,
|
||||
};
|
||||
Reference in New Issue
Block a user