Skip to content

Commit df66bbf

Browse files
authoredDec 30, 2024
Merge pull request #5 from dlants/lsp-improvements-tests
enhance listBuffers tool
2 parents fa7c5e8 + ee9ae78 commit df66bbf

File tree

6 files changed

+166
-27
lines changed

6 files changed

+166
-27
lines changed
 

‎bun/tea/tea.ts

+14-9
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,17 @@ export function createApp<Model, Msg>({
8888
try {
8989
const [nextModel, thunk] = update(msg, currentState.model);
9090

91-
if (thunk && !suppressThunks) {
92-
nvim.logger?.debug(`starting thunk`);
93-
thunk(dispatch).catch((err) => {
94-
nvim.logger?.error(err as Error);
95-
});
91+
if (thunk) {
92+
if (suppressThunks) {
93+
nvim.logger?.debug(`thunk suppressed`);
94+
} else {
95+
nvim.logger?.debug(`starting thunk`);
96+
thunk(dispatch).catch((err) => {
97+
nvim.logger?.error(err as Error);
98+
});
99+
}
96100
}
97101

98-
99102
currentState = { status: "running", model: nextModel };
100103

101104
render();
@@ -110,13 +113,15 @@ export function createApp<Model, Msg>({
110113
};
111114

112115
function render() {
113-
nvim.logger?.info(`render`)
116+
nvim.logger?.info(`render`);
114117
if (renderPromise) {
115118
reRender = true;
116-
nvim.logger?.info(`re-render scheduled`)
119+
nvim.logger?.info(`re-render scheduled`);
117120
} else {
118121
if (root) {
119-
nvim.logger?.info(`init renderPromise of state ${JSON.stringify(currentState, null, 2)}`)
122+
nvim.logger?.info(
123+
`init renderPromise of state ${JSON.stringify(currentState, null, 2)}`,
124+
);
120125
renderPromise = root
121126
.render({ currentState, dispatch })
122127
.catch((err) => {

‎bun/test/driver.ts

+4
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,8 @@ vim.rpcnotify(${this.nvim.channelId}, "magentaKey", "${key}")
141141
{ timeout: 200 },
142142
);
143143
}
144+
145+
async editFile(filePath: string): Promise<void> {
146+
await this.nvim.call("nvim_exec2", [`edit ${filePath}`, {}]);
147+
}
144148
}

‎bun/test/fixtures/poem2.txt

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Moonlight whispers through the trees,
2+
Silver shadows dance with ease.
3+
Stars above like diamonds bright,
4+
Paint their stories in the night.

‎bun/tools/listBuffers.spec.ts

+62-6
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,66 @@ import { describe, it, expect } from "bun:test";
55
import { pos } from "../tea/view.ts";
66
import { NvimBuffer } from "../nvim/buffer.ts";
77
import { withNvimClient } from "../test/preamble.ts";
8+
import { withDriver } from "../test/preamble";
9+
import { pollUntil } from "../utils/async.ts";
810

911
describe("bun/tools/listBuffers.spec.ts", () => {
12+
it("listBuffers end-to-end", async () => {
13+
await withDriver(async (driver) => {
14+
await driver.editFile("bun/test/fixtures/poem.txt");
15+
await driver.editFile("bun/test/fixtures/poem2.txt");
16+
await driver.showSidebar();
17+
18+
await driver.assertWindowCount(3);
19+
20+
await driver.inputMagentaText(`Try listing some buffers`);
21+
await driver.send();
22+
23+
const toolRequestId = "id" as ToolRequestId;
24+
await driver.mockAnthropic.respond({
25+
stopReason: "tool_use",
26+
text: "ok, here goes",
27+
toolRequests: [
28+
{
29+
status: "ok",
30+
value: {
31+
type: "tool_use",
32+
id: toolRequestId,
33+
name: "list_buffers",
34+
input: {},
35+
},
36+
},
37+
],
38+
});
39+
40+
const result = await pollUntil(() => {
41+
const state = driver.magenta.chatApp.getState();
42+
if (state.status != "running") {
43+
throw new Error(`app crashed`);
44+
}
45+
46+
const toolWrapper = state.model.toolManager.toolWrappers[toolRequestId];
47+
if (!toolWrapper) {
48+
throw new Error(
49+
`could not find toolWrapper with id ${toolRequestId}`,
50+
);
51+
}
52+
53+
if (toolWrapper.model.state.state != "done") {
54+
throw new Error(`Request not done`);
55+
}
56+
57+
return toolWrapper.model.state.result;
58+
});
59+
60+
expect(result).toEqual({
61+
tool_use_id: toolRequestId,
62+
type: "tool_result",
63+
content: `bun/test/fixtures/poem.txt\nactive bun/test/fixtures/poem2.txt`,
64+
});
65+
});
66+
});
67+
1068
it("render the listBuffers tool.", async () => {
1169
await withNvimClient(async (nvim) => {
1270
const buffer = await NvimBuffer.create(false, true, nvim);
@@ -40,9 +98,7 @@ describe("bun/tools/listBuffers.spec.ts", () => {
4098

4199
const content = (await buffer.getLines({ start: 0, end: -1 })).join("\n");
42100

43-
expect(
44-
content,
45-
).toBe(`⚙️ Grabbing buffers...`);
101+
expect(content).toBe(`⚙️ Grabbing buffers...`);
46102

47103
app.dispatch({
48104
type: "finish",
@@ -54,9 +110,9 @@ describe("bun/tools/listBuffers.spec.ts", () => {
54110
});
55111

56112
await mountedApp.waitForRender();
57-
expect(
58-
(await buffer.getLines({ start: 0, end: -1 })).join("\n")
59-
).toBe(`✅ Finished getting buffers.`);
113+
expect((await buffer.getLines({ start: 0, end: -1 })).join("\n")).toBe(
114+
`✅ Finished getting buffers.`,
115+
);
60116
});
61117
});
62118
});

‎bun/tools/listBuffers.ts

+27-12
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import * as Anthropic from "@anthropic-ai/sdk";
2-
import path from "path";
32
import { assertUnreachable } from "../utils/assertUnreachable.ts";
43
import type { Thunk, Update } from "../tea/tea.ts";
54
import { d, type VDOMNode } from "../tea/view.ts";
65
import { type ToolRequestId } from "./toolManager.ts";
76
import { type Result } from "../utils/result.ts";
8-
import { getAllBuffers, getcwd } from "../nvim/nvim.ts";
97
import type { Nvim } from "bunvim";
8+
import { parseLsResponse } from "../utils/lsBuffers.ts";
109

1110
export type Model = {
1211
type: "list_buffers";
@@ -57,21 +56,35 @@ export function initModel(
5756
return [
5857
model,
5958
async (dispatch) => {
60-
const buffers = await getAllBuffers(context.nvim);
61-
const cwd = await getcwd(context.nvim);
62-
const bufferPaths = await Promise.all(
63-
buffers.map(async (buffer) => {
64-
const fullPath = await buffer.getName();
65-
return fullPath.length ? path.relative(cwd, fullPath) : "";
66-
}),
67-
);
59+
const lsResponse = await context.nvim.call("nvim_exec2", [
60+
"ls",
61+
{ output: true },
62+
]);
63+
64+
const result = parseLsResponse(lsResponse.output as string);
65+
const content = result
66+
.map((bufEntry) => {
67+
let out = "";
68+
if (bufEntry.flags.active) {
69+
out += "active ";
70+
}
71+
if (bufEntry.flags.modified) {
72+
out += "modified ";
73+
}
74+
if (bufEntry.flags.terminal) {
75+
out += "terminal ";
76+
}
77+
out += bufEntry.filePath;
78+
return out;
79+
})
80+
.join("\n");
6881

6982
dispatch({
7083
type: "finish",
7184
result: {
7285
type: "tool_result",
7386
tool_use_id: request.id,
74-
content: bufferPaths.filter((p) => p.length).join("\n"),
87+
content,
7588
},
7689
});
7790
},
@@ -108,7 +121,9 @@ export function getToolResult(
108121

109122
export const spec: Anthropic.Anthropic.Tool = {
110123
name: "list_buffers",
111-
description: `List the file paths of all the buffers the user currently has open. This can be useful to understand the context of what the user is trying to do.`,
124+
description: `List all the buffers the user currently has open.
125+
This will be similar to the output of :buffers in neovim, so buffers will be listed in the order they were opened, with the most recent buffers last.
126+
This can be useful to understand the context of what the user is trying to do.`,
112127
input_schema: {
113128
type: "object",
114129
properties: {},

‎bun/utils/lsBuffers.ts

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
interface BufferFlags {
2+
hidden: boolean; // 'h' flag
3+
active: boolean; // 'a' flag
4+
current: boolean; // '%' flag
5+
alternate: boolean; // '#' flag
6+
modified: boolean; // '+' flag
7+
readonly: boolean; // '-' flag
8+
terminal: boolean; // 'terminal' flag
9+
}
10+
11+
interface BufferEntry {
12+
id: number; // Buffer number
13+
flags: BufferFlags; // Parsed status flags
14+
filePath: string; // File path
15+
lineNumber: number; // Current line number
16+
}
17+
18+
function parseFlags(flagStr: string): BufferFlags {
19+
return {
20+
hidden: flagStr.includes("h"),
21+
active: flagStr.includes("a"),
22+
current: flagStr.includes("%"),
23+
alternate: flagStr.includes("#"),
24+
modified: flagStr.includes("+"),
25+
readonly: flagStr.includes("-"),
26+
terminal: flagStr.includes("t"),
27+
};
28+
}
29+
30+
/**
31+
* Parses the output of Neovim's :buffers command into structured data
32+
*lsResponse.output is like: " 1 h \"bun/test/fixtures/poem.txt\" line 1\n 2 a \"bun/test/fixtures/poem2.txt\" line 1"
33+
* see docfiles for :buffers to understand output format
34+
*/
35+
export function parseLsResponse(response: string): BufferEntry[] {
36+
// Split the response into lines and filter out empty lines
37+
const lines = response.split("\n").filter((line) => line.trim());
38+
39+
return lines.map((line) => {
40+
// Remove extra whitespace and split by multiple spaces
41+
const parts = line.trim().split(/\s+/);
42+
43+
// Extract filepath by finding the quoted string
44+
const filepathStart = line.indexOf('"');
45+
const filepathEnd = line.lastIndexOf('"');
46+
const filePath = line.slice(filepathStart + 1, filepathEnd);
47+
48+
return {
49+
id: parseInt(parts[0], 10),
50+
flags: parseFlags(parts[1]),
51+
filePath,
52+
lineNumber: parseInt(parts[parts.length - 1], 10),
53+
};
54+
});
55+
}

0 commit comments

Comments
 (0)
Failed to load comments.