mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(coding-agent): embed all session status spinners in the editor border
This commit is contained in:
parent
aa23e784c6
commit
c1d4c80111
7 changed files with 124 additions and 39 deletions
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
### Changed
|
||||
|
||||
- Moved compaction, branch summarization, and retry spinners into the editor border alongside the working indicator. Custom editors use the same embedding opt-in for all status spinners.
|
||||
- Enabled strict-prefer JSON-schema sampling by default for built-in `read`, `bash`, `powershell`, `edit`, and `write` tools, without requiring `PI_EXPERIMENTAL`. Extensions can re-register tool definitions with `constrainedSampling: false`.
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -930,7 +930,7 @@ export default function (pi: ExtensionAPI) {
|
|||
|
||||
- **Extend `CustomEditor`** (not base `Editor`) to get app keybindings (escape to abort, ctrl+d to exit, model switching, etc.)
|
||||
- **Call `super.handleInput(data)`** for keys you don't handle
|
||||
- **Working status**: custom editors keep the standalone working row by default. Pass `{ embedWorkingStatus: true }` as the fourth `CustomEditor` constructor argument to use the built-in editor-border spinner instead.
|
||||
- **Status spinners**: custom editors keep standalone status rows by default. Pass `{ embedWorkingStatus: true }` as the fourth `CustomEditor` constructor argument to embed working, compaction, branch summarization, and retry spinners in the editor border instead.
|
||||
- **Factory pattern**: `setEditorComponent` receives a factory function that gets `tui`, `theme`, and `keybindings`
|
||||
- **Pass `undefined`** to restore the default editor: `ctx.ui.setEditorComponent(undefined)`
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Editor, type EditorOptions, type EditorTheme, type TUI, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.ts";
|
||||
import type { WorkingStatusIndicator } from "./status-indicator.ts";
|
||||
import type { StatusIndicator } from "./status-indicator.ts";
|
||||
|
||||
export type CustomEditorOptions = EditorOptions & {
|
||||
/** Render the streaming working status in the editor's top border. */
|
||||
/** Render working, compaction, summarization, and retry status in the editor's top border. */
|
||||
embedWorkingStatus?: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ export type CustomEditorOptions = EditorOptions & {
|
|||
*/
|
||||
export class CustomEditor extends Editor {
|
||||
private keybindings: KeybindingsManager;
|
||||
private workingStatusIndicator: WorkingStatusIndicator | undefined;
|
||||
private workingStatusIndicator: StatusIndicator | undefined;
|
||||
public readonly embedWorkingStatus: boolean;
|
||||
public actionHandlers: Map<AppKeybinding, () => void> = new Map();
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ export class CustomEditor extends Editor {
|
|||
this.embedWorkingStatus = options?.embedWorkingStatus ?? false;
|
||||
}
|
||||
|
||||
setWorkingStatusIndicator(indicator: WorkingStatusIndicator | undefined): void {
|
||||
setWorkingStatusIndicator(indicator: StatusIndicator | undefined): void {
|
||||
this.workingStatusIndicator = indicator;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ export class StatusIndicator extends Loader {
|
|||
this.kind = kind;
|
||||
}
|
||||
|
||||
renderInBorder(width: number): string {
|
||||
const line = super.render(width + 2)[1] ?? "";
|
||||
return truncateToWidth(line.startsWith(" ") ? line.slice(1).trimEnd() : line.trimEnd(), width, "");
|
||||
}
|
||||
|
||||
renderSpinnerInBorder(width: number): string {
|
||||
return truncateToWidth(this.getRenderedIndicator(), width, "");
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
}
|
||||
|
|
@ -37,15 +46,6 @@ export class WorkingStatusIndicator extends StatusIndicator {
|
|||
indicator,
|
||||
);
|
||||
}
|
||||
|
||||
renderInBorder(width: number): string {
|
||||
const line = super.render(width + 2)[1] ?? "";
|
||||
return truncateToWidth(line.startsWith(" ") ? line.slice(1).trimEnd() : line.trimEnd(), width, "");
|
||||
}
|
||||
|
||||
renderSpinnerInBorder(width: number): string {
|
||||
return truncateToWidth(this.getRenderedIndicator(), width, "");
|
||||
}
|
||||
}
|
||||
|
||||
export class RetryStatusIndicator extends StatusIndicator {
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ interface Expandable {
|
|||
|
||||
interface WorkingStatusEditor extends EditorComponent {
|
||||
readonly embedWorkingStatus: boolean;
|
||||
setWorkingStatusIndicator(indicator: WorkingStatusIndicator | undefined): void;
|
||||
setWorkingStatusIndicator(indicator: StatusIndicator | undefined): void;
|
||||
}
|
||||
|
||||
function isWorkingStatusEditor(editor: EditorComponent): editor is WorkingStatusEditor {
|
||||
|
|
@ -2092,7 +2092,7 @@ export class InteractiveMode {
|
|||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private setEditorWorkingStatusIndicator(indicator: WorkingStatusIndicator | undefined): boolean {
|
||||
private setEditorWorkingStatusIndicator(indicator: StatusIndicator | undefined): boolean {
|
||||
this.defaultEditor.setWorkingStatusIndicator(undefined);
|
||||
if (!isWorkingStatusEditor(this.editor)) return false;
|
||||
this.editor.setWorkingStatusIndicator(indicator);
|
||||
|
|
@ -2105,7 +2105,7 @@ export class InteractiveMode {
|
|||
this.activeWorkingIndicatorEmbedded = false;
|
||||
this.statusContainer.clear();
|
||||
this.setEditorWorkingStatusIndicator(undefined);
|
||||
if (indicator instanceof WorkingStatusIndicator && this.setEditorWorkingStatusIndicator(indicator)) {
|
||||
if (this.setEditorWorkingStatusIndicator(indicator)) {
|
||||
this.activeWorkingIndicatorEmbedded = true;
|
||||
return;
|
||||
}
|
||||
|
|
@ -2117,7 +2117,7 @@ export class InteractiveMode {
|
|||
return;
|
||||
}
|
||||
const clearedIndicator = this.activeStatusIndicator;
|
||||
const clearedIndicatorWasEmbedded = clearedIndicator?.kind === "working" && this.activeWorkingIndicatorEmbedded;
|
||||
const clearedIndicatorWasEmbedded = this.activeWorkingIndicatorEmbedded;
|
||||
clearedIndicator?.dispose();
|
||||
this.activeStatusIndicator = undefined;
|
||||
this.activeWorkingIndicatorEmbedded = false;
|
||||
|
|
@ -2717,7 +2717,7 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
this.editorContainer.addChild(this.editor as Component);
|
||||
if (this.activeStatusIndicator instanceof WorkingStatusIndicator) {
|
||||
if (this.activeStatusIndicator) {
|
||||
this.statusContainer.clear();
|
||||
this.activeWorkingIndicatorEmbedded = this.setEditorWorkingStatusIndicator(this.activeStatusIndicator);
|
||||
if (!this.activeWorkingIndicatorEmbedded) {
|
||||
|
|
@ -4170,9 +4170,7 @@ export class InteractiveMode {
|
|||
const level = this.session.thinkingLevel || "off";
|
||||
this.editor.borderColor = theme.getThinkingBorderColor(level);
|
||||
}
|
||||
if (this.activeStatusIndicator?.kind === "working") {
|
||||
this.activeStatusIndicator.invalidate();
|
||||
}
|
||||
this.activeStatusIndicator?.invalidate();
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
|
||||
import { KeybindingsManager } from "../src/core/keybindings.ts";
|
||||
import type { FullscreenExitOutput, TuiMode } from "../src/core/settings-manager.ts";
|
||||
import {
|
||||
BranchSummaryStatusIndicator,
|
||||
CompactionStatusIndicator,
|
||||
RetryStatusIndicator,
|
||||
type StatusIndicator,
|
||||
type StatusIndicatorKind,
|
||||
WorkingStatusIndicator,
|
||||
} from "../src/modes/interactive/components/status-indicator.ts";
|
||||
import {
|
||||
createInteractiveTui,
|
||||
createInteractiveTuiReference,
|
||||
|
|
@ -345,11 +353,11 @@ describe("InteractiveMode copy confirmation", () => {
|
|||
|
||||
type StatusEditor = {
|
||||
embedWorkingStatus: boolean;
|
||||
setWorkingStatusIndicator: (indicator: undefined) => void;
|
||||
setWorkingStatusIndicator: (indicator: StatusIndicator | undefined) => void;
|
||||
};
|
||||
|
||||
type ClearStatusContext = {
|
||||
activeStatusIndicator: { kind: "working" | "retry"; dispose: () => void } | undefined;
|
||||
activeStatusIndicator: { kind: StatusIndicatorKind; dispose: () => void } | undefined;
|
||||
activeWorkingIndicatorEmbedded: boolean;
|
||||
statusContainer: Container;
|
||||
defaultEditor: StatusEditor;
|
||||
|
|
@ -357,39 +365,83 @@ type ClearStatusContext = {
|
|||
options: { tuiMode?: TuiMode };
|
||||
ui: { getClearOnShrink: () => boolean };
|
||||
idleStatus: Component;
|
||||
setEditorWorkingStatusIndicator(indicator: undefined): boolean;
|
||||
setEditorWorkingStatusIndicator(indicator: StatusIndicator | undefined): boolean;
|
||||
};
|
||||
|
||||
type InteractiveModePrototype = {
|
||||
clearStatusIndicator(this: ClearStatusContext, kind?: "working" | "retry"): void;
|
||||
setEditorWorkingStatusIndicator(this: ClearStatusContext, indicator: undefined): boolean;
|
||||
showStatusIndicator(this: ClearStatusContext, indicator: StatusIndicator): void;
|
||||
clearStatusIndicator(this: ClearStatusContext, kind?: StatusIndicatorKind): void;
|
||||
setEditorWorkingStatusIndicator(this: ClearStatusContext, indicator: StatusIndicator | undefined): boolean;
|
||||
};
|
||||
|
||||
const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype;
|
||||
|
||||
describe("clear-on-shrink status spacing", () => {
|
||||
it("does not reserve separate status height for the editor-border working indicator", () => {
|
||||
const dispose = vi.fn();
|
||||
const editor: StatusEditor = { embedWorkingStatus: true, setWorkingStatusIndicator: vi.fn() };
|
||||
it.each([true, false])("routes every status through the editor opt-in (%s)", (embedWorkingStatus) => {
|
||||
initTheme("dark");
|
||||
const tui = { requestRender: vi.fn() } as unknown as TUI;
|
||||
const editor: StatusEditor = { embedWorkingStatus, setWorkingStatusIndicator: vi.fn() };
|
||||
const context: ClearStatusContext = {
|
||||
activeStatusIndicator: { kind: "working", dispose },
|
||||
activeWorkingIndicatorEmbedded: true,
|
||||
activeStatusIndicator: undefined,
|
||||
activeWorkingIndicatorEmbedded: false,
|
||||
statusContainer: new Container(),
|
||||
defaultEditor: editor,
|
||||
defaultEditor: { embedWorkingStatus: true, setWorkingStatusIndicator: vi.fn() },
|
||||
editor,
|
||||
options: { tuiMode: "regular" },
|
||||
ui: { getClearOnShrink: () => true },
|
||||
idleStatus: new Text("", 0, 0),
|
||||
setEditorWorkingStatusIndicator: interactiveModePrototype.setEditorWorkingStatusIndicator,
|
||||
};
|
||||
|
||||
interactiveModePrototype.clearStatusIndicator.call(context);
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(editor.setWorkingStatusIndicator).toHaveBeenCalledWith(undefined);
|
||||
expect(context.statusContainer.children).toHaveLength(0);
|
||||
const indicators = [
|
||||
new WorkingStatusIndicator(tui, "Working"),
|
||||
new CompactionStatusIndicator(tui, "manual"),
|
||||
new CompactionStatusIndicator(tui, "threshold"),
|
||||
new CompactionStatusIndicator(tui, "overflow"),
|
||||
new BranchSummaryStatusIndicator(tui),
|
||||
new RetryStatusIndicator(tui, 1, 3, 1000),
|
||||
];
|
||||
try {
|
||||
for (const indicator of indicators) {
|
||||
interactiveModePrototype.showStatusIndicator.call(context, indicator);
|
||||
expect(context.activeStatusIndicator).toBe(indicator);
|
||||
expect(context.activeWorkingIndicatorEmbedded).toBe(embedWorkingStatus);
|
||||
if (embedWorkingStatus) {
|
||||
expect(editor.setWorkingStatusIndicator).toHaveBeenLastCalledWith(indicator);
|
||||
expect(context.statusContainer.children).toHaveLength(0);
|
||||
} else {
|
||||
expect(context.statusContainer.children).toEqual([indicator]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (const indicator of indicators) indicator.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each<StatusIndicatorKind>(["working", "compaction", "branchSummary", "retry"])(
|
||||
"does not reserve separate status height for an embedded %s indicator",
|
||||
(kind) => {
|
||||
const dispose = vi.fn();
|
||||
const editor: StatusEditor = { embedWorkingStatus: true, setWorkingStatusIndicator: vi.fn() };
|
||||
const context: ClearStatusContext = {
|
||||
activeStatusIndicator: { kind, dispose },
|
||||
activeWorkingIndicatorEmbedded: true,
|
||||
statusContainer: new Container(),
|
||||
defaultEditor: editor,
|
||||
editor,
|
||||
options: { tuiMode: "regular" },
|
||||
ui: { getClearOnShrink: () => true },
|
||||
idleStatus: new Text("", 0, 0),
|
||||
setEditorWorkingStatusIndicator: interactiveModePrototype.setEditorWorkingStatusIndicator,
|
||||
};
|
||||
|
||||
interactiveModePrototype.clearStatusIndicator.call(context);
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(editor.setWorkingStatusIndicator).toHaveBeenCalledWith(undefined);
|
||||
expect(context.statusContainer.children).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the standalone row for a custom editor that has not opted in", () => {
|
||||
for (const [tuiMode, expectedChildren] of [
|
||||
["regular", 1],
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||
import { KeybindingsManager } from "../src/core/keybindings.ts";
|
||||
import { CustomEditor } from "../src/modes/interactive/components/custom-editor.ts";
|
||||
import {
|
||||
BranchSummaryStatusIndicator,
|
||||
CompactionStatusIndicator,
|
||||
IdleStatus,
|
||||
RetryStatusIndicator,
|
||||
WorkingStatusIndicator,
|
||||
|
|
@ -61,6 +63,38 @@ describe("status indicators", () => {
|
|||
indicator.dispose();
|
||||
});
|
||||
|
||||
it("embeds compaction, summary, and retry labels within the border width", () => {
|
||||
initTheme("dark");
|
||||
vi.useFakeTimers();
|
||||
const tui = { requestRender: vi.fn(), terminal: { rows: 10 } } as unknown as TUI;
|
||||
const editor = new CustomEditor(tui, getEditorTheme(), KeybindingsManager.create(), {
|
||||
embedWorkingStatus: true,
|
||||
});
|
||||
const indicators = [
|
||||
new CompactionStatusIndicator(tui, "manual"),
|
||||
new CompactionStatusIndicator(tui, "threshold"),
|
||||
new CompactionStatusIndicator(tui, "overflow"),
|
||||
new BranchSummaryStatusIndicator(tui),
|
||||
new RetryStatusIndicator(tui, 1, 3, 3000),
|
||||
];
|
||||
try {
|
||||
for (const indicator of indicators) {
|
||||
editor.setWorkingStatusIndicator(indicator);
|
||||
const label = stripAnsi(indicator.render(120)[1]!).trim();
|
||||
expect(stripAnsi(editor.render(120)[0]!)).toContain(`── ${label} `);
|
||||
for (const width of [1, 4, 10, 20, 80, 120]) {
|
||||
expect(visibleWidth(editor.render(width)[0]!)).toBe(width);
|
||||
}
|
||||
}
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(stripAnsi(editor.render(120)[0]!)).toContain("Retrying (1/3) in 2s");
|
||||
editor.setWorkingStatusIndicator(undefined);
|
||||
expect(stripAnsi(editor.render(120)[0]!)).toBe("─".repeat(120));
|
||||
} finally {
|
||||
for (const indicator of indicators) indicator.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("disposes retry countdown updates", () => {
|
||||
initTheme("dark");
|
||||
vi.useFakeTimers();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue