@oh-my-pi/pi-coding-agent 16.0.2 → 16.0.4

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 (97) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +0 -1
  3. package/dist/cli.js +580 -359
  4. package/dist/types/advisor/advise-tool.d.ts +30 -1
  5. package/dist/types/cli/args.d.ts +1 -0
  6. package/dist/types/commands/install.d.ts +1 -1
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/model-resolver.d.ts +8 -0
  9. package/dist/types/config/settings-schema.d.ts +1 -11
  10. package/dist/types/edit/file-snapshot-store.d.ts +2 -0
  11. package/dist/types/eval/js/shared/runtime.d.ts +1 -0
  12. package/dist/types/eval/js/worker-core.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  14. package/dist/types/goals/runtime.d.ts +0 -1
  15. package/dist/types/mcp/tool-bridge.d.ts +3 -0
  16. package/dist/types/modes/components/custom-editor.d.ts +14 -4
  17. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  18. package/dist/types/modes/interactive-mode.d.ts +1 -1
  19. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +3 -2
  20. package/dist/types/modes/theme/mermaid-cache.d.ts +18 -1
  21. package/dist/types/modes/types.d.ts +1 -1
  22. package/dist/types/registry/agent-lifecycle.d.ts +16 -1
  23. package/dist/types/sdk.d.ts +8 -0
  24. package/dist/types/session/agent-session.d.ts +20 -8
  25. package/dist/types/session/session-dump-format.d.ts +8 -2
  26. package/dist/types/session/session-entries.d.ts +4 -0
  27. package/dist/types/session/session-history-format.d.ts +2 -0
  28. package/dist/types/session/session-manager.d.ts +22 -0
  29. package/dist/types/stt/downloader.d.ts +5 -5
  30. package/dist/types/task/executor.d.ts +6 -0
  31. package/dist/types/task/persisted-revive.d.ts +36 -0
  32. package/dist/types/tiny/models.d.ts +8 -0
  33. package/dist/types/tools/builtin-names.d.ts +1 -1
  34. package/dist/types/tools/index.d.ts +0 -1
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +150 -50
  37. package/src/advisor/advise-tool.ts +48 -6
  38. package/src/advisor/runtime.ts +10 -3
  39. package/src/auto-thinking/classifier.ts +12 -3
  40. package/src/cli/args.ts +3 -0
  41. package/src/cli/flag-tables.ts +1 -0
  42. package/src/cli.ts +2 -2
  43. package/src/commands/install.ts +3 -3
  44. package/src/commands/launch.ts +3 -0
  45. package/src/config/model-resolver.ts +28 -11
  46. package/src/config/settings-schema.ts +1 -12
  47. package/src/edit/file-snapshot-store.ts +12 -3
  48. package/src/eval/agent-bridge.ts +2 -0
  49. package/src/eval/js/context-manager.ts +2 -1
  50. package/src/eval/js/shared/runtime.ts +189 -15
  51. package/src/eval/js/worker-core.ts +19 -0
  52. package/src/export/html/index.ts +1 -1
  53. package/src/export/html/tool-views.generated.js +34 -35
  54. package/src/extensibility/extensions/loader.ts +21 -9
  55. package/src/goals/runtime.ts +1 -23
  56. package/src/internal-urls/docs-index.generated.ts +82 -84
  57. package/src/main.ts +26 -4
  58. package/src/mcp/render.ts +11 -1
  59. package/src/mcp/tool-bridge.ts +3 -0
  60. package/src/modes/components/custom-editor.test.ts +63 -18
  61. package/src/modes/components/custom-editor.ts +63 -15
  62. package/src/modes/components/tips.txt +2 -1
  63. package/src/modes/controllers/command-controller.ts +2 -2
  64. package/src/modes/controllers/input-controller.ts +15 -9
  65. package/src/modes/controllers/selector-controller.ts +13 -8
  66. package/src/modes/controllers/tan-command-controller.ts +1 -0
  67. package/src/modes/interactive-mode.ts +4 -2
  68. package/src/modes/setup-wizard/wizard-overlay.ts +26 -4
  69. package/src/modes/theme/mermaid-cache.ts +74 -11
  70. package/src/modes/theme/theme.ts +14 -1
  71. package/src/modes/types.ts +1 -1
  72. package/src/prompts/system/system-prompt.md +4 -1
  73. package/src/registry/agent-lifecycle.ts +60 -8
  74. package/src/sdk.ts +20 -26
  75. package/src/session/agent-session.ts +253 -82
  76. package/src/session/artifacts.ts +19 -1
  77. package/src/session/session-dump-format.ts +167 -23
  78. package/src/session/session-entries.ts +4 -0
  79. package/src/session/session-history-format.ts +37 -3
  80. package/src/session/session-manager.ts +94 -4
  81. package/src/slash-commands/builtin-registry.ts +4 -7
  82. package/src/stt/asr-client.ts +6 -0
  83. package/src/stt/downloader.ts +13 -6
  84. package/src/stt/stt-controller.ts +52 -11
  85. package/src/task/executor.ts +18 -2
  86. package/src/task/index.ts +2 -2
  87. package/src/task/persisted-revive.ts +128 -0
  88. package/src/tiny/models.ts +10 -0
  89. package/src/tiny/worker.ts +4 -3
  90. package/src/tools/builtin-names.ts +0 -1
  91. package/src/tools/index.ts +0 -4
  92. package/src/tools/output-meta.ts +17 -3
  93. package/src/tools/read.ts +26 -0
  94. package/src/utils/title-generator.ts +4 -4
  95. package/dist/types/tools/render-mermaid.d.ts +0 -38
  96. package/src/prompts/tools/render-mermaid.md +0 -9
  97. package/src/tools/render-mermaid.ts +0 -69
package/src/main.ts CHANGED
@@ -55,6 +55,7 @@ import type { PrintModeOptions } from "./modes/print-mode";
55
55
  import { CURRENT_SETUP_VERSION } from "./modes/setup-version";
56
56
  import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
57
57
  import type { SubmittedUserInput } from "./modes/types";
58
+ import { AgentLifecycleManager } from "./registry/agent-lifecycle";
58
59
  import {
59
60
  type CreateAgentSessionOptions,
60
61
  type CreateAgentSessionResult,
@@ -68,6 +69,7 @@ import { resolveResumableSession, type SessionInfo } from "./session/session-lis
68
69
  import { SessionManager } from "./session/session-manager";
69
70
  import { executeBuiltinSlashCommand } from "./slash-commands/builtin-registry";
70
71
  import { discoverTitleSystemPromptFile, resolvePromptInput } from "./system-prompt";
72
+ import { createPersistedSubagentReviverFactory } from "./task/persisted-revive";
71
73
  import { initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export";
72
74
  import { AUTO_THINKING } from "./thinking";
73
75
  import type { LspStartupServerInfo } from "./tools";
@@ -122,11 +124,9 @@ async function checkForNewVersion(currentVersion: string): Promise<string | unde
122
124
  }
123
125
  }
124
126
 
127
+ // Todo settings are caller-controlled in protocol modes. Do not host-default them:
128
+ // embedders need project-level opt-outs for reminder/prelude prompt injection.
125
129
  const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [
126
- "todo.enabled",
127
- "todo.reminders",
128
- "todo.reminders.max",
129
- "todo.eager",
130
130
  "task.isolation.mode",
131
131
  "task.isolation.merge",
132
132
  "task.isolation.commits",
@@ -1039,6 +1039,10 @@ export async function runRootCommand(
1039
1039
  if (parsedArgs.hideThinking) {
1040
1040
  settingsInstance.override("hideThinkingBlock", true);
1041
1041
  }
1042
+ // Apply --advisor CLI flag (ephemeral, not persisted)
1043
+ if (parsedArgs.advisor) {
1044
+ settingsInstance.override("advisor.enabled", true);
1045
+ }
1042
1046
 
1043
1047
  await logger.time(
1044
1048
  "initTheme:final",
@@ -1262,6 +1266,24 @@ export async function runRootCommand(
1262
1266
  eventBus,
1263
1267
  preloadedExtensions: extensionsResult,
1264
1268
  });
1269
+
1270
+ // Cold-revive support: a `parked` subagent ref restored from disk (Agent Hub
1271
+ // scan, collab mirror, resumed process) has a sessionFile but no in-memory
1272
+ // reviver, so `ensureLive` (IRC sends, hub focus) would refuse it. Install a
1273
+ // factory — bound to THIS top-level session — that rebuilds the subagent from
1274
+ // its persisted JSONL (see persisted-revive.ts). Scoped to the non-ACP
1275
+ // bootstrap: ACP keeps several concurrent top-level sessions and a single
1276
+ // process-global factory must not be clobbered by the most recent one.
1277
+ AgentLifecycleManager.global().setPersistedSubagentReviverFactory(
1278
+ createPersistedSubagentReviverFactory({
1279
+ session,
1280
+ authStorage,
1281
+ modelRegistry,
1282
+ settings: settingsInstance,
1283
+ enableLsp: sessionOptions.enableLsp ?? true,
1284
+ }),
1285
+ Math.trunc(Number(settingsInstance.get("task.agentIdleTtlMs") ?? 420_000) || 0),
1286
+ );
1265
1287
  if (parsedArgs.apiKey && !sessionOptions.model && session.model) {
1266
1288
  authStorage.setRuntimeApiKey(session.model.provider, parsedArgs.apiKey);
1267
1289
  }
package/src/mcp/render.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  JSON_TREE_SCALAR_LEN_EXPANDED,
19
19
  renderJsonTreeLines,
20
20
  } from "../tools/json-tree";
21
+ import { formatStyledTruncationWarning, stripOutputNotice } from "../tools/output-meta";
21
22
  import { formatExpandHint, truncateToWidth } from "../tools/render-utils";
22
23
  import { renderStatusLine } from "../tui";
23
24
  import type { MCPToolDetails } from "./tool-bridge";
@@ -78,7 +79,14 @@ export function renderMCPResult(
78
79
 
79
80
  // Output section
80
81
  const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
81
- const trimmedOutput = textContent.trimEnd();
82
+ // Strip the LLM-facing spill notice before parsing/rendering: a spilled
83
+ // result appends `[Showing… artifact://N]` to the body, which would break
84
+ // JSON detection and bury the recovery link. Surface it as a styled warning
85
+ // instead, mirroring the built-in read/bash/ssh/browser renderers.
86
+ const trimmedOutput = stripOutputNotice(textContent, result.details?.meta).trimEnd();
87
+ const truncationWarning = result.details?.meta?.truncation
88
+ ? formatStyledTruncationWarning(result.details.meta, theme)
89
+ : null;
82
90
 
83
91
  if (!trimmedOutput) {
84
92
  lines.push(theme.fg("dim", "(no output)"));
@@ -104,6 +112,7 @@ export function renderMCPResult(
104
112
  } else if (tree.truncated) {
105
113
  lines.push(theme.fg("dim", "…"));
106
114
  }
115
+ if (truncationWarning) lines.push(truncationWarning);
107
116
  return new Text(lines.join("\n"), 0, 0);
108
117
  }
109
118
  } catch {
@@ -128,5 +137,6 @@ export function renderMCPResult(
128
137
  lines.push(formatExpandHint(theme, expanded, true));
129
138
  }
130
139
 
140
+ if (truncationWarning) lines.push(truncationWarning);
131
141
  return new Text(lines.join("\n"), 0, 0);
132
142
  }
@@ -15,6 +15,7 @@ import type {
15
15
  RenderResultOptions,
16
16
  } from "../extensibility/custom-tools/types";
17
17
  import type { Theme } from "../modes/theme/theme";
18
+ import type { OutputMeta } from "../tools/output-meta";
18
19
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
19
20
  import { callTool } from "./client";
20
21
  import { renderMCPCall, renderMCPResult } from "./render";
@@ -71,6 +72,8 @@ export interface MCPToolDetails {
71
72
  provider?: string;
72
73
  /** Provider display name (e.g., "Claude Code", "MCP Config") */
73
74
  providerName?: string;
75
+ /** Structured output metadata (set by the spill wrapper when output is truncated to an artifact). */
76
+ meta?: OutputMeta;
74
77
  }
75
78
  /**
76
79
  * Format MCP content for LLM consumption.
@@ -1,7 +1,12 @@
1
- import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
1
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
2
2
  import { $ } from "bun";
3
3
  import { getEditorTheme, initTheme } from "../theme/theme";
4
- import { CustomEditor, SPACE_HOLD_RELEASE_MS, SPACE_HOLD_THRESHOLD } from "./custom-editor";
4
+ import {
5
+ CustomEditor,
6
+ SPACE_HOLD_MECHANICAL_RUN,
7
+ SPACE_HOLD_RELEASE_MS,
8
+ SPACE_REPEAT_MAX_GAP_MS,
9
+ } from "./custom-editor";
5
10
 
6
11
  function makeEditor() {
7
12
  const editor = new CustomEditor(getEditorTheme());
@@ -12,8 +17,26 @@ function makeEditor() {
12
17
  return { editor, events };
13
18
  }
14
19
 
15
- function holdSpace(editor: CustomEditor, count: number): void {
16
- for (let i = 0; i < count; i++) editor.handleInput(" ");
20
+ /** A gap below SPACE_REPEAT_MAX_GAP_MS — looks like OS key auto-repeat (a held bar). */
21
+ const REPEAT_GAP_MS = 30;
22
+ /** A gap above the threshold — looks like a deliberate keypress. */
23
+ const TAP_GAP_MS = SPACE_REPEAT_MAX_GAP_MS + 80;
24
+
25
+ /** Feed `count` spaces `gapMs` apart on the fake clock. The first space of a run has no prior
26
+ * space, so its gap is effectively infinite and it always reads as a deliberate tap. */
27
+ function feedSpaces(editor: CustomEditor, count: number, gapMs: number): void {
28
+ for (let i = 0; i < count; i++) {
29
+ vi.advanceTimersByTime(gapMs);
30
+ editor.handleInput(" ");
31
+ }
32
+ }
33
+
34
+ /** Feed spaces at explicit per-press gaps (ms) on the fake clock — for simulating an irregular cadence. */
35
+ function feedGaps(editor: CustomEditor, gaps: number[]): void {
36
+ for (const gapMs of gaps) {
37
+ vi.advanceTimersByTime(gapMs);
38
+ editor.handleInput(" ");
39
+ }
17
40
  }
18
41
 
19
42
  async function decorateInFreshProcess(text: string): Promise<string> {
@@ -47,29 +70,32 @@ describe("CustomEditor space-hold push-to-talk", () => {
47
70
  await initTheme();
48
71
  });
49
72
 
73
+ beforeEach(() => {
74
+ vi.useFakeTimers();
75
+ });
76
+
50
77
  afterEach(() => {
51
78
  vi.useRealTimers();
52
79
  });
53
80
 
54
- it("inserts spaces normally below the hold threshold", () => {
81
+ it("types deliberate space taps without triggering, even several in a row", () => {
55
82
  const { editor, events } = makeEditor();
56
- holdSpace(editor, SPACE_HOLD_THRESHOLD);
57
- expect(editor.getText()).toBe(" ".repeat(SPACE_HOLD_THRESHOLD));
83
+ feedSpaces(editor, 3, TAP_GAP_MS);
84
+ expect(editor.getText()).toBe(" ");
58
85
  expect(events).toEqual([]);
59
86
  });
60
87
 
61
- it("tracks back the space burst and drives the hold lifecycle", () => {
62
- vi.useFakeTimers();
88
+ it("recognizes a held bar from a steady fast cadence and tracks back the burst", () => {
63
89
  const { editor, events } = makeEditor();
64
90
  editor.handleInput("h");
65
91
  editor.handleInput("i");
66
- // Crossing the threshold deletes the optimistically-inserted spaces and starts recording,
67
- // leaving only the pre-burst text behind.
68
- holdSpace(editor, SPACE_HOLD_THRESHOLD + 1);
92
+ // Metronomic auto-repeat: the few pre-burst spaces typed are tracked back out when the hold is
93
+ // recognized, leaving only the pre-burst text.
94
+ feedSpaces(editor, SPACE_HOLD_MECHANICAL_RUN + 2, REPEAT_GAP_MS);
69
95
  expect(editor.getText()).toBe("hi");
70
96
  expect(events).toEqual(["start"]);
71
97
  // Continued auto-repeat while the bar is held is swallowed: no spam, no re-trigger.
72
- holdSpace(editor, 5);
98
+ feedSpaces(editor, 5, REPEAT_GAP_MS);
73
99
  expect(editor.getText()).toBe("hi");
74
100
  expect(events).toEqual(["start"]);
75
101
  // An idle gap with no further repeats means the bar was released -> stop + transcribe.
@@ -77,20 +103,39 @@ describe("CustomEditor space-hold push-to-talk", () => {
77
103
  expect(events).toEqual(["start", "end"]);
78
104
  });
79
105
 
106
+ it("does not trigger when the space bar is smashed at an irregular cadence", () => {
107
+ const { editor, events } = makeEditor();
108
+ // Fast but jittery, the way a human mashes — not the metronomic delta of OS auto-repeat.
109
+ const gaps = [40, 95, 45, 100, 35, 90, 50, 105];
110
+ feedGaps(editor, gaps);
111
+ expect(events).toEqual([]);
112
+ // Nothing is eaten: every smashed space still types a real space.
113
+ expect(editor.getText()).toBe(" ".repeat(gaps.length));
114
+ });
115
+
116
+ it("does not trigger on steady but slow spacing", () => {
117
+ const { editor, events } = makeEditor();
118
+ // Even cadence, but slower than auto-repeat: consistent deltas alone must not start recording.
119
+ feedSpaces(editor, 6, TAP_GAP_MS);
120
+ expect(events).toEqual([]);
121
+ expect(editor.getText()).toBe(" ".repeat(6));
122
+ });
123
+
80
124
  it("does not trigger when a non-space breaks the run", () => {
81
125
  const { editor, events } = makeEditor();
82
- holdSpace(editor, SPACE_HOLD_THRESHOLD);
126
+ // Each partial run climbs the mechanical counter one short of the threshold; the non-space
127
+ // resets it so they never combine into a hold.
128
+ feedSpaces(editor, 3, REPEAT_GAP_MS);
83
129
  editor.handleInput("x");
84
- holdSpace(editor, SPACE_HOLD_THRESHOLD);
130
+ feedSpaces(editor, 3, REPEAT_GAP_MS);
85
131
  expect(events).toEqual([]);
86
- expect(editor.getText()).toBe(`${" ".repeat(SPACE_HOLD_THRESHOLD)}x${" ".repeat(SPACE_HOLD_THRESHOLD)}`);
87
132
  });
88
133
 
89
134
  it("leaves the space bar typing normally when the gesture is disabled", () => {
90
135
  const { editor, events } = makeEditor();
91
136
  editor.sttHoldEnabled = () => false;
92
- holdSpace(editor, SPACE_HOLD_THRESHOLD + 5);
93
- expect(editor.getText()).toBe(" ".repeat(SPACE_HOLD_THRESHOLD + 5));
137
+ feedSpaces(editor, 8, REPEAT_GAP_MS);
138
+ expect(editor.getText()).toBe(" ".repeat(8));
94
139
  expect(events).toEqual([]);
95
140
  });
96
141
  });
@@ -62,14 +62,34 @@ const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
62
62
  const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi;
63
63
  const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
64
64
 
65
- /** Plain spaces from one auto-repeat run that trigger the space-hold push-to-talk STT gesture.
66
- * Holding the space bar makes the terminal emit a burst of spaces; once more than this many land
67
- * in the editor we treat it as "space held", track them back out, and start recording. */
68
- export const SPACE_HOLD_THRESHOLD = 5;
65
+ /** Max gap (ms) between two spaces for the later one to count as OS key auto-repeat rather than a
66
+ * deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
67
+ export const SPACE_REPEAT_MAX_GAP_MS = 120;
68
+ /** Two consecutive inter-space gaps are "mechanical" (machine-driven auto-repeat) when both are
69
+ * within {@link SPACE_REPEAT_MAX_GAP_MS} and differ by no more than this — an absolute jitter floor
70
+ * or, for slower repeat rates, {@link SPACE_REPEAT_JITTER_RATIO} of the smaller gap. OS key-repeat
71
+ * is metronomic; a human smashing the bar is fast but irregular, so its deltas never stay this
72
+ * steady. */
73
+ export const SPACE_REPEAT_JITTER_MS = 18;
74
+ export const SPACE_REPEAT_JITTER_RATIO = 0.35;
75
+ /** Consecutive mechanical (fast + steady) deltas that confirm the space bar is held and start
76
+ * recording. Needs a sustained metronomic cadence, so jittery smashing and deliberate taps never
77
+ * reach it. */
78
+ export const SPACE_HOLD_MECHANICAL_RUN = 2;
69
79
  /** Idle gap (ms) after the last repeated space that counts as the space bar being released, ending
70
80
  * the push-to-talk recording. Must comfortably exceed the OS key-repeat interval. */
71
81
  export const SPACE_HOLD_RELEASE_MS = 250;
72
82
 
83
+ /** Whether two consecutive inter-space gaps look machine-driven: both within the auto-repeat band
84
+ * and steady enough (small absolute or proportional difference). OS key-repeat is metronomic, so
85
+ * its successive deltas match closely; human smashing is fast but irregular and deliberate taps are
86
+ * too slow, so neither passes. */
87
+ function gapsAreMechanical(gap: number, prevGap: number): boolean {
88
+ if (gap > SPACE_REPEAT_MAX_GAP_MS || prevGap > SPACE_REPEAT_MAX_GAP_MS) return false;
89
+ const tolerance = Math.max(SPACE_REPEAT_JITTER_MS, Math.min(gap, prevGap) * SPACE_REPEAT_JITTER_RATIO);
90
+ return Math.abs(gap - prevGap) <= tolerance;
91
+ }
92
+
73
93
  function isPastedPathSeparator(char: string | undefined): boolean {
74
94
  return char === undefined || char === " " || char === "\t" || char === "\r" || char === "\n";
75
95
  }
@@ -266,8 +286,14 @@ export class CustomEditor extends Editor {
266
286
  /** Custom key handlers from extensions and non-built-in app actions. */
267
287
  #customKeyHandlers = new Map<KeyId, () => void>();
268
288
  #customMatchKeys = new Map<string, () => void>();
269
- /** Consecutive plain spaces inserted in the current run; any other key resets it. */
289
+ /** Spaces actually inserted in the current run; tracked back out when a hold is recognized. */
270
290
  #spaceRunInserted = 0;
291
+ /** Consecutive "mechanical" deltas (fast + steady); a sustained run of these confirms a held bar. */
292
+ #mechanicalRun = 0;
293
+ /** Inter-space gap (ms) of the previous space pair, compared against the next to judge steadiness. */
294
+ #prevSpaceGap: number | undefined;
295
+ /** Monotonic timestamp (ms) of the last space, to measure the gap to the next one. */
296
+ #lastSpaceAt = Number.NEGATIVE_INFINITY;
271
297
  /** True while a recognized space-hold push-to-talk recording is in progress. */
272
298
  #spaceHoldActive = false;
273
299
  /** Idle timer that fires `onSpaceHoldEnd` once repeated spaces stop arriving. */
@@ -334,9 +360,12 @@ export class CustomEditor extends Editor {
334
360
  }
335
361
 
336
362
  /** Drive the space-hold push-to-talk state machine. Returns true when the gesture consumed the
337
- * input so it must not reach normal editing. Holding the space bar makes the terminal emit a
338
- * burst of auto-repeat spaces; once more than {@link SPACE_HOLD_THRESHOLD} of them land we treat
339
- * it as a hold, delete the spam, and start recording until the repeats stop. */
363
+ * input so it must not reach normal editing. A held space bar emits OS auto-repeat: a *steady*
364
+ * stream of spaces at a fixed fast interval. We watch the inter-space deltas and only recognize a
365
+ * hold once {@link SPACE_HOLD_MECHANICAL_RUN} consecutive deltas are "mechanical" both
366
+ * auto-repeat-fast and near-identical (see {@link gapsAreMechanical}). Smashing the bar is fast
367
+ * but jittery and deliberate taps are too slow, so neither escalates and both keep typing real
368
+ * spaces; the few spaces typed before a real hold is recognized are tracked back out. */
340
369
  #handleSpaceHold(data: string, canonical: string | undefined): boolean {
341
370
  const isSpace = canonical === "space";
342
371
  if (this.#spaceHoldActive) {
@@ -350,21 +379,40 @@ export class CustomEditor extends Editor {
350
379
  return false;
351
380
  }
352
381
  if (!isSpace) {
353
- this.#spaceRunInserted = 0;
382
+ this.#resetSpaceRun();
354
383
  return false;
355
384
  }
356
385
  if (!this.#spaceHoldGestureEnabled()) return false;
357
- // A short tap should still type a normal space, so insert optimistically and count the run.
358
- super.handleInput(data);
359
- this.#spaceRunInserted++;
360
- if (this.#spaceRunInserted > SPACE_HOLD_THRESHOLD) {
386
+ const now = performance.now();
387
+ const gap = now - this.#lastSpaceAt;
388
+ const prevGap = this.#prevSpaceGap;
389
+ this.#lastSpaceAt = now;
390
+ this.#prevSpaceGap = gap;
391
+ if (prevGap === undefined || !gapsAreMechanical(gap, prevGap)) {
392
+ // First space, a deliberate tap, or jittery smashing: not a steady machine cadence yet, so
393
+ // type a real space and reset the mechanical run.
394
+ this.#mechanicalRun = 0;
395
+ super.handleInput(data);
396
+ this.#spaceRunInserted++;
397
+ return true;
398
+ }
399
+ // Steady fast repeat: swallow it. Once the cadence has held for SPACE_HOLD_MECHANICAL_RUN
400
+ // deltas it's a held bar — track back the few pre-burst spaces already typed and start.
401
+ if (++this.#mechanicalRun >= SPACE_HOLD_MECHANICAL_RUN) {
361
402
  this.deleteBeforeCursor(this.#spaceRunInserted);
362
- this.#spaceRunInserted = 0;
403
+ this.#resetSpaceRun();
363
404
  this.#beginSpaceHold();
364
405
  }
365
406
  return true;
366
407
  }
367
408
 
409
+ #resetSpaceRun(): void {
410
+ this.#spaceRunInserted = 0;
411
+ this.#mechanicalRun = 0;
412
+ this.#prevSpaceGap = undefined;
413
+ this.#lastSpaceAt = Number.NEGATIVE_INFINITY;
414
+ }
415
+
368
416
  #beginSpaceHold(): void {
369
417
  this.#spaceHoldActive = true;
370
418
  this.#armSpaceHoldReleaseTimer();
@@ -383,7 +431,7 @@ export class CustomEditor extends Editor {
383
431
  #endSpaceHold(): void {
384
432
  if (!this.#spaceHoldActive) return;
385
433
  this.#spaceHoldActive = false;
386
- this.#spaceRunInserted = 0;
434
+ this.#resetSpaceRun();
387
435
  if (this.#spaceHoldTimer) {
388
436
  clearTimeout(this.#spaceHoldTimer);
389
437
  this.#spaceHoldTimer = undefined;
@@ -19,4 +19,5 @@ Press ctrl+r to search your prompt history and reuse a past message
19
19
  `/shake` rips heavy tool results out of context to reclaim tokens without a full /compact — `/shake images` drops just images
20
20
  Pair up live: `/collab` shares your session through an end-to-end encrypted relay link — a teammate runs `/join <link>` to watch tool calls stream and prompt the agent from their own omp
21
21
  Press ← ← to drill into a running or finished agent and inspect its tool calls and transcript
22
- Hit a Codex rate limit? `/usage reset` spends a saved reset credit to immediately restore your quota
22
+ Hit a Codex rate limit? `/usage reset` spends a saved reset credit to immediately restore your quota
23
+ No native tool_calling? Inference provider botches parsing them? `PI_DIALECT=glm|kimi|anthropic…` rolls it locally for them!
@@ -84,9 +84,9 @@ export class CommandController {
84
84
  }
85
85
  }
86
86
 
87
- handleDumpCommand(isRaw = false) {
87
+ handleDumpCommand() {
88
88
  try {
89
- const formatted = this.ctx.session.formatSessionAsText({ compact: !isRaw });
89
+ const formatted = this.ctx.session.formatSessionAsText();
90
90
  if (!formatted) {
91
91
  this.ctx.showError("No messages to dump yet.");
92
92
  return;
@@ -708,21 +708,25 @@ export class InputController {
708
708
  // No input waiter: the main loop is between turns (post-turn
709
709
  // epilogue, retry backoff, or a scheduled continue) with the agent
710
710
  // momentarily idle. The editor already cleared itself on Enter, so
711
- // falling through here would silently swallow the message. Queue it
712
- // as a steer instead: the idle drain in #queueSteer delivers it
713
- // immediately when the session is resumable, and a retry/continue
714
- // run picks it up at loop start otherwise.
711
+ // falling through here would silently swallow the message. Submit a
712
+ // real prompt directly; if a background turn starts in the gap,
713
+ // `streamingBehavior: "steer"` preserves the typed-message queueing
714
+ // semantics instead of throwing AgentBusyError.
715
715
  this.ctx.editor.imageLinks = undefined;
716
716
  const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
717
717
  this.ctx.pendingImages = [];
718
718
  this.ctx.pendingImageLinks = [];
719
719
  try {
720
- await this.ctx.withLocalSubmission(text, () => this.ctx.session.steer(text, images), {
721
- imageCount: images?.length ?? 0,
722
- });
720
+ await this.ctx.withLocalSubmission(
721
+ text,
722
+ () => this.ctx.session.prompt(text, { streamingBehavior: "steer", images }),
723
+ {
724
+ imageCount: images?.length ?? 0,
725
+ },
726
+ );
723
727
  } catch (error) {
724
728
  // Don't lose the message: hand the text and images back to the
725
- // editor so the user can retry (e.g. steer() rejecting an
729
+ // editor so the user can retry (e.g. prompt dispatch rejecting an
726
730
  // extension command).
727
731
  this.ctx.editor.setText(text);
728
732
  if (images && images.length > 0) {
@@ -994,7 +998,9 @@ export class InputController {
994
998
 
995
999
  restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
996
1000
  this.ctx.locallySubmittedUserSignatures.clear();
997
- const { steering, followUp } = this.ctx.session.clearQueue();
1001
+ // On Esc (abort) drop non-user internal steers so the post-abort drain can't
1002
+ // auto-resume; plain Alt+Up dequeue preserves them for the continuing stream.
1003
+ const { steering, followUp } = this.ctx.session.clearQueue({ forInterrupt: options?.abort });
998
1004
  // Messages typed while compacting live in `compactionQueuedMessages`, not the
999
1005
  // agent queue `clearQueue()` drains — but the pending bar shows the same
1000
1006
  // "Alt+Up to edit" hint for them (ui-helpers `updatePendingMessagesDisplay`).
@@ -1216,11 +1216,20 @@ export class SelectorController {
1216
1216
  ...this.ctx.keybindings.getKeys("app.session.observe"),
1217
1217
  ];
1218
1218
  let hub: AgentHubOverlayComponent | undefined;
1219
- let overlayHandle: OverlayHandle | undefined;
1220
1219
 
1220
+ // Render the hub inline in the editor slot — the same anchored region
1221
+ // every other selector (model, session, tree, the `ask` tool) uses —
1222
+ // rather than a floating overlay. A non-fullscreen overlay composited over
1223
+ // a live transcript strands a stale copy in native scrollback every time a
1224
+ // running subagent's progress grows the frame and scrolls the window; the
1225
+ // hub is opened mid-run, so those copies stacked into a wall of duplicate
1226
+ // "Agent Hub" frames bleeding the task tree behind them. As an editor-slot
1227
+ // component it rides the normal append-only commit path: the transcript
1228
+ // commits above it exactly once and the hub repaints in place.
1221
1229
  const done = () => {
1222
1230
  hub?.dispose();
1223
- overlayHandle?.hide();
1231
+ this.ctx.editorContainer.clear();
1232
+ this.ctx.editorContainer.addChild(this.ctx.editor);
1224
1233
  this.ctx.ui.setFocus(this.ctx.editor);
1225
1234
  this.ctx.ui.requestRender();
1226
1235
  };
@@ -1251,12 +1260,8 @@ export class SelectorController {
1251
1260
  return;
1252
1261
  }
1253
1262
 
1254
- overlayHandle = this.ctx.ui.showOverlay(hub, {
1255
- anchor: "bottom-center",
1256
- width: "100%",
1257
- maxHeight: "100%",
1258
- margin: 0,
1259
- });
1263
+ this.ctx.editorContainer.clear();
1264
+ this.ctx.editorContainer.addChild(hub);
1260
1265
  this.ctx.ui.setFocus(hub);
1261
1266
  this.ctx.ui.requestRender();
1262
1267
  }
@@ -122,6 +122,7 @@ export class TanCommandController {
122
122
  agentId: cloneId,
123
123
  agentDisplayName: "tan",
124
124
  parentTaskPrefix: cloneId,
125
+ parentAgentId: ownerId,
125
126
  agentRegistry,
126
127
  disableExtensionDiscovery: true,
127
128
  });
@@ -151,6 +151,7 @@ import type { ObservableSession } from "./session-observer-registry";
151
151
  import { SessionObserverRegistry } from "./session-observer-registry";
152
152
  import { runProviderSetupWizard } from "./setup-wizard/lazy";
153
153
  import { interruptHint } from "./shared";
154
+ import { clearMermaidCache } from "./theme/mermaid-cache";
154
155
  import { type ShimmerPalette, shimmerEnabled, shimmerSegments, shimmerText } from "./theme/shimmer";
155
156
  import type { Theme } from "./theme/theme";
156
157
  import {
@@ -854,6 +855,7 @@ export class InteractiveMode implements InteractiveModeContext {
854
855
  onThemeChange(() => {
855
856
  this.#clearWorkingMessageAccentCache();
856
857
  clearRenderCache();
858
+ clearMermaidCache();
857
859
  this.ui.invalidate();
858
860
  this.updateEditorBorderColor();
859
861
  this.ui.requestRender();
@@ -3382,8 +3384,8 @@ export class InteractiveMode implements InteractiveModeContext {
3382
3384
  return this.#commandController.handleExportCommand(text);
3383
3385
  }
3384
3386
 
3385
- handleDumpCommand(isRaw?: boolean) {
3386
- return this.#commandController.handleDumpCommand(isRaw);
3387
+ handleDumpCommand() {
3388
+ return this.#commandController.handleDumpCommand();
3387
3389
  }
3388
3390
 
3389
3391
  handleAdvisorDumpCommand(isRaw?: boolean) {
@@ -1,4 +1,12 @@
1
- import { type Component, matchesKey, padding, parseSgrMouse, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
1
+ import {
2
+ type Component,
3
+ matchesKey,
4
+ type OverlayFocusOwner,
5
+ padding,
6
+ parseSgrMouse,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ } from "@oh-my-pi/pi-tui";
2
10
  import { APP_NAME } from "@oh-my-pi/pi-utils";
3
11
  import { gradientLogo, PI_LOGO } from "../components/welcome";
4
12
  import { theme } from "../theme/theme";
@@ -53,7 +61,7 @@ function dissolveFrames(from: string[], to: string[], progress: number, height:
53
61
  return out;
54
62
  }
55
63
 
56
- export class SetupWizardComponent implements Component {
64
+ export class SetupWizardComponent implements Component, OverlayFocusOwner {
57
65
  #phase: WizardPhase = "splash";
58
66
  #phaseStartedAt = performance.now();
59
67
  #sceneIndex = 0;
@@ -63,6 +71,7 @@ export class SetupWizardComponent implements Component {
63
71
  #disposed = false;
64
72
  /** Screen row where the active scene's body began in the last rendered frame. */
65
73
  #bodyRowStart = 0;
74
+ #sceneFocusTarget: Component | undefined;
66
75
 
67
76
  constructor(
68
77
  readonly ctx: InteractiveModeContext,
@@ -87,6 +96,11 @@ export class SetupWizardComponent implements Component {
87
96
  this.#activeScene?.invalidate?.();
88
97
  }
89
98
 
99
+ ownsOverlayFocusTarget(component: Component): boolean {
100
+ if (this.#sceneFocusTarget !== component) return false;
101
+ return true;
102
+ }
103
+
90
104
  handleInput(data: string): void {
91
105
  if (this.#phase === "done") return;
92
106
  if (data.startsWith("\x1b[<")) {
@@ -260,12 +274,19 @@ export class SetupWizardComponent implements Component {
260
274
  ctx: this.ctx,
261
275
  requestRender: () => this.ctx.ui.requestRender(),
262
276
  finish: (_result: SetupSceneResult) => this.#finishScene(),
263
- setFocus: component => this.ctx.ui.setFocus(component),
264
- restoreFocus: () => this.ctx.ui.setFocus(this),
277
+ setFocus: component => {
278
+ this.#sceneFocusTarget = component ?? undefined;
279
+ this.ctx.ui.setFocus(component);
280
+ },
281
+ restoreFocus: () => {
282
+ this.#sceneFocusTarget = undefined;
283
+ this.ctx.ui.setFocus(this);
284
+ },
265
285
  };
266
286
  this.#activeScene = scene.mount(host);
267
287
  this.#phase = targetPhase;
268
288
  this.#phaseStartedAt = performance.now();
289
+ this.#sceneFocusTarget = undefined;
269
290
  this.ctx.ui.setFocus(this);
270
291
  void this.#activeScene.onMount?.();
271
292
  this.ctx.ui.requestRender();
@@ -288,6 +309,7 @@ export class SetupWizardComponent implements Component {
288
309
  }
289
310
 
290
311
  #unmountActiveScene(): void {
312
+ this.#sceneFocusTarget = undefined;
291
313
  this.#activeScene?.onUnmount?.();
292
314
  this.#activeScene?.dispose?.();
293
315
  this.#activeScene = undefined;