From b3e3af3cc7f1729a4f606328ec2b0f67cefec227 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:40:05 -0300 Subject: [PATCH] feat(chat): show the lines each edit adds and removes, with its diff The daemon counts the lines a Write, Edit or MultiEdit call adds and removes from its whole input, before the chat cuts it at 4 KB, and keeps them in the tool item (`lines`). The tool line shows "+N -M", and opening it shows the diff of each replacement instead of the raw input. The app compares lines the same way the daemon counts them, so both agree. No counts for an Edit with replace_all (the input does not say how many matches) or when the input has no texts (Codex and Antigravity send only the path). Spec 8.2 and 15.3 updated. --- app/src/dev/seedChats.ts | 14 ++ app/src/features/chat/EditDiff.tsx | 80 ++++++++++ app/src/features/chat/ToolLines.tsx | 28 +++- app/src/features/chat/editDiff.test.tsx | 138 ++++++++++++++++ app/src/features/chat/lineDiff.ts | 138 ++++++++++++++++ app/src/features/chat/rows.test.ts | 1 + app/src/i18n/en/chat.ts | 5 + app/src/i18n/es/chat.ts | 4 + app/src/i18n/pt-BR/chat.ts | 4 + app/src/lib/fakeChat.ts | 1 + app/src/lib/protocol.gen.ts | 13 +- crates/botloft-core/src/edits.rs | 174 +++++++++++++++++++++ crates/botloft-core/src/lib.rs | 1 + crates/botloft-core/src/protocol/chat.rs | 19 ++- crates/botloft-core/src/protocol/export.rs | 1 + crates/botloft-store/src/chat/tests.rs | 2 + crates/botloft-store/src/search/tests.rs | 1 + crates/botloftd/src/chat/sink.rs | 3 + crates/botloftd/tests/chat.rs | 26 +++ docs/spec.md | 4 +- 20 files changed, 649 insertions(+), 8 deletions(-) create mode 100644 app/src/features/chat/EditDiff.tsx create mode 100644 app/src/features/chat/editDiff.test.tsx create mode 100644 app/src/features/chat/lineDiff.ts create mode 100644 crates/botloft-core/src/edits.rs diff --git a/app/src/dev/seedChats.ts b/app/src/dev/seedChats.ts index 441b84d2..bacd07f5 100644 --- a/app/src/dev/seedChats.ts +++ b/app/src/dev/seedChats.ts @@ -142,6 +142,20 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void { at(90); chat.finish(chat.tool(crew.writer, "Read", { summary: "week-39.md" }), "42 lines"); chat.reply(crew.writer, "Starting the draft. I'll keep the three highlights as sections."); + const draft = "C:\\Work\\reports\\week-39-report.md"; + const outline = chat.tool(crew.writer, "Edit", { + summary: "week-39-report.md", + file: draft, + input: JSON.stringify({ + file_path: draft, + old_string: "# Week 39\n\nTODO: highlights", + new_string: + "# Week 39\n\n## Retrieval at scale\n## Agents that plan\n## Small evals\n\nSources: shared/summaries/week-39.md", + }), + lines: { added: 5, removed: 1 }, + }); + chat.finish(outline, "The file has been updated."); + chat.reply(crew.writer, "The outline is in. Writing the sections now."); chat.tool(crew.writer, "Write", { summary: "week-39-report.md" }); at(40); diff --git a/app/src/features/chat/EditDiff.tsx b/app/src/features/chat/EditDiff.tsx new file mode 100644 index 00000000..10601288 --- /dev/null +++ b/app/src/features/chat/EditDiff.tsx @@ -0,0 +1,80 @@ +// How much an edit changed, on its tool line ("+12 −3"), and the diff the +// line opens to (spec 15.3). Each changed line carries its + or −, so the +// colors are never the only sign. + +import { useT } from "../../i18n"; +import type { EditLines } from "../../lib/protocol.gen"; +import type { DiffLine } from "./lineDiff"; + +/** The counts the daemon took from the whole edit (spec 8.2); a zero side + * is left out, and an edit that changed nothing shows nothing. */ +export function EditCounts({ lines }: { lines: EditLines }) { + const t = useT(); + const { added, removed } = lines; + if (added === 0 && removed === 0) { + return null; + } + const said = [ + added > 0 && t.chat.tools.added(added), + removed > 0 && t.chat.tools.removed(removed), + ] + .filter(Boolean) + .join(", "); + return ( + + {added > 0 && +{added}} + {removed > 0 && −{removed}} + + ); +} + +const MARK: Record = { same: " ", added: "+", removed: "−" }; + +const LINE: Record = { + same: "", + added: "bg-ok/12", + removed: "bg-danger/12", +}; + +const SIGN: Record = { + same: "text-muted", + added: "text-ok", + removed: "text-danger", +}; + +/** One block per replacement, each line with its mark. */ +export function EditDiff({ diffs }: { diffs: DiffLine[][] }) { + return ( +
+ {diffs.map((lines, n) => ( +
+          {lines.map((line, i) => (
+            
+              
+                {MARK[line.kind]}
+              
+              {line.text || " "}
+            
+          ))}
+        
+ ))} +
+ ); +} diff --git a/app/src/features/chat/ToolLines.tsx b/app/src/features/chat/ToolLines.tsx index 2cdb9684..90fbc5cb 100644 --- a/app/src/features/chat/ToolLines.tsx +++ b/app/src/features/chat/ToolLines.tsx @@ -27,7 +27,7 @@ import { Wrench, X, } from "lucide-react"; -import { useContext, useState } from "react"; +import { useContext, useMemo, useState } from "react"; import { useT } from "../../i18n"; import type { ChatItem, ToolItem } from "../../lib/protocol.gen"; import { useArrival } from "../../ui/motion"; @@ -37,6 +37,8 @@ import { ShowFile } from "../files/showFile"; import { isScreenFile, ShowScreen } from "../screens/showScreen"; import { ShowTerminal } from "../terminal/showTerminal"; import { commandOf, isCommand } from "./command"; +import { EditCounts, EditDiff } from "./EditDiff"; +import { editDiffs, isEditTool } from "./lineDiff"; import { ReadPicture, ReadThumb, useReadImage } from "./readImage"; import { toolDetail, toolKey, toolTitle } from "./toolNames"; @@ -129,6 +131,11 @@ function ToolLine({ const said = tool.explanation; const detail = said || toolDetail(tool, t.tools); const command = isCommand(tool.name) ? commandOf(tool.input) : null; + // Read only once the line opens: an edit's input can be 4 KB. + const diffs = useMemo( + () => (open && isEditTool(tool.name) ? editDiffs(tool.name, tool.input) : null), + [open, tool.name, tool.input], + ); 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"; @@ -150,6 +157,7 @@ function ToolLine({ {detail} + {tool.lines && tool.status !== "failed" && } -

- {command ? t.chat.tools.command : t.chat.tools.input} -

-
{command ? command.text : pretty(tool.input)}
+ {diffs ? ( + <> +

{t.chat.tools.changes}

+ + + ) : ( + <> +

+ {command ? t.chat.tools.command : t.chat.tools.input} +

+
{command ? command.text : pretty(tool.input)}
+ + )} {command && !command.whole && (

{t.chat.tools.commandCut}

)} + {!diffs && tool.lines &&

{t.chat.tools.changesCut}

} {tool.output !== null && ( <>

diff --git a/app/src/features/chat/editDiff.test.tsx b/app/src/features/chat/editDiff.test.tsx new file mode 100644 index 00000000..19c915db --- /dev/null +++ b/app/src/features/chat/editDiff.test.tsx @@ -0,0 +1,138 @@ +import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp } from "../../test/app"; +import { type DiffLine, diffLines, editDiffs, splitLines } from "./lineDiff"; + +afterEach(cleanup); + +/** Lines only in the new text and only in the old, as the daemon counts. */ +function counts(lines: DiffLine[]) { + return { + added: lines.filter((line) => line.kind === "added").length, + removed: lines.filter((line) => line.kind === "removed").length, + }; +} + +describe("the lines an edit changes", () => { + test("split the way the daemon splits them", () => { + expect(splitLines("")).toEqual([]); + expect(splitLines("a\nb\n")).toEqual(["a", "b"]); + expect(splitLines("a\r\n\r\nb")).toEqual(["a", "", "b"]); + expect(splitLines("\n")).toEqual([""]); + }); + + test("keep what both share and change the rest, removed first", () => { + const diff = diffLines( + "fn a() {\n one();\n two();\n}", + "fn a() {\n one();\n three();\n four();\n}", + ); + expect(diff.map((line) => `${line.kind[0]} ${line.text}`)).toEqual([ + "s fn a() {", + "s one();", + "r two();", + "a three();", + "a four();", + "s }", + ]); + // The same counts as the daemon's tests (crates/botloft-core/src/edits.rs). + expect(counts(diff)).toEqual({ added: 2, removed: 1 }); + expect(counts(diffLines("a\nb\nc\nd", "b\na\nc\nd\ne"))).toEqual({ added: 2, removed: 1 }); + expect(counts(diffLines("let x = 1;", "let x = 2;"))).toEqual({ added: 1, removed: 1 }); + expect(counts(diffLines("a\r\nb\r\n", ""))).toEqual({ added: 0, removed: 2 }); + }); + + test("come from the input of each edit tool, when it came whole", () => { + const edit = JSON.stringify({ file_path: "a.md", old_string: "a", new_string: "a\nb" }); + expect(editDiffs("Edit", edit)?.map(counts)).toEqual([{ added: 1, removed: 0 }]); + const multi = JSON.stringify({ + edits: [ + { old_string: "a", new_string: "b" }, + { old_string: "x\ny", new_string: "x", replace_all: true }, + ], + }); + expect(editDiffs("MultiEdit", multi)?.map(counts)).toEqual([ + { added: 1, removed: 1 }, + { added: 0, removed: 1 }, + ]); + const write = JSON.stringify({ file_path: "new.txt", content: "one\ntwo\n" }); + expect(editDiffs("Write", write)?.map(counts)).toEqual([{ added: 2, removed: 0 }]); + // Cut to size, only the path (Codex, Antigravity), or another tool. + expect(editDiffs("Edit", `${edit.slice(0, 20)}…`)).toBeNull(); + expect(editDiffs("Edit", JSON.stringify({ file_path: "a.md" }))).toBeNull(); + expect(editDiffs("Read", JSON.stringify({ content: "a" }))).toBeNull(); + }); +}); + +describe("an edit in the chat", () => { + async function chat() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + const list = await screen.findByRole("list", { name: "Messages" }); + return { fake, scout, list }; + } + + test("shows its counts on the line and the diff when opened", async () => { + const { fake, scout, list } = await chat(); + act(() => { + fake.chat.tool(scout.id, "Edit", { + summary: "notes.md", + status: "done", + input: JSON.stringify({ + file_path: "C:\\Work\\notes.md", + old_string: "# Notes\nTODO", + new_string: "# Notes\nFirst\nSecond", + }), + lines: { added: 2, removed: 1 }, + }); + }); + const line = within(list).getByRole("button", { name: /Edit a file/ }); + expect(within(line).getByRole("img", { name: "2 lines added, 1 line removed" })).toBeDefined(); + fireEvent.click(line); + expect(within(list).getByText("Changes")).toBeDefined(); + expect(within(list).queryByText("Input")).toBeNull(); + const kinds = [...list.querySelectorAll("[data-kind]")].map((row) => [ + row.getAttribute("data-kind"), + row.textContent?.slice(1), + ]); + expect(kinds).toEqual([ + ["same", "# Notes"], + ["removed", "TODO"], + ["added", "First"], + ["added", "Second"], + ]); + }); + + test("says when the change is too long to show, and shows no counts when it failed", async () => { + const { fake, scout, list } = await chat(); + act(() => { + fake.chat.tool(scout.id, "Write", { + summary: "big.md", + status: "done", + input: '{"file_path":"big.md","content":"one\\ntwo…', + lines: { added: 900, removed: 0 }, + }); + // A reply in between keeps the two calls on lines of their own. + fake.chat.reply(scout.id, "Now the old notes."); + fake.chat.tool(scout.id, "Edit", { + summary: "gone.md", + status: "failed", + input: JSON.stringify({ file_path: "gone.md", old_string: "a", new_string: "b" }), + lines: { added: 1, removed: 1 }, + }); + }); + const [write, failed] = within(list).getAllByRole("button", { name: /a file/ }); + if (!write || !failed) { + throw new Error("two lines"); + } + expect(within(write).getByRole("img", { name: "900 lines added" })).toBeDefined(); + fireEvent.click(write); + expect(within(list).getByText("This change is too long to show here.")).toBeDefined(); + expect(within(list).getByText("Input")).toBeDefined(); + expect(within(failed).queryByRole("img")).toBeNull(); + }); +}); diff --git a/app/src/features/chat/lineDiff.ts b/app/src/features/chat/lineDiff.ts new file mode 100644 index 00000000..738a87f1 --- /dev/null +++ b/app/src/features/chat/lineDiff.ts @@ -0,0 +1,138 @@ +// What a file edit changes, line by line (spec 15.3): the diff a `Write`, +// `Edit` or `MultiEdit` line opens to, from the call's input when it came +// whole. The daemon counts the same lines the same way (spec 8.2), so the +// diff and the "+N −M" on the line agree. + +export interface DiffLine { + kind: "same" | "added" | "removed"; + text: string; +} + +/** Most pairs of lines compared; past it, every line left after the common + * start and end counts as changed, as in the daemon. */ +const MAX_PAIRS = 4_000_000; + +/** Lines as the daemon reads them: a final line break ends the last line, + * and a carriage return before a break is not part of the line. */ +export function splitLines(text: string): string[] { + if (text === "") { + return []; + } + const lines = text.split("\n"); + if (lines[lines.length - 1] === "") { + lines.pop(); + } + return lines.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); +} + +/** The lines of `old` and `next` in order, each kept, added or removed: + * the longest run they share stays, the rest changes, removed first. */ +export function diffLines(old: string, next: string): DiffLine[] { + const a = splitLines(old); + const b = splitLines(next); + let start = 0; + while (start < a.length && start < b.length && a[start] === b[start]) { + start += 1; + } + let end = 0; + while ( + end < a.length - start && + end < b.length - start && + a[a.length - 1 - end] === b[b.length - 1 - end] + ) { + end += 1; + } + const same = (text: string): DiffLine => ({ kind: "same", text }); + const middleA = a.slice(start, a.length - end); + const middleB = b.slice(start, b.length - end); + return [ + ...a.slice(0, start).map(same), + ...middle(middleA, middleB), + ...a.slice(a.length - end).map(same), + ]; +} + +function middle(a: string[], b: string[]): DiffLine[] { + const removed = (text: string): DiffLine => ({ kind: "removed", text }); + const added = (text: string): DiffLine => ({ kind: "added", text }); + if (a.length * b.length > MAX_PAIRS) { + return [...a.map(removed), ...b.map(added)]; + } + // run(i, j): the longest run `a` from i and `b` from j share. + const width = b.length + 1; + const table = new Uint32Array((a.length + 1) * width); + const run = (i: number, j: number) => table[i * width + j] ?? 0; + for (let i = a.length - 1; i >= 0; i -= 1) { + for (let j = b.length - 1; j >= 0; j -= 1) { + table[i * width + j] = + a[i] === b[j] ? run(i + 1, j + 1) + 1 : Math.max(run(i + 1, j), run(i, j + 1)); + } + } + const lines: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < a.length || j < b.length) { + const left = a[i]; + const right = b[j]; + if (left !== undefined && left === right) { + lines.push({ kind: "same", text: left }); + i += 1; + j += 1; + } else if (left !== undefined && (right === undefined || run(i + 1, j) >= run(i, j + 1))) { + lines.push(removed(left)); + i += 1; + } else { + lines.push(added(right ?? "")); + j += 1; + } + } + return lines; +} + +function text(value: unknown, name: string): string | null { + const field = + value && typeof value === "object" ? (value as Record)[name] : null; + return typeof field === "string" ? field : null; +} + +/** One diff per replacement of an edit call, from its input; `null` for + * other tools, an input cut to size and one without the texts (Codex and + * Antigravity send only the path). */ +export function editDiffs(name: string, input: string): DiffLine[][] | null { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + return null; + } + const replacement = (edit: unknown) => { + const old = text(edit, "old_string"); + const next = text(edit, "new_string"); + return old === null || next === null ? null : diffLines(old, next); + }; + switch (name) { + case "Write": { + const content = text(parsed, "content"); + return content === null ? null : [diffLines("", content)]; + } + case "Edit": { + const diff = replacement(parsed); + return diff && [diff]; + } + case "MultiEdit": { + const edits = (parsed as { edits?: unknown } | null)?.edits; + if (!Array.isArray(edits) || edits.length === 0) { + return null; + } + const diffs = edits.map(replacement); + return diffs.every((diff) => diff !== null) ? (diffs as DiffLine[][]) : null; + } + default: + return null; + } +} + +/** Whether `name` changes a file the way `editDiffs` reads. */ +export function isEditTool(name: string): boolean { + return name === "Write" || name === "Edit" || name === "MultiEdit"; +} diff --git a/app/src/features/chat/rows.test.ts b/app/src/features/chat/rows.test.ts index 10ce0415..7d1192f6 100644 --- a/app/src/features/chat/rows.test.ts +++ b/app/src/features/chat/rows.test.ts @@ -23,6 +23,7 @@ const tool = (name: string) => status: "done", output: null, file: null, + lines: null, }); const turn = () => item({ kind: "turn", durationMs: 1000, tokens: null, error: null }); const inbound = (body: string, at?: number) => diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index 3ae54993..d223f856 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -72,6 +72,11 @@ export const chat = { error: "Error", command: "Command", commandCut: "This command is too long to show in full.", + /** What an edit changed, line by line (spec 15.3). */ + changes: "Changes", + changesCut: "This change is too long to show here.", + added: (n: number) => (n === 1 ? "1 line added" : `${n} lines added`), + removed: (n: number) => (n === 1 ? "1 line removed" : `${n} lines removed`), /** A finished group of calls in one line: "Ran 4 commands, read 2 files". */ group: { command: (n: number) => (n === 1 ? "ran 1 command" : `ran ${n} commands`), diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index ec6d80d3..38a3340e 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -68,6 +68,10 @@ export const chat: Messages["chat"] = { error: "Error", command: "Comando", commandCut: "Este comando es demasiado largo para mostrarse entero.", + changes: "Cambios", + changesCut: "Este cambio es demasiado largo para mostrarse aquí.", + added: (n: number) => (n === 1 ? "1 línea añadida" : `${n} líneas añadidas`), + removed: (n: number) => (n === 1 ? "1 línea eliminada" : `${n} líneas eliminadas`), group: { command: (n: number) => (n === 1 ? "1 comando ejecutado" : `${n} comandos ejecutados`), read: (n: number) => (n === 1 ? "1 archivo leído" : `${n} archivos leídos`), diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index 24ecb608..8842c42b 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -68,6 +68,10 @@ export const chat: Messages["chat"] = { error: "Erro", command: "Comando", commandCut: "Este comando é longo demais para aparecer inteiro.", + changes: "Mudanças", + changesCut: "Esta mudança é longa demais para aparecer aqui.", + added: (n: number) => (n === 1 ? "1 linha adicionada" : `${n} linhas adicionadas`), + removed: (n: number) => (n === 1 ? "1 linha removida" : `${n} linhas removidas`), group: { command: (n: number) => (n === 1 ? "1 comando rodado" : `${n} comandos rodados`), read: (n: number) => (n === 1 ? "1 arquivo lido" : `${n} arquivos lidos`), diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts index 46622576..d7a0a823 100644 --- a/app/src/lib/fakeChat.ts +++ b/app/src/lib/fakeChat.ts @@ -124,6 +124,7 @@ export class FakeChat { status: "running", output: null, file: null, + lines: null, ...changes, }); } diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index ae599039..f1c481d0 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -919,7 +919,18 @@ output: string | null, * The full path of the file a `Write`/`Edit`/`NotebookEdit` call * changes; absent for other tools and for items stored before it existed. */ -file: string | null, }; +file: string | null, +/** + * The lines a `Write`, `Edit` or `MultiEdit` call adds and removes, + * counted from its whole input; absent for other tools, when the input + * has no texts to count and for older items (spec 8.2). + */ +lines: EditLines | null, }; + +/** + * How many lines an edit adds and removes (spec 8.2). + */ +export type EditLines = { added: number, removed: number, }; /** * What an allow rule covers, besides its tool. diff --git a/crates/botloft-core/src/edits.rs b/crates/botloft-core/src/edits.rs new file mode 100644 index 00000000..d8684b9e --- /dev/null +++ b/crates/botloft-core/src/edits.rs @@ -0,0 +1,174 @@ +//! How much a file edit changes (spec 8.2): the lines a `Write`, `Edit` or +//! `MultiEdit` call adds and removes, counted from its whole input before the +//! chat cuts it to size. + +use serde_json::Value; + +use crate::protocol::EditLines; + +/// Most pairs of lines compared for one edit. Past it, every line left after +/// the common start and end counts as changed: an upper bound, never less. +const MAX_PAIRS: usize = 4_000_000; + +/// The lines `name` adds and removes, from its input. `None` for other +/// tools, for an input without the texts (Codex and Antigravity send only the +/// path) and for an `Edit` that replaces every match, whose count of matches +/// the input does not say. +pub fn edit_lines(name: &str, input: &Value) -> Option { + let mut lines = EditLines { + added: 0, + removed: 0, + }; + match name { + "Write" => lines.added = count(text(input, "content")?.lines().count()), + "Edit" => replace(&mut lines, input)?, + "MultiEdit" => { + for edit in input.get("edits")?.as_array()? { + replace(&mut lines, edit)?; + } + } + _ => return None, + } + Some(lines) +} + +fn text<'a>(input: &'a Value, name: &str) -> Option<&'a str> { + input.get(name).and_then(Value::as_str) +} + +fn count(lines: usize) -> u32 { + u32::try_from(lines).unwrap_or(u32::MAX) +} + +/// Adds one `old_string` → `new_string` replacement to `lines`. +fn replace(lines: &mut EditLines, edit: &Value) -> Option<()> { + if edit.get("replace_all").and_then(Value::as_bool) == Some(true) { + return None; + } + let old: Vec<&str> = text(edit, "old_string")?.lines().collect(); + let new: Vec<&str> = text(edit, "new_string")?.lines().collect(); + let (added, removed) = changed(&old, &new); + lines.added = lines.added.saturating_add(count(added)); + lines.removed = lines.removed.saturating_add(count(removed)); + Some(()) +} + +/// Lines only in `new` and lines only in `old`: what is left of each once the +/// longest run of lines they share, in order, is taken out. +fn changed(old: &[&str], new: &[&str]) -> (usize, usize) { + let start = old.iter().zip(new).take_while(|(a, b)| a == b).count(); + let (old, new) = (&old[start..], &new[start..]); + let end = old + .iter() + .rev() + .zip(new.iter().rev()) + .take_while(|(a, b)| a == b) + .count(); + let (old, new) = (&old[..old.len() - end], &new[..new.len() - end]); + let shared = if old.len().saturating_mul(new.len()) > MAX_PAIRS { + 0 + } else { + shared(old, new) + }; + (new.len() - shared, old.len() - shared) +} + +/// Length of the longest run of lines `a` and `b` share, in order, keeping +/// two rows of the table. +fn shared(a: &[&str], b: &[&str]) -> usize { + let mut above = vec![0; b.len() + 1]; + let mut row = vec![0; b.len() + 1]; + for line in a { + for (j, other) in b.iter().enumerate() { + row[j + 1] = if line == other { + above[j] + 1 + } else { + above[j + 1].max(row[j]) + }; + } + std::mem::swap(&mut above, &mut row); + } + above[b.len()] +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn lines(added: u32, removed: u32) -> Option { + Some(EditLines { added, removed }) + } + + #[test] + fn an_edit_counts_the_lines_it_changes() { + let edit = json!({ + "file_path": "C:\\work\\a.rs", + "old_string": "fn a() {\n one();\n two();\n}", + "new_string": "fn a() {\n one();\n three();\n four();\n}", + }); + assert_eq!(edit_lines("Edit", &edit), lines(2, 1)); + let grow = json!({ "old_string": "a", "new_string": "a\nb\n" }); + assert_eq!(edit_lines("Edit", &grow), lines(1, 0)); + let gone = json!({ "old_string": "a\r\nb\r\n", "new_string": "" }); + assert_eq!(edit_lines("Edit", &gone), lines(0, 2)); + // A line changed in place is one removed and one added. + let word = json!({ "old_string": "let x = 1;", "new_string": "let x = 2;" }); + assert_eq!(edit_lines("Edit", &word), lines(1, 1)); + } + + #[test] + fn lines_moved_around_count_only_once() { + let edit = json!({ "old_string": "a\nb\nc\nd", "new_string": "b\na\nc\nd\ne" }); + assert_eq!(edit_lines("Edit", &edit), lines(2, 1)); + } + + #[test] + fn a_multi_edit_adds_up_its_edits() { + let input = json!({ + "file_path": "/w/a.md", + "edits": [ + { "old_string": "a", "new_string": "a\nb" }, + { "old_string": "x\ny", "new_string": "z" }, + ], + }); + assert_eq!(edit_lines("MultiEdit", &input), lines(2, 2)); + } + + #[test] + fn a_write_adds_every_line_it_writes() { + let input = json!({ "file_path": "/w/new.txt", "content": "one\ntwo\nthree\n" }); + assert_eq!(edit_lines("Write", &input), lines(3, 0)); + assert_eq!(edit_lines("Write", &json!({ "content": "" })), lines(0, 0)); + } + + #[test] + fn no_counts_without_the_texts() { + // Codex and Antigravity send only the path. + assert_eq!(edit_lines("Edit", &json!({ "file_path": "/w/a" })), None); + assert_eq!(edit_lines("Write", &json!({ "file_path": "/w/a" })), None); + assert_eq!( + edit_lines("Read", &json!({ "file_path": "/w/a", "content": "x" })), + None + ); + let every = json!({ "old_string": "a", "new_string": "b", "replace_all": true }); + assert_eq!(edit_lines("Edit", &every), None); + let multi = json!({ "edits": [{ "old_string": "a", "new_string": "b" }, every] }); + assert_eq!(edit_lines("MultiEdit", &multi), None); + } + + #[test] + fn a_huge_edit_counts_every_changed_line() { + let old: Vec = (0..3000).map(|n| format!("old {n}")).collect(); + let mut new: Vec = (0..3000).map(|n| format!("new {n}")).collect(); + new.insert(0, "same".into()); + new.push("end".into()); + let edit = json!({ + "old_string": format!("same\n{}\nend", old.join("\n")), + "new_string": new.join("\n"), + }); + // The common start and end are still found. + assert_eq!(edit_lines("Edit", &edit), lines(3000, 3000)); + } +} diff --git a/crates/botloft-core/src/lib.rs b/crates/botloft-core/src/lib.rs index 70d16923..9fd9841d 100644 --- a/crates/botloft-core/src/lib.rs +++ b/crates/botloft-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod avatar; pub mod chat; pub mod command; +pub mod edits; pub mod envelope; pub mod ids; pub mod protocol; diff --git a/crates/botloft-core/src/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs index d13b6b0f..62e4bcf5 100644 --- a/crates/botloft-core/src/protocol/chat.rs +++ b/crates/botloft-core/src/protocol/chat.rs @@ -139,6 +139,20 @@ pub struct ToolItem { /// changes; absent for other tools and for items stored before it existed. #[serde(default)] pub file: Option, + /// The lines a `Write`, `Edit` or `MultiEdit` call adds and removes, + /// counted from its whole input; absent for other tools, when the input + /// has no texts to count and for older items (spec 8.2). + #[serde(default)] + pub lines: Option, +} + +/// How many lines an edit adds and removes (spec 8.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct EditLines { + pub added: u32, + pub removed: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -314,12 +328,15 @@ mod tests { status: ToolStatus::Running, output: None, file: None, + lines: None, }); let mut json = serde_json::to_value(&tool).expect("serialize"); assert_eq!(json["kind"], "tool"); assert_eq!(json["toolUseId"], "toolu_1"); // Items stored before a field existed still read. - json.as_object_mut().expect("object").remove("explanation"); + let object = json.as_object_mut().expect("object"); + object.remove("explanation"); + object.remove("lines"); assert_eq!(serde_json::from_value::(json).ok(), Some(tool)); } } diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 2e4a839e..d2c2b13f 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -201,6 +201,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); rules::decls(&mut out); desktop::decls(&mut out); mcp::decls(&mut out); diff --git a/crates/botloft-store/src/chat/tests.rs b/crates/botloft-store/src/chat/tests.rs index 8fac6a27..a7668ce7 100644 --- a/crates/botloft-store/src/chat/tests.rs +++ b/crates/botloft-store/src/chat/tests.rs @@ -58,6 +58,7 @@ fn a_tool_item_is_found_and_updated() { status: ToolStatus::Running, output: None, file: None, + lines: None, }; let entry = item(bot, ChatBody::Tool(running.clone()), 5); fx.store.insert_chat_item(&entry).expect("insert"); @@ -99,6 +100,7 @@ fn written_files_come_newest_first_without_repeats_or_failures() { status, output: None, file: file.map(str::to_owned), + lines: None, }) }; for (n, (file, status)) in [ diff --git a/crates/botloft-store/src/search/tests.rs b/crates/botloft-store/src/search/tests.rs index 5f9d1772..3c831279 100644 --- a/crates/botloft-store/src/search/tests.rs +++ b/crates/botloft-store/src/search/tests.rs @@ -94,6 +94,7 @@ fn filters_paging_and_what_is_left_out() { status: ToolStatus::Done, output: Some("report".into()), file: None, + lines: None, }), created_at: 0, updated_at: 0, diff --git a/crates/botloftd/src/chat/sink.rs b/crates/botloftd/src/chat/sink.rs index 0e2f034a..9018a879 100644 --- a/crates/botloftd/src/chat/sink.rs +++ b/crates/botloftd/src/chat/sink.rs @@ -5,6 +5,7 @@ use botloft_core::chat::{TOOL_OUTPUT_MAX, clip, tool_file, tool_input_max, tool_summary}; use botloft_core::command::tool_explanation; +use botloft_core::edits::edit_lines; use botloft_core::ids::BotId; use botloft_core::protocol::{ChatBody, ReplyItem, TokenUsage, ToolItem, ToolStatus, TurnItem}; use serde_json::Value; @@ -43,6 +44,8 @@ pub(crate) fn tool_started(daemon: &Daemon, bot: &BotId, id: &str, name: &str, i status: ToolStatus::Running, output: None, file: tool_file(name, input), + // Counted before the input is cut (spec 8.2). + lines: edit_lines(name, input), }), ); } diff --git a/crates/botloftd/tests/chat.rs b/crates/botloftd/tests/chat.rs index db607930..ae429043 100644 --- a/crates/botloftd/tests/chat.rs +++ b/crates/botloftd/tests/chat.rs @@ -89,6 +89,7 @@ async fn what_the_bot_prints_becomes_the_chat() { // The app names the tool in the owner's language. assert_eq!(running["activity"]["text"], "Run the tests"); assert_eq!(running["activity"]["tool"], "Bash"); + assert_eq!(running["item"]["body"]["lines"], Value::Null); let done = kind(&mut c.app, "tool").await; assert_eq!(done["item"]["id"], running["item"]["id"]); assert_eq!(done["item"]["body"]["status"], "done"); @@ -131,6 +132,31 @@ async fn what_the_bot_prints_becomes_the_chat() { assert_eq!(listed[0]["lastReplyAt"], history[1]["updatedAt"]); } +#[tokio::test] +async fn an_edit_counts_its_lines_before_the_input_is_cut() { + let mut c = two_bots().await; + let line = talk_to_lead(&mut c, "Fill the notes").await; + let p = &c.lead_process; + p.emit(stream::replay(&line)).await; + let notes: Vec = (0..800).map(|n| format!("note {n}")).collect(); + let edit = json!({ + "file_path": "C:\\work\\notes.md", + "old_string": "# Notes\nTODO", + "new_string": format!("# Notes\n{}", notes.join("\n")), + }); + p.emit(stream::tool_use("toolu_1", "Edit", edit)).await; + p.emit(stream::tool_result("toolu_1", "Updated", false)) + .await; + + let running = kind(&mut c.app, "tool").await; + let body = &running["item"]["body"]; + assert!(body["input"].as_str().expect("input").ends_with('…')); + assert_eq!(body["lines"], json!({ "added": 800, "removed": 1 })); + let done = kind(&mut c.app, "tool").await; + assert_eq!(done["item"]["body"]["status"], "done"); + assert_eq!(done["item"]["body"]["lines"], body["lines"]); +} + /// Starts a permission request for `tool_use_id` the way Claude Code does, /// in the background; the answer comes out of the handle. fn ask(mcp: &Mcp, tool_use_id: &str) -> tokio::task::JoinHandle { diff --git a/docs/spec.md b/docs/spec.md index 9da1f0c3..0c84bbb2 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -360,7 +360,7 @@ Todo item tem `id` (`cht_`), `botId`, `kind`, `createdAt` e `updatedAt`. |---|---|---| | `inbound` | `message` (a `Message`, com anexos) | criado junto com a message para o bot: dono, outro bot ou daemon | | `reply` | `text` | texto do bot | -| `tool` | `toolUseId`, `name`, `summary`, `explanation`, `input`, `status` (`running`, `done`, `failed`), `output`, `file` | ferramenta usada pelo bot; `explanation` é o que o bot diz que um comando faz (10.1; ausente nas outras ferramentas, quando o bot não disse nada e nos itens antigos); `file` é o caminho completo que uma chamada de `Write`, `Edit`, `MultiEdit` ou `NotebookEdit` altera (ausente nas outras e nos itens antigos) | +| `tool` | `toolUseId`, `name`, `summary`, `explanation`, `input`, `status` (`running`, `done`, `failed`), `output`, `file`, `lines` | ferramenta usada pelo bot; `explanation` é o que o bot diz que um comando faz (10.1; ausente nas outras ferramentas, quando o bot não disse nada e nos itens antigos); `file` é o caminho completo que uma chamada de `Write`, `Edit`, `MultiEdit` ou `NotebookEdit` altera (ausente nas outras e nos itens antigos); `lines` (`added`, `removed`) são as linhas que um `Write`, `Edit` ou `MultiEdit` adiciona e remove (abaixo) | | `approval` | `approvalId`, `toolName`, `summary`, `explanation`, `input`, `status` (`pending`, `allowed`, `denied`, `expired`), `note` | pedido de permissão (10.1); `explanation` como no item `tool`, guardada só no item do chat | | `question` | `question` (a `Question`, 23.3) | pergunta do bot ao dono (23.4), regravada quando ele responde ou descarta | | `turn` | `durationMs`, `tokens` (8.7; `null` quando o Claude Code não disse e nos itens antigos), `error` | fim de um turno | @@ -368,6 +368,7 @@ Todo item tem `id` (`cht_`), `botId`, `kind`, `createdAt` e `updatedAt`. - `summary` é um trecho curto tirado da entrada, sem palavras do daemon: o comando do `Bash` e do `PowerShell` (o comando mesmo, nunca o que o bot diz dele: isso é a `explanation`), o arquivo do `Read`/`Edit`/`Write`, o padrão do `Grep`/`Glob`, a URL do `WebFetch`, a busca do `WebSearch`, o destinatário do `send_message` (`@writer`), a tecla do `browser_press`. Fica vazio onde só palavras diriam algo (`TodoWrite`, `complete_task`, `ToolSearch` que carrega ferramentas, a direção do `browser_scroll`) e onde o trecho não diz nada ao dono (a `ref` de um elemento do navegador). O app escreve o nome da ferramenta como uma ação no idioma do dono ("Mandar uma mensagem", "Abrir uma página"; uma ferramenta que ele não conhece aparece pelo nome) e, nas que ficam vazias, o que der para dizer a partir da entrada (as ferramentas carregadas, a direção da rolagem). - `input` guarda o JSON da entrada até 4 KB (a de um comando, até 32 KB, para o dono ler inteiro o que permite, 10.1); `output`, até 8 KB de texto. O resto fica só no transcript do próprio Claude Code. +- `lines` é contado pelo daemon na entrada inteira, antes do corte, para o "+N −M" valer mesmo quando o diff não cabe (`botloft_core::edits`). Linhas são separadas por quebra de linha; uma quebra no fim fecha a última linha e o `\r` antes dela não conta. Num `Edit`, o que sobra do `old_string` e do `new_string` depois de tirar a maior sequência de linhas que os dois têm em comum, na ordem, é removido e adicionado; o `MultiEdit` soma suas edições; o `Write` adiciona todas as linhas que escreve (o daemon não sabe o que havia antes). Acima de 4 milhões de pares de linhas a comparar, toda linha fora do começo e do fim em comum conta como mudada. Fica ausente num `Edit` com `replace_all`, cuja entrada não diz quantas trocas houve, e quando a entrada não traz os textos: o Codex e o Antigravity (30) mandam só o caminho. - A resposta do bot (`reply`) é guardada inteira. - Uma message de um bot para outro aparece duas vezes: no chat de quem mandou, como o item `tool` do `send_message`; no chat de quem recebe, como `inbound`. @@ -986,6 +987,7 @@ No chat: - **Bots se chamando.** Uma message de um bot para outro é uma chamada até o outro pegá-la (`read_at`, 9.1). Enquanto toca, uma pílula mostra os mascotes dos dois, um sobre o outro em círculos, com "Chamando " e três pontos; quando o outro pega, vira " atendeu", com um tique, por 4 s, e some. Uma delivery `dead` encerra a chamada, e uma que ninguém pegou em uma hora deixa de ser chamada. Aparece no chat de quem chamou, flutuando acima do compositor sem mexer na conversa, e no cabeçalho da página da crew, com as chamadas de todos os bots dela. Uma pílula por par de bots, a da chamada mais nova. O app guarda quem chamou quem a partir de `message.created` desde que conectou; se foi atendida, sabe pela delivery, que toda conexão carrega de novo. Os mascotes da pílula se olham como os outros (15.3). O leitor de tela ouve quem chama antes ("Writer: Chamando Revisão"). - Um bot citado pelo handle ("@writer") nas respostas e nas messages fica em azul-claro (token `--mention`, que ainda lê a 4,5:1) e um pouco mais forte, para não se perder no texto. E-mail, caminho e o que está em código ou link não contam. - O que o bot faz com as ferramentas aparece em linhas compactas (ícone, ferramenta, resumo e estado), que abrem para mostrar entrada e saída. As chamadas seguidas ficam dobradas numa linha só, com uma seta para abrir todas: enquanto o bot trabalha nelas, a linha diz a chamada em que ele está (o nome com um brilho passando e um círculo girando, sem os três pontos embaixo) e troca, subindo, a cada chamada nova; quando ele escreve, pede algo ou para, a linha vira um resumo por tipo, na ordem em que fez, com as falhas no fim ("2 passos no navegador, 1 comando rodado (1 falha)"). Uma chamada sozinha que já terminou fica na sua própria linha. Com menos movimento, o nome fica parado. Um `Read` de imagem (png, jpg, gif, webp, bmp) mostra a imagem pequena na linha, também na linha dobrada enquanto o bot a lê, e maior quando a linha abre, para o dono ver o que o bot está olhando. Ela é lida com `files.read` (8.4), como está agora: uma imagem fora das pastas do bot fica só com o nome. +- Uma edição (`Write`, `Edit`, `MultiEdit`) com `lines` mostra na linha quanto mudou, "+12 −3" em verde e vermelho, sem o lado que é zero (o leitor de tela ouve "12 linhas adicionadas, 3 linhas removidas"); uma chamada que falhou não mostra. Aberta, a linha mostra "Mudanças" no lugar da entrada: o diff de cada troca, um bloco por troca, com as linhas em comum, as removidas com "−" num fundo vermelho fraco e as adicionadas com "+" num fundo verde fraco (`EditDiff.tsx`, `lineDiff.ts`). O app compara as linhas como o daemon conta, então o diff e o "+N −M" batem. Quando a entrada veio cortada (8.2), a linha abre na entrada, como as outras, com "Esta mudança é longa demais para aparecer aqui.". - Pedido de aprovação é um cartão com o que o bot quer fazer e os botões Permitir e Negar. - Pedido para seguir com um plano (10.1) é um cartão com o plano em markdown, um campo para o que deve mudar e os botões Aprovar plano e Pedir mudanças. Respondido, vira uma linha que abre o plano de novo. - Anexos aparecem como miniatura (imagem) ou cartão com nome, tipo e tamanho.