@herbertgao/pi-extensions 2026.10.3 → 2026.10.5

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 (34) hide show
  1. package/README.md +3 -2
  2. package/THIRD_PARTY_NOTICES.md +4 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +12 -7
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.md +12 -7
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +67 -16
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +240 -48
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer-currency.ts +29 -0
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer.ts +92 -59
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/git-stats.ts +162 -0
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/token-rate.ts +192 -0
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +51 -65
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +113 -25
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +160 -24
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +30 -1
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +26 -24
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/sticky-prompt.ts +402 -0
  17. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/ask-user-question.ts +173 -0
  18. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +4 -8
  19. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-edit-render.ts +64 -11
  20. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-presentation.ts +41 -0
  21. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +5 -1
  22. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +35 -9
  23. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +101 -31
  24. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +3 -3
  25. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +106 -15
  26. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/format.ts +9 -0
  27. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/patch-keys.ts +5 -0
  28. package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
  29. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-dark.json +1 -1
  30. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-light.json +1 -1
  31. package/node_modules/pi-provider-qoder/README.md +140 -0
  32. package/node_modules/pi-provider-qoder/dist/index.js +10249 -0
  33. package/node_modules/pi-provider-qoder/package.json +57 -0
  34. package/package.json +5 -2
@@ -6,6 +6,7 @@ import { showMoreHintText } from "./show-more-hint.ts";
6
6
  import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "../../utils/tool-loading-icon.ts";
7
7
  import { getToolMouseTui } from "../mouse/scroll.ts";
8
8
  import { sanitizeToolResultText } from "../../utils/tool-result-sanitize.ts";
9
+ import { oneLine } from "../../utils/format.ts";
9
10
 
10
11
  const TOOL_VIEWPORT_WIDTH_RATIO = 0.8;
11
12
  /** 宽屏右侧留白上限:比例留白超过这么多列时改用固定留白。 */
@@ -75,10 +76,46 @@ export function countLines(text: string): number {
75
76
  .filter((line) => line.trim().length > 0).length;
76
77
  }
77
78
 
79
+ /**
80
+ * 折叠行的结果文案:`N lines returned`(read 说 loaded)、`Done`、失败时首行错误。
81
+ * 单卡折叠行与工具组的子行共用这一处口径;TaskList 的树形摘要不走这里,由调用方先判。
82
+ */
83
+ export function collapsedResultSummary(options: {
84
+ result: any;
85
+ toolName: string;
86
+ text: string;
87
+ isError: boolean;
88
+ }): string {
89
+ const { result, toolName, text, isError } = options;
90
+ if (isError) {
91
+ // 72 与原 result.ts 默认一致,保持错误摘要截断宽度。
92
+ return text ? oneLine(text, 72) : "Failed";
93
+ }
94
+ const outputLines = outputLineCount(result) || countLines(text);
95
+ if (!outputLines) return "Done";
96
+ const action = toolName === "read" ? "loaded" : "returned";
97
+ return `${outputLines} ${outputLines === 1 ? "line" : "lines"} ${action}`;
98
+ }
99
+
78
100
  function hasExpandableResult(text: string): boolean {
79
101
  return countLines(text) > 1;
80
102
  }
81
103
 
104
+ /**
105
+ * 工具耗时:不到 1 秒报毫秒,分钟级起带上分/时。
106
+ * 只报到秒的话,几分钟的长命令会显示成 `300.0s`。
107
+ */
108
+ export function formatToolDuration(ms: number): string {
109
+ const seconds = ms / 1000;
110
+ if (seconds < 1) return `${Math.round(ms)}ms`;
111
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
112
+ const totalSeconds = Math.floor(seconds);
113
+ const minutes = Math.floor(totalSeconds / 60);
114
+ const remainder = totalSeconds % 60;
115
+ if (minutes < 60) return `${minutes}m ${remainder}s`;
116
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m ${remainder}s`;
117
+ }
118
+
82
119
  const activeAnimationContexts = new Set<any>();
83
120
  let sharedAnimationTimer: ReturnType<typeof setTimeout> | null = null;
84
121
 
@@ -135,6 +172,36 @@ export function scheduleAnimation(
135
172
  }
136
173
  }
137
174
 
175
+ /**
176
+ * 本回合流式消息里出现过的工具调用。Pi 要等整条 assistant 消息结束才派发执行,
177
+ * 这段窗口工具行已经在等启动,spinner 该逐帧转。
178
+ * 恢复或翻历史重建的旧行不在这个集合里,保持静止。
179
+ */
180
+ const liveToolCalls = new Set<string>();
181
+
182
+ /** 收下当前流式 assistant 消息里的工具调用 id。 */
183
+ export function noteStreamingToolCalls(message: any): void {
184
+ const content = Array.isArray(message?.content) ? message.content : [];
185
+ for (const block of content) {
186
+ const id = block?.type === "toolCall" ? block.id : undefined;
187
+ if (typeof id === "string" && id) liveToolCalls.add(id);
188
+ }
189
+ }
190
+
191
+ /** 工具收尾后移出等待窗口。 */
192
+ export function retireLiveToolCall(toolCallId: unknown): void {
193
+ if (typeof toolCallId === "string") liveToolCalls.delete(toolCallId);
194
+ }
195
+
196
+ export function clearLiveToolCalls(): void {
197
+ liveToolCalls.clear();
198
+ }
199
+
200
+ /** 工具行是否处于等待窗口(流式声明中或等待派发)。 */
201
+ export function isLiveToolCall(toolCallId: unknown): boolean {
202
+ return typeof toolCallId === "string" && liveToolCalls.has(toolCallId);
203
+ }
204
+
138
205
  export function pendingIcon(_name: string): string {
139
206
  return toolLoadingIcon();
140
207
  }
@@ -231,7 +298,7 @@ export class ExpandedToolResultText {
231
298
  }
232
299
  }
233
300
 
234
- /** 截断体末行 `… +N more lines` 旁的展开提示,点击打开全量预览。 */
301
+ /** 截断体末行 `… N more lines` 旁的展开提示,点击打开全量预览。 */
235
302
  export const SHOW_MORE_LABEL = "· click to show more";
236
303
 
237
304
  export type ToolIoSection = "input" | "output";
@@ -243,14 +310,12 @@ const EXPANDED_TOOL_IO_VIEW_GENERATION = Symbol("ccstyle-expanded-tool-io-view")
243
310
  /**
244
311
  * Expanded tool body with clear Input / Output sections (Grok Build–style).
245
312
  *
246
- * Visual frame:
247
- * ├ Input click to show more
248
- * │ path: src/a.ts
249
- * │
250
- * └ Output click to show more
251
- * result line…
252
- *
253
- * flushLeft=true(仅 mode=on 展开卡):去掉树线前导空格,由外层 Box(1,1) 提供 1 格 padding。
313
+ * Visual frame(flushLeft=true:树线的前导空格交给外层 Box(1,1),├/└ 与标题行的勾同列):
314
+ * ├ Input click to show more
315
+ * │ path: src/a.ts
316
+ * │
317
+ * └ Output click to show more
318
+ * result line…
254
319
  *
255
320
  * Reused across re-renders via context.lastComponent when possible.
256
321
  */
@@ -281,8 +346,10 @@ export class ExpandedToolIoView {
281
346
  }
282
347
  | undefined;
283
348
 
284
- /** flushLeft:贴左渲染(mode=on 展开卡);默认 false 保留前导空格(compact 等共用路径)。 */
349
+ /** flushLeft:贴左渲染(外层 Box 负责 1 格 pad);默认 false 保留 1 格前导空格。 */
285
350
  private flushLeft: boolean;
351
+ /** 尾部附注(shell 的耗时)。挂到 Output 的 show-more 行上,没有那一行时单独成行。 */
352
+ private footer: string | undefined;
286
353
 
287
354
  constructor(
288
355
  theme: any,
@@ -398,6 +465,13 @@ export class ExpandedToolIoView {
398
465
  this.invalidate();
399
466
  }
400
467
 
468
+ /** 尾部附注(耗时等)。挂在 show-more 行上;无 show-more 行时单独成行。 */
469
+ setFooter(text: string | undefined): void {
470
+ if (this.footer === text) return;
471
+ this.footer = text;
472
+ this.invalidate();
473
+ }
474
+
401
475
  getHoveredSection(): ToolIoSection | null {
402
476
  return this.hoveredSection;
403
477
  }
@@ -405,7 +479,7 @@ export class ExpandedToolIoView {
405
479
  /** True when the plain truncation footer carries show-more. Input 续行带 │,Output 不带。 */
406
480
  matchShowMoreLine(plainLine: string): ToolIoSection | null {
407
481
  const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
408
- if (!line.includes(` · ${showMoreHintText()}`) || !/\+\d+ more lines/.test(line)) return null;
482
+ if (!line.includes(` · ${showMoreHintText()}`) || !/\d+ more lines/.test(line)) return null;
409
483
  if (this.truncated.input && line.includes("│")) return "input";
410
484
  if (this.truncated.output) return "output";
411
485
  return null;
@@ -442,7 +516,7 @@ export class ExpandedToolIoView {
442
516
 
443
517
  const theme = this.theme;
444
518
  const safeWidth = Math.max(1, Math.floor(width));
445
- // flushLeft:贴左(外层 Box 负责 1 格 pad);否则保留 1 格前导空格(compact 共用)
519
+ // flushLeft:贴左(外层 Box 负责 1 格 pad);否则保留 1 格前导空格
446
520
  const lead = this.flushLeft ? "" : " ";
447
521
  const rail = `${lead}│ `;
448
522
  const railWidth = visibleWidth(rail);
@@ -474,7 +548,14 @@ export class ExpandedToolIoView {
474
548
 
475
549
  const pushBody = (
476
550
  body: string,
477
- opts: { input?: boolean; limit: number; continued?: boolean; section: ToolIoSection },
551
+ opts: {
552
+ input?: boolean;
553
+ limit: number;
554
+ continued?: boolean;
555
+ section: ToolIoSection;
556
+ /** 挂在 show-more 行上的附注(shell 耗时),插在展开提示前。 */
557
+ footerNote?: string;
558
+ },
478
559
  ): boolean /* truncated */ => {
479
560
  const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
480
561
  if (!raw.trim()) {
@@ -502,6 +583,8 @@ export class ExpandedToolIoView {
502
583
  if (truncated) {
503
584
  const hidden = Math.max(0, wrapped.length - visible.length);
504
585
  if (hidden > 0) {
586
+ // 省略号已经表达了「还有」,计数前不再加 +。
587
+ const note = opts.footerNote ? theme.fg("dim", ` · ${opts.footerNote}`) : "";
505
588
  // hover 只高亮文字,圆点保持 dim(与 group hint 一致)。
506
589
  const more =
507
590
  theme.fg("dim", " ·") +
@@ -509,7 +592,7 @@ export class ExpandedToolIoView {
509
592
  this.hoveredSection === opts.section ? "text" : "dim",
510
593
  ` ${showMoreHintText()}`,
511
594
  );
512
- pushRailLine(theme.fg("dim", `… +${hidden} more lines`) + more, opts.continued);
595
+ pushRailLine(theme.fg("dim", `… ${hidden} more lines`) + note + more, opts.continued);
513
596
  this.showMoreHeaderRows[opts.section] = lines.length - 1;
514
597
  }
515
598
  }
@@ -556,6 +639,7 @@ export class ExpandedToolIoView {
556
639
  limit: this.maxOutputLines,
557
640
  continued: false,
558
641
  section: "output",
642
+ footerNote: this.footer,
559
643
  });
560
644
  } else {
561
645
  this.truncated.output = outputWouldTruncate;
@@ -564,9 +648,16 @@ export class ExpandedToolIoView {
564
648
  limit: this.maxOutputLines,
565
649
  continued: false,
566
650
  section: "output",
651
+ footerNote: this.footer,
567
652
  });
568
653
  }
569
654
 
655
+ // 耗时挂在 Output 的 show-more 行上(与折叠态一样,提示留在行尾);
656
+ // 没截断就没有那一行,才单独成行。放在最后追加,前面记下的行号不受影响。
657
+ if (this.footer && this.showMoreHeaderRows.output === undefined) {
658
+ pushRailLine(theme.fg("muted", this.footer), false);
659
+ }
660
+
570
661
  this.cachedWidth = width;
571
662
  this.cachedLines = lines;
572
663
  return withIoViewMarkers(this, lines);
@@ -830,7 +921,7 @@ export function renderExpandedToolResult(
830
921
  lastComponent?: unknown,
831
922
  args?: unknown,
832
923
  context?: any,
833
- /** mode=on 展开卡贴左;compact 等保持默认前导空格 */
924
+ /** 贴左渲染:树线的前导空格交给外层 Box(1,1) */
834
925
  flushLeft = false,
835
926
  ): ExpandedToolIoView | ExpandedToolResultText | Text {
836
927
  const { text: inputBody, codeBlock } = formatToolInputBody(args);
@@ -1,6 +1,15 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { sanitizeToolResultText } from "./tool-result-sanitize.ts";
3
3
 
4
+ /** token/计数缩写:<1k 原数,<1M 用 k,≥1M 用 M。footer 与 working message 共用。 */
5
+ export function formatTokenCount(value: number): string {
6
+ if (value < 1000) return `${value}`;
7
+ if (value < 10_000) return `${(value / 1000).toFixed(1)}k`;
8
+ if (value < 1_000_000) return `${Math.round(value / 1000)}k`;
9
+ if (value < 10_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
10
+ return `${Math.round(value / 1_000_000)}M`;
11
+ }
12
+
4
13
  /** 毫秒 → "1h 2m 3s"/"2m 3s"/"3s";低于 1 秒返回 ""(省略)。 */
5
14
  export function formatDuration(ms: number): string {
6
15
  const totalSec = Math.floor(ms / 1000);
@@ -43,6 +43,11 @@ export const TOOL_GROUPING_GENERATION_KEY = Symbol.for("pi.ccstyle.tool-grouping
43
43
  // ── regular 主屏渲染补丁 ──
44
44
  export const MAIN_SCREEN_DO_RENDER_PATCH = Symbol.for("pi.ccstyle.main-screen-do-render-patch");
45
45
 
46
+ // ── fullscreen 粘性用户消息头 ──
47
+ export const STICKY_PROMPT_MARKER_PATCH = Symbol.for("pi.ccstyle.sticky-prompt-marker-patch");
48
+ export const STICKY_PROMPT_FRAME_PATCH = Symbol.for("pi.ccstyle.sticky-prompt-frame-patch");
49
+ export const STICKY_PROMPT_STATE_SLOT = Symbol.for("pi.ccstyle.sticky-prompt-state");
50
+
46
51
  // ── 鼠标交互 ──
47
52
  export const TOOL_MOUSE_OWNER_KEY = Symbol.for("pi.ccstyle.tool-mouse-owner");
48
53
  export const TOOL_MOUSE_TUI_SLOT = Symbol.for("pi.ccstyle.tool-mouse-tui");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herbertgao/pi-cc-extensions",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "license": "MIT",
5
5
  "description": "A Pi productivity suite with Claude Code-style UI, context inspection, and agent/session references",
6
6
  "type": "module",
@@ -71,9 +71,9 @@
71
71
  },
72
72
  "x-upstream": {
73
73
  "package": "pi-cc-extensions",
74
- "version": "0.9.10",
75
- "reviewedVersion": "0.9.12",
74
+ "version": "1.0.1",
75
+ "reviewedVersion": "1.0.1",
76
76
  "repository": "https://github.com/minuque/pi-cc-extensions",
77
- "commit": "2b72669cafefe0771b2e3e63b0db5c478314d2d4"
77
+ "commit": "0188b76f2597772bea3b42075857e030d9a89666"
78
78
  }
79
79
  }
@@ -18,7 +18,7 @@
18
18
  "red": "#fb7185",
19
19
  "yellow": "#facc15",
20
20
  "orange": "#fb923c",
21
- "text": "#f5f5f5",
21
+ "text": "#e4e4e7",
22
22
  "muted": "#a1a1aa",
23
23
  "dim": "#71717a",
24
24
  "selectedBg": "#252035",
@@ -18,7 +18,7 @@
18
18
  "red": "#dc2626",
19
19
  "yellow": "#a16207",
20
20
  "orange": "#c2410c",
21
- "text": "#242127",
21
+ "text": "#282530",
22
22
  "muted": "#625f68",
23
23
  "dim": "#76717d",
24
24
  "selectedBg": "#e3e8f5",
@@ -0,0 +1,140 @@
1
+ # pi-provider-qoder
2
+
3
+ A [pi](https://shittycodingagent.ai/) extension that connects pi to Qoder.
4
+
5
+ ```bash
6
+ pi install npm:pi-provider-qoder
7
+ # or: omp install npm:pi-provider-qoder
8
+ ```
9
+
10
+ ```bash
11
+ pi --provider qoder --model Lite
12
+ pi --provider qoder-cn --model Qwen3.7-Plus
13
+ ```
14
+
15
+ Inside pi:
16
+
17
+ ```text
18
+ /login qoder
19
+ /model Qwen3.8-Max
20
+ ```
21
+
22
+ ## Providers
23
+
24
+ Both providers register together.
25
+
26
+ ### `qoder` (global)
27
+
28
+ - `https://api3.qoder.sh/`
29
+ - Login: `/login qoder` (browser OAuth or PAT)
30
+ - PAT page: https://qoder.com/account/integrations
31
+ - Env (first match): `QODER_API_KEY`, `QODER_PERSONAL_ACCESS_TOKEN`, `QODER_PAT`
32
+
33
+ ### `qoder-cn` (China)
34
+
35
+ - `https://gateway.qoder.com.cn/`
36
+ - Login: `/login qoder-cn` (PAT only)
37
+ - PAT page: https://qoder.com.cn/account/integrations
38
+ - Env (first match): `QODERCN_API_KEY`, `QODERCN_PERSONAL_ACCESS_TOKEN`, `QODERCN_PAT`
39
+
40
+ A PAT (`pt-...`) is exchanged for a job token. Setting any of those env vars logs the provider in at startup.
41
+
42
+ ## Models
43
+
44
+ Model IDs are the catalog `display_name` with whitespace stripped. After login, `/model` lists what that region offers.
45
+
46
+ Examples: `Lite`, `Qwen3.8-Max`, `Qwen3.7-Plus`, `Qwen3.8-Flash`.
47
+
48
+ Context uses the largest live catalog option (often 1M). Output is 128K.
49
+
50
+ ## Endpoints
51
+
52
+ | | Global (`qoder`) | China (`qoder-cn`) |
53
+ | --- | --- | --- |
54
+ | PAT exchange | `https://openapi.qoder.sh/api/v1/jobToken/exchange` | `https://openapi.qoder.com.cn/api/v1/jobToken/exchange` |
55
+ | User info | `https://openapi.qoder.sh/api/v1/userinfo` | `https://openapi.qoder.com.cn/api/v1/userinfo` |
56
+ | Usage | `https://openapi.qoder.sh/api/v2/quota/usage` | `https://openapi.qoder.com.cn/api/v2/quota/usage` |
57
+ | Chat gateway | `https://api3.qoder.sh/` | `https://gateway.qoder.com.cn/` |
58
+
59
+ ## Request and account Credits
60
+
61
+ The footer shows **actual session deductions in Credits**, accumulated from Qoder's
62
+ request-level `usage.credits` and `usage.billable` response fields. Tokens are shown
63
+ separately and are not used to estimate Credits. A non-billable request contributes zero
64
+ to actual deductions even when its reported nominal Credits are positive. Missing
65
+ billing metadata is counted as `unknown`, so an incomplete session total is visible.
66
+ Global and CN session totals are separate and are rebuilt from stored session entries,
67
+ including attributed compaction, summary and tool usage entries.
68
+
69
+ Account quota is an **account-wide period balance**, including other Qoder clients.
70
+ Personal, add-on and organization quota are displayed separately. Account balances may
71
+ be rounded or updated later; they do not determine per-request cost.
72
+
73
+ Assistant `usage` preserves Qoder's `credits`, `original_credits`, and `billable`, and
74
+ adds `qoder_provider`, `charged_credits`, and `usd_equivalent`. `charged_credits` is zero
75
+ for non-billable calls, or the returned discounted Credits for billable calls; it remains
76
+ absent when actual deductions cannot be determined. `usd_equivalent` is a reference
77
+ conversion at **$20 / 1,500 Credits**, not a dollar charge. Credits never enter pi's
78
+ dollar-denominated `usage.cost` fields. See the [Qoder usage field definitions](https://docs.qoder.com/cli/sdk/cost-usage).
79
+
80
+ For example, a CN response with `credits: 0.000613272` and `billable: true` contributes
81
+ `0.000613272` Credits, while a Global response with `credits: 0.005221428571428571` and
82
+ `billable: false` contributes zero. The UI keeps up to eight decimal places; serialized
83
+ usage preserves the full returned precision.
84
+
85
+ A compact session-Credits and account-quota status is shown for the selected Qoder provider.
86
+ `/qoder-usage` also shows the session deductions and account quota. For the full
87
+ credit footer, opt in for the current pi session:
88
+
89
+ ```text
90
+ /qoder-usage footer on
91
+ /qoder-usage
92
+ /qoder-usage footer off
93
+ ```
94
+
95
+ Or start pi with `--qoder-credit-footer`. The custom footer replaces the misleading
96
+ `$0.000 (sub)` with labelled session Credits and account-quota rows. Input/output,
97
+ cache totals/hit rate, context, model/thinking level, directory, branch, session name,
98
+ and other extension statuses remain visible. Existing nonzero dollar charges in mixed
99
+ sessions are retained as `session $…` and are not relabelled as Qoder credits.
100
+ Narrow terminals wrap quota and put the model on its own line.
101
+
102
+ pi exposes one custom-footer slot. Enabling this option replaces another custom footer;
103
+ leave it off when using a separate footer extension. If another extension takes the slot
104
+ later, Qoder stops trying to reclaim it. Disabling restores pi's built-in footer only
105
+ while Qoder still owns the slot. The public API does not expose the current auto-compaction
106
+ setting, so the custom footer does not add the built-in `(auto)` hint. Alternate hosts without
107
+ pi's TUI footer API keep the status/command where supported.
108
+
109
+ Quota refreshes asynchronously at session start, model selection, before a new agent run,
110
+ and after an agent run. The in-memory cache lasts 30 seconds and coalesces simultaneous
111
+ requests; agent completion and `/qoder-usage` force a refresh. A local timer marks expired snapshots `[stale]`
112
+ without polling. Missing data shows `?`/unavailable, not zero; failed refreshes retain only
113
+ the last snapshot for the same account. Personal, add-on and organization quota are separate,
114
+ with the API's own units. The command also shows the allowance and expiry when provided.
115
+ Credential lookup and quota requests are each bounded to 10 seconds.
116
+
117
+ There is no dedicated host login/logout event. After changing authentication for an
118
+ already-selected model, run `/qoder-usage`; otherwise the next supported lifecycle event
119
+ rechecks identity. No account quota is written to disk. Per-request Credits are stored
120
+ with the assistant usage in the host's session log.
121
+
122
+ ### Offline visual preview
123
+
124
+ ```bash
125
+ npm ci
126
+ npm run demo:credits
127
+ ```
128
+
129
+ The demo runs the same footer renderer inside pi-tui with **sample data only**. It makes
130
+ no account or model requests. Press `f` (fresh), `s` (stale), `u` (unavailable), `0` (zero
131
+ quota), or `q` (quit). It shows both standard and 54-column layouts. Global and CN
132
+ quota queries and request-level billing fields have been verified with live calls.
133
+
134
+ Sanitized billing samples in `src/__fixtures__/billing/usage.json` cover free Global
135
+ calls, paid Global/CN calls, and two Kimi-K3 calls totaling 1.03185159 Credits.
136
+ Session accounting regression tests use these samples without making live requests.
137
+
138
+ ## License
139
+
140
+ MIT