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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-08 10:36:40 +08:00
1 джерело 2bf412ff0d
коміт b746a0a3a9
16 files changed
+790 -241

No files matched your search

+21 -6
Переглянути файл
@@ -1756,12 +1756,7 @@ impl Task {
fn ask_permission(self: &Arc<Self>, 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<AgentPrompt> {
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
}
fn permission_title(params: &Value) -> String {
let call = &params["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
+1 -1
Переглянути файл
@@ -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,
+28 -2
Переглянути файл
@@ -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": [
+49 -9
Переглянути файл
@@ -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 `<shell> -c <quoted script>` command line, when the whole line is exactly that.
fn shell_script(command: &str) -> Option<String> {
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();
+14
Переглянути файл
@@ -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 {
+122
Переглянути файл
@@ -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",
});
});
}
}
+8 -7
Переглянути файл
@@ -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 }) => {
+17 -10
Переглянути файл
@@ -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 (
<span className={styles.messageActions} data-copied={copied || undefined}>
{children}
</span>
);
}
/** 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 (
<span className={clsx(styles.messageActions, styles.replyActions)} data-copied={copied === text || undefined}>
<MessageActions copied={copied === text}>
<IconButton
icon={copied === text ? "check" : "copy"}
title={copied === text ? "Copied" : "Copy Markdown"}
size="normal"
size="small"
action={copyReplyAction.id}
actionArg={{ text, onCopied: () => setCopied(text) } satisfies CopyReply}
/>
@@ -69,7 +76,7 @@ export function ReplyActions({
<IconButton
icon="quote"
title="Quote Reply"
size="normal"
size="small"
action="agents.quoteReply"
actionArg={{ task: task.id, text }}
/>
@@ -78,7 +85,7 @@ export function ReplyActions({
<IconButton
icon="refresh"
title="Regenerate Response"
size="normal"
size="small"
action="agents.regenerateReply"
actionArg={{ task: task.id, turn: reply.turn }}
/>
@@ -87,11 +94,11 @@ export function ReplyActions({
<IconButton
icon="branch"
title="Branch Conversation from Here"
size="normal"
size="small"
action="agents.forkReply"
actionArg={{ task: task.id, turn: reply.turn }}
/>
)}
</span>
</MessageActions>
);
}
+135 -146
Переглянути файл
@@ -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);
+130 -46
Переглянути файл
@@ -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 && <SubagentBar task={task} path={path} cards={cards} onNavigate={setPath} />}
<PermissionBanner task={task} entries={entries ?? null} />
<Stream
key={view ?? ""}
task={task}
@@ -597,6 +603,18 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
const times = useMemo(() => (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<number>();
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) => (
<Entry
@@ -608,11 +626,12 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
reply={replies?.get(row.index)}
time={times?.get(row.index) ?? null}
working={row.index === lastIndex && busy}
showAuthor={authorHeaders.has(row.index)}
onOpenSubagent={onOpenSubagent}
onDiagramDrawn={onDiagramDrawn}
/>
),
[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 <UserMessage task={task} entry={entry} turn={turn} turnCount={turnCount} />;
case "agent":
return (
<div className={styles.agentMessage}>
<div className={styles.messageHead}>
<div className={styles.role}>
<Icon name={providerIcon(task.provider)} size={13} />
<span>{providerName(task.provider)}</span>
<div className={styles.agentMessage} data-continuation={!showAuthor || undefined}>
{showAuthor && (
<div className={styles.messageHead}>
<div className={styles.role}>
<Icon name={providerIcon(task.provider)} size={13} />
<span>{providerName(task.provider)}</span>
</div>
</div>
</div>
)}
<div className={styles.agentCard}>
<AgentReply
text={entry.text}
@@ -1128,11 +1151,14 @@ function UserMessage({
)}
</div>
<div className={styles.messageFooter}>
<span className={styles.messageActions}>
<IconButton icon="copy" title="Copy" onClick={() => void copyText(entry.text)} />
{inPlace && entry.text && <IconButton icon="edit" title="Edit" onClick={() => setEditing(true)} />}
<MessageActions>
<IconButton size="small" icon="copy" title="Copy" onClick={() => void copyText(entry.text)} />
{inPlace && entry.text && (
<IconButton size="small" icon="edit" title="Edit" onClick={() => setEditing(true)} />
)}
{inPlace && (
<IconButton
size="small"
icon="diff-revert"
title="Retract Message"
action="agents.retractMessage"
@@ -1141,6 +1167,7 @@ function UserMessage({
)}
{!archived && entry.text && entry.attachments.length === 0 && (
<IconButton
size="small"
icon="refresh"
title={inPlace ? "Run Again From Here" : "Send Again"}
onClick={() =>
@@ -1152,12 +1179,13 @@ function UserMessage({
)}
{canFork && (
<IconButton
size="small"
icon="branch"
title="Copy to a New Task From Here"
onClick={() => void forkTask(task.id, turn + 1).catch(failed("Couldn't copy the task"))}
/>
)}
</span>
</MessageActions>
<MessageTime sentMs={entry.sentMs ?? null} doneMs={entry.doneMs ?? null} />
</div>
</div>
@@ -1386,7 +1414,9 @@ const ToolEntry = memo(function ToolEntry({
const head = (
<>
<Icon name={TOOL_ICONS[entry.kind] ?? "run"} size={14} />
<span className={styles.toolTitle}>{shortTitle(task, entry.title || entry.kind)}</span>
<span className={styles.toolTitle} title={entry.title || entry.kind}>
{shortTitle(task, entry.title || entry.kind)}
</span>
{entry.diffs.map((d) => (
<button
key={d.path}
@@ -1414,7 +1444,10 @@ const ToolEntry = memo(function ToolEntry({
return (
<div className={styles.process}>
<details className={styles.toolDetails} open={liveTool(entry) || undefined}>
<summary className={styles.tool}>{head}</summary>
<summary className={styles.tool}>
<Icon name="chevron-right" size={12} className={styles.toolChevron} />
{head}
</summary>
<pre className={styles.output}>{entry.output}</pre>
</details>
</div>
@@ -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<AgentEntry, { type: "permission" }>;
/**
* 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<string | null>(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 (
<span className={className}>
{entry.options.length ? (
entry.options.map((o) => (
<button key={o.id} type="button" className={styles.bannerLink} disabled={busy} onClick={() => answer(o.id)}>
{o.name}
</button>
))
entry.options.map((o) => {
const { label, variant } = permissionOption(o, !!command);
return (
<Button
key={o.id}
variant={variant}
className={styles.permissionOption}
size="small"
aria-label={label}
title={o.name}
disabled={busy}
onClick={() => answer(o.id)}
>
<span className={styles.permissionOptionLabel}>{label}</span>
</Button>
);
})
) : (
<IconButton icon="close" title="Cancel" disabled={busy} onClick={() => answer(null)} />
<Button variant="ghost" disabled={busy} onClick={() => answer(null)}>
Cancel
</Button>
)}
{error && (
<span className={styles.error} role="alert">
{error}
</span>
)}
</span>
);
}
/** 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<HTMLDivElement>(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 (
<div className={clsx(styles.permissionLine, entry.chosen ? styles.permissionDone : styles.permissionAsking)}>
<Icon name={!entry.chosen ? "warning" : entry.chosen === "Cancelled" ? "minus" : "check"} size={13} />
<div
ref={ref}
role="region"
aria-label={title}
tabIndex={entry.chosen ? -1 : undefined}
onFocusCapture={() => {
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)}
>
<Icon name={!entry.chosen ? "warning" : result.allowed ? "check" : "minus"} size={14} />
<div className={styles.permissionBody}>
<span className={styles.permissionTitle} title={shortTitle(task, entry.title)}>
{shortTitle(task, entry.title)}
</span>
<span className={styles.muted}>{entry.chosen ?? <T>Waiting for your answer</T>}</span>
{!entry.chosen && !entry.questions?.length && (
<PermissionAnswers task={task} entry={entry} className={styles.permissionAnswers} />
{entry.chosen ? (
<span className={styles.permissionRecord} title={detail}>
{result.label}:{" "}
<span className={command ? styles.permissionPrefix : undefined}>
{command ? commandPrefix(command) : shortTitle(task, detail)}
</span>
</span>
) : (
<>
<span className={styles.permissionTitle}>{title}</span>
{command ? (
<pre className={styles.permissionCommand} tabIndex={0} aria-label={tx("Command")}>
<code>{command}</code>
</pre>
) : (
<span className={styles.permissionDetail}>{shortTitle(task, detail)}</span>
)}
{!entry.questions?.length && (
<PermissionAnswers task={task} entry={entry} className={styles.permissionAnswers} />
)}
</>
)}
</div>
</div>
);
}
/** 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 (
<div className={styles.banner} role="alert" aria-label={entry.title}>
<Icon name="warning" size={16} />
<span className={styles.bannerText}>{shortTitle(task, entry.title)}</span>
<PermissionAnswers task={task} entry={entry} className={styles.bannerActions} />
</div>
);
}
interface QuestionAnswer {
question: AgentQuestion;
picked: string[];
+67
Переглянути файл
@@ -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,
});
});
});
+55
Переглянути файл
@@ -0,0 +1,55 @@
import type { AgentEntry, AgentPermissionOption } from "@/bindings";
import { tx } from "@/platform/i18n";
export type PermissionEntry = Extract<AgentEntry, { type: "permission" }>;
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<string, string> = {
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] ?? ""} …`;
}
+107 -12
Переглянути файл
@@ -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(<SessionEditor input={input("A")} />);
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(<SessionEditor input={input("A")} />);
const banner = await screen.findByRole("alert", { name: question });
expect(banner.querySelector(`.${styles.bannerText}`)?.textContent).toBe(question);
const answers = within(banner.querySelector<HTMLElement>(`.${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(<SessionEditor input={input("A")} />);
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(<SessionEditor input={input("A")} />);
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(<SessionEditor input={input("A")} />);
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<HTMLElement>(`.${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(<SessionEditor input={input("B")} />);
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"],
+14 -1
Переглянути файл
@@ -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"
}
+14 -1
Переглянути файл
@@ -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": "已设为始终拒绝"
}
+8
Переглянути файл
@@ -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: '<path d="M3 3v10M6.5 4.5H13M6.5 8H13M6.5 11.5H11" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>',
hasIds: false,
mono: true,
};
}
const key = `${path}|${large ? 20 : 16}`;
let out = prepared.get(key);
if (out !== undefined) return out;