pi-open-tui 0.4.0 → 0.4.2

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.
@@ -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 { EditorTheme, TuiMouseEvent, TuiMouseEventResult, TUI } from "@earendil-works/pi-tui";
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
- export class OpenTuiEditor extends CustomEditor {
204
- readonly embedWorkingStatus = true;
205
- private readonly getRail: () => string;
206
- private readonly getBorder: (s: string) => string;
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
- override handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
236
- if (event.width < EDITOR_FRAME_HORIZONTAL_CHROME) return super.handleMouse(event);
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
- return super.handleMouse({
239
- ...event,
240
- x: event.x - EDITOR_FRAME_LEFT_INSET,
241
- width: event.width - EDITOR_FRAME_HORIZONTAL_CHROME,
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
- setWorkingStatusIndicator(indicator: WorkingStatusIndicator | undefined): void {
246
- this.embeddedWorkingStatusIndicator = indicator;
247
- this.tui.requestRender();
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
- setCursorStyle(cursorStyle: CursorStyle, blockHardwareCursor = false): void {
251
- const styleChanged = cursorStyle !== this.cursorStyle;
252
- this.previewHardwareCursor = cursorStyle !== "block";
253
- this.cursorStyle = cursorStyle;
254
- if (styleChanged) {
255
- if (cursorStyle === "block") {
256
- this.tui.terminal.write(DEFAULT_CURSOR_STYLE_SEQUENCE);
257
- this.tui.setShowHardwareCursor(blockHardwareCursor);
258
- } else {
259
- configureCursor(this.tui, cursorStyle);
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
- private renderBase(width: number): string[] {
266
- const renderedLines = super.render(width);
267
- if (this.cursorStyle === "block") return renderedLines;
268
-
269
- // Pi re-applies settings.showHardwareCursor after session_start (/reload,
270
- // /new, session switches). Non-block styles have no software cursor left to
271
- // fall back on, so re-assert the hardware cursor instead of losing it.
272
- if (!this.tui.getShowHardwareCursor()) configureCursor(this.tui, this.cursorStyle);
273
-
274
- // A focused overlay suppresses the editor's cursor marker. Preserve its
275
- // position only for the live settings preview, then clear it on refocus.
276
- let cursorMarker = this.previewHardwareCursor && !this.focused ? CURSOR_MARKER : "";
277
- if (this.focused) this.previewHardwareCursor = false;
278
- return renderedLines.map((line) => {
279
- const rendered = removeSoftwareCursor(line, cursorMarker);
280
- if (rendered !== line) cursorMarker = "";
281
- return rendered;
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
- render(width: number): string[] {
286
- if (width < EDITOR_FRAME_HORIZONTAL_CHROME) return this.renderBase(width);
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
- result.push(inlineFooter
313
- ? inlineBorder(width, "bottom", borderPaint, (budget) => renderInlineLine("bottom", budget), baseLines[bottomIdx])
314
- : roundedBorder(width, "bottom", borderPaint, baseLines[bottomIdx]));
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
- for (let i = bottomIdx + 1; i < baseLines.length; i++) {
317
- result.push(baseLines[i]!);
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
- return result.map((line) => truncateToWidth(line, width, ""));
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: OpenTuiEditor | undefined;
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.setEditorComponent((tui, editorTheme, keybindings) => {
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
- previousHardwareCursor = tui.getShowHardwareCursor();
417
- activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle, inlineFooter);
418
- return activeEditor;
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
- ctx.ui.setEditorComponent(undefined);
440
- if (activeTui) {
441
- if (currentCursorStyle !== "block") activeTui.terminal.write(DEFAULT_CURSOR_STYLE_SEQUENCE);
442
- if (previousHardwareCursor !== undefined) activeTui.setShowHardwareCursor(previousHardwareCursor);
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: 0,
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: 1,
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: 3 });
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: 4 });
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: 1 });
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
- // ponytail: priority 4 = sheds with runtime, before git/timer/cwd.
395
- allParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
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: 4 });
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
- return getConfig().inlineFooter ? extensionLines : [...mainLines, ...extensionLines];
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", "--show-stash"],
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
- if (line.startsWith("# stash ")) {
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") status.renamed++;
111
- else if (x === "D" || y === "D") status.deleted++;
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 && stashSupported && status.stashed === 0 && !stdout.includes("# stash")) {
119
- const stashOut = await gitExec(["stash", "list", "--count"], cwd);
110
+ if (options.readCounts !== false) {
111
+ const stashOut = await gitExec(["stash", "list", "--format=%H"], cwd);
120
112
  if (stashOut !== null) {
121
- const count = parseInt(stashOut.trim(), 10);
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
- }