@pi-unipi/unipi 2.1.2 → 2.2.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 (82) hide show
  1. package/README.md +6 -2
  2. package/package.json +24 -23
  3. package/packages/ask-user/ask-ui.ts +45 -31
  4. package/packages/ask-user/launcher-ui.ts +26 -22
  5. package/packages/ask-user/package.json +3 -3
  6. package/packages/ask-user/settings-tui.ts +22 -9
  7. package/packages/autocomplete/package.json +1 -1
  8. package/packages/autocomplete/src/constants.ts +5 -0
  9. package/packages/btw/package.json +2 -2
  10. package/packages/cocoindex/package.json +2 -2
  11. package/packages/compactor/package.json +4 -4
  12. package/packages/compactor/src/tui/settings-overlay.ts +2 -1
  13. package/packages/core/constants.ts +18 -0
  14. package/packages/core/index.ts +1 -0
  15. package/packages/core/package.json +1 -1
  16. package/packages/core/tui-width.ts +127 -0
  17. package/packages/footer/package.json +3 -3
  18. package/packages/footer/src/tui/settings-tui.ts +2 -1
  19. package/packages/image/README.md +82 -0
  20. package/packages/image/index.ts +1 -0
  21. package/packages/image/package.json +57 -0
  22. package/packages/image/skills/image/SKILL.md +73 -0
  23. package/packages/image/src/commands.ts +17 -0
  24. package/packages/image/src/generate.ts +201 -0
  25. package/packages/image/src/image-source.ts +204 -0
  26. package/packages/image/src/index.ts +99 -0
  27. package/packages/image/src/models.ts +290 -0
  28. package/packages/image/src/recognize.ts +223 -0
  29. package/packages/image/src/settings.ts +149 -0
  30. package/packages/image/src/tools.ts +296 -0
  31. package/packages/image/src/tui/model-selector.ts +279 -0
  32. package/packages/image/src/tui/settings-dialog.ts +236 -0
  33. package/packages/info-screen/package.json +2 -2
  34. package/packages/info-screen/tui/info-overlay.ts +3 -2
  35. package/packages/input-shortcuts/package.json +3 -3
  36. package/packages/kanboard/package.json +2 -2
  37. package/packages/mcp/package.json +2 -2
  38. package/packages/mcp/src/tui/add-overlay.ts +10 -7
  39. package/packages/mcp/src/tui/settings-overlay.ts +10 -7
  40. package/packages/memory/package.json +3 -3
  41. package/packages/milestone/package.json +2 -2
  42. package/packages/notify/README.md +4 -1
  43. package/packages/notify/events.ts +130 -81
  44. package/packages/notify/package.json +3 -3
  45. package/packages/notify/permission-prompt-message.ts +95 -0
  46. package/packages/notify/settings.ts +2 -0
  47. package/packages/notify/skills/configure-notify/SKILL.md +31 -2
  48. package/packages/notify/src/__tests__/event-bus.test.ts +3 -2
  49. package/packages/notify/src/__tests__/permission-prompt-message.test.ts +172 -0
  50. package/packages/notify/tui/gotify-setup.ts +2 -1
  51. package/packages/notify/tui/ntfy-setup.ts +2 -1
  52. package/packages/notify/tui/recap-model-selector.ts +2 -2
  53. package/packages/notify/tui/settings-overlay.ts +2 -1
  54. package/packages/notify/tui/telegram-setup.ts +2 -1
  55. package/packages/ralph/package.json +3 -3
  56. package/packages/subagents/package.json +5 -5
  57. package/packages/subagents/src/__tests__/shutdown-stale-ctx.test.ts +185 -0
  58. package/packages/subagents/src/index.ts +52 -10
  59. package/packages/unipi/index.ts +2 -0
  60. package/packages/updater/package.json +3 -3
  61. package/packages/updater/src/tui/changelog-overlay.ts +2 -2
  62. package/packages/updater/src/tui/readme-overlay.ts +2 -1
  63. package/packages/updater/src/tui/update-overlay.ts +2 -1
  64. package/packages/utility/package.json +2 -2
  65. package/packages/utility/src/tui/badge-settings-tui.ts +2 -2
  66. package/packages/utility/src/tui/util-settings-tui.ts +2 -2
  67. package/packages/web-api/README.md +28 -11
  68. package/packages/web-api/package.json +8 -2
  69. package/packages/web-api/skills/web/SKILL.md +36 -9
  70. package/packages/web-api/src/index.ts +15 -0
  71. package/packages/web-api/src/providers/duckduckgo.ts +71 -27
  72. package/packages/web-api/src/providers/firecrawl.ts +1 -1
  73. package/packages/web-api/src/providers/jina-reader.ts +1 -1
  74. package/packages/web-api/src/providers/jina-search.ts +1 -1
  75. package/packages/web-api/src/providers/perplexity.ts +2 -2
  76. package/packages/web-api/src/providers/serpapi.ts +1 -1
  77. package/packages/web-api/src/providers/tavily.ts +1 -1
  78. package/packages/web-api/src/providers/wigolo-client.ts +196 -0
  79. package/packages/web-api/src/providers/wigolo.ts +117 -0
  80. package/packages/web-api/src/settings.ts +1 -0
  81. package/packages/web-api/src/tools.ts +93 -36
  82. package/packages/workflow/package.json +2 -2
@@ -0,0 +1,279 @@
1
+ /**
2
+ * @pi-unipi/image — Model selector overlay
3
+ *
4
+ * Picks either an image-generation model (from pi-ai's image catalog) or a
5
+ * vision model (from pi's chat registry, filtered to image-capable models).
6
+ */
7
+
8
+ import type { Component } from "@earendil-works/pi-tui";
9
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import type { Theme } from "@earendil-works/pi-coding-agent";
11
+ import { boxInnerWidth, safeRepeat } from "@pi-unipi/core";
12
+
13
+ export interface SelectableModel {
14
+ provider: string;
15
+ id: string;
16
+ name?: string;
17
+ }
18
+
19
+ export type ModelSelectorKind = "generate" | "recognize";
20
+
21
+ /** Overlay listing models with filter, navigation and selection. */
22
+ export class ImageModelSelectorOverlay implements Component {
23
+ private models: SelectableModel[] = [];
24
+ private filtered: SelectableModel[] = [];
25
+ private selectedIndex = 0;
26
+ private filter = "";
27
+ private filterMode = false;
28
+ private saved = false;
29
+ private error: string | null = null;
30
+ private theme: Theme | null = null;
31
+
32
+ onClose?: () => void;
33
+ onSelect?: (modelRef: string) => void;
34
+ requestRender?: () => void;
35
+
36
+ constructor(
37
+ private readonly kind: ModelSelectorKind,
38
+ models: SelectableModel[],
39
+ currentRef?: string,
40
+ ) {
41
+ this.models = models;
42
+ this.applyFilter();
43
+
44
+ if (currentRef) {
45
+ const index = this.filtered.findIndex(
46
+ (m) => `${m.provider}/${m.id}` === currentRef,
47
+ );
48
+ if (index >= 0) this.selectedIndex = index;
49
+ }
50
+ }
51
+
52
+ setTheme(theme: Theme): void {
53
+ this.theme = theme;
54
+ }
55
+
56
+ invalidate(): void {}
57
+
58
+ handleInput(data: string): void {
59
+ if (this.filterMode) {
60
+ this.handleFilterInput(data);
61
+ return;
62
+ }
63
+
64
+ switch (data) {
65
+ case "\x1b[A":
66
+ case "k":
67
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
68
+ break;
69
+ case "\x1b[B":
70
+ case "j":
71
+ this.selectedIndex = Math.min(this.filtered.length - 1, this.selectedIndex + 1);
72
+ break;
73
+ case "/":
74
+ this.filterMode = true;
75
+ this.filter = "";
76
+ break;
77
+ case "\r":
78
+ this.commit();
79
+ break;
80
+ case "\x1b":
81
+ this.onClose?.();
82
+ break;
83
+ }
84
+ }
85
+
86
+ private handleFilterInput(data: string): void {
87
+ if (data === "\r") {
88
+ this.filterMode = false;
89
+ return;
90
+ }
91
+ if (data === "\x1b") {
92
+ this.filter = "";
93
+ this.filterMode = false;
94
+ this.applyFilter();
95
+ this.selectedIndex = 0;
96
+ return;
97
+ }
98
+ if (data === "\x7f" || data === "\b") {
99
+ this.filter = this.filter.slice(0, -1);
100
+ this.applyFilter();
101
+ this.clampSelection();
102
+ return;
103
+ }
104
+ if (data.length === 1 && data >= " ") {
105
+ this.filter += data;
106
+ this.applyFilter();
107
+ this.clampSelection();
108
+ }
109
+ }
110
+
111
+ private clampSelection(): void {
112
+ this.selectedIndex = Math.min(
113
+ this.selectedIndex,
114
+ Math.max(0, this.filtered.length - 1),
115
+ );
116
+ }
117
+
118
+ private applyFilter(): void {
119
+ const query = this.filter.toLowerCase();
120
+ this.filtered = query
121
+ ? this.models.filter(
122
+ (m) =>
123
+ m.id.toLowerCase().includes(query) ||
124
+ m.provider.toLowerCase().includes(query) ||
125
+ (m.name?.toLowerCase().includes(query) ?? false),
126
+ )
127
+ : [...this.models];
128
+ }
129
+
130
+ private commit(): void {
131
+ const model = this.filtered[this.selectedIndex];
132
+ if (!model) {
133
+ this.error = "No model selected";
134
+ return;
135
+ }
136
+
137
+ this.onSelect?.(`${model.provider}/${model.id}`);
138
+ this.saved = true;
139
+ this.error = null;
140
+ setTimeout(() => this.onClose?.(), 500);
141
+ }
142
+
143
+ // ─── Theme helpers ───────────────────────────────────────────────────
144
+
145
+ private fg(color: string, text: string): string {
146
+ if (this.theme) return this.theme.fg(color as never, text);
147
+ const codes: Record<string, string> = {
148
+ accent: "\x1b[36m",
149
+ success: "\x1b[32m",
150
+ warning: "\x1b[33m",
151
+ error: "\x1b[31m",
152
+ dim: "\x1b[2m",
153
+ borderMuted: "\x1b[90m",
154
+ };
155
+ return `${codes[color] ?? ""}${text}\x1b[0m`;
156
+ }
157
+
158
+ private bold(text: string): string {
159
+ return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
160
+ }
161
+
162
+ private frameLine(content: string, innerWidth: number): string {
163
+ const truncated = truncateToWidth(content, innerWidth, "");
164
+ const padding = safeRepeat(" ", innerWidth - visibleWidth(truncated));
165
+ return `${this.fg("borderMuted", "│")}${truncated}${padding}${this.fg("borderMuted", "│")}`;
166
+ }
167
+
168
+ private ruleLine(innerWidth: number): string {
169
+ return this.fg("borderMuted", `├${safeRepeat("─", innerWidth)}┤`);
170
+ }
171
+
172
+ private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
173
+ const left = edge === "top" ? "┌" : "└";
174
+ const right = edge === "top" ? "┐" : "┘";
175
+ return this.fg("borderMuted", `${left}${safeRepeat("─", innerWidth)}${right}`);
176
+ }
177
+
178
+ render(width: number): string[] {
179
+ const innerWidth = boxInnerWidth(width);
180
+ const lines: string[] = [];
181
+
182
+ const title =
183
+ this.kind === "generate"
184
+ ? "🎨 Image Generation Model"
185
+ : "👁 Image Recognition Model";
186
+ const subtitle =
187
+ this.kind === "generate"
188
+ ? "Model used by image_generate"
189
+ : "Vision model used by image_recognize";
190
+
191
+ lines.push(this.borderLine(innerWidth, "top"));
192
+ lines.push(this.frameLine(this.fg("accent", this.bold(title)), innerWidth));
193
+ lines.push(this.frameLine(this.fg("dim", subtitle), innerWidth));
194
+ lines.push(this.ruleLine(innerWidth));
195
+
196
+ // Filter bar
197
+ if (this.filterMode) {
198
+ lines.push(
199
+ this.frameLine(
200
+ ` ${this.fg("accent", "Filter:")} ${this.filter}${this.fg("accent", "█")}`,
201
+ innerWidth,
202
+ ),
203
+ );
204
+ } else if (this.filter) {
205
+ lines.push(
206
+ this.frameLine(
207
+ ` ${this.fg("dim", "Filter:")} ${this.filter} ${this.fg("dim", "(press / to edit)")}`,
208
+ innerWidth,
209
+ ),
210
+ );
211
+ } else {
212
+ lines.push(
213
+ this.frameLine(
214
+ ` ${this.fg("dim", `${this.models.length} models · press / to filter`)}`,
215
+ innerWidth,
216
+ ),
217
+ );
218
+ }
219
+ lines.push(this.ruleLine(innerWidth));
220
+
221
+ // Model list
222
+ const terminalRows = process.stdout.rows ?? 30;
223
+ const maxVisible = Math.max(5, terminalRows - 14);
224
+ const start = Math.max(0, this.selectedIndex - Math.floor(maxVisible / 2));
225
+ const end = Math.min(this.filtered.length, start + maxVisible);
226
+
227
+ if (this.filtered.length === 0) {
228
+ const empty =
229
+ this.models.length === 0
230
+ ? this.kind === "generate"
231
+ ? "No image models available (needs OpenRouter)"
232
+ : "No vision-capable models configured"
233
+ : "No models match the filter";
234
+ lines.push(this.frameLine(` ${this.fg("dim", empty)}`, innerWidth));
235
+ } else {
236
+ for (let i = start; i < end; i++) {
237
+ const model = this.filtered[i];
238
+ const isSelected = i === this.selectedIndex;
239
+ const marker = isSelected ? this.fg("accent", "▸") : " ";
240
+ const label = model.name || model.id;
241
+ const providerTag = this.fg("dim", `[${model.provider}]`);
242
+ const display = isSelected
243
+ ? `${providerTag} ${this.bold(label)}`
244
+ : `${providerTag} ${this.fg("dim", label)}`;
245
+ lines.push(this.frameLine(` ${marker} ${display}`, innerWidth));
246
+ }
247
+ }
248
+
249
+ if (this.filtered.length > maxVisible) {
250
+ const pct = Math.round(((this.selectedIndex + 1) / this.filtered.length) * 100);
251
+ lines.push(
252
+ this.frameLine(
253
+ this.fg("dim", ` ${pct}% (${this.selectedIndex + 1}/${this.filtered.length})`),
254
+ innerWidth,
255
+ ),
256
+ );
257
+ }
258
+
259
+ if (this.error) {
260
+ lines.push(this.ruleLine(innerWidth));
261
+ lines.push(this.frameLine(` ${this.fg("error", `⚠ ${this.error}`)}`, innerWidth));
262
+ }
263
+ if (this.saved) {
264
+ lines.push(this.ruleLine(innerWidth));
265
+ lines.push(this.frameLine(` ${this.fg("success", "✓ Model saved")}`, innerWidth));
266
+ }
267
+
268
+ lines.push(this.ruleLine(innerWidth));
269
+ lines.push(
270
+ this.frameLine(
271
+ this.fg("dim", "↑↓ navigate · / filter · Enter select · Esc cancel"),
272
+ innerWidth,
273
+ ),
274
+ );
275
+ lines.push(this.borderLine(innerWidth, "bottom"));
276
+
277
+ return lines;
278
+ }
279
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * @pi-unipi/image — Settings dialog
3
+ *
4
+ * Simple `ctx.ui.select` loop, matching the web-api settings pattern. The
5
+ * model pickers are richer overlays (see model-selector.ts).
6
+ */
7
+
8
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
+
10
+ import {
11
+ DEFAULT_CONFIG,
12
+ DEFAULT_RECOGNIZE_SYSTEM_PROMPT,
13
+ getOutputDir,
14
+ loadConfig,
15
+ saveConfig,
16
+ type ImageConfig,
17
+ } from "../settings.js";
18
+ import {
19
+ formatModelRef,
20
+ listImageGenModels,
21
+ listVisionModels,
22
+ type ChatModelRegistry,
23
+ } from "../models.js";
24
+ import { ImageModelSelectorOverlay, type SelectableModel } from "./model-selector.js";
25
+
26
+ const EXIT = "__exit__";
27
+
28
+ function describe(config: ImageConfig): Array<{ value: string; label: string }> {
29
+ return [
30
+ {
31
+ value: "__gen_model__",
32
+ label: `🎨 Generation model — ${config.generate.model || "(none)"}`,
33
+ },
34
+ {
35
+ value: "__gen_enabled__",
36
+ label: ` image_generate tool — ${config.generate.enabled ? "enabled" : "disabled"}`,
37
+ },
38
+ {
39
+ value: "__gen_save__",
40
+ label: ` Save to disk — ${config.generate.saveToDisk ? "on" : "off"}`,
41
+ },
42
+ {
43
+ value: "__gen_dir__",
44
+ label: ` Output directory — ${config.generate.outputDir}`,
45
+ },
46
+ {
47
+ value: "__rec_model__",
48
+ label: `👁 Recognition model — ${config.recognize.model || "(session model)"}`,
49
+ },
50
+ {
51
+ value: "__rec_enabled__",
52
+ label: ` image_recognize tool — ${config.recognize.enabled ? "enabled" : "disabled"}`,
53
+ },
54
+ {
55
+ value: "__rec_prompt__",
56
+ label: ` System prompt — ${summarize(config.recognize.systemPrompt)}`,
57
+ },
58
+ { value: "__reset__", label: "↺ Reset to defaults" },
59
+ { value: EXIT, label: "← Back" },
60
+ ];
61
+ }
62
+
63
+ function summarize(text: string, max = 45): string {
64
+ const flat = text.replace(/\s+/g, " ").trim();
65
+ if (!flat) return "(empty)";
66
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
67
+ }
68
+
69
+ /** Show the settings dialog until the user backs out. */
70
+ export async function showSettingsDialog(ctx: ExtensionCommandContext): Promise<void> {
71
+ for (;;) {
72
+ const config = loadConfig();
73
+ const options = describe(config);
74
+ const labels = options.map((o) => o.label);
75
+
76
+ const picked = await ctx.ui.select("Image Settings", labels);
77
+ if (!picked) return;
78
+
79
+ const choice = options.find((o) => o.label === picked)?.value;
80
+ if (!choice || choice === EXIT) return;
81
+
82
+ await applyChoice(ctx, choice, config);
83
+ }
84
+ }
85
+
86
+ async function applyChoice(
87
+ ctx: ExtensionCommandContext,
88
+ choice: string,
89
+ config: ImageConfig,
90
+ ): Promise<void> {
91
+ switch (choice) {
92
+ case "__gen_enabled__":
93
+ config.generate.enabled = !config.generate.enabled;
94
+ saveConfig(config);
95
+ ctx.ui.notify(
96
+ `image_generate ${config.generate.enabled ? "enabled" : "disabled"} — restart the session to apply.`,
97
+ "info",
98
+ );
99
+ return;
100
+
101
+ case "__rec_enabled__":
102
+ config.recognize.enabled = !config.recognize.enabled;
103
+ saveConfig(config);
104
+ ctx.ui.notify(
105
+ `image_recognize ${config.recognize.enabled ? "enabled" : "disabled"} — restart the session to apply.`,
106
+ "info",
107
+ );
108
+ return;
109
+
110
+ case "__gen_save__":
111
+ config.generate.saveToDisk = !config.generate.saveToDisk;
112
+ saveConfig(config);
113
+ ctx.ui.notify(
114
+ config.generate.saveToDisk
115
+ ? `Generated images will be saved to ${getOutputDir(config)}`
116
+ : "Generated images will be returned inline only.",
117
+ "info",
118
+ );
119
+ return;
120
+
121
+ case "__gen_dir__": {
122
+ const value = await ctx.ui.input(
123
+ "Output directory for generated images",
124
+ config.generate.outputDir,
125
+ );
126
+ if (value?.trim()) {
127
+ config.generate.outputDir = value.trim();
128
+ saveConfig(config);
129
+ ctx.ui.notify(`Output directory set to ${getOutputDir(config)}`, "info");
130
+ }
131
+ return;
132
+ }
133
+
134
+ case "__rec_prompt__": {
135
+ const value = await ctx.ui.input(
136
+ "System prompt for image recognition (blank to restore the default)",
137
+ config.recognize.systemPrompt,
138
+ );
139
+ if (value === undefined) return;
140
+ config.recognize.systemPrompt = value.trim() || DEFAULT_RECOGNIZE_SYSTEM_PROMPT;
141
+ saveConfig(config);
142
+ ctx.ui.notify("System prompt updated.", "info");
143
+ return;
144
+ }
145
+
146
+ case "__gen_model__":
147
+ await pickModel(ctx, "generate", config);
148
+ return;
149
+
150
+ case "__rec_model__":
151
+ await pickModel(ctx, "recognize", config);
152
+ return;
153
+
154
+ case "__reset__":
155
+ saveConfig(structuredClone(DEFAULT_CONFIG));
156
+ ctx.ui.notify("Image settings reset to defaults.", "info");
157
+ return;
158
+ }
159
+ }
160
+
161
+ async function pickModel(
162
+ ctx: ExtensionCommandContext,
163
+ kind: "generate" | "recognize",
164
+ config: ImageConfig,
165
+ ): Promise<void> {
166
+ const models = await collectModels(ctx, kind);
167
+
168
+ if (models.length === 0) {
169
+ ctx.ui.notify(
170
+ kind === "generate"
171
+ ? "No image models available. Image generation is served through OpenRouter — add a key with /login."
172
+ : "No vision-capable models configured. Add a model that accepts image input.",
173
+ "warning",
174
+ );
175
+ return;
176
+ }
177
+
178
+ if (!ctx.hasUI) {
179
+ ctx.ui.notify("Model selection requires an interactive UI.", "warning");
180
+ return;
181
+ }
182
+
183
+ const current =
184
+ kind === "generate" ? config.generate.model : config.recognize.model;
185
+
186
+ await new Promise<void>((resolve) => {
187
+ ctx.ui.custom(
188
+ (tui, theme, _keybindings, done) => {
189
+ const overlay = new ImageModelSelectorOverlay(kind, models, current);
190
+ overlay.setTheme(theme);
191
+ overlay.onSelect = (modelRef) => {
192
+ const next = loadConfig();
193
+ if (kind === "generate") next.generate.model = modelRef;
194
+ else next.recognize.model = modelRef;
195
+ saveConfig(next);
196
+ };
197
+ overlay.onClose = () => done(undefined);
198
+ return {
199
+ render: (width: number) => overlay.render(width),
200
+ invalidate: () => overlay.invalidate(),
201
+ handleInput: (data: string) => {
202
+ overlay.handleInput(data);
203
+ tui.requestRender();
204
+ },
205
+ };
206
+ },
207
+ {
208
+ overlay: true,
209
+ overlayOptions: { width: "80%", minWidth: 50, anchor: "center", margin: 2 },
210
+ },
211
+ );
212
+ resolve();
213
+ });
214
+ }
215
+
216
+ async function collectModels(
217
+ ctx: ExtensionCommandContext,
218
+ kind: "generate" | "recognize",
219
+ ): Promise<SelectableModel[]> {
220
+ if (kind === "generate") {
221
+ const models = await listImageGenModels();
222
+ return models.map((m) => ({ provider: m.provider, id: m.id, name: m.name }));
223
+ }
224
+
225
+ const registry = (ctx as unknown as { modelRegistry?: ChatModelRegistry })
226
+ .modelRegistry;
227
+ if (!registry) return [];
228
+
229
+ return listVisionModels(registry).map((m) => ({
230
+ provider: m.provider,
231
+ id: m.id,
232
+ name: m.name,
233
+ }));
234
+ }
235
+
236
+ export { formatModelRef };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "Dashboard and module registry for Unipi — configurable info overlay with tabbed groups",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.1.2"
36
+ "@pi-unipi/core": "2.2.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -13,6 +13,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
13
13
  import { infoRegistry } from "../registry.js";
14
14
  import { getInfoSettings } from "../config.js";
15
15
  import type { InfoGroup, GroupData } from "../types.js";
16
+ import { boxInnerWidth } from "@pi-unipi/core";
16
17
 
17
18
  /** Tab color palette */
18
19
  const TAB_FG: Array<"accent" | "success" | "warning" | "error"> = [
@@ -275,7 +276,7 @@ export class InfoOverlay implements Component {
275
276
  // ─── State views ─────────────────────────────────────────────────────
276
277
 
277
278
  private renderEmpty(width: number): string[] {
278
- const innerWidth = Math.max(22, width - 2);
279
+ const innerWidth = boxInnerWidth(width);
279
280
  const lines: string[] = [];
280
281
  lines.push(this.borderLine(innerWidth, "top"));
281
282
  lines.push(this.frameLine(this.fg("accent", this.bold("📊 UniPi Info Screen")), innerWidth));
@@ -292,7 +293,7 @@ export class InfoOverlay implements Component {
292
293
  // ─── Dashboard ───────────────────────────────────────────────────────
293
294
 
294
295
  private renderDashboard(width: number): string[] {
295
- const innerWidth = Math.max(22, width - 2);
296
+ const innerWidth = boxInnerWidth(width);
296
297
  const group = this.groups[this.activeTabIndex];
297
298
  const data = this.groupData.get(group.id) ?? {};
298
299
  const isLoading = this.groupLoading.get(group.id) ?? false;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/input-shortcuts",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "Keyboard shortcuts for stash/restore, undo/redo, clipboard, and thinking toggle — chord-based overlay system",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -27,13 +27,13 @@
27
27
  "README.md"
28
28
  ],
29
29
  "scripts": {
30
- "test": "node --experimental-strip-types --test tests/**/*.test.ts"
30
+ "test": "npx tsx --test tests/**/*.test.ts"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.1.2"
36
+ "@pi-unipi/core": "2.2.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "Visualization layer for unipi workflow — HTTP server with htmx/Alpine.js UI, modular parsers, TUI overlay, and kanban board",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@pi-unipi/core": "2.1.2"
42
+ "@pi-unipi/core": "2.2.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/mcp",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "MCP server management extension for Pi coding agent — browse, add, configure, and use MCP servers",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -27,7 +27,7 @@
27
27
  "README.md"
28
28
  ],
29
29
  "dependencies": {
30
- "@pi-unipi/core": "2.1.2"
30
+ "@pi-unipi/core": "2.2.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -31,6 +31,7 @@ import {
31
31
  getProjectConfigDir,
32
32
  } from "../config/manager.js";
33
33
  import { validateMcpConfig, DEFAULT_MCP_CONFIG, DEFAULT_METADATA } from "../config/schema.js";
34
+ import { boxInnerWidth, normalizeWidth, WidthKeyedCache } from "@pi-unipi/core";
34
35
 
35
36
  type Mode = "normal" | "search" | "editor";
36
37
  type StatusKind = "info" | "success" | "warn" | "error";
@@ -158,11 +159,12 @@ export function renderMcpAddOverlay(params?: {
158
159
  // Ctrl+S handles saving; no submit path needed.
159
160
  (editor as any).disableSubmit = true;
160
161
 
161
- let cachedLines: string[] | undefined;
162
+ // Width-keyed so a terminal resize can never serve stale, over-wide lines.
163
+ const lineCache = new WidthKeyedCache();
162
164
  let lastListHeight = 12;
163
165
 
164
166
  function refresh() {
165
- cachedLines = undefined;
167
+ lineCache.clear();
166
168
  tui.requestRender();
167
169
  }
168
170
 
@@ -537,11 +539,13 @@ export function renderMcpAddOverlay(params?: {
537
539
 
538
540
  // ─── Rendering ────────────────────────────────────────────────────
539
541
 
540
- function render(width: number): string[] {
541
- if (cachedLines) return cachedLines;
542
+ function render(rawWidth: number): string[] {
543
+ const width = normalizeWidth(rawWidth);
544
+ const cached = lineCache.get(width);
545
+ if (cached) return cached;
542
546
 
543
547
  const lines: string[] = [];
544
- const innerWidth = Math.max(40, width - 2);
548
+ const innerWidth = boxInnerWidth(width);
545
549
 
546
550
  // Pane widths: account for left │, middle │, right │ borders
547
551
  const leftW = Math.floor((innerWidth - 1) / 2);
@@ -776,8 +780,7 @@ export function renderMcpAddOverlay(params?: {
776
780
  );
777
781
  lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
778
782
 
779
- cachedLines = lines;
780
- return lines;
783
+ return lineCache.set(width, lines);
781
784
  }
782
785
 
783
786
  return { render, invalidate: refresh, handleInput };