@oh-my-pi/pi-coding-agent 13.6.2 → 13.7.1

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,12 @@
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
+
5
11
  ## [13.6.2] - 2026-03-03
6
12
  ### Fixed
7
13
 
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.2",
4
+ "version": "13.7.1",
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.2",
45
- "@oh-my-pi/pi-agent-core": "13.6.2",
46
- "@oh-my-pi/pi-ai": "13.6.2",
47
- "@oh-my-pi/pi-natives": "13.6.2",
48
- "@oh-my-pi/pi-tui": "13.6.2",
49
- "@oh-my-pi/pi-utils": "13.6.2",
44
+ "@oh-my-pi/omp-stats": "13.7.1",
45
+ "@oh-my-pi/pi-agent-core": "13.7.1",
46
+ "@oh-my-pi/pi-ai": "13.7.1",
47
+ "@oh-my-pi/pi-natives": "13.7.1",
48
+ "@oh-my-pi/pi-tui": "13.7.1",
49
+ "@oh-my-pi/pi-utils": "13.7.1",
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)
@@ -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();
@@ -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"
@@ -275,6 +275,16 @@ export interface ScanSkillsFromDirOptions {
275
275
  requireDescription?: boolean;
276
276
  }
277
277
 
278
+ // Stable ordering used for skill lists in prompts: name (case-insensitive), then name, then path.
279
+ export function compareSkillOrder(aName: string, aPath: string, bName: string, bPath: string): number {
280
+ const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
281
+ const lowerCompare = cmp(aName.toLowerCase(), bName.toLowerCase());
282
+ if (lowerCompare !== 0) return lowerCompare;
283
+ const nameCompare = cmp(aName, bName);
284
+ if (nameCompare !== 0) return nameCompare;
285
+ return cmp(aPath, bPath);
286
+ }
287
+
278
288
  export async function scanSkillsFromDir(
279
289
  _ctx: LoadContext,
280
290
  options: ScanSkillsFromDirOptions,
@@ -301,8 +311,10 @@ export async function scanSkillsFromDir(
301
311
  return;
302
312
  }
303
313
  const skillDirName = path.basename(path.dirname(skillPath));
314
+ const rawName = frontmatter.name;
315
+ const name = typeof rawName === "string" ? rawName.trim() || skillDirName : skillDirName;
304
316
  items.push({
305
- name: (frontmatter.name as string) || skillDirName,
317
+ name,
306
318
  path: skillPath,
307
319
  content: body,
308
320
  frontmatter: frontmatter as SkillFrontmatter,
@@ -325,6 +337,9 @@ export async function scanSkillsFromDir(
325
337
  }
326
338
  await Promise.all(work);
327
339
 
340
+ // Deterministic ordering: async file reads complete nondeterministically, so sort after loading.
341
+ items.sort((a, b) => compareSkillOrder(a.name, a.path, b.name, b.path));
342
+
328
343
  return { items, warnings };
329
344
  }
330
345
 
@@ -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. */
@@ -5,7 +5,7 @@ import { skillCapability } from "../capability/skill";
5
5
  import type { SourceMeta } from "../capability/types";
6
6
  import type { SkillsSettings } from "../config/settings";
7
7
  import { type Skill as CapabilitySkill, loadCapability } from "../discovery";
8
- import { scanSkillsFromDir } from "../discovery/helpers";
8
+ import { compareSkillOrder, scanSkillsFromDir } from "../discovery/helpers";
9
9
  import { expandTilde } from "../tools/path-utils";
10
10
 
11
11
  export interface Skill {
@@ -235,8 +235,12 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
235
235
  }
236
236
  }
237
237
 
238
+ const skills = Array.from(skillMap.values());
239
+ // Deterministic ordering for prompt stability (case-insensitive, then exact name, then path).
240
+ skills.sort((a, b) => compareSkillOrder(a.name, a.filePath, b.name, b.filePath));
241
+
238
242
  return {
239
- skills: Array.from(skillMap.values()),
243
+ skills,
240
244
  warnings: [...(result.warnings ?? []).map(w => ({ skillPath: "", message: w })), ...collisionWarnings],
241
245
  };
242
246
  }
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);
@@ -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",
@@ -203,6 +203,17 @@ const tokenTotalSegment: StatusLineSegment = {
203
203
  },
204
204
  };
205
205
 
206
+ const tokenRateSegment: StatusLineSegment = {
207
+ id: "token_rate",
208
+ render(ctx) {
209
+ const { tokensPerSecond } = ctx.usageStats;
210
+ if (!tokensPerSecond) return { content: "", visible: false };
211
+
212
+ const content = withIcon(theme.icon.output, `${tokensPerSecond.toFixed(1)}/s`);
213
+ return { content: theme.fg("statusLineOutput", content), visible: true };
214
+ },
215
+ };
216
+
206
217
  const costSegment: StatusLineSegment = {
207
218
  id: "cost",
208
219
  render(ctx) {
@@ -351,6 +362,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
351
362
  token_in: tokenInSegment,
352
363
  token_out: tokenOutSegment,
353
364
  token_total: tokenTotalSegment,
365
+ token_rate: tokenRateSegment,
354
366
  cost: costSegment,
355
367
  context_pct: contextPctSegment,
356
368
  context_total: contextTotalSegment,
@@ -0,0 +1,66 @@
1
+ const MIN_DURATION_MS = 100;
2
+
3
+ type AssistantUsage = {
4
+ output: number;
5
+ };
6
+
7
+ type AssistantLikeMessage = {
8
+ role: "assistant";
9
+ timestamp: number;
10
+ duration?: number;
11
+ usage: AssistantUsage;
12
+ };
13
+
14
+ type MaybeAssistantMessage = {
15
+ role?: string;
16
+ timestamp?: number;
17
+ duration?: number;
18
+ usage?: {
19
+ output?: number;
20
+ };
21
+ };
22
+
23
+ function isAssistantMessage(message: MaybeAssistantMessage | undefined): message is AssistantLikeMessage {
24
+ return (
25
+ message?.role === "assistant" &&
26
+ typeof message.timestamp === "number" &&
27
+ message.usage !== undefined &&
28
+ typeof message.usage.output === "number"
29
+ );
30
+ }
31
+
32
+ function getLastAssistantMessage(messages: ReadonlyArray<MaybeAssistantMessage>): AssistantLikeMessage | null {
33
+ for (let i = messages.length - 1; i >= 0; i--) {
34
+ const message = messages[i];
35
+ if (isAssistantMessage(message)) {
36
+ return message;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ export function calculateTokensPerSecond(
43
+ messages: ReadonlyArray<MaybeAssistantMessage>,
44
+ isStreaming: boolean,
45
+ nowMs: number = Date.now(),
46
+ ): number | null {
47
+ const assistant = getLastAssistantMessage(messages);
48
+ if (!assistant) return null;
49
+
50
+ const outputTokens = assistant.usage.output;
51
+ if (!Number.isFinite(outputTokens) || outputTokens <= 0) return null;
52
+
53
+ const resolvedDurationMs =
54
+ typeof assistant.duration === "number" && Number.isFinite(assistant.duration) && assistant.duration > 0
55
+ ? assistant.duration
56
+ : isStreaming
57
+ ? nowMs - assistant.timestamp
58
+ : null;
59
+
60
+ if (resolvedDurationMs === null || resolvedDurationMs < MIN_DURATION_MS) return null;
61
+
62
+ const tokensPerSecond = (outputTokens * 1000) / resolvedDurationMs;
63
+ if (!Number.isFinite(tokensPerSecond) || tokensPerSecond <= 0) return null;
64
+
65
+ return tokensPerSecond;
66
+ }
@@ -32,6 +32,7 @@ export interface SegmentContext {
32
32
  cacheWrite: number;
33
33
  premiumRequests: number;
34
34
  cost: number;
35
+ tokensPerSecond: number | null;
35
36
  };
36
37
  contextPercent: number;
37
38
  contextWindow: number;
@@ -25,6 +25,7 @@ const SEGMENT_INFO: Record<StatusLineSegmentId, { label: string; short: string }
25
25
  token_in: { label: "Tokens In", short: "input tokens" },
26
26
  token_out: { label: "Tokens Out", short: "output tokens" },
27
27
  token_total: { label: "Tokens", short: "total tokens" },
28
+ token_rate: { label: "Tokens/s", short: "output throughput" },
28
29
  cost: { label: "Cost", short: "session cost" },
29
30
  context_pct: { label: "Context %", short: "context usage" },
30
31
  context_total: { label: "Context", short: "context window" },