@oh-my-pi/pi-coding-agent 13.11.1 → 13.12.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 (75) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/package.json +7 -7
  3. package/src/capability/context-file.ts +2 -0
  4. package/src/capability/extension-module.ts +1 -0
  5. package/src/capability/hook.ts +1 -0
  6. package/src/capability/index.ts +21 -10
  7. package/src/capability/instruction.ts +1 -0
  8. package/src/capability/mcp.ts +1 -0
  9. package/src/capability/prompt.ts +1 -0
  10. package/src/capability/rule.ts +5 -0
  11. package/src/capability/skill.ts +1 -0
  12. package/src/capability/slash-command.ts +1 -0
  13. package/src/capability/tool.ts +1 -0
  14. package/src/capability/types.ts +10 -0
  15. package/src/cli/commands/init-xdg.ts +27 -0
  16. package/src/cli/config-cli.ts +8 -3
  17. package/src/cli/shell-cli.ts +1 -1
  18. package/src/commands/config.ts +1 -1
  19. package/src/config/model-registry.ts +63 -10
  20. package/src/config/model-resolver.ts +84 -21
  21. package/src/config/settings-schema.ts +977 -769
  22. package/src/discovery/helpers.ts +8 -2
  23. package/src/exec/bash-executor.ts +62 -25
  24. package/src/extensibility/custom-tools/types.ts +2 -3
  25. package/src/extensibility/extensions/loader.ts +5 -1
  26. package/src/extensibility/extensions/types.ts +2 -0
  27. package/src/extensibility/hooks/types.ts +2 -0
  28. package/src/extensibility/plugins/loader.ts +23 -5
  29. package/src/extensibility/plugins/manager.ts +14 -0
  30. package/src/extensibility/plugins/types.ts +4 -0
  31. package/src/extensibility/skills.ts +7 -1
  32. package/src/index.ts +6 -6
  33. package/src/internal-urls/docs-index.generated.ts +2 -2
  34. package/src/ipy/kernel.ts +4 -5
  35. package/src/memories/index.ts +20 -7
  36. package/src/memories/storage.ts +46 -32
  37. package/src/modes/components/agent-dashboard.ts +23 -35
  38. package/src/modes/components/assistant-message.ts +25 -2
  39. package/src/modes/components/btw-panel.ts +104 -0
  40. package/src/modes/components/diff.ts +2 -7
  41. package/src/modes/components/extensions/state-manager.ts +3 -2
  42. package/src/modes/components/settings-defs.ts +56 -6
  43. package/src/modes/components/settings-selector.ts +11 -6
  44. package/src/modes/controllers/btw-controller.ts +193 -0
  45. package/src/modes/controllers/command-controller.ts +9 -3
  46. package/src/modes/controllers/event-controller.ts +4 -0
  47. package/src/modes/controllers/input-controller.ts +10 -1
  48. package/src/modes/interactive-mode.ts +22 -0
  49. package/src/modes/prompt-action-autocomplete.ts +17 -3
  50. package/src/modes/rpc/rpc-client.ts +30 -19
  51. package/src/modes/theme/theme.ts +28 -36
  52. package/src/modes/types.ts +4 -0
  53. package/src/modes/utils/ui-helpers.ts +3 -0
  54. package/src/patch/diff.ts +9 -1
  55. package/src/patch/index.ts +56 -9
  56. package/src/prompts/system/btw-user.md +8 -0
  57. package/src/prompts/system/custom-system-prompt.md +1 -1
  58. package/src/prompts/system/system-prompt.md +1 -0
  59. package/src/sdk.ts +23 -26
  60. package/src/session/agent-session.ts +65 -37
  61. package/src/session/blob-store.ts +32 -0
  62. package/src/session/compaction/compaction.ts +37 -6
  63. package/src/session/history-storage.ts +2 -2
  64. package/src/session/session-manager.ts +129 -49
  65. package/src/slash-commands/builtin-registry.ts +11 -0
  66. package/src/system-prompt.ts +4 -17
  67. package/src/task/agents.ts +1 -1
  68. package/src/task/index.ts +9 -8
  69. package/src/tools/browser.ts +11 -0
  70. package/src/tools/output-meta.ts +103 -3
  71. package/src/tools/path-utils.ts +11 -0
  72. package/src/utils/title-generator.ts +70 -92
  73. package/src/utils/tools-manager.ts +1 -1
  74. package/src/web/scrapers/index.ts +7 -7
  75. package/src/web/scrapers/utils.ts +1 -0
@@ -34,9 +34,16 @@ export interface GlobalClaim {
34
34
 
35
35
  const STAGE1_KIND = "memory_stage1";
36
36
  const GLOBAL_KIND = "memory_consolidate_global";
37
- const GLOBAL_KEY = "global";
38
37
  const DEFAULT_RETRY_REMAINING = 3;
39
38
 
39
+ /**
40
+ * Per-project job key so Phase 2 consolidation is isolated to a single cwd.
41
+ * Previously a single "global" key caused cross-project memory contamination.
42
+ */
43
+ function globalJobKey(cwd: string): string {
44
+ return `global:${cwd}`;
45
+ }
46
+
40
47
  export function openMemoryDb(dbPath: string): Database {
41
48
  const db = new Database(dbPath);
42
49
  db.exec(`
@@ -119,11 +126,11 @@ VALUES (?, ?, 'pending', ?, 0, 0)
119
126
  `).run(STAGE1_KIND, threadId, DEFAULT_RETRY_REMAINING);
120
127
  }
121
128
 
122
- function ensureGlobalJob(db: Database): void {
129
+ function ensureGlobalJob(db: Database, cwd: string): void {
123
130
  db.prepare(`
124
131
  INSERT OR IGNORE INTO jobs (kind, job_key, status, retry_remaining, input_watermark, last_success_watermark)
125
132
  VALUES (?, ?, 'pending', ?, 0, 0)
126
- `).run(GLOBAL_KIND, GLOBAL_KEY, DEFAULT_RETRY_REMAINING);
133
+ `).run(GLOBAL_KIND, globalJobKey(cwd), DEFAULT_RETRY_REMAINING);
127
134
  }
128
135
 
129
136
  export function claimStage1Jobs(
@@ -240,10 +247,11 @@ WHERE kind = ? AND job_key = ?
240
247
  export function enqueueGlobalWatermark(
241
248
  db: Database,
242
249
  sourceUpdatedAt: number,
250
+ cwd: string,
243
251
  params?: { forceDirtyWhenNotAdvanced?: boolean },
244
252
  ): void {
245
253
  const forceDirtyWhenNotAdvanced = params?.forceDirtyWhenNotAdvanced ?? false;
246
- ensureGlobalJob(db);
254
+ ensureGlobalJob(db, cwd);
247
255
  db.prepare(`
248
256
  UPDATE jobs
249
257
  SET
@@ -283,7 +291,7 @@ WHERE kind = ? AND job_key = ?
283
291
  sourceUpdatedAt,
284
292
  forceDirtyWhenNotAdvanced ? 1 : 0,
285
293
  GLOBAL_KIND,
286
- GLOBAL_KEY,
294
+ globalJobKey(cwd),
287
295
  );
288
296
  }
289
297
 
@@ -297,9 +305,10 @@ export function markStage1SucceededWithOutput(
297
305
  rolloutSummary: string;
298
306
  rolloutSlug: string | null;
299
307
  nowSec: number;
308
+ cwd: string;
300
309
  },
301
310
  ): boolean {
302
- const { threadId, ownershipToken, sourceUpdatedAt, rawMemory, rolloutSummary, rolloutSlug, nowSec } = params;
311
+ const { threadId, ownershipToken, sourceUpdatedAt, rawMemory, rolloutSummary, rolloutSlug, nowSec, cwd } = params;
303
312
  const tx = db.transaction(() => {
304
313
  const matched = db
305
314
  .prepare(
@@ -327,7 +336,7 @@ ON CONFLICT(thread_id) DO UPDATE SET
327
336
  WHERE excluded.source_updated_at >= stage1_outputs.source_updated_at
328
337
  `).run(threadId, sourceUpdatedAt, rawMemory, rolloutSummary, rolloutSlug, nowSec);
329
338
 
330
- enqueueGlobalWatermark(db, sourceUpdatedAt, { forceDirtyWhenNotAdvanced: true });
339
+ enqueueGlobalWatermark(db, sourceUpdatedAt, cwd, { forceDirtyWhenNotAdvanced: true });
331
340
  return true;
332
341
  });
333
342
  return tx() as boolean;
@@ -335,9 +344,9 @@ WHERE excluded.source_updated_at >= stage1_outputs.source_updated_at
335
344
 
336
345
  export function markStage1SucceededNoOutput(
337
346
  db: Database,
338
- params: { threadId: string; ownershipToken: string; sourceUpdatedAt: number; nowSec: number },
347
+ params: { threadId: string; ownershipToken: string; sourceUpdatedAt: number; nowSec: number; cwd: string },
339
348
  ): boolean {
340
- const { threadId, ownershipToken, sourceUpdatedAt, nowSec } = params;
349
+ const { threadId, ownershipToken, sourceUpdatedAt, nowSec, cwd } = params;
341
350
  const tx = db.transaction(() => {
342
351
  const matched = db
343
352
  .prepare(
@@ -354,7 +363,7 @@ WHERE kind = ? AND job_key = ? AND ownership_token = ?
354
363
  `).run(nowSec, STAGE1_KIND, threadId, ownershipToken);
355
364
 
356
365
  db.prepare("DELETE FROM stage1_outputs WHERE thread_id = ?").run(threadId);
357
- enqueueGlobalWatermark(db, sourceUpdatedAt, { forceDirtyWhenNotAdvanced: true });
366
+ enqueueGlobalWatermark(db, sourceUpdatedAt, cwd, { forceDirtyWhenNotAdvanced: true });
358
367
  return true;
359
368
  });
360
369
  return tx() as boolean;
@@ -379,15 +388,16 @@ WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?
379
388
 
380
389
  export function tryClaimGlobalPhase2Job(
381
390
  db: Database,
382
- params: { workerId: string; leaseSeconds: number; nowSec: number },
391
+ params: { workerId: string; leaseSeconds: number; nowSec: number; cwd: string },
383
392
  ): { kind: "claimed"; claim: GlobalClaim } | { kind: "skipped_not_dirty" } | { kind: "skipped_running" } {
384
- const { workerId, leaseSeconds, nowSec } = params;
385
- ensureGlobalJob(db);
393
+ const { workerId, leaseSeconds, nowSec, cwd } = params;
394
+ const jobKey = globalJobKey(cwd);
395
+ ensureGlobalJob(db, cwd);
386
396
  const pre = db
387
397
  .prepare(
388
398
  "SELECT status, lease_until, input_watermark, last_success_watermark, retry_at, retry_remaining FROM jobs WHERE kind = ? AND job_key = ?",
389
399
  )
390
- .get(GLOBAL_KIND, GLOBAL_KEY) as
400
+ .get(GLOBAL_KIND, jobKey) as
391
401
  | {
392
402
  status: string;
393
403
  lease_until: number | null;
@@ -410,11 +420,11 @@ WHERE kind = ? AND job_key = ?
410
420
  AND retry_remaining > 0
411
421
  AND (retry_at IS NULL OR retry_at <= ?)
412
422
  `)
413
- .run(workerId, ownershipToken, nowSec, nowSec + leaseSeconds, GLOBAL_KIND, GLOBAL_KEY, nowSec, nowSec);
423
+ .run(workerId, ownershipToken, nowSec, nowSec + leaseSeconds, GLOBAL_KIND, jobKey, nowSec, nowSec);
414
424
  if (Number(claimed.changes ?? 0) > 0) {
415
425
  const row = db
416
426
  .prepare("SELECT input_watermark FROM jobs WHERE kind = ? AND job_key = ? AND ownership_token = ?")
417
- .get(GLOBAL_KIND, GLOBAL_KEY, ownershipToken) as { input_watermark: number | null } | undefined;
427
+ .get(GLOBAL_KIND, jobKey, ownershipToken) as { input_watermark: number | null } | undefined;
418
428
  return {
419
429
  kind: "claimed",
420
430
  claim: {
@@ -443,7 +453,7 @@ WHERE kind = ? AND job_key = ?
443
453
  .prepare(
444
454
  "SELECT status, lease_until, input_watermark, last_success_watermark, retry_at, retry_remaining FROM jobs WHERE kind = ? AND job_key = ?",
445
455
  )
446
- .get(GLOBAL_KIND, GLOBAL_KEY) as
456
+ .get(GLOBAL_KIND, jobKey) as
447
457
  | {
448
458
  status: string;
449
459
  lease_until: number | null;
@@ -463,30 +473,34 @@ WHERE kind = ? AND job_key = ?
463
473
 
464
474
  export function heartbeatGlobalJob(
465
475
  db: Database,
466
- params: { ownershipToken: string; leaseSeconds: number; nowSec: number },
476
+ params: { ownershipToken: string; leaseSeconds: number; nowSec: number; cwd: string },
467
477
  ): boolean {
468
- const { ownershipToken, leaseSeconds, nowSec } = params;
478
+ const { ownershipToken, leaseSeconds, nowSec, cwd } = params;
469
479
  const result = db
470
480
  .prepare(`
471
481
  UPDATE jobs
472
482
  SET lease_until = ?
473
483
  WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?
474
484
  `)
475
- .run(nowSec + leaseSeconds, GLOBAL_KIND, GLOBAL_KEY, ownershipToken);
485
+ .run(nowSec + leaseSeconds, GLOBAL_KIND, globalJobKey(cwd), ownershipToken);
476
486
  return Number(result.changes ?? 0) > 0;
477
487
  }
478
488
 
479
- export function listStage1OutputsForGlobal(db: Database, limit: number): Stage1OutputRow[] {
489
+ // Filter by cwd so each project only consolidates its own thread outputs.
490
+ // Before this filter existed, whichever project ran Phase 2 first got every
491
+ // project's data written into its memory directory (see #369).
492
+ export function listStage1OutputsForGlobal(db: Database, limit: number, cwd: string): Stage1OutputRow[] {
480
493
  const rows = db
481
494
  .prepare(`
482
495
  SELECT o.thread_id, o.source_updated_at, o.raw_memory, o.rollout_summary, o.rollout_slug, o.generated_at, t.cwd
483
496
  FROM stage1_outputs o
484
497
  LEFT JOIN threads t ON t.id = o.thread_id
485
- WHERE TRIM(COALESCE(o.raw_memory, '')) != '' OR TRIM(COALESCE(o.rollout_summary, '')) != ''
498
+ WHERE (TRIM(COALESCE(o.raw_memory, '')) != '' OR TRIM(COALESCE(o.rollout_summary, '')) != '')
499
+ AND t.cwd = ?
486
500
  ORDER BY o.source_updated_at DESC
487
501
  LIMIT ?
488
502
  `)
489
- .all(limit) as Array<{
503
+ .all(cwd, limit) as Array<{
490
504
  thread_id: string;
491
505
  source_updated_at: number;
492
506
  raw_memory: string;
@@ -508,9 +522,9 @@ LIMIT ?
508
522
 
509
523
  export function markGlobalPhase2Succeeded(
510
524
  db: Database,
511
- params: { ownershipToken: string; newWatermark: number; nowSec: number },
525
+ params: { ownershipToken: string; newWatermark: number; nowSec: number; cwd: string },
512
526
  ): boolean {
513
- const { ownershipToken, newWatermark, nowSec } = params;
527
+ const { ownershipToken, newWatermark, nowSec, cwd } = params;
514
528
  const result = db
515
529
  .prepare(`
516
530
  UPDATE jobs
@@ -523,15 +537,15 @@ SET status = 'done', finished_at = ?, lease_until = NULL, retry_at = NULL,
523
537
  END
524
538
  WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?
525
539
  `)
526
- .run(nowSec, newWatermark, newWatermark, newWatermark, GLOBAL_KIND, GLOBAL_KEY, ownershipToken);
540
+ .run(nowSec, newWatermark, newWatermark, newWatermark, GLOBAL_KIND, globalJobKey(cwd), ownershipToken);
527
541
  return Number(result.changes ?? 0) > 0;
528
542
  }
529
543
 
530
544
  export function markGlobalPhase2Failed(
531
545
  db: Database,
532
- params: { ownershipToken: string; retryDelaySeconds: number; reason: string; nowSec: number },
546
+ params: { ownershipToken: string; retryDelaySeconds: number; reason: string; nowSec: number; cwd: string },
533
547
  ): boolean {
534
- const { ownershipToken, retryDelaySeconds, reason, nowSec } = params;
548
+ const { ownershipToken, retryDelaySeconds, reason, nowSec, cwd } = params;
535
549
  const result = db
536
550
  .prepare(`
537
551
  UPDATE jobs
@@ -540,15 +554,15 @@ SET status = 'error', finished_at = ?, lease_until = NULL, retry_at = ?,
540
554
  last_error = ?
541
555
  WHERE kind = ? AND job_key = ? AND status = 'running' AND ownership_token = ?
542
556
  `)
543
- .run(nowSec, nowSec + retryDelaySeconds, reason, GLOBAL_KIND, GLOBAL_KEY, ownershipToken);
557
+ .run(nowSec, nowSec + retryDelaySeconds, reason, GLOBAL_KIND, globalJobKey(cwd), ownershipToken);
544
558
  return Number(result.changes ?? 0) > 0;
545
559
  }
546
560
 
547
561
  export function markGlobalPhase2FailedUnowned(
548
562
  db: Database,
549
- params: { retryDelaySeconds: number; reason: string; nowSec: number },
563
+ params: { retryDelaySeconds: number; reason: string; nowSec: number; cwd: string },
550
564
  ): boolean {
551
- const { retryDelaySeconds, reason, nowSec } = params;
565
+ const { retryDelaySeconds, reason, nowSec, cwd } = params;
552
566
  const result = db
553
567
  .prepare(`
554
568
  UPDATE jobs
@@ -558,6 +572,6 @@ SET status = 'error', finished_at = ?, lease_until = NULL, retry_at = ?,
558
572
  WHERE kind = ? AND job_key = ? AND status = 'running'
559
573
  AND (ownership_token IS NULL OR lease_until IS NULL OR lease_until <= ?)
560
574
  `)
561
- .run(nowSec, nowSec + retryDelaySeconds, reason, GLOBAL_KIND, GLOBAL_KEY, nowSec);
575
+ .run(nowSec, nowSec + retryDelaySeconds, reason, GLOBAL_KIND, globalJobKey(cwd), nowSec);
562
576
  return Number(result.changes ?? 0) > 0;
563
577
  }
@@ -35,7 +35,12 @@ import { isEnoent } from "@oh-my-pi/pi-utils";
35
35
  import { YAML } from "bun";
36
36
  import { getConfigDirs } from "../../config";
37
37
  import type { ModelRegistry } from "../../config/model-registry";
38
- import { formatModelString, isDefaultModelAlias, resolveModelOverride } from "../../config/model-resolver";
38
+ import {
39
+ formatModelString,
40
+ resolveAgentModelPatterns,
41
+ resolveConfiguredModelPatterns,
42
+ resolveModelOverride,
43
+ } from "../../config/model-resolver";
39
44
  import { renderPromptTemplate } from "../../config/prompt-templates";
40
45
  import { Settings } from "../../config/settings";
41
46
  import agentCreationArchitectPrompt from "../../prompts/system/agent-creation-architect.md" with { type: "text" };
@@ -93,21 +98,8 @@ const SOURCE_LABEL: Record<AgentSource, string> = {
93
98
 
94
99
  const IDENTIFIER_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+){1,5}$/;
95
100
 
96
- function normalizeModelPatterns(value: string | string[] | undefined): string[] {
97
- if (Array.isArray(value)) {
98
- return value.map(pattern => pattern.trim()).filter(pattern => pattern.length > 0);
99
- }
100
- if (typeof value === "string") {
101
- const normalized = value.trim();
102
- if (normalized.length > 0) {
103
- return [normalized];
104
- }
105
- }
106
- return [];
107
- }
108
-
109
101
  function joinPatterns(patterns: string[]): string {
110
- if (patterns.length === 0) return "(session default)";
102
+ if (patterns.length === 0) return "(session model)";
111
103
  return patterns.join(", ");
112
104
  }
113
105
 
@@ -398,8 +390,7 @@ export class AgentDashboard extends Container {
398
390
  const activeTabId = this.#tabs[this.#activeTabIndex]?.id ?? "all";
399
391
  const { agents } = await discoverAgents(this.cwd);
400
392
  const disabled = new Set((this.#settingsManager?.get("task.disabledAgents") as string[] | undefined) ?? []);
401
- const overrides =
402
- (this.#settingsManager?.get("task.agentModelOverrides") as Record<string, string> | undefined) ?? {};
393
+ const overrides = this.#settingsManager?.get("task.agentModelOverrides") ?? {};
403
394
 
404
395
  this.#allAgents = agents
405
396
  .slice()
@@ -622,10 +613,11 @@ export class AgentDashboard extends Container {
622
613
  await modelRegistry.refresh();
623
614
 
624
615
  const settings = this.#settingsManager ?? undefined;
625
- const modelPatterns = normalizeModelPatterns(
616
+ const modelPatterns = resolveConfiguredModelPatterns(
626
617
  this.modelContext.activeModelPattern ??
627
618
  this.modelContext.defaultModelPattern ??
628
619
  settings?.getModelRole("default"),
620
+ settings,
629
621
  );
630
622
  const { model } = resolveModelOverride(modelPatterns, modelRegistry, settings);
631
623
  const fallbackModel = modelRegistry.getAvailable()[0];
@@ -750,26 +742,22 @@ export class AgentDashboard extends Container {
750
742
  }
751
743
 
752
744
  #defaultPatternsFor(agent: DashboardAgent): string[] {
753
- const explicitAgentPatterns = isDefaultModelAlias(agent.model) ? [] : normalizeModelPatterns(agent.model);
754
- if (explicitAgentPatterns.length > 0) {
755
- return explicitAgentPatterns;
756
- }
757
-
758
- const fallback =
759
- this.modelContext.activeModelPattern?.trim() ||
760
- this.modelContext.defaultModelPattern?.trim() ||
761
- this.#settingsManager?.getModelRole("default")?.trim() ||
762
- "";
763
- if (!fallback) return [];
764
- return normalizeModelPatterns(fallback);
745
+ return resolveAgentModelPatterns({
746
+ agentModel: agent.model,
747
+ settings: this.#settingsManager ?? undefined,
748
+ activeModelPattern: this.modelContext.activeModelPattern,
749
+ fallbackModelPattern: this.modelContext.defaultModelPattern,
750
+ });
765
751
  }
766
752
 
767
753
  #effectivePatternsFor(agent: DashboardAgent, draftOverride: string | undefined): string[] {
768
- const override = draftOverride?.trim() || "";
769
- if (override.length > 0) {
770
- return [override];
771
- }
772
- return this.#defaultPatternsFor(agent);
754
+ return resolveAgentModelPatterns({
755
+ settingsOverride: draftOverride,
756
+ agentModel: agent.model,
757
+ settings: this.#settingsManager ?? undefined,
758
+ activeModelPattern: this.modelContext.activeModelPattern,
759
+ fallbackModelPattern: this.modelContext.defaultModelPattern,
760
+ });
773
761
  }
774
762
 
775
763
  #resolvePatterns(patterns: string[]): ModelResolution | undefined {
@@ -1,6 +1,7 @@
1
- import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
1
+ import type { AssistantMessage, ImageContent, Usage } from "@oh-my-pi/pi-ai";
2
2
  import { Container, Image, ImageProtocol, Markdown, Spacer, TERMINAL, Text } from "@oh-my-pi/pi-tui";
3
- import { logger } from "@oh-my-pi/pi-utils";
3
+ import { formatNumber, logger } from "@oh-my-pi/pi-utils";
4
+ import { settings } from "../../config/settings";
4
5
  import { hasPendingMermaid, prerenderMermaid } from "../../modes/theme/mermaid-cache";
5
6
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
6
7
 
@@ -12,6 +13,7 @@ export class AssistantMessageComponent extends Container {
12
13
  #lastMessage?: AssistantMessage;
13
14
  #prerenderInFlight = false;
14
15
  #toolImagesByCallId = new Map<string, ImageContent[]>();
16
+ #usageInfo?: Usage;
15
17
 
16
18
  constructor(
17
19
  message?: AssistantMessage,
@@ -52,6 +54,13 @@ export class AssistantMessageComponent extends Container {
52
54
  }
53
55
  }
54
56
 
57
+ setUsageInfo(usage: Usage): void {
58
+ this.#usageInfo = usage;
59
+ if (this.#lastMessage) {
60
+ this.updateContent(this.#lastMessage);
61
+ }
62
+ }
63
+
55
64
  #renderToolImages(): void {
56
65
  const images = Array.from(this.#toolImagesByCallId.values()).flat();
57
66
  if (images.length === 0) return;
@@ -178,5 +187,19 @@ export class AssistantMessageComponent extends Container {
178
187
  this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
179
188
  }
180
189
  }
190
+
191
+ // Token usage metadata
192
+ if (settings.get("display.showTokenUsage") && this.#usageInfo) {
193
+ const usage = this.#usageInfo;
194
+ const totalInput = usage.input + usage.cacheWrite;
195
+ const parts: string[] = [];
196
+ parts.push(`${theme.icon.input} ${formatNumber(totalInput)}`);
197
+ parts.push(`${theme.icon.output} ${formatNumber(usage.output)}`);
198
+ if (usage.cacheRead > 0) {
199
+ parts.push(`cache: ${formatNumber(usage.cacheRead)}`);
200
+ }
201
+ this.#contentContainer.addChild(new Spacer(1));
202
+ this.#contentContainer.addChild(new Text(theme.fg("dim", parts.join(" ")), 1, 0));
203
+ }
181
204
  }
182
205
  }
@@ -0,0 +1,104 @@
1
+ import { type Component, Container, Markdown, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
2
+ import { replaceTabs } from "../../tools/render-utils";
3
+ import { getMarkdownTheme, theme } from "../theme/theme";
4
+ import { DynamicBorder } from "./dynamic-border";
5
+
6
+ type BtwPanelState = "running" | "complete" | "aborted" | "error";
7
+
8
+ interface BtwPanelComponentOptions {
9
+ question: string;
10
+ tui: TUI;
11
+ }
12
+
13
+ export class BtwPanelComponent extends Container {
14
+ #question: string;
15
+ #tui: TUI;
16
+ #state: BtwPanelState = "running";
17
+ #answer = "";
18
+ #errorMessage: string | undefined;
19
+ #closed = false;
20
+
21
+ constructor(options: BtwPanelComponentOptions) {
22
+ super();
23
+ this.#question = options.question;
24
+ this.#tui = options.tui;
25
+ this.#rebuild();
26
+ }
27
+
28
+ appendText(delta: string): void {
29
+ if (!delta || this.#closed) return;
30
+ this.#answer += delta;
31
+ this.#rebuild();
32
+ }
33
+
34
+ setAnswer(text: string): void {
35
+ if (this.#closed) return;
36
+ this.#answer = text;
37
+ this.#rebuild();
38
+ }
39
+
40
+ markComplete(): void {
41
+ if (this.#closed) return;
42
+ this.#state = "complete";
43
+ this.#errorMessage = undefined;
44
+ this.#rebuild();
45
+ }
46
+
47
+ markAborted(): void {
48
+ if (this.#closed) return;
49
+ this.#state = "aborted";
50
+ this.#errorMessage = undefined;
51
+ this.#rebuild();
52
+ }
53
+
54
+ markError(message: string): void {
55
+ if (this.#closed) return;
56
+ this.#state = "error";
57
+ this.#errorMessage = message;
58
+ this.#rebuild();
59
+ }
60
+
61
+ close(): void {
62
+ this.#closed = true;
63
+ }
64
+
65
+ #rebuild(): void {
66
+ this.clear();
67
+ this.addChild(new DynamicBorder(str => theme.fg("dim", str)));
68
+ this.addChild(new Spacer(1));
69
+ this.addChild(new Text(theme.fg("accent", replaceTabs(this.#question)), 1, 0));
70
+ this.addChild(new Spacer(1));
71
+ this.addChild(this.#contentComponent());
72
+ this.addChild(new Spacer(1));
73
+ this.addChild(new Text(this.#footerLine(), 1, 0));
74
+ this.addChild(new Spacer(1));
75
+ this.addChild(new DynamicBorder(str => theme.fg("dim", str)));
76
+ this.#tui.requestRender();
77
+ }
78
+
79
+ #footerLine(): string {
80
+ switch (this.#state) {
81
+ case "running":
82
+ return theme.fg("muted", "Esc cancel /btw");
83
+ case "complete":
84
+ return theme.fg("muted", "Esc dismiss");
85
+ case "aborted":
86
+ return theme.fg("warning", `${theme.status.warning} Cancelled · Esc dismiss`);
87
+ case "error":
88
+ return theme.fg("error", `${theme.status.error} Error · Esc dismiss`);
89
+ }
90
+ }
91
+
92
+ #contentComponent(): Component {
93
+ if (this.#state === "error") {
94
+ return new Text(theme.fg("error", replaceTabs(this.#errorMessage ?? "Unknown error")), 1, 0);
95
+ }
96
+ const text = replaceTabs(this.#answer).trim();
97
+ if (!text) {
98
+ const waiting =
99
+ this.#state === "running" ? `${theme.status.pending} Waiting for response…` : "No text returned.";
100
+ return new Text(theme.fg("dim", waiting), 1, 0);
101
+ }
102
+ return new Markdown(text, 1, 0, getMarkdownTheme());
103
+ }
104
+ }
@@ -23,17 +23,12 @@ function visualizeIndent(text: string, filePath?: string): string {
23
23
  const leftPadding = Math.floor(tabWidth / 2);
24
24
  const rightPadding = Math.max(0, tabWidth - leftPadding - 1);
25
25
  const tabMarker = `${DIM}${" ".repeat(leftPadding)}→${" ".repeat(rightPadding)}${DIM_OFF}`;
26
- // Normalize: collapse configured tab-width groups into tab markers, then handle remaining spaces.
27
- const normalized = indent.replaceAll("\t", indentation);
28
26
  let visible = "";
29
- let pos = 0;
30
- while (pos < normalized.length) {
31
- if (pos + tabWidth <= normalized.length && normalized.slice(pos, pos + tabWidth) === indentation) {
27
+ for (const ch of indent) {
28
+ if (ch === "\t") {
32
29
  visible += tabMarker;
33
- pos += tabWidth;
34
30
  } else {
35
31
  visible += `${DIM}·${DIM_OFF}`;
36
- pos++;
37
32
  }
38
33
  }
39
34
  return `${visible}${replaceTabs(rest, filePath)}`;
@@ -2,6 +2,7 @@
2
2
  * State manager for the Extension Control Center.
3
3
  * Handles data loading, tree building, filtering, and toggle persistence.
4
4
  */
5
+ import * as path from "node:path";
5
6
  import { logger } from "@oh-my-pi/pi-utils";
6
7
  import type { ContextFile } from "../../../capability/context-file";
7
8
  import type { ExtensionModule } from "../../../capability/extension-module";
@@ -96,7 +97,7 @@ export async function loadAllExtensions(cwd?: string, disabledIds?: string[]): P
96
97
  }
97
98
  }
98
99
 
99
- const loadOpts = cwd ? { cwd } : {};
100
+ const loadOpts = cwd ? { cwd, includeDisabled: true } : { includeDisabled: true };
100
101
 
101
102
  // Load skills
102
103
  try {
@@ -252,7 +253,7 @@ export async function loadAllExtensions(cwd?: string, disabledIds?: string[]): P
252
253
  const contextFiles = await loadCapability<ContextFile>("context-files", loadOpts);
253
254
  for (const file of contextFiles.all) {
254
255
  // Extract filename from path for display
255
- const name = file.path.split("/").pop() || file.path;
256
+ const name = path.basename(file.path);
256
257
  const id = makeExtensionId("context-file", `${file.level}:${name}`);
257
258
  const isDisabled = disabledExtensions.has(id);
258
259
  const isShadowed = (file as { _shadowed?: boolean })._shadowed;
@@ -82,13 +82,29 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
82
82
  // Context maintenance threshold
83
83
  "compaction.thresholdPercent": [
84
84
  { value: "default", label: "Default", description: "Legacy reserve-based threshold" },
85
- { value: "70", label: "70%", description: "Very early maintenance" },
86
- { value: "75", label: "75%", description: "Early maintenance" },
87
- { value: "80", label: "80%", description: "Balanced" },
88
- { value: "85", label: "85%", description: "Typical threshold" },
89
- { value: "90", label: "90%", description: "Aggressive context usage" },
85
+ { value: "10", label: "10%", description: "Extremely early maintenance" },
86
+ { value: "20", label: "20%", description: "Very early maintenance" },
87
+ { value: "30", label: "30%", description: "Early maintenance" },
88
+ { value: "40", label: "40%", description: "Moderately early maintenance" },
89
+ { value: "50", label: "50%", description: "Halfway point" },
90
+ { value: "60", label: "60%", description: "Moderate context usage" },
91
+ { value: "70", label: "70%", description: "Balanced" },
92
+ { value: "75", label: "75%", description: "Slightly aggressive" },
93
+ { value: "80", label: "80%", description: "Typical threshold" },
94
+ { value: "85", label: "85%", description: "Aggressive context usage" },
95
+ { value: "90", label: "90%", description: "Very aggressive" },
90
96
  { value: "95", label: "95%", description: "Near context limit" },
91
97
  ],
98
+ "compaction.thresholdTokens": [
99
+ { value: "default", label: "Default", description: "Use percentage-based threshold" },
100
+ { value: "25000", label: "25K tokens", description: "Quarter of a 200K window" },
101
+ { value: "50000", label: "50K tokens", description: "Half of a 200K window" },
102
+ { value: "100000", label: "100K tokens", description: "Half of a 200K window" },
103
+ { value: "150000", label: "150K tokens", description: "Three-quarters of a 200K window" },
104
+ { value: "200000", label: "200K tokens", description: "Full standard context window" },
105
+ { value: "300000", label: "300K tokens", description: "Large context window" },
106
+ { value: "500000", label: "500K tokens", description: "Very large context window" },
107
+ ],
92
108
  // Retry max retries
93
109
  "retry.maxRetries": [
94
110
  { value: "1", label: "1 retry" },
@@ -190,6 +206,40 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
190
206
  { value: "300", label: "5 minutes" },
191
207
  { value: "600", label: "10 minutes" },
192
208
  ],
209
+ // Artifact spill settings
210
+ "tools.artifactSpillThreshold": [
211
+ { value: "1", label: "1 KB", description: "~250 tokens" },
212
+ { value: "2.5", label: "2.5 KB", description: "~625 tokens" },
213
+ { value: "5", label: "5 KB", description: "~1.25K tokens" },
214
+ { value: "10", label: "10 KB", description: "~2.5K tokens" },
215
+ { value: "20", label: "20 KB", description: "~5K tokens" },
216
+ { value: "30", label: "30 KB", description: "~7.5K tokens" },
217
+ { value: "50", label: "50 KB", description: "Default; ~12.5K tokens" },
218
+ { value: "75", label: "75 KB", description: "~19K tokens" },
219
+ { value: "100", label: "100 KB", description: "~25K tokens" },
220
+ { value: "200", label: "200 KB", description: "~50K tokens" },
221
+ { value: "500", label: "500 KB", description: "~125K tokens" },
222
+ { value: "1000", label: "1 MB", description: "~250K tokens" },
223
+ ],
224
+ "tools.artifactTailBytes": [
225
+ { value: "1", label: "1 KB", description: "~250 tokens" },
226
+ { value: "2.5", label: "2.5 KB", description: "~625 tokens" },
227
+ { value: "5", label: "5 KB", description: "~1.25K tokens" },
228
+ { value: "10", label: "10 KB", description: "~2.5K tokens" },
229
+ { value: "20", label: "20 KB", description: "Default; ~5K tokens" },
230
+ { value: "50", label: "50 KB", description: "~12.5K tokens" },
231
+ { value: "100", label: "100 KB", description: "~25K tokens" },
232
+ { value: "200", label: "200 KB", description: "~50K tokens" },
233
+ ],
234
+ "tools.artifactTailLines": [
235
+ { value: "50", label: "50 lines", description: "~250 tokens" },
236
+ { value: "100", label: "100 lines", description: "~500 tokens" },
237
+ { value: "250", label: "250 lines", description: "~1.25K tokens" },
238
+ { value: "500", label: "500 lines", description: "Default; ~2.5K tokens" },
239
+ { value: "1000", label: "1000 lines", description: "~5K tokens" },
240
+ { value: "2000", label: "2000 lines", description: "~10K tokens" },
241
+ { value: "5000", label: "5000 lines", description: "~25K tokens" },
242
+ ],
193
243
  // Read line limit
194
244
  "read.defaultLimit": [
195
245
  { value: "200", label: "200 lines" },
@@ -419,7 +469,7 @@ export function getAllSettingDefs(): SettingDef[] {
419
469
  if (cachedDefs) return cachedDefs;
420
470
 
421
471
  const defs: SettingDef[] = [];
422
- for (const tab of [...SETTING_TABS, "status"] as SettingTab[]) {
472
+ for (const tab of SETTING_TABS) {
423
473
  for (const path of getPathsForTab(tab)) {
424
474
  const def = pathToSettingDef(path);
425
475
  if (def) defs.push(def);