@oh-my-pi/pi-coding-agent 15.11.3 → 15.11.6

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 (135) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/dist/cli.js +692 -607
  3. package/dist/types/cli/usage-cli.d.ts +10 -1
  4. package/dist/types/commands/usage.d.ts +9 -0
  5. package/dist/types/config/api-key-resolver.d.ts +9 -3
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/model-discovery.d.ts +6 -4
  8. package/dist/types/config/model-registry.d.ts +7 -4
  9. package/dist/types/config/settings-schema.d.ts +508 -155
  10. package/dist/types/export/html/template.generated.d.ts +1 -1
  11. package/dist/types/mnemopi/config.d.ts +3 -1
  12. package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/components/settings-defs.d.ts +9 -2
  15. package/dist/types/modes/components/settings-selector.d.ts +9 -4
  16. package/dist/types/modes/components/tool-execution.d.ts +26 -1
  17. package/dist/types/modes/components/transcript-container.d.ts +12 -0
  18. package/dist/types/modes/controllers/input-controller.d.ts +9 -1
  19. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  20. package/dist/types/modes/interactive-mode.d.ts +10 -0
  21. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  22. package/dist/types/modes/theme/theme.d.ts +23 -3
  23. package/dist/types/modes/types.d.ts +2 -0
  24. package/dist/types/modes/utils/context-usage.d.ts +6 -1
  25. package/dist/types/session/agent-session.d.ts +28 -8
  26. package/dist/types/session/auth-storage.d.ts +1 -1
  27. package/dist/types/session/codex-auto-reset.d.ts +107 -0
  28. package/dist/types/session/snapcompact-inline.d.ts +129 -0
  29. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
  30. package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
  31. package/dist/types/system-prompt.d.ts +3 -1
  32. package/dist/types/task/render.d.ts +17 -6
  33. package/dist/types/tools/gh.d.ts +3 -0
  34. package/dist/types/tools/render-utils.d.ts +8 -16
  35. package/dist/types/tools/todo.d.ts +0 -11
  36. package/dist/types/utils/session-color.d.ts +15 -3
  37. package/dist/types/web/kagi.d.ts +1 -2
  38. package/dist/types/web/search/providers/codex.d.ts +1 -1
  39. package/dist/types/web/search/providers/gemini.d.ts +9 -6
  40. package/package.json +11 -11
  41. package/src/auto-thinking/classifier.ts +1 -5
  42. package/src/cli/usage-cli.ts +187 -16
  43. package/src/commands/usage.ts +8 -0
  44. package/src/commit/model-selection.ts +3 -6
  45. package/src/config/api-key-resolver.ts +10 -3
  46. package/src/config/keybindings.ts +1 -1
  47. package/src/config/model-discovery.ts +60 -46
  48. package/src/config/model-registry.ts +21 -8
  49. package/src/config/model-resolver.ts +57 -3
  50. package/src/config/settings-schema.ts +654 -153
  51. package/src/config/settings.ts +9 -0
  52. package/src/eval/completion-bridge.ts +1 -5
  53. package/src/export/html/template.generated.ts +1 -1
  54. package/src/export/html/template.js +13 -6
  55. package/src/internal-urls/docs-index.generated.ts +6 -6
  56. package/src/internal-urls/issue-pr-protocol.ts +10 -4
  57. package/src/memories/index.ts +2 -10
  58. package/src/mnemopi/backend.ts +30 -8
  59. package/src/mnemopi/config.ts +6 -1
  60. package/src/mnemopi/state.ts +6 -0
  61. package/src/modes/components/extensions/inspector-panel.ts +6 -2
  62. package/src/modes/components/plan-review-overlay.ts +15 -17
  63. package/src/modes/components/plugin-settings.ts +22 -5
  64. package/src/modes/components/reset-usage-selector.ts +161 -0
  65. package/src/modes/components/session-selector.ts +8 -2
  66. package/src/modes/components/settings-defs.ts +19 -4
  67. package/src/modes/components/settings-selector.ts +510 -95
  68. package/src/modes/components/status-line/component.ts +3 -1
  69. package/src/modes/components/status-line/segments.ts +3 -1
  70. package/src/modes/components/tool-execution.ts +87 -12
  71. package/src/modes/components/transcript-container.ts +49 -1
  72. package/src/modes/components/tree-selector.ts +16 -6
  73. package/src/modes/controllers/command-controller.ts +61 -8
  74. package/src/modes/controllers/event-controller.ts +1 -0
  75. package/src/modes/controllers/input-controller.ts +68 -6
  76. package/src/modes/controllers/selector-controller.ts +149 -61
  77. package/src/modes/interactive-mode.ts +63 -2
  78. package/src/modes/rpc/rpc-mode.ts +2 -1
  79. package/src/modes/session-observer-registry.ts +61 -3
  80. package/src/modes/shared.ts +2 -0
  81. package/src/modes/theme/theme.ts +102 -9
  82. package/src/modes/types.ts +2 -0
  83. package/src/modes/utils/context-usage.ts +78 -2
  84. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  85. package/src/modes/utils/ui-helpers.ts +9 -5
  86. package/src/prompts/system/personalities/default.md +26 -0
  87. package/src/prompts/system/personalities/friendly.md +17 -0
  88. package/src/prompts/system/personalities/pragmatic.md +15 -0
  89. package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
  90. package/src/prompts/system/snapcompact-context-stub.md +1 -0
  91. package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
  92. package/src/prompts/system/snapcompact-system-stub.md +1 -0
  93. package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
  94. package/src/prompts/system/system-prompt.md +5 -22
  95. package/src/prompts/tools/browser.md +33 -43
  96. package/src/prompts/tools/eval.md +27 -50
  97. package/src/prompts/tools/irc.md +29 -31
  98. package/src/prompts/tools/read.md +31 -37
  99. package/src/prompts/tools/task.md +3 -3
  100. package/src/prompts/tools/todo.md +1 -2
  101. package/src/sdk.ts +23 -1
  102. package/src/session/agent-session.ts +221 -29
  103. package/src/session/auth-storage.ts +4 -0
  104. package/src/session/codex-auto-reset.ts +190 -0
  105. package/src/session/session-dump-format.ts +8 -1
  106. package/src/session/session-manager.ts +5 -5
  107. package/src/session/snapcompact-inline.ts +524 -0
  108. package/src/slash-commands/builtin-registry.ts +145 -8
  109. package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
  110. package/src/slash-commands/helpers/context-report.ts +28 -1
  111. package/src/slash-commands/helpers/reset-usage.ts +66 -0
  112. package/src/slash-commands/helpers/usage-report.ts +36 -3
  113. package/src/system-prompt.ts +15 -1
  114. package/src/task/index.ts +30 -7
  115. package/src/task/render.ts +57 -32
  116. package/src/tool-discovery/tool-index.ts +2 -0
  117. package/src/tools/bash.ts +10 -3
  118. package/src/tools/eval-render.ts +13 -8
  119. package/src/tools/gh.ts +39 -1
  120. package/src/tools/image-gen.ts +114 -78
  121. package/src/tools/inspect-image.ts +1 -5
  122. package/src/tools/job.ts +25 -5
  123. package/src/tools/read.ts +1 -57
  124. package/src/tools/render-utils.ts +29 -31
  125. package/src/tools/ssh.ts +3 -3
  126. package/src/tools/todo.ts +8 -128
  127. package/src/tools/tts.ts +40 -20
  128. package/src/utils/clipboard.ts +56 -4
  129. package/src/utils/commit-message-generator.ts +1 -5
  130. package/src/utils/session-color.ts +83 -9
  131. package/src/utils/title-generator.ts +1 -1
  132. package/src/web/kagi.ts +26 -27
  133. package/src/web/search/providers/codex.ts +42 -40
  134. package/src/web/search/providers/gemini.ts +42 -22
  135. package/src/web/search/providers/perplexity.ts +22 -10
@@ -23,6 +23,7 @@ import {
23
23
  getOrFetchIssue,
24
24
  getOrFetchPr,
25
25
  getOrFetchPrDiff,
26
+ githubIssueJsonWithStateReasonFallback,
26
27
  type PrDiffFile,
27
28
  parsePositiveDecimalInt,
28
29
  resolveDefaultRepoMemoized,
@@ -294,7 +295,7 @@ async function fetchAndRenderList(
294
295
  const cwd = resolveCwd(context);
295
296
  const fields =
296
297
  scheme === "issue"
297
- ? ["number", "title", "state", "stateReason", "author", "labels", "createdAt", "updatedAt", "url"]
298
+ ? ["number", "title", "state", "author", "labels", "createdAt", "updatedAt", "url"]
298
299
  : [
299
300
  "number",
300
301
  "title",
@@ -323,9 +324,14 @@ async function fetchAndRenderList(
323
324
  if (options.author) args.push("--author", options.author);
324
325
  if (options.label) args.push("--label", options.label);
325
326
 
326
- const items = await git.github.json<Array<IssueListItem | PrListItem>>(cwd, args, context?.signal, {
327
- repoProvided: true,
328
- });
327
+ const items =
328
+ scheme === "issue"
329
+ ? await githubIssueJsonWithStateReasonFallback<Array<IssueListItem>>(cwd, args, context?.signal, {
330
+ repoProvided: true,
331
+ })
332
+ : await git.github.json<Array<PrListItem>>(cwd, args, context?.signal, {
333
+ repoProvided: true,
334
+ });
329
335
  const header =
330
336
  scheme === "issue"
331
337
  ? `# Issues in ${repo} (${options.state}, up to ${options.limit})`
@@ -274,11 +274,7 @@ async function runPhase1(options: {
274
274
  const result = await runStage1Job({
275
275
  claim,
276
276
  model: phase1Model,
277
- apiKey: modelRegistry.resolver(phase1Model.provider, {
278
- sessionId: session.sessionId,
279
- baseUrl: phase1Model.baseUrl,
280
- modelId: phase1Model.id,
281
- }),
277
+ apiKey: modelRegistry.resolver(phase1Model, session.sessionId),
282
278
  modelMaxTokens: computeModelTokenBudget(phase1Model, config),
283
279
  config,
284
280
  metadata: session.agent?.metadataForProvider(phase1Model.provider),
@@ -435,11 +431,7 @@ async function runPhase2(options: {
435
431
  const consolidated = await runConsolidationModel({
436
432
  memoryRoot,
437
433
  model: phase2Model,
438
- apiKey: modelRegistry.resolver(phase2Model.provider, {
439
- sessionId: session.sessionId,
440
- baseUrl: phase2Model.baseUrl,
441
- modelId: phase2Model.id,
442
- }),
434
+ apiKey: modelRegistry.resolver(phase2Model, session.sessionId),
443
435
  metadata: session.agent?.metadataForProvider(phase2Model.provider),
444
436
  });
445
437
  await applyConsolidation(memoryRoot, consolidated);
@@ -1,6 +1,7 @@
1
1
  import { rm } from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { completeSimple } from "@oh-my-pi/pi-ai";
3
+ import { type ApiKeyResolver, completeSimple } from "@oh-my-pi/pi-ai";
4
+ import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
4
5
  import type { Mnemopi } from "@oh-my-pi/pi-mnemopi";
5
6
  import type * as MnemopiDiagnoseNs from "@oh-my-pi/pi-mnemopi/diagnose";
6
7
  import type { DiagnosticSummary } from "@oh-my-pi/pi-mnemopi/diagnose";
@@ -433,6 +434,25 @@ async function loadMnemopiConfigWithProviders(
433
434
  return config;
434
435
  }
435
436
 
437
+ /**
438
+ * When mnemopi targets OpenRouter (its default embedding host) without a
439
+ * user-pinned key, hand it the central {@link ApiKeyResolver} so requests pick
440
+ * up AuthStorage credentials, force-refresh on 401, and rotate across sibling
441
+ * keys. Returns undefined when the URL points elsewhere or when no OpenRouter
442
+ * credential exists, preserving mnemopi's env-key fallback and its
443
+ * "no key -> API embeddings unavailable" gating.
444
+ */
445
+ async function openrouterKeyResolver(
446
+ modelRegistry: ModelRegistry,
447
+ sessionId: string,
448
+ baseUrl: string | undefined,
449
+ ): Promise<ApiKeyResolver | undefined> {
450
+ if (baseUrl !== undefined && !hostMatchesUrl(baseUrl, "openrouter")) return undefined;
451
+ const key = await modelRegistry.getApiKeyForProvider("openrouter", sessionId);
452
+ if (key === undefined || key === "") return undefined;
453
+ return modelRegistry.resolver("openrouter", { sessionId });
454
+ }
455
+
436
456
  async function resolveMnemopiProviderOptions(
437
457
  config: MnemopiBackendConfig,
438
458
  settings: MemoryBackendStartOptions["settings"],
@@ -443,7 +463,9 @@ async function resolveMnemopiProviderOptions(
443
463
  noEmbeddings: config.providerOptions.noEmbeddings,
444
464
  embeddingModel: config.providerOptions.embeddingModel,
445
465
  embeddingApiUrl: config.providerOptions.embeddingApiUrl,
446
- embeddingApiKey: config.providerOptions.embeddingApiKey,
466
+ embeddingApiKey:
467
+ config.providerOptions.embeddingApiKey ??
468
+ (await openrouterKeyResolver(modelRegistry, sessionId, config.providerOptions.embeddingApiUrl)),
447
469
  llm: false,
448
470
  };
449
471
 
@@ -469,7 +491,11 @@ async function resolveMnemopiProviderOptions(
469
491
  ...base,
470
492
  llm: {
471
493
  baseUrl: config.llmBaseUrl,
472
- apiKey: config.llmApiKey,
494
+ apiKey:
495
+ config.llmApiKey ??
496
+ (config.llmBaseUrl === undefined
497
+ ? undefined
498
+ : await openrouterKeyResolver(modelRegistry, sessionId, config.llmBaseUrl)),
473
499
  model: config.llmModel,
474
500
  },
475
501
  };
@@ -499,11 +525,7 @@ async function resolveMnemopiProviderOptions(
499
525
  messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
500
526
  },
501
527
  {
502
- apiKey: modelRegistry.resolver(model.provider, {
503
- sessionId,
504
- baseUrl: model.baseUrl,
505
- modelId: model.id,
506
- }),
528
+ apiKey: modelRegistry.resolver(model, sessionId),
507
529
  maxTokens: opts?.maxTokens,
508
530
  temperature: opts?.temperature,
509
531
  },
@@ -10,7 +10,7 @@ export type MnemopiScoping = "global" | "per-project" | "per-project-tagged";
10
10
 
11
11
  export type MnemopiProviderOptions = Pick<
12
12
  MnemopiOptions,
13
- "noEmbeddings" | "embeddingModel" | "embeddingApiUrl" | "embeddingApiKey" | "llm"
13
+ "noEmbeddings" | "embeddingModel" | "embeddingApiUrl" | "embeddingApiKey" | "llm" | "debug"
14
14
  >;
15
15
 
16
16
  export interface MnemopiBackendConfig {
@@ -23,6 +23,8 @@ export interface MnemopiBackendConfig {
23
23
  scoping?: MnemopiScoping;
24
24
  autoRecall: boolean;
25
25
  autoRetain: boolean;
26
+ polyphonicRecall: boolean;
27
+ enhancedRecall: boolean;
26
28
  retainEveryNTurns: number;
27
29
  recallLimit: number;
28
30
  recallContextTurns: number;
@@ -52,6 +54,8 @@ export function loadMnemopiConfig(settings: Settings, agentDir: string): Mnemopi
52
54
  scoping,
53
55
  autoRecall: settings.get("mnemopi.autoRecall"),
54
56
  autoRetain: settings.get("mnemopi.autoRetain"),
57
+ polyphonicRecall: settings.get("mnemopi.polyphonicRecall"),
58
+ enhancedRecall: settings.get("mnemopi.enhancedRecall"),
55
59
  retainEveryNTurns: Math.max(1, Math.floor(settings.get("mnemopi.retainEveryNTurns"))),
56
60
  recallLimit: Math.max(1, Math.floor(settings.get("mnemopi.recallLimit"))),
57
61
  recallContextTurns: Math.max(1, Math.floor(settings.get("mnemopi.recallContextTurns"))),
@@ -60,6 +64,7 @@ export function loadMnemopiConfig(settings: Settings, agentDir: string): Mnemopi
60
64
  debug: settings.get("mnemopi.debug"),
61
65
  providerOptions: {
62
66
  noEmbeddings: settings.get("mnemopi.noEmbeddings"),
67
+ debug: settings.get("mnemopi.debug"),
63
68
  embeddingModel: settings.get("mnemopi.embeddingModel"),
64
69
  embeddingApiUrl: settings.get("mnemopi.embeddingApiUrl"),
65
70
  embeddingApiKey: settings.get("mnemopi.embeddingApiKey"),
@@ -421,6 +421,12 @@ export class MnemopiSessionState {
421
421
  // `per-project-tagged` is implemented by opening both the project bank and the
422
422
  // shared bank, then merging recall results while keeping writes project-local.
423
423
  function createScopedResources(config: MnemopiBackendConfig): MnemopiScopedResources {
424
+ // Env vars (MNEMOPI_POLYPHONIC_RECALL / MNEMOPI_ENHANCED_RECALL) still override
425
+ // these config-driven defaults inside the core gates.
426
+ requireMnemopi().configureRecallFeatures({
427
+ polyphonicRecall: config.polyphonicRecall,
428
+ enhancedRecall: config.enhancedRecall,
429
+ });
424
430
  const banks = resolveScopedBanks(config);
425
431
  const memories = new Map<string, MnemopiScopedMemory>();
426
432
  const open = (bank: string): MnemopiScopedMemory => {
@@ -4,6 +4,7 @@
4
4
  * Shows name, description, origin, status, and kind-specific preview.
5
5
  */
6
6
  import * as os from "node:os";
7
+ import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
7
8
  import { type Component, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
8
9
  import { theme } from "../../../modes/theme/theme";
9
10
  import { shortenPath } from "../../../tools/render-utils";
@@ -168,12 +169,15 @@ export class InspectorPanel implements Component {
168
169
 
169
170
  try {
170
171
  const tool = raw as any;
171
- const params = tool?.parameters?.properties || tool?.inputSchema?.properties || {};
172
+ const wire = (s: unknown): any => (isZodSchema(s) ? zodToWireSchema(s) : s);
173
+ const paramSchema = wire(tool?.parameters);
174
+ const inputSchema = wire(tool?.inputSchema);
175
+ const params = paramSchema?.properties || inputSchema?.properties || {};
172
176
 
173
177
  if (Object.keys(params).length === 0) {
174
178
  lines.push(theme.fg("dim", " (no arguments)"));
175
179
  } else {
176
- const required = new Set(tool?.parameters?.required || tool?.inputSchema?.required || []);
180
+ const required = new Set(paramSchema?.required || inputSchema?.required || []);
177
181
 
178
182
  for (const [name, spec] of Object.entries(params)) {
179
183
  const param = spec as any;
@@ -23,6 +23,7 @@ import {
23
23
  Markdown,
24
24
  type MarkdownTheme,
25
25
  matchesKey,
26
+ parseSgrMouse,
26
27
  ScrollView,
27
28
  truncateToWidth,
28
29
  visibleWidth,
@@ -141,7 +142,7 @@ export class PlanReviewOverlay implements Component {
141
142
  #optionClickRows = new Map<number, number>();
142
143
  #tocClickRows = new Map<number, number>();
143
144
  #bodyClickRows = new Set<number>();
144
- /** 1-based column at/under which a region-row click targets the sidebar. */
145
+ /** Exclusive 0-based column bound below which a region-row click targets the sidebar. */
145
146
  #sidebarClickMaxCol = 0;
146
147
  /** Option index the pointer is currently hovering, or undefined. Updated from
147
148
  * motion mouse reports and cleared when the pointer leaves the option rows. */
@@ -332,26 +333,23 @@ export class PlanReviewOverlay implements Component {
332
333
  * the body.
333
334
  */
334
335
  #handleMouse(data: string): boolean {
335
- const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
336
- if (!match) return false;
337
- const button = Number(match[1]);
338
- const x = Number(match[2]);
339
- const row = Number(match[3]) - 1;
340
- if (button & 64) {
341
- // Scroll wheel: low bit selects direction (64 up, 65 down).
342
- this.#scrollView.scroll(button & 1 ? 3 : -3);
336
+ const event = parseSgrMouse(data);
337
+ if (!event) return false;
338
+ if (event.wheel !== null) {
339
+ // Scroll wheel: three rows per notch.
340
+ this.#scrollView.scroll(event.wheel * 3);
343
341
  return true;
344
342
  }
345
- if (match[4] !== "M") return true; // release
346
- if (button & 32) {
343
+ if (event.release) return true;
344
+ if (event.motion) {
347
345
  // Motion (hover or drag): light up the option row under the pointer so a
348
346
  // mouse user gets the same affordance the keyboard cursor gives. Any
349
347
  // non-option row clears the highlight.
350
- this.#setHoveredOption(this.#optionClickRows.get(row));
348
+ this.#setHoveredOption(this.#optionClickRows.get(event.row));
351
349
  return true;
352
350
  }
353
- if ((button & 3) !== 0) return true; // not the left button
354
- const optionIndex = this.#optionClickRows.get(row);
351
+ if (!event.leftClick) return true;
352
+ const optionIndex = this.#optionClickRows.get(event.row);
355
353
  if (optionIndex !== undefined) {
356
354
  if (!this.#disabled.has(optionIndex)) {
357
355
  this.#focus = "actions";
@@ -360,14 +358,14 @@ export class PlanReviewOverlay implements Component {
360
358
  }
361
359
  return true;
362
360
  }
363
- const tocPos = this.#tocClickRows.get(row);
364
- if (tocPos !== undefined && x <= this.#sidebarClickMaxCol) {
361
+ const tocPos = this.#tocClickRows.get(event.row);
362
+ if (tocPos !== undefined && event.col < this.#sidebarClickMaxCol) {
365
363
  this.#focus = "toc";
366
364
  this.#tocCursor = tocPos;
367
365
  this.#scrubBodyToToc();
368
366
  return true;
369
367
  }
370
- if (this.#bodyClickRows.has(row)) {
368
+ if (this.#bodyClickRows.has(event.row)) {
371
369
  this.#setFocus("body");
372
370
  }
373
371
  return true;
@@ -629,11 +629,18 @@ export class PluginSettingsComponent extends Container {
629
629
  this.#currentMarketplacePlugin = null;
630
630
  this.clear();
631
631
 
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.
632
+ // Surface registry failures without taking the whole tab down either
633
+ // registry can fail to load (corrupt JSON, missing project root) and the
634
+ // user still benefits from the other half. An uncaught rejection here
635
+ // would also leave the tab permanently blank: this method is invoked
636
+ // fire-and-forget from the constructor, so nothing awaits it.
635
637
  const [npmPlugins, marketplacePlugins] = await Promise.all([
636
- this.#manager.list(),
638
+ this.#manager.list().catch(err => {
639
+ logger.error("Settings → Plugins: failed to list npm plugins", {
640
+ error: err instanceof Error ? err.message : String(err),
641
+ });
642
+ return [] as InstalledPlugin[];
643
+ }),
637
644
  this.#buildMarketplaceManager()
638
645
  .then(mgr => mgr.listInstalledPlugins())
639
646
  .catch(err => {
@@ -717,6 +724,16 @@ export class PluginSettingsComponent extends Container {
717
724
  }
718
725
 
719
726
  handleInput(data: string): void {
720
- this.#viewComponent?.handleInput(data);
727
+ if (!this.#viewComponent) {
728
+ // The list view mounts asynchronously (npm + marketplace listing).
729
+ // Until it does — or if listing rejected and no view ever mounted —
730
+ // Escape must still close the panel instead of leaving /settings
731
+ // non-dismissible.
732
+ if (data === "\x1b" || data === "\x1b\x1b") {
733
+ this.callbacks.onClose();
734
+ }
735
+ return;
736
+ }
737
+ this.#viewComponent.handleInput(data);
721
738
  }
722
739
  }
@@ -0,0 +1,161 @@
1
+ import { Container, matchesKey, ScrollView, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
2
+ import { theme } from "../../modes/theme/theme";
3
+ import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
4
+ import type { ResetUsageAccount } from "../../slash-commands/helpers/reset-usage";
5
+ import { DynamicBorder } from "./dynamic-border";
6
+
7
+ const RESET_SELECTOR_MAX_VISIBLE = 10;
8
+
9
+ /**
10
+ * Account picker for `/usage reset`. Lists Codex accounts with their saved
11
+ * rate-limit reset counts; selecting one redeems a reset. Because a reset is a
12
+ * scarce, irreversible credit, Enter requires a second press to confirm.
13
+ */
14
+ export class ResetUsageSelectorComponent extends Container {
15
+ #listContainer: Container;
16
+ #accounts: ResetUsageAccount[];
17
+ #selectedIndex = 0;
18
+ #pendingIndex: number | null = null;
19
+ #statusMessage: string | undefined;
20
+ #onSelectCallback: (account: ResetUsageAccount) => void;
21
+ #onCancelCallback: () => void;
22
+
23
+ constructor(accounts: ResetUsageAccount[], onSelect: (account: ResetUsageAccount) => void, onCancel: () => void) {
24
+ super();
25
+ this.#accounts = accounts;
26
+ this.#onSelectCallback = onSelect;
27
+ this.#onCancelCallback = onCancel;
28
+ const firstRedeemable = accounts.findIndex(account => account.availableCount > 0);
29
+ this.#selectedIndex = firstRedeemable >= 0 ? firstRedeemable : 0;
30
+
31
+ this.addChild(new DynamicBorder());
32
+ this.addChild(new Spacer(1));
33
+ this.addChild(new TruncatedText(theme.bold("Spend a saved rate-limit reset:")));
34
+ this.addChild(new Spacer(1));
35
+ this.#listContainer = new Container();
36
+ this.addChild(this.#listContainer);
37
+ this.addChild(new Spacer(1));
38
+ this.addChild(new DynamicBorder());
39
+ this.#updateList();
40
+ }
41
+
42
+ #updateList(): void {
43
+ this.#listContainer.clear();
44
+
45
+ const total = this.#accounts.length;
46
+ const maxVisible = RESET_SELECTOR_MAX_VISIBLE;
47
+ const startIndex =
48
+ total <= maxVisible
49
+ ? 0
50
+ : Math.max(0, Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
51
+ const endIndex = Math.min(startIndex + maxVisible, total);
52
+
53
+ const rows: string[] = [];
54
+ for (let i = startIndex; i < endIndex; i++) {
55
+ const account = this.#accounts[i];
56
+ if (!account) continue;
57
+ const isSelected = i === this.#selectedIndex;
58
+ const redeemable = account.availableCount > 0;
59
+ const countLabel = account.error
60
+ ? account.error
61
+ : `${account.availableCount} saved reset${account.availableCount === 1 ? "" : "s"}`;
62
+ const countText = account.error
63
+ ? theme.fg("error", countLabel)
64
+ : redeemable
65
+ ? theme.fg("success", countLabel)
66
+ : theme.fg("dim", countLabel);
67
+ const activeTag = account.active ? theme.fg("muted", " (active)") : "";
68
+ if (isSelected) {
69
+ const name = redeemable ? theme.fg("accent", account.label) : theme.fg("dim", account.label);
70
+ rows.push(`${theme.fg("accent", `${theme.nav.cursor} `)}${name}${activeTag} ${countText}`);
71
+ } else {
72
+ const name = redeemable ? ` ${account.label}` : theme.fg("dim", ` ${account.label}`);
73
+ rows.push(`${name}${activeTag} ${countText}`);
74
+ }
75
+ }
76
+
77
+ if (rows.length > 0) {
78
+ const sv = new ScrollView(rows, {
79
+ height: rows.length,
80
+ scrollbar: "auto",
81
+ totalRows: total,
82
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
83
+ });
84
+ sv.setScrollOffset(startIndex);
85
+ this.#listContainer.addChild(sv);
86
+ }
87
+
88
+ if (total === 0) {
89
+ this.#listContainer.addChild(
90
+ new TruncatedText(theme.fg("muted", " No Codex accounts with saved resets"), 0, 0),
91
+ );
92
+ }
93
+
94
+ const pending = this.#pendingIndex !== null ? this.#accounts[this.#pendingIndex] : undefined;
95
+ const hint = pending
96
+ ? theme.fg("warning", ` Press Enter again to spend 1 reset for ${pending.label}, Esc to cancel`)
97
+ : theme.fg("muted", " ↑/↓ select · ↵ spend a reset · Esc cancel");
98
+ this.#listContainer.addChild(new TruncatedText(hint, 0, 0));
99
+
100
+ if (this.#statusMessage) {
101
+ this.#listContainer.addChild(new Spacer(1));
102
+ this.#listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.#statusMessage}`), 0, 0));
103
+ }
104
+ }
105
+
106
+ handleInput(keyData: string): void {
107
+ if (matchesSelectCancel(keyData)) {
108
+ if (this.#pendingIndex !== null) {
109
+ this.#pendingIndex = null;
110
+ this.#statusMessage = undefined;
111
+ this.#updateList();
112
+ return;
113
+ }
114
+ this.#onCancelCallback();
115
+ return;
116
+ }
117
+
118
+ if (matchesSelectUp(keyData)) {
119
+ if (this.#accounts.length > 0) {
120
+ this.#selectedIndex = this.#selectedIndex === 0 ? this.#accounts.length - 1 : this.#selectedIndex - 1;
121
+ }
122
+ this.#pendingIndex = null;
123
+ this.#statusMessage = undefined;
124
+ this.#updateList();
125
+ } else if (matchesSelectDown(keyData)) {
126
+ if (this.#accounts.length > 0) {
127
+ this.#selectedIndex = this.#selectedIndex === this.#accounts.length - 1 ? 0 : this.#selectedIndex + 1;
128
+ }
129
+ this.#pendingIndex = null;
130
+ this.#statusMessage = undefined;
131
+ this.#updateList();
132
+ } else if (matchesKey(keyData, "pageUp")) {
133
+ if (this.#accounts.length > 0) {
134
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - RESET_SELECTOR_MAX_VISIBLE);
135
+ }
136
+ this.#pendingIndex = null;
137
+ this.#updateList();
138
+ } else if (matchesKey(keyData, "pageDown")) {
139
+ if (this.#accounts.length > 0) {
140
+ this.#selectedIndex = Math.min(this.#accounts.length - 1, this.#selectedIndex + RESET_SELECTOR_MAX_VISIBLE);
141
+ }
142
+ this.#pendingIndex = null;
143
+ this.#updateList();
144
+ } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
145
+ const account = this.#accounts[this.#selectedIndex];
146
+ if (!account) return;
147
+ if (account.availableCount <= 0) {
148
+ this.#statusMessage = "That account has no saved resets to spend.";
149
+ this.#updateList();
150
+ return;
151
+ }
152
+ if (this.#pendingIndex === this.#selectedIndex) {
153
+ this.#onSelectCallback(account);
154
+ return;
155
+ }
156
+ this.#pendingIndex = this.#selectedIndex;
157
+ this.#statusMessage = undefined;
158
+ this.#updateList();
159
+ }
160
+ }
161
+ }
@@ -67,13 +67,15 @@ function compareSessionRecency(a: SessionInfo, b: SessionInfo): number {
67
67
  return b.modified.getTime() - a.modified.getTime();
68
68
  }
69
69
 
70
+ const MIN_PURE_FUZZY_TOKEN_SCORE = -20;
71
+
70
72
  /**
71
73
  * Filter and rank session picker search results.
72
74
  *
73
75
  * Resume search narrows a recency-sorted list: once every query token appears
74
76
  * as a literal substring, newer sessions should beat a slightly better fuzzy
75
77
  * position match. Pure fuzzy/acronym matches still sort by fuzzy score after
76
- * literal matches.
78
+ * literal matches, but weak pure fuzzy tokens are dropped as noise.
77
79
  */
78
80
  export function rankSessionSearchMatches(allSessions: SessionInfo[], query: string): SessionInfo[] {
79
81
  const tokens = tokenizeSessionQuery(query);
@@ -85,6 +87,7 @@ export function rankSessionSearchMatches(allSessions: SessionInfo[], query: stri
85
87
  const text = sessionSearchText(session);
86
88
  const textLower = text.toLowerCase();
87
89
  let score = 0;
90
+ let worstTokenScore = Number.NEGATIVE_INFINITY;
88
91
  let literal = true;
89
92
  let matches = true;
90
93
 
@@ -95,10 +98,13 @@ export function rankSessionSearchMatches(allSessions: SessionInfo[], query: stri
95
98
  break;
96
99
  }
97
100
  score += match.score;
101
+ worstTokenScore = Math.max(worstTokenScore, match.score);
98
102
  if (!textLower.includes(token)) literal = false;
99
103
  }
100
104
 
101
- if (matches) results.push({ session, score, literal, index });
105
+ if (matches && (literal || worstTokenScore < MIN_PURE_FUZZY_TOKEN_SCORE)) {
106
+ results.push({ session, score, literal, index });
107
+ }
102
108
  }
103
109
 
104
110
  results.sort((a, b) => {
@@ -4,7 +4,8 @@
4
4
  * settings selector.
5
5
  *
6
6
  * To add a new setting to the UI: declare it in `settings-schema.ts`
7
- * with a `ui` block. If it needs a submenu, include `options: [...]`
7
+ * with a `ui` block carrying `tab` and `group` (the group must be listed
8
+ * in `TAB_GROUPS[tab]`). If it needs a submenu, include `options: [...]`
8
9
  * (or `options: "runtime"` for runtime-injected lists like themes).
9
10
  */
10
11
 
@@ -21,6 +22,7 @@ import {
21
22
  type SettingPath,
22
23
  type SettingTab,
23
24
  type SubmenuOption,
25
+ TAB_GROUPS,
24
26
  } from "../../config/settings-schema";
25
27
 
26
28
  // ═══════════════════════════════════════════════════════════════════════════
@@ -34,6 +36,8 @@ interface BaseSettingDef {
34
36
  label: string;
35
37
  description: string;
36
38
  tab: SettingTab;
39
+ /** Section within the tab; items are ordered by TAB_GROUPS[tab] and rendered under a heading row. */
40
+ group?: string;
37
41
  /**
38
42
  * Optional visibility predicate. When supplied and returning false, the
39
43
  * setting is hidden from the UI. Applies to every variant — booleans,
@@ -111,7 +115,7 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
111
115
 
112
116
  const schemaType = getType(path);
113
117
  const condition = ui.condition ? CONDITIONS[ui.condition] : undefined;
114
- const base = { path, label: ui.label, description: ui.description, tab: ui.tab, condition };
118
+ const base = { path, label: ui.label, description: ui.description, tab: ui.tab, group: ui.group, condition };
115
119
 
116
120
  if (schemaType === "boolean") {
117
121
  return { ...base, type: "boolean" };
@@ -170,9 +174,20 @@ export function getAllSettingDefs(): SettingDef[] {
170
174
  return defs;
171
175
  }
172
176
 
173
- /** Get settings for a specific tab */
177
+ /**
178
+ * Get settings for a specific tab, ordered by the tab's group layout
179
+ * (TAB_GROUPS). Ungrouped settings sort first; within a group, schema
180
+ * declaration order is preserved.
181
+ */
174
182
  export function getSettingsForTab(tab: SettingTab): SettingDef[] {
175
- return getAllSettingDefs().filter(def => def.tab === tab);
183
+ const defs = getAllSettingDefs().filter(def => def.tab === tab);
184
+ const order = TAB_GROUPS[tab];
185
+ const rank = (def: SettingDef): number => {
186
+ if (!def.group) return -1;
187
+ const index = order.indexOf(def.group);
188
+ return index >= 0 ? index : order.length;
189
+ };
190
+ return defs.sort((a, b) => rank(a) - rank(b));
176
191
  }
177
192
 
178
193
  /** Get a setting definition by path */