lsd-pi 1.3.7 → 1.3.9

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 (65) hide show
  1. package/README.md +82 -0
  2. package/dist/resources/extensions/mcp-client/index.js +72 -4
  3. package/dist/resources/extensions/slash-commands/plan.js +5 -5
  4. package/dist/resources/extensions/usage/index.js +34 -2
  5. package/dist/resources/extensions/voice/index.js +1 -0
  6. package/dist/resources/extensions/voice/push-to-talk.js +2 -0
  7. package/package.json +1 -1
  8. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.d.ts +2 -0
  9. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.d.ts.map +1 -0
  10. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.js +72 -0
  11. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.js.map +1 -0
  12. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +4 -0
  13. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  14. package/packages/pi-coding-agent/dist/core/agent-session.js +29 -2
  15. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  16. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  17. package/packages/pi-coding-agent/dist/core/extensions/runner.js +1 -0
  18. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  19. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -0
  20. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  21. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  22. package/packages/pi-coding-agent/dist/core/tool-priority.js +1 -1
  23. package/packages/pi-coding-agent/dist/core/tool-priority.js.map +1 -1
  24. package/packages/pi-coding-agent/dist/main.d.ts.map +1 -1
  25. package/packages/pi-coding-agent/dist/main.js +1 -0
  26. package/packages/pi-coding-agent/dist/main.js.map +1 -1
  27. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +2 -0
  28. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  29. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +17 -1
  30. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  31. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js +1 -1
  32. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js.map +1 -1
  33. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  34. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +37 -14
  35. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  36. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.d.ts.map +1 -1
  37. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js +1 -0
  38. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js.map +1 -1
  39. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  40. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +3 -2
  41. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  42. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  43. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +3 -0
  44. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  45. package/packages/pi-coding-agent/package.json +1 -1
  46. package/packages/pi-coding-agent/src/core/agent-session.context-usage.test.ts +87 -0
  47. package/packages/pi-coding-agent/src/core/agent-session.ts +40 -2
  48. package/packages/pi-coding-agent/src/core/extensions/runner.ts +1 -0
  49. package/packages/pi-coding-agent/src/core/extensions/types.ts +3 -0
  50. package/packages/pi-coding-agent/src/core/tool-priority.ts +1 -1
  51. package/packages/pi-coding-agent/src/main.ts +1 -0
  52. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +18 -1
  53. package/packages/pi-coding-agent/src/modes/interactive/components/tool-summary-line.ts +1 -1
  54. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +37 -15
  55. package/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +1 -0
  56. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +3 -2
  57. package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +4 -0
  58. package/pkg/package.json +1 -1
  59. package/src/resources/extensions/mcp-client/index.ts +83 -4
  60. package/src/resources/extensions/mcp-client/tests/server-name-spaces.test.ts +16 -0
  61. package/src/resources/extensions/slash-commands/plan.ts +6 -6
  62. package/src/resources/extensions/usage/index.ts +40 -2
  63. package/src/resources/extensions/voice/index.ts +1 -0
  64. package/src/resources/extensions/voice/push-to-talk.ts +3 -0
  65. package/src/resources/extensions/voice/tests/push-to-talk.test.ts +6 -0
@@ -56,19 +56,9 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
56
56
  "bash", "bg_shell",
57
57
  "web_search", "search-the-web", "google_search", "search_and_read",
58
58
  "fetch_page", "resolve_library", "get_library_docs",
59
+ "subagent", "await_subagent",
59
60
  ]);
60
61
 
61
- const shouldStartToolHidden = (toolName: string): boolean => {
62
- const collapseToolCalls = host.settingsManager.getCollapseToolCalls?.() ?? false;
63
- if (!collapseToolCalls || host.collapsedToolCallsExpanded) {
64
- return false;
65
- }
66
- if (ALWAYS_DIRECT_TOOLS.has(toolName)) {
67
- return false;
68
- }
69
- return shouldCollapse(toolName, false);
70
- };
71
-
72
62
  const hasVisibleRender = (child: { render?: (width: number) => string[] } | undefined): boolean => {
73
63
  if (!child?.render) return true;
74
64
  try {
@@ -190,7 +180,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
190
180
  (text) => theme.fg("accent", text),
191
181
  host.defaultWorkingMessage,
192
182
  );
193
- host.loadingAnimation.setCycleMessages(host.workingMessages, 3000);
183
+ host.loadingAnimation.setCycleMessages(host.workingMessages, 10_000);
194
184
  host.statusContainer.addChild(host.loadingAnimation);
195
185
  host.startLoadingTips();
196
186
  // Show steer/queue + expand hint in editor bottom border while agent is running
@@ -253,9 +243,19 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
253
243
  host.ui,
254
244
  );
255
245
  component.setExpanded(host.toolOutputExpanded);
256
- component.setHidden(shouldStartToolHidden(content.name));
246
+ const collapseToolCalls = host.settingsManager.getCollapseToolCalls?.() ?? false;
247
+ const shouldHide = collapseToolCalls && !host.collapsedToolCallsExpanded && shouldCollapse(content.name, false) && !ALWAYS_DIRECT_TOOLS.has(content.name);
257
248
  host.chatContainer.addChild(component);
249
+ if (shouldHide) {
250
+ // Check for adjacent summary AFTER adding to container so indexOf works
251
+ const adjacentSummary = findAdjacentCollapsedToolSummary(content.name, component);
252
+ if (adjacentSummary) {
253
+ component.setIndented(true);
254
+ }
255
+ }
256
+ component.setHidden(shouldHide);
258
257
  host.pendingTools.set(content.id, component);
258
+ host.updateEditorExpandHint();
259
259
  } else {
260
260
  host.pendingTools.get(content.id)?.updateArgs(content.arguments);
261
261
  }
@@ -273,9 +273,19 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
273
273
  host.ui,
274
274
  );
275
275
  component.setExpanded(host.toolOutputExpanded);
276
- component.setHidden(shouldStartToolHidden(content.name));
276
+ const collapseToolCalls = host.settingsManager.getCollapseToolCalls?.() ?? false;
277
+ const shouldHide = collapseToolCalls && !host.collapsedToolCallsExpanded && shouldCollapse(content.name, false) && !ALWAYS_DIRECT_TOOLS.has(content.name);
277
278
  host.chatContainer.addChild(component);
279
+ if (shouldHide) {
280
+ // Check for adjacent summary AFTER adding to container so indexOf works
281
+ const adjacentSummary = findAdjacentCollapsedToolSummary(content.name, component);
282
+ if (adjacentSummary) {
283
+ component.setIndented(true);
284
+ }
285
+ }
286
+ component.setHidden(shouldHide);
278
287
  host.pendingTools.set(content.id, component);
288
+ host.updateEditorExpandHint();
279
289
  }
280
290
  } else if (content.type === "webSearchResult") {
281
291
  const component = host.pendingTools.get(content.toolUseId);
@@ -356,9 +366,19 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
356
366
  host.ui,
357
367
  );
358
368
  component.setExpanded(host.toolOutputExpanded);
359
- component.setHidden(shouldStartToolHidden(event.toolName));
369
+ const collapseToolCalls = host.settingsManager.getCollapseToolCalls?.() ?? false;
370
+ const shouldHide = collapseToolCalls && !host.collapsedToolCallsExpanded && shouldCollapse(event.toolName, false) && !ALWAYS_DIRECT_TOOLS.has(event.toolName);
360
371
  host.chatContainer.addChild(component);
372
+ if (shouldHide) {
373
+ // Check for adjacent summary AFTER adding to container so indexOf works
374
+ const adjacentSummary = findAdjacentCollapsedToolSummary(event.toolName, component);
375
+ if (adjacentSummary) {
376
+ component.setIndented(true);
377
+ }
378
+ }
379
+ component.setHidden(shouldHide);
361
380
  host.pendingTools.set(event.toolCallId, component);
381
+ host.updateEditorExpandHint();
362
382
  host.ui.requestRender();
363
383
  }
364
384
  break;
@@ -410,6 +430,8 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
410
430
  if (collapseToolCalls && shouldCollapse(event.toolName, event.isError) && !ALWAYS_DIRECT_TOOLS.has(event.toolName)) {
411
431
  appendCollapsedToolSummary(event.toolName, component.getElapsed(), component);
412
432
  component.setHidden(!host.collapsedToolCallsExpanded);
433
+ component.setIndented(false);
434
+ host.updateEditorExpandHint();
413
435
  } else {
414
436
  component.setHidden(false);
415
437
  resetCollapsedToolSummary();
@@ -55,6 +55,7 @@ export function createExtensionUIContext(host: any): ExtensionUIContext {
55
55
  },
56
56
  getToolsExpanded: () => host.collapsedToolCallsExpanded || host.toolOutputExpanded,
57
57
  setToolsExpanded: (expanded) => host.setToolsExpanded(expanded),
58
+ isEditorFocused: () => !!host.editor.focused,
58
59
  };
59
60
  }
60
61
 
@@ -1538,7 +1538,7 @@ export class InteractiveMode {
1538
1538
  this.defaultEditor.onExtensionShortcut = undefined;
1539
1539
  this.updateTerminalTitle();
1540
1540
  if (this.loadingAnimation) {
1541
- this.loadingAnimation.setCycleMessages(this.workingMessages);
1541
+ this.loadingAnimation.setCycleMessages(this.workingMessages, 10_000);
1542
1542
  }
1543
1543
  }
1544
1544
 
@@ -1881,7 +1881,7 @@ export class InteractiveMode {
1881
1881
  /** Reset the loading animation back to cycling working messages. */
1882
1882
  private resetLoadingMessage(): void {
1883
1883
  if (this.loadingAnimation) {
1884
- this.loadingAnimation.setCycleMessages(this.workingMessages);
1884
+ this.loadingAnimation.setCycleMessages(this.workingMessages, 10_000);
1885
1885
  }
1886
1886
  }
1887
1887
 
@@ -3110,6 +3110,7 @@ export class InteractiveMode {
3110
3110
  }
3111
3111
  if (child instanceof ToolExecutionComponent) {
3112
3112
  child.setHidden(!showCollapsedToolCalls && child.shouldHideWhenCollapsed(collapseToolCalls));
3113
+ child.setIndented(false);
3113
3114
  }
3114
3115
  if (child instanceof ToolSummaryLine) {
3115
3116
  child.setHidden(!collapseToolCalls || showCollapsedToolCalls);
@@ -422,6 +422,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
422
422
  setToolsExpanded(_expanded: boolean) {
423
423
  // Tool expansion not supported in RPC mode - no TUI
424
424
  },
425
+
426
+ isEditorFocused() {
427
+ return false;
428
+ },
425
429
  });
426
430
 
427
431
  // Set up extensions with RPC-based UI context.
package/pkg/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lsd-pi",
3
- "version": "1.3.7",
3
+ "version": "1.3.9",
4
4
  "piConfig": {
5
5
  "name": "lsd",
6
6
  "configDir": ".lsd"
@@ -62,6 +62,14 @@ interface McpState {
62
62
  const connections = new Map<string, ManagedConnection>();
63
63
  let configCache: McpServerConfig[] | null = null;
64
64
  const toolCache = new Map<string, McpToolSchema[]>();
65
+ let warmupPromise: Promise<McpWarmupResult[]> | null = null;
66
+
67
+ interface McpWarmupResult {
68
+ name: string;
69
+ status: "connected" | "error";
70
+ toolCount?: number;
71
+ error?: string;
72
+ }
65
73
 
66
74
  const MCP_STATE_PATH = join(process.cwd(), ".lsd", "mcp-state.json");
67
75
 
@@ -263,6 +271,49 @@ async function getOrConnect(name: string, signal?: AbortSignal): Promise<Client>
263
271
  return client;
264
272
  }
265
273
 
274
+ async function warmupEnabledServers(): Promise<McpWarmupResult[]> {
275
+ if (warmupPromise) return warmupPromise;
276
+
277
+ warmupPromise = (async () => {
278
+ const enabledServers = readConfigs().filter((server) => server.enabled);
279
+ if (enabledServers.length === 0) return [];
280
+
281
+ const results = await Promise.allSettled(enabledServers.map(async (server) => {
282
+ const client = await getOrConnect(server.name);
283
+ const result = await client.listTools(undefined, { timeout: 30000 });
284
+ const tools: McpToolSchema[] = (result.tools ?? []).map((tool) => ({
285
+ name: tool.name,
286
+ description: tool.description ?? "",
287
+ inputSchema: tool.inputSchema as Record<string, unknown> | undefined,
288
+ }));
289
+ toolCache.set(server.name, tools);
290
+ return {
291
+ name: server.name,
292
+ status: "connected" as const,
293
+ toolCount: tools.length,
294
+ };
295
+ }));
296
+
297
+ return results.map((result, index) => {
298
+ if (result.status === "fulfilled") {
299
+ return result.value;
300
+ }
301
+
302
+ return {
303
+ name: enabledServers[index]?.name ?? `server-${index + 1}`,
304
+ status: "error" as const,
305
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason),
306
+ };
307
+ });
308
+ })();
309
+
310
+ try {
311
+ return await warmupPromise;
312
+ } finally {
313
+ warmupPromise = null;
314
+ }
315
+ }
316
+
266
317
  async function closeAll(): Promise<void> {
267
318
  const closing = Array.from(connections.entries()).map(async ([name, conn]) => {
268
319
  try {
@@ -277,6 +328,7 @@ async function closeAll(): Promise<void> {
277
328
  }
278
329
 
279
330
  async function reloadMcpState(): Promise<void> {
331
+ warmupPromise = null;
280
332
  await closeAll();
281
333
  configCache = null;
282
334
  }
@@ -402,6 +454,14 @@ async function handleMcpCommand(args: string, ctx: ExtensionCommandContext): Pro
402
454
  const action = enabled ? "enabled" : "disabled";
403
455
  const changeText = result.changed ? action : `already ${action}`;
404
456
  ctx.ui.notify(`MCP server ${result.canonicalName} ${changeText}.`, "info");
457
+
458
+ if (enabled) {
459
+ const warmupResults = await warmupEnabledServers();
460
+ const warmupResult = warmupResults.find((entry) => entry.name === result.canonicalName);
461
+ if (warmupResult?.status === "error") {
462
+ ctx.ui.notify(`Failed to connect ${result.canonicalName}: ${warmupResult.error}`, "error");
463
+ }
464
+ }
405
465
  } catch (error) {
406
466
  const message = error instanceof Error ? error.message : String(error);
407
467
  ctx.ui.notify(message, "error");
@@ -412,7 +472,12 @@ async function handleMcpCommand(args: string, ctx: ExtensionCommandContext): Pro
412
472
  if (subcommand === "reload") {
413
473
  await reloadMcpState();
414
474
  const servers = readConfigs();
415
- ctx.ui.notify(`Reloaded MCP config ${servers.length} server(s) available.`, "info");
475
+ const warmupResults = await warmupEnabledServers();
476
+ const failed = warmupResults.filter((entry) => entry.status === "error");
477
+ const summary = failed.length > 0
478
+ ? `Reloaded MCP config — ${servers.length} server(s) available, ${failed.length} failed to connect.`
479
+ : `Reloaded MCP config — ${servers.length} server(s) available.`;
480
+ ctx.ui.notify(summary, failed.length > 0 ? "warning" : "info");
416
481
  return;
417
482
  }
418
483
 
@@ -733,17 +798,31 @@ export default function(pi: ExtensionAPI) {
733
798
 
734
799
  pi.on("session_start", async (_event, ctx) => {
735
800
  const servers = readConfigs();
801
+ const enabledServers = servers.filter((server) => server.enabled);
736
802
  if (servers.length > 0) {
737
- ctx.ui.notify(`MCP client ready — ${servers.filter((server) => server.enabled).length}/${servers.length} server(s) enabled`, "info");
803
+ ctx.ui.notify(`MCP client ready — ${enabledServers.length}/${servers.length} server(s) enabled`, "info");
738
804
  }
805
+ if (enabledServers.length === 0) return;
806
+
807
+ void warmupEnabledServers().then((results) => {
808
+ const failed = results.filter((entry) => entry.status === "error");
809
+ if (failed.length > 0) {
810
+ const failureSummary = failed.map((entry) => `${entry.name}: ${entry.error}`).join("; ");
811
+ ctx.ui.notify(`MCP autoconnect partial failure — ${failureSummary}`, "warning");
812
+ }
813
+ }).catch((error) => {
814
+ const message = error instanceof Error ? error.message : String(error);
815
+ ctx.ui.notify(`MCP autoconnect failed: ${message}`, "warning");
816
+ });
739
817
  });
740
818
 
741
819
  pi.on("session_shutdown", async () => {
820
+ warmupPromise = null;
742
821
  await closeAll();
743
822
  });
744
823
 
745
824
  pi.on("session_switch", async () => {
746
- await closeAll();
747
- configCache = null;
825
+ await reloadMcpState();
826
+ void warmupEnabledServers();
748
827
  });
749
828
  }
@@ -53,3 +53,19 @@ test("#3029: getOrConnect normalizes name for connection cache lookup", () => {
53
53
  "getOrConnect should use config.name (canonical) as the connections cache key",
54
54
  );
55
55
  });
56
+
57
+ test("enabled MCP servers are warmed up on session start", () => {
58
+ assert.match(
59
+ source,
60
+ /pi\.on\("session_start", async \(_event, ctx\) => {[\s\S]*?warmupEnabledServers\(/,
61
+ "session_start should trigger MCP autoconnect warmup for enabled servers",
62
+ );
63
+ });
64
+
65
+ test("warmupEnabledServers preloads tool schemas during autoconnect", () => {
66
+ assert.match(
67
+ source,
68
+ /async function warmupEnabledServers\([\s\S]*?client\.listTools\(undefined, \{ timeout: 30000 \}\)[\s\S]*?toolCache\.set\(/,
69
+ "warmupEnabledServers should list tools and populate tool cache during startup",
70
+ );
71
+ });
@@ -191,8 +191,8 @@ function readAutoSwitchPlanModelSetting(): boolean {
191
191
  const settingsPath = join(getAgentDir(), "settings.json");
192
192
  if (!existsSync(settingsPath)) return false;
193
193
  const raw = readFileSync(settingsPath, "utf-8");
194
- const parsed = JSON.parse(raw) as { autoSwitchPlanModel?: unknown };
195
- return parsed.autoSwitchPlanModel === true;
194
+ const parsed = JSON.parse(raw) as { planModeAutoSwitchModel?: unknown };
195
+ return parsed.planModeAutoSwitchModel === true;
196
196
  } catch {
197
197
  return false;
198
198
  }
@@ -418,10 +418,10 @@ async function approvePlan(
418
418
  permissionMode: RestorablePermissionMode,
419
419
  executeWithSubagent = false,
420
420
  ): Promise<void> {
421
- const reasoningModel = parseQualifiedModelRef(readPlanModeReasoningModel());
422
- if (reasoningModel) {
423
- await setModelIfNeeded(pi, ctx, reasoningModel);
424
- }
421
+ // Do NOT switch to reasoning model during execution.
422
+ // The reasoning model is only for plan-mode investigation, not execution.
423
+ // If a coding model is configured and we're using a subagent, the explicit
424
+ // model="<planModeCodingModel>" in the kickoff message will handle it.
425
425
 
426
426
  state = {
427
427
  ...state,
@@ -75,6 +75,8 @@ type UsageRow = {
75
75
  cacheWrite: number;
76
76
  total: number;
77
77
  cost: number;
78
+ totalDurationMs: number;
79
+ totalOutputForSpeed: number;
78
80
  };
79
81
 
80
82
  type UsageReport = {
@@ -313,6 +315,7 @@ function collectUsage(sessionFiles: string[], startMs: number, endMs: number, sc
313
315
  let projectLabel = basename(file);
314
316
  let headerResolved = false;
315
317
  let currentModel = "";
318
+ let lastUserTimestamp = 0;
316
319
 
317
320
  const raw = readFileSync(file, "utf-8");
318
321
  for (const line of raw.split("\n")) {
@@ -367,6 +370,8 @@ function collectUsage(sessionFiles: string[], startMs: number, endMs: number, sc
367
370
  cacheWrite: 0,
368
371
  total: 0,
369
372
  cost: 0,
373
+ totalDurationMs: 0,
374
+ totalOutputForSpeed: 0,
370
375
  };
371
376
 
372
377
  existing.messages += 1;
@@ -376,10 +381,27 @@ function collectUsage(sessionFiles: string[], startMs: number, endMs: number, sc
376
381
  existing.cacheWrite += cacheWrite;
377
382
  existing.total += total;
378
383
  existing.cost += cost;
384
+
385
+ // Track tok/sec: use preceding user message timestamp
386
+ if (output > 0 && lastUserTimestamp > 0) {
387
+ const durationMs = timestamp - lastUserTimestamp;
388
+ if (durationMs > 0) {
389
+ existing.totalDurationMs += durationMs;
390
+ existing.totalOutputForSpeed += output;
391
+ }
392
+ }
393
+
379
394
  rows.set(key, existing);
380
395
  } else if (message.role === "user") {
381
396
  const timestamp = Number(message.timestamp ?? 0);
382
- if (!timestamp || timestamp < startMs || timestamp >= endMs) continue;
397
+ if (!timestamp) continue;
398
+
399
+ // Always track last user timestamp for tok/sec calculation,
400
+ // even if this user message is outside the time range
401
+ // (the assistant response may still be within range).
402
+ lastUserTimestamp = timestamp;
403
+
404
+ if (timestamp < startMs || timestamp >= endMs) continue;
383
405
 
384
406
  matchedUserPrompts++;
385
407
  const model = currentModel;
@@ -397,6 +419,8 @@ function collectUsage(sessionFiles: string[], startMs: number, endMs: number, sc
397
419
  cacheWrite: 0,
398
420
  total: 0,
399
421
  cost: 0,
422
+ totalDurationMs: 0,
423
+ totalOutputForSpeed: 0,
400
424
  };
401
425
 
402
426
  existing.userPrompts += 1;
@@ -431,9 +455,11 @@ function collectUsage(sessionFiles: string[], startMs: number, endMs: number, sc
431
455
  acc.cacheWrite += row.cacheWrite;
432
456
  acc.total += row.total;
433
457
  acc.cost += row.cost;
458
+ acc.totalDurationMs += row.totalDurationMs;
459
+ acc.totalOutputForSpeed += row.totalOutputForSpeed;
434
460
  return acc;
435
461
  },
436
- { messages: 0, userPrompts: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 },
462
+ { messages: 0, userPrompts: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0, totalDurationMs: 0, totalOutputForSpeed: 0 },
437
463
  );
438
464
 
439
465
  return {
@@ -448,6 +474,11 @@ function collectUsage(sessionFiles: string[], startMs: number, endMs: number, sc
448
474
  };
449
475
  }
450
476
 
477
+ function formatSpeed(totalDurationMs: number, totalOutputForSpeed: number): string {
478
+ if (totalDurationMs <= 0) return "—";
479
+ return Math.round(totalOutputForSpeed / (totalDurationMs / 1000)).toLocaleString();
480
+ }
481
+
451
482
  function renderTable(report: UsageReport): string {
452
483
  const firstColumnHeader = report.groupBy === "project"
453
484
  ? "project"
@@ -465,8 +496,11 @@ function renderTable(report: UsageReport): string {
465
496
  write: formatInt(row.cacheWrite),
466
497
  total: formatInt(row.total),
467
498
  cost: formatCost(row.cost),
499
+ speed: formatSpeed(row.totalDurationMs, row.totalOutputForSpeed),
468
500
  }));
469
501
 
502
+ const totalsSpeed = formatSpeed(report.totals.totalDurationMs, report.totals.totalOutputForSpeed);
503
+
470
504
  const widths = {
471
505
  label: Math.max(firstColumnHeader.length, ...displayRows.map((row) => row.label.length), 5),
472
506
  userPrompts: Math.max(11, ...displayRows.map((row) => row.userPrompts.length), String(report.totals.userPrompts).length),
@@ -477,6 +511,7 @@ function renderTable(report: UsageReport): string {
477
511
  write: Math.max(5, ...displayRows.map((row) => row.write.length), formatInt(report.totals.cacheWrite).length),
478
512
  total: Math.max(5, ...displayRows.map((row) => row.total.length), formatInt(report.totals.total).length),
479
513
  cost: Math.max(7, ...displayRows.map((row) => row.cost.length), formatCost(report.totals.cost).length),
514
+ speed: Math.max(5, ...displayRows.map((row) => row.speed.length), totalsSpeed.length),
480
515
  };
481
516
 
482
517
  const header = [
@@ -489,6 +524,7 @@ function renderTable(report: UsageReport): string {
489
524
  "write".padStart(widths.write),
490
525
  "total".padStart(widths.total),
491
526
  "cost".padStart(widths.cost),
527
+ "tok/s".padStart(widths.speed),
492
528
  ].join(" ");
493
529
 
494
530
  const divider = "-".repeat(header.length);
@@ -502,6 +538,7 @@ function renderTable(report: UsageReport): string {
502
538
  row.write.padStart(widths.write),
503
539
  row.total.padStart(widths.total),
504
540
  row.cost.padStart(widths.cost),
541
+ row.speed.padStart(widths.speed),
505
542
  ].join(" "));
506
543
 
507
544
  const totalsLine = [
@@ -514,6 +551,7 @@ function renderTable(report: UsageReport): string {
514
551
  formatInt(report.totals.cacheWrite).padStart(widths.write),
515
552
  formatInt(report.totals.total).padStart(widths.total),
516
553
  formatCost(report.totals.cost).padStart(widths.cost),
554
+ totalsSpeed.padStart(widths.speed),
517
555
  ].join(" ");
518
556
 
519
557
  return [header, divider, ...body, divider, totalsLine].join("\n");
@@ -303,6 +303,7 @@ export default function(pi: ExtensionAPI) {
303
303
  activationMode,
304
304
  editorText: ctx.ui.getEditorText(),
305
305
  holdToTalkSupported: isKittyProtocolActive(),
306
+ isEditorFocused: ctx.ui.isEditorFocused(),
306
307
  onUnsupported: () => {
307
308
  if (holdToTalkUnsupportedNotified) return;
308
309
  holdToTalkUnsupportedNotified = true;
@@ -8,6 +8,7 @@ export interface PushToTalkState {
8
8
  activationMode: VoiceActivationMode | null;
9
9
  editorText: string;
10
10
  holdToTalkSupported: boolean;
11
+ isEditorFocused: boolean;
11
12
  onUnsupported?(): void;
12
13
  startPushToTalk(): void | Promise<void>;
13
14
  stopVoice(): void | Promise<void>;
@@ -16,6 +17,8 @@ export interface PushToTalkState {
16
17
  export function handlePushToTalkInput(data: string, state: PushToTalkState): ReturnType<TerminalInputHandler> {
17
18
  if (!matchesKey(data, Key.space)) return undefined;
18
19
 
20
+ if (!state.isEditorFocused) return undefined;
21
+
19
22
  if (isKeyRelease(data)) {
20
23
  if (state.activationMode === "push-to-talk") {
21
24
  void Promise.resolve(state.stopVoice());
@@ -15,6 +15,7 @@ describe("voice push-to-talk handler", () => {
15
15
  activationMode: null,
16
16
  editorText: "",
17
17
  holdToTalkSupported: true,
18
+ isEditorFocused: true,
18
19
  startPushToTalk: () => { startCalls += 1; },
19
20
  stopVoice: () => { stopCalls += 1; },
20
21
  });
@@ -32,6 +33,7 @@ describe("voice push-to-talk handler", () => {
32
33
  activationMode: null,
33
34
  editorText: "hello",
34
35
  holdToTalkSupported: true,
36
+ isEditorFocused: true,
35
37
  startPushToTalk: () => { startCalls += 1; },
36
38
  stopVoice: () => { },
37
39
  });
@@ -48,6 +50,7 @@ describe("voice push-to-talk handler", () => {
48
50
  activationMode: "push-to-talk",
49
51
  editorText: "",
50
52
  holdToTalkSupported: true,
53
+ isEditorFocused: true,
51
54
  startPushToTalk: () => { startCalls += 1; },
52
55
  stopVoice: () => { },
53
56
  });
@@ -64,6 +67,7 @@ describe("voice push-to-talk handler", () => {
64
67
  activationMode: "push-to-talk",
65
68
  editorText: "",
66
69
  holdToTalkSupported: true,
70
+ isEditorFocused: true,
67
71
  startPushToTalk: () => { },
68
72
  stopVoice: () => { stopCalls += 1; },
69
73
  });
@@ -80,6 +84,7 @@ describe("voice push-to-talk handler", () => {
80
84
  activationMode: "toggle",
81
85
  editorText: "",
82
86
  holdToTalkSupported: true,
87
+ isEditorFocused: true,
83
88
  startPushToTalk: () => { },
84
89
  stopVoice: () => { stopCalls += 1; },
85
90
  });
@@ -97,6 +102,7 @@ describe("voice push-to-talk handler", () => {
97
102
  activationMode: null,
98
103
  editorText: "",
99
104
  holdToTalkSupported: false,
105
+ isEditorFocused: true,
100
106
  onUnsupported: () => { notifyCalls += 1; },
101
107
  startPushToTalk: () => { startCalls += 1; },
102
108
  stopVoice: () => { },