Skip to content

Commit a15ee0a

Browse files
authored
Merge pull request #88 from dlants/dlants-thread-titles
thread titles
2 parents d24c4f3 + ca8fc2f commit a15ee0a

File tree

8 files changed

+334
-19
lines changed

8 files changed

+334
-19
lines changed

node/chat/chat.spec.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe("node/chat/chat.spec.ts", () => {
2828
);
2929

3030
await driver.magenta.command("new-thread");
31-
await driver.assertDisplayBufferContent(LOGO + "\n");
31+
await driver.assertDisplayBufferContent("# [ Untitled ]\n" + LOGO + "\n");
3232
});
3333
});
3434

@@ -47,10 +47,11 @@ describe("node/chat/chat.spec.ts", () => {
4747
await driver.assertDisplayBufferContains(`\
4848
# Threads
4949
50-
- 1
51-
* 2`);
50+
- 1 [Untitled]
51+
* 2 [Untitled]`);
5252

53-
const threadPos = await driver.assertDisplayBufferContains("1");
53+
const threadPos =
54+
await driver.assertDisplayBufferContains("1 [Untitled]");
5455
await driver.triggerDisplayBufferKey(threadPos, "<CR>");
5556
await driver.awaitChatState({
5657
state: "thread-selected",

node/chat/chat.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,10 @@ export class Chat {
238238
case "pending":
239239
status = `${marker} ${id} - loading...\n`;
240240
break;
241-
case "initialized":
242-
status = `${marker} ${id}\n`;
241+
case "initialized": {
242+
status = `${marker} ${id} ${threadState.thread.state.title ?? "[Untitled]"}\n`;
243243
break;
244+
}
244245
case "error":
245246
status = `${marker} ${id} - error: ${threadState.error.message}\n`;
246247
break;

node/chat/thread.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ import { type MagentaOptions, type Profile } from "../options.ts";
3030
import type { RootMsg } from "../root-msg.ts";
3131
import type { UnresolvedFilePath } from "../utils/files.ts";
3232
import type { BufferTracker } from "../buffer-tracker.ts";
33+
import {
34+
type Input as ThreadTitleInput,
35+
spec as threadTitleToolSpec,
36+
} from "../tools/thread-title.ts";
3337

3438
export type Role = "user" | "assistant";
3539

@@ -51,6 +55,7 @@ export type ConversationState =
5155
};
5256

5357
export type Msg =
58+
| { type: "set-title"; title: string }
5459
| { type: "update-profile"; profile: Profile }
5560
| {
5661
type: "stream-event";
@@ -103,6 +108,7 @@ export type ThreadId = number & { __threadId: true };
103108

104109
export class Thread {
105110
public state: {
111+
title?: string | undefined;
106112
lastUserMessageId: MessageId;
107113
profile: Profile;
108114
conversation: ConversationState;
@@ -244,6 +250,13 @@ export class Thread {
244250
this.sendMessage(msg.content).catch(
245251
this.handleSendMessageError.bind(this),
246252
);
253+
if (!this.state.title) {
254+
this.setThreadTitle(msg.content).catch((err: Error) =>
255+
this.context.nvim.logger?.error(
256+
"Error getting thread title: " + err.message + "\n" + err.stack,
257+
),
258+
);
259+
}
247260
});
248261
break;
249262
}
@@ -375,6 +388,11 @@ export class Thread {
375388
return;
376389
}
377390

391+
case "set-title": {
392+
this.state.title = msg.title;
393+
return;
394+
}
395+
378396
default:
379397
assertUnreachable(msg);
380398
}
@@ -565,20 +583,56 @@ export class Thread {
565583

566584
return messages.map((m) => m.message);
567585
}
586+
587+
async setThreadTitle(userMessage: string) {
588+
const request = getProvider(
589+
this.context.nvim,
590+
this.context.profile,
591+
).forceToolUse(
592+
[
593+
{
594+
role: "user",
595+
content: [
596+
{
597+
type: "text",
598+
text: `\
599+
The user has provided the following prompt:
600+
${userMessage}
601+
602+
Come up with a succinct thread title for this prompt. It should be less than 80 characters long.
603+
`,
604+
},
605+
],
606+
},
607+
],
608+
threadTitleToolSpec,
609+
);
610+
const result = await request.promise;
611+
if (result.toolRequest.status == "ok") {
612+
this.myDispatch({
613+
type: "set-title",
614+
title: (result.toolRequest.value.input as ThreadTitleInput).title,
615+
});
616+
}
617+
}
568618
}
569619

570620
export const view: View<{
571621
thread: Thread;
572622
dispatch: Dispatch<Msg>;
573623
}> = ({ thread }) => {
624+
const titleView = thread.state.title
625+
? d`# ${thread.state.title}`
626+
: d`# [ Untitled ]`;
627+
574628
if (
575629
thread.state.messages.length == 0 &&
576630
thread.state.conversation.state == "stopped"
577631
) {
578-
return d`${LOGO}\n${thread.context.contextManager.view()}`;
632+
return d`${titleView}\n${LOGO}\n${thread.context.contextManager.view()}`;
579633
}
580634

581-
return d`${thread.state.messages.map((m) => d`${m.view()}\n`)}${
635+
return d`${titleView}\n${thread.state.messages.map((m) => d`${m.view()}\n`)}${
582636
thread.state.conversation.state == "message-in-flight"
583637
? d`Awaiting response ${
584638
MESSAGE_ANIMATION[

node/magenta.spec.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,16 @@ describe("node/magenta.spec.ts", () => {
1818
toolRequests: [],
1919
});
2020

21-
await driver.assertDisplayBufferContent(`\
22-
# user:
21+
await driver.assertDisplayBufferContains(`# user:
2322
hello
2423
2524
# assistant:
2625
sup?
2726
28-
Stopped (end_turn) [input: 0, output: 0]
29-
`);
27+
Stopped (end_turn) [input: 0, output: 0]`);
3028

3129
await driver.clear();
32-
await driver.assertDisplayBufferContent(LOGO + "\n");
30+
await driver.assertDisplayBufferContains(LOGO);
3331
await driver.inputMagentaText(`hello again`);
3432
await driver.send();
3533
await driver.mockAnthropic.respond({
@@ -38,15 +36,13 @@ Stopped (end_turn) [input: 0, output: 0]
3836
toolRequests: [],
3937
});
4038

41-
await driver.assertDisplayBufferContent(`\
42-
# user:
39+
await driver.assertDisplayBufferContains(`# user:
4340
hello again
4441
4542
# assistant:
4643
huh?
4744
48-
Stopped (end_turn) [input: 0, output: 0]
49-
`);
45+
Stopped (end_turn) [input: 0, output: 0]`);
5046
});
5147
});
5248

@@ -55,8 +51,7 @@ Stopped (end_turn) [input: 0, output: 0]
5551
await driver.showSidebar();
5652
await driver.inputMagentaText(`hello`);
5753
await driver.send();
58-
await driver.assertDisplayBufferContent(`\
59-
# user:
54+
await driver.assertDisplayBufferContains(`# user:
6055
hello
6156
6257
Awaiting response ⠁`);

node/tools/helpers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as Diagnostics from "./diagnostics";
99
import * as BashCommand from "./bashCommand";
1010
import * as ReplaceSelection from "./replace-selection-tool";
1111
import * as InlineEdit from "./inline-edit-tool";
12+
import * as ThreadTitle from "./thread-title";
1213
import type { StreamingBlock } from "../providers/helpers";
1314
import { d, type VDOMNode } from "../tea/view";
1415

@@ -39,6 +40,8 @@ export function validateInput(
3940
return InlineEdit.validateInput(input);
4041
case "replace_selection":
4142
return ReplaceSelection.validateInput(input);
43+
case "thread_title":
44+
return ThreadTitle.validateInput(input);
4245
default:
4346
throw new Error(`Unexpected toolName: ${toolName as string}`);
4447
}

node/tools/thread-title.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from "vitest";
2+
import { withDriver } from "../test/preamble";
3+
import type { ToolRequestId } from "./toolManager";
4+
5+
describe("node/tools/thread-title.spec.ts", () => {
6+
it("sets thread title after user message", async () => {
7+
await withDriver({}, async (driver) => {
8+
// 1. Open the sidebar
9+
await driver.showSidebar();
10+
11+
// Verify initial state shows untitled
12+
await driver.assertDisplayBufferContains("# [ Untitled ]");
13+
14+
// 2. Send a message
15+
const userMessage = "Tell me about the solar system";
16+
await driver.inputMagentaText(userMessage);
17+
await driver.send();
18+
19+
// 3. Verify the forceToolUse request was made for thread_title
20+
const request =
21+
await driver.mockAnthropic.awaitPendingForceToolUseRequest();
22+
23+
// Verify the request contains the user message
24+
expect(request.messages).toMatchObject([
25+
{
26+
role: "user",
27+
content: [
28+
{
29+
type: "text",
30+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
31+
text: expect.stringContaining(userMessage),
32+
},
33+
],
34+
},
35+
]);
36+
37+
// 4. Respond to the tool use request with a title
38+
const title = "Exploring the Solar System";
39+
await driver.mockAnthropic.respondToForceToolUse({
40+
stopReason: "end_turn",
41+
toolRequest: {
42+
status: "ok",
43+
value: {
44+
id: "id" as ToolRequestId,
45+
toolName: "thread_title",
46+
input: {
47+
title,
48+
},
49+
},
50+
},
51+
});
52+
53+
// 5. Verify the thread title was updated in the display buffer
54+
await driver.assertDisplayBufferContains(`# ${title}`);
55+
56+
// Respond to the original user message
57+
await driver.mockAnthropic.streamText(
58+
"The solar system consists of the Sun and everything that orbits around it.",
59+
);
60+
61+
// Verify both the title and message are displayed
62+
await driver.assertDisplayBufferContains(`# ${title}`);
63+
await driver.assertDisplayBufferContains(
64+
"The solar system consists of the Sun and everything that orbits around it.",
65+
);
66+
});
67+
});
68+
});

0 commit comments

Comments
 (0)