Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0dd5af22f | |||
| 7cb4f64781 | |||
| 69a2c37cd2 |
@@ -25,6 +25,7 @@ make while you work here as Codex.
|
|||||||
## Validation expectations
|
## Validation expectations
|
||||||
- Backend changes: run `npm run test:server` when possible.
|
- Backend changes: run `npm run test:server` when possible.
|
||||||
- Frontend changes: run `npm run test:client` 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.
|
- If any check is skipped, report it explicitly.
|
||||||
|
|
||||||
## Safety expectations
|
## Safety expectations
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# 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
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- 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
|
## Testing and verification policy
|
||||||
- Backend changes: run `npm run test:server` before finishing.
|
- 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.
|
- 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.
|
- If you cannot run a verification step, state exactly what was not run and why.
|
||||||
|
|
||||||
## Change guidelines by area
|
## Change guidelines by area
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ interface ConversationViewProps {
|
|||||||
waiting?: boolean;
|
waiting?: boolean;
|
||||||
/** Shows the pane's terminal, where that question is answered. */
|
/** Shows the pane's terminal, where that question is answered. */
|
||||||
onOpenTerminal?: () => void;
|
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
|
/** The newest reply's prompt size (input + cache) and model: how full the
|
||||||
* context is. */
|
* context is. */
|
||||||
onContext?: (tokens: number, model: string | null) => void;
|
onContext?: (tokens: number, model: string | null) => void;
|
||||||
@@ -113,6 +115,7 @@ export function ConversationView({
|
|||||||
follow = 0,
|
follow = 0,
|
||||||
waiting = false,
|
waiting = false,
|
||||||
onOpenTerminal,
|
onOpenTerminal,
|
||||||
|
stopped = 0,
|
||||||
onContext,
|
onContext,
|
||||||
onNotStarted,
|
onNotStarted,
|
||||||
}: ConversationViewProps) {
|
}: ConversationViewProps) {
|
||||||
@@ -558,7 +561,11 @@ export function ConversationView({
|
|||||||
{t("conversation.notStartedBody")}
|
{t("conversation.notStartedBody")}
|
||||||
</p>
|
</p>
|
||||||
{onOpenTerminal && (
|
{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")}
|
{t("conversation.openTerminal")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -578,6 +585,7 @@ export function ConversationView({
|
|||||||
live={live}
|
live={live}
|
||||||
waiting={waiting}
|
waiting={waiting}
|
||||||
onOpenTerminal={onOpenTerminal}
|
onOpenTerminal={onOpenTerminal}
|
||||||
|
stopped={stopped}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
* files ends with Claude Desktop's "Edited N files" card: every file with the
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* chat (`live`) ends, until its turn is over, with what the model is doing
|
||||||
@@ -95,6 +97,8 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Hand,
|
Hand,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
|
MessageCircleQuestion,
|
||||||
|
CornerDownRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||||
|
|
||||||
@@ -156,6 +160,9 @@ interface MessageListProps {
|
|||||||
waiting?: boolean;
|
waiting?: boolean;
|
||||||
/** Shows the pane's terminal, where that question is answered. */
|
/** Shows the pane's terminal, where that question is answered. */
|
||||||
onOpenTerminal?: () => void;
|
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 */
|
/** Build a map from tool_use id → tool_result for matching */
|
||||||
@@ -458,6 +465,7 @@ type Item =
|
|||||||
| { kind: "user"; key: string; msg: TranscriptMessage }
|
| { kind: "user"; key: string; msg: TranscriptMessage }
|
||||||
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
|
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
|
||||||
| { kind: "work"; key: string; blocks: TranscriptContent[] }
|
| { kind: "work"; key: string; blocks: TranscriptContent[] }
|
||||||
|
| { kind: "ask"; key: string; block: TranscriptContent }
|
||||||
| { kind: "edits"; key: string; blocks: TranscriptContent[] }
|
| { kind: "edits"; key: string; blocks: TranscriptContent[] }
|
||||||
| { kind: "done"; key: string; ms: number; output: number };
|
| { 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) => {
|
msg.content.forEach((block, b) => {
|
||||||
const last = items[items.length - 1];
|
const last = items[items.length - 1];
|
||||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
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);
|
if (last?.kind === "work") last.blocks.push(block);
|
||||||
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
|
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
|
||||||
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
|
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
|
||||||
@@ -611,15 +622,24 @@ export function MessageList({
|
|||||||
live = false,
|
live = false,
|
||||||
waiting = false,
|
waiting = false,
|
||||||
onOpenTerminal,
|
onOpenTerminal,
|
||||||
|
stopped = 0,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
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(
|
const pending = useMemo(
|
||||||
() => (live ? workingOn(messages, toolResultMap) : null),
|
() => (live && !halted ? workingOn(messages, toolResultMap) : null),
|
||||||
[live, messages, toolResultMap]
|
[live, halted, messages, toolResultMap]
|
||||||
);
|
);
|
||||||
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
|
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
|
||||||
const keys = useMemo(() => messageKeys(messages), [messages]);
|
|
||||||
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -657,6 +677,14 @@ export function MessageList({
|
|||||||
return (
|
return (
|
||||||
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
|
<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":
|
case "edits":
|
||||||
// The card closes a finished turn; one still working has none yet.
|
// The card closes a finished turn; one still working has none yet.
|
||||||
if (pending && item === items[items.length - 1]) return null;
|
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. */
|
/** 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 }) {
|
function TurnDone({ ms, output }: { ms: number; output: number }) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
return (
|
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();
|
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", () => {
|
it("counts a turn's output once per API call, since its prompt", () => {
|
||||||
const at = "2026-06-26T08:19:00.000Z";
|
const at = "2026-06-26T08:19:00.000Z";
|
||||||
const line = (cache: number, out: number) =>
|
const line = (cache: number, out: number) =>
|
||||||
|
|||||||
@@ -277,6 +277,13 @@ function HomeBody({
|
|||||||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||||
});
|
});
|
||||||
setPrompt("");
|
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));
|
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||||
setImages([]);
|
setImages([]);
|
||||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
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 { Lane, Session } from "../../../lib/types";
|
||||||
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
|
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
|
||||||
|
|
||||||
@@ -241,6 +241,27 @@ describe("WorkspaceHome", () => {
|
|||||||
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
|
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 () => {
|
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
|
||||||
renderHome();
|
renderHome();
|
||||||
await screen.findByTestId("workspace-recent-s-live");
|
await screen.findByTestId("workspace-recent-s-live");
|
||||||
|
|||||||
@@ -12,11 +12,18 @@
|
|||||||
* `/model <id>` / `/effort <level>` — and the permission mode, which only
|
* `/model <id>` / `/effort <level>` — and the permission mode, which only
|
||||||
* Shift+Tab changes in a running session, so the server steps it and reads
|
* 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
|
* 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,
|
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
||||||
* outputs, what the session used), open at first in a pane spanning the
|
* 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
|
* grid's full width or height (`panelOpen`) once the session has an output,
|
||||||
* with the output count in a quarter-sized one; it sits beside the
|
* 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
|
* 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
|
* 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
|
* 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 { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { ConversationView } from "../conversation/ConversationView";
|
||||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||||
import { ChatAddMenu } from "./ChatAddMenu";
|
import { ChatAddMenu } from "./ChatAddMenu";
|
||||||
@@ -59,7 +67,14 @@ import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } f
|
|||||||
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
|
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
import { Divider, showTerminal } from "./PaneGrid";
|
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 { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||||
import { openInChanges } from "../../lib/openInChanges";
|
import { openInChanges } from "../../lib/openInChanges";
|
||||||
import { toast } from "../../lib/toast";
|
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 isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
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 storedSide = () => {
|
||||||
const f = Number(localStorage.getItem(SIDE_KEY));
|
const f = Number(localStorage.getItem(SIDE_KEY));
|
||||||
return f ? clampSide(f) : null;
|
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({
|
export function PaneChat({
|
||||||
run,
|
run,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -213,11 +335,20 @@ export function PaneChat({
|
|||||||
const [model, setModel] = useState(run.model ?? "");
|
const [model, setModel] = useState(run.model ?? "");
|
||||||
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
||||||
const pickFiles = useRef<(() => void) | null>(null);
|
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.
|
// 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) => {
|
const openOutline = (open: boolean) => {
|
||||||
setOutlineOpen(open);
|
sessionStorage.setItem(outlineKey(sessionId), open ? "1" : "0");
|
||||||
|
setOutlineChoice(open);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
};
|
};
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
@@ -240,6 +371,8 @@ export function PaneChat({
|
|||||||
}, []);
|
}, []);
|
||||||
const [list, setList] = useState<OutlineList | null>(null);
|
const [list, setList] = useState<OutlineList | null>(null);
|
||||||
const activity = useSessionOutline(sessionId);
|
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 scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||||
const text = promptWithImages(prompt, images);
|
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.
|
// waits on the user from a Notification (a permission prompt) to the next hook.
|
||||||
const [working, setWorking] = useState(false);
|
const [working, setWorking] = useState(false);
|
||||||
const [waiting, setWaiting] = 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(
|
const readWorking = useCallback(
|
||||||
() =>
|
() =>
|
||||||
api.agents
|
api.agents
|
||||||
@@ -275,14 +410,41 @@ export function PaneChat({
|
|||||||
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
(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.
|
// The menu the terminal underneath shows now. AskUserQuestion fires no
|
||||||
const was = useRef({ working: false, waiting: false });
|
// 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(() => {
|
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 (working) setTabBadge(runId, null);
|
||||||
else if (was.current.working || was.current.waiting) setTabBadge(runId, "done");
|
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
|
||||||
was.current = { working, waiting };
|
was.current = { working, asking };
|
||||||
}, [runId, working, waiting]);
|
}, [runId, working, asking]);
|
||||||
useEffect(() => () => setTabBadge(runId, null), [runId]);
|
useEffect(() => () => setTabBadge(runId, null), [runId]);
|
||||||
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
|
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
|
||||||
tokens: 0,
|
tokens: 0,
|
||||||
@@ -339,7 +501,8 @@ export function PaneChat({
|
|||||||
fill
|
fill
|
||||||
live
|
live
|
||||||
follow={follow}
|
follow={follow}
|
||||||
waiting={waiting}
|
waiting={asking}
|
||||||
|
stopped={stops}
|
||||||
onOpenTerminal={() => showTerminal(runId, sessionId)}
|
onOpenTerminal={() => showTerminal(runId, sessionId)}
|
||||||
onContext={onContext}
|
onContext={onContext}
|
||||||
onNotStarted={setNotStarted}
|
onNotStarted={setNotStarted}
|
||||||
@@ -366,7 +529,24 @@ export function PaneChat({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mx-4">
|
<div className="mx-4">
|
||||||
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
|
<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
|
<PromptEditor
|
||||||
bare
|
bare
|
||||||
images={images}
|
images={images}
|
||||||
@@ -413,8 +593,10 @@ export function PaneChat({
|
|||||||
data-testid="pane-chat-stop"
|
data-testid="pane-chat-stop"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// An interrupted turn fires no Stop hook: clear it here.
|
// An interrupted turn fires no Stop hook: clear it here.
|
||||||
if (interruptRun(runId)) setWorking(false);
|
if (interruptRun(runId)) {
|
||||||
else toast.error(t("chat.notStopped"));
|
setWorking(false);
|
||||||
|
setStops((n) => n + 1);
|
||||||
|
} else toast.error(t("chat.notStopped"));
|
||||||
}}
|
}}
|
||||||
aria-label={t("chat.stop")}
|
aria-label={t("chat.stop")}
|
||||||
title={t("chat.stop")}
|
title={t("chat.stop")}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
* from its terminal — announced by a hint the textarea is described by;
|
* 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
|
* 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.
|
* (`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
|
* 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
|
* (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
|
* 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`).
|
* here takes the lead back (`resize` with `drive: true`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import type { Terminal } from "@xterm/xterm";
|
import type { Terminal } from "@xterm/xterm";
|
||||||
import type { CanvasAddon } from "@xterm/addon-canvas";
|
import type { CanvasAddon } from "@xterm/addon-canvas";
|
||||||
@@ -49,6 +60,7 @@ import { useTheme } from "../../hooks/useTheme";
|
|||||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||||
|
import { attachImeInput } from "../../lib/imeInput";
|
||||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||||
|
|
||||||
type DropState = "hint" | "uploading" | "failed";
|
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
|
// 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
|
// 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
|
// paste listener stops the event. Text is xterm's to paste, Ctrl+V's too;
|
||||||
// ^V again (vim's block select, readline's quoted insert), not a text paste.
|
// 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 onPaste = (e: ClipboardEvent) => {
|
||||||
const ctrlV = ctrlVRef.current;
|
const ctrlV = ctrlVRef.current;
|
||||||
ctrlVRef.current = false;
|
ctrlVRef.current = false;
|
||||||
const files = Array.from(e.clipboardData?.files ?? []);
|
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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (files.length > 0) void attachFiles(files);
|
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
|
// Ctrl+V: xterm would send ^V, and claude would then read the
|
||||||
// SERVER's clipboard, never this browser's. Let the browser paste
|
// 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") {
|
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "v") {
|
||||||
ctrlVRef.current = true;
|
ctrlVRef.current = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -519,29 +533,11 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
const reassert = takeLead;
|
const reassert = takeLead;
|
||||||
term.textarea?.addEventListener("focus", reassert);
|
term.textarea?.addEventListener("focus", reassert);
|
||||||
|
|
||||||
// Safari with macOS's own Vietnamese Telex types a word by REPLACING it:
|
// macOS's own Vietnamese Telex (Safari and Chrome) types into the
|
||||||
// after the first letter every key fires `insertReplacementText` carrying
|
// textarea with no composition events: a capital arrives while Shift is
|
||||||
// the whole word ("x" → "xi" → "xin"), before its keydown. xterm forwards
|
// held and every later key REPLACES the word — both dropped by xterm
|
||||||
// only `insertText`, and its keyCode-229 fallback snapshots the textarea
|
// (xterm.js #5887). lib/imeInput.ts sends those edits itself.
|
||||||
// after the change, so all but each word's first letter was lost (xterm.js
|
const detachIme = attachImeInput(term);
|
||||||
// #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);
|
|
||||||
|
|
||||||
const refit = () => {
|
const refit = () => {
|
||||||
// Never fit a collapsed console — see sendSize.
|
// Never fit a collapsed console — see sendSize.
|
||||||
@@ -571,8 +567,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||||
term.textarea?.removeEventListener("focus", reassert);
|
term.textarea?.removeEventListener("focus", reassert);
|
||||||
term.textarea?.removeEventListener("beforeinput", onBeforeReplace);
|
detachIme();
|
||||||
term.textarea?.removeEventListener("input", onReplace);
|
|
||||||
resizeDisposable.dispose();
|
resizeDisposable.dispose();
|
||||||
dataDisposable.dispose();
|
dataDisposable.dispose();
|
||||||
ws?.close();
|
ws?.close();
|
||||||
|
|||||||
@@ -7,16 +7,25 @@
|
|||||||
* activity panel, collapsed into a file button with the output count, whose
|
* 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
|
* 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
|
* 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>
|
* @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 { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { PaneChat, contextWindow } from "../PaneChat";
|
import { PaneChat, contextWindow } from "../PaneChat";
|
||||||
import { api } from "../../../lib/api";
|
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";
|
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||||
|
|
||||||
vi.mock("../../conversation/ConversationView", () => ({
|
vi.mock("../../conversation/ConversationView", () => ({
|
||||||
@@ -41,6 +50,9 @@ vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
|
|||||||
vi.mock("../../../lib/panePaste", () => ({
|
vi.mock("../../../lib/panePaste", () => ({
|
||||||
submitToRun: vi.fn(() => true),
|
submitToRun: vi.fn(() => true),
|
||||||
interruptRun: 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) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
@@ -136,6 +148,7 @@ beforeEach(() => {
|
|||||||
vi.mocked(submitToRun).mockClear();
|
vi.mocked(submitToRun).mockClear();
|
||||||
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
|
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
|
||||||
localStorage.removeItem("agenthub.chat.draft.s1");
|
localStorage.removeItem("agenthub.chat.draft.s1");
|
||||||
|
sessionStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PaneChat — the + menu", () => {
|
describe("PaneChat — the + menu", () => {
|
||||||
@@ -301,27 +314,63 @@ describe("PaneChat — the activity panel", () => {
|
|||||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
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) => (
|
const pane = (panelOpen: boolean) => (
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
const { rerender } = render(pane(true));
|
const { rerender } = render(pane(true));
|
||||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
await screen.findByTestId("chat-outline");
|
||||||
rerender(pane(false));
|
rerender(pane(false));
|
||||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||||
expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
|
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");
|
localStorage.removeItem("agenthub.chat.panelWidth");
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
||||||
</MemoryRouter>
|
</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");
|
expect(splitter.getAttribute("aria-valuenow")).toBe("70");
|
||||||
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
|
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
|
||||||
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
|
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
|
||||||
@@ -412,3 +461,51 @@ describe("PaneChat — the unsent prompt", () => {
|
|||||||
expect(confirmDialog).not.toHaveBeenCalled();
|
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,
|
paste: pasteMock,
|
||||||
input: inputMock,
|
input: inputMock,
|
||||||
textarea: (textarea = document.createElement("textarea")),
|
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 }),
|
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
|
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
|
||||||
// reads the SERVER's clipboard, not the browser's.
|
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
|
||||||
it("lets Ctrl+V reach the browser's paste, and still sends ^V when it holds no file", () => {
|
// 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();
|
inputMock.mockClear();
|
||||||
pasteMock.mockClear();
|
|
||||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||||
expect(handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }))).toBe(false);
|
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"), {
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
clipboardData: { files: [], getData: () => "some text" },
|
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: () => "" },
|
||||||
});
|
});
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(inputMock).toHaveBeenCalledTimes(1);
|
||||||
expect(inputMock).toHaveBeenCalledWith("\x16", true);
|
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", () => {
|
it("sends ^V on Ctrl+V when the browser fires no paste at all", () => {
|
||||||
|
|||||||
@@ -127,6 +127,10 @@
|
|||||||
"tab": {
|
"tab": {
|
||||||
"waiting": "Waiting",
|
"waiting": "Waiting",
|
||||||
"done": "Done"
|
"done": "Done"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"placeholder": "Type your answer…",
|
||||||
|
"cancel": "Cancel (Esc)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interrupted": {
|
"interrupted": {
|
||||||
|
|||||||
@@ -209,6 +209,7 @@
|
|||||||
"moreSteps_one": "+{{count}} more",
|
"moreSteps_one": "+{{count}} more",
|
||||||
"moreSteps_other": "+{{count}} more",
|
"moreSteps_other": "+{{count}} more",
|
||||||
"quiet": "no new activity for {{time}}",
|
"quiet": "no new activity for {{time}}",
|
||||||
|
"askDeclined": "Declined — no answer given",
|
||||||
"turnDone": "Done in {{time}}"
|
"turnDone": "Done in {{time}}"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
|
|||||||
@@ -127,6 +127,10 @@
|
|||||||
"tab": {
|
"tab": {
|
||||||
"waiting": "Đang chờ",
|
"waiting": "Đang chờ",
|
||||||
"done": "Xong"
|
"done": "Xong"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"placeholder": "Nhập câu trả lời của bạn…",
|
||||||
|
"cancel": "Huỷ (Esc)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interrupted": {
|
"interrupted": {
|
||||||
|
|||||||
@@ -209,6 +209,7 @@
|
|||||||
"moreSteps_one": "+{{count}} việc nữa",
|
"moreSteps_one": "+{{count}} việc nữa",
|
||||||
"moreSteps_other": "+{{count}} việc nữa",
|
"moreSteps_other": "+{{count}} việc nữa",
|
||||||
"quiet": "không có hoạt động mới trong {{time}}",
|
"quiet": "không có hoạt động mới trong {{time}}",
|
||||||
|
"askDeclined": "Đã từ chối — không trả lời",
|
||||||
"turnDone": "Xong trong {{time}}"
|
"turnDone": "Xong trong {{time}}"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,16 +6,22 @@
|
|||||||
* is not on screen (a hidden console behind a full-width Changes panel); and
|
* 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;
|
* `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 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
interruptRun,
|
interruptRun,
|
||||||
notePaneFocus,
|
notePaneFocus,
|
||||||
parsePromptInput,
|
parsePromptInput,
|
||||||
|
answerRunMenu,
|
||||||
|
chooseRunOption,
|
||||||
|
parsePaneMenu,
|
||||||
pasteToLane,
|
pasteToLane,
|
||||||
|
readRunMenu,
|
||||||
registerPane,
|
registerPane,
|
||||||
takeRunInput,
|
takeRunInput,
|
||||||
} from "../panePaste";
|
} from "../panePaste";
|
||||||
@@ -122,4 +128,186 @@ describe("panePaste", () => {
|
|||||||
off();
|
off();
|
||||||
expect(takeRunInput("agenthub-lane-7")).toBe("");
|
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",
|
||||||
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@
|
|||||||
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
|
* 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
|
* 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.
|
* 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>
|
* @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));
|
p!.key!("\x1b[3~".repeat(n) + "\x7f".repeat(n));
|
||||||
return text;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3213,6 +3213,9 @@ export interface TranscriptContent {
|
|||||||
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
|
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
|
||||||
* removed (from Claude Code's `structuredPatch`). */
|
* removed (from Claude Code's `structuredPatch`). */
|
||||||
diff?: { added: number; removed: number };
|
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
|
/** 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
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.7.4 (rebuild client to match)
|
UI build v2.7.5 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
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.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.7.4",
|
"version": "2.7.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.7.4",
|
"version": "2.7.5",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.7.4",
|
"version": "2.7.5",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const fs = require("node:fs");
|
|||||||
const TEST_DB = path.join(os.tmpdir(), `transcript-sender-${Date.now()}-${process.pid}.db`);
|
const TEST_DB = path.join(os.tmpdir(), `transcript-sender-${Date.now()}-${process.pid}.db`);
|
||||||
process.env.DASHBOARD_DB_PATH = TEST_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");
|
const { db } = require("../db");
|
||||||
|
|
||||||
after(() => {
|
after(() => {
|
||||||
@@ -168,3 +168,22 @@ describe("patchCounts — the +/- lines of a Write/Edit result", () => {
|
|||||||
assert.equal(patchCounts(undefined), null);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1029,6 +1029,7 @@ router.get("/:id/transcript", async (req, res) => {
|
|||||||
} else if (block.type === "tool_result") {
|
} else if (block.type === "tool_result") {
|
||||||
// A line carries one tool result; an edit's +/- counts ride on it.
|
// A line carries one tool result; an edit's +/- counts ride on it.
|
||||||
const diff = patchCounts(entry.toolUseResult);
|
const diff = patchCounts(entry.toolUseResult);
|
||||||
|
const answers = askAnswers(entry.toolUseResult);
|
||||||
content.push({
|
content.push({
|
||||||
type: "tool_result",
|
type: "tool_result",
|
||||||
id: block.tool_use_id || null,
|
id: block.tool_use_id || null,
|
||||||
@@ -1040,6 +1041,7 @@ router.get("/:id/transcript", async (req, res) => {
|
|||||||
),
|
),
|
||||||
is_error: !!block.is_error,
|
is_error: !!block.is_error,
|
||||||
...(diff ? { diff } : {}),
|
...(diff ? { diff } : {}),
|
||||||
|
...(answers ? { answers } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1330,6 +1332,16 @@ function patchCounts(r) {
|
|||||||
return null;
|
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
|
// 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 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
|
// (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.
|
// Exported for unit tests — sender attribution is correctness-critical.
|
||||||
module.exports.classifyTranscriptSender = classifyTranscriptSender;
|
module.exports.classifyTranscriptSender = classifyTranscriptSender;
|
||||||
module.exports.patchCounts = patchCounts;
|
module.exports.patchCounts = patchCounts;
|
||||||
|
module.exports.askAnswers = askAnswers;
|
||||||
|
|||||||
Reference in New Issue
Block a user