@selesai/code 0.5.21 → 0.5.23

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.
Files changed (56) hide show
  1. package/README.md +1 -1
  2. package/dist/core/agent-session-skill-block.test.d.ts +2 -0
  3. package/dist/core/agent-session-skill-block.test.d.ts.map +1 -0
  4. package/dist/core/agent-session-skill-block.test.js +20 -0
  5. package/dist/core/agent-session-skill-block.test.js.map +1 -0
  6. package/dist/core/agent-session.d.ts +7 -4
  7. package/dist/core/agent-session.d.ts.map +1 -1
  8. package/dist/core/agent-session.js +22 -12
  9. package/dist/core/agent-session.js.map +1 -1
  10. package/dist/core/skills.d.ts.map +1 -1
  11. package/dist/core/skills.js +1 -1
  12. package/dist/core/skills.js.map +1 -1
  13. package/dist/data/capabilities.d.ts +48 -0
  14. package/dist/data/capabilities.d.ts.map +1 -0
  15. package/dist/data/capabilities.js +282 -0
  16. package/dist/data/capabilities.js.map +1 -0
  17. package/dist/data/navigation.d.ts +3 -0
  18. package/dist/data/navigation.d.ts.map +1 -0
  19. package/dist/data/navigation.js +90 -0
  20. package/dist/data/navigation.js.map +1 -0
  21. package/dist/extensions/inline-skills.test.ts +96 -0
  22. package/dist/extensions/inline-skills.ts +92 -0
  23. package/dist/extensions/package.json +1 -0
  24. package/dist/extensions/pi-powerline-footer/tests/tps.test.ts +39 -0
  25. package/dist/extensions/pi-powerline-footer/tps.ts +172 -113
  26. package/dist/extensions/pi-subagents/src/tui/fleet.ts +0 -4
  27. package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +1 -1
  28. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +1 -1
  29. package/dist/extensions/question/batch.ts +89 -25
  30. package/dist/extensions/question/constants.ts +1 -1
  31. package/dist/extensions/question/helpers.ts +34 -78
  32. package/dist/extensions/question/index.ts +327 -564
  33. package/dist/extensions/question/package.json +2 -1
  34. package/dist/extensions/question/question-list.ts +21 -14
  35. package/dist/extensions/question/row-layout.ts +2 -2
  36. package/dist/extensions/question/schemas.ts +33 -47
  37. package/dist/extensions/question/selection-mode.ts +5 -7
  38. package/dist/extensions/question/tests/batch.test.ts +65 -16
  39. package/dist/extensions/question/tests/helpers.test.ts +31 -156
  40. package/dist/extensions/question/tests/question-list.test.ts +17 -3
  41. package/dist/extensions/question/tests/row-layout.test.ts +4 -4
  42. package/dist/extensions/question/tests/schemas.test.ts +30 -0
  43. package/dist/extensions/question/tests/wizard.test.ts +99 -0
  44. package/dist/extensions/question/types.ts +65 -66
  45. package/dist/extensions/workflow/adapter.ts +20 -77
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +1 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  51. package/dist/modes/interactive/interactive-mode.js +10 -10
  52. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  53. package/dist/skills/workflow-creation/SKILL.md +2 -2
  54. package/docs/skills.md +8 -0
  55. package/docs/workflows.md +4 -4
  56. package/package.json +1 -1
@@ -1,39 +1,98 @@
1
1
  /**
2
2
  * TPS Tracker — tokens-per-second tracking merged into the powerline footer.
3
3
  *
4
- * Writes live and final TPS stats to `ctx.ui.setStatus("tps", ...)`, which the
5
- * powerline `extension_statuses` segment surfaces in the status bar (and the
6
- * `setExtensionStatus` repaint hook turns into a re-render automatically).
7
- *
8
- * Tracks:
9
- * - Main model output tokens vs. streaming wall time (excludes first-token
10
- * latency and tool execution gaps).
11
- * - pi-subagents child output tokens via tool_execution_* events, aggregated
12
- * into a combined "main + subagents" TPS at agent_end.
4
+ * Live status remains intentionally approximate. Final main-model TPS uses
5
+ * provider output usage plus pi-tps' reliability, stall, and tool-call gates.
6
+ * Subagent UI/aggregation remains separate.
13
7
  */
14
8
 
15
9
  import type { ExtensionAPI } from "@selesai/code";
16
10
 
11
+ const STALL_THRESHOLD_MS = 500;
12
+ const MAX_PLAUSIBLE_TPS = 10_000;
13
+ const MIN_STREAM_MS = 1;
14
+ const MIN_STREAM_UPDATES = 5;
15
+ const MIN_INTER_CHUNK_MS = 1;
16
+ const MIN_GENERATION_MS = 200;
17
+ const ACTIVE_TIME_THRESHOLD_MS = 200;
18
+ const STALL_REDUCTION_DENOM = 2;
19
+ const STALL_DOMINANCE_RATIO = 0.85;
20
+
21
+ export interface TpsTiming {
22
+ messageStartMs: number;
23
+ lastUpdateMs: number;
24
+ firstTokenMs: number | null;
25
+ updateCount: number;
26
+ firstStreamUpdateMs: number | null;
27
+ lastStreamUpdateMs: number;
28
+ stallMs: number;
29
+ generationMs: number;
30
+ }
31
+
32
+ export interface ReliableTps {
33
+ tps: number;
34
+ effectiveMs: number;
35
+ isPrimary: boolean;
36
+ }
37
+
38
+ /** Port of pi-tps' primary/fallback reliability and volume gates. */
39
+ export function calculateReliableTps(outputTokens: number, timing: TpsTiming): ReliableTps | null {
40
+ if (outputTokens <= 0 || timing.firstTokenMs === null) return null;
41
+
42
+ const streamMs = timing.updateCount > 0 && timing.firstStreamUpdateMs !== null
43
+ ? timing.lastStreamUpdateMs - timing.firstStreamUpdateMs
44
+ : null;
45
+ const avgInterChunkGap = streamMs !== null && timing.updateCount > 1
46
+ ? streamMs / (timing.updateCount - 1)
47
+ : 0;
48
+
49
+ let tps: number | null = null;
50
+ let effectiveMs = 0;
51
+ let isPrimary = false;
52
+ if (
53
+ streamMs !== null &&
54
+ streamMs >= MIN_STREAM_MS &&
55
+ timing.updateCount >= MIN_STREAM_UPDATES &&
56
+ avgInterChunkGap >= MIN_INTER_CHUNK_MS &&
57
+ timing.stallMs < streamMs &&
58
+ streamMs - timing.stallMs >= MIN_GENERATION_MS &&
59
+ timing.stallMs < streamMs - timing.stallMs
60
+ ) {
61
+ effectiveMs = streamMs - timing.stallMs;
62
+ tps = Math.round((outputTokens / (effectiveMs / 1000)) * 10) / 10;
63
+ isPrimary = true;
64
+ } else if (timing.updateCount >= 2 && timing.generationMs >= MIN_GENERATION_MS) {
65
+ const stallsDominate =
66
+ timing.generationMs - timing.stallMs < ACTIVE_TIME_THRESHOLD_MS ||
67
+ timing.stallMs > timing.generationMs * STALL_DOMINANCE_RATIO;
68
+ effectiveMs = stallsDominate
69
+ ? Math.max(timing.generationMs - timing.stallMs / STALL_REDUCTION_DENOM, MIN_GENERATION_MS)
70
+ : Math.max(timing.generationMs - timing.stallMs, MIN_GENERATION_MS);
71
+ tps = Math.round((outputTokens / (effectiveMs / 1000)) * 10) / 10;
72
+ }
73
+
74
+ if (tps === null || tps > MAX_PLAUSIBLE_TPS) return null;
75
+ return { tps, effectiveMs, isPrimary };
76
+ }
77
+
17
78
  export function setupTpsTracker(pi: ExtensionAPI): void {
18
- /** Timestamp when the current assistant message event started. Used as a fallback. */
19
79
  let messageStart: number | null = null;
20
- /** Timestamp of the first streamed output delta for the current assistant message. */
21
80
  let streamStart: number | null = null;
22
- /** Estimated streamed output tokens for live display before providers report final usage. */
23
81
  let estimatedStreamedTokens = 0;
24
- /** Cumulative official output tokens across all assistant messages in this agent run. */
25
- let totalOutputTokens = 0;
26
- /** Cumulative time (ms) spent actually streaming output deltas (excludes tool execution and first-token latency). */
27
- let totalStreamMs = 0;
28
- /** Subagent tool-call start times, used to include pi-subagents child output in aggregate TPS. */
82
+ let mainTiming: TpsTiming | null = null;
83
+ const mainMeasurements: Array<{
84
+ outputTokens: number;
85
+ timing: TpsTiming;
86
+ modelKey: string | null;
87
+ isToolCall: boolean;
88
+ }> = [];
89
+ let pendingToolMeasurement: (typeof mainMeasurements)[number] | null = null;
90
+ const tpsCaps = new Map<string, number>();
91
+
29
92
  const subagentStarts = new Map<string, number>();
30
- /** Latest cumulative foreground subagent tokens seen during tool_execution_update. */
31
93
  const subagentLiveTokens = new Map<string, number>();
32
- /** Cumulative subagent output tokens in this agent run. */
33
94
  let totalSubagentTokens = 0;
34
- /** Cumulative subagent wall time (ms) in this agent run. */
35
95
  let totalSubagentMs = 0;
36
- /** Completed subagent call summaries in this agent run. */
37
96
  const subagentSummaries: Array<{ tokens: number; ms: number; tps: number }> = [];
38
97
 
39
98
  function asRecord(value: unknown): Record<string, unknown> | null {
@@ -61,25 +120,8 @@ export function setupTpsTracker(pi: ExtensionAPI): void {
61
120
  return numberValue(usage?.output ?? usage?.outputTokens ?? usage?.completionTokens ?? usage?.completion_tokens);
62
121
  }
63
122
 
64
- function generatedTokensFromMessage(message: { usage?: unknown; content?: unknown }): number {
65
- const usageTokens = generatedTokensFromUsage(asRecord(message.usage));
66
- if (usageTokens > 0) return usageTokens;
67
-
68
- const content = Array.isArray(message.content) ? message.content : [];
69
- const chars = content.reduce((sum, block) => {
70
- const item = asRecord(block);
71
- if (!item) return sum;
72
- if (item.type === "text") return sum + String(item.text ?? "").length;
73
- if (item.type === "thinking") return sum + String(item.thinking ?? "").length;
74
- if (item.type === "toolCall") return sum + String(item.name ?? "").length + JSON.stringify(item.arguments ?? {}).length;
75
- return sum;
76
- }, 0);
77
- return Math.ceil(chars / 4);
78
- }
79
-
80
123
  function outputTokensFromResult(value: unknown): number {
81
- const results = getResults(value);
82
- return results.reduce((sum, result) => sum + generatedTokensFromUsage(asRecord(result.usage)), 0);
124
+ return getResults(value).reduce((sum, result) => sum + generatedTokensFromUsage(asRecord(result.usage)), 0);
83
125
  }
84
126
 
85
127
  function liveTokensFromResult(value: unknown): number {
@@ -117,8 +159,9 @@ export function setupTpsTracker(pi: ExtensionAPI): void {
117
159
  }
118
160
 
119
161
  pi.on("agent_start", async (_event, ctx) => {
120
- totalOutputTokens = 0;
121
- totalStreamMs = 0;
162
+ mainMeasurements.length = 0;
163
+ pendingToolMeasurement = null;
164
+ mainTiming = null;
122
165
  totalSubagentTokens = 0;
123
166
  totalSubagentMs = 0;
124
167
  subagentSummaries.length = 0;
@@ -127,77 +170,99 @@ export function setupTpsTracker(pi: ExtensionAPI): void {
127
170
  estimatedStreamedTokens = 0;
128
171
  subagentStarts.clear();
129
172
  subagentLiveTokens.clear();
130
- const theme = ctx.ui.theme;
131
- ctx.ui.setStatus("tps", theme.fg("success", "generating..."));
173
+ ctx.ui.setStatus("tps", ctx.ui.theme.fg("success", "generating..."));
132
174
  });
133
175
 
134
176
  pi.on("message_start", async (event) => {
135
177
  if (event.message.role !== "assistant") return;
136
- messageStart = Date.now();
178
+ const now = performance.now();
179
+ messageStart = now;
137
180
  streamStart = null;
138
181
  estimatedStreamedTokens = 0;
182
+ mainTiming = {
183
+ messageStartMs: now,
184
+ lastUpdateMs: now,
185
+ firstTokenMs: null,
186
+ updateCount: 0,
187
+ firstStreamUpdateMs: null,
188
+ lastStreamUpdateMs: 0,
189
+ stallMs: 0,
190
+ generationMs: 0,
191
+ };
192
+ pendingToolMeasurement = null;
139
193
  });
140
194
 
141
195
  pi.on("message_update", async (event, ctx) => {
142
196
  if (event.message.role !== "assistant") return;
143
197
 
198
+ const now = performance.now();
199
+ if (mainTiming) {
200
+ if (mainTiming.firstTokenMs === null) {
201
+ mainTiming.firstTokenMs = now;
202
+ mainTiming.lastUpdateMs = now;
203
+ } else {
204
+ mainTiming.updateCount++;
205
+ mainTiming.firstStreamUpdateMs ??= now;
206
+ mainTiming.lastStreamUpdateMs = now;
207
+ const gap = now - mainTiming.lastUpdateMs;
208
+ if (gap >= STALL_THRESHOLD_MS) mainTiming.stallMs += gap;
209
+ mainTiming.lastUpdateMs = now;
210
+ }
211
+ }
212
+
144
213
  const streamEvent = event.assistantMessageEvent;
145
214
  const isOutputDelta =
146
215
  streamEvent.type === "text_delta" ||
147
216
  streamEvent.type === "thinking_delta" ||
148
217
  streamEvent.type === "toolcall_delta";
149
-
150
218
  if (!isOutputDelta) return;
151
219
 
152
- const now = Date.now();
153
220
  streamStart ??= now;
154
221
  estimatedStreamedTokens += Math.max(0, streamEvent.delta.length / 4);
155
-
156
222
  const elapsed = (now - streamStart) / 1000;
157
223
  const officialTokens = generatedTokensFromUsage(asRecord(event.message.usage));
158
224
  const currentTokens = officialTokens > 0 ? officialTokens : estimatedStreamedTokens;
159
-
160
225
  if (elapsed > 0 && currentTokens > 0) {
161
- const tps = Math.round(currentTokens / elapsed);
162
- const tokenLabel = officialTokens > 0
163
- ? `${officialTokens} tok`
164
- : `~${Math.round(estimatedStreamedTokens)} tok`;
165
- const theme = ctx.ui.theme;
166
- ctx.ui.setStatus(
167
- "tps",
168
- `${theme.fg("accent", `${tps} tok/s`)}`,
169
- );
226
+ ctx.ui.setStatus("tps", ctx.ui.theme.fg("accent", `${Math.round(currentTokens / elapsed)} tok/s`));
170
227
  }
171
228
  });
172
229
 
173
230
  pi.on("message_end", async (event) => {
174
231
  if (event.message.role !== "assistant") return;
175
232
 
176
- const messageTokens = generatedTokensFromMessage(event.message);
177
- const timingStart = streamStart ?? messageStart;
178
- if (!timingStart || messageTokens <= 0) {
179
- messageStart = null;
180
- streamStart = null;
181
- estimatedStreamedTokens = 0;
182
- return;
233
+ const now = performance.now();
234
+ if (mainTiming) {
235
+ mainTiming.generationMs = now - mainTiming.messageStartMs;
236
+ const message = asRecord(event.message);
237
+ const provider = typeof message?.provider === "string" ? message.provider : null;
238
+ const model = typeof message?.model === "string" ? message.model : null;
239
+ const outputTokens = generatedTokensFromUsage(asRecord(message?.usage));
240
+ if (outputTokens > 0) {
241
+ const measurement = {
242
+ outputTokens,
243
+ timing: mainTiming,
244
+ modelKey: provider && model ? `${provider}:${model}` : null,
245
+ isToolCall: false,
246
+ };
247
+ mainMeasurements.push(measurement);
248
+ pendingToolMeasurement = measurement;
249
+ }
183
250
  }
184
251
 
185
- totalOutputTokens += messageTokens;
186
- totalStreamMs += Math.max(0, Date.now() - timingStart);
187
-
252
+ mainTiming = null;
188
253
  messageStart = null;
189
254
  streamStart = null;
190
255
  estimatedStreamedTokens = 0;
191
256
  });
192
257
 
193
258
  pi.on("tool_execution_start", async (event) => {
259
+ if (pendingToolMeasurement) pendingToolMeasurement.isToolCall = true;
194
260
  if (event.toolName !== "subagent") return;
195
261
  subagentStarts.set(event.toolCallId, Date.now());
196
262
  });
197
263
 
198
264
  pi.on("tool_execution_update", async (event, ctx) => {
199
265
  if (event.toolName !== "subagent") return;
200
-
201
266
  const start = subagentStarts.get(event.toolCallId);
202
267
  if (!start) return;
203
268
 
@@ -209,17 +274,13 @@ export function setupTpsTracker(pi: ExtensionAPI): void {
209
274
 
210
275
  const tps = Math.round(tokens / elapsed);
211
276
  const mode = subagentModeLabel(event.partialResult);
212
- const agentSummary = items
213
- .filter((item) => item.tokens > 0 || item.status === "running")
214
- .map((item) => `${item.name} ${Math.round(item.tokens / elapsed)} t/s`)
215
- .join(" ");
216
- const theme = ctx.ui.theme;
217
- ctx.ui.setStatus("tps", `${theme.fg("accent", `main+${mode} ${tps} t/s`)} ${theme.fg("success", `(${agentSummary || `${tps} t/s`})`)}`);
277
+ const agentSummary = items.filter((item) => item.tokens > 0 || item.status === "running")
278
+ .map((item) => `${item.name} ${Math.round(item.tokens / elapsed)} t/s`).join(" ");
279
+ ctx.ui.setStatus("tps", `${ctx.ui.theme.fg("accent", `main+${mode} ${tps} t/s`)} ${ctx.ui.theme.fg("success", `(${agentSummary || `${tps} t/s`})`)}`);
218
280
  });
219
281
 
220
282
  pi.on("tool_execution_end", async (event, ctx) => {
221
283
  if (event.toolName !== "subagent") return;
222
-
223
284
  const start = subagentStarts.get(event.toolCallId);
224
285
  subagentStarts.delete(event.toolCallId);
225
286
  const liveTokens = subagentLiveTokens.get(event.toolCallId) ?? liveTokensFromResult(event.result);
@@ -228,59 +289,57 @@ export function setupTpsTracker(pi: ExtensionAPI): void {
228
289
 
229
290
  const outputTokens = outputTokensFromResult(event.result);
230
291
  const displayTokens = outputTokens || liveTokens;
231
- const mode = subagentModeLabel(event.result);
232
292
  const elapsedMs = Math.max(0, Date.now() - start);
233
293
  if (displayTokens <= 0 || elapsedMs <= 0) return;
234
294
  const elapsedSeconds = elapsedMs / 1000;
235
295
  const items = subagentProgressItems(event.result);
236
- const agentSummary = items
237
- .filter((item) => item.tokens > 0)
238
- .map((item) => `${item.name} ${Math.round(item.tokens / elapsedSeconds)} t/s`)
239
- .join(" ");
296
+ const agentSummary = items.filter((item) => item.tokens > 0)
297
+ .map((item) => `${item.name} ${Math.round(item.tokens / elapsedSeconds)} t/s`).join(" ");
298
+ const tps = Math.round(displayTokens / elapsedSeconds);
299
+ const mode = subagentModeLabel(event.result);
240
300
 
241
- totalOutputTokens += displayTokens;
242
- totalStreamMs += elapsedMs;
243
301
  totalSubagentTokens += displayTokens;
244
302
  totalSubagentMs += elapsedMs;
245
-
246
- const tps = Math.round(displayTokens / elapsedSeconds);
247
303
  subagentSummaries.push({ tokens: displayTokens, ms: elapsedMs, tps });
248
- const theme = ctx.ui.theme;
249
- ctx.ui.notify(`${theme.fg("success", "✓")} ${theme.fg("accent", `${tps} t/s`)}`, "info");
250
- ctx.ui.setStatus(
251
- "tps",
252
- `${theme.fg("accent", `main+${mode} ${tps} t/s`)} ${theme.fg("success", `(${agentSummary || `${tps} t/s`})`)}`,
253
- );
304
+ ctx.ui.notify(`${ctx.ui.theme.fg("success", "✓")} ${ctx.ui.theme.fg("accent", `${tps} t/s`)}`, "info");
305
+ ctx.ui.setStatus("tps", `${ctx.ui.theme.fg("accent", `main+${mode} ${tps} t/s`)} ${ctx.ui.theme.fg("success", `(${agentSummary || `${tps} t/s`})`)}`);
254
306
  });
255
307
 
256
308
  pi.on("agent_end", async (_event, ctx) => {
257
- const elapsed = totalStreamMs / 1000;
258
- const tps = totalOutputTokens > 0 && elapsed > 0 ? Math.round(totalOutputTokens / elapsed) : 0;
309
+ let measuredMainTokens = 0;
310
+ let measuredMainMs = 0;
311
+ for (const measurement of mainMeasurements) {
312
+ if (!measurement.modelKey) continue;
313
+ const reliable = calculateReliableTps(measurement.outputTokens, measurement.timing);
314
+ if (!reliable) continue;
315
+
316
+ let tps = reliable.tps;
317
+ if (measurement.isToolCall) {
318
+ const cap = tpsCaps.get(measurement.modelKey);
319
+ if (cap === undefined) continue;
320
+ tps = Math.min(tps, cap);
321
+ } else if (reliable.isPrimary) {
322
+ const cap = tpsCaps.get(measurement.modelKey);
323
+ if (cap === undefined || tps > cap) tpsCaps.set(measurement.modelKey, tps);
324
+ }
325
+ measuredMainTokens += tps * (reliable.effectiveMs / 1000);
326
+ measuredMainMs += reliable.effectiveMs;
327
+ }
259
328
 
260
- const theme = ctx.ui.theme;
261
- const icon = theme.fg("success", "✓");
262
- const tpsLabel = tps > 0
263
- ? theme.fg("accent", `${tps} t/s`)
264
- : theme.fg("success", "N/A");
265
- const mainTokens = totalOutputTokens - totalSubagentTokens;
266
- const mainMs = totalStreamMs - totalSubagentMs;
267
- const mainTps = mainTokens > 0 && mainMs > 0 ? Math.round(mainTokens / (mainMs / 1000)) : 0;
329
+ const totalTokens = measuredMainTokens + totalSubagentTokens;
330
+ const totalMs = measuredMainMs + totalSubagentMs;
331
+ const tps = totalTokens > 0 && totalMs > 0 ? Math.round(totalTokens / (totalMs / 1000)) : 0;
332
+ const mainTps = measuredMainTokens > 0 && measuredMainMs > 0
333
+ ? Math.round(measuredMainTokens / (measuredMainMs / 1000)) : 0;
268
334
  const subagentTps = totalSubagentTokens > 0 && totalSubagentMs > 0
269
- ? Math.round(totalSubagentTokens / (totalSubagentMs / 1000))
270
- : 0;
335
+ ? Math.round(totalSubagentTokens / (totalSubagentMs / 1000)) : 0;
336
+ const theme = ctx.ui.theme;
271
337
  const mainDetail = mainTps > 0 ? theme.fg("success", ` | main ${mainTps} t/s`) : "";
272
338
  const subagentDetail = subagentTps > 0
273
- ? theme.fg("success", ` | subagents ${subagentTps} t/s (${subagentSummaries.length} call${subagentSummaries.length === 1 ? "" : "s"})`)
274
- : "";
275
-
276
- ctx.ui.notify(`${icon} total ${tpsLabel}${mainDetail}${subagentDetail}`, "info");
339
+ ? theme.fg("success", ` | subagents ${subagentTps} t/s (${subagentSummaries.length} call${subagentSummaries.length === 1 ? "" : "s"})`) : "";
340
+ ctx.ui.notify(`${theme.fg("success", "✓")} total ${tps > 0 ? theme.fg("accent", `${tps} t/s`) : theme.fg("success", "N/A")}${mainDetail}${subagentDetail}`, "info");
277
341
  const finalMainStatus = mainTps > 0 ? `main ${mainTps} t/s` : "main N/A";
278
- const finalSubagentStatus = totalSubagentTokens > 0 && totalSubagentMs > 0
279
- ? `subagents ${Math.round(totalSubagentTokens / (totalSubagentMs / 1000))} t/s`
280
- : "";
281
- ctx.ui.setStatus(
282
- "tps",
283
- `${theme.fg("accent", `done ${tps} t/s`)} ${theme.fg("success", `(${[finalMainStatus, finalSubagentStatus].filter(Boolean).join(" ")})`)}`,
284
- );
342
+ const finalSubagentStatus = subagentTps > 0 ? `subagents ${subagentTps} t/s` : "";
343
+ ctx.ui.setStatus("tps", `${theme.fg("accent", `done ${tps} t/s`)} ${theme.fg("success", `(${[finalMainStatus, finalSubagentStatus].filter(Boolean).join(" ")})`)}`);
285
344
  });
286
345
  }
@@ -653,10 +653,6 @@ export async function openSubagentFleet(ctx: ExtensionContext, state: SubagentSt
653
653
  try {
654
654
  await ctx.ui.custom<undefined>(
655
655
  (tui, theme, _keybindings, done) => new SubagentFleetComponent(tui, theme, state, done, options),
656
- {
657
- overlay: true,
658
- overlayOptions: { anchor: "center", width: "95%", minWidth: 60, maxHeight: "85%", margin: 1 },
659
- },
660
656
  );
661
657
  } finally {
662
658
  state.fleetInspectorOpen = wasOpen;
@@ -2042,7 +2042,7 @@ describe("subagents-doctor slash command", { skip: !available ? "slash-commands.
2042
2042
  );
2043
2043
  rendered = component.render(100).join("\n");
2044
2044
  component.dispose();
2045
- assert.deepEqual(options, { overlay: true, overlayOptions: { anchor: "center", width: "95%", minWidth: 60, maxHeight: "85%", margin: 1 } });
2045
+ assert.equal(options, undefined);
2046
2046
  return undefined;
2047
2047
  };
2048
2048
  const pi = {
@@ -539,7 +539,7 @@ describe("native subagent fleet", () => {
539
539
  }
540
540
  });
541
541
 
542
- it("refreshes the roster while the overlay remains open", async () => {
542
+ it("refreshes the roster while the inspector remains open", async () => {
543
543
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-fleet-refresh-"));
544
544
  try {
545
545
  const state = stateForTest();
@@ -1,39 +1,103 @@
1
- import { getOptionsFormatError, normalizeOptions } from "./helpers.ts";
2
- import { resolveShortcuts } from "./shortcuts.ts";
3
- import type { BatchedQuestion, RawBatchQuestion } from "./types.ts";
1
+ import type {
2
+ DraftQuestionState,
3
+ PreparedQuestion,
4
+ QuestionAnswer,
5
+ QuestionOption,
6
+ QuestionResponse,
7
+ RawQuestion,
8
+ } from "./types.ts";
4
9
 
5
- export function prepareBatchQuestions(value: unknown): { questions: BatchedQuestion[] } | { error: string } {
10
+ function clean(value: unknown): string | undefined {
11
+ if (typeof value !== "string") return undefined;
12
+ const trimmed = value.trim();
13
+ return trimmed || undefined;
14
+ }
15
+
16
+ function prepareOptions(value: unknown, questionNumber: number): { options: QuestionOption[] } | { error: string } {
17
+ if (!Array.isArray(value) || value.length === 0) {
18
+ return { error: `Question ${questionNumber}: choice questions require at least one option.` };
19
+ }
20
+ const values = new Set<string>();
21
+ const options: QuestionOption[] = [];
22
+ for (let index = 0; index < value.length; index++) {
23
+ const raw = value[index] as { value?: unknown; label?: unknown; description?: unknown };
24
+ const optionValue = clean(raw?.value);
25
+ const label = clean(raw?.label);
26
+ if (!optionValue || !label) {
27
+ return { error: `Question ${questionNumber}, option ${index + 1}: value and label must be non-empty strings.` };
28
+ }
29
+ if (values.has(optionValue)) {
30
+ return { error: `Question ${questionNumber}: option values must be unique; duplicate value: ${optionValue}` };
31
+ }
32
+ values.add(optionValue);
33
+ options.push({ value: optionValue, label, description: clean(raw.description) });
34
+ }
35
+ return { options };
36
+ }
37
+
38
+ export function prepareQuestions(value: unknown): { questions: PreparedQuestion[] } | { error: string } {
6
39
  if (!Array.isArray(value) || value.length === 0) return { error: "questions must contain at least one question." };
7
40
 
8
41
  const ids = new Set<string>();
9
- const questions: BatchedQuestion[] = [];
42
+ const questions: PreparedQuestion[] = [];
10
43
  for (let index = 0; index < value.length; index++) {
11
- const raw = value[index] as RawBatchQuestion;
12
- const question = raw?.question?.trim();
13
- if (!question) return { error: `Question ${index + 1} must include a non-empty question.` };
14
-
15
- const optionsError = getOptionsFormatError(raw.options);
16
- if (optionsError) return { error: `Question ${index + 1}: ${optionsError}` };
17
- const options = normalizeOptions(raw.options);
18
- const allowFreeform = raw.allowFreeform ?? options.length === 0;
19
- if (options.length === 0 && !allowFreeform) {
20
- return { error: `Question ${index + 1}: options are empty and allowFreeform is false.` };
21
- }
44
+ const raw = value[index] as RawQuestion;
45
+ const number = index + 1;
46
+ const question = clean(raw?.question);
47
+ if (!question) return { error: `Question ${number} must include a non-empty question.` };
22
48
 
23
- const id = raw.id?.trim() || `q${index + 1}`;
49
+ const id = clean(raw.id) ?? `q${number}`;
24
50
  if (ids.has(id)) return { error: `Question ids must be unique; duplicate id: ${id}` };
25
51
  ids.add(id);
26
- questions.push({
52
+
53
+ const common = {
27
54
  id,
28
- label: raw.label?.trim() || `Q${index + 1}`,
55
+ label: clean(raw.label) ?? `Q${number}`,
29
56
  question,
30
- context: raw.context?.trim() || raw.title?.trim() || undefined,
31
- options,
32
- allowMultiple: raw.allowMultiple ?? false,
33
- allowFreeform,
34
- allowComment: raw.allowComment ?? false,
35
- shortcuts: resolveShortcuts(raw.commentToggleKey, process.env.PI_QUESTION_COMMENT_TOGGLE_KEY),
57
+ context: clean(raw.context),
58
+ };
59
+
60
+ if (raw.type === "text") {
61
+ questions.push({ ...common, type: "text", options: [], allowOther: false });
62
+ continue;
63
+ }
64
+ if (raw.type !== "select" && raw.type !== "multiselect") {
65
+ return { error: `Question ${number}: type must be select, multiselect, or text.` };
66
+ }
67
+ const preparedOptions = prepareOptions(raw.options, number);
68
+ if ("error" in preparedOptions) return preparedOptions;
69
+ questions.push({
70
+ ...common,
71
+ type: raw.type,
72
+ options: preparedOptions.options,
73
+ allowOther: raw.allowOther ?? false,
36
74
  });
37
75
  }
38
76
  return { questions };
39
77
  }
78
+
79
+ export function saveDraftQuestion(
80
+ states: Map<string, DraftQuestionState>,
81
+ id: string,
82
+ response: QuestionResponse | null,
83
+ markBlankSkipped: boolean,
84
+ ): void {
85
+ if (response) {
86
+ states.set(id, { status: "answered", response });
87
+ return;
88
+ }
89
+ if (markBlankSkipped) states.set(id, { status: "skipped" });
90
+ else states.delete(id);
91
+ }
92
+
93
+ export function buildQuestionAnswers(
94
+ questions: PreparedQuestion[],
95
+ states: ReadonlyMap<string, DraftQuestionState>,
96
+ ): QuestionAnswer[] {
97
+ return questions.map((question) => {
98
+ const state = states.get(question.id);
99
+ if (!state) return { id: question.id, status: "unanswered" };
100
+ if (state.status !== "answered" || !state.response) return { id: question.id, status: "skipped" };
101
+ return { id: question.id, status: "answered", response: state.response };
102
+ });
103
+ }
@@ -3,7 +3,7 @@
3
3
  import { Key } from "@earendil-works/pi-tui";
4
4
  import type { ResolvedShortcut } from "./types.ts";
5
5
 
6
- export const QUESTION_VERSION = "1";
6
+ export const QUESTION_VERSION = "2";
7
7
 
8
8
  export const SPLIT_PANE_MIN_WIDTH = 84;
9
9
  export const SPLIT_PANE_LEFT_MIN_WIDTH = 32;