@gavin-hu/my-pi-agent 0.15.0 → 0.16.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.
Files changed (106) hide show
  1. package/README.md +12 -11
  2. package/README.zh-CN.md +9 -7
  3. package/extensions/bytefly/README.md +98 -116
  4. package/extensions/bytefly/notice/component.ts +6 -4
  5. package/extensions/bytefly/notice/config.ts +16 -2
  6. package/extensions/bytefly/panel/format.ts +3 -8
  7. package/extensions/bytefly/panel/join.ts +1 -6
  8. package/extensions/bytefly/panel/settings.ts +74 -377
  9. package/extensions/bytefly/panel/tui/entries.ts +16 -14
  10. package/extensions/bytefly/panel/tui/extensions-view.ts +3 -0
  11. package/extensions/bytefly/panel/tui/frame.ts +2 -2
  12. package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
  13. package/extensions/bytefly/panel/tui.ts +4 -6
  14. package/extensions/bytefly/picker/resources.ts +7 -3
  15. package/extensions/bytefly/working/register.ts +2 -8
  16. package/extensions/check/tools.ts +2 -11
  17. package/extensions/context/README.md +162 -0
  18. package/extensions/context/collect.ts +53 -0
  19. package/extensions/context/commands.ts +100 -0
  20. package/extensions/context/format.ts +82 -0
  21. package/extensions/context/index.ts +16 -0
  22. package/extensions/context/report.ts +319 -0
  23. package/extensions/context/tui.ts +268 -0
  24. package/extensions/context/types.ts +122 -0
  25. package/extensions/file-browse/README.md +83 -8
  26. package/extensions/file-browse/client.ts +3 -1
  27. package/extensions/file-browse/config.ts +23 -0
  28. package/extensions/file-browse/files.ts +183 -48
  29. package/extensions/file-browse/fuzzy.ts +65 -11
  30. package/extensions/file-browse/html.ts +78 -133
  31. package/extensions/file-browse/index.ts +17 -2
  32. package/extensions/file-browse/live-client.ts +158 -0
  33. package/extensions/file-browse/live.ts +256 -0
  34. package/extensions/file-browse/markdown.ts +3 -10
  35. package/extensions/file-browse/raw-html.ts +3 -10
  36. package/extensions/file-browse/router.ts +33 -8
  37. package/extensions/file-browse/server.ts +78 -13
  38. package/extensions/file-browse/styles.ts +124 -0
  39. package/extensions/file-open/README.md +1 -2
  40. package/extensions/{document → file-read}/README.md +72 -56
  41. package/extensions/{document → file-read}/config.ts +12 -9
  42. package/extensions/{document → file-read}/extract/doc.ts +2 -2
  43. package/extensions/{document → file-read}/extract/docx.ts +3 -3
  44. package/extensions/{document → file-read}/extract/file.ts +9 -9
  45. package/extensions/{document → file-read}/extract/odt.ts +3 -3
  46. package/extensions/{document → file-read}/extract/pdf.ts +3 -3
  47. package/extensions/{document → file-read}/extract/rtf.ts +3 -3
  48. package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
  49. package/extensions/{document → file-read}/formats.ts +11 -11
  50. package/extensions/file-read/index.ts +17 -0
  51. package/extensions/{document → file-read}/paging.ts +15 -13
  52. package/extensions/{document → file-read}/render.ts +17 -13
  53. package/extensions/{document → file-read}/schema.ts +7 -7
  54. package/extensions/{document → file-read}/tool.ts +36 -28
  55. package/extensions/goal/tools.ts +10 -14
  56. package/extensions/job/commands.ts +32 -46
  57. package/extensions/job/runtime.ts +6 -8
  58. package/extensions/job/tools.ts +7 -26
  59. package/extensions/memory/commands.ts +30 -44
  60. package/extensions/memory/tools.ts +6 -14
  61. package/extensions/plan/tools/enter.ts +6 -7
  62. package/extensions/plan/tools/exit.ts +6 -7
  63. package/extensions/plan/tools/steps.ts +6 -7
  64. package/extensions/plan/tools/write.ts +8 -9
  65. package/extensions/plan/web-router.ts +2 -12
  66. package/extensions/plan/web.ts +30 -15
  67. package/extensions/question/tools.ts +5 -4
  68. package/extensions/rewind/checklist.ts +10 -3
  69. package/extensions/rewind/git.ts +27 -14
  70. package/extensions/rewind/hunk-picker.ts +1 -1
  71. package/extensions/rewind/path-picker.ts +1 -1
  72. package/extensions/rewind/review-tui.ts +3 -3
  73. package/extensions/rewind/snapshot.ts +13 -4
  74. package/extensions/subagent/background-tui.ts +1 -1
  75. package/extensions/subagent/background.ts +1 -7
  76. package/extensions/todo/tools.ts +10 -14
  77. package/extensions/watch/README.md +5 -5
  78. package/extensions/watch/commands.ts +33 -47
  79. package/extensions/watch/runtime.ts +5 -7
  80. package/extensions/watch/tools.ts +7 -18
  81. package/extensions/web-access/README.md +3 -3
  82. package/extensions/web-access/search/search.ts +9 -18
  83. package/extensions/wechat/timer.ts +9 -30
  84. package/extensions/wechat/tools.ts +4 -7
  85. package/extensions/worktree/status.ts +14 -9
  86. package/lib/README.md +12 -8
  87. package/lib/escape.ts +15 -2
  88. package/lib/git/read.ts +20 -0
  89. package/lib/http/headers.ts +20 -0
  90. package/lib/http/server.ts +22 -4
  91. package/lib/pdf.ts +2 -2
  92. package/lib/pool.ts +62 -0
  93. package/lib/requirements.ts +2 -2
  94. package/lib/screen-session.ts +85 -0
  95. package/lib/timer.ts +61 -0
  96. package/lib/tui/README.md +3 -3
  97. package/lib/tui/columns.ts +33 -8
  98. package/lib/tui/dock-screen.ts +12 -4
  99. package/lib/tui/rows.ts +15 -6
  100. package/lib/tui/rules.ts +4 -8
  101. package/lib/tui/tool.ts +55 -2
  102. package/package.json +8 -4
  103. package/skills/changelog/SKILL.md +136 -0
  104. package/extensions/document/index.ts +0 -17
  105. /package/extensions/{document → file-read}/deadline.ts +0 -0
  106. /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
@@ -28,8 +28,8 @@ import { selectableRange, type PanelEntry } from "./entries.ts";
28
28
  export const SCREEN_DEFAULT_ROWS = 16;
29
29
  /** Fixed rows around the body: header, summary, blank, blank, hint, blank. */
30
30
  export const FRAME_ROWS = 6;
31
- /** Detail rows reserved for the focused row. */
32
- export const DETAIL_ROWS = 4;
31
+ /** Detail rows reserved for the focused row: its head, one description line, and its origin. */
32
+ export const DETAIL_ROWS = 3;
33
33
  /** Body rows that must survive before the detail pane collapses to its title. */
34
34
  export const MIN_BODY_ROWS = 3;
35
35
  /** Detail rows when the terminal height is unknown and the focused row is expanded. */
@@ -27,6 +27,8 @@ const PI_SETTINGS_FILE = "settings.json";
27
27
  const ROW_PREFIX_WIDTH = 4;
28
28
  /** Columns a value keeps when the row has to choose between the label and the value. */
29
29
  const MIN_VALUE_WIDTH = 8;
30
+ /** Columns between the label and its value. */
31
+ const VALUE_GAP = 2;
30
32
 
31
33
  /**
32
34
  * The editing cell: the buffer around the caret, scrolled so the caret is always
@@ -56,7 +58,7 @@ export function settingsSummary(theme: Theme, width: number, scope: PanelScope,
56
58
  return countsLine(theme, width, settingsSummarySegments(scope, count));
57
59
  }
58
60
 
59
- /** One setting row: a checkbox for booleans, the value right-aligned. */
61
+ /** One setting row: a checkbox for booleans, and the value beside the label. */
60
62
  export function settingRow(
61
63
  theme: Theme,
62
64
  width: number,
@@ -67,7 +69,7 @@ export function settingRow(
67
69
  const label = sanitizeLine(view.label);
68
70
  // Keep the whole label when it fits, and never let a long value or an editing
69
71
  // buffer erase it: the value gets what is left, with a floor of eight columns.
70
- const available = Math.max(1, width - ROW_PREFIX_WIDTH - 1);
72
+ const available = Math.max(1, width - ROW_PREFIX_WIDTH - VALUE_GAP);
71
73
  const labelReserve = Math.min(visibleWidth(label), Math.max(0, available - MIN_VALUE_WIDTH));
72
74
  const valueBudget = Math.max(1, available - labelReserve);
73
75
  const rawValue = editing ? editingCell(editing.buffer, editing.caret, valueBudget) : view.valueLabel;
@@ -78,6 +80,9 @@ export function settingRow(
78
80
  labelRole: selected ? "accent" : "label",
79
81
  trailing: clip(sanitizeLine(rawValue), valueBudget),
80
82
  trailingRole: editing ? "accent" : "meta",
83
+ // The value sits beside the label, so a wide terminal never strands it at
84
+ // the far edge where the eye has to travel to read it.
85
+ align: "inline",
81
86
  });
82
87
  }
83
88
 
@@ -137,16 +142,7 @@ export function settingDetail(options: {
137
142
 
138
143
  /** The section's key hints, in the order they drop as the width runs out. */
139
144
  export function settingsHints(json = false): string[] {
140
- return [
141
- "Esc close",
142
- "←/→",
143
- "Space change",
144
- "Tab scope",
145
- "u reset",
146
- json ? "J hide" : "J JSON",
147
- "[ ] group",
148
- "↑/↓ select",
149
- ];
145
+ return ["Esc close", "←/→", "Space change", "Tab scope", "u reset", json ? "J hide" : "J JSON", "↑/↓ select"];
150
146
  }
151
147
 
152
148
  /**
@@ -352,12 +352,10 @@ export class PanelComponent implements Component {
352
352
  this.select(lastRow(this.activeEntries()));
353
353
  return;
354
354
  }
355
- if (data === "[") {
356
- this.jumpGroup(-1);
357
- return;
358
- }
359
- if (data === "]") {
360
- this.jumpGroup(1);
355
+ // Only Extensions carries group headings; the Settings list is flat, so the
356
+ // keys would move the cursor nowhere there.
357
+ if (this.section === "extensions" && (data === "[" || data === "]")) {
358
+ this.jumpGroup(data === "[" ? -1 : 1);
361
359
  return;
362
360
  }
363
361
  switch (navIntent(data)) {
@@ -141,8 +141,12 @@ export function getDisplayName(item: ResourceItem): string {
141
141
  return parentFolder === "extensions" ? fileName : `${parentFolder}/${fileName}`;
142
142
  }
143
143
 
144
- /** Stable identity for a resource row, used to keep the cursor across renders. */
145
- function itemKey(item: ResourceItem): string {
144
+ /**
145
+ * Stable identity for one resource, independent of the directory it sits in: a
146
+ * row key for the cursor, a merge key for the panel, and `report:<name>` for a
147
+ * report with no resolved item.
148
+ */
149
+ export function resourceKey(item: ResourceItem): string {
146
150
  return `${item.metadata.source}:${item.path}`;
147
151
  }
148
152
 
@@ -190,7 +194,7 @@ export function buildEntries(items: ResourceItem[], agentDir: string): Entry[] {
190
194
  for (const group of sorted) {
191
195
  const items = [...group.items].sort((a, b) => getDisplayName(a).localeCompare(getDisplayName(b)));
192
196
  entries.push({ kind: "group", key: group.key, label: group.label, count: items.length });
193
- for (const item of items) entries.push({ kind: "item", key: itemKey(item), item, label: getDisplayName(item) });
197
+ for (const item of items) entries.push({ kind: "item", key: resourceKey(item), item, label: getDisplayName(item) });
194
198
  }
195
199
  return entries;
196
200
  }
@@ -30,6 +30,7 @@
30
30
  */
31
31
 
32
32
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
33
+ import { defaultInterval } from "../../../lib/timer.ts";
33
34
  import { WORKING_CONFIG } from "./config.ts";
34
35
  import { workingCounter } from "./format.ts";
35
36
 
@@ -41,17 +42,10 @@ export interface WorkingDeps {
41
42
  schedule?: (tick: () => void, intervalMs: number) => () => void;
42
43
  }
43
44
 
44
- /** A repeating timer that never holds the process open on its own. */
45
- function defaultSchedule(tick: () => void, intervalMs: number): () => void {
46
- const timer = setInterval(tick, intervalMs);
47
- (timer as { unref?: () => void }).unref?.();
48
- return () => clearInterval(timer);
49
- }
50
-
51
45
  /** Register the timer; it runs only in interactive (`tui`) sessions. */
52
46
  export function registerWorkingTimer(pi: ExtensionAPI, deps: WorkingDeps = {}): void {
53
47
  const now = deps.now ?? (() => Date.now());
54
- const schedule = deps.schedule ?? defaultSchedule;
48
+ const schedule = deps.schedule ?? ((tick, intervalMs) => defaultInterval(intervalMs, tick));
55
49
  let cancel: (() => void) | undefined;
56
50
  let startedAt = 0;
57
51
  // A boolean, not `startedAt === 0`: an injected clock is allowed to start at 0.
@@ -16,14 +16,7 @@ import type { ExtensionAPI, ExtensionToolContext } from "@earendil-works/pi-codi
16
16
  import { Type, type Static } from "typebox";
17
17
  import { sanitizeLine } from "../../lib/format.ts";
18
18
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
19
- import {
20
- elapsedSuffix,
21
- errorLine,
22
- rejectionLine,
23
- reuseCallText,
24
- syncElapsedTimer,
25
- toolCallTitle,
26
- } from "../../lib/tui/tool.ts";
19
+ import { callLine, elapsedSuffix, errorLine, rejectionLine, syncElapsedTimer } from "../../lib/tui/tool.ts";
27
20
  import { paint } from "../../lib/tui/tokens.ts";
28
21
  import { formatCheckReport } from "./format.ts";
29
22
  import { CheckResultBlock } from "./render.ts";
@@ -138,9 +131,7 @@ export function registerCheckTool(pi: ExtensionAPI, runtime: CheckRuntime): void
138
131
 
139
132
  renderCall(args, theme, context) {
140
133
  const subject = context.argsComplete ? (args.target ?? "all") : "…";
141
- const text = reuseCallText(context.lastComponent, "", "middle");
142
- text.setText(toolCallTitle(theme, `${TOOL_NAME} `) + paint(theme, "meta", sanitizeLine(subject)));
143
- return text;
134
+ return callLine(theme, context.lastComponent, TOOL_NAME, subject, "middle");
144
135
  },
145
136
 
146
137
  renderResult(result, options, theme, context) {
@@ -0,0 +1,162 @@
1
+ # context — see where the window goes
2
+
3
+ A read-only report of the context window, with one action hanging off it: when
4
+ the conversation is too full, the report is where you decide to compact. It is
5
+ command-only on purpose — no tool is declared, so the model's prompt budget is
6
+ untouched.
7
+
8
+ ```bash
9
+ pi --extension ./extensions/context # load just this extension
10
+ pi -e . # load the whole @gavin-hu/my-pi-agent package
11
+ pi install ./ # install the package
12
+ ```
13
+
14
+ ## What it does
15
+
16
+ - Registers one command, `/context`. In the TUI it opens a docked screen; in
17
+ `rpc`, `json`, and `print` it notifies the same facts as plain text.
18
+ - Reports Pi's own estimate first — tokens, window, percentage, free space, the
19
+ auto-compaction threshold, and the headroom left before it — then the
20
+ composition: the system prompt, the active tool definitions, the conversation
21
+ by role, injected pack context grouped by type, summaries, and shell output.
22
+ - Names the difference between Pi's total and the composition estimate instead
23
+ of forcing the parts to add up: provider usage plus trailing messages is not
24
+ the same measurement as characters/4 over the rows.
25
+ - Reads the branch's compaction history: how many compactions there have been,
26
+ what the newest one cut from and to, and how much its summary costs.
27
+ - Lists the five largest single blocks, so a report that is mostly one tool
28
+ result says so.
29
+ - Compacts from the report: `c` on the screen asks first, `/context compact
30
+ [instructions]` runs directly. Both refuse while the agent is busy — a
31
+ streaming turn or a running compaction.
32
+
33
+ ## Commands
34
+
35
+ | Command | What it does |
36
+ |---|---|
37
+ | `/context` | The report: a screen in the TUI, a notification elsewhere |
38
+ | `/context compact [instructions]` | Compact from the report; refuses while a turn or a compaction is running, so it never interrupts one. Instructions focus the summary |
39
+
40
+ ### Screen keys
41
+
42
+ | Key | Action |
43
+ |---|---|
44
+ | `↑` / `↓`, `j` / `k` | Scroll one line |
45
+ | `PgUp` / `PgDn` | Scroll one page |
46
+ | `Home` / `End`, `g` / `G` | Jump to the start or the end |
47
+ | `c` | Compact after a `y`/`N` confirmation |
48
+ | `r` | Re-read the session and repaint |
49
+ | `Esc` / `q` | Close the screen |
50
+
51
+ `c` is refused while the agent is busy — a streaming turn or a compaction
52
+ already running: Pi's compaction starts by aborting the active turn, and one
53
+ keystroke should not discard a running answer. Wait for the turn (or stop it),
54
+ then compact. Pi's built-in `/compact` still aborts when that is what you asked
55
+ for.
56
+
57
+ ## Behaviour by mode
58
+
59
+ `/context` works everywhere. The screen needs `ctx.mode === "tui"`; the other
60
+ modes notify the same report as text, including the composition rows and the
61
+ largest blocks. `/context compact` is the same in every mode and reports through
62
+ `ctx.ui.notify` when it finishes or fails.
63
+
64
+ ## How the numbers are computed
65
+
66
+ | Row | Source |
67
+ |---|---|
68
+ | Headline (`tokens`, `percent`) | `ctx.getContextUsage()`: the last valid provider usage plus Pi's estimate of the messages after it. `tokens` is `null` between a compaction and the next successful reply, because the last usage describes the pre-compaction context. |
69
+ | Window | `getContextUsage().contextWindow`, then the active model's `contextWindow`. |
70
+ | System prompt | A `system` message in the projection when one exists, otherwise `ctx.getSystemPrompt()` at characters/4. |
71
+ | Tool definitions | `pi.getAllTools()` filtered to `pi.getActiveTools()`, over each active tool's name, description, and parameter schema at characters/4. `promptGuidelines` are counted in the system prompt instead, because Pi renders them into its rules section. |
72
+ | Conversation rows | `sessionManager.buildSessionProjection().messages`, each counted with Pi's own `estimateTokens` (characters/4; an image is 4800 characters; a tool call is its name plus its JSON arguments). |
73
+ | Injected context | `custom` messages grouped by `customType`, biggest first; the five largest types are named and the rest fold into one row. |
74
+ | Threshold | `compaction.modelOverrides["provider/id"]`, then `compaction`, then `DEFAULT_COMPACTION_SETTINGS` — the same three-step fallback Pi uses. |
75
+ | Compaction history | `getLatestCompactionEntry(branch)` and the branch's `compaction` count. The summary's cost is the summary text at characters/4. |
76
+ | Drift | `tokens - estimate`. Positive means Pi's total includes overhead no row attributes; negative means the rows are larger, which happens before the first reply because Pi's message-only fallback omits the system prompt and tool definitions. Both numbers are estimates and the row says which direction the gap runs. |
77
+
78
+ ## Limitations
79
+
80
+ - The composition is an estimate. A provider tokenizer differs from characters/4,
81
+ images are a fixed guess, and the system prompt is measured from its string, so
82
+ the rows will not exactly equal the headline. The drift row is the honest
83
+ difference, not a rounding bug.
84
+ - Right after a compaction `tokens` is unknown until the next reply. The screen
85
+ shows the composition estimate and says the total is pending.
86
+ - The report is a snapshot. It is re-read when the screen opens, on `r`, and after
87
+ a compaction; it does not watch the session.
88
+ - Tool definitions are estimated from the schemas, not from the rendered prompt, so
89
+ a host that formats them differently will move the drift.
90
+ - Before the session's first reply, Pi's total is a message-only estimate: it
91
+ leaves out the system prompt and tool definitions, so the rows can exceed it.
92
+ Once a reply exists the host total includes them.
93
+ - Without a model whose `contextWindow` is known there is no threshold to show;
94
+ the report says so and leaves compaction to Pi.
95
+ - Descriptors in **Largest blocks** are one line, clipped to 40 characters; a long
96
+ tool result is represented by its tool name, not its content.
97
+
98
+ ## Non-goals
99
+
100
+ - No automatic compaction policy: Pi owns the threshold, and this extension only
101
+ reports it and forwards a manual request.
102
+ - No tokenizer API call, no provider SDK, and no network.
103
+ - No trimming, editing, or pinning of individual messages.
104
+ - No second `/compact`: the guarded compact is the screen's own action, not a
105
+ replacement.
106
+ - No persistent widget; unlike `goal`/`todo`, nothing is added to the rails.
107
+
108
+ ## Pi integration
109
+
110
+ | Contract | Detail |
111
+ |---|---|
112
+ | Registration | The default export registers the `/context` command and nothing else. No tool, no flag, no shortcut, no session hook. |
113
+ | Tool surface | None. Command-only, so there is no `promptSnippet`, `annotations`, `executionMode`, or `outputSchema`. |
114
+ | Host reads | `ctx.getContextUsage()`, `ctx.getSystemPrompt()`, `ctx.model`, `ctx.isIdle()`, `ctx.sessionManager.buildSessionProjection()` / `getBranch()`, `pi.getActiveTools()` / `getAllTools()` / `getSettings()`. All in `collect.ts`. |
115
+ | Action | `ctx.compact({ customInstructions, onComplete, onError })`. It is fire-and-forget; the callbacks repaint the screen or notify the command's caller. |
116
+ | State | None. The report is derived from the live session on demand, so `/resume` and `/tree` need no replay and nothing can drift. |
117
+ | UI | `ctx.ui.custom` behind `ctx.mode === "tui"` and `withRailsSuppressed`; `ctx.ui.notify` elsewhere. |
118
+ | Plan mode | `/context` is usable while planning. `/context compact` mutates the session; commands are user-initiated and are not gated by the read-only tool policy. |
119
+
120
+ ## Design notes
121
+
122
+ - **The headline is the host's, the parts are ours.** Pi already computes a
123
+ context estimate that blends the last provider usage with a trailing estimate;
124
+ recomputing it would be a second policy. The report reads it and then labels
125
+ its own composition as an estimate, with `drift` as the visible difference.
126
+ - **One collector.** `getContextUsage()` rebuilds the session projection, and the
127
+ composition needs that projection too, so `collect.ts` reads once per open or
128
+ refresh. The screen holds the report; arrow keys only move a slice.
129
+ - **The threshold is resolved, not approximated.** The report repeats Pi's
130
+ three-step fallback rather than reading only the shared setting, because
131
+ predicting the wrong threshold is worse than not predicting one.
132
+ - **A guarded compact, not a second one.** `/compact` is Pi's; `/context compact`
133
+ exists so the report's action works in every mode, and it adds the one guard
134
+ that matches this package's "never lose work" rule: it will not abort a turn.
135
+ - **Sanitized at the boundary.** Custom types, message text, and tool names are
136
+ model- or extension-authored, so every descriptor goes through `sanitizeLine`
137
+ before it is painted.
138
+ - **A docked screen with a clamped body.** The frame follows
139
+ `lib/tui/chrome.ts` and `lib/tui/cursor.ts`, and the body budget leaves the hint
140
+ row visible on a short terminal.
141
+
142
+ ## Files
143
+
144
+ | File | Responsibility |
145
+ |---|---|
146
+ | `index.ts` | Factory: the `PI_DISABLED_EXTENSIONS` guard and command registration. |
147
+ | `commands.ts` | `/context` and `/context compact`: the TUI mount, the text fallback, and the idle guard. |
148
+ | `collect.ts` | The host boundary: one read of usage, prompt, tools, settings, projection, and branch. |
149
+ | `report.ts` | The pure builder: composition, reserve resolution, compaction history, largest blocks. |
150
+ | `format.ts` | The plain-text report for non-TUI modes. |
151
+ | `tui.ts` | `ContextComponent`: gauge, rows, scroll, confirmation, feedback. |
152
+ | `types.ts` | `ContextInputs`, `ContextReport`, buckets, and the host types named through `ReturnType<…>`. |
153
+
154
+ ## Testing
155
+
156
+ `bun test extensions/context` covers the pure report (composition, the reserve
157
+ fallback chain, an unknown window, a missing host total, drift in both
158
+ directions, compaction history, and descriptor clipping/sanitization), the
159
+ screen (width safety at five widths, scroll, refresh, the confirmation, a
160
+ running-turn refusal, and failures), and the extension surface (one command, no
161
+ tool, the disabled switch, and every command path) through
162
+ `test/helpers/fixtures/context.ts`.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The host boundary: read Pi's own numbers once, in one place.
3
+ *
4
+ * Everything here is a read. `getContextUsage()` already rebuilds the session
5
+ * projection, and the composition needs that projection too, so the caller
6
+ * collects once per screen open or refresh rather than per frame; the component
7
+ * then renders from the report it was handed.
8
+ *
9
+ * Only active tools are counted, because only they are declared to the model.
10
+ * A tool definition is estimated from the schema and text the host would send,
11
+ * not from a rendered prompt, so the number is an estimate like every other row.
12
+ */
13
+
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { estimateChars } from "./report.ts";
16
+ import type { ContextInputs } from "./types.ts";
17
+
18
+ /** Estimated tokens a set of tool definitions adds to the request. */
19
+ function toolTokens(tools: ReturnType<ExtensionAPI["getAllTools"]>): number {
20
+ let chars = 0;
21
+ for (const tool of tools) {
22
+ // `promptGuidelines` are rendered into the system prompt's rules section,
23
+ // which `ctx.getSystemPrompt()` already contains, so counting them here
24
+ // would count them twice.
25
+ chars += JSON.stringify({ name: tool.name, description: tool.description, parameters: tool.parameters }).length;
26
+ }
27
+ return estimateChars(chars);
28
+ }
29
+
30
+ /** Read everything the report needs from one host context. */
31
+ export function collectInputs(pi: ExtensionAPI, ctx: ExtensionContext): ContextInputs {
32
+ const usage = ctx.getContextUsage();
33
+ const model = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null;
34
+ const activeNames = new Set(pi.getActiveTools());
35
+ const allTools = pi.getAllTools();
36
+ const activeTools = allTools.filter((tool) => activeNames.has(tool.name));
37
+ const session = ctx.sessionManager;
38
+
39
+ return {
40
+ model,
41
+ window: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
42
+ systemPrompt: ctx.getSystemPrompt(),
43
+ tools: {
44
+ active: activeNames.size,
45
+ total: allTools.length,
46
+ tokens: toolTokens(activeTools),
47
+ },
48
+ usage,
49
+ messages: session.buildSessionProjection().messages,
50
+ branch: session.getBranch(),
51
+ settings: pi.getSettings(),
52
+ };
53
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The `/context` command: the report, and the one action it justifies.
3
+ *
4
+ * A bare `/context` opens the screen in the TUI and prints the same facts as
5
+ * text everywhere else. `/context compact [instructions]` is deliberately not a
6
+ * second `/compact`: it refuses to start while a turn is streaming, because Pi's
7
+ * own compaction begins with an abort, and reports the before/after numbers when
8
+ * it finishes.
9
+ */
10
+
11
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import { formatTokens } from "../../lib/format.ts";
13
+ import { withRailsSuppressed } from "../../lib/rails.ts";
14
+ import { messageOf } from "../../lib/errors.ts";
15
+ import { collectInputs } from "./collect.ts";
16
+ import { formatReport } from "./format.ts";
17
+ import { buildReport } from "./report.ts";
18
+ import { ContextComponent } from "./tui.ts";
19
+
20
+ /** The command's argument surface, shared by the description and the usage warning. */
21
+ const CONTEXT_USAGE = "/context [compact [instructions]]";
22
+
23
+ /** Advise the next token after `/context`. */
24
+ function completeArg(prefix: string): Array<{ value: string; label: string }> | null {
25
+ const trimmed = prefix.trimStart();
26
+ if (trimmed.includes(" ")) return null;
27
+ const options = ["compact"].filter((option) => option.startsWith(trimmed));
28
+ return options.length > 0 ? options.map((value) => ({ value: `${value} `, label: value })) : null;
29
+ }
30
+
31
+ /** The report as a screen in the TUI, or as a notification elsewhere. */
32
+ async function openReport(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
33
+ const report = buildReport(collectInputs(pi, ctx));
34
+ if (ctx.mode !== "tui") {
35
+ ctx.ui.notify(formatReport(report));
36
+ return;
37
+ }
38
+
39
+ // The dock screen owns the editor slot; hide the rails for its lifetime.
40
+ await withRailsSuppressed(pi, () =>
41
+ ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
42
+ return new ContextComponent({
43
+ theme,
44
+ report,
45
+ read: () => buildReport(collectInputs(pi, ctx)),
46
+ isIdle: () => ctx.isIdle(),
47
+ compact: (instructions, callbacks) => {
48
+ ctx.compact({
49
+ customInstructions: instructions,
50
+ onComplete: callbacks.done,
51
+ onError: callbacks.fail,
52
+ });
53
+ },
54
+ close: () => done(),
55
+ requestRender: () => tui.requestRender(),
56
+ viewportRowsSource: () => tui.terminal?.rows,
57
+ });
58
+ }),
59
+ );
60
+ }
61
+
62
+ /** Compact without interrupting a running turn, then report what changed. */
63
+ function compact(ctx: ExtensionCommandContext, instructions: string): void {
64
+ if (!ctx.isIdle()) {
65
+ ctx.ui.notify(
66
+ "The agent is busy — a turn or a compaction is running. Wait for it to finish (or stop the turn) " +
67
+ "before compacting; `/context compact` never interrupts a turn. Pi's `/compact` aborts it if that is what you want.",
68
+ "warning",
69
+ );
70
+ return;
71
+ }
72
+ ctx.compact({
73
+ customInstructions: instructions || undefined,
74
+ onComplete: (result) => {
75
+ const after = result.estimatedTokensAfter === undefined ? "?" : formatTokens(result.estimatedTokensAfter);
76
+ ctx.ui.notify(`Compacted ${formatTokens(result.tokensBefore)} → ${after}.`, "info");
77
+ },
78
+ onError: (error) => ctx.ui.notify(`Compaction failed: ${messageOf(error)}`, "error"),
79
+ });
80
+ }
81
+
82
+ export function registerCommands(pi: ExtensionAPI): void {
83
+ pi.registerCommand("context", {
84
+ description: `Show where the context window goes, and compact from the report: ${CONTEXT_USAGE}`,
85
+ getArgumentCompletions: completeArg,
86
+ handler: async (args, ctx) => {
87
+ const trimmed = args.trim();
88
+ if (trimmed === "") {
89
+ await openReport(pi, ctx);
90
+ return;
91
+ }
92
+ const [sub = "", ...rest] = trimmed.split(/\s+/);
93
+ if (sub === "compact") {
94
+ compact(ctx, rest.join(" ").trim());
95
+ return;
96
+ }
97
+ ctx.ui.notify(`Unknown /context subcommand "${sub}". Usage: ${CONTEXT_USAGE}`, "warning");
98
+ },
99
+ });
100
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The plain-text form of a report, for RPC, JSON, and print sessions.
3
+ *
4
+ * The screen draws its own colour and width-aware rows; this module keeps the
5
+ * same facts in the order the screen uses, so a reader in a non-TUI mode sees
6
+ * the same sentence the terminal would. No theme, no terminal width.
7
+ */
8
+
9
+ import { formatTokens } from "../../lib/format.ts";
10
+ import type { ContextBucket, ContextReport } from "./types.ts";
11
+
12
+ /** `Context · 124k / 200k (62%)`, or why one of the numbers is missing. */
13
+ export function formatHeadline(report: ContextReport): string {
14
+ if (report.window <= 0) return "Context · model context window unknown";
15
+ if (report.tokens === null) {
16
+ return `Context · ${formatTokens(report.window)} window · tokens unknown until the next reply`;
17
+ }
18
+ const percent = report.percent === null ? "?" : `${Math.round(report.percent)}%`;
19
+ return `Context · ${formatTokens(report.tokens)} / ${formatTokens(report.window)} (${percent})`;
20
+ }
21
+
22
+ /** The free space, the auto-compaction threshold, and what it keeps. */
23
+ export function formatBudget(report: ContextReport): string {
24
+ if (report.window <= 0) {
25
+ return "No model window, so no threshold: Pi's compaction needs a model with a known context window.";
26
+ }
27
+ const parts: string[] = [];
28
+ if (report.free !== null) parts.push(`${formatTokens(report.free)} free`);
29
+ parts.push(report.enabled ? `auto-compact at ${formatTokens(report.autoCompactAt)}` : "auto-compaction off");
30
+ if (report.headroom !== null) {
31
+ parts.push(
32
+ report.headroom >= 0
33
+ ? `${formatTokens(report.headroom)} headroom`
34
+ : `past the threshold by ${formatTokens(-report.headroom)}`,
35
+ );
36
+ }
37
+ parts.push(`keeps newest ${formatTokens(report.keepRecentTokens)}`);
38
+ return parts.join(" · ");
39
+ }
40
+
41
+ /** The branch's compaction history in one line. */
42
+ export function formatCompaction(report: ContextReport): string {
43
+ const { count, last } = report.compaction;
44
+ if (!last) return "none yet";
45
+ const times = `${count} so far`;
46
+ return `${times} · last ${last.at} cut ${formatTokens(last.tokensBefore)} → ${formatTokens(last.summaryTokens)} summary (freed ${formatTokens(last.freed)})`;
47
+ }
48
+
49
+ /** The difference between the host's total and the composition estimate, when it is not zero. */
50
+ export function formatDrift(report: ContextReport): string | undefined {
51
+ if (report.drift === null || report.drift === 0) return undefined;
52
+ if (report.drift > 0) {
53
+ return `Provider overhead · +${formatTokens(report.drift)} not attributed to a row`;
54
+ }
55
+ return `Estimate drift · the rows are ${formatTokens(-report.drift)} above the host's total; both are estimates`;
56
+ }
57
+
58
+ /** One composition row, as plain text. */
59
+ export function formatBucket(bucket: ContextBucket): string {
60
+ const indent = bucket.depth > 0 ? " " : " ";
61
+ const label = bucket.label.padEnd(bucket.depth > 0 ? 22 : 24);
62
+ const tokens = formatTokens(bucket.tokens).padStart(6);
63
+ const share = `${bucket.share.toFixed(1)}%`.padStart(6);
64
+ const note = bucket.note ? ` ${bucket.note}` : "";
65
+ return `${indent}${label}${tokens}${share}${note}`;
66
+ }
67
+
68
+ /** The report as a notification body: headline, budget, rows, then what happened. */
69
+ export function formatReport(report: ContextReport): string {
70
+ const lines = [formatHeadline(report), formatBudget(report)];
71
+ if (report.buckets.length > 0) lines.push("", ...report.buckets.map(formatBucket));
72
+ const drift = formatDrift(report);
73
+ if (drift) lines.push("", drift);
74
+ lines.push("", `Compaction · ${formatCompaction(report)}`);
75
+ if (report.largest.length > 0) {
76
+ const blocks = report.largest
77
+ .map((block) => `${formatTokens(block.tokens)} ${block.role} ${block.label}`)
78
+ .join(" · ");
79
+ lines.push(`Largest · ${blocks}`);
80
+ }
81
+ return lines.join("\n");
82
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `context` — see where the window goes, and compact from the report.
3
+ *
4
+ * Command-only by design: usage is a user-facing concern, so no tool is declared
5
+ * and the model's prompt budget is untouched. The factory only registers; the
6
+ * report reads the session on demand and keeps no resources.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { isExtensionEnabled } from "../../lib/env.ts";
11
+ import { registerCommands } from "./commands.ts";
12
+
13
+ export default function context(pi: ExtensionAPI): void {
14
+ if (!isExtensionEnabled("context")) return;
15
+ registerCommands(pi);
16
+ }