@danypops/pi-jittor 0.1.1 → 0.2.1

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.
@@ -1,46 +1,60 @@
1
- import type { ContextSegment, ContextSegmentItem } from "@danypops/jittor";
1
+ import type { ContextSegment } from "@danypops/jittor";
2
+ import { buildContextRows, type ContextSegment as MalevichContextSegment } from "malevich-tui-components";
3
+ import type { ContextBreakdown } from "./context-breakdown.ts";
2
4
 
3
- export interface ContextReportUsage {
4
- tokens: number | null;
5
- contextWindow: number;
6
- percent: number | null;
7
- }
8
-
9
- /** Bounds how many items render per segment -- a report is a scan-at-a-glance summary, not a full dump. */
5
+ /** Bounds how many items render per segment in the plain-text fallback -- a notify-mode report is a scan-at-a-glance summary, not a full dump (the interactive TUI view has no such cap, since it scrolls). */
10
6
  const MAX_ITEMS_PER_SEGMENT_LINE = 5;
11
7
 
12
8
  function formatTokens(tokens: number): string {
13
9
  return tokens >= 1_000 ? `${(tokens / 1_000).toFixed(1)}k` : String(tokens);
14
10
  }
15
11
 
16
- function topItems(items: ContextSegmentItem[] | undefined): ContextSegmentItem[] {
17
- return [...(items ?? [])].sort((left, right) => right.estimatedTokens - left.estimatedTokens).slice(0, MAX_ITEMS_PER_SEGMENT_LINE);
12
+ function percentOf(part: number, whole: number): string {
13
+ return whole > 0 ? `${((part / whole) * 100).toFixed(1)}%` : "—";
14
+ }
15
+
16
+ /** Malevich's row builder is confidence-unaware (it's a generic segment/item shape); folding the tier into the label is how it survives into the rendered row text, e.g. "Active Rules [exact-cooperative]". */
17
+ function withConfidenceLabel(segment: ContextSegment): MalevichContextSegment {
18
+ const items = [...(segment.items ?? [])]
19
+ .sort((left, right) => right.estimatedTokens - left.estimatedTokens)
20
+ .slice(0, MAX_ITEMS_PER_SEGMENT_LINE);
21
+ return {
22
+ key: segment.key,
23
+ label: `${segment.label} [${segment.confidence}]`,
24
+ estimatedTokens: segment.estimatedTokens,
25
+ items,
26
+ unknown: segment.unknown,
27
+ };
18
28
  }
19
29
 
20
30
  /**
21
- * Plain-text Context Hub report: real provider-reported usage first (never a character-based
22
- * estimate when real usage is available, matching Papyrus's own real-vs-estimate honesty
23
- * accounting), then every segment heaviest-first with an explicit confidence tag
24
- * (exact-tool/exact-cooperative/correlated/audited) so a reader never mistakes one attribution
25
- * tier's certainty for another's.
31
+ * Plain-text Context Hub report (non-TUI fallback): real usage against the model's effective
32
+ * (reserve-adjusted) budget first -- matching Papyrus's own real-vs-estimate honesty accounting
33
+ * -- an explicit overshoot warning when the known segments' estimates exceed the real total, then
34
+ * every segment heaviest-first. Malevich's buildContextRows renders segments in the order given,
35
+ * so sorting by weight is this function's own policy, not Malevich's.
26
36
  */
27
- export function buildContextReport(segments: readonly ContextSegment[], usage: ContextReportUsage | undefined): string {
37
+ export function buildContextReport(breakdown: ContextBreakdown): string {
28
38
  const lines: string[] = [];
29
- if (usage?.tokens !== null && usage?.tokens !== undefined) {
30
- const percent = usage.percent !== null && usage.percent !== undefined ? ` (${usage.percent.toFixed(1)}%)` : "";
31
- lines.push(`Real usage: ${formatTokens(usage.tokens)} / ${formatTokens(usage.contextWindow)} tokens${percent}`);
39
+ if (breakdown.totalTokens !== null && breakdown.effectiveBudget !== null) {
40
+ lines.push(
41
+ `Real usage: ${formatTokens(breakdown.totalTokens)} / ${formatTokens(breakdown.effectiveBudget)} tokens (${percentOf(breakdown.totalTokens, breakdown.effectiveBudget)} of usable budget)`,
42
+ );
43
+ } else if (breakdown.totalTokens !== null) {
44
+ lines.push(`Real usage: ${formatTokens(breakdown.totalTokens)} tokens (model context window unknown)`);
32
45
  } else {
33
46
  lines.push("Real usage: not yet reported -- sizes below are estimates only");
34
47
  }
35
- const sorted = [...segments].sort((left, right) => right.estimatedTokens - left.estimatedTokens);
36
- if (sorted.length === 0) {
48
+ if (breakdown.overshootTokens > 0)
49
+ lines.push(`Estimates exceed real total by ~${breakdown.overshootTokens} tok -- sizes below are approximate, not exact`);
50
+
51
+ const sorted = [...breakdown.segments].sort((left, right) => right.estimatedTokens - left.estimatedTokens).map(withConfidenceLabel);
52
+ const rows = buildContextRows(sorted, breakdown.totalTokens ?? undefined);
53
+ if (rows.length === 0) {
37
54
  lines.push("", "(no segments observed yet)");
38
55
  return lines.join("\n");
39
56
  }
40
57
  lines.push("");
41
- for (const segment of sorted) {
42
- lines.push(`${segment.label} — ${formatTokens(segment.estimatedTokens)} tok [${segment.confidence}]`);
43
- for (const item of topItems(segment.items)) lines.push(` ${formatTokens(item.estimatedTokens)} tok ${item.label}`);
44
- }
58
+ for (const row of rows) lines.push(row.isHeader ? row.text : `${" ".repeat(row.depth)}${row.text}`);
45
59
  return lines.join("\n");
46
60
  }
@@ -0,0 +1,140 @@
1
+ import type { ContextSegment } from "@danypops/jittor";
2
+ import type { ExtensionCommandContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
3
+ import { matchesKey, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
4
+ import {
5
+ buildContextRows,
6
+ type ContextBarTheme,
7
+ type ContextRow,
8
+ type ContextRowsTheme,
9
+ type ContextSegment as MalevichContextSegment,
10
+ renderContextRowLines,
11
+ renderContextUsageBar,
12
+ } from "malevich-tui-components";
13
+ import type { ContextBreakdown } from "./context-breakdown.ts";
14
+ import { buildContextReport } from "./context-report.ts";
15
+
16
+ const VISIBLE_ROWS = 24;
17
+
18
+ /**
19
+ * A dynamically-contributed segment set (any string key from any extension, not a fixed enum)
20
+ * can't use a hardcoded per-key color map the way Papyrus's own ContextViewport did for its
21
+ * fixed seven segments -- this cycles a small categorical palette keyed by a stable hash of the
22
+ * segment key, so the same key always renders the same color within one process without needing
23
+ * every possible contributor's key to be known in advance.
24
+ */
25
+ const PALETTE: ThemeColor[] = ["accent", "success", "syntaxFunction", "warning", "syntaxKeyword", "syntaxType", "muted"];
26
+
27
+ function paletteColor(key: string): ThemeColor {
28
+ let hash = 0;
29
+ for (let index = 0; index < key.length; index += 1) hash = (hash * 31 + key.charCodeAt(index)) >>> 0;
30
+ return PALETTE[hash % PALETTE.length]!;
31
+ }
32
+
33
+ function formatTokenCount(tokens: number): string {
34
+ return tokens >= 1_000 ? `${(tokens / 1_000).toFixed(1)}k` : String(tokens);
35
+ }
36
+
37
+ function percentOf(part: number, whole: number): string {
38
+ return whole > 0 ? `${((part / whole) * 100).toFixed(1)}%` : "—";
39
+ }
40
+
41
+ /** Folds each segment's confidence tier into its label so it survives Malevich's confidence-unaware row builder. */
42
+ function withConfidenceLabel(segment: ContextSegment): MalevichContextSegment {
43
+ return {
44
+ key: segment.key,
45
+ label: `${segment.label} [${segment.confidence}]`,
46
+ estimatedTokens: segment.estimatedTokens,
47
+ items: segment.items,
48
+ unknown: segment.unknown,
49
+ };
50
+ }
51
+
52
+ class ContextViewport {
53
+ private offsetY = 0;
54
+ private readonly rows: ContextRow[];
55
+ private readonly segments: readonly MalevichContextSegment[];
56
+
57
+ constructor(
58
+ private readonly tui: TUI,
59
+ private readonly theme: Theme,
60
+ private readonly breakdown: ContextBreakdown,
61
+ private readonly close: () => void,
62
+ ) {
63
+ // Heaviest-first: Malevich renders segments in the order given, so sorting by weight for a
64
+ // merged multi-producer view is this viewport's own policy, matching context-report.ts.
65
+ this.segments = [...breakdown.segments].sort((left, right) => right.estimatedTokens - left.estimatedTokens).map(withConfidenceLabel);
66
+ this.rows = buildContextRows(this.segments, breakdown.totalTokens ?? undefined);
67
+ }
68
+
69
+ invalidate(): void {}
70
+
71
+ render(width: number): string[] {
72
+ const theme = this.theme;
73
+ const contentWidth = Math.max(1, width);
74
+ const border = theme.fg("borderMuted", "─".repeat(contentWidth));
75
+ const lines: string[] = [border, truncateToWidth(theme.fg("accent", theme.bold("Context")), contentWidth, "")];
76
+
77
+ const { totalTokens, effectiveBudget } = this.breakdown;
78
+ if (totalTokens !== null && effectiveBudget !== null) {
79
+ lines.push(
80
+ truncateToWidth(
81
+ `${formatTokenCount(totalTokens)} / ${formatTokenCount(effectiveBudget)} tokens (${percentOf(totalTokens, effectiveBudget)} of usable budget)`,
82
+ contentWidth,
83
+ "",
84
+ ),
85
+ );
86
+ } else if (totalTokens !== null) {
87
+ lines.push(truncateToWidth(`${formatTokenCount(totalTokens)} tokens (model context window unknown)`, contentWidth, ""));
88
+ } else {
89
+ lines.push(theme.fg("dim", "No real usage reported yet — sizes below are estimates only"));
90
+ }
91
+
92
+ const colorFor = (key: string) => (s: string) => theme.fg(paletteColor(key), s);
93
+ const barTheme: ContextBarTheme = { colorFor, empty: (s) => theme.fg("dim", s) };
94
+ lines.push(renderContextUsageBar(barTheme, this.segments, contentWidth, effectiveBudget ?? undefined, totalTokens ?? undefined));
95
+ if (this.breakdown.overshootTokens > 0) {
96
+ lines.push(
97
+ truncateToWidth(
98
+ theme.fg(
99
+ "warning",
100
+ `Estimates exceed real total by ~${this.breakdown.overshootTokens} tok — sizes below are approximate, not exact`,
101
+ ),
102
+ contentWidth,
103
+ "",
104
+ ),
105
+ );
106
+ }
107
+ lines.push("");
108
+
109
+ const rowsTheme: ContextRowsTheme = { colorFor, header: (s) => theme.bold(s) };
110
+ const visible = this.rows.slice(this.offsetY, this.offsetY + VISIBLE_ROWS);
111
+ lines.push(...renderContextRowLines(visible, contentWidth, rowsTheme));
112
+ if (this.rows.length === 0) lines.push(theme.fg("dim", " (nothing observed yet)"));
113
+ else lines.push(theme.fg("muted", ` ${Math.min(this.offsetY + VISIBLE_ROWS, this.rows.length)}/${this.rows.length}`));
114
+
115
+ lines.push("");
116
+ lines.push(theme.fg("dim", "↑↓ scroll · esc close"));
117
+ lines.push(border);
118
+ return lines;
119
+ }
120
+
121
+ handleInput(data: string): void {
122
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
123
+ this.close();
124
+ return;
125
+ }
126
+ if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
127
+ else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.rows.length - VISIBLE_ROWS), this.offsetY + 1);
128
+ else return;
129
+ this.tui.requestRender();
130
+ }
131
+ }
132
+
133
+ /** Interactive scrollable Context Hub view in TUI mode; the same plain-text report as /context's non-interactive path otherwise. */
134
+ export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown): Promise<void> {
135
+ if (ctx.mode !== "tui") {
136
+ ctx.ui.notify(buildContextReport(breakdown), "info");
137
+ return;
138
+ }
139
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new ContextViewport(tui, theme, breakdown, done));
140
+ }
@@ -1,13 +1,10 @@
1
1
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
- import { ProgressBar } from "malevich-tui-components";
5
2
  import {
6
3
  FOOTER_BAR_MAX_WIDTH,
7
4
  FOOTER_BAR_MIN_WIDTH,
5
+ FOOTER_COMPACTION_BLINK_HALF_PERIOD_MS,
8
6
  FOOTER_CONTEXT_ACCENT_FRACTION,
9
7
  FOOTER_CONTEXT_ERROR_FRACTION,
10
- FOOTER_COMPACTION_BLINK_HALF_PERIOD_MS,
11
8
  FOOTER_CONTEXT_WARNING_FRACTION,
12
9
  FOOTER_WIDE_TERMINAL_WIDTH,
13
10
  MILLISECONDS_PER_DAY,
@@ -15,6 +12,9 @@ import {
15
12
  MILLISECONDS_PER_MINUTE,
16
13
  TELEMETRY_STALE_AFTER_MS,
17
14
  } from "@danypops/jittor";
15
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
17
+ import { ProgressBar } from "malevich-tui-components";
18
18
 
19
19
  type FooterColor = "accent" | "dim" | "warning" | "error";
20
20
 
@@ -48,19 +48,21 @@ interface FooterContext {
48
48
  }
49
49
 
50
50
  /** A bounded quota is explicitly remaining; unbounded values never receive a fabricated bar. */
51
- export type ProviderBudget = {
52
- kind: "bounded";
53
- label: string;
54
- remainingFraction: number;
55
- observedAt?: number;
56
- resetsAt?: number;
57
- resetText?: string;
58
- } | {
59
- kind: "unbounded";
60
- label: string;
61
- valueText: string;
62
- observedAt?: number;
63
- };
51
+ export type ProviderBudget =
52
+ | {
53
+ kind: "bounded";
54
+ label: string;
55
+ remainingFraction: number;
56
+ observedAt?: number;
57
+ resetsAt?: number;
58
+ resetText?: string;
59
+ }
60
+ | {
61
+ kind: "unbounded";
62
+ label: string;
63
+ valueText: string;
64
+ observedAt?: number;
65
+ };
64
66
 
65
67
  export interface CompactionProgress {
66
68
  startedAt: number;
@@ -92,17 +94,25 @@ function footerCwd(cwd: string, home: string | undefined): string {
92
94
  const resolvedCwd = resolve(cwd);
93
95
  const resolvedHome = resolve(home);
94
96
  const relativeToHome = relative(resolvedHome, resolvedCwd);
95
- const inside = relativeToHome === "" || (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
97
+ const inside =
98
+ relativeToHome === "" || (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
96
99
  if (!inside) return cwd;
97
100
  return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
98
101
  }
99
102
 
100
103
  function sanitize(value: string): string {
101
- return value.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
104
+ return value
105
+ .replace(/[\r\n\t]/g, " ")
106
+ .replace(/ +/g, " ")
107
+ .trim();
102
108
  }
103
109
 
104
110
  function usageTotals(context: FooterContext): UsageTotals {
105
- let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0;
111
+ let input = 0,
112
+ output = 0,
113
+ cacheRead = 0,
114
+ cacheWrite = 0,
115
+ cost = 0;
106
116
  for (const entry of context.sessionManager.getEntries()) {
107
117
  if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
108
118
  const usage = entry.message.usage;
@@ -113,7 +123,7 @@ function usageTotals(context: FooterContext): UsageTotals {
113
123
  cost += usage?.cost?.total ?? 0;
114
124
  }
115
125
  const prompt = input + cacheRead + cacheWrite;
116
- return { input, output, cacheRead, cacheWrite, cost, ...(prompt > 0 ? { cacheHit: cacheRead / prompt * 100 } : {}) };
126
+ return { input, output, cacheRead, cacheWrite, cost, ...(prompt > 0 ? { cacheHit: (cacheRead / prompt) * 100 } : {}) };
117
127
  }
118
128
 
119
129
  function barWidth(width: number): number {
@@ -201,7 +211,13 @@ function resetLabel(resetsAt: number | undefined, now: number): string | undefin
201
211
  * the segment is omitted entirely rather than showing a placeholder that could never resolve.
202
212
  * `null` means not known yet but might resolve, which still earns the `?` placeholder.
203
213
  */
204
- function budgetSegment(budget: ProviderBudget | null | undefined, theme: FooterTheme, width: number, compact: boolean, now: number): string | undefined {
214
+ function budgetSegment(
215
+ budget: ProviderBudget | null | undefined,
216
+ theme: FooterTheme,
217
+ width: number,
218
+ compact: boolean,
219
+ now: number,
220
+ ): string | undefined {
205
221
  if (budget === undefined) return undefined;
206
222
  const w = barWidth(width);
207
223
  if (!budget) return `budget ${theme.fg("dim", progressBar(null, w))} ?`;
@@ -210,8 +226,8 @@ function budgetSegment(budget: ProviderBudget | null | undefined, theme: FooterT
210
226
  if (budget.kind === "unbounded") return `${budget.label} ${budget.valueText}${staleText}`;
211
227
  const remaining = Math.min(1, Math.max(0, budget.remainingFraction));
212
228
  const bar = theme.fg(fillColor(1 - remaining), progressBar(remaining, w));
213
- const value = `${(compact ? Math.round(remaining * 100) : (remaining * 100).toFixed(1))}% left`;
214
- const reset = compact ? undefined : resetLabel(budget.resetsAt, now) ?? budget.resetText;
229
+ const value = `${compact ? Math.round(remaining * 100) : (remaining * 100).toFixed(1)}% left`;
230
+ const reset = compact ? undefined : (resetLabel(budget.resetsAt, now) ?? budget.resetText);
215
231
  return `${budget.label} ${bar} ${value}${reset ? ` · ${reset}` : ""}${staleText}`;
216
232
  }
217
233
 
@@ -238,7 +254,12 @@ function repositorySegment(context: FooterContext, footerData: FooterData, theme
238
254
  return theme.fg("dim", cwd);
239
255
  }
240
256
 
241
- function modelSegments(context: FooterContext, footerData: FooterData, theme: FooterTheme, thinkingLevel: string): { full: string; compact: string } {
257
+ function modelSegments(
258
+ context: FooterContext,
259
+ footerData: FooterData,
260
+ theme: FooterTheme,
261
+ thinkingLevel: string,
262
+ ): { full: string; compact: string } {
242
263
  const model = context.model;
243
264
  const modelName = theme.bold(model?.id ?? "no-model");
244
265
  const provider = model && footerData.getAvailableProviderCount() > 1 ? `(${model.provider}) ` : "";
@@ -312,7 +333,16 @@ export function installIntegratedFooter(ctx: ExtensionContext, state: Integrated
312
333
  return {
313
334
  invalidate() {},
314
335
  render(width: number): string[] {
315
- return renderFooterLines(ctx as unknown as FooterContext, footerData, theme, state.providerBudget, getThinkingLevel(), width, Date.now(), state.compaction);
336
+ return renderFooterLines(
337
+ ctx as unknown as FooterContext,
338
+ footerData,
339
+ theme,
340
+ state.providerBudget,
341
+ getThinkingLevel(),
342
+ width,
343
+ Date.now(),
344
+ state.compaction,
345
+ );
316
346
  },
317
347
  dispose() {
318
348
  unsubscribe?.();