gentle-pi 2.3.0 → 2.5.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 +195 -11
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/orchestrator-delegation.md +21 -10
- package/assets/orchestrator.md +8 -12
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
- package/docs/delegated-verification.md +25 -0
- package/docs/review-integration.md +1 -1
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +1421 -143
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agent-home.ts +8 -0
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +103 -12
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-host-relay.ts +146 -60
- package/lib/review-integration-v2.ts +92 -13
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +219 -0
- package/lib/sdd-preflight.ts +2 -2
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +102 -11
- package/runtime/review-integration-v2.mjs +92 -13
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +18 -13
- package/skills/_shared/review-ledger-contract.md +9 -1
- package/skills/issue-creation/SKILL.md +53 -93
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
- package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
- package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +31 -14
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/issue-creation-skill.test.ts +103 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +33 -1
- package/tests/native-review-cli.test.ts +40 -0
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +29 -22
- package/tests/orchestrator-budget.test.ts +71 -2
- package/tests/orchestrator-rdd-ownership.test.ts +10 -1
- package/tests/package-manifest.test.ts +134 -9
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +142 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-routing.test.ts +229 -11
- package/tests/review-host-relay.test.ts +195 -7
- package/tests/review-integration-v2-forward.test.ts +47 -0
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-ledger-contract.test.ts +1 -1
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +608 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +126 -0
- package/tests/runtime-harness.mjs +1 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/telemetry-trigger.test.ts +349 -0
- package/tests/writer-edit-surface-scope.test.ts +153 -17
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
- /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { CHANGE_STATUS, changesSummary, type ChangedFile, type ChangesModel } from "./shell-changes.ts";
|
|
3
|
+
|
|
4
|
+
// Gentle Shell changes overlay: a framed two-pane view with the working
|
|
5
|
+
// tree's changed files on the left and the selected file's diff on the right.
|
|
6
|
+
// Git access is injected so the component renders without a repository.
|
|
7
|
+
|
|
8
|
+
export interface ChangesViewTheme {
|
|
9
|
+
fg(color: string, text: string): string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ChangesViewDeps {
|
|
13
|
+
theme: ChangesViewTheme;
|
|
14
|
+
rows: number;
|
|
15
|
+
loadDiff(file: ChangedFile): Promise<string>;
|
|
16
|
+
onOpen(file: ChangedFile): void;
|
|
17
|
+
onClose(): void;
|
|
18
|
+
requestRender(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ROLE = {
|
|
22
|
+
FRAME: "border",
|
|
23
|
+
TITLE: "customMessageLabel",
|
|
24
|
+
SELECTED: "accent",
|
|
25
|
+
PATH: "text",
|
|
26
|
+
PATH_IDLE: "muted",
|
|
27
|
+
ADDED: "success",
|
|
28
|
+
REMOVED: "error",
|
|
29
|
+
NEW: "success",
|
|
30
|
+
HUNK: "customMessageLabel",
|
|
31
|
+
KEY: "accent",
|
|
32
|
+
KEY_TEXT: "dim",
|
|
33
|
+
EMPTY: "dim",
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
const HEADER_PREFIXES = ["diff --git", "index ", "--- ", "+++ ", "new file mode", "deleted file mode", "similarity index", "rename from", "rename to", "Binary files"];
|
|
37
|
+
const LIST_MAX_WIDTH = 36;
|
|
38
|
+
const LIST_RATIO = 0.35;
|
|
39
|
+
const CHROME_ROWS = 3;
|
|
40
|
+
const MIN_BODY_ROWS = 1;
|
|
41
|
+
const EMPTY_DIFF = "no diff for this file";
|
|
42
|
+
const CLEAN_TREE = "working tree is clean";
|
|
43
|
+
const KEYS = [
|
|
44
|
+
["j/k", "file"],
|
|
45
|
+
["ctrl+j/k", "scroll"],
|
|
46
|
+
["o", "open in editor"],
|
|
47
|
+
["esc", "close"],
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
function rule(length: number): string {
|
|
51
|
+
return "─".repeat(Math.max(0, length));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function colorDiff(text: string, theme: ChangesViewTheme): string[] {
|
|
55
|
+
const lines: string[] = [];
|
|
56
|
+
for (const line of text.split("\n")) {
|
|
57
|
+
if (line === "" || HEADER_PREFIXES.some((prefix) => line.startsWith(prefix))) continue;
|
|
58
|
+
if (line.startsWith("@@")) lines.push(theme.fg(ROLE.HUNK, line));
|
|
59
|
+
else if (line.startsWith("+")) lines.push(theme.fg("toolDiffAdded", line));
|
|
60
|
+
else if (line.startsWith("-")) lines.push(theme.fg("toolDiffRemoved", line));
|
|
61
|
+
else lines.push(theme.fg("toolDiffContext", line));
|
|
62
|
+
}
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fileCounts(file: ChangedFile, theme: ChangesViewTheme): string {
|
|
67
|
+
if (file.status === CHANGE_STATUS.UNTRACKED || file.status === CHANGE_STATUS.ADDED) {
|
|
68
|
+
return `${theme.fg(ROLE.ADDED, `+${file.added}`)} ${theme.fg(ROLE.NEW, "new")}`;
|
|
69
|
+
}
|
|
70
|
+
return `${theme.fg(ROLE.ADDED, `+${file.added}`)} ${theme.fg(ROLE.REMOVED, `−${file.deleted}`)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fit(text: string, width: number): string {
|
|
74
|
+
const clipped = truncateToWidth(text, width, "…");
|
|
75
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fingerprint(file: ChangedFile): string {
|
|
79
|
+
return `${file.status}:${file.added}:${file.deleted}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class ChangesView {
|
|
83
|
+
private model: ChangesModel;
|
|
84
|
+
private readonly deps: ChangesViewDeps;
|
|
85
|
+
private selected = 0;
|
|
86
|
+
private scroll = 0;
|
|
87
|
+
private readonly diffs = new Map<string, string[]>();
|
|
88
|
+
|
|
89
|
+
constructor(model: ChangesModel, deps: ChangesViewDeps) {
|
|
90
|
+
this.model = model;
|
|
91
|
+
this.deps = deps;
|
|
92
|
+
this.loadSelected();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Replace the model while open: keep the selection by path and drop cached
|
|
96
|
+
// diffs for files whose counts moved so they reload.
|
|
97
|
+
update(model: ChangesModel): void {
|
|
98
|
+
const selectedPath = this.model.files[this.selected]?.path;
|
|
99
|
+
const before = new Map(this.model.files.map((file) => [file.path, fingerprint(file)]));
|
|
100
|
+
for (const file of model.files) {
|
|
101
|
+
if (before.get(file.path) !== fingerprint(file)) this.diffs.delete(file.path);
|
|
102
|
+
}
|
|
103
|
+
for (const path of this.diffs.keys()) {
|
|
104
|
+
if (!model.files.some((file) => file.path === path)) this.diffs.delete(path);
|
|
105
|
+
}
|
|
106
|
+
this.model = model;
|
|
107
|
+
const index = model.files.findIndex((file) => file.path === selectedPath);
|
|
108
|
+
this.selected = index === -1 ? Math.max(0, Math.min(this.selected, model.files.length - 1)) : index;
|
|
109
|
+
this.loadSelected();
|
|
110
|
+
this.deps.requestRender();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
handleInput(data: string): void {
|
|
114
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
115
|
+
this.deps.onClose();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// ctrl+j arrives as a bare line feed, which pi also reads as enter, so
|
|
119
|
+
// the scroll keys are checked before the open key; a real Enter is CR.
|
|
120
|
+
if (data === "j" || matchesKey(data, Key.down)) this.select(this.selected + 1);
|
|
121
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.select(this.selected - 1);
|
|
122
|
+
else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("j"))) this.scrollBy(this.bodyRows());
|
|
123
|
+
else if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("k"))) this.scrollBy(-this.bodyRows());
|
|
124
|
+
else if (data === "o" || matchesKey(data, Key.enter)) {
|
|
125
|
+
const file = this.model.files[this.selected];
|
|
126
|
+
if (file) this.deps.onOpen(file);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
render(width: number): string[] {
|
|
131
|
+
const theme = this.deps.theme;
|
|
132
|
+
const inner = width - 2;
|
|
133
|
+
const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
|
|
134
|
+
const diffWidth = inner - listWidth - 4;
|
|
135
|
+
const titleText = `✎ Changes · ${changesSummary(this.model)}`;
|
|
136
|
+
const top = theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, titleText) + theme.fg(ROLE.FRAME, ` ${rule(inner - visibleWidth(titleText) - 3)}╮`);
|
|
137
|
+
const rows = this.bodyRows();
|
|
138
|
+
const diff = this.visibleDiff(rows);
|
|
139
|
+
const body: string[] = [];
|
|
140
|
+
for (let row = 0; row < rows; row += 1) {
|
|
141
|
+
const left = fit(this.fileLine(row), listWidth);
|
|
142
|
+
const right = fit(diff[row] ?? "", diffWidth);
|
|
143
|
+
body.push(`${theme.fg(ROLE.FRAME, "│")} ${left} ${theme.fg(ROLE.FRAME, "│")} ${right}${theme.fg(ROLE.FRAME, "│")}`);
|
|
144
|
+
}
|
|
145
|
+
const keys = KEYS.map(([key, label]) => `${theme.fg(ROLE.KEY, key)} ${theme.fg(ROLE.KEY_TEXT, label)}`).join(" ");
|
|
146
|
+
const keysLine = `${theme.fg(ROLE.FRAME, "│")} ${fit(keys, inner - 2)} ${theme.fg(ROLE.FRAME, "│")}`;
|
|
147
|
+
const bottom = theme.fg(ROLE.FRAME, `╰${rule(inner)}╯`);
|
|
148
|
+
return [top, ...body, keysLine, bottom];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
invalidate(): void {}
|
|
152
|
+
|
|
153
|
+
private bodyRows(): number {
|
|
154
|
+
return Math.max(MIN_BODY_ROWS, this.deps.rows - CHROME_ROWS);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private fileLine(row: number): string {
|
|
158
|
+
const file = this.model.files[row];
|
|
159
|
+
if (!file) return "";
|
|
160
|
+
const theme = this.deps.theme;
|
|
161
|
+
const marker = row === this.selected ? theme.fg(ROLE.SELECTED, "▸") : " ";
|
|
162
|
+
const path = theme.fg(row === this.selected ? ROLE.PATH : ROLE.PATH_IDLE, file.path);
|
|
163
|
+
return `${marker} ${path} ${fileCounts(file, theme)}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private visibleDiff(rows: number): string[] {
|
|
167
|
+
const file = this.model.files[this.selected];
|
|
168
|
+
if (!file) return [this.deps.theme.fg(ROLE.EMPTY, CLEAN_TREE)];
|
|
169
|
+
const lines = this.diffs.get(file.path);
|
|
170
|
+
if (!lines) return [];
|
|
171
|
+
if (lines.length === 0) return [this.deps.theme.fg(ROLE.EMPTY, EMPTY_DIFF)];
|
|
172
|
+
const maxScroll = Math.max(0, lines.length - rows);
|
|
173
|
+
this.scroll = Math.min(this.scroll, maxScroll);
|
|
174
|
+
return lines.slice(this.scroll, this.scroll + rows);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private select(index: number): void {
|
|
178
|
+
const next = Math.max(0, Math.min(this.model.files.length - 1, index));
|
|
179
|
+
if (next === this.selected) return;
|
|
180
|
+
this.selected = next;
|
|
181
|
+
this.scroll = 0;
|
|
182
|
+
this.loadSelected();
|
|
183
|
+
this.deps.requestRender();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private scrollBy(delta: number): void {
|
|
187
|
+
this.scroll = Math.max(0, this.scroll + delta);
|
|
188
|
+
this.deps.requestRender();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private loadSelected(): void {
|
|
192
|
+
const file = this.model.files[this.selected];
|
|
193
|
+
if (!file || this.diffs.has(file.path)) return;
|
|
194
|
+
void this.deps.loadDiff(file).then(
|
|
195
|
+
(text) => {
|
|
196
|
+
this.diffs.set(file.path, colorDiff(text, this.deps.theme));
|
|
197
|
+
this.deps.requestRender();
|
|
198
|
+
},
|
|
199
|
+
() => {
|
|
200
|
+
this.diffs.set(file.path, []);
|
|
201
|
+
this.deps.requestRender();
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
// Gentle Shell changes: the working tree against HEAD, new files included.
|
|
4
|
+
// Git is the source of truth; this module turns raw `git diff --numstat` and
|
|
5
|
+
// `git status --porcelain -z` output into a model and renders the widget.
|
|
6
|
+
|
|
7
|
+
export const CHANGE_STATUS = {
|
|
8
|
+
MODIFIED: "modified",
|
|
9
|
+
ADDED: "added",
|
|
10
|
+
DELETED: "deleted",
|
|
11
|
+
RENAMED: "renamed",
|
|
12
|
+
UNTRACKED: "untracked",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export type ChangeStatus = (typeof CHANGE_STATUS)[keyof typeof CHANGE_STATUS];
|
|
16
|
+
|
|
17
|
+
export interface ChangedFile {
|
|
18
|
+
path: string;
|
|
19
|
+
added: number;
|
|
20
|
+
deleted: number;
|
|
21
|
+
status: ChangeStatus;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ChangesModel {
|
|
25
|
+
files: ChangedFile[];
|
|
26
|
+
added: number;
|
|
27
|
+
deleted: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ChangesSnapshot {
|
|
31
|
+
numstat: string;
|
|
32
|
+
porcelain: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ChangesTheme {
|
|
36
|
+
fg(color: string, text: string): string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface NumstatEntry {
|
|
40
|
+
path: string;
|
|
41
|
+
added: number;
|
|
42
|
+
deleted: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const CHANGES_COMMAND = "/gentle:changes";
|
|
46
|
+
const WIDGET_GLYPH = "✎";
|
|
47
|
+
const STATUS_BY_CODE: Record<string, ChangeStatus> = {
|
|
48
|
+
A: CHANGE_STATUS.ADDED,
|
|
49
|
+
D: CHANGE_STATUS.DELETED,
|
|
50
|
+
R: CHANGE_STATUS.RENAMED,
|
|
51
|
+
C: CHANGE_STATUS.ADDED,
|
|
52
|
+
"?": CHANGE_STATUS.UNTRACKED,
|
|
53
|
+
};
|
|
54
|
+
const RENAME_BRACES = /\{([^{}]*) => ([^{}]*)\}/g;
|
|
55
|
+
const HAS_RENAME_BRACES = /\{[^{}]* => [^{}]*\}/;
|
|
56
|
+
const RENAME_ARROW = " => ";
|
|
57
|
+
|
|
58
|
+
export function emptyChanges(): ChangesModel {
|
|
59
|
+
return { files: [], added: 0, deleted: 0 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renamedPath(path: string): string {
|
|
63
|
+
if (HAS_RENAME_BRACES.test(path)) return path.replace(RENAME_BRACES, "$2").replace(/\/\//g, "/");
|
|
64
|
+
const arrow = path.indexOf(RENAME_ARROW);
|
|
65
|
+
return arrow === -1 ? path : path.slice(arrow + RENAME_ARROW.length);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function count(value: string): number {
|
|
69
|
+
const parsed = Number.parseInt(value, 10);
|
|
70
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseNumstat(text: string): NumstatEntry[] {
|
|
74
|
+
const entries: NumstatEntry[] = [];
|
|
75
|
+
for (const line of text.split("\n")) {
|
|
76
|
+
const [added, deleted, ...rest] = line.split("\t");
|
|
77
|
+
if (added === undefined || deleted === undefined || rest.length === 0) continue;
|
|
78
|
+
entries.push({ path: renamedPath(rest.join("\t")), added: count(added), deleted: count(deleted) });
|
|
79
|
+
}
|
|
80
|
+
return entries;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parsePorcelain(text: string): Map<string, ChangeStatus> {
|
|
84
|
+
const statuses = new Map<string, ChangeStatus>();
|
|
85
|
+
const records = text.split("\0").filter((record) => record.length > 0);
|
|
86
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
87
|
+
const record = records[index];
|
|
88
|
+
const code = record.slice(0, 2);
|
|
89
|
+
const path = record.slice(3);
|
|
90
|
+
const status = STATUS_BY_CODE[code[0]] ?? STATUS_BY_CODE[code[1]] ?? CHANGE_STATUS.MODIFIED;
|
|
91
|
+
statuses.set(path, status);
|
|
92
|
+
if (code[0] === "R" || code[0] === "C") index += 1;
|
|
93
|
+
}
|
|
94
|
+
return statuses;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function snapshotChanges(snapshot: ChangesSnapshot): ChangedFile[] {
|
|
98
|
+
const counts = new Map(parseNumstat(snapshot.numstat).map((entry) => [entry.path, entry]));
|
|
99
|
+
const files: ChangedFile[] = [];
|
|
100
|
+
for (const [path, status] of parsePorcelain(snapshot.porcelain)) {
|
|
101
|
+
const entry = counts.get(path);
|
|
102
|
+
files.push({ path, added: entry?.added ?? 0, deleted: entry?.deleted ?? 0, status });
|
|
103
|
+
}
|
|
104
|
+
return files;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function changesModel(changed: ChangedFile[]): ChangesModel {
|
|
108
|
+
const files = [...changed].sort((a, b) => a.path.localeCompare(b.path));
|
|
109
|
+
return {
|
|
110
|
+
files,
|
|
111
|
+
added: files.reduce((total, file) => total + file.added, 0),
|
|
112
|
+
deleted: files.reduce((total, file) => total + file.deleted, 0),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function changesSummary(model: ChangesModel): string {
|
|
117
|
+
const noun = model.files.length === 1 ? "file" : "files";
|
|
118
|
+
return `${model.files.length} ${noun} · +${model.added} −${model.deleted}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// One line: summary, the files joined by dots, and the command pushed to
|
|
122
|
+
// the right edge. The file list goes first when the terminal is narrow.
|
|
123
|
+
export function renderChangesWidget(model: ChangesModel, theme: ChangesTheme, width: number): string[] {
|
|
124
|
+
if (model.files.length === 0) return [];
|
|
125
|
+
const noun = model.files.length === 1 ? "file" : "files";
|
|
126
|
+
const dot = theme.fg("muted", "·");
|
|
127
|
+
const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}`;
|
|
128
|
+
const hint = theme.fg("dim", CHANGES_COMMAND);
|
|
129
|
+
const list = model.files.map((file) => theme.fg("muted", file.path)).join(` ${dot} `);
|
|
130
|
+
const left = `${head} ${dot} ${list}`;
|
|
131
|
+
const gap = width - visibleWidth(left) - visibleWidth(hint);
|
|
132
|
+
if (gap >= 2) return [`${left}${" ".repeat(gap)}${hint}`];
|
|
133
|
+
const headGap = width - visibleWidth(head) - visibleWidth(hint);
|
|
134
|
+
if (headGap >= 2) return [`${head}${" ".repeat(headGap)}${hint}`];
|
|
135
|
+
return [truncateToWidth(head, width, "…")];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface GitResult {
|
|
139
|
+
stdout: string;
|
|
140
|
+
code: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type GitRunner = (args: string[]) => Promise<GitResult>;
|
|
144
|
+
export type LineCounter = (path: string) => Promise<number>;
|
|
145
|
+
|
|
146
|
+
const noLines: LineCounter = async () => 0;
|
|
147
|
+
|
|
148
|
+
const NUMSTAT_ARGS = ["diff", "--numstat", "HEAD"];
|
|
149
|
+
const PORCELAIN_ARGS = ["status", "--porcelain=v1", "--untracked-files=all", "-z"];
|
|
150
|
+
|
|
151
|
+
// Tracks working-tree changes. Concurrent refreshes coalesce: one git
|
|
152
|
+
// round-trip runs at a time and a refresh requested meanwhile triggers
|
|
153
|
+
// exactly one more.
|
|
154
|
+
export class ChangesTracker {
|
|
155
|
+
private readonly git: GitRunner;
|
|
156
|
+
private readonly countLines: LineCounter;
|
|
157
|
+
private current: ChangesModel = emptyChanges();
|
|
158
|
+
private available = false;
|
|
159
|
+
private inFlight: Promise<ChangesModel> | undefined;
|
|
160
|
+
private queued = false;
|
|
161
|
+
|
|
162
|
+
constructor(git: GitRunner, countLines: LineCounter = noLines) {
|
|
163
|
+
this.git = git;
|
|
164
|
+
this.countLines = countLines;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
get model(): ChangesModel {
|
|
168
|
+
return this.current;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async start(): Promise<void> {
|
|
172
|
+
const files = await this.capture();
|
|
173
|
+
this.available = files !== undefined;
|
|
174
|
+
this.current = files ? changesModel(files) : emptyChanges();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async refresh(): Promise<ChangesModel> {
|
|
178
|
+
if (!this.available) return this.current;
|
|
179
|
+
if (this.inFlight) {
|
|
180
|
+
this.queued = true;
|
|
181
|
+
return this.inFlight;
|
|
182
|
+
}
|
|
183
|
+
this.inFlight = this.runRefresh();
|
|
184
|
+
try {
|
|
185
|
+
return await this.inFlight;
|
|
186
|
+
} finally {
|
|
187
|
+
this.inFlight = undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async runRefresh(): Promise<ChangesModel> {
|
|
192
|
+
do {
|
|
193
|
+
this.queued = false;
|
|
194
|
+
const files = await this.capture();
|
|
195
|
+
if (files) this.current = changesModel(files);
|
|
196
|
+
} while (this.queued);
|
|
197
|
+
return this.current;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private async capture(): Promise<ChangedFile[] | undefined> {
|
|
201
|
+
const [numstat, porcelain] = await Promise.all([this.git(NUMSTAT_ARGS), this.git(PORCELAIN_ARGS)]);
|
|
202
|
+
if (numstat.code !== 0 || porcelain.code !== 0) return undefined;
|
|
203
|
+
const files = snapshotChanges({ numstat: numstat.stdout, porcelain: porcelain.stdout });
|
|
204
|
+
// Untracked files never appear in numstat; count their lines directly.
|
|
205
|
+
for (const file of files) {
|
|
206
|
+
if (file.status === CHANGE_STATUS.UNTRACKED) file.added = await this.countLines(file.path).catch(() => 0);
|
|
207
|
+
}
|
|
208
|
+
return files;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Shared gauge primitives for the Gentle Shell bar and panels.
|
|
2
|
+
|
|
3
|
+
const GAUGE_TONE = {
|
|
4
|
+
ACCENT: "accent",
|
|
5
|
+
WARNING: "warning",
|
|
6
|
+
ERROR: "error",
|
|
7
|
+
DIM: "dim",
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export type GaugeTone = (typeof GAUGE_TONE)[keyof typeof GAUGE_TONE];
|
|
11
|
+
|
|
12
|
+
export interface GaugeTheme {
|
|
13
|
+
fg(color: string, text: string): string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const GAUGE_CELLS = 8;
|
|
17
|
+
const GAUGE_FILLED = "▰";
|
|
18
|
+
const GAUGE_EMPTY = "▱";
|
|
19
|
+
const GAUGE_EMPTY_ROLE = "border";
|
|
20
|
+
const WARNING_THRESHOLD = 80;
|
|
21
|
+
const ERROR_THRESHOLD = 95;
|
|
22
|
+
|
|
23
|
+
export function renderGauge(percent: number | null, cells: number = GAUGE_CELLS): string {
|
|
24
|
+
const clamped = Math.max(0, Math.min(100, percent ?? 0));
|
|
25
|
+
const filled = Math.round((clamped / 100) * cells);
|
|
26
|
+
return GAUGE_FILLED.repeat(filled) + GAUGE_EMPTY.repeat(cells - filled);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function gaugeTone(percent: number | null): GaugeTone {
|
|
30
|
+
if (percent === null) return GAUGE_TONE.DIM;
|
|
31
|
+
if (percent >= ERROR_THRESHOLD) return GAUGE_TONE.ERROR;
|
|
32
|
+
if (percent >= WARNING_THRESHOLD) return GAUGE_TONE.WARNING;
|
|
33
|
+
return GAUGE_TONE.ACCENT;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function paintGauge(percent: number | null, theme: GaugeTheme, cells: number = GAUGE_CELLS): string {
|
|
37
|
+
const gauge = renderGauge(percent, cells);
|
|
38
|
+
const filled = gauge.replace(new RegExp(`${GAUGE_EMPTY}+$`), "");
|
|
39
|
+
return theme.fg(gaugeTone(percent), filled) + theme.fg(GAUGE_EMPTY_ROLE, gauge.slice(filled.length));
|
|
40
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { stripAnsi } from "./terminal-theme.ts";
|
|
3
|
+
|
|
4
|
+
// Gentle Shell prompt frame. pi's editor renders a top rule, padded content
|
|
5
|
+
// lines, and a bottom rule; this module wraps those lines in a rounded frame
|
|
6
|
+
// with a petal that shows what the agent is doing. Everything here is pure.
|
|
7
|
+
|
|
8
|
+
export const PROMPT_STATE = {
|
|
9
|
+
IDLE: "idle",
|
|
10
|
+
WORKING: "working",
|
|
11
|
+
QUEUED: "queued",
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export type PromptState = (typeof PROMPT_STATE)[keyof typeof PROMPT_STATE];
|
|
15
|
+
|
|
16
|
+
const PETAL_TONE = {
|
|
17
|
+
BRIGHT: "borderAccent",
|
|
18
|
+
ROSE: "accent",
|
|
19
|
+
SOFT: "thinkingHigh",
|
|
20
|
+
DEEP: "mdQuoteBorder",
|
|
21
|
+
WARNING: "warning",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export type PetalTone = (typeof PETAL_TONE)[keyof typeof PETAL_TONE];
|
|
25
|
+
|
|
26
|
+
// The Gentle themes map these to the rose ramp: active pink, rose, soft
|
|
27
|
+
// rose, deep pink. The spin walks the ramp one shade per frame.
|
|
28
|
+
const PETAL_TONE_FRAMES = [PETAL_TONE.BRIGHT, PETAL_TONE.ROSE, PETAL_TONE.SOFT, PETAL_TONE.DEEP] as const;
|
|
29
|
+
|
|
30
|
+
export interface PromptFrameOptions {
|
|
31
|
+
state: PromptState;
|
|
32
|
+
tick: number;
|
|
33
|
+
borderColor: (text: string) => string;
|
|
34
|
+
fg: (color: string, text: string) => string;
|
|
35
|
+
bold?: (text: string) => string;
|
|
36
|
+
/** Paints a finished line, e.g. with the panel background. */
|
|
37
|
+
paint?: (line: string) => string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A terminal cell cannot grow, so the petal earns presence with weight and
|
|
41
|
+
// the brightest rose in the theme. Working spins through four flowers.
|
|
42
|
+
export const PROMPT_PETAL = "✿";
|
|
43
|
+
const PETAL_FRAMES = ["✿", "❀", "❁", "✾"] as const;
|
|
44
|
+
export const PROMPT_HINT = "type, or / for commands";
|
|
45
|
+
const LABEL_ROLE = "muted";
|
|
46
|
+
const HINT_ROLE = "dim";
|
|
47
|
+
const FAKE_CURSOR = "\x1b[7m \x1b[0m";
|
|
48
|
+
const SCROLL_INDICATOR = /[↑↓] \d+ more/;
|
|
49
|
+
const STATE_LABEL: Record<PromptState, string | undefined> = {
|
|
50
|
+
[PROMPT_STATE.IDLE]: undefined,
|
|
51
|
+
[PROMPT_STATE.WORKING]: "working",
|
|
52
|
+
[PROMPT_STATE.QUEUED]: "queued",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function petalTone(state: PromptState, tick: number): PetalTone {
|
|
56
|
+
if (state === PROMPT_STATE.QUEUED) return PETAL_TONE.WARNING;
|
|
57
|
+
if (state === PROMPT_STATE.WORKING) return PETAL_TONE_FRAMES[tick % PETAL_TONE_FRAMES.length];
|
|
58
|
+
return PETAL_TONE.BRIGHT;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function petalGlyph(state: PromptState, tick: number): string {
|
|
62
|
+
if (state === PROMPT_STATE.IDLE) return PROMPT_PETAL;
|
|
63
|
+
return PETAL_FRAMES[tick % PETAL_FRAMES.length];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function scrollIndicator(rule: string): string | undefined {
|
|
67
|
+
return stripAnsi(rule).match(SCROLL_INDICATOR)?.[0];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function rule(length: number): string {
|
|
71
|
+
return "─".repeat(Math.max(0, length));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function topRule(width: number, options: PromptFrameOptions, indicator: string | undefined): string {
|
|
75
|
+
const label = indicator ?? STATE_LABEL[options.state];
|
|
76
|
+
const glyph = petalGlyph(options.state, options.tick);
|
|
77
|
+
const petal = options.fg(petalTone(options.state, options.tick), options.bold ? options.bold(glyph) : glyph);
|
|
78
|
+
const labelText = label ? ` ${options.fg(LABEL_ROLE, label)}` : "";
|
|
79
|
+
const labelWidth = label ? label.length + 1 : 0;
|
|
80
|
+
const fill = width - 3 - visibleWidth(glyph) - labelWidth - 1 - 1;
|
|
81
|
+
return options.borderColor("╭─ ") + petal + labelText + options.borderColor(` ${rule(fill)}╮`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function bottomRule(width: number, options: PromptFrameOptions, indicator: string | undefined): string {
|
|
85
|
+
if (!indicator) return options.borderColor(`╰${rule(width - 2)}╯`);
|
|
86
|
+
const fill = width - 3 - indicator.length - 1 - 1;
|
|
87
|
+
return options.borderColor("╰─ ") + options.fg(LABEL_ROLE, indicator) + options.borderColor(` ${rule(fill)}╯`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sideRules(line: string, innerWidth: number, options: PromptFrameOptions): string {
|
|
91
|
+
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line)));
|
|
92
|
+
return options.borderColor("│") + line + padding + options.borderColor("│");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function framePromptLines(lines: string[], width: number, options: PromptFrameOptions): string[] {
|
|
96
|
+
if (lines.length < 2) return lines;
|
|
97
|
+
const innerWidth = width - 2;
|
|
98
|
+
const top = lines[0];
|
|
99
|
+
const bottom = lines[lines.length - 1];
|
|
100
|
+
const content = lines.slice(1, -1).map((line) => sideRules(line, innerWidth, options));
|
|
101
|
+
const paint = options.paint ?? ((line: string) => line);
|
|
102
|
+
return [topRule(width, options, scrollIndicator(top)), ...content, bottomRule(width, options, scrollIndicator(bottom))].map(paint);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function withPromptHint(line: string, hint: string, fg: PromptFrameOptions["fg"]): string {
|
|
106
|
+
const cursorAt = line.indexOf(FAKE_CURSOR);
|
|
107
|
+
if (cursorAt === -1) return line;
|
|
108
|
+
const afterCursor = cursorAt + FAKE_CURSOR.length;
|
|
109
|
+
const trailing = line.slice(afterCursor);
|
|
110
|
+
if (trailing.trim() !== "" || trailing.length < hint.length + 1) return line;
|
|
111
|
+
return `${line.slice(0, afterCursor)} ${fg(HINT_ROLE, hint)}${" ".repeat(trailing.length - hint.length - 1)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Paints a line with a background that survives the resets pi's editor
|
|
115
|
+
// emits around the cursor, so the panel color runs edge to edge.
|
|
116
|
+
export function panelPainter(bgAnsi: string): (line: string) => string {
|
|
117
|
+
const RESET = "\x1b[0m";
|
|
118
|
+
return (line) => `${bgAnsi}${line.split(RESET).join(`${RESET}${bgAnsi}`)}\x1b[49m`;
|
|
119
|
+
}
|