pi-editor-footer 0.2.0 → 0.4.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 +50 -0
- package/dist/border-renderer.js +104 -19
- package/dist/chrome-state.js +120 -0
- package/dist/color-policy.js +11 -0
- package/dist/config.js +37 -3
- package/dist/cursor-policy.js +5 -1
- package/dist/footer-controller.js +2 -2
- package/dist/footer.js +33 -42
- package/dist/git.js +1 -0
- package/dist/header.js +2 -0
- package/dist/index.js +261 -111
- package/dist/live-border.js +161 -0
- package/dist/model-info.js +2 -5
- package/dist/runtime.js +7 -3
- package/dist/session-kernel.js +25 -1
- package/dist/state.js +6 -2
- package/dist/telemetry.js +94 -67
- package/dist/theme-settings.js +42 -4
- package/dist/tracking-editor.js +94 -36
- package/dist/transcript-timeline.js +362 -0
- package/dist/utils.js +1 -1
- package/docs/reference/pi-tui-internals.md +3 -1
- package/package.json +11 -5
- package/src/border-renderer.ts +127 -25
- package/src/chrome-state.ts +211 -0
- package/src/color-policy.ts +9 -0
- package/src/config.ts +42 -2
- package/src/cursor-policy.ts +3 -0
- package/src/footer-controller.ts +2 -2
- package/src/footer.ts +26 -69
- package/src/git.ts +1 -0
- package/src/header.ts +2 -0
- package/src/index.ts +269 -134
- package/src/live-border.ts +200 -0
- package/src/model-info.ts +3 -7
- package/src/runtime.ts +7 -3
- package/src/session-kernel.ts +34 -1
- package/src/state.ts +2 -0
- package/src/telemetry.ts +111 -82
- package/src/theme-settings.ts +47 -4
- package/src/tracking-editor.ts +109 -35
- package/src/transcript-timeline.ts +405 -0
- package/src/utils.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,56 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.4.0] - 2026-08-22
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Tiered context usage colors — `contextUsageColor(pct)` with 25%/50%/75% thresholds (`dim` <25 → `accent` 25–50 → `warning` 50–75 → `error` ≥75) applied to both `percent` and `tokens/window` sections and to bar/icon, so low usage stays dimmed and high usage highlights for quota visibility (`color-policy`, `chrome-state:formatContextBar`, `utils` barrel)
|
|
12
|
+
- Deepened architecture — candidates 1–5 (`TrackingEditor`, `TranscriptTimeline`, `LiveBorder`, `ChromeState`, `SessionKernel`) behind single seams, plus sync contract extension for transcript timeline seam (candidate 2)
|
|
13
|
+
- Telemetry formatting — `TPS`/`TTFT` with units and fixed width (`60.6 tok/s TPS`, `2.5s TTFT`), default tokens at start, and `↑`/`↓` relocated to right of cache with `|` separator
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Hide `↑`/`↓` tokens at startup (only after `turn_start` via `liveInputTokens`)
|
|
18
|
+
- Dim line padding and fixed-width `TPS`/`TTFT`/`duration` with gradual TPS decay (half-life ~5s, reset after 2s idle)
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Reset TPS to default after 2s without incoming tokens before first token
|
|
23
|
+
- Interleave timeline in transcript (`chatContainer` injection, left-aligned dim `·`/`↑`/`↓`/`c`/`$` with `|`, close right) and correct `pi` extension path (`src/index.ts`)
|
|
24
|
+
- Satisfy `pi-lens` blocking — `SAFETY` comments for `as unknown as` casts (including `chrome-state`, `live-border`) and best-effort catches
|
|
25
|
+
|
|
26
|
+
## [0.3.0] - 2026-08-21
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Timeline format now two-line dim per user spec: `2026-08-21 13:48:46 GMT+8 · 11s · ↑ 495 · ↓ 708 · c 85.3% · $0.00` + `3 turns · 5 tools · 1 failed` (datetime with timezone via `Intl` `en-CA` `short` TZ, wall `formatDuration`, `↑`/`↓` `fmtTokens`, cache `glyphs.cacheHit` `latestCacheHitRate`, cost `$`, turn/tools/failed from `runActivity` snapshot)
|
|
31
|
+
- Dimmed timeline now in transcript between runs — `User: hello? / Assistant: hi. / <dim timeline>` left-aligned dim, injected into `chatContainer` (scrollable) via `InteractiveMode` patch, not `aboveEditor` widget, so it sits between each `User/Assistant` pair and scrolls with history
|
|
32
|
+
- Dimmed timeline between each agent run in transcript — `chatContainer` injection `User/Assistant/<dim timeline>` left-aligned dim `· 8s wall · ↑ 1.2k · ↓ 800 · $0.12` via `InteractiveMode` patch (not `aboveEditor` widget), one per `agent_settled` per `timeline.*`, permanent in scroll history, never exposed to model (between transcript bottom and input), one per `agent_settled` (`· 8s wall · ↑ 1.2k · ↓ 800 · $0.12`), permanent between runs, never exposed to model (pi-tui only exposes `aboveEditor`/`belowEditor` widgets, so stacking aboveEditor is the non-exposing compromise)
|
|
33
|
+
- Wall time dim line left aligned and permanent between `agent_end` and next `agent_start` (`aboveEditor` `· 8s wall · ↑` dim left, not centered, stays in gap, never exposed)
|
|
34
|
+
- Dim line after `agent_end` now with specified timeline metrics (`timeline.enabled`/`wallTime`/`tokens`/`cost` in `ThemeConfig`, `Timeline` tab in `/pi-editor-footer` settings) — `· 8s wall · ↑ 1.2k · ↓ 800 · $0.12` dim aboveEditor, never exposed to model
|
|
35
|
+
- Wall time dim line after `agent_end` now positioned after last agent message in transcript (`aboveEditor` widget `· 8s wall · ↑ 1.2k · ↓ 800 · $0.12` dim, not footer second line) — visible right after `agent_end` message, hidden on next `agent_start`, never exposed to model
|
|
36
|
+
- Wall time dim line after `agent_end` now a separate footer row (was inline `cwd · wall`) — now `line1: cwd · runtime | ↑·↓·$` and `line2: · 8s wall · ↑ 1.2k · ↓ 800 · $0.12` dim, so timeline is visible
|
|
37
|
+
- Tokens separator now `·` not `|` (`↑ 1.2k · ↓ 800 · $0.12` in footer stats and `· 8s wall · ↑ · ↓ · $` dim line)
|
|
38
|
+
- Settings window title aligned to repo name `pi-editor-footer Settings` (was `pi-lsz-theme Settings`) and slash command renamed `pi-footer` → `pi-editor-footer`
|
|
39
|
+
- Tokens/cost relocated from telemetry bottom to wall time dim line — bottom now `TPS 4.0 tok/s · TTFT 4.0s · 5.0s` (no `↑`/`↓`/`$`), dim line after `agent_end` is `· 8s wall · ↑ 1.2k · ↓ 800 · $0.12` (all `dim`, TUI-only, never exposed to model, via `FooterState.lastDoneIn` + `getUsageTotals` gated by `telemetry.tokens`/`cost`)
|
|
40
|
+
- Git branch never folded — `renderGitSegment` no longer `truncateBranch(…,20)`, full `feature/very-long-…` shown; priority `6` (> cwd `5`) so `fitSegmentsByPriority` never omits branch before cwd/runtime/stats
|
|
41
|
+
- Telemetry TPS display throttled to 1 s (data vs display separated) — `message_update` now only updates `liveDeltaChars`/`liveEstimatedTokens` in `TurnTelemetryTracker`, display `peekLive()` → `formatTurnTelemetry` only via `liveTickTimer` every `REFRESH_MS` and `message_start`/`end`/`turn_end`/`agent_settled` for approximately accurate TPS without per-delta TUI jank
|
|
42
|
+
- Telemetry cost toggling `telemetry.cost` off/on no longer resets cost to `$0.00` — `refreshLiveTelemetry` now falls back to `getLastTelemetry()` when `peekLive()` is null (idle) and `onConfigChanged` calls `refreshLiveTelemetry()` immediately
|
|
43
|
+
- Refresh rate set to one second (`REFRESH_MS = 1000` for `liveTickTimer` telemetry/top/context and `watchTimer` editor ownership watchdog, was hardcoded `1000`)
|
|
44
|
+
- Context icon bar now switchable via config `contextIconBar` (default `false` disabled) — `0.0% · 0/1.0M | c 0.0%` by default, `# [████░░] 39.6% · 416k/1.0M | c 85.3%` when enabled (footer top, `nerd`/`ascii` glyphs)
|
|
45
|
+
- Context bar format now `0.0% · 0/1.0M | c 0.0%` (was `# [#####-------] 39.6% · 416k/1.0M | c`; no icon/bar, compact `pct · tokens/W`)
|
|
46
|
+
- Context bar and cache now follow icon mode (`nerd` ``/``/`█` vs `ascii` `#`/`c`/`#`) — `refreshContextBar` called in `onConfigChanged` so top `# [...] | c` updates immediately when `icons.mode` changes
|
|
47
|
+
- Telemetry `>`, `~`, `+` glyphs removed (`TPS 60.6 tok/s · TTFT 2.5s · 8.3s · ↑395 · ↓505 · $0.16` not `> TPS … · ~ TTFT … · +8.3s`)
|
|
48
|
+
- Telemetry bottom border compacted (`TPS 60.6 tok/s · TTFT 2.5s · 8.3s · ↑395 · ↓505 · $0.16` not `> TPS 60.6 tok/s | ~ TTFT 2.5s | + 8.3s | ↑ 395 | ↓ 505 | $0.16`; `TPS`/`TTFT` labels preserved, no space after `↑`/`↓`, joiner `·` not ` | `, stall `!2·3.3s` not `! stall 2x / 3.3s`)
|
|
49
|
+
- Footer cache omitted to the right of input/output (`↑ 395 | ↓ 505 | $0.16` not `| c 85.3%`; cache remains only in top context bar `# [...] | c 0.0%`)
|
|
50
|
+
- Nerd mode cost now `$0.00` not ` $0.00` (always single `$` in both footer `renderFooter` and telemetry `formatTurnTelemetry`, was `glyph === "$" ?`$ / ` : `glyph $` -> ` $`)
|
|
51
|
+
- Cache session now always shown with zero value (`c 0.0%` in footer `↑ | ↓ | $ | c` and `| c 0.0%` in top context bar) instead of hidden when no cache tokens
|
|
52
|
+
- Footer cost now `toFixed(2)` (`$0.16` not `$0.160`) and moved directly after input/output (`↑ 395 | ↓ 505 | $0.16 | c 85.3%` not `c | $`; `theme.fg("dim","|")` pipe)
|
|
53
|
+
- Top border model label no longer shows context window (`provider/model · thinking` not `· 1.0M`; `buildLabel` ignores `contextWindow`)
|
|
54
|
+
- Context bar now appends cache hit rate with pipe (`# [#####-------] 39.6% · 416k/1.0M | c 85.3%` via `formatContextBar(..., cacheHitRate)` from `getUsageTotals`) and model/context are pipe-separated (`model | context` via dim `|`)
|
|
55
|
+
- Context bar moved from left bottom border to top border right of model info (`── model · thinking · 1.0M # [#####-------] 39.6% · 416k/1.0M ── T1 · 8s` via `embedTopWithLeftAndRight`, `TrackingEditor.setTopContextText`)
|
|
56
|
+
|
|
7
57
|
## [0.2.0] - 2026-08-21
|
|
8
58
|
|
|
9
59
|
### Added
|
package/dist/border-renderer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import {
|
|
2
|
+
import { buildLabel, } from "./model-info.js";
|
|
3
3
|
function stripAnsi(s) {
|
|
4
4
|
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
5
5
|
}
|
|
@@ -23,25 +23,67 @@ export class BorderRenderer {
|
|
|
23
23
|
let out = [...lines];
|
|
24
24
|
if (out.length === 0)
|
|
25
25
|
return out;
|
|
26
|
-
// Top
|
|
27
|
-
|
|
26
|
+
// Top: model info left, context bar right of it, run-activity far right
|
|
27
|
+
const hasTop = opts.glowEnabled || opts.topContextText || (!opts.topTokensText && opts.topRightText);
|
|
28
|
+
if (hasTop) {
|
|
29
|
+
const theme = this.getLiveTheme();
|
|
30
|
+
const info = this.getModelInfo();
|
|
31
|
+
const glow = (s) => {
|
|
32
|
+
try {
|
|
33
|
+
const maybeGlow = theme.getThinkingBorderColor;
|
|
34
|
+
if (typeof maybeGlow === "function")
|
|
35
|
+
return maybeGlow.call(theme, info.level)(s);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// SAFETY: best-effort UI, ignore recoverable error
|
|
39
|
+
}
|
|
40
|
+
return s;
|
|
41
|
+
};
|
|
42
|
+
// When tokens line exists, right moves to that line (one padding) — don't duplicate on top
|
|
43
|
+
const topRightForTop = opts.topTokensText ? "" : opts.topRightText;
|
|
28
44
|
if (opts.glowEnabled) {
|
|
29
|
-
|
|
45
|
+
// Build left label; if context bar present, append it to the right of model info with pipe separator
|
|
46
|
+
let leftLabel = buildLabel(theme, info.provider, info.modelId, info.level, info.contextWindow);
|
|
47
|
+
if (opts.topContextText) {
|
|
48
|
+
leftLabel = `${leftLabel} ${theme.fg("dim", "|")} ${opts.topContextText}`;
|
|
49
|
+
}
|
|
50
|
+
// Embed left (model+context) and optional right (run activity) in one pass to avoid double-truncate
|
|
51
|
+
if (topRightForTop) {
|
|
52
|
+
out = embedTopWithLeftAndRight(out, width, leftLabel, topRightForTop, glow);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
// Only left (model+context), no right — use applyModelInfo replacement but with combined left
|
|
56
|
+
// Reuse embedTopWithLeftAndRight with empty right
|
|
57
|
+
out = embedTopWithLeftAndRight(out, width, leftLabel, "", glow);
|
|
58
|
+
}
|
|
30
59
|
}
|
|
31
|
-
if (opts.
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
60
|
+
else if (opts.topContextText || topRightForTop) {
|
|
61
|
+
// No glow, but still need top borders for context/run activity
|
|
62
|
+
const left = opts.topContextText ?? "";
|
|
63
|
+
const right = topRightForTop ?? "";
|
|
64
|
+
if (left && right) {
|
|
65
|
+
out = embedTopWithLeftAndRight(out, width, left, right, glow);
|
|
66
|
+
}
|
|
67
|
+
else if (left) {
|
|
68
|
+
out = embedTopRightBorder(out, width, left, glow);
|
|
69
|
+
}
|
|
70
|
+
else if (right) {
|
|
71
|
+
out = embedTopRightBorder(out, width, right, glow);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Tokens above model info — left aligned, no border; right moved close to tokens with | separator per user request
|
|
76
|
+
if (opts.topTokensText || opts.topRightText) {
|
|
77
|
+
const tokensLeft = opts.topTokensText ?? "";
|
|
78
|
+
const tokensRight = opts.topRightText ?? "";
|
|
79
|
+
if (tokensLeft) {
|
|
80
|
+
// Use | as separator between ↑/↓ and tool turns (dimmed) per user request
|
|
81
|
+
// SAFETY: pi theme seam — fg is optional theme method
|
|
82
|
+
const themeForTokens = this.getLiveTheme();
|
|
83
|
+
const fg = themeForTokens.fg;
|
|
84
|
+
const dimPipe = typeof fg === "function" ? fg.call(themeForTokens, "dim", " | ") : " | ";
|
|
85
|
+
const combined = tokensRight ? `${tokensLeft}${dimPipe}${tokensRight}` : tokensLeft;
|
|
86
|
+
out = embedTokensAbove(out, width, combined);
|
|
45
87
|
}
|
|
46
88
|
}
|
|
47
89
|
// Bottom embedding (telemetry left/right)
|
|
@@ -54,7 +96,7 @@ export class BorderRenderer {
|
|
|
54
96
|
return maybeGlow.call(theme, this.getModelInfo().level)(s);
|
|
55
97
|
}
|
|
56
98
|
catch {
|
|
57
|
-
// ignore
|
|
99
|
+
// SAFETY: best-effort UI, ignore recoverable error
|
|
58
100
|
}
|
|
59
101
|
return s;
|
|
60
102
|
};
|
|
@@ -63,6 +105,49 @@ export class BorderRenderer {
|
|
|
63
105
|
return out;
|
|
64
106
|
}
|
|
65
107
|
}
|
|
108
|
+
function embedTokensAbove(lines, width, tokensText) {
|
|
109
|
+
if (!tokensText || lines.length === 0)
|
|
110
|
+
return lines;
|
|
111
|
+
// Left aligned, no border; one leading padding as before
|
|
112
|
+
const raw = ` ${tokensText}`;
|
|
113
|
+
const display = truncateToWidth(raw, width, "");
|
|
114
|
+
const result = [...lines];
|
|
115
|
+
result.splice(0, 0, display);
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
function embedTopWithLeftAndRight(lines, width, leftText, rightText, getGlow) {
|
|
119
|
+
if (lines.length === 0)
|
|
120
|
+
return lines;
|
|
121
|
+
const top = lines[0] ?? "";
|
|
122
|
+
const plainTop = stripAnsi(top);
|
|
123
|
+
if (/^─── [↑↓] \d+ more/.test(plainTop))
|
|
124
|
+
return lines;
|
|
125
|
+
const leftW = leftText ? visibleWidth(leftText) : 0;
|
|
126
|
+
const rightW = rightText ? visibleWidth(rightText) : 0;
|
|
127
|
+
const maxLeft = rightText ? Math.max(0, width - rightW - 6) : width - 3;
|
|
128
|
+
const maxRight = leftText ? Math.max(0, width - leftW - 6) : width - 3;
|
|
129
|
+
let displayLeft = leftText;
|
|
130
|
+
let displayRight = rightText;
|
|
131
|
+
if (leftW > maxLeft)
|
|
132
|
+
displayLeft = truncateToWidth(leftText, maxLeft, "");
|
|
133
|
+
if (rightW > maxRight)
|
|
134
|
+
displayRight = truncateToWidth(rightText, maxRight, "");
|
|
135
|
+
const leftSegment = displayLeft
|
|
136
|
+
? `${getGlow("─")} ${displayLeft} `
|
|
137
|
+
: getGlow("─");
|
|
138
|
+
const rightSegment = displayRight
|
|
139
|
+
? ` ${displayRight} ${getGlow("─")}`
|
|
140
|
+
: getGlow("─");
|
|
141
|
+
const used = visibleWidth(leftSegment) + visibleWidth(rightSegment);
|
|
142
|
+
const middleWidth = Math.max(0, width - used);
|
|
143
|
+
const middle = getGlow("─".repeat(middleWidth));
|
|
144
|
+
const embedded = `${leftSegment}${middle}${rightSegment}`;
|
|
145
|
+
const result = [...lines];
|
|
146
|
+
result[0] = truncateToWidth(embedded, width, "");
|
|
147
|
+
if (visibleWidth(embedded) !== width)
|
|
148
|
+
result[0] = embedded;
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
66
151
|
function embedBottomBorder(lines, width, leftText, rightText, getGlow) {
|
|
67
152
|
if ((!leftText && !rightText) || lines.length === 0)
|
|
68
153
|
return lines;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChromeState — deep module owning chrome snapshot derivation behind one seam.
|
|
3
|
+
*
|
|
4
|
+
* Problem it solves (C5): same chrome concepts (context %, git, runtime,
|
|
5
|
+
* tokens, cacheHitRate) render through two seams — footer below input and
|
|
6
|
+
* border on TrackingEditor — with derivation scattered across
|
|
7
|
+
* footer.ts:formatContextBar, state.ts:getUsageTotals, index.ts:refreshContextBar,
|
|
8
|
+
* and border-renderer. Tracing one token requires bouncing 4 modules.
|
|
9
|
+
*
|
|
10
|
+
* Depth: one interface (snapshot / format helpers) hides truncation, color
|
|
11
|
+
* policy, cache-hit derivation and cwd fallbacks. Two adapters (footer +
|
|
12
|
+
* border) justify the seam — with one it would be hypothetical.
|
|
13
|
+
*
|
|
14
|
+
* Internal seams (renderBar, stressColor, cacheHitColor, fmtTokens) stay
|
|
15
|
+
* inside this module, not part of its external seam.
|
|
16
|
+
*/
|
|
17
|
+
import { getUsageTotals } from "./state.js";
|
|
18
|
+
import { cacheHitColor, contextUsageColor } from "./color-policy.js";
|
|
19
|
+
import { fmtTokens } from "./format.js";
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Context bar formatting — moved from footer.ts to centralize chrome rendering.
|
|
22
|
+
// Re-exported from footer.ts for backward compatibility.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
function renderBar(theme, pct, barWidth, ascii) {
|
|
25
|
+
const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
|
|
26
|
+
const empty = barWidth - filled;
|
|
27
|
+
const color = contextUsageColor(pct);
|
|
28
|
+
const filledCell = ascii ? "#" : "█";
|
|
29
|
+
const emptyCell = ascii ? "-" : "░";
|
|
30
|
+
return (theme.fg("dim", "[") +
|
|
31
|
+
theme.fg(color, filledCell.repeat(filled)) +
|
|
32
|
+
theme.fg("dim", emptyCell.repeat(empty)) +
|
|
33
|
+
theme.fg("dim", "]"));
|
|
34
|
+
}
|
|
35
|
+
export function formatContextBar(contextUsage, theme, glyphs, isAscii, barWidth = 10, cacheHitRate, showIconBar = false) {
|
|
36
|
+
const contextWindow = contextUsage?.contextWindow ?? 0;
|
|
37
|
+
if (contextWindow <= 0)
|
|
38
|
+
return "";
|
|
39
|
+
const contextPct = contextUsage?.percent ?? 0;
|
|
40
|
+
const contextColor = contextUsageColor(contextPct);
|
|
41
|
+
const pctText = theme.fg(contextColor, `${contextPct.toFixed(1)}%`);
|
|
42
|
+
const contextTokens = contextUsage?.tokens ?? 0;
|
|
43
|
+
const ctxText = `${theme.fg(contextColor, fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg(contextColor, fmtTokens(contextWindow))}`;
|
|
44
|
+
const baseCore = `${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
|
|
45
|
+
const base = showIconBar
|
|
46
|
+
? `${theme.fg(contextColor, glyphs.context)} ${renderBar(theme, contextPct, barWidth, isAscii)} ${baseCore}`
|
|
47
|
+
: baseCore;
|
|
48
|
+
const rate = cacheHitRate !== undefined && Number.isFinite(cacheHitRate)
|
|
49
|
+
? cacheHitRate
|
|
50
|
+
: 0;
|
|
51
|
+
const cacheText = `${glyphs.cacheHit} ${rate.toFixed(1)}%`;
|
|
52
|
+
return `${base} ${theme.fg("dim", "|")} ${theme.fg(cacheHitColor(rate), cacheText)}`;
|
|
53
|
+
}
|
|
54
|
+
/** Border adapter helper — one call from snapshot + theme/glyphs. */
|
|
55
|
+
export function formatTopContextFromSnapshot(snapshot, theme, glyphs, isAscii, showIconBar = false) {
|
|
56
|
+
return formatContextBar(snapshot.contextUsage, theme, glyphs, isAscii, 10, snapshot.totals.latestCacheHitRate, showIconBar);
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Snapshot derivation — pure, owns cwd/sessionName/context/totals consolidation.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
/**
|
|
62
|
+
* Create a chrome snapshot from extension context + footer state.
|
|
63
|
+
* Pure derivation: hides getUsageTotals caching, getContextUsage optional,
|
|
64
|
+
* and cwd/sessionName fallbacks behind one seam.
|
|
65
|
+
*/
|
|
66
|
+
export function createChromeSnapshot(ctx, footerState) {
|
|
67
|
+
const cwd = ctx?.sessionManager?.getCwd?.() ??
|
|
68
|
+
// SAFETY: pi seam
|
|
69
|
+
ctx?.cwd ?? // SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
70
|
+
process.cwd();
|
|
71
|
+
const sessionName = ctx?.sessionManager?.getSessionName?.();
|
|
72
|
+
const contextUsage = ctx?.getContextUsage?.();
|
|
73
|
+
const totals = getUsageTotals(
|
|
74
|
+
// SAFETY: pi seam
|
|
75
|
+
(ctx ?? {}));
|
|
76
|
+
// footerState may be absent when called from LiveBorder (context-only); use empty defaults
|
|
77
|
+
// SAFETY: pi seam
|
|
78
|
+
const git = footerState?.git ??
|
|
79
|
+
{
|
|
80
|
+
branch: undefined,
|
|
81
|
+
ahead: 0,
|
|
82
|
+
behind: 0,
|
|
83
|
+
modified: 0,
|
|
84
|
+
untracked: 0,
|
|
85
|
+
staged: 0,
|
|
86
|
+
stashed: 0,
|
|
87
|
+
conflicted: 0,
|
|
88
|
+
renamed: 0,
|
|
89
|
+
deleted: 0,
|
|
90
|
+
commit: null,
|
|
91
|
+
}; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
92
|
+
const runtime = footerState?.runtime ?? null;
|
|
93
|
+
return {
|
|
94
|
+
cwd,
|
|
95
|
+
sessionName,
|
|
96
|
+
git,
|
|
97
|
+
runtime,
|
|
98
|
+
contextUsage,
|
|
99
|
+
totals,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Deep module variant — owns snapshot + refresh lifecycle behind one seam.
|
|
104
|
+
* Thin wrapper over createChromeSnapshot for callers that prefer an instance.
|
|
105
|
+
*/
|
|
106
|
+
export class ChromeState {
|
|
107
|
+
getFooterState;
|
|
108
|
+
getCtx;
|
|
109
|
+
constructor(getFooterState, getCtx) {
|
|
110
|
+
this.getFooterState = getFooterState;
|
|
111
|
+
this.getCtx = getCtx;
|
|
112
|
+
}
|
|
113
|
+
snapshot() {
|
|
114
|
+
return createChromeSnapshot(this.getCtx(), this.getFooterState());
|
|
115
|
+
}
|
|
116
|
+
/** Convenience: format top context bar from current snapshot + theme. */
|
|
117
|
+
formatTopContext(theme, glyphs, isAscii, showIconBar = false) {
|
|
118
|
+
return formatTopContextFromSnapshot(this.snapshot(), theme, glyphs, isAscii, showIconBar);
|
|
119
|
+
}
|
|
120
|
+
}
|
package/dist/color-policy.js
CHANGED
|
@@ -5,6 +5,17 @@ export function stressColor(value, warn = 70, danger = 90) {
|
|
|
5
5
|
return "warning";
|
|
6
6
|
return "accent";
|
|
7
7
|
}
|
|
8
|
+
export function contextUsageColor(pct) {
|
|
9
|
+
// 25% 50% 75% thresholds — transit from dimmed (low) to highlight (high)
|
|
10
|
+
// for intuitive quota status: dim (<25) → accent (25-50) → warning (50-75) → error (≥75)
|
|
11
|
+
if (pct >= 75)
|
|
12
|
+
return "error";
|
|
13
|
+
if (pct >= 50)
|
|
14
|
+
return "warning";
|
|
15
|
+
if (pct >= 25)
|
|
16
|
+
return "accent";
|
|
17
|
+
return "dim";
|
|
18
|
+
}
|
|
8
19
|
export function cacheHitColor(value) {
|
|
9
20
|
if (value < 30)
|
|
10
21
|
return "error";
|
package/dist/config.js
CHANGED
|
@@ -9,6 +9,7 @@ export const DEFAULT_CONFIG = {
|
|
|
9
9
|
icons: {
|
|
10
10
|
mode: "auto",
|
|
11
11
|
},
|
|
12
|
+
contextIconBar: false,
|
|
12
13
|
footerSegments: {
|
|
13
14
|
cwd: true,
|
|
14
15
|
sessionName: false,
|
|
@@ -30,6 +31,12 @@ export const DEFAULT_CONFIG = {
|
|
|
30
31
|
stalls: true,
|
|
31
32
|
cost: true,
|
|
32
33
|
},
|
|
34
|
+
timeline: {
|
|
35
|
+
enabled: true,
|
|
36
|
+
wallTime: true,
|
|
37
|
+
tokens: true,
|
|
38
|
+
cost: true,
|
|
39
|
+
},
|
|
33
40
|
};
|
|
34
41
|
export function getConfigPath() {
|
|
35
42
|
const home = homedir();
|
|
@@ -84,18 +91,41 @@ function validate(config) {
|
|
|
84
91
|
if (!isBoolean(config.enabled)) {
|
|
85
92
|
config.enabled = DEFAULT_CONFIG.enabled;
|
|
86
93
|
}
|
|
94
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
95
|
+
if (!isBoolean(config.contextIconBar)) {
|
|
96
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
97
|
+
config.contextIconBar = DEFAULT_CONFIG.contextIconBar;
|
|
98
|
+
}
|
|
99
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
87
100
|
const fs = config.footerSegments;
|
|
101
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
88
102
|
const dfs = DEFAULT_CONFIG.footerSegments;
|
|
89
103
|
for (const k of Object.keys(dfs)) {
|
|
90
104
|
if (!isBoolean(fs[k]))
|
|
91
105
|
fs[k] = dfs[k];
|
|
92
106
|
}
|
|
107
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
93
108
|
const t = config.telemetry;
|
|
109
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
94
110
|
const dt = DEFAULT_CONFIG.telemetry;
|
|
95
111
|
for (const k of Object.keys(dt)) {
|
|
96
112
|
if (!isBoolean(t[k]))
|
|
97
113
|
t[k] = dt[k];
|
|
98
114
|
}
|
|
115
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
116
|
+
const tl = config.timeline;
|
|
117
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
118
|
+
const dtl = DEFAULT_CONFIG.timeline;
|
|
119
|
+
if (!tl || typeof tl !== "object") {
|
|
120
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
121
|
+
config.timeline = structuredClone(DEFAULT_CONFIG.timeline);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
for (const k of Object.keys(dtl)) {
|
|
125
|
+
if (!isBoolean(tl[k]))
|
|
126
|
+
tl[k] = dtl[k];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
99
129
|
return config;
|
|
100
130
|
}
|
|
101
131
|
export class ConfigStore {
|
|
@@ -108,7 +138,9 @@ export class ConfigStore {
|
|
|
108
138
|
constructor(deps = {}) {
|
|
109
139
|
this.readFile =
|
|
110
140
|
deps.readFile ??
|
|
111
|
-
((p, enc) =>
|
|
141
|
+
((p, enc) =>
|
|
142
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
143
|
+
readFileSync(p, enc));
|
|
112
144
|
this.writeFile =
|
|
113
145
|
deps.writeFile ??
|
|
114
146
|
((p, d, enc) => writeFileSync(p, d, enc));
|
|
@@ -129,7 +161,7 @@ export class ConfigStore {
|
|
|
129
161
|
this.writeFile(p, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf8");
|
|
130
162
|
}
|
|
131
163
|
catch {
|
|
132
|
-
// best-effort
|
|
164
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
133
165
|
}
|
|
134
166
|
return structuredClone(DEFAULT_CONFIG);
|
|
135
167
|
}
|
|
@@ -140,6 +172,7 @@ export class ConfigStore {
|
|
|
140
172
|
return validate(merged);
|
|
141
173
|
}
|
|
142
174
|
catch (err) {
|
|
175
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
143
176
|
console.warn(`[pi-skill-desc] config parse error (${p}): ${err instanceof Error ? err.message : String(err)} — using defaults`);
|
|
144
177
|
return structuredClone(DEFAULT_CONFIG);
|
|
145
178
|
}
|
|
@@ -155,13 +188,14 @@ export class ConfigStore {
|
|
|
155
188
|
this.writeFile(p, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
|
156
189
|
}
|
|
157
190
|
catch {
|
|
158
|
-
// best-effort
|
|
191
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
159
192
|
}
|
|
160
193
|
for (const fn of this.listeners) {
|
|
161
194
|
try {
|
|
162
195
|
fn(merged, prev);
|
|
163
196
|
}
|
|
164
197
|
catch {
|
|
198
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
165
199
|
// subscriber error should not break store
|
|
166
200
|
}
|
|
167
201
|
}
|
package/dist/cursor-policy.js
CHANGED
|
@@ -17,10 +17,13 @@ function removeSoftwareCursor(line, cursorMarker = "") {
|
|
|
17
17
|
function configureCursor(tui, cursorStyle) {
|
|
18
18
|
if (cursorStyle === "block")
|
|
19
19
|
return;
|
|
20
|
-
const setShow =
|
|
20
|
+
const setShow =
|
|
21
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
22
|
+
tui.setShowHardwareCursor;
|
|
21
23
|
if (typeof setShow === "function")
|
|
22
24
|
setShow.call(tui, true);
|
|
23
25
|
const seq = CURSOR_STYLE_SEQUENCES[cursorStyle];
|
|
26
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
24
27
|
const term = tui
|
|
25
28
|
.terminal;
|
|
26
29
|
if (seq && term && typeof term.write === "function")
|
|
@@ -46,6 +49,7 @@ export class CursorPolicy {
|
|
|
46
49
|
this.previewHardwareCursor = style !== "block";
|
|
47
50
|
this.style = style;
|
|
48
51
|
if (changed) {
|
|
52
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
49
53
|
const tuiAny = this.tui;
|
|
50
54
|
if (style === "block") {
|
|
51
55
|
if (tuiAny.terminal)
|
|
@@ -37,7 +37,7 @@ export class FooterController {
|
|
|
37
37
|
this.state = { ...this.state, git };
|
|
38
38
|
}
|
|
39
39
|
catch {
|
|
40
|
-
// best-effort
|
|
40
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
async refreshRuntime() {
|
|
@@ -47,7 +47,7 @@ export class FooterController {
|
|
|
47
47
|
this.state = { ...this.state, runtime };
|
|
48
48
|
}
|
|
49
49
|
catch {
|
|
50
|
-
// best-effort
|
|
50
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
async refreshAll() {
|
package/dist/footer.js
CHANGED
|
@@ -2,37 +2,16 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-work
|
|
|
2
2
|
import { getUsageTotals } from "./state.js";
|
|
3
3
|
import { resolveGlyphs, runtimeSymbol } from "./icons.js";
|
|
4
4
|
import { alignRight, fitSegmentsByPriority, } from "./layout.js";
|
|
5
|
-
import { basenamePath, formatCwd,
|
|
6
|
-
import { cacheHitColor, stressColor, } from "./color-policy.js";
|
|
5
|
+
import { basenamePath, formatCwd, truncatePath, } from "./path-format.js";
|
|
7
6
|
import { fmtTokens, formatDuration, sanitizeStatus } from "./format.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const color = stressColor(pct);
|
|
12
|
-
const filledCell = ascii ? "#" : "█";
|
|
13
|
-
const emptyCell = ascii ? "-" : "░";
|
|
14
|
-
return (theme.fg("dim", "[") +
|
|
15
|
-
theme.fg(color, filledCell.repeat(filled)) +
|
|
16
|
-
theme.fg("dim", emptyCell.repeat(empty)) +
|
|
17
|
-
theme.fg("dim", "]"));
|
|
18
|
-
}
|
|
19
|
-
export function formatContextBar(contextUsage, theme, glyphs, isAscii, barWidth = 10) {
|
|
20
|
-
const contextWindow = contextUsage?.contextWindow ?? 0;
|
|
21
|
-
if (contextWindow <= 0)
|
|
22
|
-
return "";
|
|
23
|
-
const contextPct = contextUsage?.percent ?? 0;
|
|
24
|
-
const pctText = theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`);
|
|
25
|
-
const contextTokens = contextUsage?.tokens ?? 0;
|
|
26
|
-
const ctxText = `${theme.fg("text", fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(contextWindow))}`;
|
|
27
|
-
const contextIcon = theme.fg(stressColor(contextPct), glyphs.context);
|
|
28
|
-
const bar = renderBar(theme, contextPct, barWidth, isAscii);
|
|
29
|
-
return `${contextIcon} ${bar} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
|
|
30
|
-
}
|
|
31
|
-
function renderGitSegment(theme, git, glyphs, segments, maxBranchLen = 20) {
|
|
7
|
+
// ChromeState owns context bar formatting — re-export for backward compat.
|
|
8
|
+
export { formatContextBar } from "./chrome-state.js";
|
|
9
|
+
function renderGitSegment(theme, git, glyphs, segments) {
|
|
32
10
|
const parts = [];
|
|
33
11
|
if (segments.gitBranch) {
|
|
34
12
|
if (git.branch) {
|
|
35
|
-
|
|
13
|
+
// Never truncate branch name — full name always shown per user request
|
|
14
|
+
parts.push(theme.fg("mdLink", git.branch));
|
|
36
15
|
}
|
|
37
16
|
else if (git.commit?.detached) {
|
|
38
17
|
parts.push(theme.fg("warning", "HEAD"));
|
|
@@ -80,12 +59,17 @@ function renderRuntimeSegment(theme, runtime, iconMode) {
|
|
|
80
59
|
const label = [symbol, version].filter(Boolean).join(" ");
|
|
81
60
|
return label;
|
|
82
61
|
}
|
|
83
|
-
function renderTimerSegment(theme, state, glyphs) {
|
|
62
|
+
function renderTimerSegment(theme, state, glyphs, totals, config) {
|
|
63
|
+
// Wall time dim line — never exposed to model, TUI-only
|
|
64
|
+
// After agent_end: · 8.3s wall · ↑ 1.2k · ↓ 800 · $0.12 (all dim)
|
|
65
|
+
// During run: working 8.3s (accent)
|
|
84
66
|
if (state.workingSince !== undefined) {
|
|
85
67
|
return `${theme.fg("accent", glyphs.working)} ${theme.fg("dim", "working")} ${theme.fg("accent", formatDuration(Date.now() - state.workingSince))}`;
|
|
86
68
|
}
|
|
87
69
|
if (state.lastDoneIn !== undefined) {
|
|
88
|
-
|
|
70
|
+
// Wall time after agent_end is now a dim row after last message (aboveEditor widget), not inline footer
|
|
71
|
+
// Keep footer clean — wall time lives in transcript, not here
|
|
72
|
+
return "";
|
|
89
73
|
}
|
|
90
74
|
return "";
|
|
91
75
|
}
|
|
@@ -135,7 +119,8 @@ export function renderFooter(width, state, config, theme, ctx) {
|
|
|
135
119
|
const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
|
|
136
120
|
if (gitSeg) {
|
|
137
121
|
const sep = leftParts.length > 0 ? `${theme.fg("dim", " · ")}` : "";
|
|
138
|
-
|
|
122
|
+
// Branch name must never be folded/omitted — keep priority higher than cwd (5)
|
|
123
|
+
leftParts.push({ text: `${sep}${gitSeg}`, priority: 6 });
|
|
139
124
|
}
|
|
140
125
|
if (segments.runtime) {
|
|
141
126
|
const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
|
|
@@ -144,7 +129,7 @@ export function renderFooter(width, state, config, theme, ctx) {
|
|
|
144
129
|
leftParts.push({ text: `${sep}${runtimeSeg}`, priority: 4 });
|
|
145
130
|
}
|
|
146
131
|
}
|
|
147
|
-
const timerSeg = renderTimerSegment(theme, state, glyphs);
|
|
132
|
+
const timerSeg = renderTimerSegment(theme, state, glyphs, totals, config);
|
|
148
133
|
if (timerSeg) {
|
|
149
134
|
const sep = leftParts.length > 0 ? `${theme.fg("dim", " • ")}` : "";
|
|
150
135
|
leftParts.push({ text: `${sep}${timerSeg}`, priority: 1 });
|
|
@@ -153,18 +138,15 @@ export function renderFooter(width, state, config, theme, ctx) {
|
|
|
153
138
|
if (segments.tokens) {
|
|
154
139
|
stats.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(totals.input)}`));
|
|
155
140
|
stats.push(theme.fg("success", `${glyphs.output} ${fmtTokens(totals.output)}`));
|
|
156
|
-
const hasCacheTokens = totals.cacheRead > 0 || totals.cacheWrite > 0;
|
|
157
|
-
if (hasCacheTokens && totals.latestCacheHitRate !== undefined) {
|
|
158
|
-
stats.push(theme.fg(cacheHitColor(totals.latestCacheHitRate), `${glyphs.cacheHit} ${totals.latestCacheHitRate.toFixed(1)}%`));
|
|
159
|
-
}
|
|
160
141
|
}
|
|
161
142
|
if (segments.cost) {
|
|
162
|
-
const costValue = totals.cost.toFixed(
|
|
163
|
-
// Avoid "$ $0.
|
|
164
|
-
const costText =
|
|
143
|
+
const costValue = totals.cost.toFixed(2);
|
|
144
|
+
// Avoid "$ $0.00" when the cost glyph itself is "$" (ascii mode) — glyph already is the currency symbol
|
|
145
|
+
const costText = `$${costValue}`;
|
|
165
146
|
stats.push(theme.fg("warning", costText));
|
|
166
147
|
}
|
|
167
|
-
|
|
148
|
+
// cache to the right of input/output omitted per user request — cache stays in top context bar only
|
|
149
|
+
const statsBlock = stats.join(` ${theme.fg("dim", "·")} `);
|
|
168
150
|
const rightBlock = statsBlock;
|
|
169
151
|
const rightCompact = statsBlock;
|
|
170
152
|
const allParts = [...leftParts];
|
|
@@ -203,7 +185,9 @@ export function installFooter(ctx, getState, getConfig, getModelMeta, hooks) {
|
|
|
203
185
|
const state = getState();
|
|
204
186
|
const config = getConfig();
|
|
205
187
|
const cwd = ctx.sessionManager?.getCwd() ?? process.cwd();
|
|
206
|
-
const totals = getUsageTotals(
|
|
188
|
+
const totals = getUsageTotals(
|
|
189
|
+
// SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
190
|
+
ctx);
|
|
207
191
|
return renderFooter(width, state, config, themeStub, {
|
|
208
192
|
cwd,
|
|
209
193
|
sessionName: ctx.sessionManager?.getSessionName?.(),
|
|
@@ -214,8 +198,11 @@ export function installFooter(ctx, getState, getConfig, getModelMeta, hooks) {
|
|
|
214
198
|
});
|
|
215
199
|
};
|
|
216
200
|
// Prefer native footer if available
|
|
217
|
-
if (
|
|
201
|
+
if (
|
|
202
|
+
// SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
203
|
+
typeof ctx.ui.setFooter ===
|
|
218
204
|
"function") {
|
|
205
|
+
// SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
219
206
|
const ui = ctx.ui;
|
|
220
207
|
ui.setFooter((tui, _theme, footerData) => {
|
|
221
208
|
hooks.setRequestRender(() => tui.requestRender());
|
|
@@ -231,11 +218,14 @@ export function installFooter(ctx, getState, getConfig, getModelMeta, hooks) {
|
|
|
231
218
|
invalidate() { },
|
|
232
219
|
render(width) {
|
|
233
220
|
// Use real theme when rendering
|
|
221
|
+
// SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
234
222
|
const theme = _theme;
|
|
235
223
|
const state = getState();
|
|
236
224
|
const config = getConfig();
|
|
237
225
|
const cwd = ctx.sessionManager?.getCwd() ?? process.cwd();
|
|
238
|
-
const totals = getUsageTotals(
|
|
226
|
+
const totals = getUsageTotals(
|
|
227
|
+
// SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
228
|
+
ctx);
|
|
239
229
|
return renderFooter(width, state, config, theme, {
|
|
240
230
|
cwd,
|
|
241
231
|
sessionName: ctx.sessionManager?.getSessionName?.(),
|
|
@@ -249,6 +239,7 @@ export function installFooter(ctx, getState, getConfig, getModelMeta, hooks) {
|
|
|
249
239
|
};
|
|
250
240
|
});
|
|
251
241
|
return () => {
|
|
242
|
+
// SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
252
243
|
ctx.ui.setFooter(undefined);
|
|
253
244
|
};
|
|
254
245
|
}
|