pi-one-ui 0.7.0 → 0.7.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.en.md +19 -6
  3. package/README.md +19 -6
  4. package/extensions/app/config/context.ts +42 -0
  5. package/extensions/app/config/editor.ts +80 -0
  6. package/extensions/app/config/footer.ts +291 -0
  7. package/extensions/app/config/panel.ts +1 -5
  8. package/extensions/app/config/renderer.ts +1 -6
  9. package/extensions/app/config/shell.ts +358 -1435
  10. package/extensions/app/config/store.ts +22 -1
  11. package/extensions/app/config/values.ts +33 -0
  12. package/extensions/app/config/working-line.ts +100 -0
  13. package/extensions/app/overlay/input-router.ts +56 -32
  14. package/extensions/app/overlay/overlay-manager.ts +183 -21
  15. package/extensions/app/runtime/event-coordinator.ts +90 -42
  16. package/extensions/{features → app/runtime}/flush-docked-bash.ts +12 -2
  17. package/extensions/app/runtime/render-scheduler.ts +35 -31
  18. package/extensions/app/runtime/tui-runtime.ts +315 -312
  19. package/extensions/app/settings/controller.ts +202 -0
  20. package/extensions/app/settings/panel.ts +81 -0
  21. package/extensions/app/settings/presets.ts +43 -0
  22. package/extensions/features/context-inspector/index.ts +83 -662
  23. package/extensions/layouts/context/index.ts +58 -161
  24. package/extensions/layouts/context/message/user-message.ts +1 -1
  25. package/extensions/layouts/context/renderer/index.ts +31 -13
  26. package/extensions/layouts/context/renderer/mouse/interaction.ts +29 -14
  27. package/extensions/layouts/editor/controller.ts +9 -18
  28. package/extensions/layouts/editor/factory.ts +3 -3
  29. package/extensions/layouts/footer/controller.ts +30 -19
  30. package/extensions/layouts/footer/footer.ts +2 -2
  31. package/extensions/layouts/overlay/context-inspector.ts +497 -0
  32. package/extensions/{app → layouts}/overlay/selector-border.ts +5 -5
  33. package/extensions/{app → layouts}/overlay/selector-controller.ts +1 -1
  34. package/extensions/layouts/overlay/settings-panel.ts +359 -0
  35. package/extensions/{app/commands → layouts/overlay}/settings-previews.ts +4 -4
  36. package/extensions/layouts/working-line/controller.ts +3 -29
  37. package/extensions/layouts/working-line/working-line.ts +17 -114
  38. package/extensions/shared/working-line-text.ts +108 -0
  39. package/package.json +1 -1
  40. package/extensions/app/host/pi-extension-port.ts +0 -25
  41. package/extensions/app/host/pi-ui-port.ts +0 -52
  42. package/extensions/app/host/tui-capabilities.ts +0 -27
  43. package/extensions/app/ownership/layout-registry.ts +0 -65
  44. package/extensions/app/ownership.ts +0 -32
  45. package/extensions/app/panel.ts +0 -770
  46. package/extensions/app/presets.ts +0 -35
  47. package/extensions/app/runtime/runtime-state.ts +0 -83
  48. /package/extensions/{layouts/working-line → app/config}/working-line-messages.ts +0 -0
  49. /package/extensions/app/{ownership → runtime}/prototype-patch-registry.ts +0 -0
@@ -4,6 +4,10 @@ import {
4
4
  hasUnsupportedComponentStyle,
5
5
  type PolishedTuiConfig,
6
6
  } from "../../app/config/shell.ts";
7
+ import type {
8
+ RenderRequest,
9
+ RenderScheduler,
10
+ } from "../../app/runtime/render-scheduler.ts";
7
11
  import type { SessionLifecycle } from "../../app/runtime/session-lifecycle.ts";
8
12
  import type { LiveContextOverride } from "../../services/live-context.ts";
9
13
  import type { FooterState } from "../../services/session-state.ts";
@@ -17,9 +21,7 @@ type FooterKind = "starship" | "hidden";
17
21
 
18
22
  export type FooterControllerContext = {
19
23
  readonly getConfig: () => PolishedTuiConfig;
20
- readonly saveComponent: (
21
- patch: Partial<FooterComponentConfig>,
22
- ) => PolishedTuiConfig;
24
+ readonly render: Pick<RenderScheduler, "register">;
23
25
  readonly state: FooterState;
24
26
  readonly sessionLifecycle: SessionLifecycle;
25
27
  readonly refresh: () => void;
@@ -39,7 +41,7 @@ export class FooterLayoutController {
39
41
  private readonly context: FooterControllerContext;
40
42
  private installedKind: FooterKind | undefined;
41
43
  private installedToken: symbol | undefined;
42
- private requestFooterRender: (() => void) | undefined;
44
+ private requestFooterRender: RenderRequest | undefined;
43
45
  private getActiveExtensionStatuses: () => ReadonlyMap<string, string> = () =>
44
46
  new Map();
45
47
  private stopSessionTimer: () => void = () => {};
@@ -68,23 +70,34 @@ export class FooterLayoutController {
68
70
  }
69
71
 
70
72
  /**
71
- * Applies a Footer configuration patch and reconciles its host seam.
73
+ * 应用 app 已提交的 Footer 配置并更新组件。
72
74
  *
73
- * @param patch Footer configuration changes to persist.
74
- * @param ctx Active Pi extension context.
75
+ * @param patch 本次修改的 Footer 字段。
76
+ * @param ctx 当前 Pi session 的上下文。
77
+ * @returns 组件应用结果。
75
78
  */
76
- setComponent(
79
+ applyConfig(
77
80
  patch: Partial<FooterComponentConfig>,
78
81
  ctx: ExtensionContext,
79
- ): void {
80
- const previousStyle = this.context.getConfig().components.footer.style;
81
- const nextConfig = this.context.saveComponent(patch);
82
- const styleChanged = nextConfig.components.footer.style !== previousStyle;
82
+ previousStyle = this.context.getConfig().components.footer.style,
83
+ ): { applied: boolean; reason?: string } {
84
+ const styleChanged =
85
+ this.context.getConfig().components.footer.style !== previousStyle;
83
86
  if (patch.style !== undefined) this.reconcile(ctx);
84
87
  if (patch.modelLabel !== undefined) this.context.onModelLabelChanged(ctx);
85
88
  this.context.onProjectRequirementChanged(ctx, styleChanged);
86
89
  this.reconcileSessionTimer();
87
90
  this.context.refresh();
91
+ const style = this.effectiveFooterStyle();
92
+ const applied =
93
+ patch.style === undefined ||
94
+ (style === "native"
95
+ ? !this.ownsStatusLine(ctx)
96
+ : this.installedKind === style && this.ownsStatusLine(ctx));
97
+ return {
98
+ applied,
99
+ reason: applied ? undefined : `Footer could not apply the ${style} style`,
100
+ };
88
101
  }
89
102
 
90
103
  /**
@@ -135,6 +148,7 @@ export class FooterLayoutController {
135
148
  this.installedKind = undefined;
136
149
  this.installedToken = undefined;
137
150
  this.requestFooterRender = undefined;
151
+ this.context.render.register(this, undefined);
138
152
  this.getActiveExtensionStatuses = () => new Map();
139
153
  }
140
154
 
@@ -212,13 +226,6 @@ export class FooterLayoutController {
212
226
  : new Set<string>();
213
227
  }
214
228
 
215
- /**
216
- * Requests a redraw from the mounted Footer component.
217
- */
218
- requestRender(): void {
219
- this.requestFooterRender?.();
220
- }
221
-
222
229
  /**
223
230
  * Reports whether a context is an interactive TUI context.
224
231
  *
@@ -282,6 +289,7 @@ export class FooterLayoutController {
282
289
  this.installedToken = undefined;
283
290
  if (this.footerOwner(ctx) === token) this.setOwnership(ctx, undefined);
284
291
  this.requestFooterRender = undefined;
292
+ this.context.render.register(this, undefined);
285
293
  this.getActiveExtensionStatuses = () => new Map();
286
294
  this.stopSessionTimer();
287
295
  if (this.context.sessionLifecycle.isCurrent())
@@ -301,6 +309,7 @@ export class FooterLayoutController {
301
309
  installFooter(ctx, this.context.state, this.context.getConfig, {
302
310
  setRequestRender: (requestRender) => {
303
311
  this.requestFooterRender = requestRender;
312
+ this.context.render.register(this, requestRender);
304
313
  },
305
314
  scheduleProjectRefresh: (refreshContext) =>
306
315
  this.context.scheduleProjectRefresh(refreshContext),
@@ -334,6 +343,7 @@ export class FooterLayoutController {
334
343
  this.installedToken = token;
335
344
  this.setOwnership(ctx, token);
336
345
  this.requestFooterRender = undefined;
346
+ this.context.render.register(this, undefined);
337
347
  this.getActiveExtensionStatuses = () => new Map();
338
348
  this.stopSessionTimer();
339
349
  } catch {
@@ -373,6 +383,7 @@ export class FooterLayoutController {
373
383
  this.footerOwner(ctx) === previous.token
374
384
  ) {
375
385
  this.requestFooterRender = previous.requestRender;
386
+ this.context.render.register(this, previous.requestRender);
376
387
  this.getActiveExtensionStatuses = previous.getExtensionStatuses;
377
388
  return;
378
389
  }
@@ -230,7 +230,7 @@ export function installFooter(
230
230
  state: FooterState,
231
231
  getConfig: () => ZentuiConfig,
232
232
  hooks: {
233
- setRequestRender: (fn: (() => void) | undefined) => void;
233
+ setRequestRender: (fn: ((force: boolean) => void) | undefined) => void;
234
234
  scheduleProjectRefresh: (ctx: ExtensionContext) => void;
235
235
  setExtensionStatusesGetter?: (
236
236
  fn: (() => ReadonlyMap<string, string>) | undefined,
@@ -240,7 +240,7 @@ export function installFooter(
240
240
  },
241
241
  ): void {
242
242
  ctx.ui.setFooter((tui, theme, footerData) => {
243
- hooks.setRequestRender(() => tui.requestRender());
243
+ hooks.setRequestRender((force) => tui.requestRender(force));
244
244
  hooks.setExtensionStatusesGetter?.(() => footerData.getExtensionStatuses());
245
245
  const unsubscribeBranch = footerData.onBranchChange(() => {
246
246
  hooks.scheduleProjectRefresh(ctx);
@@ -0,0 +1,497 @@
1
+ import {
2
+ type ExtensionCommandContext,
3
+ getMarkdownTheme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ Key,
7
+ Markdown,
8
+ matchesKey,
9
+ type TUI,
10
+ visibleWidth,
11
+ } from "@earendil-works/pi-tui";
12
+ import {
13
+ type OverlayManager,
14
+ overlayManager,
15
+ } from "../../app/overlay/overlay-manager.ts";
16
+ import { padLine } from "../../tools/format.ts";
17
+ import { mouseBaseButton, parseSgrMousePacket } from "../../tools/sgr-mouse.ts";
18
+
19
+ export type ContextPart = {
20
+ label: string;
21
+ tokens: number;
22
+ color:
23
+ | "accent"
24
+ | "success"
25
+ | "warning"
26
+ | "customMessageLabel"
27
+ | "muted"
28
+ | "dim"
29
+ | "error";
30
+ };
31
+ export type ContextPreviewKey =
32
+ | "systemPrompt"
33
+ | "memoryFiles"
34
+ | "skills"
35
+ | "tools"
36
+ | "toolResults"
37
+ | "contextFiles";
38
+ export type ContextInspectorData = {
39
+ parts: ContextPart[];
40
+ used: number;
41
+ contextWindow: number;
42
+ previews: Record<ContextPreviewKey, string>;
43
+ };
44
+ export type DialogBounds = { left: number; top: number; width: number };
45
+ type Hitbox = { row: number; startCol: number; endCol: number };
46
+
47
+ export function escCloseHitbox(bounds: DialogBounds): Hitbox {
48
+ return {
49
+ row: bounds.top + 2,
50
+ startCol: bounds.left + bounds.width - 5,
51
+ endCol: bounds.left + bounds.width - 1,
52
+ };
53
+ }
54
+ export function hasActiveTextPreview(): boolean {
55
+ return overlayManager.hasActive();
56
+ }
57
+ export function formatTokens(tokens: number): string {
58
+ return tokens < 1_000
59
+ ? String(tokens)
60
+ : tokens < 100_000
61
+ ? `${(tokens / 1_000).toFixed(1)}k`
62
+ : `${Math.round(tokens / 1_000)}k`;
63
+ }
64
+ function normalizePreviewText(text: string): string {
65
+ return text
66
+ .replace(/\r\n?/g, "\n")
67
+ .replace(/\t/g, " ")
68
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
69
+ }
70
+ function ensureFullscreenMouseMotion(tui: TUI): void {
71
+ if (tui.mode === "fullscreen") {
72
+ tui.terminal.write("\x1b[?1003h\x1b[?1006h");
73
+ }
74
+ }
75
+ function contains(
76
+ hitbox: Hitbox | undefined,
77
+ row: number,
78
+ col: number,
79
+ ): boolean {
80
+ return Boolean(
81
+ hitbox &&
82
+ row === hitbox.row &&
83
+ col >= hitbox.startCol &&
84
+ col <= hitbox.endCol,
85
+ );
86
+ }
87
+
88
+ /** Markdown 预览的滚动、鼠标命中与视觉状态完全属于视图。 */
89
+ export async function showTextPreview(
90
+ ctx: Pick<ExtensionCommandContext, "ui">,
91
+ title: string,
92
+ rawContent: string,
93
+ overlays: OverlayManager = overlayManager,
94
+ ): Promise<void> {
95
+ const content = normalizePreviewText(rawContent);
96
+ await overlays.open(
97
+ ctx,
98
+ (tui, theme, _keybindings, done) => {
99
+ ensureFullscreenMouseMotion(tui);
100
+ let scrollOffset = 0;
101
+ let pageSize = 1;
102
+ let totalLines = 1;
103
+ let escHovered = false;
104
+ let escHitbox: Hitbox | undefined;
105
+ let scrollbarHitbox:
106
+ | {
107
+ col: number;
108
+ startRow: number;
109
+ endRow: number;
110
+ thumbStart: number;
111
+ thumbSize: number;
112
+ maxOffset: number;
113
+ }
114
+ | undefined;
115
+ let scrollbarDragOffset: number | null = null;
116
+ const markdown = new Markdown(content, 0, 0, getMarkdownTheme());
117
+ const scrollTo = (offset: number): void => {
118
+ const next = Math.max(
119
+ 0,
120
+ Math.min(offset, Math.max(0, totalLines - pageSize)),
121
+ );
122
+ if (next !== scrollOffset) {
123
+ scrollOffset = next;
124
+ tui.requestRender();
125
+ }
126
+ };
127
+ const dragScrollbarTo = (row: number): void => {
128
+ if (!scrollbarHitbox || scrollbarDragOffset === null) {
129
+ return;
130
+ }
131
+ const trackSize = scrollbarHitbox.endRow - scrollbarHitbox.startRow + 1;
132
+ const maxThumbStart = Math.max(
133
+ 0,
134
+ trackSize - scrollbarHitbox.thumbSize,
135
+ );
136
+ const start = Math.max(
137
+ 0,
138
+ Math.min(
139
+ row - scrollbarHitbox.startRow - scrollbarDragOffset,
140
+ maxThumbStart,
141
+ ),
142
+ );
143
+ scrollTo(
144
+ maxThumbStart > 0
145
+ ? Math.round((start / maxThumbStart) * scrollbarHitbox.maxOffset)
146
+ : 0,
147
+ );
148
+ };
149
+ return {
150
+ invalidate() {
151
+ markdown.invalidate();
152
+ },
153
+ handleInput(data: string) {
154
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
155
+ done(undefined);
156
+ return;
157
+ }
158
+ if (matchesKey(data, Key.up)) {
159
+ scrollTo(scrollOffset - 1);
160
+ } else if (matchesKey(data, Key.down)) {
161
+ scrollTo(scrollOffset + 1);
162
+ } else if (matchesKey(data, "pageUp")) {
163
+ scrollTo(scrollOffset - pageSize);
164
+ } else if (matchesKey(data, "pageDown")) {
165
+ scrollTo(scrollOffset + pageSize);
166
+ } else if (matchesKey(data, Key.home)) {
167
+ scrollTo(0);
168
+ } else if (matchesKey(data, Key.end)) {
169
+ scrollTo(totalLines - pageSize);
170
+ } else {
171
+ const mouse = parseSgrMousePacket(data);
172
+ if (!mouse) {
173
+ return;
174
+ }
175
+ if (mouse.final === "m") {
176
+ scrollbarDragOffset = null;
177
+ return;
178
+ }
179
+ const overEsc = contains(escHitbox, mouse.row, mouse.col);
180
+ if (overEsc !== escHovered) {
181
+ escHovered = overEsc;
182
+ tui.requestRender();
183
+ }
184
+ const button = mouseBaseButton(mouse.code);
185
+ const motion = (mouse.code & 32) !== 0;
186
+ if (button === 0 && !motion) {
187
+ if (overEsc) {
188
+ done(undefined);
189
+ return;
190
+ }
191
+ if (
192
+ scrollbarHitbox &&
193
+ mouse.col === scrollbarHitbox.col &&
194
+ mouse.row >= scrollbarHitbox.startRow &&
195
+ mouse.row <= scrollbarHitbox.endRow
196
+ ) {
197
+ const row = mouse.row - scrollbarHitbox.startRow;
198
+ const inThumb =
199
+ row >= scrollbarHitbox.thumbStart &&
200
+ row < scrollbarHitbox.thumbStart + scrollbarHitbox.thumbSize;
201
+ scrollbarDragOffset = inThumb
202
+ ? row - scrollbarHitbox.thumbStart
203
+ : Math.floor(scrollbarHitbox.thumbSize / 2);
204
+ dragScrollbarTo(mouse.row);
205
+ return;
206
+ }
207
+ }
208
+ if (motion && scrollbarDragOffset !== null) {
209
+ dragScrollbarTo(mouse.row);
210
+ } else if (button === 64) {
211
+ scrollTo(scrollOffset - 3);
212
+ } else if (button === 65) {
213
+ scrollTo(scrollOffset + 3);
214
+ }
215
+ }
216
+ },
217
+ render(width: number) {
218
+ const inner = Math.max(1, width - 2);
219
+ const escWidth = visibleWidth("[esc]");
220
+ const bodyInner = Math.max(1, inner - 1);
221
+ const terminalHeight = Math.max(1, tui.terminal.rows);
222
+ const availableHeight = Math.max(1, terminalHeight - 4);
223
+ const viewportHeight = Math.min(
224
+ 30,
225
+ Math.max(1, Math.floor(terminalHeight * 0.8)),
226
+ availableHeight,
227
+ );
228
+ pageSize = Math.max(1, viewportHeight - 6);
229
+ const wrapped = markdown.render(Math.max(1, bodyInner - 1));
230
+ totalLines = wrapped.length;
231
+ scrollOffset = Math.min(
232
+ scrollOffset,
233
+ Math.max(0, totalLines - pageSize),
234
+ );
235
+ const overlayTop =
236
+ 2 + Math.floor((availableHeight - viewportHeight) / 2);
237
+ const overlayLeft = Math.floor(
238
+ (Math.max(1, tui.terminal.columns) - width) / 2,
239
+ );
240
+ escHitbox = escCloseHitbox({
241
+ left: overlayLeft,
242
+ top: overlayTop,
243
+ width,
244
+ });
245
+ const visible = wrapped.slice(scrollOffset, scrollOffset + pageSize);
246
+ const border = (text: string) => theme.fg("border", text);
247
+ const scrollable = totalLines > pageSize;
248
+ const thumbSize = scrollable
249
+ ? Math.max(1, Math.floor((pageSize * pageSize) / totalLines))
250
+ : 0;
251
+ const maxOffset = Math.max(0, totalLines - pageSize);
252
+ const thumbStart =
253
+ scrollable && maxOffset > 0
254
+ ? Math.round((scrollOffset / maxOffset) * (pageSize - thumbSize))
255
+ : 0;
256
+ scrollbarHitbox = scrollable
257
+ ? {
258
+ col: overlayLeft + width - 1,
259
+ startRow: overlayTop + 4,
260
+ endRow: overlayTop + 3 + pageSize,
261
+ thumbStart,
262
+ thumbSize,
263
+ maxOffset,
264
+ }
265
+ : undefined;
266
+ const bodyRows = Array.from({ length: pageSize }, (_, row) => {
267
+ const inThumb = row >= thumbStart && row < thumbStart + thumbSize;
268
+ const scrollbar = scrollable
269
+ ? theme.fg(
270
+ inThumb ? "accent" : "borderMuted",
271
+ inThumb ? "█" : "│",
272
+ )
273
+ : " ";
274
+ return `${border("│")}${padLine(` ${visible[row] ?? ""}`, bodyInner)}${scrollbar}${border("│")}`;
275
+ });
276
+ const status = `${totalLines === 0 ? 0 : scrollOffset + 1}-${Math.min(totalLines, scrollOffset + pageSize)} / ${totalLines} lines · ↑↓ PgUp/PgDn Home/End · [esc] close`;
277
+ return [
278
+ border(`╭${"─".repeat(inner)}╮`),
279
+ `${border("│")}${padLine(` ${theme.bold(theme.fg("accent", title))}`, inner - escWidth)}${theme.fg(escHovered ? "text" : "muted", "[esc]")}${border("│")}`,
280
+ `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
281
+ ...bodyRows,
282
+ `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
283
+ `${border("│")}${padLine(theme.fg("dim", ` ${status}`), inner)}${border("│")}`,
284
+ border(`╰${"─".repeat(inner)}╯`),
285
+ ];
286
+ },
287
+ };
288
+ },
289
+ {
290
+ overlayOptions: {
291
+ anchor: "center",
292
+ width: "85%",
293
+ minWidth: 50,
294
+ maxHeight: "80%",
295
+ margin: 2,
296
+ },
297
+ },
298
+ );
299
+ }
300
+
301
+ const previewLabels = [
302
+ ["systemPrompt", "System prompt", "System Prompt"],
303
+ ["memoryFiles", "Memory", "Memory Files"],
304
+ ["skills", "Skills", "Skills"],
305
+ ["tools", "Tools definition", "Tools definition"],
306
+ ["toolResults", "Tool results", "Tool Results"],
307
+ ["contextFiles", "Context", "Context"],
308
+ ] as const;
309
+
310
+ export async function showContextInspector(
311
+ ctx: ExtensionCommandContext,
312
+ data: ContextInspectorData,
313
+ overlays: OverlayManager = overlayManager,
314
+ ): Promise<void> {
315
+ const { parts, used, contextWindow } = data;
316
+ const previews = previewLabels
317
+ .filter(([, label]) => parts.some((part) => part.label === label))
318
+ .map(([key, label, title]) => ({
319
+ key,
320
+ label,
321
+ title,
322
+ content: normalizePreviewText(data.previews[key]),
323
+ }));
324
+ let selectedIndex = 0;
325
+ const isCurrent = overlays.sessionGuard();
326
+ while (isCurrent()) {
327
+ const action = await overlays.open<ContextPreviewKey>(
328
+ ctx,
329
+ (tui, theme, _keybindings, done) => {
330
+ ensureFullscreenMouseMotion(tui);
331
+ let previewHitboxes: Array<Hitbox & { key: ContextPreviewKey }> = [];
332
+ let escHitbox: Hitbox | undefined;
333
+ let escHovered = false;
334
+ let hoveredKey: ContextPreviewKey | undefined;
335
+ return {
336
+ invalidate() {},
337
+ handleInput(input: string) {
338
+ if (
339
+ matchesKey(input, Key.escape) ||
340
+ matchesKey(input, Key.ctrl("c"))
341
+ ) {
342
+ done(undefined);
343
+ return;
344
+ }
345
+ if (
346
+ previews.length > 0 &&
347
+ (matchesKey(input, Key.up) || matchesKey(input, Key.down))
348
+ ) {
349
+ selectedIndex =
350
+ (selectedIndex +
351
+ (matchesKey(input, Key.up) ? -1 : 1) +
352
+ previews.length) %
353
+ previews.length;
354
+ tui.requestRender();
355
+ return;
356
+ }
357
+ if (matchesKey(input, Key.enter)) {
358
+ done(previews[selectedIndex]?.key);
359
+ return;
360
+ }
361
+ const mouse = parseSgrMousePacket(input);
362
+ if (!mouse || mouse.final !== "M") {
363
+ return;
364
+ }
365
+ const overEsc = contains(escHitbox, mouse.row, mouse.col);
366
+ const hitbox = previewHitboxes.find((candidate) =>
367
+ contains(candidate, mouse.row, mouse.col),
368
+ );
369
+ if ((mouse.code & 32) !== 0) {
370
+ if (overEsc !== escHovered || hitbox?.key !== hoveredKey) {
371
+ escHovered = overEsc;
372
+ hoveredKey = hitbox?.key;
373
+ tui.requestRender();
374
+ }
375
+ return;
376
+ }
377
+ if (mouseBaseButton(mouse.code) !== 0) {
378
+ return;
379
+ }
380
+ if (overEsc) {
381
+ done(undefined);
382
+ } else if (hitbox) {
383
+ selectedIndex = Math.max(
384
+ 0,
385
+ previews.findIndex((preview) => preview.key === hitbox.key),
386
+ );
387
+ done(hitbox.key);
388
+ }
389
+ },
390
+ render(width: number) {
391
+ const inner = Math.max(1, width - 2);
392
+ const percent =
393
+ contextWindow > 0 ? (used / contextWindow) * 100 : 0;
394
+ const title = theme.bold(theme.fg("accent", "Context Usage"));
395
+ const subtitle = `${formatTokens(used)} / ${formatTokens(contextWindow)} tokens (${percent.toFixed(1)}%)`;
396
+ const barWidth = Math.max(1, Math.min(60, inner - 2));
397
+ let remaining = barWidth;
398
+ const segments = parts
399
+ .map((part, index) => {
400
+ const cells =
401
+ index === parts.length - 1
402
+ ? remaining
403
+ : Math.min(
404
+ remaining,
405
+ Math.round(
406
+ (part.tokens / Math.max(1, contextWindow)) * barWidth,
407
+ ),
408
+ );
409
+ remaining -= cells;
410
+ return theme.fg(part.color, "█".repeat(Math.max(0, cells)));
411
+ })
412
+ .join("");
413
+ const labelWidth = Math.min(
414
+ 24,
415
+ Math.max(...parts.map((part) => part.label.length)),
416
+ );
417
+ const rows = parts.map((part) => {
418
+ const pct =
419
+ contextWindow > 0 ? (part.tokens / contextWindow) * 100 : 0;
420
+ const selected = part.label === previews[selectedIndex]?.label;
421
+ const hovered =
422
+ part.label ===
423
+ previews.find((preview) => preview.key === hoveredKey)?.label;
424
+ const amount = `${formatTokens(part.tokens).padStart(7)} ${pct.toFixed(1).padStart(5)}%`;
425
+ const row = padLine(
426
+ `${selected ? "› " : " "}${theme.fg(part.color, "■")} ${part.label.padEnd(labelWidth)} ${amount}`,
427
+ inner,
428
+ );
429
+ return selected
430
+ ? theme.bg("selectedBg", row)
431
+ : hovered
432
+ ? theme.bg("customMessageBg", row)
433
+ : row;
434
+ });
435
+ const border = (text: string) => theme.fg("border", text);
436
+ const lines = [
437
+ border(`╭${"─".repeat(inner)}╮`),
438
+ `${border("│")}${padLine(` ${title} ${theme.fg("muted", subtitle)}`, inner - visibleWidth("[esc]"))}${theme.fg(escHovered ? "text" : "muted", "[esc]")}${border("│")}`,
439
+ `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
440
+ `${border("│")}${padLine(` ${segments}`, inner)}${border("│")}`,
441
+ `${border("│")}${" ".repeat(inner)}${border("│")}`,
442
+ ...rows.map((row) => `${border("│")}${row}${border("│")}`),
443
+ `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
444
+ `${border("│")}${padLine(theme.fg("dim", " ↑↓ select · Click / Enter to preview · [esc] close"), inner)}${border("│")}`,
445
+ border(`╰${"─".repeat(inner)}╯`),
446
+ ];
447
+ const height = Math.max(1, tui.terminal.rows);
448
+ const visibleHeight = Math.min(
449
+ lines.length,
450
+ Math.max(1, Math.floor(height * 0.9)),
451
+ Math.max(1, height - 2),
452
+ );
453
+ const top =
454
+ 1 + Math.floor((Math.max(1, height - 2) - visibleHeight) / 2);
455
+ const left = Math.floor(
456
+ (Math.max(1, tui.terminal.columns) - width) / 2,
457
+ );
458
+ escHitbox = escCloseHitbox({ left, top, width });
459
+ previewHitboxes = previews.flatMap((preview) => {
460
+ const index = parts.findIndex(
461
+ (part) => part.label === preview.label,
462
+ );
463
+ const line = 5 + index;
464
+ return index >= 0 && line < visibleHeight
465
+ ? [
466
+ {
467
+ key: preview.key,
468
+ row: top + line + 1,
469
+ startCol: left + 1,
470
+ endCol: left + width,
471
+ },
472
+ ]
473
+ : [];
474
+ });
475
+ return lines;
476
+ },
477
+ };
478
+ },
479
+ {
480
+ overlayOptions: {
481
+ anchor: "center",
482
+ width: 64,
483
+ minWidth: 44,
484
+ maxHeight: "90%",
485
+ margin: 1,
486
+ },
487
+ },
488
+ );
489
+ if (!action) {
490
+ return;
491
+ }
492
+ const preview = previews.find((entry) => entry.key === action);
493
+ if (preview) {
494
+ await showTextPreview(ctx, preview.title, preview.content, overlays);
495
+ }
496
+ }
497
+ }
@@ -3,16 +3,16 @@ import {
3
3
  SettingsSelectorComponent,
4
4
  type Theme,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import type { ZentuiConfig } from "../../app/config/shell.ts";
7
+ import {
8
+ installPrototypePatch,
9
+ removePrototypePatch,
10
+ } from "../../app/runtime/prototype-patch-registry.ts";
6
11
  import {
7
12
  EDITOR_BORDER_FALLBACK,
8
13
  renderEditorBorder,
9
14
  renderThemeStyleOrFallback,
10
15
  } from "../../shared/style.ts";
11
- import type { ZentuiConfig } from "../config/shell.ts";
12
- import {
13
- installPrototypePatch,
14
- removePrototypePatch,
15
- } from "../ownership/prototype-patch-registry.ts";
16
16
 
17
17
  type PatchableSelectorPrototype = {
18
18
  render: (width: number) => string[];
@@ -2,7 +2,7 @@ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  hasUnsupportedComponentStyle,
4
4
  type PolishedTuiConfig,
5
- } from "../config/shell.ts";
5
+ } from "../../app/config/shell.ts";
6
6
  import { installSelectorBorderStyle } from "./selector-border.ts";
7
7
 
8
8
  /**