Skip to content

Commit 664c0ec

Browse files
committed
🔀 merge: integrate steer and queue release
2 parents fa43bb9 + 8f31de4 commit 664c0ec

28 files changed

Lines changed: 684 additions & 89 deletions

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
## [0.11.1] - 2026-08-15
10+
11+
### Added
12+
13+
- Choose Steer or Queue for each prompt submitted while Pi is streaming, with per-session delivery memory, dedicated shortcuts, and visible pending states for both queues.
14+
915
## [0.11.0] - 2026-08-10
1016

1117
### Added
@@ -333,7 +339,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
333339
- Add editor context capture, file navigation, Git-base diffs, diagnostics export, CSP, and trusted-workspace constraints.
334340
- Add production builds, tests, VSIX verification, release scripts, and maintenance documentation.
335341

336-
[Unreleased]: https://github.com/frostime/frostpi/compare/v0.11.0...HEAD
342+
[Unreleased]: https://github.com/frostime/frostpi/compare/v0.11.1...HEAD
343+
[0.11.1]: https://github.com/frostime/frostpi/compare/v0.11.0...v0.11.1
337344
[0.11.0]: https://github.com/frostime/frostpi/compare/v0.10.4...v0.11.0
338345
[0.10.4]: https://github.com/frostime/frostpi/compare/v0.10.3...v0.10.4
339346
[0.10.3]: https://github.com/frostime/frostpi/compare/v0.10.2...v0.10.3

apps/vscode/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "frostpi",
33
"displayName": "FrostPi — Visual UI for Pi Coding Agent",
44
"description": "A polished VS Code interface for Pi's RPC mode, with streaming conversations, tool activity, image prompts, extension commands, and extension UI requests.",
5-
"version": "0.11.0",
5+
"version": "0.11.1",
66
"publisher": "frostime",
77
"license": "AGPL-3.0-only",
88
"private": false,
@@ -193,7 +193,7 @@
193193
],
194194
"default": "followUp",
195195
"scope": "resource",
196-
"description": "How a normal prompt is queued when Pi is already streaming. Extension commands execute immediately."
196+
"description": "Default delivery for normal prompts submitted while Pi is streaming. The Composer can override it per session; extension commands execute immediately."
197197
},
198198
"frostpi.conversation.collapseTurnTrace": {
199199
"type": "boolean",

apps/vscode/src/extension/conversation/ConversationProjection.ts

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
ImageAttachmentView,
1616
MessageBlockView,
1717
MessageStatus,
18-
QueuedFollowUpView,
18+
QueuedPromptView,
1919
ResponseActivityView,
2020
SessionNoticeLevel,
2121
SessionNoticeView,
@@ -43,7 +43,8 @@ export interface ActiveBranchEdge {
4343

4444
export interface ConversationProjectionSnapshot {
4545
items: readonly ConversationItemView[];
46-
queuedFollowUps: readonly QueuedFollowUpView[];
46+
queuedSteers: readonly QueuedPromptView[];
47+
queuedFollowUps: readonly QueuedPromptView[];
4748
updatedAt: number;
4849
}
4950

@@ -57,7 +58,8 @@ interface PersistedTurnState {
5758
export class ConversationProjection {
5859
readonly #store = new ConversationItemStore();
5960
readonly #assistantMessageAdapter = new PiAssistantMessageAdapter();
60-
#queuedFollowUps: QueuedFollowUpView[] = [];
61+
#queuedSteers: QueuedPromptView[] = [];
62+
#queuedFollowUps: QueuedPromptView[] = [];
6163
#activeTurnId: string | null = null;
6264
#persistedTurn: PersistedTurnState | null = null;
6365
#pendingLiveErrorTurnId: string | null = null;
@@ -77,6 +79,7 @@ export class ConversationProjection {
7779
read(): ConversationProjectionSnapshot {
7880
return {
7981
items: this.#store.read(),
82+
queuedSteers: this.#queuedSteers,
8083
queuedFollowUps: this.#queuedFollowUps,
8184
updatedAt: this.#updatedAt,
8285
};
@@ -148,28 +151,33 @@ export class ConversationProjection {
148151
return turn.id;
149152
}
150153

154+
enqueueSteer(text: string, images: WebviewImageInput[], timestamp = Date.now()): string {
155+
const id = `queued-steer-${timestamp}-${++this.#sequence}`;
156+
this.#queuedSteers = [...this.#queuedSteers, queuedPrompt(id, text, images, timestamp)];
157+
this.#touch();
158+
return id;
159+
}
160+
151161
enqueueFollowUp(text: string, images: WebviewImageInput[], timestamp = Date.now()): string {
152162
const id = `queued-follow-up-${timestamp}-${++this.#sequence}`;
153-
this.#queuedFollowUps = [...this.#queuedFollowUps, {
154-
id,
155-
text,
156-
images: toImageViews(images),
157-
timestamp,
158-
}];
163+
this.#queuedFollowUps = [...this.#queuedFollowUps, queuedPrompt(id, text, images, timestamp)];
159164
this.#touch();
160165
return id;
161166
}
162167

163-
clearQueuedFollowUps(): void {
164-
if (this.#queuedFollowUps.length === 0) return;
168+
clearQueuedPrompts(): void {
169+
if (this.#queuedSteers.length === 0 && this.#queuedFollowUps.length === 0) return;
170+
this.#queuedSteers = [];
165171
this.#queuedFollowUps = [];
166172
this.#touch();
167173
}
168174

169-
removeQueuedFollowUp(id: string): boolean {
170-
const next = this.#queuedFollowUps.filter((item) => item.id !== id);
171-
if (next.length === this.#queuedFollowUps.length) return false;
172-
this.#queuedFollowUps = next;
175+
removeQueuedPrompt(id: string): boolean {
176+
const nextSteers = this.#queuedSteers.filter((item) => item.id !== id);
177+
const nextFollowUps = this.#queuedFollowUps.filter((item) => item.id !== id);
178+
if (nextSteers.length === this.#queuedSteers.length && nextFollowUps.length === this.#queuedFollowUps.length) return false;
179+
this.#queuedSteers = nextSteers;
180+
this.#queuedFollowUps = nextFollowUps;
173181
this.#touch();
174182
return true;
175183
}
@@ -400,14 +408,14 @@ export class ConversationProjection {
400408

401409
#startAgentTurn(): void {
402410
let active = this.#activeTurn();
403-
if (this.#queuedFollowUps.length > 0 && active?.status === "running" && active.items.length === 0) {
411+
if (this.#hasQueuedPrompts() && active?.status === "running" && active.items.length === 0) {
404412
this.#store.removeTopLevelItem(active.id);
405413
this.#activeTurnId = null;
406414
active = undefined;
407415
}
408416
const turn = active?.status === "running"
409417
? active
410-
: this.#promoteQueuedFollowUp() ?? active;
418+
: this.#promoteNextQueuedPrompt() ?? active;
411419
if (!turn) {
412420
this.#activeTurnId = null;
413421
return;
@@ -578,13 +586,12 @@ export class ConversationProjection {
578586
}
579587

580588
#tryPromoteQueuedUserMessage(event: RpcEvent): boolean {
581-
if (this.#queuedFollowUps.length === 0) return false;
589+
if (!this.#hasQueuedPrompts()) return false;
582590
const message = event.message;
583591
if (!isRecord(message) || message.role !== "user") return false;
584592

585-
const [promoted, ...remaining] = this.#queuedFollowUps;
593+
const promoted = this.#takeNextQueuedPrompt();
586594
if (!promoted) return false;
587-
this.#queuedFollowUps = remaining;
588595
if (this.#activeTurnId) {
589596
const prior = this.#turn(this.#activeTurnId);
590597
if (prior.status === "running") this.#setTurnStatus(prior.id, "completed", Date.now());
@@ -632,15 +639,29 @@ export class ConversationProjection {
632639
return undefined;
633640
}
634641

635-
#promoteQueuedFollowUp(): AgentTurnView | undefined {
636-
const [next, ...remaining] = this.#queuedFollowUps;
642+
#promoteNextQueuedPrompt(): AgentTurnView | undefined {
643+
const next = this.#takeNextQueuedPrompt();
637644
if (!next) return undefined;
638-
this.#queuedFollowUps = remaining;
639645
const turn = this.#createUserTurn(next.text, next.images, next.timestamp);
640646
this.#store.appendItem(turn);
641647
return turn;
642648
}
643649

650+
#takeNextQueuedPrompt(): QueuedPromptView | undefined {
651+
const steer = this.#queuedSteers[0];
652+
if (steer) {
653+
this.#queuedSteers = this.#queuedSteers.slice(1);
654+
return steer;
655+
}
656+
const followUp = this.#queuedFollowUps[0];
657+
if (followUp) this.#queuedFollowUps = this.#queuedFollowUps.slice(1);
658+
return followUp;
659+
}
660+
661+
#hasQueuedPrompts(): boolean {
662+
return this.#queuedSteers.length > 0 || this.#queuedFollowUps.length > 0;
663+
}
664+
644665
#refreshBranchControls(branchEdges: readonly ActiveBranchEdge[]): void {
645666
const controls = new Map(branchEdges.map((edge) => [branchControlId(edge), branchControlView(edge)]));
646667
this.#store.mapItems((item) => {
@@ -909,6 +930,15 @@ function isBranchControl(
909930
return item.type === "branchControl";
910931
}
911932

933+
function queuedPrompt(
934+
id: string,
935+
text: string,
936+
images: WebviewImageInput[],
937+
timestamp: number,
938+
): QueuedPromptView {
939+
return { id, text, images: toImageViews(images), timestamp };
940+
}
941+
912942
function toImageViews(
913943
images: readonly WebviewImageInput[] | readonly ImageAttachmentView[],
914944
): ImageAttachmentView[] {

apps/vscode/src/extension/conversation/conversation-projection.SPEC.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ scope:
55
- /apps/vscode/src/extension/conversation/**
66
- /apps/vscode/src/extension/sessions/SessionEntryState.ts
77
- /apps/vscode/src/shared/model/conversationModel.ts
8-
updated: 2026-08-09
8+
updated: 2026-08-15
99
---
1010

1111
# Conversation Projection
@@ -32,7 +32,7 @@ A branch control represents an active parent-child tree edge. Its identity is de
3232

3333
## Live reconciliation
3434

35-
Optimistic prompts, streaming assistant content, tools, queued follow-ups, and notices appear before persistence refresh. A live turn becomes eligible for persisted user identity only after Pi emits its user message event. Eligible live turns pair with newly appended user entries in protocol FIFO order; text and timestamp equality are never identity rules. Rejected prompts and immediate extension commands without a Pi user event remain ineligible.
35+
Optimistic prompts, streaming assistant content, tools, queued steering/follow-up prompts, and notices appear before persistence refresh. A live turn becomes eligible for persisted user identity only after Pi emits its user message event. Eligible live turns pair with newly appended user entries in protocol FIFO order; text and timestamp equality are never identity rules. Rejected prompts and immediate extension commands without a Pi user event remain ineligible.
3636

3737
Live assistant projection accepts both cumulative assistant messages and indexed delta-only updates. A complete assistant `event.message` wins for its event; its accompanying delta is not appended. Otherwise text, thinking, and tool arguments assemble independently by non-negative `contentIndex`. The first valid text or thinking end replaces temporary content and closes that part; later deltas and repeated ends for the part are ignored. Unknown, malformed, conflicting, or out-of-order deltas are ignored without a notice. `message_end.message` is the live final authority and can be projected without an observed start; persisted `get_entries` remains higher authority.
3838

@@ -60,7 +60,7 @@ A persisted user message closes the preceding visual turn and opens a user-ancho
6060

6161
Live `message_end(error)` displays the error activity but leaves the turn running until `agent_end` decides whether Pi will retry. `agent_end(willRetry: true)` keeps the running turn and `auto_retry_start` adds a notice; `willRetry: false` commits the pending error. This uses the existing turn statuses and does not persist retry notices.
6262

63-
While an agent run is active, queued follow-ups remain outside persisted conversation order. Pi may emit a follow-up user message without another `agent_start`; promotion follows protocol FIFO order and closes the prior visual turn. Abort, process stop, and process failure clear the local queue. Tool tracking at these lifecycle boundaries follows [Unresolved tool results](#unresolved-tool-results).
63+
While an agent run is active, queued steering and follow-up prompts remain outside persisted conversation order in separate local projections. Pi may emit queued user messages without another `agent_start`; steering is promoted FIFO before follow-ups, matching Pi's queue priority, and each promotion closes the prior visual turn. Abort, process stop, and process failure clear both local projections. Tool tracking at these lifecycle boundaries follows [Unresolved tool results](#unresolved-tool-results).
6464

6565
Live activity updates replace existing view objects instead of mutating them. This is required for bridge deltas and Webview-owned disclosure state. Documented assistant events provide an ID or timestamp correlation clue; a malformed live assistant without either is omitted until persisted refresh rather than emitted as an uncorrelatable duplicate. Within one valid assistant stream, timestamp and any ID present at start remain stable through end. Mid-stream identity changes are unsupported malformed input and recover only through authoritative history refresh; the Store does not maintain cross-clue aliases. Notices emitted during an active turn remain inside its ordered items; idle notices are top-level conversation items.
6666

apps/vscode/src/extension/sessions/SessionRegistry.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
22
import { access } from "node:fs/promises";
33
import { basename, normalize, resolve } from "node:path";
44

5-
import type { RpcExtensionUiResponse, ThinkingLevel } from "@frostime/pi-rpc";
5+
import type { RpcExtensionUiResponse, StreamingBehavior, ThinkingLevel } from "@frostime/pi-rpc";
66
import * as vscode from "vscode";
77

88
import type { WebviewImageInput } from "../../shared/bridge/webviewToHost.js";
@@ -292,7 +292,7 @@ export class SessionRegistry implements vscode.Disposable {
292292
for (const runtime of this.#runtimes.values()) runtime.refreshConfigurationState(forceRestartRequired);
293293
}
294294

295-
async sendPrompt(sessionId: string, text: string, images: WebviewImageInput[]): Promise<void> {
295+
async sendPrompt(sessionId: string, text: string, images: WebviewImageInput[], streamingBehavior?: StreamingBehavior): Promise<void> {
296296
this.#assertSessionOutsideFork(sessionId);
297297
if (!text.trim() && images.length === 0) return;
298298
const runtime = this.#requireRuntime(sessionId);
@@ -304,7 +304,7 @@ export class SessionRegistry implements vscode.Disposable {
304304
const compactInstructions = compactCommandInstructions(text);
305305
if (compactInstructions !== null && images.length > 0) throw new Error("/compact does not support image attachments.");
306306
if (compactInstructions !== null) await runtime.compact(compactInstructions || undefined);
307-
else await runtime.sendPrompt(text, images);
307+
else await runtime.sendPrompt(text, images, streamingBehavior);
308308
runtime.clearComposerSeed();
309309
if (compactInstructions === null && this.#temporarySessionIds.delete(sessionId)) await this.#persist();
310310
}

0 commit comments

Comments
 (0)