@oh-my-pi/pi-coding-agent 3.3.1337 → 3.5.1337

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.
@@ -6,7 +6,6 @@
6
6
  import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
- import { basename } from "node:path";
10
9
  import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
11
10
  import type { AssistantMessage, ImageContent, Message, OAuthProvider } from "@oh-my-pi/pi-ai";
12
11
  import type { SlashCommand } from "@oh-my-pi/pi-tui";
@@ -24,28 +23,15 @@ import {
24
23
  TUI,
25
24
  visibleWidth,
26
25
  } from "@oh-my-pi/pi-tui";
27
- import { contextFileCapability } from "../../capability/context-file";
28
- import { instructionCapability } from "../../capability/instruction";
29
- import { promptCapability } from "../../capability/prompt";
30
- import { ruleCapability } from "../../capability/rule";
31
26
  import { getAuthPath, getDebugLogPath } from "../../config";
32
27
  import type { AgentSession, AgentSessionEvent } from "../../core/agent-session";
33
28
  import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index";
34
29
  import type { HookUIContext } from "../../core/hooks/index";
35
30
  import { createCompactionSummaryMessage } from "../../core/messages";
36
31
  import { getRecentSessions, type SessionContext, SessionManager } from "../../core/session-manager";
37
- import { loadSkills } from "../../core/skills";
38
32
  import { generateSessionTitle, setTerminalTitle } from "../../core/title-generator";
39
33
  import type { TruncationResult } from "../../core/tools/truncate";
40
- import {
41
- type ContextFile,
42
- disableProvider,
43
- enableProvider,
44
- type Instruction,
45
- loadSync,
46
- type Prompt,
47
- type Rule,
48
- } from "../../discovery";
34
+ import { disableProvider, enableProvider } from "../../discovery";
49
35
  import { getChangelogPath, parseChangelog } from "../../utils/changelog";
50
36
  import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
51
37
  import { ArminComponent } from "./components/armin";
@@ -56,6 +42,7 @@ import { BranchSummaryMessageComponent } from "./components/branch-summary-messa
56
42
  import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message";
57
43
  import { CustomEditor } from "./components/custom-editor";
58
44
  import { DynamicBorder } from "./components/dynamic-border";
45
+ import { ExtensionDashboard } from "./components/extensions";
59
46
  import { FooterComponent } from "./components/footer";
60
47
  import { HookEditorComponent } from "./components/hook-editor";
61
48
  import { HookInputComponent } from "./components/hook-input";
@@ -67,6 +54,7 @@ import { SessionSelectorComponent } from "./components/session-selector";
67
54
  import { SettingsSelectorComponent } from "./components/settings-selector";
68
55
  import { ToolExecutionComponent } from "./components/tool-execution";
69
56
  import { TreeSelectorComponent } from "./components/tree-selector";
57
+ import { TtsrNotificationComponent } from "./components/ttsr-notification";
70
58
  import { UserMessageComponent } from "./components/user-message";
71
59
  import { UserMessageSelectorComponent } from "./components/user-message-selector";
72
60
  import { WelcomeComponent } from "./components/welcome";
@@ -202,7 +190,8 @@ export class InteractiveMode {
202
190
  { name: "share", description: "Share session as a secret GitHub gist" },
203
191
  { name: "copy", description: "Copy last agent message to clipboard" },
204
192
  { name: "session", description: "Show session info and stats" },
205
- { name: "status", description: "Show loaded extensions (context, skills, tools, hooks)" },
193
+ { name: "extensions", description: "Open Extension Control Center dashboard" },
194
+ { name: "status", description: "Alias for /extensions" },
206
195
  { name: "changelog", description: "Show changelog entries" },
207
196
  { name: "hotkeys", description: "Show all keyboard shortcuts" },
208
197
  { name: "branch", description: "Create a new branch from a previous message" },
@@ -412,6 +401,7 @@ export class InteractiveMode {
412
401
  this.streamingComponent = undefined;
413
402
  this.streamingMessage = undefined;
414
403
  this.pendingTools.clear();
404
+ this.titleGenerationAttempted = false;
415
405
 
416
406
  this.chatContainer.addChild(new Spacer(1));
417
407
  this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
@@ -793,8 +783,8 @@ export class InteractiveMode {
793
783
  this.editor.setText("");
794
784
  return;
795
785
  }
796
- if (text === "/status") {
797
- this.handleStatusCommand();
786
+ if (text === "/extensions" || text === "/status") {
787
+ this.showExtensionsDashboard();
798
788
  this.editor.setText("");
799
789
  return;
800
790
  }
@@ -1007,18 +997,28 @@ export class InteractiveMode {
1007
997
  if (event.message.role === "user") break;
1008
998
  if (this.streamingComponent && event.message.role === "assistant") {
1009
999
  this.streamingMessage = event.message;
1010
- this.streamingComponent.updateContent(this.streamingMessage);
1000
+ // Don't show "Aborted" text for TTSR aborts - we'll show a nicer message
1001
+ if (this.session.isTtsrAbortPending && this.streamingMessage.stopReason === "aborted") {
1002
+ // TTSR abort - suppress the "Aborted" rendering in the component
1003
+ const msgWithoutAbort = { ...this.streamingMessage, stopReason: "stop" as const };
1004
+ this.streamingComponent.updateContent(msgWithoutAbort);
1005
+ } else {
1006
+ this.streamingComponent.updateContent(this.streamingMessage);
1007
+ }
1011
1008
 
1012
1009
  if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") {
1013
- const errorMessage =
1014
- this.streamingMessage.stopReason === "aborted"
1015
- ? "Operation aborted"
1016
- : this.streamingMessage.errorMessage || "Error";
1017
- for (const [, component] of this.pendingTools.entries()) {
1018
- component.updateResult({
1019
- content: [{ type: "text", text: errorMessage }],
1020
- isError: true,
1021
- });
1010
+ // Skip error handling for TTSR aborts
1011
+ if (!this.session.isTtsrAbortPending) {
1012
+ const errorMessage =
1013
+ this.streamingMessage.stopReason === "aborted"
1014
+ ? "Operation aborted"
1015
+ : this.streamingMessage.errorMessage || "Error";
1016
+ for (const [, component] of this.pendingTools.entries()) {
1017
+ component.updateResult({
1018
+ content: [{ type: "text", text: errorMessage }],
1019
+ isError: true,
1020
+ });
1021
+ }
1022
1022
  }
1023
1023
  this.pendingTools.clear();
1024
1024
  } else {
@@ -1188,6 +1188,15 @@ export class InteractiveMode {
1188
1188
  this.ui.requestRender();
1189
1189
  break;
1190
1190
  }
1191
+
1192
+ case "ttsr_triggered": {
1193
+ // Show a fancy notification when TTSR rules are triggered
1194
+ const component = new TtsrNotificationComponent(event.rules);
1195
+ component.setExpanded(this.toolOutputExpanded);
1196
+ this.chatContainer.addChild(component);
1197
+ this.ui.requestRender();
1198
+ break;
1199
+ }
1191
1200
  }
1192
1201
  }
1193
1202
 
@@ -1654,11 +1663,11 @@ export class InteractiveMode {
1654
1663
  .then((title) => {
1655
1664
  if (title) {
1656
1665
  this.sessionManager.setSessionTitle(title);
1657
- setTerminalTitle(`pi: ${title}`);
1666
+ setTerminalTitle(`omp: ${title}`);
1658
1667
  }
1659
1668
  })
1660
1669
  .catch(() => {
1661
- // Ignore title generation errors
1670
+ // Errors logged via logger in title-generator
1662
1671
  });
1663
1672
  }
1664
1673
 
@@ -1736,6 +1745,21 @@ export class InteractiveMode {
1736
1745
  });
1737
1746
  }
1738
1747
 
1748
+ /**
1749
+ * Show the Extension Control Center dashboard.
1750
+ * Replaces /status with a unified view of all providers and extensions.
1751
+ */
1752
+ private showExtensionsDashboard(): void {
1753
+ this.showSelector((done) => {
1754
+ const dashboard = new ExtensionDashboard(process.cwd(), this.settingsManager);
1755
+ dashboard.onClose = () => {
1756
+ done();
1757
+ this.ui.requestRender();
1758
+ };
1759
+ return { component: dashboard, focus: dashboard };
1760
+ });
1761
+ }
1762
+
1739
1763
  /**
1740
1764
  * Handle setting changes from the settings selector.
1741
1765
  * Most settings are saved directly via SettingsManager in the definitions.
@@ -2390,290 +2414,6 @@ export class InteractiveMode {
2390
2414
  this.ui.requestRender();
2391
2415
  }
2392
2416
 
2393
- private handleStatusCommand(): void {
2394
- type StatusSource =
2395
- | { provider: string; level: string }
2396
- | { mcpServer: string; provider?: string }
2397
- | "builtin"
2398
- | "unknown";
2399
-
2400
- type StatusLine = {
2401
- name: string;
2402
- sourceText: string;
2403
- nameWithSource: string;
2404
- desc?: string;
2405
- };
2406
-
2407
- type LineSection = {
2408
- title: string;
2409
- lines: StatusLine[];
2410
- };
2411
-
2412
- type Section = { kind: "lines"; section: LineSection } | { kind: "text"; text: string };
2413
-
2414
- const capitalize = (value: string): string => value.charAt(0).toUpperCase() + value.slice(1);
2415
-
2416
- const resolveSourceText = (source: StatusSource): string => {
2417
- if (source === "builtin") return "builtin";
2418
- if (source === "unknown") return "unknown";
2419
- if ("mcpServer" in source) {
2420
- if (!source.provider) return `mcp:${source.mcpServer}`;
2421
- return `${source.mcpServer} via ${source.provider}`;
2422
- }
2423
- const levelLabel = capitalize(source.level);
2424
- return `via ${source.provider} (${levelLabel})`;
2425
- };
2426
-
2427
- const renderSourceText = (text: string): string => text.replace(/\bvia\b/, theme.italic("via"));
2428
-
2429
- const truncateText = (text: string, maxWidth: number): string => {
2430
- const textWidth = visibleWidth(text);
2431
- if (textWidth <= maxWidth) return text;
2432
- if (maxWidth <= 3) {
2433
- let acc = "";
2434
- let width = 0;
2435
- for (const char of text) {
2436
- const charWidth = visibleWidth(char);
2437
- if (width + charWidth > maxWidth) break;
2438
- width += charWidth;
2439
- acc += char;
2440
- }
2441
- return acc;
2442
- }
2443
- const targetWidth = maxWidth - 3;
2444
- let acc = "";
2445
- let width = 0;
2446
- for (const char of text) {
2447
- const charWidth = visibleWidth(char);
2448
- if (width + charWidth > targetWidth) break;
2449
- width += charWidth;
2450
- acc += char;
2451
- }
2452
- return `${acc}...`;
2453
- };
2454
-
2455
- const buildLineSection = <T>(
2456
- title: string,
2457
- items: readonly T[],
2458
- getName: (item: T) => string,
2459
- getDesc: (item: T) => string | undefined,
2460
- getSource: (item: T) => StatusSource,
2461
- ): LineSection | null => {
2462
- if (items.length === 0) return null;
2463
-
2464
- const lines = items.map((item) => {
2465
- const name = getName(item);
2466
- const desc = getDesc(item)?.trim();
2467
- const sourceText = resolveSourceText(getSource(item));
2468
- const nameWithSource = sourceText ? `${name} ${sourceText}` : name;
2469
- return { name, sourceText, nameWithSource, desc };
2470
- });
2471
-
2472
- return { title, lines };
2473
- };
2474
-
2475
- const renderLineSection = (section: LineSection, maxNameWidth: number): string => {
2476
- const formattedLines = section.lines.map((line) => {
2477
- let nameText = line.name;
2478
- let sourceText = line.sourceText;
2479
-
2480
- if (sourceText) {
2481
- const maxSourceWidth = Math.max(0, maxNameWidth - 2);
2482
- sourceText = truncateText(sourceText, maxSourceWidth);
2483
- }
2484
- const sourceWidth = sourceText ? visibleWidth(sourceText) : 0;
2485
- const availableForName = sourceText ? Math.max(1, maxNameWidth - sourceWidth - 1) : maxNameWidth;
2486
- nameText = truncateText(nameText, availableForName);
2487
-
2488
- const nameWithSourcePlain = sourceText ? `${nameText} ${sourceText}` : nameText;
2489
- const sourceRendered = sourceText ? renderSourceText(sourceText) : "";
2490
- const nameRendered = sourceText ? `${theme.bold(nameText)} ${sourceRendered}` : theme.bold(nameText);
2491
- const pad = Math.max(0, maxNameWidth - visibleWidth(nameWithSourcePlain));
2492
- const desc = line.desc;
2493
- const descPart = desc
2494
- ? ` ${theme.fg("dim", desc.slice(0, 50) + (desc.length > 50 ? "..." : ""))}`
2495
- : "";
2496
- return ` ${nameRendered}${" ".repeat(pad)}${descPart}`;
2497
- });
2498
-
2499
- return `${theme.bold(theme.fg("accent", section.title))}\n${formattedLines.join("\n")}`;
2500
- };
2501
-
2502
- const sections: Section[] = [];
2503
- const pushLineSection = <T>(
2504
- title: string,
2505
- items: readonly T[],
2506
- getName: (item: T) => string,
2507
- getDesc: (item: T) => string | undefined,
2508
- getSource: (item: T) => StatusSource,
2509
- ): void => {
2510
- const section = buildLineSection(title, items, getName, getDesc, getSource);
2511
- if (section) {
2512
- sections.push({ kind: "lines", section });
2513
- }
2514
- };
2515
-
2516
- // Loaded context files
2517
- const contextFilesResult = loadSync(contextFileCapability.id, { cwd: process.cwd() });
2518
- const contextFiles = contextFilesResult.items as ContextFile[];
2519
- pushLineSection(
2520
- "Context Files",
2521
- contextFiles,
2522
- (f) => basename(f.path),
2523
- () => undefined,
2524
- (f) => ({ provider: f._source.providerName, level: f.level }),
2525
- );
2526
-
2527
- // Loaded skills
2528
- const skillsSettings = this.session.skillsSettings;
2529
- if (skillsSettings?.enabled !== false) {
2530
- const { skills, warnings: skillWarnings } = loadSkills(skillsSettings ?? {});
2531
- pushLineSection(
2532
- "Skills",
2533
- skills,
2534
- (s) => s.name,
2535
- (s) => s.description,
2536
- (s) => (s._source ? { provider: s._source.providerName, level: s._source.level } : "unknown"),
2537
- );
2538
- if (skillWarnings.length > 0) {
2539
- sections.push(
2540
- {
2541
- kind: "text",
2542
- text:
2543
- theme.bold(theme.fg("warning", "Skill Warnings")) +
2544
- "\n" +
2545
- skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n"),
2546
- },
2547
- );
2548
- }
2549
- }
2550
-
2551
- // Loaded rules
2552
- const rulesResult = loadSync<Rule>(ruleCapability.id, { cwd: process.cwd() });
2553
- pushLineSection(
2554
- "Rules",
2555
- rulesResult.items,
2556
- (r) => r.name,
2557
- (r) => r.description,
2558
- (r) => ({ provider: r._source.providerName, level: r._source.level }),
2559
- );
2560
-
2561
- // Loaded prompts
2562
- const promptsResult = loadSync<Prompt>(promptCapability.id, { cwd: process.cwd() });
2563
- pushLineSection(
2564
- "Prompts",
2565
- promptsResult.items,
2566
- (p) => p.name,
2567
- () => undefined,
2568
- (p) => ({ provider: p._source.providerName, level: p._source.level }),
2569
- );
2570
-
2571
- // Loaded instructions
2572
- const instructionsResult = loadSync<Instruction>(instructionCapability.id, { cwd: process.cwd() });
2573
- pushLineSection(
2574
- "Instructions",
2575
- instructionsResult.items,
2576
- (i) => i.name,
2577
- (i) => (i.applyTo ? `applies to: ${i.applyTo}` : undefined),
2578
- (i) => ({ provider: i._source.providerName, level: i._source.level }),
2579
- );
2580
-
2581
- // Loaded custom tools - split MCP from non-MCP
2582
- if (this.customTools.size > 0) {
2583
- const allTools = Array.from(this.customTools.values());
2584
- const mcpTools = allTools.filter((ct) => ct.path.startsWith("mcp:"));
2585
- const customTools = allTools.filter((ct) => !ct.path.startsWith("mcp:"));
2586
-
2587
- // MCP Tools section
2588
- if (mcpTools.length > 0) {
2589
- pushLineSection(
2590
- "MCP Tools",
2591
- mcpTools,
2592
- (ct) => ct.tool.label || ct.tool.name,
2593
- () => undefined,
2594
- (ct) => {
2595
- const match = ct.path.match(/^mcp:(.+?) via (.+)$/);
2596
- if (match) {
2597
- const [, serverName, providerName] = match;
2598
- return { mcpServer: serverName, provider: providerName };
2599
- }
2600
- return ct.path.startsWith("mcp:") ? { mcpServer: ct.path.slice(4) } : "unknown";
2601
- },
2602
- );
2603
- }
2604
-
2605
- // Custom Tools section
2606
- if (customTools.length > 0) {
2607
- pushLineSection(
2608
- "Custom Tools",
2609
- customTools,
2610
- (ct) => ct.tool.label || ct.tool.name,
2611
- (ct) => ct.tool.description,
2612
- (ct) => {
2613
- if (ct.source?.provider === "builtin") return "builtin";
2614
- if (ct.path === "<exa>") return "builtin";
2615
- return ct.source ? { provider: ct.source.providerName, level: ct.source.level } : "unknown";
2616
- },
2617
- );
2618
- }
2619
- }
2620
-
2621
- // Loaded slash commands (file-based)
2622
- const fileCommands = this.session.fileCommands;
2623
- pushLineSection(
2624
- "Slash Commands",
2625
- fileCommands,
2626
- (cmd) => `/${cmd.name}`,
2627
- (cmd) => cmd.description,
2628
- (cmd) => (cmd._source ? { provider: cmd._source.providerName, level: cmd._source.level } : "unknown"),
2629
- );
2630
-
2631
- // Loaded hooks
2632
- const hookRunner = this.session.hookRunner;
2633
- if (hookRunner) {
2634
- const hookPaths = hookRunner.getHookPaths();
2635
- if (hookPaths.length > 0) {
2636
- sections.push(
2637
- {
2638
- kind: "text",
2639
- text:
2640
- `${theme.bold(theme.fg("accent", "Hooks"))}\n` +
2641
- hookPaths.map((p) => ` ${theme.bold(basename(p))} ${theme.fg("dim", "hook")}`).join("\n"),
2642
- },
2643
- );
2644
- }
2645
- }
2646
-
2647
- const lineSections = sections.filter((section): section is { kind: "lines"; section: LineSection } => {
2648
- return section.kind === "lines";
2649
- });
2650
- const allLines = lineSections.flatMap((section) => section.section.lines);
2651
- const maxNameWidth = allLines.length
2652
- ? Math.min(60, Math.max(...allLines.map((line) => visibleWidth(line.nameWithSource))))
2653
- : 0;
2654
- const renderedSections = sections
2655
- .map((section) =>
2656
- section.kind === "lines" ? renderLineSection(section.section, maxNameWidth) : section.text,
2657
- )
2658
- .filter((section) => section.length > 0);
2659
-
2660
- if (renderedSections.length === 0) {
2661
- this.chatContainer.addChild(new Spacer(1));
2662
- this.chatContainer.addChild(new Text(theme.fg("muted", "No extensions loaded."), 1, 0));
2663
- } else {
2664
- this.chatContainer.addChild(new Spacer(1));
2665
- this.chatContainer.addChild(new DynamicBorder());
2666
- this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Loaded Extensions")), 1, 0));
2667
- this.chatContainer.addChild(new Spacer(1));
2668
- for (const section of renderedSections) {
2669
- this.chatContainer.addChild(new Text(section, 1, 0));
2670
- this.chatContainer.addChild(new Spacer(1));
2671
- }
2672
- this.chatContainer.addChild(new DynamicBorder());
2673
- }
2674
- this.ui.requestRender();
2675
- }
2676
-
2677
2417
  private async handleClearCommand(): Promise<void> {
2678
2418
  // Stop loading animation
2679
2419
  if (this.loadingAnimation) {
@@ -7,9 +7,35 @@
7
7
  */
8
8
 
9
9
  import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
10
+ import { APP_NAME, VERSION } from "../config";
10
11
  import type { AgentSession } from "../core/agent-session";
11
12
  import { logger } from "../core/logger";
12
13
 
14
+ /**
15
+ * Print session header to stderr (text mode only).
16
+ */
17
+ function printHeader(session: AgentSession): void {
18
+ const model = session.model;
19
+ const lines = [
20
+ `${APP_NAME} v${VERSION}`,
21
+ "--------",
22
+ `workdir: ${process.cwd()}`,
23
+ `model: ${model?.id ?? "unknown"}`,
24
+ `provider: ${model?.provider ?? "unknown"}`,
25
+ `thinking: ${session.thinkingLevel}`,
26
+ `session: ${session.sessionId}`,
27
+ "--------",
28
+ ];
29
+ console.error(lines.join("\n"));
30
+ }
31
+
32
+ /**
33
+ * Print session footer to stderr (text mode only).
34
+ */
35
+ function printFooter(): void {
36
+ console.error("--------");
37
+ }
38
+
13
39
  /**
14
40
  * Run in print (single-shot) mode.
15
41
  * Sends prompts to the agent and outputs the result.
@@ -27,6 +53,11 @@ export async function runPrintMode(
27
53
  initialMessage?: string,
28
54
  initialImages?: ImageContent[],
29
55
  ): Promise<void> {
56
+ // Print header to stderr (text mode only)
57
+ if (mode === "text") {
58
+ printHeader(session);
59
+ }
60
+
30
61
  // Hook runner already has no-op UI context by default (set in main.ts)
31
62
  // Set up hooks for print mode (no UI)
32
63
  const hookRunner = session.hookRunner;
@@ -116,6 +147,9 @@ export async function runPrintMode(
116
147
  }
117
148
  }
118
149
  }
150
+
151
+ // Print footer to stderr
152
+ printFooter();
119
153
  }
120
154
 
121
155
  // Ensure stdout is fully flushed before returning