@gavin-hu/my-pi-agent 0.8.1 → 0.10.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 +35 -8
- package/README.zh-CN.md +33 -8
- package/assets/bytefly.schema.json +17 -1
- package/assets/chrome.png +0 -0
- package/assets/file-browse.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
- package/extensions/bytefly/README.md +306 -68
- package/extensions/bytefly/block/component.ts +282 -0
- package/extensions/bytefly/block/config.ts +24 -0
- package/extensions/bytefly/block/fallback.ts +120 -0
- package/extensions/bytefly/block/register.ts +164 -0
- package/extensions/bytefly/chrome.ts +97 -0
- package/extensions/bytefly/doctor/format.ts +36 -10
- package/extensions/bytefly/doctor/tools.ts +10 -6
- 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 +49 -21
- package/extensions/bytefly/header/config.ts +28 -21
- package/extensions/bytefly/header/register.ts +6 -1
- package/extensions/bytefly/index.ts +32 -12
- package/extensions/bytefly/message/config.ts +15 -0
- package/extensions/bytefly/message/format.ts +42 -0
- package/extensions/bytefly/message/register.ts +15 -0
- package/extensions/bytefly/notice/component.ts +107 -0
- package/extensions/bytefly/notice/config.ts +41 -0
- package/extensions/bytefly/notice/register.ts +27 -0
- package/extensions/bytefly/pad.ts +42 -0
- 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/component.ts +2 -1
- package/extensions/bytefly/separator/config.ts +16 -8
- package/extensions/bytefly/separator/format.ts +18 -38
- package/extensions/bytefly/separator/register.ts +3 -2
- package/extensions/bytefly/separator/types.ts +0 -10
- package/extensions/bytefly/working/config.ts +20 -0
- package/extensions/bytefly/working/format.ts +25 -0
- package/extensions/bytefly/working/register.ts +88 -0
- package/extensions/document/README.md +12 -5
- package/extensions/document/paging.ts +10 -10
- package/extensions/document/render.ts +17 -16
- package/extensions/document/tool.ts +1 -1
- package/extensions/file-browse/README.md +54 -10
- package/extensions/file-browse/client.ts +110 -2
- package/extensions/file-browse/config.ts +12 -0
- package/extensions/file-browse/diff.ts +187 -0
- package/extensions/file-browse/git.ts +180 -8
- package/extensions/file-browse/html.ts +401 -25
- package/extensions/file-browse/index.ts +15 -4
- package/extensions/file-browse/markdown.ts +33 -0
- package/extensions/file-browse/router.ts +94 -2
- package/extensions/file-browse/server.ts +4 -1
- package/extensions/file-open/README.md +6 -0
- package/extensions/goal/README.md +16 -10
- package/extensions/goal/format.ts +13 -25
- package/extensions/goal/tools.ts +11 -22
- package/extensions/goal/tui.ts +12 -40
- package/extensions/job/README.md +22 -13
- package/extensions/job/format.ts +36 -13
- package/extensions/job/index.ts +26 -21
- package/extensions/job/tools.ts +15 -12
- package/extensions/job/tui.ts +148 -222
- package/extensions/job/ui.ts +24 -49
- package/extensions/memory/README.md +7 -1
- package/extensions/memory/format.ts +5 -3
- package/extensions/memory/tools.ts +4 -3
- package/extensions/memory/tui.ts +32 -36
- package/extensions/plan/README.md +86 -36
- 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 +43 -27
- package/extensions/plan/tui.ts +21 -27
- package/extensions/plan/web-client.ts +386 -40
- package/extensions/plan/web-page.ts +138 -33
- package/extensions/plan/web-router.ts +46 -13
- package/extensions/plan/web.ts +63 -12
- package/extensions/question/README.md +14 -2
- package/extensions/question/answers.ts +25 -18
- package/extensions/question/tools.ts +22 -16
- package/extensions/question/tui.ts +132 -76
- package/extensions/rewind/README.md +3 -1
- package/extensions/rewind/format.ts +35 -2
- package/extensions/rewind/index.ts +3 -1
- package/extensions/rewind/rewind-tui.ts +66 -62
- package/extensions/rewind/snapshot-run.ts +1 -1
- package/extensions/rewind/status.ts +3 -2
- package/extensions/subagent/README.md +6 -0
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +108 -66
- package/extensions/todo/README.md +20 -13
- package/extensions/todo/format.ts +47 -34
- package/extensions/todo/tools.ts +6 -4
- package/extensions/todo/tui.ts +46 -26
- package/extensions/watch/README.md +16 -8
- package/extensions/watch/format.ts +33 -13
- package/extensions/watch/index.ts +29 -24
- package/extensions/watch/tools.ts +16 -9
- package/extensions/watch/tui.ts +142 -196
- package/extensions/watch/ui.ts +24 -46
- package/extensions/web-access/README.md +16 -7
- package/extensions/web-access/fetch/tool.ts +12 -11
- package/extensions/web-access/search/tool.ts +16 -13
- package/extensions/wechat/README.md +8 -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/README.md +6 -0
- 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 +11 -7
- package/lib/format.ts +17 -0
- package/lib/notice.ts +87 -0
- package/lib/requirements.ts +2 -2
- package/lib/{tui.ts → tui/chrome.ts} +17 -34
- package/lib/tui/columns.ts +96 -0
- package/lib/{list-cursor.ts → tui/cursor.ts} +58 -4
- package/lib/tui/gauge.ts +78 -0
- package/lib/{ui.ts → tui/glyphs.ts} +10 -11
- package/lib/tui/indent.ts +166 -0
- package/lib/tui/index.ts +27 -0
- package/lib/tui/line-pane.ts +173 -0
- package/lib/tui/rows.ts +143 -0
- package/lib/tui/rules.ts +94 -0
- package/lib/tui/status-controller.ts +107 -0
- package/lib/tui/tokens.ts +72 -0
- package/lib/web/base.ts +2 -1
- package/lib/web/components.ts +3 -1
- package/lib/web/layout.ts +5 -1
- package/package.json +7 -3
- package/themes/bytefly-dark.json +7 -8
- package/themes/bytefly-light.json +10 -11
- package/assets/bytefly-chrome.png +0 -0
|
@@ -6,8 +6,9 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
6
6
|
import { askHuman } from "../../lib/interaction.ts";
|
|
7
7
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
8
8
|
import { withRailsSuppressed } from "../../lib/rails.ts";
|
|
9
|
-
import { reuseText, toolDetails } from "../../lib/tui.ts";
|
|
10
|
-
import {
|
|
9
|
+
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
10
|
+
import { paint } from "../../lib/tui/tokens.ts";
|
|
11
|
+
import { GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
11
12
|
import { ASK_USER_QUESTION_TOOL } from "../../lib/tool-names.ts";
|
|
12
13
|
import { UNAVAILABLE_TEXT, formatAnswerText, optionSummary, questionHeaders, unavailableResult } from "./answers.ts";
|
|
13
14
|
import { askViaDialogs, type QuestionUI } from "./dialogs.ts";
|
|
@@ -69,16 +70,21 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
69
70
|
},
|
|
70
71
|
|
|
71
72
|
renderCall(args, theme, context) {
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
// The args stream in: `questions` can be absent, and an option can arrive
|
|
74
|
+
// before its label. Read a partial call as a smaller summary and never
|
|
75
|
+
// throw — a throwing renderer makes Pi fall back to its own call line.
|
|
76
|
+
const raw = (args as Partial<AskUserQuestionArgs> | undefined)?.questions;
|
|
77
|
+
const questions = Array.isArray(raw) ? raw : [];
|
|
78
|
+
const count = questions.length;
|
|
74
79
|
const noun = count === 1 ? "question" : "questions";
|
|
75
|
-
const headers = questionHeaders(
|
|
76
|
-
let text = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + theme
|
|
77
|
-
if (headers.length > 0) text += theme
|
|
80
|
+
const headers = questionHeaders(questions);
|
|
81
|
+
let text = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "meta", `${count} ${noun}`);
|
|
82
|
+
if (headers.length > 0) text += paint(theme, "faint", ` (${headers.join(", ")})`);
|
|
78
83
|
|
|
79
|
-
// One
|
|
80
|
-
//
|
|
81
|
-
|
|
84
|
+
// One line per question that offers options, so a long option list stays
|
|
85
|
+
// mapped to its question instead of wrapping on a single row. The line
|
|
86
|
+
// carries no indent of its own; the tool-block frame supplies it.
|
|
87
|
+
for (const line of optionSummary(questions)) text += `\n${paint(theme, "meta", line)}`;
|
|
82
88
|
|
|
83
89
|
const component = reuseText(context?.lastComponent);
|
|
84
90
|
component.setText(text);
|
|
@@ -94,26 +100,26 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
94
100
|
return component;
|
|
95
101
|
}
|
|
96
102
|
if (details.unavailable) {
|
|
97
|
-
component.setText(theme
|
|
103
|
+
component.setText(paint(theme, "faint", UNAVAILABLE_TEXT));
|
|
98
104
|
return component;
|
|
99
105
|
}
|
|
100
106
|
if (details.answers.length === 0) {
|
|
101
|
-
component.setText(theme
|
|
107
|
+
component.setText(paint(theme, "warn", "Cancelled"));
|
|
102
108
|
return component;
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
const lines = details.answers.map((answer) => {
|
|
106
|
-
const header = theme
|
|
112
|
+
const header = paint(theme, "accent", sanitizeLine(answer.header));
|
|
107
113
|
if (answer.wasCustom) {
|
|
108
114
|
const values = answer.values.map((value) => sanitizeLine(value)).join("; ");
|
|
109
|
-
return `${theme
|
|
115
|
+
return `${paint(theme, "ok", `${GLYPHS.check} `)}${header}: ${paint(theme, "meta", "(wrote) ")}${values}`;
|
|
110
116
|
}
|
|
111
117
|
const numbered = answer.labels
|
|
112
118
|
.map((label, i) => `${answer.indices?.[i] ?? "?"}. ${sanitizeLine(label)}`)
|
|
113
119
|
.join(", ");
|
|
114
|
-
return `${theme
|
|
120
|
+
return `${paint(theme, "ok", `${GLYPHS.check} `)}${header}: ${numbered}`;
|
|
115
121
|
});
|
|
116
|
-
if (details.cancelled) lines.push(theme
|
|
122
|
+
if (details.cancelled) lines.push(paint(theme, "warn", "(cancelled)"));
|
|
117
123
|
// A blank line separates the call header from a multi-line answer body.
|
|
118
124
|
component.setText(lines.length > 1 ? `\n${lines.join("\n")}` : lines.join("\n"));
|
|
119
125
|
return component;
|
|
@@ -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;
|
|
@@ -10,6 +10,8 @@ rewind uses Pi's session tree, so the branch you leave is not erased.
|
|
|
10
10
|
This is the `rewind` extension entrypoint; it is one of two sibling git
|
|
11
11
|
extensions in this package (`worktree` is the other).
|
|
12
12
|
|
|
13
|
+
<img src="../../assets/rewind.png" alt="The /rewind screen: prompt rows newest first, a diamond marking prompts with a code snapshot, and the focused prompt's snapshot detail" width="100%">
|
|
14
|
+
|
|
13
15
|
```
|
|
14
16
|
pi --extension ./extensions/rewind # load this extension alone
|
|
15
17
|
pi -e . # load the whole @gavin-hu/my-pi-agent package
|
|
@@ -197,6 +199,6 @@ commit. These refs are not branches and are not touched by `git push --all`.
|
|
|
197
199
|
| `runtime.ts` | Root/config caches and the snapshot/list/plan/restore operations. |
|
|
198
200
|
| `timeline.ts` | Pure: prompts on the branch, paired with their snapshots. |
|
|
199
201
|
| `rewind.ts` | Scope choice, confirmation, and applying code + conversation rewind. |
|
|
200
|
-
| `rewind-tui.ts` | The selectable, width-safe timeline component. |
|
|
202
|
+
| `rewind-tui.ts` | The selectable, width-safe timeline component, drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
|
|
201
203
|
| `commands.ts` | The `/rewind` command. |
|
|
202
204
|
| `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. */
|
|
@@ -28,8 +28,13 @@ function truncate(text: string, max = LABEL_WIDTH): string {
|
|
|
28
28
|
/**
|
|
29
29
|
* A short, single-line summary of a user prompt for storage and display.
|
|
30
30
|
* Takes the first non-empty line, collapses whitespace, and truncates.
|
|
31
|
+
*
|
|
32
|
+
* A run started by a custom message (a wake) has no user prompt: `event.prompt`
|
|
33
|
+
* is typed `string` but is `undefined` there, so a missing prompt is accepted
|
|
34
|
+
* and yields no summary rather than throwing.
|
|
31
35
|
*/
|
|
32
|
-
export function summarizePrompt(text: string): string | undefined {
|
|
36
|
+
export function summarizePrompt(text: string | undefined): string | undefined {
|
|
37
|
+
if (typeof text !== "string") return undefined;
|
|
33
38
|
const line = text
|
|
34
39
|
.split(/\r?\n/)
|
|
35
40
|
.map((part) => sanitizeLine(part))
|
|
@@ -55,6 +60,34 @@ export function formatChangeSummary(changed: number, removed: number): string {
|
|
|
55
60
|
return removed > 0 ? `${changedText}, ${removed} created` : changedText;
|
|
56
61
|
}
|
|
57
62
|
|
|
63
|
+
/** The parts of one timeline row, for the shared list-row grammar. */
|
|
64
|
+
export interface RewindRowParts {
|
|
65
|
+
/** The `⬥` snapshot marker, or a blank to keep the label column aligned. */
|
|
66
|
+
glyph: string;
|
|
67
|
+
/** The sanitized prompt summary. */
|
|
68
|
+
label: string;
|
|
69
|
+
/** The right-aligned relative time. */
|
|
70
|
+
trailing: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The timeline row's parts, composed by the screen through `listRow`.
|
|
75
|
+
* `formatRewindRow` below stays the plain-text form the headless listing uses;
|
|
76
|
+
* the screen wants the marker, colors, and column math of the shared grammar.
|
|
77
|
+
*/
|
|
78
|
+
export function formatRewindParts(
|
|
79
|
+
summary: string | undefined,
|
|
80
|
+
hasSnapshot: boolean,
|
|
81
|
+
timestamp: number,
|
|
82
|
+
now = Date.now(),
|
|
83
|
+
): RewindRowParts {
|
|
84
|
+
return {
|
|
85
|
+
glyph: hasSnapshot ? GLYPHS.snapshot : " ",
|
|
86
|
+
label: sanitizeLine(summary || "(no text)"),
|
|
87
|
+
trailing: formatRelativeTime(timestamp, now),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
58
91
|
/**
|
|
59
92
|
* Id-free row for the rewind timeline: `⬥ "prompt" 2m ago`.
|
|
60
93
|
* The leading glyph marks a point with a code snapshot; the time is
|
|
@@ -37,7 +37,9 @@ export function registerRewind(pi: ExtensionAPI): void {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
// A new prompt begins a task: the run resolves its snapshot decision up
|
|
40
|
-
// front, so the `tool_call` guard can stay synchronous.
|
|
40
|
+
// front, so the `tool_call` guard can stay synchronous. A run started by a
|
|
41
|
+
// custom message (a wake) has no user prompt — `event.prompt` is undefined
|
|
42
|
+
// there despite its type — so `beginRun` accepts a missing prompt.
|
|
41
43
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
42
44
|
await run.beginRun(event.prompt, ctx);
|
|
43
45
|
});
|