diff --git a/app/src/dev/seedChats.ts b/app/src/dev/seedChats.ts
index 52b2ae98..91ac4bcc 100644
--- a/app/src/dev/seedChats.ts
+++ b/app/src/dev/seedChats.ts
@@ -93,7 +93,7 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void {
at(92);
chat.reply(crew.scout, SUMMARY);
const handoff = chat.tool(crew.scout, "mcp__botloft__send_message", {
- summary: "to @writer",
+ summary: "@writer",
input: JSON.stringify({ to: "writer", kind: "task", body: "Turn week-39.md into the report" }),
});
chat.finish(handoff, '{"ok":true}');
diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx
index 86abd473..fcbf7924 100644
--- a/app/src/features/browser/browser.test.tsx
+++ b/app/src/features/browser/browser.test.tsx
@@ -120,7 +120,7 @@ describe("browser panel", () => {
fake.chat.tool(scout.id, "Bash", { summary: "ls", status: "done" });
await openScout(fake);
expect(screen.getAllByRole("button", { name: /Watch in browser/ })).toHaveLength(1);
- fireEvent.click(screen.getByRole("button", { name: "Watch in browser: browser_open" }));
+ fireEvent.click(screen.getByRole("button", { name: "Watch in browser: Open a page" }));
expect(await screen.findByRole("complementary", { name: "Scout's browser" })).toBeDefined();
});
});
diff --git a/app/src/features/chat/ApprovalCard.tsx b/app/src/features/chat/ApprovalCard.tsx
index b666b734..e7948b63 100644
--- a/app/src/features/chat/ApprovalCard.tsx
+++ b/app/src/features/chat/ApprovalCard.tsx
@@ -10,12 +10,13 @@ import { HELP_TOOL, HelpCard } from "../browser/HelpCard";
import { SITE_TOOL, SiteCard } from "../browser/SiteCard";
import { PLAN_TOOL, PlanCard } from "./PlanCard";
import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard";
-import { pretty, toolLabel } from "./ToolLines";
+import { pretty } from "./ToolLines";
+import { toolAction } from "./toolNames";
import { useAnswer } from "./useAnswer";
function Answered({ approval }: { approval: ApprovalItem }) {
const t = useT();
- const label = toolLabel(approval.toolName);
+ const label = toolAction(approval.toolName, t.tools);
const line = "flex min-w-0 items-center gap-2 rounded-lg px-1.5 py-1 text-sm";
switch (approval.status) {
case "allowed":
@@ -71,7 +72,7 @@ function ToolApproval({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
if (approval.status !== "pending") {
return ;
}
- const label = toolLabel(approval.toolName);
+ const label = toolAction(approval.toolName, t.tools);
return (
= {
Bash: SquareTerminal,
@@ -56,11 +57,6 @@ function iconOf(label: string): LucideIcon {
return ICONS[label] ?? (label.startsWith("browser_") ? Globe : Wrench);
}
-/** `mcp__botloft__send_message` reads as `send_message`. */
-export function toolLabel(name: string): string {
- return name.split("__").at(-1) ?? name;
-}
-
/** JSON indented for reading; anything else as it came. */
export function pretty(text: string): string {
try {
@@ -95,8 +91,9 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) {
const showBrowser = useContext(ShowBrowser);
const showScreen = useContext(ShowScreen);
const arrival = useArrival(createdAt);
- const label = toolLabel(tool.name);
- const Icon = iconOf(label);
+ const title = toolTitle(tool.name, t.tools);
+ const detail = toolDetail(tool, t.tools);
+ const Icon = iconOf(toolKey(tool.name));
const block =
"max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-line bg-sunken px-2.5 py-1.5 font-mono text-xs leading-relaxed";
return (
@@ -109,12 +106,9 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) {
className="group flex min-w-0 flex-1 items-center gap-2 rounded-lg px-1.5 py-1 text-left text-sm transition-colors hover:bg-sunken"
>
- {label}
-
- {tool.summary}
+ {title}
+
+ {detail}
showBrowser()}
className="grid size-6 shrink-0 place-items-center rounded-md text-muted transition-colors hover:bg-sunken hover:text-ink"
>
diff --git a/app/src/features/chat/chat.test.tsx b/app/src/features/chat/chat.test.tsx
index 80e8c9af..fd2f4b8a 100644
--- a/app/src/features/chat/chat.test.tsx
+++ b/app/src/features/chat/chat.test.tsx
@@ -71,7 +71,8 @@ describe("chat", () => {
input: '{"command":"npm test"}',
});
});
- const line = within(chat()).getByRole("button", { name: /Bash/ });
+ const line = within(chat()).getByRole("button", { name: /Run a command/ });
+ expect(within(line).getByText("Run the tests")).toBeDefined();
expect(within(line).getByLabelText("Running")).toBeDefined();
act(() => {
if (call) {
@@ -93,13 +94,13 @@ describe("chat", () => {
act(() => {
fake.chat.ask(scout.id, "Bash", "rm -rf build", '{"command":"rm -rf build"}');
});
- const card = screen.getByRole("region", { name: "Scout asks to use Bash" });
+ const card = screen.getByRole("region", { name: "Scout asks to run a command" });
expect(within(card).getByText("rm -rf build")).toBeDefined();
fireEvent.change(within(card).getByLabelText("Note for Scout if you deny"), {
target: { value: "keep the cache" },
});
fireEvent.click(within(card).getByRole("button", { name: "Deny" }));
- expect(await within(chat()).findByText("You denied Bash")).toBeDefined();
+ expect(await within(chat()).findByText("Denied: run a command")).toBeDefined();
expect(fake.calls.at(-1)).toEqual({
method: "approvals.answer",
params: { approvalId: expect.any(String), allow: false, note: "keep the cache" },
@@ -108,9 +109,9 @@ describe("chat", () => {
act(() => {
fake.chat.ask(scout.id, "Write", "notes.md");
});
- const next = screen.getByRole("region", { name: "Scout asks to use Write" });
+ const next = screen.getByRole("region", { name: "Scout asks to write a file" });
fireEvent.click(within(next).getByRole("button", { name: "Allow" }));
- expect(await within(chat()).findByText("You allowed Write")).toBeDefined();
+ expect(await within(chat()).findByText("Allowed: write a file")).toBeDefined();
});
test("files are picked or dropped, shown before sending, and sent with the text", async () => {
diff --git a/app/src/features/chat/toolNames.test.tsx b/app/src/features/chat/toolNames.test.tsx
new file mode 100644
index 00000000..065dd134
--- /dev/null
+++ b/app/src/features/chat/toolNames.test.tsx
@@ -0,0 +1,67 @@
+import { act, cleanup, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import { en } from "../../i18n/en";
+import { ptBR } from "../../i18n/pt-BR";
+import { FakeBotloft } from "../../lib/fake";
+import { crewOpened, openBot, renderApp } from "../../test/app";
+import { toolAction, toolDetail, toolTitle } from "./toolNames";
+
+afterEach(cleanup);
+
+const call = (name: string, summary: string, input: unknown) => ({
+ name,
+ summary,
+ input: JSON.stringify(input),
+});
+
+describe("tool names", () => {
+ test("read as plain actions in the owner's language", () => {
+ expect(toolTitle("mcp__botloft__send_message", en.tools)).toBe("Send a message");
+ expect(toolTitle("mcp__botloft__browser_open", ptBR.tools)).toBe("Abrir uma página");
+ expect(toolTitle("Bash", ptBR.tools)).toBe("Rodar um comando");
+ expect(toolAction("Write", ptBR.tools)).toBe("escrever um arquivo");
+ // One with no name here keeps its own.
+ expect(toolTitle("mcp__other__frobnicate", en.tools)).toBe("frobnicate");
+ expect(toolAction("mcp__other__frobnicate", ptBR.tools)).toBe("usar frobnicate");
+ expect(toolTitle("constructor", en.tools)).toBe("constructor");
+ });
+
+ test("say what a call is about without English words, from its input", () => {
+ const message = call("mcp__botloft__send_message", "to @writer", { to: "writer", body: "hi" });
+ expect(toolDetail(message, ptBR.tools)).toBe("@writer");
+ const load = call("ToolSearch", "load send_message, Read", {
+ query: "select:mcp__botloft__send_message,Read",
+ });
+ expect(toolDetail(load, ptBR.tools)).toBe("Mandar uma mensagem, Ler um arquivo");
+ expect(toolDetail(call("TodoWrite", "Updated the plan", { todos: [] }), en.tools)).toBe("");
+ expect(toolDetail(call("mcp__botloft__complete_task", "task tsk_1", {}), en.tools)).toBe("");
+ expect(toolDetail(call("mcp__botloft__browser_click", "e4", { ref: "e4" }), en.tools)).toBe("");
+ const scroll = call("mcp__botloft__browser_scroll", "down", { to: "down" });
+ expect(toolDetail(scroll, ptBR.tools)).toBe("para baixo");
+ expect(toolDetail(call("Bash", "Run the tests", { command: "npm test" }), en.tools)).toBe(
+ "Run the tests",
+ );
+ });
+
+ test("the conversation list names the tool and the request", async () => {
+ const fake = new FakeBotloft();
+ const ops = fake.addCrew("Ops");
+ const scout = fake.addBot(ops.id, "Scout", "Finds sources");
+ fake.setBotState(scout.id, "busy");
+ renderApp(fake);
+ await crewOpened("Ops");
+ openBot("Scout");
+ const row = () => screen.getByRole("button", { name: /^Scout,/ });
+ act(() => {
+ fake.chat.tool(scout.id, "mcp__botloft__send_message", {
+ summary: "@writer",
+ input: '{"to":"writer","body":"hi"}',
+ });
+ });
+ expect(within(row()).getByText("Send a message · @writer")).toBeDefined();
+ act(() => {
+ fake.chat.ask(scout.id, "Bash", "rm -rf build", '{"command":"rm -rf build"}');
+ });
+ expect(within(row()).getByText("Waiting for approval: run a command")).toBeDefined();
+ });
+});
diff --git a/app/src/features/chat/toolNames.ts b/app/src/features/chat/toolNames.ts
new file mode 100644
index 00000000..74f6972f
--- /dev/null
+++ b/app/src/features/chat/toolNames.ts
@@ -0,0 +1,79 @@
+// A tool in the owner's language (spec 15.6): its name as an action ("Send
+// a message", not `send_message`) and what it is about. The daemon's
+// summaries carry no wording; where one would need words, they are made
+// here from the tool's input.
+
+import type { Messages } from "../../i18n/en";
+
+type Words = Messages["tools"];
+
+/** `mcp__botloft__send_message` is `send_message`; built-ins keep theirs. */
+export function toolKey(name: string): string {
+ return name.split("__").at(-1) ?? name;
+}
+
+function known(key: string, t: Words): string | undefined {
+ return Object.hasOwn(t.names, key) ? t.names[key as keyof Words["names"]] : undefined;
+}
+
+/** The tool as a title: "Send a message"; an unknown one, by its name. */
+export function toolTitle(name: string, t: Words): string {
+ const key = toolKey(name);
+ return known(key, t) ?? key;
+}
+
+/** The tool inside a sentence: "wants to send a message". */
+export function toolAction(name: string, t: Words): string {
+ const key = toolKey(name);
+ const title = known(key, t);
+ return title ? title.charAt(0).toLowerCase() + title.slice(1) : t.use(key);
+}
+
+function input(tool: { input: string }): Record {
+ try {
+ const parsed: unknown = JSON.parse(tool.input);
+ return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {};
+ } catch {
+ return {};
+ }
+}
+
+/** What the call is about, after the title. */
+export function toolDetail(
+ tool: { name: string; summary: string; input: string },
+ t: Words,
+): string {
+ switch (toolKey(tool.name)) {
+ case "send_message": {
+ const to = input(tool).to;
+ return typeof to === "string" ? `@${to.replace(/^@/, "")}` : "";
+ }
+ // Refs like "e12" mean nothing to the owner: the browser panel shows
+ // what the bot clicked, by name.
+ case "complete_task":
+ case "TodoWrite":
+ case "browser_click":
+ case "browser_type":
+ case "browser_select":
+ return "";
+ case "browser_scroll": {
+ const to = input(tool).to;
+ return typeof to === "string" && Object.hasOwn(t.scroll, to)
+ ? t.scroll[to as keyof Words["scroll"]]
+ : "";
+ }
+ case "ToolSearch": {
+ const query = input(tool).query;
+ if (typeof query === "string" && query.startsWith("select:")) {
+ return query
+ .slice("select:".length)
+ .split(",")
+ .map((name) => toolTitle(name.trim(), t))
+ .join(", ");
+ }
+ return tool.summary;
+ }
+ default:
+ return tool.summary;
+ }
+}
diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx
index 1d894481..73883475 100644
--- a/app/src/features/crews/Sidebar.tsx
+++ b/app/src/features/crews/Sidebar.tsx
@@ -12,6 +12,7 @@ import { AccountArea } from "../account/AccountArea";
import { BotAvatar, moodOf } from "../bots/BotAvatar";
import { BotStateBadge, stateView } from "../bots/BotStateBadge";
import { ChiefBadge, isChief } from "../bots/ChiefBadge";
+import { toolAction, toolTitle } from "../chat/toolNames";
import { CrewDialog } from "./CrewDialog";
/** Crews as sections and their bots as conversations (spec 15.1). */
@@ -113,9 +114,7 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) {
- {activity
- ? activityText(activity, t.crews.sidebar)
- : bot.role || t.crews.sidebar.noMessages}
+ {activity ? activityText(activity, t) : bot.role || t.crews.sidebar.noMessages}
@@ -125,13 +124,22 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) {
}
/** The conversation-list line, worded here from what the daemon sends. */
-function activityText(activity: Activity, t: Messages["crews"]["sidebar"]): string {
+/** The line in the owner's language: the daemon sends no wording. */
+function activityText(activity: Activity, t: Messages): string {
+ const { text, tool } = activity;
switch (activity.kind) {
case "owner":
- return t.fromOwner(activity.text);
+ return t.crews.sidebar.fromOwner(text);
case "approval":
- return t.awaitingApproval(activity.text);
+ return t.crews.sidebar.awaitingApproval(tool ? toolAction(tool, t.tools) : text);
+ case "tool": {
+ if (!tool) {
+ return text;
+ }
+ const title = toolTitle(tool, t.tools);
+ return text ? `${title} · ${text}` : title;
+ }
default:
- return activity.text;
+ return text;
}
}
diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts
index c718682b..f44a0594 100644
--- a/app/src/i18n/en/chat.ts
+++ b/app/src/i18n/en/chat.ts
@@ -61,8 +61,8 @@ export const chat = {
error: "Error",
},
approval: {
- asks: (bot: string, tool: string) => `${bot} asks to use ${tool}`,
- wants: (bot: string, tool: string) => `${bot} wants to use ${tool}`,
+ asks: (bot: string, action: string) => `${bot} asks to ${action}`,
+ wants: (bot: string, action: string) => `${bot} wants to ${action}`,
fullInput: "Full input",
noteLabel: (bot: string) => `Note for ${bot} if you deny`,
notePlaceholder: "Why not? Sent to the bot if you deny (optional)",
@@ -70,9 +70,9 @@ export const chat = {
deny: "Deny",
allowFailed: "Could not allow it",
denyFailed: "Could not deny it",
- allowed: (tool: string) => `You allowed ${tool}`,
- denied: (tool: string) => `You denied ${tool}`,
- expired: (tool: string) => `${tool} was not approved in time`,
+ allowed: (action: string) => `Allowed: ${action}`,
+ denied: (action: string) => `Denied: ${action}`,
+ expired: (action: string) => `Not answered in time: ${action}`,
},
mode: {
title: "Mode",
diff --git a/app/src/i18n/en/index.ts b/app/src/i18n/en/index.ts
index fc1409e4..1ac136c6 100644
--- a/app/src/i18n/en/index.ts
+++ b/app/src/i18n/en/index.ts
@@ -12,6 +12,7 @@ import { onboarding } from "./onboarding";
import { routines } from "./routines";
import { screens } from "./screens";
import { shell } from "./shell";
+import { tools } from "./tools";
import { updates } from "./updates";
export const en = {
@@ -28,6 +29,7 @@ export const en = {
messages,
routines,
screens,
+ tools,
};
export type Messages = typeof en;
diff --git a/app/src/i18n/en/tools.ts b/app/src/i18n/en/tools.ts
new file mode 100644
index 00000000..9077c0b3
--- /dev/null
+++ b/app/src/i18n/en/tools.ts
@@ -0,0 +1,53 @@
+// What bots do with their tools, in plain words: the chat's tool lines, the
+// requests to allow one and the conversation list. Keyed by the tool's name
+// (the part after `mcp__botloft__` for ours); each is an action, so it
+// reads right while it runs and after.
+
+export const tools = {
+ names: {
+ Bash: "Run a command",
+ PowerShell: "Run a command",
+ BashOutput: "Read a command's output",
+ KillShell: "Stop a command",
+ KillBash: "Stop a command",
+ Read: "Read a file",
+ Write: "Write a file",
+ Edit: "Edit a file",
+ MultiEdit: "Edit a file",
+ NotebookEdit: "Edit a notebook",
+ Grep: "Search in files",
+ Glob: "Find files",
+ LS: "List a folder",
+ WebFetch: "Read a web page",
+ WebSearch: "Search the web",
+ TodoWrite: "Update the to-do list",
+ ExitPlanMode: "Present the plan",
+ Task: "Call a helper",
+ Agent: "Call a helper",
+ ToolSearch: "Load tools",
+ SlashCommand: "Run a shortcut",
+ Skill: "Use a skill",
+ crew_roster: "See the crew",
+ send_message: "Send a message",
+ complete_task: "Finish a task",
+ my_tasks: "See its tasks",
+ suggest_bot: "Suggest a bot",
+ browser: "Use a site",
+ browser_help: "Ask for your hand",
+ browser_open: "Open a page",
+ browser_look: "Read the page",
+ browser_click: "Click",
+ browser_type: "Type in a field",
+ browser_select: "Choose an option",
+ browser_press: "Press a key",
+ browser_scroll: "Scroll the page",
+ browser_back: "Go back a page",
+ browser_screenshot: "Look at the screen",
+ browser_close: "Close the browser",
+ browser_ask_owner: "Ask for your hand",
+ },
+ /** Where `browser_scroll` went. */
+ scroll: { down: "down", up: "up", top: "to the top", bottom: "to the end" },
+ /** A tool with no name of its own here, as an action. */
+ use: (tool: string) => `use ${tool}`,
+};
diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts
index 55ea3aa0..e8059ede 100644
--- a/app/src/i18n/es/chat.ts
+++ b/app/src/i18n/es/chat.ts
@@ -66,8 +66,8 @@ export const chat: Messages["chat"] = {
error: "Error",
},
approval: {
- asks: (bot: string, tool: string) => `${bot} pide usar ${tool}`,
- wants: (bot: string, tool: string) => `${bot} quiere usar ${tool}`,
+ asks: (bot: string, action: string) => `${bot} pide ${action}`,
+ wants: (bot: string, action: string) => `${bot} quiere ${action}`,
fullInput: "Entrada completa",
noteLabel: (bot: string) => `Nota para ${bot} si deniegas`,
notePlaceholder: "¿Por qué no? Se envía al bot si deniegas (opcional)",
@@ -75,9 +75,9 @@ export const chat: Messages["chat"] = {
deny: "Denegar",
allowFailed: "No se pudo permitir",
denyFailed: "No se pudo denegar",
- allowed: (tool: string) => `Permitiste ${tool}`,
- denied: (tool: string) => `Denegaste ${tool}`,
- expired: (tool: string) => `${tool} no se aprobó a tiempo`,
+ allowed: (action: string) => `Permitido: ${action}`,
+ denied: (action: string) => `Denegado: ${action}`,
+ expired: (action: string) => `Sin respuesta a tiempo: ${action}`,
},
mode: {
title: "Modo",
diff --git a/app/src/i18n/es/index.ts b/app/src/i18n/es/index.ts
index 5391baf7..4fa952d9 100644
--- a/app/src/i18n/es/index.ts
+++ b/app/src/i18n/es/index.ts
@@ -13,6 +13,7 @@ import { onboarding } from "./onboarding";
import { routines } from "./routines";
import { screens } from "./screens";
import { shell } from "./shell";
+import { tools } from "./tools";
import { updates } from "./updates";
export const es: Messages = {
@@ -29,4 +30,5 @@ export const es: Messages = {
messages,
routines,
screens,
+ tools,
};
diff --git a/app/src/i18n/es/tools.ts b/app/src/i18n/es/tools.ts
new file mode 100644
index 00000000..d8b9b081
--- /dev/null
+++ b/app/src/i18n/es/tools.ts
@@ -0,0 +1,57 @@
+// Lo que los bots hacen con sus herramientas, en palabras simples: las
+// líneas de herramienta del chat, los pedidos para permitir una y la lista
+// de conversaciones.
+
+import type { Messages } from "../en";
+
+export const tools: Messages["tools"] = {
+ names: {
+ Bash: "Ejecutar un comando",
+ PowerShell: "Ejecutar un comando",
+ BashOutput: "Leer la salida de un comando",
+ KillShell: "Detener un comando",
+ KillBash: "Detener un comando",
+ Read: "Leer un archivo",
+ Write: "Escribir un archivo",
+ Edit: "Editar un archivo",
+ MultiEdit: "Editar un archivo",
+ NotebookEdit: "Editar un cuaderno",
+ Grep: "Buscar en los archivos",
+ Glob: "Encontrar archivos",
+ LS: "Listar una carpeta",
+ WebFetch: "Leer una página web",
+ WebSearch: "Buscar en la web",
+ TodoWrite: "Actualizar la lista de tareas",
+ ExitPlanMode: "Presentar el plan",
+ Task: "Llamar a un ayudante",
+ Agent: "Llamar a un ayudante",
+ ToolSearch: "Cargar herramientas",
+ SlashCommand: "Ejecutar un atajo",
+ Skill: "Usar una habilidad",
+ crew_roster: "Ver el equipo",
+ send_message: "Enviar un mensaje",
+ complete_task: "Terminar una tarea",
+ my_tasks: "Ver sus tareas",
+ suggest_bot: "Sugerir un bot",
+ browser: "Usar un sitio",
+ browser_help: "Pedir tu ayuda",
+ browser_open: "Abrir una página",
+ browser_look: "Leer la página",
+ browser_click: "Hacer clic",
+ browser_type: "Escribir en un campo",
+ browser_select: "Elegir una opción",
+ browser_press: "Pulsar una tecla",
+ browser_scroll: "Desplazar la página",
+ browser_back: "Volver una página",
+ browser_screenshot: "Mirar la pantalla",
+ browser_close: "Cerrar el navegador",
+ browser_ask_owner: "Pedir tu ayuda",
+ },
+ scroll: {
+ down: "hacia abajo",
+ up: "hacia arriba",
+ top: "hasta arriba",
+ bottom: "hasta el final",
+ },
+ use: (tool) => `usar ${tool}`,
+};
diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts
index e01756e2..a25f1513 100644
--- a/app/src/i18n/pt-BR/chat.ts
+++ b/app/src/i18n/pt-BR/chat.ts
@@ -66,8 +66,8 @@ export const chat: Messages["chat"] = {
error: "Erro",
},
approval: {
- asks: (bot: string, tool: string) => `${bot} pede para usar ${tool}`,
- wants: (bot: string, tool: string) => `${bot} quer usar ${tool}`,
+ asks: (bot: string, action: string) => `${bot} pede para ${action}`,
+ wants: (bot: string, action: string) => `${bot} quer ${action}`,
fullInput: "Entrada completa",
noteLabel: (bot: string) => `Observação para ${bot} se você negar`,
notePlaceholder: "Por que não? Vai para o bot se você negar (opcional)",
@@ -75,9 +75,9 @@ export const chat: Messages["chat"] = {
deny: "Negar",
allowFailed: "Não foi possível permitir",
denyFailed: "Não foi possível negar",
- allowed: (tool: string) => `Você permitiu ${tool}`,
- denied: (tool: string) => `Você negou ${tool}`,
- expired: (tool: string) => `${tool} não foi aprovado a tempo`,
+ allowed: (action: string) => `Permitido: ${action}`,
+ denied: (action: string) => `Negado: ${action}`,
+ expired: (action: string) => `Sem resposta a tempo: ${action}`,
},
mode: {
title: "Modo",
diff --git a/app/src/i18n/pt-BR/index.ts b/app/src/i18n/pt-BR/index.ts
index e186e942..3e3a7b69 100644
--- a/app/src/i18n/pt-BR/index.ts
+++ b/app/src/i18n/pt-BR/index.ts
@@ -13,6 +13,7 @@ import { onboarding } from "./onboarding";
import { routines } from "./routines";
import { screens } from "./screens";
import { shell } from "./shell";
+import { tools } from "./tools";
import { updates } from "./updates";
export const ptBR: Messages = {
@@ -29,4 +30,5 @@ export const ptBR: Messages = {
messages,
routines,
screens,
+ tools,
};
diff --git a/app/src/i18n/pt-BR/tools.ts b/app/src/i18n/pt-BR/tools.ts
new file mode 100644
index 00000000..99e931d7
--- /dev/null
+++ b/app/src/i18n/pt-BR/tools.ts
@@ -0,0 +1,51 @@
+// O que os bots fazem com as ferramentas, em palavras simples: as linhas de
+// ferramenta do chat, os pedidos para permitir uma e a lista de conversas.
+
+import type { Messages } from "../en";
+
+export const tools: Messages["tools"] = {
+ names: {
+ Bash: "Rodar um comando",
+ PowerShell: "Rodar um comando",
+ BashOutput: "Ler a saída de um comando",
+ KillShell: "Parar um comando",
+ KillBash: "Parar um comando",
+ Read: "Ler um arquivo",
+ Write: "Escrever um arquivo",
+ Edit: "Editar um arquivo",
+ MultiEdit: "Editar um arquivo",
+ NotebookEdit: "Editar um notebook",
+ Grep: "Procurar nos arquivos",
+ Glob: "Encontrar arquivos",
+ LS: "Listar uma pasta",
+ WebFetch: "Ler uma página da web",
+ WebSearch: "Pesquisar na web",
+ TodoWrite: "Atualizar a lista de tarefas",
+ ExitPlanMode: "Apresentar o plano",
+ Task: "Chamar um ajudante",
+ Agent: "Chamar um ajudante",
+ ToolSearch: "Carregar ferramentas",
+ SlashCommand: "Rodar um atalho",
+ Skill: "Usar uma habilidade",
+ crew_roster: "Ver a equipe",
+ send_message: "Mandar uma mensagem",
+ complete_task: "Concluir uma tarefa",
+ my_tasks: "Ver as próprias tarefas",
+ suggest_bot: "Sugerir um bot",
+ browser: "Usar um site",
+ browser_help: "Pedir sua ajuda",
+ browser_open: "Abrir uma página",
+ browser_look: "Ler a página",
+ browser_click: "Clicar",
+ browser_type: "Escrever num campo",
+ browser_select: "Escolher uma opção",
+ browser_press: "Apertar uma tecla",
+ browser_scroll: "Rolar a página",
+ browser_back: "Voltar uma página",
+ browser_screenshot: "Olhar a tela",
+ browser_close: "Fechar o navegador",
+ browser_ask_owner: "Pedir sua ajuda",
+ },
+ scroll: { down: "para baixo", up: "para cima", top: "até o topo", bottom: "até o fim" },
+ use: (tool) => `usar ${tool}`,
+};
diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts
index 5f8460e5..0db5c99b 100644
--- a/app/src/lib/fakeChat.ts
+++ b/app/src/lib/fakeChat.ts
@@ -21,8 +21,10 @@ export const SUGGEST_TOOL = "mcp__botloft__suggest_bot";
const HELP_TOOL = "mcp__botloft__browser_help";
/** The conversation-list line for an item, like the daemon's (spec 8.3). */
-export function activityLine(body: ChatBody): { kind: ActivityKind; text: string } | null {
- const label = (name: string) => name.split("__").at(-1) ?? name;
+export function activityLine(
+ body: ChatBody,
+): { kind: ActivityKind; text: string; tool: string | null } | null {
+ let tool: string | null = null;
let kind: ActivityKind;
let text: string;
switch (body.kind) {
@@ -36,11 +38,13 @@ export function activityLine(body: ChatBody): { kind: ActivityKind; text: string
break;
case "tool":
kind = "tool";
- text = body.summary ? `${label(body.name)} · ${body.summary}` : label(body.name);
+ text = body.summary;
+ tool = body.name;
break;
case "approval":
kind = "approval";
- text = label(body.toolName);
+ text = body.summary;
+ tool = body.toolName;
break;
case "notice":
kind = "notice";
@@ -50,7 +54,7 @@ export function activityLine(body: ChatBody): { kind: ActivityKind; text: string
return null;
}
const flat = text.split(/\s+/).filter(Boolean).join(" ");
- return { kind, text: flat.length <= 120 ? flat : `${flat.slice(0, 119).trimEnd()}…` };
+ return { kind, text: flat.length <= 120 ? flat : `${flat.slice(0, 119).trimEnd()}…`, tool };
}
export class FakeChat {
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index 9f64e58d..733c8004 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -123,9 +123,14 @@ export type ActivityKind = "owner" | "message" | "reply" | "tool" | "approval" |
*/
export type Activity = { kind: ActivityKind,
/**
- * One line, at most 120 characters, without wording of its own.
+ * One line, at most 120 characters, without wording of its own: for
+ * a tool or a request, what it is about.
*/
text: string,
+/**
+ * The tool, for a tool call or a request: the app names it.
+ */
+tool: string | null,
/**
* Unix time in milliseconds.
*/
diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs
index 94f2ac9e..cbfd3e32 100644
--- a/crates/botloft-core/src/chat.rs
+++ b/crates/botloft-core/src/chat.rs
@@ -3,7 +3,7 @@
use serde_json::Value;
-use crate::protocol::{ActivityKind, ChatBody, SenderKind};
+use crate::protocol::{Activity, ActivityKind, ChatBody, SenderKind};
/// Longest tool input kept, in bytes.
pub const TOOL_INPUT_MAX: usize = 4 * 1024;
@@ -82,7 +82,8 @@ pub fn tool_summary(name: &str, input: &Value) -> String {
"WebFetch" => field(input, "url").map(str::to_owned),
"WebSearch" => field(input, "query").map(str::to_owned),
"Task" | "Agent" => field(input, "description").map(str::to_owned),
- "TodoWrite" => Some("Updated the plan".to_owned()),
+ // The app names these; nothing to add in any language.
+ "TodoWrite" | "mcp__botloft__complete_task" => None,
// The plan's first line, usually its title.
PLAN_TOOL => field(input, "plan").and_then(|plan| {
plan.lines()
@@ -90,31 +91,23 @@ pub fn tool_summary(name: &str, input: &Value) -> String {
.find(|line| !line.is_empty())
.map(str::to_owned)
}),
- // Claude Code loads deferred tools (such as ours) by name first.
- "ToolSearch" => field(input, "query").map(|query| match query.strip_prefix("select:") {
- Some(names) => {
- let labels: Vec<_> = names.split(',').map(|n| tool_label(n.trim())).collect();
- format!("load {}", labels.join(", "))
- }
- None => query.to_owned(),
- }),
+ // Claude Code loads deferred tools (such as ours) by name first; the
+ // app names the tools loaded, from the input.
+ "ToolSearch" => field(input, "query")
+ .filter(|query| !query.starts_with("select:"))
+ .map(str::to_owned),
"mcp__botloft__send_message" => {
- field(input, "to").map(|to| format!("to @{}", to.trim_start_matches('@')))
+ field(input, "to").map(|to| format!("@{}", to.trim_start_matches('@')))
}
- "mcp__botloft__complete_task" => field(input, "task_id").map(|id| format!("task {id}")),
SUGGEST_TOOL => field(input, "name").map(str::to_owned),
BROWSER_SITE_TOOL => field(input, "site").map(str::to_owned),
BROWSER_HELP_TOOL | "mcp__botloft__browser_ask_owner" => {
field(input, "task").map(str::to_owned)
}
"mcp__botloft__browser_open" => field(input, "url").map(str::to_owned),
- "mcp__botloft__browser_click"
- | "mcp__botloft__browser_type"
- | "mcp__botloft__browser_select" => field(input, "ref").map(str::to_owned),
+ // Element refs ("e12") mean nothing to the owner, and a direction
+ // needs words: the app says those (spec 21.8).
"mcp__botloft__browser_press" => field(input, "key").map(str::to_owned),
- "mcp__botloft__browser_scroll" => field(input, "ref")
- .or_else(|| field(input, "to"))
- .map(str::to_owned),
_ => None,
};
one_line(&summary.unwrap_or_default(), SUMMARY_MAX_CHARS)
@@ -143,32 +136,33 @@ pub fn tool_label(name: &str) -> &str {
name.rsplit("__").next().unwrap_or(name)
}
-/// The conversation-list line for a chat item, and what kind of line it
-/// is; `None` for items that do not change it (turn ends). The text has no
-/// wording of its own ("You:", "Waiting for approval"): the app adds it in
-/// the owner's language.
-pub fn activity_line(body: &ChatBody) -> Option<(ActivityKind, String)> {
+/// The conversation-list line for a chat item, from `at`; `None` for
+/// items that do not change it (turn ends). It has no wording of its own
+/// ("You:", "Waiting for approval", a tool's name): the app adds it in the
+/// owner's language, from `kind` and `tool`.
+pub fn activity(body: &ChatBody, at: i64) -> Option {
let (kind, text) = match body {
ChatBody::Inbound(item) => match item.message.from_kind {
SenderKind::Owner => (ActivityKind::Owner, item.message.body.clone()),
_ => (ActivityKind::Message, item.message.body.clone()),
},
ChatBody::Reply(item) => (ActivityKind::Reply, item.text.clone()),
- ChatBody::Tool(item) => (
- ActivityKind::Tool,
- match item.summary.as_str() {
- "" => tool_label(&item.name).to_owned(),
- summary => format!("{} · {summary}", tool_label(&item.name)),
- },
- ),
- ChatBody::Approval(item) => (
- ActivityKind::Approval,
- tool_label(&item.tool_name).to_owned(),
- ),
+ ChatBody::Tool(item) => (ActivityKind::Tool, item.summary.clone()),
+ ChatBody::Approval(item) => (ActivityKind::Approval, item.summary.clone()),
ChatBody::Notice(item) => (ActivityKind::Notice, item.text.clone()),
ChatBody::Turn(_) => return None,
};
- Some((kind, one_line(&text, ACTIVITY_MAX_CHARS)))
+ let tool = match body {
+ ChatBody::Tool(item) => Some(item.name.clone()),
+ ChatBody::Approval(item) => Some(item.tool_name.clone()),
+ _ => None,
+ };
+ Some(Activity {
+ kind,
+ text: one_line(&text, ACTIVITY_MAX_CHARS),
+ tool,
+ at,
+ })
}
#[cfg(test)]
@@ -210,15 +204,16 @@ mod tests {
"mcp__botloft__send_message",
&json!({ "to": "@writer", "body": "x" })
),
- "to @writer"
+ "@writer"
);
assert_eq!(
tool_summary(
"ToolSearch",
&json!({ "query": "select:mcp__botloft__send_message,Read", "max_results": 3 })
),
- "load send_message, Read"
+ ""
);
+ assert_eq!(tool_summary("TodoWrite", &json!({ "todos": [] })), "");
assert_eq!(
tool_summary("ToolSearch", &json!({ "query": "notebook jupyter" })),
"notebook jupyter"
@@ -236,7 +231,7 @@ mod tests {
"mcp__botloft__browser_type",
&json!({ "ref": "e4", "text": "secret" })
),
- "e4"
+ ""
);
assert_eq!(
tool_summary(BROWSER_SITE_TOOL, &json!({ "site": "example.com" })),
diff --git a/crates/botloft-core/src/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs
index 092158a8..97d55450 100644
--- a/crates/botloft-core/src/protocol/chat.rs
+++ b/crates/botloft-core/src/protocol/chat.rs
@@ -239,8 +239,12 @@ pub struct ApprovalsAnswerParams {
#[cfg_attr(test, derive(ts_rs::TS))]
pub struct Activity {
pub kind: ActivityKind,
- /// One line, at most 120 characters, without wording of its own.
+ /// One line, at most 120 characters, without wording of its own: for
+ /// a tool or a request, what it is about.
pub text: String,
+ /// The tool, for a tool call or a request: the app names it.
+ #[serde(default)]
+ pub tool: Option,
/// Unix time in milliseconds.
pub at: i64,
}
diff --git a/crates/botloft-store/src/chat.rs b/crates/botloft-store/src/chat.rs
index 7a81257b..7367e805 100644
--- a/crates/botloft-store/src/chat.rs
+++ b/crates/botloft-store/src/chat.rs
@@ -1,7 +1,7 @@
//! `chat_items` table: each bot's chat (spec 8). The body is stored as the
//! protocol's JSON, so new item kinds need no migration.
-use botloft_core::chat::activity_line;
+use botloft_core::chat::activity;
use botloft_core::ids::{BotId, ChatItemId};
use botloft_core::protocol::{Activity, ChatBody, ChatItem};
use rusqlite::types::Type;
@@ -153,13 +153,7 @@ impl Store {
ORDER BY rowid DESC LIMIT 1"
))?;
let item = stmt.query_row([bot.as_str()], from_row).optional()?;
- Ok(item.and_then(|item| {
- activity_line(&item.body).map(|(kind, text)| Activity {
- kind,
- text,
- at: item.updated_at,
- })
- }))
+ Ok(item.and_then(|item| activity(&item.body, item.updated_at)))
}
}
@@ -309,6 +303,7 @@ mod tests {
Some(Activity {
kind: ActivityKind::Reply,
text: "All done. Details below".into(),
+ tool: None,
at: 7
})
);
diff --git a/crates/botloftd/src/chat/items.rs b/crates/botloftd/src/chat/items.rs
index f89c0d61..5e11672a 100644
--- a/crates/botloftd/src/chat/items.rs
+++ b/crates/botloftd/src/chat/items.rs
@@ -1,8 +1,8 @@
//! Saving chat items and telling the app (spec 8.3).
-use botloft_core::chat::activity_line;
+use botloft_core::chat::activity;
use botloft_core::ids::{BotId, ChatItemId};
-use botloft_core::protocol::{Activity, ChatBody, ChatItem, ChatItemChanged};
+use botloft_core::protocol::{ChatBody, ChatItem, ChatItemChanged};
use tracing::warn;
use crate::state::{Daemon, Event};
@@ -28,11 +28,7 @@ pub(crate) fn add(daemon: &Daemon, bot: &BotId, body: ChatBody) -> Option`.
- Datas e horas (`lib/format.ts`) usam o idioma escolhido.
-- O daemon não escreve texto para o dono: avisos vêm com `code` e a linha da conversa com `kind` (8.2, 11.2), e o app escreve. Continuam como vêm: nomes, mensagens, respostas e saídas de ferramenta; o resumo de ferramenta que o daemon faz a partir da entrada (o comando, o arquivo); e as mensagens de erro do daemon (validação, falhas), mostradas como texto técnico.
+- O daemon não escreve texto para o dono: avisos vêm com `code` e a linha da conversa com `kind` (8.2, 11.2), e o app escreve. Continuam como vêm: nomes, mensagens, respostas e saídas de ferramenta; o resumo de ferramenta que o daemon tira da entrada (o comando, o arquivo), sem o nome da ferramenta, que o app escreve; e as mensagens de erro do daemon (validação, falhas), mostradas como texto técnico.
- O instalador NSIS também traz os três idiomas e escolhe pelo idioma do Windows.
- Tom: palavras simples, sem jargão; "você" em português, "tú" em espanhol. Glossário: crew = equipe / equipo; task = tarefa / tarea; Allow / Deny = Permitir / Negar / Denegar; role = função / rol.