gentle-pi 2.5.0 → 2.6.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 +139 -30
- package/assets/agents/gentle-ai-worker.md +4 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +21 -3
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/telemetry.md +57 -1
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +143 -15
- package/extensions/codegraph-tools.ts +1 -0
- package/extensions/gentle-agents.ts +799 -50
- package/extensions/gentle-ai.ts +2033 -322
- package/extensions/gentle-shell.ts +145 -42
- package/extensions/gentle-todo.ts +47 -12
- package/extensions/quiet-tools.ts +1 -0
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +7 -10
- package/lib/agents-history.ts +9 -1
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +77 -5
- package/lib/agents-runner.ts +548 -26
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +548 -191
- package/lib/agents-widget.ts +33 -14
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +8 -6
- package/lib/native-review-cli.ts +283 -1
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +296 -46
- package/lib/review-candidate-view.ts +30 -20
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +53 -8
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +187 -5
- package/lib/review-last-event-controller.ts +7 -4
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-session-standing-permission.ts +27 -6
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +47 -1
- package/lib/shell-card.ts +1 -4
- package/lib/shell-changes-view.ts +362 -37
- package/lib/shell-changes.ts +81 -1
- package/lib/shell-prompt.ts +11 -15
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +28 -11
- package/lib/telemetry-trigger.ts +2 -0
- package/package.json +6 -3
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +283 -1
- package/runtime/review-integration-v2.mjs +187 -5
- package/runtime/telemetry-trigger.mjs +2 -0
- package/scripts/build-runtime-modules.mjs +9 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +4 -2
- package/skills/_shared/review-ledger-contract.md +17 -1
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +62 -0
- package/tests/agents-fake-child.ts +15 -1
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +45 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner.test.ts +571 -14
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-view.test.ts +476 -65
- package/tests/agents-widget.test.ts +31 -1
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +169 -3
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +2 -1
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +2 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +1460 -33
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +38 -0
- package/tests/gentle-ai.test.ts +944 -4
- package/tests/gentle-shell.test.ts +303 -12
- package/tests/gentle-todo.test.ts +54 -10
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-review-capability-contract.test.ts +12 -1
- package/tests/native-review-cli.test.ts +277 -3
- package/tests/native-review-parity.test.ts +14 -7
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/package-manifest.test.ts +232 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +1 -0
- package/tests/rdd-aware-verification-contract.test.ts +10 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +304 -6
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +14 -0
- package/tests/review-controller-native-routing.test.ts +563 -3
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +364 -4
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +164 -0
- package/tests/review-last-event-closure.test.ts +105 -1
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-session-standing-permission-controller.test.ts +52 -4
- package/tests/review-session-standing-permission.test.ts +30 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-card.test.ts +24 -3
- package/tests/shell-changes-view.test.ts +471 -8
- package/tests/shell-changes.test.ts +168 -0
- package/tests/shell-prompt.test.ts +28 -6
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +100 -11
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +3 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import {
|
|
1
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
2
|
+
import { sanitizeTerminalText } from "./terminal-theme.ts";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { CHANGE_STATUS, changesSummary, type ChangedFile, type ChangesModel, type WorktreeChanges } from "./shell-changes.ts";
|
|
3
5
|
|
|
4
6
|
// Gentle Shell changes overlay: a framed two-pane view with the working
|
|
5
7
|
// tree's changed files on the left and the selected file's diff on the right.
|
|
@@ -11,11 +13,12 @@ export interface ChangesViewTheme {
|
|
|
11
13
|
|
|
12
14
|
export interface ChangesViewDeps {
|
|
13
15
|
theme: ChangesViewTheme;
|
|
14
|
-
rows: number;
|
|
16
|
+
rows: number | (() => number);
|
|
15
17
|
loadDiff(file: ChangedFile): Promise<string>;
|
|
16
18
|
onOpen(file: ChangedFile): void;
|
|
17
19
|
onClose(): void;
|
|
18
20
|
requestRender(): void;
|
|
21
|
+
backLabel?: string;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
const ROLE = {
|
|
@@ -26,7 +29,6 @@ const ROLE = {
|
|
|
26
29
|
PATH_IDLE: "muted",
|
|
27
30
|
ADDED: "success",
|
|
28
31
|
REMOVED: "error",
|
|
29
|
-
NEW: "success",
|
|
30
32
|
HUNK: "customMessageLabel",
|
|
31
33
|
KEY: "accent",
|
|
32
34
|
KEY_TEXT: "dim",
|
|
@@ -63,11 +65,20 @@ export function colorDiff(text: string, theme: ChangesViewTheme): string[] {
|
|
|
63
65
|
return lines;
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
const FILE_STATUS = {
|
|
69
|
+
[CHANGE_STATUS.MODIFIED]: "M",
|
|
70
|
+
[CHANGE_STATUS.ADDED]: "A",
|
|
71
|
+
[CHANGE_STATUS.DELETED]: "D",
|
|
72
|
+
[CHANGE_STATUS.RENAMED]: "R",
|
|
73
|
+
[CHANGE_STATUS.UNTRACKED]: "??",
|
|
74
|
+
} as const;
|
|
75
|
+
|
|
66
76
|
function fileCounts(file: ChangedFile, theme: ChangesViewTheme): string {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
return `${theme.fg(ROLE.ADDED, `+${file.added}`)} ${theme.fg(ROLE.REMOVED, `-${file.deleted}`)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fileLabel(file: ChangedFile, theme: ChangesViewTheme, role: string): string {
|
|
81
|
+
return `${theme.fg(role, `${FILE_STATUS[file.status]} ${displayText(file.path)}`)} ${fileCounts(file, theme)}`;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
function fit(text: string, width: number): string {
|
|
@@ -75,15 +86,252 @@ function fit(text: string, width: number): string {
|
|
|
75
86
|
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
76
87
|
}
|
|
77
88
|
|
|
89
|
+
// Both standalone files and the worktree accordion use the original frame.
|
|
90
|
+
function renderPanes(width: number, rows: number, theme: ChangesViewTheme, title: string, leftLine: (row: number) => string, rightLines: string[], keys: string): string[] {
|
|
91
|
+
const inner = Math.max(8, width - 2);
|
|
92
|
+
const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
|
|
93
|
+
const diffWidth = inner - listWidth - 4;
|
|
94
|
+
const titleText = truncateToWidth(title, inner - 4, "…");
|
|
95
|
+
const top = theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, titleText) + theme.fg(ROLE.FRAME, ` ${rule(inner - visibleWidth(titleText) - 3)}╮`);
|
|
96
|
+
const body: string[] = [];
|
|
97
|
+
for (let row = 0; row < rows; row += 1) {
|
|
98
|
+
const left = fit(leftLine(row), listWidth);
|
|
99
|
+
const right = fit(rightLines[row] ?? "", diffWidth);
|
|
100
|
+
body.push(`${theme.fg(ROLE.FRAME, "│")} ${left} ${theme.fg(ROLE.FRAME, "│")} ${right}${theme.fg(ROLE.FRAME, "│")}`);
|
|
101
|
+
}
|
|
102
|
+
const keysLine = `${theme.fg(ROLE.FRAME, "│")} ${fit(keys, inner - 2)} ${theme.fg(ROLE.FRAME, "│")}`;
|
|
103
|
+
const bottom = theme.fg(ROLE.FRAME, `╰${rule(inner)}╯`);
|
|
104
|
+
return [top, ...body, keysLine, bottom].map((line) => truncateToWidth(line, width));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface PointerLayout {
|
|
108
|
+
width: number;
|
|
109
|
+
height: number;
|
|
110
|
+
bodyRows: number;
|
|
111
|
+
listStart: number;
|
|
112
|
+
listEnd: number;
|
|
113
|
+
diffStart: number;
|
|
114
|
+
diffEnd: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pointerLayout(width: number, bodyRows: number): PointerLayout {
|
|
118
|
+
const inner = Math.max(8, width - 2);
|
|
119
|
+
const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
|
|
120
|
+
const diffWidth = inner - listWidth - 4;
|
|
121
|
+
return { width, height: bodyRows + CHROME_ROWS, bodyRows, listStart: 2, listEnd: 2 + listWidth, diffStart: 5 + listWidth, diffEnd: 5 + listWidth + diffWidth };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function displayText(text: string): string {
|
|
125
|
+
return sanitizeTerminalText(text).replace(/[\t\n]/g, " ");
|
|
126
|
+
}
|
|
127
|
+
|
|
78
128
|
function fingerprint(file: ChangedFile): string {
|
|
79
129
|
return `${file.status}:${file.added}:${file.deleted}`;
|
|
80
130
|
}
|
|
81
131
|
|
|
132
|
+
export interface WorktreeChangesViewDeps {
|
|
133
|
+
theme: ChangesViewTheme;
|
|
134
|
+
rows: number | (() => number);
|
|
135
|
+
loadDiff(root: string, file: ChangedFile): Promise<string>;
|
|
136
|
+
onOpen(root: string, file: ChangedFile): void;
|
|
137
|
+
onClose(): void;
|
|
138
|
+
onRefresh(): void;
|
|
139
|
+
requestRender(): void;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface WorktreeRow {
|
|
143
|
+
tree: WorktreeChanges;
|
|
144
|
+
file?: ChangedFile;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function rowKey(row: WorktreeRow): string {
|
|
148
|
+
return JSON.stringify([row.tree.root, row.file?.path ?? null]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The accordion owns navigation; standalone file views still own lazy previews.
|
|
152
|
+
export class WorktreeChangesView {
|
|
153
|
+
private trees: WorktreeChanges[];
|
|
154
|
+
private readonly deps: WorktreeChangesViewDeps;
|
|
155
|
+
private selected = 0;
|
|
156
|
+
private listOffset = 0;
|
|
157
|
+
private pointerLayout: PointerLayout | undefined;
|
|
158
|
+
private disposed = false;
|
|
159
|
+
private leftPressActive = false;
|
|
160
|
+
private readonly expanded = new Set<string>();
|
|
161
|
+
private readonly previews = new Map<string, { fingerprint: string; view: ChangesView }>();
|
|
162
|
+
|
|
163
|
+
constructor(trees: WorktreeChanges[], deps: WorktreeChangesViewDeps) {
|
|
164
|
+
this.trees = trees;
|
|
165
|
+
this.deps = deps;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private visibleRows(): WorktreeRow[] {
|
|
169
|
+
return this.trees.flatMap((tree) => [{ tree }, ...(this.expanded.has(tree.root) ? tree.model.files.map((file) => ({ tree, file })) : [])]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
update(trees: WorktreeChanges[]): void {
|
|
173
|
+
this.leftPressActive = false;
|
|
174
|
+
if (this.disposed) return;
|
|
175
|
+
this.pointerLayout = undefined;
|
|
176
|
+
const before = this.visibleRows()[this.selected];
|
|
177
|
+
this.trees = trees;
|
|
178
|
+
for (const root of this.expanded) if (!trees.some((tree) => tree.root === root)) this.expanded.delete(root);
|
|
179
|
+
const files = new Map(trees.flatMap((tree) => tree.model.files.map((file) => [rowKey({ tree, file }), fingerprint(file)])));
|
|
180
|
+
for (const [key, preview] of this.previews) {
|
|
181
|
+
if (files.get(key) !== preview.fingerprint) {
|
|
182
|
+
preview.view.dispose();
|
|
183
|
+
this.previews.delete(key);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const rows = this.visibleRows();
|
|
187
|
+
let index = before ? rows.findIndex((row) => rowKey(row) === rowKey(before)) : -1;
|
|
188
|
+
if (index < 0 && before) index = rows.findIndex((row) => row.tree.root === before.tree.root);
|
|
189
|
+
this.selected = index < 0 ? Math.max(0, Math.min(this.selected, rows.length - 1)) : index;
|
|
190
|
+
this.ensurePreview();
|
|
191
|
+
this.deps.requestRender();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
handleInput(data: string): void {
|
|
195
|
+
if (this.disposed) return;
|
|
196
|
+
if (data === "r") return this.deps.onRefresh();
|
|
197
|
+
if (matchesKey(data, Key.escape) || data === "q") return this.deps.onClose();
|
|
198
|
+
const rows = this.visibleRows();
|
|
199
|
+
const row = rows[this.selected];
|
|
200
|
+
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("j")) || matchesKey(data, Key.ctrl("k"))) {
|
|
201
|
+
if (row?.file) this.previews.get(rowKey(row))?.view.handleInput(data);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (data === "j" || matchesKey(data, Key.down)) this.selected = Math.max(0, Math.min(rows.length - 1, this.selected + 1));
|
|
205
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.selected = Math.max(0, this.selected - 1);
|
|
206
|
+
else if (row && (matchesKey(data, Key.left) || matchesKey(data, Key.backspace))) {
|
|
207
|
+
if (row.file) this.selected = rows.findIndex((item) => item.tree.root === row.tree.root && !item.file);
|
|
208
|
+
else this.expanded.delete(row.tree.root);
|
|
209
|
+
} else if (row && !row.file && (matchesKey(data, Key.enter) || data === " " || matchesKey(data, Key.right))) {
|
|
210
|
+
if (this.expanded.has(row.tree.root)) this.expanded.delete(row.tree.root);
|
|
211
|
+
else this.expanded.add(row.tree.root);
|
|
212
|
+
} else if (row?.file && (data === "o" || matchesKey(data, Key.enter))) this.deps.onOpen(row.tree.root, row.file);
|
|
213
|
+
this.ensureSelectedVisible();
|
|
214
|
+
this.ensurePreview();
|
|
215
|
+
this.deps.requestRender();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
219
|
+
if (this.disposed) return { handled: true, render: false };
|
|
220
|
+
if (event.type === "release") {
|
|
221
|
+
if (this.leftPressActive && (event.button === "left" || event.button === "none")) {
|
|
222
|
+
this.leftPressActive = false;
|
|
223
|
+
return { handled: true, render: false };
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
if (event.type === "press" && event.button !== "left") return undefined;
|
|
228
|
+
const layout = this.pointerLayout;
|
|
229
|
+
if (!layout || event.width !== layout.width || event.height !== layout.height) return { handled: true, render: false };
|
|
230
|
+
const inBody = event.y >= 1 && event.y <= layout.bodyRows;
|
|
231
|
+
const inFiles = inBody && event.x >= layout.listStart && event.x < layout.listEnd;
|
|
232
|
+
const inDiff = inBody && event.x >= layout.diffStart && event.x < layout.diffEnd;
|
|
233
|
+
if (event.type === "wheel") {
|
|
234
|
+
if (inFiles) return { handled: true, render: this.scrollList(event.wheelDelta ?? 0, layout.bodyRows) };
|
|
235
|
+
if (inDiff) {
|
|
236
|
+
const row = this.visibleRows()[this.selected];
|
|
237
|
+
return { handled: true, render: row?.file ? Boolean(this.previews.get(rowKey(row))?.view.scrollDiffBy(event.wheelDelta ?? 0, layout.bodyRows)) : false };
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
if (!inFiles) return undefined;
|
|
242
|
+
if (event.type === "press") {
|
|
243
|
+
if (event.button !== "left") return undefined;
|
|
244
|
+
this.leftPressActive = true;
|
|
245
|
+
return { handled: true, capture: true, render: false };
|
|
246
|
+
}
|
|
247
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
248
|
+
const index = this.listOffset + event.y - 1;
|
|
249
|
+
if (index < 0 || index >= this.visibleRows().length) return { handled: true, render: false };
|
|
250
|
+
const changed = index !== this.selected;
|
|
251
|
+
this.selected = index;
|
|
252
|
+
this.ensurePreview();
|
|
253
|
+
this.deps.requestRender();
|
|
254
|
+
return { handled: true, render: changed };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
render(width: number): string[] {
|
|
258
|
+
const theme = this.deps.theme;
|
|
259
|
+
const rows = this.visibleRows();
|
|
260
|
+
const height = this.bodyRows();
|
|
261
|
+
this.listOffset = Math.max(0, Math.min(this.listOffset, Math.max(0, rows.length - height)));
|
|
262
|
+
const selected = rows[this.selected];
|
|
263
|
+
const preview = selected?.file ? this.previews.get(rowKey(selected))?.view.previewLines() ?? [] : [selected ? displayText(selected.tree.root) : "No dirty worktrees.", selected ? changesSummary(selected.tree.model) : "", selected ? "Expand a group and select a file." : ""];
|
|
264
|
+
const leftLine = (index: number) => {
|
|
265
|
+
const row = rows[index + this.listOffset];
|
|
266
|
+
if (!row) return "";
|
|
267
|
+
const active = index + this.listOffset === this.selected;
|
|
268
|
+
const marker = active ? theme.fg(ROLE.SELECTED, "▸") : " ";
|
|
269
|
+
const text = row.file ? ` ${fileLabel(row.file, theme, active ? ROLE.SELECTED : ROLE.PATH_IDLE)}` : theme.fg(active ? ROLE.SELECTED : ROLE.PATH_IDLE, `${this.expanded.has(row.tree.root) ? "▾" : "▸"} ${displayText(row.tree.branch ?? "detached")} · ${displayText(basename(row.tree.root))}`);
|
|
270
|
+
return `${marker} ${text}`;
|
|
271
|
+
};
|
|
272
|
+
const keys = theme.fg(ROLE.KEY_TEXT, "j/k select enter toggle/open ← parent/fold ctrl+j/k scroll r refresh esc close");
|
|
273
|
+
this.pointerLayout = pointerLayout(width, height);
|
|
274
|
+
return renderPanes(width, height, theme, `✎ Changes · ${this.trees.length} worktrees`, leftLine, preview, keys);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
invalidate(): void {
|
|
278
|
+
this.leftPressActive = false;
|
|
279
|
+
this.pointerLayout = undefined;
|
|
280
|
+
for (const preview of this.previews.values()) preview.view.invalidate();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
dispose(): void {
|
|
284
|
+
this.disposed = true;
|
|
285
|
+
this.leftPressActive = false;
|
|
286
|
+
this.pointerLayout = undefined;
|
|
287
|
+
for (const preview of this.previews.values()) preview.view.dispose();
|
|
288
|
+
this.previews.clear();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private bodyRows(): number {
|
|
292
|
+
const rows = typeof this.deps.rows === "function" ? this.deps.rows() : this.deps.rows;
|
|
293
|
+
return Math.max(MIN_BODY_ROWS, rows - CHROME_ROWS);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private scrollList(delta: number, rows: number): boolean {
|
|
297
|
+
const next = Math.max(0, Math.min(Math.max(0, this.visibleRows().length - rows), this.listOffset + Math.trunc(delta)));
|
|
298
|
+
if (next === this.listOffset) return false;
|
|
299
|
+
this.listOffset = next;
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private ensureSelectedVisible(): void {
|
|
304
|
+
const height = this.bodyRows();
|
|
305
|
+
if (this.selected < this.listOffset) this.listOffset = this.selected;
|
|
306
|
+
else if (this.selected >= this.listOffset + height) this.listOffset = this.selected - height + 1;
|
|
307
|
+
this.listOffset = Math.max(0, Math.min(this.listOffset, Math.max(0, this.visibleRows().length - height)));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private ensurePreview(): void {
|
|
311
|
+
const row = this.visibleRows()[this.selected];
|
|
312
|
+
if (!row?.file || this.previews.has(rowKey(row))) return;
|
|
313
|
+
const { tree, file } = row;
|
|
314
|
+
this.previews.set(rowKey(row), {
|
|
315
|
+
fingerprint: fingerprint(file),
|
|
316
|
+
view: new ChangesView({ files: [file], added: file.added, deleted: file.deleted }, {
|
|
317
|
+
...this.deps,
|
|
318
|
+
rows: () => typeof this.deps.rows === "function" ? this.deps.rows() : this.deps.rows,
|
|
319
|
+
loadDiff: (target) => this.deps.loadDiff(tree.root, target),
|
|
320
|
+
onOpen: (target) => this.deps.onOpen(tree.root, target),
|
|
321
|
+
}),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
82
326
|
export class ChangesView {
|
|
83
327
|
private model: ChangesModel;
|
|
84
328
|
private readonly deps: ChangesViewDeps;
|
|
85
329
|
private selected = 0;
|
|
86
|
-
private
|
|
330
|
+
private fileScroll = 0;
|
|
331
|
+
private diffScroll = 0;
|
|
332
|
+
private pointerLayout: PointerLayout | undefined;
|
|
333
|
+
private disposed = false;
|
|
334
|
+
private leftPressActive = false;
|
|
87
335
|
private readonly diffs = new Map<string, string[]>();
|
|
88
336
|
|
|
89
337
|
constructor(model: ChangesModel, deps: ChangesViewDeps) {
|
|
@@ -95,6 +343,9 @@ export class ChangesView {
|
|
|
95
343
|
// Replace the model while open: keep the selection by path and drop cached
|
|
96
344
|
// diffs for files whose counts moved so they reload.
|
|
97
345
|
update(model: ChangesModel): void {
|
|
346
|
+
this.leftPressActive = false;
|
|
347
|
+
if (this.disposed) return;
|
|
348
|
+
this.pointerLayout = undefined;
|
|
98
349
|
const selectedPath = this.model.files[this.selected]?.path;
|
|
99
350
|
const before = new Map(this.model.files.map((file) => [file.path, fingerprint(file)]));
|
|
100
351
|
for (const file of model.files) {
|
|
@@ -105,12 +356,22 @@ export class ChangesView {
|
|
|
105
356
|
}
|
|
106
357
|
this.model = model;
|
|
107
358
|
const index = model.files.findIndex((file) => file.path === selectedPath);
|
|
108
|
-
|
|
359
|
+
const selectionChanged = index === -1;
|
|
360
|
+
this.selected = selectionChanged ? Math.max(0, Math.min(this.selected, model.files.length - 1)) : index;
|
|
361
|
+
this.fileScroll = this.clampFileScroll(this.fileScroll, this.bodyRows());
|
|
362
|
+
if (selectionChanged) this.ensureSelectedVisible();
|
|
109
363
|
this.loadSelected();
|
|
110
364
|
this.deps.requestRender();
|
|
111
365
|
}
|
|
112
366
|
|
|
367
|
+
dispose(): void {
|
|
368
|
+
this.disposed = true;
|
|
369
|
+
this.leftPressActive = false;
|
|
370
|
+
this.pointerLayout = undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
113
373
|
handleInput(data: string): void {
|
|
374
|
+
if (this.disposed) return;
|
|
114
375
|
if (matchesKey(data, Key.escape) || data === "q") {
|
|
115
376
|
this.deps.onClose();
|
|
116
377
|
return;
|
|
@@ -127,40 +388,70 @@ export class ChangesView {
|
|
|
127
388
|
}
|
|
128
389
|
}
|
|
129
390
|
|
|
391
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
392
|
+
if (this.disposed) return { handled: true, render: false };
|
|
393
|
+
if (event.type === "release") {
|
|
394
|
+
if (this.leftPressActive && (event.button === "left" || event.button === "none")) {
|
|
395
|
+
this.leftPressActive = false;
|
|
396
|
+
return { handled: true, render: false };
|
|
397
|
+
}
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
if (event.type === "press" && event.button !== "left") return undefined;
|
|
401
|
+
const layout = this.pointerLayout;
|
|
402
|
+
if (!layout || event.width !== layout.width || event.height !== layout.height) return { handled: true, render: false };
|
|
403
|
+
const inBody = event.y >= 1 && event.y <= layout.bodyRows;
|
|
404
|
+
const inFiles = inBody && event.x >= layout.listStart && event.x < layout.listEnd;
|
|
405
|
+
const inDiff = inBody && event.x >= layout.diffStart && event.x < layout.diffEnd;
|
|
406
|
+
if (event.type === "wheel") {
|
|
407
|
+
if (inFiles) return { handled: true, render: this.scrollFiles(event.wheelDelta ?? 0, layout.bodyRows) };
|
|
408
|
+
if (inDiff) return { handled: true, render: this.scrollDiff(event.wheelDelta ?? 0, layout.bodyRows) };
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
if (!inFiles) return undefined;
|
|
412
|
+
if (event.type === "press") {
|
|
413
|
+
if (event.button !== "left") return undefined;
|
|
414
|
+
this.leftPressActive = true;
|
|
415
|
+
return { handled: true, capture: true, render: false };
|
|
416
|
+
}
|
|
417
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
418
|
+
const index = this.fileScroll + event.y - 1;
|
|
419
|
+
if (index < 0 || index >= this.model.files.length) return { handled: true, render: false };
|
|
420
|
+
const changed = index !== this.selected;
|
|
421
|
+
this.select(index);
|
|
422
|
+
return { handled: true, render: changed };
|
|
423
|
+
}
|
|
424
|
+
|
|
130
425
|
render(width: number): string[] {
|
|
131
426
|
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
427
|
const rows = this.bodyRows();
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
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];
|
|
428
|
+
this.fileScroll = this.clampFileScroll(this.fileScroll, rows);
|
|
429
|
+
const keys = KEYS.map(([key, label]) => `${theme.fg(ROLE.KEY, key)} ${theme.fg(ROLE.KEY_TEXT, key === "esc" ? (this.deps.backLabel ?? label) : label)}`).join(" ");
|
|
430
|
+
this.pointerLayout = pointerLayout(width, rows);
|
|
431
|
+
return renderPanes(width, rows, theme, `✎ Changes · ${changesSummary(this.model)}`, (row) => this.fileLine(row), this.visibleDiff(rows), keys);
|
|
149
432
|
}
|
|
150
433
|
|
|
151
|
-
invalidate(): void {
|
|
434
|
+
invalidate(): void {
|
|
435
|
+
this.leftPressActive = false;
|
|
436
|
+
this.pointerLayout = undefined;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
previewLines(): string[] {
|
|
440
|
+
return this.visibleDiff(this.bodyRows());
|
|
441
|
+
}
|
|
152
442
|
|
|
153
443
|
private bodyRows(): number {
|
|
154
|
-
|
|
444
|
+
const rows = typeof this.deps.rows === "function" ? this.deps.rows() : this.deps.rows;
|
|
445
|
+
return Math.max(MIN_BODY_ROWS, rows - CHROME_ROWS);
|
|
155
446
|
}
|
|
156
447
|
|
|
157
448
|
private fileLine(row: number): string {
|
|
158
|
-
const
|
|
449
|
+
const index = this.fileScroll + row;
|
|
450
|
+
const file = this.model.files[index];
|
|
159
451
|
if (!file) return "";
|
|
160
452
|
const theme = this.deps.theme;
|
|
161
|
-
const marker =
|
|
162
|
-
|
|
163
|
-
return `${marker} ${path} ${fileCounts(file, theme)}`;
|
|
453
|
+
const marker = index === this.selected ? theme.fg(ROLE.SELECTED, "▸") : " ";
|
|
454
|
+
return `${marker} ${fileLabel(file, theme, index === this.selected ? ROLE.PATH : ROLE.PATH_IDLE)}`;
|
|
164
455
|
}
|
|
165
456
|
|
|
166
457
|
private visibleDiff(rows: number): string[] {
|
|
@@ -169,34 +460,68 @@ export class ChangesView {
|
|
|
169
460
|
const lines = this.diffs.get(file.path);
|
|
170
461
|
if (!lines) return [];
|
|
171
462
|
if (lines.length === 0) return [this.deps.theme.fg(ROLE.EMPTY, EMPTY_DIFF)];
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return lines.slice(this.scroll, this.scroll + rows);
|
|
463
|
+
this.diffScroll = Math.min(this.diffScroll, Math.max(0, lines.length - rows));
|
|
464
|
+
return lines.slice(this.diffScroll, this.diffScroll + rows);
|
|
175
465
|
}
|
|
176
466
|
|
|
177
467
|
private select(index: number): void {
|
|
178
468
|
const next = Math.max(0, Math.min(this.model.files.length - 1, index));
|
|
179
469
|
if (next === this.selected) return;
|
|
180
470
|
this.selected = next;
|
|
181
|
-
this.
|
|
471
|
+
this.diffScroll = 0;
|
|
472
|
+
this.ensureSelectedVisible();
|
|
182
473
|
this.loadSelected();
|
|
183
474
|
this.deps.requestRender();
|
|
184
475
|
}
|
|
185
476
|
|
|
186
477
|
private scrollBy(delta: number): void {
|
|
187
|
-
this.
|
|
478
|
+
this.scrollDiffBy(delta, this.bodyRows());
|
|
188
479
|
this.deps.requestRender();
|
|
189
480
|
}
|
|
190
481
|
|
|
482
|
+
scrollDiffBy(delta: number, rows = this.bodyRows()): boolean {
|
|
483
|
+
return this.scrollDiff(delta, rows);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private scrollFiles(delta: number, rows: number): boolean {
|
|
487
|
+
const next = this.clampFileScroll(this.fileScroll + Math.trunc(delta), rows);
|
|
488
|
+
if (next === this.fileScroll) return false;
|
|
489
|
+
this.fileScroll = next;
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private scrollDiff(delta: number, rows: number): boolean {
|
|
494
|
+
const file = this.model.files[this.selected];
|
|
495
|
+
const lines = file ? this.diffs.get(file.path) : undefined;
|
|
496
|
+
if (!lines) return false;
|
|
497
|
+
const next = Math.max(0, Math.min(Math.max(0, lines.length - rows), this.diffScroll + Math.trunc(delta)));
|
|
498
|
+
if (next === this.diffScroll) return false;
|
|
499
|
+
this.diffScroll = next;
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private clampFileScroll(scroll: number, rows: number): number {
|
|
504
|
+
return Math.max(0, Math.min(Math.max(0, this.model.files.length - rows), scroll));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private ensureSelectedVisible(): void {
|
|
508
|
+
const rows = this.bodyRows();
|
|
509
|
+
if (this.selected < this.fileScroll) this.fileScroll = this.selected;
|
|
510
|
+
else if (this.selected >= this.fileScroll + rows) this.fileScroll = this.selected - rows + 1;
|
|
511
|
+
this.fileScroll = this.clampFileScroll(this.fileScroll, rows);
|
|
512
|
+
}
|
|
513
|
+
|
|
191
514
|
private loadSelected(): void {
|
|
192
515
|
const file = this.model.files[this.selected];
|
|
193
516
|
if (!file || this.diffs.has(file.path)) return;
|
|
194
517
|
void this.deps.loadDiff(file).then(
|
|
195
518
|
(text) => {
|
|
519
|
+
if (this.disposed) return;
|
|
196
520
|
this.diffs.set(file.path, colorDiff(text, this.deps.theme));
|
|
197
521
|
this.deps.requestRender();
|
|
198
522
|
},
|
|
199
523
|
() => {
|
|
524
|
+
if (this.disposed) return;
|
|
200
525
|
this.diffs.set(file.path, []);
|
|
201
526
|
this.deps.requestRender();
|
|
202
527
|
},
|
package/lib/shell-changes.ts
CHANGED
|
@@ -143,6 +143,86 @@ export interface GitResult {
|
|
|
143
143
|
export type GitRunner = (args: string[]) => Promise<GitResult>;
|
|
144
144
|
export type LineCounter = (path: string) => Promise<number>;
|
|
145
145
|
|
|
146
|
+
export interface WorktreeChanges {
|
|
147
|
+
root: string;
|
|
148
|
+
branch?: string;
|
|
149
|
+
model: ChangesModel;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// -z avoids Git's quoting of paths containing whitespace or newlines.
|
|
153
|
+
export function parseWorktrees(text: string): Array<{ root: string; branch?: string }> {
|
|
154
|
+
return text.split("\0\0").flatMap((record) => {
|
|
155
|
+
const fields = record.split("\0");
|
|
156
|
+
const root = fields.find((field) => field.startsWith("worktree "))?.slice(9);
|
|
157
|
+
if (!root || fields.some((field) => field === "bare" || field.startsWith("prunable"))) return [];
|
|
158
|
+
const branch = fields.find((field) => field.startsWith("branch "))?.slice(7).replace(/^refs\/heads\//, "");
|
|
159
|
+
return [branch ? { root, branch } : { root }];
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export class WorktreeChangesTracker {
|
|
164
|
+
worktrees: WorktreeChanges[] = [];
|
|
165
|
+
private inFlight: Promise<ChangesModel> | undefined;
|
|
166
|
+
private readonly discover: GitRunner;
|
|
167
|
+
private readonly gitForRoot: (root: string) => GitRunner;
|
|
168
|
+
private readonly linesForRoot: (root: string) => LineCounter;
|
|
169
|
+
private readonly registeredRoots: () => readonly string[];
|
|
170
|
+
|
|
171
|
+
constructor(discover: GitRunner, gitForRoot: (root: string) => GitRunner, linesForRoot: (root: string) => LineCounter = () => noLines, registeredRoots: () => readonly string[] = () => []) {
|
|
172
|
+
this.discover = discover;
|
|
173
|
+
this.gitForRoot = gitForRoot;
|
|
174
|
+
this.linesForRoot = linesForRoot;
|
|
175
|
+
this.registeredRoots = registeredRoots;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
get model(): ChangesModel {
|
|
179
|
+
return changesModel(this.worktrees.flatMap((tree) => tree.model.files.map((file) => ({
|
|
180
|
+
...file,
|
|
181
|
+
path: this.worktrees.length === 1 ? file.path : `${tree.root}/${file.path}`,
|
|
182
|
+
}))));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async start(): Promise<void> {
|
|
186
|
+
await this.refresh();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async refresh(): Promise<ChangesModel> {
|
|
190
|
+
// A scan can take longer than the polling interval in a large clone.
|
|
191
|
+
// Share it rather than extending it indefinitely with queued polls.
|
|
192
|
+
if (this.inFlight) return this.inFlight;
|
|
193
|
+
this.inFlight = this.capture();
|
|
194
|
+
try {
|
|
195
|
+
return await this.inFlight;
|
|
196
|
+
} finally {
|
|
197
|
+
this.inFlight = undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async capture(): Promise<ChangesModel> {
|
|
202
|
+
const result = await this.discover(["worktree", "list", "--porcelain", "-z"]).catch(() => ({ code: 128, stdout: "" }));
|
|
203
|
+
const trees: WorktreeChanges[] = [];
|
|
204
|
+
const metadata = new Map((result.code === 0 ? parseWorktrees(result.stdout) : []).map((tree) => [tree.root, tree]));
|
|
205
|
+
// Discovery labels registered roots; it never grants visibility to siblings.
|
|
206
|
+
const roots = new Set(this.registeredRoots());
|
|
207
|
+
for (const root of roots) {
|
|
208
|
+
const tree = metadata.get(root) ?? { root };
|
|
209
|
+
try {
|
|
210
|
+
const tracker = new ChangesTracker(this.gitForRoot(tree.root), this.linesForRoot(tree.root));
|
|
211
|
+
await tracker.start();
|
|
212
|
+
if (tracker.model.files.length) trees.push({ ...tree, model: tracker.model });
|
|
213
|
+
} catch {
|
|
214
|
+
// Linked roots can disappear between discovery and status.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const latest = new Set(this.registeredRoots());
|
|
218
|
+
// A root admitted during status must not wait for a later poll (which may
|
|
219
|
+
// be disabled). Ordinary overlapping polls still share exactly one scan.
|
|
220
|
+
if (latest.size !== roots.size || [...latest].some((root) => !roots.has(root))) return this.capture();
|
|
221
|
+
this.worktrees = trees;
|
|
222
|
+
return this.model;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
146
226
|
const noLines: LineCounter = async () => 0;
|
|
147
227
|
|
|
148
228
|
const NUMSTAT_ARGS = ["diff", "--numstat", "HEAD"];
|
|
@@ -199,7 +279,7 @@ export class ChangesTracker {
|
|
|
199
279
|
|
|
200
280
|
private async capture(): Promise<ChangedFile[] | undefined> {
|
|
201
281
|
const [numstat, porcelain] = await Promise.all([this.git(NUMSTAT_ARGS), this.git(PORCELAIN_ARGS)]);
|
|
202
|
-
if (
|
|
282
|
+
if (porcelain.code !== 0) return undefined;
|
|
203
283
|
const files = snapshotChanges({ numstat: numstat.stdout, porcelain: porcelain.stdout });
|
|
204
284
|
// Untracked files never appear in numstat; count their lines directly.
|
|
205
285
|
for (const file of files) {
|
package/lib/shell-prompt.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { stripAnsi } from "./terminal-theme.ts";
|
|
3
3
|
|
|
4
4
|
// Gentle Shell prompt frame. pi's editor renders a top rule, padded content
|
|
@@ -33,8 +33,6 @@ export interface PromptFrameOptions {
|
|
|
33
33
|
borderColor: (text: string) => string;
|
|
34
34
|
fg: (color: string, text: string) => string;
|
|
35
35
|
bold?: (text: string) => string;
|
|
36
|
-
/** Paints a finished line, e.g. with the panel background. */
|
|
37
|
-
paint?: (line: string) => string;
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
// A terminal cell cannot grow, so the petal earns presence with weight and
|
|
@@ -78,28 +76,33 @@ function topRule(width: number, options: PromptFrameOptions, indicator: string |
|
|
|
78
76
|
const labelText = label ? ` ${options.fg(LABEL_ROLE, label)}` : "";
|
|
79
77
|
const labelWidth = label ? label.length + 1 : 0;
|
|
80
78
|
const fill = width - 3 - visibleWidth(glyph) - labelWidth - 1 - 1;
|
|
79
|
+
if (fill < 0) return options.borderColor(`╭${rule(width - 2)}╮`);
|
|
81
80
|
return options.borderColor("╭─ ") + petal + labelText + options.borderColor(` ${rule(fill)}╮`);
|
|
82
81
|
}
|
|
83
82
|
|
|
84
83
|
function bottomRule(width: number, options: PromptFrameOptions, indicator: string | undefined): string {
|
|
85
84
|
if (!indicator) return options.borderColor(`╰${rule(width - 2)}╯`);
|
|
86
85
|
const fill = width - 3 - indicator.length - 1 - 1;
|
|
86
|
+
if (fill < 0) return options.borderColor(`╰${rule(width - 2)}╯`);
|
|
87
87
|
return options.borderColor("╰─ ") + options.fg(LABEL_ROLE, indicator) + options.borderColor(` ${rule(fill)}╯`);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
function sideRules(line: string, innerWidth: number, options: PromptFrameOptions): string {
|
|
91
|
-
const
|
|
92
|
-
|
|
91
|
+
const clipped = innerWidth === 0 ? "" : truncateToWidth(line, innerWidth, "");
|
|
92
|
+
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
|
|
93
|
+
const content = clipped + padding;
|
|
94
|
+
return options.borderColor("│") + content + options.borderColor("│");
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
export function framePromptLines(lines: string[], width: number, options: PromptFrameOptions): string[] {
|
|
96
|
-
|
|
98
|
+
width = Math.max(0, Math.floor(width));
|
|
99
|
+
if (lines.length < 2) return lines.map((line) => truncateToWidth(line, width, ""));
|
|
100
|
+
if (width < 2) return lines.map((_line, index) => width === 0 ? "" : options.borderColor(index === 0 ? "╭" : index === lines.length - 1 ? "╰" : "│"));
|
|
97
101
|
const innerWidth = width - 2;
|
|
98
102
|
const top = lines[0];
|
|
99
103
|
const bottom = lines[lines.length - 1];
|
|
100
104
|
const content = lines.slice(1, -1).map((line) => sideRules(line, innerWidth, options));
|
|
101
|
-
|
|
102
|
-
return [topRule(width, options, scrollIndicator(top)), ...content, bottomRule(width, options, scrollIndicator(bottom))].map(paint);
|
|
105
|
+
return [topRule(width, options, scrollIndicator(top)), ...content, bottomRule(width, options, scrollIndicator(bottom))];
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
export function withPromptHint(line: string, hint: string, fg: PromptFrameOptions["fg"]): string {
|
|
@@ -110,10 +113,3 @@ export function withPromptHint(line: string, hint: string, fg: PromptFrameOption
|
|
|
110
113
|
if (trailing.trim() !== "" || trailing.length < hint.length + 1) return line;
|
|
111
114
|
return `${line.slice(0, afterCursor)} ${fg(HINT_ROLE, hint)}${" ".repeat(trailing.length - hint.length - 1)}`;
|
|
112
115
|
}
|
|
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
|
-
}
|