@gavin-hu/my-pi-agent 0.14.0 → 0.15.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 (229) hide show
  1. package/README.md +54 -34
  2. package/README.zh-CN.md +44 -28
  3. package/extensions/bytefly/README.md +193 -140
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/register.ts +24 -5
  7. package/extensions/bytefly/doctor/tools.ts +4 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  9. package/extensions/bytefly/footer/config.ts +10 -6
  10. package/extensions/bytefly/footer/price/price.ts +5 -3
  11. package/extensions/bytefly/footer/price/rates.ts +63 -26
  12. package/extensions/bytefly/footer/render/status.ts +3 -3
  13. package/extensions/bytefly/header/component.ts +3 -2
  14. package/extensions/bytefly/index.ts +3 -3
  15. package/extensions/bytefly/notice/component.ts +8 -3
  16. package/extensions/bytefly/notify/config.ts +6 -11
  17. package/extensions/bytefly/notify/dispatch.ts +13 -5
  18. package/extensions/bytefly/panel/commands.ts +21 -1
  19. package/extensions/bytefly/panel/format.ts +5 -2
  20. package/extensions/bytefly/panel/settings-runtime.ts +26 -6
  21. package/extensions/bytefly/panel/settings.ts +41 -4
  22. package/extensions/bytefly/panel/themes.ts +32 -0
  23. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  24. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  25. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  26. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  27. package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
  28. package/extensions/bytefly/panel/tui.ts +94 -152
  29. package/extensions/bytefly/picker/discovery.ts +1 -0
  30. package/extensions/bytefly/picker/types.ts +2 -0
  31. package/extensions/bytefly/separator/component.ts +14 -4
  32. package/extensions/bytefly/update/cache.ts +58 -19
  33. package/extensions/bytefly/update/check.ts +35 -0
  34. package/extensions/bytefly/update/format.ts +76 -35
  35. package/extensions/bytefly/update/register.ts +144 -55
  36. package/extensions/bytefly/update/state.ts +4 -2
  37. package/extensions/bytefly/update/suppress.ts +66 -0
  38. package/extensions/bytefly/update/types.ts +15 -0
  39. package/extensions/check/README.md +253 -0
  40. package/extensions/check/commands.ts +128 -0
  41. package/extensions/check/config.ts +39 -0
  42. package/extensions/check/detect.ts +269 -0
  43. package/extensions/check/failures.ts +230 -0
  44. package/extensions/check/format.ts +135 -0
  45. package/extensions/check/index.ts +26 -0
  46. package/extensions/check/render.ts +185 -0
  47. package/extensions/check/runner.ts +176 -0
  48. package/extensions/check/runtime.ts +244 -0
  49. package/extensions/check/source.ts +80 -0
  50. package/extensions/check/tools.ts +173 -0
  51. package/extensions/check/tui.ts +195 -0
  52. package/extensions/check/types.ts +99 -0
  53. package/extensions/document/README.md +1 -1
  54. package/extensions/document/extract/pdf.ts +8 -16
  55. package/extensions/document/formats.ts +2 -2
  56. package/extensions/file-browse/README.md +126 -37
  57. package/extensions/file-browse/client.ts +98 -33
  58. package/extensions/file-browse/config.ts +24 -9
  59. package/extensions/file-browse/files.ts +183 -48
  60. package/extensions/file-browse/fuzzy.ts +65 -11
  61. package/extensions/file-browse/git.ts +37 -47
  62. package/extensions/file-browse/html.ts +97 -171
  63. package/extensions/file-browse/live-client.ts +158 -0
  64. package/extensions/file-browse/live.ts +222 -0
  65. package/extensions/file-browse/markdown.ts +110 -92
  66. package/extensions/file-browse/raw-html.ts +478 -0
  67. package/extensions/file-browse/router.ts +65 -78
  68. package/extensions/file-browse/server.ts +75 -59
  69. package/extensions/file-browse/styles.ts +124 -0
  70. package/extensions/file-browse/urls.ts +80 -0
  71. package/extensions/goal/README.md +103 -47
  72. package/extensions/goal/clarify-parse.ts +4 -9
  73. package/extensions/goal/clarify.ts +27 -4
  74. package/extensions/goal/commands.ts +24 -11
  75. package/extensions/goal/format.ts +41 -10
  76. package/extensions/goal/index.ts +39 -16
  77. package/extensions/goal/plan-route.ts +51 -0
  78. package/extensions/goal/schema.ts +6 -23
  79. package/extensions/goal/state.ts +39 -57
  80. package/extensions/goal/tools.ts +26 -20
  81. package/extensions/goal/types.ts +11 -1
  82. package/extensions/job/README.md +20 -6
  83. package/extensions/job/format.ts +18 -68
  84. package/extensions/job/index.ts +26 -41
  85. package/extensions/job/log-writer.ts +208 -0
  86. package/extensions/job/logs.ts +3 -2
  87. package/extensions/job/process.ts +6 -99
  88. package/extensions/job/runtime.ts +42 -181
  89. package/extensions/job/schema.ts +1 -4
  90. package/extensions/job/tools.ts +1 -15
  91. package/extensions/job/tui.ts +117 -307
  92. package/extensions/job/ui.ts +7 -33
  93. package/extensions/memory/README.md +1 -1
  94. package/extensions/memory/errors.ts +5 -6
  95. package/extensions/memory/tui.ts +14 -24
  96. package/extensions/plan/README.md +94 -18
  97. package/extensions/plan/commands.ts +9 -5
  98. package/extensions/plan/format.ts +87 -0
  99. package/extensions/plan/index.ts +31 -15
  100. package/extensions/plan/plans.ts +2 -3
  101. package/extensions/plan/policy.ts +0 -6
  102. package/extensions/plan/runtime.ts +4 -2
  103. package/extensions/plan/steps.ts +37 -8
  104. package/extensions/plan/todos.ts +44 -3
  105. package/extensions/plan/tools/enter.ts +94 -0
  106. package/extensions/plan/tools/exit.ts +296 -0
  107. package/extensions/plan/tools/nested.ts +21 -0
  108. package/extensions/plan/tools/steps.ts +136 -0
  109. package/extensions/plan/tools/todo-seed.ts +76 -0
  110. package/extensions/plan/tools/write.ts +127 -0
  111. package/extensions/plan/tools.ts +18 -703
  112. package/extensions/plan/web-client.ts +6 -22
  113. package/extensions/plan/web-page.ts +19 -30
  114. package/extensions/plan/web-router.ts +32 -79
  115. package/extensions/plan/web.ts +21 -42
  116. package/extensions/question/answers.ts +3 -8
  117. package/extensions/question/tui.ts +6 -6
  118. package/extensions/rewind/README.md +95 -22
  119. package/extensions/rewind/checklist.ts +192 -0
  120. package/extensions/rewind/commands.ts +8 -0
  121. package/extensions/rewind/config.ts +3 -11
  122. package/extensions/rewind/format.ts +220 -2
  123. package/extensions/rewind/git.ts +101 -21
  124. package/extensions/rewind/hunk-picker.ts +114 -0
  125. package/extensions/rewind/patch.ts +88 -0
  126. package/extensions/rewind/path-picker.ts +67 -100
  127. package/extensions/rewind/paths.ts +93 -19
  128. package/extensions/rewind/restore.ts +53 -10
  129. package/extensions/rewind/review-tui.ts +320 -0
  130. package/extensions/rewind/review.ts +397 -0
  131. package/extensions/rewind/rewind-tui.ts +4 -3
  132. package/extensions/rewind/runtime.ts +94 -17
  133. package/extensions/rewind/snapshot.ts +13 -4
  134. package/extensions/subagent/README.md +61 -6
  135. package/extensions/subagent/background-tui.ts +208 -0
  136. package/extensions/subagent/background.ts +470 -0
  137. package/extensions/subagent/commands.ts +80 -0
  138. package/extensions/subagent/config.ts +4 -0
  139. package/extensions/subagent/format.ts +24 -1
  140. package/extensions/subagent/index.ts +91 -2
  141. package/extensions/subagent/render.ts +13 -0
  142. package/extensions/subagent/run.ts +17 -16
  143. package/extensions/subagent/schema.ts +12 -0
  144. package/extensions/subagent/types.ts +11 -0
  145. package/extensions/subagent/ui.ts +50 -0
  146. package/extensions/todo/README.md +18 -13
  147. package/extensions/todo/format.ts +3 -2
  148. package/extensions/todo/runtime.ts +3 -2
  149. package/extensions/todo/schema.ts +8 -84
  150. package/extensions/todo/state.ts +5 -46
  151. package/extensions/todo/tools.ts +2 -1
  152. package/extensions/todo/types.ts +6 -15
  153. package/extensions/watch/README.md +14 -6
  154. package/extensions/watch/child.ts +150 -0
  155. package/extensions/watch/format.ts +8 -48
  156. package/extensions/watch/index.ts +42 -58
  157. package/extensions/watch/runtime.ts +49 -128
  158. package/extensions/watch/schema.ts +1 -4
  159. package/extensions/watch/tools.ts +1 -11
  160. package/extensions/watch/tui.ts +103 -273
  161. package/extensions/watch/ui.ts +5 -32
  162. package/extensions/web-access/README.md +1 -1
  163. package/extensions/web-access/fetch/page.ts +1 -1
  164. package/extensions/web-access/fetch/tool.ts +6 -2
  165. package/extensions/web-access/search/search.ts +2 -5
  166. package/extensions/wechat/README.md +1 -1
  167. package/extensions/wechat/client.ts +23 -0
  168. package/extensions/wechat/commands.ts +57 -42
  169. package/extensions/wechat/deliver.ts +5 -9
  170. package/extensions/wechat/errors.ts +3 -6
  171. package/extensions/wechat/inbound.ts +2 -5
  172. package/extensions/wechat/index.ts +2 -8
  173. package/extensions/wechat/outbound.ts +2 -4
  174. package/extensions/wechat/peer-worker.ts +3 -8
  175. package/extensions/wechat/poll.ts +10 -9
  176. package/extensions/wechat/presence.ts +35 -62
  177. package/extensions/wechat/reply-pipeline.ts +3 -1
  178. package/extensions/wechat/status-screen.ts +1 -1
  179. package/extensions/wechat/timer.ts +57 -0
  180. package/extensions/worktree/config.ts +8 -20
  181. package/extensions/worktree/enter.ts +7 -12
  182. package/extensions/worktree/git.ts +38 -69
  183. package/extensions/worktree/index.ts +5 -4
  184. package/extensions/worktree/registry.ts +7 -7
  185. package/extensions/worktree/root-tools.ts +2 -2
  186. package/extensions/worktree/status.ts +15 -9
  187. package/lib/README.md +23 -8
  188. package/lib/config.ts +50 -3
  189. package/lib/errors.ts +12 -0
  190. package/lib/format.ts +50 -0
  191. package/lib/git/read.ts +28 -0
  192. package/lib/goal-state.ts +23 -8
  193. package/lib/http/headers.ts +74 -0
  194. package/lib/http/index.ts +13 -0
  195. package/lib/http/response.ts +46 -0
  196. package/lib/http/router.ts +52 -0
  197. package/lib/http/server.ts +109 -0
  198. package/lib/lines.ts +77 -0
  199. package/lib/pdf.ts +82 -0
  200. package/lib/plan-entry.ts +71 -0
  201. package/lib/plan-state.ts +34 -0
  202. package/lib/pool.ts +62 -0
  203. package/lib/report.ts +99 -0
  204. package/lib/requirements.ts +25 -19
  205. package/lib/spawn.ts +170 -0
  206. package/lib/todo-state.ts +110 -23
  207. package/lib/tool-names.ts +7 -0
  208. package/lib/tool-result.ts +33 -0
  209. package/lib/tui/README.md +68 -3
  210. package/lib/tui/chrome.ts +28 -4
  211. package/lib/tui/columns.ts +6 -1
  212. package/lib/tui/confirm.ts +65 -0
  213. package/lib/tui/dock-screen.ts +418 -0
  214. package/lib/tui/glyphs.ts +2 -1
  215. package/lib/tui/indent.ts +1 -1
  216. package/lib/tui/index.ts +2 -0
  217. package/lib/tui/rows.ts +21 -0
  218. package/lib/tui/status-controller.ts +35 -1
  219. package/lib/tui/tool.ts +59 -1
  220. package/lib/web/components.ts +20 -1
  221. package/lib/web/markup.ts +32 -0
  222. package/lib/web/runtime.ts +50 -0
  223. package/package.json +22 -5
  224. package/skills/init/SKILL.md +165 -0
  225. package/skills/init/references/agents-md-quality.md +103 -0
  226. package/skills/init/references/interview-questions.md +81 -0
  227. package/skills/refactor/SKILL.md +141 -0
  228. package/extensions/watch/process.ts +0 -63
  229. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { join } from "node:path";
10
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
- import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
11
+ import { sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
12
12
  import { sanitizeLine } from "../../../../lib/format.ts";
13
13
  import { clip } from "../../../../lib/tui/columns.ts";
14
14
  import { BODY_INDENT } from "../../../../lib/tui/indent.ts";
@@ -23,6 +23,34 @@ import { countsLine, INDENT, savedLine } from "./frame.ts";
23
23
  /** Pi's personal settings file, named in the detail pane. */
24
24
  const PI_SETTINGS_FILE = "settings.json";
25
25
 
26
+ /** Columns the marker and the leading cell take before the label. */
27
+ const ROW_PREFIX_WIDTH = 4;
28
+ /** Columns a value keeps when the row has to choose between the label and the value. */
29
+ const MIN_VALUE_WIDTH = 8;
30
+
31
+ /**
32
+ * The editing cell: the buffer around the caret, scrolled so the caret is always
33
+ * visible, with ellipses where text is off-screen. The cell fits `budget`
34
+ * columns, so a long buffer cannot push the label out of the row.
35
+ */
36
+ function editingCell(buffer: string, caret: number, budget: number): string {
37
+ const width = Math.max(1, budget);
38
+ const before = buffer.slice(0, caret);
39
+ const beforeWidth = visibleWidth(before);
40
+ const caretRoom = width - 1;
41
+ if (beforeWidth > caretRoom) {
42
+ // Reserve one column for the leading ellipsis; the caret sits at the right edge.
43
+ const shownWidth = Math.max(0, caretRoom - 1);
44
+ const prefix = shownWidth > 0 ? "…" : "";
45
+ const shown = shownWidth > 0 ? sliceByColumn(before, beforeWidth - shownWidth, shownWidth, true) : "";
46
+ return `${prefix}${shown}▏`;
47
+ }
48
+ const used = beforeWidth + 1;
49
+ const afterRoom = Math.max(0, width - used);
50
+ const after = afterRoom > 0 ? truncateToWidth(buffer.slice(caret), afterRoom, "…") : "";
51
+ return `${before}▏${after}`;
52
+ }
53
+
26
54
  /** The rows this section shows, and the scope they were read in. */
27
55
  export function settingsSummary(theme: Theme, width: number, scope: PanelScope, count: number): string {
28
56
  return countsLine(theme, width, settingsSummarySegments(scope, count));
@@ -36,17 +64,19 @@ export function settingRow(
36
64
  selected: boolean,
37
65
  editing?: { buffer: string; caret: number },
38
66
  ): string {
39
- // The editor shows the buffer with a caret block, in the accent so the row
40
- // reads as active rather than as a value that happens to contain a bar.
41
- const value = editing
42
- ? `${editing.buffer.slice(0, editing.caret)}▏${editing.buffer.slice(editing.caret)}`
43
- : view.valueLabel;
67
+ const label = sanitizeLine(view.label);
68
+ // Keep the whole label when it fits, and never let a long value or an editing
69
+ // 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);
71
+ const labelReserve = Math.min(visibleWidth(label), Math.max(0, available - MIN_VALUE_WIDTH));
72
+ const valueBudget = Math.max(1, available - labelReserve);
73
+ const rawValue = editing ? editingCell(editing.buffer, editing.caret, valueBudget) : view.valueLabel;
44
74
  return listRow(theme, width, {
45
75
  selected,
46
76
  leading: view.kind === "boolean" ? checkbox(theme, view.value === "true") : " ",
47
- label: sanitizeLine(view.label),
77
+ label,
48
78
  labelRole: selected ? "accent" : "label",
49
- trailing: sanitizeLine(value),
79
+ trailing: clip(sanitizeLine(rawValue), valueBudget),
50
80
  trailingRole: editing ? "accent" : "meta",
51
81
  });
52
82
  }
@@ -58,8 +88,9 @@ export function projectNote(theme: Theme, width: number): string {
58
88
 
59
89
  /** Where the focused row's value is written, and which scope supplied it. */
60
90
  function originLine(view: SettingView, paths: { scope: WriteScope; cwd: string; agentDir: string }): string {
91
+ const scope = view.origin === "inherited" ? "global" : paths.scope;
61
92
  const path =
62
- view.source === "app" ? join(paths.agentDir, PI_SETTINGS_FILE) : configPath(paths.scope, paths.cwd, paths.agentDir);
93
+ view.source === "app" ? join(paths.agentDir, PI_SETTINGS_FILE) : configPath(scope, paths.cwd, paths.agentDir);
63
94
  const file = sanitizeLine(path);
64
95
  switch (view.origin) {
65
96
  case "inherited":
@@ -90,7 +121,7 @@ export function settingDetail(options: {
90
121
  // The pending-changes marker survives the collapsed body: on a short terminal
91
122
  // the value still changed, and the screen has to say so.
92
123
  const saved = savedLine(theme, width, dirty);
93
- if (budget === 0) return [headLine, ...saved];
124
+ if (budget === 0) return saved;
94
125
 
95
126
  const lines = [headLine];
96
127
  const descriptionWidth = Math.max(1, width - BODY_INDENT * 2);
@@ -130,28 +161,36 @@ export function sectionJsonPane(options: {
130
161
  theme: Theme;
131
162
  width: number;
132
163
  section: string;
133
- view: SettingView;
134
164
  document: unknown;
135
165
  budget: number;
166
+ dirty: boolean;
136
167
  paths: { scope: WriteScope; cwd: string; agentDir: string };
137
168
  }): string[] {
138
- const { theme, width, section, view, document, budget, paths } = options;
139
- const source = paint(theme, "faint", originLine(view, paths));
169
+ const { theme, width, section, document, budget, dirty, paths } = options;
170
+ const saved = savedLine(theme, width, dirty);
171
+ if (budget === 0) return saved;
172
+ const scope = paths.scope === "global" ? "Global" : "Project";
173
+ const file = sanitizeLine(configPath(paths.scope, paths.cwd, paths.agentDir));
174
+ const source = paint(theme, "faint", `${scope} · ${file}`);
140
175
  const head = `${paint(theme, "meta", sanitizeLine(section))} ${paint(theme, "faint", "—")} ${source}`;
141
176
  const lines = [clip(`${INDENT}${head}`, width)];
142
- const room = Math.max(0, budget - lines.length);
177
+ const room = Math.max(0, budget - lines.length - saved.length);
143
178
  const body = document === undefined ? [] : JSON.stringify(document, null, 2).split("\n");
144
179
 
145
180
  if (body.length === 0) {
146
181
  if (room > 0) lines.push(clip(`${INDENT}${paint(theme, "faint", "Not set in this scope.")}`, width));
182
+ lines.push(...saved);
147
183
  return lines;
148
184
  }
149
185
 
150
186
  const shown = body.slice(0, room);
151
187
  if (body.length > room && shown.length > 0) {
152
- shown[shown.length - 1] = `… ${body.length - room} more lines`;
188
+ // The marker replaces one already-shown line, so the hidden count is one
189
+ // larger than the number of lines the slice left out.
190
+ shown[shown.length - 1] = `… ${body.length - room + 1} more lines`;
153
191
  }
154
192
  for (const line of shown) lines.push(clip(`${INDENT}${paint(theme, "faint", sanitizeLine(line))}`, width));
193
+ lines.push(...saved);
155
194
  return lines;
156
195
  }
157
196
 
@@ -17,29 +17,20 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
17
17
  import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
18
18
  import type { ViewportRowsSource } from "../../../lib/tui/chrome.ts";
19
19
  import { dockListFrame } from "../../../lib/tui/chrome.ts";
20
- import { fitRows, formatRange, isCloseKey, ListCursor, navIntent, wheelDelta } from "../../../lib/tui/cursor.ts";
20
+ import { isCloseKey, ListCursor, navIntent, wheelDelta } from "../../../lib/tui/cursor.ts";
21
21
  import { emptyState } from "../../../lib/tui/rows.ts";
22
22
  import { titleRule } from "../../../lib/tui/rules.ts";
23
23
  import type { ExtensionReport } from "../../../lib/requirements.ts";
24
24
  import type { ExtensionsPickerRuntime } from "../picker/runtime.ts";
25
25
  import { buildPanelModel } from "./join.ts";
26
+ import { SETTING_ROWS, UNTRUSTED_SETTINGS_NOTE } from "./settings.ts";
26
27
  import type { SettingsSurface } from "./settings-runtime.ts";
27
28
  import type { PanelModel, PanelSection } from "./types.ts";
28
29
  import { entriesOf, entryKey, isSelectable, settingEntries, type PanelEntry } from "./tui/entries.ts";
30
+ import { applyEditKey, type EditingState } from "./tui/editing.ts";
29
31
  import { clampSelection, firstRow, lastRow, moveRows, retainFocus } from "./tui/nav.ts";
30
32
  import { extensionDetail, extensionHints, extensionRow, extensionSummary } from "./tui/extensions-view.ts";
31
- import {
32
- BODY_START,
33
- DETAIL_ROWS,
34
- EXPANDED_DETAIL_FALLBACK,
35
- FRAME_ROWS,
36
- groupLine,
37
- hintLine,
38
- MIN_BODY_ROWS,
39
- noticeLine,
40
- SCREEN_DEFAULT_ROWS,
41
- sectionBar,
42
- } from "./tui/frame.ts";
33
+ import { BODY_START, detailRows, groupLine, hintLine, noticeLine, panelFrame, sectionBar } from "./tui/frame.ts";
43
34
  import {
44
35
  editHints,
45
36
  projectNote,
@@ -50,12 +41,6 @@ import {
50
41
  settingsSummary,
51
42
  } from "./tui/settings-view.ts";
52
43
 
53
- /** Terminal rows, or `undefined` when the height is unknown. */
54
- function totalRows(source: ViewportRowsSource | undefined): number | undefined {
55
- const rows = typeof source === "function" ? source() : source;
56
- return rows !== undefined && Number.isFinite(rows) && rows > 0 ? rows : undefined;
57
- }
58
-
59
44
  /** Host seams for the panel screen, injected by the command runner. */
60
45
  export interface PanelComponentOptions {
61
46
  /** The session's picker runtime: items, scope, toggles, dirty flag. */
@@ -102,10 +87,12 @@ export class PanelComponent implements Component {
102
87
  private probed = false;
103
88
  /** Transient feedback (a refusal or a failed write), shown in the hint row. */
104
89
  private notice: string | undefined;
105
- /** The text row being edited: its id, the buffer, and the caret offset. */
106
- private editing: { id: string; buffer: string; caret: number } | undefined;
90
+ /** The text row being edited, if any. */
91
+ private editing: EditingState | undefined;
107
92
  /** True while the Settings detail pane shows the focused row's section as JSON. */
108
93
  private json = false;
94
+ /** Whether the last Settings render kept the project note; the click mapping needs it. */
95
+ private noteShown = false;
109
96
  /** The visible section. */
110
97
  private section: PanelSection;
111
98
 
@@ -180,14 +167,22 @@ export class PanelComponent implements Component {
180
167
  if (next === "project" && !this.options.runtime.projectAvailable()) {
181
168
  this.notify(
182
169
  this.section === "settings"
183
- ? "Project is not trusted; settings can only be changed in Global scope."
170
+ ? UNTRUSTED_SETTINGS_NOTE
184
171
  : "Project is not trusted; manage extensions in Global scope only.",
185
172
  );
186
173
  return;
187
174
  }
188
175
  // The JSON pane names a file, so a scope switch has to close it.
189
176
  this.json = false;
177
+ const focused = entryKey(this.activeEntries()[this.cursor.selected]);
190
178
  this.options.runtime.setScope(next);
179
+ // Keep the setting under the cursor across the layer switch; the two scopes
180
+ // do not share entry indices because Project drops the app rows, so the
181
+ // render's focus retention and clamp land on the nearest row when it cannot.
182
+ if (focused !== undefined) {
183
+ const index = this.activeEntries().findIndex((entry) => entryKey(entry) === focused);
184
+ if (index >= 0) this.cursor.selected = index;
185
+ }
191
186
  this.options.requestRender();
192
187
  }
193
188
 
@@ -255,6 +250,9 @@ export class PanelComponent implements Component {
255
250
  const entry = this.activeEntries()[this.cursor.selected];
256
251
  if (entry?.kind !== "setting") return;
257
252
  if (!entry.setting.config) {
253
+ // There is no section to show, so leave the mode off rather than keeping a
254
+ // hidden toggle that would reopen the pane on another row.
255
+ this.json = false;
258
256
  this.notify(`${entry.setting.label} is a Pi setting; bytefly.json has no section for it.`);
259
257
  return;
260
258
  }
@@ -276,63 +274,26 @@ export class PanelComponent implements Component {
276
274
  .then(() => this.options.requestRender());
277
275
  }
278
276
 
279
- /** The text row being edited, read live so a scope switch cannot point at a stale row. */
280
- private editingRow(): { view: ReturnType<SettingsSurface["views"]>[number] } | undefined {
281
- const editing = this.editing;
282
- if (!editing) return undefined;
283
- const scope = this.options.runtime.scope();
284
- const view = this.options.settings.views(scope).find((entry) => entry.id === editing.id);
285
- return view ? { view } : undefined;
286
- }
287
-
288
277
  /**
289
- * Editor keys. `Esc` cancels the edit rather than closing the screen, and
290
- * everything else the screen binds is inert until the editor closes.
278
+ * Editor keys. `Esc`/`Ctrl+C` cancel the edit rather than closing the screen,
279
+ * and everything else the screen binds is inert until the editor closes.
291
280
  */
292
281
  private handleEditingInput(data: string): void {
293
282
  const editing = this.editing;
294
283
  if (!editing) return;
295
- if (isCloseKey(data)) {
296
- this.editing = undefined;
297
- this.options.requestRender();
298
- return;
299
- }
300
- if (matchesKey(data, Key.enter)) {
284
+ const max = SETTING_ROWS.find((row) => row.id === editing.id)?.text?.maxLength;
285
+ const action = applyEditKey(data, editing, max);
286
+ if (action.kind === "commit") {
301
287
  void this.commitEdit();
302
288
  return;
303
289
  }
304
- const { buffer, caret } = editing;
305
- if (matchesKey(data, Key.backspace)) {
306
- if (caret > 0)
307
- this.editing = { ...editing, buffer: buffer.slice(0, caret - 1) + buffer.slice(caret), caret: caret - 1 };
308
- } else if (matchesKey(data, Key.delete)) {
309
- if (caret < buffer.length)
310
- this.editing = { ...editing, buffer: buffer.slice(0, caret) + buffer.slice(caret + 1) };
311
- } else if (matchesKey(data, Key.left)) {
312
- this.editing = { ...editing, caret: Math.max(0, caret - 1) };
313
- } else if (matchesKey(data, Key.right)) {
314
- this.editing = { ...editing, caret: Math.min(buffer.length, caret + 1) };
315
- // `Ctrl+A`/`Ctrl+E` are the emacs spellings of Home/End, which pi-tui's
316
- // `Key.home`/`Key.end` do not match; both are offered because a terminal
317
- // may send either.
318
- } else if (matchesKey(data, Key.home) || matchesKey(data, "ctrl+a")) {
319
- this.editing = { ...editing, caret: 0 };
320
- } else if (matchesKey(data, Key.end) || matchesKey(data, "ctrl+e")) {
321
- this.editing = { ...editing, caret: buffer.length };
322
- } else if (matchesKey(data, "ctrl+u")) {
323
- this.editing = { ...editing, buffer: "", caret: 0 };
324
- } else {
325
- // Anything else must be printable; control sequences are not text.
326
- const typed = [...data].filter((char) => char >= " " && char !== "\u007f").join("");
327
- const max = this.editingRow()?.view.text?.maxLength;
328
- if (typed && max !== undefined && buffer.length + typed.length <= max) {
329
- this.editing = {
330
- ...editing,
331
- buffer: buffer.slice(0, caret) + typed + buffer.slice(caret),
332
- caret: caret + typed.length,
333
- };
334
- }
290
+ if (action.kind === "cancel") {
291
+ this.editing = undefined;
292
+ this.options.requestRender();
293
+ return;
335
294
  }
295
+ if (!action.changed) return;
296
+ this.editing = action.state;
336
297
  this.options.requestRender();
337
298
  }
338
299
 
@@ -534,23 +495,24 @@ export class PanelComponent implements Component {
534
495
  if (entries.length === 0) return undefined;
535
496
  // The Settings section prints the "Global only" note first in Project scope,
536
497
  // so the rows start one line lower there.
537
- const note = this.section === "settings" && this.options.runtime.scope() === "project" ? 1 : 0;
498
+ const note = this.section === "settings" && this.noteShown ? 1 : 0;
538
499
  const line = event.y - (BODY_START + note);
539
- if (line < 0) return undefined;
540
- const index = this.cursor.window(entries.length).start + line;
500
+ const window = this.cursor.window(entries.length);
501
+ if (line < 0 || line >= window.end - window.start) return undefined;
502
+ const index = window.start + line;
541
503
  if (!isSelectable(entries[index])) return undefined;
542
504
  this.select(index);
543
505
  return { handled: true };
544
506
  }
545
507
 
546
508
  /** Key hints for the active section; whole hints drop as the width runs out. */
547
- private hint(width: number, expanded = false): string {
509
+ private hint(width: number, options: { expanded?: boolean; jsonOpen?: boolean } = {}): string {
548
510
  if (this.editing) return hintLine(this.theme, width, editHints());
549
511
  const hints =
550
512
  this.section === "settings"
551
- ? settingsHints(this.json)
513
+ ? settingsHints(options.jsonOpen === true)
552
514
  : extensionHints({
553
- expanded,
515
+ expanded: options.expanded === true,
554
516
  refreshing: this.refreshing,
555
517
  projectScope: this.options.runtime.scope() === "project",
556
518
  });
@@ -558,21 +520,8 @@ export class PanelComponent implements Component {
558
520
  }
559
521
 
560
522
  /** The key-hint row, or the notice that temporarily replaces it. */
561
- private hintRow(width: number, expanded = false): string {
562
- return this.notice !== undefined ? noticeLine(this.theme, width, this.notice) : this.hint(width, expanded);
563
- }
564
-
565
- /**
566
- * Detail rows the focused row gets: the fixed pane when collapsed, the free
567
- * height when expanded. Zero when the terminal cannot hold one body row next to
568
- * the pane, so the hint row is never pushed off the screen.
569
- */
570
- private detailRows(expanded: boolean): number {
571
- const total = totalRows(this.options.viewportRows);
572
- if (total === undefined) return expanded ? EXPANDED_DETAIL_FALLBACK : DETAIL_ROWS;
573
- const available = total - FRAME_ROWS - MIN_BODY_ROWS;
574
- if (available < DETAIL_ROWS + 1) return 0;
575
- return expanded ? Math.max(DETAIL_ROWS, available) : DETAIL_ROWS;
523
+ private hintRow(width: number, options: { expanded?: boolean; jsonOpen?: boolean } = {}): string {
524
+ return this.notice !== undefined ? noticeLine(this.theme, width, this.notice) : this.hint(width, options);
576
525
  }
577
526
 
578
527
  /** Expand or collapse the focused row's findings. */
@@ -584,6 +533,13 @@ export class PanelComponent implements Component {
584
533
  this.options.requestRender();
585
534
  }
586
535
 
536
+ /** Retain the focus across a rebuild and return the entry it landed on. */
537
+ private retain(entries: PanelEntry[]): PanelEntry | undefined {
538
+ this.retainFocus(entries);
539
+ this.clampSelection(entries);
540
+ return entries[this.cursor.selected];
541
+ }
542
+
587
543
  /** The Settings body: scope counts, the project note, rows, and detail. */
588
544
  private renderSettings(width: number, header: string): string[] {
589
545
  const scope = this.options.runtime.scope();
@@ -597,11 +553,11 @@ export class PanelComponent implements Component {
597
553
  const entries = settingEntries(views);
598
554
  const note = scope === "project" ? projectNote(this.theme, width) : undefined;
599
555
 
600
- this.retainFocus(entries);
601
- this.clampSelection(entries);
602
-
603
- const focusedEntry = entries[this.cursor.selected];
556
+ const focusedEntry = this.retain(entries);
604
557
  const focused = focusedEntry?.kind === "setting" ? focusedEntry.setting : undefined;
558
+ // A row with no section leaves the mode off, so moving away and back cannot
559
+ // reopen a pane the user closed.
560
+ if (focused && !focused.config) this.json = false;
605
561
  const jsonConfig = this.json ? focused?.config : undefined;
606
562
  const detail =
607
563
  jsonConfig && focused
@@ -609,50 +565,40 @@ export class PanelComponent implements Component {
609
565
  theme: this.theme,
610
566
  width,
611
567
  section: jsonConfig.section,
612
- view: focused,
613
568
  document: this.options.settings.sectionDocument(scope, jsonConfig.section),
614
- budget: this.detailRows(true),
569
+ budget: detailRows(true, this.options.viewportRows),
570
+ dirty: this.dirty,
615
571
  paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
616
572
  })
617
573
  : settingDetail({
618
574
  theme: this.theme,
619
575
  width,
620
576
  view: focused,
621
- budget: this.detailRows(false),
577
+ budget: detailRows(false, this.options.viewportRows),
622
578
  dirty: this.dirty,
623
579
  paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
624
580
  });
625
581
 
626
- this.cursor.visible = fitRows(
627
- this.options.viewportRows,
628
- entries.length,
629
- FRAME_ROWS + detail.length + (note ? 1 : 0),
630
- SCREEN_DEFAULT_ROWS,
631
- );
632
- this.cursor.sync(entries.length);
633
- const { start, end } = this.cursor.window(entries.length);
634
-
635
- const body: string[] = [];
636
- if (note) body.push(note);
637
- for (let index = start; index < end; index += 1) {
638
- const entry = entries[index];
639
- if (entry.kind === "group") body.push(groupLine(this.theme, width, entry));
640
- else if (entry.kind === "setting") {
641
- const editing = this.editing?.id === entry.setting.id ? this.editing : undefined;
642
- body.push(settingRow(this.theme, width, entry.setting, index === this.cursor.selected, editing));
643
- }
644
- }
645
-
646
- return dockListFrame({
582
+ const { lines, noteShown } = panelFrame({
583
+ theme: this.theme,
584
+ viewportRows: this.options.viewportRows,
585
+ cursor: this.cursor,
586
+ width,
647
587
  header,
648
588
  summary,
649
- body,
650
- range: this.cursor.clipped(entries.length)
651
- ? formatRange(this.theme, width, { start, end, total: entries.length })
652
- : undefined,
653
- detail: detail.length > 0 ? detail : undefined,
654
- hint: this.hintRow(width),
589
+ entries,
590
+ note,
591
+ detail,
592
+ hint: this.hintRow(width, { jsonOpen: jsonConfig !== undefined }),
593
+ draw: (entry, index) => {
594
+ if (entry.kind === "group") return groupLine(this.theme, width, entry);
595
+ if (entry.kind !== "setting") return undefined;
596
+ const editing = this.editing?.id === entry.setting.id ? this.editing : undefined;
597
+ return settingRow(this.theme, width, entry.setting, index === this.cursor.selected, editing);
598
+ },
655
599
  });
600
+ this.noteShown = noteShown;
601
+ return lines;
656
602
  }
657
603
 
658
604
  render(width: number): string[] {
@@ -678,39 +624,35 @@ export class PanelComponent implements Component {
678
624
  });
679
625
  }
680
626
 
681
- this.retainFocus(entries);
682
- this.clampSelection(entries);
683
-
684
- const focusedEntry = entries[this.cursor.selected];
627
+ const focusedEntry = this.retain(entries);
685
628
  const focused = focusedEntry?.kind === "row" ? focusedEntry.row : undefined;
686
629
  const expanded = focused !== undefined && this.expanded.has(focused.key);
687
- const detail = extensionDetail(this.theme, w, focused, this.detailRows(expanded), expanded, this.dirty);
688
-
689
- this.cursor.visible = fitRows(
690
- this.options.viewportRows,
691
- entries.length,
692
- FRAME_ROWS + detail.length,
693
- SCREEN_DEFAULT_ROWS,
630
+ const detail = extensionDetail(
631
+ this.theme,
632
+ w,
633
+ focused,
634
+ detailRows(expanded, this.options.viewportRows),
635
+ expanded,
636
+ this.dirty,
694
637
  );
695
- this.cursor.sync(entries.length);
696
- const { start, end } = this.cursor.window(entries.length);
697
-
698
- const body: string[] = [];
699
- for (let index = start; index < end; index += 1) {
700
- const entry = entries[index];
701
- if (entry.kind === "group") body.push(groupLine(this.theme, w, entry));
702
- else if (entry.kind === "row") body.push(extensionRow(this.theme, w, entry.row, index === this.cursor.selected));
703
- }
704
638
 
705
- return dockListFrame({
639
+ const { lines, noteShown } = panelFrame({
640
+ theme: this.theme,
641
+ viewportRows: this.options.viewportRows,
642
+ cursor: this.cursor,
643
+ width: w,
706
644
  header,
707
645
  summary,
708
- body,
709
- range: this.cursor.clipped(entries.length)
710
- ? formatRange(this.theme, w, { start, end, total: entries.length })
711
- : undefined,
712
- detail: detail.length > 0 ? detail : undefined,
713
- hint: this.hintRow(w, expanded),
646
+ entries,
647
+ detail,
648
+ hint: this.hintRow(w, { expanded }),
649
+ draw: (entry, index) => {
650
+ if (entry.kind === "group") return groupLine(this.theme, w, entry);
651
+ if (entry.kind !== "row") return undefined;
652
+ return extensionRow(this.theme, w, entry.row, index === this.cursor.selected);
653
+ },
714
654
  });
655
+ this.noteShown = noteShown;
656
+ return lines;
715
657
  }
716
658
  }
@@ -50,6 +50,7 @@ export function createSettingsStore(manager: SettingsManager): SettingsStore {
50
50
  setQuietStartup: (quiet) => manager.setQuietStartup(quiet),
51
51
  setOutputPad: (padding) => manager.setOutputPad(padding),
52
52
  setShowHardwareCursor: (enabled) => manager.setShowHardwareCursor(enabled),
53
+ setTheme: (theme) => manager.setTheme(theme),
53
54
  isProjectTrusted: () => manager.isProjectTrusted(),
54
55
  flush: () => manager.flush(),
55
56
  drainErrors: () => manager.drainErrors(),
@@ -37,6 +37,8 @@ export interface SettingsStore {
37
37
  setQuietStartup(quiet: QuietStartup): void;
38
38
  setOutputPad(padding: 0 | 1): void;
39
39
  setShowHardwareCursor(enabled: boolean): void;
40
+ /** Pi's theme setting is a personal-file preference. */
41
+ setTheme(theme: string): void;
40
42
  isProjectTrusted(): boolean;
41
43
  flush(): Promise<void>;
42
44
  drainErrors(): Array<{ error: Error }>;
@@ -1,17 +1,26 @@
1
1
  /**
2
2
  * The transcript component that draws one separator entry.
3
3
  *
4
- * It is stateless: the line is derived from the stored entry data and the width
5
- * passed to `render`, so there is nothing to invalidate.
4
+ * `render` runs on every frame Pi draws, and a session holds one separator per
5
+ * turn, so the line is a pure function of the stored entry data, the width, and
6
+ * the theme it was built with. `centeredRule` paints and measures a full-width
7
+ * rule, so the placed line is memoized on the width (`RenderCache`); without it
8
+ * a long session rebuilds every turn's rule on every frame. Pi rebuilds the
9
+ * component from the renderer on a theme change (its
10
+ * `CustomEntryComponent.invalidate` re-runs it), so the captured theme cannot go
11
+ * stale; `invalidate` drops the memo anyway, in case that changes.
6
12
  */
7
13
 
8
14
  import type { Theme } from "@earendil-works/pi-coding-agent";
9
15
  import type { Component } from "@earendil-works/pi-tui";
16
+ import { RenderCache } from "../../../lib/tui/render-cache.ts";
10
17
  import { separatorLine } from "./format.ts";
11
18
  import type { TurnSeparatorData } from "./types.ts";
12
19
 
13
20
  /** Renders one separator entry; the data comes from the stored entry. */
14
21
  export class SeparatorComponent implements Component {
22
+ private readonly cache = new RenderCache();
23
+
15
24
  constructor(
16
25
  private readonly data: TurnSeparatorData,
17
26
  private readonly theme: Theme,
@@ -19,10 +28,11 @@ export class SeparatorComponent implements Component {
19
28
  ) {}
20
29
 
21
30
  invalidate(): void {
22
- // Stateless: the line is derived from the data and the width passed to render.
31
+ this.cache.invalidate();
23
32
  }
24
33
 
25
34
  render(width: number): string[] {
26
- return separatorLine(this.data, width, this.theme, this.pad);
35
+ const w = Math.max(1, width);
36
+ return this.cache.lines(w, undefined, () => separatorLine(this.data, w, this.theme, this.pad));
27
37
  }
28
38
  }