yoyo-pi 0.1.4
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/LICENSE +21 -0
- package/README.md +91 -0
- package/README.zh-CN.md +91 -0
- package/docs/previews/agent-status.svg +83 -0
- package/docs/previews/filetree.png +0 -0
- package/docs/previews/multiple-choice.png +0 -0
- package/docs/previews/pi-tui-agent-status.html +379 -0
- package/docs/previews/pi-tui-status-bar.html +481 -0
- package/docs/previews/single-choice.png +0 -0
- package/docs/previews/status-bar.png +0 -0
- package/docs/previews/todo-sidebar.png +0 -0
- package/extensions/choice-picker.ts +1404 -0
- package/extensions/clear-context.ts +164 -0
- package/extensions/gr0k-hack/index.ts +1231 -0
- package/extensions/kenx-infra/index.ts +1601 -0
- package/extensions/kenx-infra/themes/dark.json +80 -0
- package/extensions/kenx-infra/themes/light.json +80 -0
- package/extensions/kenx-infra/themes/paper.json +80 -0
- package/extensions/plan-mode/README.md +58 -0
- package/extensions/plan-mode/agents/plan-agent.md +98 -0
- package/extensions/plan-mode/index.ts +1956 -0
- package/extensions/plan-mode/sandbox.ts +332 -0
- package/extensions/vim-mode.ts +561 -0
- package/package.json +58 -0
|
@@ -0,0 +1,1601 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import {
|
|
6
|
+
CustomEditor,
|
|
7
|
+
getAgentDir,
|
|
8
|
+
Theme,
|
|
9
|
+
type ExtensionAPI,
|
|
10
|
+
type ExtensionCommandContext,
|
|
11
|
+
type ExtensionContext,
|
|
12
|
+
type KeybindingsManager,
|
|
13
|
+
type ThemeColor,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { EditorComponent, EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
16
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
17
|
+
|
|
18
|
+
const baseDir = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const themesDir = join(baseDir, "themes");
|
|
20
|
+
const stateDir = join(getAgentDir(), "state");
|
|
21
|
+
const persistedStatePath = join(stateDir, "kenx-infra.json");
|
|
22
|
+
const themeNames = ["paper", "light", "dark"] as const;
|
|
23
|
+
type KenxThemeName = (typeof themeNames)[number];
|
|
24
|
+
type ColorMode = "truecolor" | "256color";
|
|
25
|
+
type ThemeColorValue = string | number;
|
|
26
|
+
type ThemeBgKey = "selectedBg" | "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
|
|
27
|
+
type KenxThemeJson = {
|
|
28
|
+
name?: string;
|
|
29
|
+
vars?: Record<string, ThemeColorValue>;
|
|
30
|
+
colors?: Record<string, ThemeColorValue>;
|
|
31
|
+
};
|
|
32
|
+
type ThemeBgRuntimeState = {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
ansi: string;
|
|
35
|
+
tui?: TUI;
|
|
36
|
+
};
|
|
37
|
+
type ThemeBgPatch = {
|
|
38
|
+
originalRender: TUI["render"];
|
|
39
|
+
wrapper: TUI["render"];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type FileTreeResult =
|
|
43
|
+
| { type: "select"; path: string }
|
|
44
|
+
| { type: "cancel" };
|
|
45
|
+
|
|
46
|
+
type FileTreeEntry = {
|
|
47
|
+
name: string;
|
|
48
|
+
fullPath: string;
|
|
49
|
+
kind: "file" | "dir" | "parent" | "symlink" | "other";
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type KenxSidebarBridge = {
|
|
53
|
+
isFileTreeOpen(): boolean;
|
|
54
|
+
closeFileTree(): boolean;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
let closeFileTree: (() => void) | null = null;
|
|
58
|
+
let installedKenxSidebarBridge: KenxSidebarBridge | undefined;
|
|
59
|
+
let installedKenxStatusbarBridge: KenxStatusbarBridge | undefined;
|
|
60
|
+
|
|
61
|
+
const THEME_BG_WIDGET_KEY = "kenx-theme-bg-capture";
|
|
62
|
+
const THEME_BG_RUNTIME_STATE_KEY = Symbol.for("yoyo-pi:kenx-theme-bg-runtime-state");
|
|
63
|
+
const THEME_BG_PATCH_KEY = Symbol.for("yoyo-pi:kenx-theme-bg-patch");
|
|
64
|
+
|
|
65
|
+
const statusbarVariantNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const;
|
|
66
|
+
type StatusbarVariant = (typeof statusbarVariantNumbers)[number];
|
|
67
|
+
type EditorFactory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => EditorComponent;
|
|
68
|
+
type VimModeBridge = {
|
|
69
|
+
isEnabled(): boolean;
|
|
70
|
+
refreshStatus(ctx: ExtensionContext): void;
|
|
71
|
+
wrapEditorFactory(baseFactory: EditorFactory | undefined, ctx: ExtensionContext): EditorFactory;
|
|
72
|
+
};
|
|
73
|
+
type KenxStatusbarBridge = {
|
|
74
|
+
isEnabled(): boolean;
|
|
75
|
+
getEditorFactory(): EditorFactory | undefined;
|
|
76
|
+
setStatus(key: string, text: string | undefined): void;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type GitStatusSummary = {
|
|
80
|
+
isRepo: boolean;
|
|
81
|
+
branch?: string;
|
|
82
|
+
modified: number;
|
|
83
|
+
untracked: number;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type StatusbarState = {
|
|
87
|
+
variant?: StatusbarVariant;
|
|
88
|
+
cwd: string;
|
|
89
|
+
modelId: string;
|
|
90
|
+
modelProvider?: string;
|
|
91
|
+
thinkingLevel: string;
|
|
92
|
+
git: GitStatusSummary;
|
|
93
|
+
theme?: Theme;
|
|
94
|
+
footerBranch?: string;
|
|
95
|
+
extensionStatuses: string[];
|
|
96
|
+
requestRender?: () => void;
|
|
97
|
+
gitRequestId: number;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type PersistedKenxState = {
|
|
101
|
+
theme?: KenxThemeName;
|
|
102
|
+
themeBg?: boolean;
|
|
103
|
+
statusbarVariant?: StatusbarVariant | null;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const defaultGitStatus: GitStatusSummary = {
|
|
107
|
+
isRepo: false,
|
|
108
|
+
modified: 0,
|
|
109
|
+
untracked: 0,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const VIM_BRIDGE_KEY = Symbol.for("yoyo-pi.vim-mode.bridge");
|
|
113
|
+
const KENX_SIDEBAR_BRIDGE_KEY = Symbol.for("yoyo-pi.kenx-infra.sidebar");
|
|
114
|
+
const KENX_STATUSBAR_BRIDGE_KEY = Symbol.for("yoyo-pi.kenx-statusbar.bridge");
|
|
115
|
+
|
|
116
|
+
const statusbarVariantLabels: Record<StatusbarVariant, string> = {
|
|
117
|
+
1: "hairline rules",
|
|
118
|
+
2: "rule-embedded chips",
|
|
119
|
+
3: "status above input",
|
|
120
|
+
4: "label pills",
|
|
121
|
+
5: "corner tabs",
|
|
122
|
+
6: "margin labels",
|
|
123
|
+
7: "single-line dense",
|
|
124
|
+
8: "badges row",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const statusbarState: StatusbarState = {
|
|
128
|
+
cwd: process.cwd(),
|
|
129
|
+
modelId: "no-model",
|
|
130
|
+
thinkingLevel: "off",
|
|
131
|
+
git: { ...defaultGitStatus },
|
|
132
|
+
extensionStatuses: [],
|
|
133
|
+
gitRequestId: 0,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
let persistedKenxState = loadPersistedKenxState();
|
|
137
|
+
let activeKenxThemeName: KenxThemeName | undefined = persistedKenxState.theme;
|
|
138
|
+
|
|
139
|
+
const fgColorKeys = [
|
|
140
|
+
"accent",
|
|
141
|
+
"border",
|
|
142
|
+
"borderAccent",
|
|
143
|
+
"borderMuted",
|
|
144
|
+
"success",
|
|
145
|
+
"error",
|
|
146
|
+
"warning",
|
|
147
|
+
"muted",
|
|
148
|
+
"dim",
|
|
149
|
+
"text",
|
|
150
|
+
"thinkingText",
|
|
151
|
+
"userMessageText",
|
|
152
|
+
"customMessageText",
|
|
153
|
+
"customMessageLabel",
|
|
154
|
+
"toolTitle",
|
|
155
|
+
"toolOutput",
|
|
156
|
+
"mdHeading",
|
|
157
|
+
"mdLink",
|
|
158
|
+
"mdLinkUrl",
|
|
159
|
+
"mdCode",
|
|
160
|
+
"mdCodeBlock",
|
|
161
|
+
"mdCodeBlockBorder",
|
|
162
|
+
"mdQuote",
|
|
163
|
+
"mdQuoteBorder",
|
|
164
|
+
"mdHr",
|
|
165
|
+
"mdListBullet",
|
|
166
|
+
"toolDiffAdded",
|
|
167
|
+
"toolDiffRemoved",
|
|
168
|
+
"toolDiffContext",
|
|
169
|
+
"syntaxComment",
|
|
170
|
+
"syntaxKeyword",
|
|
171
|
+
"syntaxFunction",
|
|
172
|
+
"syntaxVariable",
|
|
173
|
+
"syntaxString",
|
|
174
|
+
"syntaxNumber",
|
|
175
|
+
"syntaxType",
|
|
176
|
+
"syntaxOperator",
|
|
177
|
+
"syntaxPunctuation",
|
|
178
|
+
"thinkingOff",
|
|
179
|
+
"thinkingMinimal",
|
|
180
|
+
"thinkingLow",
|
|
181
|
+
"thinkingMedium",
|
|
182
|
+
"thinkingHigh",
|
|
183
|
+
"thinkingXhigh",
|
|
184
|
+
"bashMode",
|
|
185
|
+
] as const satisfies readonly ThemeColor[];
|
|
186
|
+
|
|
187
|
+
const bgColorKeys = [
|
|
188
|
+
"selectedBg",
|
|
189
|
+
"userMessageBg",
|
|
190
|
+
"customMessageBg",
|
|
191
|
+
"toolPendingBg",
|
|
192
|
+
"toolSuccessBg",
|
|
193
|
+
"toolErrorBg",
|
|
194
|
+
] as const satisfies readonly ThemeBgKey[];
|
|
195
|
+
|
|
196
|
+
export default function (pi: ExtensionAPI) {
|
|
197
|
+
installKenxSidebarBridge();
|
|
198
|
+
installKenxStatusbarBridge();
|
|
199
|
+
|
|
200
|
+
pi.on("resources_discover", () => ({
|
|
201
|
+
themePaths: [themesDir],
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
205
|
+
applyPersistedTheme(ctx);
|
|
206
|
+
syncThemeBg(ctx);
|
|
207
|
+
syncStatusbarContext(ctx, pi);
|
|
208
|
+
const restoredVariant = getPersistedStatusbarVariant(ctx);
|
|
209
|
+
statusbarState.variant = restoredVariant;
|
|
210
|
+
if (restoredVariant) {
|
|
211
|
+
applyStatusbarUI(ctx);
|
|
212
|
+
void refreshGitStatus(pi, ctx.cwd);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
pi.on("model_select", (event, ctx) => {
|
|
217
|
+
syncStatusbarContext(ctx, pi);
|
|
218
|
+
statusbarState.modelId = event.model.id;
|
|
219
|
+
statusbarState.modelProvider = event.model.provider;
|
|
220
|
+
requestStatusbarRender();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
pi.on("thinking_level_select", (event) => {
|
|
224
|
+
statusbarState.thinkingLevel = event.level;
|
|
225
|
+
requestStatusbarRender();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
229
|
+
syncStatusbarContext(ctx, pi);
|
|
230
|
+
if (statusbarState.variant) void refreshGitStatus(pi, ctx.cwd);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
registerStatusbarCommand(pi, "switch-statusbar");
|
|
234
|
+
|
|
235
|
+
pi.on("session_shutdown", () => {
|
|
236
|
+
closeFileTree?.();
|
|
237
|
+
closeFileTree = null;
|
|
238
|
+
setThemeBgRuntimeEnabled(false);
|
|
239
|
+
uninstallThemeBgPatch(false);
|
|
240
|
+
statusbarState.requestRender = undefined;
|
|
241
|
+
uninstallKenxSidebarBridge();
|
|
242
|
+
uninstallKenxStatusbarBridge();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
pi.registerCommand("theme", {
|
|
246
|
+
description: "Switch kenx-infra theme: paper, light, or dark",
|
|
247
|
+
getArgumentCompletions: (prefix: string) => {
|
|
248
|
+
const p = prefix.trim().toLowerCase();
|
|
249
|
+
const completions = themeNames
|
|
250
|
+
.filter((name) => name.startsWith(p))
|
|
251
|
+
.map((name) => ({ value: name, label: name, description: `kenx-infra ${name} palette` }));
|
|
252
|
+
return completions.length > 0 ? completions : null;
|
|
253
|
+
},
|
|
254
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
255
|
+
const name = args.trim().toLowerCase() as KenxThemeName;
|
|
256
|
+
if (!isKenxThemeName(name)) {
|
|
257
|
+
ctx.ui.notify("Usage: /theme <paper|light|dark>", "warning");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
if (!applyKenxTheme(ctx, name, true)) return;
|
|
263
|
+
savePersistedKenxState({ theme: name });
|
|
264
|
+
ctx.ui.notify(`Theme: ${name} (persisted)`, "info");
|
|
265
|
+
} catch (error) {
|
|
266
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
pi.registerCommand("theme-bg", {
|
|
272
|
+
description: "Toggle kenx-infra full TUI background coverage: true or false",
|
|
273
|
+
getArgumentCompletions: (prefix: string) => {
|
|
274
|
+
const p = prefix.trim().toLowerCase();
|
|
275
|
+
const items = [
|
|
276
|
+
{ value: "true", label: "true", description: "cover the full TUI with the active /theme background" },
|
|
277
|
+
{ value: "false", label: "false", description: "restore pi's normal transparent/default terminal background" },
|
|
278
|
+
{ value: "status", label: "status", description: "show current theme-bg state" },
|
|
279
|
+
];
|
|
280
|
+
const completions = items.filter((item) => item.value.startsWith(p));
|
|
281
|
+
return completions.length > 0 ? completions : null;
|
|
282
|
+
},
|
|
283
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
284
|
+
if (!ctx.hasUI) return;
|
|
285
|
+
|
|
286
|
+
const parsed = parseThemeBgArg(args);
|
|
287
|
+
if (parsed === "status") {
|
|
288
|
+
const enabled = persistedKenxState.themeBg === true;
|
|
289
|
+
ctx.ui.notify(`Theme background: ${enabled ? "true" : "false"}`, "info");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (parsed === undefined) {
|
|
293
|
+
ctx.ui.notify("Usage: /theme-bg <true|false>", "warning");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
setThemeBg(ctx, parsed);
|
|
299
|
+
savePersistedKenxState({ themeBg: parsed });
|
|
300
|
+
ctx.ui.notify(`Theme background: ${parsed ? "true" : "false"} (persisted)`, "info");
|
|
301
|
+
} catch (error) {
|
|
302
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
pi.registerCommand("filetree", {
|
|
308
|
+
description: "Toggle the right-side overlay file tree picker",
|
|
309
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
310
|
+
await toggleFileTree(ctx);
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
pi.registerShortcut("ctrl+shift+f", {
|
|
315
|
+
description: "Toggle the right-side overlay file tree picker",
|
|
316
|
+
handler: toggleFileTree,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function toggleFileTree(ctx: ExtensionContext): Promise<void> {
|
|
321
|
+
if (!ctx.hasUI) return;
|
|
322
|
+
|
|
323
|
+
if (closeFileTree) {
|
|
324
|
+
closeFileTree();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const result = await ctx.ui.custom<FileTreeResult>(
|
|
330
|
+
(tui, theme, _keybindings, done) => {
|
|
331
|
+
let closed = false;
|
|
332
|
+
const finish = (value: FileTreeResult) => {
|
|
333
|
+
if (closed) return;
|
|
334
|
+
closed = true;
|
|
335
|
+
done(value);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
closeFileTree = () => finish({ type: "cancel" });
|
|
339
|
+
|
|
340
|
+
return new FileTreePanel({
|
|
341
|
+
tui,
|
|
342
|
+
theme,
|
|
343
|
+
basePath: resolve(ctx.cwd),
|
|
344
|
+
done: finish,
|
|
345
|
+
});
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
overlay: true,
|
|
349
|
+
overlayOptions: {
|
|
350
|
+
anchor: "right-center",
|
|
351
|
+
width: "28%",
|
|
352
|
+
minWidth: 32,
|
|
353
|
+
maxHeight: "90%",
|
|
354
|
+
margin: { right: 0 },
|
|
355
|
+
},
|
|
356
|
+
onHandle: (handle) => {
|
|
357
|
+
handle.focus();
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
if (result.type === "select") {
|
|
363
|
+
ctx.ui.pasteToEditor(`@${result.path}`);
|
|
364
|
+
}
|
|
365
|
+
} finally {
|
|
366
|
+
closeFileTree = null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function isKenxThemeName(value: unknown): value is KenxThemeName {
|
|
371
|
+
return typeof value === "string" && (themeNames as readonly string[]).includes(value);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function readKenxThemeJson(name: KenxThemeName): { themePath: string; json: KenxThemeJson } {
|
|
375
|
+
const themePath = join(themesDir, `${name}.json`);
|
|
376
|
+
const json = JSON.parse(readFileSync(themePath, "utf8")) as KenxThemeJson;
|
|
377
|
+
return { themePath, json };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function resolveKenxThemeValue(
|
|
381
|
+
name: KenxThemeName,
|
|
382
|
+
value: ThemeColorValue | undefined,
|
|
383
|
+
vars: Record<string, ThemeColorValue>,
|
|
384
|
+
seen = new Set<string>(),
|
|
385
|
+
): ThemeColorValue {
|
|
386
|
+
if (value === undefined) throw new Error(`Theme ${name} is missing a color token`);
|
|
387
|
+
if (typeof value === "number") return value;
|
|
388
|
+
if (value === "" || value.startsWith("#")) return value;
|
|
389
|
+
if (seen.has(value)) throw new Error(`Circular theme variable reference: ${value}`);
|
|
390
|
+
const next = vars[value];
|
|
391
|
+
if (next === undefined) throw new Error(`Unknown theme variable: ${value}`);
|
|
392
|
+
seen.add(value);
|
|
393
|
+
return resolveKenxThemeValue(name, next, vars, seen);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function loadKenxTheme(name: KenxThemeName, mode: ColorMode): Theme {
|
|
397
|
+
const { themePath, json } = readKenxThemeJson(name);
|
|
398
|
+
const vars = json.vars ?? {};
|
|
399
|
+
const colors = json.colors ?? {};
|
|
400
|
+
|
|
401
|
+
const fg = {} as Record<ThemeColor, ThemeColorValue>;
|
|
402
|
+
for (const key of fgColorKeys) fg[key] = resolveKenxThemeValue(name, colors[key], vars);
|
|
403
|
+
|
|
404
|
+
const bg = {} as Record<ThemeBgKey, ThemeColorValue>;
|
|
405
|
+
for (const key of bgColorKeys) bg[key] = resolveKenxThemeValue(name, colors[key], vars);
|
|
406
|
+
|
|
407
|
+
return new Theme(fg, bg, mode, { name: json.name ?? name, sourcePath: themePath });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function loadPersistedKenxState(): PersistedKenxState {
|
|
411
|
+
try {
|
|
412
|
+
const raw = JSON.parse(readFileSync(persistedStatePath, "utf8")) as {
|
|
413
|
+
theme?: unknown;
|
|
414
|
+
themeBg?: unknown;
|
|
415
|
+
statusbarVariant?: unknown;
|
|
416
|
+
};
|
|
417
|
+
const numericVariant =
|
|
418
|
+
typeof raw.statusbarVariant === "string" ? Number.parseInt(raw.statusbarVariant, 10) : raw.statusbarVariant;
|
|
419
|
+
return {
|
|
420
|
+
theme: isKenxThemeName(raw.theme) ? raw.theme : undefined,
|
|
421
|
+
themeBg: typeof raw.themeBg === "boolean" ? raw.themeBg : undefined,
|
|
422
|
+
statusbarVariant:
|
|
423
|
+
raw.statusbarVariant === null ? null : isStatusbarVariant(numericVariant) ? numericVariant : undefined,
|
|
424
|
+
};
|
|
425
|
+
} catch {
|
|
426
|
+
return {};
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function savePersistedKenxState(update: PersistedKenxState): void {
|
|
431
|
+
persistedKenxState = { ...persistedKenxState, ...update };
|
|
432
|
+
mkdirSync(stateDir, { recursive: true });
|
|
433
|
+
writeFileSync(persistedStatePath, `${JSON.stringify(persistedKenxState, null, "\t")}\n`, "utf8");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function applyPersistedTheme(ctx: ExtensionContext): void {
|
|
437
|
+
if (!ctx.hasUI || !persistedKenxState.theme) return;
|
|
438
|
+
applyKenxTheme(ctx, persistedKenxState.theme, false);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function applyKenxTheme(ctx: ExtensionContext, name: KenxThemeName, notifyErrors: boolean): boolean {
|
|
442
|
+
if (!ctx.hasUI) return false;
|
|
443
|
+
try {
|
|
444
|
+
const mode = ctx.ui.theme.getColorMode() as ColorMode;
|
|
445
|
+
const result = ctx.ui.setTheme(loadKenxTheme(name, mode));
|
|
446
|
+
if (!result.success) {
|
|
447
|
+
if (notifyErrors) ctx.ui.notify(`Failed to switch theme: ${result.error ?? "unknown error"}`, "error");
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
activeKenxThemeName = name;
|
|
451
|
+
statusbarState.theme = ctx.ui.theme;
|
|
452
|
+
updateThemeBgAnsi(ctx, name);
|
|
453
|
+
requestStatusbarRender();
|
|
454
|
+
return true;
|
|
455
|
+
} catch (error) {
|
|
456
|
+
if (notifyErrors) ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function parseThemeBgArg(args: string): boolean | "status" | undefined {
|
|
462
|
+
const value = args.trim().toLowerCase();
|
|
463
|
+
if (!value || value === "status") return "status";
|
|
464
|
+
if (["true", "on", "1", "yes", "enable", "enabled"].includes(value)) return true;
|
|
465
|
+
if (["false", "off", "0", "no", "disable", "disabled"].includes(value)) return false;
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function syncThemeBg(ctx: ExtensionContext): void {
|
|
470
|
+
if (persistedKenxState.themeBg === true) {
|
|
471
|
+
try {
|
|
472
|
+
setThemeBg(ctx, true);
|
|
473
|
+
} catch (error) {
|
|
474
|
+
setThemeBgRuntimeEnabled(false);
|
|
475
|
+
if (ctx.hasUI) ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
setThemeBgRuntimeEnabled(false);
|
|
480
|
+
if (ctx.hasUI) ctx.ui.setWidget(THEME_BG_WIDGET_KEY, undefined);
|
|
481
|
+
uninstallThemeBgPatch(false);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function setThemeBg(ctx: ExtensionContext, enabled: boolean): void {
|
|
485
|
+
if (!ctx.hasUI) {
|
|
486
|
+
setThemeBgRuntimeEnabled(enabled);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (!enabled) {
|
|
491
|
+
setThemeBgRuntimeEnabled(false);
|
|
492
|
+
ctx.ui.setWidget(THEME_BG_WIDGET_KEY, undefined);
|
|
493
|
+
uninstallThemeBgPatch(true);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (!updateThemeBgAnsi(ctx)) {
|
|
498
|
+
throw new Error("Use /theme <paper|light|dark> before /theme-bg true");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
setThemeBgRuntimeEnabled(true);
|
|
502
|
+
ctx.ui.setWidget(THEME_BG_WIDGET_KEY, (tui) => new ThemeBgCaptureWidget(tui));
|
|
503
|
+
getThemeBgRuntimeState().tui?.requestRender(true);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
class ThemeBgCaptureWidget {
|
|
507
|
+
constructor(private readonly tui: TUI) {
|
|
508
|
+
installThemeBgPatch(tui);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
render(_width: number): string[] {
|
|
512
|
+
return [];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
invalidate(): void {
|
|
516
|
+
installThemeBgPatch(this.tui);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function getThemeBgRuntimeState(): ThemeBgRuntimeState {
|
|
521
|
+
const globals = globalThis as unknown as Record<PropertyKey, ThemeBgRuntimeState | undefined>;
|
|
522
|
+
let state = globals[THEME_BG_RUNTIME_STATE_KEY];
|
|
523
|
+
if (!state) {
|
|
524
|
+
state = { enabled: false, ansi: "" };
|
|
525
|
+
globals[THEME_BG_RUNTIME_STATE_KEY] = state;
|
|
526
|
+
}
|
|
527
|
+
return state;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function setThemeBgRuntimeEnabled(enabled: boolean): void {
|
|
531
|
+
getThemeBgRuntimeState().enabled = enabled;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function installThemeBgPatch(tui: TUI): void {
|
|
535
|
+
const state = getThemeBgRuntimeState();
|
|
536
|
+
state.tui = tui;
|
|
537
|
+
const target = tui as TUI & Record<PropertyKey, ThemeBgPatch | undefined>;
|
|
538
|
+
if (target[THEME_BG_PATCH_KEY]) {
|
|
539
|
+
tui.requestRender(true);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const originalRender = tui.render;
|
|
544
|
+
const wrapper: TUI["render"] = function (this: TUI, width: number): string[] {
|
|
545
|
+
const lines = originalRender.call(this, width);
|
|
546
|
+
const runtime = getThemeBgRuntimeState();
|
|
547
|
+
if (!runtime.enabled || !runtime.ansi) return lines;
|
|
548
|
+
|
|
549
|
+
const safeWidth = Math.max(1, width);
|
|
550
|
+
const rendered = lines.map((line) => applyThemeBgToLine(line, safeWidth, runtime.ansi));
|
|
551
|
+
const minLines = Math.max(rendered.length, this.terminal.rows || 0);
|
|
552
|
+
while (rendered.length < minLines) rendered.push(applyThemeBgToLine("", safeWidth, runtime.ansi));
|
|
553
|
+
return rendered;
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
target[THEME_BG_PATCH_KEY] = { originalRender, wrapper };
|
|
557
|
+
tui.render = wrapper;
|
|
558
|
+
tui.requestRender(true);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function uninstallThemeBgPatch(forceRender: boolean): void {
|
|
562
|
+
const state = getThemeBgRuntimeState();
|
|
563
|
+
const tui = state.tui;
|
|
564
|
+
if (!tui) return;
|
|
565
|
+
|
|
566
|
+
const target = tui as TUI & Record<PropertyKey, ThemeBgPatch | undefined>;
|
|
567
|
+
const patch = target[THEME_BG_PATCH_KEY];
|
|
568
|
+
if (patch && tui.render === patch.wrapper) {
|
|
569
|
+
tui.render = patch.originalRender;
|
|
570
|
+
delete target[THEME_BG_PATCH_KEY];
|
|
571
|
+
}
|
|
572
|
+
state.tui = undefined;
|
|
573
|
+
if (forceRender) tui.requestRender(true);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function applyThemeBgToLine(line: string, width: number, bgAnsi: string): string {
|
|
577
|
+
if (line.startsWith("\x1b_G")) return line;
|
|
578
|
+
const content = visibleWidth(line) > width ? truncateToWidth(line, width, "", true) : line;
|
|
579
|
+
const withAmbientBg = reapplyThemeBgAfterReset(content, bgAnsi);
|
|
580
|
+
const pad = Math.max(0, width - visibleWidth(content));
|
|
581
|
+
return `${bgAnsi}${withAmbientBg}${" ".repeat(pad)}\x1b[49m`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function reapplyThemeBgAfterReset(line: string, bgAnsi: string): string {
|
|
585
|
+
return line.replace(/\x1b\[([0-9;]*)m/g, (sequence: string, rawCodes: string) => {
|
|
586
|
+
const codes = rawCodes === "" ? [0] : rawCodes.split(";").map((code) => Number.parseInt(code, 10));
|
|
587
|
+
const resetsBackground = codes.includes(0) || codes.includes(49);
|
|
588
|
+
const setsBackground = codes.some((code) => code === 48 || (code >= 40 && code <= 47) || (code >= 100 && code <= 107));
|
|
589
|
+
return resetsBackground && !setsBackground ? `${sequence}${bgAnsi}` : sequence;
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function updateThemeBgAnsi(ctx: ExtensionContext, name?: KenxThemeName): boolean {
|
|
594
|
+
if (!ctx.hasUI) return false;
|
|
595
|
+
const themeName = name ?? getThemeBgName(ctx);
|
|
596
|
+
if (!themeName) return false;
|
|
597
|
+
const mode = ctx.ui.theme.getColorMode() as ColorMode;
|
|
598
|
+
getThemeBgRuntimeState().ansi = getKenxThemeBgAnsi(themeName, mode);
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function getThemeBgName(ctx: ExtensionContext): KenxThemeName | undefined {
|
|
603
|
+
const currentThemeName = ctx.hasUI ? ctx.ui.theme.name : undefined;
|
|
604
|
+
if (isKenxThemeName(currentThemeName)) return currentThemeName;
|
|
605
|
+
return activeKenxThemeName ?? persistedKenxState.theme;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function getKenxThemeBgAnsi(name: KenxThemeName, mode: ColorMode): string {
|
|
609
|
+
const { json } = readKenxThemeJson(name);
|
|
610
|
+
const vars = json.vars ?? {};
|
|
611
|
+
if (vars.bg === undefined) throw new Error(`Theme ${name} is missing vars.bg for full background`);
|
|
612
|
+
return colorValueToBgAnsi(resolveKenxThemeValue(name, vars.bg, vars), mode);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function colorValueToBgAnsi(value: ThemeColorValue, mode: ColorMode): string {
|
|
616
|
+
if (value === "") return "\x1b[49m";
|
|
617
|
+
if (typeof value === "number") return `\x1b[48;5;${value}m`;
|
|
618
|
+
if (!value.startsWith("#")) throw new Error(`Invalid background color value: ${value}`);
|
|
619
|
+
const { r, g, b } = hexToRgb(value);
|
|
620
|
+
if (mode === "truecolor") return `\x1b[48;2;${r};${g};${b}m`;
|
|
621
|
+
return `\x1b[48;5;${rgbToAnsi256(r, g, b)}m`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
|
625
|
+
const cleaned = hex.replace("#", "");
|
|
626
|
+
if (!/^[0-9a-f]{6}$/i.test(cleaned)) throw new Error(`Invalid hex color: ${hex}`);
|
|
627
|
+
return {
|
|
628
|
+
r: Number.parseInt(cleaned.slice(0, 2), 16),
|
|
629
|
+
g: Number.parseInt(cleaned.slice(2, 4), 16),
|
|
630
|
+
b: Number.parseInt(cleaned.slice(4, 6), 16),
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function rgbToAnsi256(r: number, g: number, b: number): number {
|
|
635
|
+
const levels = [0, 95, 135, 175, 215, 255];
|
|
636
|
+
const nearest = (value: number) => {
|
|
637
|
+
let best = 0;
|
|
638
|
+
let bestDistance = Infinity;
|
|
639
|
+
for (let i = 0; i < levels.length; i++) {
|
|
640
|
+
const distance = Math.abs(value - (levels[i] ?? 0));
|
|
641
|
+
if (distance < bestDistance) {
|
|
642
|
+
best = i;
|
|
643
|
+
bestDistance = distance;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return best;
|
|
647
|
+
};
|
|
648
|
+
const ri = nearest(r);
|
|
649
|
+
const gi = nearest(g);
|
|
650
|
+
const bi = nearest(b);
|
|
651
|
+
return 16 + 36 * ri + 6 * gi + bi;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function getPersistedStatusbarVariant(ctx: ExtensionContext): StatusbarVariant | undefined {
|
|
655
|
+
if ("statusbarVariant" in persistedKenxState) {
|
|
656
|
+
return persistedKenxState.statusbarVariant ?? undefined;
|
|
657
|
+
}
|
|
658
|
+
return getRestoredStatusbarVariant(ctx);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
type StatusbarRenderData = {
|
|
662
|
+
path: string;
|
|
663
|
+
compactPath: string;
|
|
664
|
+
model: string;
|
|
665
|
+
strength: string;
|
|
666
|
+
branch?: string;
|
|
667
|
+
modified: number;
|
|
668
|
+
untracked: number;
|
|
669
|
+
isRepo: boolean;
|
|
670
|
+
statuses: string[];
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
function registerStatusbarCommand(pi: ExtensionAPI, name: "switch-statusbar"): void {
|
|
674
|
+
pi.registerCommand(name, {
|
|
675
|
+
description: "Switch kenx-infra input/statusbar UI (1-8). Use 0/off to restore default.",
|
|
676
|
+
getArgumentCompletions: (prefix: string) => {
|
|
677
|
+
const p = prefix.trim().toLowerCase();
|
|
678
|
+
const items = [
|
|
679
|
+
...statusbarVariantNumbers.map((variant) => ({
|
|
680
|
+
value: String(variant),
|
|
681
|
+
label: String(variant),
|
|
682
|
+
description: statusbarVariantLabels[variant],
|
|
683
|
+
})),
|
|
684
|
+
{ value: "0", label: "0", description: "restore pi default input/footer" },
|
|
685
|
+
];
|
|
686
|
+
const completions = items.filter((item) => item.value.startsWith(p) || item.description.includes(p));
|
|
687
|
+
return completions.length > 0 ? completions : null;
|
|
688
|
+
},
|
|
689
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
690
|
+
if (!ctx.hasUI) return;
|
|
691
|
+
|
|
692
|
+
const parsed = parseStatusbarArg(args);
|
|
693
|
+
if (parsed === undefined) {
|
|
694
|
+
ctx.ui.notify("Usage: /switch-statusbar <1-8> (or 0/off to restore default)", "warning");
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
syncStatusbarContext(ctx, pi);
|
|
699
|
+
if (parsed === 0) {
|
|
700
|
+
statusbarState.variant = undefined;
|
|
701
|
+
const vimBridge = getVimModeBridge();
|
|
702
|
+
vimBridge?.refreshStatus(ctx);
|
|
703
|
+
ctx.ui.setEditorComponent(vimBridge?.wrapEditorFactory(undefined, ctx));
|
|
704
|
+
ctx.ui.setFooter(undefined);
|
|
705
|
+
statusbarState.requestRender = undefined;
|
|
706
|
+
savePersistedKenxState({ statusbarVariant: null });
|
|
707
|
+
pi.appendEntry("kenx-statusbar", { variant: null });
|
|
708
|
+
ctx.ui.notify("Statusbar: default (persisted)", "info");
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
statusbarState.variant = parsed;
|
|
713
|
+
applyStatusbarUI(ctx);
|
|
714
|
+
savePersistedKenxState({ statusbarVariant: parsed });
|
|
715
|
+
pi.appendEntry("kenx-statusbar", { variant: parsed });
|
|
716
|
+
void refreshGitStatus(pi, ctx.cwd);
|
|
717
|
+
ctx.ui.notify(`Statusbar ${parsed}: ${statusbarVariantLabels[parsed]} (persisted)`, "info");
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function parseStatusbarArg(args: string): StatusbarVariant | 0 | undefined {
|
|
723
|
+
const value = args.trim().toLowerCase();
|
|
724
|
+
if (value === "0" || value === "off" || value === "default" || value === "reset") return 0;
|
|
725
|
+
const numeric = Number.parseInt(value, 10);
|
|
726
|
+
return isStatusbarVariant(numeric) ? numeric : undefined;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function isStatusbarVariant(value: unknown): value is StatusbarVariant {
|
|
730
|
+
return typeof value === "number" && (statusbarVariantNumbers as readonly number[]).includes(value);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function getRestoredStatusbarVariant(ctx: ExtensionContext): StatusbarVariant | undefined {
|
|
734
|
+
let restored: StatusbarVariant | undefined;
|
|
735
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
736
|
+
if (entry.type !== "custom" || entry.customType !== "kenx-statusbar") continue;
|
|
737
|
+
const value = (entry.data as { variant?: unknown } | undefined)?.variant;
|
|
738
|
+
const numeric = typeof value === "string" ? Number.parseInt(value, 10) : value;
|
|
739
|
+
restored = isStatusbarVariant(numeric) ? numeric : undefined;
|
|
740
|
+
}
|
|
741
|
+
return restored;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function syncStatusbarContext(ctx: ExtensionContext, pi: ExtensionAPI): void {
|
|
745
|
+
statusbarState.cwd = ctx.cwd;
|
|
746
|
+
statusbarState.modelId = ctx.model?.id ?? "no-model";
|
|
747
|
+
statusbarState.modelProvider = ctx.model?.provider;
|
|
748
|
+
statusbarState.thinkingLevel = pi.getThinkingLevel();
|
|
749
|
+
if (ctx.hasUI) statusbarState.theme = ctx.ui.theme;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function requestStatusbarRender(): void {
|
|
753
|
+
statusbarState.requestRender?.();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function refreshGitStatus(pi: ExtensionAPI, cwd: string): Promise<void> {
|
|
757
|
+
const requestId = ++statusbarState.gitRequestId;
|
|
758
|
+
try {
|
|
759
|
+
const result = await pi.exec("git", ["status", "--short", "--branch"], { cwd, timeout: 2000 });
|
|
760
|
+
if (requestId !== statusbarState.gitRequestId) return;
|
|
761
|
+
if (result.code !== 0) {
|
|
762
|
+
setGitStatus({ ...defaultGitStatus });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
setGitStatus(parseGitStatus(result.stdout));
|
|
766
|
+
} catch {
|
|
767
|
+
if (requestId === statusbarState.gitRequestId) setGitStatus({ ...defaultGitStatus });
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function setGitStatus(next: GitStatusSummary): void {
|
|
772
|
+
const current = statusbarState.git;
|
|
773
|
+
if (
|
|
774
|
+
current.isRepo === next.isRepo &&
|
|
775
|
+
current.branch === next.branch &&
|
|
776
|
+
current.modified === next.modified &&
|
|
777
|
+
current.untracked === next.untracked
|
|
778
|
+
) {
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
statusbarState.git = next;
|
|
782
|
+
requestStatusbarRender();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function parseGitStatus(stdout: string): GitStatusSummary {
|
|
786
|
+
const summary: GitStatusSummary = { isRepo: true, modified: 0, untracked: 0 };
|
|
787
|
+
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
788
|
+
const line = rawLine.trimEnd();
|
|
789
|
+
if (!line) continue;
|
|
790
|
+
if (line.startsWith("## ")) {
|
|
791
|
+
summary.branch = parseGitBranch(line);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (line.startsWith("??")) summary.untracked++;
|
|
795
|
+
else summary.modified++;
|
|
796
|
+
}
|
|
797
|
+
return summary;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function parseGitBranch(line: string): string | undefined {
|
|
801
|
+
let branch = line.slice(3).trim();
|
|
802
|
+
branch = branch.replace(/\s+\[.*\]$/, "");
|
|
803
|
+
const upstreamIndex = branch.indexOf("...");
|
|
804
|
+
if (upstreamIndex >= 0) branch = branch.slice(0, upstreamIndex);
|
|
805
|
+
if (branch === "HEAD (no branch)") branch = "detached";
|
|
806
|
+
return branch || undefined;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function getVimModeBridge(): VimModeBridge | undefined {
|
|
810
|
+
const bridge = (globalThis as Record<symbol, VimModeBridge | undefined>)[VIM_BRIDGE_KEY];
|
|
811
|
+
return bridge?.isEnabled() ? bridge : undefined;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function createStatusbarEditorFactory(): EditorFactory {
|
|
815
|
+
return (tui, editorTheme, keybindings) => new StatusbarEditor(tui, editorTheme, keybindings, statusbarState);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function createComposedStatusbarEditorFactory(ctx: ExtensionContext): EditorFactory {
|
|
819
|
+
const statusbarFactory = createStatusbarEditorFactory();
|
|
820
|
+
const vimBridge = getVimModeBridge();
|
|
821
|
+
vimBridge?.refreshStatus(ctx);
|
|
822
|
+
return vimBridge?.wrapEditorFactory(statusbarFactory, ctx) ?? statusbarFactory;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function setKnownExtensionStatus(key: string, text: string | undefined): void {
|
|
826
|
+
if (key !== "vim-mode") return;
|
|
827
|
+
const withoutVim = statusbarState.extensionStatuses.filter((status) => !status.startsWith("VIM "));
|
|
828
|
+
const cleaned = text ? sanitizeStatusText(text) : "";
|
|
829
|
+
statusbarState.extensionStatuses = cleaned ? [...withoutVim, cleaned] : withoutVim;
|
|
830
|
+
requestStatusbarRender();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function installKenxSidebarBridge(): void {
|
|
834
|
+
const bridge: KenxSidebarBridge = {
|
|
835
|
+
isFileTreeOpen: () => Boolean(closeFileTree),
|
|
836
|
+
closeFileTree: () => {
|
|
837
|
+
if (!closeFileTree) return false;
|
|
838
|
+
closeFileTree();
|
|
839
|
+
return true;
|
|
840
|
+
},
|
|
841
|
+
};
|
|
842
|
+
installedKenxSidebarBridge = bridge;
|
|
843
|
+
(globalThis as Record<symbol, KenxSidebarBridge | undefined>)[KENX_SIDEBAR_BRIDGE_KEY] = bridge;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function uninstallKenxSidebarBridge(): void {
|
|
847
|
+
const globalBridge = globalThis as Record<symbol, KenxSidebarBridge | undefined>;
|
|
848
|
+
if (globalBridge[KENX_SIDEBAR_BRIDGE_KEY] === installedKenxSidebarBridge) delete globalBridge[KENX_SIDEBAR_BRIDGE_KEY];
|
|
849
|
+
installedKenxSidebarBridge = undefined;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function installKenxStatusbarBridge(): void {
|
|
853
|
+
const bridge: KenxStatusbarBridge = {
|
|
854
|
+
isEnabled: () => Boolean(statusbarState.variant),
|
|
855
|
+
getEditorFactory: () => (statusbarState.variant ? createStatusbarEditorFactory() : undefined),
|
|
856
|
+
setStatus: setKnownExtensionStatus,
|
|
857
|
+
};
|
|
858
|
+
installedKenxStatusbarBridge = bridge;
|
|
859
|
+
(globalThis as Record<symbol, KenxStatusbarBridge | undefined>)[KENX_STATUSBAR_BRIDGE_KEY] = bridge;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function uninstallKenxStatusbarBridge(): void {
|
|
863
|
+
const globalBridge = globalThis as Record<symbol, KenxStatusbarBridge | undefined>;
|
|
864
|
+
if (globalBridge[KENX_STATUSBAR_BRIDGE_KEY] === installedKenxStatusbarBridge) delete globalBridge[KENX_STATUSBAR_BRIDGE_KEY];
|
|
865
|
+
installedKenxStatusbarBridge = undefined;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function applyStatusbarUI(ctx: ExtensionContext): void {
|
|
869
|
+
if (!ctx.hasUI) return;
|
|
870
|
+
statusbarState.theme = ctx.ui.theme;
|
|
871
|
+
|
|
872
|
+
if (!statusbarState.variant) {
|
|
873
|
+
ctx.ui.setEditorComponent(undefined);
|
|
874
|
+
ctx.ui.setFooter(undefined);
|
|
875
|
+
statusbarState.requestRender = undefined;
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
ctx.ui.setEditorComponent(createComposedStatusbarEditorFactory(ctx));
|
|
880
|
+
ctx.ui.setFooter((tui, theme, footerData) => new StatusbarFooterSilencer(tui, theme, footerData, statusbarState));
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function sanitizeStatusText(text: string): string {
|
|
884
|
+
return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function readExtensionStatuses(footerData: { getExtensionStatuses(): ReadonlyMap<string, string> }): string[] {
|
|
888
|
+
return Array.from(footerData.getExtensionStatuses().entries())
|
|
889
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
890
|
+
.map(([, text]) => sanitizeStatusText(text))
|
|
891
|
+
.filter(Boolean);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function sameStringArray(a: readonly string[], b: readonly string[]): boolean {
|
|
895
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
class StatusbarFooterSilencer {
|
|
899
|
+
private readonly requestRender: () => void;
|
|
900
|
+
private readonly unsubscribe: () => void;
|
|
901
|
+
|
|
902
|
+
constructor(
|
|
903
|
+
private readonly tui: TUI,
|
|
904
|
+
private readonly theme: Theme,
|
|
905
|
+
private readonly footerData: {
|
|
906
|
+
getGitBranch(): string | null;
|
|
907
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
908
|
+
onBranchChange(callback: () => void): () => void;
|
|
909
|
+
},
|
|
910
|
+
private readonly state: StatusbarState,
|
|
911
|
+
) {
|
|
912
|
+
this.requestRender = () => this.tui.requestRender();
|
|
913
|
+
this.state.theme = this.theme;
|
|
914
|
+
this.state.footerBranch = this.footerData.getGitBranch() ?? undefined;
|
|
915
|
+
this.state.extensionStatuses = readExtensionStatuses(this.footerData);
|
|
916
|
+
this.state.requestRender = this.requestRender;
|
|
917
|
+
this.unsubscribe = this.footerData.onBranchChange(() => {
|
|
918
|
+
this.state.footerBranch = this.footerData.getGitBranch() ?? undefined;
|
|
919
|
+
this.tui.requestRender();
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
render(): string[] {
|
|
924
|
+
this.state.theme = this.theme;
|
|
925
|
+
this.state.footerBranch = this.footerData.getGitBranch() ?? undefined;
|
|
926
|
+
const extensionStatuses = readExtensionStatuses(this.footerData);
|
|
927
|
+
if (!sameStringArray(this.state.extensionStatuses, extensionStatuses)) {
|
|
928
|
+
this.state.extensionStatuses = extensionStatuses;
|
|
929
|
+
this.tui.requestRender();
|
|
930
|
+
}
|
|
931
|
+
return [];
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
invalidate(): void {}
|
|
935
|
+
|
|
936
|
+
dispose(): void {
|
|
937
|
+
this.unsubscribe();
|
|
938
|
+
if (this.state.requestRender === this.requestRender) this.state.requestRender = undefined;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
class StatusbarEditor extends CustomEditor {
|
|
943
|
+
private readonly requestRender: () => void;
|
|
944
|
+
|
|
945
|
+
constructor(
|
|
946
|
+
tui: TUI,
|
|
947
|
+
theme: EditorTheme,
|
|
948
|
+
keybindings: KeybindingsManager,
|
|
949
|
+
private readonly statusbarState: StatusbarState,
|
|
950
|
+
) {
|
|
951
|
+
super(tui, theme, keybindings, { paddingX: 0 });
|
|
952
|
+
this.requestRender = () => tui.requestRender();
|
|
953
|
+
this.statusbarState.requestRender = this.requestRender;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
dispose(): void {
|
|
957
|
+
if (this.statusbarState.requestRender === this.requestRender) this.statusbarState.requestRender = undefined;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
render(width: number): string[] {
|
|
961
|
+
const theme = this.statusbarState.theme;
|
|
962
|
+
const variant = this.statusbarState.variant;
|
|
963
|
+
if (!theme || !variant) return super.render(width);
|
|
964
|
+
|
|
965
|
+
const w = Math.max(1, width);
|
|
966
|
+
const data = getStatusbarRenderData(this.statusbarState);
|
|
967
|
+
const lines = this.renderVariant(variant, theme, data, w);
|
|
968
|
+
return fitStatusbarLines(lines, w);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private renderVariant(variant: StatusbarVariant, theme: Theme, data: StatusbarRenderData, width: number): string[] {
|
|
972
|
+
switch (variant) {
|
|
973
|
+
case 1:
|
|
974
|
+
return this.renderHairline(theme, data, width);
|
|
975
|
+
case 2:
|
|
976
|
+
return this.renderChips(theme, data, width);
|
|
977
|
+
case 3:
|
|
978
|
+
return this.renderStatusAbove(theme, data, width);
|
|
979
|
+
case 4:
|
|
980
|
+
return this.renderPills(theme, data, width);
|
|
981
|
+
case 5:
|
|
982
|
+
return this.renderCornerTabs(theme, data, width);
|
|
983
|
+
case 6:
|
|
984
|
+
return this.renderMarginLabels(theme, data, width);
|
|
985
|
+
case 7:
|
|
986
|
+
return this.renderDense(theme, data, width);
|
|
987
|
+
case 8:
|
|
988
|
+
return this.renderBadges(theme, data, width);
|
|
989
|
+
}
|
|
990
|
+
return this.renderHairline(theme, data, width);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
private renderHairline(theme: Theme, data: StatusbarRenderData, width: number): string[] {
|
|
994
|
+
return [
|
|
995
|
+
rule(theme, width),
|
|
996
|
+
...this.inputRows(theme, Math.max(1, width), true),
|
|
997
|
+
rule(theme, width),
|
|
998
|
+
"",
|
|
999
|
+
" " + lr(statusLeft(theme, data), statusRight(theme, data) + " ", Math.max(1, width - 1)),
|
|
1000
|
+
];
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
private renderChips(theme: Theme, data: StatusbarRenderData, width: number): string[] {
|
|
1004
|
+
const inner = Math.max(1, width - 2);
|
|
1005
|
+
const contentWidth = Math.max(1, inner - 2);
|
|
1006
|
+
const lines = [border(theme, `╭${"─".repeat(inner)}╮`)];
|
|
1007
|
+
for (const line of this.inputRows(theme, contentWidth, true)) {
|
|
1008
|
+
lines.push(border(theme, "│ ") + padLine(line, contentWidth) + border(theme, " │"));
|
|
1009
|
+
}
|
|
1010
|
+
lines.push(border(theme, "│") + " ".repeat(inner) + border(theme, "│"));
|
|
1011
|
+
lines.push(border(theme, "╰") + embeddedChipLine(theme, data, inner) + border(theme, "╯"));
|
|
1012
|
+
return lines;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
private renderStatusAbove(theme: Theme, data: StatusbarRenderData, width: number): string[] {
|
|
1016
|
+
const boxWidth = Math.max(1, width - 4);
|
|
1017
|
+
const inner = Math.max(1, boxWidth - 2);
|
|
1018
|
+
const contentWidth = Math.max(1, inner - 2);
|
|
1019
|
+
const lines = [
|
|
1020
|
+
" " + lr(statusLeft(theme, data, "in"), statusRight(theme, data, "using") + " ", Math.max(1, width - 2)),
|
|
1021
|
+
"",
|
|
1022
|
+
" " + border(theme, `╭${"─".repeat(inner)}╮`),
|
|
1023
|
+
];
|
|
1024
|
+
for (const line of this.inputRows(theme, contentWidth, true)) {
|
|
1025
|
+
lines.push(" " + border(theme, "│ ") + padLine(line, contentWidth) + border(theme, " │"));
|
|
1026
|
+
}
|
|
1027
|
+
lines.push(" " + border(theme, `╰${"─".repeat(inner)}╯`));
|
|
1028
|
+
lines.push(" " + lr("", statusHints(theme), Math.max(1, width - 2)));
|
|
1029
|
+
return lines;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
private renderPills(theme: Theme, data: StatusbarRenderData, width: number): string[] {
|
|
1033
|
+
const p1 = pill(theme, "selectedBg", "muted", ` ${data.path} `);
|
|
1034
|
+
const p2 = data.branch
|
|
1035
|
+
? pill(theme, "toolSuccessBg", "success", ` ⎇ ${data.branch} `) + gitCountPills(theme, data)
|
|
1036
|
+
: pill(theme, "selectedBg", "dim", " no git ");
|
|
1037
|
+
const statusPills = extensionStatusPills(theme, data);
|
|
1038
|
+
const modelPills = pill(theme, "toolErrorBg", "accent", ` ${data.model} `) + " " + pill(theme, "selectedBg", "warning", ` ${data.strength} `);
|
|
1039
|
+
const p3 = statusPills ? `${statusPills} ${modelPills}` : modelPills;
|
|
1040
|
+
return [
|
|
1041
|
+
dottedRule(theme, width),
|
|
1042
|
+
...this.inputRows(theme, Math.max(1, width), true),
|
|
1043
|
+
dottedRule(theme, width),
|
|
1044
|
+
"",
|
|
1045
|
+
" " + lr(`${p1} ${p2}`, p3 + " ", Math.max(1, width - 1)),
|
|
1046
|
+
];
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
private renderCornerTabs(theme: Theme, data: StatusbarRenderData, width: number): string[] {
|
|
1050
|
+
const inner = Math.max(1, width - 2);
|
|
1051
|
+
const contentWidth = Math.max(1, inner - 4);
|
|
1052
|
+
const topLeft = `${border(theme, "─ ")}${theme.fg("dim", "chat")} `;
|
|
1053
|
+
const topModel = `${theme.bold(data.model)} ${theme.fg("dim", "·")} ${strengthText(theme, data.strength)}`;
|
|
1054
|
+
const topRight = ` ${withExtensionStatuses(theme, data, topModel)} ${border(theme, "─")}`;
|
|
1055
|
+
const bottomLeft = `${border(theme, "─ ")}${theme.fg("dim", data.path)} `;
|
|
1056
|
+
const bottomRight = data.branch ? ` ${branchText(theme, data)} ${border(theme, "─")}` : ` ${theme.fg("dim", "no git")} ${border(theme, "─")}`;
|
|
1057
|
+
const lines = [border(theme, "╭") + lrFill(topLeft, topRight, inner, "─", theme) + border(theme, "╮")];
|
|
1058
|
+
lines.push(border(theme, "│") + " ".repeat(inner) + border(theme, "│"));
|
|
1059
|
+
for (const line of this.inputRows(theme, contentWidth, true)) {
|
|
1060
|
+
lines.push(border(theme, "│ ") + padLine(line, contentWidth) + border(theme, " │"));
|
|
1061
|
+
}
|
|
1062
|
+
lines.push(border(theme, "│") + " ".repeat(inner) + border(theme, "│"));
|
|
1063
|
+
lines.push(border(theme, "╰") + lrFill(bottomLeft, bottomRight, inner, "─", theme) + border(theme, "╯"));
|
|
1064
|
+
return lines;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
private renderMarginLabels(theme: Theme, data: StatusbarRenderData, width: number): string[] {
|
|
1068
|
+
const margin = Math.min(22, Math.max(8, Math.floor(width * 0.28)));
|
|
1069
|
+
const field = Math.max(1, width - margin - 3);
|
|
1070
|
+
const m = (text: string) => padLine(text, margin);
|
|
1071
|
+
const input = this.inputRows(theme, field, false);
|
|
1072
|
+
const lines = [m(theme.fg("dim", "path")) + border(theme, "╴") + " " + (input[0] ?? "")];
|
|
1073
|
+
for (const extra of input.slice(1)) lines.push(m("") + " " + extra);
|
|
1074
|
+
lines.push(m(theme.fg("muted", data.path)) + rule(theme, Math.max(1, width - margin)));
|
|
1075
|
+
lines.push("");
|
|
1076
|
+
lines.push(m(theme.fg("dim", "branch")) + " ");
|
|
1077
|
+
lines.push(m(data.branch ? branchText(theme, data) : theme.fg("dim", "no git")) + " ");
|
|
1078
|
+
const statuses = extensionStatusTags(theme, data);
|
|
1079
|
+
if (statuses) {
|
|
1080
|
+
lines.push("");
|
|
1081
|
+
lines.push(m(theme.fg("dim", "status")) + " ");
|
|
1082
|
+
lines.push(m(statuses) + " ");
|
|
1083
|
+
}
|
|
1084
|
+
lines.push("");
|
|
1085
|
+
lines.push(m(theme.fg("dim", "model")) + lr("", statusHints(theme), Math.max(1, width - margin)));
|
|
1086
|
+
lines.push(m(`${theme.bold(data.model)} ${theme.fg("dim", "·")} ${strengthText(theme, data.strength)}`) + " ");
|
|
1087
|
+
return lines;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
private renderDense(theme: Theme, data: StatusbarRenderData, width: number): string[] {
|
|
1091
|
+
const left = `${theme.fg("muted", data.compactPath)} ${theme.fg("dim", "│")} ${data.branch ? branchText(theme, data) : theme.fg("dim", "no git")}`;
|
|
1092
|
+
const model = `${theme.bold(data.model)} ${theme.fg("dim", "·")} ${strengthText(theme, data.strength)}`;
|
|
1093
|
+
const right = withExtensionStatuses(theme, data, model);
|
|
1094
|
+
return [
|
|
1095
|
+
" " + lr(left, right + " ", Math.max(1, width - 1)),
|
|
1096
|
+
rule(theme, width),
|
|
1097
|
+
...this.inputRows(theme, Math.max(1, width), true),
|
|
1098
|
+
" " + lr("", statusHints(theme) + " ", Math.max(1, width - 1)),
|
|
1099
|
+
];
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
private renderBadges(theme: Theme, data: StatusbarRenderData, width: number): string[] {
|
|
1103
|
+
const boxWidth = Math.max(1, width - 4);
|
|
1104
|
+
const inner = Math.max(1, boxWidth - 2);
|
|
1105
|
+
const contentWidth = Math.max(1, inner - 2);
|
|
1106
|
+
const lines = [" " + border(theme, `╭${"─".repeat(inner)}╮`)];
|
|
1107
|
+
for (const line of this.inputRows(theme, contentWidth, true)) {
|
|
1108
|
+
lines.push(" " + border(theme, "│ ") + padLine(line, contentWidth) + border(theme, " │"));
|
|
1109
|
+
}
|
|
1110
|
+
lines.push(" " + border(theme, `╰${"─".repeat(inner)}╯`));
|
|
1111
|
+
lines.push("");
|
|
1112
|
+
const b1 = `${theme.fg("dim", "❯")} ${theme.fg("muted", data.path)}`;
|
|
1113
|
+
const b2 = data.branch ? branchText(theme, data, false) : theme.fg("dim", "no git");
|
|
1114
|
+
const b3 = `${theme.fg("warning", "✚")}${data.modified} ${theme.fg("error", "?")}${data.untracked}`;
|
|
1115
|
+
const badgeModel = `${theme.bold(data.model)} ${theme.fg("dim", "/")} ${strengthText(theme, data.strength)}`;
|
|
1116
|
+
const b4 = `${theme.fg("dim", "◆")} ${withExtensionStatuses(theme, data, badgeModel)}`;
|
|
1117
|
+
lines.push(" " + lr(`${b1} ${theme.fg("dim", "·")} ${b2} ${theme.fg("dim", "·")} ${b3}`, b4 + " ", Math.max(1, width - 2)));
|
|
1118
|
+
return lines;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
private inputRows(theme: Theme, width: number, prompt: boolean): string[] {
|
|
1122
|
+
const safeWidth = Math.max(1, width);
|
|
1123
|
+
const promptText = prompt ? ` ${theme.fg("accent", theme.bold("›"))} ` : "";
|
|
1124
|
+
const nextPrefix = prompt ? " " : "";
|
|
1125
|
+
const bodyWidth = Math.max(1, safeWidth - visibleWidth(promptText));
|
|
1126
|
+
const raw = super.render(bodyWidth);
|
|
1127
|
+
const bottomIndex = findEditorBottomIndex(raw);
|
|
1128
|
+
const body = raw.slice(1, bottomIndex);
|
|
1129
|
+
const autocomplete = raw.slice(bottomIndex + 1);
|
|
1130
|
+
const rows = (body.length > 0 ? body : [""]).map((line, index) =>
|
|
1131
|
+
padLine((index === 0 ? promptText : nextPrefix) + line, safeWidth),
|
|
1132
|
+
);
|
|
1133
|
+
for (const line of autocomplete) rows.push(padLine(nextPrefix + line, safeWidth));
|
|
1134
|
+
return rows;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function getStatusbarRenderData(state: StatusbarState): StatusbarRenderData {
|
|
1139
|
+
const branch = state.footerBranch ?? state.git.branch;
|
|
1140
|
+
return {
|
|
1141
|
+
path: formatCwdPath(state.cwd, 3),
|
|
1142
|
+
compactPath: formatCwdPath(state.cwd, 2, true),
|
|
1143
|
+
model: state.modelId || "no-model",
|
|
1144
|
+
strength: state.thinkingLevel || "off",
|
|
1145
|
+
branch,
|
|
1146
|
+
modified: state.git.modified,
|
|
1147
|
+
untracked: state.git.untracked,
|
|
1148
|
+
isRepo: state.git.isRepo || Boolean(branch),
|
|
1149
|
+
statuses: state.extensionStatuses,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function formatCwdPath(cwd: string, maxSegments: number, forceCompact = false): string {
|
|
1154
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
1155
|
+
let display = cwd;
|
|
1156
|
+
if (home) {
|
|
1157
|
+
const resolvedCwd = resolve(cwd);
|
|
1158
|
+
const resolvedHome = resolve(home);
|
|
1159
|
+
const rel = relative(resolvedHome, resolvedCwd);
|
|
1160
|
+
const insideHome = rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
1161
|
+
if (insideHome) display = rel ? `~${sep}${rel}` : "~";
|
|
1162
|
+
}
|
|
1163
|
+
display = display.split(sep).join("/");
|
|
1164
|
+
const prefix = display.startsWith("~/") ? "~/" : display.startsWith("/") ? "/" : "";
|
|
1165
|
+
const body = prefix ? display.slice(prefix.length) : display;
|
|
1166
|
+
const segments = body.split("/").filter(Boolean);
|
|
1167
|
+
if ((forceCompact || segments.length > maxSegments) && segments.length > maxSegments) {
|
|
1168
|
+
return `${prefix}…/${segments.slice(-maxSegments).join("/")}`;
|
|
1169
|
+
}
|
|
1170
|
+
return display;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function statusLeft(theme: Theme, data: StatusbarRenderData, label?: string): string {
|
|
1174
|
+
const prefix = label ? `${theme.fg("dim", label)} ` : "";
|
|
1175
|
+
const git = data.branch ? ` ${theme.fg("dim", "·")} ${branchText(theme, data)}` : "";
|
|
1176
|
+
return `${prefix}${theme.fg("muted", data.path)}${git}`;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function extensionStatusTags(theme: Theme, data: StatusbarRenderData): string {
|
|
1180
|
+
if (data.statuses.length === 0) return "";
|
|
1181
|
+
return data.statuses.map((status) => theme.fg("accent", status)).join(` ${theme.fg("dim", "·")} `);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function extensionStatusPills(theme: Theme, data: StatusbarRenderData): string {
|
|
1185
|
+
if (data.statuses.length === 0) return "";
|
|
1186
|
+
return data.statuses.map((status) => pill(theme, "selectedBg", "accent", ` ${status} `)).join(" ");
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function withExtensionStatuses(theme: Theme, data: StatusbarRenderData, text: string): string {
|
|
1190
|
+
const statuses = extensionStatusTags(theme, data);
|
|
1191
|
+
return statuses ? `${statuses} ${theme.fg("dim", "·")} ${text}` : text;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function statusRight(theme: Theme, data: StatusbarRenderData, label?: string): string {
|
|
1195
|
+
const prefix = label ? `${theme.fg("dim", label)} ` : `${theme.fg("dim", "model")} `;
|
|
1196
|
+
const model = `${theme.bold(data.model)} ${theme.fg("dim", "·")} ${strengthText(theme, data.strength)}`;
|
|
1197
|
+
return `${prefix}${withExtensionStatuses(theme, data, model)}`;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function branchText(theme: Theme, data: StatusbarRenderData, includeCounts = true): string {
|
|
1201
|
+
const parts = [`${theme.fg("success", "⎇")} ${theme.fg("text", theme.bold(data.branch ?? ""))}`];
|
|
1202
|
+
if (includeCounts && data.modified > 0) parts.push(theme.fg("warning", `✚${data.modified}`));
|
|
1203
|
+
if (includeCounts && data.untracked > 0) parts.push(theme.fg("error", `?${data.untracked}`));
|
|
1204
|
+
return parts.join(" ");
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function gitCountPills(theme: Theme, data: StatusbarRenderData): string {
|
|
1208
|
+
const parts: string[] = [];
|
|
1209
|
+
if (data.modified > 0) parts.push(pill(theme, "toolPendingBg", "warning", ` ✚${data.modified} `));
|
|
1210
|
+
if (data.untracked > 0) parts.push(pill(theme, "toolErrorBg", "error", ` ?${data.untracked} `));
|
|
1211
|
+
return parts.length > 0 ? " " + parts.join(" ") : "";
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function statusHints(theme: Theme): string {
|
|
1215
|
+
return `${theme.fg("muted", "[/]")} ${theme.fg("dim", "cmds")} ${theme.fg("dim", "·")} ${theme.fg("muted", "[@]")} ${theme.fg("dim", "files")} ${theme.fg("dim", "·")} ${theme.fg("muted", "[⏎]")} ${theme.fg("dim", "send")}`;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function strengthText(theme: Theme, strength: string): string {
|
|
1219
|
+
const key = thinkingColorFor(strength);
|
|
1220
|
+
return theme.fg(key, strength);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function thinkingColorFor(strength: string): ThemeColor {
|
|
1224
|
+
switch (strength) {
|
|
1225
|
+
case "off":
|
|
1226
|
+
return "thinkingOff";
|
|
1227
|
+
case "minimal":
|
|
1228
|
+
return "thinkingMinimal";
|
|
1229
|
+
case "low":
|
|
1230
|
+
return "thinkingLow";
|
|
1231
|
+
case "medium":
|
|
1232
|
+
return "thinkingMedium";
|
|
1233
|
+
case "high":
|
|
1234
|
+
return "thinkingHigh";
|
|
1235
|
+
case "xhigh":
|
|
1236
|
+
return "thinkingXhigh";
|
|
1237
|
+
default:
|
|
1238
|
+
return "warning";
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function embeddedChipLine(theme: Theme, data: StatusbarRenderData, width: number): string {
|
|
1243
|
+
const seg1 = ` ${theme.fg("muted", data.path)} `;
|
|
1244
|
+
const seg2 = data.branch ? ` ${branchText(theme, data)} ` : ` ${theme.fg("dim", "no git")} `;
|
|
1245
|
+
const model = `${theme.bold(data.model)} ${theme.fg("dim", "·")} ${strengthText(theme, data.strength)}`;
|
|
1246
|
+
const seg3 = ` ${withExtensionStatuses(theme, data, model)} `;
|
|
1247
|
+
const left = `${border(theme, "─")}${seg1}${border(theme, "─")}${seg2}${border(theme, "─")}`;
|
|
1248
|
+
const right = `${border(theme, "─")}${seg3}${border(theme, "─")}`;
|
|
1249
|
+
return lrFill(left, right, width, "─", theme);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function pill(theme: Theme, bg: ThemeBgKey, fg: ThemeColor, text: string): string {
|
|
1253
|
+
return theme.bg(bg, theme.fg(fg, text));
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function border(theme: Theme, text: string): string {
|
|
1257
|
+
return theme.fg("border", text);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function rule(theme: Theme, width: number): string {
|
|
1261
|
+
return border(theme, "─".repeat(Math.max(0, width)));
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function dottedRule(theme: Theme, width: number): string {
|
|
1265
|
+
return theme.fg("border", truncateToWidth("╴ ".repeat(Math.ceil(width / 2)), width, "", true));
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function lr(left: string, right: string, width: number): string {
|
|
1269
|
+
return lrFill(left, right, width, " ");
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function lrFill(left: string, right: string, width: number, fill: string, theme?: Theme): string {
|
|
1273
|
+
const safeWidth = Math.max(1, width);
|
|
1274
|
+
let l = left;
|
|
1275
|
+
let r = right;
|
|
1276
|
+
if (visibleWidth(r) > safeWidth) r = truncateToWidth(r, safeWidth, "…");
|
|
1277
|
+
const availableLeft = Math.max(0, safeWidth - visibleWidth(r) - 1);
|
|
1278
|
+
if (visibleWidth(l) > availableLeft) l = truncateToWidth(l, availableLeft, "…");
|
|
1279
|
+
const gap = Math.max(1, safeWidth - visibleWidth(l) - visibleWidth(r));
|
|
1280
|
+
const filler = fill.repeat(gap);
|
|
1281
|
+
return l + (theme ? border(theme, filler) : filler) + r;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function padLine(line: string, width: number): string {
|
|
1285
|
+
return truncateToWidth(line, Math.max(1, width), "…", true);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function fitStatusbarLines(lines: string[], width: number): string[] {
|
|
1289
|
+
return lines.map((line) => (line === "" ? "" : truncateToWidth(line, width, "…", true)));
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function findEditorBottomIndex(lines: string[]): number {
|
|
1293
|
+
for (let i = lines.length - 1; i >= 1; i--) {
|
|
1294
|
+
if (stripAnsi(lines[i] ?? "").includes("─")) return i;
|
|
1295
|
+
}
|
|
1296
|
+
return Math.max(1, lines.length - 1);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function stripAnsi(value: string): string {
|
|
1300
|
+
return value
|
|
1301
|
+
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
|
|
1302
|
+
.replace(/\x1b_[\s\S]*?\x1b\\/g, "")
|
|
1303
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
1304
|
+
.replace(/\x1b[@-Z\\-_]/g, "");
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
class FileTreePanel {
|
|
1308
|
+
private readonly tui: TUI;
|
|
1309
|
+
private readonly theme: Theme;
|
|
1310
|
+
private readonly basePath: string;
|
|
1311
|
+
private readonly done: (result: FileTreeResult) => void;
|
|
1312
|
+
|
|
1313
|
+
private currentPath: string;
|
|
1314
|
+
private entries: FileTreeEntry[] = [];
|
|
1315
|
+
private selected = 0;
|
|
1316
|
+
private scroll = 0;
|
|
1317
|
+
private loading = false;
|
|
1318
|
+
private error: string | undefined;
|
|
1319
|
+
private disposed = false;
|
|
1320
|
+
private loadVersion = 0;
|
|
1321
|
+
private closed = false;
|
|
1322
|
+
|
|
1323
|
+
constructor(options: {
|
|
1324
|
+
tui: TUI;
|
|
1325
|
+
theme: Theme;
|
|
1326
|
+
basePath: string;
|
|
1327
|
+
done: (result: FileTreeResult) => void;
|
|
1328
|
+
}) {
|
|
1329
|
+
this.tui = options.tui;
|
|
1330
|
+
this.theme = options.theme;
|
|
1331
|
+
this.basePath = options.basePath;
|
|
1332
|
+
this.currentPath = options.basePath;
|
|
1333
|
+
this.done = options.done;
|
|
1334
|
+
void this.loadDirectory(this.currentPath);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
handleInput(data: string): void {
|
|
1338
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "ctrl+shift+f")) {
|
|
1339
|
+
this.close({ type: "cancel" });
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
if (matchesKey(data, "up") || data === "k") {
|
|
1344
|
+
this.moveSelection(-1);
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
if (matchesKey(data, "down") || data === "j") {
|
|
1348
|
+
this.moveSelection(1);
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
if (matchesKey(data, "pageUp")) {
|
|
1352
|
+
this.moveSelection(-this.visibleEntryRows());
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
if (matchesKey(data, "pageDown")) {
|
|
1356
|
+
this.moveSelection(this.visibleEntryRows());
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
if (matchesKey(data, "home")) {
|
|
1360
|
+
this.selected = 0;
|
|
1361
|
+
this.ensureVisible();
|
|
1362
|
+
this.tui.requestRender();
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (matchesKey(data, "end")) {
|
|
1366
|
+
this.selected = Math.max(0, this.entries.length - 1);
|
|
1367
|
+
this.ensureVisible();
|
|
1368
|
+
this.tui.requestRender();
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
if (matchesKey(data, "left") || matchesKey(data, "backspace") || data === "h") {
|
|
1372
|
+
void this.goParent();
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
if (matchesKey(data, "right") || data === "l") {
|
|
1376
|
+
const entry = this.entries[this.selected];
|
|
1377
|
+
if (entry?.kind === "dir") void this.loadDirectory(entry.fullPath);
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return")) {
|
|
1381
|
+
void this.activateSelection();
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
render(width: number): string[] {
|
|
1386
|
+
const th = this.theme;
|
|
1387
|
+
const w = Math.max(24, width);
|
|
1388
|
+
const innerW = Math.max(1, w - 2);
|
|
1389
|
+
const entryRows = this.visibleEntryRows();
|
|
1390
|
+
this.ensureVisible(entryRows);
|
|
1391
|
+
|
|
1392
|
+
const border = (s: string) => th.fg("border", s);
|
|
1393
|
+
const row = (content: string) => border("│") + this.pad(content, innerW) + border("│");
|
|
1394
|
+
const title = " FILES ";
|
|
1395
|
+
const titleW = visibleWidth(title);
|
|
1396
|
+
const leftRule = "─".repeat(Math.max(0, Math.floor((innerW - titleW) / 2)));
|
|
1397
|
+
const rightRule = "─".repeat(Math.max(0, innerW - titleW - leftRule.length));
|
|
1398
|
+
const lines: string[] = [
|
|
1399
|
+
border(`╭${leftRule}`) + th.fg("accent", title) + border(`${rightRule}╮`),
|
|
1400
|
+
row(` ${th.fg("dim", "cwd")} ${th.fg("accent", this.relativeCurrentPath())}`),
|
|
1401
|
+
border("├") + border("─".repeat(innerW)) + border("┤"),
|
|
1402
|
+
];
|
|
1403
|
+
|
|
1404
|
+
if (this.loading) {
|
|
1405
|
+
lines.push(...this.fillRows(entryRows, ` ${th.fg("warning", "loading…")}`, row));
|
|
1406
|
+
} else if (this.error) {
|
|
1407
|
+
lines.push(...this.fillRows(entryRows, ` ${th.fg("error", this.error)}`, row));
|
|
1408
|
+
} else if (this.entries.length === 0) {
|
|
1409
|
+
lines.push(...this.fillRows(entryRows, ` ${th.fg("dim", "(empty directory)")}`, row));
|
|
1410
|
+
} else {
|
|
1411
|
+
const visible = this.entries.slice(this.scroll, this.scroll + entryRows);
|
|
1412
|
+
for (let i = 0; i < entryRows; i++) {
|
|
1413
|
+
const entry = visible[i];
|
|
1414
|
+
if (!entry) {
|
|
1415
|
+
lines.push(row(""));
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
const index = this.scroll + i;
|
|
1419
|
+
lines.push(this.renderEntryRow(entry, index === this.selected, innerW, row));
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const position = this.entries.length > 0 ? `${this.selected + 1}/${this.entries.length}` : "0/0";
|
|
1424
|
+
lines.push(border("├") + border("─".repeat(innerW)) + border("┤"));
|
|
1425
|
+
lines.push(row(` ${th.fg("dim", "↑↓ select • Enter insert • →/l open • Ctrl+Shift+F close")} ${th.fg("accent", position)}`));
|
|
1426
|
+
lines.push(border(`╰${"─".repeat(innerW)}╯`));
|
|
1427
|
+
return lines;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
invalidate(): void {}
|
|
1431
|
+
|
|
1432
|
+
dispose(): void {
|
|
1433
|
+
this.disposed = true;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
private async loadDirectory(dirPath: string): Promise<void> {
|
|
1437
|
+
const requested = resolve(dirPath);
|
|
1438
|
+
if (!this.isInsideBase(requested)) return;
|
|
1439
|
+
|
|
1440
|
+
const version = ++this.loadVersion;
|
|
1441
|
+
this.loading = true;
|
|
1442
|
+
this.error = undefined;
|
|
1443
|
+
this.tui.requestRender();
|
|
1444
|
+
|
|
1445
|
+
try {
|
|
1446
|
+
const dirents = await readdir(requested, { withFileTypes: true });
|
|
1447
|
+
if (this.disposed || version !== this.loadVersion) return;
|
|
1448
|
+
|
|
1449
|
+
const entries: FileTreeEntry[] = [];
|
|
1450
|
+
if (requested !== this.basePath) {
|
|
1451
|
+
entries.push({ name: "..", fullPath: dirname(requested), kind: "parent" });
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
for (const dirent of dirents) {
|
|
1455
|
+
const name = cleanName(dirent.name);
|
|
1456
|
+
if (!name) continue;
|
|
1457
|
+
const fullPath = join(requested, dirent.name);
|
|
1458
|
+
const kind: FileTreeEntry["kind"] = dirent.isDirectory()
|
|
1459
|
+
? "dir"
|
|
1460
|
+
: dirent.isFile()
|
|
1461
|
+
? "file"
|
|
1462
|
+
: dirent.isSymbolicLink()
|
|
1463
|
+
? "symlink"
|
|
1464
|
+
: "other";
|
|
1465
|
+
entries.push({ name, fullPath, kind });
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
entries.sort(compareEntries);
|
|
1469
|
+
this.currentPath = requested;
|
|
1470
|
+
this.entries = entries;
|
|
1471
|
+
this.selected = 0;
|
|
1472
|
+
this.scroll = 0;
|
|
1473
|
+
} catch (error) {
|
|
1474
|
+
if (this.disposed || version !== this.loadVersion) return;
|
|
1475
|
+
this.entries = [];
|
|
1476
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
1477
|
+
} finally {
|
|
1478
|
+
if (!this.disposed && version === this.loadVersion) {
|
|
1479
|
+
this.loading = false;
|
|
1480
|
+
this.ensureVisible();
|
|
1481
|
+
this.tui.requestRender();
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
private async activateSelection(): Promise<void> {
|
|
1487
|
+
const entry = this.entries[this.selected];
|
|
1488
|
+
if (!entry) return;
|
|
1489
|
+
|
|
1490
|
+
if (entry.kind === "parent") {
|
|
1491
|
+
await this.goParent();
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
this.close({ type: "select", path: this.referencePath(entry.fullPath) });
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
private async goParent(): Promise<void> {
|
|
1499
|
+
if (this.currentPath === this.basePath) return;
|
|
1500
|
+
await this.loadDirectory(dirname(this.currentPath));
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
private close(result: FileTreeResult): void {
|
|
1504
|
+
if (this.closed) return;
|
|
1505
|
+
this.closed = true;
|
|
1506
|
+
this.done(result);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
private moveSelection(delta: number): void {
|
|
1510
|
+
if (this.entries.length === 0) return;
|
|
1511
|
+
this.selected = Math.max(0, Math.min(this.entries.length - 1, this.selected + delta));
|
|
1512
|
+
this.ensureVisible();
|
|
1513
|
+
this.tui.requestRender();
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
private visibleEntryRows(): number {
|
|
1517
|
+
const rows = this.tui.terminal.rows || 30;
|
|
1518
|
+
return Math.max(3, Math.floor(rows * 0.9) - 6);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
private ensureVisible(entryRows = this.visibleEntryRows()): void {
|
|
1522
|
+
if (this.selected < this.scroll) this.scroll = this.selected;
|
|
1523
|
+
if (this.selected >= this.scroll + entryRows) this.scroll = this.selected - entryRows + 1;
|
|
1524
|
+
this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, this.entries.length - entryRows)));
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
private renderEntryRow(
|
|
1528
|
+
entry: FileTreeEntry,
|
|
1529
|
+
selected: boolean,
|
|
1530
|
+
innerW: number,
|
|
1531
|
+
row: (content: string) => string,
|
|
1532
|
+
): string {
|
|
1533
|
+
const th = this.theme;
|
|
1534
|
+
const prefix = selected ? th.fg("accent", "›") : " ";
|
|
1535
|
+
const icon = this.iconFor(entry);
|
|
1536
|
+
const label = this.labelFor(entry);
|
|
1537
|
+
let content = ` ${prefix} ${icon} ${label}`;
|
|
1538
|
+
|
|
1539
|
+
if (entry.kind === "dir") content += th.fg("dim", "/");
|
|
1540
|
+
if (entry.kind === "symlink") content += ` ${th.fg("dim", "↝")}`;
|
|
1541
|
+
|
|
1542
|
+
content = this.pad(content, innerW);
|
|
1543
|
+
if (selected) content = th.bg("selectedBg", content);
|
|
1544
|
+
return row(content);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
private iconFor(entry: FileTreeEntry): string {
|
|
1548
|
+
const th = this.theme;
|
|
1549
|
+
if (entry.kind === "parent") return th.fg("muted", "↰");
|
|
1550
|
+
if (entry.kind === "dir") return th.fg("accent", "▸");
|
|
1551
|
+
if (entry.kind === "symlink") return th.fg("warning", "◆");
|
|
1552
|
+
if (entry.kind === "other") return th.fg("dim", "·");
|
|
1553
|
+
return th.fg("dim", "•");
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
private labelFor(entry: FileTreeEntry): string {
|
|
1557
|
+
const th = this.theme;
|
|
1558
|
+
if (entry.kind === "parent") return th.fg("muted", "..");
|
|
1559
|
+
if (entry.kind === "dir") return th.fg("text", entry.name);
|
|
1560
|
+
if (entry.kind === "file") return th.fg("text", entry.name);
|
|
1561
|
+
return th.fg("muted", entry.name);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
private fillRows(count: number, first: string, row: (content: string) => string): string[] {
|
|
1565
|
+
const lines = [row(first)];
|
|
1566
|
+
while (lines.length < count) lines.push(row(""));
|
|
1567
|
+
return lines;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
private pad(content: string, width: number): string {
|
|
1571
|
+
return truncateToWidth(content, width, "…", true);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
private relativeCurrentPath(): string {
|
|
1575
|
+
const rel = relative(this.basePath, this.currentPath);
|
|
1576
|
+
return rel ? rel.split(sep).join("/") : ".";
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
private referencePath(filePath: string): string {
|
|
1580
|
+
const rel = relative(this.basePath, filePath).split(sep).join("/");
|
|
1581
|
+
return rel || cleanName(filePath.split(sep).pop() ?? filePath);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
private isInsideBase(targetPath: string): boolean {
|
|
1585
|
+
const rel = relative(this.basePath, targetPath);
|
|
1586
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function compareEntries(a: FileTreeEntry, b: FileTreeEntry): number {
|
|
1591
|
+
if (a.kind === "parent") return -1;
|
|
1592
|
+
if (b.kind === "parent") return 1;
|
|
1593
|
+
const aDir = a.kind === "dir";
|
|
1594
|
+
const bDir = b.kind === "dir";
|
|
1595
|
+
if (aDir !== bDir) return aDir ? -1 : 1;
|
|
1596
|
+
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function cleanName(name: string): string {
|
|
1600
|
+
return name.replace(/[\r\n]/g, " ");
|
|
1601
|
+
}
|