From ca57ab2b5d9e5a62db55c89c9c12d571647232c9 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:53:58 -0300 Subject: [PATCH 1/2] feat(chat): tool summaries and the list line carry no words of the daemon A tool's summary is only what its input says (the command, the file, @writer, a key), empty where words would be needed (TodoWrite, complete_task, a ToolSearch that loads tools, a scroll's direction) or where it means nothing to the owner (an element ref). The conversation list line no longer starts with the tool's name: it comes apart in Activity.tool, for the app to name in the owner's language. Spec 8.2, 11 and 15.6. --- app/src/lib/protocol.gen.ts | 7 ++- crates/botloft-core/src/chat.rs | 71 +++++++++++------------- crates/botloft-core/src/protocol/chat.rs | 6 +- crates/botloft-store/src/chat.rs | 11 +--- crates/botloftd/src/chat/items.rs | 10 +--- crates/botloftd/tests/chat.rs | 10 +++- docs/spec.md | 6 +- 7 files changed, 60 insertions(+), 61 deletions(-) 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. From 7242886b08d39c674b5aa0af22b55ae22f225ad6 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:53:58 -0300 Subject: [PATCH 2/2] feat(app): tools read as plain actions in the owner's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat's tool lines, the requests to allow a tool and the conversation list name each tool as an action in the owner's language ("Mandar uma mensagem", "Abrir uma página", "Rodar um comando") instead of its identifier. Where the daemon leaves the detail empty, the app says it from the input: the tools a ToolSearch loads, a scroll's direction. An unknown tool shows by its name. Approvals read "Scout quer rodar um comando" and "Permitido: escrever um arquivo". --- app/src/dev/seedChats.ts | 2 +- app/src/features/browser/browser.test.tsx | 2 +- app/src/features/chat/ApprovalCard.tsx | 7 +- app/src/features/chat/ToolLines.tsx | 22 +++---- app/src/features/chat/chat.test.tsx | 11 ++-- app/src/features/chat/toolNames.test.tsx | 67 +++++++++++++++++++ app/src/features/chat/toolNames.ts | 79 +++++++++++++++++++++++ app/src/features/crews/Sidebar.tsx | 22 +++++-- app/src/i18n/en/chat.ts | 10 +-- app/src/i18n/en/index.ts | 2 + app/src/i18n/en/tools.ts | 53 +++++++++++++++ app/src/i18n/es/chat.ts | 10 +-- app/src/i18n/es/index.ts | 2 + app/src/i18n/es/tools.ts | 57 ++++++++++++++++ app/src/i18n/pt-BR/chat.ts | 10 +-- app/src/i18n/pt-BR/index.ts | 2 + app/src/i18n/pt-BR/tools.ts | 51 +++++++++++++++ app/src/lib/fakeChat.ts | 14 ++-- 18 files changed, 372 insertions(+), 51 deletions(-) create mode 100644 app/src/features/chat/toolNames.test.tsx create mode 100644 app/src/features/chat/toolNames.ts create mode 100644 app/src/i18n/en/tools.ts create mode 100644 app/src/i18n/es/tools.ts create mode 100644 app/src/i18n/pt-BR/tools.ts 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 {