From b746a0a3a99d969c1d9af5c1eae494ee98a52cbd Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:36:40 +0800 Subject: [PATCH] fix(agents): flatten the conversation and tidy command approvals (#2083) Flatten the agent conversation into a dense tool-window layout and show command approvals as an inline strip with the request's own command (shell wrapper unwrapped), standard buttons and no horizontal overflow. --- crates/ide-agent/src/lib.rs | 27 +- crates/ide-agent/src/native/codex/mod.rs | 2 +- crates/ide-agent/src/native/codex/tests.rs | 30 +- .../ide-agent/src/native/codex/translate.rs | 58 +++- crates/ide-agent/src/tests.rs | 14 + e2e/agentApproval.spec.ts | 122 ++++++++ e2e/agentMarkdown.spec.ts | 15 +- src/features/agents/AgentReply.tsx | 27 +- src/features/agents/Session.module.css | 281 +++++++++--------- src/features/agents/SessionEditor.tsx | 176 ++++++++--- src/features/agents/permission.test.ts | 67 +++++ src/features/agents/permission.ts | 55 ++++ src/features/agents/sessionState.test.tsx | 119 +++++++- src/platform/locales/text/en.json | 15 +- src/platform/locales/text/zh.json | 15 +- src/ui/icons.tsx | 8 + 16 files changed, 790 insertions(+), 241 deletions(-) create mode 100644 e2e/agentApproval.spec.ts create mode 100644 src/features/agents/permission.test.ts create mode 100644 src/features/agents/permission.ts diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index d61845f51..af30ddbdb 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -1756,12 +1756,7 @@ impl Task { fn ask_permission(self: &Arc, params: &Value) -> Value { let id = self.next_permission.fetch_add(1, Ordering::Relaxed); let (questions, by_index) = asked_questions(params); - let title = params - .pointer("/toolCall/title") - .and_then(Value::as_str) - .filter(|t| !t.is_empty()) - .unwrap_or("The agent asks for permission") - .to_owned(); + let title = permission_title(params); // An agent that answers by index titles its request "Ask user N question(s)"; its // first question says what it wants, as the Claude Code adapter titles its own. let title = @@ -1935,6 +1930,26 @@ fn clean(mut prompt: AgentPrompt) -> Option { (!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt) } +fn permission_title(params: &Value) -> String { + let call = ¶ms["toolCall"]; + let title = call.get("title").and_then(Value::as_str).filter(|t| !t.is_empty()); + if call.get("kind").and_then(Value::as_str) == Some("execute") { + if let Some(command) = call.pointer("/rawInput/command").and_then(Value::as_str).filter(|c| !c.is_empty()) { + // Network approvals describe the host rather than approving command execution. + if !title.is_some_and(|t| t.starts_with("Allow network access to ")) { + return format!("Run {command}"); + } + } + if title.is_none() + && let Some(content) = call.get("content").and_then(Value::as_array) + && let Some(text) = content.iter().find_map(|c| c.pointer("/content/text").and_then(Value::as_str)) + { + return format!("Run {text}"); + } + } + title.unwrap_or("The agent asks for permission").to_owned() +} + /// The questions a permission request asks, and whether they answer by question index. /// The Claude Code adapter carries them as `_meta.questions`, answered as the option id; /// Qwen Code (ACP) as `_meta.qwenQuestions`, answered with `answers` keyed by index beside diff --git a/crates/ide-agent/src/native/codex/mod.rs b/crates/ide-agent/src/native/codex/mod.rs index a7d8c850d..945b9a908 100644 --- a/crates/ide-agent/src/native/codex/mod.rs +++ b/crates/ide-agent/src/native/codex/mod.rs @@ -822,7 +822,7 @@ impl CodexShim { match method { "item/commandExecution/requestApproval" => { let choices = translate::decisions(params, true); - let title = translate::command_approval_title(params, item.as_ref(), cwd); + let title = translate::command_approval_title(params, item.as_ref()); let call = tool_call_of( &item_id, &title, diff --git a/crates/ide-agent/src/native/codex/tests.rs b/crates/ide-agent/src/native/codex/tests.rs index 66118360b..43499711c 100644 --- a/crates/ide-agent/src/native/codex/tests.rs +++ b/crates/ide-agent/src/native/codex/tests.rs @@ -247,13 +247,39 @@ fn approvals_offer_what_codex_offers_and_always_a_decline() { assert_eq!( command_approval_title( &json!({"command": "/bin/zsh -lc 'ls'", "commandActions": [{"type": "unknown", "command": "touch b"}]}), - None, None ), - "Run touch b" + "Run ls" ); } +#[test] +fn approval_title_shows_the_script_inside_a_shell_wrapper() { + let title = |command: &str| command_approval_title(&json!({ "command": command }), None); + assert_eq!( + title(r#"/bin/zsh -lc "python3 -c 'import pathlib\np = pathlib.Path(\"a.txt\")'""#), + "Run python3 -c 'import pathlib\\np = pathlib.Path(\"a.txt\")'" + ); + assert_eq!(title(r#"bash -lc 'echo '\''hi'\'''"#), "Run echo 'hi'"); + assert_eq!(title("/bin/zsh -lc ls"), "Run /bin/zsh -lc ls"); + assert_eq!(title("/bin/zsh -lc 'ls' && rm x"), "Run /bin/zsh -lc 'ls' && rm x"); + assert_eq!(title("python3 -c 'print(1)'"), "Run python3 -c 'print(1)'"); +} + +#[test] +fn approval_command_uses_its_request_and_matching_item_only() { + let command = "python3 -c 'import pathlib\np = pathlib.Path(\"note.txt\")\np.write_text(\"hi\\n\")'"; + let pending_read = json!({"id": "read", "type": "commandExecution", "command": "ls", + "commandActions": [{"type": "listFiles", "path": "."}]}); + let request = json!({"itemId": "write", "command": command}); + assert_eq!(command_approval_title(&request, Some(&pending_read)), format!("Run {command}")); + let item = json!({"id": "write", "command": command}); + let request = json!({"itemId": "write"}); + assert_eq!(command_approval_title(&request, Some(&item)), format!("Run {command}")); + assert_eq!(command_approval_title(&request, Some(&pending_read)), "Run a command"); + assert_eq!(command_approval_title(&request, None), "Run a command"); +} + #[test] fn history_replays_turns() { let thread = json!({"turns": [{"id": "t", "status": "completed", "items": [ diff --git a/crates/ide-agent/src/native/codex/translate.rs b/crates/ide-agent/src/native/codex/translate.rs index 365d4fe62..2dc0a4152 100644 --- a/crates/ide-agent/src/native/codex/translate.rs +++ b/crates/ide-agent/src/native/codex/translate.rs @@ -733,22 +733,62 @@ pub(super) fn answer_of(choices: &[Choice], outcome: &Value, decline: Value) -> } /// The ACP title of a command approval. -pub(super) fn command_approval_title(params: &Value, item: Option<&Value>, cwd: Option<&str>) -> String { +pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String { if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) { return format!("Allow network access to {host}"); } - let from_item = item.and_then(|i| tool_call(i, false, cwd, None)).and_then(|c| str_of(&c, "title")); - let title = from_item.or_else(|| { - let synthetic = json!({ "id": "", "type": "commandExecution", "command": params.get("command"), - "commandActions": params.get("commandActions").cloned().unwrap_or(json!([])) }); - tool_call(&synthetic, false, cwd, None).and_then(|c| str_of(&c, "title")) - }); - match title.filter(|t| !t.is_empty()) { - Some(t) => format!("Run {t}"), + // Approval payloads can describe a different command from the item's earlier summary. + let item = item.filter(|i| i.get("id") == params.get("itemId")); + let command = params + .get("command") + .and_then(Value::as_str) + .filter(|c| !c.is_empty()) + .or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty())); + match command { + Some(command) => format!("Run {}", shell_script(command).unwrap_or_else(|| command.to_owned())), None => "Run a command".to_owned(), } } +/// The script of a ` -c ` command line, when the whole line is exactly that. +fn shell_script(command: &str) -> Option { + let (shell, rest) = command.split_once(' ')?; + if !matches!(shell.rsplit('/').next(), Some("sh" | "bash" | "zsh")) { + return None; + } + let quoted = rest.strip_prefix("-lc ").or_else(|| rest.strip_prefix("-c "))?; + let mut out = String::new(); + let mut chars = quoted.chars(); + // Adjacent quoted words, e.g. 'a'\''b' or "a \"b\"". + while let Some(c) = chars.next() { + match c { + '\'' => loop { + match chars.next()? { + '\'' => break, + c => out.push(c), + } + }, + '"' => loop { + match chars.next()? { + '"' => break, + '\\' => match chars.next()? { + c @ ('"' | '\\' | '$' | '`') => out.push(c), + '\n' => {} + c => { + out.push('\\'); + out.push(c); + } + }, + c => out.push(c), + } + }, + '\\' => out.push(chars.next()?), + _ => return None, + } + } + Some(out) +} + /// The ACP title of a file-change approval, from the item it is about. pub(super) fn file_approval_title(item: Option<&Value>, cwd: Option<&str>) -> String { let changes = item.and_then(|i| i.get("changes")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index 928ab0254..1ee70c274 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -3,6 +3,20 @@ use super::*; use tokio::io::duplex; +#[test] +fn permission_uses_its_own_command_payload() { + let command = "python3 -c 'import pathlib\npathlib.Path(\"a.txt\").touch()'"; + let mut request = json!({"toolCall": {"toolCallId": "write", "kind": "execute", + "title": "Run List .", "rawInput": {"command": command}}}); + assert_eq!(permission_title(&request), format!("Run {command}")); + request["toolCall"]["title"] = json!("Allow network access to example.com"); + assert_eq!(permission_title(&request), "Allow network access to example.com"); + request["toolCall"].as_object_mut().unwrap().remove("title"); + request["toolCall"].as_object_mut().unwrap().remove("rawInput"); + request["toolCall"]["content"] = json!([{ "type": "content", "content": {"type": "text", "text": command}}]); + assert_eq!(permission_title(&request), format!("Run {command}")); +} + /// Says "Hi", edits a file, asks to run a command and reports what it was allowed. #[derive(Default)] struct FakeAgent { diff --git a/e2e/agentApproval.spec.ts b/e2e/agentApproval.spec.ts new file mode 100644 index 000000000..146aac342 --- /dev/null +++ b/e2e/agentApproval.spec.ts @@ -0,0 +1,122 @@ +import { expect, test } from "@playwright/test"; +import type { AgentEntry, AgentTask } from "../src/bindings"; + +const task: AgentTask = { + id: "approval", + title: "Command approval", + status: "needsInput", + isolation: "local", + provider: "codex", + model: null, + cwd: "/dev/acme-api", + branch: null, + base: null, + baseCommit: null, + error: null, + createdMs: 0, + updatedMs: 0, + config: [], + commands: [], + usage: null, + queue: [], +}; +const command = `python3 -c 'import pathlib\np = pathlib.Path("note.txt")\np.write_text("${"long content ".repeat(80)}")'`; +const entries: AgentEntry[] = [ + { type: "user", text: "Write a note with Python.", attachments: [], steered: false }, + { type: "agent", text: "I will request approval before writing the note." }, + { + type: "tool", + id: "read", + title: "List .", + kind: "search", + status: "running", + locations: [], + output: null, + diffs: [], + }, + { + type: "permission", + id: 1, + title: `Run ${command}`, + options: [ + { id: "once", kind: "allow_once", name: "Allow once" }, + { id: "always", kind: "allow_always", name: `Always allow \`${command}\`` }, + { id: "decline", kind: "reject_once", name: "Decline" }, + { id: "cancel", kind: "reject_once", name: "Decline and stop" }, + ], + chosen: null, + }, +]; + +for (const theme of ["dark", "light"] as const) { + for (const width of [760, 1400]) { + test(`approval stays inside a flat transcript at ${width}px in ${theme}`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto("/"); + await page.getByRole("tree", { name: "Project" }).waitFor(); + await page.evaluate( + async ({ task, entries, theme }) => { + const path = "/src/features/agents/store.ts"; + const store = await import(path); + const themePath = "/src/workbench/theme.ts"; + (await import(themePath)).previewTheme(theme); + store.useAgents.setState({ + loaded: true, + tasks: new Map([[task.id, task]]), + entries: new Map([[task.id, entries]]), + }); + store.openTaskInEditorLocation(task.id); + }, + { task, entries, theme }, + ); + const card = page.getByRole("region", { name: "codex wants to run a command" }); + await expect(card).toHaveCount(1); + await expect(card.locator("pre")).toHaveText(command); + await expect(card.getByRole("button")).toHaveText(["Allow Once", "Always Allow", "Decline", "Decline and Stop"]); + const geometry = await card.evaluate((el) => { + const transcript = el.parentElement!; + const prompt = transcript.querySelector('[class*="_user_"]')!; + const reply = transcript.querySelector('[class*="_agentCard_"]')!; + const rect = el.getBoundingClientRect(); + const bounds = transcript.getBoundingClientRect(); + return { + scroll: transcript.scrollWidth, + client: transcript.clientWidth, + contained: rect.left >= bounds.left && rect.right <= bounds.right, + promptRadius: parseFloat(getComputedStyle(prompt).borderRadius), + replyBackground: getComputedStyle(reply).backgroundColor, + replyBorder: getComputedStyle(reply).borderTopWidth, + leftAligned: Math.abs(prompt.getBoundingClientRect().left - reply.getBoundingClientRect().left) < 1, + }; + }); + expect(geometry.scroll).toBe(geometry.client); + expect(geometry.contained).toBe(true); + expect(geometry.promptRadius).toBeLessThanOrEqual(4); + expect(geometry.replyBackground).toBe("rgba(0, 0, 0, 0)"); + expect(geometry.replyBorder).toBe("0px"); + expect(geometry.leftAligned).toBe(true); + await card.locator("pre").focus(); + for (const label of ["Allow Once", "Always Allow", "Decline", "Decline and Stop"]) { + await page.keyboard.press("Tab"); + await expect(card.getByRole("button", { name: label, exact: true })).toBeFocused(); + } + await page.evaluate(async () => { + const path = "/src/platform/ipc.ts"; + const { commands } = await import(path); + commands.agentRespond = async (task: string, permission: number, option: string) => { + (window as unknown as { approvalAnswer: unknown }).approvalAnswer = { task, permission, option }; + return { status: "ok", data: null }; + }; + }); + await card.getByRole("button", { name: "Allow Once", exact: true }).focus(); + await page.keyboard.press("Enter"); + await expect + .poll(() => page.evaluate(() => (window as unknown as { approvalAnswer: unknown }).approvalAnswer)) + .toEqual({ + task: task.id, + permission: 1, + option: "once", + }); + }); + } +} diff --git a/e2e/agentMarkdown.spec.ts b/e2e/agentMarkdown.spec.ts index 7ebc0f61a..f71476bc1 100644 --- a/e2e/agentMarkdown.spec.ts +++ b/e2e/agentMarkdown.spec.ts @@ -165,7 +165,7 @@ test("copies reply Markdown with the keyboard and keeps code copying separate", const copy = page.getByRole("button", { name: "Copy Markdown", exact: true }); const actions = copy.locator(".."); await page.mouse.move(0, 0); - await expect(actions).toHaveCSS("opacity", "1"); + await expect(actions).toHaveCSS("opacity", "0"); await copy.focus(); await expect(actions).toHaveCSS("opacity", "1"); await page.keyboard.press("Enter"); @@ -175,21 +175,21 @@ test("copies reply Markdown with the keyboard and keeps code copying separate", expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;"); }); -test("keeps message actions visible without shifting content and quotes into the draft", async ({ page }) => { +test("shows message actions on hover without shifting content and quotes into the draft", async ({ page }) => { const reply = await openReply(page, REPLY); const copy = page.getByRole("button", { name: "Copy Markdown", exact: true }); const actions = copy.locator(".."); await page.mouse.move(0, 0); - await expect(actions).toHaveCSS("opacity", "1"); + await expect(actions).toHaveCSS("opacity", "0"); const before = await reply.boundingBox(); await reply.hover(); await expect(actions).toHaveCSS("opacity", "1"); expect(await reply.boundingBox()).toEqual(before); await expect(actions.getByRole("button")).toHaveCount(4); await page.mouse.move(0, 0); - await expect(actions).toHaveCSS("opacity", "1"); + await expect(actions).toHaveCSS("opacity", "0"); const userCopy = page.getByRole("button", { name: "Copy", exact: true }); - await expect(userCopy.locator("..")).toHaveCSS("opacity", "1"); + await expect(userCopy.locator("..")).toHaveCSS("opacity", "0"); await page.getByText("Show Markdown", { exact: true }).hover(); await expect(userCopy.locator("..")).toHaveCSS("opacity", "1"); await reply.hover(); @@ -258,7 +258,8 @@ test("keeps chat history readable and selections free of timestamps and status l }); expect(geometry.reply).toBeGreaterThan(0); expect(geometry.reply).toBeLessThan(geometry.root); - expect(geometry.bubbleLeft).toBeGreaterThan(geometry.replyRight); + expect(geometry.bubbleLeft).toBe(geometry.replyLeft); + expect(geometry.bubbleRight).toBe(geometry.replyRight); expect(Math.abs(geometry.columnCenter - geometry.composerCenter)).toBeLessThan(1); await stream.getByText("hi", { exact: true }).hover(); await expect(stream.locator("[class*='_messageTime_']").first()).toBeVisible(); @@ -294,7 +295,7 @@ test("keeps chat history readable and selections free of timestamps and status l })); expect(narrow.width).toBeGreaterThan(0); expect(narrow.content).toBeLessThanOrEqual(narrow.width); - expect(narrow.reply).toBeLessThanOrEqual((narrow.width - narrow.padding) * 0.8); + expect(narrow.reply).toBe(narrow.width - narrow.padding); }); test("keeps hover actions reachable when the pointer leaves the message body", async ({ page, context }) => { diff --git a/src/features/agents/AgentReply.tsx b/src/features/agents/AgentReply.tsx index 9a1995c8e..3c4cffc4c 100644 --- a/src/features/agents/AgentReply.tsx +++ b/src/features/agents/AgentReply.tsx @@ -1,5 +1,4 @@ -import { useEffect, useState } from "react"; -import clsx from "clsx"; +import { useEffect, useState, type ReactNode } from "react"; import type { AgentTask } from "@/bindings"; import { IconButton } from "@/ui"; import { Markdown } from "./Markdown"; @@ -22,7 +21,7 @@ export function AgentReply({ reply?: ReplyTurn; turnCount?: number; onDiagramDrawn?: () => void; - /** False when the caller shows `ReplyActions` in the message's header row. */ + /** False when the caller supplies the message footer. */ actions?: boolean; }) { return ( @@ -37,7 +36,15 @@ export function AgentReply({ ); } -/** Copy, quote, regenerate and branch for a reply; kept in its header row when it has one. */ +export function MessageActions({ children, copied = false }: { children: ReactNode; copied?: boolean }) { + return ( + + {children} + + ); +} + +/** Shared action row for a reply in either the conversation or a subagent preview. */ export function ReplyActions({ text, task, @@ -57,11 +64,11 @@ export function ReplyActions({ return () => clearTimeout(timer); }, [copied]); return ( - + setCopied(text) } satisfies CopyReply} /> @@ -69,7 +76,7 @@ export function ReplyActions({ @@ -78,7 +85,7 @@ export function ReplyActions({ @@ -87,11 +94,11 @@ export function ReplyActions({ )} - + ); } diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index e1adbad64..2876dd52c 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -1,11 +1,13 @@ .session { + --session-content-width: calc(var(--agent-conversation-width) - var(--space-4) * 6); flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; - background: var(--bg-editor); + background: var(--bg-app); color: var(--fg); + font-size: var(--fs-ui); } .conversation { @@ -82,62 +84,9 @@ color: var(--fg-subtle); } -/* Pending permission: an editor notification banner. A long question (AskUserQuestion) wraps, - scrolling past a third of the height; the answers follow it, wrapping too. */ -.banner { - flex: none; - display: flex; - flex-wrap: wrap; - align-items: flex-start; - gap: 4px 8px; - min-height: 36px; - padding: 9px 12px; - background: var(--bg-feedback-warning); - border-bottom: 1px solid var(--border-feedback-warning); - color: var(--fg-strong); -} -.banner > svg { - flex: none; - margin-top: 1px; -} -.bannerText { - flex: 1 1 240px; - min-width: 0; - max-height: 33vh; - overflow-y: auto; - white-space: pre-wrap; - overflow-wrap: anywhere; -} -.bannerSpacer { - flex: 1; -} -.bannerActions { - display: flex; - flex-wrap: wrap; - gap: 4px 12px; - margin-left: auto; -} -.bannerLink { - flex: none; - padding: 0; - border: 0; - background: none; - color: var(--link); - font: inherit; - white-space: nowrap; - cursor: pointer; -} -.bannerLink:hover:not(:disabled) { - color: var(--link-hover); - text-decoration: underline; -} -.bannerLink:disabled { - color: var(--fg-subtle); - cursor: default; -} - /* The conversation with its find bar. */ .streamArea { + min-width: 0; position: relative; flex: 1; min-height: 0; @@ -148,23 +97,26 @@ .stream { flex: 1; min-height: 0; - overflow: auto; + min-width: 0; + overflow-y: auto; + overflow-x: hidden; overflow-anchor: none; - padding: calc(var(--space-3) * 2) var(--space-4); + padding: var(--space-3); display: flex; flex-direction: column; } /* Actual row heights keep WebKit's scrollbar and cross-message selections anchored. */ .stream > * { flex: none; - width: 100%; - max-width: var(--agent-conversation-width); - margin-inline: auto; + min-width: 0; + width: min(100%, var(--session-content-width)); + max-width: 100%; + margin-inline: 0; box-sizing: border-box; } -/* Messages keep room around them; the folded reasoning/tool rows sit close together. */ +/* Messages and folded process rows share the tool-window gutter. */ .stream > * + * { - margin-top: calc(var(--space-3) * 2); + margin-top: var(--space-3); } .stream > * + .process { margin-top: var(--space-2); @@ -172,6 +124,9 @@ .stream > .process + * { margin-top: var(--space-2); } +.stream > * + .agentMessage[data-continuation] { + margin-top: var(--space-2); +} .stream > .process + .process { margin-top: 2px; } @@ -208,14 +163,14 @@ flex-direction: column; align-items: stretch; } -/* The author line, with the reply actions on its right; its height is always reserved. */ +/* Keep the author attached to the message. */ .messageHead { display: flex; align-items: center; justify-content: flex-start; - gap: 8px; - min-height: 24px; - margin-bottom: 3px; + gap: var(--space-2); + min-height: var(--h-control-small); + margin-bottom: calc(var(--space-2) / 2); } .messageTime { -webkit-user-select: none; @@ -227,39 +182,25 @@ .messageHead > .role { margin-bottom: 0; } -/* Chat layout: the user's messages sit on the right, the agent's on the left. */ +/* User prompts stay in the same column as replies and tool calls. */ .userMessage { display: flex; flex-direction: column; - align-items: flex-end; -} -.userMessage > .messageHead { - align-self: stretch; - justify-content: flex-end; -} -.userMessage > .user { - width: fit-content; - max-width: min(80%, 720px); -} -.userMessage > .messageFooter { - align-self: flex-end; + align-items: stretch; } /* The actions and time below a message, in flow so they never cover the next entry. */ .messageFooter { display: flex; align-items: center; gap: 8px; - min-height: 24px; + min-height: var(--h-control-small); max-width: 100%; flex-wrap: wrap; } -/* The footer's icons stay visible: the time follows them, at the message's side. */ -.messageFooter .messageActions { - margin-left: 0; - opacity: 1; - pointer-events: auto; +.messageFooter > .messageTime { + order: -1; } -/* Who wrote the message, above its card. */ +/* Who wrote the message. */ .role { display: flex; align-items: center; @@ -273,26 +214,20 @@ .agentMessage { display: flex; flex-direction: column; - align-items: flex-start; + align-items: stretch; } .agentMessage > .messageHead, -.agentCard { - max-width: min(80%, 760px); -} .agentCard { box-sizing: border-box; - width: fit-content; + width: 100%; min-width: 0; overflow-wrap: anywhere; - padding: var(--space-2) var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-popup); - background: var(--bg-elev); } .messageActions { display: inline-flex; gap: 4px; - margin-left: 8px; + max-width: 100%; + flex-wrap: wrap; opacity: 0; pointer-events: none; -webkit-user-select: none; @@ -324,9 +259,6 @@ -webkit-user-select: none; user-select: none; } -.replyActions { - margin-left: 0; -} @media (hover: none) { .messageActions { @@ -369,10 +301,10 @@ .user { width: 100%; box-sizing: border-box; - padding: var(--space-2) var(--space-3); - border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border)); - border-radius: var(--radius-popup); - background: color-mix(in srgb, var(--accent) 12%, var(--bg-app)); + padding: var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius-small); + background: var(--bg-elev); color: var(--fg-strong); line-height: 1.5; white-space: pre-wrap; @@ -407,7 +339,7 @@ } .markdown { - line-height: 1.55; + line-height: 1.5; overflow-wrap: anywhere; } .markdown :global(p) { @@ -582,7 +514,8 @@ display: flex; align-items: center; gap: 4px; - width: fit-content; + min-width: 0; + height: var(--h-row); color: var(--fg-muted); font-size: var(--fs-small); -webkit-user-select: none; @@ -614,24 +547,34 @@ align-items: center; gap: 8px; min-width: 0; - padding: 2px 8px; - margin-left: -8px; + box-sizing: border-box; + min-height: var(--h-row); + padding: 0 calc(var(--space-2) / 2); border-radius: var(--radius-row); color: var(--fg-secondary); - width: fit-content; + width: 100%; max-width: 100%; } .toolDetails > summary.tool:hover { background: var(--bg-hover); color: var(--fg); } +.tool > svg { + flex: none; +} .toolTitle { - font-family: var(--font-mono); + min-width: 0; font-size: var(--fs-small); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.toolChevron { + flex: none; +} +.toolDetails[open] > summary .toolChevron { + transform: rotate(90deg); +} .toolDiff { font: inherit; font-size: var(--fs-small); @@ -658,10 +601,11 @@ display: flex; align-items: center; gap: 6px; - width: fit-content; + width: 100%; max-width: 100%; - padding: 2px 8px; - margin-left: -8px; + box-sizing: border-box; + min-height: var(--h-row); + padding: 0 calc(var(--space-2) / 2); border-radius: var(--radius-row); color: var(--fg-muted); font-size: var(--fs-small); @@ -680,6 +624,7 @@ transform: rotate(90deg); } .processRun:not([open]) > .processBody { + min-width: 0; display: none; } .processBody { @@ -701,6 +646,7 @@ font-family: var(--font-mono); font-size: var(--fs-small); white-space: pre-wrap; + overflow-wrap: anywhere; color: var(--fg-secondary); } @@ -726,56 +672,99 @@ box-sizing: border-box; display: flex; align-items: flex-start; - gap: 8px; + gap: var(--space-2); min-width: 0; - padding: var(--space-2) var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-popup); - background: var(--bg-elev); + padding: var(--space-2); + border: 1px solid var(--border-feedback-warning); + border-radius: var(--radius-small); + background: var(--bg-feedback-warning); color: var(--fg-secondary); } -/* Waiting for an answer: the card takes the warning colour of the banner above. */ -.permissionAsking { - border-color: var(--border-feedback-warning); - background: var(--bg-feedback-warning); +.permissionLine > svg { + flex: none; + margin-top: calc(var(--space-2) / 4); } -.permissionDone { - opacity: 0.85; +.permissionAsking > svg { + color: var(--warning); } -/* Title, state and answers on one line when they fit; a long title takes its own line and - the state and answers go below it, in line with it. */ .permissionBody { flex: 1; min-width: 0; display: flex; - flex-wrap: wrap; - align-items: flex-start; - gap: 2px 6px; + flex-direction: column; + gap: var(--space-2); } -/* Up to three lines; the full text is its tooltip. */ .permissionTitle { - display: -webkit-box; - flex: 0 1 auto; color: var(--fg-strong); - font-weight: 600; + font-weight: 400; +} +.permissionCommand { min-width: 0; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; + max-width: 100%; + max-height: calc(var(--lh-code) * 6); + box-sizing: border-box; + margin: 0; + padding: var(--space-2); + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-small); + background: var(--bg-editor); + color: var(--fg); + font-family: var(--font-mono); + font-size: var(--fs-code); + line-height: var(--lh-code); + white-space: pre-wrap; + overflow-wrap: anywhere; + tab-size: 2; +} +.permissionCommand:focus-visible, +.permissionDone:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} +.permissionDetail { + max-height: 33vh; + overflow-y: auto; white-space: pre-wrap; overflow-wrap: anywhere; } -.permissionLine > svg, -.permissionBody > .muted { - flex: none; -} .permissionAnswers { - flex: 1 1 160px; min-width: 0; display: flex; flex-wrap: wrap; - gap: 2px 12px; - margin-left: 6px; + gap: var(--space-2); +} +.permissionOption { + min-width: 0; + max-width: 100%; + padding-inline: var(--space-3); +} +.permissionOptionLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.permissionPrefix { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: var(--fs-small); +} +.permissionDone { + padding: calc(var(--space-2) / 2) 0; + border: 0; + border-radius: var(--radius-small); + background: none; + color: var(--fg-muted); + font-size: var(--fs-small); +} +.permissionRecord { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .error { @@ -803,11 +792,11 @@ display: flex; flex-direction: column; gap: 6px; - align-self: center; + align-self: flex-start; width: 100%; - max-width: calc(var(--agent-conversation-width) + var(--space-4) * 2); + max-width: calc(var(--session-content-width) + var(--space-3) * 2); box-sizing: border-box; - padding: var(--space-2) var(--space-4) var(--space-4); + padding: var(--space-2) var(--space-3) var(--space-4); } .background { @@ -1249,7 +1238,7 @@ overflow-y: auto; /* The composer's width, so the card sits exactly over the box it belongs to. */ width: 100%; - max-width: var(--agent-conversation-width); + max-width: var(--session-content-width); margin: 0 auto 8px; padding: 12px; border: 1px solid var(--border-strong); diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 919e338e1..7b845bbf7 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -67,7 +67,14 @@ import { import { ConfigPickers } from "./ConfigPickers"; import { findRanges, formatDuration, formatSentAt } from "./conversation"; import { ImagePreview, imageSrc } from "./ImagePreview"; -import { AgentReply, ReplyActions } from "./AgentReply"; +import { AgentReply, MessageActions, ReplyActions } from "./AgentReply"; +import { + commandPrefix, + permissionDetails, + permissionOption, + permissionResult, + type PermissionEntry, +} from "./permission"; import { Markdown } from "./Markdown"; import { useAgentErrorText } from "./loginHint"; import { @@ -182,7 +189,6 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) { docked={docked} /> {view && } - (entries && !subagent ? replyTimes(entries) : null), [entries, subagent]); const busy = subagent ? subagentWorking(subagent.status) : isBusy(task.status); const lastIndex = entries ? entries.length - 1 : -1; + const authorHeaders = useMemo(() => { + const headers = new Set(); + let first = true; + entries?.slice(start).forEach((entry, i) => { + if (entry.type === "user") first = true; + if (entry.type === "agent" && first) { + headers.add(start + i); + first = false; + } + }); + return headers; + }, [entries, start]); const renderRow = useCallback( (row: EntryRow) => ( ), - [task, turns, replies, times, lastIndex, busy, onOpenSubagent, onDiagramDrawn], + [task, turns, replies, times, lastIndex, busy, authorHeaders, onOpenSubagent, onDiagramDrawn], ); // Only the tail is built while following the end; `start` moves earlier as batches load. const rows = useMemo(() => (entries ? conversationRows(entries, start) : []), [entries, start]); @@ -945,6 +964,7 @@ const Entry = memo(function Entry({ reply, time, working, + showAuthor, onOpenSubagent, onDiagramDrawn, }: { @@ -957,6 +977,7 @@ const Entry = memo(function Entry({ /** When the turn this reply answered was asked and finished. */ time?: ReplyTimes | null; working: boolean; + showAuthor: boolean; onOpenSubagent: (id: string) => void; onDiagramDrawn: () => void; }) { @@ -965,13 +986,15 @@ const Entry = memo(function Entry({ return ; case "agent": return ( -
-
-
- - {providerName(task.provider)} +
+ {showAuthor && ( +
+
+ + {providerName(task.provider)} +
-
+ )}
- - void copyText(entry.text)} /> - {inPlace && entry.text && setEditing(true)} />} + + void copyText(entry.text)} /> + {inPlace && entry.text && ( + setEditing(true)} /> + )} {inPlace && ( @@ -1152,12 +1179,13 @@ function UserMessage({ )} {canFork && ( void forkTask(task.id, turn + 1).catch(failed("Couldn't copy the task"))} /> )} - +
@@ -1386,7 +1414,9 @@ const ToolEntry = memo(function ToolEntry({ const head = ( <> - {shortTitle(task, entry.title || entry.kind)} + + {shortTitle(task, entry.title || entry.kind)} + {entry.diffs.map((d) => (
@@ -1546,8 +1579,6 @@ function WorkingSubagents({ entries, onOpen }: { entries: AgentEntry[] | null; o /** Agent-written titles with the task's own folder dropped from paths. */ const shortTitle = (task: AgentTask, title: string) => title.replaceAll(`${task.cwd}/`, ""); -type PermissionEntry = Extract; - /** * The answers to an open permission request. A request without options (some agents send * none) can still be cancelled, so the agent doesn't wait for an answer it can't get. @@ -1562,56 +1593,109 @@ function PermissionAnswers({ className?: string; }) { const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const pending = useRef(false); + const { command } = permissionDetails(entry); const answer = (option: string | null) => { + if (pending.current) return; + pending.current = true; setBusy(true); - void respond(task.id, entry.id, option).finally(() => setBusy(false)); + setError(null); + void respond(task.id, entry.id, option) + .catch((e: unknown) => setError(String(e))) + .finally(() => { + pending.current = false; + setBusy(false); + }); }; return ( {entry.options.length ? ( - entry.options.map((o) => ( - - )) + entry.options.map((o) => { + const { label, variant } = permissionOption(o, !!command); + return ( + + ); + }) ) : ( - answer(null)} /> + + )} + {error && ( + + {error} + )} ); } -/** A permission request in the conversation; while open, answered right there as in the banner. */ +/** A single inline decision, reduced to a compact record after it is answered. */ function PermissionLine({ task, entry }: { task: AgentTask; entry: PermissionEntry }) { + const tx = useTx(); + const ref = useRef(null); + const hadFocus = useRef(false); + const { title, detail, command } = permissionDetails(entry, providerName(task.provider)); + const result = permissionResult(entry); + useLayoutEffect(() => { + if (entry.chosen && hadFocus.current && document.activeElement === document.body) + ref.current?.focus({ preventScroll: true }); + }, [entry.chosen]); return ( -
- +
{ + hadFocus.current = true; + }} + onBlurCapture={(e) => { + if (e.relatedTarget && !e.currentTarget.contains(e.relatedTarget as Node)) hadFocus.current = false; + }} + className={clsx(styles.permissionLine, entry.chosen ? styles.permissionDone : styles.permissionAsking)} + > +
- - {shortTitle(task, entry.title)} - - {entry.chosen ?? Waiting for your answer} - {!entry.chosen && !entry.questions?.length && ( - + {entry.chosen ? ( + + {result.label}:{" "} + + {command ? commandPrefix(command) : shortTitle(task, detail)} + + + ) : ( + <> + {title} + {command ? ( +
+                {command}
+              
+ ) : ( + {shortTitle(task, detail)} + )} + {!entry.questions?.length && ( + + )} + )}
); } -/** The open permission request, as an editor notification banner above the conversation. */ -function PermissionBanner({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) { - const entry = entries?.find((e): e is PermissionEntry => e.type === "permission" && !e.chosen); - if (!entry || entry.questions?.length) return null; - return ( -
- - {shortTitle(task, entry.title)} - -
- ); -} - interface QuestionAnswer { question: AgentQuestion; picked: string[]; diff --git a/src/features/agents/permission.test.ts b/src/features/agents/permission.test.ts new file mode 100644 index 000000000..de2b4dbbc --- /dev/null +++ b/src/features/agents/permission.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + commandPrefix, + permissionDetails, + permissionOption, + permissionResult, + type PermissionEntry, +} from "./permission"; + +const entry: PermissionEntry = { + type: "permission", + id: 1, + title: 'Run python3 -c \'from pathlib import Path\np = Path("note.txt")\np.write_text("one\\ntwo")\'', + options: [], + chosen: null, +}; + +it("separates the command from the heading without interpreting shell or Python escapes", () => { + const { title, command } = permissionDetails(entry); + expect(title).toBe("Codex wants to run a command"); + expect(command).toContain('Path("note.txt")\np.write_text("one\\ntwo")'); + expect(commandPrefix(command!)).toBe("python3 …"); +}); + +it.each(["Codex", "Claude Code", "Gemini"])("names the requesting provider %s", (provider) => { + expect(permissionDetails(entry, provider).title).toBe(`${provider} wants to run a command`); +}); + +it("keeps a subagent command in the code area without its session label", () => { + expect(permissionDetails({ ...entry, title: `Explore: ${entry.title}` }).command).toBe( + permissionDetails(entry).command, + ); +}); + +describe("permission choices", () => { + it.each([ + ["allow_once", "yes", "Allow Once", "primary"], + ["allow_always", "policy", "Always Allow", "secondary"], + ["reject_once", "no", "Decline", "secondary"], + ["reject_always", "never", "Always Decline", "ghost"], + ["reject_once", "cancel", "Decline and Stop", "ghost"], + ])("presents %s without changing its id", (kind, id, label, variant) => { + const option = { kind, id, name: "Always Allow `python3 -c a very long script`" }; + expect(permissionOption(option, true)).toMatchObject({ label, variant }); + expect(option.id).toBe(id); + }); + + it("preserves the narrower session scope and custom question answers", () => { + expect(permissionOption({ id: "session", kind: "allow_always", name: "Allow for This Session" }, true).label).toBe( + "Allow for This Session", + ); + expect(permissionOption({ id: "ship", kind: "allow_once", name: "Ship it" }, false).label).toBe("Ship it"); + }); + + it.each([ + ["allow_once", "Allowed once", true], + ["allow_always", "Always allowed", true], + ["reject_once", "Declined", false], + ["reject_always", "Always declined", false], + ])("records %s compactly", (kind, label, allowed) => { + const name = "Option with a long command"; + expect(permissionResult({ ...entry, chosen: name, options: [{ id: "opaque", name, kind }] })).toEqual({ + label, + allowed, + }); + }); +}); diff --git a/src/features/agents/permission.ts b/src/features/agents/permission.ts new file mode 100644 index 000000000..e9a08f452 --- /dev/null +++ b/src/features/agents/permission.ts @@ -0,0 +1,55 @@ +import type { AgentEntry, AgentPermissionOption } from "@/bindings"; +import { tx } from "@/platform/i18n"; + +export type PermissionEntry = Extract; + +export function permissionDetails(entry: PermissionEntry, provider = "Codex") { + const command = entry.title.match(/^Run ([\s\S]+)/)?.[1] ?? entry.title.match(/^[^\n]+?: Run ([\s\S]+)/)?.[1]; + return { + command, + title: command ? tx`${provider} wants to run a command` : tx("Allow tool call?"), + detail: command ?? entry.title, + }; +} + +/** Keep the permission scope explicit; the option id is sent back unchanged. */ +export function permissionOption(option: AgentPermissionOption, command: boolean) { + const stop = option.id === "cancel" || /decline and stop/i.test(option.name); + let label = option.name; + if (stop) label = tx("Decline and Stop"); + else if (option.kind === "allow_always") { + label = /for (this |the )?session/i.test(option.name) ? tx("Allow for This Session") : tx("Always Allow"); + } else if (option.kind === "reject_always") label = tx("Always Decline"); + else if (command || /^(allow( once)?|accept|decline|reject)$/i.test(option.name)) { + if (option.kind === "allow_once") label = tx("Allow Once"); + else if (option.kind === "reject_once") label = tx("Decline"); + } + return { + label, + variant: + option.kind === "allow_once" + ? ("primary" as const) + : stop || option.kind === "reject_always" + ? ("ghost" as const) + : ("secondary" as const), + }; +} + +export function permissionResult(entry: PermissionEntry) { + const option = entry.options.find((o) => o.name === entry.chosen); + if (entry.chosen === "Cancelled") return { label: tx("Cancelled"), allowed: false }; + const allowed = option?.kind.startsWith("allow") ?? false; + const labels: Record = { + allow_once: tx("Allowed once"), + allow_always: /for (this |the )?session/i.test(option?.name ?? "") + ? tx("Allowed for this session") + : tx("Always allowed"), + reject_once: tx("Declined"), + reject_always: tx("Always declined"), + }; + return { label: option ? (labels[option.kind] ?? entry.chosen) : entry.chosen, allowed }; +} + +export function commandPrefix(command: string) { + return `${command.trim().split(/\s/)[0] ?? ""} …`; +} diff --git a/src/features/agents/sessionState.test.tsx b/src/features/agents/sessionState.test.tsx index 4b495fca6..f99573ab6 100644 --- a/src/features/agents/sessionState.test.tsx +++ b/src/features/agents/sessionState.test.tsx @@ -121,6 +121,40 @@ beforeEach(() => { }); describe("conversation rows", () => { + it("groups agent authors by turn and uses matching icon-only message action rows", async () => { + const user = (text: string): AgentEntry => ({ + type: "user", + text, + attachments: [], + steered: false, + sentMs: null, + doneMs: null, + }); + backend.entries.set("A", [ + user("First question"), + { type: "agent", text: "First reply" }, + tool("read"), + { type: "agent", text: "More detail" }, + user("Next question"), + { type: "agent", text: "Next reply" }, + ]); + useAgents.setState({ entries: new Map(backend.entries) }); + render(); + await screen.findByText("More detail"); + expect(stream().querySelectorAll(`.${styles.agentMessage} .${styles.role}`)).toHaveLength(2); + const quote = within(stream()).getAllByRole("button", { name: "Quote Reply" }); + const copy = within(stream()).getAllByRole("button", { name: "Copy Markdown" }); + expect(quote).toHaveLength(3); + for (const button of [...quote, ...copy]) { + expect(button.textContent).toBe(""); + expect(button.querySelector("svg path")).not.toBeNull(); + expect(button.parentElement).toHaveClass(styles.messageActions!); + } + expect(stream()).not.toHaveTextContent("66"); + expect(quote[0]?.className).toBe(copy[0]?.className); + expect(within(stream()).getAllByRole("button", { name: "Copy" })[0]?.className).toBe(copy[0]?.className); + }); + it("builds only the requested tail, with absolute indexes", () => { const entries = Array.from({ length: 45 }, (_, i) => ({ type: "agent", text: `reply ${i}` }) as AgentEntry); const rows = conversationRows(entries, 40); @@ -269,7 +303,7 @@ describe("tool calls", () => { }); describe("a pending question", () => { - it("shows the whole question in the banner, with every answer, and sends the chosen one", async () => { + it("shows the whole question once inline, with every answer, and sends the chosen one", async () => { const question = 'Scenario 4 ("follows the TR") still leaves the "About your request" section empty after two rewrites.\nHow should we continue?'; const entries: AgentEntry[] = [ @@ -289,9 +323,10 @@ describe("a pending question", () => { backend.answers = []; useAgents.setState({ entries: new Map(backend.entries) }); render(); - const banner = await screen.findByRole("alert", { name: question }); - expect(banner.querySelector(`.${styles.bannerText}`)?.textContent).toBe(question); - const answers = within(banner.querySelector(`.${styles.bannerActions}`)!).getAllByRole("button"); + const card = await screen.findByRole("region", { name: "Allow tool call?" }); + expect(within(card).getByText(question.replaceAll("\n", " "))).toBeInTheDocument(); + expect(screen.getAllByText(question.replaceAll("\n", " "))).toHaveLength(1); + const answers = within(card).getAllByRole("button"); expect(answers.map((b) => b.textContent)).toEqual([ "Ship it as is and record it (Recommended)", "Detect the request in code", @@ -434,14 +469,76 @@ describe("a pending AskUserQuestion", () => { }); describe("a pending permission request in the conversation", () => { + const command = 'python3 -c \'from pathlib import Path\np = Path("note.txt")\np.write_text("one\\ntwo")\''; + const options = [ + { id: "opaque-once", name: "Allow Once", kind: "allow_once" }, + { id: "opaque-policy", name: `Always Allow \`${command}\``, kind: "allow_always" }, + { id: "opaque-no", name: "Decline", kind: "reject_once" }, + { id: "opaque-stop", name: "Decline and Stop", kind: "reject_always" }, + ]; + + it("shows the requested command while another read tool is pending, with one card and short buttons", async () => { + backend.entries.set("A", [ + { + type: "tool", + id: "pending-read", + title: "List .", + kind: "search", + status: "running", + output: null, + diffs: [], + locations: [], + }, + { type: "permission", id: 12, title: `Run ${command}`, options, chosen: null }, + ]); + useAgents.setState({ entries: new Map(backend.entries) }); + render(); + const card = await screen.findByRole("region", { name: "codex wants to run a command" }); + expect(screen.getAllByRole("region", { name: "codex wants to run a command" })).toHaveLength(1); + expect(screen.queryByRole("alert")).toBeNull(); + expect(stream()).toContainElement(card); + expect(card.querySelector("pre")?.textContent).toBe(command); + const buttons = within(card).getAllByRole("button"); + expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual([ + "Allow Once", + "Always Allow", + "Decline", + "Decline and Stop", + ]); + expect(buttons.every((b) => b.tabIndex === 0)).toBe(true); + expect(buttons[1]?.textContent).toBe("Always Allow"); + expect(buttons[1]?.title).toBe(options[1]?.name); + }); + + it.each(options)("sends the original $kind option id, then collapses the resolved card", async (option) => { + const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, options, chosen: null }; + backend.answers = []; + backend.entries.set("A", [pending]); + useAgents.setState({ entries: new Map(backend.entries) }); + render(); + const card = await screen.findByRole("region", { name: "codex wants to run a command" }); + const button = within(card).getAllByRole("button")[options.indexOf(option)]!; + button.focus(); + fireEvent.click(button); + await waitFor(() => expect(backend.answers).toEqual([["A", 12, option.id]])); + act(() => + useAgents.setState({ entries: new Map(backend.entries).set("A", [{ ...pending, chosen: option.name }]) }), + ); + expect(card.querySelector("pre")).toBeNull(); + expect(within(card).queryByRole("button")).toBeNull(); + expect(card.textContent).toContain("python3 …"); + expect(card.textContent).not.toContain("write_text"); + expect(card).toHaveFocus(); + }); + it("is answered from its line too, and one without options can be cancelled", async () => { backend.entries.set("A", [ { type: "permission", id: 3, - title: "ls d:/project", + title: "Run ls d:/project", options: [ - { id: "once", name: "Allow once", kind: "allow_once" }, + { id: "once", name: "Allow Once", kind: "allow_once" }, { id: "reject", name: "Reject", kind: "reject_once" }, ], chosen: null, @@ -453,18 +550,16 @@ describe("a pending permission request in the conversation", () => { backend.answers = []; useAgents.setState({ entries: new Map(backend.entries) }); const { unmount } = render(); - const line = (await screen.findAllByText("ls d:/project")).find((e) => - e.classList.contains(styles.permissionTitle!), - )!.parentElement!; + const line = await screen.findByRole("region", { name: "codex wants to run a command" }); const answers = within(line.querySelector(`.${styles.permissionAnswers}`)!).getAllByRole("button"); - expect(answers.map((b) => b.textContent)).toEqual(["Allow once", "Reject"]); + expect(answers.map((b) => b.textContent)).toEqual(["Allow Once", "Decline"]); fireEvent.click(answers[0]!); await waitFor(() => expect(backend.answers).toEqual([["A", 3, "once"]])); unmount(); render(); - const banner = await screen.findByRole("alert", { name: "The agent asks for permission" }); - fireEvent.click(within(banner).getByRole("button", { name: "Cancel" })); + const card = await screen.findByRole("region", { name: "Allow tool call?" }); + fireEvent.click(within(card).getByRole("button", { name: "Cancel" })); await waitFor(() => expect(backend.answers).toEqual([ ["A", 3, "once"], diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 3acad903c..ea901b4a0 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -403,5 +403,18 @@ "Running an action applies pending settings first.": "Running an action applies pending settings first.", "Could not apply settings.": "Could not apply settings.", "The value exceeds this setting’s limits.": "The value exceeds this setting’s limits.", - "Log tabs opened by plugin code are not available in remote projects yet.": "Log tabs opened by plugin code are not available in remote projects yet." + "Log tabs opened by plugin code are not available in remote projects yet.": "Log tabs opened by plugin code are not available in remote projects yet.", + "{0} wants to run a command": "{0} wants to run a command", + "Allow tool call?": "Allow tool call?", + "Allow Once": "Allow Once", + "Always Allow": "Always Allow", + "Allow for This Session": "Allow for This Session", + "Decline": "Decline", + "Decline and Stop": "Decline and Stop", + "Always Decline": "Always Decline", + "Allowed once": "Allowed once", + "Allowed for this session": "Allowed for this session", + "Always allowed": "Always allowed", + "Declined": "Declined", + "Always declined": "Always declined" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c30b9385d..d35a04638 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6756,5 +6756,18 @@ "Running an action applies pending settings first.": "运行动作前会先应用待保存的设置。", "Could not apply settings.": "无法应用设置。", "The value exceeds this setting’s limits.": "该值超出了此设置的限制。", - "Log tabs opened by plugin code are not available in remote projects yet.": "插件代码打开的日志标签页暂不支持远程项目。" + "Log tabs opened by plugin code are not available in remote projects yet.": "插件代码打开的日志标签页暂不支持远程项目。", + "{0} wants to run a command": "{0} 想要运行命令", + "Allow tool call?": "允许工具调用?", + "Allow Once": "允许一次", + "Always Allow": "始终允许", + "Allow for This Session": "在此会话中允许", + "Decline": "拒绝", + "Decline and Stop": "拒绝并停止", + "Always Decline": "始终拒绝", + "Allowed once": "已允许一次", + "Allowed for this session": "已在此会话中允许", + "Always allowed": "已设为始终允许", + "Declined": "已拒绝", + "Always declined": "已设为始终拒绝" } diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 3703eb667..5ca3e7018 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -699,6 +699,14 @@ function build(lightSrc: string, darkSrc: string | undefined, keepColours: boole } function prepare(path: string, large: boolean): Prepared | null { + if (path === ICONS.quote) { + return { + viewBox: "0 0 16 16", + html: '', + hasIds: false, + mono: true, + }; + } const key = `${path}|${large ? 20 : 16}`; let out = prepared.get(key); if (out !== undefined) return out;