@gavin-hu/my-pi-agent 0.8.0 → 0.9.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 (98) hide show
  1. package/README.md +9 -8
  2. package/README.zh-CN.md +8 -8
  3. package/assets/file-browse.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +49 -36
  6. package/extensions/bytefly/doctor/format.ts +36 -10
  7. package/extensions/bytefly/doctor/tools.ts +6 -5
  8. package/extensions/bytefly/footer/config.ts +1 -1
  9. package/extensions/bytefly/footer/render/format.ts +5 -18
  10. package/extensions/bytefly/footer/render/layout.ts +2 -1
  11. package/extensions/bytefly/footer/render/segments.ts +51 -78
  12. package/extensions/bytefly/header/component.ts +33 -16
  13. package/extensions/bytefly/header/config.ts +28 -18
  14. package/extensions/bytefly/index.ts +1 -1
  15. package/extensions/bytefly/panel/commands.ts +69 -35
  16. package/extensions/bytefly/panel/format.ts +8 -8
  17. package/extensions/bytefly/panel/tui.ts +45 -70
  18. package/extensions/bytefly/separator/config.ts +16 -8
  19. package/extensions/bytefly/separator/format.ts +13 -40
  20. package/extensions/bytefly/separator/types.ts +0 -10
  21. package/extensions/document/README.md +1 -1
  22. package/extensions/document/render.ts +11 -10
  23. package/extensions/document/tool.ts +1 -1
  24. package/extensions/file-browse/README.md +62 -14
  25. package/extensions/file-browse/client.ts +103 -2
  26. package/extensions/file-browse/config.ts +12 -0
  27. package/extensions/file-browse/diff.ts +187 -0
  28. package/extensions/file-browse/git.ts +176 -1
  29. package/extensions/file-browse/html.ts +412 -23
  30. package/extensions/file-browse/index.ts +15 -4
  31. package/extensions/file-browse/markdown.ts +98 -5
  32. package/extensions/file-browse/router.ts +94 -2
  33. package/extensions/file-browse/server.ts +4 -1
  34. package/extensions/goal/format.ts +11 -10
  35. package/extensions/goal/tools.ts +6 -5
  36. package/extensions/goal/tui.ts +1 -1
  37. package/extensions/job/README.md +2 -2
  38. package/extensions/job/format.ts +36 -13
  39. package/extensions/job/index.ts +1 -1
  40. package/extensions/job/tools.ts +11 -10
  41. package/extensions/job/tui.ts +141 -220
  42. package/extensions/job/ui.ts +24 -49
  43. package/extensions/memory/README.md +1 -1
  44. package/extensions/memory/tools.ts +4 -3
  45. package/extensions/memory/tui.ts +32 -36
  46. package/extensions/plan/README.md +64 -34
  47. package/extensions/plan/blocks.ts +44 -2
  48. package/extensions/plan/feedback.ts +20 -5
  49. package/extensions/plan/index.ts +1 -1
  50. package/extensions/plan/runtime.ts +3 -2
  51. package/extensions/plan/tools.ts +23 -22
  52. package/extensions/plan/tui.ts +21 -27
  53. package/extensions/plan/web-client.ts +335 -30
  54. package/extensions/plan/web-page.ts +110 -30
  55. package/extensions/plan/web-router.ts +6 -4
  56. package/extensions/plan/web.ts +41 -10
  57. package/extensions/question/README.md +1 -1
  58. package/extensions/question/tools.ts +12 -11
  59. package/extensions/question/tui.ts +132 -76
  60. package/extensions/rewind/README.md +1 -1
  61. package/extensions/rewind/format.ts +29 -1
  62. package/extensions/rewind/rewind-tui.ts +66 -62
  63. package/extensions/rewind/status.ts +3 -2
  64. package/extensions/subagent/render.ts +62 -47
  65. package/extensions/todo/README.md +2 -2
  66. package/extensions/todo/format.ts +31 -10
  67. package/extensions/todo/tools.ts +5 -4
  68. package/extensions/todo/tui.ts +45 -26
  69. package/extensions/watch/README.md +2 -2
  70. package/extensions/watch/format.ts +33 -13
  71. package/extensions/watch/index.ts +1 -1
  72. package/extensions/watch/tools.ts +12 -7
  73. package/extensions/watch/tui.ts +135 -194
  74. package/extensions/watch/ui.ts +24 -46
  75. package/extensions/web-access/fetch/tool.ts +12 -11
  76. package/extensions/web-access/search/tool.ts +16 -13
  77. package/extensions/wechat/README.md +2 -2
  78. package/extensions/wechat/index.ts +4 -3
  79. package/extensions/wechat/login-screen.ts +8 -5
  80. package/extensions/wechat/status-screen.ts +41 -22
  81. package/extensions/wechat/tools.ts +6 -5
  82. package/extensions/worktree/enter.ts +1 -1
  83. package/extensions/worktree/index.ts +1 -1
  84. package/extensions/worktree/runtime.ts +1 -1
  85. package/lib/README.md +10 -7
  86. package/lib/requirements.ts +2 -2
  87. package/lib/{tui.ts → tui/chrome.ts} +9 -30
  88. package/lib/tui/columns.ts +96 -0
  89. package/lib/{list-cursor.ts → tui/cursor.ts} +56 -3
  90. package/lib/tui/gauge.ts +78 -0
  91. package/lib/tui/index.ts +25 -0
  92. package/lib/tui/line-pane.ts +173 -0
  93. package/lib/tui/rows.ts +109 -0
  94. package/lib/tui/rules.ts +88 -0
  95. package/lib/tui/status-controller.ts +107 -0
  96. package/lib/tui/tokens.ts +58 -0
  97. package/package.json +2 -2
  98. /package/lib/{ui.ts → tui/glyphs.ts} +0 -0
@@ -6,11 +6,23 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
- import { Editor, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
9
+ import {
10
+ Editor,
11
+ Key,
12
+ matchesKey,
13
+ visibleWidth,
14
+ wrapTextWithAnsi,
15
+ type TuiMouseEvent,
16
+ type TuiMouseEventResult,
17
+ } from "@earendil-works/pi-tui";
10
18
  import { sanitizeLine } from "../../lib/format.ts";
11
- import { selectionMarker } from "../../lib/list-cursor.ts";
12
- import { editorTheme, screenHint } from "../../lib/tui.ts";
13
- import { GLYPHS } from "../../lib/ui.ts";
19
+ import { flowCells } from "../../lib/tui/columns.ts";
20
+ import { clampScroll, formatRange, keepVisible, selectionMarker, wheelDelta } from "../../lib/tui/cursor.ts";
21
+ import { editorTheme, screenHint } from "../../lib/tui/chrome.ts";
22
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
23
+ import { checkbox } from "../../lib/tui/rows.ts";
24
+ import { plainRule, titleRule } from "../../lib/tui/rules.ts";
25
+ import { paint } from "../../lib/tui/tokens.ts";
14
26
  import { OTHER_LABEL, cancelledResult, collectAnswers, completedResult, describeSelection } from "./answers.ts";
15
27
  import {
16
28
  allAnswered,
@@ -29,35 +41,14 @@ type Outcome =
29
41
  | { type: "cancelled"; selections: Map<string, Selection> }
30
42
  | { type: "submitted"; selections: Map<string, Selection> };
31
43
 
32
- /**
33
- * Lay atomic, ANSI-styled cells onto lines no wider than `width`.
34
- *
35
- * Cells are never split: a cell that would not fit moves to the next line, and
36
- * a cell wider than the whole viewport is truncated. This keeps tab labels and
37
- * the submit arrow intact instead of letting `wrapTextWithAnsi` break them
38
- * mid-token.
39
- */
40
- function layoutCells(cells: string[], width: number): string[] {
41
- const lines: string[] = [];
42
- let current = "";
43
- let currentWidth = 0;
44
- for (const cell of cells) {
45
- let text = cell;
46
- let cellWidth = visibleWidth(cell);
47
- if (cellWidth > width) {
48
- text = truncateToWidth(cell, width);
49
- cellWidth = visibleWidth(text);
50
- }
51
- if (currentWidth > 0 && currentWidth + cellWidth > width) {
52
- lines.push(current);
53
- current = "";
54
- currentWidth = 0;
55
- }
56
- current += text;
57
- currentWidth += cellWidth;
44
+ /** Index of the option block containing line `line`, given each block's first line. */
45
+ function blockAt(offsets: number[], line: number): number {
46
+ let index = 0;
47
+ for (let i = 0; i < offsets.length; i++) {
48
+ if (offsets[i] > line) break;
49
+ index = i;
58
50
  }
59
- lines.push(current);
60
- return lines;
51
+ return index;
61
52
  }
62
53
 
63
54
  function mapKey(data: string): KeyAction | undefined {
@@ -82,9 +73,18 @@ export async function askViaTui(
82
73
  const outcome = await ctx.ui.custom<Outcome>((tui, theme, _keybindings, done) => {
83
74
  let state: TuiState = initialState(questions);
84
75
  let cachedWidth: number | undefined;
76
+ let cachedRows: number | undefined;
85
77
  let cachedLines: string[] | undefined;
78
+ /** First visible line of the option list, when it is clipped. */
79
+ let optionsScroll = 0;
86
80
  let settled = false;
87
81
 
82
+ /** Live terminal height, or undefined when the host does not report one. */
83
+ const terminalRows = (): number | undefined => {
84
+ const rows = tui.terminal?.rows;
85
+ return typeof rows === "number" && Number.isFinite(rows) && rows > 0 ? rows : undefined;
86
+ };
87
+
88
88
  /** Snapshot the answers recorded so far, for submit or partial cancel. */
89
89
  const selectionMap = (): Map<string, Selection> => new Map(Object.entries(state.selections));
90
90
 
@@ -109,6 +109,7 @@ export async function askViaTui(
109
109
 
110
110
  const refresh = (): void => {
111
111
  cachedWidth = undefined;
112
+ cachedRows = undefined;
112
113
  cachedLines = undefined;
113
114
  tui.requestRender();
114
115
  };
@@ -160,7 +161,8 @@ export async function askViaTui(
160
161
 
161
162
  function render(width: number): string[] {
162
163
  syncEditorFocus();
163
- if (cachedLines && cachedWidth === width) return cachedLines;
164
+ const rows = terminalRows();
165
+ if (cachedLines && cachedWidth === width && cachedRows === rows) return cachedLines;
164
166
 
165
167
  const lines: string[] = [];
166
168
  const renderWidth = Math.max(1, width);
@@ -168,48 +170,56 @@ export async function askViaTui(
168
170
  const isMulti = questions.length > 1;
169
171
  const submitTab = state.tab >= questions.length;
170
172
 
171
- const addWrapped = (text: string): void => {
172
- lines.push(...wrapTextWithAnsi(text, renderWidth));
173
- };
174
- const addWithPrefix = (prefix: string, text: string): void => {
173
+ /** Wrap `text` under `prefix`; only the first line carries the prefix. */
174
+ const prefixWrapped = (prefix: string, text: string): string[] => {
175
175
  const prefixWidth = visibleWidth(prefix);
176
- if (prefixWidth >= renderWidth) {
177
- addWrapped(prefix + text);
178
- return;
179
- }
176
+ if (prefixWidth >= renderWidth) return wrapTextWithAnsi(prefix + text, renderWidth);
180
177
  const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
181
178
  const continuation = " ".repeat(prefixWidth);
182
- for (let i = 0; i < wrapped.length; i++) {
183
- lines.push(`${i === 0 ? prefix : continuation}${wrapped[i]}`);
184
- }
179
+ return wrapped.map((line, index) => `${index === 0 ? prefix : continuation}${line}`);
180
+ };
181
+ const addWithPrefix = (prefix: string, text: string): void => {
182
+ lines.push(...prefixWrapped(prefix, text));
185
183
  };
186
184
 
187
- function renderOptions(target: Question): void {
185
+ /** One block of lines per option, so the option list can be windowed. */
186
+ const renderOptionBlocks = (target: Question): string[][] => {
188
187
  const selection = state.selections[target.id];
189
188
  const chosen = selection?.type === "options" ? selection.indices : [];
190
189
  const count = optionCount(target);
190
+ const blocks: string[][] = [];
191
191
  for (let i = 0; i < count; i++) {
192
192
  const option = target.options[i];
193
193
  const active = i === state.cursor;
194
194
  const prefix = selectionMarker(theme, active);
195
195
  if (!option) {
196
196
  const otherSelected = selection?.type === "custom";
197
- const otherMarker = target.multiSelect ? `[${otherSelected ? "x" : " "}] ` : "";
198
- const label = `${i + 1}. ${otherMarker}${OTHER_LABEL}${state.editing ? " ✎" : ""}`;
199
- addWithPrefix(prefix, theme.fg(active || state.editing ? "accent" : "text", label));
197
+ const box = target.multiSelect ? `${checkbox(theme, otherSelected)} ` : "";
198
+ const tone = active || state.editing ? "accent" : "label";
199
+ blocks.push(
200
+ prefixWrapped(
201
+ prefix,
202
+ paint(theme, tone, `${i + 1}. `) +
203
+ box +
204
+ paint(theme, tone, `${OTHER_LABEL}${state.editing ? " ✎" : ""}`),
205
+ ),
206
+ );
200
207
  continue;
201
208
  }
202
- const marker = target.multiSelect ? `[${chosen.includes(i + 1) ? "x" : " "}] ` : "";
209
+ const marker = target.multiSelect ? `${checkbox(theme, chosen.includes(i + 1))} ` : "";
203
210
  const label = sanitizeLine(option.label);
204
- addWithPrefix(prefix, theme.fg(active ? "accent" : "text", `${i + 1}. ${marker}${label}`));
211
+ const tone = active ? "accent" : "label";
212
+ const block = prefixWrapped(prefix, paint(theme, tone, `${i + 1}. `) + marker + paint(theme, tone, label));
205
213
  if (option.description) {
206
214
  const indent = " ".repeat(visibleWidth(prefix) + String(i + 1).length + 2 + visibleWidth(marker));
207
- addWithPrefix(indent, theme.fg("muted", sanitizeLine(option.description)));
215
+ block.push(...prefixWrapped(indent, paint(theme, "meta", sanitizeLine(option.description))));
208
216
  }
217
+ blocks.push(block);
209
218
  }
210
- }
219
+ return blocks;
220
+ };
211
221
 
212
- lines.push(theme.fg("accent", "─".repeat(renderWidth)));
222
+ lines.push(titleRule(theme, renderWidth, "Question"));
213
223
 
214
224
  if (isMulti) {
215
225
  const cells: string[] = [];
@@ -218,60 +228,63 @@ export async function askViaTui(
218
228
  const active = i === state.tab;
219
229
  const text = ` ${answered ? GLYPHS.boxOn : GLYPHS.boxOff} ${sanitizeLine(questions[i].header)} `;
220
230
  const styled = active
221
- ? theme.bg("selectedBg", theme.fg("text", text))
222
- : theme.fg(answered ? "success" : "muted", text);
231
+ ? theme.bg("selectedBg", paint(theme, "label", text))
232
+ : paint(theme, answered ? "ok" : "meta", text);
223
233
  // Keep the leading hint glued to the first tab so it never wraps alone.
224
- cells.push(cells.length === 0 ? `${theme.fg("muted", `${GLYPHS.prev} `)}${styled} ` : `${styled} `);
234
+ cells.push(cells.length === 0 ? `${paint(theme, "meta", `${GLYPHS.prev} `)}${styled} ` : `${styled} `);
225
235
  }
226
236
  const canSubmit = allAnswered(state);
227
237
  const submitText = ` ${GLYPHS.check} Submit `;
228
238
  const submitStyled = submitTab
229
- ? theme.bg("selectedBg", theme.fg("text", submitText))
230
- : theme.fg(canSubmit ? "success" : "dim", submitText);
239
+ ? theme.bg("selectedBg", paint(theme, "label", submitText))
240
+ : paint(theme, canSubmit ? "ok" : "faint", submitText);
231
241
  cells.push(`${submitStyled} ${GLYPHS.next}`);
232
- for (const tabLine of layoutCells(cells, Math.max(1, renderWidth - 1))) lines.push(` ${tabLine}`);
242
+ for (const tabLine of flowCells(cells, Math.max(1, renderWidth - 1))) lines.push(` ${tabLine}`);
233
243
  lines.push("");
234
244
  }
235
245
 
236
246
  if (state.editing && question) {
237
- addWithPrefix(" ", theme.fg("text", sanitizeLine(question.question)));
247
+ addWithPrefix(" ", paint(theme, "label", sanitizeLine(question.question)));
238
248
  lines.push("");
239
- renderOptions(question);
249
+ lines.push(...renderOptionBlocks(question).flat());
240
250
  lines.push("");
241
- addWithPrefix(" ", theme.fg("muted", "Your answer:"));
251
+ addWithPrefix(" ", paint(theme, "meta", "Your answer:"));
242
252
  const editorPad = renderWidth > 1 ? " " : "";
243
253
  for (const line of editor.render(Math.max(1, renderWidth - 2))) {
244
254
  lines.push(`${editorPad}${line}`);
245
255
  }
246
256
  lines.push("");
247
- if (state.message) addWithPrefix(" ", theme.fg("warning", state.message));
257
+ if (state.message) addWithPrefix(" ", paint(theme, "warn", state.message));
248
258
  lines.push(screenHint(theme, renderWidth, ["Enter to submit", "Esc to go back"]));
249
259
  } else if (submitTab) {
250
- addWithPrefix(" ", theme.fg("accent", theme.bold("Ready to submit")));
260
+ addWithPrefix(" ", paint(theme, "accent", theme.bold("Ready to submit")));
251
261
  lines.push("");
252
262
  for (const target of questions) {
253
263
  addWithPrefix(
254
264
  " ",
255
- `${theme.fg("muted", `${sanitizeLine(target.header)}: `)}${theme.fg("text", sanitizeLine(describeSelection(state.selections[target.id])))}`,
265
+ `${paint(theme, "meta", `${sanitizeLine(target.header)}: `)}${paint(theme, "label", sanitizeLine(describeSelection(state.selections[target.id])))}`,
256
266
  );
257
267
  }
258
268
  lines.push("");
259
- if (state.message) addWithPrefix(" ", theme.fg("warning", state.message));
269
+ if (state.message) addWithPrefix(" ", paint(theme, "warn", state.message));
260
270
  if (allAnswered(state)) {
261
- addWithPrefix(" ", theme.fg("success", "Press Enter to submit"));
271
+ addWithPrefix(" ", paint(theme, "ok", "Press Enter to submit"));
262
272
  } else {
263
273
  const missing = questions
264
274
  .filter((target) => state.selections[target.id] === undefined)
265
275
  .map((target) => sanitizeLine(target.header))
266
276
  .join(", ");
267
- addWithPrefix(" ", theme.fg("warning", `Unanswered: ${missing}`));
277
+ addWithPrefix(" ", paint(theme, "warn", `Unanswered: ${missing}`));
268
278
  }
269
279
  } else if (question) {
270
- addWithPrefix(" ", theme.fg("text", sanitizeLine(question.question)));
280
+ addWithPrefix(" ", paint(theme, "label", sanitizeLine(question.question)));
271
281
  lines.push("");
272
- renderOptions(question);
273
- lines.push("");
274
- if (state.message) addWithPrefix(" ", theme.fg("warning", state.message));
282
+ const blocks = renderOptionBlocks(question);
283
+
284
+ // Everything after the options is fixed chrome; build it first so the
285
+ // option list can be given exactly the rows that are left.
286
+ const tail: string[] = [""];
287
+ if (state.message) tail.push(...prefixWrapped(" ", paint(theme, "warn", state.message)));
275
288
  const hints =
276
289
  optionCount(question) === 0
277
290
  ? ["Enter to type an answer", "Esc cancel"]
@@ -279,19 +292,53 @@ export async function askViaTui(
279
292
  ? ["↑/↓ move", "Space toggle", "Enter confirm", "Esc cancel"]
280
293
  : ["↑/↓ choose", "Enter select", "Esc cancel"];
281
294
  if (isMulti) hints.unshift(`Tab/${GLYPHS.prev}${GLYPHS.next} navigate`);
282
- lines.push(screenHint(theme, renderWidth, hints));
295
+ tail.push(screenHint(theme, renderWidth, hints));
283
296
  if (question.multiSelect) {
284
297
  const selection = state.selections[question.id];
285
298
  if (selection?.type === "options" && selection.labels.length > 0) {
286
- addWithPrefix(" ", theme.fg("success", `Selected: ${selection.labels.map(sanitizeLine).join(", ")}`));
299
+ tail.push(
300
+ ...prefixWrapped(" ", paint(theme, "ok", `Selected: ${selection.labels.map(sanitizeLine).join(", ")}`)),
301
+ );
287
302
  } else if (selection?.type === "custom") {
288
- addWithPrefix(" ", theme.fg("success", `Selected: ${sanitizeLine(selection.text)}`));
303
+ tail.push(...prefixWrapped(" ", paint(theme, "ok", `Selected: ${sanitizeLine(selection.text)}`)));
289
304
  }
290
305
  }
306
+
307
+ // Window the option list into the rows left after the fixed chrome and the
308
+ // closing rule. The focused option stays visible; a clipped list says which
309
+ // options it is showing rather than silently dropping the tail.
310
+ const flat = blocks.flat();
311
+ const fixed = lines.length + tail.length + 1;
312
+ const budget = rows === undefined ? Number.POSITIVE_INFINITY : Math.max(1, rows - fixed);
313
+ if (flat.length > budget) {
314
+ const usable = Math.max(1, budget - 1);
315
+ const offsets: number[] = [];
316
+ let offset = 0;
317
+ for (const block of blocks) {
318
+ offsets.push(offset);
319
+ offset += block.length;
320
+ }
321
+ const focusLine = offsets[Math.min(state.cursor, offsets.length - 1)] ?? 0;
322
+ optionsScroll = clampScroll(keepVisible(optionsScroll, focusLine, usable), flat.length, usable);
323
+ const end = Math.min(flat.length, optionsScroll + usable);
324
+ lines.push(...flat.slice(optionsScroll, end));
325
+ lines.push(
326
+ formatRange(theme, renderWidth, {
327
+ start: blockAt(offsets, optionsScroll),
328
+ end: blockAt(offsets, end - 1) + 1,
329
+ total: blocks.length,
330
+ label: "option",
331
+ }),
332
+ );
333
+ } else {
334
+ lines.push(...flat);
335
+ }
336
+ lines.push(...tail);
291
337
  }
292
338
 
293
- lines.push(theme.fg("accent", "─".repeat(renderWidth)));
339
+ lines.push(plainRule(theme, renderWidth));
294
340
  cachedWidth = width;
341
+ cachedRows = rows;
295
342
  cachedLines = lines;
296
343
  return lines;
297
344
  }
@@ -300,9 +347,18 @@ export async function askViaTui(
300
347
  render,
301
348
  invalidate: () => {
302
349
  cachedWidth = undefined;
350
+ cachedRows = undefined;
303
351
  cachedLines = undefined;
304
352
  },
305
353
  handleInput,
354
+ handleMouse: (event: TuiMouseEvent): TuiMouseEventResult | undefined => {
355
+ const delta = wheelDelta(event);
356
+ if (delta === undefined) return undefined;
357
+ // The render clamps the scroll once it knows how many rows are left.
358
+ optionsScroll = Math.max(0, optionsScroll + delta);
359
+ refresh();
360
+ return { handled: true };
361
+ },
306
362
  dispose: () => {
307
363
  signal?.removeEventListener("abort", onAbort);
308
364
  editor.focused = false;
@@ -197,6 +197,6 @@ commit. These refs are not branches and are not touched by `git push --all`.
197
197
  | `runtime.ts` | Root/config caches and the snapshot/list/plan/restore operations. |
198
198
  | `timeline.ts` | Pure: prompts on the branch, paired with their snapshots. |
199
199
  | `rewind.ts` | Scope choice, confirmation, and applying code + conversation rewind. |
200
- | `rewind-tui.ts` | The selectable, width-safe timeline component. |
200
+ | `rewind-tui.ts` | The selectable, width-safe timeline component, drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
201
201
  | `commands.ts` | The `/rewind` command. |
202
202
  | `format.ts` | Model-facing and transcript text (pure). |
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { sliceByColumn, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
10
  import { sanitizeLine } from "../../lib/format.ts";
11
- import { GLYPHS } from "../../lib/ui.ts";
11
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
12
12
  import type { RestoreSummary } from "./types.ts";
13
13
 
14
14
  /** Longest prompt summary shown before truncation. */
@@ -55,6 +55,34 @@ export function formatChangeSummary(changed: number, removed: number): string {
55
55
  return removed > 0 ? `${changedText}, ${removed} created` : changedText;
56
56
  }
57
57
 
58
+ /** The parts of one timeline row, for the shared list-row grammar. */
59
+ export interface RewindRowParts {
60
+ /** The `⬥` snapshot marker, or a blank to keep the label column aligned. */
61
+ glyph: string;
62
+ /** The sanitized prompt summary. */
63
+ label: string;
64
+ /** The right-aligned relative time. */
65
+ trailing: string;
66
+ }
67
+
68
+ /**
69
+ * The timeline row's parts, composed by the screen through `listRow`.
70
+ * `formatRewindRow` below stays the plain-text form the headless listing uses;
71
+ * the screen wants the marker, colors, and column math of the shared grammar.
72
+ */
73
+ export function formatRewindParts(
74
+ summary: string | undefined,
75
+ hasSnapshot: boolean,
76
+ timestamp: number,
77
+ now = Date.now(),
78
+ ): RewindRowParts {
79
+ return {
80
+ glyph: hasSnapshot ? GLYPHS.snapshot : " ",
81
+ label: sanitizeLine(summary || "(no text)"),
82
+ trailing: formatRelativeTime(timestamp, now),
83
+ };
84
+ }
85
+
58
86
  /**
59
87
  * Id-free row for the rewind timeline: `⬥ "prompt" 2m ago`.
60
88
  * The leading glyph marks a point with a code snapshot; the time is
@@ -9,26 +9,15 @@
9
9
  */
10
10
 
11
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
- import {
13
- Key,
14
- matchesKey,
15
- truncateToWidth,
16
- type Component,
17
- type TuiMouseEvent,
18
- type TuiMouseEventResult,
19
- } from "@earendil-works/pi-tui";
20
- import {
21
- fitRows,
22
- formatRange,
23
- isCloseKey,
24
- ListCursor,
25
- navigateList,
26
- scrollList,
27
- selectionMarker,
28
- } from "../../lib/list-cursor.ts";
29
- import { screenHeader, screenHint, type ViewportRowsSource } from "../../lib/tui.ts";
30
- import { SEPARATORS } from "../../lib/ui.ts";
31
- import { formatRewindDetail, formatRewindRow } from "./format.ts";
12
+ import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
13
+ import { fitRows, formatRange, isCloseKey, ListCursor, navigateList, scrollList } from "../../lib/tui/cursor.ts";
14
+ import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
15
+ import { clip } from "../../lib/tui/columns.ts";
16
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
17
+ import { emptyState, listRow } from "../../lib/tui/rows.ts";
18
+ import { titleRule } from "../../lib/tui/rules.ts";
19
+ import { paint } from "../../lib/tui/tokens.ts";
20
+ import { formatRewindDetail, formatRewindParts } from "./format.ts";
32
21
  import type { RewindPoint } from "./timeline.ts";
33
22
 
34
23
  export interface RewindListOptions {
@@ -43,7 +32,7 @@ export interface RewindListOptions {
43
32
  /** Rows the screen shows when the terminal height is unknown. */
44
33
  const SCREEN_DEFAULT_ITEMS = 12;
45
34
  /** Header, summary, blanks, and footer rows around the list (excluding the optional range/detail rows). */
46
- const SCREEN_CHROME_ROWS = 7;
35
+ const SCREEN_CHROME_ROWS = 6;
47
36
 
48
37
  /** Selectable, scrollable timeline of prompts opened by `/rewind`. */
49
38
  export class RewindListComponent implements Component {
@@ -83,53 +72,68 @@ export class RewindListComponent implements Component {
83
72
 
84
73
  invalidate(): void {}
85
74
 
86
- // Kept off `dockListFrame`: `/rewind` leads with a blank after the header and
87
- // has no blank before the hint, so the shared frame would change its output.
88
- // Its blank-line convention is deliberately local.
75
+ // The shared dock frame, like every other list screen.
89
76
  render(width: number): string[] {
90
77
  const w = Math.max(1, width);
91
- const lines: string[] = [screenHeader(this.theme, w, "Rewind"), ""];
78
+ const header = titleRule(this.theme, w, "Rewind");
92
79
 
93
80
  if (this.points.length === 0) {
94
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "No prompts on this branch yet.")}`, w));
95
- } else {
96
- const count = `${this.points.length} prompt${this.points.length === 1 ? "" : "s"}`;
97
- lines.push(truncateToWidth(` ${this.theme.fg("muted", `${count}${SEPARATORS.item}newest first`)}`, w));
98
- lines.push("");
99
- const cursor = this.cursor;
100
- const focused = this.points[cursor.selected];
101
- const detail = focused ? formatRewindDetail(Boolean(focused.snapshot), focused.snapshot?.id) : "";
102
- cursor.visible = fitRows(
103
- this.options.viewportRows,
104
- this.points.length,
105
- SCREEN_CHROME_ROWS + (detail ? 1 : 0),
106
- SCREEN_DEFAULT_ITEMS,
107
- );
108
-
109
- const { start, end } = cursor.window(this.points.length);
110
- for (let i = start; i < end; i++) {
111
- const selected = i === cursor.selected;
112
- const row = formatRewindRow(
113
- this.points[i].summary,
114
- Boolean(this.points[i].snapshot),
115
- this.points[i].timestamp,
116
- Date.now(),
117
- Math.max(1, w - 2),
118
- );
119
- const body = selected ? this.theme.fg("accent", row) : this.theme.fg("muted", row);
120
- lines.push(truncateToWidth(selectionMarker(this.theme, selected) + body, w));
121
- }
122
- if (cursor.clipped(this.points.length)) {
123
- lines.push(formatRange(this.theme, w, { start, end, total: this.points.length }));
124
- }
125
- lines.push("");
126
- if (detail) lines.push(truncateToWidth(` ${this.theme.fg("muted", detail)}`, w));
81
+ return dockListFrame({
82
+ header,
83
+ body: emptyState(this.theme, w, { label: "No prompts on this branch yet." }),
84
+ hint: this.hint(w),
85
+ });
127
86
  }
128
87
 
129
- lines.push(
130
- screenHint(this.theme, w, ["Esc close", { text: "Enter choose", color: "accent" }, "↑/↓ or k/j select"]),
88
+ const count = `${this.points.length} prompt${this.points.length === 1 ? "" : "s"}`;
89
+ const cursor = this.cursor;
90
+ const focused = this.points[cursor.selected];
91
+ const detail = focused ? formatRewindDetail(Boolean(focused.snapshot), focused.snapshot?.id) : "";
92
+ cursor.visible = fitRows(
93
+ this.options.viewportRows,
94
+ this.points.length,
95
+ SCREEN_CHROME_ROWS + (detail ? 1 : 0),
96
+ SCREEN_DEFAULT_ITEMS,
131
97
  );
132
- lines.push("");
133
- return lines;
98
+
99
+ const { start, end } = cursor.window(this.points.length);
100
+ const body: string[] = [];
101
+ for (let i = start; i < end; i++) {
102
+ const selected = i === cursor.selected;
103
+ const parts = formatRewindParts(
104
+ this.points[i].summary,
105
+ Boolean(this.points[i].snapshot),
106
+ this.points[i].timestamp,
107
+ Date.now(),
108
+ );
109
+ const tone = selected ? "accent" : "meta";
110
+ body.push(
111
+ listRow(this.theme, w, {
112
+ selected,
113
+ glyph: parts.glyph,
114
+ glyphRole: tone,
115
+ label: parts.label,
116
+ labelRole: tone,
117
+ trailing: parts.trailing,
118
+ trailingRole: tone,
119
+ }),
120
+ );
121
+ }
122
+
123
+ return dockListFrame({
124
+ header,
125
+ summary: clip(` ${paint(this.theme, "meta", `${count}${SEPARATORS.item}newest first`)}`, w),
126
+ body,
127
+ range: cursor.clipped(this.points.length)
128
+ ? formatRange(this.theme, w, { start, end, total: this.points.length })
129
+ : undefined,
130
+ detail: detail ? [clip(` ${paint(this.theme, "meta", detail)}`, w)] : undefined,
131
+ hint: this.hint(w),
132
+ });
133
+ }
134
+
135
+ /** The key-hint row, with the primary action in the accent role. */
136
+ private hint(width: number): string {
137
+ return screenHint(this.theme, width, ["Esc close", { text: "Enter choose", color: "accent" }, "↑/↓ or k/j select"]);
134
138
  }
135
139
  }
@@ -8,7 +8,8 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
- import { GLYPHS, STATUS_KEYS } from "../../lib/ui.ts";
11
+ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
12
+ import { paint } from "../../lib/tui/tokens.ts";
12
13
  import type { RewindConfig } from "./config.ts";
13
14
  import { userMessagesFromBranch } from "./timeline.ts";
14
15
 
@@ -45,7 +46,7 @@ export function createStatusChip(deps: StatusChipDeps): StatusChip {
45
46
  const label = `${STATUS_GLYPH} ${count}`;
46
47
  let themed = label;
47
48
  try {
48
- themed = ctx.ui.theme.fg("accent", label);
49
+ themed = paint(ctx.ui.theme, "accent", label);
49
50
  } catch {
50
51
  // No initialized theme in a headless run.
51
52
  }