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.