@gavin-hu/my-pi-agent 0.14.0 → 0.15.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 +55 -33
- package/README.zh-CN.md +44 -27
- package/extensions/bytefly/README.md +193 -140
- package/extensions/bytefly/block/component.ts +7 -15
- package/extensions/bytefly/block/register.ts +6 -1
- package/extensions/bytefly/doctor/register.ts +24 -5
- package/extensions/bytefly/doctor/tools.ts +4 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
- package/extensions/bytefly/footer/config.ts +10 -6
- package/extensions/bytefly/footer/price/price.ts +5 -3
- package/extensions/bytefly/footer/price/rates.ts +63 -26
- package/extensions/bytefly/footer/render/status.ts +3 -3
- package/extensions/bytefly/header/component.ts +3 -2
- package/extensions/bytefly/index.ts +3 -3
- package/extensions/bytefly/notice/component.ts +8 -3
- package/extensions/bytefly/notify/config.ts +6 -11
- package/extensions/bytefly/notify/dispatch.ts +13 -5
- package/extensions/bytefly/panel/commands.ts +21 -1
- package/extensions/bytefly/panel/format.ts +5 -2
- package/extensions/bytefly/panel/settings-runtime.ts +26 -6
- package/extensions/bytefly/panel/settings.ts +41 -4
- package/extensions/bytefly/panel/themes.ts +32 -0
- package/extensions/bytefly/panel/tui/editing.ts +77 -0
- package/extensions/bytefly/panel/tui/entries.ts +24 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
- package/extensions/bytefly/panel/tui/frame.ts +121 -4
- package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
- package/extensions/bytefly/panel/tui.ts +94 -152
- package/extensions/bytefly/picker/discovery.ts +1 -0
- package/extensions/bytefly/picker/types.ts +2 -0
- package/extensions/bytefly/separator/component.ts +14 -4
- package/extensions/bytefly/update/cache.ts +58 -19
- package/extensions/bytefly/update/check.ts +35 -0
- package/extensions/bytefly/update/format.ts +76 -35
- package/extensions/bytefly/update/register.ts +144 -55
- package/extensions/bytefly/update/state.ts +4 -2
- package/extensions/bytefly/update/suppress.ts +66 -0
- package/extensions/bytefly/update/types.ts +15 -0
- package/extensions/check/README.md +253 -0
- package/extensions/check/commands.ts +128 -0
- package/extensions/check/config.ts +39 -0
- package/extensions/check/detect.ts +269 -0
- package/extensions/check/failures.ts +230 -0
- package/extensions/check/format.ts +135 -0
- package/extensions/check/index.ts +26 -0
- package/extensions/check/render.ts +185 -0
- package/extensions/check/runner.ts +176 -0
- package/extensions/check/runtime.ts +244 -0
- package/extensions/check/source.ts +80 -0
- package/extensions/check/tools.ts +173 -0
- package/extensions/check/tui.ts +195 -0
- package/extensions/check/types.ts +99 -0
- package/extensions/document/README.md +1 -1
- package/extensions/document/extract/pdf.ts +8 -16
- package/extensions/document/formats.ts +2 -2
- package/extensions/file-browse/README.md +66 -32
- package/extensions/file-browse/client.ts +95 -32
- package/extensions/file-browse/config.ts +9 -9
- package/extensions/file-browse/git.ts +37 -47
- package/extensions/file-browse/html.ts +39 -58
- package/extensions/file-browse/markdown.ts +110 -92
- package/extensions/file-browse/raw-html.ts +478 -0
- package/extensions/file-browse/router.ts +32 -70
- package/extensions/file-browse/server.ts +18 -56
- package/extensions/file-browse/urls.ts +80 -0
- package/extensions/goal/README.md +103 -47
- package/extensions/goal/clarify-parse.ts +4 -9
- package/extensions/goal/clarify.ts +27 -4
- package/extensions/goal/commands.ts +24 -11
- package/extensions/goal/format.ts +41 -10
- package/extensions/goal/index.ts +39 -16
- package/extensions/goal/plan-route.ts +51 -0
- package/extensions/goal/schema.ts +6 -23
- package/extensions/goal/state.ts +39 -57
- package/extensions/goal/tools.ts +26 -20
- package/extensions/goal/types.ts +11 -1
- package/extensions/job/README.md +20 -6
- package/extensions/job/format.ts +18 -68
- package/extensions/job/index.ts +26 -41
- package/extensions/job/log-writer.ts +208 -0
- package/extensions/job/logs.ts +3 -2
- package/extensions/job/process.ts +6 -99
- package/extensions/job/runtime.ts +42 -181
- package/extensions/job/schema.ts +1 -4
- package/extensions/job/tools.ts +1 -15
- package/extensions/job/tui.ts +117 -307
- package/extensions/job/ui.ts +7 -33
- package/extensions/memory/README.md +1 -1
- package/extensions/memory/errors.ts +5 -6
- package/extensions/memory/tui.ts +14 -24
- package/extensions/plan/README.md +94 -18
- package/extensions/plan/commands.ts +9 -5
- package/extensions/plan/format.ts +87 -0
- package/extensions/plan/index.ts +31 -15
- package/extensions/plan/plans.ts +2 -3
- package/extensions/plan/policy.ts +0 -6
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/steps.ts +37 -8
- package/extensions/plan/todos.ts +44 -3
- package/extensions/plan/tools/enter.ts +94 -0
- package/extensions/plan/tools/exit.ts +296 -0
- package/extensions/plan/tools/nested.ts +21 -0
- package/extensions/plan/tools/steps.ts +136 -0
- package/extensions/plan/tools/todo-seed.ts +76 -0
- package/extensions/plan/tools/write.ts +127 -0
- package/extensions/plan/tools.ts +18 -703
- package/extensions/plan/web-client.ts +6 -22
- package/extensions/plan/web-page.ts +19 -30
- package/extensions/plan/web-router.ts +34 -71
- package/extensions/plan/web.ts +13 -40
- package/extensions/question/answers.ts +3 -8
- package/extensions/question/tui.ts +6 -6
- package/extensions/rewind/README.md +95 -22
- package/extensions/rewind/checklist.ts +185 -0
- package/extensions/rewind/commands.ts +8 -0
- package/extensions/rewind/config.ts +3 -11
- package/extensions/rewind/format.ts +220 -2
- package/extensions/rewind/git.ts +74 -7
- package/extensions/rewind/hunk-picker.ts +114 -0
- package/extensions/rewind/patch.ts +88 -0
- package/extensions/rewind/path-picker.ts +67 -100
- package/extensions/rewind/paths.ts +93 -19
- package/extensions/rewind/restore.ts +53 -10
- package/extensions/rewind/review-tui.ts +320 -0
- package/extensions/rewind/review.ts +397 -0
- package/extensions/rewind/rewind-tui.ts +4 -3
- package/extensions/rewind/runtime.ts +94 -17
- package/extensions/subagent/README.md +61 -6
- package/extensions/subagent/background-tui.ts +208 -0
- package/extensions/subagent/background.ts +470 -0
- package/extensions/subagent/commands.ts +80 -0
- package/extensions/subagent/config.ts +4 -0
- package/extensions/subagent/format.ts +24 -1
- package/extensions/subagent/index.ts +91 -2
- package/extensions/subagent/render.ts +13 -0
- package/extensions/subagent/run.ts +17 -16
- package/extensions/subagent/schema.ts +12 -0
- package/extensions/subagent/types.ts +11 -0
- package/extensions/subagent/ui.ts +50 -0
- package/extensions/todo/README.md +18 -13
- package/extensions/todo/format.ts +3 -2
- package/extensions/todo/runtime.ts +3 -2
- package/extensions/todo/schema.ts +8 -84
- package/extensions/todo/state.ts +5 -46
- package/extensions/todo/tools.ts +2 -1
- package/extensions/todo/types.ts +6 -15
- package/extensions/watch/README.md +14 -6
- package/extensions/watch/child.ts +150 -0
- package/extensions/watch/format.ts +8 -48
- package/extensions/watch/index.ts +42 -58
- package/extensions/watch/runtime.ts +49 -128
- package/extensions/watch/schema.ts +1 -4
- package/extensions/watch/tools.ts +1 -11
- package/extensions/watch/tui.ts +103 -273
- package/extensions/watch/ui.ts +5 -32
- package/extensions/web-access/README.md +1 -1
- package/extensions/web-access/fetch/page.ts +1 -1
- package/extensions/web-access/fetch/tool.ts +6 -2
- package/extensions/web-access/search/search.ts +2 -5
- package/extensions/wechat/README.md +1 -1
- package/extensions/wechat/client.ts +23 -0
- package/extensions/wechat/commands.ts +57 -42
- package/extensions/wechat/deliver.ts +5 -9
- package/extensions/wechat/errors.ts +3 -6
- package/extensions/wechat/inbound.ts +2 -5
- package/extensions/wechat/index.ts +2 -8
- package/extensions/wechat/outbound.ts +2 -4
- package/extensions/wechat/peer-worker.ts +3 -8
- package/extensions/wechat/poll.ts +10 -9
- package/extensions/wechat/presence.ts +35 -62
- package/extensions/wechat/reply-pipeline.ts +3 -1
- package/extensions/wechat/status-screen.ts +1 -1
- package/extensions/wechat/timer.ts +57 -0
- package/extensions/worktree/config.ts +8 -20
- package/extensions/worktree/enter.ts +7 -12
- package/extensions/worktree/git.ts +38 -69
- package/extensions/worktree/index.ts +5 -4
- package/extensions/worktree/registry.ts +7 -7
- package/extensions/worktree/root-tools.ts +2 -2
- package/extensions/worktree/status.ts +3 -2
- package/lib/README.md +22 -8
- package/lib/config.ts +50 -3
- package/lib/errors.ts +12 -0
- package/lib/format.ts +50 -0
- package/lib/git/read.ts +8 -0
- package/lib/goal-state.ts +23 -8
- package/lib/http/headers.ts +54 -0
- package/lib/http/index.ts +13 -0
- package/lib/http/response.ts +46 -0
- package/lib/http/router.ts +52 -0
- package/lib/http/server.ts +91 -0
- package/lib/lines.ts +77 -0
- package/lib/pdf.ts +82 -0
- package/lib/plan-entry.ts +71 -0
- package/lib/plan-state.ts +34 -0
- package/lib/report.ts +99 -0
- package/lib/requirements.ts +25 -19
- package/lib/spawn.ts +170 -0
- package/lib/todo-state.ts +110 -23
- package/lib/tool-names.ts +7 -0
- package/lib/tool-result.ts +33 -0
- package/lib/tui/README.md +68 -3
- package/lib/tui/chrome.ts +28 -4
- package/lib/tui/columns.ts +6 -1
- package/lib/tui/confirm.ts +65 -0
- package/lib/tui/dock-screen.ts +410 -0
- package/lib/tui/glyphs.ts +2 -1
- package/lib/tui/indent.ts +1 -1
- package/lib/tui/index.ts +2 -0
- package/lib/tui/rows.ts +21 -0
- package/lib/tui/status-controller.ts +35 -1
- package/lib/tui/tool.ts +59 -1
- package/lib/web/components.ts +20 -1
- package/lib/web/markup.ts +32 -0
- package/lib/web/runtime.ts +50 -0
- package/package.json +22 -5
- package/skills/init/SKILL.md +165 -0
- package/skills/init/references/agents-md-quality.md +103 -0
- package/skills/init/references/interview-questions.md +81 -0
- package/skills/refactor/SKILL.md +141 -0
- package/extensions/watch/process.ts +0 -63
- package/extensions/web-access/fetch/pdf.ts +0 -45
package/lib/tui/chrome.ts
CHANGED
|
@@ -102,16 +102,37 @@ export interface DockListFrame {
|
|
|
102
102
|
body: string[];
|
|
103
103
|
/** A scroll-range row printed after the body. */
|
|
104
104
|
range?: string;
|
|
105
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Focused-row footnotes printed after a blank line, so they do not read as one
|
|
107
|
+
* more row. Empty means no block and no blank line.
|
|
108
|
+
*/
|
|
106
109
|
detail?: string[];
|
|
110
|
+
/**
|
|
111
|
+
* Draw the blank line above the detail block. Defaults to true. A caller that
|
|
112
|
+
* has already shed every optional row on a short terminal passes false so the
|
|
113
|
+
* block itself survives without its separator.
|
|
114
|
+
*/
|
|
115
|
+
detailBlank?: boolean;
|
|
107
116
|
/** The key-hint row. */
|
|
108
117
|
hint: string;
|
|
109
118
|
}
|
|
110
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The rows a detail block costs: its lines plus the blank line above it.
|
|
122
|
+
*
|
|
123
|
+
* Callers feed this to `fitRows` so the block cannot push the hint row off a
|
|
124
|
+
* short terminal. It is the companion to {@link dockListFrame}, which places the
|
|
125
|
+
* block; the two have to agree on when the block exists.
|
|
126
|
+
*/
|
|
127
|
+
export function detailBlockRows(detail: readonly string[] | undefined, blank = true): number {
|
|
128
|
+
if (detail === undefined || detail.length === 0) return 0;
|
|
129
|
+
return detail.length + (blank ? 1 : 0);
|
|
130
|
+
}
|
|
131
|
+
|
|
111
132
|
/**
|
|
112
133
|
* Assemble a docked list screen: the caller's header rule, an optional summary
|
|
113
|
-
* and its blank line, the body, an optional range row, optional detail rows
|
|
114
|
-
* the blank-line-padded key-hint footer.
|
|
134
|
+
* and its blank line, the body, an optional range row, optional detail rows after
|
|
135
|
+
* their own blank line, and the blank-line-padded key-hint footer.
|
|
115
136
|
*
|
|
116
137
|
* This is the convention every docked list screen shares — the bytefly panel,
|
|
117
138
|
* `/jobs`, `/watch`, `/todos`, `/memory`, and `/rewind`. The header line is built
|
|
@@ -122,7 +143,10 @@ export function dockListFrame(frame: DockListFrame): string[] {
|
|
|
122
143
|
if (frame.summary !== undefined) lines.push(frame.summary, "");
|
|
123
144
|
lines.push(...frame.body);
|
|
124
145
|
if (frame.range !== undefined) lines.push(frame.range);
|
|
125
|
-
if (frame.detail !== undefined
|
|
146
|
+
if (frame.detail !== undefined && frame.detail.length > 0) {
|
|
147
|
+
if (frame.detailBlank !== false) lines.push("");
|
|
148
|
+
lines.push(...frame.detail);
|
|
149
|
+
}
|
|
126
150
|
lines.push("", frame.hint, "");
|
|
127
151
|
return lines;
|
|
128
152
|
}
|
package/lib/tui/columns.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { sliceByColumn, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
|
-
import { sanitizeLine } from "../format.ts";
|
|
13
|
+
import { sanitizeLine, sanitizeUntrustedLine } from "../format.ts";
|
|
14
14
|
|
|
15
15
|
/** The columns a label gets before it stops reading as a label. */
|
|
16
16
|
export const LABEL_WIDTH = 48;
|
|
@@ -62,6 +62,11 @@ export function labelLine(text: string, sanitize: (text: string) => string = san
|
|
|
62
62
|
return clip(sanitize(text), LABEL_WIDTH);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** A label clipped to one safe display line. */
|
|
66
|
+
export function clipLabel(text: string): string {
|
|
67
|
+
return labelLine(text, sanitizeUntrustedLine);
|
|
68
|
+
}
|
|
69
|
+
|
|
65
70
|
/** Pad `text` on the right to exactly `width` columns. Never truncates. */
|
|
66
71
|
export function padTo(text: string, width: number): string {
|
|
67
72
|
const missing = columns(width) - visibleWidth(text);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The y/N confirmation layer every destructive screen action shares.
|
|
3
|
+
*
|
|
4
|
+
* A screen that can kill, stop, clear, or forget asks first, and the ask is
|
|
5
|
+
* always the same: `y`/`Y`/Enter acts, `n`/`N`/Escape/`q` backs out, and any
|
|
6
|
+
* other key is left to the screen so a stray arrow does not cancel the question.
|
|
7
|
+
* The payload stays with the caller: it hands over a whole closure, so this
|
|
8
|
+
* holds no type parameter and no half-built arguments.
|
|
9
|
+
*
|
|
10
|
+
* Value-only: the pending request lives on the instance.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
14
|
+
import { isCloseKey } from "./cursor.ts";
|
|
15
|
+
|
|
16
|
+
/** What a resolved key asked for. */
|
|
17
|
+
export type ConfirmOutcome = "accept" | "dismiss";
|
|
18
|
+
|
|
19
|
+
export interface ConfirmRequest {
|
|
20
|
+
/** The question, ending in `y/N`. */
|
|
21
|
+
prompt: string;
|
|
22
|
+
/** Runs when the user accepts. */
|
|
23
|
+
accept(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ConfirmState {
|
|
27
|
+
private request: ConfirmRequest | undefined;
|
|
28
|
+
|
|
29
|
+
/** The pending question, or undefined when nothing is being confirmed. */
|
|
30
|
+
get pending(): ConfirmRequest | undefined {
|
|
31
|
+
return this.request;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Ask before acting. Replaces any earlier question. */
|
|
35
|
+
ask(prompt: string, accept: () => void): void {
|
|
36
|
+
this.request = { prompt, accept };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Drop the question without acting. */
|
|
40
|
+
clear(): void {
|
|
41
|
+
this.request = undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve one key against the pending question.
|
|
46
|
+
*
|
|
47
|
+
* Returns `undefined` for a key that is neither an answer nor a dismissal, so
|
|
48
|
+
* the caller keeps handling it — which is what makes an accidental arrow key
|
|
49
|
+
* harmless while the prompt is up.
|
|
50
|
+
*/
|
|
51
|
+
resolve(data: string): ConfirmOutcome | undefined {
|
|
52
|
+
if (!this.request) return undefined;
|
|
53
|
+
if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
|
|
54
|
+
const request = this.request;
|
|
55
|
+
this.request = undefined;
|
|
56
|
+
request.accept();
|
|
57
|
+
return "accept";
|
|
58
|
+
}
|
|
59
|
+
if (data === "n" || data === "N" || isCloseKey(data) || data === "q") {
|
|
60
|
+
this.request = undefined;
|
|
61
|
+
return "dismiss";
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two-mode docked list screen: a selectable list with a focus pane, and a
|
|
3
|
+
* scrollable detail pane over one row.
|
|
4
|
+
*
|
|
5
|
+
* Every live list in this package has the same body: `/jobs`, `/watch`, the
|
|
6
|
+
* agent board, and a check result all draw a title rule, a summary line, rows
|
|
7
|
+
* under an id-anchored cursor, an optional focus block, a range note, and a
|
|
8
|
+
* footer that shows either the key hints, a pending y/N confirmation, or one
|
|
9
|
+
* line of feedback. Only the vocabulary differs — the row, the detail block, the
|
|
10
|
+
* verbs, the words.
|
|
11
|
+
*
|
|
12
|
+
* So this class owns the frame, the navigation and scroll keys, the wheel, the
|
|
13
|
+
* confirm layer, and the pane lifecycle; a subclass supplies `rows`, the row
|
|
14
|
+
* line, the detail block, the summary cells, the hints, and what a verb does.
|
|
15
|
+
* The cursor is anchored to a row id, so a live re-sort cannot move the focus or
|
|
16
|
+
* swap the open detail out from under the user.
|
|
17
|
+
*
|
|
18
|
+
* Value-only: the cursor, pane, and pending state live on the instance.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
23
|
+
import { detailBlockRows, dockListFrame, screenHint, type ScreenHint, type ViewportRowsSource } from "./chrome.ts";
|
|
24
|
+
import { clip, fitCells } from "./columns.ts";
|
|
25
|
+
import { ConfirmState } from "./confirm.ts";
|
|
26
|
+
import { fitRows, formatRange, IdCursor, isCloseKey, navIntent, wheelDelta } from "./cursor.ts";
|
|
27
|
+
import { SEPARATORS } from "./glyphs.ts";
|
|
28
|
+
import { LinePane } from "./line-pane.ts";
|
|
29
|
+
import { emptyState } from "./rows.ts";
|
|
30
|
+
import { titleRule } from "./rules.ts";
|
|
31
|
+
import { paint } from "./tokens.ts";
|
|
32
|
+
|
|
33
|
+
/** Rows the body shows when the terminal height is unknown. */
|
|
34
|
+
const SCREEN_DEFAULT_ROWS = 14;
|
|
35
|
+
|
|
36
|
+
/** Fixed rows around the list body: header, summary, blank, blank, hint, blank. */
|
|
37
|
+
const LIST_CHROME_ROWS = 6;
|
|
38
|
+
|
|
39
|
+
/** Fixed rows around a detail body: header, subtitle, blank, blank, hint, blank. */
|
|
40
|
+
const DETAIL_CHROME_ROWS = 6;
|
|
41
|
+
|
|
42
|
+
/** What a detail pane shows: its title, lines, and the one-line subtitle. */
|
|
43
|
+
export interface DetailContents {
|
|
44
|
+
/** Pane title, e.g. `j1 build`. */
|
|
45
|
+
title: string;
|
|
46
|
+
/** The body lines, already sanitized by the caller. */
|
|
47
|
+
lines: string[];
|
|
48
|
+
/** More lines exist above the ones supplied. */
|
|
49
|
+
more?: boolean;
|
|
50
|
+
/** Subtitle under the title, e.g. the log file path. */
|
|
51
|
+
subtitle?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Rows above the scrollable body that depend on the width: key/value facts,
|
|
54
|
+
* a legend, a divider. Read fresh every frame with the live width and already
|
|
55
|
+
* painted by the subclass, because the body's lines are built once at open and
|
|
56
|
+
* would keep the width they were built at. Counted against the pane's height.
|
|
57
|
+
*/
|
|
58
|
+
facts?: (width: number) => string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What the pane can be refreshed from: the lines for one row id. */
|
|
62
|
+
export interface DetailSource {
|
|
63
|
+
lines: string[];
|
|
64
|
+
more?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DockListScreenOptions {
|
|
68
|
+
theme: Theme;
|
|
69
|
+
/** The header rule's label. */
|
|
70
|
+
title: string;
|
|
71
|
+
/** The detail pane's title when the opened row supplies none. */
|
|
72
|
+
detailTitle: string;
|
|
73
|
+
/** Empty-list line. */
|
|
74
|
+
emptyList: string;
|
|
75
|
+
/** Empty-detail line. */
|
|
76
|
+
emptyDetail: string;
|
|
77
|
+
/** Ask the host to repaint. */
|
|
78
|
+
requestRender(): void;
|
|
79
|
+
/** Close the screen. */
|
|
80
|
+
onClose(): void;
|
|
81
|
+
/** The terminal height source, for the visible-row count. */
|
|
82
|
+
viewportRowsSource?: ViewportRowsSource;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One docked list screen. Subclass it and implement the vocabulary hooks; the
|
|
87
|
+
* frame, the keys, the confirm layer, and the pane lifecycle are shared.
|
|
88
|
+
*
|
|
89
|
+
* `/jobs`, `/watch`, `/agents`, and `/check` are the subclasses. The optional
|
|
90
|
+
* members are for a screen that needs more than the basic shape, and default to
|
|
91
|
+
* the behavior a plain list wants: no pollable pane (`detailById`), a pane whose
|
|
92
|
+
* lines are fixed at open (`liveDetail`), and no verb inside the pane
|
|
93
|
+
* (`handleDetailVerb`).
|
|
94
|
+
*/
|
|
95
|
+
export abstract class DockListScreen<T> implements Component {
|
|
96
|
+
/** Focus anchored to a row id; the list is re-sorted from live state each render. */
|
|
97
|
+
protected readonly cursor = new IdCursor();
|
|
98
|
+
/** The open detail pane. */
|
|
99
|
+
protected readonly pane = new LinePane();
|
|
100
|
+
private mode: "list" | "detail" = "list";
|
|
101
|
+
private readonly confirmState = new ConfirmState();
|
|
102
|
+
private feedback: string | undefined;
|
|
103
|
+
private detailFacts: ((width: number) => string[]) | undefined;
|
|
104
|
+
|
|
105
|
+
constructor(protected readonly options: DockListScreenOptions) {}
|
|
106
|
+
|
|
107
|
+
/** The rows to show, already ordered. Re-read on every render. */
|
|
108
|
+
protected abstract rows(): readonly T[];
|
|
109
|
+
/** A row's stable id, for the cursor and the pane. */
|
|
110
|
+
protected abstract idOf(row: T): string;
|
|
111
|
+
/** One list line for `row`. */
|
|
112
|
+
protected abstract rowLine(row: T, width: number, selected: boolean): string;
|
|
113
|
+
/** The focus block under the list: one entry per line, unpainted. */
|
|
114
|
+
protected abstract detailBlock(row: T): string[];
|
|
115
|
+
/** The summary cells, painted by the subclass and joined by the base. */
|
|
116
|
+
protected abstract summaryCells(rows: readonly T[]): string[];
|
|
117
|
+
/** Footer hints while listing. */
|
|
118
|
+
protected abstract listHints(rows: readonly T[]): ScreenHint[];
|
|
119
|
+
/** Footer hints while reading a detail pane. */
|
|
120
|
+
protected abstract detailHints(row: T | undefined): ScreenHint[];
|
|
121
|
+
/** What to open for `row`, fetched fresh. */
|
|
122
|
+
protected abstract detailFor(row: T): DetailContents;
|
|
123
|
+
/** The lines to refresh an already-open pane with; omit when nothing polls it. */
|
|
124
|
+
protected detailById?(id: string): DetailSource | undefined;
|
|
125
|
+
/** Whether the open pane must re-read its source on every frame. */
|
|
126
|
+
protected liveDetail(): boolean {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
/** A verb inside the open pane; returns whether it consumed the key. */
|
|
130
|
+
protected handleDetailVerb?(key: string, row: T): boolean;
|
|
131
|
+
/** A list-mode verb other than navigation, open, and close. */
|
|
132
|
+
protected abstract handleVerb(key: string, focused: T | undefined): void;
|
|
133
|
+
|
|
134
|
+
/** Paint through the theme. */
|
|
135
|
+
protected get theme(): Theme {
|
|
136
|
+
return this.options.theme;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The row under the cursor, if any. */
|
|
140
|
+
protected get focused(): T | undefined {
|
|
141
|
+
const rows = this.rows();
|
|
142
|
+
return rows[this.cursor.indexOf(rows, (row) => this.idOf(row))];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The row whose pane is open, while it is still in the list. */
|
|
146
|
+
protected get detailRow(): T | undefined {
|
|
147
|
+
const id = this.pane.id;
|
|
148
|
+
if (!id) return undefined;
|
|
149
|
+
return this.rows().find((row) => this.idOf(row) === id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Ask for a y/N confirmation before `accept` runs. */
|
|
153
|
+
protected confirm(prompt: string, accept: () => void): void {
|
|
154
|
+
this.confirmState.ask(prompt, accept);
|
|
155
|
+
this.options.requestRender();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Show one line of feedback in place of the hints. */
|
|
159
|
+
protected notify(text: string): void {
|
|
160
|
+
this.feedback = text;
|
|
161
|
+
this.options.requestRender();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Re-clamp the focus after a mutation removed the selected row. */
|
|
165
|
+
protected resort(): void {
|
|
166
|
+
this.cursor.clamp(this.rows(), (row) => this.idOf(row));
|
|
167
|
+
this.options.requestRender();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
invalidate(): void {}
|
|
171
|
+
|
|
172
|
+
/** Replace the open detail pane's contents. */
|
|
173
|
+
setDetail(contents: DetailContents): void {
|
|
174
|
+
// Facts stay with the pane unless this update supplies its own, so a poll that
|
|
175
|
+
// only refreshes the body cannot drop the facts the row opened with.
|
|
176
|
+
if (contents.facts) this.detailFacts = contents.facts;
|
|
177
|
+
const changed =
|
|
178
|
+
contents.subtitle === undefined
|
|
179
|
+
? this.pane.update({ title: contents.title, lines: contents.lines, more: contents.more })
|
|
180
|
+
: this.pane.update({
|
|
181
|
+
title: contents.title,
|
|
182
|
+
lines: contents.lines,
|
|
183
|
+
more: contents.more,
|
|
184
|
+
subtitle: contents.subtitle,
|
|
185
|
+
});
|
|
186
|
+
if (changed) this.options.requestRender();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The row id whose detail is open, or undefined in list mode. */
|
|
190
|
+
currentDetailId(): string | undefined {
|
|
191
|
+
return this.mode === "detail" && this.pane.id ? this.pane.id : undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Re-read the open detail pane, so a poll cannot show stale lines. */
|
|
195
|
+
refreshDetail(id: string): void {
|
|
196
|
+
const source = this.detailById?.(id);
|
|
197
|
+
if (source && this.pane.update({ lines: source.lines, more: source.more })) this.options.requestRender();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
handleInput(data: string): void {
|
|
201
|
+
if (this.confirmState.pending) {
|
|
202
|
+
this.handleConfirmKey(data);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (this.mode === "list") this.handleListKey(data);
|
|
206
|
+
else this.handleDetailKey(data);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Wheel scrolling moves the selection, or scrolls the open pane. */
|
|
210
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
211
|
+
const delta = wheelDelta(event);
|
|
212
|
+
if (delta === undefined) return undefined;
|
|
213
|
+
if (this.confirmState.pending) return { handled: true };
|
|
214
|
+
if (this.mode === "list") {
|
|
215
|
+
const rows = this.rows();
|
|
216
|
+
this.select(this.cursor.indexOf(rows, (row) => this.idOf(row)) + delta);
|
|
217
|
+
} else {
|
|
218
|
+
this.pane.setScroll(this.pane.scroll + delta);
|
|
219
|
+
this.options.requestRender();
|
|
220
|
+
}
|
|
221
|
+
return { handled: true };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private select(next: number): void {
|
|
225
|
+
const rows = this.rows();
|
|
226
|
+
if (rows.length === 0) return;
|
|
227
|
+
this.cursor.select(rows, (row) => this.idOf(row), next);
|
|
228
|
+
this.options.requestRender();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private openDetail(): void {
|
|
232
|
+
const row = this.focused;
|
|
233
|
+
if (!row) return;
|
|
234
|
+
const contents = this.detailFor(row);
|
|
235
|
+
this.mode = "detail";
|
|
236
|
+
this.detailFacts = contents.facts;
|
|
237
|
+
this.pane.open(this.idOf(row), contents);
|
|
238
|
+
this.options.requestRender();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private handleListKey(data: string): void {
|
|
242
|
+
if (isCloseKey(data) || data === "q") {
|
|
243
|
+
this.options.onClose();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// Any other key dismisses a prior notice, so it does not linger.
|
|
247
|
+
if (this.feedback !== undefined) {
|
|
248
|
+
this.feedback = undefined;
|
|
249
|
+
this.options.requestRender();
|
|
250
|
+
}
|
|
251
|
+
const rows = this.rows();
|
|
252
|
+
const index = this.cursor.indexOf(rows, (row) => this.idOf(row));
|
|
253
|
+
switch (navIntent(data)) {
|
|
254
|
+
case "up":
|
|
255
|
+
this.select(index - 1);
|
|
256
|
+
return;
|
|
257
|
+
case "down":
|
|
258
|
+
this.select(index + 1);
|
|
259
|
+
return;
|
|
260
|
+
case "pageUp":
|
|
261
|
+
this.select(index - this.cursor.visible);
|
|
262
|
+
return;
|
|
263
|
+
case "pageDown":
|
|
264
|
+
this.select(index + this.cursor.visible);
|
|
265
|
+
return;
|
|
266
|
+
case "home":
|
|
267
|
+
this.select(0);
|
|
268
|
+
return;
|
|
269
|
+
case "end":
|
|
270
|
+
this.select(rows.length - 1);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (matchesKey(data, Key.enter) || data === "l") {
|
|
274
|
+
this.openDetail();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
this.handleVerb(data, this.focused);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private handleConfirmKey(data: string): void {
|
|
281
|
+
const outcome = this.confirmState.resolve(data);
|
|
282
|
+
if (!outcome) return;
|
|
283
|
+
if (outcome === "accept") this.cursor.clamp(this.rows(), (row) => this.idOf(row));
|
|
284
|
+
this.options.requestRender();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private handleDetailKey(data: string): void {
|
|
288
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) || data === "q") {
|
|
289
|
+
this.mode = "list";
|
|
290
|
+
this.pane.close();
|
|
291
|
+
this.detailFacts = undefined;
|
|
292
|
+
this.options.requestRender();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const row = this.detailRow;
|
|
296
|
+
// The verb owns its repaint: it may change state the host already watches.
|
|
297
|
+
if (row !== undefined && this.handleDetailVerb?.(data, row) === true) return;
|
|
298
|
+
if (this.pane.handleKey(data)) this.options.requestRender();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The footer: a pending confirmation, one line of feedback, or the hints. */
|
|
302
|
+
private hint(width: number, rows: readonly T[]): string {
|
|
303
|
+
const pending = this.confirmState.pending;
|
|
304
|
+
if (pending) return clip(` ${paint(this.theme, "warn", pending.prompt)}`, width);
|
|
305
|
+
if (this.feedback) return clip(` ${paint(this.theme, "meta", this.feedback)}`, width);
|
|
306
|
+
return screenHint(this.theme, width, this.listHints(rows));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private summary(rows: readonly T[], width: number): string {
|
|
310
|
+
const separator = paint(this.theme, "meta", SEPARATORS.item);
|
|
311
|
+
return clip(` ${fitCells(this.summaryCells(rows), Math.max(1, width - 2), separator)}`, width);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
render(width: number): string[] {
|
|
315
|
+
const w = Math.max(1, width);
|
|
316
|
+
return this.mode === "detail" ? this.renderDetail(w) : this.renderList(w);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private renderList(w: number): string[] {
|
|
320
|
+
const rows = this.rows();
|
|
321
|
+
const id = (row: T): string => this.idOf(row);
|
|
322
|
+
this.cursor.clamp(rows, id);
|
|
323
|
+
const focused = rows[this.cursor.indexOf(rows, id)];
|
|
324
|
+
const block = focused ? this.detailBlock(focused) : [];
|
|
325
|
+
this.cursor.visible = fitRows(
|
|
326
|
+
this.options.viewportRowsSource,
|
|
327
|
+
rows.length,
|
|
328
|
+
LIST_CHROME_ROWS + detailBlockRows(block),
|
|
329
|
+
SCREEN_DEFAULT_ROWS,
|
|
330
|
+
);
|
|
331
|
+
this.cursor.clamp(rows, id);
|
|
332
|
+
const index = this.cursor.indexOf(rows, id);
|
|
333
|
+
this.cursor.ensureVisible(index);
|
|
334
|
+
|
|
335
|
+
const body: string[] = [];
|
|
336
|
+
let range: string | undefined;
|
|
337
|
+
if (rows.length === 0) {
|
|
338
|
+
body.push(...emptyState(this.theme, w, { label: this.options.emptyList }));
|
|
339
|
+
} else {
|
|
340
|
+
const end = Math.min(rows.length, this.cursor.scroll + this.cursor.visible);
|
|
341
|
+
for (let i = this.cursor.scroll; i < end; i++) body.push(this.rowLine(rows[i], w, i === index));
|
|
342
|
+
if (this.cursor.scroll > 0 || end < rows.length) {
|
|
343
|
+
range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: rows.length });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return dockListFrame({
|
|
348
|
+
header: titleRule(this.theme, w, this.options.title),
|
|
349
|
+
summary: this.summary(rows, w),
|
|
350
|
+
body,
|
|
351
|
+
range,
|
|
352
|
+
detail: block.map((line) => clip(` ${paint(this.theme, "meta", line)}`, w)),
|
|
353
|
+
hint: this.hint(w, rows),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private renderDetail(w: number): string[] {
|
|
358
|
+
this.syncDetail();
|
|
359
|
+
// A live pane whose row is gone has already closed itself: draw the list.
|
|
360
|
+
if (this.mode !== "detail") return this.renderList(w);
|
|
361
|
+
const facts = this.detailFacts ? this.detailFacts(w) : [];
|
|
362
|
+
const visible = fitRows(
|
|
363
|
+
this.options.viewportRowsSource,
|
|
364
|
+
this.pane.count,
|
|
365
|
+
DETAIL_CHROME_ROWS + facts.length,
|
|
366
|
+
SCREEN_DEFAULT_ROWS,
|
|
367
|
+
);
|
|
368
|
+
this.pane.fit(visible);
|
|
369
|
+
|
|
370
|
+
const body: string[] = [...facts];
|
|
371
|
+
if (body.length > 0) body.push("");
|
|
372
|
+
let range: string | undefined;
|
|
373
|
+
if (this.pane.count === 0) {
|
|
374
|
+
body.push(...emptyState(this.theme, w, { label: this.options.emptyDetail }));
|
|
375
|
+
} else {
|
|
376
|
+
const { start, end } = this.pane.window();
|
|
377
|
+
for (let i = start; i < end; i++) body.push(clip(` ${this.pane.lines[i]}`, w));
|
|
378
|
+
if (start > 0 || end < this.pane.count) {
|
|
379
|
+
range = formatRange(this.theme, w, { start, end, total: this.pane.count, label: "line" });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return dockListFrame({
|
|
384
|
+
header: titleRule(this.theme, w, this.pane.title(this.options.detailTitle)),
|
|
385
|
+
summary: clip(` ${paint(this.theme, "faint", this.pane.subtitleLine())}`, w),
|
|
386
|
+
body,
|
|
387
|
+
range,
|
|
388
|
+
hint: screenHint(this.theme, w, this.detailHints(this.detailRow)),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Re-read a live pane in place. This runs inside a render, so it must not ask
|
|
394
|
+
* for another frame; a source that disappeared closes the pane instead of
|
|
395
|
+
* showing lines the list no longer has a row for.
|
|
396
|
+
*/
|
|
397
|
+
private syncDetail(): void {
|
|
398
|
+
if (!this.liveDetail()) return;
|
|
399
|
+
const id = this.pane.id;
|
|
400
|
+
if (!id) return;
|
|
401
|
+
const source = this.detailById?.(id);
|
|
402
|
+
if (!source) {
|
|
403
|
+
this.mode = "list";
|
|
404
|
+
this.pane.close();
|
|
405
|
+
this.detailFacts = undefined;
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
this.pane.update({ lines: source.lines, more: source.more });
|
|
409
|
+
}
|
|
410
|
+
}
|
package/lib/tui/glyphs.ts
CHANGED
|
@@ -25,7 +25,7 @@ export const GLYPHS = {
|
|
|
25
25
|
worktree: "⑂",
|
|
26
26
|
rewind: "↺",
|
|
27
27
|
snapshot: "⬥",
|
|
28
|
-
plan: "
|
|
28
|
+
plan: "≡",
|
|
29
29
|
jobsRunning: "▸",
|
|
30
30
|
jobsFailure: "✗",
|
|
31
31
|
stopped: "⊘",
|
|
@@ -72,6 +72,7 @@ export const STATUS_KEYS = {
|
|
|
72
72
|
watchMatches: "watch-matches",
|
|
73
73
|
browse: "browse",
|
|
74
74
|
rewind: "rewind",
|
|
75
|
+
agents: "agents",
|
|
75
76
|
wechat: "wechat",
|
|
76
77
|
} as const;
|
|
77
78
|
|
package/lib/tui/indent.ts
CHANGED
|
@@ -56,7 +56,7 @@ export interface IndentOptions {
|
|
|
56
56
|
* line that is only spaces carrying a background (a `Box`'s own padding) counts
|
|
57
57
|
* as blank rather than as content.
|
|
58
58
|
*/
|
|
59
|
-
function isBlank(line: string): boolean {
|
|
59
|
+
export function isBlank(line: string): boolean {
|
|
60
60
|
return stripTerminalSequences(line).trim().length === 0;
|
|
61
61
|
}
|
|
62
62
|
|
package/lib/tui/index.ts
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
|
|
20
20
|
export * from "./chrome.ts";
|
|
21
21
|
export * from "./columns.ts";
|
|
22
|
+
export * from "./confirm.ts";
|
|
22
23
|
export * from "./cursor.ts";
|
|
24
|
+
export * from "./dock-screen.ts";
|
|
23
25
|
export * from "./gauge.ts";
|
|
24
26
|
export * from "./glyphs.ts";
|
|
25
27
|
export * from "./line-pane.ts";
|
package/lib/tui/rows.ts
CHANGED
|
@@ -75,6 +75,8 @@ export interface StatusRow {
|
|
|
75
75
|
glyph: string;
|
|
76
76
|
glyphRole: TuiRole;
|
|
77
77
|
label: string;
|
|
78
|
+
/** Role for the label. Default `label`; a list accents its focused row. */
|
|
79
|
+
labelRole?: TuiRole;
|
|
78
80
|
trailing: string;
|
|
79
81
|
/** Role for the trailing meta. Default `faint`. */
|
|
80
82
|
trailingRole?: TuiRole;
|
|
@@ -88,6 +90,7 @@ export function statusRow(theme: Theme, width: number, row: StatusRow): string {
|
|
|
88
90
|
glyph: row.glyph,
|
|
89
91
|
glyphRole: row.glyphRole,
|
|
90
92
|
label: row.label,
|
|
93
|
+
labelRole: row.labelRole,
|
|
91
94
|
trailing: row.trailing,
|
|
92
95
|
trailingRole: row.trailingRole ?? "faint",
|
|
93
96
|
});
|
|
@@ -141,3 +144,21 @@ export function emptyState(theme: Theme, width: number, state: EmptyState): stri
|
|
|
141
144
|
if (state.hint) lines.push(clip(`${indent}${paint(theme, "faint", state.hint)}`, width));
|
|
142
145
|
return lines;
|
|
143
146
|
}
|
|
147
|
+
|
|
148
|
+
/** A row a live list can order: it has a start time. */
|
|
149
|
+
export interface LiveRow {
|
|
150
|
+
startedAt: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Order a live list the way every screen in this package does: the active rows
|
|
155
|
+
* first, then the most recently started.
|
|
156
|
+
*
|
|
157
|
+
* The two accessors keep each caller's vocabulary local — a job is active when
|
|
158
|
+
* its status is `running`, a watcher likewise — while the ordering rule lives
|
|
159
|
+
* once, so the screen and the transcript cannot drift apart.
|
|
160
|
+
*/
|
|
161
|
+
export function byRunningFirst<T extends LiveRow>(isRunning: (row: T) => boolean): (a: T, b: T) => number {
|
|
162
|
+
const rank = (row: T): number => (isRunning(row) ? 0 : 1);
|
|
163
|
+
return (a, b) => rank(a) - rank(b) || b.startedAt - a.startedAt;
|
|
164
|
+
}
|
|
@@ -36,16 +36,32 @@ export interface StatusControllerOptions {
|
|
|
36
36
|
chips(): readonly StatusChip[];
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** How a transient notification reads. */
|
|
40
|
+
export type NotifySeverity = "info" | "warning" | "error";
|
|
41
|
+
|
|
39
42
|
/** An attach/paint/clear surface for one extension's footer chips. */
|
|
40
43
|
export interface StatusController {
|
|
41
44
|
/** Remember the live context; later calls use it when none is passed. */
|
|
42
45
|
attach(ctx: ExtensionContext): void;
|
|
43
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Clear the chips and forget the context, for example on shutdown or reload.
|
|
48
|
+
*
|
|
49
|
+
* A detached controller cannot paint, so leaving a stale chip behind would
|
|
50
|
+
* misreport a feature that is gone.
|
|
51
|
+
*/
|
|
44
52
|
detach(): void;
|
|
45
53
|
/** Republish using the attached context. */
|
|
46
54
|
paint(): void;
|
|
47
55
|
/** Publish the chips (and attach `ctx` when given). */
|
|
48
56
|
setStatus(ctx?: ExtensionContext): void;
|
|
57
|
+
/**
|
|
58
|
+
* Show a transient notification through the attached context.
|
|
59
|
+
*
|
|
60
|
+
* The chips and the alert belong to the same feature, so they share the one
|
|
61
|
+
* remembered context: without this, each caller re-mirrors it in a wrapper.
|
|
62
|
+
* A non-interactive mode has no UI, so this no-ops instead of throwing.
|
|
63
|
+
*/
|
|
64
|
+
notify(text: string, severity: NotifySeverity): void;
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
/** A context's theme, or `undefined` when the UI is unavailable. */
|
|
@@ -92,16 +108,34 @@ export function createStatusController(options: StatusControllerOptions): Status
|
|
|
92
108
|
}
|
|
93
109
|
};
|
|
94
110
|
|
|
111
|
+
const notify = (text: string, severity: NotifySeverity): void => {
|
|
112
|
+
const target = ctx;
|
|
113
|
+
if (!target) return;
|
|
114
|
+
try {
|
|
115
|
+
target.ui.notify(text, severity);
|
|
116
|
+
} catch {
|
|
117
|
+
// UI may be unavailable in non-interactive modes.
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
95
121
|
return {
|
|
96
122
|
attach: (next) => {
|
|
97
123
|
ctx = next;
|
|
98
124
|
},
|
|
99
125
|
detach: () => {
|
|
126
|
+
const target = ctx;
|
|
100
127
|
ctx = undefined;
|
|
128
|
+
if (!target) return;
|
|
129
|
+
try {
|
|
130
|
+
for (const key of options.keys) target.ui.setStatus(key, undefined);
|
|
131
|
+
} catch {
|
|
132
|
+
// UI is already gone during shutdown.
|
|
133
|
+
}
|
|
101
134
|
},
|
|
102
135
|
paint: () => {
|
|
103
136
|
setStatus();
|
|
104
137
|
},
|
|
105
138
|
setStatus,
|
|
139
|
+
notify,
|
|
106
140
|
};
|
|
107
141
|
}
|