@gavin-hu/my-pi-agent 0.9.0 → 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 +28 -2
- package/README.zh-CN.md +27 -2
- package/assets/bytefly.schema.json +17 -1
- package/assets/chrome.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 +261 -36
- 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/tools.ts +5 -2
- package/extensions/bytefly/header/component.ts +21 -10
- package/extensions/bytefly/header/config.ts +0 -3
- 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/separator/component.ts +2 -1
- package/extensions/bytefly/separator/format.ts +17 -10
- package/extensions/bytefly/separator/register.ts +3 -2
- 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 +11 -4
- package/extensions/document/paging.ts +10 -10
- package/extensions/document/render.ts +7 -7
- package/extensions/file-browse/README.md +3 -1
- package/extensions/file-browse/client.ts +9 -2
- package/extensions/file-browse/git.ts +4 -7
- package/extensions/file-browse/html.ts +27 -17
- package/extensions/file-open/README.md +6 -0
- package/extensions/goal/README.md +16 -10
- package/extensions/goal/format.ts +7 -20
- package/extensions/goal/tools.ts +5 -17
- package/extensions/goal/tui.ts +12 -40
- package/extensions/job/README.md +21 -12
- package/extensions/job/format.ts +2 -2
- package/extensions/job/index.ts +25 -20
- package/extensions/job/tools.ts +5 -3
- package/extensions/job/tui.ts +13 -8
- package/extensions/memory/README.md +6 -0
- package/extensions/memory/format.ts +5 -3
- package/extensions/plan/README.md +32 -12
- package/extensions/plan/tools.ts +20 -5
- package/extensions/plan/web-client.ts +58 -17
- package/extensions/plan/web-page.ts +34 -9
- package/extensions/plan/web-router.ts +40 -9
- package/extensions/plan/web.ts +22 -2
- package/extensions/question/README.md +13 -1
- package/extensions/question/answers.ts +25 -18
- package/extensions/question/tools.ts +11 -6
- package/extensions/rewind/README.md +2 -0
- package/extensions/rewind/format.ts +6 -1
- package/extensions/rewind/index.ts +3 -1
- package/extensions/rewind/snapshot-run.ts +1 -1
- package/extensions/subagent/README.md +6 -0
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +56 -29
- package/extensions/todo/README.md +19 -12
- package/extensions/todo/format.ts +17 -25
- package/extensions/todo/tools.ts +2 -1
- package/extensions/todo/tui.ts +3 -2
- package/extensions/watch/README.md +14 -6
- package/extensions/watch/format.ts +2 -2
- package/extensions/watch/index.ts +28 -23
- package/extensions/watch/tools.ts +5 -3
- package/extensions/watch/tui.ts +12 -7
- package/extensions/web-access/README.md +16 -7
- package/extensions/web-access/fetch/tool.ts +1 -1
- package/extensions/web-access/search/tool.ts +3 -3
- package/extensions/wechat/README.md +6 -0
- package/extensions/worktree/README.md +6 -0
- package/lib/README.md +2 -1
- package/lib/format.ts +17 -0
- package/lib/notice.ts +87 -0
- package/lib/tui/chrome.ts +8 -4
- package/lib/tui/cursor.ts +2 -1
- package/lib/tui/glyphs.ts +10 -11
- package/lib/tui/indent.ts +166 -0
- package/lib/tui/index.ts +3 -1
- package/lib/tui/rows.ts +39 -5
- package/lib/tui/rules.ts +9 -3
- package/lib/tui/tokens.ts +14 -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
|
@@ -15,8 +15,9 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
|
15
15
|
import { Container, Markdown, Spacer, Text, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
16
16
|
import { sanitizeLine, shortenPath, stripControlChars } from "../../lib/format.ts";
|
|
17
17
|
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
18
|
-
import { expandHint, GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
18
|
+
import { expandHint, GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
19
19
|
import { centeredRule } from "../../lib/tui/rules.ts";
|
|
20
|
+
import { BODY_INDENT } from "../../lib/tui/indent.ts";
|
|
20
21
|
import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
|
|
21
22
|
import { aggregateUsage, clip, formatToolCall, formatUsageStats } from "./format.ts";
|
|
22
23
|
import { COLLAPSED_ERROR_MAX, COLLAPSED_ITEM_COUNT, COLLAPSED_TEXT_LINES, type SubagentArgs } from "./schema.ts";
|
|
@@ -187,7 +188,7 @@ function resultHeader(
|
|
|
187
188
|
stepPrefix: string,
|
|
188
189
|
now: number,
|
|
189
190
|
): string {
|
|
190
|
-
let header = `${statusIcon(status, theme, now)} ${theme
|
|
191
|
+
let header = `${statusIcon(status, theme, now)} ${paint(theme, "accent", theme.bold(sanitizeLine(result.agent)))}`;
|
|
191
192
|
if (result.agentSource && result.agentSource !== "builtin" && result.agentSource !== "unknown") {
|
|
192
193
|
header += paint(theme, "faint", ` [${sanitizeLine(result.agentSource)}]`);
|
|
193
194
|
}
|
|
@@ -195,12 +196,13 @@ function resultHeader(
|
|
|
195
196
|
return header + statusSuffix(result, status, theme, now);
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
/** Prefix each line of intermediate assistant text with
|
|
199
|
-
function
|
|
199
|
+
/** Prefix each line of intermediate assistant text with the block's body indent. */
|
|
200
|
+
function indentBody(text: string, theme: Theme): string {
|
|
201
|
+
const indent = " ".repeat(BODY_INDENT);
|
|
200
202
|
return stripControlChars(text)
|
|
201
203
|
.trim()
|
|
202
204
|
.split("\n")
|
|
203
|
-
.map((line) =>
|
|
205
|
+
.map((line) => indent + theme.fg("toolOutput", line))
|
|
204
206
|
.join("\n");
|
|
205
207
|
}
|
|
206
208
|
|
|
@@ -233,47 +235,72 @@ export interface SubagentCallContext {
|
|
|
233
235
|
lastComponent?: Component;
|
|
234
236
|
}
|
|
235
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The call's arguments as Pi hands them to `renderCall`. While the model streams
|
|
240
|
+
* a call, a field can still be missing, or arrive with a type the schema will
|
|
241
|
+
* later reject, so the call line reads every one defensively and never throws: a
|
|
242
|
+
* throwing renderer makes Pi fall back to its own generic call line.
|
|
243
|
+
*/
|
|
244
|
+
export type StreamedSubagentArgs = Partial<Record<keyof SubagentArgs, unknown>>;
|
|
245
|
+
|
|
246
|
+
/** One field of a partially-streamed element, or undefined when it has not arrived. */
|
|
247
|
+
function streamedText(element: unknown, key: "agent" | "task" | "cwd"): string | undefined {
|
|
248
|
+
if (typeof element !== "object" || element === null) return undefined;
|
|
249
|
+
const value = (element as Record<string, unknown>)[key];
|
|
250
|
+
return typeof value === "string" ? value : undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
236
253
|
/** Preview the requested mode and its agents, reusing the slot's `Text`. */
|
|
237
|
-
export function renderSubagentCall(
|
|
254
|
+
export function renderSubagentCall(
|
|
255
|
+
args: StreamedSubagentArgs | undefined,
|
|
256
|
+
theme: Theme,
|
|
257
|
+
context?: SubagentCallContext,
|
|
258
|
+
): Component {
|
|
238
259
|
const text = reuseText(context?.lastComponent);
|
|
239
|
-
text.setText(callText(args, theme, context));
|
|
260
|
+
text.setText(callText(args ?? {}, theme, context));
|
|
240
261
|
return text;
|
|
241
262
|
}
|
|
242
263
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
264
|
+
/**
|
|
265
|
+
* The call-line string for the requested mode. Continuation lines carry no
|
|
266
|
+
* indent of their own: the tool-block frame headlines the first line and
|
|
267
|
+
* indents the rest by `BODY_INDENT`.
|
|
268
|
+
*/
|
|
269
|
+
function callText(args: StreamedSubagentArgs, theme: Theme, context?: { cwd?: string }): string {
|
|
245
270
|
const title =
|
|
246
271
|
theme.fg("toolTitle", theme.bold("subagent ")) + (args.readOnly ? paint(theme, "faint", "read-only ") : "");
|
|
247
|
-
const cwdNote = (cwd?:
|
|
248
|
-
if (!cwd || cwd === context?.cwd) return "";
|
|
272
|
+
const cwdNote = (cwd?: unknown) => {
|
|
273
|
+
if (typeof cwd !== "string" || !cwd || cwd === context?.cwd) return "";
|
|
249
274
|
return paint(theme, "faint", ` in ${shortenPath(cwd)}`);
|
|
250
275
|
};
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
let text =
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
276
|
+
const chain = Array.isArray(args.chain) ? args.chain : [];
|
|
277
|
+
if (chain.length > 0) {
|
|
278
|
+
let text =
|
|
279
|
+
title + paint(theme, "label", `chain${SEPARATORS.item}${chain.length} step${chain.length > 1 ? "s" : ""}`);
|
|
280
|
+
for (let i = 0; i < Math.min(chain.length, 3); i++) {
|
|
281
|
+
const step = chain[i];
|
|
282
|
+
const task = (streamedText(step, "task") ?? "…").replace(/\{previous\}/g, "");
|
|
283
|
+
text += `\n${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "label", sanitizeLine(streamedText(step, "agent") || "…"))}${paint(theme, "meta", ` ${clip(sanitizeLine(task), 40)}`)}${cwdNote(streamedText(step, "cwd"))}`;
|
|
258
284
|
}
|
|
259
|
-
if (
|
|
285
|
+
if (chain.length > 3) text += `\n${paint(theme, "meta", `… +${chain.length - 3} more`)}`;
|
|
260
286
|
return text;
|
|
261
287
|
}
|
|
262
288
|
|
|
263
|
-
|
|
289
|
+
const tasks = Array.isArray(args.tasks) ? args.tasks : [];
|
|
290
|
+
if (tasks.length > 0) {
|
|
264
291
|
let text =
|
|
265
|
-
title + paint(theme, "
|
|
266
|
-
for (let i = 0; i < Math.min(
|
|
267
|
-
const task =
|
|
268
|
-
text += `\n
|
|
292
|
+
title + paint(theme, "label", `parallel${SEPARATORS.item}${tasks.length} task${tasks.length > 1 ? "s" : ""}`);
|
|
293
|
+
for (let i = 0; i < Math.min(tasks.length, 3); i++) {
|
|
294
|
+
const task = tasks[i];
|
|
295
|
+
text += `\n${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "label", sanitizeLine(streamedText(task, "agent") || "…"))}${paint(theme, "meta", ` ${clip(sanitizeLine(streamedText(task, "task") ?? "…"), 40)}`)}${cwdNote(streamedText(task, "cwd"))}`;
|
|
269
296
|
}
|
|
270
|
-
if (
|
|
297
|
+
if (tasks.length > 3) text += `\n${paint(theme, "meta", `… +${tasks.length - 3} more`)}`;
|
|
271
298
|
return text;
|
|
272
299
|
}
|
|
273
300
|
|
|
274
|
-
const agentName = sanitizeLine(args
|
|
275
|
-
let text = title + paint(theme, "
|
|
276
|
-
text += `\n
|
|
301
|
+
const agentName = sanitizeLine(streamedText(args, "agent") || "…");
|
|
302
|
+
let text = title + paint(theme, "label", agentName) + cwdNote(streamedText(args, "cwd"));
|
|
303
|
+
text += `\n${paint(theme, "meta", clip(sanitizeLine(streamedText(args, "task") ?? "…"), 60))}`;
|
|
277
304
|
return text;
|
|
278
305
|
}
|
|
279
306
|
|
|
@@ -316,7 +343,7 @@ function addExpandedResult(
|
|
|
316
343
|
);
|
|
317
344
|
renderedOutput = true;
|
|
318
345
|
} else if (item.text.trim() && item.text !== trailingOutput) {
|
|
319
|
-
container.addChild(new Text(
|
|
346
|
+
container.addChild(new Text(indentBody(item.text, theme), 0, 0));
|
|
320
347
|
renderedOutput = true;
|
|
321
348
|
}
|
|
322
349
|
}
|
|
@@ -8,12 +8,20 @@ about to end after mutating work without a todo update, the extension injects
|
|
|
8
8
|
one short reminder and forces a continuation, so the list does not silently
|
|
9
9
|
drift behind the work.
|
|
10
10
|
|
|
11
|
+
<img src="../../assets/todo.png" alt="The /todos screen: a Todos title rule, a 3/7 completed summary, and the list with check, spinner, and circle glyphs" width="100%">
|
|
12
|
+
|
|
11
13
|
```
|
|
12
14
|
pi --extension ./extensions/todo # load just this extension
|
|
13
15
|
pi -e . # load the whole @gavin-hu/my-pi-agent package
|
|
14
16
|
pi install ./ # install the package
|
|
15
17
|
```
|
|
16
18
|
|
|
19
|
+
## Tool rendering
|
|
20
|
+
|
|
21
|
+
How the `todo` call and its result look in the transcript — the call line as a headline and the body indented under it:
|
|
22
|
+
|
|
23
|
+
<img src="../../assets/tools/todo.png" alt="The todo tool's result: the call line and an indented body with the progress line and the task rows below it, each led by a check, spinner, or circle glyph" width="100%">
|
|
24
|
+
|
|
17
25
|
## What it does
|
|
18
26
|
|
|
19
27
|
- Registers one model-callable tool, `todo`, that **replaces** the task list.
|
|
@@ -49,22 +57,20 @@ Todos · 1/3 · Writing the tests
|
|
|
49
57
|
`/todos` is the expanded view and keeps the model's list order (use it to see
|
|
50
58
|
the plan as written).
|
|
51
59
|
|
|
52
|
-
In the transcript, the call line
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
submitted order:
|
|
60
|
+
In the transcript, the call line carries progress only (`todo → 1/4`); the
|
|
61
|
+
result keeps the `✓` / `◒` / `◌` rows, directly under the call line. The item
|
|
62
|
+
text lives on the rows, so the call line never repeats it. Collapsed, it leads
|
|
63
|
+
with active work; expanded, it keeps the submitted order:
|
|
57
64
|
|
|
58
65
|
```
|
|
59
|
-
todo → 1/4
|
|
60
|
-
|
|
66
|
+
todo → 1/4
|
|
61
67
|
◒ Writing tests
|
|
62
68
|
◌ Ship it
|
|
63
69
|
◌ Write docs
|
|
64
70
|
✓ Write schema
|
|
65
71
|
```
|
|
66
72
|
|
|
67
|
-
Progress lives on the call line, so the
|
|
73
|
+
Progress lives on the call line, so the rows have no footer; the persistent
|
|
68
74
|
widget and `/todos` still show the count. A capped collapsed rail ends with
|
|
69
75
|
`… N more` and the `app.tools.expand` hint.
|
|
70
76
|
|
|
@@ -204,10 +210,11 @@ depends on config.
|
|
|
204
210
|
widget is a single line (`currentTodo` picks the `in_progress` item, else the
|
|
205
211
|
first pending); `/todos` keeps the submitted order, and the transcript result
|
|
206
212
|
orders by status via `compareByActivity` so active work is never hidden. The
|
|
207
|
-
transcript result draws the
|
|
208
|
-
|
|
209
|
-
long items with
|
|
210
|
-
the
|
|
213
|
+
transcript result draws the glyph rows at the block's body indent (the frame
|
|
214
|
+
supplies `BODY_INDENT`; `GLYPH_GAP` comes from
|
|
215
|
+
[`lib/tui/glyphs.ts`](../../lib/tui/glyphs.ts)), wrapping long items with
|
|
216
|
+
continuation rows aligned under the text. Progress lives on the call line, so
|
|
217
|
+
the rows carry no footer.
|
|
211
218
|
- **The transcript reuses its components.** `renderCall` reuses the slot's
|
|
212
219
|
`Text`, and `renderResult` reuses the `TodoResult` view via
|
|
213
220
|
`context.lastComponent`, updating each in place instead of allocating on every
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
7
|
+
import { expandHint, GLYPH_GAP, GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
8
8
|
import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
|
|
9
9
|
import { completedCount, currentTodo } from "./state.ts";
|
|
10
10
|
import type { Todo, TodoStatus } from "./types.ts";
|
|
@@ -17,9 +17,6 @@ export function compareByActivity(a: Todo, b: Todo): number {
|
|
|
17
17
|
return STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/** Longest first-item preview shown in the transcript call line. */
|
|
21
|
-
const CALL_PREVIEW_WIDTH = 40;
|
|
22
|
-
|
|
23
20
|
/** Plain status glyph per status; `todoGlyph` adds the theme color. */
|
|
24
21
|
const STATUS_GLYPH: Record<TodoStatus, string> = {
|
|
25
22
|
pending: GLYPHS.pending,
|
|
@@ -107,22 +104,16 @@ export function formatTodoText(todos: Todo[]): string {
|
|
|
107
104
|
* One-line body for the transcript call renderer.
|
|
108
105
|
*
|
|
109
106
|
* The renderer owns the styled `todo ` title, so this returns only the body.
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* the
|
|
107
|
+
* Progress is the summary; the result rail lists the items (active work first),
|
|
108
|
+
* so the call line does not repeat the current item. `todos` is `undefined`
|
|
109
|
+
* while the call's arguments are still streaming, which is distinct from an
|
|
110
|
+
* empty list (a real clear). `argsComplete` disambiguates the tail end of the
|
|
111
|
+
* stream.
|
|
113
112
|
*/
|
|
114
113
|
export function formatCallText(todos: Todo[] | undefined, argsComplete = true): string {
|
|
115
114
|
if (todos === undefined) return argsComplete ? "→ clear list" : "→ …";
|
|
116
115
|
if (todos.length === 0) return "→ clear list";
|
|
117
|
-
|
|
118
|
-
const current = currentTodo(todos);
|
|
119
|
-
const label = current ? (current.activeForm ?? current.content).trim() : "completed";
|
|
120
|
-
if (!label) return argsComplete ? `→ ${progressCount(todos)} · …` : "→ …";
|
|
121
|
-
// `strict` drops a wide grapheme that would cross the boundary, so the slice
|
|
122
|
-
// plus ellipsis never exceeds `CALL_PREVIEW_WIDTH` columns.
|
|
123
|
-
const preview =
|
|
124
|
-
visibleWidth(label) > CALL_PREVIEW_WIDTH ? `${sliceByColumn(label, 0, CALL_PREVIEW_WIDTH - 1, true)}…` : label;
|
|
125
|
-
return `→ ${progressCount(todos)} · ${preview}`;
|
|
116
|
+
return `→ ${progressCount(todos)}`;
|
|
126
117
|
}
|
|
127
118
|
|
|
128
119
|
/** Rows and footer for the transcript result rail. */
|
|
@@ -134,18 +125,19 @@ export interface TodoRailInput {
|
|
|
134
125
|
}
|
|
135
126
|
|
|
136
127
|
/**
|
|
137
|
-
* Transcript result rail: each row is
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
128
|
+
* Transcript result rail: each row is led by its status glyph, and its label
|
|
129
|
+
* wrapped with continuation rows aligned under the text. A trailing `… N more`
|
|
130
|
+
* follows when rows were capped. Progress lives on the call line, so the rail
|
|
131
|
+
* carries no footer. Every line is clipped to `width`.
|
|
132
|
+
*
|
|
133
|
+
* The rail draws at its own left edge; the tool-block frame supplies
|
|
134
|
+
* `BODY_INDENT`, so the rail must not add it again.
|
|
142
135
|
*/
|
|
143
136
|
export function todoRailLines(input: TodoRailInput, theme: Theme, width: number): string[] {
|
|
144
137
|
const w = Math.max(1, width);
|
|
145
138
|
// All three status glyphs are single-column, so the text column is fixed.
|
|
146
|
-
const textColumn =
|
|
139
|
+
const textColumn = 1 + GLYPH_GAP;
|
|
147
140
|
const inner = Math.max(1, w - textColumn);
|
|
148
|
-
const pad = " ".repeat(BODY_INDENT);
|
|
149
141
|
const gap = " ".repeat(GLYPH_GAP);
|
|
150
142
|
const continuation = " ".repeat(textColumn);
|
|
151
143
|
const lines: string[] = [];
|
|
@@ -153,7 +145,7 @@ export function todoRailLines(input: TodoRailInput, theme: Theme, width: number)
|
|
|
153
145
|
const glyph = todoGlyph(todo, theme);
|
|
154
146
|
const wrapped = wrapTextWithAnsi(todoLabel(todo, theme), inner);
|
|
155
147
|
wrapped.forEach((line, index) => {
|
|
156
|
-
lines.push(index === 0 ? `${
|
|
148
|
+
lines.push(index === 0 ? `${glyph}${gap}${line}` : `${continuation}${line}`);
|
|
157
149
|
});
|
|
158
150
|
}
|
|
159
151
|
if (input.more && input.more > 0) {
|
package/extensions/todo/tools.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { JsonValue } from "@earendil-works/pi-ai";
|
|
|
14
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
16
16
|
import { TODO_TOOL } from "../../lib/tool-names.ts";
|
|
17
|
+
import { GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
17
18
|
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
18
19
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
19
20
|
import { compareByActivity, formatCallText, formatTodoText, type TodoRailInput } from "./format.ts";
|
|
@@ -118,7 +119,7 @@ export function registerTools(pi: ExtensionAPI, runtime: TodoRuntime): void {
|
|
|
118
119
|
return text;
|
|
119
120
|
}
|
|
120
121
|
if (details.todos.length === 0) {
|
|
121
|
-
text.setText(paint(theme, "ok",
|
|
122
|
+
text.setText(paint(theme, "ok", `${GLYPHS.check} `) + paint(theme, "meta", "Cleared the todo list"));
|
|
122
123
|
return text;
|
|
123
124
|
}
|
|
124
125
|
|
package/extensions/todo/tui.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { clampScroll, fitRows, formatRange, isCloseKey, navigateScroll, wheelDel
|
|
|
12
12
|
import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
|
|
13
13
|
import { clip } from "../../lib/tui/columns.ts";
|
|
14
14
|
import { emptyState, listRow } from "../../lib/tui/rows.ts";
|
|
15
|
+
import { SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
15
16
|
import { titleRule } from "../../lib/tui/rules.ts";
|
|
16
17
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
17
18
|
import {
|
|
@@ -50,10 +51,10 @@ function todoRow(todo: Todo, theme: Theme, width: number): string {
|
|
|
50
51
|
* `jobs` widgets. Callers clip it to the available width.
|
|
51
52
|
*/
|
|
52
53
|
function todoLine(todos: Todo[], theme: Theme): string {
|
|
53
|
-
const head = `${paint(theme, "accent", "Todos")}
|
|
54
|
+
const head = `${paint(theme, "accent", "Todos")}${paint(theme, "faint", SEPARATORS.item)}${paint(theme, "faint", progressCount(todos))}`;
|
|
54
55
|
const current = currentTodo(todos);
|
|
55
56
|
if (!current) return `${head} ${paint(theme, "faint", "completed")}`;
|
|
56
|
-
return `${head}
|
|
57
|
+
return `${head}${paint(theme, "faint", SEPARATORS.item)}${todoLabel(current, theme)}`;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** Transcript result block: the indented glyph rail, wrapping at the render width. */
|
|
@@ -11,6 +11,12 @@ pi -e . # load the whole @gavin-hu/my-pi-agent pack
|
|
|
11
11
|
pi install ./ # install the package
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
## Tool rendering
|
|
15
|
+
|
|
16
|
+
How the `watch` call and its result look in the transcript — the call line as a headline and the body indented under it:
|
|
17
|
+
|
|
18
|
+
<img src="../../assets/tools/watch.png" alt="The watch tool's result: the call line and an indented body with a running file watcher and an exited command watcher" width="100%">
|
|
19
|
+
|
|
14
20
|
## What it does
|
|
15
21
|
|
|
16
22
|
- Registers one model-callable tool, `watch`, with actions `add`, `list`,
|
|
@@ -85,9 +91,11 @@ and the command's `pid`/`exitCode`/`signal` when applicable.
|
|
|
85
91
|
|
|
86
92
|
### Transcript
|
|
87
93
|
|
|
88
|
-
`add` and `stop` render one outcome line; `list` renders
|
|
89
|
-
capped at eight rows
|
|
90
|
-
|
|
94
|
+
`add` and `stop` render one outcome line; `list` renders the `/watch` rows
|
|
95
|
+
capped at eight rows, minus the selection-marker column (`listRow`'s
|
|
96
|
+
`marker: false`) that only the interactive screen needs; `matches` and `logs`
|
|
97
|
+
render the tail of the sanitized lines, with the hidden count noted above and
|
|
98
|
+
`(ctrl+o to expand)`.
|
|
91
99
|
|
|
92
100
|
### Command: /watch
|
|
93
101
|
|
|
@@ -172,8 +180,8 @@ other.
|
|
|
172
180
|
| Annotations | `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. |
|
|
173
181
|
| `outputSchema` | None; the result carries `details` only (no `structuredContent` mirror). |
|
|
174
182
|
| State storage | In-memory runtime closure; no registry, no `getBranch()` rebuild. |
|
|
175
|
-
| Injected context | A
|
|
176
|
-
| Lifecycle | `session_start` loads config and re-arms `onMatch`; `session_tree` repaints; `agent_start`/`agent_settled` track busy; `before_agent_start`
|
|
183
|
+
| Injected context | A shared transcript notice (`my-pi-agent/notice`), rendered by bytefly; pending matches are read from the runtime, never from the transcript. |
|
|
184
|
+
| Lifecycle | `session_start` loads config and re-arms `onMatch`; `session_tree` repaints; `agent_start`/`agent_settled` track busy; `before_agent_start` posts pending matches as a notice; `context` prunes stale notices of its own source; `session_shutdown` kills commands and clears chips. |
|
|
177
185
|
|
|
178
186
|
`watch` is gated by `PI_DISABLED_EXTENSIONS` like every extension.
|
|
179
187
|
|
|
@@ -193,7 +201,7 @@ turn — so a match is never dropped when the wake window is busy.
|
|
|
193
201
|
|
|
194
202
|
| Module | Responsibility |
|
|
195
203
|
|---|---|
|
|
196
|
-
| `index.ts` | Extension factory: registers the tool and command, wires the lifecycle and
|
|
204
|
+
| `index.ts` | Extension factory: registers the tool and command, wires the lifecycle and the shared notices. |
|
|
197
205
|
| `tools.ts` | The `watch` tool definition, execution, and transcript rendering. |
|
|
198
206
|
| `schema.ts` | TypeBox parameters and pure `normalizeCall` validation. |
|
|
199
207
|
| `types.ts` | `WatchAction`/`WatchStatus`, `WatchRecord`, `WatchDetails`, `PendingMatch`. |
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
14
14
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
15
|
-
import { GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
15
|
+
import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
16
16
|
import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
|
|
17
17
|
import type { PendingMatch, WatchRecord, WatchStatus } from "./types.ts";
|
|
18
18
|
|
|
@@ -195,7 +195,7 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
|
|
|
195
195
|
|
|
196
196
|
/** One-line outcome for a watcher, themed for the transcript result. */
|
|
197
197
|
export function formatWatchOutcomeLine(watch: WatchRecord, theme: Theme, now = Date.now()): string {
|
|
198
|
-
const sep = paint(theme, "faint",
|
|
198
|
+
const sep = paint(theme, "faint", SEPARATORS.item);
|
|
199
199
|
const age = Math.max(0, (watch.endedAt ?? now) - watch.startedAt);
|
|
200
200
|
const outcome =
|
|
201
201
|
watch.status === "error"
|
|
@@ -3,19 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Registers the `watch` tool and `/watch` command. Watchers read a file or a
|
|
5
5
|
* command's merged output, match each new line against a regular expression,
|
|
6
|
-
* and alert the user; matching lines are reported to the model
|
|
7
|
-
* turn, or wake a turn immediately when the
|
|
8
|
-
* are session-only and stopped on shutdown.
|
|
6
|
+
* and alert the user; matching lines are reported to the model — and shown as a
|
|
7
|
+
* bytefly notice — at the next turn, or wake a turn immediately when the
|
|
8
|
+
* watcher opted into `wake`. Watchers are session-only and stopped on shutdown.
|
|
9
9
|
*
|
|
10
10
|
* Load with: pi --extension ./extensions/watch
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
14
13
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
14
|
import { isExtensionEnabled } from "../../lib/env.ts";
|
|
15
|
+
import { sanitizeLine } from "../../lib/format.ts";
|
|
16
|
+
import { noticeMessage, type NoticeMessage, pruneStaleNotices } from "../../lib/notice.ts";
|
|
16
17
|
import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
17
18
|
import { registerCommands } from "./commands.ts";
|
|
18
|
-
import { formatPendingMatch } from "./format.ts";
|
|
19
|
+
import { clipLabel, formatPendingMatch } from "./format.ts";
|
|
19
20
|
import { createWatchRuntime, type WatchRuntime } from "./runtime.ts";
|
|
20
21
|
import { registerTools } from "./tools.ts";
|
|
21
22
|
import type { PendingMatch } from "./types.ts";
|
|
@@ -23,13 +24,10 @@ import type { PendingMatch } from "./types.ts";
|
|
|
23
24
|
/** Marker embedded in the injected match context. */
|
|
24
25
|
const WATCH_CONTEXT_MARKER = "[WATCH MATCHES]";
|
|
25
26
|
|
|
26
|
-
/** Custom-entry type used for the injected match message. */
|
|
27
|
-
export const WATCH_CONTEXT_TYPE = "watch-context";
|
|
28
|
-
|
|
29
27
|
/** At most this many match lines are injected in one message. */
|
|
30
28
|
const MAX_CONTEXT_MATCHES = 20;
|
|
31
29
|
|
|
32
|
-
/** Build the
|
|
30
|
+
/** Build the model-facing text for the pending-match notice. */
|
|
33
31
|
export function buildWatchContext(matches: PendingMatch[]): string {
|
|
34
32
|
const shown = matches.slice(0, MAX_CONTEXT_MATCHES);
|
|
35
33
|
const lines = shown.map(formatPendingMatch);
|
|
@@ -38,8 +36,21 @@ export function buildWatchContext(matches: PendingMatch[]): string {
|
|
|
38
36
|
return `${WATCH_CONTEXT_MARKER}\n${matches.length} watched line${matches.length === 1 ? "" : "s"} matched:\n${lines.join("\n")}${suffix}\nRead more with \`watch matches <id>\`.`;
|
|
39
37
|
}
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
/** The shared notice for pending matches: model text plus renderer details. */
|
|
40
|
+
function watchNotice(matches: PendingMatch[]): NoticeMessage {
|
|
41
|
+
const count = matches.length;
|
|
42
|
+
return noticeMessage({
|
|
43
|
+
content: buildWatchContext(matches),
|
|
44
|
+
details: {
|
|
45
|
+
source: "watch",
|
|
46
|
+
title: `${count} watched line${count === 1 ? "" : "s"} matched`,
|
|
47
|
+
items: matches.slice(0, MAX_CONTEXT_MATCHES).map((match) => ({
|
|
48
|
+
label: `${match.watcherId} ${clipLabel(match.label)}`,
|
|
49
|
+
detail: sanitizeLine(match.line),
|
|
50
|
+
severity: "warn",
|
|
51
|
+
})),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
export interface WatchDeps {
|
|
@@ -64,10 +75,7 @@ export default function watch(pi: ExtensionAPI, deps: WatchDeps = {}): void {
|
|
|
64
75
|
const pending = runtime.pendingMatches();
|
|
65
76
|
if (pending.length === 0) return;
|
|
66
77
|
try {
|
|
67
|
-
pi.sendMessage(
|
|
68
|
-
{ customType: WATCH_CONTEXT_TYPE, content: buildWatchContext(pending), display: false },
|
|
69
|
-
{ triggerTurn: true },
|
|
70
|
-
);
|
|
78
|
+
pi.sendMessage(watchNotice(pending), { triggerTurn: true });
|
|
71
79
|
runtime.markReported(pending);
|
|
72
80
|
waking = true;
|
|
73
81
|
} catch {
|
|
@@ -103,17 +111,14 @@ export default function watch(pi: ExtensionAPI, deps: WatchDeps = {}): void {
|
|
|
103
111
|
if (pending.length === 0) return undefined;
|
|
104
112
|
runtime.markReported(pending);
|
|
105
113
|
runtime.setStatus(ctx);
|
|
106
|
-
return {
|
|
107
|
-
message: { customType: WATCH_CONTEXT_TYPE, content: buildWatchContext(pending), display: false },
|
|
108
|
-
};
|
|
114
|
+
return { message: watchNotice(pending) };
|
|
109
115
|
});
|
|
110
116
|
|
|
111
|
-
// Keep stale match
|
|
117
|
+
// Keep stale match notices out of later turns. Only this extension's notices
|
|
118
|
+
// are pruned; another source's are left for its own handler.
|
|
112
119
|
pi.on("context", (event) => {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
const last = contexts[contexts.length - 1];
|
|
116
|
-
return { messages: event.messages.filter((message) => !isWatchContext(message) || message === last) };
|
|
120
|
+
const pruned = pruneStaleNotices(event.messages, "watch");
|
|
121
|
+
return pruned ? { messages: pruned } : undefined;
|
|
117
122
|
});
|
|
118
123
|
|
|
119
124
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
12
12
|
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
13
|
+
import { SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
13
14
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
14
15
|
import {
|
|
15
16
|
compareWatches,
|
|
@@ -193,8 +194,9 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
|
|
|
193
194
|
const text = reuseText(context.lastComponent);
|
|
194
195
|
text.setText(
|
|
195
196
|
theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) +
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
paint(
|
|
198
|
+
theme,
|
|
199
|
+
"meta",
|
|
198
200
|
sanitizeLine(formatCallText(action, args as Record<string, unknown>, context.argsComplete)),
|
|
199
201
|
),
|
|
200
202
|
);
|
|
@@ -269,7 +271,7 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
|
|
|
269
271
|
const badges: string[] = [];
|
|
270
272
|
if (details.watch.wake) badges.push("wake");
|
|
271
273
|
if (details.watch.fromStart) badges.push("from start");
|
|
272
|
-
if (badges.length > 0) line += paint(theme, "faint",
|
|
274
|
+
if (badges.length > 0) line += paint(theme, "faint", `${SEPARATORS.item}${badges.join(SEPARATORS.item)}`);
|
|
273
275
|
}
|
|
274
276
|
text.setText(line);
|
|
275
277
|
return text;
|
package/extensions/watch/tui.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { clip, fitCells } from "../../lib/tui/columns.ts";
|
|
|
18
18
|
import { fitRows, formatRange, IdCursor, isCloseKey, navIntent, wheelDelta } from "../../lib/tui/cursor.ts";
|
|
19
19
|
import { expandHint, SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
20
20
|
import { LinePane } from "../../lib/tui/line-pane.ts";
|
|
21
|
-
import { emptyState,
|
|
21
|
+
import { emptyState, statusRow, type StatusRow } from "../../lib/tui/rows.ts";
|
|
22
22
|
import { titleRule } from "../../lib/tui/rules.ts";
|
|
23
23
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
24
24
|
import { compareWatches, formatWatchDetail, shortLabel, statusGlyphChar, statusRole } from "./format.ts";
|
|
@@ -37,14 +37,18 @@ const LINES_CHROME_ROWS = 6;
|
|
|
37
37
|
const watchId = (watch: WatchRecord): string => watch.id;
|
|
38
38
|
|
|
39
39
|
/** One watcher row: marker, status glyph, id, label, and a right-aligned count. */
|
|
40
|
-
function watchRow(
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
function watchRow(
|
|
41
|
+
watch: WatchRecord,
|
|
42
|
+
theme: Theme,
|
|
43
|
+
width: number,
|
|
44
|
+
options: Pick<StatusRow, "selected" | "marker"> = {},
|
|
45
|
+
): string {
|
|
46
|
+
return statusRow(theme, width, {
|
|
47
|
+
...options,
|
|
43
48
|
glyph: statusGlyphChar(watch.status),
|
|
44
49
|
glyphRole: statusRole(watch.status),
|
|
45
50
|
label: `${watch.id} ${shortLabel(watch)}`,
|
|
46
51
|
trailing: `${watch.matchCount}`,
|
|
47
|
-
trailingRole: "faint",
|
|
48
52
|
});
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -80,7 +84,7 @@ export class WatchResult implements Component {
|
|
|
80
84
|
// A leading blank line separates the result from the call header.
|
|
81
85
|
const lines: string[] = [""];
|
|
82
86
|
if (this.input.kind === "list") {
|
|
83
|
-
for (const watch of this.input.watches) lines.push(watchRow(watch, this.theme, w));
|
|
87
|
+
for (const watch of this.input.watches) lines.push(watchRow(watch, this.theme, w, { marker: false }));
|
|
84
88
|
if (this.input.more > 0) {
|
|
85
89
|
const note = `${paint(this.theme, "faint", `… ${this.input.more} more`)} ${expandHint(this.theme)}`;
|
|
86
90
|
lines.push(clip(` ${note}`, w));
|
|
@@ -311,7 +315,8 @@ export class WatchListComponent implements Component {
|
|
|
311
315
|
body.push(...emptyState(this.theme, w, { label: "No watchers." }));
|
|
312
316
|
} else {
|
|
313
317
|
const end = Math.min(watches.length, this.cursor.scroll + this.cursor.visible);
|
|
314
|
-
for (let i = this.cursor.scroll; i < end; i++)
|
|
318
|
+
for (let i = this.cursor.scroll; i < end; i++)
|
|
319
|
+
body.push(watchRow(watches[i], this.theme, w, { selected: i === index }));
|
|
315
320
|
if (this.cursor.scroll > 0 || end < watches.length) {
|
|
316
321
|
range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: watches.length });
|
|
317
322
|
}
|
|
@@ -12,6 +12,14 @@ pi -e . # load the whole @gavin-hu/my-pi-agent
|
|
|
12
12
|
pi install ./ # or install the package
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
## Tool rendering
|
|
16
|
+
|
|
17
|
+
How the `web_search` and `web_fetch` calls and their results look in the transcript — the call line as a headline and the body indented under it:
|
|
18
|
+
|
|
19
|
+
<img src="../../assets/tools/web_search.png" alt="The web_search tool's call and result: the call line and an indented body with the query and the ranked results" width="100%">
|
|
20
|
+
|
|
21
|
+
<img src="../../assets/tools/web_fetch.png" alt="The web_fetch tool's call and result: the call line and an indented body with the URL, title, and fetched text preview" width="100%">
|
|
22
|
+
|
|
15
23
|
## What it does
|
|
16
24
|
|
|
17
25
|
- Registers `web_search`, general web search through a pluggable provider:
|
|
@@ -46,17 +54,18 @@ web_fetch({ url: "https://pi.dev/", find: ["documentation", "extensions"] })
|
|
|
46
54
|
|
|
47
55
|
### Transcript
|
|
48
56
|
|
|
49
|
-
The call line names the query or URL
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
result
|
|
57
|
+
The call line names the query or URL and states the requested limit
|
|
58
|
+
(`(max 8)`), so it never reads as the number of results returned. The result
|
|
59
|
+
separates the header from its body and sanitizes untrusted web text (answers,
|
|
60
|
+
titles, fetched titles and errors) to one line before it reaches the terminal.
|
|
61
|
+
A search result labels its answer and lists one URL per result; expanding
|
|
62
|
+
reveals snippets and the full result list.
|
|
54
63
|
|
|
55
64
|
```
|
|
56
|
-
web_search "pi coding agent" (8
|
|
65
|
+
web_search "pi coding agent" (max 8)
|
|
57
66
|
|
|
58
67
|
via searxng · 8 results
|
|
59
|
-
|
|
68
|
+
Answer: Pi is a coding agent harness …
|
|
60
69
|
1. Pi — a coding agent harness
|
|
61
70
|
https://pi.dev/
|
|
62
71
|
2. GitHub - earendil-works/pi
|
|
@@ -205,7 +205,7 @@ export function registerFetchTool(pi: ExtensionAPI, deps: FetchToolDeps = {}): v
|
|
|
205
205
|
renderCall(args, theme, context) {
|
|
206
206
|
const { url, find, startIndex } = args as WebFetchArgs;
|
|
207
207
|
const text = reuseText(context?.lastComponent);
|
|
208
|
-
let line = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "
|
|
208
|
+
let line = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "label", oneLine(url ?? ""));
|
|
209
209
|
if (find?.length) line += paint(theme, "faint", ` find ${find.map((f) => `"${oneLine(f)}"`).join(", ")}`);
|
|
210
210
|
else if (startIndex) line += paint(theme, "faint", ` (from ${startIndex})`);
|
|
211
211
|
text.setText(line);
|