@oh-my-pi/pi-coding-agent 13.6.2 → 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 +6 -0
- package/package.json +7 -7
- package/src/cli/grep-cli.ts +9 -1
- package/src/commands/grep.ts +2 -0
- package/src/config/settings-schema.ts +1 -0
- package/src/extensibility/extensions/types.ts +8 -0
- package/src/main.ts +19 -27
- package/src/modes/components/countdown-timer.ts +39 -10
- package/src/modes/components/hook-input.ts +7 -1
- package/src/modes/components/hook-selector.ts +30 -5
- package/src/modes/components/mcp-add-wizard.ts +8 -2
- package/src/modes/components/status-line/presets.ts +2 -2
- package/src/modes/components/status-line/segments.ts +12 -0
- package/src/modes/components/status-line/token-rate.ts +66 -0
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/components/status-line-segment-editor.ts +1 -0
- package/src/modes/components/status-line.ts +38 -1
- package/src/modes/controllers/extension-ui-controller.ts +21 -0
- package/src/modes/controllers/mcp-command-controller.ts +4 -2
- package/src/modes/rpc/rpc-mode.ts +25 -18
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/prompts/tools/grep.md +1 -0
- package/src/prompts/tools/hashline.md +41 -139
- package/src/session/session-manager.ts +50 -0
- package/src/tools/ask.ts +237 -75
- package/src/tools/grep.ts +6 -1
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.
|
|
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.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "13.
|
|
46
|
-
"@oh-my-pi/pi-ai": "13.
|
|
47
|
-
"@oh-my-pi/pi-natives": "13.
|
|
48
|
-
"@oh-my-pi/pi-tui": "13.
|
|
49
|
-
"@oh-my-pi/pi-utils": "13.
|
|
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",
|
package/src/cli/grep-cli.ts
CHANGED
|
@@ -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(
|
|
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)
|
package/src/commands/grep.ts
CHANGED
|
@@ -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();
|
|
@@ -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. */
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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.#
|
|
22
|
-
this
|
|
23
|
-
this.
|
|
24
|
-
|
|
36
|
+
this.#expireTimeoutId = setTimeout(() => {
|
|
37
|
+
this.dispose();
|
|
38
|
+
this.onExpire();
|
|
39
|
+
}, this.#initialMs);
|
|
40
|
+
|
|
41
|
+
this.#startInterval();
|
|
42
|
+
}
|
|
25
43
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
36
|
-
this
|
|
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
|
-
() =>
|
|
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 {
|
|
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
|
|
32
|
-
|
|
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
|
-
|
|
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:
|
|
372
|
-
{ value: "project" as const, label:
|
|
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
|
+
}
|
|
@@ -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" },
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { getPreset } from "./status-line/presets";
|
|
19
19
|
import { renderSegment, type SegmentContext } from "./status-line/segments";
|
|
20
20
|
import { getSeparator } from "./status-line/separators";
|
|
21
|
+
import { calculateTokensPerSecond } from "./status-line/token-rate";
|
|
21
22
|
|
|
22
23
|
export interface StatusLineSegmentOptions {
|
|
23
24
|
model?: { showThinkingLevel?: boolean };
|
|
@@ -65,6 +66,8 @@ export class StatusLineComponent implements Component {
|
|
|
65
66
|
#cachedPrContext: PrCacheContext | undefined = undefined;
|
|
66
67
|
#prLookupInFlight = false;
|
|
67
68
|
#defaultBranch?: string;
|
|
69
|
+
#lastTokensPerSecond: number | null = null;
|
|
70
|
+
#lastTokensPerSecondTimestamp: number | null = null;
|
|
68
71
|
|
|
69
72
|
constructor(private readonly session: AgentSession) {
|
|
70
73
|
this.#settings = {
|
|
@@ -309,11 +312,41 @@ export class StatusLineComponent implements Component {
|
|
|
309
312
|
return null;
|
|
310
313
|
}
|
|
311
314
|
|
|
315
|
+
#getTokensPerSecond(): number | null {
|
|
316
|
+
let lastAssistantTimestamp: number | null = null;
|
|
317
|
+
for (let i = this.session.state.messages.length - 1; i >= 0; i--) {
|
|
318
|
+
const message = this.session.state.messages[i];
|
|
319
|
+
if (message?.role === "assistant") {
|
|
320
|
+
lastAssistantTimestamp = message.timestamp;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (lastAssistantTimestamp === null) {
|
|
326
|
+
this.#lastTokensPerSecond = null;
|
|
327
|
+
this.#lastTokensPerSecondTimestamp = null;
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const rate = calculateTokensPerSecond(this.session.state.messages, this.session.isStreaming);
|
|
332
|
+
if (rate !== null) {
|
|
333
|
+
this.#lastTokensPerSecond = rate;
|
|
334
|
+
this.#lastTokensPerSecondTimestamp = lastAssistantTimestamp;
|
|
335
|
+
return rate;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (this.#lastTokensPerSecondTimestamp === lastAssistantTimestamp) {
|
|
339
|
+
return this.#lastTokensPerSecond;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
312
345
|
#buildSegmentContext(width: number): SegmentContext {
|
|
313
346
|
const state = this.session.state;
|
|
314
347
|
|
|
315
348
|
// Get usage statistics
|
|
316
|
-
const
|
|
349
|
+
const aggregateUsageStats = this.session.sessionManager?.getUsageStatistics() ?? {
|
|
317
350
|
input: 0,
|
|
318
351
|
output: 0,
|
|
319
352
|
cacheRead: 0,
|
|
@@ -321,6 +354,10 @@ export class StatusLineComponent implements Component {
|
|
|
321
354
|
premiumRequests: 0,
|
|
322
355
|
cost: 0,
|
|
323
356
|
};
|
|
357
|
+
const usageStats = {
|
|
358
|
+
...aggregateUsageStats,
|
|
359
|
+
tokensPerSecond: this.#getTokensPerSecond(),
|
|
360
|
+
};
|
|
324
361
|
|
|
325
362
|
// Get context percentage
|
|
326
363
|
const lastAssistantMessage = state.messages
|