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.