@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
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* External images are rendered as links (the page CSP is `img-src 'self' data:`)
|
|
15
15
|
* and non-http(s) schemes such as `javascript:` are dropped.
|
|
16
16
|
*
|
|
17
|
+
* Headings get a stable anchor id and are demoted one level so the embedding
|
|
18
|
+
* page keeps the only `h1`; `renderMarkdownDocument` returns them for an
|
|
19
|
+
* outline.
|
|
20
|
+
*
|
|
17
21
|
* Kept free of the page renderer: it imports only `lib/escape.ts`,
|
|
18
22
|
* `lib/highlight.ts`, and `paths.ts`, so `html.ts` can compose it without a
|
|
19
23
|
* module cycle.
|
|
@@ -27,6 +31,22 @@ import { encodePath } from "./paths.ts";
|
|
|
27
31
|
const HAS_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
28
32
|
const SAFE_LINK_SCHEME = /^(?:https?|mailto):/i;
|
|
29
33
|
const HTTP_SCHEME = /^https?:/i;
|
|
34
|
+
/** A GFM extended autolink: `http(s)://` or `www.` followed by a domain. */
|
|
35
|
+
const AUTOLINK = /^(?:https?:\/\/|www\.)[^\s<>]*/i;
|
|
36
|
+
|
|
37
|
+
/** GFM starts an autolink at the line start or after whitespace or one of `*_~(`. */
|
|
38
|
+
function autolinkBoundary(text: string, index: number): boolean {
|
|
39
|
+
return index === 0 || /[\s*_~(]/.test(text[index - 1]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Drop the trailing punctuation GFM excludes, and an unclosed `)`. */
|
|
43
|
+
function trimAutolink(url: string): string {
|
|
44
|
+
let end = url.replace(/[.,;:!?*_~]+$/, "");
|
|
45
|
+
while (end.endsWith(")") && (end.match(/\(/g)?.length ?? 0) < (end.match(/\)/g)?.length ?? 0)) {
|
|
46
|
+
end = end.slice(0, -1).replace(/[.,;:!?*_~]+$/, "");
|
|
47
|
+
}
|
|
48
|
+
return end;
|
|
49
|
+
}
|
|
30
50
|
/** A link reference definition: `[id]: dest "title"`. */
|
|
31
51
|
const DEFINITION = /^ {0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+["'(]([^"')]*)["')])?\s*$/;
|
|
32
52
|
|
|
@@ -35,16 +55,69 @@ interface LinkDefinition {
|
|
|
35
55
|
title?: string;
|
|
36
56
|
}
|
|
37
57
|
|
|
38
|
-
/**
|
|
58
|
+
/** A heading the renderer emitted, in document order. */
|
|
59
|
+
export interface MarkdownHeading {
|
|
60
|
+
/** The rendered tag level, already demoted and capped at `h6`. */
|
|
61
|
+
level: number;
|
|
62
|
+
/** The anchor id, unique within the document. */
|
|
63
|
+
id: string;
|
|
64
|
+
/** The heading's raw inline Markdown. */
|
|
65
|
+
text: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Per-document render state: the resolution base, the reference definitions,
|
|
70
|
+
* and the headings emitted so far.
|
|
71
|
+
*/
|
|
39
72
|
interface Context {
|
|
40
73
|
baseDir: string;
|
|
41
74
|
defs: Map<string, LinkDefinition>;
|
|
75
|
+
headings: MarkdownHeading[];
|
|
76
|
+
slug(text: string): string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Render a Markdown source string to a safe HTML fragment, plus the headings it
|
|
81
|
+
* emitted so a caller can build an outline. Headings carry an anchor id and are
|
|
82
|
+
* demoted one level, so the embedding page keeps the only `h1`.
|
|
83
|
+
*/
|
|
84
|
+
export function renderMarkdownDocument(source: string, baseDir = ""): { html: string; headings: MarkdownHeading[] } {
|
|
85
|
+
const { defs, body } = collectDefinitions(source.split(/\r\n|\r|\n/));
|
|
86
|
+
const context = createContext(baseDir, defs);
|
|
87
|
+
return { html: renderBlocks(body, context), headings: context.headings };
|
|
42
88
|
}
|
|
43
89
|
|
|
44
90
|
/** Render a Markdown source string to a safe HTML fragment. */
|
|
45
91
|
export function renderMarkdown(source: string, baseDir = ""): string {
|
|
46
|
-
|
|
47
|
-
|
|
92
|
+
return renderMarkdownDocument(source, baseDir).html;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Turn heading text into a stable anchor id, dropping Markdown punctuation. */
|
|
96
|
+
function headingSlug(text: string): string {
|
|
97
|
+
const plain = text
|
|
98
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
99
|
+
.replace(/[`*_~]/g, "")
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
|
102
|
+
.replace(/[\s-]+/g, "-")
|
|
103
|
+
.replace(/^-+|-+$/g, "");
|
|
104
|
+
return plain || "section";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Build the per-document render state, suffixing repeated heading slugs. */
|
|
108
|
+
function createContext(baseDir: string, defs: Map<string, LinkDefinition>): Context {
|
|
109
|
+
const seen = new Map<string, number>();
|
|
110
|
+
return {
|
|
111
|
+
baseDir,
|
|
112
|
+
defs,
|
|
113
|
+
headings: [],
|
|
114
|
+
slug(text: string): string {
|
|
115
|
+
const base = headingSlug(text);
|
|
116
|
+
const count = seen.get(base) ?? 0;
|
|
117
|
+
seen.set(base, count + 1);
|
|
118
|
+
return count === 0 ? base : `${base}-${count}`;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
48
121
|
}
|
|
49
122
|
|
|
50
123
|
/** Fold a reference label the way CommonMark does: trim, collapse, case-fold. */
|
|
@@ -135,7 +208,10 @@ function renderBlocks(lines: string[], ctx: Context): string {
|
|
|
135
208
|
|
|
136
209
|
const heading = matchHeading(line);
|
|
137
210
|
if (heading) {
|
|
138
|
-
|
|
211
|
+
const level = Math.min(heading.level + 1, 6);
|
|
212
|
+
const id = ctx.slug(heading.text);
|
|
213
|
+
ctx.headings.push({ level, id, text: heading.text });
|
|
214
|
+
out.push(`<h${level} id="${id}">${renderInline(heading.text, ctx)}</h${level}>`);
|
|
139
215
|
i++;
|
|
140
216
|
continue;
|
|
141
217
|
}
|
|
@@ -403,7 +479,7 @@ function renderListItem(contentLines: string[], ctx: Context): string {
|
|
|
403
479
|
const tight = !source.some(isBlank);
|
|
404
480
|
let body = renderBlocks(source, ctx);
|
|
405
481
|
if (tight) body = body.replace(/<\/?p>/g, "");
|
|
406
|
-
const mark = task ? `<
|
|
482
|
+
const mark = task ? `<input class="md-check" type="checkbox" disabled${done ? " checked" : ""}> ` : "";
|
|
407
483
|
return `<li${task ? (done ? ` class="task done"` : ` class="task"`) : ""}>${mark}${body}</li>`;
|
|
408
484
|
}
|
|
409
485
|
|
|
@@ -740,6 +816,23 @@ function renderInline(text: string, ctx: Context): string {
|
|
|
740
816
|
}
|
|
741
817
|
}
|
|
742
818
|
|
|
819
|
+
// GFM extended autolink: a bare `https://` or `www.` URL in running text.
|
|
820
|
+
if ((char === "h" || char === "H" || char === "w" || char === "W") && autolinkBoundary(text, i)) {
|
|
821
|
+
const match = AUTOLINK.exec(text.slice(i));
|
|
822
|
+
if (match) {
|
|
823
|
+
const label = trimAutolink(match[0]);
|
|
824
|
+
const dots = label.indexOf(".");
|
|
825
|
+
// Require a domain, so a bare scheme is not a link.
|
|
826
|
+
if (dots > 0 && /\.[^./]/.test(label.slice(dots))) {
|
|
827
|
+
const href = /^www\./i.test(label) ? `http://${label}` : label;
|
|
828
|
+
const external = HTTP_SCHEME.test(href) ? ` target="_blank" rel="noopener noreferrer"` : "";
|
|
829
|
+
out += `<a href="${escapeHtml(href)}"${external}>${escapeHtml(label)}</a>`;
|
|
830
|
+
i += label.length;
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
743
836
|
if ((char === "*" || char === "_") && text[i + 1] === char) {
|
|
744
837
|
const close = text.indexOf(char + char, i + 2);
|
|
745
838
|
if (close > i + 2) {
|
|
@@ -11,7 +11,8 @@ import { stat } from "node:fs/promises";
|
|
|
11
11
|
import { THEME_JS } from "../../lib/web/index.ts";
|
|
12
12
|
import { CLIENT_JS } from "./client.ts";
|
|
13
13
|
import type { ServeConfig } from "./config.ts";
|
|
14
|
-
import
|
|
14
|
+
import { changeDirs, isDiffMode, type DiffMode } from "./diff.ts";
|
|
15
|
+
import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
|
|
15
16
|
import {
|
|
16
17
|
buildFileIndex,
|
|
17
18
|
buildTree,
|
|
@@ -25,7 +26,8 @@ import {
|
|
|
25
26
|
} from "./files.ts";
|
|
26
27
|
import { FOLDER_ICON, iconHref, languageOf } from "./icons.ts";
|
|
27
28
|
import { fuzzySearch } from "./fuzzy.ts";
|
|
28
|
-
import { renderDirectoryPage, renderErrorPage, renderFilePage } from "./html.ts";
|
|
29
|
+
import { renderDirectoryPage, renderDiffPage, renderErrorPage, renderFileDiffPage, renderFilePage } from "./html.ts";
|
|
30
|
+
import type { ChangeSummary } from "./html.ts";
|
|
29
31
|
import { encodePath, HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "./paths.ts";
|
|
30
32
|
|
|
31
33
|
export interface ServeContext {
|
|
@@ -33,6 +35,8 @@ export interface ServeContext {
|
|
|
33
35
|
config: ServeConfig;
|
|
34
36
|
/** Read-only git context for the page header; omitted when unavailable. */
|
|
35
37
|
git?: GitStatusProvider;
|
|
38
|
+
/** Read-only diff provider; omitted when the diff view is unavailable. */
|
|
39
|
+
diff?: GitDiffProvider;
|
|
36
40
|
/** Cached quick-open file index; built on demand when omitted. */
|
|
37
41
|
index?: FileIndexProvider;
|
|
38
42
|
}
|
|
@@ -97,6 +101,33 @@ async function gitFor(ctx: ServeContext): Promise<GitStatus | undefined> {
|
|
|
97
101
|
}
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
/** The change list for a mode, or `undefined` outside a repository. */
|
|
105
|
+
async function diffFor(ctx: ServeContext, mode: DiffMode) {
|
|
106
|
+
if (!ctx.config.gitDiff || !ctx.diff) return undefined;
|
|
107
|
+
try {
|
|
108
|
+
return await ctx.diff.list(mode);
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The marker/totals index derived from a change list. */
|
|
115
|
+
function summaryOf(list: DiffList): ChangeSummary {
|
|
116
|
+
return {
|
|
117
|
+
count: list.files.length,
|
|
118
|
+
insertions: list.insertions,
|
|
119
|
+
deletions: list.deletions,
|
|
120
|
+
files: new Set(list.files.map((file) => file.rel)),
|
|
121
|
+
dirs: changeDirs(list.files),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The `head` change summary, for the header chip, tree markers, and file action. */
|
|
126
|
+
async function changed(ctx: ServeContext): Promise<ChangeSummary | undefined> {
|
|
127
|
+
const list = await diffFor(ctx, "head");
|
|
128
|
+
return list ? summaryOf(list) : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
100
131
|
async function errorResponse(
|
|
101
132
|
ctx: ServeContext,
|
|
102
133
|
status: number,
|
|
@@ -137,6 +168,7 @@ async function browse(ctx: ServeContext, rawRel: string): Promise<ServeResponse>
|
|
|
137
168
|
const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries);
|
|
138
169
|
const tree = await treeFor(ctx, rel);
|
|
139
170
|
const git = await gitFor(ctx);
|
|
171
|
+
const changes = await changed(ctx);
|
|
140
172
|
return htmlResponse(
|
|
141
173
|
200,
|
|
142
174
|
renderDirectoryPage({
|
|
@@ -147,6 +179,7 @@ async function browse(ctx: ServeContext, rawRel: string): Promise<ServeResponse>
|
|
|
147
179
|
thumbnails: ctx.config.thumbnails,
|
|
148
180
|
maxThumbBytes: ctx.config.maxThumbBytes,
|
|
149
181
|
git,
|
|
182
|
+
summary: changes,
|
|
150
183
|
}),
|
|
151
184
|
);
|
|
152
185
|
}
|
|
@@ -161,6 +194,7 @@ async function view(ctx: ServeContext, rawRel: string, raw: boolean): Promise<Se
|
|
|
161
194
|
const file = await readFileView(ctx.root, rel, ctx.config.maxFileBytes, ctx.config.maxTextLines, ctx.config.markdown);
|
|
162
195
|
const tree = await treeFor(ctx, rel);
|
|
163
196
|
const git = await gitFor(ctx);
|
|
197
|
+
const changes = await changed(ctx);
|
|
164
198
|
return htmlResponse(
|
|
165
199
|
200,
|
|
166
200
|
renderFilePage({
|
|
@@ -171,6 +205,59 @@ async function view(ctx: ServeContext, rawRel: string, raw: boolean): Promise<Se
|
|
|
171
205
|
maxFileBytes: ctx.config.maxFileBytes,
|
|
172
206
|
git,
|
|
173
207
|
markdownRaw: raw,
|
|
208
|
+
changed: changes?.files.has(rel),
|
|
209
|
+
summary: changes,
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The change overview page for a mode. A non-repository is an empty state, not an error. */
|
|
215
|
+
async function diffOverview(ctx: ServeContext, mode: DiffMode): Promise<ServeResponse> {
|
|
216
|
+
if (!ctx.config.gitDiff || !ctx.diff) throw new HttpError(404, "The diff view is not available.");
|
|
217
|
+
const list = await diffFor(ctx, mode);
|
|
218
|
+
const tree = await treeFor(ctx, "");
|
|
219
|
+
const git = await gitFor(ctx);
|
|
220
|
+
return htmlResponse(
|
|
221
|
+
200,
|
|
222
|
+
renderDiffPage({
|
|
223
|
+
rootLabel: ctx.root,
|
|
224
|
+
mode,
|
|
225
|
+
files: list?.files ?? [],
|
|
226
|
+
stat: list?.stat ?? "",
|
|
227
|
+
tree,
|
|
228
|
+
git,
|
|
229
|
+
summary: list ? summaryOf(list) : undefined,
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A single file's unified diff for a mode. */
|
|
235
|
+
async function diffFile(ctx: ServeContext, rawRel: string, mode: DiffMode): Promise<ServeResponse> {
|
|
236
|
+
if (!ctx.config.gitDiff || !ctx.diff) throw new HttpError(404, "The diff view is not available.");
|
|
237
|
+
const abs = await resolveRequestPath(ctx.root, rawRel);
|
|
238
|
+
const rel = relativePath(ctx.root, abs);
|
|
239
|
+
const info = await stat(abs).catch(() => undefined);
|
|
240
|
+
if (info?.isDirectory()) throw new HttpError(400, "That path is a folder.");
|
|
241
|
+
let diff: Awaited<ReturnType<GitDiffProvider["file"]>>;
|
|
242
|
+
try {
|
|
243
|
+
diff = await ctx.diff.file(rel, mode);
|
|
244
|
+
} catch {
|
|
245
|
+
diff = undefined;
|
|
246
|
+
}
|
|
247
|
+
if (!diff) throw new HttpError(404, "No diff is available for that file.");
|
|
248
|
+
const tree = await treeFor(ctx, rel);
|
|
249
|
+
const git = await gitFor(ctx);
|
|
250
|
+
const changes = await changed(ctx);
|
|
251
|
+
return htmlResponse(
|
|
252
|
+
200,
|
|
253
|
+
renderFileDiffPage({
|
|
254
|
+
rootLabel: ctx.root,
|
|
255
|
+
rel,
|
|
256
|
+
mode,
|
|
257
|
+
diff,
|
|
258
|
+
tree,
|
|
259
|
+
git,
|
|
260
|
+
summary: changes,
|
|
174
261
|
}),
|
|
175
262
|
);
|
|
176
263
|
}
|
|
@@ -276,6 +363,11 @@ export async function handleRequest(
|
|
|
276
363
|
if (pathname === "/favicon.ico") return { status: 204, headers: { ...HTML_SECURITY }, body: "" };
|
|
277
364
|
if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? "");
|
|
278
365
|
if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? "");
|
|
366
|
+
const rawMode = search.get("mode");
|
|
367
|
+
if (rawMode !== null && !isDiffMode(rawMode)) return errorResponse(ctx, 400, "Unknown diff mode.");
|
|
368
|
+
const mode: DiffMode = isDiffMode(rawMode) ? rawMode : "head";
|
|
369
|
+
if (pathname === "/diff" || pathname === "/diff/") return await diffOverview(ctx, mode);
|
|
370
|
+
if (pathname.startsWith("/diff/")) return await diffFile(ctx, pathname.slice("/diff/".length), mode);
|
|
279
371
|
if (pathname === "/" || pathname === "/browse" || pathname === "/browse/") return await browse(ctx, "");
|
|
280
372
|
if (pathname.startsWith("/browse/")) return await browse(ctx, pathname.slice("/browse/".length));
|
|
281
373
|
if (pathname.startsWith("/view/"))
|
|
@@ -11,7 +11,7 @@ import { createReadStream, realpathSync } from "node:fs";
|
|
|
11
11
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
12
12
|
import type { ServeConfig } from "./config.ts";
|
|
13
13
|
import { createFileIndex } from "./files.ts";
|
|
14
|
-
import type { GitStatusProvider } from "./git.ts";
|
|
14
|
+
import type { GitDiffProvider, GitStatusProvider } from "./git.ts";
|
|
15
15
|
import { handleRequest, type ServeContext, type ServeResponse } from "./router.ts";
|
|
16
16
|
|
|
17
17
|
export interface FileServer {
|
|
@@ -30,6 +30,8 @@ export interface FileServerOptions {
|
|
|
30
30
|
config: ServeConfig;
|
|
31
31
|
/** Read-only git context for the page header; omitted when unavailable. */
|
|
32
32
|
git?: GitStatusProvider;
|
|
33
|
+
/** Read-only diff provider for the change and diff views; omitted when unavailable. */
|
|
34
|
+
diff?: GitDiffProvider;
|
|
33
35
|
/** Override the request handler (tests). */
|
|
34
36
|
handler?: typeof handleRequest;
|
|
35
37
|
}
|
|
@@ -68,6 +70,7 @@ export async function createFileServer(options: FileServerOptions): Promise<File
|
|
|
68
70
|
root,
|
|
69
71
|
config: options.config,
|
|
70
72
|
git: options.git,
|
|
73
|
+
diff: options.diff,
|
|
71
74
|
index: createFileIndex(root, options.config.maxIndexEntries),
|
|
72
75
|
};
|
|
73
76
|
const handler = options.handler ?? handleRequest;
|
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
* persistent widget, by contrast, is a plain label-first line
|
|
8
8
|
* (`Goal · active · <objective>`) matching the `todo` and `jobs` widgets. The
|
|
9
9
|
* symbols and labels live here; the rail indent constants are shared with
|
|
10
|
-
* `todo` in `lib/
|
|
10
|
+
* `todo` in `lib/tui/glyphs.ts`.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
-
import { GLYPHS } from "../../lib/
|
|
15
|
+
import { GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
16
|
+
import { paint } from "../../lib/tui/tokens.ts";
|
|
16
17
|
import type { Goal, GoalStatus } from "./types.ts";
|
|
17
18
|
|
|
18
19
|
/** Active-goal glyph. */
|
|
@@ -25,7 +26,7 @@ const CALL_PREVIEW_WIDTH = 48;
|
|
|
25
26
|
|
|
26
27
|
/** Themed status glyph, shared by the widget and the transcript renderer. */
|
|
27
28
|
export function goalGlyph(goal: Goal, theme: Theme): string {
|
|
28
|
-
return goal.status === "achieved" ? theme
|
|
29
|
+
return goal.status === "achieved" ? paint(theme, "ok", ACHIEVED_SYMBOL) : paint(theme, "accent", ACTIVE_SYMBOL);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/** Human label for a status, e.g. `active`. */
|
|
@@ -38,10 +39,10 @@ export function goalStatusLabel(status: GoalStatus): string {
|
|
|
38
39
|
* the status word is dim while active, success-colored once achieved.
|
|
39
40
|
*/
|
|
40
41
|
export function goalHeader(goal: Goal, theme: Theme): string {
|
|
41
|
-
const label = theme
|
|
42
|
-
const separator = theme
|
|
42
|
+
const label = paint(theme, "accent", "Goal");
|
|
43
|
+
const separator = paint(theme, "faint", " · ");
|
|
43
44
|
const word = goalStatusLabel(goal.status);
|
|
44
|
-
const status = goal.status === "achieved" ? theme
|
|
45
|
+
const status = goal.status === "achieved" ? paint(theme, "ok", word) : paint(theme, "faint", word);
|
|
45
46
|
return `${label}${separator}${status}`;
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -52,15 +53,15 @@ export function goalHeader(goal: Goal, theme: Theme): string {
|
|
|
52
53
|
* Callers clip it to the available width.
|
|
53
54
|
*/
|
|
54
55
|
export function goalLine(goal: Goal, theme: Theme): string {
|
|
55
|
-
const separator = theme
|
|
56
|
+
const separator = paint(theme, "faint", " · ");
|
|
56
57
|
const word = goalStatusLabel(goal.status);
|
|
57
|
-
const status = goal.status === "achieved" ? theme
|
|
58
|
-
return `${theme
|
|
58
|
+
const status = goal.status === "achieved" ? paint(theme, "ok", word) : paint(theme, "faint", word);
|
|
59
|
+
return `${paint(theme, "accent", "Goal")}${separator}${status}${separator}${goalObjective(goal, theme)}`;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
/** Themed objective text: dim once achieved, normal while active. */
|
|
62
63
|
export function goalObjective(goal: Goal, theme: Theme): string {
|
|
63
|
-
return goal.status === "achieved" ? theme
|
|
64
|
+
return goal.status === "achieved" ? paint(theme, "faint", goal.objective) : paint(theme, "label", goal.objective);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/** Model-facing result text. */
|
package/extensions/goal/tools.ts
CHANGED
|
@@ -11,8 +11,9 @@ import type { JsonValue } from "@earendil-works/pi-ai";
|
|
|
11
11
|
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
13
13
|
import { GOAL_TOOL } from "../../lib/tool-names.ts";
|
|
14
|
-
import { reuseText, toolDetails } from "../../lib/tui.ts";
|
|
15
|
-
import {
|
|
14
|
+
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
15
|
+
import { paint } from "../../lib/tui/tokens.ts";
|
|
16
|
+
import { GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
16
17
|
import { formatCallText, formatGoalText, goalObjective, previewObjective } from "./format.ts";
|
|
17
18
|
import type { GoalRuntime } from "./runtime.ts";
|
|
18
19
|
import { GoalParams, GoalResult, normalizeGoal, type GoalArgs } from "./schema.ts";
|
|
@@ -102,7 +103,7 @@ export function registerTools(pi: ExtensionAPI, runtime: GoalRuntime): void {
|
|
|
102
103
|
const text = reuseText(context.lastComponent);
|
|
103
104
|
text.setText(
|
|
104
105
|
theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) +
|
|
105
|
-
theme
|
|
106
|
+
paint(theme, "meta", formatCallText(args.objective, context.argsComplete, args.status)),
|
|
106
107
|
);
|
|
107
108
|
return text;
|
|
108
109
|
},
|
|
@@ -116,12 +117,12 @@ export function registerTools(pi: ExtensionAPI, runtime: GoalRuntime): void {
|
|
|
116
117
|
return text;
|
|
117
118
|
}
|
|
118
119
|
if (details.error) {
|
|
119
|
-
text.setText(theme
|
|
120
|
+
text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
|
|
120
121
|
return text;
|
|
121
122
|
}
|
|
122
123
|
if (!details.goal) {
|
|
123
124
|
// A neutral marker, deliberately not the achieved check.
|
|
124
|
-
text.setText(theme
|
|
125
|
+
text.setText(paint(theme, "faint", `${GLYPHS.stopped} `) + paint(theme, "meta", "Cleared the goal"));
|
|
125
126
|
return text;
|
|
126
127
|
}
|
|
127
128
|
const body = resultBody(details.goal, expanded, theme);
|
package/extensions/goal/tui.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
|
|
16
|
-
import { BODY_INDENT, GLYPH_GAP } from "../../lib/
|
|
16
|
+
import { BODY_INDENT, GLYPH_GAP } from "../../lib/tui/glyphs.ts";
|
|
17
17
|
import { goalGlyph, goalHeader, goalLine } from "./format.ts";
|
|
18
18
|
import type { AchievedStyle, Goal } from "./types.ts";
|
|
19
19
|
|
package/extensions/job/README.md
CHANGED
|
@@ -289,10 +289,10 @@ rewritten.
|
|
|
289
289
|
| `session.ts` | Per-session heartbeat markers and owner-liveness rules. |
|
|
290
290
|
| `store.ts` | Durable store: registry dir, this session's atomic merge-write, deletions. |
|
|
291
291
|
| `logs.ts` | Bounded, sanitized log-tail reading. |
|
|
292
|
-
| `ui.ts` | Footer status chips. |
|
|
292
|
+
| `ui.ts` | Footer status chips, over the shared `lib/tui/status-controller.ts`. |
|
|
293
293
|
| `waiters.ts` | `wait` resolver bookkeeping. |
|
|
294
294
|
| `runtime.ts` | Composition: job table, handles, process events, repaint clock. |
|
|
295
|
-
| `tui.ts` | The `/jobs` screen and the transcript `JobResult` rail. |
|
|
295
|
+
| `tui.ts` | The `/jobs` screen and the transcript `JobResult` rail, drawn from the `lib/tui/` kit (`titleRule`, `listRow`, `emptyState`, `IdCursor`, `LinePane`). |
|
|
296
296
|
| `tools.ts` | The `job` tool and its transcript rendering. |
|
|
297
297
|
| `commands.ts` | The `/jobs` command. |
|
|
298
298
|
| `types.ts` | Shared types. |
|
package/extensions/job/format.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
17
|
-
import { GLYPHS } from "../../lib/
|
|
17
|
+
import { GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
18
|
+
import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
|
|
18
19
|
import type { JobRecord, JobStatus } from "./types.ts";
|
|
19
20
|
|
|
20
21
|
/** Longest sanitized log line kept for display; longer lines are clipped. */
|
|
@@ -76,22 +77,43 @@ export function elapsedMs(job: JobRecord, now: number): number {
|
|
|
76
77
|
return (job.finishedAt ?? now) - job.startedAt;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
/**
|
|
80
|
-
export function
|
|
80
|
+
/** The design role a job status is drawn in. */
|
|
81
|
+
export function statusRole(status: JobStatus): TuiRole {
|
|
81
82
|
switch (status) {
|
|
82
83
|
case "exited":
|
|
83
|
-
return
|
|
84
|
+
return "ok";
|
|
84
85
|
case "failed":
|
|
85
|
-
return
|
|
86
|
+
return "bad";
|
|
86
87
|
case "killed":
|
|
87
|
-
return
|
|
88
|
+
return "meta";
|
|
88
89
|
case "unknown":
|
|
89
|
-
return
|
|
90
|
+
return "faint";
|
|
90
91
|
default:
|
|
91
|
-
return
|
|
92
|
+
return "accent";
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
/** Width-1 glyph for a status, from the shared vocabulary. */
|
|
97
|
+
export function statusGlyphChar(status: JobStatus): string {
|
|
98
|
+
switch (status) {
|
|
99
|
+
case "exited":
|
|
100
|
+
return GLYPHS.check;
|
|
101
|
+
case "failed":
|
|
102
|
+
return GLYPHS.jobsFailure;
|
|
103
|
+
case "killed":
|
|
104
|
+
return GLYPHS.killed;
|
|
105
|
+
case "unknown":
|
|
106
|
+
return GLYPHS.unknown;
|
|
107
|
+
default:
|
|
108
|
+
return GLYPHS.jobsRunning;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Width-1 themed glyph for a status. */
|
|
113
|
+
export function statusGlyph(status: JobStatus, theme: Theme): string {
|
|
114
|
+
return paint(theme, statusRole(status), statusGlyphChar(status));
|
|
115
|
+
}
|
|
116
|
+
|
|
95
117
|
/** Short status word for text output. */
|
|
96
118
|
function statusWord(job: JobRecord): string {
|
|
97
119
|
if (job.status === "failed" && job.exitCode !== null) return `failed (exit ${job.exitCode})`;
|
|
@@ -229,18 +251,19 @@ export function jobOutcome(job: JobRecord): string {
|
|
|
229
251
|
|
|
230
252
|
/** One-line outcome for a single job, themed for the transcript result. */
|
|
231
253
|
export function formatJobOutcomeLine(job: JobRecord, theme: Theme, now = Date.now()): string {
|
|
232
|
-
const sep = theme
|
|
233
|
-
const outcome =
|
|
254
|
+
const sep = paint(theme, "faint", " · ");
|
|
255
|
+
const outcome =
|
|
256
|
+
job.status === "failed" ? paint(theme, "bad", jobOutcome(job)) : paint(theme, "faint", jobOutcome(job));
|
|
234
257
|
return (
|
|
235
258
|
statusGlyph(job.status, theme) +
|
|
236
259
|
" " +
|
|
237
|
-
theme
|
|
260
|
+
paint(theme, "accent", job.id) +
|
|
238
261
|
" " +
|
|
239
|
-
theme
|
|
262
|
+
paint(theme, "meta", shortLabel(job)) +
|
|
240
263
|
sep +
|
|
241
264
|
outcome +
|
|
242
265
|
sep +
|
|
243
|
-
theme
|
|
266
|
+
paint(theme, "faint", formatDuration(elapsedMs(job, now)))
|
|
244
267
|
);
|
|
245
268
|
}
|
|
246
269
|
|
package/extensions/job/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
|
13
13
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { isExtensionEnabled } from "../../lib/env.ts";
|
|
15
15
|
import { JOB_FINISHED_CHANNEL, type JobFinishedEvent } from "../../lib/events.ts";
|
|
16
|
-
import { STATUS_KEYS } from "../../lib/
|
|
16
|
+
import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
17
17
|
import { registerCommands } from "./commands.ts";
|
|
18
18
|
import { formatCompletion } from "./format.ts";
|
|
19
19
|
import { createJobsRuntime, type JobsRuntime } from "./runtime.ts";
|
package/extensions/job/tools.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
12
|
-
import { reuseText, toolDetails } from "../../lib/tui.ts";
|
|
12
|
+
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
13
|
+
import { paint } from "../../lib/tui/tokens.ts";
|
|
13
14
|
import {
|
|
14
15
|
compareJobs,
|
|
15
16
|
formatCallText,
|
|
@@ -225,20 +226,20 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
|
|
|
225
226
|
return text;
|
|
226
227
|
}
|
|
227
228
|
if (details.error) {
|
|
228
|
-
text.setText(theme
|
|
229
|
+
text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
|
|
229
230
|
return text;
|
|
230
231
|
}
|
|
231
232
|
|
|
232
233
|
if (options.isPartial) {
|
|
233
234
|
const id = details.job?.id;
|
|
234
|
-
text.setText(theme
|
|
235
|
+
text.setText(paint(theme, "warn", id ? `Waiting on ${id}…` : "Waiting…"));
|
|
235
236
|
return text;
|
|
236
237
|
}
|
|
237
238
|
|
|
238
239
|
if (details.action === "logs" && details.logs !== undefined) {
|
|
239
240
|
const all = details.logs === "" ? [] : details.logs.split("\n");
|
|
240
241
|
if (all.length === 0) {
|
|
241
|
-
text.setText(theme
|
|
242
|
+
text.setText(paint(theme, "meta", "No output yet."));
|
|
242
243
|
return text;
|
|
243
244
|
}
|
|
244
245
|
const shown = options.expanded ? all : all.slice(Math.max(0, all.length - RESULT_ROWS));
|
|
@@ -250,7 +251,7 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
|
|
|
250
251
|
|
|
251
252
|
if (details.action === "list" && details.jobs) {
|
|
252
253
|
if (details.jobs.length === 0) {
|
|
253
|
-
text.setText(theme
|
|
254
|
+
text.setText(paint(theme, "meta", "No jobs."));
|
|
254
255
|
return text;
|
|
255
256
|
}
|
|
256
257
|
const ordered = [...details.jobs].sort(compareJobs);
|
|
@@ -264,7 +265,7 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
|
|
|
264
265
|
if (details.action === "clear") {
|
|
265
266
|
const count = details.cleared ?? 0;
|
|
266
267
|
text.setText(
|
|
267
|
-
theme
|
|
268
|
+
paint(theme, "meta", count === 0 ? "Nothing to clear." : `Cleared ${count} job${count === 1 ? "" : "s"}.`),
|
|
268
269
|
);
|
|
269
270
|
return text;
|
|
270
271
|
}
|
|
@@ -272,16 +273,16 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
|
|
|
272
273
|
if (details.job) {
|
|
273
274
|
let line = formatJobOutcomeLine(details.job, theme);
|
|
274
275
|
if (details.action === "wait" && details.timedOut) {
|
|
275
|
-
line += theme
|
|
276
|
+
line += paint(theme, "warn", " · still running after the timeout");
|
|
276
277
|
} else if (details.action === "wait" && details.cancelled) {
|
|
277
|
-
line += theme
|
|
278
|
+
line += paint(theme, "warn", " · wait cancelled; job still running");
|
|
278
279
|
} else if (details.action === "kill" && details.signalled) {
|
|
279
|
-
line += theme
|
|
280
|
+
line += paint(theme, "warn", ` · ${details.signal ?? "SIGTERM"} sent`);
|
|
280
281
|
} else if (details.action === "start") {
|
|
281
282
|
const badges: string[] = [];
|
|
282
283
|
if (details.job.detached) badges.push("detached");
|
|
283
284
|
if (details.job.wake) badges.push("wake");
|
|
284
|
-
if (badges.length > 0) line += theme
|
|
285
|
+
if (badges.length > 0) line += paint(theme, "faint", ` · ${badges.join(" · ")}`);
|
|
285
286
|
}
|
|
286
287
|
text.setText(line);
|
|
287
288
|
return text;
|