pi-one-ui 0.1.1 → 0.2.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 +66 -39
- package/extensions/{shell → app/commands}/settings-previews.ts +7 -7
- package/extensions/app/config/renderer.ts +13 -63
- package/extensions/app/config/shell.ts +24 -138
- package/extensions/app/config/store.ts +290 -0
- package/extensions/app/host/pi-extension-port.ts +25 -0
- package/extensions/app/host/pi-ui-port.ts +52 -0
- package/extensions/app/host/tui-capabilities.ts +27 -0
- package/extensions/app/overlay/input-router.ts +63 -0
- package/extensions/app/overlay/overlay-manager.ts +39 -0
- package/extensions/{shell → app/overlay}/selector-border.ts +3 -3
- package/extensions/app/overlay/selector-controller.ts +104 -0
- package/extensions/app/ownership/layout-registry.ts +65 -0
- package/extensions/app/ownership.ts +21 -7
- package/extensions/app/panel.ts +261 -140
- package/extensions/app/runtime/event-coordinator.ts +84 -0
- package/extensions/app/runtime/render-scheduler.ts +56 -0
- package/extensions/app/runtime/runtime-state.ts +83 -0
- package/extensions/app/runtime/tui-runtime.ts +546 -0
- package/extensions/features/{context.ts → context-inspector/index.ts} +30 -23
- package/extensions/features/{shell/flush-docked-bash.ts → flush-docked-bash.ts} +1 -4
- package/extensions/index.ts +2 -48
- package/extensions/layouts/context/index.ts +188 -0
- package/extensions/{shell → layouts/context/message}/user-message-styles.ts +2 -2
- package/extensions/{shell → layouts/context/message}/user-message.ts +2 -2
- package/extensions/{transcript → layouts/context}/renderer/compact-mode.ts +17 -13
- package/extensions/layouts/context/renderer/context-refresh.ts +79 -0
- package/extensions/{transcript → layouts/context}/renderer/default-mode.ts +3 -3
- package/extensions/{transcript → layouts/context}/renderer/index.ts +14 -14
- package/extensions/{transcript → layouts/context}/renderer/mouse/hover.ts +2 -2
- package/extensions/{transcript → layouts/context}/renderer/mouse/interaction.ts +26 -7
- package/extensions/{transcript → layouts/context}/renderer/mouse/layout.ts +1 -1
- package/extensions/{transcript → layouts/context}/renderer/mouse/packets.ts +3 -3
- package/extensions/{transcript → layouts/context}/renderer/mouse/scroll.ts +12 -7
- package/extensions/{transcript → layouts/context}/renderer/tool/diff/diff-component.ts +2 -2
- package/extensions/{transcript → layouts/context}/renderer/tool/diff/diff-edit-render.ts +1 -1
- package/extensions/{transcript → layouts/context}/renderer/tool/diff/diff-highlight.ts +1 -1
- package/extensions/{transcript → layouts/context}/renderer/tool/diff/diff-layout.ts +1 -1
- package/extensions/{transcript → layouts/context}/renderer/tool/diff/diff-limits.ts +1 -1
- package/extensions/{transcript → layouts/context}/renderer/tool/diff/diff-palette.ts +1 -1
- package/extensions/{transcript → layouts/context}/renderer/tool/diff/diff-presentation.ts +1 -1
- package/extensions/{transcript → layouts/context}/renderer/tool/diff/diff-renderer.ts +1 -1
- package/extensions/{transcript → layouts/context}/renderer/tool/diff/diff-write-render.ts +1 -1
- package/extensions/{transcript → layouts/context}/renderer/tool/diff/index.ts +2 -2
- package/extensions/{transcript → layouts/context}/renderer/tool/grouping.ts +4 -4
- package/extensions/{transcript → layouts/context}/renderer/tool/message-display.ts +6 -4
- package/extensions/{transcript → layouts/context}/renderer/tool/names.ts +2 -2
- package/extensions/{transcript → layouts/context}/renderer/tool/result.ts +3 -3
- package/extensions/{features/agent-summary → layouts/context/summary}/core.ts +1 -1
- package/extensions/{features → layouts/context/thinking}/compact-thinking.ts +7 -7
- package/extensions/{shell → layouts/editor}/accent-rail-editor.ts +8 -8
- package/extensions/{shell → layouts/editor}/completion-menu.ts +3 -6
- package/extensions/layouts/editor/controller.ts +613 -0
- package/extensions/{shell → layouts/editor}/editor-metadata-format.ts +6 -3
- package/extensions/layouts/editor/factory.ts +159 -0
- package/extensions/layouts/editor/index.ts +16 -0
- package/extensions/{shell → layouts/editor}/minimalist-editor.ts +4 -4
- package/extensions/layouts/editor/ownership.ts +103 -0
- package/extensions/{shell → layouts/editor}/ui.ts +2 -2
- package/extensions/layouts/footer/controller.ts +392 -0
- package/extensions/layouts/footer/data.ts +41 -0
- package/extensions/{shell → layouts/footer}/extension-status.ts +2 -2
- package/extensions/{shell → layouts/footer}/footer-layout.ts +1 -1
- package/extensions/{shell → layouts/footer}/footer.ts +11 -8
- package/extensions/layouts/footer/index.ts +24 -0
- package/extensions/layouts/header/index.ts +5 -0
- package/extensions/{features/shell → layouts/header}/startup-header.ts +105 -19
- package/extensions/layouts/working-line/controller.ts +291 -0
- package/extensions/{shell → layouts/working-line}/interaction-summary.ts +3 -3
- package/extensions/{shell → layouts/working-line}/working-line-spinners.ts +1 -1
- package/extensions/{shell → layouts/working-line}/working-line.ts +4 -4
- package/extensions/{shell → services}/live-context.ts +29 -1
- package/extensions/services/project-refresh.ts +374 -0
- package/extensions/{shell → services}/project-state.ts +5 -5
- package/extensions/{shell/state.ts → services/session-state.ts} +75 -5
- package/extensions/{shell → shared}/format.ts +4 -4
- package/extensions/tools/component-tree.ts +6 -6
- package/package.json +4 -4
- package/extensions/shell/index.ts +0 -1659
- package/extensions/shell/project-refresh.ts +0 -130
- package/extensions/shell/settings-command.ts +0 -2144
- package/extensions/transcript/index.ts +0 -65
- package/extensions/transcript/renderer/transcript-refresh.ts +0 -79
- /package/extensions/{shell → app/ownership}/prototype-patch-registry.ts +0 -0
- /package/extensions/{shell → app/runtime}/session-lifecycle.ts +0 -0
- /package/extensions/features/{shell/aliases.ts → aliases.ts} +0 -0
- /package/extensions/features/{shell → legacy}/working-message.ts +0 -0
- /package/extensions/features/{reference → session-reference}/index.ts +0 -0
- /package/extensions/features/{reference → session-reference}/session.ts +0 -0
- /package/extensions/features/{reference/subagent.ts → subagent-autocomplete.ts} +0 -0
- /package/extensions/{shell → layouts/context/message}/user-message-osc.ts +0 -0
- /package/extensions/{transcript → layouts/context}/renderer/markdown-enhance.ts +0 -0
- /package/extensions/{transcript → layouts/context}/renderer/tool/diff/ATTRIBUTION.md +0 -0
- /package/extensions/{transcript → layouts/context}/renderer/tool/diff/ansi-utils.ts +0 -0
- /package/extensions/{transcript → layouts/context}/renderer/tool/diff/diff-header.ts +0 -0
- /package/extensions/{transcript → layouts/context}/renderer/tool/diff/diff-inline.ts +0 -0
- /package/extensions/{transcript → layouts/context}/renderer/tool/diff/diff-parse.ts +0 -0
- /package/extensions/{transcript → layouts/context}/renderer/tool/diff/diff-text.ts +0 -0
- /package/extensions/{transcript → layouts/context}/renderer/tool/diff/line-width-safety.ts +0 -0
- /package/extensions/{transcript → layouts/context}/renderer/tool/diff/shiki-highlight.ts +0 -0
- /package/extensions/{transcript → layouts/context}/renderer/tool/diff/write-execution.ts +0 -0
- /package/extensions/{transcript → layouts/context}/renderer/tool/show-more-hint.ts +0 -0
- /package/extensions/{features/agent-summary → layouts/context/summary}/index.ts +0 -0
- /package/extensions/{shell → layouts/editor}/accent-rail-layout-patch.ts +0 -0
- /package/extensions/{shell → layouts/editor}/editor-transfer.ts +0 -0
- /package/extensions/{shell → layouts/footer}/footer-format.ts +0 -0
- /package/extensions/{shell → layouts/working-line}/working-line-messages.ts +0 -0
- /package/extensions/{shell/git.ts → services/git-data.ts} +0 -0
- /package/extensions/{shell/package-version.ts → services/package-data.ts} +0 -0
- /package/extensions/{shell/runtime.ts → services/runtime-data.ts} +0 -0
- /package/extensions/{shell → services}/telemetry.ts +0 -0
- /package/extensions/{shell → shared}/icons.ts +0 -0
- /package/extensions/{shell → shared}/style.ts +0 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
fchmodSync,
|
|
5
|
+
fsyncSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
statSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { basename, dirname, join } from "node:path";
|
|
16
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
export type ConfigRecord = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
export type ConfigStorePaths = {
|
|
21
|
+
canonical: string;
|
|
22
|
+
legacyUnified: string;
|
|
23
|
+
legacyShell: string;
|
|
24
|
+
legacyRenderer: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns canonical and legacy configuration locations for an agent directory.
|
|
29
|
+
*/
|
|
30
|
+
export function configPaths(agentDir = getAgentDir()): ConfigStorePaths {
|
|
31
|
+
return {
|
|
32
|
+
canonical: join(agentDir, "pi-one-ui.json"),
|
|
33
|
+
legacyUnified: join(agentDir, "pi-mine-ui.json"),
|
|
34
|
+
legacyShell: join(agentDir, "zentui.json"),
|
|
35
|
+
legacyRenderer: join(agentDir, "claude-code-style.json"),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const defaultConfigPaths = configPaths();
|
|
40
|
+
export const configPath = defaultConfigPaths.canonical;
|
|
41
|
+
|
|
42
|
+
export type ConfigFileState =
|
|
43
|
+
| { kind: "missing"; record: ConfigRecord; writePath: string }
|
|
44
|
+
| { kind: "valid"; record: ConfigRecord; writePath: string; mode: number }
|
|
45
|
+
| { kind: "corrupt"; error: unknown };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extracts a Node filesystem error code without depending on an error class.
|
|
49
|
+
*/
|
|
50
|
+
function errorCode(error: unknown): string | undefined {
|
|
51
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
52
|
+
? String(error.code)
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reads one config file, resolving symlink targets without replacing the link.
|
|
58
|
+
*/
|
|
59
|
+
export function readConfigFileState(path: string): ConfigFileState {
|
|
60
|
+
let writePath = path;
|
|
61
|
+
try {
|
|
62
|
+
const pathStat = lstatSync(path);
|
|
63
|
+
if (pathStat.isSymbolicLink()) writePath = realpathSync(path);
|
|
64
|
+
const targetStat = statSync(writePath);
|
|
65
|
+
const parsed = JSON.parse(readFileSync(writePath, "utf8"));
|
|
66
|
+
return parsed !== null &&
|
|
67
|
+
typeof parsed === "object" &&
|
|
68
|
+
!Array.isArray(parsed)
|
|
69
|
+
? {
|
|
70
|
+
kind: "valid",
|
|
71
|
+
record: parsed as ConfigRecord,
|
|
72
|
+
writePath,
|
|
73
|
+
mode: targetStat.mode & 0o7777,
|
|
74
|
+
}
|
|
75
|
+
: {
|
|
76
|
+
kind: "corrupt",
|
|
77
|
+
error: new Error("top-level value must be a JSON object"),
|
|
78
|
+
};
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (errorCode(error) === "ENOENT") {
|
|
81
|
+
try {
|
|
82
|
+
lstatSync(path);
|
|
83
|
+
} catch (pathError) {
|
|
84
|
+
if (errorCode(pathError) === "ENOENT")
|
|
85
|
+
return { kind: "missing", record: {}, writePath: path };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { kind: "corrupt", error };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Writes JSON through a temporary file and atomic rename.
|
|
94
|
+
*/
|
|
95
|
+
export function writeConfigAtomically(
|
|
96
|
+
path: string,
|
|
97
|
+
record: ConfigRecord,
|
|
98
|
+
mode?: number,
|
|
99
|
+
): void {
|
|
100
|
+
const tempPath = join(
|
|
101
|
+
dirname(path),
|
|
102
|
+
`.${basename(path)}.${process.pid}.${randomUUID()}.tmp`,
|
|
103
|
+
);
|
|
104
|
+
let file: number | undefined;
|
|
105
|
+
try {
|
|
106
|
+
file = openSync(tempPath, "wx", mode ?? 0o666);
|
|
107
|
+
if (mode !== undefined) fchmodSync(file, mode);
|
|
108
|
+
writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
109
|
+
fsyncSync(file);
|
|
110
|
+
closeSync(file);
|
|
111
|
+
file = undefined;
|
|
112
|
+
renameSync(tempPath, path);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (file !== undefined) {
|
|
115
|
+
try {
|
|
116
|
+
closeSync(file);
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
unlinkSync(tempPath);
|
|
121
|
+
} catch (cleanupError) {
|
|
122
|
+
if (errorCode(cleanupError) !== "ENOENT") {
|
|
123
|
+
// Preserve the persistence failure; the best-effort cleanup error is secondary.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Builds a stable read error while preserving the underlying failure detail.
|
|
132
|
+
*/
|
|
133
|
+
function configReadError(path: string, error: unknown): Error {
|
|
134
|
+
const detail = error instanceof Error ? ` (${error.message})` : "";
|
|
135
|
+
return new Error(
|
|
136
|
+
`Unable to read pi-one-ui config because ${path} is corrupt or unreadable${detail}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Builds the compatibility error used when a config write is unsafe.
|
|
142
|
+
*/
|
|
143
|
+
function configWriteError(path: string, label: string, error: unknown): Error {
|
|
144
|
+
const detail = error instanceof Error ? ` (${error.message})` : "";
|
|
145
|
+
return new Error(
|
|
146
|
+
`Refusing to save ${label} config because ${path} is corrupt or unreadable; fix or remove it first.${detail}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type SelectedConfig = {
|
|
151
|
+
record: ConfigRecord;
|
|
152
|
+
writePath: string;
|
|
153
|
+
mode?: number;
|
|
154
|
+
materialize: boolean;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Reads an optional config source and turns corruption into an explicit error.
|
|
159
|
+
*/
|
|
160
|
+
function optionalConfig(
|
|
161
|
+
path: string,
|
|
162
|
+
): { record: ConfigRecord; writePath: string; mode: number } | undefined {
|
|
163
|
+
const state = readConfigFileState(path);
|
|
164
|
+
if (state.kind === "missing") return undefined;
|
|
165
|
+
if (state.kind === "corrupt") throw configReadError(path, state.error);
|
|
166
|
+
return state;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Selects the active source and the destination used by a later update.
|
|
171
|
+
*/
|
|
172
|
+
function selectUnifiedConfig(paths: ConfigStorePaths): SelectedConfig {
|
|
173
|
+
const canonical = optionalConfig(paths.canonical);
|
|
174
|
+
if (canonical) return { ...canonical, materialize: false };
|
|
175
|
+
|
|
176
|
+
const legacyUnified = optionalConfig(paths.legacyUnified);
|
|
177
|
+
if (legacyUnified)
|
|
178
|
+
return {
|
|
179
|
+
...legacyUnified,
|
|
180
|
+
writePath: paths.canonical,
|
|
181
|
+
materialize: true,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const shell = optionalConfig(paths.legacyShell);
|
|
185
|
+
const renderer = optionalConfig(paths.legacyRenderer);
|
|
186
|
+
if (!shell && !renderer) {
|
|
187
|
+
return {
|
|
188
|
+
record: {},
|
|
189
|
+
writePath: paths.canonical,
|
|
190
|
+
materialize: false,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
record: {
|
|
196
|
+
...(shell?.record ?? {}),
|
|
197
|
+
version: 1,
|
|
198
|
+
renderer: renderer?.record ?? {},
|
|
199
|
+
},
|
|
200
|
+
writePath: paths.canonical,
|
|
201
|
+
mode: shell?.mode ?? renderer?.mode,
|
|
202
|
+
materialize: true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read the one raw unified record used by all configuration domains.
|
|
208
|
+
* Legacy sources are best-effort materialized through the same atomic writer;
|
|
209
|
+
* the in-memory record remains usable if the agent directory is read-only.
|
|
210
|
+
*/
|
|
211
|
+
export function readUnifiedConfigRecord(
|
|
212
|
+
paths: ConfigStorePaths = defaultConfigPaths,
|
|
213
|
+
): ConfigRecord {
|
|
214
|
+
const selected = selectUnifiedConfig(paths);
|
|
215
|
+
if (selected.materialize) {
|
|
216
|
+
try {
|
|
217
|
+
writeConfigAtomically(selected.writePath, selected.record, selected.mode);
|
|
218
|
+
} catch {
|
|
219
|
+
// Keep using the selected legacy record in memory when migration cannot be written.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return selected.record;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Mutates one explicitly selected file while preserving its safety semantics.
|
|
227
|
+
*/
|
|
228
|
+
export function mutateConfigFile(
|
|
229
|
+
path: string,
|
|
230
|
+
mutate: (record: ConfigRecord) => void,
|
|
231
|
+
label = "pi-one-ui",
|
|
232
|
+
): ConfigRecord {
|
|
233
|
+
const state = readConfigFileState(path);
|
|
234
|
+
if (state.kind === "corrupt")
|
|
235
|
+
throw configWriteError(path, label, state.error);
|
|
236
|
+
|
|
237
|
+
mutate(state.record);
|
|
238
|
+
writeConfigAtomically(
|
|
239
|
+
state.writePath,
|
|
240
|
+
state.record,
|
|
241
|
+
state.kind === "valid" ? state.mode : undefined,
|
|
242
|
+
);
|
|
243
|
+
return state.record;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export type ConfigStoreListener = (record: ConfigRecord) => void;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Process-wide raw configuration store. Domain config modules own parsing and
|
|
250
|
+
* selectors; this module owns source selection, migration and persistence.
|
|
251
|
+
*/
|
|
252
|
+
export class ConfigStore {
|
|
253
|
+
readonly paths: ConfigStorePaths;
|
|
254
|
+
private readonly listeners = new Set<ConfigStoreListener>();
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Creates a store for canonical and legacy paths.
|
|
258
|
+
*/
|
|
259
|
+
constructor(paths: ConfigStorePaths = defaultConfigPaths) {
|
|
260
|
+
this.paths = paths;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Reads the current canonical or legacy-backed raw configuration record.
|
|
265
|
+
*/
|
|
266
|
+
read(): ConfigRecord {
|
|
267
|
+
return readUnifiedConfigRecord(this.paths);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Mutates and atomically persists the active raw configuration record.
|
|
272
|
+
*/
|
|
273
|
+
update(mutate: (record: ConfigRecord) => void): ConfigRecord {
|
|
274
|
+
const selected = selectUnifiedConfig(this.paths);
|
|
275
|
+
mutate(selected.record);
|
|
276
|
+
writeConfigAtomically(selected.writePath, selected.record, selected.mode);
|
|
277
|
+
for (const listener of this.listeners) listener(selected.record);
|
|
278
|
+
return selected.record;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Subscribes to successful updates and returns an idempotent unsubscribe function.
|
|
283
|
+
*/
|
|
284
|
+
subscribe(listener: ConfigStoreListener): () => void {
|
|
285
|
+
this.listeners.add(listener);
|
|
286
|
+
return () => this.listeners.delete(listener);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export const configStore = new ConfigStore();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type PiExtensionPort = Pick<
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
| "appendEntry"
|
|
6
|
+
| "on"
|
|
7
|
+
| "registerCommand"
|
|
8
|
+
| "registerEntryRenderer"
|
|
9
|
+
| "registerMarkdownTransformer"
|
|
10
|
+
| "registerMessageRenderer"
|
|
11
|
+
>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Binds the host extension API once so modules do not pass the raw object around.
|
|
15
|
+
*/
|
|
16
|
+
export function createPiExtensionPort(pi: ExtensionAPI): PiExtensionPort {
|
|
17
|
+
return {
|
|
18
|
+
appendEntry: pi.appendEntry.bind(pi),
|
|
19
|
+
on: pi.on.bind(pi) as PiExtensionPort["on"],
|
|
20
|
+
registerCommand: pi.registerCommand.bind(pi),
|
|
21
|
+
registerEntryRenderer: pi.registerEntryRenderer.bind(pi),
|
|
22
|
+
registerMarkdownTransformer: pi.registerMarkdownTransformer.bind(pi),
|
|
23
|
+
registerMessageRenderer: pi.registerMessageRenderer.bind(pi),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
type PiUi = ExtensionContext["ui"];
|
|
4
|
+
|
|
5
|
+
export type PiUiPort = Pick<
|
|
6
|
+
PiUi,
|
|
7
|
+
| "custom"
|
|
8
|
+
| "getEditorComponent"
|
|
9
|
+
| "getToolsExpanded"
|
|
10
|
+
| "notify"
|
|
11
|
+
| "onTerminalInput"
|
|
12
|
+
| "setEditorComponent"
|
|
13
|
+
| "setFooter"
|
|
14
|
+
| "setHeader"
|
|
15
|
+
| "setStatus"
|
|
16
|
+
| "setToolsExpanded"
|
|
17
|
+
| "setWidget"
|
|
18
|
+
| "setWorkingIndicator"
|
|
19
|
+
| "setWorkingMessage"
|
|
20
|
+
| "setWorkingVisible"
|
|
21
|
+
> & {
|
|
22
|
+
readonly theme: PiUi["theme"];
|
|
23
|
+
requestRender(force?: boolean): void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Adapts a session UI context to the narrow port consumed by layouts.
|
|
28
|
+
*/
|
|
29
|
+
export function createPiUiPort(
|
|
30
|
+
ctx: ExtensionContext,
|
|
31
|
+
requestRender: (force?: boolean) => void = () => {},
|
|
32
|
+
): PiUiPort {
|
|
33
|
+
const ui = ctx.ui;
|
|
34
|
+
return {
|
|
35
|
+
custom: (...args) => ui.custom(...args),
|
|
36
|
+
getEditorComponent: () => ui.getEditorComponent(),
|
|
37
|
+
getToolsExpanded: () => ui.getToolsExpanded(),
|
|
38
|
+
notify: (...args) => ui.notify(...args),
|
|
39
|
+
onTerminalInput: (handler) => ui.onTerminalInput(handler),
|
|
40
|
+
setEditorComponent: (factory) => ui.setEditorComponent(factory),
|
|
41
|
+
setFooter: (factory) => ui.setFooter(factory),
|
|
42
|
+
setHeader: (factory) => ui.setHeader(factory),
|
|
43
|
+
setStatus: (key, text) => ui.setStatus(key, text),
|
|
44
|
+
setToolsExpanded: (expanded) => ui.setToolsExpanded(expanded),
|
|
45
|
+
setWidget: ui.setWidget.bind(ui) as PiUiPort["setWidget"],
|
|
46
|
+
setWorkingIndicator: (options) => ui.setWorkingIndicator(options),
|
|
47
|
+
setWorkingMessage: (message) => ui.setWorkingMessage(message),
|
|
48
|
+
setWorkingVisible: (visible) => ui.setWorkingVisible(visible),
|
|
49
|
+
theme: ui.theme,
|
|
50
|
+
requestRender,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type TuiCapabilities = Readonly<{
|
|
4
|
+
interactive: boolean;
|
|
5
|
+
customUi: boolean;
|
|
6
|
+
editorFactory: boolean;
|
|
7
|
+
footer: boolean;
|
|
8
|
+
header: boolean;
|
|
9
|
+
workingIndicator: boolean;
|
|
10
|
+
overlays: boolean;
|
|
11
|
+
}>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Detects which interactive UI seams are available in the current host context.
|
|
15
|
+
*/
|
|
16
|
+
export function detectTuiCapabilities(ctx: ExtensionContext): TuiCapabilities {
|
|
17
|
+
const ui = ctx.ui as Partial<ExtensionContext["ui"]>;
|
|
18
|
+
return {
|
|
19
|
+
interactive: ctx.mode === "tui" && ctx.hasUI,
|
|
20
|
+
customUi: typeof ui.custom === "function",
|
|
21
|
+
editorFactory: typeof ui.setEditorComponent === "function",
|
|
22
|
+
footer: typeof ui.setFooter === "function",
|
|
23
|
+
header: typeof ui.setHeader === "function",
|
|
24
|
+
workingIndicator: typeof ui.setWorkingIndicator === "function",
|
|
25
|
+
overlays: typeof ui.custom === "function",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type InputRouteResult = { consume?: boolean; data?: string } | undefined;
|
|
2
|
+
export type InputRoute = (data: string) => InputRouteResult;
|
|
3
|
+
|
|
4
|
+
type RegisteredRoute = {
|
|
5
|
+
priority: number;
|
|
6
|
+
order: number;
|
|
7
|
+
route: InputRoute;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Routes raw terminal input by descending priority and stops at a consumer.
|
|
12
|
+
*/
|
|
13
|
+
export class InputRouter {
|
|
14
|
+
private nextOrder = 0;
|
|
15
|
+
private readonly routes: RegisteredRoute[] = [];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Registers an input route.
|
|
19
|
+
*
|
|
20
|
+
* @param route Handler that may consume one input packet.
|
|
21
|
+
* @param priority Higher-priority routes receive input first.
|
|
22
|
+
* @returns A function that removes the route.
|
|
23
|
+
*/
|
|
24
|
+
register(route: InputRoute, priority = 0): () => void {
|
|
25
|
+
const entry: RegisteredRoute = {
|
|
26
|
+
priority,
|
|
27
|
+
order: this.nextOrder++,
|
|
28
|
+
route,
|
|
29
|
+
};
|
|
30
|
+
this.routes.push(entry);
|
|
31
|
+
this.routes.sort(
|
|
32
|
+
(left, right) =>
|
|
33
|
+
right.priority - left.priority || left.order - right.order,
|
|
34
|
+
);
|
|
35
|
+
return () => {
|
|
36
|
+
const index = this.routes.indexOf(entry);
|
|
37
|
+
if (index >= 0) this.routes.splice(index, 1);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Routes one input packet until a registered handler consumes it.
|
|
43
|
+
*
|
|
44
|
+
* @param data Raw terminal input packet.
|
|
45
|
+
* @returns The consuming route result, if any.
|
|
46
|
+
*/
|
|
47
|
+
dispatch(data: string): InputRouteResult {
|
|
48
|
+
for (const entry of [...this.routes]) {
|
|
49
|
+
const result = entry.route(data);
|
|
50
|
+
if (result?.consume) return result;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Removes all registered routes, normally during runtime teardown.
|
|
57
|
+
*/
|
|
58
|
+
clear(): void {
|
|
59
|
+
this.routes.length = 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const inputRouter = new InputRouter();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type OverlayTask<T> = () => Promise<T>;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tracks plugin-owned overlay activity across settings and context dialogs.
|
|
5
|
+
*/
|
|
6
|
+
export class OverlayManager {
|
|
7
|
+
private activeCount = 0;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runs one asynchronous overlay task and balances its active count.
|
|
11
|
+
*
|
|
12
|
+
* @param task Function that opens and waits for the overlay.
|
|
13
|
+
* @returns The value produced when the overlay closes.
|
|
14
|
+
*/
|
|
15
|
+
async run<T>(task: OverlayTask<T>): Promise<T> {
|
|
16
|
+
this.activeCount += 1;
|
|
17
|
+
try {
|
|
18
|
+
return await task();
|
|
19
|
+
} finally {
|
|
20
|
+
this.activeCount = Math.max(0, this.activeCount - 1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reports whether at least one plugin-owned overlay is active.
|
|
26
|
+
*/
|
|
27
|
+
hasActive(): boolean {
|
|
28
|
+
return this.activeCount > 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns the current plugin-owned overlay depth.
|
|
33
|
+
*/
|
|
34
|
+
depth(): number {
|
|
35
|
+
return this.activeCount;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const overlayManager = new OverlayManager();
|
|
@@ -3,16 +3,16 @@ import {
|
|
|
3
3
|
SettingsSelectorComponent,
|
|
4
4
|
type Theme,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import type { ZentuiConfig } from "../
|
|
6
|
+
import type { ZentuiConfig } from "../config/shell.ts";
|
|
7
7
|
import {
|
|
8
8
|
installPrototypePatch,
|
|
9
9
|
removePrototypePatch,
|
|
10
|
-
} from "
|
|
10
|
+
} from "../ownership/prototype-patch-registry.ts";
|
|
11
11
|
import {
|
|
12
12
|
EDITOR_BORDER_STYLE,
|
|
13
13
|
renderChromeBorder,
|
|
14
14
|
renderEditorBorder,
|
|
15
|
-
} from "
|
|
15
|
+
} from "../../shared/style.ts";
|
|
16
16
|
|
|
17
17
|
type PatchableSelectorPrototype = {
|
|
18
18
|
render: (width: number) => string[];
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
hasUnsupportedComponentStyle,
|
|
4
|
+
type PolishedTuiConfig,
|
|
5
|
+
} from "../config/shell.ts";
|
|
6
|
+
import { installSelectorBorderStyle } from "./selector-border.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Provides the shared configuration and host state consumed by selector styling.
|
|
10
|
+
*/
|
|
11
|
+
export type SelectorControllerContext = {
|
|
12
|
+
readonly getConfig: () => PolishedTuiConfig;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Owns selector border patch installation and restoration for the Overlay layer.
|
|
17
|
+
*/
|
|
18
|
+
export class SelectorController {
|
|
19
|
+
private readonly context: SelectorControllerContext;
|
|
20
|
+
private activeTheme: Theme | undefined;
|
|
21
|
+
private cleanupPatch: () => void = () => {};
|
|
22
|
+
private installed = false;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates a selector controller backed by the canonical configuration.
|
|
26
|
+
*
|
|
27
|
+
* @param context Configuration selector used by the patch.
|
|
28
|
+
*/
|
|
29
|
+
constructor(context: SelectorControllerContext) {
|
|
30
|
+
this.context = context;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Starts selector ownership for an interactive TUI session.
|
|
35
|
+
*
|
|
36
|
+
* @param ctx Active Pi extension context.
|
|
37
|
+
*/
|
|
38
|
+
startSession(ctx: ExtensionContext): void {
|
|
39
|
+
if (!this.isTuiContext(ctx)) return;
|
|
40
|
+
this.activeTheme = ctx.ui.theme;
|
|
41
|
+
this.reconcile();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reconciles selector border ownership with the latest configuration.
|
|
46
|
+
*/
|
|
47
|
+
reconcile(): void {
|
|
48
|
+
const config = this.context.getConfig();
|
|
49
|
+
const enabled =
|
|
50
|
+
config.components.selectorBorders.enabled &&
|
|
51
|
+
config.components.selectorBorders.style === "zentui" &&
|
|
52
|
+
!hasUnsupportedComponentStyle(config, "selectorBorders");
|
|
53
|
+
if (enabled) this.install();
|
|
54
|
+
else this.uninstall();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Restores selector prototypes and clears session-local state.
|
|
59
|
+
*/
|
|
60
|
+
cleanup(): void {
|
|
61
|
+
this.uninstall();
|
|
62
|
+
this.activeTheme = undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Installs the selector prototype patch once.
|
|
67
|
+
*/
|
|
68
|
+
private install(): void {
|
|
69
|
+
if (this.installed || !this.activeTheme) return;
|
|
70
|
+
try {
|
|
71
|
+
this.cleanupPatch = installSelectorBorderStyle(
|
|
72
|
+
() => this.activeTheme,
|
|
73
|
+
this.context.getConfig,
|
|
74
|
+
);
|
|
75
|
+
this.installed = true;
|
|
76
|
+
} catch {
|
|
77
|
+
this.cleanupPatch = () => {};
|
|
78
|
+
this.installed = false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Removes the selector prototype patch when owned by this controller.
|
|
84
|
+
*/
|
|
85
|
+
private uninstall(): void {
|
|
86
|
+
try {
|
|
87
|
+
this.cleanupPatch();
|
|
88
|
+
} finally {
|
|
89
|
+
this.cleanupPatch = () => {};
|
|
90
|
+
this.installed = false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reports whether a context exposes an interactive TUI.
|
|
96
|
+
*
|
|
97
|
+
* @param ctx Candidate Pi extension context.
|
|
98
|
+
* @returns Whether selector styling may be installed.
|
|
99
|
+
*/
|
|
100
|
+
private isTuiContext(ctx: ExtensionContext): boolean {
|
|
101
|
+
const mode = (ctx as ExtensionContext & { mode?: string }).mode;
|
|
102
|
+
return ctx.hasUI && (mode === undefined || mode === "tui");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export const LAYOUT_OWNERSHIP = {
|
|
2
|
+
header: "header",
|
|
3
|
+
context: "context",
|
|
4
|
+
workingLine: "working-line",
|
|
5
|
+
editor: "editor",
|
|
6
|
+
footer: "footer",
|
|
7
|
+
overlay: "overlay",
|
|
8
|
+
input: "overlay",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type LayoutId = keyof typeof LAYOUT_OWNERSHIP;
|
|
12
|
+
export type LayoutOwner = (typeof LAYOUT_OWNERSHIP)[LayoutId];
|
|
13
|
+
|
|
14
|
+
type Claim = {
|
|
15
|
+
owner: LayoutOwner;
|
|
16
|
+
token: object;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Tracks ownership of host UI seams. A layout can be claimed once and its
|
|
21
|
+
* release callback is safe to call repeatedly or after another owner wins.
|
|
22
|
+
*/
|
|
23
|
+
export class LayoutRegistry {
|
|
24
|
+
private readonly claims = new Map<LayoutId, Claim>();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Claims one layout for a runtime token.
|
|
28
|
+
*
|
|
29
|
+
* @returns A release function that only removes this token's claim.
|
|
30
|
+
*/
|
|
31
|
+
claim(layout: LayoutId, token: object): () => void {
|
|
32
|
+
const owner = LAYOUT_OWNERSHIP[layout];
|
|
33
|
+
const current = this.claims.get(layout);
|
|
34
|
+
if (current && current.token !== token) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`UI layout ${layout} is already owned by ${current.owner}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
this.claims.set(layout, { owner, token });
|
|
40
|
+
return () => {
|
|
41
|
+
if (this.claims.get(layout)?.token === token) this.claims.delete(layout);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns the canonical owner assigned to a claimed layout.
|
|
47
|
+
*/
|
|
48
|
+
ownerOf(layout: LayoutId): LayoutOwner | undefined {
|
|
49
|
+
return this.claims.get(layout)?.owner;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reports whether a runtime token currently claims the layout.
|
|
54
|
+
*/
|
|
55
|
+
isClaimed(layout: LayoutId): boolean {
|
|
56
|
+
return this.claims.has(layout);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Releases all claims, normally during runtime disposal.
|
|
61
|
+
*/
|
|
62
|
+
clear(): void {
|
|
63
|
+
this.claims.clear();
|
|
64
|
+
}
|
|
65
|
+
}
|