useful-pi-extensions 1.11.0 → 1.11.2
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "useful-pi-extensions",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.2",
|
|
4
4
|
"description": "A small collection of pi extensions, installed with one command — a labelled status line with context pressure, cache, cost, effort, TTFT and tokens/sec, plus an opt-in context breakdown panel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -5,7 +5,7 @@ English | [中文](README_CN.md)
|
|
|
5
5
|
[npm](https://www.npmjs.com/package/@reedchan/statusline) · [pi packages gallery](https://pi.dev/packages/@reedchan/statusline) · [repository](https://github.com/reedchan7/useful-pi-extensions)
|
|
6
6
|
|
|
7
7
|
Replaces pi's footer with a labelled two-row one. Every value carries a word, so nothing has to
|
|
8
|
-
be decoded from a symbol or remembered from a legend. `ctrl+
|
|
8
|
+
be decoded from a symbol or remembered from a legend. `ctrl+q` opens an optional context-breakdown
|
|
9
9
|
panel (next section) when you want to see what is occupying the window.
|
|
10
10
|
|
|
11
11
|
```text
|
|
@@ -24,7 +24,7 @@ columns only the meter is left. Every step is a whole value — a number is neve
|
|
|
24
24
|
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
25
25
|
| `Context window` + meter + `47%` | Share of the model's context window in use. The fill turns `warning` above 70% and `error` above 90%, the same thresholds pi's shipped footer uses, and the percentage changes color with it |
|
|
26
26
|
| `471k / 1.0M` | Absolute context tokens over the window size. The first thing dropped when the terminal is narrow |
|
|
27
|
-
| Breakdown panel | What occupies the window — opt-in via `ctrl+
|
|
27
|
+
| Breakdown panel | What occupies the window — opt-in via `ctrl+q` or `/breakdown` — one bucket per line under a total. See [Context breakdown](#context-breakdown) |
|
|
28
28
|
| `Input` / `Output` | Session prompt and completion tokens. `Input` counts the prompt tokens that were neither read from nor written to cache, because pi reports those two separately in the same `usage` object |
|
|
29
29
|
| `Cache hit` | The latest turn's cache hit rate, `cacheRead / (input + cacheRead + cacheWrite)` |
|
|
30
30
|
| `Cost` | Session cost, in USD unless a config file names another currency (see below) |
|
|
@@ -46,7 +46,7 @@ The glyphs are that preset's block fill and shaded block track, with a 1/8-cell
|
|
|
46
46
|
|
|
47
47
|
## Context breakdown
|
|
48
48
|
|
|
49
|
-
`ctrl+
|
|
49
|
+
`ctrl+q` or `/breakdown` opens an opt-in panel that answers the question the meter raises: what
|
|
50
50
|
is actually occupying the window. The concise footer stays the default; the choice is remembered
|
|
51
51
|
in `~/.pi/agent/statusline/config.json` (`"detail": true`).
|
|
52
52
|
|
|
@@ -101,9 +101,10 @@ multi-provider). **Deferred tools** have no pi equivalent either — every activ
|
|
|
101
101
|
always on the wire.
|
|
102
102
|
|
|
103
103
|
The panel is ~50 columns wide and fits any terminal by truncating rather than by dropping
|
|
104
|
-
buckets. The toggle lives on `ctrl+
|
|
105
|
-
as Enter
|
|
106
|
-
|
|
104
|
+
buckets. The toggle lives on `ctrl+q`: `ctrl+j` is a bare LF in legacy terminals and reads
|
|
105
|
+
as Enter, and `ctrl+e` is taken by pi's built-in cursor-to-line-end (`tui.editor.cursorLineEnd`);
|
|
106
|
+
`ctrl+q` is unbound in pi's default keymaps. If another extension owns `ctrl+q` on your machine,
|
|
107
|
+
the key is `BREAKDOWN_SHORTCUT` at the top of `index.ts`.
|
|
107
108
|
|
|
108
109
|
Configuration lives at the top of [`render.ts`](render.ts):
|
|
109
110
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[npm](https://www.npmjs.com/package/@reedchan/statusline) · [pi packages 画廊](https://pi.dev/packages/@reedchan/statusline) · [仓库](https://github.com/reedchan7/useful-pi-extensions)
|
|
6
6
|
|
|
7
7
|
替换 pi 的 footer,改成带文字标签的两行。每个值都带一个词,不需要靠符号猜、也不需要记图例。
|
|
8
|
-
需要看窗口里到底装了什么时,`ctrl+
|
|
8
|
+
需要看窗口里到底装了什么时,`ctrl+q` 可打开一个可选的上下文明细面板(见下文)。
|
|
9
9
|
|
|
10
10
|
```text
|
|
11
11
|
Context window █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k | Cache hit 99.9% | Cost $0.23 · Today $1.63
|
|
@@ -23,7 +23,7 @@ LSP Active: typescript
|
|
|
23
23
|
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
24
24
|
| `Context window` + 仪表 + `47.1%` | 已占模型上下文窗口的比例。填充超过 70% 转 `warning`、超过 90% 转 `error`(与 pi 原生 footer 同一套阈值),百分比同步变色 |
|
|
25
25
|
| `471k / 1.0M` | 上下文已用 token / 窗口大小。终端变窄时**第一个被舍弃** |
|
|
26
|
-
| Breakdown 面板 | 窗口里到底装了什么,总数领队、每桶一行——可选,`ctrl+
|
|
26
|
+
| Breakdown 面板 | 窗口里到底装了什么,总数领队、每桶一行——可选,`ctrl+q` 或 `/breakdown` 打开。见[上下文明细](#上下文明细) |
|
|
27
27
|
| `Input` / `Output` | 会话的输入与输出 token 量。`Input` 指既没命中缓存、也没写入缓存的那部分 prompt token,因为 pi 把这两种情况在同一个 `usage` 对象里分开报 |
|
|
28
28
|
| `Cache hit` | 最近一轮的缓存命中率,`cacheRead / (input + cacheRead + cacheWrite)` |
|
|
29
29
|
| `Cost` | 会话花费,默认美元;配置了其他币种则换算显示(见下) |
|
|
@@ -44,7 +44,7 @@ LSP Active: typescript
|
|
|
44
44
|
|
|
45
45
|
## 上下文明细
|
|
46
46
|
|
|
47
|
-
`ctrl+
|
|
47
|
+
`ctrl+q` 或 `/breakdown` 打开一个可选面板,回答仪表提出的问题:窗口里到底装了什么。
|
|
48
48
|
简洁两行是默认形态;开关状态记在 `~/.pi/agent/statusline/config.json`(`"detail": true`)。
|
|
49
49
|
|
|
50
50
|
```text
|
|
@@ -92,9 +92,10 @@ Claude Code 面板里有、而这里有意不做的:**Custom agents**(pi 没
|
|
|
92
92
|
(特定厂商的订阅额度,pi 是多 provider 的)。**Deferred tools** 在 pi 里也没有对应物——
|
|
93
93
|
激活工具的 schema 永远在线上。
|
|
94
94
|
|
|
95
|
-
面板约 50 列宽,任何终端都放得下;放不下时逐行截断,而不是丢桶。开关键默认 `ctrl+
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
面板约 50 列宽,任何终端都放得下;放不下时逐行截断,而不是丢桶。开关键默认 `ctrl+q`——
|
|
96
|
+
`ctrl+j` 在传统终端里是一个裸 LF,和回车无法区分,`ctrl+e` 又撞上了 pi 内建的
|
|
97
|
+
光标移行尾(`tui.editor.cursorLineEnd`);`ctrl+q` 在 pi 的默认键位里是空着的。如果你的机器上
|
|
98
|
+
`ctrl+q` 已被其他扩展占用,改 `index.ts` 顶部的 `BREAKDOWN_SHORTCUT` 即可。
|
|
98
99
|
|
|
99
100
|
可调项在 [`render.ts`](render.ts) 顶部:
|
|
100
101
|
|
|
@@ -38,10 +38,10 @@ import {
|
|
|
38
38
|
formatCwd,
|
|
39
39
|
formatLatency,
|
|
40
40
|
formatTps,
|
|
41
|
+
frozenThroughput,
|
|
41
42
|
avgMs,
|
|
42
43
|
isQuietStatus,
|
|
43
44
|
messagesTokens,
|
|
44
|
-
pair,
|
|
45
45
|
cachedRates,
|
|
46
46
|
ratesFromPayload,
|
|
47
47
|
cacheIsFresh,
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
row,
|
|
52
52
|
shortenPath,
|
|
53
53
|
splitToolTokens,
|
|
54
|
+
timingRow,
|
|
54
55
|
ttftDisplay,
|
|
55
56
|
ttftMs,
|
|
56
57
|
USD,
|
|
@@ -89,10 +90,12 @@ const DEFAULT_RESERVE_TOKENS = 16_384
|
|
|
89
90
|
* The key that toggles the breakdown line; change here if another extension owns it.
|
|
90
91
|
*
|
|
91
92
|
* Ctrl+j was tried first and had to go: legacy terminals send it as a bare LF, which is
|
|
92
|
-
* indistinguishable from Enter, so it typed newlines instead of toggling. ctrl+e
|
|
93
|
-
* pi's
|
|
93
|
+
* indistinguishable from Enter, so it typed newlines instead of toggling. ctrl+e then collided with
|
|
94
|
+
* pi's built-in tui.editor.cursorLineEnd. ctrl+q is free: unbound in both pi-tui's TUI_KEYBINDINGS
|
|
95
|
+
* and pi's app KEYBINDINGS on macOS/Linux (only Windows maps ctrl+q, to followUp), and pi already
|
|
96
|
+
* binds ctrl+s, so the terminal runs with flow control (IXON) off and ctrl+q reaches the app.
|
|
94
97
|
*/
|
|
95
|
-
const BREAKDOWN_SHORTCUT = 'ctrl+
|
|
98
|
+
const BREAKDOWN_SHORTCUT = 'ctrl+q'
|
|
96
99
|
const FETCH_TIMEOUT_MS = 5000
|
|
97
100
|
|
|
98
101
|
function today(): string {
|
|
@@ -398,8 +401,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
398
401
|
let requestRender: (() => void) | null = null
|
|
399
402
|
let currency: Currency = USD
|
|
400
403
|
|
|
401
|
-
// Latest turn reading: { rate, exact, ttftMs }
|
|
402
|
-
|
|
404
|
+
// Latest turn reading: { rate, exact, ttftMs }. rate is null when the message arrived as one
|
|
405
|
+
// burst: the honest TTFT stays, the unmeasurable throughput goes.
|
|
406
|
+
let reading: { rate: number | null; exact: boolean; ttftMs: number | null } | null = null
|
|
403
407
|
|
|
404
408
|
// Breakdown-line inputs, refreshed once per turn rather than per frame. The prompt and the
|
|
405
409
|
// tool list change only across turns (before_agent_start re-captures both), and the message
|
|
@@ -493,7 +497,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
493
497
|
renderedAt = 0
|
|
494
498
|
}
|
|
495
499
|
|
|
496
|
-
function publish(rate: number, exact: boolean, ttft: number | null): void {
|
|
500
|
+
function publish(rate: number | null, exact: boolean, ttft: number | null): void {
|
|
497
501
|
reading = { rate, exact, ttftMs: ttft }
|
|
498
502
|
requestRender?.()
|
|
499
503
|
}
|
|
@@ -553,9 +557,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
553
557
|
totalMeasuredOutput = num(state.totalMeasuredOutput)
|
|
554
558
|
totalTtftMs = num(state.totalTtftMs)
|
|
555
559
|
ttftCount = num(state.ttftCount)
|
|
556
|
-
if (isRecord(state.last)
|
|
560
|
+
if (isRecord(state.last)) {
|
|
557
561
|
reading = {
|
|
558
|
-
rate: state.last.rate,
|
|
562
|
+
rate: typeof state.last.rate === 'number' ? state.last.rate : null,
|
|
559
563
|
exact: state.last.exact === true,
|
|
560
564
|
ttftMs: typeof state.last.ttftMs === 'number' ? state.last.ttftMs : null,
|
|
561
565
|
}
|
|
@@ -599,7 +603,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
599
603
|
)
|
|
600
604
|
|
|
601
605
|
// The breakdown panel sits directly under the meter it explains, before the identity
|
|
602
|
-
// row. The concise footer renders by default; ctrl+
|
|
606
|
+
// row. The concise footer renders by default; ctrl+q or /breakdown opens the panel.
|
|
603
607
|
let breakdown: string[] | null = null
|
|
604
608
|
if (detailVisible) {
|
|
605
609
|
const window =
|
|
@@ -618,40 +622,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
618
622
|
})
|
|
619
623
|
}
|
|
620
624
|
|
|
621
|
-
// Row 2: model and the latest turn's timing on the right, path on the left.
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
const model = ctx.model?.id ?? 'no model'
|
|
625
|
-
const separator = theme.fg('dim', ' · ')
|
|
626
|
-
const wall = theme.fg('dim', ' | ')
|
|
627
|
-
const identity = [theme.fg('accent', model)]
|
|
628
|
-
if (ctx.thinkingLevel) identity.push(pair(theme, 'Effort', ctx.thinkingLevel))
|
|
629
|
-
|
|
630
|
-
const ttft: string[] = []
|
|
625
|
+
// Row 2: model and the latest turn's timing on the right, path on the left. The right
|
|
626
|
+
// side assembles through timingRow's drop ladder, so a narrow terminal loses whole
|
|
627
|
+
// slots — never a half-rendered one.
|
|
631
628
|
const waiting = ttftDisplay(requestAt, firstTokenAt, Date.now())
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
629
|
+
// The clock is running: this wait has no reading yet, so the previous turn's numbers
|
|
630
|
+
// would only be mistaken for the current one.
|
|
631
|
+
const ttft =
|
|
632
|
+
waiting !== null
|
|
633
|
+
? waiting.text
|
|
634
|
+
: reading && reading.ttftMs !== null
|
|
635
|
+
? formatLatency(reading.ttftMs)
|
|
636
|
+
: null
|
|
640
637
|
const avgTtft = avgMs(totalTtftMs, ttftCount)
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
.filter((group) => group.length > 0)
|
|
653
|
-
.map((group) => group.join(separator))
|
|
654
|
-
.join(wall)
|
|
638
|
+
const row2Right = timingRow(theme, width, {
|
|
639
|
+
model: ctx.model?.id ?? 'no model',
|
|
640
|
+
effort: ctx.thinkingLevel ?? null,
|
|
641
|
+
ttft,
|
|
642
|
+
avgTtft: avgTtft !== null ? formatLatency(avgTtft) : null,
|
|
643
|
+
last:
|
|
644
|
+
reading && reading.rate !== null
|
|
645
|
+
? `${reading.exact ? '' : '~'}${formatTps(reading.rate)} tok/s`
|
|
646
|
+
: null,
|
|
647
|
+
avg: avg !== null ? `${formatTps(avg)} tok/s` : null,
|
|
648
|
+
})
|
|
655
649
|
const branch = footerData.getGitBranch()
|
|
656
650
|
const path = formatCwd(ctx.cwd)
|
|
657
651
|
const branchSuffix = branch ? ` (${branch})` : ''
|
|
@@ -826,7 +820,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
826
820
|
const decodeMs = firstTokenAt !== null ? Date.now() - firstTokenAt : 0
|
|
827
821
|
const measured = ttftMs(requestAt, firstTokenAt)
|
|
828
822
|
const tokens = output > 0 ? output : totalChars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
|
|
829
|
-
|
|
823
|
+
const rate = frozenThroughput(decodeMs, tokens, MIN_SAMPLE_MS)
|
|
824
|
+
if (measured !== null && rate !== null) {
|
|
830
825
|
// The average's numerator and denominator must cover the same messages: totals.output spans
|
|
831
826
|
// the whole session (a reload replays none of it), so pairing it with the partial denominator
|
|
832
827
|
// below once produced an Avg of 4324 tok/s.
|
|
@@ -835,11 +830,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
835
830
|
totalTtftMs += measured
|
|
836
831
|
ttftCount += 1
|
|
837
832
|
persist(ctx.sessionManager.getSessionFile() ?? null)
|
|
838
|
-
publish(
|
|
839
|
-
} else if (measured !== null
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
|
|
833
|
+
publish(rate, output > 0, measured)
|
|
834
|
+
} else if (measured !== null) {
|
|
835
|
+
// A sub-floor window is a burst arrival: the message landed in one or two chunks and there
|
|
836
|
+
// is no decode phase to measure — dividing by those milliseconds once printed Last 82000
|
|
837
|
+
// tok/s. Keep the honest TTFT, publish no rate; the live sliding-window sample is no
|
|
838
|
+
// fallback either, a buffered burst mid-stream reads triple there too.
|
|
839
|
+
publish(null, false, measured)
|
|
840
|
+
persist(ctx.sessionManager.getSessionFile() ?? null)
|
|
843
841
|
}
|
|
844
842
|
// Null it with the stream: a request that has produced its message is no longer in flight, and
|
|
845
843
|
// a stale anchor would let the live branch count against nothing until the next turn.
|
|
@@ -848,9 +846,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
848
846
|
})
|
|
849
847
|
|
|
850
848
|
// The breakdown panel is opt-in: the concise footer stays the default. Both toggles remember
|
|
851
|
-
// the choice in the config file. ctrl+
|
|
852
|
-
// extension
|
|
853
|
-
// is the one place to move it.
|
|
849
|
+
// the choice in the config file. ctrl+q is unbound across pi's default keymaps; if another
|
|
850
|
+
// extension owns it here, BREAKDOWN_SHORTCUT is the one place to move it.
|
|
854
851
|
pi.registerShortcut(BREAKDOWN_SHORTCUT, {
|
|
855
852
|
description: 'Toggle the context breakdown panel in the status line',
|
|
856
853
|
handler: () => {
|
|
@@ -85,6 +85,29 @@ export function ttftMs(requestAt: number | null, firstTokenAt: number | null): n
|
|
|
85
85
|
return firstTokenAt > requestAt ? firstTokenAt - requestAt : null
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The frozen Last throughput for a finished message: its whole-message rate, output tokens over the
|
|
90
|
+
* entire decode window.
|
|
91
|
+
*
|
|
92
|
+
* A decode window below the sample floor is a burst arrival — the whole message landed in one or
|
|
93
|
+
* two chunks, so there is no decode phase to measure. Dividing by those couple of milliseconds once
|
|
94
|
+
* printed `Last 82000 tok/s` for an 82-token greeting; like the negative TTFT above, an
|
|
95
|
+
* unmeasurable rate reports absence rather than a confident fake.
|
|
96
|
+
*
|
|
97
|
+
* @param decodeMs - Milliseconds from the first streamed delta to message end.
|
|
98
|
+
* @param tokens - Output tokens for the message (provider-reported when available).
|
|
99
|
+
* @param minSampleMs - The shortest window considered a real decode phase.
|
|
100
|
+
* @returns The tokens/second rate, or null when the window is too short to measure.
|
|
101
|
+
*/
|
|
102
|
+
export function frozenThroughput(
|
|
103
|
+
decodeMs: number,
|
|
104
|
+
tokens: number,
|
|
105
|
+
minSampleMs: number,
|
|
106
|
+
): number | null {
|
|
107
|
+
if (decodeMs < minSampleMs) return null
|
|
108
|
+
return (tokens / decodeMs) * 1000
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
/** What the TTFT slot renders, and whether the clock is still running. */
|
|
89
112
|
export interface TtftDisplay {
|
|
90
113
|
text: string
|
|
@@ -419,12 +442,13 @@ export interface ContextRowParts {
|
|
|
419
442
|
* Row 1: context pressure on the left, session totals on the right.
|
|
420
443
|
*
|
|
421
444
|
* Space is given up in a fixed order rather than all at once, because this row carries numbers that
|
|
422
|
-
* appear nowhere else in the footer. A narrower terminal
|
|
423
|
-
* visibly rather than silently dropping the whole group:
|
|
445
|
+
* appear nowhere else in the footer. A narrower terminal costs, in turn:
|
|
424
446
|
*
|
|
425
447
|
* 1. The absolute `tokens / window` detail,
|
|
426
|
-
* 2. The input/output volumes, which are informative but not
|
|
427
|
-
* 3. The
|
|
448
|
+
* 2. The input/output volumes, which are informative but not bills,
|
|
449
|
+
* 3. The cache-hit rate, which is diagnostic rather than money,
|
|
450
|
+
* 4. Today's running total (the session's own bill outlives it),
|
|
451
|
+
* 5. The bar's own cells, down to a stub — the percent survives everything.
|
|
428
452
|
*
|
|
429
453
|
* @param currency - Used for the cost; defaults to USD so the row renders without a config file.
|
|
430
454
|
*/
|
|
@@ -441,7 +465,9 @@ export function contextRow(
|
|
|
441
465
|
parts.percent === null ? '--' : `${Math.round(parts.percent)}%`,
|
|
442
466
|
)
|
|
443
467
|
// The title is Claude Code's: "Context window", so the two panels read the same at a glance.
|
|
444
|
-
const
|
|
468
|
+
const meterWith = (cells: number): string =>
|
|
469
|
+
`${theme.fg('dim', 'Context window')} ${bar(theme, cells, fraction)} ${percent}`
|
|
470
|
+
const meter = meterWith(BAR_CELLS)
|
|
445
471
|
const detail =
|
|
446
472
|
parts.window > 0 ? `${formatTokens(parts.tokens)} / ${formatTokens(parts.window)}` : ''
|
|
447
473
|
const withDetail = detail === '' ? meter : `${meter} ${theme.fg('dim', detail)}`
|
|
@@ -475,9 +501,97 @@ export function contextRow(
|
|
|
475
501
|
const fits = (left: string, right: string): boolean =>
|
|
476
502
|
right === '' || visibleWidth(left) + 2 + visibleWidth(right) <= width
|
|
477
503
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
504
|
+
const moneyOnly = money.join(separator)
|
|
505
|
+
// The session's own bill outlives today's running total.
|
|
506
|
+
const ownBill = money.length > 0 ? (money[0] ?? '') : ''
|
|
507
|
+
|
|
508
|
+
// The ladder, highest fidelity first: the detail, the volumes, the cache diagnostics and
|
|
509
|
+
// today's total each give up their seat before the bar itself shrinks — twenty cells of bar
|
|
510
|
+
// are a luxury the bills should not have to pay for.
|
|
511
|
+
const compact = 10
|
|
512
|
+
const rungs: Array<{ left: string; right: string }> = [
|
|
513
|
+
{ left: withDetail, right: full },
|
|
514
|
+
{ left: meter, right: full },
|
|
515
|
+
{ left: meter, right: core },
|
|
516
|
+
{ left: meter, right: moneyOnly },
|
|
517
|
+
{ left: meterWith(compact), right: moneyOnly },
|
|
518
|
+
{ left: meterWith(compact), right: ownBill },
|
|
519
|
+
]
|
|
520
|
+
for (const { left, right } of rungs) {
|
|
521
|
+
if (right === '' ? visibleWidth(left) <= width : fits(left, right)) {
|
|
522
|
+
return row(theme, width, left, right)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Floor: the percent is the one number this row exists to show — the bar shrinks to a stub
|
|
527
|
+
// before the percent is ever truncated, and past that the label goes, never the number.
|
|
528
|
+
const stub = Math.min(compact, width - 22)
|
|
529
|
+
if (stub >= 4) return row(theme, width, meterWith(stub), '')
|
|
530
|
+
if (width >= 18) {
|
|
531
|
+
return row(theme, width, `${theme.fg('dim', 'Context window')} ${percent}`, '')
|
|
532
|
+
}
|
|
533
|
+
return truncateToWidth(percent, width, theme.fg('dim', '…'))
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** The row-2 right side, the caller pre-formatting each slot's display text. */
|
|
537
|
+
export interface TimingRowParts {
|
|
538
|
+
model: string
|
|
539
|
+
/** Thinking level label, or null to hide the slot. */
|
|
540
|
+
effort: string | null
|
|
541
|
+
/** The live TTFT clock or the frozen reading, or null to hide the slot. */
|
|
542
|
+
ttft: string | null
|
|
543
|
+
/** Session-average TTFT, or null to hide the slot. */
|
|
544
|
+
avgTtft: string | null
|
|
545
|
+
/** Last turn's decode rate, or null to hide the slot. */
|
|
546
|
+
last: string | null
|
|
547
|
+
/** Session-average decode rate, or null to hide the slot. */
|
|
548
|
+
avg: string | null
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Row 2's right side: the model, the effort level, and the latest turn's timing.
|
|
553
|
+
*
|
|
554
|
+
* Like row 1, space is given up in a fixed order rather than half rendered. A narrower terminal
|
|
555
|
+
* costs, in turn: the throughput average, the TTFT average, the effort level, the last rate —
|
|
556
|
+
* aggregates before live numbers, static config before measured history. The model and the TTFT
|
|
557
|
+
* clock are the floor; past them the line truncates, because a model id is the one string here with
|
|
558
|
+
* no drop-in substitute.
|
|
559
|
+
*
|
|
560
|
+
* @returns The assembled right side, at most `width` cells wide.
|
|
561
|
+
*/
|
|
562
|
+
export function timingRow(theme: Theme, width: number, parts: TimingRowParts): string {
|
|
563
|
+
const separator = theme.fg('dim', ' · ')
|
|
564
|
+
const wall = theme.fg('dim', ' | ')
|
|
565
|
+
const slots = {
|
|
566
|
+
model: theme.fg('accent', parts.model),
|
|
567
|
+
effort: parts.effort !== null ? pair(theme, 'Effort', parts.effort) : null,
|
|
568
|
+
ttft: parts.ttft !== null ? pair(theme, 'TTFT', parts.ttft) : null,
|
|
569
|
+
avgTtft: parts.avgTtft !== null ? pair(theme, 'Avg TTFT', parts.avgTtft) : null,
|
|
570
|
+
last: parts.last !== null ? pair(theme, 'Last', parts.last) : null,
|
|
571
|
+
avg: parts.avg !== null ? pair(theme, 'Avg', parts.avg) : null,
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
type Droppable = 'effort' | 'ttft' | 'avgTtft' | 'last' | 'avg'
|
|
575
|
+
const hidden = new Set<Droppable>()
|
|
576
|
+
const assemble = (): string =>
|
|
577
|
+
[
|
|
578
|
+
[slots.model, hidden.has('effort') ? null : slots.effort],
|
|
579
|
+
[hidden.has('ttft') ? null : slots.ttft, hidden.has('avgTtft') ? null : slots.avgTtft],
|
|
580
|
+
[hidden.has('last') ? null : slots.last, hidden.has('avg') ? null : slots.avg],
|
|
581
|
+
]
|
|
582
|
+
.map((group) => group.filter((slot): slot is string => slot !== null))
|
|
583
|
+
.filter((group) => group.length > 0)
|
|
584
|
+
.map((group) => group.join(separator))
|
|
585
|
+
.join(wall)
|
|
586
|
+
|
|
587
|
+
const ladder: Droppable[] = ['avg', 'avgTtft', 'effort', 'last']
|
|
588
|
+
let right = assemble()
|
|
589
|
+
for (const key of ladder) {
|
|
590
|
+
if (visibleWidth(right) <= width) break
|
|
591
|
+
hidden.add(key)
|
|
592
|
+
right = assemble()
|
|
593
|
+
}
|
|
594
|
+
return truncateToWidth(right, width, theme.fg('dim', '…'))
|
|
481
595
|
}
|
|
482
596
|
|
|
483
597
|
/** `label value` where the key is quiet and the value is not. */
|