pi-tui-footer 1.2.0
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 +56 -0
- package/extensions/config.ts +196 -0
- package/extensions/editor.ts +295 -0
- package/extensions/footer.ts +344 -0
- package/extensions/fullscreen-scroll.ts +34 -0
- package/extensions/git.ts +151 -0
- package/extensions/header.ts +244 -0
- package/extensions/icons.ts +190 -0
- package/extensions/index.ts +457 -0
- package/extensions/peek.ts +168 -0
- package/extensions/rounded-tools.ts +269 -0
- package/extensions/runtime.ts +174 -0
- package/extensions/session-lifecycle.ts +43 -0
- package/extensions/settings-command.ts +511 -0
- package/extensions/state.ts +104 -0
- package/extensions/telemetry.ts +303 -0
- package/extensions/utils.ts +381 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 onenora
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# pi-tui-footer
|
|
2
|
+
|
|
3
|
+
pi 插件:TUI 美化套件 — 动画 Pi logo 头部、Starship 风格底部状态栏、圆角编辑器、圆角工具框、回合遥测(TPS/TTFT/耗时/token/费用)。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install git:github.com/onenora/pi-tui-footer
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
固定版本:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install git:github.com/onenora/pi-tui-footer@v1.2.0
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
更新:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi update --extensions
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 配置
|
|
24
|
+
|
|
25
|
+
配置文件位于 `~/.pi/agent/pi-tui.json`,示例:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"enabled": true,
|
|
30
|
+
"roundedTools": true,
|
|
31
|
+
"settingsLanguage": "zh",
|
|
32
|
+
"cursorStyle": "underline",
|
|
33
|
+
"fullscreen": { "wheelScrollLines": 4 },
|
|
34
|
+
"icons": { "mode": "nerd" }
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
也可在 pi 内通过 `/pi-tui` 命令调整。
|
|
39
|
+
|
|
40
|
+
## 致谢
|
|
41
|
+
|
|
42
|
+
本项目是以下两个开源项目的深度融合集成:
|
|
43
|
+
|
|
44
|
+
- [pi-open-tui](https://github.com/OldSuns/pi-open-tui) — 头部、底部、编辑器、遥测和设置界面的整体结构
|
|
45
|
+
- [pi-rounded-tools](https://github.com/orionpax1997/pi-rounded-tools) — 圆角工具调用/结果框
|
|
46
|
+
|
|
47
|
+
## 结构
|
|
48
|
+
|
|
49
|
+
- `extensions/` — 全部插件源码(TypeScript,pi 直接加载)
|
|
50
|
+
- `LICENSE` — MIT 许可证
|
|
51
|
+
|
|
52
|
+
仅依赖 pi 内置模块(`@earendil-works/pi-*` peer)与 Node 内置模块,无第三方运行时依赖。
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
|
|
6
|
+
normalizeFullscreenWheelScrollLines,
|
|
7
|
+
} from "./fullscreen-scroll.ts";
|
|
8
|
+
import type { IconMode } from "./icons.ts";
|
|
9
|
+
|
|
10
|
+
export type SettingsLanguage = "en" | "zh";
|
|
11
|
+
export type CursorStyle = "block" | "bar" | "underline";
|
|
12
|
+
export type ThinkingPeekLines = 0 | 1 | 2;
|
|
13
|
+
|
|
14
|
+
export type { IconMode } from "./icons.ts";
|
|
15
|
+
|
|
16
|
+
export interface FooterSegments {
|
|
17
|
+
cwd: boolean;
|
|
18
|
+
hostname: boolean;
|
|
19
|
+
sessionName: boolean;
|
|
20
|
+
gitBranch: boolean;
|
|
21
|
+
gitStatus: boolean;
|
|
22
|
+
gitCommit: boolean;
|
|
23
|
+
runtime: boolean;
|
|
24
|
+
context: boolean;
|
|
25
|
+
tokens: boolean;
|
|
26
|
+
cost: boolean;
|
|
27
|
+
extensionStatuses: boolean;
|
|
28
|
+
capitalizeProviderName: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TelemetryConfig {
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
tps: boolean;
|
|
34
|
+
ttft: boolean;
|
|
35
|
+
duration: boolean;
|
|
36
|
+
tokens: boolean;
|
|
37
|
+
stalls: boolean;
|
|
38
|
+
cost: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ThinkingPeekConfig {
|
|
42
|
+
lines: ThinkingPeekLines;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FullscreenConfig {
|
|
46
|
+
wheelScrollLines: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface OpenTuiConfig {
|
|
50
|
+
enabled: boolean;
|
|
51
|
+
roundedTools: boolean;
|
|
52
|
+
settingsLanguage: SettingsLanguage;
|
|
53
|
+
cursorStyle: CursorStyle;
|
|
54
|
+
fullscreen: FullscreenConfig;
|
|
55
|
+
icons: {
|
|
56
|
+
mode: IconMode;
|
|
57
|
+
};
|
|
58
|
+
footerSegments: FooterSegments;
|
|
59
|
+
telemetry: TelemetryConfig;
|
|
60
|
+
thinkingPeek: ThinkingPeekConfig;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type PiTuiConfig = OpenTuiConfig;
|
|
64
|
+
|
|
65
|
+
export const DEFAULT_CONFIG: OpenTuiConfig = {
|
|
66
|
+
enabled: true,
|
|
67
|
+
roundedTools: true,
|
|
68
|
+
settingsLanguage: "en",
|
|
69
|
+
cursorStyle: "block",
|
|
70
|
+
fullscreen: {
|
|
71
|
+
wheelScrollLines: DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
|
|
72
|
+
},
|
|
73
|
+
icons: {
|
|
74
|
+
mode: "auto",
|
|
75
|
+
},
|
|
76
|
+
footerSegments: {
|
|
77
|
+
cwd: true,
|
|
78
|
+
hostname: false,
|
|
79
|
+
sessionName: false,
|
|
80
|
+
gitBranch: true,
|
|
81
|
+
gitStatus: true,
|
|
82
|
+
gitCommit: false,
|
|
83
|
+
runtime: true,
|
|
84
|
+
context: true,
|
|
85
|
+
tokens: true,
|
|
86
|
+
cost: true,
|
|
87
|
+
extensionStatuses: true,
|
|
88
|
+
capitalizeProviderName: true,
|
|
89
|
+
},
|
|
90
|
+
telemetry: {
|
|
91
|
+
enabled: true,
|
|
92
|
+
tps: true,
|
|
93
|
+
ttft: true,
|
|
94
|
+
duration: true,
|
|
95
|
+
tokens: true,
|
|
96
|
+
stalls: true,
|
|
97
|
+
cost: true,
|
|
98
|
+
},
|
|
99
|
+
thinkingPeek: {
|
|
100
|
+
lines: 1,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function getConfigPath(): string {
|
|
105
|
+
const agentDir = getAgentDir();
|
|
106
|
+
const piTuiPath = join(agentDir, "pi-tui.json");
|
|
107
|
+
const openTuiPath = join(agentDir, "open-tui.json");
|
|
108
|
+
if (existsSync(piTuiPath)) return piTuiPath;
|
|
109
|
+
if (existsSync(openTuiPath)) return openTuiPath;
|
|
110
|
+
return piTuiPath;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeThinkingPeekLines(value: unknown): ThinkingPeekLines {
|
|
114
|
+
return value === 0 || value === 1 || value === 2 ? value : DEFAULT_CONFIG.thinkingPeek.lines;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function deepMerge<T>(base: T, override: unknown): T {
|
|
118
|
+
if (typeof base !== "object" || base === null || Array.isArray(base)) {
|
|
119
|
+
return (override as T) ?? base;
|
|
120
|
+
}
|
|
121
|
+
if (typeof override !== "object" || override === null || Array.isArray(override)) {
|
|
122
|
+
return base;
|
|
123
|
+
}
|
|
124
|
+
const result = { ...(base as Record<string, unknown>) };
|
|
125
|
+
const overrideRec = override as Record<string, unknown>;
|
|
126
|
+
for (const key of Object.keys(overrideRec)) {
|
|
127
|
+
const baseVal = (base as Record<string, unknown>)[key];
|
|
128
|
+
const overVal = overrideRec[key];
|
|
129
|
+
if (typeof baseVal === "object" && baseVal !== null && !Array.isArray(baseVal)
|
|
130
|
+
&& typeof overVal === "object" && overVal !== null && !Array.isArray(overVal)) {
|
|
131
|
+
result[key] = deepMerge(baseVal, overVal);
|
|
132
|
+
} else if (overVal !== undefined) {
|
|
133
|
+
result[key] = overVal;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return result as T;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function ensureConfigExists(): void {
|
|
140
|
+
const path = getConfigPath();
|
|
141
|
+
if (existsSync(path)) return;
|
|
142
|
+
try {
|
|
143
|
+
const agentDir = getAgentDir();
|
|
144
|
+
if (!existsSync(agentDir)) mkdirSync(agentDir, { recursive: true });
|
|
145
|
+
writeFileSync(path, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf8");
|
|
146
|
+
} catch {
|
|
147
|
+
// ponytail: silent fallback — config creation is best-effort
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function loadConfig(notify?: (msg: string, level: "warning" | "info") => void): OpenTuiConfig {
|
|
152
|
+
const path = getConfigPath();
|
|
153
|
+
if (!existsSync(path)) {
|
|
154
|
+
ensureConfigExists();
|
|
155
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const raw = readFileSync(path, "utf8");
|
|
160
|
+
const parsed: unknown = JSON.parse(raw);
|
|
161
|
+
const config = deepMerge(DEFAULT_CONFIG, parsed);
|
|
162
|
+
if (config.settingsLanguage !== "en" && config.settingsLanguage !== "zh") {
|
|
163
|
+
config.settingsLanguage = DEFAULT_CONFIG.settingsLanguage;
|
|
164
|
+
}
|
|
165
|
+
if (config.cursorStyle !== "block" && config.cursorStyle !== "bar" && config.cursorStyle !== "underline") {
|
|
166
|
+
config.cursorStyle = DEFAULT_CONFIG.cursorStyle;
|
|
167
|
+
}
|
|
168
|
+
config.fullscreen.wheelScrollLines = normalizeFullscreenWheelScrollLines(
|
|
169
|
+
config.fullscreen.wheelScrollLines,
|
|
170
|
+
DEFAULT_CONFIG.fullscreen.wheelScrollLines,
|
|
171
|
+
);
|
|
172
|
+
if (typeof config.thinkingPeek !== "object" || config.thinkingPeek === null || Array.isArray(config.thinkingPeek)) {
|
|
173
|
+
config.thinkingPeek = structuredClone(DEFAULT_CONFIG.thinkingPeek);
|
|
174
|
+
} else {
|
|
175
|
+
config.thinkingPeek.lines = normalizeThinkingPeekLines(config.thinkingPeek.lines);
|
|
176
|
+
}
|
|
177
|
+
if (typeof config.roundedTools !== "boolean") {
|
|
178
|
+
config.roundedTools = DEFAULT_CONFIG.roundedTools;
|
|
179
|
+
}
|
|
180
|
+
return config;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
notify?.(`pi-tui config parse error: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
183
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function saveConfig(config: OpenTuiConfig): void {
|
|
188
|
+
const path = getConfigPath();
|
|
189
|
+
try {
|
|
190
|
+
const agentDir = getAgentDir();
|
|
191
|
+
if (!existsSync(agentDir)) mkdirSync(agentDir, { recursive: true });
|
|
192
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
193
|
+
} catch {
|
|
194
|
+
// ponytail: silent fallback — config save is best-effort
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CustomEditor,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
type KeybindingsManager,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
8
|
+
import { CURSOR_MARKER, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import type { CursorStyle } from "./config.ts";
|
|
10
|
+
import {
|
|
11
|
+
applyFullscreenWheelScrollLines,
|
|
12
|
+
DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
|
|
13
|
+
} from "./fullscreen-scroll.ts";
|
|
14
|
+
import { findBottomBorderIndex, isEditorBorderLine, stripAnsi } from "./utils.ts";
|
|
15
|
+
|
|
16
|
+
function fillLine(content: string, width: number): string {
|
|
17
|
+
const truncated = truncateToWidth(content, Math.max(0, width), "");
|
|
18
|
+
const pad = " ".repeat(Math.max(0, width - visibleWidth(truncated)));
|
|
19
|
+
return `${truncated}${pad}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CURSOR_STYLE_SEQUENCES: Partial<Record<CursorStyle, string>> = {
|
|
23
|
+
bar: "\x1b[6 q",
|
|
24
|
+
underline: "\x1b[4 q",
|
|
25
|
+
};
|
|
26
|
+
const DEFAULT_CURSOR_STYLE_SEQUENCE = "\x1b[0 q";
|
|
27
|
+
|
|
28
|
+
interface WorkingStatusIndicator {
|
|
29
|
+
renderInBorder(width: number): string;
|
|
30
|
+
renderSpinnerInBorder(width: number): string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface HiddenThinkingLabelComponent {
|
|
34
|
+
children: unknown[];
|
|
35
|
+
setHiddenThinkingLabel(label: string): void;
|
|
36
|
+
setHideThinkingBlock(hide: boolean): void;
|
|
37
|
+
updateContent(message: unknown, isStreaming?: boolean): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isHiddenThinkingLabelComponent(value: object): value is HiddenThinkingLabelComponent {
|
|
41
|
+
const component = value as unknown as Record<string, unknown>;
|
|
42
|
+
return Array.isArray(component.children)
|
|
43
|
+
&& typeof component.setHiddenThinkingLabel === "function"
|
|
44
|
+
&& typeof component.setHideThinkingBlock === "function"
|
|
45
|
+
&& typeof component.updateContent === "function";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Ponytail until Pi exposes a per-message hidden-thinking label API. */
|
|
49
|
+
function findLatestHiddenThinkingLabel(tui: TUI): HiddenThinkingLabelComponent {
|
|
50
|
+
const pending: unknown[] = [tui];
|
|
51
|
+
const visited = new Set<object>();
|
|
52
|
+
let latest: HiddenThinkingLabelComponent | undefined;
|
|
53
|
+
while (pending.length > 0) {
|
|
54
|
+
const value = pending.pop();
|
|
55
|
+
if (!value || typeof value !== "object" || visited.has(value)) continue;
|
|
56
|
+
visited.add(value);
|
|
57
|
+
if (isHiddenThinkingLabelComponent(value)) latest = value;
|
|
58
|
+
const children = (value as { children?: unknown }).children;
|
|
59
|
+
if (!Array.isArray(children)) continue;
|
|
60
|
+
for (let i = children.length - 1; i >= 0; i--) pending.push(children[i]);
|
|
61
|
+
}
|
|
62
|
+
if (!latest) throw new Error("Unable to find the active hidden-thinking component");
|
|
63
|
+
return latest;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function removeSoftwareCursor(line: string, cursorMarker = ""): string {
|
|
67
|
+
return line.replace(/\x1b\[7m([\s\S]*?)\x1b\[0m/g, (_match, cursor: string) => {
|
|
68
|
+
const replacement = `${cursorMarker}${cursor}`;
|
|
69
|
+
cursorMarker = "";
|
|
70
|
+
return replacement;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function configureCursor(tui: TUI, cursorStyle: CursorStyle): void {
|
|
75
|
+
if (cursorStyle === "block") return;
|
|
76
|
+
tui.setShowHardwareCursor(true);
|
|
77
|
+
const sequence = CURSOR_STYLE_SEQUENCES[cursorStyle];
|
|
78
|
+
if (sequence) tui.terminal.write(sequence);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function roundedBorder(
|
|
82
|
+
width: number,
|
|
83
|
+
kind: "top" | "bottom",
|
|
84
|
+
paint: (s: string) => string,
|
|
85
|
+
sourceLine?: string,
|
|
86
|
+
indicator?: WorkingStatusIndicator,
|
|
87
|
+
): string {
|
|
88
|
+
if (width < 2) return paint(truncateToWidth(kind === "top" ? "╭╮" : "╰╯", width, ""));
|
|
89
|
+
const corners = kind === "top" ? (["╭", "╮"] as const) : (["╰", "╯"] as const);
|
|
90
|
+
|
|
91
|
+
if (kind === "top" && indicator) {
|
|
92
|
+
const plain = sourceLine ? stripAnsi(sourceLine) : "";
|
|
93
|
+
const scrollMatch = plain.match(/([↑↓]\s+\d+\s+more)/);
|
|
94
|
+
const contentWidth = width - 2;
|
|
95
|
+
let status = indicator.renderInBorder(Math.max(1, contentWidth - 5));
|
|
96
|
+
let statusWidth = visibleWidth(status);
|
|
97
|
+
if (statusWidth > 0) {
|
|
98
|
+
const overflowLabel = scrollMatch ? ` ${scrollMatch[1]} ` : undefined;
|
|
99
|
+
const overflowLabelWidth = overflowLabel ? visibleWidth(overflowLabel) : 0;
|
|
100
|
+
const overflowStart = Math.floor((contentWidth - overflowLabelWidth) / 2);
|
|
101
|
+
const canFitOverflow = () => overflowLabel !== undefined
|
|
102
|
+
&& overflowLabelWidth + 2 <= contentWidth
|
|
103
|
+
&& overflowStart - (3 + statusWidth + 1) >= 1;
|
|
104
|
+
if (overflowLabel && !canFitOverflow()) {
|
|
105
|
+
status = indicator.renderSpinnerInBorder(contentWidth);
|
|
106
|
+
statusWidth = visibleWidth(status);
|
|
107
|
+
}
|
|
108
|
+
if (canFitOverflow()) {
|
|
109
|
+
const leftBlockWidth = 3 + statusWidth + 1;
|
|
110
|
+
return `${paint(`${corners[0]}── `)}${status}${paint(` ${"─".repeat(overflowStart - leftBlockWidth)}${overflowLabel}${"─".repeat(contentWidth - overflowStart - overflowLabelWidth)}${corners[1]}`)}`;
|
|
111
|
+
}
|
|
112
|
+
if (contentWidth >= statusWidth + 5) {
|
|
113
|
+
return `${paint(`${corners[0]}── `)}${status}${paint(` ${"─".repeat(contentWidth - statusWidth - 4)}${corners[1]}`)}`;
|
|
114
|
+
}
|
|
115
|
+
status = indicator.renderSpinnerInBorder(contentWidth);
|
|
116
|
+
statusWidth = visibleWidth(status);
|
|
117
|
+
const prefixWidth = Math.min(3, Math.max(0, contentWidth - statusWidth));
|
|
118
|
+
return `${paint(`${corners[0]}${"─".repeat(prefixWidth)}`)}${status}${paint(`${"─".repeat(Math.max(0, contentWidth - prefixWidth - statusWidth))}${corners[1]}`)}`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (sourceLine) {
|
|
123
|
+
const plain = stripAnsi(sourceLine);
|
|
124
|
+
const scrollMatch = plain.match(/([↑↓]\s+\d+\s+more)/);
|
|
125
|
+
if (scrollMatch) {
|
|
126
|
+
const label = `─── ${scrollMatch[1]} `;
|
|
127
|
+
const fill = Math.max(0, width - 2 - visibleWidth(label));
|
|
128
|
+
return paint(`${corners[0]}${label}${"─".repeat(fill)}${corners[1]}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return paint(`${corners[0]}${"─".repeat(Math.max(0, width - 2))}${corners[1]}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class OpenTuiEditor extends CustomEditor {
|
|
136
|
+
readonly embedWorkingStatus = true;
|
|
137
|
+
private readonly getRail: () => string;
|
|
138
|
+
private readonly getBorder: (s: string) => string;
|
|
139
|
+
private embeddedWorkingStatusIndicator: WorkingStatusIndicator | undefined;
|
|
140
|
+
private cursorStyle: CursorStyle;
|
|
141
|
+
private previewHardwareCursor = false;
|
|
142
|
+
|
|
143
|
+
constructor(
|
|
144
|
+
tui: TUI,
|
|
145
|
+
editorTheme: EditorTheme,
|
|
146
|
+
keybindings: KeybindingsManager,
|
|
147
|
+
cursorStyle: CursorStyle = "block",
|
|
148
|
+
) {
|
|
149
|
+
super(tui, editorTheme, keybindings, { paddingX: 0 });
|
|
150
|
+
this.cursorStyle = cursorStyle;
|
|
151
|
+
configureCursor(tui, cursorStyle);
|
|
152
|
+
// ponytail: route the frame through this.borderColor so Pi can recolor it
|
|
153
|
+
// via updateEditorBorderColor() — bash mode ("! " prefix → green) and
|
|
154
|
+
// thinking-level borders both flow through this one property.
|
|
155
|
+
this.getRail = () => this.borderColor("│");
|
|
156
|
+
this.getBorder = (s: string) => this.borderColor(s);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
override setPaddingX(_padding: number): void {
|
|
160
|
+
// The custom rail owns the horizontal inset and keeps one stable text gap.
|
|
161
|
+
super.setPaddingX(0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
setWorkingStatusIndicator(indicator: WorkingStatusIndicator | undefined): void {
|
|
165
|
+
this.embeddedWorkingStatusIndicator = indicator;
|
|
166
|
+
this.tui.requestRender();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
setCursorStyle(cursorStyle: CursorStyle, blockHardwareCursor = false): void {
|
|
170
|
+
const styleChanged = cursorStyle !== this.cursorStyle;
|
|
171
|
+
this.previewHardwareCursor = cursorStyle !== "block";
|
|
172
|
+
this.cursorStyle = cursorStyle;
|
|
173
|
+
if (styleChanged) {
|
|
174
|
+
if (cursorStyle === "block") {
|
|
175
|
+
this.tui.terminal.write(DEFAULT_CURSOR_STYLE_SEQUENCE);
|
|
176
|
+
this.tui.setShowHardwareCursor(blockHardwareCursor);
|
|
177
|
+
} else {
|
|
178
|
+
configureCursor(this.tui, cursorStyle);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.tui.requestRender();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private renderBase(width: number): string[] {
|
|
185
|
+
const renderedLines = super.render(width);
|
|
186
|
+
if (this.cursorStyle === "block") return renderedLines;
|
|
187
|
+
|
|
188
|
+
// Pi re-applies settings.showHardwareCursor after session_start (/reload,
|
|
189
|
+
// /new, session switches). Non-block styles have no software cursor left to
|
|
190
|
+
// fall back on, so re-assert the hardware cursor instead of losing it.
|
|
191
|
+
if (!this.tui.getShowHardwareCursor()) configureCursor(this.tui, this.cursorStyle);
|
|
192
|
+
|
|
193
|
+
// A focused overlay suppresses the editor's cursor marker. Preserve its
|
|
194
|
+
// position only for the live settings preview, then clear it on refocus.
|
|
195
|
+
let cursorMarker = this.previewHardwareCursor && !this.focused ? CURSOR_MARKER : "";
|
|
196
|
+
if (this.focused) this.previewHardwareCursor = false;
|
|
197
|
+
return renderedLines.map((line) => {
|
|
198
|
+
const rendered = removeSoftwareCursor(line, cursorMarker);
|
|
199
|
+
if (rendered !== line) cursorMarker = "";
|
|
200
|
+
return rendered;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
render(width: number): string[] {
|
|
205
|
+
if (width < 4) return this.renderBase(width);
|
|
206
|
+
|
|
207
|
+
const rail = this.getRail();
|
|
208
|
+
const borderPaint = this.getBorder;
|
|
209
|
+
// ponytail: 1-char rail + 1-char gap on each side = 4 chars of chrome.
|
|
210
|
+
const innerWidth = Math.max(0, width - 4);
|
|
211
|
+
const baseLines = this.renderBase(innerWidth);
|
|
212
|
+
const bottomIdx = findBottomBorderIndex(baseLines);
|
|
213
|
+
|
|
214
|
+
const result: string[] = [];
|
|
215
|
+
result.push(roundedBorder(width, "top", borderPaint, baseLines[0], this.embeddedWorkingStatusIndicator));
|
|
216
|
+
|
|
217
|
+
for (let i = 1; i < bottomIdx; i++) {
|
|
218
|
+
const line = baseLines[i] ?? "";
|
|
219
|
+
if (isEditorBorderLine(line)) {
|
|
220
|
+
result.push(`${rail} ${fillLine("", innerWidth)} ${rail}`);
|
|
221
|
+
} else {
|
|
222
|
+
result.push(`${rail} ${fillLine(line, innerWidth)} ${rail}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
result.push(roundedBorder(width, "bottom", borderPaint, baseLines[bottomIdx]));
|
|
227
|
+
|
|
228
|
+
for (let i = bottomIdx + 1; i < baseLines.length; i++) {
|
|
229
|
+
result.push(baseLines[i]!);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return result.map((line) => truncateToWidth(line, width, ""));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function installEditor(
|
|
237
|
+
_pi: ExtensionAPI,
|
|
238
|
+
ctx: ExtensionContext,
|
|
239
|
+
cursorStyle: CursorStyle = "block",
|
|
240
|
+
wheelScrollLines = DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
|
|
241
|
+
) {
|
|
242
|
+
let activeTui: TUI | undefined;
|
|
243
|
+
let activeEditor: OpenTuiEditor | undefined;
|
|
244
|
+
let previousHardwareCursor: boolean | undefined;
|
|
245
|
+
let currentCursorStyle = cursorStyle;
|
|
246
|
+
let currentWheelScrollLines = wheelScrollLines;
|
|
247
|
+
let hiddenThinkingTarget: HiddenThinkingLabelComponent | undefined;
|
|
248
|
+
const getActiveTui = (): TUI => {
|
|
249
|
+
if (!activeTui) throw new Error("Open TUI editor is not mounted");
|
|
250
|
+
return activeTui;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
ctx.ui.setEditorComponent((tui, editorTheme, keybindings) => {
|
|
254
|
+
activeTui = tui;
|
|
255
|
+
hiddenThinkingTarget = undefined;
|
|
256
|
+
applyFullscreenWheelScrollLines(tui, currentWheelScrollLines);
|
|
257
|
+
previousHardwareCursor = tui.getShowHardwareCursor();
|
|
258
|
+
activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle);
|
|
259
|
+
return activeEditor;
|
|
260
|
+
});
|
|
261
|
+
return {
|
|
262
|
+
getViewportWidth(): number {
|
|
263
|
+
const columns = getActiveTui().terminal.columns;
|
|
264
|
+
if (typeof columns !== "number" || !Number.isFinite(columns)) {
|
|
265
|
+
throw new Error("Open TUI editor terminal has an invalid width");
|
|
266
|
+
}
|
|
267
|
+
return Math.max(1, Math.floor(columns));
|
|
268
|
+
},
|
|
269
|
+
setLatestHiddenThinkingLabel(label: string): void {
|
|
270
|
+
const tui = getActiveTui();
|
|
271
|
+
hiddenThinkingTarget ??= findLatestHiddenThinkingLabel(tui);
|
|
272
|
+
hiddenThinkingTarget.setHiddenThinkingLabel(label);
|
|
273
|
+
tui.requestRender();
|
|
274
|
+
},
|
|
275
|
+
resetHiddenThinkingLabelTarget(): void {
|
|
276
|
+
hiddenThinkingTarget = undefined;
|
|
277
|
+
},
|
|
278
|
+
setCursorStyle(nextCursorStyle: CursorStyle): void {
|
|
279
|
+
currentCursorStyle = nextCursorStyle;
|
|
280
|
+
activeEditor?.setCursorStyle(nextCursorStyle, previousHardwareCursor);
|
|
281
|
+
},
|
|
282
|
+
setWheelScrollLines(nextWheelScrollLines: number): void {
|
|
283
|
+
currentWheelScrollLines = nextWheelScrollLines;
|
|
284
|
+
if (activeTui) applyFullscreenWheelScrollLines(activeTui, currentWheelScrollLines);
|
|
285
|
+
},
|
|
286
|
+
cleanup(): void {
|
|
287
|
+
hiddenThinkingTarget = undefined;
|
|
288
|
+
ctx.ui.setEditorComponent(undefined);
|
|
289
|
+
if (activeTui) {
|
|
290
|
+
if (currentCursorStyle !== "block") activeTui.terminal.write(DEFAULT_CURSOR_STYLE_SEQUENCE);
|
|
291
|
+
if (previousHardwareCursor !== undefined) activeTui.setShowHardwareCursor(previousHardwareCursor);
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|