@pi-unipi/unipi 2.6.0 → 2.6.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 (205) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +21 -22
  3. package/packages/ask-user/package.json +2 -2
  4. package/packages/ask-user/settings-tui.ts +18 -34
  5. package/packages/autocomplete/package.json +1 -1
  6. package/packages/autocomplete/src/constants.ts +2 -15
  7. package/packages/autocomplete/src/provider.ts +3 -17
  8. package/packages/btw/extensions/btw.ts +18 -45
  9. package/packages/btw/package.json +2 -2
  10. package/packages/compactor/README.md +2 -2
  11. package/packages/compactor/package.json +3 -3
  12. package/packages/compactor/skills/compactor/SKILL.md +1 -1
  13. package/packages/compactor/skills/compactor-detail/SKILL.md +3 -5
  14. package/packages/compactor/skills/compactor-doctor/SKILL.md +1 -1
  15. package/packages/compactor/skills/compactor-stats/SKILL.md +1 -1
  16. package/packages/compactor/src/commands/index.ts +47 -78
  17. package/packages/compactor/src/compaction/brief.ts +161 -90
  18. package/packages/compactor/src/compaction/build-sections.ts +3 -4
  19. package/packages/compactor/src/compaction/compact-args.ts +86 -0
  20. package/packages/compactor/src/compaction/cut.ts +270 -28
  21. package/packages/compactor/src/compaction/drill-down.ts +261 -0
  22. package/packages/compactor/src/compaction/format-recall.ts +96 -0
  23. package/packages/compactor/src/compaction/format.ts +8 -3
  24. package/packages/compactor/src/compaction/hooks.ts +248 -72
  25. package/packages/compactor/src/compaction/merge.ts +34 -4
  26. package/packages/compactor/src/compaction/rank.ts +270 -0
  27. package/packages/compactor/src/compaction/recall-scope.ts +28 -0
  28. package/packages/compactor/src/compaction/search-entries.ts +333 -96
  29. package/packages/compactor/src/compaction/skill-collapse.ts +35 -0
  30. package/packages/compactor/src/compaction/summarize.ts +37 -6
  31. package/packages/compactor/src/compaction/token-estimate.ts +104 -0
  32. package/packages/compactor/src/compaction/touched-files.ts +35 -0
  33. package/packages/compactor/src/config/manager.ts +2 -27
  34. package/packages/compactor/src/config/presets.ts +0 -2
  35. package/packages/compactor/src/config/schema.ts +2 -16
  36. package/packages/compactor/src/executor/executor.ts +6 -15
  37. package/packages/compactor/src/executor/runtime.ts +2 -12
  38. package/packages/compactor/src/index.ts +4 -121
  39. package/packages/compactor/src/info-screen.ts +3 -10
  40. package/packages/compactor/src/security/evaluator.ts +0 -53
  41. package/packages/compactor/src/security/policy.ts +7 -8
  42. package/packages/compactor/src/session/db.ts +0 -6
  43. package/packages/compactor/src/tools/ctx-execute-file.ts +0 -5
  44. package/packages/compactor/src/tools/register.ts +27 -50
  45. package/packages/compactor/src/tools/vcc-recall.ts +86 -48
  46. package/packages/compactor/src/tui/settings-overlay.ts +20 -40
  47. package/packages/compactor/src/types.ts +43 -100
  48. package/packages/core/constants.ts +1 -28
  49. package/packages/core/events.ts +1 -83
  50. package/packages/core/index.ts +1 -0
  51. package/packages/core/model-cache.ts +18 -12
  52. package/packages/core/package.json +3 -1
  53. package/packages/core/tui-overlay.ts +89 -0
  54. package/packages/core/tui-width.ts +18 -0
  55. package/packages/core/utils.ts +43 -0
  56. package/packages/footer/package.json +2 -2
  57. package/packages/footer/src/help.ts +2 -2
  58. package/packages/footer/src/index.ts +9 -10
  59. package/packages/footer/src/presets.ts +1 -27
  60. package/packages/footer/src/registry/index.ts +0 -23
  61. package/packages/footer/src/rendering/icons.ts +0 -13
  62. package/packages/footer/src/rendering/renderer.ts +5 -40
  63. package/packages/footer/src/rendering/separators.ts +0 -10
  64. package/packages/footer/src/rendering/theme.ts +1 -15
  65. package/packages/footer/src/segments/compactor.ts +7 -14
  66. package/packages/footer/src/segments/core.ts +15 -54
  67. package/packages/footer/src/segments/kanboard.ts +5 -5
  68. package/packages/footer/src/segments/mcp.ts +5 -5
  69. package/packages/footer/src/segments/memory.ts +4 -4
  70. package/packages/footer/src/segments/notify.ts +2 -2
  71. package/packages/footer/src/segments/ralph.ts +3 -3
  72. package/packages/footer/src/segments/status-ext.ts +1 -1
  73. package/packages/footer/src/segments/workflow.ts +3 -3
  74. package/packages/footer/src/tps-tracker.ts +0 -8
  75. package/packages/footer/src/tui/settings-tui.ts +14 -31
  76. package/packages/footer/src/types.ts +0 -15
  77. package/packages/image/package.json +2 -2
  78. package/packages/image/src/register-providers.ts +0 -11
  79. package/packages/image/src/tools.ts +0 -3
  80. package/packages/image/src/tui/model-selector.ts +36 -71
  81. package/packages/info-screen/config.ts +0 -16
  82. package/packages/info-screen/core-groups.ts +0 -10
  83. package/packages/info-screen/index.ts +22 -13
  84. package/packages/info-screen/package.json +2 -2
  85. package/packages/info-screen/registry.ts +1 -72
  86. package/packages/info-screen/tui/info-overlay.ts +85 -100
  87. package/packages/info-screen/usage-parser.ts +12 -47
  88. package/packages/input-shortcuts/package.json +2 -2
  89. package/packages/input-shortcuts/src/index.ts +3 -3
  90. package/packages/input-shortcuts/src/registers.ts +0 -8
  91. package/packages/input-shortcuts/src/settings-overlay.ts +4 -13
  92. package/packages/input-shortcuts/src/undo-redo.ts +0 -12
  93. package/packages/kanboard/package.json +2 -2
  94. package/packages/mcp/package.json +2 -2
  95. package/packages/mcp/src/bridge/client.ts +0 -15
  96. package/packages/mcp/src/config/manager.ts +0 -38
  97. package/packages/mcp/src/config/schema.ts +0 -27
  98. package/packages/mcp/src/config/sync.ts +0 -29
  99. package/packages/mcp/src/index.ts +1 -1
  100. package/packages/mcp/src/tui/add-overlay.ts +6 -6
  101. package/packages/mcp/src/types.ts +0 -6
  102. package/packages/memory/embedding.ts +0 -31
  103. package/packages/memory/mempalace.ts +0 -16
  104. package/packages/memory/package.json +4 -8
  105. package/packages/memory/storage.ts +56 -753
  106. package/packages/milestone/hooks.ts +1 -6
  107. package/packages/milestone/milestone.ts +2 -41
  108. package/packages/milestone/package.json +2 -2
  109. package/packages/notify/README.md +1 -0
  110. package/packages/notify/commands.ts +61 -3
  111. package/packages/notify/events.ts +0 -10
  112. package/packages/notify/package.json +2 -2
  113. package/packages/notify/settings.ts +0 -19
  114. package/packages/notify/tui/gotify-setup.ts +108 -143
  115. package/packages/notify/tui/ntfy-setup.ts +137 -172
  116. package/packages/notify/tui/recap-model-selector.ts +107 -101
  117. package/packages/notify/tui/settings-overlay.ts +94 -117
  118. package/packages/notify/tui/telegram-setup.ts +48 -79
  119. package/packages/notify/types.ts +0 -2
  120. package/packages/ralph/index.ts +11 -35
  121. package/packages/ralph/package.json +3 -3
  122. package/packages/ralph/ralph-loop.ts +3 -44
  123. package/packages/subagents/package.json +2 -1
  124. package/packages/subagents/src/agent-manager.ts +0 -51
  125. package/packages/subagents/src/agent-runner.ts +6 -80
  126. package/packages/subagents/src/conversation-viewer.ts +1 -19
  127. package/packages/subagents/src/core-compat.ts +8 -25
  128. package/packages/subagents/src/custom-agents.ts +0 -1
  129. package/packages/subagents/src/index.ts +3 -48
  130. package/packages/subagents/src/types.ts +0 -11
  131. package/packages/subagents/src/widget.ts +5 -5
  132. package/packages/unipi/bundled.js +4468 -7769
  133. package/packages/unipi/index.ts +6 -3
  134. package/packages/updater/package.json +2 -2
  135. package/packages/updater/src/changelog.ts +1 -16
  136. package/packages/updater/src/index.ts +0 -5
  137. package/packages/updater/src/installer.ts +4 -26
  138. package/packages/updater/src/markdown.ts +6 -152
  139. package/packages/updater/src/settings.ts +0 -15
  140. package/packages/updater/src/tui/changelog-overlay.ts +42 -234
  141. package/packages/updater/src/tui/list-detail-overlay.ts +260 -0
  142. package/packages/updater/src/tui/readme-overlay.ts +53 -210
  143. package/packages/updater/src/tui/settings-overlay.ts +13 -26
  144. package/packages/updater/src/version.ts +2 -31
  145. package/packages/utility/package.json +2 -4
  146. package/packages/utility/src/commands.ts +4 -5
  147. package/packages/utility/src/herdr-sync.ts +136 -0
  148. package/packages/utility/src/index.ts +11 -68
  149. package/packages/utility/src/lifecycle/cleanup.ts +2 -16
  150. package/packages/utility/src/{diff/settings.ts → settings.ts} +42 -80
  151. package/packages/utility/src/tools/env.ts +0 -0
  152. package/packages/utility/src/tui/name-badge-state.ts +18 -2
  153. package/packages/utility/src/tui/name-badge.ts +5 -4
  154. package/packages/utility/src/tui/util-settings-tui.ts +41 -224
  155. package/packages/web-api/package.json +2 -6
  156. package/packages/web-api/src/engine/dependencies.ts +13 -134
  157. package/packages/web-api/src/engine/extract.ts +9 -99
  158. package/packages/web-api/src/engine/format.ts +0 -75
  159. package/packages/web-api/src/engine/types.ts +0 -39
  160. package/packages/web-api/src/index.ts +1 -2
  161. package/packages/web-api/src/providers/base.ts +0 -10
  162. package/packages/web-api/src/providers/duckduckgo.ts +0 -1
  163. package/packages/web-api/src/providers/firecrawl.ts +0 -9
  164. package/packages/web-api/src/providers/jina-reader.ts +0 -9
  165. package/packages/web-api/src/providers/jina-search.ts +0 -9
  166. package/packages/web-api/src/providers/perplexity.ts +0 -9
  167. package/packages/web-api/src/providers/registry.ts +0 -80
  168. package/packages/web-api/src/providers/serpapi.ts +0 -9
  169. package/packages/web-api/src/providers/tavily.ts +0 -9
  170. package/packages/web-api/src/providers/wigolo-client.ts +0 -30
  171. package/packages/web-api/src/providers/wigolo.ts +0 -1
  172. package/packages/web-api/src/settings.ts +17 -77
  173. package/packages/web-api/src/tools.ts +21 -24
  174. package/packages/web-api/src/tui/settings-dialog.ts +0 -1
  175. package/packages/workflow/commands.ts +38 -122
  176. package/packages/workflow/index.ts +1 -5
  177. package/packages/workflow/package.json +2 -2
  178. package/packages/cocoindex/README.md +0 -94
  179. package/packages/cocoindex/bridge.ts +0 -842
  180. package/packages/cocoindex/commands.ts +0 -175
  181. package/packages/cocoindex/index.ts +0 -48
  182. package/packages/cocoindex/installer.ts +0 -397
  183. package/packages/cocoindex/package.json +0 -49
  184. package/packages/cocoindex/skills/cocoindex/SKILL.md +0 -88
  185. package/packages/cocoindex/tools.ts +0 -157
  186. package/packages/compactor/src/display/diff-renderer.ts +0 -281
  187. package/packages/compactor/src/display/line-width-safety.ts +0 -28
  188. package/packages/compactor/src/display/render-utils.ts +0 -52
  189. package/packages/compactor/src/display/thinking-label.ts +0 -18
  190. package/packages/compactor/src/display/tool-overrides.ts +0 -136
  191. package/packages/compactor/src/tools/compact.ts +0 -20
  192. package/packages/subagents/src/file-lock.ts +0 -102
  193. package/packages/utility/src/cache/ttl-cache.ts +0 -311
  194. package/packages/utility/src/diff/highlighter.ts +0 -352
  195. package/packages/utility/src/diff/parser.ts +0 -191
  196. package/packages/utility/src/diff/renderer.ts +0 -395
  197. package/packages/utility/src/diff/theme.ts +0 -316
  198. package/packages/utility/src/diff/wrapper.ts +0 -339
  199. package/packages/utility/src/display/capabilities.ts +0 -214
  200. package/packages/utility/src/display/width.ts +0 -226
  201. package/packages/utility/src/tools/batch.ts +0 -229
  202. package/packages/utility/src/tui/badge-settings-tui.ts +0 -388
  203. package/packages/utility/src/tui/badge-settings.ts +0 -67
  204. package/packages/utility/src/tui/settings-inspector.ts +0 -303
  205. package/packages/web-api/src/providers/llm-summarize.ts +0 -71
@@ -0,0 +1,260 @@
1
+ /**
2
+ * @pi-unipi/updater — Shared List+Detail TUI Overlay
3
+ *
4
+ * Parameterized overlay for browse-a-list-then-view-detail patterns.
5
+ * Used by both the changelog and readme browsers.
6
+ */
7
+
8
+ import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import type { Theme } from "@earendil-works/pi-coding-agent";
10
+ import { boxInnerWidth } from "@pi-unipi/core";
11
+
12
+ /** Pad content to exact visible width. */
13
+ function padVisible(content: string, targetWidth: number): string {
14
+ const vw = visibleWidth(content);
15
+ const pad = Math.max(0, targetWidth - vw);
16
+ return content + " ".repeat(pad);
17
+ }
18
+
19
+ /** Configuration for a list+detail overlay. */
20
+ export interface ListDetailConfig<T> {
21
+ /** Header title (e.g. " 📋 Changelog ") */
22
+ title: string;
23
+ /** Message when list is empty */
24
+ emptyMessage: string;
25
+ /** Footer text in list view */
26
+ listFooter: string;
27
+ /** Footer text in detail view */
28
+ detailFooter: string;
29
+ /** Load list entries lazily on first render */
30
+ loadEntries: () => T[];
31
+ /** Render a single list item line (without the │ borders) */
32
+ renderItem: (entry: T, selected: boolean, theme: Theme) => string;
33
+ /** Render the detail title line for an entry */
34
+ renderDetailTitle: (entry: T, theme: Theme) => string;
35
+ /** Render the detail body lines for an entry */
36
+ renderDetailBody: (entry: T, innerWidth: number, theme: Theme) => string[];
37
+ /** Optional: if set, Enter in list view calls this instead of default detail switch.
38
+ * Return true to switch to detail view, false to stay. */
39
+ onEnter?: (entry: T, tui: import("@earendil-works/pi-tui").TUI, theme: Theme) => boolean;
40
+ /** Optional: if set, Esc from detail view closes the overlay instead of returning to list */
41
+ closeOnDetailBack?: boolean;
42
+ /** Optional: open directly to a specific entry's detail view */
43
+ openDirectIndex?: number;
44
+ }
45
+
46
+ type View = "list" | "detail";
47
+
48
+ /**
49
+ * Create a list+detail overlay component.
50
+ * Returns the ctx.ui.custom() callback.
51
+ */
52
+ export function createListDetailOverlay<T>(
53
+ config: ListDetailConfig<T>,
54
+ ): (
55
+ tui: import("@earendil-works/pi-tui").TUI,
56
+ theme: Theme,
57
+ _kb: import("@earendil-works/pi-coding-agent").KeybindingsManager,
58
+ done: (result: { viewed: boolean } | null) => void,
59
+ ) => { render: (width: number) => string[]; handleInput: (data: string) => void; invalidate: () => void; focused: boolean } {
60
+ return (tui, theme, _kb, done) => {
61
+ const state: {
62
+ view: View;
63
+ entries: T[];
64
+ listIndex: number;
65
+ listScroll: number;
66
+ detailScroll: number;
67
+ } = {
68
+ view: "list",
69
+ entries: [],
70
+ listIndex: 0,
71
+ listScroll: 0,
72
+ detailScroll: 0,
73
+ };
74
+
75
+ let loaded = false;
76
+ const ensureLoaded = () => {
77
+ if (loaded) return;
78
+ state.entries = config.loadEntries();
79
+ if (config.openDirectIndex !== undefined && config.openDirectIndex >= 0 && config.openDirectIndex < state.entries.length) {
80
+ state.listIndex = config.openDirectIndex;
81
+ state.view = "detail";
82
+ state.detailScroll = 0;
83
+ }
84
+ loaded = true;
85
+ };
86
+
87
+ const render = (width: number): string[] => {
88
+ ensureLoaded();
89
+
90
+ const innerWidth = boxInnerWidth(width);
91
+ const lines: string[] = [];
92
+
93
+ // ── Header ──────────────────────────────────────────────────────
94
+ lines.push(theme.fg("accent", `╭${"─".repeat(innerWidth)}╮`));
95
+ lines.push(
96
+ theme.fg("accent", "│") +
97
+ padVisible(theme.fg("accent", theme.bold(config.title)), innerWidth) +
98
+ theme.fg("accent", "│"),
99
+ );
100
+ lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
101
+
102
+ // ── Content ─────────────────────────────────────────────────────
103
+ if (state.view === "list") {
104
+ renderListView(lines, innerWidth);
105
+ } else {
106
+ renderDetailView(lines, innerWidth);
107
+ }
108
+
109
+ // ── Footer ──────────────────────────────────────────────────────
110
+ lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
111
+ const footer = state.view === "list" ? config.listFooter : config.detailFooter;
112
+ lines.push(
113
+ theme.fg("accent", "│") +
114
+ padVisible(truncateToWidth(footer, innerWidth), innerWidth) +
115
+ theme.fg("accent", "│"),
116
+ );
117
+ lines.push(theme.fg("accent", `╰${"─".repeat(innerWidth)}╯`));
118
+
119
+ return lines;
120
+ };
121
+
122
+ const renderListView = (lines: string[], innerWidth: number) => {
123
+ if (state.entries.length === 0) {
124
+ lines.push(
125
+ theme.fg("accent", "│") +
126
+ padVisible(theme.fg("muted", ` ${config.emptyMessage}`), innerWidth) +
127
+ theme.fg("accent", "│"),
128
+ );
129
+ return;
130
+ }
131
+
132
+ state.listIndex = Math.min(state.listIndex, state.entries.length - 1);
133
+ state.listIndex = Math.max(0, state.listIndex);
134
+
135
+ const maxLines = 20;
136
+ if (state.listIndex < state.listScroll) state.listScroll = state.listIndex;
137
+ if (state.listIndex >= state.listScroll + maxLines) {
138
+ state.listScroll = state.listIndex - maxLines + 1;
139
+ }
140
+
141
+ const visible = state.entries.slice(state.listScroll, state.listScroll + maxLines);
142
+
143
+ for (let i = 0; i < visible.length; i++) {
144
+ const entry = visible[i]!;
145
+ const globalIdx = state.listScroll + i;
146
+ const selected = globalIdx === state.listIndex;
147
+ const line = config.renderItem(entry, selected, theme);
148
+ lines.push(
149
+ theme.fg("accent", "│") +
150
+ padVisible(
151
+ selected ? theme.bg("selectedBg", truncateToWidth(line, innerWidth)) : truncateToWidth(line, innerWidth),
152
+ innerWidth,
153
+ ) +
154
+ theme.fg("accent", "│"),
155
+ );
156
+ }
157
+ };
158
+
159
+ const renderDetailView = (lines: string[], innerWidth: number) => {
160
+ const entry = state.entries[state.listIndex];
161
+ if (!entry) {
162
+ lines.push(
163
+ theme.fg("accent", "│") +
164
+ padVisible(theme.fg("muted", " No entry selected."), innerWidth) +
165
+ theme.fg("accent", "│"),
166
+ );
167
+ return;
168
+ }
169
+
170
+ const title = config.renderDetailTitle(entry, theme);
171
+ lines.push(
172
+ theme.fg("accent", "│") +
173
+ padVisible(truncateToWidth(` ${title}`, innerWidth), innerWidth) +
174
+ theme.fg("accent", "│"),
175
+ );
176
+ lines.push(
177
+ theme.fg("accent", "│") +
178
+ padVisible("", innerWidth) +
179
+ theme.fg("accent", "│"),
180
+ );
181
+
182
+ const bodyLines = config.renderDetailBody(entry, innerWidth, theme);
183
+ const maxScroll = Math.max(0, bodyLines.length - 15);
184
+ state.detailScroll = Math.min(state.detailScroll, maxScroll);
185
+ state.detailScroll = Math.max(0, state.detailScroll);
186
+
187
+ const visible = bodyLines.slice(state.detailScroll, state.detailScroll + 15);
188
+ for (const line of visible) {
189
+ lines.push(
190
+ theme.fg("accent", "│") +
191
+ padVisible(truncateToWidth(` ${line}`, innerWidth), innerWidth) +
192
+ theme.fg("accent", "│"),
193
+ );
194
+ }
195
+ };
196
+
197
+ const handleInput = (data: string) => {
198
+ ensureLoaded();
199
+
200
+ // Close from list view
201
+ if ((matchesKey(data, Key.escape) || data === "q") && state.view === "list") {
202
+ done({ viewed: true });
203
+ return;
204
+ }
205
+
206
+ // Back from detail view
207
+ if ((matchesKey(data, Key.escape) || data === "q") && state.view === "detail") {
208
+ if (config.closeOnDetailBack) {
209
+ done({ viewed: true });
210
+ return;
211
+ }
212
+ state.view = "list";
213
+ state.detailScroll = 0;
214
+ tui.requestRender();
215
+ return;
216
+ }
217
+
218
+ // Navigation
219
+ if (state.view === "list") {
220
+ if (matchesKey(data, Key.down) || data === "j") {
221
+ state.listIndex = Math.min(state.listIndex + 1, state.entries.length - 1);
222
+ } else if (matchesKey(data, Key.up) || data === "k") {
223
+ state.listIndex = Math.max(state.listIndex - 1, 0);
224
+ } else if (matchesKey(data, Key.enter)) {
225
+ if (state.entries.length > 0) {
226
+ if (config.onEnter) {
227
+ const entry = state.entries[state.listIndex]!;
228
+ const shouldSwitch = config.onEnter(entry, tui, theme);
229
+ if (shouldSwitch) {
230
+ state.view = "detail";
231
+ state.detailScroll = 0;
232
+ }
233
+ } else {
234
+ state.view = "detail";
235
+ state.detailScroll = 0;
236
+ }
237
+ }
238
+ } else if (data === "g") {
239
+ state.listIndex = 0;
240
+ } else if (data === "G") {
241
+ state.listIndex = state.entries.length - 1;
242
+ }
243
+ } else {
244
+ if (matchesKey(data, Key.down) || data === "j") {
245
+ state.detailScroll++;
246
+ } else if (matchesKey(data, Key.up) || data === "k") {
247
+ state.detailScroll = Math.max(0, state.detailScroll - 1);
248
+ } else if (data === "g") {
249
+ state.detailScroll = 0;
250
+ } else if (data === "G") {
251
+ state.detailScroll = 999999;
252
+ }
253
+ }
254
+
255
+ tui.requestRender();
256
+ };
257
+
258
+ return { render, handleInput, invalidate: () => {}, focused: true };
259
+ };
260
+ }
@@ -7,231 +7,74 @@
7
7
  */
8
8
 
9
9
  import { readFileSync } from "fs";
10
- import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
11
  import { discoverReadmes, resolveReadmePath } from "../readme.js";
13
12
  import { renderMarkdown } from "../markdown.js";
13
+ import { createListDetailOverlay } from "./list-detail-overlay.js";
14
14
  import type { ReadmeEntry } from "../../types.js";
15
- import { boxInnerWidth } from "@pi-unipi/core";
16
15
 
17
- type View = "list" | "content";
18
-
19
- interface ReadmeState {
20
- view: View;
21
- entries: ReadmeEntry[];
22
- listIndex: number;
23
- listScroll: number;
24
- contentScroll: number;
25
- contentLines: string[];
26
- }
27
-
28
- /**
29
- * Pad content to exact visible width.
30
- */
31
- function padVisible(content: string, targetWidth: number): string {
32
- const vw = visibleWidth(content);
33
- const pad = Math.max(0, targetWidth - vw);
34
- return content + " ".repeat(pad);
16
+ interface ReadmeParams {
17
+ openDirect?: string;
35
18
  }
36
19
 
37
20
  /**
38
21
  * Render the readme overlay.
39
22
  */
40
- export function renderReadmeOverlay(params?: { openDirect?: string }) {
41
- return (
42
- tui: import("@earendil-works/pi-tui").TUI,
43
- theme: Theme,
44
- _kb: import("@earendil-works/pi-coding-agent").KeybindingsManager,
45
- done: (result: { viewed: boolean } | null) => void,
46
- ) => {
47
- const state: ReadmeState = {
48
- view: "list",
49
- entries: [],
50
- listIndex: 0,
51
- listScroll: 0,
52
- contentScroll: 0,
53
- contentLines: [],
54
- };
55
-
56
- let loaded = false;
57
- const ensureLoaded = () => {
58
- if (loaded) return;
59
- state.entries = discoverReadmes();
60
-
23
+ export function renderReadmeOverlay(params?: ReadmeParams) {
24
+ // Cache for rendered markdown content (keyed by entry path)
25
+ const contentCache = new Map<string, string[]>();
26
+ let openDirectIndex: number | undefined;
27
+
28
+ return createListDetailOverlay<ReadmeEntry>({
29
+ title: " 📖 README Browser ",
30
+ emptyMessage: "No README files found.",
31
+ listFooter: " j/k navigate Enter read q/Esc close",
32
+ detailFooter: " j/k scroll q/Esc back to list",
33
+
34
+ loadEntries: () => {
35
+ const entries = discoverReadmes();
61
36
  if (params?.openDirect) {
62
37
  const readmePath = resolveReadmePath(params.openDirect);
63
38
  if (readmePath) {
64
- const content = readFileSync(readmePath, "utf-8");
65
- state.contentLines = renderMarkdown(content, (tui.terminal?.columns ?? 80) - 4, theme);
66
- state.view = "content";
39
+ const idx = entries.findIndex((e) => e.path === readmePath);
40
+ if (idx >= 0) {
41
+ openDirectIndex = idx;
42
+ // Pre-load the content
43
+ const content = readFileSync(readmePath, "utf-8");
44
+ const lines = renderMarkdown(content, (process.stdout.columns ?? 80) - 4, undefined as any);
45
+ contentCache.set(readmePath, lines);
46
+ }
67
47
  }
68
48
  }
69
- loaded = true;
70
- };
71
-
72
- const render = (width: number): string[] => {
73
- ensureLoaded();
74
-
75
- const innerWidth = boxInnerWidth(width);
76
- const lines: string[] = [];
77
-
78
- // ── Header ──────────────────────────────────────────────────────
79
- lines.push(theme.fg("accent", `╭${"─".repeat(innerWidth)}╮`));
80
- lines.push(
81
- theme.fg("accent", "│") +
82
- padVisible(theme.fg("accent", theme.bold(" 📖 README Browser ")), innerWidth) +
83
- theme.fg("accent", "│"),
84
- );
85
- lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
86
-
87
- // ── Content ─────────────────────────────────────────────────────
88
- if (state.view === "list") {
89
- renderListView(lines, innerWidth);
90
- } else {
91
- renderContentView(lines, innerWidth);
92
- }
93
-
94
- // ── Footer ──────────────────────────────────────────────────────
95
- lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
96
- const footer =
97
- state.view === "list"
98
- ? ` ${theme.fg("accent", "j/k")} navigate ${theme.fg("success", "Enter")} read ${theme.fg("error", "q/Esc")} close`
99
- : ` ${theme.fg("accent", "j/k")} scroll ${theme.fg("error", "q/Esc")} back to list`;
100
- lines.push(
101
- theme.fg("accent", "│") +
102
- padVisible(truncateToWidth(footer, innerWidth), innerWidth) +
103
- theme.fg("accent", "│"),
104
- );
105
- lines.push(theme.fg("accent", `╰${"─".repeat(innerWidth)}╯`));
106
-
107
- return lines;
108
- };
109
-
110
- const renderListView = (lines: string[], innerWidth: number) => {
111
- if (state.entries.length === 0) {
112
- lines.push(
113
- theme.fg("accent", "│") +
114
- padVisible(theme.fg("muted", " No README files found."), innerWidth) +
115
- theme.fg("accent", "│"),
116
- );
117
- return;
118
- }
119
-
120
- state.listIndex = Math.min(state.listIndex, state.entries.length - 1);
121
- state.listIndex = Math.max(0, state.listIndex);
122
-
123
- const maxLines = 20;
124
- if (state.listIndex < state.listScroll) state.listScroll = state.listIndex;
125
- if (state.listIndex >= state.listScroll + maxLines) {
126
- state.listScroll = state.listIndex - maxLines + 1;
127
- }
128
-
129
- const visible = state.entries.slice(state.listScroll, state.listScroll + maxLines);
130
-
131
- for (let i = 0; i < visible.length; i++) {
132
- const entry = visible[i]!;
133
- const globalIdx = state.listScroll + i;
134
- const selected = globalIdx === state.listIndex;
135
- const prefix = selected ? theme.fg("accent", "▸ ") : " ";
136
-
137
- const name = selected ? theme.bold(entry.name) : theme.fg("text", entry.name);
138
- const version = theme.fg("muted", `v${entry.version}`);
139
- const line = ` ${prefix}${name} ${version}`;
140
- lines.push(
141
- theme.fg("accent", "│") +
142
- padVisible(
143
- selected ? theme.bg("selectedBg", truncateToWidth(line, innerWidth)) : truncateToWidth(line, innerWidth),
144
- innerWidth,
145
- ) +
146
- theme.fg("accent", "│"),
147
- );
148
- }
149
- };
150
-
151
- const renderContentView = (lines: string[], innerWidth: number) => {
152
- if (state.contentLines.length === 0) {
153
- lines.push(
154
- theme.fg("accent", "│") +
155
- padVisible(theme.fg("muted", " No content available."), innerWidth) +
156
- theme.fg("accent", "│"),
157
- );
158
- return;
159
- }
160
-
161
- const maxLines = 20;
162
- const maxScroll = Math.max(0, state.contentLines.length - maxLines);
163
- state.contentScroll = Math.min(state.contentScroll, maxScroll);
164
- state.contentScroll = Math.max(0, state.contentScroll);
165
-
166
- const visible = state.contentLines.slice(state.contentScroll, state.contentScroll + maxLines);
167
- for (const line of visible) {
168
- lines.push(
169
- theme.fg("accent", "│") +
170
- padVisible(truncateToWidth(` ${line}`, innerWidth), innerWidth) +
171
- theme.fg("accent", "│"),
172
- );
173
- }
174
- };
175
-
176
- const handleInput = (data: string) => {
177
- ensureLoaded();
178
-
179
- // Close from list view
180
- if ((matchesKey(data, Key.escape) || data === "q") && state.view === "list") {
181
- done({ viewed: true });
182
- return;
49
+ return entries;
50
+ },
51
+
52
+ openDirectIndex,
53
+ closeOnDetailBack: !!params?.openDirect,
54
+
55
+ renderItem: (entry, selected, theme) => {
56
+ const name = selected ? theme.bold(entry.name) : theme.fg("text", entry.name);
57
+ const version = theme.fg("muted", `v${entry.version}`);
58
+ const prefix = selected ? theme.fg("accent", "▸ ") : " ";
59
+ return ` ${prefix}${name} ${version}`;
60
+ },
61
+
62
+ renderDetailTitle: (entry, theme) =>
63
+ `${theme.bold(entry.name)} — ${theme.fg("muted", `v${entry.version}`)}`,
64
+
65
+ renderDetailBody: (entry, _innerWidth, theme) => {
66
+ // Return cached content if available
67
+ if (contentCache.has(entry.path)) {
68
+ return contentCache.get(entry.path)!;
183
69
  }
184
-
185
- // Back from content view
186
- if ((matchesKey(data, Key.escape) || data === "q") && state.view === "content") {
187
- if (params?.openDirect) {
188
- done({ viewed: true });
189
- return;
190
- }
191
- state.view = "list";
192
- state.contentScroll = 0;
193
- tui.requestRender();
194
- return;
195
- }
196
-
197
- if (state.view === "list") {
198
- if (matchesKey(data, Key.down) || data === "j") {
199
- state.listIndex = Math.min(state.listIndex + 1, state.entries.length - 1);
200
- } else if (matchesKey(data, Key.up) || data === "k") {
201
- state.listIndex = Math.max(state.listIndex - 1, 0);
202
- } else if (matchesKey(data, Key.enter)) {
203
- if (state.entries.length > 0) {
204
- const entry = state.entries[state.listIndex]!;
205
- try {
206
- const content = readFileSync(entry.path, "utf-8");
207
- state.contentLines = renderMarkdown(content, (tui.terminal?.columns ?? 80) - 4, theme);
208
- state.contentScroll = 0;
209
- state.view = "content";
210
- } catch {
211
- state.contentLines = [" Error reading README file."];
212
- state.view = "content";
213
- }
214
- }
215
- } else if (data === "g") {
216
- state.listIndex = 0;
217
- } else if (data === "G") {
218
- state.listIndex = state.entries.length - 1;
219
- }
220
- } else {
221
- if (matchesKey(data, Key.down) || data === "j") {
222
- state.contentScroll++;
223
- } else if (matchesKey(data, Key.up) || data === "k") {
224
- state.contentScroll = Math.max(0, state.contentScroll - 1);
225
- } else if (data === "g") {
226
- state.contentScroll = 0;
227
- } else if (data === "G") {
228
- state.contentScroll = 999999;
229
- }
70
+ try {
71
+ const content = readFileSync(entry.path, "utf-8");
72
+ const lines = renderMarkdown(content, (process.stdout.columns ?? 80) - 4, theme);
73
+ contentCache.set(entry.path, lines);
74
+ return lines;
75
+ } catch {
76
+ return [" Error reading README file."];
230
77
  }
231
-
232
- tui.requestRender();
233
- };
234
-
235
- return { render, handleInput, invalidate: () => {}, focused: true };
236
- };
78
+ },
79
+ });
237
80
  }
@@ -5,6 +5,7 @@
5
5
  * Space cycles options, Enter saves, Esc cancels.
6
6
  */
7
7
 
8
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
8
9
  import {
9
10
  loadConfig,
10
11
  saveConfig,
@@ -22,21 +23,6 @@ const TEAL = `${ESC}[36m`;
22
23
  const GREEN = `${ESC}[32m`;
23
24
  const RESET = `${ESC}[0m`;
24
25
 
25
- /** Truncate string to visible width */
26
- function trunc(text: string, width: number): string {
27
- let vw = 0;
28
- let result = "";
29
- let inEsc = false;
30
- for (const ch of text) {
31
- if (ch === "\x1b") { inEsc = true; result += ch; continue; }
32
- if (inEsc) { result += ch; if (ch === "m") inEsc = false; continue; }
33
- if (vw >= width) break;
34
- result += ch;
35
- vw++;
36
- }
37
- return result;
38
- }
39
-
40
26
  /**
41
27
  * Render the settings overlay.
42
28
  */
@@ -58,7 +44,7 @@ export function renderSettingsOverlay() {
58
44
  const render = (width: number): string[] => {
59
45
  const lines: string[] = [];
60
46
 
61
- lines.push(trunc(` ${BOLD}⚙ Updater Settings${RESET}`, width));
47
+ lines.push(truncateToWidth(` ${BOLD}⚙ Updater Settings${RESET}`, width));
62
48
  lines.push("─".repeat(width));
63
49
  lines.push("");
64
50
 
@@ -67,7 +53,7 @@ export function renderSettingsOverlay() {
67
53
  const row0Selected = state.row === 0;
68
54
  const row0Prefix = row0Selected ? `${TEAL}▸${RESET} ` : " ";
69
55
  lines.push(
70
- trunc(
56
+ truncateToWidth(
71
57
  ` ${row0Prefix}${BOLD}Check Interval${RESET} ${DIM}${intervalLabel}${RESET}`,
72
58
  width,
73
59
  ),
@@ -81,7 +67,7 @@ export function renderSettingsOverlay() {
81
67
  : `${DIM}○ ${opt.label}${RESET}`;
82
68
  })
83
69
  .join(" ");
84
- lines.push(trunc(` ${intervalLine}`, width));
70
+ lines.push(truncateToWidth(` ${intervalLine}`, width));
85
71
  lines.push("");
86
72
 
87
73
  // Row 1: Auto Update
@@ -89,7 +75,7 @@ export function renderSettingsOverlay() {
89
75
  const row1Selected = state.row === 1;
90
76
  const row1Prefix = row1Selected ? `${TEAL}▸${RESET} ` : " ";
91
77
  lines.push(
92
- trunc(
78
+ truncateToWidth(
93
79
  ` ${row1Prefix}${BOLD}Auto Update${RESET} ${DIM}${modeLabel}${RESET}`,
94
80
  width,
95
81
  ),
@@ -103,12 +89,12 @@ export function renderSettingsOverlay() {
103
89
  : `${DIM}○ ${mode}${RESET}`;
104
90
  })
105
91
  .join(" ");
106
- lines.push(trunc(` ${modeLine}`, width));
92
+ lines.push(truncateToWidth(` ${modeLine}`, width));
107
93
  lines.push("");
108
94
 
109
95
  lines.push("─".repeat(width));
110
96
  lines.push(
111
- trunc(
97
+ truncateToWidth(
112
98
  ` j/k: navigate Space: cycle ${GREEN}Enter: save${RESET} ${DIM}Esc: cancel${RESET}`,
113
99
  width,
114
100
  ),
@@ -119,9 +105,10 @@ export function renderSettingsOverlay() {
119
105
 
120
106
  const handleInput = (data: string) => {
121
107
  const key = data.toLowerCase();
108
+ const keyRaw = data;
122
109
 
123
110
  // Close without saving
124
- if (key === "\x1b") {
111
+ if (matchesKey(keyRaw, Key.escape)) {
125
112
  done({ saved: false });
126
113
  return;
127
114
  }
@@ -134,9 +121,9 @@ export function renderSettingsOverlay() {
134
121
  }
135
122
 
136
123
  // Navigate rows
137
- if (key === "j" || key === "\x1b[B") {
124
+ if (key === "j" || matchesKey(keyRaw, Key.down)) {
138
125
  state.row = Math.min(state.row + 1, 1);
139
- } else if (key === "k" || key === "\x1b[A") {
126
+ } else if (key === "k" || matchesKey(keyRaw, Key.up)) {
140
127
  state.row = Math.max(state.row - 1, 0);
141
128
  }
142
129
 
@@ -156,7 +143,7 @@ export function renderSettingsOverlay() {
156
143
  }
157
144
 
158
145
  // Cycle with left/right
159
- if (key === "h" || key === "\x1b[D") {
146
+ if (key === "h" || matchesKey(keyRaw, Key.left)) {
160
147
  if (state.row === 0) {
161
148
  const currentIdx = intervalOptions.findIndex(
162
149
  (opt) => opt.ms === state.config.checkIntervalMs,
@@ -169,7 +156,7 @@ export function renderSettingsOverlay() {
169
156
  state.config.autoUpdate = modeOptions[prevIdx];
170
157
  }
171
158
  }
172
- if (key === "l" || key === "\x1b[C") {
159
+ if (key === "l" || matchesKey(keyRaw, Key.right)) {
173
160
  if (state.row === 0) {
174
161
  const currentIdx = intervalOptions.findIndex(
175
162
  (opt) => opt.ms === state.config.checkIntervalMs,