@oh-my-pi/pi-coding-agent 15.9.0 → 15.9.3

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 (124) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  3. package/dist/types/cli/update-cli.d.ts +15 -1
  4. package/dist/types/commands/dry-balance.d.ts +31 -0
  5. package/dist/types/config/append-only-context-mode.d.ts +8 -0
  6. package/dist/types/config/model-registry.d.ts +5 -0
  7. package/dist/types/config/models-config-schema.d.ts +18 -0
  8. package/dist/types/config/settings-schema.d.ts +3 -3
  9. package/dist/types/config/settings.d.ts +11 -0
  10. package/dist/types/discovery/helpers.d.ts +1 -0
  11. package/dist/types/exa/mcp-client.d.ts +2 -1
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  13. package/dist/types/hindsight/bank.d.ts +17 -9
  14. package/dist/types/hindsight/mental-models.d.ts +1 -1
  15. package/dist/types/hindsight/state.d.ts +9 -3
  16. package/dist/types/mcp/json-rpc.d.ts +6 -1
  17. package/dist/types/mcp/manager.d.ts +1 -1
  18. package/dist/types/mcp/tool-bridge.d.ts +4 -0
  19. package/dist/types/mnemopi/state.d.ts +2 -2
  20. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  21. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
  22. package/dist/types/modes/components/plugin-settings.d.ts +40 -8
  23. package/dist/types/modes/components/session-selector.d.ts +8 -3
  24. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  25. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  26. package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
  27. package/dist/types/session/agent-session.d.ts +13 -1
  28. package/dist/types/session/auth-storage.d.ts +2 -2
  29. package/dist/types/session/history-storage.d.ts +3 -4
  30. package/dist/types/session/messages.d.ts +1 -0
  31. package/dist/types/session/session-manager.d.ts +1 -0
  32. package/dist/types/slash-commands/types.d.ts +17 -4
  33. package/dist/types/task/types.d.ts +2 -0
  34. package/dist/types/tiny/text.d.ts +17 -0
  35. package/dist/types/tools/index.d.ts +16 -0
  36. package/dist/types/tools/path-utils.d.ts +11 -0
  37. package/dist/types/web/search/providers/base.d.ts +14 -0
  38. package/dist/types/web/search/providers/exa.d.ts +9 -0
  39. package/dist/types/web/search/providers/perplexity.d.ts +2 -2
  40. package/dist/types/web/search/types.d.ts +2 -1
  41. package/package.json +9 -9
  42. package/src/cli/dry-balance-cli.ts +823 -0
  43. package/src/cli/session-picker.ts +1 -0
  44. package/src/cli/update-cli.ts +54 -2
  45. package/src/cli-commands.ts +1 -0
  46. package/src/commands/completions.ts +1 -1
  47. package/src/commands/dry-balance.ts +43 -0
  48. package/src/config/append-only-context-mode.ts +37 -0
  49. package/src/config/model-registry.ts +6 -0
  50. package/src/config/models-config-schema.ts +3 -0
  51. package/src/config/settings-schema.ts +2 -2
  52. package/src/config/settings.ts +38 -0
  53. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  54. package/src/discovery/github.ts +37 -1
  55. package/src/discovery/helpers.ts +3 -1
  56. package/src/exa/mcp-client.ts +11 -5
  57. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  58. package/src/hindsight/backend.ts +184 -35
  59. package/src/hindsight/bank.ts +32 -22
  60. package/src/hindsight/mental-models.ts +1 -1
  61. package/src/hindsight/state.ts +21 -7
  62. package/src/internal-urls/docs-index.generated.ts +5 -5
  63. package/src/internal-urls/omp-protocol.ts +8 -2
  64. package/src/main.ts +4 -2
  65. package/src/mcp/json-rpc.ts +8 -0
  66. package/src/mcp/manager.ts +40 -21
  67. package/src/mcp/render.ts +3 -0
  68. package/src/mcp/tool-bridge.ts +10 -2
  69. package/src/mcp/transports/http.ts +33 -16
  70. package/src/mnemopi/state.ts +4 -4
  71. package/src/modes/acp/acp-agent.ts +168 -3
  72. package/src/modes/components/agent-dashboard.ts +103 -31
  73. package/src/modes/components/extensions/extension-dashboard.ts +56 -10
  74. package/src/modes/components/history-search.ts +128 -14
  75. package/src/modes/components/plugin-settings.ts +270 -36
  76. package/src/modes/components/session-selector.ts +45 -14
  77. package/src/modes/components/settings-selector.ts +1 -1
  78. package/src/modes/components/tips.txt +5 -1
  79. package/src/modes/components/transcript-container.ts +35 -6
  80. package/src/modes/components/tree-selector.ts +29 -2
  81. package/src/modes/controllers/command-controller.ts +4 -3
  82. package/src/modes/controllers/input-controller.ts +18 -7
  83. package/src/modes/controllers/selector-controller.ts +30 -19
  84. package/src/modes/interactive-mode.ts +38 -3
  85. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  86. package/src/modes/utils/keybinding-matchers.ts +10 -0
  87. package/src/prompts/agents/explore.md +1 -0
  88. package/src/prompts/agents/librarian.md +1 -0
  89. package/src/prompts/dry-balance-bench.md +8 -0
  90. package/src/prompts/steering/user-interjection.md +10 -0
  91. package/src/prompts/system/agent-creation-architect.md +1 -26
  92. package/src/prompts/system/system-prompt.md +143 -145
  93. package/src/prompts/system/title-system.md +3 -2
  94. package/src/prompts/tools/browser.md +29 -29
  95. package/src/prompts/tools/render-mermaid.md +2 -2
  96. package/src/sdk.ts +87 -30
  97. package/src/session/agent-session.ts +96 -14
  98. package/src/session/auth-storage.ts +4 -0
  99. package/src/session/history-storage.ts +11 -18
  100. package/src/session/messages.ts +80 -0
  101. package/src/session/session-manager.ts +7 -1
  102. package/src/slash-commands/types.ts +27 -10
  103. package/src/task/executor.ts +6 -2
  104. package/src/task/index.ts +8 -7
  105. package/src/task/types.ts +2 -0
  106. package/src/tiny/text.ts +112 -1
  107. package/src/tools/bash.ts +3 -4
  108. package/src/tools/index.ts +16 -0
  109. package/src/tools/job.ts +3 -3
  110. package/src/tools/memory-recall.ts +1 -1
  111. package/src/tools/memory-reflect.ts +3 -3
  112. package/src/tools/path-utils.ts +21 -0
  113. package/src/tools/search.ts +18 -1
  114. package/src/tools/ssh.ts +26 -10
  115. package/src/tools/write.ts +14 -2
  116. package/src/tui/status-line.ts +15 -4
  117. package/src/utils/file-mentions.ts +7 -107
  118. package/src/utils/title-generator.ts +66 -38
  119. package/src/web/search/index.ts +3 -1
  120. package/src/web/search/provider.ts +1 -1
  121. package/src/web/search/providers/base.ts +17 -0
  122. package/src/web/search/providers/exa.ts +111 -7
  123. package/src/web/search/providers/perplexity.ts +8 -4
  124. package/src/web/search/types.ts +2 -1
@@ -2,8 +2,9 @@
2
2
  * Plugin settings UI components.
3
3
  *
4
4
  * Provides a hierarchical settings interface:
5
- * - Plugin list (shows all installed plugins)
6
- * - Plugin detail (enable/disable, features, config)
5
+ * - Plugin list (npm plugins + marketplace plugins)
6
+ * - npm plugin detail (enable/disable, features, config)
7
+ * - Marketplace plugin detail (enable/disable + read-only metadata)
7
8
  * - Feature toggles
8
9
  * - Config value editor
9
10
  */
@@ -17,9 +18,20 @@ import {
17
18
  Spacer,
18
19
  Text,
19
20
  } from "@oh-my-pi/pi-tui";
21
+ import { logger } from "@oh-my-pi/pi-utils";
22
+ import { clearPluginRootsAndCaches, resolveOrDefaultProjectRegistryPath } from "../../discovery/helpers";
20
23
  import { PluginManager } from "../../extensibility/plugins/manager";
24
+ import type { InstalledPluginSummary } from "../../extensibility/plugins/marketplace";
25
+ import {
26
+ getInstalledPluginsRegistryPath,
27
+ getMarketplacesCacheDir,
28
+ getMarketplacesRegistryPath,
29
+ getPluginsCacheDir,
30
+ MarketplaceManager,
31
+ } from "../../extensibility/plugins/marketplace";
21
32
  import type { InstalledPlugin, PluginSettingSchema } from "../../extensibility/plugins/types";
22
33
  import { getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme";
34
+ import { shortenPath } from "../../tools/render-utils";
23
35
  import { DynamicBorder } from "./dynamic-border";
24
36
 
25
37
  /**
@@ -41,20 +53,53 @@ export function handleInputOrEscape(
41
53
  // Plugin List Component
42
54
  // =============================================================================
43
55
 
56
+ /**
57
+ * One row in the unified plugin list. npm and marketplace plugins live in
58
+ * separate registries with different shapes, so a tagged union keeps both
59
+ * paths type-safe end-to-end (list rendering, value lookup, detail callback).
60
+ */
61
+ export type PluginListEntry =
62
+ | { kind: "npm"; plugin: InstalledPlugin }
63
+ | { kind: "marketplace"; plugin: InstalledPluginSummary };
64
+
44
65
  export interface PluginListCallbacks {
45
- onPluginSelect: (plugin: InstalledPlugin) => void;
66
+ onNpmSelect: (plugin: InstalledPlugin) => void;
67
+ onMarketplaceSelect: (plugin: InstalledPluginSummary) => void;
46
68
  onCancel: () => void;
47
69
  }
48
70
 
49
71
  /**
50
- * Shows list of installed plugins with enable/disable status.
51
- * Selecting a plugin opens its detail view.
72
+ * True when the marketplace summary's first entry is not explicitly disabled.
73
+ * Mirrors the `/plugins list` convention: a missing `enabled` flag means enabled.
74
+ */
75
+ function marketplaceEnabled(summary: InstalledPluginSummary): boolean {
76
+ return summary.entries[0]?.enabled !== false;
77
+ }
78
+
79
+ /**
80
+ * Stable SelectList value for a list entry. Combined with `findEntryByValue`
81
+ * this keeps lookup correct even when the same plugin id exists in both user
82
+ * and project scope (one of which is `shadowedBy: "project"`).
83
+ */
84
+ function entryValue(entry: PluginListEntry): string {
85
+ if (entry.kind === "npm") return `npm:${entry.plugin.name}`;
86
+ return `mkt:${entry.plugin.scope}:${entry.plugin.id}`;
87
+ }
88
+
89
+ function findEntryByValue(entries: ReadonlyArray<PluginListEntry>, value: string): PluginListEntry | undefined {
90
+ return entries.find(e => entryValue(e) === value);
91
+ }
92
+
93
+ /**
94
+ * Shows installed plugins from both registries (npm + marketplace) with
95
+ * enable/disable status, scope tag, and shadow indicator. Selecting an entry
96
+ * fans out to the kind-specific detail callback.
52
97
  */
53
98
  export class PluginListComponent extends Container {
54
99
  readonly #selectList: SelectList;
55
100
 
56
101
  constructor(
57
- private readonly plugins: InstalledPlugin[],
102
+ private readonly entries: ReadonlyArray<PluginListEntry>,
58
103
  callbacks: PluginListCallbacks,
59
104
  ) {
60
105
  super();
@@ -64,53 +109,87 @@ export class PluginListComponent extends Container {
64
109
  this.addChild(new Text(theme.bold(theme.fg("accent", " Plugins")), 0, 0));
65
110
  this.addChild(new Spacer(1));
66
111
 
67
- if (plugins.length === 0) {
112
+ if (entries.length === 0) {
68
113
  this.addChild(new Text(theme.fg("muted", " No plugins installed"), 0, 0));
69
114
  this.addChild(new Spacer(1));
70
- this.addChild(new Text(theme.fg("dim", " Install with: omp plugin install <package>"), 0, 0));
115
+ this.addChild(new Text(theme.fg("dim", " Install npm plugins: omp plugin install <package>"), 0, 0));
116
+ this.addChild(
117
+ new Text(theme.fg("dim", " Install marketplace plugins: omp plugin install <name>@<marketplace>"), 0, 0),
118
+ );
71
119
  this.addChild(new Spacer(1));
72
120
  this.addChild(new DynamicBorder());
73
121
 
74
- // Create empty list that just handles escape
122
+ // Empty list still handles Escape so the user can leave the panel.
75
123
  this.#selectList = new SelectList([], 1, getSelectListTheme());
76
124
  this.#selectList.onCancel = callbacks.onCancel;
77
125
  return;
78
126
  }
79
127
 
80
- const items: SelectItem[] = plugins.map(p => {
128
+ const items: SelectItem[] = entries.map(entry => this.#renderItem(entry));
129
+
130
+ // Marketplace plugin ids (`name@marketplace`) routinely run past the
131
+ // SelectList default primary column (32 chars). Widen the bound so the
132
+ // id remains readable; the description gets whatever width is left.
133
+ this.#selectList = new SelectList(items, Math.min(items.length, 8), getSelectListTheme(), {
134
+ minPrimaryColumnWidth: 24,
135
+ maxPrimaryColumnWidth: 64,
136
+ });
137
+
138
+ this.#selectList.onSelect = item => {
139
+ const found = findEntryByValue(this.entries, item.value);
140
+ if (!found) return;
141
+ if (found.kind === "npm") callbacks.onNpmSelect(found.plugin);
142
+ else callbacks.onMarketplaceSelect(found.plugin);
143
+ };
144
+
145
+ this.#selectList.onCancel = callbacks.onCancel;
146
+
147
+ this.addChild(this.#selectList);
148
+ this.addChild(new Spacer(1));
149
+ this.addChild(new Text(theme.fg("dim", " Enter to configure · Esc to go back"), 0, 0));
150
+ this.addChild(new DynamicBorder());
151
+ }
152
+
153
+ #renderItem(entry: PluginListEntry): SelectItem {
154
+ const kindBadge = theme.fg("dim", entry.kind === "npm" ? "[npm]" : "[marketplace]");
155
+
156
+ if (entry.kind === "npm") {
157
+ const p = entry.plugin;
81
158
  const status = p.enabled
82
159
  ? theme.fg("success", theme.status.enabled)
83
160
  : theme.fg("muted", theme.status.disabled);
84
161
  const featureCount = p.manifest.features ? Object.keys(p.manifest.features).length : 0;
85
162
  const enabledCount = p.enabledFeatures?.length ?? featureCount;
86
163
 
87
- let details = `v${p.version}`;
164
+ let details = `${kindBadge} ${theme.sep.dot} v${p.version}`;
88
165
  if (featureCount > 0) {
89
166
  details += ` ${theme.sep.dot} ${enabledCount}/${featureCount} features`;
90
167
  }
91
168
 
92
169
  return {
93
- value: p.name,
170
+ value: entryValue(entry),
94
171
  label: `${status} ${p.name}`,
95
172
  description: details,
96
173
  };
97
- });
98
-
99
- this.#selectList = new SelectList(items, Math.min(items.length, 8), getSelectListTheme());
174
+ }
100
175
 
101
- this.#selectList.onSelect = item => {
102
- const plugin = this.plugins.find(p => p.name === item.value);
103
- if (plugin) {
104
- callbacks.onPluginSelect(plugin);
105
- }
106
- };
176
+ const summary = entry.plugin;
177
+ const enabled = marketplaceEnabled(summary);
178
+ const status = enabled ? theme.fg("success", theme.status.enabled) : theme.fg("muted", theme.status.disabled);
179
+ const scopeTag = theme.fg("dim", `[${summary.scope}]`);
180
+ const shadowMarker = summary.shadowedBy ? ` ${theme.fg("warning", theme.status.shadowed)}` : "";
181
+ const version = summary.entries[0]?.version ?? "?";
107
182
 
108
- this.#selectList.onCancel = callbacks.onCancel;
183
+ let details = `${kindBadge} ${scopeTag} ${theme.sep.dot} v${version}`;
184
+ if (summary.shadowedBy) {
185
+ details += ` ${theme.sep.dot} shadowed by ${summary.shadowedBy}`;
186
+ }
109
187
 
110
- this.addChild(this.#selectList);
111
- this.addChild(new Spacer(1));
112
- this.addChild(new Text(theme.fg("dim", " Enter to configure · Esc to go back"), 0, 0));
113
- this.addChild(new DynamicBorder());
188
+ return {
189
+ value: entryValue(entry),
190
+ label: `${status} ${summary.id}${shadowMarker}`,
191
+ description: details,
192
+ };
114
193
  }
115
194
 
116
195
  handleInput(data: string): void {
@@ -296,6 +375,98 @@ export class PluginDetailComponent extends Container {
296
375
  }
297
376
  }
298
377
 
378
+ // =============================================================================
379
+ // Marketplace Plugin Detail Component
380
+ // =============================================================================
381
+
382
+ export interface MarketplacePluginDetailCallbacks {
383
+ onEnabledChange: (enabled: boolean) => void;
384
+ onBack: () => void;
385
+ }
386
+
387
+ /**
388
+ * Detail view for a marketplace plugin. Marketplace plugins do not declare
389
+ * features or settings, so the panel exposes a single enable/disable toggle
390
+ * plus the read-only metadata from the installed-plugins registry.
391
+ */
392
+ export class MarketplacePluginDetailComponent extends Container {
393
+ #settingsList: SettingsList;
394
+
395
+ constructor(
396
+ private plugin: InstalledPluginSummary,
397
+ private readonly callbacks: MarketplacePluginDetailCallbacks,
398
+ ) {
399
+ super();
400
+
401
+ const entry = plugin.entries[0];
402
+ const enabled = marketplaceEnabled(plugin);
403
+
404
+ // Header
405
+ this.addChild(new DynamicBorder());
406
+ this.addChild(new Text(theme.bold(theme.fg("accent", ` ${plugin.id}`)), 0, 0));
407
+
408
+ const subtitleParts = [`[${plugin.scope}]`];
409
+ if (plugin.shadowedBy) subtitleParts.push(`${theme.status.shadowed} shadowed by ${plugin.shadowedBy}`);
410
+ this.addChild(new Text(theme.fg("muted", ` ${subtitleParts.join(" ")}`), 0, 0));
411
+ this.addChild(new Spacer(1));
412
+
413
+ const items: SettingItem[] = [
414
+ {
415
+ id: "__enabled__",
416
+ label: "Enabled",
417
+ description: "Enable or disable this marketplace plugin",
418
+ currentValue: enabled ? "true" : "false",
419
+ values: ["true", "false"],
420
+ },
421
+ ];
422
+
423
+ this.#settingsList = new SettingsList(
424
+ items,
425
+ items.length,
426
+ getSettingsListTheme(),
427
+ (id, newValue) => {
428
+ if (id === "__enabled__") {
429
+ const next = newValue === "true";
430
+ this.callbacks.onEnabledChange(next);
431
+ this.plugin = {
432
+ ...this.plugin,
433
+ entries: this.plugin.entries.map(e => ({ ...e, enabled: next })),
434
+ };
435
+ }
436
+ },
437
+ this.callbacks.onBack,
438
+ );
439
+
440
+ this.addChild(this.#settingsList);
441
+ this.addChild(new Spacer(1));
442
+
443
+ // Read-only metadata. SettingsList rejects items without `values`/`submenu`,
444
+ // so we render the metadata as plain text rows beneath the toggle.
445
+ this.addChild(new Text(theme.fg("dim", ` version ${entry?.version ?? "(unknown)"}`), 0, 0));
446
+ this.addChild(new Text(theme.fg("dim", ` scope ${plugin.scope}`), 0, 0));
447
+ this.addChild(
448
+ new Text(
449
+ theme.fg("dim", ` install path ${entry?.installPath ? shortenPath(entry.installPath) : "(unknown)"}`),
450
+ 0,
451
+ 0,
452
+ ),
453
+ );
454
+ this.addChild(new Text(theme.fg("dim", ` installed at ${entry?.installedAt ?? "(unknown)"}`), 0, 0));
455
+ this.addChild(new Text(theme.fg("dim", ` last updated ${entry?.lastUpdated ?? "(unknown)"}`), 0, 0));
456
+ if (entry?.gitCommitSha) {
457
+ this.addChild(new Text(theme.fg("dim", ` git sha ${entry.gitCommitSha}`), 0, 0));
458
+ }
459
+
460
+ this.addChild(new Spacer(1));
461
+ this.addChild(new Text(theme.fg("dim", " Enter to toggle · Esc to go back"), 0, 0));
462
+ this.addChild(new DynamicBorder());
463
+ }
464
+
465
+ handleInput(data: string): void {
466
+ this.#settingsList.handleInput(data);
467
+ }
468
+ }
469
+
299
470
  // =============================================================================
300
471
  // Config Submenus
301
472
  // =============================================================================
@@ -408,7 +579,7 @@ class ConfigInputSubmenu extends Container {
408
579
 
409
580
  export interface PluginSettingsCallbacks {
410
581
  onClose: () => void;
411
- onPluginChanged: () => void;
582
+ onPluginChanged: () => void | Promise<void>;
412
583
  }
413
584
 
414
585
  /** Component with handleInput method */
@@ -421,31 +592,66 @@ interface InputHandler {
421
592
  * Manages navigation between plugin list and plugin detail views.
422
593
  */
423
594
  export class PluginSettingsComponent extends Container {
595
+ #cwd: string;
424
596
  #manager: PluginManager;
425
597
  #viewComponent: (Container & InputHandler) | null = null;
426
598
  // biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management
427
- #currentView: "list" | "detail" = "list";
599
+ #currentView: "list" | "npm-detail" | "marketplace-detail" = "list";
428
600
  // biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management
429
601
  #currentPlugin: InstalledPlugin | null = null;
602
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management
603
+ #currentMarketplacePlugin: InstalledPluginSummary | null = null;
430
604
 
431
605
  constructor(
432
606
  cwd: string,
433
607
  private readonly callbacks: PluginSettingsCallbacks,
434
608
  ) {
435
609
  super();
610
+ this.#cwd = cwd;
436
611
  this.#manager = new PluginManager(cwd);
437
612
  this.#showPluginList();
438
613
  }
439
614
 
615
+ async #buildMarketplaceManager(): Promise<MarketplaceManager> {
616
+ return new MarketplaceManager({
617
+ marketplacesRegistryPath: getMarketplacesRegistryPath(),
618
+ installedRegistryPath: getInstalledPluginsRegistryPath(),
619
+ projectInstalledRegistryPath: await resolveOrDefaultProjectRegistryPath(this.#cwd),
620
+ marketplacesCacheDir: getMarketplacesCacheDir(),
621
+ pluginsCacheDir: getPluginsCacheDir(),
622
+ clearPluginRootsCache: clearPluginRootsAndCaches,
623
+ });
624
+ }
625
+
440
626
  async #showPluginList(): Promise<void> {
441
627
  this.#currentView = "list";
442
628
  this.#currentPlugin = null;
629
+ this.#currentMarketplacePlugin = null;
443
630
  this.clear();
444
631
 
445
- const plugins = await this.#manager.list();
446
-
447
- this.#viewComponent = new PluginListComponent(plugins, {
448
- onPluginSelect: plugin => this.#showPluginDetail(plugin),
632
+ // Surface marketplace failures without taking the npm path down with it —
633
+ // the registry can fail to load (corrupt JSON, missing project root) and
634
+ // the user still benefits from seeing their npm plugins.
635
+ const [npmPlugins, marketplacePlugins] = await Promise.all([
636
+ this.#manager.list(),
637
+ this.#buildMarketplaceManager()
638
+ .then(mgr => mgr.listInstalledPlugins())
639
+ .catch(err => {
640
+ logger.error("Settings → Plugins: failed to list marketplace plugins", {
641
+ error: err instanceof Error ? err.message : String(err),
642
+ });
643
+ return [] as InstalledPluginSummary[];
644
+ }),
645
+ ]);
646
+
647
+ const entries: PluginListEntry[] = [
648
+ ...npmPlugins.map(plugin => ({ kind: "npm" as const, plugin })),
649
+ ...marketplacePlugins.map(plugin => ({ kind: "marketplace" as const, plugin })),
650
+ ];
651
+
652
+ this.#viewComponent = new PluginListComponent(entries, {
653
+ onNpmSelect: plugin => this.#showPluginDetail(plugin),
654
+ onMarketplaceSelect: plugin => this.#showMarketplaceDetail(plugin),
449
655
  onCancel: () => this.callbacks.onClose(),
450
656
  });
451
657
 
@@ -453,14 +659,15 @@ export class PluginSettingsComponent extends Container {
453
659
  }
454
660
 
455
661
  #showPluginDetail(plugin: InstalledPlugin): void {
456
- this.#currentView = "detail";
662
+ this.#currentView = "npm-detail";
457
663
  this.#currentPlugin = plugin;
664
+ this.#currentMarketplacePlugin = null;
458
665
  this.clear();
459
666
 
460
667
  this.#viewComponent = new PluginDetailComponent(plugin, this.#manager, {
461
668
  onEnabledChange: async enabled => {
462
669
  await this.#manager.setEnabled(plugin.name, enabled);
463
- this.callbacks.onPluginChanged();
670
+ await this.callbacks.onPluginChanged();
464
671
  },
465
672
  onFeatureChange: async (feature, enabled) => {
466
673
  const current = new Set((await this.#manager.getEnabledFeatures(plugin.name)) ?? []);
@@ -470,11 +677,38 @@ export class PluginSettingsComponent extends Container {
470
677
  current.delete(feature);
471
678
  }
472
679
  await this.#manager.setEnabledFeatures(plugin.name, [...current]);
473
- this.callbacks.onPluginChanged();
680
+ await this.callbacks.onPluginChanged();
474
681
  },
475
682
  onConfigChange: async (key, value) => {
476
683
  await this.#manager.setPluginSetting(plugin.name, key, value);
477
- this.callbacks.onPluginChanged();
684
+ await this.callbacks.onPluginChanged();
685
+ },
686
+ onBack: () => this.#showPluginList(),
687
+ });
688
+
689
+ this.addChild(this.#viewComponent);
690
+ }
691
+
692
+ #showMarketplaceDetail(plugin: InstalledPluginSummary): void {
693
+ this.#currentView = "marketplace-detail";
694
+ this.#currentPlugin = null;
695
+ this.#currentMarketplacePlugin = plugin;
696
+ this.clear();
697
+
698
+ this.#viewComponent = new MarketplacePluginDetailComponent(plugin, {
699
+ onEnabledChange: async enabled => {
700
+ try {
701
+ const mgr = await this.#buildMarketplaceManager();
702
+ await mgr.setPluginEnabled(plugin.id, enabled, plugin.scope);
703
+ await this.callbacks.onPluginChanged();
704
+ } catch (err) {
705
+ logger.error("Settings → Plugins: failed to toggle marketplace plugin", {
706
+ pluginId: plugin.id,
707
+ scope: plugin.scope,
708
+ enabled,
709
+ error: err instanceof Error ? err.message : String(err),
710
+ });
711
+ }
478
712
  },
479
713
  onBack: () => this.#showPluginList(),
480
714
  });
@@ -51,10 +51,10 @@ export type SessionHistoryMatcher = (query: string) => string[];
51
51
  *
52
52
  * - `fuzzy` is the ordered fuzzy-filter result over session metadata (best first).
53
53
  * - `historyIds` are session IDs whose recorded prompts matched the query,
54
- * ordered by history relevance (best first); duplicates are tolerated.
54
+ * ordered by prompt-history rank (typically newest matching prompt first); duplicates are tolerated.
55
55
  *
56
56
  * Ranking: sessions matched by **both** signals lead (keeping fuzzy order), then
57
- * fuzzy-only matches, then history-only matches (by history order). A fuzzy match
57
+ * fuzzy-only matches, then history-only matches (by prompt-history order). A fuzzy match
58
58
  * is never dropped, and history matches not present in `allSessions` (e.g. deleted
59
59
  * or out-of-scope sessions) are ignored since they cannot be resumed from here.
60
60
  */
@@ -95,7 +95,9 @@ class SessionList implements Component {
95
95
  onCancel?: () => void;
96
96
  onExit: () => void = () => {};
97
97
  onToggleScope?: () => void;
98
- #maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)
98
+ // Snapshot of the live terminal-row getter; the visible window is derived
99
+ // from it per render so the picker fits the viewport (and adapts to resize).
100
+ readonly #getTerminalRows: () => number;
99
101
 
100
102
  onDeleteRequest?: (session: SessionInfo) => void;
101
103
 
@@ -103,7 +105,13 @@ class SessionList implements Component {
103
105
  #showCwd: boolean;
104
106
  readonly #historyMatcher?: SessionHistoryMatcher;
105
107
 
106
- constructor(sessions: SessionInfo[], showCwd = false, historyMatcher?: SessionHistoryMatcher) {
108
+ constructor(
109
+ sessions: SessionInfo[],
110
+ showCwd = false,
111
+ historyMatcher?: SessionHistoryMatcher,
112
+ getTerminalRows: () => number = () => 24,
113
+ ) {
114
+ this.#getTerminalRows = getTerminalRows;
107
115
  this.#allSessions = sessions;
108
116
  this.#showCwd = showCwd;
109
117
  this.#historyMatcher = historyMatcher;
@@ -119,6 +127,25 @@ class SessionList implements Component {
119
127
  };
120
128
  }
121
129
 
130
+ /**
131
+ * Number of sessions to show at once, sized so the whole picker fits the
132
+ * current viewport instead of pushing its header/search off the top.
133
+ *
134
+ * Budget = rows − chrome − reserve, divided by the worst-case per-session
135
+ * height. Chrome (12) is the surrounding spacers/borders/header (7) plus the
136
+ * list's search line, blank, scroll indicator, blank, and hint (5). A titled
137
+ * session is the tallest item at 4 lines (title + preview + metadata +
138
+ * blank); budgeting for that guarantees no overflow even when every visible
139
+ * entry has a title. The reserve covers below-editor hook widgets / cursor.
140
+ */
141
+ #visibleCount(): number {
142
+ const CHROME = 12;
143
+ const PER_SESSION = 4;
144
+ const RESERVE = 1;
145
+ const budget = this.#getTerminalRows() - CHROME - RESERVE;
146
+ return Math.max(2, Math.floor(budget / PER_SESSION));
147
+ }
148
+
122
149
  /** Replace the visible dataset, e.g. when toggling folder/all-projects scope. */
123
150
  setSessions(sessions: SessionInfo[], showCwd: boolean): void {
124
151
  this.#allSessions = sessions;
@@ -210,17 +237,16 @@ class SessionList implements Component {
210
237
  return date.toLocaleDateString();
211
238
  };
212
239
 
213
- // Calculate visible range with scrolling
240
+ // Calculate visible range with scrolling. The window is sized to the
241
+ // current viewport so the picker never overflows past the top.
242
+ const maxVisible = this.#visibleCount();
214
243
  const startIndex = Math.max(
215
244
  0,
216
- Math.min(
217
- this.#selectedIndex - Math.floor(this.#maxVisible / 2),
218
- this.#filteredSessions.length - this.#maxVisible,
219
- ),
245
+ Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), this.#filteredSessions.length - maxVisible),
220
246
  );
221
- const endIndex = Math.min(startIndex + this.#maxVisible, this.#filteredSessions.length);
247
+ const endIndex = Math.min(startIndex + maxVisible, this.#filteredSessions.length);
222
248
 
223
- // Render visible sessions (2-3 lines per session + blank line)
249
+ // Render visible sessions (3 lines, or 4 when a title adds a preview line).
224
250
  for (let i = startIndex; i < endIndex; i++) {
225
251
  const session = this.#filteredSessions[i];
226
252
  const isSelected = i === this.#selectedIndex;
@@ -311,12 +337,12 @@ class SessionList implements Component {
311
337
  }
312
338
  // Page up - jump up by maxVisible items
313
339
  if (matchesKey(keyData, "pageUp")) {
314
- this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#maxVisible);
340
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#visibleCount());
315
341
  return;
316
342
  }
317
343
  // Page down - jump down by maxVisible items
318
344
  if (matchesKey(keyData, "pageDown")) {
319
- this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + this.#maxVisible);
345
+ this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + this.#visibleCount());
320
346
  return;
321
347
  }
322
348
  // Enter
@@ -359,6 +385,11 @@ export interface SessionSelectorOptions {
359
385
  allSessions?: SessionInfo[];
360
386
  /** Open directly in all-projects scope (e.g. the current folder has no sessions). */
361
387
  startInAllScope?: boolean;
388
+ /**
389
+ * Reads the live terminal height so the visible window fits the viewport.
390
+ * Omitted only in tests; defaults to a conservative 24 rows.
391
+ */
392
+ getTerminalRows?: () => number;
362
393
  }
363
394
 
364
395
  /**
@@ -405,7 +436,7 @@ export class SessionSelectorComponent extends Container {
405
436
  this.addChild(new Spacer(1));
406
437
  this.addChild(this.#messageContainer);
407
438
  // Create session list
408
- this.#sessionList = new SessionList(initialSessions, startAll, options.historyMatcher);
439
+ this.#sessionList = new SessionList(initialSessions, startAll, options.historyMatcher, options.getTerminalRows);
409
440
  this.#sessionList.onSelect = onSelect;
410
441
  this.#sessionList.onCancel = onCancel;
411
442
  this.#sessionList.onExit = onExit;
@@ -208,7 +208,7 @@ export interface SettingsCallbacks {
208
208
  /** Get current rendered status line for inline preview */
209
209
  getStatusLinePreview?: () => string;
210
210
  /** Called when plugins change */
211
- onPluginsChanged?: () => void;
211
+ onPluginsChanged?: () => void | Promise<void>;
212
212
  /** Called when settings panel is closed */
213
213
  onCancel: () => void;
214
214
  }
@@ -11,4 +11,8 @@ Say `orchestrate` in your message to drive a multi-phase task with parallel suba
11
11
  Say `workflow` in your message to drive the task with parallel subagents in eval — watch it glow as you type
12
12
  Log in to several accounts of the same provider — `/login` again — and omp load-balances across them automatically
13
13
  Run `omp auth-broker serve` once and every machine pulls live tokens over the wire — refresh keys never leave the host; `omp auth-gateway` fronts it as a drop-in proxy any OpenAI-compatible client can hit
14
- Press alt+p (or /switch) to switch provider, and ctrl+p to cycle role models smol -> slow -> etc
14
+ Press alt+p (or /switch) to switch provider, and ctrl+p to cycle role models smol -> slow -> etc
15
+ Press ctrl+r to search your prompt history and reuse a past message
16
+ `/force read` pins the next turn to one specific tool when the model keeps reaching for the wrong one
17
+ `/copy code` grabs the last code block to your clipboard — `/copy cmd` grabs the last shell/python command
18
+ `/shake` rips heavy tool results out of context to reclaim tokens without a full /compact — `/shake images` drops just images
@@ -1,4 +1,4 @@
1
- import { type Component, Container, TERMINAL } from "@oh-my-pi/pi-tui";
1
+ import { type Component, Container, type NativeScrollbackLiveRegion, TERMINAL } from "@oh-my-pi/pi-tui";
2
2
 
3
3
  const kSnapshot = Symbol("transcript.frozenRender");
4
4
 
@@ -34,10 +34,19 @@ interface SnapshotCarrier {
34
34
  * and any drift reconciles safely. On terminals that can rebuild history this
35
35
  * freezing is unnecessary, so it renders every block live for full fidelity.
36
36
  */
37
- export class TranscriptContainer extends Container {
37
+ export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion {
38
38
  // Bumped to invalidate every block's snapshot at once; a snapshot is only
39
39
  // honored when its stored generation still matches.
40
40
  #generation = 0;
41
+ // The block that was bottom-most (live) on the previous render. When the live
42
+ // position moves past it, its snapshot was last refreshed mid-stream and may
43
+ // predate content that finalized in the same coalesced frame that appended the
44
+ // block now below it — so it must recompute once on the live→frozen transition.
45
+ #prevLiveChild: Component | undefined;
46
+ // Local line index where the current bottom-most block begins in the most
47
+ // recent render. TUI extends the native-scrollback pinned region from this
48
+ // point through the live block and the root chrome rendered below it.
49
+ #nativeScrollbackLiveRegionStart: number | undefined;
41
50
 
42
51
  override invalidate(): void {
43
52
  // A theme/global invalidation forces a full recompute on the rebuild that
@@ -51,6 +60,10 @@ export class TranscriptContainer extends Container {
51
60
  super.clear();
52
61
  }
53
62
 
63
+ getNativeScrollbackLiveRegionStart(): number | undefined {
64
+ return this.#nativeScrollbackLiveRegionStart;
65
+ }
66
+
54
67
  /**
55
68
  * Retire all frozen snapshots so the next render reflects each block's current
56
69
  * state. Call at reconciliation checkpoints (prompt submit) where the whole
@@ -63,26 +76,42 @@ export class TranscriptContainer extends Container {
63
76
 
64
77
  override render(width: number): string[] {
65
78
  width = Math.max(1, width);
79
+ this.#nativeScrollbackLiveRegionStart = undefined;
66
80
  if (!TERMINAL.eagerEraseScrollbackRisk) return super.render(width);
67
81
 
68
82
  const lines: string[] = [];
69
83
  const liveIndex = this.children.length - 1;
84
+ const liveChild = this.children[liveIndex];
85
+ const prevLiveChild = this.#prevLiveChild;
86
+ this.#prevLiveChild = liveChild;
70
87
  for (let i = 0; i < this.children.length; i++) {
71
88
  const child = this.children[i]! as Component & SnapshotCarrier;
72
- if (i !== liveIndex) {
89
+ if (child === liveChild) {
90
+ this.#nativeScrollbackLiveRegionStart = lines.length;
91
+ } else {
73
92
  const snapshot = child[kSnapshot];
74
93
  // Replay the block's last render from while it was live. A stale
75
94
  // generation (post-thaw) or width mismatch (resize in flight, an
76
95
  // explicit rebuild that reconciles history anyway) recomputes instead.
77
- if (snapshot && snapshot.generation === this.#generation && snapshot.width === width) {
96
+ // The block that was live on the previous render is also recomputed
97
+ // here: TUI render coalescing can advance its content (final streamed
98
+ // tokens) in the very frame that appends the block now below it, so its
99
+ // cached snapshot predates that final content. Recomputing on the
100
+ // transition seals the block at its true final state, not a mid-stream one.
101
+ if (
102
+ child !== prevLiveChild &&
103
+ snapshot &&
104
+ snapshot.generation === this.#generation &&
105
+ snapshot.width === width
106
+ ) {
78
107
  lines.push(...snapshot.lines);
79
108
  continue;
80
109
  }
81
110
  }
82
111
  const rendered = child.render(width);
83
112
  // Cache every block's latest render. While a block is live this keeps its
84
- // snapshot current; the frame it stops being live the cache already holds
85
- // its final live render, so nothing recomputes underneath it.
113
+ // snapshot current; on the frame it stops being live the recompute above
114
+ // refreshes it to the final state before it freezes.
86
115
  child[kSnapshot] = { width, lines: rendered, generation: this.#generation };
87
116
  lines.push(...rendered);
88
117
  }