@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.
- package/README.md +9 -8
- package/README.zh-CN.md +8 -8
- package/assets/file-browse.png +0 -0
- package/assets/plan-review.png +0 -0
- package/extensions/bytefly/README.md +49 -36
- package/extensions/bytefly/doctor/format.ts +36 -10
- package/extensions/bytefly/doctor/tools.ts +6 -5
- package/extensions/bytefly/footer/config.ts +1 -1
- package/extensions/bytefly/footer/render/format.ts +5 -18
- package/extensions/bytefly/footer/render/layout.ts +2 -1
- package/extensions/bytefly/footer/render/segments.ts +51 -78
- package/extensions/bytefly/header/component.ts +33 -16
- package/extensions/bytefly/header/config.ts +28 -18
- package/extensions/bytefly/index.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +69 -35
- package/extensions/bytefly/panel/format.ts +8 -8
- package/extensions/bytefly/panel/tui.ts +45 -70
- package/extensions/bytefly/separator/config.ts +16 -8
- package/extensions/bytefly/separator/format.ts +13 -40
- package/extensions/bytefly/separator/types.ts +0 -10
- package/extensions/document/README.md +1 -1
- package/extensions/document/render.ts +11 -10
- package/extensions/document/tool.ts +1 -1
- package/extensions/file-browse/README.md +62 -14
- package/extensions/file-browse/client.ts +103 -2
- package/extensions/file-browse/config.ts +12 -0
- package/extensions/file-browse/diff.ts +187 -0
- package/extensions/file-browse/git.ts +176 -1
- package/extensions/file-browse/html.ts +412 -23
- package/extensions/file-browse/index.ts +15 -4
- package/extensions/file-browse/markdown.ts +98 -5
- package/extensions/file-browse/router.ts +94 -2
- package/extensions/file-browse/server.ts +4 -1
- package/extensions/goal/format.ts +11 -10
- package/extensions/goal/tools.ts +6 -5
- package/extensions/goal/tui.ts +1 -1
- package/extensions/job/README.md +2 -2
- package/extensions/job/format.ts +36 -13
- package/extensions/job/index.ts +1 -1
- package/extensions/job/tools.ts +11 -10
- package/extensions/job/tui.ts +141 -220
- package/extensions/job/ui.ts +24 -49
- package/extensions/memory/README.md +1 -1
- package/extensions/memory/tools.ts +4 -3
- package/extensions/memory/tui.ts +32 -36
- package/extensions/plan/README.md +64 -34
- package/extensions/plan/blocks.ts +44 -2
- package/extensions/plan/feedback.ts +20 -5
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/runtime.ts +3 -2
- package/extensions/plan/tools.ts +23 -22
- package/extensions/plan/tui.ts +21 -27
- package/extensions/plan/web-client.ts +335 -30
- package/extensions/plan/web-page.ts +110 -30
- package/extensions/plan/web-router.ts +6 -4
- package/extensions/plan/web.ts +41 -10
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +12 -11
- package/extensions/question/tui.ts +132 -76
- package/extensions/rewind/README.md +1 -1
- package/extensions/rewind/format.ts +29 -1
- package/extensions/rewind/rewind-tui.ts +66 -62
- package/extensions/rewind/status.ts +3 -2
- package/extensions/subagent/render.ts +62 -47
- package/extensions/todo/README.md +2 -2
- package/extensions/todo/format.ts +31 -10
- package/extensions/todo/tools.ts +5 -4
- package/extensions/todo/tui.ts +45 -26
- package/extensions/watch/README.md +2 -2
- package/extensions/watch/format.ts +33 -13
- package/extensions/watch/index.ts +1 -1
- package/extensions/watch/tools.ts +12 -7
- package/extensions/watch/tui.ts +135 -194
- package/extensions/watch/ui.ts +24 -46
- package/extensions/web-access/fetch/tool.ts +12 -11
- package/extensions/web-access/search/tool.ts +16 -13
- package/extensions/wechat/README.md +2 -2
- package/extensions/wechat/index.ts +4 -3
- package/extensions/wechat/login-screen.ts +8 -5
- package/extensions/wechat/status-screen.ts +41 -22
- package/extensions/wechat/tools.ts +6 -5
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/index.ts +1 -1
- package/extensions/worktree/runtime.ts +1 -1
- package/lib/README.md +10 -7
- package/lib/requirements.ts +2 -2
- package/lib/{tui.ts → tui/chrome.ts} +9 -30
- package/lib/tui/columns.ts +96 -0
- package/lib/{list-cursor.ts → tui/cursor.ts} +56 -3
- package/lib/tui/gauge.ts +78 -0
- package/lib/tui/index.ts +25 -0
- package/lib/tui/line-pane.ts +173 -0
- package/lib/tui/rows.ts +109 -0
- package/lib/tui/rules.ts +88 -0
- package/lib/tui/status-controller.ts +107 -0
- package/lib/tui/tokens.ts +58 -0
- package/package.json +2 -2
- /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 {
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
198
|
-
const
|
|
199
|
-
|
|
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 ?
|
|
209
|
+
const marker = target.multiSelect ? `${checkbox(theme, chosen.includes(i + 1))} ` : "";
|
|
203
210
|
const label = sanitizeLine(option.label);
|
|
204
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
222
|
-
: theme
|
|
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
|
|
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
|
|
230
|
-
: theme
|
|
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
|
|
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
|
|
247
|
+
addWithPrefix(" ", paint(theme, "label", sanitizeLine(question.question)));
|
|
238
248
|
lines.push("");
|
|
239
|
-
|
|
249
|
+
lines.push(...renderOptionBlocks(question).flat());
|
|
240
250
|
lines.push("");
|
|
241
|
-
addWithPrefix(" ", theme
|
|
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
|
|
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
|
|
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
|
|
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
|
|
269
|
+
if (state.message) addWithPrefix(" ", paint(theme, "warn", state.message));
|
|
260
270
|
if (allAnswered(state)) {
|
|
261
|
-
addWithPrefix(" ", theme
|
|
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
|
|
277
|
+
addWithPrefix(" ", paint(theme, "warn", `Unanswered: ${missing}`));
|
|
268
278
|
}
|
|
269
279
|
} else if (question) {
|
|
270
|
-
addWithPrefix(" ", theme
|
|
280
|
+
addWithPrefix(" ", paint(theme, "label", sanitizeLine(question.question)));
|
|
271
281
|
lines.push("");
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
+
tail.push(
|
|
300
|
+
...prefixWrapped(" ", paint(theme, "ok", `Selected: ${selection.labels.map(sanitizeLine).join(", ")}`)),
|
|
301
|
+
);
|
|
287
302
|
} else if (selection?.type === "custom") {
|
|
288
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} from "
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
78
|
+
const header = titleRule(this.theme, w, "Rewind");
|
|
92
79
|
|
|
93
80
|
if (this.points.length === 0) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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/
|
|
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
|
|
49
|
+
themed = paint(ctx.ui.theme, "accent", label);
|
|
49
50
|
} catch {
|
|
50
51
|
// No initialized theme in a headless run.
|
|
51
52
|
}
|