@oh-my-pi/pi-coding-agent 16.4.5 → 16.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/cli.js +3052 -3005
  3. package/dist/types/cli/bench-cli.d.ts +1 -7
  4. package/dist/types/cli/usage-cli.d.ts +1 -0
  5. package/dist/types/commands/usage.d.ts +7 -0
  6. package/dist/types/config/settings-schema.d.ts +1 -1
  7. package/dist/types/modes/components/custom-editor.d.ts +3 -8
  8. package/dist/types/modes/components/model-browser.d.ts +3 -0
  9. package/dist/types/modes/components/model-hub.d.ts +4 -3
  10. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  11. package/dist/types/modes/interactive-mode.d.ts +2 -0
  12. package/dist/types/modes/queue-input.d.ts +8 -0
  13. package/dist/types/modes/types.d.ts +2 -0
  14. package/dist/types/session/agent-storage.d.ts +57 -0
  15. package/package.json +12 -12
  16. package/scripts/build-binary.ts +0 -1
  17. package/scripts/compile-binary.ts +4 -3
  18. package/src/cli/bench-cli.ts +7 -26
  19. package/src/cli/usage-cli.ts +11 -0
  20. package/src/commands/usage.ts +13 -2
  21. package/src/config/settings-schema.ts +1 -1
  22. package/src/modes/components/advisor-config.ts +3 -1
  23. package/src/modes/components/custom-editor.test.ts +58 -1
  24. package/src/modes/components/custom-editor.ts +42 -11
  25. package/src/modes/components/model-browser.ts +114 -33
  26. package/src/modes/components/model-hub.ts +455 -108
  27. package/src/modes/components/usage-row.ts +5 -6
  28. package/src/modes/controllers/input-controller.ts +140 -6
  29. package/src/modes/controllers/selector-controller.ts +20 -13
  30. package/src/modes/controllers/todo-command-controller.ts +1 -2
  31. package/src/modes/interactive-mode.ts +5 -0
  32. package/src/modes/queue-input.ts +132 -0
  33. package/src/modes/types.ts +2 -0
  34. package/src/modes/utils/ui-helpers.ts +19 -20
  35. package/src/session/agent-session.ts +184 -48
  36. package/src/session/agent-storage.ts +330 -3
  37. package/src/session/history-storage.ts +1 -34
  38. package/src/slash-commands/builtin-registry.ts +9 -0
@@ -30,7 +30,7 @@ export interface BenchRunSuccess {
30
30
  ttftMs: number;
31
31
  durationMs: number;
32
32
  outputTokens: number;
33
- /** Generation throughput measured over the post-first-token window. */
33
+ /** Output tokens/sec over the total request duration. */
34
34
  tokensPerSecond: number;
35
35
  }
36
36
  export interface BenchRunFailure {
@@ -74,12 +74,6 @@ export interface BenchDependencies {
74
74
  now?: () => number;
75
75
  stdoutIsTTY?: boolean;
76
76
  }
77
- /**
78
- * Tokens/s over the generation window (duration minus TTFT) so queue/prefill
79
- * latency does not dilute throughput. Falls back to total duration when the
80
- * response arrived as a single chunk (TTFT ~ duration).
81
- */
82
- export declare function computeTokensPerSecond(outputTokens: number, durationMs: number, ttftMs: number, deltaChunkCount: number): number;
83
77
  export declare function formatBenchTable(summary: BenchSummary): string;
84
78
  export declare function runBenchCommand(command: BenchCommandArgs, deps?: BenchDependencies): Promise<BenchSummary>;
85
79
  export {};
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { type UsageHistoryEntry, type UsageReport } from "@oh-my-pi/pi-ai";
11
11
  export interface UsageCommandArgs {
12
+ action?: string;
12
13
  json?: boolean;
13
14
  provider?: string;
14
15
  redact?: boolean;
@@ -4,6 +4,13 @@
4
4
  import { Command } from "@oh-my-pi/pi-utils/cli";
5
5
  export default class Usage extends Command {
6
6
  static description: string;
7
+ static args: {
8
+ action: import("@oh-my-pi/pi-utils/cli").ArgDescriptor & {
9
+ description: string;
10
+ required: false;
11
+ options: string[];
12
+ };
13
+ };
7
14
  static flags: {
8
15
  json: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"boolean"> & {
9
16
  char: string;
@@ -1384,7 +1384,7 @@ export declare const SETTINGS_SCHEMA: {
1384
1384
  readonly tab: "model";
1385
1385
  readonly group: "Retry & Fallback";
1386
1386
  readonly label: "Retry Fallback Chains";
1387
- readonly description: 'JSON object mapping model roles to ordered fallback model selectors, e.g. {"default":["openai/gpt-4o-mini"]}.';
1387
+ readonly description: 'JSON object mapping model roles, model selectors ("provider/model-id"), or provider wildcards ("provider/*") to ordered fallback selectors, e.g. {"default":["openai/gpt-4o-mini"],"google-antigravity/*":["google/*","google-vertex/*"]}. Model-oriented keys apply whenever that model/provider is active, regardless of role; a "provider/*" entry keeps the failing model\'s id and swaps the provider.';
1388
1388
  };
1389
1389
  };
1390
1390
  readonly "retry.fallbackRevertPolicy": {
@@ -86,14 +86,9 @@ export declare class CustomEditor extends Editor {
86
86
  static readonly SHIMMER_FRAME_MS = 70;
87
87
  /** Time for the gradient to sweep one full cycle across each keyword. */
88
88
  static readonly SHIMMER_PERIOD_MS = 1800;
89
- /** Gradient-highlight the "ultrathink" / "orchestrate" / "workflowz" keywords as the user types
90
- * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make
91
- * pasted image placeholders visually distinct and hyperlink them once their blob file exists.
92
- * When the editor is focused, the buffer contains a magic keyword, and `magicKeywords.enabled`
93
- * is on, the gradient shifts every frame to produce a Claude-Code-style shimmer; each render
94
- * schedules the next frame, so losing focus, deleting the keyword, or flipping the setting
95
- * stops the animation on its own. The static glow itself runs even when shimmering is gated
96
- * off, matching existing behavior for the editor and sent bubbles. */
89
+ /** Decorate magic keywords, attachments, and the queue-composer header/list markers.
90
+ * Queue shorthand reserves its first logical line as a dim `Queueing` label; sequential
91
+ * item markers use the accent color so separate follow-ups remain visible while composing. */
97
92
  decorateText: (text: string) => string;
98
93
  /** Optional test/host override for the magic-keyword shimmer gate. When
99
94
  * defined, takes precedence over the global `magicKeywords.enabled` setting,
@@ -1,6 +1,7 @@
1
1
  import type { Model } from "@oh-my-pi/pi-ai";
2
2
  import { type Component, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
3
3
  import type { Settings } from "../../config/settings.js";
4
+ import type { ModelPerfStats } from "../../session/agent-storage.js";
4
5
  import { type ConfiguredThinkingLevel } from "../../thinking.js";
5
6
  /** One selectable model row. `selector` is the canonical `provider/id` key. */
6
7
  export interface ModelBrowserItem {
@@ -73,6 +74,8 @@ export declare class ModelBrowser implements Component {
73
74
  setItems(items: ModelBrowserItem[]): void;
74
75
  setRoles(roles: RoleAssignments): void;
75
76
  setMruOrder(order: ReadonlyArray<string>): void;
77
+ /** Measured TPS/TTFT averages keyed by `provider/id` selector (see AgentStorage.getModelPerf). */
78
+ setPerfStats(perf: ReadonlyMap<string, ModelPerfStats>): void;
76
79
  setMaxVisible(rows: number): void;
77
80
  setShowProvider(show: boolean): void;
78
81
  /** Total rendered height for the current `maxVisible` (host layout budgeting). */
@@ -5,17 +5,18 @@ import type { Settings } from "../../config/settings.js";
5
5
  import { type ConfiguredThinkingLevel } from "../../thinking.js";
6
6
  /** `roles` is the full /models hub; `pick` is a one-shot session/embedded picker. */
7
7
  export type ModelHubMode = "roles" | "pick";
8
- export type ModelHubAction = "modelRole" | "retryFallback";
9
8
  /** A `--models` scope entry (mirrors the session's scoped model list). */
10
9
  export interface ScopedModelItem {
11
10
  model: Model;
12
11
  thinkingLevel?: string;
13
12
  }
14
13
  export interface ModelHubCallbacks {
15
- /** Persist a role assignment (or a retry-fallback registration). */
16
- onAssign: (model: Model, role: string, thinkingLevel: ConfiguredThinkingLevel | undefined, selector: string, action: ModelHubAction) => void;
14
+ /** Persist a role assignment. */
15
+ onAssign: (model: Model, role: string, thinkingLevel: ConfiguredThinkingLevel | undefined, selector: string) => void;
17
16
  /** Clear a configured role back to auto-selection. */
18
17
  onUnassign: (role: string) => void;
18
+ /** Persist a `retry.fallbackChains` entry — keyed by a role, `provider/model-id`, or `provider/*`; an empty chain clears the key. */
19
+ onFallbackChainChange?: (role: string, chain: string[]) => void;
19
20
  /** Pick-mode activation: session-only switch or embedded pick. */
20
21
  onPick?: (model: Model, selector: string) => void;
21
22
  /** Locked provider activation: forward to the /login flow. */
@@ -33,6 +33,8 @@ export declare class InputController {
33
33
  handleCtrlZ(): void;
34
34
  handleDequeue(): void;
35
35
  handleRetry(): Promise<void>;
36
+ /** Queue `/queue` input behind an active turn, or start it immediately when idle. */
37
+ handleQueueCommand(text: string): Promise<void>;
36
38
  /** Send editor text as a follow-up message (queued behind current stream). */
37
39
  handleFollowUp(): Promise<void>;
38
40
  restoreQueuedMessagesToEditor(options?: {
@@ -365,6 +365,8 @@ export declare class InteractiveMode implements InteractiveModeContext {
365
365
  handleCtrlZ(): void;
366
366
  handleDequeue(): void;
367
367
  handleImagePaste(): Promise<boolean>;
368
+ /** Queue slash-command input behind the active turn. */
369
+ handleQueueCommand(message: string): Promise<void>;
368
370
  handleBtwCommand(question: string): Promise<void>;
369
371
  handleTanCommand(work: string): Promise<void>;
370
372
  hasActiveBtw(): boolean;
@@ -0,0 +1,8 @@
1
+ /** Prefix matcher shared by queue-list parsing and editor highlighting. */
2
+ export declare const QUEUE_LIST_MARKER_RE: RegExp;
3
+ /** Extract the message body from the `->` / `=>` yield-queue shorthand. */
4
+ export declare function parseQueueShorthand(text: string): string | undefined;
5
+ /** Whether text currently forms a sequential queue list, including an unfinished trailing item. */
6
+ export declare function isQueuedMessageList(text: string): boolean;
7
+ /** Split a sequential numeric, Roman-numeral, or alphabetic list into queue entries. */
8
+ export declare function splitQueuedMessages(text: string): string[];
@@ -352,6 +352,8 @@ export interface InteractiveModeContext {
352
352
  handleCtrlZ(): void;
353
353
  handleDequeue(): void;
354
354
  handleImagePaste(): Promise<boolean>;
355
+ /** Queue a message for delivery only after the active agent turn would stop. */
356
+ handleQueueCommand(message: string): Promise<void>;
355
357
  handleBtwCommand(question: string): Promise<void>;
356
358
  handleTanCommand(work: string): Promise<void>;
357
359
  hasActiveBtw(): boolean;
@@ -1,5 +1,25 @@
1
1
  import { type AuthCredential, type AuthCredentialStore, type StoredAuthCredential } from "@oh-my-pi/pi-ai";
2
2
  import type { RawSettings as Settings } from "../config/settings.js";
3
+ /** One completed request's timing, folded into the per-model aggregates. */
4
+ export interface ModelPerfSample {
5
+ /** Output tokens the provider reported for the turn. */
6
+ outputTokens: number;
7
+ /** Total request duration in milliseconds. */
8
+ durationMs: number;
9
+ /** Time to first token in milliseconds; omit when the provider did not report one. */
10
+ ttftMs?: number;
11
+ }
12
+ /** Recency-weighted per-model performance averages. */
13
+ export interface ModelPerfStats {
14
+ /** Decayed sample count backing the averages. */
15
+ samples: number;
16
+ /** Average output tokens/sec over the total request duration. */
17
+ tps: number;
18
+ /** Average time-to-first-token in milliseconds; null when no sample reported one. */
19
+ ttftMs: number | null;
20
+ }
21
+ /** Current agent.db schema version; bump when schema changes require migration. */
22
+ export declare const SCHEMA_VERSION = 6;
3
23
  /**
4
24
  * Unified SQLite storage for agent settings, model usage, and auth credentials.
5
25
  * Delegates auth credential operations to AuthCredentialStore from @oh-my-pi/pi-ai.
@@ -36,6 +56,43 @@ export declare class AgentStorage {
36
56
  * @returns Array of model keys ("provider/modelId") in MRU order
37
57
  */
38
58
  getModelUsageOrder(): string[];
59
+ /**
60
+ * Folds one completed request's timing into the model's perf aggregates.
61
+ * TPS is measured over the total request duration — not the post-TTFT
62
+ * decode window, which undercounts generation time (and so inflates the
63
+ * rate) when reasoning tokens are generated before the first visible
64
+ * token. Invalid samples (no tokens, no duration) are dropped.
65
+ *
66
+ * Deferred like prompt history: samples are batched and written in one
67
+ * transaction after {@link MODEL_PERF_FLUSH_DELAY_MS}, keeping SQLite off
68
+ * the turn-completion hot path. Fire-and-forget safe — flush failures are
69
+ * logged, never thrown; await the returned promise only to observe the flush.
70
+ * @param modelKey - Model key in "provider/modelId" format
71
+ */
72
+ recordModelPerf(modelKey: string, sample: ModelPerfSample): Promise<void>;
73
+ /**
74
+ * Returns recency-weighted TPS/TTFT averages for every model with recorded
75
+ * requests, keyed by "provider/modelId". Read by the /models browser.
76
+ * Also kicks the one-time background stats.db import; until it completes,
77
+ * models without live samples are simply absent.
78
+ */
79
+ getModelPerf(): Map<string, ModelPerfStats>;
80
+ /**
81
+ * Imports recent measurable request rows from an `omp stats` database
82
+ * (`messages` table) into the model_perf aggregates. Walks newest-first
83
+ * over the timestamp index in {@link MODEL_PERF_BACKFILL_CHUNK}-row chunks,
84
+ * yielding to the event loop between chunks, and keeps at most
85
+ * {@link MODEL_PERF_DECAY_AT} rows per model within the
86
+ * {@link MODEL_PERF_BACKFILL_MAX_AGE_MS} window — beyond either bound the
87
+ * live decay would erase the contribution anyway. Errored turns are
88
+ * excluded; aborted turns with reported usage count, matching live capture.
89
+ * Sums land in one additive transaction at the end, so concurrent live
90
+ * samples merge correctly regardless of order.
91
+ * @param statsDbPath - Path to a stats.db file; opened read-only
92
+ * @returns Number of rows folded in
93
+ * @throws When the stats db cannot be opened or queried
94
+ */
95
+ backfillModelPerfFromStats(statsDbPath: string): Promise<number>;
39
96
  /**
40
97
  * Checks if any auth credentials exist in storage.
41
98
  * @returns True if at least one credential is stored
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": "16.4.5",
4
+ "version": "16.4.6",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -52,17 +52,17 @@
52
52
  "@agentclientprotocol/sdk": "0.25.0",
53
53
  "@babel/parser": "^7.29.7",
54
54
  "@mozilla/readability": "^0.6.0",
55
- "@oh-my-pi/hashline": "16.4.5",
56
- "@oh-my-pi/omp-stats": "16.4.5",
57
- "@oh-my-pi/pi-agent-core": "16.4.5",
58
- "@oh-my-pi/pi-ai": "16.4.5",
59
- "@oh-my-pi/pi-catalog": "16.4.5",
60
- "@oh-my-pi/pi-mnemopi": "16.4.5",
61
- "@oh-my-pi/pi-natives": "16.4.5",
62
- "@oh-my-pi/pi-tui": "16.4.5",
63
- "@oh-my-pi/pi-utils": "16.4.5",
64
- "@oh-my-pi/pi-wire": "16.4.5",
65
- "@oh-my-pi/snapcompact": "16.4.5",
55
+ "@oh-my-pi/hashline": "16.4.6",
56
+ "@oh-my-pi/omp-stats": "16.4.6",
57
+ "@oh-my-pi/pi-agent-core": "16.4.6",
58
+ "@oh-my-pi/pi-ai": "16.4.6",
59
+ "@oh-my-pi/pi-catalog": "16.4.6",
60
+ "@oh-my-pi/pi-mnemopi": "16.4.6",
61
+ "@oh-my-pi/pi-natives": "16.4.6",
62
+ "@oh-my-pi/pi-tui": "16.4.6",
63
+ "@oh-my-pi/pi-utils": "16.4.6",
64
+ "@oh-my-pi/pi-wire": "16.4.6",
65
+ "@oh-my-pi/snapcompact": "16.4.6",
66
66
  "@opentelemetry/api": "^1.9.1",
67
67
  "@opentelemetry/context-async-hooks": "^2.7.1",
68
68
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -99,7 +99,6 @@ async function main(): Promise<void> {
99
99
  outfile: outputPath,
100
100
  transformersVersion,
101
101
  target: crossBuild?.target,
102
- external: ["fastembed", "onnxruntime-node"],
103
102
  skipBuiltinCodesign: shouldAdhocSignDarwinBinary(crossBuild),
104
103
  });
105
104
 
@@ -1,6 +1,9 @@
1
1
  import { buildDocsIndexPayload } from "./generate-docs-index";
2
2
  import { createLegacyPiVirtualModulePlugin } from "./legacy-pi-virtual-module";
3
3
 
4
+ /** Native runtime dependencies always resolved from the on-demand install instead of embedded into compiled binaries. */
5
+ export const COMPILED_EXTERNAL_DEPENDENCIES: readonly string[] = Object.freeze(["fastembed", "onnxruntime-node"]);
6
+
4
7
  /** Inputs shared by local and release coding-agent binary builds. */
5
8
  export interface CodingAgentCompileOptions {
6
9
  /** Absolute repository root used for package resolution. */
@@ -13,8 +16,6 @@ export interface CodingAgentCompileOptions {
13
16
  readonly transformersVersion: string;
14
17
  /** Optional cross-compilation runtime target. */
15
18
  readonly target?: Bun.Build.CompileTarget;
16
- /** Dependencies intentionally resolved from the runtime filesystem. */
17
- readonly external?: readonly string[];
18
19
  /** Match release builds that minify identifiers while retaining names. */
19
20
  readonly minifyIdentifiers?: boolean;
20
21
  /** Disable Bun's built-in Darwin signing before the caller re-signs. */
@@ -34,7 +35,7 @@ export async function compileCodingAgent(options: CodingAgentCompileOptions): Pr
34
35
  const output = await Bun.build({
35
36
  entrypoints: [options.entrypoint],
36
37
  root: options.repoRoot,
37
- external: options.external ? [...options.external] : undefined,
38
+ external: [...COMPILED_EXTERNAL_DEPENDENCIES],
38
39
  define: {
39
40
  "process.env.PI_COMPILED": JSON.stringify("true"),
40
41
  "process.env.PI_TINY_TRANSFORMERS_VERSION": JSON.stringify(options.transformersVersion),
@@ -74,7 +74,7 @@ export interface BenchRunSuccess {
74
74
  ttftMs: number;
75
75
  durationMs: number;
76
76
  outputTokens: number;
77
- /** Generation throughput measured over the post-first-token window. */
77
+ /** Output tokens/sec over the total request duration. */
78
78
  tokensPerSecond: number;
79
79
  }
80
80
 
@@ -181,23 +181,6 @@ function hasVisibleFinalContent(message: AssistantMessage): boolean {
181
181
  });
182
182
  }
183
183
 
184
- /**
185
- * Tokens/s over the generation window (duration minus TTFT) so queue/prefill
186
- * latency does not dilute throughput. Falls back to total duration when the
187
- * response arrived as a single chunk (TTFT ~ duration).
188
- */
189
- export function computeTokensPerSecond(
190
- outputTokens: number,
191
- durationMs: number,
192
- ttftMs: number,
193
- deltaChunkCount: number,
194
- ): number {
195
- const decodeMs = durationMs - ttftMs;
196
- // Fall back to total duration when the response arrived as a single chunk/non-streaming.
197
- const windowMs = decodeMs > 0 && deltaChunkCount >= 2 ? decodeMs : durationMs;
198
- return windowMs > 0 ? (outputTokens * 1000) / windowMs : 0;
199
- }
200
-
201
184
  interface BenchRequestOptions {
202
185
  apiKey: ApiKeyResolver;
203
186
  sessionId: string;
@@ -247,17 +230,10 @@ async function runBenchRequest(
247
230
  headers: model.provider === "openrouter" ? { "X-OpenRouter-Cache": "false" } : undefined,
248
231
  });
249
232
  let message: AssistantMessage | undefined;
250
- let deltaChunkCount = 0;
251
233
  for await (const event of stream) {
252
234
  if (firstTokenAt === undefined && isFirstTokenEvent(event)) {
253
235
  firstTokenAt = now();
254
236
  }
255
- if (
256
- (event.type === "text_delta" || event.type === "thinking_delta" || event.type === "toolcall_delta") &&
257
- event.delta.length > 0
258
- ) {
259
- deltaChunkCount++;
260
- }
261
237
  if (event.type === "error") {
262
238
  return { ok: false, error: event.error.errorMessage ?? "request failed" };
263
239
  }
@@ -291,7 +267,12 @@ async function runBenchRequest(
291
267
  ttftMs,
292
268
  durationMs,
293
269
  outputTokens,
294
- tokensPerSecond: computeTokensPerSecond(outputTokens, durationMs, ttftMs, deltaChunkCount),
270
+ // TPS over the TOTAL request duration, deliberately not the post-TTFT
271
+ // decode window: reasoning models can spend seconds generating hidden
272
+ // thinking tokens (counted in usage.output) before the first visible
273
+ // byte, so "duration - TTFT" inflates TPS several-fold on providers
274
+ // that buffer or hide reasoning (e.g. google vs google-vertex).
275
+ tokensPerSecond: durationMs > 0 ? (outputTokens * 1000) / durationMs : 0,
295
276
  };
296
277
  } catch (error) {
297
278
  return { ok: false, error: getErrorMessage(error) };
@@ -23,6 +23,7 @@ import { discoverAuthStorage } from "../sdk";
23
23
  const BAR_WIDTH = 28;
24
24
 
25
25
  export interface UsageCommandArgs {
26
+ action?: string;
26
27
  json?: boolean;
27
28
  provider?: string;
28
29
  redact?: boolean;
@@ -755,6 +756,16 @@ function redactReportForJson(
755
756
  export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
756
757
  const authStorage = await discoverAuthStorage();
757
758
  try {
759
+ if (cmd.action === "invalidate") {
760
+ const provider = cmd.provider?.toLowerCase();
761
+ await authStorage.invalidateUsageCache(provider);
762
+ if (provider) {
763
+ process.stdout.write(`Invalidated cached usage reports for provider "${provider}".\n`);
764
+ } else {
765
+ process.stdout.write("Invalidated cached usage reports for all providers.\n");
766
+ }
767
+ return;
768
+ }
758
769
  if (cmd.history) {
759
770
  const days = cmd.days !== undefined && Number.isFinite(cmd.days) && cmd.days > 0 ? cmd.days : 7;
760
771
  const nowMs = Date.now();
@@ -1,12 +1,20 @@
1
1
  /**
2
2
  * Show provider usage limits for every authenticated account.
3
3
  */
4
- import { Command, Flags } from "@oh-my-pi/pi-utils/cli";
4
+ import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
5
5
  import { runUsageCommand } from "../cli/usage-cli";
6
6
 
7
7
  export default class Usage extends Command {
8
8
  static description = "Show provider usage limits for every authenticated account";
9
9
 
10
+ static args = {
11
+ action: Args.string({
12
+ description: "Optional subcommand to execute",
13
+ required: false,
14
+ options: ["invalidate"],
15
+ }),
16
+ };
17
+
10
18
  static flags = {
11
19
  json: Flags.boolean({ char: "j", description: "Output usage reports as JSON", default: false }),
12
20
  provider: Flags.string({ char: "p", description: "Only show usage for this provider id (e.g. anthropic)" }),
@@ -28,11 +36,14 @@ export default class Usage extends Command {
28
36
  "# Redact account identifiers for screenshots\n omp usage --redact",
29
37
  "# Machine-readable output\n omp usage --json",
30
38
  "# Usage-limit trend over the last 30 days\n omp usage --history --days 30",
39
+ "# Invalidate cached usage reports for all providers\n omp usage invalidate",
40
+ "# Invalidate cached usage reports for a specific provider\n omp usage invalidate --provider anthropic",
31
41
  ];
32
42
 
33
43
  async run(): Promise<void> {
34
- const { flags } = await this.parse(Usage);
44
+ const { args, flags } = await this.parse(Usage);
35
45
  await runUsageCommand({
46
+ action: args.action,
36
47
  json: flags.json,
37
48
  provider: flags.provider,
38
49
  redact: flags.redact,
@@ -1375,7 +1375,7 @@ export const SETTINGS_SCHEMA = {
1375
1375
  group: "Retry & Fallback",
1376
1376
  label: "Retry Fallback Chains",
1377
1377
  description:
1378
- 'JSON object mapping model roles to ordered fallback model selectors, e.g. {"default":["openai/gpt-4o-mini"]}.',
1378
+ 'JSON object mapping model roles, model selectors ("provider/model-id"), or provider wildcards ("provider/*") to ordered fallback selectors, e.g. {"default":["openai/gpt-4o-mini"],"google-antigravity/*":["google/*","google-vertex/*"]}. Model-oriented keys apply whenever that model/provider is active, regardless of role; a "provider/*" entry keeps the failing model\'s id and swaps the provider.',
1379
1379
  },
1380
1380
  },
1381
1381
  "retry.fallbackRevertPolicy": {
@@ -461,7 +461,8 @@ export class AdvisorConfigOverlayComponent implements Component {
461
461
  }
462
462
 
463
463
  #showModelPicker(index: number): void {
464
- const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? [];
464
+ const storage = this.#settings.getStorage();
465
+ const mruOrder = storage?.getModelUsageOrder() ?? [];
465
466
  let models: ReadonlyArray<Model>;
466
467
  if (this.#scopedModels.length > 0) {
467
468
  models = this.#scopedModels.map(scoped => scoped.model);
@@ -477,6 +478,7 @@ export class AdvisorConfigOverlayComponent implements Component {
477
478
 
478
479
  const picker = new ModelBrowser(this.#settings, {});
479
480
  picker.setMruOrder(mruOrder);
481
+ picker.setPerfStats(storage?.getModelPerf() ?? new Map());
480
482
  picker.setItems(items);
481
483
  picker.onActivate = item => {
482
484
  const efforts = getSupportedEfforts(item.model);
@@ -1,8 +1,9 @@
1
1
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
2
+ import { CURSOR_MARKER } from "@oh-my-pi/pi-tui";
2
3
  import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
3
4
  import { $ } from "bun";
4
5
  import { getDefaultPasteImageKeys } from "../../config/keybindings";
5
- import { getEditorTheme, initTheme } from "../theme/theme";
6
+ import { getEditorTheme, initTheme, theme } from "../theme/theme";
6
7
  import {
7
8
  CustomEditor,
8
9
  extractBracketedImagePastePaths,
@@ -78,6 +79,62 @@ describe("CustomEditor placeholder decoration", () => {
78
79
  });
79
80
  });
80
81
 
82
+ describe("CustomEditor queue shorthand decoration", () => {
83
+ beforeAll(async () => {
84
+ await initTheme();
85
+ });
86
+
87
+ it("reserves the first line as soon as either queue prefix is completed", () => {
88
+ for (const prefix of ["->", "=>"]) {
89
+ const editor = new CustomEditor(getEditorTheme());
90
+ editor.handleInput(prefix[0] ?? "");
91
+ expect(editor.getText()).toBe(prefix[0]);
92
+
93
+ editor.handleInput(prefix[1] ?? "");
94
+ expect(editor.getText()).toBe(`${prefix}\n`);
95
+ expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
96
+
97
+ editor.handleInput("\x7f");
98
+ expect(editor.getText()).toBe(`${prefix}\n`);
99
+ expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
100
+ }
101
+ });
102
+
103
+ it("renders the reserved line as a dim Queueing header", () => {
104
+ for (const prefix of ["->", "=>"]) {
105
+ const editor = new CustomEditor(getEditorTheme());
106
+ editor.setText(`${prefix}\nqueue this`);
107
+
108
+ expect(editor.decorateText(prefix)).toBe(theme.fg("dim", `Queueing ${theme.nav.selected}`));
109
+ editor.focused = true;
110
+ const rendered = editor.render(40).map(line => Bun.stripANSI(line.replace(CURSOR_MARKER, "")));
111
+ expect(rendered.some(line => line.includes(`Queueing ${theme.nav.selected}`))).toBe(true);
112
+ expect(rendered.every(line => Bun.stringWidth(line) === 40)).toBe(true);
113
+ expect(rendered.some(line => line.includes("queue this"))).toBe(true);
114
+ }
115
+ });
116
+
117
+ it("highlights dot and parenthesis markers only for detected queue lists", () => {
118
+ for (const [input, marker] of [
119
+ ["=>\n1. first\n2. second", "1."],
120
+ ["=>\n1) first\n2) second", "1)"],
121
+ ]) {
122
+ const editor = new CustomEditor(getEditorTheme());
123
+ editor.setText(input);
124
+ expect(editor.decorateText(`${marker} first`).startsWith(theme.fg("accent", marker))).toBe(true);
125
+ }
126
+
127
+ const unfinished = new CustomEditor(getEditorTheme());
128
+ unfinished.setText("=>\n1. first\n2. second\n3. third\n4.");
129
+ expect(unfinished.decorateText("1. first").startsWith(theme.fg("accent", "1."))).toBe(true);
130
+ expect(unfinished.decorateText("4.").startsWith(theme.fg("accent", "4."))).toBe(true);
131
+
132
+ const editor = new CustomEditor(getEditorTheme());
133
+ editor.setText("=>\n1. first\n3. third");
134
+ expect(editor.decorateText("1. first")).toBe("1. first");
135
+ });
136
+ });
137
+
81
138
  describe("CustomEditor bracketed path paste", () => {
82
139
  it("leaves a pasted bare .png filename on the normal text path", () => {
83
140
  expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined();
@@ -6,7 +6,8 @@ import type { AppKeybinding } from "../../config/keybindings";
6
6
  import { isSettingsInitialized, settings } from "../../config/settings";
7
7
  import { imageReferenceHyperlink, PLACEHOLDER_REGEX, renderPlaceholders } from "../image-references";
8
8
  import { hasMagicKeyword, highlightMagicKeywords } from "../magic-keywords";
9
- import { fgOrPlain } from "../theme/theme";
9
+ import { isQueuedMessageList, parseQueueShorthand, QUEUE_LIST_MARKER_RE } from "../queue-input";
10
+ import { fgOrPlain, theme } from "../theme/theme";
10
11
 
11
12
  type ConfigurableEditorAction = Extract<
12
13
  AppKeybinding,
@@ -326,21 +327,41 @@ export class CustomEditor extends Editor {
326
327
  * timer to request the next animation frame. Undefined when nobody is
327
328
  * listening (tests, headless callers); the timer chain still self-cleans. */
328
329
  #requestShimmerRepaint: (() => void) | undefined;
330
+ #queueDecorationText: string | undefined;
331
+ #queueShorthandActive = false;
332
+ #queueListActive = false;
329
333
 
330
- /** Gradient-highlight the "ultrathink" / "orchestrate" / "workflowz" keywords as the user types
331
- * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make
332
- * pasted image placeholders visually distinct and hyperlink them once their blob file exists.
333
- * When the editor is focused, the buffer contains a magic keyword, and `magicKeywords.enabled`
334
- * is on, the gradient shifts every frame to produce a Claude-Code-style shimmer; each render
335
- * schedules the next frame, so losing focus, deleting the keyword, or flipping the setting
336
- * stops the animation on its own. The static glow itself runs even when shimmering is gated
337
- * off, matching existing behavior for the editor and sent bubbles. */
334
+ /** Decorate magic keywords, attachments, and the queue-composer header/list markers.
335
+ * Queue shorthand reserves its first logical line as a dim `Queueing` label; sequential
336
+ * item markers use the accent color so separate follow-ups remain visible while composing. */
338
337
  decorateText = (text: string): string => {
339
- const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(this.getText());
338
+ const editorText = this.getText();
339
+ const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(editorText);
340
340
  const phase = animated ? (Date.now() % CustomEditor.SHIMMER_PERIOD_MS) / CustomEditor.SHIMMER_PERIOD_MS : 0;
341
341
  if (animated) this.#scheduleShimmerFrame();
342
+ if (this.#queueDecorationText !== editorText) {
343
+ this.#queueDecorationText = editorText;
344
+ const queueBody = parseQueueShorthand(editorText);
345
+ this.#queueShorthandActive = queueBody !== undefined;
346
+ this.#queueListActive = queueBody !== undefined && isQueuedMessageList(queueBody);
347
+ }
342
348
  return renderPlaceholders(text, {
343
- renderText: value => highlightMagicKeywords(value, undefined, phase),
349
+ renderText: value => {
350
+ const highlighted = highlightMagicKeywords(value, undefined, phase);
351
+ if (this.#queueShorthandActive && (value.startsWith("->") || value.startsWith("=>"))) {
352
+ const icon = typeof theme === "undefined" ? "➤" : theme.nav.selected;
353
+ return `${fgOrPlain("dim", `Queueing ${icon}`)}${highlighted.slice(2)}`;
354
+ }
355
+ if (this.#queueListActive) {
356
+ const markerMatch = QUEUE_LIST_MARKER_RE.exec(value);
357
+ if (markerMatch) {
358
+ const indent = markerMatch[1] ?? "";
359
+ const markerEnd = markerMatch[0].length;
360
+ return `${indent}${fgOrPlain("accent", value.slice(indent.length, markerEnd))}${highlighted.slice(markerEnd)}`;
361
+ }
362
+ }
363
+ return highlighted;
364
+ },
344
365
  renderReference: (value, kind, index) =>
345
366
  kind === "image"
346
367
  ? imageReferenceHyperlink(value, index, this.imageLinks, label =>
@@ -628,6 +649,7 @@ export class CustomEditor extends Editor {
628
649
  this.#pendingInput.push(data);
629
650
  return;
630
651
  }
652
+ const hadBareQueuePrefix = this.getText() === "->" || this.getText() === "=>";
631
653
  const kittyParsed = parseKittySequence(data);
632
654
  if (kittyParsed && (kittyParsed.modifier & 64) !== 0 && this.onCapsLock) {
633
655
  // Caps Lock is modifier bit 64
@@ -831,5 +853,14 @@ export class CustomEditor extends Editor {
831
853
 
832
854
  // Pass to parent for normal handling
833
855
  super.handleInput(data);
856
+ const cursor = this.getCursor();
857
+ if (
858
+ !hadBareQueuePrefix &&
859
+ (this.getText() === "->" || this.getText() === "=>") &&
860
+ cursor.line === 0 &&
861
+ cursor.col === 2
862
+ ) {
863
+ this.insertText("\n");
864
+ }
834
865
  }
835
866
  }