pi-open-tui 0.4.0 → 0.4.1
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 +237 -197
- package/README.zh-CN.md +237 -197
- package/assets/preview_dashboard_1.png +0 -0
- package/extensions/open-tui/config.ts +9 -2
- package/extensions/open-tui/copy-buttons.ts +363 -0
- package/extensions/open-tui/editor.ts +311 -119
- package/extensions/open-tui/footer.ts +13 -11
- package/extensions/open-tui/git.ts +10 -32
- package/extensions/open-tui/header.ts +133 -244
- package/extensions/open-tui/index.ts +34 -33
- package/extensions/open-tui/runtime.ts +10 -11
- package/extensions/open-tui/session-lifecycle.ts +23 -43
- package/extensions/open-tui/settings-command.ts +54 -45
- package/extensions/open-tui/state.ts +11 -14
- package/extensions/open-tui/tool-preview.ts +167 -0
- package/extensions/open-tui/tool-view.ts +94 -24
- package/extensions/open-tui/utils.ts +3 -3
- package/package.json +7 -11
|
@@ -2,9 +2,8 @@ import {
|
|
|
2
2
|
CustomEditor,
|
|
3
3
|
type ExtensionAPI,
|
|
4
4
|
type ExtensionContext,
|
|
5
|
-
type KeybindingsManager,
|
|
6
5
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import type {
|
|
6
|
+
import type { Component, Container, EditorComponent, TUI } from "@earendil-works/pi-tui";
|
|
8
7
|
import { CURSOR_MARKER, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
8
|
import type { CursorStyle } from "./config.ts";
|
|
10
9
|
import { findBottomBorderIndex, isEditorBorderLine, stripAnsi } from "./utils.ts";
|
|
@@ -23,6 +22,8 @@ const DEFAULT_CURSOR_STYLE_SEQUENCE = "\x1b[0 q";
|
|
|
23
22
|
const EDITOR_FRAME_LEFT_INSET = 2;
|
|
24
23
|
const EDITOR_FRAME_HORIZONTAL_CHROME = EDITOR_FRAME_LEFT_INSET * 2;
|
|
25
24
|
|
|
25
|
+
type EditorFactory = NonNullable<Parameters<ExtensionContext["ui"]["setEditorComponent"]>[0]>;
|
|
26
|
+
|
|
26
27
|
interface WorkingStatusIndicator {
|
|
27
28
|
renderInBorder(width: number): string;
|
|
28
29
|
renderSpinnerInBorder(width: number): string;
|
|
@@ -200,125 +201,204 @@ function inlineBorder(
|
|
|
200
201
|
return `${paint(corners[0])}${left}${leftCell}${paint("─".repeat(fill))}${rightCell}${right}${paint("─")}${paint(corners[1])}`;
|
|
201
202
|
}
|
|
202
203
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
private embeddedWorkingStatusIndicator: WorkingStatusIndicator | undefined;
|
|
208
|
-
private readonly inlineFooter: InlineFooterRenderer | undefined;
|
|
209
|
-
private cursorStyle: CursorStyle;
|
|
210
|
-
private previewHardwareCursor = false;
|
|
211
|
-
|
|
212
|
-
constructor(
|
|
213
|
-
tui: TUI,
|
|
214
|
-
editorTheme: EditorTheme,
|
|
215
|
-
keybindings: KeybindingsManager,
|
|
216
|
-
cursorStyle: CursorStyle = "block",
|
|
217
|
-
inlineFooter?: InlineFooterRenderer,
|
|
218
|
-
) {
|
|
219
|
-
super(tui, editorTheme, keybindings, { paddingX: 0 });
|
|
220
|
-
this.cursorStyle = cursorStyle;
|
|
221
|
-
this.inlineFooter = inlineFooter;
|
|
222
|
-
configureCursor(tui, cursorStyle);
|
|
223
|
-
// ponytail: route the frame through this.borderColor so Pi can recolor it
|
|
224
|
-
// via updateEditorBorderColor() — bash mode ("! " prefix → green) and
|
|
225
|
-
// thinking-level borders both flow through this one property.
|
|
226
|
-
this.getRail = () => this.borderColor("│");
|
|
227
|
-
this.getBorder = (s: string) => this.borderColor(s);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
override setPaddingX(_padding: number): void {
|
|
231
|
-
// The custom rail owns the horizontal inset and keeps one stable text gap.
|
|
232
|
-
super.setPaddingX(0);
|
|
233
|
-
}
|
|
204
|
+
interface OpenTuiEditorOptions {
|
|
205
|
+
cursorStyle?: CursorStyle;
|
|
206
|
+
inlineFooter?: InlineFooterRenderer;
|
|
207
|
+
}
|
|
234
208
|
|
|
235
|
-
|
|
236
|
-
|
|
209
|
+
/** Capabilities this decorator adds to the editor instance it wraps. */
|
|
210
|
+
interface OpenTuiEditorDecoration {
|
|
211
|
+
embedWorkingStatus?: boolean;
|
|
212
|
+
setWorkingStatusIndicator(indicator: WorkingStatusIndicator | undefined): void;
|
|
213
|
+
setCursorStyle(cursorStyle: CursorStyle, blockHardwareCursor?: boolean): void;
|
|
214
|
+
}
|
|
237
215
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
216
|
+
type FrameableEditor = EditorComponent & Pick<CustomEditor,
|
|
217
|
+
"focused" | "borderColor" | "handleMouse" | "getPaddingX" | "setPaddingX"
|
|
218
|
+
>;
|
|
219
|
+
|
|
220
|
+
// 按能力识别,避免跨模块加载时 instanceof 失效;仅支持保留 Pi 原生布局的编辑器。
|
|
221
|
+
function isFrameableEditor(editor: EditorComponent): editor is FrameableEditor {
|
|
222
|
+
return typeof editor.borderColor === "function"
|
|
223
|
+
&& typeof editor.handleMouse === "function"
|
|
224
|
+
&& "getPaddingX" in editor && typeof editor.getPaddingX === "function"
|
|
225
|
+
&& typeof editor.setPaddingX === "function"
|
|
226
|
+
&& "focused" in editor && typeof editor.focused === "boolean";
|
|
227
|
+
}
|
|
244
228
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
229
|
+
interface EditorDecoration {
|
|
230
|
+
tui: TUI;
|
|
231
|
+
editor: FrameableEditor;
|
|
232
|
+
cursorStyle: CursorStyle;
|
|
233
|
+
previewHardwareCursor: boolean;
|
|
234
|
+
inlineFooter: InlineFooterRenderer | undefined;
|
|
235
|
+
indicator: WorkingStatusIndicator | undefined;
|
|
236
|
+
baseRender: (width: number) => string[];
|
|
237
|
+
framedWidth: number | undefined;
|
|
238
|
+
active: boolean;
|
|
239
|
+
cleanup(): void;
|
|
240
|
+
}
|
|
249
241
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
242
|
+
// One decoration per instance: installing twice must not stack two frames.
|
|
243
|
+
const DECORATIONS = new WeakMap<object, EditorDecoration>();
|
|
244
|
+
|
|
245
|
+
function applyCursorStyle(
|
|
246
|
+
decoration: EditorDecoration,
|
|
247
|
+
cursorStyle: CursorStyle,
|
|
248
|
+
blockHardwareCursor = false,
|
|
249
|
+
): void {
|
|
250
|
+
const styleChanged = cursorStyle !== decoration.cursorStyle;
|
|
251
|
+
decoration.previewHardwareCursor = cursorStyle !== "block";
|
|
252
|
+
decoration.cursorStyle = cursorStyle;
|
|
253
|
+
if (styleChanged) {
|
|
254
|
+
const tui = decoration.tui;
|
|
255
|
+
if (cursorStyle === "block") {
|
|
256
|
+
tui.terminal.write(DEFAULT_CURSOR_STYLE_SEQUENCE);
|
|
257
|
+
tui.setShowHardwareCursor(blockHardwareCursor);
|
|
258
|
+
} else {
|
|
259
|
+
configureCursor(tui, cursorStyle);
|
|
261
260
|
}
|
|
262
|
-
this.tui.requestRender();
|
|
263
261
|
}
|
|
262
|
+
decoration.tui.requestRender();
|
|
263
|
+
}
|
|
264
264
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
265
|
+
function renderWithCursor(decoration: EditorDecoration, width: number): string[] {
|
|
266
|
+
const renderedLines = decoration.baseRender(width);
|
|
267
|
+
if (decoration.cursorStyle === "block") return renderedLines;
|
|
268
|
+
|
|
269
|
+
const tui = decoration.tui;
|
|
270
|
+
// Pi re-applies settings.showHardwareCursor after session_start (/reload,
|
|
271
|
+
// /new, session switches). Non-block styles have no software cursor left to
|
|
272
|
+
// fall back on, so re-assert the hardware cursor instead of losing it.
|
|
273
|
+
if (!tui.getShowHardwareCursor()) configureCursor(tui, decoration.cursorStyle);
|
|
274
|
+
|
|
275
|
+
// A focused overlay suppresses the editor's cursor marker. Preserve its
|
|
276
|
+
// position only for the live settings preview, then clear it on refocus.
|
|
277
|
+
const focused = decoration.editor.focused;
|
|
278
|
+
let cursorMarker = decoration.previewHardwareCursor && !focused ? CURSOR_MARKER : "";
|
|
279
|
+
if (focused) decoration.previewHardwareCursor = false;
|
|
280
|
+
return renderedLines.map((line) => {
|
|
281
|
+
const rendered = removeSoftwareCursor(line, cursorMarker);
|
|
282
|
+
if (rendered !== line) cursorMarker = "";
|
|
283
|
+
return rendered;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
284
286
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const rail = this.getRail();
|
|
289
|
-
const borderPaint = this.getBorder;
|
|
290
|
-
// ponytail: 1-char rail + 1-char gap on each side = 4 chars of chrome.
|
|
291
|
-
const innerWidth = Math.max(0, width - EDITOR_FRAME_HORIZONTAL_CHROME);
|
|
292
|
-
const baseLines = this.renderBase(innerWidth);
|
|
293
|
-
const bottomIdx = findBottomBorderIndex(baseLines);
|
|
294
|
-
|
|
295
|
-
const result: string[] = [];
|
|
296
|
-
const inlineFooter = this.inlineFooter?.enabled() === true;
|
|
297
|
-
const renderInlineLine = (kind: "top" | "bottom", budget: number): InlineFooterLine | undefined =>
|
|
298
|
-
this.inlineFooter?.render(budget)?.[kind];
|
|
299
|
-
result.push(inlineFooter
|
|
300
|
-
? inlineBorder(width, "top", borderPaint, (budget) => renderInlineLine("top", budget), baseLines[0], this.embeddedWorkingStatusIndicator)
|
|
301
|
-
: roundedBorder(width, "top", borderPaint, baseLines[0], this.embeddedWorkingStatusIndicator));
|
|
302
|
-
|
|
303
|
-
for (let i = 1; i < bottomIdx; i++) {
|
|
304
|
-
const line = baseLines[i] ?? "";
|
|
305
|
-
if (isEditorBorderLine(line)) {
|
|
306
|
-
result.push(`${rail} ${fillLine("", innerWidth)} ${rail}`);
|
|
307
|
-
} else {
|
|
308
|
-
result.push(`${rail} ${fillLine(line, innerWidth)} ${rail}`);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
287
|
+
function renderFrame(decoration: EditorDecoration, width: number): string[] {
|
|
288
|
+
decoration.framedWidth = undefined;
|
|
289
|
+
if (width < EDITOR_FRAME_HORIZONTAL_CHROME) return renderWithCursor(decoration, width);
|
|
311
290
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
291
|
+
const innerWidth = Math.max(0, width - EDITOR_FRAME_HORIZONTAL_CHROME);
|
|
292
|
+
const baseLines = renderWithCursor(decoration, innerWidth);
|
|
293
|
+
const bottomIdx = findBottomBorderIndex(baseLines);
|
|
294
|
+
if (bottomIdx <= 1 || !isEditorBorderLine(baseLines[0] ?? "") || !isEditorBorderLine(baseLines[bottomIdx] ?? "")) {
|
|
295
|
+
return renderWithCursor(decoration, width);
|
|
296
|
+
}
|
|
297
|
+
const borderPaint = (text: string) => decoration.editor.borderColor(text);
|
|
298
|
+
const rail = borderPaint("│");
|
|
299
|
+
|
|
300
|
+
const result: string[] = [];
|
|
301
|
+
const inlineFooter = decoration.inlineFooter?.enabled() === true;
|
|
302
|
+
const renderInlineLine = (kind: "top" | "bottom", budget: number): InlineFooterLine | undefined =>
|
|
303
|
+
decoration.inlineFooter?.render(budget)?.[kind];
|
|
304
|
+
result.push(inlineFooter
|
|
305
|
+
? inlineBorder(width, "top", borderPaint, (budget) => renderInlineLine("top", budget), baseLines[0], decoration.indicator)
|
|
306
|
+
: roundedBorder(width, "top", borderPaint, baseLines[0], decoration.indicator));
|
|
307
|
+
|
|
308
|
+
for (let i = 1; i < bottomIdx; i++) {
|
|
309
|
+
const line = baseLines[i] ?? "";
|
|
310
|
+
result.push(`${rail} ${fillLine(line, innerWidth)} ${rail}`);
|
|
311
|
+
}
|
|
315
312
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
313
|
+
result.push(inlineFooter
|
|
314
|
+
? inlineBorder(width, "bottom", borderPaint, (budget) => renderInlineLine("bottom", budget), baseLines[bottomIdx])
|
|
315
|
+
: roundedBorder(width, "bottom", borderPaint, baseLines[bottomIdx]));
|
|
319
316
|
|
|
320
|
-
|
|
317
|
+
for (let i = bottomIdx + 1; i < baseLines.length; i++) {
|
|
318
|
+
result.push(baseLines[i]!);
|
|
321
319
|
}
|
|
320
|
+
|
|
321
|
+
decoration.framedWidth = width;
|
|
322
|
+
return result.map((line) => truncateToWidth(line, width, ""));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Adds the open-tui frame to an existing editor **in place** and returns that same
|
|
327
|
+
* instance. Patching (instead of wrapping) keeps `onSubmit` / `onChange` / `handleInput`
|
|
328
|
+
* on one object, so Pi and every extension that wrapped the factory before us keep working.
|
|
329
|
+
*/
|
|
330
|
+
export function decorateOpenTuiEditor<T extends FrameableEditor>(
|
|
331
|
+
editor: T,
|
|
332
|
+
tui: TUI,
|
|
333
|
+
options: OpenTuiEditorOptions = {},
|
|
334
|
+
): T & OpenTuiEditorDecoration {
|
|
335
|
+
const existing = DECORATIONS.get(editor);
|
|
336
|
+
if (existing) return editor as T & OpenTuiEditorDecoration;
|
|
337
|
+
|
|
338
|
+
const decorated = editor as FrameableEditor & OpenTuiEditorDecoration;
|
|
339
|
+
const restore: (() => void)[] = [];
|
|
340
|
+
const baseHandleMouse = editor.handleMouse.bind(editor);
|
|
341
|
+
const baseSetPaddingX = editor.setPaddingX.bind(editor);
|
|
342
|
+
const baseSetWorkingStatus = decorated.setWorkingStatusIndicator?.bind(editor);
|
|
343
|
+
const baseSetCursorStyle = decorated.setCursorStyle?.bind(editor);
|
|
344
|
+
let padding = editor.getPaddingX();
|
|
345
|
+
const decoration: EditorDecoration = {
|
|
346
|
+
tui,
|
|
347
|
+
editor,
|
|
348
|
+
cursorStyle: options.cursorStyle ?? "block",
|
|
349
|
+
previewHardwareCursor: false,
|
|
350
|
+
inlineFooter: options.inlineFooter,
|
|
351
|
+
indicator: undefined,
|
|
352
|
+
baseRender: editor.render.bind(editor),
|
|
353
|
+
framedWidth: undefined,
|
|
354
|
+
active: true,
|
|
355
|
+
cleanup() {
|
|
356
|
+
if (!decoration.active) return;
|
|
357
|
+
decoration.active = false;
|
|
358
|
+
for (const undo of restore) undo();
|
|
359
|
+
baseSetPaddingX(padding);
|
|
360
|
+
DECORATIONS.delete(editor);
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
// 只撤销仍属于自己的属性;后来包装的方法会通过 active=false 调用原实现。
|
|
364
|
+
const patch = <K extends keyof OpenTuiEditorDecoration | "render" | "handleMouse" | "setPaddingX">(
|
|
365
|
+
key: K,
|
|
366
|
+
value: (FrameableEditor & OpenTuiEditorDecoration)[K],
|
|
367
|
+
): void => {
|
|
368
|
+
const descriptor = Object.getOwnPropertyDescriptor(editor, key);
|
|
369
|
+
decorated[key] = value;
|
|
370
|
+
restore.push(() => {
|
|
371
|
+
if (decorated[key] !== value) return;
|
|
372
|
+
if (descriptor) Object.defineProperty(editor, key, descriptor);
|
|
373
|
+
else Reflect.deleteProperty(editor, key);
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
DECORATIONS.set(editor, decoration);
|
|
377
|
+
configureCursor(tui, decoration.cursorStyle);
|
|
378
|
+
patch("embedWorkingStatus", true);
|
|
379
|
+
patch("setWorkingStatusIndicator", (indicator) => {
|
|
380
|
+
if (!decoration.active) return baseSetWorkingStatus?.(indicator);
|
|
381
|
+
decoration.indicator = indicator;
|
|
382
|
+
tui.requestRender();
|
|
383
|
+
});
|
|
384
|
+
patch("setCursorStyle", (style, hardware = false) => {
|
|
385
|
+
if (decoration.active) applyCursorStyle(decoration, style, hardware);
|
|
386
|
+
else baseSetCursorStyle?.(style, hardware);
|
|
387
|
+
});
|
|
388
|
+
patch("render", (width) => decoration.active
|
|
389
|
+
? renderFrame(decoration, width)
|
|
390
|
+
: decoration.baseRender(width));
|
|
391
|
+
patch("handleMouse", (event) => baseHandleMouse(
|
|
392
|
+
decoration.active && event.width === decoration.framedWidth
|
|
393
|
+
? { ...event, x: event.x - EDITOR_FRAME_LEFT_INSET, width: event.width - EDITOR_FRAME_HORIZONTAL_CHROME }
|
|
394
|
+
: event,
|
|
395
|
+
));
|
|
396
|
+
patch("setPaddingX", (nextPadding) => {
|
|
397
|
+
padding = nextPadding;
|
|
398
|
+
baseSetPaddingX(decoration.active ? 0 : nextPadding);
|
|
399
|
+
});
|
|
400
|
+
baseSetPaddingX(0);
|
|
401
|
+
return editor as T & OpenTuiEditorDecoration;
|
|
322
402
|
}
|
|
323
403
|
|
|
324
404
|
type StreamWrite = (...args: unknown[]) => boolean;
|
|
@@ -364,7 +444,8 @@ export function installEditor(
|
|
|
364
444
|
inlineFooter?: InlineFooterRenderer,
|
|
365
445
|
) {
|
|
366
446
|
let activeTui: TUI | undefined;
|
|
367
|
-
let activeEditor:
|
|
447
|
+
let activeEditor: (FrameableEditor & OpenTuiEditorDecoration) | undefined;
|
|
448
|
+
let enabled = true;
|
|
368
449
|
let previousHardwareCursor: boolean | undefined;
|
|
369
450
|
let currentCursorStyle = cursorStyle;
|
|
370
451
|
let hiddenThinkingTarget: HiddenThinkingLabelComponent | undefined;
|
|
@@ -410,14 +491,114 @@ export function installEditor(
|
|
|
410
491
|
if (activeTui?.mode === "fullscreen") activeTui.requestRender(true);
|
|
411
492
|
});
|
|
412
493
|
|
|
413
|
-
ctx.ui
|
|
494
|
+
const ui = ctx.ui;
|
|
495
|
+
const originalSetEditor = ui.setEditorComponent;
|
|
496
|
+
const originalGetEditor = ui.getEditorComponent;
|
|
497
|
+
let inheritedFactory = originalGetEditor.call(ui);
|
|
498
|
+
let mountedEditor: EditorComponent | undefined;
|
|
499
|
+
let mountedFactory: EditorFactory | undefined;
|
|
500
|
+
let mountedRender: EditorComponent["render"] | undefined;
|
|
501
|
+
let restoreEditorMount: (() => void) | undefined;
|
|
502
|
+
const releaseEditor = (): void => {
|
|
503
|
+
if (activeEditor) DECORATIONS.get(activeEditor)?.cleanup();
|
|
504
|
+
activeEditor = undefined;
|
|
505
|
+
if (activeTui && previousHardwareCursor !== undefined) {
|
|
506
|
+
if (currentCursorStyle !== "block") activeTui.terminal.write(DEFAULT_CURSOR_STYLE_SEQUENCE);
|
|
507
|
+
activeTui.setShowHardwareCursor(previousHardwareCursor);
|
|
508
|
+
}
|
|
509
|
+
previousHardwareCursor = undefined;
|
|
510
|
+
};
|
|
511
|
+
const mountEditor = (tui: TUI, editor: EditorComponent): void => {
|
|
512
|
+
releaseEditor();
|
|
414
513
|
activeTui = tui;
|
|
514
|
+
mountedEditor = editor;
|
|
515
|
+
mountedFactory = originalGetEditor.call(ui);
|
|
415
516
|
resetHiddenThinkingTarget();
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
517
|
+
if (isFrameableEditor(editor)) {
|
|
518
|
+
let lines: string[] = [];
|
|
519
|
+
try {
|
|
520
|
+
lines = editor.render(tui.terminal.columns);
|
|
521
|
+
} catch {
|
|
522
|
+
// A failed layout probe must not prevent Pi from mounting the editor.
|
|
523
|
+
}
|
|
524
|
+
const bottom = findBottomBorderIndex(lines);
|
|
525
|
+
// Capabilities alone do not guarantee that the first and bottom rows are replaceable borders.
|
|
526
|
+
if (bottom > 1 && isEditorBorderLine(lines[0] ?? "") && isEditorBorderLine(lines[bottom] ?? "")) {
|
|
527
|
+
previousHardwareCursor = tui.getShowHardwareCursor?.();
|
|
528
|
+
activeEditor = decorateOpenTuiEditor(editor, tui, { cursorStyle: currentCursorStyle, inlineFooter });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
mountedRender = editor.render;
|
|
532
|
+
};
|
|
533
|
+
const wrapFactory = (delegate: EditorFactory | undefined): EditorFactory => {
|
|
534
|
+
const wrapped: EditorFactory = (tui, editorTheme, keybindings) => {
|
|
535
|
+
let inner: EditorComponent | undefined;
|
|
536
|
+
try {
|
|
537
|
+
inner = delegate?.(tui, editorTheme, keybindings);
|
|
538
|
+
} catch {
|
|
539
|
+
// 上游工厂不可用时保留默认编辑器。
|
|
540
|
+
}
|
|
541
|
+
inner ??= new CustomEditor(tui, editorTheme, keybindings, { paddingX: 0 });
|
|
542
|
+
// A factory captured by another context must keep its original delegate.
|
|
543
|
+
if (enabled && originalGetEditor.call(ui) === wrapped) mountEditor(tui, inner);
|
|
544
|
+
return inner;
|
|
545
|
+
};
|
|
546
|
+
return wrapped;
|
|
547
|
+
};
|
|
548
|
+
let factory = wrapFactory(inheritedFactory);
|
|
549
|
+
const getEditor: typeof ui.getEditorComponent = () => {
|
|
550
|
+
const current = originalGetEditor.call(ui);
|
|
551
|
+
return enabled && current === factory ? inheritedFactory : current;
|
|
552
|
+
};
|
|
553
|
+
const setEditor: typeof ui.setEditorComponent = (next) => {
|
|
554
|
+
if (!enabled) return originalSetEditor.call(ui, next);
|
|
555
|
+
inheritedFactory = next;
|
|
556
|
+
factory = wrapFactory(next);
|
|
557
|
+
originalSetEditor.call(ui, factory);
|
|
558
|
+
};
|
|
559
|
+
ui.getEditorComponent = getEditor;
|
|
560
|
+
ui.setEditorComponent = setEditor;
|
|
561
|
+
originalSetEditor.call(ui, factory);
|
|
562
|
+
|
|
563
|
+
// Shortcut contexts have a fresh ui object; intercept their shared mount before status routing.
|
|
564
|
+
const pending: Component[] = activeTui ? [activeTui] : [];
|
|
565
|
+
const visited = new Set<Component>();
|
|
566
|
+
while (pending.length > 0) {
|
|
567
|
+
const component = pending.pop()!;
|
|
568
|
+
if (visited.has(component)) continue;
|
|
569
|
+
visited.add(component);
|
|
570
|
+
const container = component as Partial<Container>;
|
|
571
|
+
if (!Array.isArray(container.children)) continue;
|
|
572
|
+
if (!mountedEditor || !container.children.includes(mountedEditor) || typeof container.addChild !== "function") {
|
|
573
|
+
pending.push(...container.children);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const target = container as Container;
|
|
577
|
+
const original = target.addChild;
|
|
578
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, "addChild");
|
|
579
|
+
const patched: typeof original = function (this: Container, child) {
|
|
580
|
+
const candidate = child as Partial<EditorComponent>;
|
|
581
|
+
// Reused instances can have a new factory or render; unchanged dialog returns need no remount.
|
|
582
|
+
if (enabled && activeTui
|
|
583
|
+
&& (child !== mountedEditor || originalGetEditor.call(ui) !== mountedFactory || child.render !== mountedRender)
|
|
584
|
+
&& typeof candidate.getText === "function" && typeof candidate.setText === "function"
|
|
585
|
+
&& typeof candidate.handleInput === "function"
|
|
586
|
+
&& typeof candidate.onSubmit === "function" && candidate.onSubmit === mountedEditor?.onSubmit
|
|
587
|
+
&& typeof candidate.onChange === "function" && candidate.onChange === mountedEditor?.onChange) {
|
|
588
|
+
mountEditor(activeTui, candidate as EditorComponent);
|
|
589
|
+
}
|
|
590
|
+
original.call(this, child);
|
|
591
|
+
};
|
|
592
|
+
target.addChild = patched;
|
|
593
|
+
restoreEditorMount = () => {
|
|
594
|
+
if (target.addChild !== patched) return;
|
|
595
|
+
if (descriptor) Object.defineProperty(target, "addChild", descriptor);
|
|
596
|
+
else Reflect.deleteProperty(target, "addChild");
|
|
597
|
+
};
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
420
600
|
return {
|
|
601
|
+
isFramed: () => enabled && activeEditor !== undefined,
|
|
421
602
|
/** `build` receives the hidden-thinking text width and runs again whenever that width changes. */
|
|
422
603
|
setLatestHiddenThinkingLabel(build: (width: number) => string): void {
|
|
423
604
|
const tui = getActiveTui();
|
|
@@ -429,17 +610,28 @@ export function installEditor(
|
|
|
429
610
|
tui.requestRender();
|
|
430
611
|
},
|
|
431
612
|
resetHiddenThinkingLabelTarget: resetHiddenThinkingTarget,
|
|
613
|
+
getTui: (): TUI | undefined => activeTui,
|
|
432
614
|
setCursorStyle(nextCursorStyle: CursorStyle): void {
|
|
433
615
|
currentCursorStyle = nextCursorStyle;
|
|
434
616
|
activeEditor?.setCursorStyle(nextCursorStyle, previousHardwareCursor);
|
|
435
617
|
},
|
|
436
618
|
cleanup(): void {
|
|
619
|
+
if (!enabled) return;
|
|
620
|
+
enabled = false;
|
|
621
|
+
restoreEditorMount?.();
|
|
622
|
+
releaseEditor();
|
|
437
623
|
restoreStderr();
|
|
438
624
|
resetHiddenThinkingTarget();
|
|
439
|
-
|
|
440
|
-
if (
|
|
441
|
-
|
|
442
|
-
|
|
625
|
+
// 只恢复自己的代理;后来捕获的代理会在停用后透传到原接口。
|
|
626
|
+
if (ui.setEditorComponent === setEditor) ui.setEditorComponent = originalSetEditor;
|
|
627
|
+
if (ui.getEditorComponent === getEditor) ui.getEditorComponent = originalGetEditor;
|
|
628
|
+
const current = originalGetEditor.call(ui);
|
|
629
|
+
if (current === factory || current === mountedFactory) {
|
|
630
|
+
try {
|
|
631
|
+
originalSetEditor.call(ui, current === factory ? inheritedFactory : current);
|
|
632
|
+
} catch {
|
|
633
|
+
originalSetEditor.call(ui, undefined);
|
|
634
|
+
}
|
|
443
635
|
}
|
|
444
636
|
},
|
|
445
637
|
};
|
|
@@ -226,6 +226,7 @@ export interface FooterHooks {
|
|
|
226
226
|
setRequestRender: (fn: (() => void) | undefined) => void;
|
|
227
227
|
scheduleGitRefresh: () => void;
|
|
228
228
|
getLiveTps?: () => LiveTps | null;
|
|
229
|
+
canEmbedInEditor?: () => boolean;
|
|
229
230
|
}
|
|
230
231
|
|
|
231
232
|
interface FooterDataLike {
|
|
@@ -274,7 +275,7 @@ function fitInlineSegments(
|
|
|
274
275
|
parts,
|
|
275
276
|
Math.max(0, width - separatorReserve),
|
|
276
277
|
theme.fg("dim", "..."),
|
|
277
|
-
).join(separator);
|
|
278
|
+
).filter(Boolean).join(separator);
|
|
278
279
|
}
|
|
279
280
|
|
|
280
281
|
function renderFooterContent(
|
|
@@ -321,7 +322,7 @@ function renderFooterContent(
|
|
|
321
322
|
leftParts.push({
|
|
322
323
|
text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
|
|
323
324
|
compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
|
|
324
|
-
priority:
|
|
325
|
+
priority: 4,
|
|
325
326
|
truncate: (_text, maxWidth, ellipsis) => {
|
|
326
327
|
const pathWidth = maxWidth - visibleWidth(cwdPrefix);
|
|
327
328
|
if (pathWidth <= visibleWidth(ellipsis)) {
|
|
@@ -337,7 +338,7 @@ function renderFooterContent(
|
|
|
337
338
|
if (shortHost) {
|
|
338
339
|
leftParts.push({
|
|
339
340
|
text: `${theme.fg("dim", glyphs.host)} ${theme.fg("accent", shortHost)}`,
|
|
340
|
-
priority:
|
|
341
|
+
priority: 3,
|
|
341
342
|
});
|
|
342
343
|
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
343
344
|
}
|
|
@@ -355,13 +356,13 @@ function renderFooterContent(
|
|
|
355
356
|
}
|
|
356
357
|
const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
|
|
357
358
|
if (gitSeg) {
|
|
358
|
-
leftParts.push({ text: gitSeg, priority:
|
|
359
|
+
leftParts.push({ text: gitSeg, priority: 1 });
|
|
359
360
|
inlineTopLeftParts.push(leftParts.at(-1)!);
|
|
360
361
|
}
|
|
361
362
|
if (segments.runtime) {
|
|
362
363
|
const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode, segments.nerdIconSpacing);
|
|
363
364
|
if (runtimeSeg) {
|
|
364
|
-
leftParts.push({ text: runtimeSeg, priority:
|
|
365
|
+
leftParts.push({ text: runtimeSeg, priority: 0 });
|
|
365
366
|
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
366
367
|
}
|
|
367
368
|
}
|
|
@@ -374,7 +375,7 @@ function renderFooterContent(
|
|
|
374
375
|
? timerSeg
|
|
375
376
|
: `${timerSeg} ${theme.fg("dim", "·")} ${theme.fg(liveTps.stale ? "dim" : "accent", `${glyphs.speed} ~${Math.round(liveTps.tps)} tok/s`)}`;
|
|
376
377
|
// Narrow widths shed the live TPS before the timer itself.
|
|
377
|
-
leftParts.push({ text, compactText: liveTps === null ? undefined : timerSeg, priority:
|
|
378
|
+
leftParts.push({ text, compactText: liveTps === null ? undefined : timerSeg, priority: 3 });
|
|
378
379
|
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
379
380
|
}
|
|
380
381
|
|
|
@@ -391,15 +392,15 @@ function renderFooterContent(
|
|
|
391
392
|
}
|
|
392
393
|
const allParts: PrioritizedSegment[] = [...leftParts];
|
|
393
394
|
if (contextText) {
|
|
394
|
-
//
|
|
395
|
-
allParts.push({ text: contextText, compactText: contextCompact, priority:
|
|
395
|
+
// Context sheds with runtime, before git/session/host/timer/cwd.
|
|
396
|
+
allParts.push({ text: contextText, compactText: contextCompact, priority: 0 });
|
|
396
397
|
// Context stays at the far right; cwd is the first item in this group.
|
|
397
|
-
inlineTopRightParts.push({ text: contextText, compactText: contextCompact, priority:
|
|
398
|
+
inlineTopRightParts.push({ text: contextText, compactText: contextCompact, priority: 0 });
|
|
398
399
|
}
|
|
399
400
|
|
|
400
401
|
const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
|
|
401
402
|
const fittedContext = contextText ? fitted.pop() ?? "" : "";
|
|
402
|
-
const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
|
|
403
|
+
const line1 = alignRight(fitted.filter(Boolean).join(" "), fittedContext, width, theme);
|
|
403
404
|
|
|
404
405
|
const inlineLeftBudget = Math.floor(width * 0.45);
|
|
405
406
|
const inlineTopLeft = fitInlineSegments(inlineTopLeftParts, inlineLeftBudget, theme);
|
|
@@ -468,7 +469,8 @@ export function installFooter(
|
|
|
468
469
|
render(width: number): string[] {
|
|
469
470
|
if (width <= 0) return [""];
|
|
470
471
|
const { mainLines, extensionLines } = getContent(width);
|
|
471
|
-
|
|
472
|
+
const inline = getConfig().inlineFooter && (hooks.canEmbedInEditor?.() ?? true);
|
|
473
|
+
return inline ? extensionLines : [...mainLines, ...extensionLines];
|
|
472
474
|
},
|
|
473
475
|
};
|
|
474
476
|
});
|
|
@@ -58,7 +58,7 @@ export async function readGitStatus(
|
|
|
58
58
|
options: { readCommit?: boolean; readTag?: boolean; readCounts?: boolean } = {},
|
|
59
59
|
): Promise<GitStatus> {
|
|
60
60
|
const stdout = await gitExec(
|
|
61
|
-
["status", "--porcelain=v1", "--branch"
|
|
61
|
+
["status", "--porcelain=v1", "--branch"],
|
|
62
62
|
cwd,
|
|
63
63
|
);
|
|
64
64
|
if (stdout === null) {
|
|
@@ -67,7 +67,6 @@ export async function readGitStatus(
|
|
|
67
67
|
|
|
68
68
|
const status = emptyGitStatus();
|
|
69
69
|
const lines = stdout.split("\n");
|
|
70
|
-
let stashSupported = true;
|
|
71
70
|
|
|
72
71
|
for (const line of lines) {
|
|
73
72
|
if (line.startsWith("## ")) {
|
|
@@ -89,17 +88,7 @@ export async function readGitStatus(
|
|
|
89
88
|
continue;
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
|
|
93
|
-
const stashCount = parseInt(line.slice(8).trim(), 10);
|
|
94
|
-
if (!Number.isNaN(stashCount)) {
|
|
95
|
-
status.stashed = stashCount;
|
|
96
|
-
stashSupported = true;
|
|
97
|
-
}
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ponytail: skip file-entry counting when only branch/commit display is on;
|
|
102
|
-
// the first `git status` call itself can't be split, but the stash fallback below can.
|
|
91
|
+
// Skip file-entry counting and the separate stash query for branch-only display.
|
|
103
92
|
if (options.readCounts === false) continue;
|
|
104
93
|
if (line.length < 3) continue;
|
|
105
94
|
const x = line[0]!;
|
|
@@ -107,19 +96,21 @@ export async function readGitStatus(
|
|
|
107
96
|
|
|
108
97
|
if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) status.conflicted++;
|
|
109
98
|
else if (x === "?" && y === "?") status.untracked++;
|
|
110
|
-
else if (x === "R")
|
|
111
|
-
|
|
99
|
+
else if (x === "R") {
|
|
100
|
+
status.renamed++;
|
|
101
|
+
if (y === "M") status.modified++;
|
|
102
|
+
else if (y === "D") status.deleted++;
|
|
103
|
+
} else if (x === "D" || y === "D") status.deleted++;
|
|
112
104
|
else {
|
|
113
105
|
if (x !== " " && x !== "?") status.staged++;
|
|
114
106
|
if (y === "M" || y === "D") status.modified++;
|
|
115
107
|
}
|
|
116
108
|
}
|
|
117
109
|
|
|
118
|
-
if (options.readCounts !== false
|
|
119
|
-
const stashOut = await gitExec(["stash", "list", "--
|
|
110
|
+
if (options.readCounts !== false) {
|
|
111
|
+
const stashOut = await gitExec(["stash", "list", "--format=%H"], cwd);
|
|
120
112
|
if (stashOut !== null) {
|
|
121
|
-
|
|
122
|
-
if (!Number.isNaN(count)) status.stashed = count;
|
|
113
|
+
status.stashed = stashOut.split("\n").filter((line) => line.trim() !== "").length;
|
|
123
114
|
}
|
|
124
115
|
}
|
|
125
116
|
|
|
@@ -138,16 +129,3 @@ export async function readGitStatus(
|
|
|
138
129
|
|
|
139
130
|
return status;
|
|
140
131
|
}
|
|
141
|
-
|
|
142
|
-
export function hasGitChanges(s: GitStatus): boolean {
|
|
143
|
-
return (
|
|
144
|
-
s.modified > 0 ||
|
|
145
|
-
s.untracked > 0 ||
|
|
146
|
-
s.staged > 0 ||
|
|
147
|
-
s.conflicted > 0 ||
|
|
148
|
-
s.renamed > 0 ||
|
|
149
|
-
s.deleted > 0 ||
|
|
150
|
-
s.ahead > 0 ||
|
|
151
|
-
s.behind > 0
|
|
152
|
-
);
|
|
153
|
-
}
|