Compare commits

..

3 Commits

Author SHA1 Message Date
nntrivi2001 b0dd5af22f fix: v2.7.5 — the chat shows each AskUserQuestion with the user's answer
- Chat: an AskUserQuestion is its own card (AskCard in MessageList.tsx), not
  part of the collapsed work line — each question with the option picked or
  the text typed, "declined" when refused. The transcript route passes the
  answers from Claude Code's toolUseResult (askAnswers, beside diff).
- Chat: the activity panel keeps the user's open/close per session for the
  tab, and opens by default only once the session has an output.
- Workspace home: the sent prompt's draft is dropped even though opening the
  session unmounts the home.
- Terminal: Ctrl+V with text on the clipboard pastes the text; only an empty
  clipboard still sends ^V.
- AGENTS.md / CLAUDE.md: run only the tests a change touches while iterating,
  the full suite once before committing.
2026-10-13 10:46:56 +07:00
nntrivi2001 7cb4f64781 feat(chat): answer the CLI's menus from the chat; Stop ends Thinking… (#74)
- A menu the CLI asks with (AskUserQuestion — one question or several,
  multiSelect —, a permission or trust prompt, Codex's approval, a
  first-start login menu) is read off the terminal under the chat
  (`parsePaneMenu`/`readRunMenu`) and shown in the prompt box's place as a
  card of buttons. A pick presses its digit (picks it, or ticks its box),
  Claude's unnumbered Submit row is reached with the arrows and Enter, and
  Cancel presses Esc. "Type something" opens a field in the card: its text
  goes in by the arrows, Ctrl+U over anything already typed there, a
  bracketed paste and Enter. That row presses no key — a second press of
  its digit typed the digit into the CLI's field ("44444…"). Keys verified
  on Claude Code 2.1.296 and Codex 0.161. AskUserQuestion fires no
  Notification, so a menu on screen also counts as waiting (live line, tab
  badge). Client only: no new route.
- #74: Esc before the model wrote anything puts the prompt back in Claude
  Code's input box and writes no "[Request interrupted by user]" line, so
  the transcript ended on the prompt and the chat read "Thinking…" for
  15 min. The chat's Stop now ends the live line until a newer transcript
  line arrives (matched by line, not by clock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:59:40 +07:00
nntrivi2001 69a2c37cd2 fix(terminal): macOS Vietnamese Telex keeps its capitals in Chrome too
macOS's own Vietnamese Telex types into xterm's textarea with no
composition events: a letter is an `insertText`, every later key of the
word an `insertReplacementText`, each keydown (keyCode 229) arriving after
the change. v2.7.4 forwarded the replacements, but a capital typed with
Shift still vanished — xterm drops any `input` that lands while a key is
held — and its 229 fallback could resend an edit. Replayed from a real
Chrome trace, "Abc xin chào" reached the pane as " x c".

lib/imeInput.ts now owns those edits: capture phase on xterm's element,
each one sent as DELs plus the new tail, the IME's input and 229 keydowns
hidden from xterm. Keys xterm sends from keypress (Space) and real
compositions (CJK) stay xterm's. Verified against xterm 5.5 in headless
Chrome: Telex → "Abc xin chào"; a plain keyboard sends the same bytes
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:20:07 +07:00
28 changed files with 1207 additions and 78 deletions
+1
View File
@@ -25,6 +25,7 @@ make while you work here as Codex.
## Validation expectations
- Backend changes: run `npm run test:server` when possible.
- Frontend changes: run `npm run test:client` when possible.
- Full suites take 5–6 minutes, so while iterating run only what the change touches: `npm run test:server -- server/__tests__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). Run the full suite once before committing, and always when the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib) — the per-screen snapshots catch that.
- If any check is skipped, report it explicitly.
## Safety expectations
+2 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.7.4 (2026-10-12) — Changes hides a staged change undone on disk (#72); chat says when the session has not started (#73); a member's missing folder is made as them, bypass offered only once accepted._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.7.5 (2026-10-13) — the chat shows each AskUserQuestion with the user's answer; while iterating, run only the tests a change touches._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -96,6 +96,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
## Testing and verification policy
- Backend changes: run `npm run test:server` before finishing.
- Frontend changes: run `npm run test:client` when relevant. This includes per-screen render snapshots (`client/src/pages/__tests__/screens.snapshot.test.tsx`). If a UI change is intentional, review the snapshot diff and regenerate baselines with `cd client && npx vitest run -u`; never blindly update snapshots to make tests pass.
- Full suites take 5–6 minutes — while iterating run only what the change touches (`npm run test:server -- server/__tests__/<name>.test.js`; in `client/` `NODE_ENV=test npx vitest related <changed files> --run`), then the full suite once before committing, and always when touching something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib).
- If you cannot run a verification step, state exactly what was not run and why.
## Change guidelines by area
@@ -97,6 +97,8 @@ interface ConversationViewProps {
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
/** Bumped by the chat's Stop: the live line ends with the turn on screen. */
stopped?: number;
/** The newest reply's prompt size (input + cache) and model: how full the
* context is. */
onContext?: (tokens: number, model: string | null) => void;
@@ -113,6 +115,7 @@ export function ConversationView({
follow = 0,
waiting = false,
onOpenTerminal,
stopped = 0,
onContext,
onNotStarted,
}: ConversationViewProps) {
@@ -558,7 +561,11 @@ export function ConversationView({
{t("conversation.notStartedBody")}
</p>
{onOpenTerminal && (
<button type="button" onClick={onOpenTerminal} className="btn-secondary mt-3 text-xs">
<button
type="button"
onClick={onOpenTerminal}
className="btn-secondary mt-3 text-xs"
>
{t("conversation.openTerminal")}
</button>
)}
@@ -578,6 +585,7 @@ export function ConversationView({
live={live}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
stopped={stopped}
/>
)}
</div>
@@ -15,7 +15,9 @@
* files ends with Claude Desktop's "Edited N files" card: every file with the
* lines added and removed (the tool results' `diff`); a click opens the file
* when the caller can (`onOpenFile`), and a row (or the card's header, for
* every file in it) drags into a prompt box as an `@path` attachment. A thinking block that carries text is
* every file in it) drags into a prompt box as an `@path` attachment. An
* AskUserQuestion is its own card (`AskCard`), not part of the collapsed work
* line: each question with the answer the user picked or typed. A thinking block that carries text is
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
* chat (`live`) ends, until its turn is over, with what the model is doing
@@ -95,6 +97,8 @@ import {
Check,
Hand,
SquareTerminal,
MessageCircleQuestion,
CornerDownRight,
} from "lucide-react";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
@@ -156,6 +160,9 @@ interface MessageListProps {
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
/** Bumped by the chat's Stop: the turn on screen is over, even when the
* transcript never says so. */
stopped?: number;
}
/** Build a map from tool_use id → tool_result for matching */
@@ -458,6 +465,7 @@ type Item =
| { kind: "user"; key: string; msg: TranscriptMessage }
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
| { kind: "work"; key: string; blocks: TranscriptContent[] }
| { kind: "ask"; key: string; block: TranscriptContent }
| { kind: "edits"; key: string; blocks: TranscriptContent[] }
| { kind: "done"; key: string; ms: number; output: number };
@@ -519,7 +527,10 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
if (block.type === "tool_use") {
// A question to the user is a turn of the conversation, not hidden work.
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
items.push({ kind: "ask", key: `${key}:${b}`, block });
} else if (block.type === "tool_use") {
if (last?.kind === "work") last.blocks.push(block);
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
@@ -611,15 +622,24 @@ export function MessageList({
live = false,
waiting = false,
onOpenTerminal,
stopped = 0,
}: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
const keys = useMemo(() => messageKeys(messages), [messages]);
const lastKey = keys[keys.length - 1] ?? null;
// The newest line when Stop was pressed. Esc before the model wrote anything
// puts the prompt back in Claude Code's input box and writes no
// "[Request interrupted…]" line (issue #74), so the transcript still ends on
// the prompt: no live line until a newer one arrives.
const [stop, setStop] = useState<{ n: number; on: string | null }>({ n: stopped, on: null });
if (stop.n !== stopped) setStop({ n: stopped, on: lastKey });
const halted = stop.on !== null && stop.on === lastKey;
const pending = useMemo(
() => (live ? workingOn(messages, toolResultMap) : null),
[live, messages, toolResultMap]
() => (live && !halted ? workingOn(messages, toolResultMap) : null),
[live, halted, messages, toolResultMap]
);
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
const keys = useMemo(() => messageKeys(messages), [messages]);
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
if (loading) {
@@ -657,6 +677,14 @@ export function MessageList({
return (
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
);
case "ask":
return (
<AskCard
key={item.key}
block={item.block}
result={item.block.id ? (toolResultMap.get(item.block.id) ?? null) : null}
/>
);
case "edits":
// The card closes a finished turn; one still working has none yet.
if (pending && item === items[items.length - 1]) return null;
@@ -923,6 +951,50 @@ function RunningStep({
}
/** Under a turn that ended: how long it took and the output it made. */
/** An AskUserQuestion: each question with the answer the user picked or
* typed (the result's `answers`), "declined" when they refused it, no answer
* yet while the CLI still asks. */
function AskCard({
block,
result,
}: {
block: TranscriptContent;
result: TranscriptContent | null;
}) {
const { t } = useTranslation("sessions");
const input = block.input as { questions?: unknown } | undefined;
const questions = (Array.isArray(input?.questions) ? input.questions : [])
.map((q) => (q && typeof q === "object" ? (q as { question?: unknown }).question : null))
.filter((q): q is string => typeof q === "string");
return (
<div
data-testid="chat-ask"
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
>
{questions.map((q, i) => {
const answer = result?.answers?.[q];
return (
<div key={i}>
<div className="flex items-start gap-1.5 text-fg-secondary">
<MessageCircleQuestion className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span>{q}</span>
</div>
{answer && (
<div className="ml-5 mt-0.5 flex items-start gap-1.5 font-medium text-fg-primary">
<CornerDownRight className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
<span className="whitespace-pre-wrap">{answer}</span>
</div>
)}
</div>
);
})}
{result?.is_error && (
<div className="text-xs text-fg-muted">{t("conversation.askDeclined")}</div>
)}
</div>
);
}
function TurnDone({ ms, output }: { ms: number; output: number }) {
const { t } = useTranslation("sessions");
return (
@@ -0,0 +1,85 @@
/**
* @file MessageList.ask.test.tsx
* @description An AskUserQuestion call is not folded into the collapsed work
* line: the chat shows it as its own card — each question with the answer the
* user picked or typed, "declined" when they refused it, nothing yet while the
* CLI still waits.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { MessageList } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const at = "2026-10-13T08:00:00.000Z";
const user = (text: string): TranscriptMessage =>
({
type: "user",
sender: "user",
timestamp: at,
content: [{ type: "text", text }],
}) as TranscriptMessage;
const ask = (id: string, questions: string[]): TranscriptMessage =>
({
type: "assistant",
sender: "assistant",
timestamp: at,
content: [
{ type: "tool_use", id: `${id}r`, name: "Read", input: { file_path: "/w/a.ts" } },
{
type: "tool_use",
id,
name: "AskUserQuestion",
input: {
questions: questions.map((question) => ({
question,
header: "Pick",
options: [{ label: "Yes" }, { label: "No" }],
})),
},
},
],
}) as TranscriptMessage;
const result = (id: string, extra: Record<string, unknown>) =>
({
type: "user",
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id, output: "x", ...extra }],
}) as TranscriptMessage;
describe("an AskUserQuestion card", () => {
it("shows each question with the user's answer, outside the work line", () => {
render(
<MessageList
loading={false}
messages={[
user("go"),
ask("q1", ["Commit?", "Push?"]),
result("q1", { answers: { "Commit?": "Yes", "Push?": "only after review" } }),
]}
/>
);
const card = screen.getByTestId("chat-ask");
expect(within(card).getByText("Commit?")).toBeTruthy();
expect(within(card).getByText("Yes")).toBeTruthy();
expect(within(card).getByText("only after review")).toBeTruthy();
expect(within(screen.getByTestId("chat-tools")).queryByText(/Commit\?/)).toBeNull();
});
it("says declined when refused, and shows no answer while still asked", () => {
const { rerender } = render(
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
);
const card = screen.getByTestId("chat-ask");
expect(within(card).getByText("Commit?")).toBeTruthy();
expect(within(card).queryByText(/declined/i)).toBeNull();
rerender(
<MessageList
loading={false}
messages={[user("go"), ask("q1", ["Commit?"]), result("q1", { is_error: true })]}
/>
);
expect(within(screen.getByTestId("chat-ask")).getByText(/declined/i)).toBeTruthy();
});
});
@@ -182,6 +182,29 @@ describe("MessageList — sender attribution", () => {
expect(screen.queryByTestId("chat-running-step")).toBeNull();
});
// Issue #74: Esc before the model wrote anything takes the prompt back into
// Claude Code's input box and writes NO "[Request interrupted…]" line — the
// transcript still ends on the prompt, which read as "Thinking…" for 15 min.
it("drops Thinking… once Stop was pressed, until the transcript moves on", () => {
const prompt = msg({
sender: "user",
timestamp: new Date(Date.now() - 14_000).toISOString(),
content: [{ type: "text", text: "Phần đề xuất trong báo cáo" }],
});
const { rerender } = render(<MessageList loading={false} messages={[prompt]} live />);
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
rerender(<MessageList loading={false} messages={[prompt]} live stopped={1} />);
expect(screen.queryByTestId("chat-running-step")).toBeNull();
// The next prompt is a new turn: its Thinking… shows.
const next = msg({
sender: "user",
timestamp: new Date().toISOString(),
content: [{ type: "text", text: "again" }],
});
rerender(<MessageList loading={false} messages={[prompt, next]} live stopped={1} />);
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
});
it("counts a turn's output once per API call, since its prompt", () => {
const at = "2026-06-26T08:19:00.000Z";
const line = (cache: number, out: number) =>
@@ -277,6 +277,13 @@ function HomeBody({
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
});
setPrompt("");
// Here, not only through the effect: the navigate below unmounts this
// home in the same batch, so the effect never sees the empty prompt.
try {
localStorage.removeItem(draftKey);
} catch {
/* storage unavailable: nothing was kept */
}
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
@@ -14,7 +14,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { MemoryRouter, Route, Routes, useLocation, useSearchParams } from "react-router-dom";
import type { Lane, Session } from "../../../lib/types";
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
@@ -241,6 +241,27 @@ describe("WorkspaceHome", () => {
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
});
it("drops the sent prompt's draft even though opening the session unmounts the home", async () => {
// As the Workspace page: `&run=` replaces the home with the session view.
function HomeUntilRun() {
const [params] = useSearchParams();
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
}
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
<Routes>
<Route path="*" element={<HomeUntilRun />} />
</Routes>
</MemoryRouter>
);
await screen.findByTestId("workspace-recent-s-live");
await user.type(screen.getByLabelText("How can I help you today?"), "sent once");
await user.click(screen.getByTestId("workspace-home-start"));
await waitFor(() => expect(screen.queryByTestId("workspace-home-start")).toBeNull());
expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull();
});
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
renderHome();
await screen.findByTestId("workspace-recent-s-live");
+200 -18
View File
@@ -12,11 +12,18 @@
* `/model <id>` / `/effort <level>` — and the permission mode, which only
* Shift+Tab changes in a running session, so the server steps it and reads
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
* 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
* a field in the card, whose text is typed there as the answer
* (`answerRunMenu`).
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
* outputs, what the session used), open at first in a pane spanning the
* grid's full width or height (`panelOpen`) and collapsed into a file button
* with the output count in a quarter-sized one; it sits beside the
* grid's full width or height (`panelOpen`) once the session has an output,
* else collapsed into a file button with the output count; the user's own
* open/close is kept per session for the tab (sessionStorage), so the chat
* leaving for the terminal and back keeps it; it sits beside the
* conversation when the pane is wide and over it when narrow (a CSS container
* query on the pane, not the window), dragged wider or narrower by its
* splitter there (a fraction of the pane, remembered per browser). A file
@@ -49,7 +56,8 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square } from "lucide-react";
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import { isSubmitEnter } from "../../lib/keys";
import { ConversationView } from "../conversation/ConversationView";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import { ChatAddMenu } from "./ChatAddMenu";
@@ -59,7 +67,14 @@ import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } f
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
import { Select } from "../Select";
import { Divider, showTerminal } from "./PaneGrid";
import { interruptRun, submitToRun } from "../../lib/panePaste";
import {
answerRunMenu,
chooseRunOption,
interruptRun,
readRunMenu,
submitToRun,
type PaneMenu,
} from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { openInChanges } from "../../lib/openInChanges";
import { toast } from "../../lib/toast";
@@ -79,6 +94,11 @@ const MODES: [PermissionMode, string][] = [
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
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}`;
const readOutlineChoice = (sessionId: string) => {
const v = sessionStorage.getItem(outlineKey(sessionId));
return v === null ? null : v === "1";
};
const storedSide = () => {
const f = Number(localStorage.getItem(SIDE_KEY));
return f ? clampSide(f) : null;
@@ -184,6 +204,108 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
);
}
/** 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. */
function PaneMenuCard({
menu,
onChoose,
onAnswer,
onCancel,
}: {
menu: PaneMenu;
onChoose: (index: number) => void;
onAnswer: (text: string) => void;
onCancel: () => void;
}) {
const { t } = useTranslation("run");
const [typing, setTyping] = useState(false);
const [draft, setDraft] = useState("");
const submit = () => draft.trim() && onAnswer(draft);
return (
<div
data-testid="pane-chat-menu"
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
>
{menu.question && (
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
{menu.question}
</p>
)}
<div className="flex flex-col gap-1">
{menu.options.map((o, i) =>
typing && i === menu.typeAt ? (
<div
key={`${i}-type`}
className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1"
>
<span className="w-4 flex-shrink-0 font-mono text-xs text-fg-muted">{o.n}</span>
<input
autoFocus
data-testid="pane-chat-menu-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
submit();
} else if (e.key === "Escape") setTyping(false);
}}
placeholder={t("chat.menu.placeholder")}
aria-label={t("chat.menu.placeholder")}
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"
data-testid="pane-chat-menu-send"
onClick={submit}
disabled={!draft.trim()}
aria-label={t("chat.send")}
title={t("chat.send")}
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
</div>
) : (
<button
key={`${i}-${o.label}`}
type="button"
onClick={() => (i === menu.typeAt ? setTyping(true) : onChoose(i))}
className={`flex items-start gap-2 rounded-lg border px-3 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 ${
i === menu.cursor ? "border-accent/50 bg-surface-2" : "border-border"
}`}
>
<span className="mt-0.5 w-4 flex-shrink-0 font-mono text-xs text-fg-muted">
{o.checked === null ? (
(o.n ?? "↵")
) : o.checked ? (
<SquareCheck className="h-3.5 w-3.5 text-accent" />
) : (
<Square className="h-3.5 w-3.5" />
)}
</span>
<span className="min-w-0 break-words">
{o.label}
{o.hint && <span className="block text-xs text-fg-muted">{o.hint}</span>}
</span>
</button>
)
)}
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-fg-muted">
<button
type="button"
onClick={onCancel}
className="ml-auto rounded-md border border-border px-2 py-0.5 hover:bg-surface-3"
>
{t("chat.menu.cancel")}
</button>
</div>
</div>
);
}
export function PaneChat({
run,
sessionId,
@@ -213,11 +335,20 @@ export function PaneChat({
const [model, setModel] = useState(run.model ?? "");
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
const pickFiles = useRef<(() => void) | null>(null);
const [outlineOpen, setOutlineOpen] = useState(panelOpen);
// The user's own open/close, per session for this tab: it outlives the chat
// leaving for the terminal (an unmount). Unset, the default rules.
const [outlineChoice, setOutlineChoice] = useState(() => readOutlineChoice(sessionId));
// A split or a close changes the pane's shape: its default comes back.
useEffect(() => setOutlineOpen(panelOpen), [panelOpen]);
const shape = useRef(panelOpen);
useEffect(() => {
if (shape.current === panelOpen) return;
shape.current = panelOpen;
sessionStorage.removeItem(outlineKey(sessionId));
setOutlineChoice(null);
}, [panelOpen, sessionId]);
const openOutline = (open: boolean) => {
setOutlineOpen(open);
sessionStorage.setItem(outlineKey(sessionId), open ? "1" : "0");
setOutlineChoice(open);
setPreview(null);
};
const [preview, setPreview] = useState<string | null>(null);
@@ -240,6 +371,8 @@ export function PaneChat({
}, []);
const [list, setList] = useState<OutlineList | null>(null);
const activity = useSessionOutline(sessionId);
// Open by default only in a spanning pane, and only once there is an output.
const outlineOpen = outlineChoice ?? (panelOpen && activity.outputs.length > 0);
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
const text = promptWithImages(prompt, images);
@@ -247,6 +380,8 @@ export function PaneChat({
// waits on the user from a Notification (a permission prompt) to the next hook.
const [working, setWorking] = useState(false);
const [waiting, setWaiting] = useState(false);
// Stop presses, for the conversation's live line (issue #74).
const [stops, setStops] = useState(0);
const readWorking = useCallback(
() =>
api.agents
@@ -275,14 +410,41 @@ export function PaneChat({
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
});
// A hidden tab says when this session waits on the user or its turn ended.
const was = useRef({ working: false, waiting: false });
// The menu the terminal underneath shows now. AskUserQuestion fires no
// Notification, so this — not `waiting` alone — says the CLI asks.
// ponytail: polls the xterm buffer; subscribe to its onRender if it ever shows in a profile.
const [menu, setMenu] = useState<PaneMenu | null>(null);
useEffect(() => {
if (waiting) setTabBadge(runId, "waiting");
const read = () =>
setMenu((prev) => {
const next = readRunMenu(runId);
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
});
read();
const timer = window.setInterval(read, 700);
return () => window.clearInterval(timer);
}, [runId]);
const asking = waiting || menu !== null;
// Gone until the pane redraws: no second press on a stale menu.
const choose = (index: number) => {
if (menu && chooseRunOption(runId, menu, index)) setMenu(null);
else toast.error(t("chat.notSent"));
};
const answer = (reply: string) => {
if (menu && answerRunMenu(runId, menu, reply)) {
setMenu(null);
setFollow((n) => n + 1);
} else toast.error(t("chat.notSent"));
};
// 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 (was.current.working || was.current.waiting) setTabBadge(runId, "done");
was.current = { working, waiting };
}, [runId, working, waiting]);
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
was.current = { working, asking };
}, [runId, working, asking]);
useEffect(() => () => setTabBadge(runId, null), [runId]);
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
tokens: 0,
@@ -339,7 +501,8 @@ export function PaneChat({
fill
live
follow={follow}
waiting={waiting}
waiting={asking}
stopped={stops}
onOpenTerminal={() => showTerminal(runId, sessionId)}
onContext={onContext}
onNotStarted={setNotStarted}
@@ -366,7 +529,24 @@ export function PaneChat({
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
{menu && (
<PaneMenuCard
// A new question starts with its field closed.
key={menu.question}
menu={menu}
onChoose={choose}
onAnswer={answer}
onCancel={() => {
if (interruptRun(runId)) setMenu(null);
else toast.error(t("chat.notSent"));
}}
/>
)}
{/* In the card's place while the CLI asks: hidden, never unmounted,
so a draft or an upload in flight survives the question. */}
<div
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`}
>
<PromptEditor
bare
images={images}
@@ -413,8 +593,10 @@ export function PaneChat({
data-testid="pane-chat-stop"
onClick={() => {
// An interrupted turn fires no Stop hook: clear it here.
if (interruptRun(runId)) setWorking(false);
else toast.error(t("chat.notStopped"));
if (interruptRun(runId)) {
setWorking(false);
setStops((n) => n + 1);
} else toast.error(t("chat.notStopped"));
}}
aria-label={t("chat.stop")}
title={t("chat.stop")}
+25 -30
View File
@@ -25,6 +25,9 @@
* from its terminal — announced by a hint the textarea is described by;
* Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode
* (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's.
* An input method that edits the textarea without composition events (macOS
* Vietnamese Telex) is typed through `lib/imeInput.ts` — xterm alone
* dropped its capitals and the words it rewrites.
* URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies
* (OSC 52 — tmux's default `set-clipboard external` passes its own
* copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser
@@ -37,7 +40,15 @@
* here takes the lead back (`resize` with `drive: true`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useId, useMemo, useRef, useState, type ClipboardEvent, type DragEvent } from "react";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type ClipboardEvent,
type DragEvent,
} from "react";
import { useTranslation } from "react-i18next";
import type { Terminal } from "@xterm/xterm";
import type { CanvasAddon } from "@xterm/addon-canvas";
@@ -49,6 +60,7 @@ import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { notePaneFocus, registerPane } from "../../lib/panePaste";
import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
type DropState = "hint" | "uploading" | "failed";
@@ -196,13 +208,14 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
// text/plain and would paste nothing. Capture phase, because xterm's own
// paste listener stops the event. A Ctrl+V with no file in it is the pane's
// ^V again (vim's block select, readline's quoted insert), not a text paste.
// paste listener stops the event. Text is xterm's to paste, Ctrl+V's too;
// only a Ctrl+V with nothing on the clipboard is the pane's ^V again
// (vim's block select, readline's quoted insert).
const onPaste = (e: ClipboardEvent) => {
const ctrlV = ctrlVRef.current;
ctrlVRef.current = false;
const files = Array.from(e.clipboardData?.files ?? []);
if (files.length === 0 && !ctrlV) return;
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
e.preventDefault();
e.stopPropagation();
if (files.length > 0) void attachFiles(files);
@@ -350,7 +363,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
}
// Ctrl+V: xterm would send ^V, and claude would then read the
// SERVER's clipboard, never this browser's. Let the browser paste
// instead (onPaste); a browser that fires none still sends ^V.
// instead (onPaste: a file's path, or the text); a browser that fires
// no paste, or one with nothing on the clipboard, still sends ^V.
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "v") {
ctrlVRef.current = true;
setTimeout(() => {
@@ -519,29 +533,11 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const reassert = takeLead;
term.textarea?.addEventListener("focus", reassert);
// Safari with macOS's own Vietnamese Telex types a word by REPLACING it:
// after the first letter every key fires `insertReplacementText` carrying
// the whole word ("x" → "xi" → "xin"), before its keydown. xterm forwards
// only `insertText`, and its keyCode-229 fallback snapshots the textarea
// after the change, so all but each word's first letter was lost (xterm.js
// #5887). Send the edit ourselves: a DEL per replaced char, then the tail.
let beforeReplace = "";
const onBeforeReplace = (e: Event) => {
if ((e as InputEvent).inputType === "insertReplacementText") {
beforeReplace = term.textarea?.value ?? "";
}
};
const onReplace = (e: Event) => {
if ((e as InputEvent).inputType !== "insertReplacementText") return;
const before = Array.from(beforeReplace);
const after = Array.from(term.textarea?.value ?? "");
let same = 0;
while (same < before.length && before[same] === after[same]) same++;
const edit = "\x7f".repeat(before.length - same) + after.slice(same).join("");
if (edit) term.input(edit, true);
};
term.textarea?.addEventListener("beforeinput", onBeforeReplace);
term.textarea?.addEventListener("input", onReplace);
// macOS's own Vietnamese Telex (Safari and Chrome) types into the
// textarea with no composition events: a capital arrives while Shift is
// held and every later key REPLACES the word — both dropped by xterm
// (xterm.js #5887). lib/imeInput.ts sends those edits itself.
const detachIme = attachImeInput(term);
const refit = () => {
// Never fit a collapsed console — see sendSize.
@@ -571,8 +567,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
term.textarea?.removeEventListener("focus", reassert);
term.textarea?.removeEventListener("beforeinput", onBeforeReplace);
term.textarea?.removeEventListener("input", onReplace);
detachIme();
resizeDisposable.dispose();
dataDisposable.dispose();
ws?.close();
@@ -7,16 +7,25 @@
* 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, 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PaneChat, contextWindow } from "../PaneChat";
import { api } from "../../../lib/api";
import { interruptRun, submitToRun } from "../../../lib/panePaste";
import {
answerRunMenu,
chooseRunOption,
interruptRun,
readRunMenu,
submitToRun,
type PaneMenu,
} from "../../../lib/panePaste";
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
vi.mock("../../conversation/ConversationView", () => ({
@@ -41,6 +50,9 @@ vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
vi.mock("../../../lib/panePaste", () => ({
submitToRun: vi.fn(() => true),
interruptRun: vi.fn(() => true),
readRunMenu: vi.fn(() => null),
chooseRunOption: vi.fn(() => true),
answerRunMenu: vi.fn(() => true),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -136,6 +148,7 @@ beforeEach(() => {
vi.mocked(submitToRun).mockClear();
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
localStorage.removeItem("agenthub.chat.draft.s1");
sessionStorage.clear();
});
describe("PaneChat — the + menu", () => {
@@ -301,27 +314,63 @@ describe("PaneChat — the activity panel", () => {
expect(screen.getByTestId("chat-outline")).toBeTruthy();
});
it("starts open in a pane spanning the grid, and follows the pane when it is split", () => {
it("starts open in a pane spanning the grid, and follows the pane when it is split", async () => {
const pane = (panelOpen: boolean) => (
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
</MemoryRouter>
);
const { rerender } = render(pane(true));
expect(screen.getByTestId("chat-outline")).toBeTruthy();
await screen.findByTestId("chat-outline");
rerender(pane(false));
expect(screen.queryByTestId("chat-outline")).toBeNull();
expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
});
it("resizes from its splitter and remembers the width", () => {
it("starts closed while the session has no output, even in a spanning pane", async () => {
vi.mocked(api.sessions.outline).mockResolvedValueOnce({
progress: [],
outputs: [],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
} as never);
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
await waitFor(() => expect(api.sessions.outline).toHaveBeenCalled());
expect(screen.queryByTestId("chat-outline")).toBeNull();
expect(screen.getByTestId("chat-outline-show").textContent).toBe("0");
});
it("keeps the user's open or close across the chat leaving for the terminal", async () => {
const pane = () => (
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
const first = render(pane());
fireEvent.click(
within(await screen.findByTestId("chat-outline")).getByRole("button", { name: "Hide side panel" })
);
first.unmount();
render(pane());
await waitFor(() => expect(screen.getByTestId("chat-outline-show").textContent).toBe("2"));
expect(screen.queryByTestId("chat-outline")).toBeNull();
});
it("resizes from its splitter and remembers the width", async () => {
localStorage.removeItem("agenthub.chat.panelWidth");
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
const splitter = screen.getByRole("separator", { name: "Resize side panel" });
const splitter = await screen.findByRole("separator", { name: "Resize side panel" });
expect(splitter.getAttribute("aria-valuenow")).toBe("70");
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
@@ -412,3 +461,51 @@ describe("PaneChat — the unsent prompt", () => {
expect(confirmDialog).not.toHaveBeenCalled();
});
});
describe("PaneChat — a menu the CLI asks with", () => {
const MENU: PaneMenu = {
question: "Which color?",
options: [
{ n: 1, label: "Red", hint: "warm", checked: null },
{ n: 2, label: "Type something.", hint: "", checked: null },
],
cursor: 0,
multi: false,
typeAt: 1,
};
it("shows the question as buttons in the box's place, and a pick presses its key", async () => {
vi.mocked(readRunMenu).mockReturnValue(MENU);
try {
renderChat();
const card = await screen.findByTestId("pane-chat-menu");
expect(card).toHaveTextContent("Which color?");
expect(card).toHaveTextContent("warm");
// The prompt box is hidden (kept mounted) while the CLI asks.
expect(screen.getByPlaceholderText("Write a message…").closest(".hidden")).not.toBeNull();
fireEvent.click(screen.getByText("Red"));
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", MENU, 0);
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
it("types the answer in the card's own field, never pressing that row's key", async () => {
vi.mocked(readRunMenu).mockReturnValue(MENU);
vi.mocked(chooseRunOption).mockClear();
vi.mocked(submitToRun).mockClear();
try {
renderChat();
fireEvent.click(await screen.findByText("Type something."));
expect(chooseRunOption).not.toHaveBeenCalled();
const field = screen.getByTestId("pane-chat-menu-input");
expect(field).toHaveFocus();
fireEvent.change(field, { target: { value: "purple" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(answerRunMenu).toHaveBeenCalledWith("agenthub-lane-1", MENU, "purple");
expect(submitToRun).not.toHaveBeenCalled();
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
});
@@ -75,6 +75,8 @@ vi.mock("@xterm/xterm", () => ({
paste: pasteMock,
input: inputMock,
textarea: (textarea = document.createElement("textarea")),
// xterm's own element, holding its textarea (lib/imeInput listens on it).
element: document.createElement("div").appendChild(textarea).parentElement,
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
})),
}));
@@ -525,18 +527,30 @@ describe("TerminalView", () => {
});
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
// reads the SERVER's clipboard, not the browser's.
it("lets Ctrl+V reach the browser's paste, and still sends ^V when it holds no file", () => {
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
// pasted like any browser paste: sending ^V for it pasted nothing.
it("lets Ctrl+V reach the browser's paste: text is pasted, ^V only for an empty clipboard", () => {
vi.useFakeTimers();
inputMock.mockClear();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
expect(handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }))).toBe(false);
// Not cancelled: xterm's own paste listener takes the text.
expect(
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [], getData: () => "some text" },
})
).toBe(true);
vi.advanceTimersByTime(1);
expect(inputMock).not.toHaveBeenCalled();
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [], getData: () => "some text" },
clipboardData: { files: [], getData: () => "" },
});
vi.advanceTimersByTime(1);
expect(inputMock).toHaveBeenCalledTimes(1);
expect(inputMock).toHaveBeenCalledWith("\x16", true);
expect(pasteMock).not.toHaveBeenCalled();
vi.useRealTimers();
});
it("sends ^V on Ctrl+V when the browser fires no paste at all", () => {
+4
View File
@@ -127,6 +127,10 @@
"tab": {
"waiting": "Waiting",
"done": "Done"
},
"menu": {
"placeholder": "Type your answer…",
"cancel": "Cancel (Esc)"
}
},
"interrupted": {
+1
View File
@@ -209,6 +209,7 @@
"moreSteps_one": "+{{count}} more",
"moreSteps_other": "+{{count}} more",
"quiet": "no new activity for {{time}}",
"askDeclined": "Declined — no answer given",
"turnDone": "Done in {{time}}"
},
"overview": {
+4
View File
@@ -127,6 +127,10 @@
"tab": {
"waiting": "Đang chờ",
"done": "Xong"
},
"menu": {
"placeholder": "Nhập câu trả lời của bạn…",
"cancel": "Huỷ (Esc)"
}
},
"interrupted": {
+1
View File
@@ -209,6 +209,7 @@
"moreSteps_one": "+{{count}} việc nữa",
"moreSteps_other": "+{{count}} việc nữa",
"quiet": "không có hoạt động mới trong {{time}}",
"askDeclined": "Đã từ chối — không trả lời",
"turnDone": "Xong trong {{time}}"
},
"overview": {
+159
View File
@@ -0,0 +1,159 @@
/**
* @file imeInput.test.ts
* @description Pins `attachImeInput` against a real event trace of macOS's
* Vietnamese Simple Telex input method in Chrome (typing "Abc xin chào"): the
* IME writes into xterm's textarea with `insertText` / `insertReplacementText`
* and no composition events, its keydown (keyCode 229) arriving AFTER the
* change — which stock xterm 5.5 turned into " x c". The pane must receive
* exactly the text typed, a word the IME rewrites as backspaces plus the new
* tail, and keys xterm already sent from `keypress` (Space) never twice.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { afterEach, describe, expect, it } from "vitest";
import { attachImeInput, textEdit } from "../imeInput";
type Step = [type: string, a: string, b: string, keyCode?: number, shift?: boolean];
// Recorded in the user's browser, one line per event on the textarea.
const TELEX: Step[] = [
["keydown", "Shift", "ShiftLeft", 16, true],
["input", "A", "insertText"],
["keydown", "A", "KeyA", 229, true],
["keyup", "A", "KeyA", 65, true],
["keyup", "Shift", "ShiftLeft", 16],
["input", "Ab", "insertReplacementText"],
["keydown", "b", "KeyB", 229],
["keyup", "b", "KeyB", 66],
["input", "Abc", "insertReplacementText"],
["keydown", "c", "KeyC", 229],
["keyup", "c", "KeyC", 67],
["keydown", " ", "Space", 32],
["keypress", " ", "Space", 32],
["input", " ", "insertText"],
["keyup", " ", "Space", 32],
["input", "x", "insertText"],
["keydown", "x", "KeyX", 229],
["keyup", "x", "KeyX", 88],
["input", "xi", "insertReplacementText"],
["keydown", "i", "KeyI", 229],
["input", "xin", "insertReplacementText"],
["keydown", "n", "KeyN", 229],
["keydown", " ", "Space", 32],
["keypress", " ", "Space", 32],
["input", " ", "insertText"],
["input", "c", "insertText"],
["keydown", "c", "KeyC", 229],
["input", "ch", "insertReplacementText"],
["keydown", "h", "KeyH", 229],
["input", "cha", "insertReplacementText"],
["keydown", "a", "KeyA", 229],
["input", "chao", "insertReplacementText"],
["keydown", "o", "KeyO", 229],
["input", "chào", "insertReplacementText"],
["keydown", "f", "KeyF", 229],
];
let cleanup: (() => void) | null = null;
afterEach(() => {
cleanup?.();
cleanup = null;
document.body.innerHTML = "";
});
/** A terminal stand-in: xterm's element and textarea, `input` collecting what
* reaches the pane, and `xterm` what xterm's own textarea listeners saw. */
function setup() {
const element = document.createElement("div");
const textarea = document.createElement("textarea");
element.appendChild(textarea);
document.body.appendChild(element);
let sent = "";
const xterm: string[] = [];
for (const t of ["keydown", "keypress", "input"])
textarea.addEventListener(t, (e) => xterm.push(`${t}:${(e as KeyboardEvent).key ?? ""}`), true);
// xterm sends a printable key from its keypress handler (Space, here).
textarea.addEventListener("keypress", (e) => (sent += e.key), true);
const term = { element, textarea, input: (d: string) => (sent += d) };
cleanup = attachImeInput(term);
return { textarea, xterm, sent: () => sent };
}
function play(ta: HTMLTextAreaElement, steps: Step[]) {
for (const [type, a, b, kc, shift] of steps) {
if (type === "input") {
const before = ta.value;
const next = b === "insertText" ? before + a : before.replace(/\S*$/, "") + a;
ta.dispatchEvent(new InputEvent("beforeinput", { data: a, inputType: b, bubbles: true }));
ta.value = next;
ta.dispatchEvent(new InputEvent("input", { data: a, inputType: b, bubbles: true }));
} else {
const ev = new KeyboardEvent(type, { key: a, code: b, shiftKey: !!shift, bubbles: true });
Object.defineProperty(ev, "keyCode", { get: () => kc });
ta.dispatchEvent(ev);
}
}
}
/** What a line editor holds after `data` (DEL erases one character). */
const typed = (data: string) => {
const out: string[] = [];
for (const ch of data) {
if (ch === "\x7f") out.pop();
else out.push(ch);
}
return out.join("");
};
describe("textEdit", () => {
it("is the backspaces and new tail that turn one value into the other", () => {
expect(textEdit("Abc xin cha", "Abc xin chao")).toBe("o");
expect(textEdit("Abc xin chao", "Abc xin chào")).toBe("\x7f\x7fào");
expect(textEdit("x", "x")).toBe("");
expect(textEdit("ab", "")).toBe("\x7f\x7f");
});
it("counts characters, not UTF-16 units", () => {
expect(textEdit("a😀", "a")).toBe("\x7f");
});
});
describe("attachImeInput", () => {
it("sends what Telex typed — capitals and rewritten words included", () => {
const { textarea, sent } = setup();
play(textarea, TELEX);
expect(typed(sent())).toBe("Abc xin chào");
});
it("leaves xterm the keys it sends itself, and hides the IME's from it", () => {
const { textarea, xterm, sent } = setup();
play(textarea, TELEX);
// Space is xterm's (its keypress sends it, its input event follows); the
// 229 keydowns and the IME's own input events never reach it.
expect(xterm.filter((x) => x.startsWith("keypress"))).toHaveLength(2);
expect(xterm.filter((x) => x.startsWith("input"))).toHaveLength(2);
expect(xterm.some((x) => x === "keydown:A" || x === "keydown:b")).toBe(false);
expect(sent().split(" ")).toHaveLength(3);
});
it("does not touch a composition (CJK input) — xterm handles that", () => {
const { textarea, xterm, sent } = setup();
const ev = new InputEvent("input", {
data: "に",
inputType: "insertCompositionText",
bubbles: true,
});
textarea.value = "に";
textarea.dispatchEvent(ev);
expect(sent()).toBe("");
expect(xterm).toContain("input:");
});
it("stops after dispose", () => {
const { textarea, sent } = setup();
cleanup?.();
cleanup = null;
play(textarea, TELEX.slice(0, 2));
expect(sent()).toBe("");
});
});
+190 -2
View File
@@ -6,16 +6,22 @@
* is not on screen (a hidden console behind a full-width Changes panel); and
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane;
* and the input box read off a real `claude`/`codex` screen (`parsePromptInput`)
* and moved out of it (`takeRunInput`) when the pane switches to Chat.
* and moved out of it (`takeRunInput`) when the pane switches to Chat; and
* the menu a CLI asks with, read off real Claude Code 2.1.296 / Codex 0.161
* screens (`parsePaneMenu`) and answered with the keys verified there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
interruptRun,
notePaneFocus,
parsePromptInput,
answerRunMenu,
chooseRunOption,
parsePaneMenu,
pasteToLane,
readRunMenu,
registerPane,
takeRunInput,
} from "../panePaste";
@@ -122,4 +128,186 @@ describe("panePaste", () => {
off();
expect(takeRunInput("agenthub-lane-7")).toBe("");
});
it("reads an AskUserQuestion menu: question, options, hints, cursor", () => {
const m = parsePaneMenu(ASK);
expect(m).toEqual({
question: "Which color do you like: red, green, or blue?",
options: [
{ n: 1, label: "Red", hint: "Crisp red", checked: null },
{ n: 2, label: "Green", hint: "", checked: null },
{ n: 3, label: "Type something.", hint: "", checked: null },
{ n: 4, label: "Chat about this", hint: "", checked: null },
],
cursor: 0,
multi: false,
typeAt: 2,
});
});
it("reads a multiSelect question with its unnumbered Submit row", () => {
const m = parsePaneMenu(MULTI)!;
expect(m.multi).toBe(true);
expect(m.options.map((o) => [o.n, o.label, o.checked])).toEqual([
[1, "Nuts", true],
[2, "Honey", false],
[3, "Type something", false],
[null, "Submit", null],
[4, "Chat about this", null],
]);
expect(m.cursor).toBe(1);
expect(m.typeAt).toBe(2);
});
// 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", () => {
vi.useFakeTimers();
const keys: string[] = [];
const typed = ASK.map((r) =>
r === "❯ 1. Red" ? " 1. Red" : r === " 3. Type something." ? "❯ 3. 44444" : r
);
const m = parsePaneMenu(typed)!;
expect(m.typeAt).toBe(2);
expect(m.cursor).toBe(2);
const off = registerPane(
"agenthub-lane-9",
() => {},
() => true,
(d) => keys.push(d)
);
expect(answerRunMenu("agenthub-lane-9", m, "teal")).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x15", "\x1b[200~teal\x1b[201~", "\r"]);
off();
vi.useRealTimers();
});
it("reads Claude's and Codex's permission prompts", () => {
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"]);
const x = parsePaneMenu(CODEX_APPROVAL)!;
expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt");
expect(x.options).toHaveLength(3);
expect(x.cursor).toBe(0);
});
it("reads a menu with no footer: the login method at first start", () => {
const m = parsePaneMenu([
" Select login method:",
"",
" ❯ 1. Claude account with subscription · Pro, Max, Team, or Enterprise",
"",
" 2. Anthropic Console account · API usage billing",
"",
])!;
expect(m.question).toBe("Select login method:");
expect(m.options.map((o) => o.n)).toEqual([1, 2]);
});
it("finds no menu in a reply's numbered list or a typed `1.`", () => {
// No cursor on the list.
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
// A numbered list typed into the input box (under its rule), mid-turn.
expect(parsePaneMenu([RULE, "❯ 1. fix it", RULE, " esc to interrupt"])).toBeNull();
expect(parsePaneMenu([RULE, "❯\u00a01. a", " 2. b", RULE, " esc to interrupt"])).toBeNull();
expect(parsePaneMenu(CLAUDE)).toBeNull();
});
it("answers by digit, walks to Submit, and types into Type something", () => {
vi.useFakeTimers();
const keys: string[] = [];
let rows = ASK;
const off = registerPane(
"agenthub-lane-8",
() => {},
() => true,
(d) => keys.push(d),
() => ({ rows, cols: 80 })
);
const ask = readRunMenu("agenthub-lane-8")!;
expect(chooseRunOption("agenthub-lane-8", ask, 1)).toBe(true);
expect(keys).toEqual(["2"]);
keys.length = 0;
// Single select: walk to the row, paste, Enter.
expect(answerRunMenu("agenthub-lane-8", ask, "purple")).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x1b[B", "\x1b[B", "\x1b[200~purple\x1b[201~", "\r"]);
keys.length = 0;
rows = MULTI;
const multi = readRunMenu("agenthub-lane-8")!;
// From Honey (cursor 1) to Submit (3): two Downs, then Enter.
expect(chooseRunOption("agenthub-lane-8", multi, 3)).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x1b[B", "\x1b[B", "\r"]);
keys.length = 0;
// multiSelect: the typed text ticks the row; no Enter, Submit still to pick.
expect(answerRunMenu("agenthub-lane-8", multi, "salt")).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x1b[B", "\x1b[200~salt\x1b[201~"]);
off();
vi.useRealTimers();
expect(readRunMenu("agenthub-lane-8")).toBeNull();
});
});
// Real screens (descriptions trimmed), bottom of a 120-column pane.
const ASK = [
"❯ Use the AskUserQuestion tool to ask me which color I like.",
RULE,
" ☐ Color",
"",
"Which color do you like: red, green, or blue?",
"",
"❯ 1. Red",
" Crisp red",
" 2. Green",
" Green",
" 3. Type something.",
RULE,
" 4. Chat about this",
"",
"Enter to select · ↑/↓ to navigate · Esc to cancel",
"",
];
const MULTI = [
RULE,
"← ☒ Fruit ☐ Toppings ✔ Submit →",
"",
"Which toppings do you want?",
"",
" 1. [✔] Nuts",
" Crunchy, adds texture.",
"❯ 2. [ ] Honey",
" 3. [ ] Type something",
" Submit",
RULE,
" 4. Chat about this",
"",
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
];
const PERMISSION = [
" Bash command",
" touch probe-file.txt",
"╌".repeat(60),
" Do you want to proceed?",
" ❯ 1. Yes",
" 2. Yes, and always allow",
" 3. No",
"",
" Esc to cancel · Tab to amend",
];
const CODEX_APPROVAL = [
"• Running touch probe.txt",
"",
" Would you like to run the following command?",
" $ touch probe.txt",
"",
"› 1. Yes, proceed (y)",
" 2. Yes, and don't ask again for commands that start with `touch probe.txt` (p)",
" 3. No, and tell Codex what to do differently",
"",
// "Press enter to confirm or esc to cancel", its dim cells left out.
" enteresc",
];
+90
View File
@@ -0,0 +1,90 @@
/**
* @file imeInput.ts
* @description Typing through an input method that edits xterm's textarea
* without composition events — macOS's Vietnamese Telex, in Chrome and
* Safari, writes each letter with `insertText` and rewrites the whole word
* with `insertReplacementText` ("cha" → "chao" → "chào"), its keydown
* (keyCode 229) arriving only AFTER the change. xterm 5.5 drops every
* replacement (xterm.js #5887) and every `input` that lands while a key is
* held (Shift, for a capital), so "Abc xin chào" reached the pane as " x c".
* `attachImeInput` turns each such change into what a line editor needs — a
* backspace per replaced character, then the new tail — and hides those
* events from xterm so nothing is sent twice. A key xterm sends itself (its
* `keypress`, e.g. Space) and a real composition (CJK) stay xterm's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** DEL, the byte a terminal's Backspace sends (xterm's too). */
const DEL = "\x7f";
/** The edits this module answers; anything else stays xterm's. */
const EDITS = new Set(["insertText", "insertReplacementText", "deleteContentBackward"]);
/** The backspaces and new tail that turn `before` into `after` — per
* character (code point), as the pane's line editor counts them. */
export function textEdit(before: string, after: string): string {
const a = Array.from(before);
const b = Array.from(after);
let same = 0;
while (same < a.length && same < b.length && a[same] === b[same]) same++;
return DEL.repeat(a.length - same) + b.slice(same).join("");
}
/** The parts of an xterm `Terminal` this needs. */
interface ImeTerminal {
element?: HTMLElement;
textarea?: HTMLTextAreaElement;
input: (data: string, wasUserInput?: boolean) => void;
}
/** Sends `term`'s non-composition textarea edits to the pane itself; returns
* its dispose. Listens in the capture phase on xterm's element, so it runs
* before xterm's own listeners on the textarea and can keep them from it. */
export function attachImeInput(term: ImeTerminal): () => void {
const root = term.element;
const ta = term.textarea;
if (!root || !ta) return () => {};
// The value before the edit in flight (from `beforeinput`).
let before: string | null = null;
// xterm sent this key from its keypress: the input event that follows is its.
let pressed = false;
const onKeyDown = (e: KeyboardEvent) => {
if (e.target !== ta) return;
pressed = false;
// The IME's own key, its edit already in (or about to come as) an input
// event: xterm's fallback would diff the textarea and send it again.
if (e.keyCode === 229 && !e.isComposing) e.stopPropagation();
};
const onKeyPress = (e: KeyboardEvent) => {
if (e.target === ta) pressed = true;
};
const onBeforeInput = (e: Event) => {
if (e.target === ta) before = ta.value;
};
const onInput = (e: Event) => {
const ev = e as InputEvent;
if (ev.target !== ta) return;
const was = before;
before = null;
if (pressed) {
pressed = false;
return;
}
if (ev.isComposing || !EDITS.has(ev.inputType) || was === null) return;
e.stopPropagation();
const data = textEdit(was, ta.value);
if (data) term.input(data, true);
};
root.addEventListener("keydown", onKeyDown, true);
root.addEventListener("keypress", onKeyPress, true);
root.addEventListener("beforeinput", onBeforeInput, true);
root.addEventListener("input", onInput, true);
return () => {
root.removeEventListener("keydown", onKeyDown, true);
root.removeEventListener("keypress", onKeyPress, true);
root.removeEventListener("beforeinput", onBeforeInput, true);
root.removeEventListener("input", onInput, true);
};
}
+141
View File
@@ -14,6 +14,9 @@
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
* input box off the screen and empties it; the chat's draft is pasted back
* without Enter), so the half-typed text is never left behind on the other side.
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
* arrows and Enter, or type into its "Type something" row).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -151,3 +154,141 @@ export function takeRunInput(runId: string): string {
p!.key!("\x1b[3~".repeat(n) + "\x7f".repeat(n));
return text;
}
/** One choice of a menu the CLI shows: `n` its number (null for Claude's
* unnumbered Submit row), `checked` a multiSelect box's state. */
export type PaneMenuOption = {
n: number | null;
label: string;
hint: string;
checked: boolean | null;
};
/** 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). */
export type PaneMenu = {
question: string;
options: PaneMenuOption[];
cursor: number;
multi: boolean;
typeAt: number;
};
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
const RULE_ROW = /^ *[─╌]{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/;
const PLACEHOLDER = /^Type something\.?$/;
/** 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
* above its first row — a reply's numbered list has no cursor, and a list
* typed into Claude's input box sits under the box's rule. */
export function parsePaneMenu(rows: string[]): PaneMenu | null {
const r = rows.map((l) => l.trimEnd());
let end = r.length - 1;
while (end >= 0 && !r[end]) end--;
const options: PaneMenuOption[] = [];
let cursor = -1;
let hint: string[] = [];
let top = -1;
// Bottom up: a row under an option that is no option is its hint.
for (let i = end; i >= 0 && end - i < 40; i--) {
const row = r[i] ?? "";
const m = OPTION.exec(row);
const s = m ? null : SUBMIT.exec(row);
if (m || s) {
const text = m ? (m[3] ?? "") : "Submit";
const c = m ? CHECK.exec(text) : null;
const label = c ? (c[2] ?? "") : text;
const h = hint.join(" ");
options.unshift({
n: m ? Number(m[2]) : null,
label,
hint: h === label ? "" : h,
checked: c ? c[1] === "✔" : null,
});
if (m?.[1] || s?.[1]) cursor = options.length;
hint = [];
if (m?.[2] === "1") {
top = i;
break;
}
} else if (row && !RULE_ROW.test(row) && options.length) {
hint.unshift(row.trim());
} else if (row && !RULE_ROW.test(row)) {
hint = []; // the footer and anything under the last option
}
}
const numbered = options.filter((o) => o.n !== null);
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? "")) return null;
if (numbered.some((o, i) => o.n !== i + 1)) return null;
// The question: the block of text right above the first option.
let q = top - 1;
while (q >= 0 && !r[q]) q--;
const lines: string[] = [];
for (; q >= 0 && r[q] && !RULE_ROW.test(r[q]!) && lines.length < 4; q--)
lines.unshift(r[q]!.trim());
return {
question: lines.join("\n"),
options,
cursor: options.length - cursor,
multi: options.some((o) => o.checked !== null),
typeAt: typeRow(options),
};
}
/** The "Type something" row. Text typed into it replaces its label, and it is
* still the last numbered row above "Chat about this". */
function typeRow(options: PaneMenuOption[]): number {
const at = options.findIndex((o) => PLACEHOLDER.test(o.label));
if (at >= 0) return at;
for (let i = options.findIndex((o) => o.label === "Chat about this") - 1; i >= 0; i--)
if (options[i]!.n !== null) return i;
return -1;
}
/** The menu `runId`'s visible pane shows now, or null. */
export function readRunMenu(runId: string): PaneMenu | null {
const p = panes.get(runId);
const screen = p?.visible() && p.key ? p.screen?.() : null;
return screen ? parsePaneMenu(screen.rows) : null;
}
// One key per write, a beat apart: a TUI reading two arrows in one chunk may
// take them as one key.
function pressKeys(key: (data: string) => void, keys: string[]) {
keys.forEach((k, i) => setTimeout(() => key(k), i * 40));
}
/** The arrow presses from the menu's cursor to row `to`. */
const walk = (menu: PaneMenu, to: number) =>
Array<string>(Math.abs(to - menu.cursor)).fill(to > menu.cursor ? "\x1b[B" : "\x1b[A");
/** Picks `menu`'s option `index` in `runId`'s pane: its digit — which picks
* it, or ticks its box in a multiSelect — else (Submit) the arrows to it
* and Enter. False when the pane is not visible or not attached. */
export function chooseRunOption(runId: string, menu: PaneMenu, index: number): boolean {
const p = panes.get(runId);
const opt = menu.options[index];
if (!p?.visible() || !p.key || !opt) return false;
if (opt.n !== null && opt.n < 10) p.key(String(opt.n));
else pressKeys(p.key, [...walk(menu, index), "\r"]);
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
* paste, and Enter — none in a multiSelect, where the text only ticks its
* row and Submit is still to pick. */
export function answerRunMenu(runId: string, menu: PaneMenu, text: string): boolean {
const p = panes.get(runId);
const row = menu.options[menu.typeAt];
if (!p?.visible() || !p.key || !row) return false;
const keys = walk(menu, menu.typeAt);
if (!PLACEHOLDER.test(row.label)) keys.push("\x15");
keys.push(`\x1b[200~${text}\x1b[201~`);
pressKeys(p.key, menu.multi ? keys : [...keys, "\r"]);
return true;
}
+3
View File
@@ -3213,6 +3213,9 @@ export interface TranscriptContent {
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
* removed (from Claude Code's `structuredPatch`). */
diff?: { added: number; removed: number };
/** Present on an AskUserQuestion "tool_result": question text → the option
* the user picked, or what they typed. */
answers?: Record<string, string>;
}
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.7.4 (rebuild client to match)
UI build v2.7.5 (rebuild client to match)
</span>
</p>
</div>
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.7.4
version: 2.7.5
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.7.4",
"version": "2.7.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.7.4",
"version": "2.7.5",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.7.4",
"version": "2.7.5",
"bin": {
"agenthub": "bin/agenthub.js"
},
+20 -1
View File
@@ -18,7 +18,7 @@ const fs = require("node:fs");
const TEST_DB = path.join(os.tmpdir(), `transcript-sender-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { classifyTranscriptSender, patchCounts } = require("../routes/sessions");
const { classifyTranscriptSender, patchCounts, askAnswers } = require("../routes/sessions");
const { db } = require("../db");
after(() => {
@@ -168,3 +168,22 @@ describe("patchCounts — the +/- lines of a Write/Edit result", () => {
assert.equal(patchCounts(undefined), null);
});
});
describe("askAnswers — what the user picked or typed for an AskUserQuestion", () => {
it("returns each question's answer", () => {
const r = {
questions: [{ question: "Commit?", options: [{ label: "Yes" }, { label: "No" }] }],
answers: { "Commit?": "Yes", "Notes?": "typed text" },
annotations: {},
};
assert.deepEqual(askAnswers(r), { "Commit?": "Yes", "Notes?": "typed text" });
});
it("drops a non-string answer and answers null for a result that is no question", () => {
assert.deepEqual(askAnswers({ answers: { a: "x", b: 3 } }), { a: "x" });
assert.equal(askAnswers({ answers: {} }), null);
assert.equal(askAnswers({ answers: ["x"] }), null);
assert.equal(askAnswers({ stdout: "ok" }), null);
assert.equal(askAnswers("text"), null);
});
});
+13
View File
@@ -1029,6 +1029,7 @@ router.get("/:id/transcript", async (req, res) => {
} 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);
content.push({
type: "tool_result",
id: block.tool_use_id || null,
@@ -1040,6 +1041,7 @@ router.get("/:id/transcript", async (req, res) => {
),
is_error: !!block.is_error,
...(diff ? { diff } : {}),
...(answers ? { answers } : {}),
});
}
}
@@ -1330,6 +1332,16 @@ function patchCounts(r) {
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;
}
// 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
@@ -1353,3 +1365,4 @@ module.exports.sessionStats = sessionStats;
// Exported for unit tests — sender attribution is correctness-critical.
module.exports.classifyTranscriptSender = classifyTranscriptSender;
module.exports.patchCounts = patchCounts;
module.exports.askAnswers = askAnswers;