pim-agent 0.9.0 → 0.11.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/bin/pim.ts +1 -1
- package/package.json +2 -3
- package/packages/core/src/extensions/CoreExtensions.ts +19 -16
- package/packages/core/src/extensions/apply-patch/render.ts +16 -55
- package/packages/core/src/extensions/bash/index.ts +2 -1
- package/packages/core/src/extensions/bash/run.ts +8 -2
- package/packages/core/src/extensions/system-prompt/index.ts +45 -22
- package/packages/core/src/extensions/system-prompt/prompt.ts +6 -3
- package/packages/core/src/picker/PickerService.ts +23 -1
- package/packages/core/src/picker/token.ts +12 -5
- package/packages/core/src/session/EventLog.ts +22 -79
- package/packages/core/src/session/SearchIndex.ts +752 -0
- package/packages/core/src/session/SearchTokens.ts +97 -0
- package/packages/core/src/session/SessionDigest.ts +161 -0
- package/packages/core/src/session/SessionHost.ts +52 -10
- package/packages/core/src/session/SessionMeta.ts +267 -0
- package/packages/core/src/session/SessionName.ts +18 -0
- package/packages/core/src/session/SessionRegistry.ts +111 -15
- package/packages/core/src/session/SessionUi.ts +127 -0
- package/packages/core/src/session/WriteMark.ts +15 -1
- package/packages/core/src/shared/DiffLines.ts +1 -1
- package/packages/core/src/shared/DiffPatch.ts +70 -0
- package/packages/core/src/shared/DiffRenderer.ts +1 -1
- package/packages/core/src/shared/Directories.ts +39 -5
- package/packages/core/src/shared/ExtensionToggles.ts +8 -0
- package/packages/core/src/shared/FileWatch.ts +15 -1
- package/packages/core/src/shared/Git.ts +483 -42
- package/packages/core/src/shared/GitMonitor.ts +221 -0
- package/packages/core/src/shared/Levenshtein.ts +54 -1
- package/packages/core/src/shared/PiExtensions.ts +228 -0
- package/packages/core/src/shared/Proc.ts +21 -1
- package/packages/core/src/shared/RepoDiff.ts +741 -0
- package/packages/core/src/shared/Surface.ts +2 -0
- package/packages/core/src/shared/Updater.ts +21 -5
- package/packages/core/src/view/DiffExpand.ts +214 -0
- package/packages/core/src/view/DiffLayout.ts +7 -7
- package/packages/core/src/view/DiffPairs.ts +47 -0
- package/packages/core/src/view/MovePath.ts +70 -0
- package/packages/daemon/src/WebSurface.ts +1 -2
- package/packages/protocol/src/Command.ts +151 -3
- package/packages/protocol/src/Diff.ts +15 -0
- package/packages/protocol/src/ServerEvent.ts +191 -2
- package/packages/server/src/ClientConnection.ts +15 -3
- package/packages/server/src/ProbeClient.ts +111 -28
- package/packages/server/src/SessionCatalogue.ts +292 -38
- package/packages/server/src/SessionStream.ts +297 -45
- package/packages/server/src/StaticClient.ts +95 -12
- package/packages/server/src/WsGateway.ts +417 -45
- package/packages/server/src/probe.ts +77 -7
- package/packages/telegram/src/Session.ts +1 -0
- package/packages/tui/src/extensions/command-picker/index.ts +64 -32
- package/packages/tui/src/extensions/footer/index.ts +19 -43
- package/packages/tui/src/extensions/session-lease/index.ts +8 -3
- package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
- package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-D3tyj0PP.js} +1 -1
- package/packages/web/dist/client/assets/index-CTrlTM53.js +45 -0
- package/packages/web/dist/client/assets/index-JnduS4Cd.css +1 -0
- package/packages/web/dist/client/index.html +2 -2
- package/packages/protocol/src/Protocol.ts +0 -6
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
- package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
- package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
SessionManager,
|
|
3
|
+
type ToolDefinition,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
5
|
import { stat } from "node:fs/promises";
|
|
3
6
|
import { join } from "node:path";
|
|
4
7
|
|
|
5
8
|
import { Directories } from "../shared/Directories";
|
|
9
|
+
import { Pool } from "../shared/Pool";
|
|
10
|
+
import type { Surface } from "../shared/Surface";
|
|
6
11
|
import { AgentRuntime, type ModelChoice } from "./AgentRuntime";
|
|
7
12
|
import { EventLog } from "./EventLog";
|
|
8
13
|
import { SessionCache } from "./SessionCache";
|
|
@@ -11,6 +16,9 @@ import {
|
|
|
11
16
|
type CustomToolContext,
|
|
12
17
|
type HostSettings,
|
|
13
18
|
} from "./SessionHost";
|
|
19
|
+
import { SessionLease } from "./SessionLease";
|
|
20
|
+
import { SessionName } from "./SessionName";
|
|
21
|
+
import type { SessionUi } from "./SessionUi";
|
|
14
22
|
|
|
15
23
|
/** A session as pi stores it: one JSONL file under a cwd-encoded directory. */
|
|
16
24
|
export type SessionSummary = {
|
|
@@ -33,6 +41,8 @@ export type SessionRegistryDeps = {
|
|
|
33
41
|
) => readonly ToolDefinition[];
|
|
34
42
|
/** Appended to every session's system prompt. */
|
|
35
43
|
readonly systemInstruction?: () => Promise<string | undefined>;
|
|
44
|
+
/** Where this frontend's human is reading. */
|
|
45
|
+
readonly surface?: Surface;
|
|
36
46
|
};
|
|
37
47
|
|
|
38
48
|
export type SessionCreateOptions = {
|
|
@@ -41,11 +51,15 @@ export type SessionCreateOptions = {
|
|
|
41
51
|
readonly like?: SessionHost;
|
|
42
52
|
};
|
|
43
53
|
|
|
54
|
+
/** A session tree is thousands of files and each header is one open; fanning out over all of them at once is how a listing runs the process out of descriptors. */
|
|
55
|
+
const HEADER_READS = 32;
|
|
56
|
+
|
|
44
57
|
/** Live sessions keyed on pi's session UUID; the catalogue is pi's sessions directory, read on demand. */
|
|
45
58
|
export class SessionRegistry {
|
|
46
59
|
private readonly deps: SessionRegistryDeps;
|
|
47
60
|
private readonly runtime: AgentRuntime;
|
|
48
61
|
private readonly hosts: SessionCache<SessionHost>;
|
|
62
|
+
private ui: ((host: SessionHost) => SessionUi | undefined) | undefined;
|
|
49
63
|
|
|
50
64
|
public constructor(deps: SessionRegistryDeps) {
|
|
51
65
|
this.deps = deps;
|
|
@@ -53,28 +67,37 @@ export class SessionRegistry {
|
|
|
53
67
|
this.hosts = new SessionCache(deps.capacity);
|
|
54
68
|
}
|
|
55
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Where the extensions of every host built from here speak. It belongs to the
|
|
72
|
+
* registry rather than each caller because `create` binds an agent eagerly,
|
|
73
|
+
* and pi freezes a session's UI mode at that bind. Resolved per call: a
|
|
74
|
+
* host outlives the stream that speaks for it.
|
|
75
|
+
*/
|
|
76
|
+
public setUi(ui: (host: SessionHost) => SessionUi | undefined): void {
|
|
77
|
+
this.ui = ui;
|
|
78
|
+
}
|
|
79
|
+
|
|
56
80
|
public get sessionsRoot(): string {
|
|
57
81
|
return join(this.runtime.agentDir, "sessions");
|
|
58
82
|
}
|
|
59
83
|
|
|
84
|
+
public get agentDir(): string {
|
|
85
|
+
return this.runtime.agentDir;
|
|
86
|
+
}
|
|
87
|
+
|
|
60
88
|
public async init(): Promise<void> {
|
|
61
89
|
await this.runtime.init();
|
|
62
90
|
}
|
|
63
91
|
|
|
64
92
|
/** Sessions on disk, newest first; reads only each file's header line. */
|
|
65
93
|
public async list(cwd?: string): Promise<readonly SessionSummary[]> {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
onlyFiles: true,
|
|
74
|
-
})) {
|
|
75
|
-
paths.push(join(root, relative));
|
|
76
|
-
}
|
|
77
|
-
const summaries = (await Promise.all(paths.map(readSummary))).filter(
|
|
94
|
+
const summaries = (
|
|
95
|
+
await Pool.mapPooled(
|
|
96
|
+
await this.paths("*/*.jsonl"),
|
|
97
|
+
HEADER_READS,
|
|
98
|
+
readSummary
|
|
99
|
+
)
|
|
100
|
+
).filter(
|
|
78
101
|
(summary): summary is SessionSummary =>
|
|
79
102
|
summary !== undefined && (cwd === undefined || summary.cwd === cwd)
|
|
80
103
|
);
|
|
@@ -86,6 +109,38 @@ export class SessionRegistry {
|
|
|
86
109
|
return this.hosts.peek(sessionId);
|
|
87
110
|
}
|
|
88
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Rename a session through pi's own session name, so its `/resume` picker shows
|
|
114
|
+
* it too; `null` clears it. A live session is renamed by its agent, a closed one
|
|
115
|
+
* by appending to its file under the turn lease. Answers with the name pi kept.
|
|
116
|
+
*/
|
|
117
|
+
public async setName(
|
|
118
|
+
sessionId: string,
|
|
119
|
+
name: string | null
|
|
120
|
+
): Promise<string | undefined> {
|
|
121
|
+
const host = this.peek(sessionId);
|
|
122
|
+
if (host?.agentSession) {
|
|
123
|
+
return await host.setName(name);
|
|
124
|
+
}
|
|
125
|
+
const summary = await this.find(sessionId);
|
|
126
|
+
if (summary === undefined) {
|
|
127
|
+
throw new Error(`unknown session: ${sessionId}`);
|
|
128
|
+
}
|
|
129
|
+
const next = SessionName.normalise(name);
|
|
130
|
+
// Reopening is a plain read today, but a pi that migrates the file rewrites it.
|
|
131
|
+
// The lease is only ever held for a whole turn, so a rename refuses rather than queues.
|
|
132
|
+
return await SessionLease.hold(
|
|
133
|
+
summary.path,
|
|
134
|
+
"daemon",
|
|
135
|
+
async () => {
|
|
136
|
+
const manager = SessionManager.open(summary.path);
|
|
137
|
+
manager.appendSessionInfo(next);
|
|
138
|
+
return manager.getSessionName();
|
|
139
|
+
},
|
|
140
|
+
{ timeoutMs: 0 }
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
89
144
|
/** Every model this machine has credentials for, qualified as `SessionHost.setModel` takes them. */
|
|
90
145
|
public models(): readonly ModelChoice[] {
|
|
91
146
|
return this.runtime.models();
|
|
@@ -96,7 +151,7 @@ export class SessionRegistry {
|
|
|
96
151
|
if (cached) {
|
|
97
152
|
return cached;
|
|
98
153
|
}
|
|
99
|
-
const summary =
|
|
154
|
+
const summary = await this.find(sessionId);
|
|
100
155
|
if (!summary) {
|
|
101
156
|
throw new Error(`unknown session: ${sessionId}`);
|
|
102
157
|
}
|
|
@@ -134,8 +189,44 @@ export class SessionRegistry {
|
|
|
134
189
|
await this.hosts.disposeAll();
|
|
135
190
|
}
|
|
136
191
|
|
|
192
|
+
/**
|
|
193
|
+
* One session by id. Pi names a file for the id it carries, so the usual
|
|
194
|
+
* answer is one glob and one header; resolving it by listing would read the
|
|
195
|
+
* header of every session on disk to throw all but one away.
|
|
196
|
+
*/
|
|
197
|
+
private async find(sessionId: string): Promise<SessionSummary | undefined> {
|
|
198
|
+
for (const path of await this.paths(`*/*${sessionId}.jsonl`)) {
|
|
199
|
+
const summary = await readSummary(path);
|
|
200
|
+
if (summary?.sessionId === sessionId) {
|
|
201
|
+
return summary;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// A file named something else entirely: the id is the header's, not the name's.
|
|
205
|
+
return (await this.list()).find(
|
|
206
|
+
(summary) => summary.sessionId === sessionId
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async paths(pattern: string): Promise<readonly string[]> {
|
|
211
|
+
const root = this.sessionsRoot;
|
|
212
|
+
if (!(await stat(root).catch(() => undefined))?.isDirectory()) {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
const found: string[] = [];
|
|
216
|
+
for await (const relative of new Bun.Glob(pattern).scan({
|
|
217
|
+
cwd: root,
|
|
218
|
+
onlyFiles: true,
|
|
219
|
+
})) {
|
|
220
|
+
found.push(join(root, relative));
|
|
221
|
+
}
|
|
222
|
+
return found;
|
|
223
|
+
}
|
|
224
|
+
|
|
137
225
|
private buildHost(label: string, settings: HostSettings): SessionHost {
|
|
138
|
-
|
|
226
|
+
const ui = this.ui;
|
|
227
|
+
// Named so the sink can reach back for the host it belongs to; the closure
|
|
228
|
+
// only ever runs once construction has returned.
|
|
229
|
+
const host: SessionHost = new SessionHost({
|
|
139
230
|
label: `session ${label}`,
|
|
140
231
|
settings,
|
|
141
232
|
defaults: this.deps.defaults,
|
|
@@ -150,7 +241,12 @@ export class SessionRegistry {
|
|
|
150
241
|
...(this.deps.systemInstruction === undefined
|
|
151
242
|
? {}
|
|
152
243
|
: { systemInstruction: this.deps.systemInstruction }),
|
|
244
|
+
...(this.deps.surface === undefined
|
|
245
|
+
? {}
|
|
246
|
+
: { surface: this.deps.surface }),
|
|
247
|
+
...(ui === undefined ? {} : { ui: () => ui(host) }),
|
|
153
248
|
});
|
|
249
|
+
return host;
|
|
154
250
|
}
|
|
155
251
|
}
|
|
156
252
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionUIContext,
|
|
3
|
+
Theme,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import type { NoticeSeverity } from "../view/ViewBlock";
|
|
7
|
+
|
|
8
|
+
/** The slice of pi's dialog options a surface without a terminal can honour; the rest is countdown chrome. */
|
|
9
|
+
export type UiAsk = {
|
|
10
|
+
readonly timeout?: number;
|
|
11
|
+
readonly signal?: AbortSignal;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Where an extension's own words go. Four of pi's thirty methods: the ones that address a human rather than a terminal. */
|
|
15
|
+
export type SessionUi = {
|
|
16
|
+
readonly notify: (text: string, severity: NoticeSeverity) => void;
|
|
17
|
+
readonly select: (
|
|
18
|
+
title: string,
|
|
19
|
+
options: readonly string[],
|
|
20
|
+
opts?: UiAsk
|
|
21
|
+
) => Promise<string | undefined>;
|
|
22
|
+
readonly confirm: (
|
|
23
|
+
title: string,
|
|
24
|
+
message: string,
|
|
25
|
+
opts?: UiAsk
|
|
26
|
+
) => Promise<boolean>;
|
|
27
|
+
readonly input: (
|
|
28
|
+
title: string,
|
|
29
|
+
placeholder?: string,
|
|
30
|
+
opts?: UiAsk
|
|
31
|
+
) => Promise<string | undefined>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Everything off the terminal paints with; also what pi's border getters hand back. */
|
|
35
|
+
const IDENTITY = (text: string): string => text;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* pi keeps its own `theme` instance to itself, and nothing off the terminal has
|
|
39
|
+
* colours to paint with. `satisfies` covers the whole public surface — `keyof`
|
|
40
|
+
* skips the private fields the cast is for — so a method pi adds is a type
|
|
41
|
+
* error here rather than a throw inside somebody's event handler.
|
|
42
|
+
*/
|
|
43
|
+
const PLAIN_THEME = {
|
|
44
|
+
fg: (_color: string, text: string) => text,
|
|
45
|
+
bg: (_color: string, text: string) => text,
|
|
46
|
+
bold: IDENTITY,
|
|
47
|
+
italic: IDENTITY,
|
|
48
|
+
underline: IDENTITY,
|
|
49
|
+
inverse: IDENTITY,
|
|
50
|
+
strikethrough: IDENTITY,
|
|
51
|
+
getFgAnsi: () => "",
|
|
52
|
+
getBgAnsi: () => "",
|
|
53
|
+
getColorMode: () => "truecolor",
|
|
54
|
+
getThinkingBorderColor: () => IDENTITY,
|
|
55
|
+
getBashModeBorderColor: () => IDENTITY,
|
|
56
|
+
} satisfies { [K in keyof Theme]: Theme[K] } as unknown as Theme;
|
|
57
|
+
|
|
58
|
+
function severityOf(
|
|
59
|
+
kind: Parameters<ExtensionUIContext["notify"]>[1]
|
|
60
|
+
): NoticeSeverity {
|
|
61
|
+
return kind === "warning" ? "warn" : (kind ?? "info");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A dialog an extension awaits goes unanswered rather than ever throwing at it. */
|
|
65
|
+
async function settle<T>(
|
|
66
|
+
ask: () => Promise<T> | undefined
|
|
67
|
+
): Promise<T | undefined> {
|
|
68
|
+
try {
|
|
69
|
+
return await ask();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.warn("[SessionUi] dialog sink failed:", err);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Widens the sink onto pi's `ExtensionUIContext`, no-opping everything a
|
|
78
|
+
* terminal owns exactly as pi's own RPC mode does. `sink` is read per call, so
|
|
79
|
+
* an agent rebuilt under a host keeps whatever its host was last handed.
|
|
80
|
+
*
|
|
81
|
+
* Every method has to be an *own* property: pi re-wraps what it is given with
|
|
82
|
+
* `{ ...ui }` (`runner.js:274`), which copies own enumerable keys only. A class
|
|
83
|
+
* instance would arrive with `notify` and the rest of the prototype missing.
|
|
84
|
+
*/
|
|
85
|
+
export function adaptSessionUi(
|
|
86
|
+
sink: () => SessionUi | undefined
|
|
87
|
+
): ExtensionUIContext {
|
|
88
|
+
return {
|
|
89
|
+
select: (title, options, opts) =>
|
|
90
|
+
settle(() => sink()?.select(title, options, opts)),
|
|
91
|
+
confirm: async (title, message, opts) =>
|
|
92
|
+
(await settle(() => sink()?.confirm(title, message, opts))) === true,
|
|
93
|
+
input: (title, placeholder, opts) =>
|
|
94
|
+
settle(() => sink()?.input(title, placeholder, opts)),
|
|
95
|
+
notify: (message, kind) => {
|
|
96
|
+
try {
|
|
97
|
+
sink()?.notify(message, severityOf(kind));
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.warn("[SessionUi] notify sink failed:", err);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
onTerminalInput: () => () => {},
|
|
103
|
+
setStatus: () => {},
|
|
104
|
+
setWorkingMessage: () => {},
|
|
105
|
+
setWorkingVisible: () => {},
|
|
106
|
+
setWorkingIndicator: () => {},
|
|
107
|
+
setHiddenThinkingLabel: () => {},
|
|
108
|
+
setWidget: () => {},
|
|
109
|
+
setFooter: () => {},
|
|
110
|
+
setHeader: () => {},
|
|
111
|
+
setTitle: () => {},
|
|
112
|
+
custom: async <T>() => undefined as T,
|
|
113
|
+
pasteToEditor: () => {},
|
|
114
|
+
setEditorText: () => {},
|
|
115
|
+
getEditorText: () => "",
|
|
116
|
+
editor: async () => undefined,
|
|
117
|
+
addAutocompleteProvider: () => {},
|
|
118
|
+
setEditorComponent: () => {},
|
|
119
|
+
getEditorComponent: () => undefined,
|
|
120
|
+
theme: PLAIN_THEME,
|
|
121
|
+
getAllThemes: () => [],
|
|
122
|
+
getTheme: () => undefined,
|
|
123
|
+
setTheme: () => ({ success: false, error: "UI not available" }),
|
|
124
|
+
getToolsExpanded: () => false,
|
|
125
|
+
setToolsExpanded: () => {},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -33,5 +33,19 @@ function foreignSince(mark: WriteMark, next: WriteMark): boolean {
|
|
|
33
33
|
return next.head - base.head - (next.entries - base.entries) > 0;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Whether every line the file gained since `mark` is one no reader has to replay.
|
|
38
|
+
* A rename appends `session_info` and moves no history, so a surface that absorbs
|
|
39
|
+
* it stays on the session it is already holding. Only ask between turns: a window
|
|
40
|
+
* that also holds our own appends is not benign, and a running turn's is.
|
|
41
|
+
*/
|
|
42
|
+
async function benignSince(log: EventLog, mark: WriteMark): Promise<boolean> {
|
|
43
|
+
const added = await log.read(mark.head);
|
|
44
|
+
return (
|
|
45
|
+
added.length > 0 &&
|
|
46
|
+
added.every(({ entry }) => entry.type === "session_info")
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
/** Write accounting over one session file: whose lines are these? */
|
|
37
|
-
export const WriteMark = { UNREAD, of, foreignSince };
|
|
51
|
+
export const WriteMark = { UNREAD, of, foreignSince, benignSince };
|
|
@@ -301,4 +301,4 @@ function partLines(value: string): readonly string[] {
|
|
|
301
301
|
|
|
302
302
|
const emptySide: ToolDiffSide = { lines: [], hasTrailingNewline: false };
|
|
303
303
|
|
|
304
|
-
export const DiffLines = { buildToolDiff, fromText, emptySide };
|
|
304
|
+
export const DiffLines = { buildToolDiff, fromText, emptySide, attachEmphasis };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as Diff from "diff";
|
|
2
|
+
import {
|
|
3
|
+
DiffLines,
|
|
4
|
+
type ToolDiff,
|
|
5
|
+
type ToolDiffHunk,
|
|
6
|
+
type ToolDiffLine,
|
|
7
|
+
type ToolDiffLineKind,
|
|
8
|
+
} from "./DiffLines";
|
|
9
|
+
|
|
10
|
+
function fromUnified(path: string, patch: string): ToolDiff | undefined {
|
|
11
|
+
const hunks = Diff.parsePatch(patch)
|
|
12
|
+
.flatMap((file) => file.hunks)
|
|
13
|
+
.map(toHunk)
|
|
14
|
+
.filter((hunk) => hunk.lines.length > 0);
|
|
15
|
+
|
|
16
|
+
return hunks.length === 0 ? undefined : { path, hunks };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toHunk(hunk: Diff.StructuredPatchHunk): ToolDiffHunk {
|
|
20
|
+
const lines: ToolDiffLine[] = [];
|
|
21
|
+
let oldLine = hunk.oldStart;
|
|
22
|
+
let newLine = hunk.newStart;
|
|
23
|
+
|
|
24
|
+
for (const raw of hunk.lines) {
|
|
25
|
+
if (raw.startsWith("\\")) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const kind = kindOf(raw);
|
|
30
|
+
const text = raw.slice(1);
|
|
31
|
+
|
|
32
|
+
if (kind === "added") {
|
|
33
|
+
lines.push({ kind, newLine, text });
|
|
34
|
+
newLine += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (kind === "removed") {
|
|
39
|
+
lines.push({ kind, oldLine, text });
|
|
40
|
+
oldLine += 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
lines.push({ kind, oldLine, newLine, text });
|
|
45
|
+
oldLine += 1;
|
|
46
|
+
newLine += 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
oldStart: hunk.oldStart,
|
|
51
|
+
oldLines: hunk.oldLines,
|
|
52
|
+
newStart: hunk.newStart,
|
|
53
|
+
newLines: hunk.newLines,
|
|
54
|
+
lines: DiffLines.attachEmphasis(lines),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function kindOf(line: string): ToolDiffLineKind {
|
|
59
|
+
if (line.startsWith("+")) {
|
|
60
|
+
return "added";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (line.startsWith("-")) {
|
|
64
|
+
return "removed";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return "context";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const DiffPatch = { fromUnified };
|
|
@@ -95,7 +95,7 @@ function highlightHunkLines(
|
|
|
95
95
|
hunk: ToolDiffHunk,
|
|
96
96
|
highlighter: DiffHighlighter
|
|
97
97
|
): readonly string[] {
|
|
98
|
-
const highlighted = DiffLayout.mapSides(hunk, highlighter);
|
|
98
|
+
const highlighted = DiffLayout.mapSides(hunk.lines, highlighter);
|
|
99
99
|
return hunk.lines.map((line, index) => highlighted[index] ?? line.text);
|
|
100
100
|
}
|
|
101
101
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readdir, stat } from "node:fs/promises";
|
|
2
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
1
|
+
import { mkdir, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { FsErrors } from "./FsErrors";
|
|
5
5
|
import { Paths } from "./Paths";
|
|
@@ -29,12 +29,16 @@ async function check(path: string): Promise<string | undefined> {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
function rooted(path: string): string {
|
|
33
33
|
const expanded = Paths.expandHome(path);
|
|
34
34
|
if (!isAbsolute(expanded)) {
|
|
35
35
|
throw new Error(`not an absolute path: ${path}`);
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
return resolve(expanded);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function list(path: string): Promise<DirectoryListing> {
|
|
41
|
+
const resolved = rooted(path);
|
|
38
42
|
const reason = await check(resolved);
|
|
39
43
|
if (reason !== undefined) {
|
|
40
44
|
throw new Error(reason);
|
|
@@ -58,4 +62,34 @@ async function list(path: string): Promise<DirectoryListing> {
|
|
|
58
62
|
};
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
function refusal(error: unknown, path: string): string {
|
|
66
|
+
switch (FsErrors.code(error)) {
|
|
67
|
+
case "ENOENT":
|
|
68
|
+
return `path does not exist: ${dirname(path)}`;
|
|
69
|
+
case "EEXIST":
|
|
70
|
+
return `already exists: ${path}`;
|
|
71
|
+
case "ENOTDIR":
|
|
72
|
+
return `not a directory: ${dirname(path)}`;
|
|
73
|
+
case "EACCES":
|
|
74
|
+
case "EPERM":
|
|
75
|
+
return `permission denied: ${path}`;
|
|
76
|
+
default:
|
|
77
|
+
return `mkdir failed: ${(error as Error).message}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Makes one directory inside an existing one. Never recursive: a parent that is missing is said so, not invented. */
|
|
82
|
+
async function create(path: string): Promise<void> {
|
|
83
|
+
const name = basename(Paths.expandHome(path));
|
|
84
|
+
if (name === "" || name === "." || name === "..") {
|
|
85
|
+
throw new Error(`not a directory to create: ${path}`);
|
|
86
|
+
}
|
|
87
|
+
const resolved = rooted(path);
|
|
88
|
+
try {
|
|
89
|
+
await mkdir(resolved);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new Error(refusal(error, resolved));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const Directories = { check, list, create };
|
|
@@ -34,6 +34,13 @@ const DEFAULT_DISABLED: readonly PimExtensionName[] = ["todo", "tps"];
|
|
|
34
34
|
|
|
35
35
|
const REQUIRED: readonly PimExtensionName[] = ["_init", "pim", "session-lease"];
|
|
36
36
|
|
|
37
|
+
// Hardcoded, never derived: the browser switches these and nothing else, under
|
|
38
|
+
// the label it gives them — the terminal menu names them by key, this does not.
|
|
39
|
+
const WEB = [{ name: "todo", label: "Todo Tool" }] as const satisfies readonly {
|
|
40
|
+
name: PimExtensionName;
|
|
41
|
+
label: string;
|
|
42
|
+
}[];
|
|
43
|
+
|
|
37
44
|
function isRequired(name: string): boolean {
|
|
38
45
|
return (REQUIRED as readonly string[]).includes(name);
|
|
39
46
|
}
|
|
@@ -119,6 +126,7 @@ function enabled(
|
|
|
119
126
|
export const ExtensionToggles = {
|
|
120
127
|
REQUIRED,
|
|
121
128
|
NAMES,
|
|
129
|
+
WEB,
|
|
122
130
|
isRequired,
|
|
123
131
|
isKnown,
|
|
124
132
|
describe,
|
|
@@ -8,6 +8,12 @@ import { basename, dirname, join } from "node:path";
|
|
|
8
8
|
|
|
9
9
|
const POLL_MS = 1_000;
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* A burst coalesces into one event, and the read it triggers can land inside
|
|
13
|
+
* the burst — so look once more after it, or the last write waits on the poll.
|
|
14
|
+
*/
|
|
15
|
+
const SETTLE_MS = 25;
|
|
16
|
+
|
|
11
17
|
function signatureOf(path: string): string {
|
|
12
18
|
const stats = statSync(path, { throwIfNoEntry: false });
|
|
13
19
|
return stats === undefined
|
|
@@ -41,6 +47,13 @@ function follow(
|
|
|
41
47
|
pollMs: number
|
|
42
48
|
): () => void {
|
|
43
49
|
let watcher: FSWatcher | undefined;
|
|
50
|
+
let settle: ReturnType<typeof setTimeout> | undefined;
|
|
51
|
+
const fire = (): void => {
|
|
52
|
+
onEvent();
|
|
53
|
+
clearTimeout(settle);
|
|
54
|
+
settle = setTimeout(onEvent, SETTLE_MS);
|
|
55
|
+
settle.unref?.();
|
|
56
|
+
};
|
|
44
57
|
const arm = (): void => {
|
|
45
58
|
if (watcher !== undefined) {
|
|
46
59
|
return;
|
|
@@ -48,7 +61,7 @@ function follow(
|
|
|
48
61
|
try {
|
|
49
62
|
watcher = watchFs(watched, { persistent: false }, (_event, changed) => {
|
|
50
63
|
if (entry === undefined || changed === null || changed === entry) {
|
|
51
|
-
|
|
64
|
+
fire();
|
|
52
65
|
}
|
|
53
66
|
});
|
|
54
67
|
watcher.on("error", () => {
|
|
@@ -66,6 +79,7 @@ function follow(
|
|
|
66
79
|
timer.unref?.();
|
|
67
80
|
return (): void => {
|
|
68
81
|
clearInterval(timer);
|
|
82
|
+
clearTimeout(settle);
|
|
69
83
|
try {
|
|
70
84
|
watcher?.close();
|
|
71
85
|
} catch {}
|