@oh-my-pi/pi-coding-agent 16.4.5 → 16.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/dist/cli.js +3271 -3224
- package/dist/types/cli/bench-cli.d.ts +1 -7
- package/dist/types/cli/usage-cli.d.ts +1 -0
- package/dist/types/commands/usage.d.ts +7 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/modes/components/custom-editor.d.ts +3 -8
- package/dist/types/modes/components/model-browser.d.ts +14 -1
- package/dist/types/modes/components/model-hub.d.ts +4 -3
- package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
- package/dist/types/modes/components/welcome.d.ts +4 -0
- package/dist/types/modes/controllers/input-controller.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/queue-input.d.ts +8 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/session/agent-storage.d.ts +57 -0
- package/package.json +12 -12
- package/scripts/build-binary.ts +0 -1
- package/scripts/compile-binary.ts +4 -3
- package/src/cli/bench-cli.ts +7 -26
- package/src/cli/usage-cli.ts +11 -0
- package/src/commands/usage.ts +13 -2
- package/src/config/settings-schema.ts +1 -1
- package/src/eval/js/shared/rewrite-imports.ts +31 -13
- package/src/modes/components/advisor-config.ts +3 -1
- package/src/modes/components/custom-editor.test.ts +58 -1
- package/src/modes/components/custom-editor.ts +42 -11
- package/src/modes/components/model-browser.ts +154 -60
- package/src/modes/components/model-hub.ts +475 -122
- package/src/modes/components/plan-review-overlay.ts +7 -0
- package/src/modes/components/tips.txt +2 -1
- package/src/modes/components/usage-row.ts +5 -6
- package/src/modes/components/welcome.ts +13 -14
- package/src/modes/controllers/input-controller.ts +140 -6
- package/src/modes/controllers/selector-controller.ts +20 -13
- package/src/modes/controllers/todo-command-controller.ts +1 -2
- package/src/modes/interactive-mode.ts +18 -0
- package/src/modes/queue-input.ts +132 -0
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +19 -20
- package/src/session/agent-session.ts +184 -48
- package/src/session/agent-storage.ts +330 -3
- package/src/session/history-storage.ts +1 -34
- package/src/slash-commands/builtin-registry.ts +9 -0
- package/src/web/search/providers/perplexity.ts +18 -2
|
@@ -82,6 +82,8 @@ export interface PlanReviewOverlayCallbacks {
|
|
|
82
82
|
onPick: (label: string) => void;
|
|
83
83
|
/** Invoked on Esc / cancel. */
|
|
84
84
|
onCancel: () => void;
|
|
85
|
+
/** Invoked with the current full plan text when the copy hotkey is pressed. */
|
|
86
|
+
onCopyPlan?: (content: string) => void | Promise<void>;
|
|
85
87
|
/** Invoked when the external-editor key is pressed (overlay stays open). */
|
|
86
88
|
onExternalEditor?: () => void;
|
|
87
89
|
/** Invoked when the external-editor key edits the active annotation draft. */
|
|
@@ -302,6 +304,10 @@ export class PlanReviewOverlay implements Component {
|
|
|
302
304
|
this.callbacks.onExternalEditor();
|
|
303
305
|
return;
|
|
304
306
|
}
|
|
307
|
+
if (this.callbacks.onCopyPlan && keyData === "c") {
|
|
308
|
+
void this.callbacks.onCopyPlan(joinPlanSections(this.#sections));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
305
311
|
if (matchesKey(keyData, "tab") || keyData === "\t") {
|
|
306
312
|
this.#cycleRegion(1);
|
|
307
313
|
return;
|
|
@@ -677,6 +683,7 @@ export class PlanReviewOverlay implements Component {
|
|
|
677
683
|
parts.push("↑↓ scroll", "⇧ faster", "pgup/pgdn", "g/G ends");
|
|
678
684
|
break;
|
|
679
685
|
}
|
|
686
|
+
if (this.callbacks.onCopyPlan) parts.push("c copy");
|
|
680
687
|
parts.push("tab regions");
|
|
681
688
|
if (this.#externalEditorLabel && this.#focus !== "toc") parts.push(`${this.#externalEditorLabel} editor`);
|
|
682
689
|
parts.push(this.#helpSuffix);
|
|
@@ -21,4 +21,5 @@ Pair up live: `/collab` shares your session through an end-to-end encrypted rela
|
|
|
21
21
|
Press ← ← to drill into a running or finished agent and inspect its tool calls and transcript
|
|
22
22
|
Hit a Codex rate limit? `/usage reset` spends a saved reset credit to immediately restore your quota
|
|
23
23
|
No native tool_calling? Inference provider botches parsing them? `PI_DIALECT=glm|kimi|anthropic…` rolls it locally for them!
|
|
24
|
-
Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice
|
|
24
|
+
Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice
|
|
25
|
+
Try starting your prompt with a ->, and writing a list (1. Do X, 2. Do Y)
|
|
@@ -18,12 +18,11 @@ export function createUsageRowBlock(usage: Usage, durationMs?: number, ttftMs?:
|
|
|
18
18
|
parts.push(`${theme.icon.time} ${(ttftMs / 1000).toFixed(1)}s`);
|
|
19
19
|
}
|
|
20
20
|
if (durationMs && durationMs > MIN_DURATION_MS && usage.output > 0) {
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
21
|
+
// TPS over the total request duration — the post-TTFT window undercounts
|
|
22
|
+
// generation time when reasoning tokens are hidden before the first
|
|
23
|
+
// visible byte, inflating the rate.
|
|
24
|
+
const tokPerSec = (usage.output / durationMs) * 1000;
|
|
25
|
+
parts.push(`${theme.icon.throughput} ${tokPerSec.toFixed(1)}/s`);
|
|
27
26
|
}
|
|
28
27
|
const block = new Container();
|
|
29
28
|
block.addChild(new Spacer(1));
|
|
@@ -44,20 +44,19 @@ const NEW_GLOW_PERIOD_MS = 1500;
|
|
|
44
44
|
* affordance surfaces this many times as often. */
|
|
45
45
|
const NEW_TIP_WEIGHT = 4;
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
let
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (r < 0) return TIPS[i] ?? "";
|
|
47
|
+
/** Pick a tip from `tips`, biased toward "[NEW]" tips by {@link NEW_TIP_WEIGHT};
|
|
48
|
+
* `r` is a uniform sample in [0, 1). Returns "" when `tips` is empty.
|
|
49
|
+
* Exported for tests. */
|
|
50
|
+
export function pickWeightedTip(tips: readonly string[], r: number): string {
|
|
51
|
+
if (tips.length === 0) return "";
|
|
52
|
+
const weights = tips.map(tip => (NEW_TIP_MARKER.test(tip) ? NEW_TIP_WEIGHT : 1));
|
|
53
|
+
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
|
54
|
+
let acc = r * total;
|
|
55
|
+
for (let i = 0; i < tips.length; i++) {
|
|
56
|
+
acc -= weights[i] ?? 1;
|
|
57
|
+
if (acc < 0) return tips[i] ?? "";
|
|
59
58
|
}
|
|
60
|
-
return
|
|
59
|
+
return tips[tips.length - 1] ?? "";
|
|
61
60
|
}
|
|
62
61
|
|
|
63
62
|
type ColorEncoding = "ansi-16m" | "ansi-256";
|
|
@@ -161,7 +160,7 @@ export class WelcomeComponent implements Component {
|
|
|
161
160
|
if (theme.getSymbolPreset() === "unicode" && Math.random() < 0.1) {
|
|
162
161
|
this.#selectedTip = "Please use nerdfont 😭.";
|
|
163
162
|
} else {
|
|
164
|
-
this.#selectedTip = pickWeightedTip();
|
|
163
|
+
this.#selectedTip = pickWeightedTip(TIPS, Math.random());
|
|
165
164
|
}
|
|
166
165
|
}
|
|
167
166
|
return this.#selectedTip || undefined;
|
|
@@ -13,6 +13,7 @@ import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-
|
|
|
13
13
|
import { expandEmoticons } from "../../modes/emoji-autocomplete";
|
|
14
14
|
import { materializeImageReferenceLinks, shiftImageMarkers } from "../../modes/image-references";
|
|
15
15
|
import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
|
|
16
|
+
import { parseQueueShorthand, splitQueuedMessages } from "../../modes/queue-input";
|
|
16
17
|
import { invokeSkillCommandFromText, isKnownSkillCommand } from "../../modes/skill-command";
|
|
17
18
|
import type { InteractiveModeContext } from "../../modes/types";
|
|
18
19
|
import manualContinuePrompt from "../../prompts/system/manual-continue.md" with { type: "text" };
|
|
@@ -266,6 +267,8 @@ export class InputController {
|
|
|
266
267
|
});
|
|
267
268
|
}
|
|
268
269
|
this.ctx.editor.onEscape = () => {
|
|
270
|
+
// Side-channel panels are the topmost view. Esc dismisses them before
|
|
271
|
+
// touching loop mode, maintenance, or the underlying main turn.
|
|
269
272
|
// Active context maintenance owns Esc: auto/manual compaction,
|
|
270
273
|
// handoff generation, and auto-retry backoff all advertise
|
|
271
274
|
// "(esc to cancel)". Dispatch on live session state instead of
|
|
@@ -281,6 +284,13 @@ export class InputController {
|
|
|
281
284
|
// (see EventController). Main-session maintenance still owns Esc and
|
|
282
285
|
// stays cancellable from the main view (focused submit gates /compact
|
|
283
286
|
// and handoff, so manual maintenance is main-only anyway).
|
|
287
|
+
if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
284
294
|
if (!this.ctx.focusedAgentId) {
|
|
285
295
|
const viewSession = this.ctx.viewSession;
|
|
286
296
|
let aborted = false;
|
|
@@ -308,12 +318,6 @@ export class InputController {
|
|
|
308
318
|
}
|
|
309
319
|
return;
|
|
310
320
|
}
|
|
311
|
-
if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
317
321
|
if (this.ctx.focusedAgentId) {
|
|
318
322
|
// Esc never interrupts the focused agent's turn: clear typed text,
|
|
319
323
|
// else return the view to the main session. Interrupt via empty
|
|
@@ -647,6 +651,16 @@ export class InputController {
|
|
|
647
651
|
|
|
648
652
|
if (!text && !hasInputImages) return;
|
|
649
653
|
|
|
654
|
+
const queueBody = parseQueueShorthand(text);
|
|
655
|
+
if (queueBody !== undefined) {
|
|
656
|
+
await this.#queueForYield(queueBody, {
|
|
657
|
+
historyText: text,
|
|
658
|
+
images: inputImages,
|
|
659
|
+
imageLinks: inputImageLinks,
|
|
660
|
+
});
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
650
664
|
// Handle built-in slash commands
|
|
651
665
|
if (text) {
|
|
652
666
|
const slashResult = await executeBuiltinSlashCommand(text, {
|
|
@@ -1114,6 +1128,126 @@ export class InputController {
|
|
|
1114
1128
|
}
|
|
1115
1129
|
}
|
|
1116
1130
|
|
|
1131
|
+
/** Queue `/queue` input behind an active turn, or start it immediately when idle. */
|
|
1132
|
+
async handleQueueCommand(text: string): Promise<void> {
|
|
1133
|
+
const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
|
|
1134
|
+
const imageLinks =
|
|
1135
|
+
images && this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
|
|
1136
|
+
await this.#queueForYield(text, { images, imageLinks });
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async #queueForYield(
|
|
1140
|
+
text: string,
|
|
1141
|
+
options: {
|
|
1142
|
+
historyText?: string;
|
|
1143
|
+
images?: ImageContent[];
|
|
1144
|
+
imageLinks?: (string | undefined)[];
|
|
1145
|
+
},
|
|
1146
|
+
): Promise<void> {
|
|
1147
|
+
const splitMessages = splitQueuedMessages(text);
|
|
1148
|
+
if (splitMessages.length === 0 && !options.images?.length) {
|
|
1149
|
+
this.ctx.editor.clearDraft();
|
|
1150
|
+
this.ctx.showWarning("Usage: /queue <message> (or start a prompt with -> / =>)");
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const messages = splitMessages.length > 0 ? splitMessages : [""];
|
|
1155
|
+
const originalDraft = this.ctx.editor.getText();
|
|
1156
|
+
const images = options.images?.length ? [...options.images] : undefined;
|
|
1157
|
+
const imageLinks = options.imageLinks
|
|
1158
|
+
? [...options.imageLinks]
|
|
1159
|
+
: images
|
|
1160
|
+
? images.map(() => undefined)
|
|
1161
|
+
: undefined;
|
|
1162
|
+
this.ctx.editor.clearDraft(options.historyText);
|
|
1163
|
+
|
|
1164
|
+
if (this.ctx.session.isCompacting) {
|
|
1165
|
+
for (let index = 0; index < messages.length; index++) {
|
|
1166
|
+
this.ctx.compactionQueuedMessages.push({
|
|
1167
|
+
text: messages[index] ?? "",
|
|
1168
|
+
mode: "followUp",
|
|
1169
|
+
images: index === 0 ? images : undefined,
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
this.ctx.updatePendingMessagesDisplay();
|
|
1173
|
+
this.ctx.showStatus(
|
|
1174
|
+
messages.length === 1
|
|
1175
|
+
? "Queued message for after compaction"
|
|
1176
|
+
: `Queued ${messages.length} messages for after compaction`,
|
|
1177
|
+
);
|
|
1178
|
+
this.ctx.ui.requestRender();
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const startImmediately = !this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount === 0;
|
|
1183
|
+
let queuedCount = 0;
|
|
1184
|
+
try {
|
|
1185
|
+
if (startImmediately && this.ctx.onInputCallback) {
|
|
1186
|
+
const first = messages[0] ?? "";
|
|
1187
|
+
const submission = this.ctx.startPendingSubmission({
|
|
1188
|
+
text: first,
|
|
1189
|
+
images,
|
|
1190
|
+
imageLinks,
|
|
1191
|
+
streamingBehavior: "followUp",
|
|
1192
|
+
});
|
|
1193
|
+
this.ctx.onInputCallback(submission);
|
|
1194
|
+
queuedCount = 1;
|
|
1195
|
+
}
|
|
1196
|
+
while (queuedCount < messages.length) {
|
|
1197
|
+
const message = messages[queuedCount] ?? "";
|
|
1198
|
+
const queuedImages = queuedCount === 0 ? images : undefined;
|
|
1199
|
+
await this.ctx.withLocalSubmission(
|
|
1200
|
+
message,
|
|
1201
|
+
async () => {
|
|
1202
|
+
if (startImmediately && queuedCount === 0) {
|
|
1203
|
+
await this.ctx.session.prompt(message, {
|
|
1204
|
+
images: queuedImages,
|
|
1205
|
+
streamingBehavior: "followUp",
|
|
1206
|
+
});
|
|
1207
|
+
} else {
|
|
1208
|
+
await this.ctx.session.followUp(message, queuedImages);
|
|
1209
|
+
}
|
|
1210
|
+
},
|
|
1211
|
+
{ imageCount: queuedImages?.length ?? 0 },
|
|
1212
|
+
);
|
|
1213
|
+
queuedCount++;
|
|
1214
|
+
}
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
if (queuedCount === 0) {
|
|
1217
|
+
this.ctx.editor.setText(originalDraft);
|
|
1218
|
+
if (images) {
|
|
1219
|
+
this.ctx.editor.pendingImages = images;
|
|
1220
|
+
this.ctx.editor.pendingImageLinks = imageLinks ?? images.map(() => undefined);
|
|
1221
|
+
this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
|
|
1222
|
+
}
|
|
1223
|
+
} else {
|
|
1224
|
+
const remaining = messages.slice(queuedCount);
|
|
1225
|
+
const restored =
|
|
1226
|
+
remaining.length === 1
|
|
1227
|
+
? `=> ${remaining[0]}`
|
|
1228
|
+
: `=>\n${remaining
|
|
1229
|
+
.map((message, index) => `${index + 1}. ${message.replaceAll("\n", "\n ")}`)
|
|
1230
|
+
.join("\n")}`;
|
|
1231
|
+
this.ctx.editor.setText(restored);
|
|
1232
|
+
}
|
|
1233
|
+
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
this.ctx.updatePendingMessagesDisplay();
|
|
1237
|
+
if (queuedCount === messages.length) {
|
|
1238
|
+
this.ctx.showStatus(
|
|
1239
|
+
startImmediately
|
|
1240
|
+
? queuedCount === 1
|
|
1241
|
+
? "Sent queued message"
|
|
1242
|
+
: `Sent first message; queued ${queuedCount - 1} for later yields`
|
|
1243
|
+
: queuedCount === 1
|
|
1244
|
+
? "Queued message for when the agent yields"
|
|
1245
|
+
: `Queued ${queuedCount} messages for when the agent yields`,
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
this.ctx.ui.requestRender();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1117
1251
|
/** Send editor text as a follow-up message (queued behind current stream). */
|
|
1118
1252
|
async handleFollowUp(): Promise<void> {
|
|
1119
1253
|
let text = this.ctx.editor.getExpandedText().trim();
|
|
@@ -616,7 +616,7 @@ export class SelectorController {
|
|
|
616
616
|
this.ctx.session.modelRegistry,
|
|
617
617
|
this.ctx.session.scopedModels,
|
|
618
618
|
{
|
|
619
|
-
onAssign: async (model, role, thinkingLevel, selector
|
|
619
|
+
onAssign: async (model, role, thinkingLevel, selector) => {
|
|
620
620
|
// `auto` is session-global: never baked into a per-role model value
|
|
621
621
|
// (it can't round-trip through `model:<level>`). Apply it to the session
|
|
622
622
|
// separately and persist via `defaultThinkingLevel`.
|
|
@@ -624,18 +624,6 @@ export class SelectorController {
|
|
|
624
624
|
const concreteThinking = isAuto || thinkingLevel === undefined ? undefined : thinkingLevel;
|
|
625
625
|
const selectorValue = selector ?? `${model.provider}/${model.id}`;
|
|
626
626
|
try {
|
|
627
|
-
if (action === "retryFallback") {
|
|
628
|
-
const fallbackSelector = formatModelSelectorValue(selectorValue, concreteThinking);
|
|
629
|
-
const fallbackChains = this.ctx.settings.get("retry.fallbackChains");
|
|
630
|
-
const chain = Array.isArray(fallbackChains[role]) ? fallbackChains[role] : [];
|
|
631
|
-
this.ctx.settings.set("retry.fallbackChains", {
|
|
632
|
-
...fallbackChains,
|
|
633
|
-
[role]: [fallbackSelector, ...chain.filter(existing => existing !== fallbackSelector)],
|
|
634
|
-
});
|
|
635
|
-
const roleInfo = getRoleInfo(role, settings);
|
|
636
|
-
this.ctx.showStatus(`${roleInfo?.name ?? role} fallback model: ${fallbackSelector}`);
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
627
|
if (role === "default") {
|
|
640
628
|
const { switched } = await this.ctx.session.setModel(model, role, {
|
|
641
629
|
selector,
|
|
@@ -679,6 +667,25 @@ export class SelectorController {
|
|
|
679
667
|
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
680
668
|
}
|
|
681
669
|
},
|
|
670
|
+
onFallbackChainChange: (role, chain) => {
|
|
671
|
+
try {
|
|
672
|
+
const chains = { ...this.ctx.settings.get("retry.fallbackChains") };
|
|
673
|
+
if (chain.length === 0) {
|
|
674
|
+
delete chains[role];
|
|
675
|
+
} else {
|
|
676
|
+
chains[role] = chain;
|
|
677
|
+
}
|
|
678
|
+
this.ctx.settings.set("retry.fallbackChains", chains);
|
|
679
|
+
const roleInfo = getRoleInfo(role, settings);
|
|
680
|
+
this.ctx.showStatus(
|
|
681
|
+
chain.length > 0
|
|
682
|
+
? `${roleInfo?.name ?? role} fallbacks: ${chain.join(" → ")}`
|
|
683
|
+
: `${roleInfo?.name ?? role} fallbacks cleared`,
|
|
684
|
+
);
|
|
685
|
+
} catch (error) {
|
|
686
|
+
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
687
|
+
}
|
|
688
|
+
},
|
|
682
689
|
onPick: async (model, selector) => {
|
|
683
690
|
try {
|
|
684
691
|
// Session-only: update agent state but don't persist the model to settings.
|
|
@@ -133,8 +133,7 @@ export class TodoCommandController {
|
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
135
|
* True latest todo state for the user-facing /todo verbs. Reads from session
|
|
136
|
-
* entries
|
|
137
|
-
* (where `session.getTodoPhases()` would have stripped them).
|
|
136
|
+
* entries or falls back to the active session state.
|
|
138
137
|
*/
|
|
139
138
|
#currentPhases(): TodoPhase[] {
|
|
140
139
|
const fromEntries = getLatestTodoPhasesFromEntries(this.ctx.sessionManager.getBranch());
|
|
@@ -120,6 +120,7 @@ import { formatPhaseDisplayName, todoMatchesAnyDescription } from "../tools/todo
|
|
|
120
120
|
import { ToolError } from "../tools/tool-errors";
|
|
121
121
|
import { vocalizer } from "../tts/vocalizer";
|
|
122
122
|
import { renderTreeList } from "../tui/tree-list";
|
|
123
|
+
import { copyToClipboard } from "../utils/clipboard";
|
|
123
124
|
import type { EventBus } from "../utils/event-bus";
|
|
124
125
|
import { getEditorCommand, openInEditor } from "../utils/external-editor";
|
|
125
126
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
|
|
@@ -2517,6 +2518,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2517
2518
|
{
|
|
2518
2519
|
onPick: choice => finish(choice),
|
|
2519
2520
|
onCancel: () => finish(undefined),
|
|
2521
|
+
onCopyPlan: content => void this.#copyPlanToClipboard(content),
|
|
2520
2522
|
onExternalEditor: dialogOptions?.onExternalEditor,
|
|
2521
2523
|
onAnnotationExternalEditor: (draft, commit) => void this.#openPlanAnnotationInExternalEditor(draft, commit),
|
|
2522
2524
|
onPlanEdited: dialogOptions?.onPlanEdited,
|
|
@@ -2583,6 +2585,17 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2583
2585
|
return contextUsage !== undefined && contextUsage.percent > PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT;
|
|
2584
2586
|
}
|
|
2585
2587
|
|
|
2588
|
+
async #copyPlanToClipboard(content: string): Promise<void> {
|
|
2589
|
+
try {
|
|
2590
|
+
await copyToClipboard(content);
|
|
2591
|
+
this.showStatus("Copied plan to clipboard");
|
|
2592
|
+
} catch (error) {
|
|
2593
|
+
this.showWarning(
|
|
2594
|
+
`Failed to copy plan to clipboard: ${error instanceof Error ? error.message : String(error)}`,
|
|
2595
|
+
);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2586
2599
|
async #openPlanInExternalEditor(planFilePath: string): Promise<void> {
|
|
2587
2600
|
const editorCmd = getEditorCommand();
|
|
2588
2601
|
if (!editorCmd) {
|
|
@@ -4287,6 +4300,11 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
4287
4300
|
return this.#inputController.handleImagePaste();
|
|
4288
4301
|
}
|
|
4289
4302
|
|
|
4303
|
+
/** Queue slash-command input behind the active turn. */
|
|
4304
|
+
handleQueueCommand(message: string): Promise<void> {
|
|
4305
|
+
return this.#inputController.handleQueueCommand(message);
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4290
4308
|
handleBtwCommand(question: string): Promise<void> {
|
|
4291
4309
|
return this.#btwController.start(question);
|
|
4292
4310
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const QUEUE_PREFIXES: readonly string[] = ["->", "=>"];
|
|
2
|
+
/** Prefix matcher shared by queue-list parsing and editor highlighting. */
|
|
3
|
+
export const QUEUE_LIST_MARKER_RE = /^([\t ]*)(\d+|[A-Za-z]+)([.)])(?=[\t ]|$)/;
|
|
4
|
+
const CANONICAL_ROMAN_RE = /^(?=[MDCLXVI])M{0,3}(?:CM|CD|D?C{0,3})(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3})$/i;
|
|
5
|
+
|
|
6
|
+
interface EnumeratedItem {
|
|
7
|
+
line: number;
|
|
8
|
+
indent: string;
|
|
9
|
+
marker: string;
|
|
10
|
+
punctuation: string;
|
|
11
|
+
content: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface EnumeratedList {
|
|
15
|
+
source: string;
|
|
16
|
+
lines: string[];
|
|
17
|
+
items: EnumeratedItem[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Extract the message body from the `->` / `=>` yield-queue shorthand. */
|
|
21
|
+
export function parseQueueShorthand(text: string): string | undefined {
|
|
22
|
+
const prefix = QUEUE_PREFIXES.find(candidate => text.startsWith(candidate));
|
|
23
|
+
return prefix ? text.slice(prefix.length).trim() : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseEnumeratedItem(line: string, lineIndex: number): EnumeratedItem | undefined {
|
|
27
|
+
const match = QUEUE_LIST_MARKER_RE.exec(line);
|
|
28
|
+
if (!match) return undefined;
|
|
29
|
+
const [matched, indent, marker, punctuation] = match;
|
|
30
|
+
if (indent === undefined || marker === undefined || punctuation === undefined) return undefined;
|
|
31
|
+
return { line: lineIndex, indent, marker, punctuation, content: line.slice(matched.length).trimStart() };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function decodeDecimal(marker: string): number | undefined {
|
|
35
|
+
if (!/^\d+$/.test(marker)) return undefined;
|
|
36
|
+
const value = Number(marker);
|
|
37
|
+
return Number.isSafeInteger(value) ? value : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function decodeRoman(marker: string): number | undefined {
|
|
41
|
+
if (!CANONICAL_ROMAN_RE.test(marker)) return undefined;
|
|
42
|
+
const values: Readonly<Record<string, number>> = {
|
|
43
|
+
I: 1,
|
|
44
|
+
V: 5,
|
|
45
|
+
X: 10,
|
|
46
|
+
L: 50,
|
|
47
|
+
C: 100,
|
|
48
|
+
D: 500,
|
|
49
|
+
M: 1000,
|
|
50
|
+
};
|
|
51
|
+
const upper = marker.toUpperCase();
|
|
52
|
+
let value = 0;
|
|
53
|
+
for (let index = 0; index < upper.length; index++) {
|
|
54
|
+
const current = values[upper[index] ?? ""];
|
|
55
|
+
if (current === undefined) return undefined;
|
|
56
|
+
const next = values[upper[index + 1] ?? ""] ?? 0;
|
|
57
|
+
value += current < next ? -current : current;
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function decodeAlpha(marker: string): number | undefined {
|
|
63
|
+
if (!/^[A-Za-z]+$/.test(marker)) return undefined;
|
|
64
|
+
let value = 0;
|
|
65
|
+
for (const char of marker.toUpperCase()) {
|
|
66
|
+
value = value * 26 + char.charCodeAt(0) - 64;
|
|
67
|
+
if (!Number.isSafeInteger(value)) return undefined;
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isSequential(markers: readonly string[], decode: (marker: string) => number | undefined): boolean {
|
|
73
|
+
let previous = decode(markers[0] ?? "");
|
|
74
|
+
if (previous === undefined) return false;
|
|
75
|
+
for (let index = 1; index < markers.length; index++) {
|
|
76
|
+
const current = decode(markers[index] ?? "");
|
|
77
|
+
if (current === undefined || current !== previous + 1) return false;
|
|
78
|
+
previous = current;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isEnumeratedSequence(items: readonly EnumeratedItem[]): boolean {
|
|
84
|
+
const markers = items.map(item => item.marker);
|
|
85
|
+
if (markers.every(marker => /^\d+$/.test(marker))) return isSequential(markers, decodeDecimal);
|
|
86
|
+
if (
|
|
87
|
+
!markers.every(marker => marker === marker.toUpperCase()) &&
|
|
88
|
+
!markers.every(marker => marker === marker.toLowerCase())
|
|
89
|
+
) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return isSequential(markers, decodeRoman) || isSequential(markers, decodeAlpha);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseEnumeratedList(text: string): EnumeratedList | undefined {
|
|
96
|
+
const source = text.trim();
|
|
97
|
+
if (!source) return undefined;
|
|
98
|
+
const lines = source.split(/\r?\n/);
|
|
99
|
+
const first = parseEnumeratedItem(lines[0] ?? "", 0);
|
|
100
|
+
if (!first) return undefined;
|
|
101
|
+
|
|
102
|
+
const items = [first];
|
|
103
|
+
for (let lineIndex = 1; lineIndex < lines.length; lineIndex++) {
|
|
104
|
+
const item = parseEnumeratedItem(lines[lineIndex] ?? "", lineIndex);
|
|
105
|
+
if (item?.indent === first.indent) items.push(item);
|
|
106
|
+
}
|
|
107
|
+
if (items.length < 2 || items.some(item => item.punctuation !== first.punctuation) || !isEnumeratedSequence(items)) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
return { source, lines, items };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether text currently forms a sequential queue list, including an unfinished trailing item. */
|
|
114
|
+
export function isQueuedMessageList(text: string): boolean {
|
|
115
|
+
return parseEnumeratedList(text) !== undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Split a sequential numeric, Roman-numeral, or alphabetic list into queue entries. */
|
|
119
|
+
export function splitQueuedMessages(text: string): string[] {
|
|
120
|
+
const list = parseEnumeratedList(text);
|
|
121
|
+
if (!list) {
|
|
122
|
+
const source = text.trim();
|
|
123
|
+
return source ? [source] : [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const messages = list.items.map((item, index) => {
|
|
127
|
+
const nextLine = list.items[index + 1]?.line ?? list.lines.length;
|
|
128
|
+
return [item.content, ...list.lines.slice(item.line + 1, nextLine)].join("\n").trim();
|
|
129
|
+
});
|
|
130
|
+
while (messages.at(-1) === "") messages.pop();
|
|
131
|
+
return messages.length > 0 && messages.every(Boolean) ? messages : [list.source];
|
|
132
|
+
}
|
package/src/modes/types.ts
CHANGED
|
@@ -377,6 +377,8 @@ export interface InteractiveModeContext {
|
|
|
377
377
|
handleCtrlZ(): void;
|
|
378
378
|
handleDequeue(): void;
|
|
379
379
|
handleImagePaste(): Promise<boolean>;
|
|
380
|
+
/** Queue a message for delivery only after the active agent turn would stop. */
|
|
381
|
+
handleQueueCommand(message: string): Promise<void>;
|
|
380
382
|
handleBtwCommand(question: string): Promise<void>;
|
|
381
383
|
handleTanCommand(work: string): Promise<void>;
|
|
382
384
|
hasActiveBtw(): boolean;
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
type SkillPromptDetails,
|
|
47
47
|
} from "../../session/messages";
|
|
48
48
|
import type { SessionContext } from "../../session/session-context";
|
|
49
|
+
import { replaceTabs } from "../../tools/render-utils";
|
|
49
50
|
import { buildSkillCommandPrompt, invokeSkillCommandFromText, isKnownSkillCommand } from "../skill-command";
|
|
50
51
|
import { createAssistantMessageComponent } from "./interactive-context-helpers";
|
|
51
52
|
import {
|
|
@@ -677,35 +678,33 @@ export class UiHelpers {
|
|
|
677
678
|
this.ctx.pendingMessagesContainer.disposeChildren();
|
|
678
679
|
const queuedMessages = this.ctx.viewSession.getQueuedMessages() as QueuedMessages;
|
|
679
680
|
|
|
680
|
-
const steeringMessages
|
|
681
|
-
for (const message of queuedMessages.steering) {
|
|
682
|
-
steeringMessages.push({ message, label: "Steer" });
|
|
683
|
-
}
|
|
681
|
+
const steeringMessages = [...queuedMessages.steering];
|
|
684
682
|
for (const entry of this.ctx.compactionQueuedMessages as CompactionQueuedMessage[]) {
|
|
685
|
-
if (entry.mode === "steer")
|
|
686
|
-
steeringMessages.push({ message: entry.text, label: "Steer" });
|
|
687
|
-
}
|
|
683
|
+
if (entry.mode === "steer") steeringMessages.push(entry.text);
|
|
688
684
|
}
|
|
689
685
|
|
|
690
|
-
const followUpMessages
|
|
691
|
-
for (const message of queuedMessages.followUp) {
|
|
692
|
-
followUpMessages.push({ message, label: "Follow-up" });
|
|
693
|
-
}
|
|
686
|
+
const followUpMessages = [...queuedMessages.followUp];
|
|
694
687
|
for (const entry of this.ctx.compactionQueuedMessages as CompactionQueuedMessage[]) {
|
|
695
|
-
if (entry.mode === "followUp")
|
|
696
|
-
followUpMessages.push({ message: entry.text, label: "Follow-up" });
|
|
697
|
-
}
|
|
688
|
+
if (entry.mode === "followUp") followUpMessages.push(entry.text);
|
|
698
689
|
}
|
|
699
690
|
|
|
700
|
-
const
|
|
701
|
-
|
|
691
|
+
const groups = [
|
|
692
|
+
{ label: "Steering", messages: steeringMessages },
|
|
693
|
+
{ label: "After yield", messages: followUpMessages },
|
|
694
|
+
].filter(group => group.messages.length > 0);
|
|
695
|
+
if (groups.length > 0) {
|
|
702
696
|
this.ctx.pendingMessagesContainer.addChild(new Spacer(1));
|
|
703
|
-
for (const
|
|
704
|
-
const
|
|
705
|
-
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(
|
|
697
|
+
for (const group of groups) {
|
|
698
|
+
const heading = theme.fg("muted", `${group.label}${theme.sep.dot}${group.messages.length}`);
|
|
699
|
+
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(heading, 1, 0));
|
|
700
|
+
for (let index = 0; index < group.messages.length; index++) {
|
|
701
|
+
const message = replaceTabs(group.messages[index] ?? "").replace(/\r?\n/g, " ↵ ");
|
|
702
|
+
const queuedText = theme.fg("dim", ` ${index + 1}. ${message}`);
|
|
703
|
+
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
|
|
704
|
+
}
|
|
706
705
|
}
|
|
707
706
|
const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up";
|
|
708
|
-
const hintText = theme.fg("dim",
|
|
707
|
+
const hintText = theme.fg("dim", ` ${theme.tree.hook} ${dequeueKey} to edit`);
|
|
709
708
|
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
|
|
710
709
|
}
|
|
711
710
|
}
|