Skip to content

Commit 3c6c01e

Browse files
committed
feat: add thinking block handling
1 parent 855076f commit 3c6c01e

File tree

5 files changed

+155
-9
lines changed

5 files changed

+155
-9
lines changed

src/lib/acp/models.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export type { PlanEntry };
6060

6161
export interface ChatMessage {
6262
id: string;
63-
role: "user" | "agent";
63+
role: "user" | "agent" | "thought";
6464
content: ContentBlock[];
6565
timestamp: number;
6666
streaming?: boolean;

src/lib/acp/session.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ export class ACPSession {
3434
updatedAt: string | null = null;
3535

3636
private agentTextBuffer = "";
37+
private thoughtTextBuffer = "";
3738
private userTextBuffer = "";
3839
private currentAgentMessageId: string | null = null;
40+
private currentThoughtMessageId: string | null = null;
3941
private currentUserMessageId: string | null = null;
4042
private currentPlanEntryId: string | null = null;
4143
private listeners = new Map<SessionEventType, Set<SessionEventHandler>>();
@@ -71,6 +73,7 @@ export class ACPSession {
7173

7274
addUserMessage(content: ContentBlock[]): ChatMessage {
7375
this.closeCurrentAgentMessage();
76+
this.closeCurrentThoughtMessage();
7477
this.closeCurrentUserMessage();
7578

7679
const message: ChatMessage = {
@@ -98,6 +101,9 @@ export class ACPSession {
98101
case "agent_message_chunk":
99102
this.handleMessageChunk("agent", update.content);
100103
break;
104+
case "agent_thought_chunk":
105+
this.handleMessageChunk("thought", update.content);
106+
break;
101107
case "tool_call":
102108
this.handleToolCall(update);
103109
break;
@@ -124,6 +130,7 @@ export class ACPSession {
124130

125131
finishAgentTurn(): void {
126132
this.closeCurrentAgentMessage();
133+
this.closeCurrentThoughtMessage();
127134
this.closeCurrentUserMessage();
128135
this.currentPlanEntryId = null;
129136
this.emit("session_end", null);
@@ -139,32 +146,49 @@ export class ACPSession {
139146
): void {
140147
let message: ChatMessage;
141148
if (role === "agent") {
149+
this.closeCurrentThoughtMessage();
142150
this.closeCurrentUserMessage();
143151
message = this.getOrCreateCurrentAgentMessage();
152+
} else if (role === "thought") {
153+
this.closeCurrentAgentMessage();
154+
this.closeCurrentUserMessage();
155+
message = this.getOrCreateCurrentThoughtMessage();
144156
} else {
145157
this.closeCurrentAgentMessage();
158+
this.closeCurrentThoughtMessage();
146159
message = this.getOrCreateCurrentUserMessage();
147160
}
148161
if (!message) return;
149162

150-
if (role === "agent") {
163+
if (role === "agent" || role === "thought") {
151164
message.streaming = true;
152165
}
153166

154167
if (content.type === "text") {
155168
if (role === "agent") {
156169
this.agentTextBuffer += content.text;
170+
} else if (role === "thought") {
171+
this.thoughtTextBuffer += content.text;
157172
} else {
158173
this.userTextBuffer += content.text;
159174
}
160175
const existingText = message.content.find((c) => c.type === "text");
161176
if (existingText && existingText.type === "text") {
162177
existingText.text =
163-
role === "agent" ? this.agentTextBuffer : this.userTextBuffer;
178+
role === "agent"
179+
? this.agentTextBuffer
180+
: role === "thought"
181+
? this.thoughtTextBuffer
182+
: this.userTextBuffer;
164183
} else {
165184
message.content.push({
166185
type: "text",
167-
text: role === "agent" ? this.agentTextBuffer : this.userTextBuffer,
186+
text:
187+
role === "agent"
188+
? this.agentTextBuffer
189+
: role === "thought"
190+
? this.thoughtTextBuffer
191+
: this.userTextBuffer,
168192
});
169193
}
170194
} else {
@@ -178,6 +202,7 @@ export class ACPSession {
178202
update: SessionUpdate & { sessionUpdate: "tool_call" },
179203
): void {
180204
this.closeCurrentAgentMessage();
205+
this.closeCurrentThoughtMessage();
181206
this.closeCurrentUserMessage();
182207

183208
const toolCall: ToolCall = {
@@ -237,6 +262,7 @@ export class ACPSession {
237262
}
238263

239264
private handlePlan(entries: PlanEntry[]): void {
265+
this.closeCurrentThoughtMessage();
240266
this.plan = {
241267
entries,
242268
timestamp: this.plan?.timestamp || Date.now(),
@@ -309,6 +335,10 @@ export class ACPSession {
309335
this.currentAgentMessageId = message.id;
310336
this.agentTextBuffer = "";
311337
}
338+
if (role === "thought") {
339+
this.currentThoughtMessageId = message.id;
340+
this.thoughtTextBuffer = "";
341+
}
312342
if (role === "user") {
313343
this.currentUserMessageId = message.id;
314344
this.userTextBuffer = "";
@@ -324,6 +354,14 @@ export class ACPSession {
324354
return this.createMessage("agent", [], true);
325355
}
326356

357+
private getOrCreateCurrentThoughtMessage(): ChatMessage {
358+
const existing = this.messages.find(
359+
(message) => message.id === this.currentThoughtMessageId,
360+
);
361+
if (existing) return existing;
362+
return this.createMessage("thought", [], true);
363+
}
364+
327365
private getOrCreateCurrentUserMessage(): ChatMessage {
328366
const existing = this.messages.find(
329367
(message) => message.id === this.currentUserMessageId,
@@ -347,6 +385,21 @@ export class ACPSession {
347385
this.agentTextBuffer = "";
348386
}
349387

388+
private closeCurrentThoughtMessage(): void {
389+
if (!this.currentThoughtMessageId) return;
390+
391+
const message = this.messages.find(
392+
(entry) => entry.id === this.currentThoughtMessageId,
393+
);
394+
if (message) {
395+
message.streaming = false;
396+
this.emit("message_update", message);
397+
}
398+
399+
this.currentThoughtMessageId = null;
400+
this.thoughtTextBuffer = "";
401+
}
402+
350403
private closeCurrentUserMessage(): void {
351404
if (!this.currentUserMessageId) return;
352405
this.currentUserMessageId = null;
@@ -357,8 +410,10 @@ export class ACPSession {
357410
this.listeners.clear();
358411
this.pendingPermissions.length = 0;
359412
this.currentAgentMessageId = null;
413+
this.currentThoughtMessageId = null;
360414
this.currentUserMessageId = null;
361415
this.agentTextBuffer = "";
416+
this.thoughtTextBuffer = "";
362417
this.userTextBuffer = "";
363418
}
364419
}

src/pages/acp/acp.js

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export default function AcpPageInclude() {
2727
let connectedUrl = "";
2828
let currentSessionUrl = "";
2929
const timelineElements = new Map();
30+
let pendingThinkingElement = null;
3031
let isPrompting = false;
3132

3233
// ─── Connection Form ───
@@ -93,6 +94,7 @@ export default function AcpPageInclude() {
9394
function switchToChat(agentName) {
9495
currentView = "chat";
9596
timelineElements.clear();
97+
pendingThinkingElement = null;
9698

9799
setChatAgentName(agentName);
98100

@@ -119,6 +121,7 @@ export default function AcpPageInclude() {
119121
function switchToConnect() {
120122
currentView = "connect";
121123
timelineElements.clear();
124+
pendingThinkingElement = null;
122125
$form.setConnecting(false);
123126
setFormStatus("");
124127
setPrompting(false);
@@ -808,7 +811,10 @@ export default function AcpPageInclude() {
808811

809812
const messages = client.session.messages;
810813
for (let index = messages.length - 1; index >= 0; index--) {
811-
if (messages[index].role === "agent") {
814+
if (
815+
messages[index].role === "agent" ||
816+
messages[index].role === "thought"
817+
) {
812818
return messages[index].id;
813819
}
814820
}
@@ -825,7 +831,7 @@ export default function AcpPageInclude() {
825831
message: entry.message,
826832
cwd,
827833
isResponding:
828-
entry.message.role === "agent" &&
834+
entry.message.role !== "user" &&
829835
entry.message.id === activeAgentMessageId,
830836
});
831837
case "tool_call":
@@ -837,6 +843,59 @@ export default function AcpPageInclude() {
837843
}
838844
}
839845

846+
function hasAgentActivitySinceLatestUser(entries) {
847+
for (let index = entries.length - 1; index >= 0; index--) {
848+
const entry = entries[index];
849+
if (entry.type !== "message") return true;
850+
if (entry.message.role === "user") return false;
851+
if (entry.message.role === "agent" || entry.message.role === "thought") {
852+
return true;
853+
}
854+
}
855+
return false;
856+
}
857+
858+
function syncPendingThinkingIndicator($messages, entries) {
859+
const shouldShow =
860+
isPrompting && !hasAgentActivitySinceLatestUser(entries || []);
861+
862+
if (!shouldShow) {
863+
if (pendingThinkingElement) {
864+
pendingThinkingElement.remove();
865+
pendingThinkingElement = null;
866+
}
867+
return;
868+
}
869+
870+
const placeholderMessage = {
871+
id: "__acp_pending_thinking__",
872+
role: "thought",
873+
content: [{ type: "text", text: "Thinking…" }],
874+
timestamp: Date.now(),
875+
streaming: true,
876+
};
877+
const cwd = client.session?.cwd || $form.getValues().cwd || "";
878+
879+
if (!pendingThinkingElement) {
880+
pendingThinkingElement = ChatMessage({
881+
message: placeholderMessage,
882+
cwd,
883+
isResponding: true,
884+
});
885+
pendingThinkingElement.classList.add("acp-thinking-placeholder");
886+
} else {
887+
pendingThinkingElement.update({
888+
message: placeholderMessage,
889+
cwd,
890+
isResponding: true,
891+
});
892+
}
893+
894+
if (pendingThinkingElement.parentNode !== $messages) {
895+
$messages.append(pendingThinkingElement);
896+
}
897+
}
898+
840899
function syncTimeline() {
841900
if (!client.session) return;
842901
const entries = client.session.timeline;
@@ -856,7 +915,7 @@ export default function AcpPageInclude() {
856915
cwd: client.session?.cwd || $form.getValues().cwd || "",
857916
isResponding:
858917
entry.type === "message" &&
859-
entry.message.role === "agent" &&
918+
entry.message.role !== "user" &&
860919
entry.message.id === activeAgentMessageId,
861920
};
862921
if (timelineElements.has(entry.entryId)) {
@@ -868,6 +927,7 @@ export default function AcpPageInclude() {
868927
$messages.append($entry);
869928
}
870929
});
930+
syncPendingThinkingIndicator($messages, entries);
871931

872932
$messages.scrollTop = $messages.scrollHeight;
873933
saveCurrentSessionHistory();

src/pages/acp/acp.scss

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,28 @@
581581
}
582582
}
583583

584+
&.thought {
585+
align-self: stretch;
586+
587+
.acp-message-content {
588+
background: color-mix(
589+
in srgb,
590+
var(--popup-background-color),
591+
var(--secondary-text-color) 2%
592+
);
593+
border: 1px dashed
594+
color-mix(in srgb, var(--border-color), transparent 10%);
595+
color: color-mix(
596+
in srgb,
597+
var(--secondary-text-color),
598+
transparent 20%
599+
);
600+
border-radius: 12px;
601+
padding: 12px 16px;
602+
font-style: italic;
603+
}
604+
}
605+
584606
.acp-message-content {
585607
font-size: 0.88rem;
586608
line-height: 1.6;
@@ -792,6 +814,10 @@
792814
}
793815
}
794816

817+
.acp-thinking-placeholder {
818+
opacity: 0.9;
819+
}
820+
795821
/* ── Tool Call Card ── */
796822
.acp-tool-call {
797823
border-radius: 12px;

src/pages/acp/components/chatMessage.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ export default function ChatMessage({
342342
};
343343

344344
function appendTextBlock(text) {
345-
if (message.role === "agent") {
345+
if (message.role === "agent" || message.role === "thought") {
346346
const $markdown = <div className="acp-markdown-block md"></div>;
347347
$markdown.innerHTML = renderMarkdown(text);
348348
$content.append($markdown);
@@ -434,7 +434,12 @@ export default function ChatMessage({
434434
</span>,
435435
);
436436
}
437-
$role.textContent = message.role === "user" ? "You" : "Agent";
437+
$role.textContent =
438+
message.role === "user"
439+
? "You"
440+
: message.role === "thought"
441+
? "Thinking"
442+
: "Agent";
438443
$meta.hidden = $meta.childElementCount === 0;
439444
const timestamp = new Date(message.timestamp);
440445
$el.title = Number.isNaN(timestamp.getTime())

0 commit comments

Comments
 (0)