@oh-my-pi/pi-tui 18.13.0 → 18.13.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +36 -32
  2. package/dist/types/chrome/context-map.d.ts +15 -0
  3. package/dist/types/chrome/context-presentation.d.ts +48 -0
  4. package/dist/types/chrome/context-view-model.d.ts +77 -0
  5. package/dist/types/overlays/advisor-config.d.ts +6 -0
  6. package/dist/types/overlays/ask-dialog.d.ts +12 -3
  7. package/dist/types/overlays/context-viewer-format.d.ts +42 -0
  8. package/dist/types/overlays/context-viewer-overview.d.ts +70 -0
  9. package/dist/types/overlays/context-viewer.d.ts +25 -0
  10. package/dist/types/overlays/hook-input.d.ts +1 -0
  11. package/dist/types/overlays/pause-screen.d.ts +13 -3
  12. package/dist/types/overlays/running-subagent-badge.d.ts +7 -1
  13. package/dist/types/status-line/component.d.ts +2 -2
  14. package/dist/types/status-line/context-usage.d.ts +15 -30
  15. package/dist/types/theme/symbols.d.ts +1 -1
  16. package/dist/types/tools/ask.d.ts +4 -0
  17. package/dist/types/tools/fetch.d.ts +1 -0
  18. package/dist/types/tools/github.d.ts +3 -0
  19. package/dist/types/tools/read.d.ts +3 -0
  20. package/dist/types/tools/wait.d.ts +6 -0
  21. package/dist/types/tui.d.ts +2 -0
  22. package/package.json +9 -9
  23. package/src/chrome/context-map.ts +62 -0
  24. package/src/chrome/context-presentation.ts +26 -0
  25. package/src/chrome/context-view-model.ts +73 -0
  26. package/src/components/editor.ts +23 -0
  27. package/src/components/markdown.ts +14 -2
  28. package/src/overlays/advisor-config.ts +9 -2
  29. package/src/overlays/ask-dialog.ts +122 -35
  30. package/src/overlays/context-viewer-format.ts +145 -0
  31. package/src/overlays/context-viewer-overview.ts +381 -0
  32. package/src/overlays/context-viewer.ts +728 -0
  33. package/src/overlays/hook-input.ts +2 -0
  34. package/src/overlays/pause-screen.ts +166 -37
  35. package/src/overlays/running-subagent-badge.ts +12 -1
  36. package/src/render/output-pane.ts +3 -3
  37. package/src/status-line/component.ts +6 -4
  38. package/src/status-line/context-usage.ts +56 -580
  39. package/src/theme/symbols.ts +16 -0
  40. package/src/tools/ask.ts +4 -0
  41. package/src/tools/bash.ts +5 -23
  42. package/src/tools/fetch.ts +1 -0
  43. package/src/tools/github.ts +23 -3
  44. package/src/tools/read.ts +3 -1
  45. package/src/tools/wait.ts +6 -0
  46. package/src/tui.ts +23 -3
@@ -4,13 +4,6 @@ import { effectiveReserveTokens, resolveThresholdTokens } from "@oh-my-pi/pi-age
4
4
  import type { Tool as AiTool, Model } from "@oh-my-pi/pi-ai";
5
5
  import { renderToolExamples } from "@oh-my-pi/pi-ai/dialect";
6
6
  import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
7
- import { formatNumber } from "@oh-my-pi/pi-utils";
8
- import type { Theme, ThemeColor } from "../theme";
9
- import { Container } from "../tui";
10
- import { Text } from "../components/text";
11
- import type { TspSpan, TspText } from "@oh-my-pi/pi-wire";
12
- import type { DescribeContext, NativeNode } from "../native/node";
13
- import { col, node, row, span, text } from "../native/describe";
14
7
 
15
8
  interface ContextSkill {
16
9
  readonly name: string;
@@ -70,24 +63,12 @@ export interface ContextUsageOptions {
70
63
  snapcompact?: ContextSavingsEstimate;
71
64
  }
72
65
 
73
- const GRID_COLS = 20;
74
- const GRID_ROWS = 10;
75
- const GRID_CELLS = GRID_COLS * GRID_ROWS;
76
- const GRID_GUTTER = " ";
77
-
78
- const CELL_FILLED = "⛁";
79
- const CELL_FILLED_MESSAGES = "⛃";
80
- const CELL_FREE = "⛶";
81
- const CELL_BUFFER = "⛝";
82
-
83
66
  type CategoryId = "systemPrompt" | "systemContext" | "systemTools" | "skills" | "messages";
84
67
 
85
68
  interface CategoryInfo {
86
69
  id: CategoryId;
87
70
  label: string;
88
71
  tokens: number;
89
- color: "accent" | "warning" | "success" | "userMessageText" | "customMessageLabel";
90
- glyph: string;
91
72
  }
92
73
 
93
74
  export interface ContextBreakdown {
@@ -225,6 +206,56 @@ export function invalidateToolSchemaMetadata(tools: ToolSchemaSource): void {
225
206
  toolSchemaTokenCache(tools).revision++;
226
207
  }
227
208
 
209
+ /** Provider-visible text of one tool definition, in wire order. */
210
+ export interface ToolSchemaFragments {
211
+ /** Name, description, wire schema JSON, then rendered examples; fields that cannot be serialized are omitted. */
212
+ readonly fragments: string[];
213
+ /** `fragments` with the schema JSON indented for reading; present only when a display indent was requested. */
214
+ readonly display?: string[];
215
+ /** Why serialization stopped; the schema or examples after that point are missing from `fragments`. */
216
+ readonly schemaError?: string;
217
+ }
218
+
219
+ /**
220
+ * Provider-visible text of one tool definition. `displayIndent` additionally
221
+ * returns `display`, the same fragments with the schema JSON indented, from
222
+ * the same schema conversion.
223
+ */
224
+ export function toolSchemaFragments(tool: ContextTool, displayIndent?: number): ToolSchemaFragments {
225
+ // Extension-supplied tools may carry a non-string name/description or a
226
+ // parameters value whose wire schema stringifies to `undefined` (e.g. a
227
+ // callable schema that escaped normalization). A non-string fragment is
228
+ // fatal inside the native tokenizer, so only real strings are kept.
229
+ const { name, description, parameters } = tool;
230
+ const fragments: string[] = [];
231
+ const display: string[] | undefined = displayIndent === undefined ? undefined : [];
232
+ const push = (wire: string, shown: string = wire): void => {
233
+ fragments.push(wire);
234
+ display?.push(shown);
235
+ };
236
+ if (typeof name === "string") push(name);
237
+ if (typeof description === "string") push(description);
238
+ try {
239
+ const wireTool: AiTool = {
240
+ name,
241
+ description,
242
+ parameters: parameters as AiTool["parameters"],
243
+ examples: tool.examples,
244
+ };
245
+ const wireSchema = toolWireSchema(wireTool) ?? {};
246
+ const wireJson = JSON.stringify(wireSchema);
247
+ if (typeof wireJson === "string")
248
+ push(wireJson, display ? JSON.stringify(wireSchema, null, displayIndent) : wireJson);
249
+ // The agent loop appends rendered examples to the wire description.
250
+ const examplesBlock = renderToolExamples(wireTool);
251
+ if (examplesBlock) push(examplesBlock);
252
+ } catch (error) {
253
+ // Schema may contain functions or cycles.
254
+ return { fragments, display, schemaError: error instanceof Error ? error.message : String(error) };
255
+ }
256
+ return { fragments, display };
257
+ }
258
+
228
259
  /**
229
260
  * Estimate provider-visible tool-schema tokens.
230
261
  *
@@ -239,33 +270,7 @@ export function estimateToolSchemaTokens(tools: ToolSchemaSource, tokenizer: Tok
239
270
  const cached = cache.byTokenizer.get(tokenizer);
240
271
  if (cached?.revision === cache.revision && cached.sourceRevision === sourceRevision) return cached.tokens;
241
272
 
242
- const fragments: string[] = [];
243
- for (const tool of tools) {
244
- // Extension-supplied tools may carry a non-string name/description or a
245
- // parameters value whose wire schema stringifies to `undefined` (e.g. a
246
- // callable schema that escaped normalization). A non-string fragment is
247
- // fatal inside the native tokenizer, so only real strings are counted.
248
- const name = tool.name;
249
- const description = tool.description;
250
- const parameters = tool.parameters;
251
- if (typeof name === "string") fragments.push(name);
252
- if (typeof description === "string") fragments.push(description);
253
- try {
254
- const wireTool: AiTool = {
255
- name,
256
- description,
257
- parameters: parameters as AiTool["parameters"],
258
- examples: tool.examples,
259
- };
260
- const wireJson = JSON.stringify(toolWireSchema(wireTool) ?? {});
261
- if (typeof wireJson === "string") fragments.push(wireJson);
262
- // The agent loop appends rendered examples to the wire description.
263
- const examplesBlock = renderToolExamples(wireTool);
264
- if (examplesBlock) fragments.push(examplesBlock);
265
- } catch {
266
- // Schema may contain functions or cycles; ignore.
267
- }
268
- }
273
+ const fragments = tools.flatMap(tool => toolSchemaFragments(tool).fragments);
269
274
  const tokens = tokenizer.countTokens(fragments);
270
275
  cache.byTokenizer.set(tokenizer, { revision: cache.revision, sourceRevision, tokens });
271
276
  return tokens;
@@ -452,23 +457,11 @@ export function computeContextBreakdown(session: ContextUsageSession, options: C
452
457
  }
453
458
 
454
459
  const categories: CategoryInfo[] = [
455
- { id: "systemPrompt", label: "System prompt", tokens: systemPromptTokens, color: "accent", glyph: CELL_FILLED },
456
- { id: "systemTools", label: "System tools", tokens: toolsTokens, color: "warning", glyph: CELL_FILLED },
457
- {
458
- id: "systemContext",
459
- label: "System context",
460
- tokens: systemContextTokens,
461
- color: "customMessageLabel",
462
- glyph: CELL_FILLED,
463
- },
464
- { id: "skills", label: "Skills", tokens: skillsTokens, color: "success", glyph: CELL_FILLED },
465
- {
466
- id: "messages",
467
- label: "Messages",
468
- tokens: messagesTokens,
469
- color: "userMessageText",
470
- glyph: CELL_FILLED_MESSAGES,
471
- },
460
+ { id: "systemPrompt", label: "System prompt", tokens: systemPromptTokens },
461
+ { id: "systemTools", label: "System tools", tokens: toolsTokens },
462
+ { id: "systemContext", label: "System context", tokens: systemContextTokens },
463
+ { id: "skills", label: "Skills", tokens: skillsTokens },
464
+ { id: "messages", label: "Messages", tokens: messagesTokens },
472
465
  ];
473
466
 
474
467
  let autoCompactBufferTokens = 0;
@@ -502,520 +495,3 @@ export function computeContextBreakdown(session: ContextUsageSession, options: C
502
495
  snapcompact: options.snapcompact,
503
496
  };
504
497
  }
505
-
506
- interface CellSpec {
507
- glyph: string;
508
- color: "accent" | "warning" | "success" | "userMessageText" | "customMessageLabel" | "muted" | "dim";
509
- }
510
-
511
- function planCells(breakdown: ContextBreakdown): CellSpec[] {
512
- const cells: CellSpec[] = [];
513
- const window = breakdown.contextWindow;
514
-
515
- if (window <= 0) {
516
- for (let i = 0; i < GRID_CELLS; i++) {
517
- cells.push({ glyph: CELL_FREE, color: "dim" });
518
- }
519
- return cells;
520
- }
521
-
522
- const tokensPerCell = window / GRID_CELLS;
523
-
524
- const ratioCells = (tokens: number): number => {
525
- if (tokens <= 0) return 0;
526
- return Math.max(1, Math.round(tokens / tokensPerCell));
527
- };
528
-
529
- const categoryCounts = breakdown.categories.map(category => ({
530
- category,
531
- count: ratioCells(category.tokens),
532
- }));
533
-
534
- let bufferCount = ratioCells(breakdown.autoCompactBufferTokens);
535
-
536
- let usedCount = categoryCounts.reduce((sum, c) => sum + c.count, 0);
537
-
538
- // Prevent the visualization from over-running the grid.
539
- const maxUsable = GRID_CELLS - bufferCount;
540
- if (usedCount > maxUsable) {
541
- // Scale categories proportionally down to fit.
542
- let overflow = usedCount - maxUsable;
543
- // Trim from the largest categories first to preserve visibility for small ones.
544
- const order = [...categoryCounts].sort((a, b) => b.count - a.count);
545
- for (const entry of order) {
546
- while (overflow > 0 && entry.count > 1) {
547
- entry.count -= 1;
548
- overflow -= 1;
549
- }
550
- }
551
- usedCount = categoryCounts.reduce((sum, c) => sum + c.count, 0);
552
- if (usedCount + bufferCount > GRID_CELLS) {
553
- bufferCount = Math.max(0, GRID_CELLS - usedCount);
554
- }
555
- }
556
-
557
- for (const { category, count } of categoryCounts) {
558
- for (let i = 0; i < count; i++) {
559
- cells.push({ glyph: category.glyph, color: category.color });
560
- }
561
- }
562
-
563
- const freeCount = Math.max(0, GRID_CELLS - cells.length - bufferCount);
564
- for (let i = 0; i < freeCount; i++) {
565
- cells.push({ glyph: CELL_FREE, color: "dim" });
566
- }
567
- for (let i = 0; i < bufferCount; i++) {
568
- cells.push({ glyph: CELL_BUFFER, color: "warning" });
569
- }
570
-
571
- // Pad to exactly GRID_CELLS in case rounding undershot.
572
- while (cells.length < GRID_CELLS) {
573
- cells.push({ glyph: CELL_FREE, color: "dim" });
574
- }
575
- return cells.slice(0, GRID_CELLS);
576
- }
577
-
578
- function percentString(part: number, whole: number, fractionDigits = 1): string {
579
- if (whole <= 0) return "0%";
580
- const pct = (part / whole) * 100;
581
- if (pct > 0 && pct < 0.05) return "<0.1%";
582
- return `${pct.toFixed(fractionDigits)}%`;
583
- }
584
-
585
- /** One styled run of a legend line: bold (`strong`), a theme color, or plain. */
586
- interface LegendPart {
587
- t: string;
588
- s?: ThemeColor | "strong";
589
- }
590
-
591
- /** Legend lines as styled runs, shared by the ANSI panel and the native description. */
592
- function buildLegendParts(breakdown: ContextBreakdown): LegendPart[][] {
593
- const lines: LegendPart[][] = [];
594
- const { model, contextWindow, categories, usedTokens, autoCompactBufferTokens, freeTokens } = breakdown;
595
-
596
- const modelName = model?.name ?? model?.id ?? "no model";
597
- const modelId = model?.id ?? "unknown";
598
- const windowLabel = formatNumber(contextWindow).toLowerCase();
599
-
600
- lines.push([
601
- { t: `${modelName}`, s: "strong" },
602
- { t: ` (${windowLabel} context)`, s: "dim" },
603
- ]);
604
- lines.push([{ t: `${modelId}[${windowLabel}]`, s: "muted" }]);
605
- lines.push([
606
- { t: formatNumber(usedTokens), s: "strong" },
607
- { t: `/${windowLabel} tokens`, s: "dim" },
608
- { t: ` (${percentString(usedTokens, contextWindow)})`, s: "muted" },
609
- ]);
610
- lines.push([]);
611
- lines.push([{ t: "Estimated usage by category", s: "muted" }]);
612
-
613
- for (const category of categories) {
614
- const pct = percentString(category.tokens, contextWindow);
615
- lines.push([
616
- { t: category.glyph, s: category.color },
617
- { t: ` ${category.label}: ` },
618
- { t: formatNumber(category.tokens), s: "strong" },
619
- { t: " " },
620
- { t: `tokens (${pct})`, s: "dim" },
621
- ]);
622
- }
623
-
624
- lines.push([
625
- { t: CELL_FREE, s: "dim" },
626
- { t: " Free space: " },
627
- { t: formatNumber(freeTokens), s: "strong" },
628
- { t: " " },
629
- { t: `(${percentString(freeTokens, contextWindow)})`, s: "dim" },
630
- ]);
631
-
632
- if (autoCompactBufferTokens > 0) {
633
- lines.push([
634
- { t: CELL_BUFFER, s: "warning" },
635
- { t: " Autocompact buffer: " },
636
- { t: formatNumber(autoCompactBufferTokens), s: "strong" },
637
- { t: " " },
638
- { t: `tokens (${percentString(autoCompactBufferTokens, contextWindow)})`, s: "dim" },
639
- ]);
640
- }
641
-
642
- const snap = buildSnapcompactParts(breakdown);
643
- if (snap.length > 0) lines.push([], ...snap);
644
- return lines;
645
- }
646
-
647
- /** Snapcompact savings lines of the legend; empty when no snapcompact setting is on. */
648
- function buildSnapcompactParts(breakdown: ContextBreakdown): LegendPart[][] {
649
- const lines: LegendPart[][] = [];
650
- const { usedTokens } = breakdown;
651
- const snap = breakdown.snapcompact;
652
- if (snap) {
653
- if (!snap.visionCapable) {
654
- lines.push([{ t: "Snapcompact: inactive (model has no image input)", s: "muted" }]);
655
- } else {
656
- lines.push([{ t: "Snapcompact (estimated wire savings)", s: "muted" }]);
657
- if (snap.systemPrompt) {
658
- const sp = snap.systemPrompt;
659
- const scope = sp.scope === "agents-md" ? "AGENTS.md" : "all";
660
- if (sp.applied) {
661
- lines.push([
662
- { t: ` System prompt (${scope}): saves ` },
663
- { t: `~${formatNumber(sp.savedTokens)}`, s: "strong" },
664
- { t: " " },
665
- {
666
- t: `(${formatNumber(sp.textTokens)} text → ${sp.frames} frame${sp.frames === 1 ? "" : "s"} ≈ ${formatNumber(sp.imageTokens)})`,
667
- s: "dim",
668
- },
669
- ]);
670
- } else {
671
- const reason =
672
- sp.reason === "budget"
673
- ? "image budget exhausted"
674
- : sp.reason === "empty"
675
- ? "nothing to image"
676
- : "frames would not save tokens";
677
- lines.push([{ t: ` System prompt (${scope}): ` }, { t: `stays text (${reason})`, s: "dim" }]);
678
- }
679
- }
680
- if (snap.toolResults) {
681
- const tr = snap.toolResults;
682
- if (tr.swapped > 0) {
683
- lines.push([
684
- { t: " Tool results: saves " },
685
- { t: `~${formatNumber(tr.savedTokens)}`, s: "strong" },
686
- { t: " " },
687
- {
688
- t: `(${tr.swapped}/${tr.total} imaged, ${formatNumber(tr.textTokens)} text → ${tr.frames} frames ≈ ${formatNumber(tr.imageTokens)})`,
689
- s: "dim",
690
- },
691
- ]);
692
- } else {
693
- lines.push([{ t: " Tool results: " }, { t: `none imaged (${tr.total} in history)`, s: "dim" }]);
694
- }
695
- }
696
- if (snap.savedTokens > 0) {
697
- lines.push([
698
- { t: " Next request: " },
699
- { t: `~${formatNumber(Math.max(0, usedTokens - snap.savedTokens))}`, s: "strong" },
700
- { t: " " },
701
- { t: "tokens on the wire", s: "dim" },
702
- ]);
703
- }
704
- }
705
- }
706
-
707
- return lines;
708
- }
709
-
710
- function buildLegendLines(breakdown: ContextBreakdown, theme: Theme): string[] {
711
- return buildLegendParts(breakdown).map(line => {
712
- let out = "";
713
- for (const part of line) {
714
- out += part.s === "strong" ? theme.bold(part.t) : part.s ? theme.fg(part.s, part.t) : part.t;
715
- }
716
- return out;
717
- });
718
- }
719
-
720
- /** Stacked meter parts in grid order: the categories, free space as the empty track, then the hatched buffer. */
721
- function contextMeterParts(
722
- breakdown: ContextBreakdown,
723
- ): { value: number; token?: string; label?: string; hatch?: boolean }[] {
724
- const window = breakdown.contextWindow;
725
- const parts: { value: number; token?: string; label?: string; hatch?: boolean }[] = breakdown.categories
726
- .filter(category => category.tokens > 0)
727
- .map(category => ({
728
- value: category.tokens / window,
729
- token: category.color,
730
- label: `${category.label} · ${formatNumber(category.tokens)} tokens (${percentString(category.tokens, window)})`,
731
- }));
732
- parts.push({
733
- value: breakdown.freeTokens / window,
734
- token: "track",
735
- label: `Free space · ${formatNumber(breakdown.freeTokens)} tokens (${percentString(breakdown.freeTokens, window)})`,
736
- });
737
- if (breakdown.autoCompactBufferTokens > 0) {
738
- parts.push({
739
- value: breakdown.autoCompactBufferTokens / window,
740
- token: "warning",
741
- hatch: true,
742
- label: `Autocompact buffer · ${formatNumber(breakdown.autoCompactBufferTokens)} tokens (${percentString(breakdown.autoCompactBufferTokens, window)})`,
743
- });
744
- }
745
- return parts;
746
- }
747
-
748
- /**
749
- * One line of the native `/context` breakdown: a swatch in the part's bar
750
- * color, its name, its tokens and its share of the window; the exact count
751
- * is its tooltip.
752
- */
753
- function contextShareRow(
754
- key: string,
755
- role: string,
756
- swatch: TspSpan,
757
- label: string,
758
- tokens: number,
759
- window: number,
760
- ): NativeNode {
761
- return node(
762
- "row",
763
- { role, align: "center", title: `${label} · ${tokens.toLocaleString("en-US")} tokens` },
764
- [
765
- text([swatch], { role: "omp.context.swatch", wrap: "none" }),
766
- text(label, { role: "omp.context.label", grow: 1, wrap: "none", truncate: "end" }),
767
- text(formatNumber(tokens), { role: "omp.context.tokens", wrap: "none" }),
768
- text(percentString(tokens, window), { role: "omp.context.share", wrap: "none" }),
769
- ],
770
- key,
771
- );
772
- }
773
-
774
- /**
775
- * The snapcompact estimate as a titled group of lines (what is imaged, how,
776
- * and what it saves), or a note saying why it is inactive; `undefined` when
777
- * no snapcompact setting is on.
778
- */
779
- function describeSnapcompact(breakdown: ContextBreakdown): NativeNode | undefined {
780
- const snap = breakdown.snapcompact;
781
- if (!snap) return undefined;
782
- const head = text([span("Snapcompact", "strong"), span(" · estimated savings on the wire", "dim")], {
783
- role: "omp.context.snap.head",
784
- });
785
- if (!snap.visionCapable) {
786
- const note = text("Inactive: the model has no image input.", { role: "omp.context.snap.note" });
787
- return node("col", { role: "omp.context.snap" }, [head, note], "snapcompact");
788
- }
789
- const lines: { label: string; detail: string; value?: TspSpan }[] = [];
790
- const sp = snap.systemPrompt;
791
- if (sp) {
792
- const label = `System prompt · ${sp.scope === "agents-md" ? "AGENTS.md" : "all"}`;
793
- if (sp.applied) {
794
- lines.push({
795
- label,
796
- detail: `${formatNumber(sp.textTokens)} text → ${sp.frames} frame${sp.frames === 1 ? "" : "s"} ≈ ${formatNumber(sp.imageTokens)}`,
797
- value: span(`−${formatNumber(sp.savedTokens)}`, "success"),
798
- });
799
- } else {
800
- const reason =
801
- sp.reason === "budget"
802
- ? "image budget exhausted"
803
- : sp.reason === "empty"
804
- ? "nothing to image"
805
- : "frames would not save tokens";
806
- lines.push({ label, detail: `Stays text: ${reason}` });
807
- }
808
- }
809
- const tr = snap.toolResults;
810
- if (tr) {
811
- lines.push(
812
- tr.swapped > 0
813
- ? {
814
- label: "Tool results",
815
- detail: `${tr.swapped} of ${tr.total} imaged · ${formatNumber(tr.textTokens)} text → ${tr.frames} frames ≈ ${formatNumber(tr.imageTokens)}`,
816
- value: span(`−${formatNumber(tr.savedTokens)}`, "success"),
817
- }
818
- : { label: "Tool results", detail: `None imaged · ${tr.total} in history` },
819
- );
820
- }
821
- if (snap.savedTokens > 0) {
822
- lines.push({
823
- label: "Next request",
824
- detail: "tokens on the wire",
825
- value: span(`~${formatNumber(Math.max(0, breakdown.usedTokens - snap.savedTokens))}`, "strong"),
826
- });
827
- }
828
- const rows = lines.map((line, index) =>
829
- node(
830
- "row",
831
- { role: "omp.context.snap.row", align: "baseline" },
832
- [
833
- text(line.label, { role: "omp.context.label", wrap: "none" }),
834
- text(line.detail, { role: "omp.context.detail", grow: 1, wrap: "none", truncate: "end" }),
835
- ...(line.value ? [text([line.value], { role: "omp.context.tokens", wrap: "none" })] : []),
836
- ],
837
- `snap-${index}`,
838
- ),
839
- );
840
- return node("col", { role: "omp.context.snap" }, [head, ...rows], "snapcompact");
841
- }
842
-
843
- /**
844
- * The `/context` body for terminals that draw `meter`: the tokens used as
845
- * the figure that leads, beside the room left before auto-compaction; one
846
- * stacked bar of the parts (categories, free space as the empty track, the
847
- * hatched buffer) ticked where compaction fires; the breakdown in the bar's
848
- * order with free space and the buffer under it; then the snapcompact
849
- * estimate when one is on. {@link contextUsageHead} names the model.
850
- */
851
- function describeContextFrame(breakdown: ContextBreakdown): NativeNode {
852
- const { contextWindow: window, usedTokens, freeTokens, autoCompactBufferTokens, thresholdTokens } = breakdown;
853
- const parts = contextMeterParts(breakdown);
854
- const used = Math.min(1, usedTokens / window);
855
- const room =
856
- thresholdTokens === undefined
857
- ? `${formatNumber(freeTokens)} free`
858
- : usedTokens >= thresholdTokens
859
- ? `Past auto-compaction at ${percentString(thresholdTokens, window, 0)}`
860
- : `${formatNumber(thresholdTokens - usedTokens)} left before auto-compaction at ${percentString(thresholdTokens, window, 0)}`;
861
- const summary = node(
862
- "row",
863
- { role: "omp.context.summary", align: "baseline", wrap: true },
864
- [
865
- text(
866
- [
867
- span(formatNumber(usedTokens), "strong"),
868
- span(` of ${formatNumber(window)} tokens · ${percentString(usedTokens, window, 0)}`, "dim"),
869
- ],
870
- { role: "omp.context.used", grow: 1, wrap: "none" },
871
- ),
872
- text(room, { role: "omp.context.room", wrap: "none" }),
873
- ],
874
- "summary",
875
- );
876
- const marks =
877
- thresholdTokens !== undefined
878
- ? [
879
- {
880
- at: thresholdTokens / window,
881
- tone: "warning" as const,
882
- title: `Auto-compaction at ${percentString(thresholdTokens, window, 0)} · ${formatNumber(thresholdTokens)} tokens`,
883
- },
884
- ]
885
- : undefined;
886
- const bar = node(
887
- "meter",
888
- { value: used, style: "bar", size: "lg", parts, ...(marks ? { marks } : {}), aria: "Context usage by category" },
889
- undefined,
890
- "bar",
891
- );
892
- const shares = breakdown.categories.map(category =>
893
- contextShareRow(
894
- category.id,
895
- "omp.context.cat",
896
- span("■", category.color),
897
- category.label,
898
- category.tokens,
899
- window,
900
- ),
901
- );
902
- shares.push(contextShareRow("free", "omp.context.cat.free", span("□", "dim"), "Free space", freeTokens, window));
903
- if (autoCompactBufferTokens > 0) {
904
- shares.push(
905
- contextShareRow(
906
- "buffer",
907
- "omp.context.cat.buffer",
908
- span("▨", "warning"),
909
- "Autocompact buffer",
910
- autoCompactBufferTokens,
911
- window,
912
- ),
913
- );
914
- }
915
- const children = [summary, bar, node("col", { role: "omp.context.cats" }, shares, "cats")];
916
- const snap = describeSnapcompact(breakdown);
917
- if (snap) children.push(snap);
918
- return col(children, { role: "omp.context" });
919
- }
920
-
921
- /** The `/context` title: the model once one is selected (the body leads with its window). */
922
- export function contextUsageHead(breakdown: ContextBreakdown): TspText {
923
- if (breakdown.contextWindow <= 0) return [span("Context usage", "strong")];
924
- const modelName = breakdown.model?.name ?? breakdown.model?.id ?? "no model";
925
- return [span("Context", "strong"), span(` · ${modelName}`, "muted")];
926
- }
927
-
928
- /**
929
- * Native context-usage panel for terminals without `meter`: the cell grid as
930
- * unwrapped styled rows beside the legend. The terminal places the legend
931
- * beside the grid, or below it when narrow.
932
- */
933
- export function describeContextUsage(breakdown: ContextBreakdown): NativeNode {
934
- if (breakdown.contextWindow <= 0) {
935
- return text([{ t: "Context usage is unavailable: no model is selected for this session.", s: "muted" }]);
936
- }
937
- const cells = planCells(breakdown);
938
- const grid: NativeNode[] = [];
939
- for (let gridRow = 0; gridRow < GRID_ROWS; gridRow++) {
940
- const spans: TspSpan[] = [];
941
- for (let gridCol = 0; gridCol < GRID_COLS; gridCol++) {
942
- const cell = cells[gridRow * GRID_COLS + gridCol]!;
943
- spans.push({ t: gridCol === 0 ? cell.glyph : ` ${cell.glyph}`, s: cell.color });
944
- }
945
- grid.push(node("text", { spans, wrap: "none" }, undefined, `row-${gridRow}`));
946
- }
947
- const legend = buildLegendParts(breakdown).map((line, index) =>
948
- node("text", { spans: line.length > 0 ? line : [{ t: " " }] }, undefined, `legend-${index}`),
949
- );
950
- return row([col(grid), col(legend)], {
951
- gap: "lg",
952
- wrap: true,
953
- role: "omp.context.usage",
954
- });
955
- }
956
-
957
- /**
958
- * The `/context` report body, without a frame or title of its own (the panel
959
- * showing it is titled {@link contextUsageHead}): ANSI renders the cell grid
960
- * beside the legend; natively it is drawn with `meter`s where the terminal has
961
- * them and as the glyph grid otherwise.
962
- */
963
- export class ContextUsageView extends Container {
964
- readonly #breakdown: ContextBreakdown;
965
- #native: { meter: boolean; node: NativeNode } | undefined;
966
-
967
- constructor(breakdown: ContextBreakdown, theme: Theme) {
968
- super();
969
- this.#breakdown = breakdown;
970
- this.addChild(new Text(renderContextUsage(breakdown, theme), 0, 0));
971
- }
972
-
973
- override describe(cx: DescribeContext): NativeNode {
974
- const meter = cx.supports("meter");
975
- if (this.#native?.meter === meter) return this.#native.node;
976
- const breakdown = this.#breakdown;
977
- const described =
978
- meter && breakdown.contextWindow > 0 ? describeContextFrame(breakdown) : describeContextUsage(breakdown);
979
- this.#native = { meter, node: described };
980
- return described;
981
- }
982
- }
983
-
984
- /**
985
- * Render a colorful context-usage panel as ANSI text. Output is a series of
986
- * lines pairing the grid (left) with the legend (right).
987
- */
988
- export function renderContextUsage(breakdown: ContextBreakdown, theme: Theme): string {
989
- if (breakdown.contextWindow <= 0) {
990
- return theme.fg("muted", "Context usage is unavailable: no model is selected for this session.");
991
- }
992
-
993
- const cells = planCells(breakdown);
994
- const legend = buildLegendLines(breakdown, theme);
995
-
996
- const totalLines = Math.max(GRID_ROWS, legend.length);
997
- const lines: string[] = [];
998
-
999
- for (let row = 0; row < totalLines; row++) {
1000
- let gridSegment = "";
1001
- if (row < GRID_ROWS) {
1002
- const rowCells: string[] = [];
1003
- for (let col = 0; col < GRID_COLS; col++) {
1004
- const cell = cells[row * GRID_COLS + col];
1005
- rowCells.push(theme.fg(cell.color, cell.glyph));
1006
- }
1007
- gridSegment = rowCells.join(" ");
1008
- } else {
1009
- // Pad with blanks the same visible width as a grid row so legend lines
1010
- // past the grid stay aligned with their column.
1011
- const blank = " ".repeat(GRID_COLS * 2 - 1);
1012
- gridSegment = blank;
1013
- }
1014
-
1015
- const legendSegment = legend[row] ?? "";
1016
- const line = legendSegment.length > 0 ? `${gridSegment}${GRID_GUTTER}${legendSegment}` : gridSegment;
1017
- lines.push(line);
1018
- }
1019
-
1020
- return lines.join("\n");
1021
- }