@buyi1net/pi-toolkit 0.0.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/LICENSE +21 -0
- package/README.md +45 -0
- package/assembler.ts +127 -0
- package/config.ts +201 -0
- package/i18n.ts +693 -0
- package/index.ts +99 -0
- package/menu/items.ts +264 -0
- package/menu/panels.ts +91 -0
- package/menu/settings-list.ts +84 -0
- package/menu/theme.ts +31 -0
- package/menu/toolkit-menu.ts +139 -0
- package/module.ts +156 -0
- package/modules/eyes/chain.ts +173 -0
- package/modules/eyes/config.ts +319 -0
- package/modules/eyes/index.ts +161 -0
- package/modules/eyes/menu.ts +828 -0
- package/modules/eyes/pi-model-backend.ts +245 -0
- package/modules/eyes/resilience.ts +161 -0
- package/modules/eyes/vision-bridge.ts +232 -0
- package/modules/eyes/vision-cache.ts +86 -0
- package/modules/eyes/vision-json.ts +19 -0
- package/modules/eyes/vision-preprocess.ts +192 -0
- package/modules/eyes/vision-probe.ts +21 -0
- package/modules/eyes/vision-prompt.ts +90 -0
- package/modules/eyes/vision-tool.ts +59 -0
- package/modules/eyes/vision-types.ts +33 -0
- package/modules/index.ts +10 -0
- package/modules/subagents/agents/researcher.md +51 -0
- package/modules/subagents/agents/scout.md +40 -0
- package/modules/subagents/agents/worker.md +79 -0
- package/modules/subagents/config.json.example +6 -0
- package/modules/subagents/config.ts +144 -0
- package/modules/subagents/index.ts +91 -0
- package/modules/subagents/menu.ts +388 -0
- package/modules/subagents/src/activity.ts +511 -0
- package/modules/subagents/src/agents.ts +126 -0
- package/modules/subagents/src/command.ts +37 -0
- package/modules/subagents/src/dependencies.ts +246 -0
- package/modules/subagents/src/diagnostics.ts +13 -0
- package/modules/subagents/src/display.ts +94 -0
- package/modules/subagents/src/headless.ts +342 -0
- package/modules/subagents/src/herdr.ts +203 -0
- package/modules/subagents/src/index.ts +2798 -0
- package/modules/subagents/src/inspect-tool.ts +839 -0
- package/modules/subagents/src/launch-config.ts +196 -0
- package/modules/subagents/src/layout-budget.ts +26 -0
- package/modules/subagents/src/list-tool.ts +292 -0
- package/modules/subagents/src/message-tool.ts +808 -0
- package/modules/subagents/src/names.ts +17 -0
- package/modules/subagents/src/pane-layout.ts +49 -0
- package/modules/subagents/src/params.ts +145 -0
- package/modules/subagents/src/renderers.ts +181 -0
- package/modules/subagents/src/result.ts +43 -0
- package/modules/subagents/src/retention.ts +114 -0
- package/modules/subagents/src/route-error.ts +212 -0
- package/modules/subagents/src/routing.ts +235 -0
- package/modules/subagents/src/runtime-registry.ts +159 -0
- package/modules/subagents/src/session.ts +801 -0
- package/modules/subagents/src/status.ts +513 -0
- package/modules/subagents/src/stop-tool.ts +235 -0
- package/modules/subagents/src/subagent-done.ts +590 -0
- package/modules/subagents/src/subagent-tool.ts +1155 -0
- package/modules/subagents/src/surface.ts +355 -0
- package/modules/subagents/src/team-dispatch-tool.ts +219 -0
- package/modules/subagents/src/team.ts +232 -0
- package/modules/subagents/src/tmux.ts +210 -0
- package/modules/subagents/src/tools/safe-bash.ts +72 -0
- package/modules/subagents/src/types.ts +131 -0
- package/modules/tui/adapter/provider-usage.ts +143 -0
- package/modules/tui/config.ts +50 -0
- package/modules/tui/index.ts +120 -0
- package/modules/tui/kernel/pkg/shared/grok-subscription.ts +169 -0
- package/modules/tui/kernel/pkg/shared/official-subscription.ts +237 -0
- package/modules/tui/kernel/pkg/shared/provider-catalog.ts +367 -0
- package/modules/tui/kernel/pkg/shared/provider-contracts.ts +150 -0
- package/modules/tui/kernel/pkg/shared/provider-display.ts +55 -0
- package/modules/tui/kernel/pkg/shared/provider-parsers.ts +171 -0
- package/modules/tui/kernel/pkg/shared/volcengine.ts +191 -0
- package/modules/tui/kernel/pkg/shared/zhipu.ts +149 -0
- package/modules/tui/kernel/pkg/usage-core/index.ts +335 -0
- package/modules/tui/kernel/pkg/usage-core/provider-routes.ts +187 -0
- package/modules/tui/kernel/pkg/usage-node/index.ts +635 -0
- package/modules/tui/kernel/pkg/usage-node/provider-usage.ts +388 -0
- package/modules/tui/kernel/usage-core.ts +2 -0
- package/modules/tui/kernel/usage-node.ts +2 -0
- package/modules/tui/menu.ts +418 -0
- package/modules/tui/plugin/editor.ts +396 -0
- package/modules/tui/plugin/footer.ts +161 -0
- package/modules/tui/plugin/index.ts +30 -0
- package/modules/tui/plugin/lifecycle.ts +637 -0
- package/modules/tui/plugin/package-order.ts +169 -0
- package/modules/tui/plugin/screen-transition.ts +203 -0
- package/modules/tui/plugin/settings-config.ts +297 -0
- package/modules/tui/plugin/status-sources.ts +49 -0
- package/modules/tui/plugin/transition-gate.ts +261 -0
- package/modules/tui/renderer/custom-header.ts +157 -0
- package/modules/tui/renderer/editor.ts +70 -0
- package/modules/tui/renderer/header.ts +72 -0
- package/modules/tui/renderer/icons.ts +149 -0
- package/modules/tui/renderer/pi-installer-logo.ts +194 -0
- package/modules/tui/status/auto-compaction.ts +67 -0
- package/modules/tui/status/project-status.ts +655 -0
- package/modules/tui/status/provider-status.ts +120 -0
- package/modules/tui/status/runtime-status.ts +307 -0
- package/modules/tui/status/session-status.ts +325 -0
- package/modules/tui/status/status-config.ts +95 -0
- package/modules/tui/status/status-segments.ts +263 -0
- package/modules/tui/status/turn-telemetry.ts +466 -0
- package/modules/tui/themes/LICENSE.pi-themes-bundle +21 -0
- package/modules/tui/themes/UPSTREAM.md +7 -0
- package/modules/tui/themes/catppuccin-latte.json +80 -0
- package/modules/tui/themes/catppuccin-mocha.json +79 -0
- package/modules/tui/themes/crimson-noir.json +85 -0
- package/modules/tui/themes/dracula.json +79 -0
- package/modules/tui/themes/everforest-dark.json +85 -0
- package/modules/tui/themes/gruvbox-dark.json +85 -0
- package/modules/tui/themes/gruvbox-light.json +85 -0
- package/modules/tui/themes/matrix.json +85 -0
- package/modules/tui/themes/nord.json +85 -0
- package/modules/tui/themes/one-dark.json +85 -0
- package/modules/tui/themes/rose-pine-dawn.json +85 -0
- package/modules/tui/themes/rose-pine.json +85 -0
- package/modules/tui/themes/solarized-dark.json +85 -0
- package/modules/tui/themes/solarized-light.json +85 -0
- package/modules/tui/themes/tokyo-night-storm.json +79 -0
- package/modules/tui/themes/tokyo-night.json +79 -0
- package/package.json +28 -0
- package/services.ts +38 -0
- package/toolkit.ts +147 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CustomEditor,
|
|
3
|
+
type ExtensionContext,
|
|
4
|
+
type KeybindingsManager,
|
|
5
|
+
type Theme,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
CURSOR_MARKER,
|
|
9
|
+
stripTerminalSequences,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
visibleWidth,
|
|
12
|
+
type Component,
|
|
13
|
+
type EditorTheme,
|
|
14
|
+
type OverlayHandle,
|
|
15
|
+
type OverlayMargin,
|
|
16
|
+
type OverlayOptions,
|
|
17
|
+
type TUI,
|
|
18
|
+
type TuiMainScreenRenderState,
|
|
19
|
+
} from "@earendil-works/pi-tui";
|
|
20
|
+
import type { UsageRuntimeState } from "../kernel/usage-node.ts";
|
|
21
|
+
import {
|
|
22
|
+
splitNativeEditorRender,
|
|
23
|
+
insertTopBorderStatus,
|
|
24
|
+
} from "../renderer/editor.ts";
|
|
25
|
+
import { resolveGlyphs, type IconGlyphs } from "../renderer/icons.ts";
|
|
26
|
+
import {
|
|
27
|
+
durationStatusColor,
|
|
28
|
+
formatElapsed,
|
|
29
|
+
layoutEditorStatus,
|
|
30
|
+
thinkingStatusColor,
|
|
31
|
+
type StatusSegment,
|
|
32
|
+
type TurnTimerSnapshot,
|
|
33
|
+
} from "../status/status-segments.ts";
|
|
34
|
+
import {
|
|
35
|
+
resolveStatusSettings,
|
|
36
|
+
type EditorLeftSegmentId,
|
|
37
|
+
type ResolvedStatusSettings,
|
|
38
|
+
} from "../status/status-config.ts";
|
|
39
|
+
import { buildEditorProviderSegments } from "../status/provider-status.ts";
|
|
40
|
+
import type {
|
|
41
|
+
EditorLayoutSource,
|
|
42
|
+
ProviderUsageSource,
|
|
43
|
+
SessionStatusSource,
|
|
44
|
+
StatusAppearance,
|
|
45
|
+
} from "./status-sources.ts";
|
|
46
|
+
import { isMainScreenTui, isSafeRenderState } from "./screen-transition.ts";
|
|
47
|
+
import { sanitizeSingleLine } from "../status/project-status.ts";
|
|
48
|
+
|
|
49
|
+
const MIN_DECORATED_WIDTH = 6;
|
|
50
|
+
|
|
51
|
+
function cropAutocompleteLines(lines: readonly string[], maxHeight: number): string[] {
|
|
52
|
+
const height = Math.max(0, Math.floor(maxHeight));
|
|
53
|
+
if (height === 0) return [];
|
|
54
|
+
if (lines.length <= height) return [...lines];
|
|
55
|
+
const selectedIndex = lines.findIndex((line) =>
|
|
56
|
+
stripTerminalSequences(line).trimStart().startsWith("→ "),
|
|
57
|
+
);
|
|
58
|
+
const targetIndex = selectedIndex >= 0 ? selectedIndex : 0;
|
|
59
|
+
const start = Math.max(
|
|
60
|
+
0,
|
|
61
|
+
Math.min(targetIndex - Math.floor((height - 1) / 2), lines.length - height),
|
|
62
|
+
);
|
|
63
|
+
return lines.slice(start, start + height);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatModel(ctx: ExtensionContext): string {
|
|
67
|
+
const model = ctx.model;
|
|
68
|
+
if (!model) return "no model";
|
|
69
|
+
return sanitizeSingleLine(model.provider ? `${model.provider}/${model.id}` : model.id) || "no model";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatCompactModel(ctx: ExtensionContext): string {
|
|
73
|
+
return sanitizeSingleLine(ctx.model?.id ?? "no model") || "no model";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getFrameStatus(
|
|
77
|
+
ctx: ExtensionContext,
|
|
78
|
+
theme: Theme,
|
|
79
|
+
glyphs: IconGlyphs,
|
|
80
|
+
width: number,
|
|
81
|
+
settings: ResolvedStatusSettings,
|
|
82
|
+
providerState: UsageRuntimeState | undefined,
|
|
83
|
+
timer: TurnTimerSnapshot,
|
|
84
|
+
): { left: string; right: string } {
|
|
85
|
+
const thinking = ctx.thinkingLevel ?? "off";
|
|
86
|
+
const color = (role: Parameters<Theme["fg"]>[0], text: string): string => theme.fg(role, text);
|
|
87
|
+
const provider = providerState
|
|
88
|
+
? buildEditorProviderSegments(providerState, theme)
|
|
89
|
+
: null;
|
|
90
|
+
const leftById: Record<EditorLeftSegmentId, StatusSegment> = {
|
|
91
|
+
provider: provider?.provider ?? {
|
|
92
|
+
id: "provider",
|
|
93
|
+
text: "",
|
|
94
|
+
priority: 4,
|
|
95
|
+
},
|
|
96
|
+
model: {
|
|
97
|
+
id: "model",
|
|
98
|
+
text: color("accent", formatCompactModel(ctx)),
|
|
99
|
+
compactText: color("accent", formatCompactModel(ctx)),
|
|
100
|
+
priority: 0,
|
|
101
|
+
required: true,
|
|
102
|
+
},
|
|
103
|
+
thinking: {
|
|
104
|
+
id: "thinking",
|
|
105
|
+
text: color(thinkingStatusColor(thinking), thinking),
|
|
106
|
+
priority: 2,
|
|
107
|
+
},
|
|
108
|
+
duration: {
|
|
109
|
+
id: "duration",
|
|
110
|
+
text: timer.state === "idle"
|
|
111
|
+
? ""
|
|
112
|
+
: color(durationStatusColor(timer.state), formatElapsed(timer.elapsedMs)),
|
|
113
|
+
priority: 1,
|
|
114
|
+
},
|
|
115
|
+
balance: provider?.balance ?? {
|
|
116
|
+
id: "balance",
|
|
117
|
+
text: "",
|
|
118
|
+
priority: 3,
|
|
119
|
+
},
|
|
120
|
+
subscription: provider?.subscription ?? {
|
|
121
|
+
id: "subscription",
|
|
122
|
+
text: "",
|
|
123
|
+
priority: 3,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const left = settings.editorLeft.map((id) => leftById[id]).filter((segment) => segment.text);
|
|
127
|
+
// 顶边右侧已让位:会话遥测整体移入 Footer 第二行,顶边只保留左侧身份段
|
|
128
|
+
return layoutEditorStatus(left, [], width);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface PiUiEditorHooks {
|
|
132
|
+
onReloadSubmit?: () => void;
|
|
133
|
+
onFrameRendered?: () => void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class PiUiEditor extends CustomEditor {
|
|
137
|
+
private readonly ctx: ExtensionContext;
|
|
138
|
+
private readonly getGlyphs: () => IconGlyphs;
|
|
139
|
+
private readonly settings: ResolvedStatusSettings;
|
|
140
|
+
private readonly getFooterHeight: () => number;
|
|
141
|
+
private readonly getProviderStatus: () => UsageRuntimeState | undefined;
|
|
142
|
+
private readonly getTimer: () => TurnTimerSnapshot;
|
|
143
|
+
private readonly appKeybindings: KeybindingsManager;
|
|
144
|
+
private readonly autocompleteOverlay: Component & { setLines(lines: readonly string[]): void };
|
|
145
|
+
private readonly autocompleteOverlayOptions: OverlayOptions;
|
|
146
|
+
private autocompleteOverlayLines: readonly string[] = [];
|
|
147
|
+
private autocompleteOverlayHandle: OverlayHandle | undefined;
|
|
148
|
+
private autocompleteOverlayToken = 0;
|
|
149
|
+
private frameHeight = 0;
|
|
150
|
+
private frameCursorRow = -1;
|
|
151
|
+
private suppressAutocompleteOverlay = false;
|
|
152
|
+
private disposed = false;
|
|
153
|
+
private readonly hooks: PiUiEditorHooks;
|
|
154
|
+
|
|
155
|
+
// 前四个参数由宿主编辑框工厂签名与基类要求保持原位;其余按数据源分组,
|
|
156
|
+
// 缺省时回落到与旧位置参数一致的默认行为。
|
|
157
|
+
constructor(
|
|
158
|
+
tui: TUI,
|
|
159
|
+
theme: EditorTheme,
|
|
160
|
+
keybindings: KeybindingsManager,
|
|
161
|
+
ctx: ExtensionContext,
|
|
162
|
+
appearance: StatusAppearance = {},
|
|
163
|
+
provider: ProviderUsageSource = {},
|
|
164
|
+
session: SessionStatusSource = {},
|
|
165
|
+
layout: EditorLayoutSource = {},
|
|
166
|
+
hooks: PiUiEditorHooks = {},
|
|
167
|
+
) {
|
|
168
|
+
super(tui, theme, keybindings);
|
|
169
|
+
this.ctx = ctx;
|
|
170
|
+
this.getGlyphs = appearance.getGlyphs ?? (() => resolveGlyphs("unicode"));
|
|
171
|
+
this.settings = appearance.settings ?? resolveStatusSettings({});
|
|
172
|
+
this.getFooterHeight = layout.getFooterHeight ?? (() => 0);
|
|
173
|
+
this.getProviderStatus = provider.getState ?? (() => undefined);
|
|
174
|
+
this.hooks = hooks;
|
|
175
|
+
this.getTimer = session.getTimer ?? (() => ({ state: "idle", elapsedMs: 0 }));
|
|
176
|
+
this.appKeybindings = keybindings;
|
|
177
|
+
this.autocompleteOverlay = {
|
|
178
|
+
setLines: (lines) => { this.autocompleteOverlayLines = [...lines]; },
|
|
179
|
+
invalidate() {},
|
|
180
|
+
render: (overlayWidth) => this.autocompleteOverlayLines.map((line) => {
|
|
181
|
+
const clipped = truncateToWidth(line, overlayWidth, "");
|
|
182
|
+
return `${clipped}${" ".repeat(Math.max(0, overlayWidth - visibleWidth(clipped)))}`;
|
|
183
|
+
}),
|
|
184
|
+
};
|
|
185
|
+
const margin: OverlayMargin = {};
|
|
186
|
+
// Footer 高度会随遥测和供应商数据变化;Overlay 在合成阶段读取最新值,
|
|
187
|
+
// 始终贴在输入框上方,不进入正文布局,也不覆盖输入框和 Footer。
|
|
188
|
+
Object.defineProperty(margin, "bottom", {
|
|
189
|
+
enumerable: true,
|
|
190
|
+
get: () => this.frameHeight + Math.max(0, this.getFooterHeight()),
|
|
191
|
+
});
|
|
192
|
+
this.autocompleteOverlayOptions = {
|
|
193
|
+
anchor: "bottom-left",
|
|
194
|
+
width: 1,
|
|
195
|
+
margin,
|
|
196
|
+
nonCapturing: true,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
override render(width: number): string[] {
|
|
201
|
+
if (width < MIN_DECORATED_WIDTH) {
|
|
202
|
+
const baseLines = super.render(width);
|
|
203
|
+
return this.finishRender(this.renderWithAutocompleteOverlay(baseLines, baseLines, width));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const baseLines = super.render(width);
|
|
207
|
+
const renderedLines = insertTopBorderStatus(baseLines, {
|
|
208
|
+
left: getFrameStatus(
|
|
209
|
+
this.ctx,
|
|
210
|
+
this.ctx.ui.theme,
|
|
211
|
+
this.getGlyphs(),
|
|
212
|
+
width,
|
|
213
|
+
this.settings,
|
|
214
|
+
this.getProviderStatus(),
|
|
215
|
+
this.getTimer(),
|
|
216
|
+
).left,
|
|
217
|
+
borderColor: (text) => this.borderColor(text),
|
|
218
|
+
});
|
|
219
|
+
return this.finishRender(this.renderWithAutocompleteOverlay(baseLines, renderedLines, width));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
override handleInput(data: string): void {
|
|
223
|
+
const submitsInput = this.appKeybindings.matches(data, "tui.input.submit");
|
|
224
|
+
const submitsAutocomplete =
|
|
225
|
+
this.tui.mode === "regular" &&
|
|
226
|
+
this.autocompleteOverlayHandle &&
|
|
227
|
+
submitsInput;
|
|
228
|
+
if (!submitsAutocomplete) {
|
|
229
|
+
if (submitsInput && this.getText().trim() === "/reload") this.hooks.onReloadSubmit?.();
|
|
230
|
+
super.handleInput(data);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 补全确认和命令提交发生在同一次同步调用中。先撤下 Overlay,但不提交旧 Editor 帧;
|
|
235
|
+
// 等父类给出补全后的完整命令,再决定进入 reload 过渡或恢复普通底图。
|
|
236
|
+
const originalOnSubmit = this.onSubmit;
|
|
237
|
+
let didSubmit = false;
|
|
238
|
+
this.suppressAutocompleteOverlay = true;
|
|
239
|
+
this.hideAutocompleteOverlay();
|
|
240
|
+
this.onSubmit = (text) => {
|
|
241
|
+
didSubmit = true;
|
|
242
|
+
if (text.trim() === "/reload") this.hooks.onReloadSubmit?.();
|
|
243
|
+
else this.tui.renderNow(false);
|
|
244
|
+
originalOnSubmit?.(text);
|
|
245
|
+
};
|
|
246
|
+
try {
|
|
247
|
+
super.handleInput(data);
|
|
248
|
+
if (!didSubmit) this.tui.renderNow(false);
|
|
249
|
+
} finally {
|
|
250
|
+
this.onSubmit = originalOnSubmit;
|
|
251
|
+
this.suppressAutocompleteOverlay = false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
dispose(): void {
|
|
256
|
+
if (this.disposed) return;
|
|
257
|
+
this.disposed = true;
|
|
258
|
+
this.hideAutocompleteOverlay();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private finishRender(lines: string[]): string[] {
|
|
262
|
+
this.hooks.onFrameRendered?.();
|
|
263
|
+
return lines;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private renderWithAutocompleteOverlay(
|
|
267
|
+
baseLines: readonly string[],
|
|
268
|
+
renderedLines: readonly string[],
|
|
269
|
+
width: number,
|
|
270
|
+
): string[] {
|
|
271
|
+
const autocompleteCount = splitNativeEditorRender(baseLines).autocomplete.length;
|
|
272
|
+
const frameEnd = Math.max(0, renderedLines.length - autocompleteCount);
|
|
273
|
+
const frameLines = renderedLines.slice(0, frameEnd);
|
|
274
|
+
this.frameHeight = frameLines.length;
|
|
275
|
+
this.frameCursorRow = frameLines.findIndex((line) => line.includes(CURSOR_MARKER));
|
|
276
|
+
|
|
277
|
+
if (this.tui.mode !== "regular") {
|
|
278
|
+
this.updateAutocompleteOverlay([], width);
|
|
279
|
+
return [...renderedLines];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const autocompleteLines = this.suppressAutocompleteOverlay ? [] : renderedLines.slice(frameEnd);
|
|
283
|
+
const layout = autocompleteLines.length > 0
|
|
284
|
+
? this.resolveAutocompleteOverlayLayout(autocompleteLines.length)
|
|
285
|
+
: undefined;
|
|
286
|
+
if (layout?.maxHeight === 0) {
|
|
287
|
+
// Editor 已贴到可见区顶部时没有安全的覆盖空间,保留原生内联补全。
|
|
288
|
+
this.updateAutocompleteOverlay([], width);
|
|
289
|
+
return [...renderedLines];
|
|
290
|
+
}
|
|
291
|
+
this.updateAutocompleteOverlay(autocompleteLines, width, layout);
|
|
292
|
+
return frameLines;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private hideAutocompleteOverlay(): void {
|
|
296
|
+
this.autocompleteOverlayToken += 1;
|
|
297
|
+
this.autocompleteOverlay.setLines([]);
|
|
298
|
+
delete this.autocompleteOverlayOptions.row;
|
|
299
|
+
this.autocompleteOverlayHandle?.hide();
|
|
300
|
+
this.autocompleteOverlayHandle = undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private updateAutocompleteOverlay(
|
|
304
|
+
lines: readonly string[],
|
|
305
|
+
width: number,
|
|
306
|
+
initialLayout = this.resolveAutocompleteOverlayLayout(lines.length),
|
|
307
|
+
): void {
|
|
308
|
+
const initialLines = initialLayout
|
|
309
|
+
? cropAutocompleteLines(lines, initialLayout.maxHeight)
|
|
310
|
+
: [...lines];
|
|
311
|
+
this.setAutocompleteOverlayLines(initialLines);
|
|
312
|
+
this.setAutocompleteOverlayRow(initialLayout);
|
|
313
|
+
this.autocompleteOverlayOptions.width = Math.max(1, width);
|
|
314
|
+
const token = ++this.autocompleteOverlayToken;
|
|
315
|
+
if (initialLines.length > 0) {
|
|
316
|
+
// 当前正文渲染结束后再定位和挂载,避免在组件 render 中读取旧坐标或修改 Overlay 栈。
|
|
317
|
+
queueMicrotask(() => {
|
|
318
|
+
if (this.disposed || token !== this.autocompleteOverlayToken) return;
|
|
319
|
+
const layout = this.resolveAutocompleteOverlayLayout(lines.length);
|
|
320
|
+
const visibleLines = layout
|
|
321
|
+
? cropAutocompleteLines(lines, layout.maxHeight)
|
|
322
|
+
: [...lines];
|
|
323
|
+
const linesChanged = this.setAutocompleteOverlayLines(visibleLines);
|
|
324
|
+
const positionChanged = this.setAutocompleteOverlayRow(layout);
|
|
325
|
+
if (visibleLines.length === 0) {
|
|
326
|
+
this.autocompleteOverlayHandle?.hide();
|
|
327
|
+
this.autocompleteOverlayHandle = undefined;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (!this.autocompleteOverlayHandle) {
|
|
331
|
+
this.autocompleteOverlayHandle = this.tui.showOverlay(
|
|
332
|
+
this.autocompleteOverlay,
|
|
333
|
+
this.autocompleteOverlayOptions,
|
|
334
|
+
);
|
|
335
|
+
} else if (positionChanged || linesChanged) {
|
|
336
|
+
this.tui.requestRender();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (lines.length === 0 && this.autocompleteOverlayHandle) {
|
|
342
|
+
queueMicrotask(() => {
|
|
343
|
+
if (token !== this.autocompleteOverlayToken || !this.autocompleteOverlayHandle) return;
|
|
344
|
+
this.autocompleteOverlayHandle.hide();
|
|
345
|
+
this.autocompleteOverlayHandle = undefined;
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private resolveAutocompleteOverlayLayout(
|
|
351
|
+
autocompleteHeight: number,
|
|
352
|
+
): { row: number; maxHeight: number } | undefined {
|
|
353
|
+
if (!isMainScreenTui(this.tui) || this.frameCursorRow < 0) {
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let state: TuiMainScreenRenderState;
|
|
358
|
+
try {
|
|
359
|
+
state = this.tui.captureRenderState();
|
|
360
|
+
} catch {
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
if (!isSafeRenderState(this.tui, state)) return undefined;
|
|
364
|
+
|
|
365
|
+
const editorTop = state.hardwareCursorRow - this.frameCursorRow - state.previousViewportTop;
|
|
366
|
+
const maxHeight = Math.max(0, Math.min(editorTop, this.tui.terminal.rows));
|
|
367
|
+
return {
|
|
368
|
+
row: Math.max(0, editorTop - Math.min(autocompleteHeight, maxHeight)),
|
|
369
|
+
maxHeight,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private setAutocompleteOverlayLines(lines: readonly string[]): boolean {
|
|
374
|
+
if (
|
|
375
|
+
lines.length === this.autocompleteOverlayLines.length &&
|
|
376
|
+
lines.every((line, index) => line === this.autocompleteOverlayLines[index])
|
|
377
|
+
) {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
this.autocompleteOverlay.setLines(lines);
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private setAutocompleteOverlayRow(
|
|
385
|
+
layout: { row: number; maxHeight: number } | undefined,
|
|
386
|
+
): boolean {
|
|
387
|
+
if (!layout) {
|
|
388
|
+
if (this.autocompleteOverlayOptions.row === undefined) return false;
|
|
389
|
+
delete this.autocompleteOverlayOptions.row;
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
if (this.autocompleteOverlayOptions.row === layout.row) return false;
|
|
393
|
+
this.autocompleteOverlayOptions.row = layout.row;
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { ContextUsage, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
truncateToWidth,
|
|
4
|
+
visibleWidth,
|
|
5
|
+
type Component,
|
|
6
|
+
type TUI,
|
|
7
|
+
} from "@earendil-works/pi-tui";
|
|
8
|
+
import type { UsageRuntimeState } from "../kernel/usage-node.ts";
|
|
9
|
+
import { formatLeadingIcon, resolveGlyphs, type IconGlyphs } from "../renderer/icons.ts";
|
|
10
|
+
import {
|
|
11
|
+
renderProjectStatusLine,
|
|
12
|
+
sanitizeStyledSingleLine,
|
|
13
|
+
} from "../status/project-status.ts";
|
|
14
|
+
import type { TurnTimerSnapshot } from "../status/status-segments.ts";
|
|
15
|
+
import {
|
|
16
|
+
buildEditorUsageSegments,
|
|
17
|
+
renderStatusLineSegments,
|
|
18
|
+
type SessionStatusSnapshot,
|
|
19
|
+
} from "../status/session-status.ts";
|
|
20
|
+
import {
|
|
21
|
+
resolveStatusSettings,
|
|
22
|
+
type ResolvedStatusSettings,
|
|
23
|
+
} from "../status/status-config.ts";
|
|
24
|
+
import type {
|
|
25
|
+
FooterLayoutSource,
|
|
26
|
+
ProjectEnvironmentSource,
|
|
27
|
+
SessionStatusSource,
|
|
28
|
+
StatusAppearance,
|
|
29
|
+
} from "./status-sources.ts";
|
|
30
|
+
|
|
31
|
+
const FOOTER_PADDING_X = 1;
|
|
32
|
+
|
|
33
|
+
/** 生命周期钩子:卸载前的宿主过渡动作,不属于任何数据源,单独成组。 */
|
|
34
|
+
export interface ProjectStatusFooterHooks {
|
|
35
|
+
beforeDispose?: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class ProjectStatusFooter implements Component {
|
|
39
|
+
private readonly theme: Theme;
|
|
40
|
+
private readonly footerData: ReadonlyFooterDataProvider;
|
|
41
|
+
private readonly getProjectStatus: ProjectEnvironmentSource["getProjectStatus"];
|
|
42
|
+
private readonly cwd: string;
|
|
43
|
+
private readonly beforeDispose: (() => void) | undefined;
|
|
44
|
+
private readonly getGlyphs: () => IconGlyphs;
|
|
45
|
+
private readonly settings: ResolvedStatusSettings;
|
|
46
|
+
private readonly getRuntimeStatus: ProjectEnvironmentSource["getRuntimeStatus"];
|
|
47
|
+
private readonly reportHeight: ((height: number) => void) | undefined;
|
|
48
|
+
private readonly getTimer: () => TurnTimerSnapshot;
|
|
49
|
+
private readonly getSessionStatus: () => SessionStatusSnapshot;
|
|
50
|
+
private readonly getContextUsage: () => ContextUsage | undefined;
|
|
51
|
+
private readonly getContextWindow: () => number | undefined;
|
|
52
|
+
private readonly getAutoCompactionEnabled: () => boolean;
|
|
53
|
+
private disposed = false;
|
|
54
|
+
|
|
55
|
+
// 宿主回调参数(tui/theme/footerData)保持原位;其余入参按数据源分组,
|
|
56
|
+
// 缺省时回落到与旧位置参数一致的默认行为。
|
|
57
|
+
constructor(
|
|
58
|
+
_tui: TUI,
|
|
59
|
+
theme: Theme,
|
|
60
|
+
footerData: ReadonlyFooterDataProvider,
|
|
61
|
+
appearance: StatusAppearance = {},
|
|
62
|
+
project: ProjectEnvironmentSource = {},
|
|
63
|
+
session: SessionStatusSource = {},
|
|
64
|
+
layout: FooterLayoutSource = {},
|
|
65
|
+
hooks: ProjectStatusFooterHooks = {},
|
|
66
|
+
) {
|
|
67
|
+
this.theme = theme;
|
|
68
|
+
this.footerData = footerData;
|
|
69
|
+
this.getGlyphs = appearance.getGlyphs ?? (() => resolveGlyphs("unicode"));
|
|
70
|
+
this.settings = appearance.settings ?? resolveStatusSettings({});
|
|
71
|
+
this.getProjectStatus = project.getProjectStatus;
|
|
72
|
+
this.getRuntimeStatus = project.getRuntimeStatus;
|
|
73
|
+
this.cwd = project.cwd ?? process.cwd();
|
|
74
|
+
this.reportHeight = layout.reportHeight;
|
|
75
|
+
this.getTimer = session.getTimer ?? (() => ({ state: "idle", elapsedMs: 0 }));
|
|
76
|
+
this.getSessionStatus = session.getSessionStatus ?? (() => ({
|
|
77
|
+
sessionId: "",
|
|
78
|
+
inputTokens: 0,
|
|
79
|
+
outputTokens: 0,
|
|
80
|
+
cacheReadTokens: 0,
|
|
81
|
+
cacheWriteTokens: 0,
|
|
82
|
+
cost: 0,
|
|
83
|
+
turns: 0,
|
|
84
|
+
compactions: 0,
|
|
85
|
+
}));
|
|
86
|
+
this.getContextUsage = session.getContextUsage ?? (() => undefined);
|
|
87
|
+
this.getContextWindow = session.getContextWindow ?? (() => undefined);
|
|
88
|
+
this.getAutoCompactionEnabled = session.getAutoCompactionEnabled ?? (() => false);
|
|
89
|
+
this.beforeDispose = hooks.beforeDispose;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
invalidate(): void {}
|
|
93
|
+
|
|
94
|
+
render(width: number): string[] {
|
|
95
|
+
const glyphs = this.getGlyphs();
|
|
96
|
+
const paddingX = width >= FOOTER_PADDING_X * 2 + 1 ? FOOTER_PADDING_X : 0;
|
|
97
|
+
const contentWidth = Math.max(1, width - paddingX * 2);
|
|
98
|
+
const projectLine = renderProjectStatusLine(
|
|
99
|
+
{
|
|
100
|
+
// 快照未接线时仍保留占位,保证 Footer 结构从首帧起定型。
|
|
101
|
+
...(this.getProjectStatus?.() ?? { cwd: this.cwd, branch: null }),
|
|
102
|
+
runtime: this.getRuntimeStatus?.(),
|
|
103
|
+
duration: this.getTimer(),
|
|
104
|
+
},
|
|
105
|
+
contentWidth,
|
|
106
|
+
this.theme,
|
|
107
|
+
undefined,
|
|
108
|
+
glyphs,
|
|
109
|
+
this.settings.footerPrimary,
|
|
110
|
+
);
|
|
111
|
+
// 第二行:会话遥测(↑↓ R/CH ctx),行首 usage 图标作视觉锚点。
|
|
112
|
+
// 上下文未就绪时不展示零值 token 段,但保留 Context、auto、轮数与压缩次数。
|
|
113
|
+
const contextUsage = this.getContextUsage();
|
|
114
|
+
const usageSegments = buildEditorUsageSegments(
|
|
115
|
+
this.getSessionStatus(),
|
|
116
|
+
contextUsage,
|
|
117
|
+
this.getContextWindow(),
|
|
118
|
+
this.theme,
|
|
119
|
+
glyphs,
|
|
120
|
+
contextUsage === undefined
|
|
121
|
+
? this.settings.footerUsage.filter((segment) => segment === "context")
|
|
122
|
+
: this.settings.footerUsage,
|
|
123
|
+
this.getAutoCompactionEnabled(),
|
|
124
|
+
);
|
|
125
|
+
// 行首图标属于状态行的一部分,必须先从可用宽度中扣除;最后的截断
|
|
126
|
+
// 只是防御性兜底,避免宽字符或第三方状态文本再次把整行顶出终端。
|
|
127
|
+
const usageIconText = formatLeadingIcon(glyphs.usage);
|
|
128
|
+
const usageIconWidth = visibleWidth(usageIconText);
|
|
129
|
+
const usageSegmentsWidth = Math.max(0, contentWidth - usageIconWidth);
|
|
130
|
+
const renderedUsageSegments = usageSegmentsWidth > 0
|
|
131
|
+
? renderStatusLineSegments(usageSegments, usageSegmentsWidth, this.theme.fg("muted", " · "))
|
|
132
|
+
: "";
|
|
133
|
+
const usageLine = usageSegments.length > 0
|
|
134
|
+
? truncateToWidth(
|
|
135
|
+
`${this.theme.fg("muted", usageIconText)}${renderedUsageSegments}`,
|
|
136
|
+
contentWidth,
|
|
137
|
+
)
|
|
138
|
+
: "";
|
|
139
|
+
const showExtensions = this.settings.footerExtra.includes("extensions");
|
|
140
|
+
const statuses = showExtensions
|
|
141
|
+
? [...this.footerData.getExtensionStatuses().entries()]
|
|
142
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
143
|
+
.map(([, status]) => sanitizeStyledSingleLine(status))
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
: [];
|
|
146
|
+
const lines = projectLine ? [projectLine] : [];
|
|
147
|
+
if (usageLine) lines.push(usageLine);
|
|
148
|
+
if (statuses.length > 0) {
|
|
149
|
+
lines.push(truncateToWidth(statuses.join(" · "), contentWidth, this.theme.fg("dim", "…")));
|
|
150
|
+
}
|
|
151
|
+
this.reportHeight?.(lines.length);
|
|
152
|
+
return paddingX > 0 ? lines.map((line) => `${" ".repeat(paddingX)}${line}`) : lines;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
dispose(): void {
|
|
156
|
+
if (this.disposed) return;
|
|
157
|
+
this.disposed = true;
|
|
158
|
+
this.beforeDispose?.();
|
|
159
|
+
this.reportHeight?.(0);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerPiTuiLifecycle, type PiTuiPluginDependencies } from "./lifecycle.ts";
|
|
3
|
+
import type { VisibleScreenOutput } from "./screen-transition.ts";
|
|
4
|
+
|
|
5
|
+
// 视图与机制的稳定出口:测试和下游从这里 import,物理位置变化不影响调用方。
|
|
6
|
+
export { PiUiEditor, formatModel } from "./editor.ts";
|
|
7
|
+
export { ProjectStatusFooter } from "./footer.ts";
|
|
8
|
+
export {
|
|
9
|
+
flashVisibleScreen,
|
|
10
|
+
restoreVisibleMainScreen,
|
|
11
|
+
} from "./screen-transition.ts";
|
|
12
|
+
export { AutoCompactionStatusController } from "../status/auto-compaction.ts";
|
|
13
|
+
export type { PiTuiPluginDependencies } from "./lifecycle.ts";
|
|
14
|
+
// 数据源分组接口是视图构造器的公开面,随视图一起从稳定出口导出。
|
|
15
|
+
export type {
|
|
16
|
+
EditorLayoutSource,
|
|
17
|
+
FooterLayoutSource,
|
|
18
|
+
ProjectEnvironmentSource,
|
|
19
|
+
ProviderUsageSource,
|
|
20
|
+
SessionStatusSource,
|
|
21
|
+
StatusAppearance,
|
|
22
|
+
} from "./status-sources.ts";
|
|
23
|
+
|
|
24
|
+
export default function (
|
|
25
|
+
pi: ExtensionAPI,
|
|
26
|
+
output: VisibleScreenOutput = process.stdout,
|
|
27
|
+
dependencies: PiTuiPluginDependencies = {},
|
|
28
|
+
): void {
|
|
29
|
+
registerPiTuiLifecycle(pi, output, dependencies);
|
|
30
|
+
}
|