-
- {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.