@oh-my-pi/pi-coding-agent 13.6.1 → 13.7.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.7.0] - 2026-03-03
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `ask` timeout handling to auto-select the recommended option instead of aborting the turn, while preserving explicit user-cancel abort behavior ([#266](https://github.com/can1357/oh-my-pi/issues/266))
10
+
11
+ ## [13.6.2] - 2026-03-03
12
+ ### Fixed
13
+
14
+ - Fixed LM Studio API key retrieval to use configured provider name instead of hardcoded 'lm-studio'
15
+ - Fixed resource content handling to properly check for empty text values (null/undefined)
16
+ - Fixed resource refresh tracking to prevent stale promise reuse when server connection changes
17
+ - Fixed update target resolution to properly handle cases where binary path cannot be resolved
18
+
5
19
  ## [13.6.1] - 2026-03-03
6
20
 
7
21
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.6.1",
4
+ "version": "13.7.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.6.1",
45
- "@oh-my-pi/pi-agent-core": "13.6.1",
46
- "@oh-my-pi/pi-ai": "13.6.1",
47
- "@oh-my-pi/pi-natives": "13.6.1",
48
- "@oh-my-pi/pi-tui": "13.6.1",
49
- "@oh-my-pi/pi-utils": "13.6.1",
44
+ "@oh-my-pi/omp-stats": "13.7.0",
45
+ "@oh-my-pi/pi-agent-core": "13.7.0",
46
+ "@oh-my-pi/pi-ai": "13.7.0",
47
+ "@oh-my-pi/pi-natives": "13.7.0",
48
+ "@oh-my-pi/pi-tui": "13.7.0",
49
+ "@oh-my-pi/pi-utils": "13.7.0",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -15,6 +15,7 @@ export interface GrepCommandArgs {
15
15
  limit: number;
16
16
  context: number;
17
17
  mode: "content" | "filesWithMatches" | "count";
18
+ gitignore: boolean;
18
19
  }
19
20
 
20
21
  /**
@@ -32,6 +33,7 @@ export function parseGrepArgs(args: string[]): GrepCommandArgs | undefined {
32
33
  limit: 20,
33
34
  context: 2,
34
35
  mode: "content",
36
+ gitignore: true,
35
37
  };
36
38
 
37
39
  const positional: string[] = [];
@@ -48,6 +50,8 @@ export function parseGrepArgs(args: string[]): GrepCommandArgs | undefined {
48
50
  result.mode = "filesWithMatches";
49
51
  } else if (arg === "--count" || arg === "-c") {
50
52
  result.mode = "count";
53
+ } else if (arg === "--no-gitignore") {
54
+ result.gitignore = false;
51
55
  } else if (!arg.startsWith("-")) {
52
56
  positional.push(arg);
53
57
  }
@@ -72,7 +76,9 @@ export async function runGrepCommand(cmd: GrepCommandArgs): Promise<void> {
72
76
  const searchPath = path.resolve(cmd.path);
73
77
  console.log(chalk.dim(`Searching in: ${searchPath}`));
74
78
  console.log(chalk.dim(`Pattern: ${cmd.pattern}`));
75
- console.log(chalk.dim(`Mode: ${cmd.mode}, Limit: ${cmd.limit}, Context: ${cmd.context}`));
79
+ console.log(
80
+ chalk.dim(`Mode: ${cmd.mode}, Limit: ${cmd.limit}, Context: ${cmd.context}, Gitignore: ${cmd.gitignore}`),
81
+ );
76
82
 
77
83
  console.log("");
78
84
 
@@ -85,6 +91,7 @@ export async function runGrepCommand(cmd: GrepCommandArgs): Promise<void> {
85
91
  maxCount: cmd.limit,
86
92
  context: cmd.mode === "content" ? cmd.context : undefined,
87
93
  hidden: true,
94
+ gitignore: cmd.gitignore,
88
95
  });
89
96
 
90
97
  console.log(chalk.green(`Total matches: ${result.totalMatches}`));
@@ -140,6 +147,7 @@ ${chalk.bold("Options:")}
140
147
  -f, --files Output file names only
141
148
  -c, --count Output match counts per file
142
149
  -h, --help Show this help
150
+ --no-gitignore Include files excluded by .gitignore
143
151
 
144
152
  ${chalk.bold("Environment:")}
145
153
  PI_GREP_WORKERS=0 Disable worker pool (use single-threaded mode)
@@ -47,14 +47,6 @@ async function getBunGlobalBinDir(): Promise<string | undefined> {
47
47
  }
48
48
  }
49
49
 
50
- function getRealPathOrOriginal(filePath: string): string {
51
- try {
52
- return fs.realpathSync(filePath);
53
- } catch {
54
- return filePath;
55
- }
56
- }
57
-
58
50
  function normalizePathForComparison(filePath: string): string {
59
51
  const normalized = path.normalize(filePath);
60
52
  if (process.platform === "win32") return normalized.toLowerCase();
@@ -62,16 +54,13 @@ function normalizePathForComparison(filePath: string): string {
62
54
  }
63
55
 
64
56
  function isPathInDirectory(filePath: string, directoryPath: string): boolean {
65
- const normalizedPath = normalizePathForComparison(getRealPathOrOriginal(filePath));
66
- const normalizedDirectory = normalizePathForComparison(getRealPathOrOriginal(directoryPath));
57
+ const normalizedPath = normalizePathForComparison(path.resolve(filePath));
58
+ const normalizedDirectory = normalizePathForComparison(path.resolve(directoryPath));
67
59
  const relativePath = path.relative(normalizedDirectory, normalizedPath);
68
60
  return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
69
61
  }
70
62
 
71
- interface UpdateTarget {
72
- method: "bun" | "binary";
73
- path: string;
74
- }
63
+ type UpdateTarget = { method: "bun" } | { method: "binary"; path: string };
75
64
 
76
65
  function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
77
66
  if (!bunBinDir) return "binary";
@@ -82,10 +71,18 @@ export function _resolveUpdateMethodForTest(ompPath: string, bunBinDir: string |
82
71
  return resolveUpdateMethod(ompPath, bunBinDir);
83
72
  }
84
73
  async function resolveUpdateTarget(): Promise<UpdateTarget> {
85
- const ompPath = resolveOmpPath() ?? process.execPath;
86
74
  const bunBinDir = await getBunGlobalBinDir();
87
- const method = resolveUpdateMethod(ompPath, bunBinDir);
88
- return { method, path: ompPath };
75
+ const ompPath = resolveOmpPath();
76
+
77
+ if (ompPath) {
78
+ const method = resolveUpdateMethod(ompPath, bunBinDir);
79
+ if (method === "bun") return { method };
80
+ return { method, path: ompPath };
81
+ }
82
+
83
+ if (bunBinDir) return { method: "bun" };
84
+
85
+ throw new Error(`Could not resolve ${APP_NAME} binary path in PATH`);
89
86
  }
90
87
 
91
88
  /**
@@ -19,6 +19,7 @@ export default class Grep extends Command {
19
19
  context: Flags.integer({ char: "C", description: "Context lines", default: 2 }),
20
20
  files: Flags.boolean({ char: "f", description: "Output file names only" }),
21
21
  count: Flags.boolean({ char: "c", description: "Output match counts per file" }),
22
+ "no-gitignore": Flags.boolean({ description: "Include files excluded by .gitignore" }),
22
23
  };
23
24
 
24
25
  async run(): Promise<void> {
@@ -33,6 +34,7 @@ export default class Grep extends Command {
33
34
  limit: flags.limit,
34
35
  context: flags.context,
35
36
  mode,
37
+ gitignore: !flags["no-gitignore"],
36
38
  };
37
39
 
38
40
  await initTheme();
@@ -883,7 +883,7 @@ export class ModelRegistry {
883
883
  const modelsUrl = `${baseUrl}/models`;
884
884
 
885
885
  const headers: Record<string, string> = { ...(providerConfig.headers ?? {}) };
886
- const apiKey = await this.authStorage.getApiKey("lm-studio");
886
+ const apiKey = await this.authStorage.getApiKey(providerConfig.provider);
887
887
  if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) {
888
888
  headers.Authorization = `Bearer ${apiKey}`;
889
889
  }
@@ -68,6 +68,7 @@ export type StatusLineSegmentId =
68
68
  | "token_in"
69
69
  | "token_out"
70
70
  | "token_total"
71
+ | "token_rate"
71
72
  | "cost"
72
73
  | "context_pct"
73
74
  | "context_total"
@@ -70,10 +70,18 @@ export type { AgentToolResult, AgentToolUpdateCallback };
70
70
  export interface ExtensionUIDialogOptions {
71
71
  signal?: AbortSignal;
72
72
  timeout?: number;
73
+ /** Invoked when the UI times out while waiting for a selection/input */
74
+ onTimeout?: () => void;
73
75
  /** Initial cursor position for select dialogs (0-indexed) */
74
76
  initialIndex?: number;
75
77
  /** Render an outlined list for select dialogs */
76
78
  outline?: boolean;
79
+ /** Invoked when user presses left arrow in select dialogs */
80
+ onLeft?: () => void;
81
+ /** Invoked when user presses right arrow in select dialogs */
82
+ onRight?: () => void;
83
+ /** Optional footer hint text rendered by interactive selector */
84
+ helpText?: string;
77
85
  }
78
86
 
79
87
  /** Raw terminal input listener for extensions. */
@@ -137,14 +137,14 @@ export class McpProtocolHandler implements ProtocolHandler {
137
137
 
138
138
  const textParts: string[] = [];
139
139
  for (const item of result.contents) {
140
- if (item.text) {
140
+ if (item.text !== undefined && item.text !== null) {
141
141
  textParts.push(item.text);
142
142
  } else if (item.blob) {
143
143
  textParts.push(`[Binary content: ${item.mimeType ?? "unknown"}, base64 length ${item.blob.length}]`);
144
144
  }
145
145
  }
146
146
 
147
- const content = textParts.join("\n---\n") || "(empty resource)";
147
+ const content = textParts.length > 0 ? textParts.join("\n---\n") : "(empty resource)";
148
148
  return {
149
149
  url: url.href,
150
150
  content,
package/src/main.ts CHANGED
@@ -28,7 +28,7 @@ import { InteractiveMode, runPrintMode, runRpcMode } from "./modes";
28
28
  import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
29
29
  import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "./sdk";
30
30
  import type { AgentSession } from "./session/agent-session";
31
- import { type SessionInfo, SessionManager } from "./session/session-manager";
31
+ import { resolveResumableSession, type SessionInfo, SessionManager } from "./session/session-manager";
32
32
  import { resolvePromptInput } from "./system-prompt";
33
33
  import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog";
34
34
 
@@ -162,23 +162,13 @@ async function prepareInitialMessage(
162
162
  };
163
163
  }
164
164
 
165
- /**
166
- * Resolve a session argument to a local or global session match.
167
- */
168
- async function resolveSessionMatch(
169
- sessionArg: string,
170
- cwd: string,
171
- sessionDir?: string,
172
- ): Promise<SessionInfo | undefined> {
173
- const sessions = await SessionManager.list(cwd, sessionDir);
174
- let matches = sessions.filter(session => session.id.startsWith(sessionArg));
175
-
176
- if (matches.length === 0 && !sessionDir) {
177
- const globalSessions = await SessionManager.listAll();
178
- matches = globalSessions.filter(session => session.id.startsWith(sessionArg));
179
- }
180
-
181
- return matches[0];
165
+ function normalizePathForComparison(value: string): string {
166
+ const resolved = path.resolve(value);
167
+ let realPath = resolved;
168
+ try {
169
+ realPath = realpathSync(resolved);
170
+ } catch {}
171
+ return process.platform === "win32" ? realPath.toLowerCase() : realPath;
182
172
  }
183
173
 
184
174
  async function promptForkSession(session: SessionInfo): Promise<boolean> {
@@ -229,20 +219,22 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
229
219
  if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) {
230
220
  return await SessionManager.open(sessionArg, parsed.sessionDir);
231
221
  }
232
- const match = await resolveSessionMatch(sessionArg, cwd, parsed.sessionDir);
222
+ const match = await resolveResumableSession(sessionArg, cwd, parsed.sessionDir);
233
223
  if (!match) {
234
224
  throw new Error(`Session "${sessionArg}" not found.`);
235
225
  }
236
- const normalizedCwd = path.resolve(cwd);
237
- const normalizedMatchCwd = path.resolve(match.cwd || cwd);
238
- if (normalizedCwd !== normalizedMatchCwd) {
239
- const shouldFork = await promptForkSession(match);
240
- if (!shouldFork) {
241
- throw new Error(`Session "${sessionArg}" is in another project (${match.cwd}).`);
226
+ if (match.scope === "global") {
227
+ const normalizedCwd = normalizePathForComparison(cwd);
228
+ const normalizedMatchCwd = normalizePathForComparison(match.session.cwd || cwd);
229
+ if (normalizedCwd !== normalizedMatchCwd) {
230
+ const shouldFork = await promptForkSession(match.session);
231
+ if (!shouldFork) {
232
+ throw new Error(`Session "${sessionArg}" is in another project (${match.session.cwd}).`);
233
+ }
234
+ return await SessionManager.forkFrom(match.session.path, cwd, parsed.sessionDir);
242
235
  }
243
- return await SessionManager.forkFrom(match.path, cwd, parsed.sessionDir);
244
236
  }
245
- return await SessionManager.open(match.path, parsed.sessionDir);
237
+ return await SessionManager.open(match.session.path, parsed.sessionDir);
246
238
  }
247
239
  if (parsed.continue) {
248
240
  return await SessionManager.continueRecent(cwd, parsed.sessionDir);
@@ -126,7 +126,7 @@ export class MCPManager {
126
126
  #notificationsEnabled = false;
127
127
  #notificationsEpoch = 0;
128
128
  #subscribedResources = new Map<string, Set<string>>();
129
- #pendingResourceRefresh = new Map<string, Promise<void>>();
129
+ #pendingResourceRefresh = new Map<string, { connection: MCPServerConnection; promise: Promise<void> }>();
130
130
 
131
131
  constructor(
132
132
  private cwd: string,
@@ -590,6 +590,7 @@ export class MCPManager {
590
590
  this.#pendingConnections.delete(name);
591
591
  this.#pendingToolLoads.delete(name);
592
592
  this.#sources.delete(name);
593
+ this.#pendingResourceRefresh.delete(name);
593
594
 
594
595
  const connection = this.#connections.get(name);
595
596
 
@@ -622,6 +623,7 @@ export class MCPManager {
622
623
 
623
624
  this.#pendingConnections.clear();
624
625
  this.#pendingToolLoads.clear();
626
+ this.#pendingResourceRefresh.clear();
625
627
  this.#sources.clear();
626
628
  this.#connections.clear();
627
629
  this.#tools = [];
@@ -660,13 +662,13 @@ export class MCPManager {
660
662
  * Refresh resources from a specific server.
661
663
  */
662
664
  async refreshServerResources(name: string): Promise<void> {
665
+ const connection = this.#connections.get(name);
666
+ if (!connection || !serverSupportsResources(connection.capabilities)) return;
667
+
663
668
  const existing = this.#pendingResourceRefresh.get(name);
664
- if (existing) return existing;
669
+ if (existing && existing.connection === connection) return existing.promise;
665
670
 
666
671
  const doRefresh = async (): Promise<void> => {
667
- const connection = this.#connections.get(name);
668
- if (!connection || !serverSupportsResources(connection.capabilities)) return;
669
-
670
672
  // Clear cached resources
671
673
  connection.resources = undefined;
672
674
  connection.resourceTemplates = undefined;
@@ -716,11 +718,12 @@ export class MCPManager {
716
718
  };
717
719
 
718
720
  const promise = doRefresh().finally(() => {
719
- if (this.#pendingResourceRefresh.get(name) === promise) {
721
+ const pending = this.#pendingResourceRefresh.get(name);
722
+ if (pending?.promise === promise) {
720
723
  this.#pendingResourceRefresh.delete(name);
721
724
  }
722
725
  });
723
- this.#pendingResourceRefresh.set(name, promise);
726
+ this.#pendingResourceRefresh.set(name, { connection, promise });
724
727
  return promise;
725
728
  }
726
729
 
@@ -1,3 +1,4 @@
1
+ import { logger } from "@oh-my-pi/pi-utils";
1
2
  import type { MCPServerConfig } from "./types";
2
3
 
3
4
  const SMITHERY_REGISTRY_BASE_URL = "https://registry.smithery.ai";
@@ -324,8 +325,12 @@ async function fetchServerDetailsFromEntry(
324
325
  ): Promise<SmitheryServerDetails | null> {
325
326
  const candidates = resolveDetailPathCandidates(entry);
326
327
  for (const candidate of candidates) {
327
- const details = await fetchServerDetails(candidate, options);
328
- if (details) return details;
328
+ try {
329
+ const details = await fetchServerDetails(candidate, options);
330
+ if (details) return details;
331
+ } catch (error) {
332
+ logger.debug("Smithery detail fetch candidate failed", { candidate, error: String(error) });
333
+ }
329
334
  }
330
335
  return null;
331
336
  }
@@ -439,14 +444,31 @@ export async function searchSmitheryRegistry(
439
444
  );
440
445
  });
441
446
 
447
+ const detailFailures: Array<{ identity: string; error: string }> = [];
442
448
  const results = await Promise.all(
443
449
  uniqueEntries.map(async entry => {
444
- const details = await fetchServerDetailsFromEntry(entry, { apiKey: options?.apiKey });
445
- if (!details) return null;
446
- return toSearchResult(entry, details);
450
+ try {
451
+ const details = await fetchServerDetailsFromEntry(entry, { apiKey: options?.apiKey });
452
+ if (!details) return null;
453
+ return toSearchResult(entry, details);
454
+ } catch (error) {
455
+ detailFailures.push({
456
+ identity: getEntryIdentityKey(entry) ?? entry.id ?? "unknown",
457
+ error: String(error),
458
+ });
459
+ return null;
460
+ }
447
461
  }),
448
462
  );
449
463
 
464
+ if (detailFailures.length > 0) {
465
+ logger.warn("Smithery detail fetch failed for some entries", {
466
+ query,
467
+ failedEntries: detailFailures.length,
468
+ totalEntries: uniqueEntries.length,
469
+ sample: detailFailures.slice(0, 3),
470
+ });
471
+ }
450
472
  return results.filter((result): result is SmitherySearchResult => result !== null).slice(0, limit);
451
473
  }
452
474
 
@@ -5,7 +5,9 @@ import type { TUI } from "@oh-my-pi/pi-tui";
5
5
 
6
6
  export class CountdownTimer {
7
7
  #intervalId: NodeJS.Timeout | undefined;
8
+ #expireTimeoutId: NodeJS.Timeout | undefined;
8
9
  #remainingSeconds: number;
10
+ #deadlineMs = 0;
9
11
  readonly #initialMs: number;
10
12
 
11
13
  constructor(
@@ -16,25 +18,48 @@ export class CountdownTimer {
16
18
  ) {
17
19
  this.#initialMs = timeoutMs;
18
20
  this.#remainingSeconds = Math.ceil(timeoutMs / 1000);
21
+ this.#start();
22
+ }
23
+
24
+ #calculateRemainingSeconds(now = Date.now()): number {
25
+ const remainingMs = Math.max(0, this.#deadlineMs - now);
26
+ return Math.ceil(remainingMs / 1000);
27
+ }
28
+
29
+ #start(): void {
30
+ const now = Date.now();
31
+ this.#deadlineMs = now + this.#initialMs;
32
+ this.#remainingSeconds = this.#calculateRemainingSeconds(now);
19
33
  this.onTick(this.#remainingSeconds);
34
+ this.tui?.requestRender();
20
35
 
21
- this.#intervalId = setInterval(() => {
22
- this.#remainingSeconds--;
23
- this.onTick(this.#remainingSeconds);
24
- this.tui?.requestRender();
36
+ this.#expireTimeoutId = setTimeout(() => {
37
+ this.dispose();
38
+ this.onExpire();
39
+ }, this.#initialMs);
40
+
41
+ this.#startInterval();
42
+ }
25
43
 
26
- if (this.#remainingSeconds <= 0) {
27
- this.dispose();
28
- this.onExpire();
44
+ #startInterval(): void {
45
+ if (this.#intervalId) {
46
+ clearInterval(this.#intervalId);
47
+ this.#intervalId = undefined;
48
+ }
49
+ this.#intervalId = setInterval(() => {
50
+ const remainingSeconds = this.#calculateRemainingSeconds();
51
+ if (remainingSeconds !== this.#remainingSeconds) {
52
+ this.#remainingSeconds = remainingSeconds;
53
+ this.onTick(this.#remainingSeconds);
29
54
  }
55
+ this.tui?.requestRender();
30
56
  }, 1000);
31
57
  }
32
58
 
33
59
  /** Reset the countdown to its initial value */
34
60
  reset(): void {
35
- this.#remainingSeconds = Math.ceil(this.#initialMs / 1000);
36
- this.onTick(this.#remainingSeconds);
37
- this.tui?.requestRender();
61
+ this.dispose();
62
+ this.#start();
38
63
  }
39
64
 
40
65
  dispose(): void {
@@ -42,5 +67,9 @@ export class CountdownTimer {
42
67
  clearInterval(this.#intervalId);
43
68
  this.#intervalId = undefined;
44
69
  }
70
+ if (this.#expireTimeoutId) {
71
+ clearTimeout(this.#expireTimeoutId);
72
+ this.#expireTimeoutId = undefined;
73
+ }
45
74
  }
46
75
  }
@@ -9,6 +9,7 @@ import { DynamicBorder } from "./dynamic-border";
9
9
  export interface HookInputOptions {
10
10
  tui?: TUI;
11
11
  timeout?: number;
12
+ onTimeout?: () => void;
12
13
  }
13
14
 
14
15
  export class HookInputComponent extends Container {
@@ -44,7 +45,10 @@ export class HookInputComponent extends Container {
44
45
  opts.timeout,
45
46
  opts.tui,
46
47
  s => this.#titleText.setText(theme.fg("accent", `${this.#baseTitle} (${s}s)`)),
47
- () => this.#onCancelCallback(),
48
+ () => {
49
+ opts.onTimeout?.();
50
+ this.#onCancelCallback();
51
+ },
48
52
  );
49
53
  }
50
54
 
@@ -57,6 +61,8 @@ export class HookInputComponent extends Container {
57
61
  }
58
62
 
59
63
  handleInput(keyData: string): void {
64
+ // Reset countdown on any interaction
65
+ this.#countdown?.reset();
60
66
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
61
67
  this.#onSubmitCallback(this.#input.getValue());
62
68
  } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
@@ -2,7 +2,17 @@
2
2
  * Generic selector component for hooks.
3
3
  * Displays a list of string options with keyboard navigation.
4
4
  */
5
- import { Container, matchesKey, padding, Spacer, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
5
+ import {
6
+ Container,
7
+ matchesKey,
8
+ padding,
9
+ replaceTabs,
10
+ Spacer,
11
+ Text,
12
+ type TUI,
13
+ truncateToWidth,
14
+ visibleWidth,
15
+ } from "@oh-my-pi/pi-tui";
6
16
  import { theme } from "../../modes/theme/theme";
7
17
  import { CountdownTimer } from "./countdown-timer";
8
18
  import { DynamicBorder } from "./dynamic-border";
@@ -10,9 +20,13 @@ import { DynamicBorder } from "./dynamic-border";
10
20
  export interface HookSelectorOptions {
11
21
  tui?: TUI;
12
22
  timeout?: number;
23
+ onTimeout?: () => void;
13
24
  initialIndex?: number;
14
25
  outline?: boolean;
15
26
  maxVisible?: number;
27
+ onLeft?: () => void;
28
+ onRight?: () => void;
29
+ helpText?: string;
16
30
  }
17
31
 
18
32
  class OutlinedList extends Container {
@@ -28,8 +42,10 @@ class OutlinedList extends Container {
28
42
  const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width)));
29
43
  const innerWidth = Math.max(1, width - 2);
30
44
  const content = this.#lines.map(line => {
31
- const pad = Math.max(0, innerWidth - visibleWidth(line));
32
- return `${borderColor(theme.boxSharp.vertical)}${line}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`;
45
+ const normalized = replaceTabs(line);
46
+ const fitted = truncateToWidth(normalized, innerWidth);
47
+ const pad = Math.max(0, innerWidth - visibleWidth(fitted));
48
+ return `${borderColor(theme.boxSharp.vertical)}${fitted}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`;
33
49
  });
34
50
  return [horizontal, ...content, horizontal];
35
51
  }
@@ -46,7 +62,8 @@ export class HookSelectorComponent extends Container {
46
62
  #titleText: Text;
47
63
  #baseTitle: string;
48
64
  #countdown: CountdownTimer | undefined;
49
-
65
+ #onLeftCallback: (() => void) | undefined;
66
+ #onRightCallback: (() => void) | undefined;
50
67
  constructor(
51
68
  title: string,
52
69
  options: string[],
@@ -62,6 +79,8 @@ export class HookSelectorComponent extends Container {
62
79
  this.#onSelectCallback = onSelect;
63
80
  this.#onCancelCallback = onCancel;
64
81
  this.#baseTitle = title;
82
+ this.#onLeftCallback = opts?.onLeft;
83
+ this.#onRightCallback = opts?.onRight;
65
84
 
66
85
  this.addChild(new DynamicBorder());
67
86
  this.addChild(new Spacer(1));
@@ -76,6 +95,7 @@ export class HookSelectorComponent extends Container {
76
95
  opts.tui,
77
96
  s => this.#titleText.setText(theme.fg("accent", `${this.#baseTitle} (${s}s)`)),
78
97
  () => {
98
+ opts?.onTimeout?.();
79
99
  // Auto-select current option on timeout (typically the first/recommended option)
80
100
  const selected = this.#options[this.#selectedIndex];
81
101
  if (selected) {
@@ -95,7 +115,8 @@ export class HookSelectorComponent extends Container {
95
115
  this.addChild(this.#listContainer);
96
116
  }
97
117
  this.addChild(new Spacer(1));
98
- this.addChild(new Text(theme.fg("dim", "up/down navigate enter select esc cancel"), 1, 0));
118
+ const controlsHint = opts?.helpText ?? "up/down navigate enter select esc cancel";
119
+ this.addChild(new Text(theme.fg("dim", controlsHint), 1, 0));
99
120
  this.addChild(new Spacer(1));
100
121
  this.addChild(new DynamicBorder());
101
122
 
@@ -144,6 +165,10 @@ export class HookSelectorComponent extends Container {
144
165
  } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
145
166
  const selected = this.#options[this.#selectedIndex];
146
167
  if (selected) this.#onSelectCallback(selected);
168
+ } else if (matchesKey(keyData, "left")) {
169
+ this.#onLeftCallback?.();
170
+ } else if (matchesKey(keyData, "right")) {
171
+ this.#onRightCallback?.();
147
172
  } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
148
173
  this.#onCancelCallback();
149
174
  }
@@ -13,9 +13,11 @@ import {
13
13
  TruncatedText,
14
14
  truncateToWidth,
15
15
  } from "@oh-my-pi/pi-tui";
16
+ import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils";
16
17
  import { validateServerName } from "../../mcp/config-writer";
17
18
  import { analyzeAuthError, discoverOAuthEndpoints } from "../../mcp/oauth-discovery";
18
19
  import type { MCPHttpServerConfig, MCPServerConfig, MCPSseServerConfig, MCPStdioServerConfig } from "../../mcp/types";
20
+ import { shortenPath } from "../../tools/render-utils";
19
21
  import { theme } from "../theme/theme";
20
22
  import { DynamicBorder } from "./dynamic-border";
21
23
 
@@ -367,9 +369,13 @@ export class MCPAddWizard extends Container {
367
369
  this.#contentContainer.addChild(new Text(theme.fg("accent", "Step: Configuration Scope")));
368
370
  this.#contentContainer.addChild(new Spacer(1));
369
371
 
372
+ const cwd = getProjectDir();
373
+
374
+ const userPathLabel = shortenPath(getMCPConfigPath("user", cwd));
375
+ const projectPathLabel = shortenPath(getMCPConfigPath("project", cwd));
370
376
  const options = [
371
- { value: "user" as const, label: "User level (~/.omp/mcp.json)" },
372
- { value: "project" as const, label: "Project level (.omp/mcp.json)" },
377
+ { value: "user" as const, label: `User level (${userPathLabel})` },
378
+ { value: "project" as const, label: `Project level (${projectPathLabel})` },
373
379
  ];
374
380
 
375
381
  for (let i = 0; i < options.length; i++) {
@@ -2,7 +2,6 @@ import type { PresetDef, StatusLinePreset } from "./types";
2
2
 
3
3
  export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
4
4
  default: {
5
- // Matches current behavior
6
5
  leftSegments: ["pi", "model", "plan_mode", "path", "git", "pr", "context_pct", "token_total", "cost"],
7
6
  rightSegments: [],
8
7
  separator: "powerline-thin",
@@ -35,7 +34,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
35
34
 
36
35
  full: {
37
36
  leftSegments: ["pi", "hostname", "model", "plan_mode", "path", "git", "pr", "subagents"],
38
- rightSegments: ["token_in", "token_out", "cache_read", "cost", "context_pct", "time_spent", "time"],
37
+ rightSegments: ["token_in", "token_out", "token_rate", "cache_read", "cost", "context_pct", "time_spent", "time"],
39
38
  separator: "powerline",
40
39
  segmentOptions: {
41
40
  model: { showThinkingLevel: true },
@@ -53,6 +52,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
53
52
  "token_out",
54
53
  "cache_read",
55
54
  "cache_write",
55
+ "token_rate",
56
56
  "cost",
57
57
  "context_pct",
58
58
  "context_total",