): void {
+ this.avatarMap = { ...DEFAULT_AVATAR_MAP, ...map };
+ }
+
+ /**
+ * Add a non-conversational system message styled distinctly from agent
+ * messages (no avatar, no source label, no timestamp). Use for `@`-config
+ * confirmations, session lifecycle events, and similar host notices.
+ */
+ public addSystemMessage(text: string): void {
+ const sentinel = this.messageDiv.firstElementChild!;
+ const el = document.createElement("div");
+ el.className = "chat-message-system";
+ el.textContent = text;
+ sentinel.before(el);
+ this.scrollToBottom();
+ }
+
+ /**
+ * Atomically replay a list of past entries from a session history.
+ * Each replayed DOM element is marked with the `.history` class so
+ * hosts can style replayed turns differently (e.g. dimmed).
+ *
+ * Recognized entry kinds:
+ * - `{ kind: "user", text, requestId?, timestamp? }`
+ * - `{ kind: "agent-replace", content, source?, sourceIcon?, requestId?, timestamp? }`
+ * - `{ kind: "agent-append", content, source?, sourceIcon?, mode?, requestId?, timestamp? }`
+ * - `{ kind: "system", text }`
+ *
+ * Temporary append entries (`mode === "temporary"`) are skipped — they're
+ * ephemeral status text from the original interaction and would otherwise
+ * appear as orphan status lines in the replayed transcript.
+ *
+ * After replay, the per-request bubble map is cleared so future live
+ * messages don't accidentally route into history bubbles.
+ */
+ public replayHistory(entries: HistoryEntry[]): void {
+ if (!entries || entries.length === 0) return;
+
+ const firstHistoryIdx = this.messageDiv.children.length;
+
+ // Reset live state so replay starts fresh.
+ this.currentAgentContainer = undefined;
+
+ // Suppress first-message timing tracking during replay — those
+ // timestamps would reflect the speed of replay, not the original
+ // request-to-first-response time.
+ this.suppressFirstMessageTracking = true;
+ try {
+ for (const entry of entries) {
+ switch (entry.kind) {
+ case "user":
+ this.addUserMessage(entry.text, entry.requestId);
+ break;
+ case "agent-replace":
+ this.replaceAgentMessage(
+ entry.content,
+ entry.source,
+ entry.sourceIcon,
+ entry.requestId,
+ );
+ break;
+ case "agent-append":
+ if (entry.mode === "temporary") break;
+ this.addAgentMessage(
+ entry.content,
+ entry.source,
+ entry.sourceIcon,
+ entry.mode,
+ entry.requestId,
+ );
+ break;
+ case "system":
+ this.addSystemMessage(entry.text);
+ break;
+ case "display-info":
+ this.setDisplayInfo(
+ entry.source,
+ entry.sourceIcon,
+ entry.action,
+ entry.requestId,
+ );
+ break;
+ case "command-result":
+ if (entry.requestId) {
+ // Pre-seed the per-request firstMessageMs so the
+ // metrics tooltip can show "First Message" on
+ // history-replayed bubbles (live tracking is
+ // suppressed during replay).
+ if (entry.firstMessageMs !== undefined) {
+ this.firstMessageMsByRequestId.set(
+ entry.requestId,
+ entry.firstMessageMs,
+ );
+ }
+ this.completeRequest(entry.requestId, {
+ actionPhase: entry.actionPhase,
+ totalDuration: entry.totalDuration,
+ tokenUsage: entry.tokenUsage,
+ parsePhase: entry.parsePhase,
+ });
+ }
+ break;
+ }
}
- this.pendingDisplayInfo = undefined;
+ } finally {
+ this.suppressFirstMessageTracking = false;
}
- this.currentAgentContainer.setMessage(content, source, appendMode);
+ // Mark everything just appended as history. Iteration is over the
+ // live NodeList so `children.length` reflects current count.
+ for (
+ let i = firstHistoryIdx;
+ i < this.messageDiv.children.length;
+ i++
+ ) {
+ this.messageDiv.children[i].classList.add("history");
+ }
+ // Reset state so the next live message starts a fresh bubble and
+ // doesn't reuse a history bubble via the requestId map.
+ // Also clear userMessageById: clientRequestIds from prior sessions
+ // (e.g. the shell's cmd-N counter resets each launch) can collide
+ // with new live requests, causing hasUserMessage() to return a
+ // false positive and silently drop the live user-message bubble.
+ this.currentAgentContainer = undefined;
+ this.agentContainersByRequestId.clear();
+ this.userMessageById.clear();
this.scrollToBottom();
}
@@ -644,11 +1445,15 @@ export class ChatPanel {
source: string,
sourceIcon?: string,
action?: unknown,
+ requestId?: string,
) {
- if (this.currentAgentContainer) {
- this.currentAgentContainer.updateSource(source, sourceIcon);
+ const target =
+ (requestId && this.agentContainersByRequestId.get(requestId)) ||
+ this.currentAgentContainer;
+ if (target) {
+ target.updateSource(source, sourceIcon);
if (action !== undefined) {
- this.currentAgentContainer.setActionData(action);
+ target.setActionData(action);
}
return;
}
@@ -658,13 +1463,36 @@ export class ChatPanel {
this.pendingDisplayInfo = { source, sourceIcon, action };
}
+ /** Returns true if a user-message bubble for `requestId` already exists. */
+ public hasUserMessage(requestId: string): boolean {
+ return this.userMessageById.has(requestId);
+ }
+
/** Clear all messages. */
public clear() {
- while (this.messageDiv.children.length > 1) {
- this.messageDiv.removeChild(this.messageDiv.lastChild!);
+ // Wipe everything then re-create the sticky scroll-anchor
+ // sentinel that the constructor installs as messageDiv's first
+ // child. addUserMessage / replaceAgentMessage / showSeparator /
+ // historyReplay all depend on `messageDiv.firstElementChild`
+ // being that sentinel and call `sentinel.before(...)`; without
+ // it they throw and the next command surfaces as a dispatcher
+ // error in the UI.
+ while (this.messageDiv.firstChild) {
+ this.messageDiv.removeChild(this.messageDiv.firstChild);
}
+ const sentinel = document.createElement("div");
+ sentinel.className = "chat-sentinel";
+ this.messageDiv.appendChild(sentinel);
this.currentAgentContainer = undefined;
+ this.agentContainersByRequestId.clear();
this.userMessageById.clear();
+ this.requestStartByRequestId.clear();
+ this.firstMessageMsByRequestId.clear();
+ this.pendingDisplayInfo = undefined;
+ if (this.statusContainer) {
+ this.statusContainer.remove();
+ this.statusContainer = undefined;
+ }
}
/**
@@ -730,29 +1558,83 @@ export class ChatPanel {
* Signal that the current request has finished. Mirrors the shell's
* `statusMessage.complete()` hook — clears any lingering status /
* dispatcher-temporary bubble regardless of the arrival order of the
- * agent's real response. If `result` is provided, finalize the
- * current agent bubble's hover-reveal metrics with overall duration
- * and token usage. Also resets the current agent container so the
- * next request starts a fresh bubble.
+ * agent's real response. If `result` is provided, finalize the target
+ * agent bubble's hover-reveal metrics with overall duration and token
+ * usage. If `requestId` is supplied, that bubble is finalized;
+ * otherwise the most recently created bubble is used. Also resets the
+ * current agent container so the next request starts a fresh bubble.
*/
- public completeRequest(result?: {
- actionPhase?: PhaseTiming;
- totalDuration?: number;
- tokenUsage?: CompletionUsageStats;
- }) {
+ public completeRequest(
+ requestId?: string,
+ result?: {
+ actionPhase?: PhaseTiming;
+ totalDuration?: number;
+ tokenUsage?: CompletionUsageStats;
+ parsePhase?: PhaseTiming;
+ cancelled?: boolean;
+ },
+ ) {
if (this.statusContainer) {
this.statusContainer.remove();
this.statusContainer = undefined;
}
- if (result && this.currentAgentContainer) {
- this.currentAgentContainer.updateMetrics(
+ const target =
+ (requestId && this.agentContainersByRequestId.get(requestId)) ||
+ this.currentAgentContainer;
+ const firstMessageMs =
+ requestId !== undefined
+ ? this.firstMessageMsByRequestId.get(requestId)
+ : undefined;
+ if (result?.cancelled) {
+ // Mirror the Electron shell's "⚠ Cancelled" status line so the
+ // user has visible confirmation that Stop / Esc cancelled the
+ // in-flight command. If no agent bubble was created yet (cancel
+ // landed before any agent output), spin up a minimal one so
+ // the status is still visible.
+ const cancelTarget =
+ target ??
+ this.createAgentContainer("shell", this.iconForSource("shell"));
+ cancelTarget.setMessage(
+ {
+ type: "text",
+ content: "⚠ Cancelled",
+ kind: "status",
+ },
+ "shell",
+ "block",
+ );
+ }
+ if (result && target) {
+ target.updateMetrics(
"Action",
result.actionPhase,
result.totalDuration,
result.tokenUsage,
+ firstMessageMs,
);
}
- this.currentAgentContainer = undefined;
+ if (result && requestId) {
+ // Always attempt to populate user-side metrics. Even when the
+ // request had no parse phase (e.g. cached translations or
+ // chat-only paths), we still show the total elapsed so the
+ // user bubble gets a metrics tooltip just like the agent's.
+ this.applyUserMetrics(
+ requestId,
+ "Translation",
+ result.parsePhase,
+ result.totalDuration,
+ );
+ }
+ if (requestId) {
+ this.agentContainersByRequestId.delete(requestId);
+ this.requestStartByRequestId.delete(requestId);
+ this.firstMessageMsByRequestId.delete(requestId);
+ }
+ // If we just finalized the active bubble, reset it so the next
+ // request starts fresh.
+ if (!requestId || target === this.currentAgentContainer) {
+ this.currentAgentContainer = undefined;
+ }
}
/** Show a status message (temporary, removed when the next real message arrives). */
@@ -971,13 +1853,18 @@ export class ChatPanel {
/** Programmatically inject and send a command.
* @param displayText Optional friendly text shown in the user bubble instead of the raw command.
+ * @param requestId Optional request id; one is generated if not supplied.
*/
- public injectCommand(command: string, displayText?: string) {
+ public injectCommand(
+ command: string,
+ displayText?: string,
+ requestId?: string,
+ ) {
this.commandHistory.unshift(command);
this.historyIndex = -1;
- const requestId = generateRequestId();
- this.addUserMessage(displayText ?? command, requestId);
- this.onSend?.(command, undefined, requestId);
+ const id = requestId ?? generateRequestId();
+ this.addUserMessage(displayText ?? command, id);
+ this.onSend?.(command, undefined, id);
}
/** Add a separator for previous session history. */
@@ -1037,8 +1924,239 @@ export class ChatPanel {
this.historyAgentContainer = undefined;
}
+ /**
+ * Set the local user's display name (and optional initial). Used in the
+ * user-bubble timestamp label and the round avatar to the right of the
+ * bubble. Affects bubbles created AFTER this call; existing bubbles are
+ * not retroactively updated. The initial defaults to the first
+ * non-whitespace character of `name` (uppercased).
+ */
+ public setUserInfo(name: string, initial?: string) {
+ const trimmed = (name ?? "").trim();
+ if (trimmed.length > 0) {
+ this.userName = trimmed;
+ }
+ if (initial !== undefined) {
+ const trimmedInitial = initial.trim();
+ if (trimmedInitial.length > 0) {
+ this.userInitial = trimmedInitial.charAt(0).toUpperCase();
+ }
+ } else if (trimmed.length > 0) {
+ this.userInitial = trimmed.charAt(0).toUpperCase();
+ }
+ }
+
+ /**
+ * Mount inline + dropdown command-completion driven by the host. The
+ * `post` callback receives messages (`pcUpdate` / `pcAccept` /
+ * `pcDismiss` / `pcHide` / `pcDispose`) which the host should forward
+ * to its CompletionController. The host pushes state updates back via
+ * `applyPcState(state)`.
+ *
+ * Returns the underlying PartialCompletion instance for callers that
+ * need direct access (e.g. to call `reset()` on session change).
+ * Re-attaching disposes the previous instance.
+ */
+ /**
+ * Tear down the panel's lifetime-bound listeners. Call this when the
+ * host (e.g. a VS Code webview) is being disposed so the window-level
+ * demo key handler doesn't survive the panel and fire stale callbacks
+ * (or leak memory across panel reincarnations).
+ *
+ * Idempotent. Safe to call even if attachCompletion / setDemoPaused
+ * were never invoked.
+ */
+ public dispose(): void {
+ if (this.demoKeyHandler) {
+ window.removeEventListener("keydown", this.demoKeyHandler, true);
+ this.demoKeyHandler = undefined;
+ }
+ this.isDemoPaused = false;
+ this.isDemoRunning = false;
+ this.partialCompletion?.dispose();
+ this.partialCompletion = undefined;
+ }
+
+ public attachCompletion(
+ post: PcPost,
+ opts?: { inline?: boolean },
+ ): PartialCompletion {
+ this.partialCompletion?.dispose();
+ // The textInput is wrapped in `chat-input-text-wrapper`; mount the
+ // toggle button on the wrapper so it sits flush with the input.
+ const wrapper =
+ (this.textInput.parentElement as HTMLElement | null) ??
+ this.inputArea;
+ this.partialCompletion = new PartialCompletion(
+ wrapper,
+ this.textInput,
+ this.ghostSpan,
+ post,
+ opts,
+ );
+ return this.partialCompletion;
+ }
+
+ /** Forward a host-pushed completion state update to the controller. */
+ public applyPcState(state: PcCompletionState | undefined) {
+ this.partialCompletion?.applyState(state);
+ }
+
/** Enable or disable the input. */
public setEnabled(enabled: boolean) {
+ // setSwitching/setHistoryLoading take precedence: if either is active,
+ // the host should not be able to re-enable the input until they clear.
+ if (enabled && (this.isSwitching || this.isHistoryLoading)) {
+ return;
+ }
+ this.textInput.contentEditable = enabled ? "true" : "false";
+ this.sendButton.disabled = !enabled;
+ if (enabled) {
+ this.inputArea.classList.remove("chat-input-disabled");
+ } else {
+ this.inputArea.classList.add("chat-input-disabled");
+ // No commands can be in flight when input is disabled (typically
+ // because the dispatcher disconnected). Hide the stop button so
+ // users can't try to cancel into a dead RPC channel.
+ if (this.activeRequestId !== undefined) {
+ this.setIdle();
+ }
+ }
+ }
+
+ /**
+ * Show or hide the reconnect banner above the chat. Pass `undefined` to
+ * hide. The banner is plain text only — hosts format the message
+ * (countdown, attempt number, etc.) before passing it in.
+ */
+ public setReconnectStatus(message: string | undefined): void {
+ if (this.reconnectBanner === undefined) return;
+ if (message === undefined) {
+ this.reconnectBanner.style.display = "none";
+ this.reconnectBanner.textContent = "";
+ } else {
+ this.reconnectBanner.textContent = message;
+ this.reconnectBanner.style.display = "";
+ }
+ }
+
+ /**
+ * Disable input and show a placeholder while a conversation switch is in
+ * progress. Re-enables input on `setSwitching(false)` (unless history is
+ * still loading).
+ */
+ public setSwitching(switching: boolean, targetName?: string) {
+ this.isSwitching = switching;
+ if (switching) {
+ this.setEnabledInternal(false);
+ const label = targetName
+ ? `Switching to conversation "${targetName}"…`
+ : "Switching conversation…";
+ this.textInput.setAttribute("data-placeholder", label);
+ this.inputArea.classList.add("chat-input-switching");
+ } else {
+ this.inputArea.classList.remove("chat-input-switching");
+ if (!this.isHistoryLoading) {
+ this.setEnabledInternal(true);
+ this.textInput.setAttribute(
+ "data-placeholder",
+ "Type a message...",
+ );
+ }
+ }
+ }
+
+ /**
+ * Disable input and show a "Loading history…" placeholder until the host
+ * finishes replaying past messages on (re)connect or session restore.
+ * Re-enables input on `setHistoryLoading(false)` (unless a switch is
+ * still in progress).
+ */
+ public setHistoryLoading(loading: boolean) {
+ this.isHistoryLoading = loading;
+ if (loading) {
+ this.setEnabledInternal(false);
+ this.textInput.setAttribute("data-placeholder", "Loading history…");
+ this.inputArea.classList.add("chat-input-history-loading");
+ } else {
+ this.inputArea.classList.remove("chat-input-history-loading");
+ if (!this.isSwitching) {
+ this.setEnabledInternal(true);
+ this.textInput.setAttribute(
+ "data-placeholder",
+ "Type a message...",
+ );
+ }
+ }
+ }
+
+ /**
+ * Toggle "demo paused" mode. While paused, the panel installs a
+ * window-level capture-phase keydown listener that swallows
+ * Ctrl/Meta+→ (continue) and Esc (cancel) before the focused input
+ * field sees them, and forwards the action via `onDemoAction`.
+ *
+ * The host is responsible for any user-facing "Demo paused" indicator
+ * (e.g., status ribbon suffix) — chat-ui no longer renders its own
+ * banner so the host can integrate the state into its existing UI
+ * without an extra component getting in the way.
+ */
+ public setDemoPaused(paused: boolean, _message?: string): void {
+ this.isDemoPaused = paused;
+ this.refreshDemoKeyHandler();
+ // When pausing, ensure the chat input has focus so the
+ // window-level demoKeyHandler reliably receives Ctrl+Right /
+ // Esc. VS Code's `vscode-shell.chatView.focus` reveals the
+ // webview view but doesn't always land focus inside the
+ // contenteditable, so without this nudge the key events can
+ // be swallowed by the surrounding VS Code UI before the
+ // capture-phase handler runs.
+ if (paused) {
+ try {
+ this.textInput.focus();
+ } catch {
+ // best-effort
+ }
+ }
+ }
+
+ private refreshDemoKeyHandler(): void {
+ const wantHandler = this.isDemoPaused || this.isDemoRunning;
+ if (wantHandler && !this.demoKeyHandler) {
+ this.demoKeyHandler = (e: KeyboardEvent) => {
+ if (!this.isDemoPaused && !this.isDemoRunning) return;
+ if (
+ e.key === "ArrowRight" &&
+ (e.altKey || e.ctrlKey || e.metaKey) &&
+ this.isDemoPaused
+ ) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.onDemoAction?.("continue");
+ } else if (e.key === "Escape") {
+ // Esc cancels the demo whether it's currently
+ // paused at @pauseForInput or actively running a
+ // line. The host's requestDemoCancel() sets a
+ // sticky flag so the loop sees it on the next
+ // iteration even mid-line.
+ e.preventDefault();
+ e.stopPropagation();
+ this.onDemoAction?.("cancel");
+ }
+ };
+ window.addEventListener("keydown", this.demoKeyHandler, true);
+ } else if (!wantHandler && this.demoKeyHandler) {
+ window.removeEventListener("keydown", this.demoKeyHandler, true);
+ this.demoKeyHandler = undefined;
+ }
+ }
+
+ /**
+ * Internal enable/disable that bypasses the isSwitching/isHistoryLoading
+ * guard in setEnabled. Used by setSwitching and setHistoryLoading
+ * themselves to actually toggle input state.
+ */
+ private setEnabledInternal(enabled: boolean) {
this.textInput.contentEditable = enabled ? "true" : "false";
this.sendButton.disabled = !enabled;
if (enabled) {
@@ -1132,6 +2250,12 @@ class AgentMessageContainer {
// rendered response and a of the action JSON.
private actionDataHtml?: string;
private savedMessageHtml?: string;
+ // When setActionData receives an action with schemaName/actionName,
+ // we display "schema.action" as the bubble title instead of the raw
+ // source agent name. setMessage's source-driven label-update is then
+ // suppressed so the action label doesn't get clobbered by later
+ // setDisplay calls.
+ private actionDerivedName?: string;
constructor(
beforeElement: Element,
@@ -1201,6 +2325,8 @@ class AgentMessageContainer {
this.div.appendChild(bodyDiv);
+ attachHoverPush(bodyDiv, this.div, this.metricsDiv);
+
// Insert into DOM (column-reverse order)
beforeElement.before(this.div);
}
@@ -1210,22 +2336,62 @@ class AgentMessageContainer {
phase?: PhaseTiming,
totalDuration?: number,
tokenUsage?: CompletionUsageStats,
+ firstMessageMs?: number,
) {
- const lines: string[] = [];
+ // Layout: .metrics-details flex row with three columns
+ // left — "First Message" + phase.marks (one line each)
+ // middle — (reserved; tts metrics in the future)
+ // right — main metrics (Action Elapsed / Total Elapsed / Tokens)
+ // This mirrors the Electron shell's MessageContainer layout so the
+ // tooltip reads as "marks on the left, totals on the right".
+ const mainLines: string[] = [];
if (phase?.duration !== undefined) {
- lines.push(metricsLine(`${actionLabel} Elapsed`, phase.duration));
+ mainLines.push(
+ metricsLine(`${actionLabel} Elapsed`, phase.duration),
+ );
}
if (totalDuration !== undefined) {
- lines.push(metricsLine("Total Elapsed", totalDuration));
+ mainLines.push(metricsLine("Total Elapsed", totalDuration));
}
if (tokenUsage) {
- lines.push(
+ // Compact form: "Tokens: 14356 (14257+99)" — the long
+ // "(prompt N, completion M)" form overflowed the metrics
+ // tooltip in narrow webview sidebars.
+ mainLines.push(
`Tokens: ${tokenUsage.total_tokens} ` +
- `(prompt ${tokenUsage.prompt_tokens}, ` +
- `completion ${tokenUsage.completion_tokens})`,
+ `(${tokenUsage.prompt_tokens}+${tokenUsage.completion_tokens})`,
);
}
- this.metricsDiv.innerHTML = lines.join("
");
+ const leftLines: string[] = [];
+ if (firstMessageMs !== undefined) {
+ leftLines.push(metricsLine("First Message", firstMessageMs));
+ }
+ if (phase?.marks) {
+ for (const [key, value] of Object.entries(phase.marks)) {
+ const avg = value.duration / Math.max(value.count, 1);
+ const suffix =
+ value.count !== 1 ? `(out of ${value.count})` : "";
+ leftLines.push(
+ `${escapeHtml(key)}: ${formatDuration(avg)}${suffix}`,
+ );
+ }
+ }
+ this.metricsDiv.innerHTML = sanitize(
+ `` +
+ `
${leftLines.join("
")}
` +
+ `
` +
+ `
${mainLines.join("
")}
` +
+ `
`,
+ );
+ // Flag the container so the chat-level selector that pushes
+ // visually-below bubbles down on hover can target it without
+ // relying on a nested `:has()` chain (which proved unreliable in
+ // some webview versions).
+ if (leftLines.length > 0 || mainLines.length > 0) {
+ this.div.classList.add("chat-message-has-metrics");
+ } else {
+ this.div.classList.remove("chat-message-has-metrics");
+ }
}
/**
@@ -1236,31 +2402,79 @@ class AgentMessageContainer {
public setActionData(action: unknown) {
if (action === undefined || action === null) return;
let html: string;
+ let label: string | undefined;
if (Array.isArray(action)) {
- html = `${escapeHtml(action.join(" "))}`;
- } else if (typeof action === "object") {
- html = `${escapeHtml(
+ // Skip arrays-of-primitives (e.g. dispatcher's ['request']
+ // housekeeping events) and empty arrays — making the agent
+ // name clickable here would produce a no-op popup.
+ const objectEntries = action.filter(
+ (v) => typeof v === "object" && v !== null,
+ );
+ if (objectEntries.length === 0) return;
+ // Skip arrays whose objects are all empty `{}` — same
+ // rationale (some agents emit `[{}]` placeholder events).
+ if (
+ objectEntries.every(
+ (v) => Object.keys(v as object).length === 0,
+ )
+ ) {
+ return;
+ }
+ // Derive a label from the first object that carries a
+ // schema/action name (matches Electron shell behavior).
+ for (const entry of objectEntries) {
+ const o = entry as {
+ schemaName?: unknown;
+ actionName?: unknown;
+ };
+ if (
+ typeof o.schemaName === "string" &&
+ typeof o.actionName === "string"
+ ) {
+ label = `${o.schemaName}.${o.actionName}`;
+ break;
+ }
+ }
+ html = `${highlightJson(
JSON.stringify(action, undefined, 2),
)}`;
+ } else if (typeof action === "object") {
+ // Skip empty objects — the popup would show only `{}` which
+ // is misleading (looks broken to the user).
+ if (Object.keys(action as object).length === 0) return;
+ const obj = action as {
+ schemaName?: unknown;
+ actionName?: unknown;
+ };
+ if (
+ typeof obj.schemaName === "string" &&
+ typeof obj.actionName === "string"
+ ) {
+ label = `${obj.schemaName}.${obj.actionName}`;
+ }
+ const json = JSON.stringify(action, undefined, 2);
+ html = `${highlightJson(json)}`;
} else {
- html = `${escapeHtml(String(action))}`;
+ // Primitives (string/number/bool) aren't useful as a JSON
+ // popup. Don't make the bubble clickable for them.
+ return;
}
+ // Render the JSON below the message in the collapsible details
+ // area, instead of swapping out the message body. The agent name
+ // becomes a click affordance to toggle the details panel.
+ this.detailsDiv.innerHTML = sanitize(html);
this.actionDataHtml = html;
+ if (label) {
+ this.nameSpan.textContent = label;
+ this.actionDerivedName = label;
+ }
this.nameSpan.classList.add("clickable");
- this.nameSpan.title = "Click to show action JSON";
+ this.nameSpan.title = "Click to show / hide action JSON";
}
private toggleActionData() {
if (this.actionDataHtml === undefined) return;
- if (this.savedMessageHtml === undefined) {
- this.savedMessageHtml = this.messageDiv.innerHTML;
- this.messageDiv.innerHTML = this.actionDataHtml;
- this.messageDiv.classList.add("chat-message-action-data");
- } else {
- this.messageDiv.innerHTML = this.savedMessageHtml;
- this.savedMessageHtml = undefined;
- this.messageDiv.classList.remove("chat-message-action-data");
- }
+ this.detailsDiv.classList.toggle("chat-details-visible");
}
public setMessage(
@@ -1268,7 +2482,7 @@ class AgentMessageContainer {
source?: string,
appendMode?: DisplayAppendMode,
) {
- if (source) {
+ if (source && !this.actionDerivedName) {
this.nameSpan.textContent = source;
}
@@ -1320,12 +2534,25 @@ class AgentMessageContainer {
}
public updateSource(source: string, icon?: string) {
- this.nameSpan.textContent = source;
+ if (!this.actionDerivedName) {
+ this.nameSpan.textContent = source;
+ }
if (icon) {
this.iconDiv.textContent = icon;
}
}
+ /**
+ * Mark this container as a transient status indicator (used for
+ * "Executing action ...", reasoning's streaming "Thinking…" chunks,
+ * etc.). Adds a CSS class that styles the bubble as a small italicized
+ * indicator with no agent label/avatar/border — so the user can't
+ * mistake it for a duplicate of the real reply bubble.
+ */
+ public markAsStatusBubble() {
+ this.div.classList.add("chat-message-status-bubble");
+ }
+
/** Mark this container as a dimmed history message. */
public setHistoryStyle() {
this.div.classList.add("chat-history-message");
diff --git a/ts/packages/chat-ui/src/index.ts b/ts/packages/chat-ui/src/index.ts
index 72ea7bb7de..7dace7793b 100644
--- a/ts/packages/chat-ui/src/index.ts
+++ b/ts/packages/chat-ui/src/index.ts
@@ -9,10 +9,20 @@ export {
export { setContent, swapContent } from "./setContent.js";
+export {
+ PartialCompletion,
+ PcCompletionState,
+ PcDirection,
+ PcPostMessage,
+ PcPost,
+} from "./partialCompletion.js";
+
export {
ChatPanel,
ChatPanelOptions,
CompletionResult,
DynamicDisplayResult,
+ DEFAULT_AVATAR_MAP,
NotifyExplainedData,
+ HistoryEntry,
} from "./chatPanel.js";
diff --git a/ts/packages/chat-ui/src/partialCompletion.ts b/ts/packages/chat-ui/src/partialCompletion.ts
new file mode 100644
index 0000000000..ee6e39d93a
--- /dev/null
+++ b/ts/packages/chat-ui/src/partialCompletion.ts
@@ -0,0 +1,528 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import {
+ CompletionToggle,
+ LocalSearchMenuUI,
+ type SearchMenuItem,
+ type SearchMenuPosition,
+} from "@typeagent/completion-ui";
+
+/**
+ * Subset of agent-dispatcher's CompletionState received from the host
+ * over the pcState postMessage protocol.
+ */
+export type PcCompletionState = {
+ prefix: string;
+ items: SearchMenuItem[];
+ generation: number;
+ anchorIndex: number;
+};
+
+export type PcDirection = "forward" | "backward";
+
+export type PcPostMessage =
+ | { type: "pcUpdate"; input: string; direction: PcDirection }
+ | { type: "pcAccept" }
+ | { type: "pcDismiss"; input: string; direction: PcDirection }
+ | { type: "pcHide" }
+ | { type: "pcDispose" };
+
+export type PcPost = (msg: PcPostMessage) => void;
+
+/**
+ * Adapts ChatPanel's contentEditable input + ghost span for command
+ * completion using @typeagent/completion-ui.
+ *
+ * Architecture mirrors the legacy vscode-shell partialCompletion:
+ * - The CompletionController lives in the host (extension / agent-server).
+ * - This class drives it from the webview via postMessage:
+ * pcUpdate(input, direction) on input / focus / caret moves
+ * pcAccept() when the user picks a completion
+ * pcDismiss(input, direction) when the user explicitly hides (Esc)
+ * pcHide() when the caret leaves the end of input
+ * pcDispose() on dispose
+ * - The host sends pcState back on every state change. We render two UIs:
+ * Inline ghost-text suffix in the existing ChatPanel ghost span.
+ * Dropdown menu (LocalSearchMenuUI) anchored above the input.
+ *
+ * This adaptation differs from the legacy version (which targeted a
+ *