pi-open-tui 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -5
- package/README.zh-CN.md +12 -5
- package/extensions/open-tui/config.ts +9 -0
- package/extensions/open-tui/editor.ts +76 -3
- package/extensions/open-tui/footer.ts +216 -102
- package/extensions/open-tui/icons.ts +9 -26
- package/extensions/open-tui/index.ts +68 -58
- package/extensions/open-tui/settings-command.ts +11 -1
- package/extensions/open-tui/state.ts +2 -1
- package/extensions/open-tui/utils.ts +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,11 +41,11 @@ pi -e npm:pi-open-tui
|
|
|
41
41
|
|
|
42
42
|
Download any patched font from the official [Nerd Fonts downloads page](https://www.nerdfonts.com/font-downloads) or [latest GitHub release](https://github.com/ryanoasis/nerd-fonts/releases/latest). Install it, select that font in your terminal profile, and restart the terminal.
|
|
43
43
|
|
|
44
|
-
The default `auto` mode
|
|
44
|
+
The default `auto` mode checks the terminal environment, not the installed font file. It uses Nerd Font icons in interactive UTF-8 TTYs, including terminals running through a runner or subshell. If icons appear as boxes or incorrect symbols, open `/open-tui` and choose one of these modes under **Appearance**:
|
|
45
45
|
|
|
46
46
|
- `nerd`: force Nerd Font icons after configuring a Nerd Font in the terminal
|
|
47
47
|
- `ascii`: use plain-text icons with no patched font required
|
|
48
|
-
- `auto`: use Nerd Font icons in
|
|
48
|
+
- `auto`: use Nerd Font icons in interactive UTF-8 TTYs; use ASCII for non-interactive output, `TERM=dumb`, or an explicitly non-UTF-8 locale
|
|
49
49
|
|
|
50
50
|
If the font is installed but `auto` still selects ASCII, choose `nerd` explicitly. In VS Code, Windows Terminal, and similar apps, configure the font in the terminal profile rather than only installing it in the operating system.
|
|
51
51
|
|
|
@@ -56,6 +56,7 @@ Run `/open-tui` to open the settings dialog. It provides **General**, **Appearan
|
|
|
56
56
|
```json
|
|
57
57
|
{
|
|
58
58
|
"enabled": true,
|
|
59
|
+
"inlineFooter": false,
|
|
59
60
|
"settingsLanguage": "en",
|
|
60
61
|
"cursorStyle": "block",
|
|
61
62
|
"fullscreen": {
|
|
@@ -66,6 +67,7 @@ Run `/open-tui` to open the settings dialog. It provides **General**, **Appearan
|
|
|
66
67
|
},
|
|
67
68
|
"footerSegments": {
|
|
68
69
|
"cwd": true,
|
|
70
|
+
"hostname": false,
|
|
69
71
|
"sessionName": false,
|
|
70
72
|
"gitBranch": true,
|
|
71
73
|
"gitStatus": true,
|
|
@@ -74,7 +76,8 @@ Run `/open-tui` to open the settings dialog. It provides **General**, **Appearan
|
|
|
74
76
|
"context": true,
|
|
75
77
|
"tokens": true,
|
|
76
78
|
"cost": true,
|
|
77
|
-
"extensionStatuses": true
|
|
79
|
+
"extensionStatuses": true,
|
|
80
|
+
"capitalizeProviderName": true
|
|
78
81
|
},
|
|
79
82
|
"telemetry": {
|
|
80
83
|
"enabled": true,
|
|
@@ -96,14 +99,18 @@ Key options:
|
|
|
96
99
|
| Option | Values | Notes |
|
|
97
100
|
| --- | --- | --- |
|
|
98
101
|
| `settingsLanguage` | `en`, `zh` | Changes the `/open-tui` interface language |
|
|
102
|
+
| `inlineFooter` | `true`, `false` | Moves the two main Footer rows into the editor's top and bottom borders; defaults to `false`. Extension status rows remain below the editor |
|
|
99
103
|
| `cursorStyle` | `block`, `bar`, `underline` | `bar` and `underline` require terminal cursor-shape support |
|
|
100
104
|
| `fullscreen.wheelScrollLines` | `1`-`10` | Lines scrolled per mouse-wheel notch in fullscreen mode; defaults to `4`. In `/open-tui`, press Enter on this item and type a number (values are clamped to `1`-`10`) |
|
|
101
105
|
| `icons.mode` | `auto`, `nerd`, `ascii` | Controls footer and telemetry icons |
|
|
102
106
|
| `footerSegments` | Boolean flags | Shows or hides individual footer data |
|
|
107
|
+
| `footerSegments.capitalizeProviderName` | Boolean | Capitalizes the first character of the provider name in the footer; set it to `false` to keep the provider's original casing |
|
|
103
108
|
| `telemetry` | Boolean flags | Enables telemetry and its individual measurements |
|
|
104
109
|
| `thinkingPeek.lines` | `0`, `1`, `2` | Off, one-line, or two-line hidden thinking preview |
|
|
105
110
|
|
|
106
|
-
`sessionName` appears only when the session has a name. `gitCommit` shows the short hash and tag in detached HEAD state. Disabling `extensionStatuses` hides the entire extension status line, including MCP status.
|
|
111
|
+
`sessionName` appears only when the session has a name. `hostname` shows the short host name (first label of the machine's host name, e.g. `mba` from `mba.example.com`) with a server icon. `gitCommit` shows the short hash and tag in detached HEAD state. Disabling `extensionStatuses` hides the entire extension status line, including MCP status.
|
|
112
|
+
|
|
113
|
+
With `inlineFooter` enabled, the two normal Footer rows are rendered inside the editor frame to save vertical space. The top border places the Git branch on the left and CWD first in the right-hand group; the session title appears on the left too when `sessionName` is enabled. The Header and extension status rows remain separate; narrow terminals truncate lower-priority Footer data first, keeping the right-hand statistics and the border corner.
|
|
107
114
|
|
|
108
115
|
Fullscreen wheel speed uses an isolated compatibility shim for Pi 0.84.2's runtime field because Pi does not yet expose a public setter. On Pi versions without a compatible field, the setting is ignored and Pi's default scrolling remains active.
|
|
109
116
|
|
|
@@ -125,7 +132,7 @@ When Pi's **Hide thinking** setting is enabled, pi-open-tui shows a compact tick
|
|
|
125
132
|
|
|
126
133
|
- while the model is reasoning, the current thinking tail streams by with a spinner (`~ think ⠋ …`);
|
|
127
134
|
- when the answer starts, it settles on a check mark (`~ think ✓`);
|
|
128
|
-
- in 2-line mode, the previous and latest thinking lines share the same text indentation; if the latest line overflows, both rows follow its newest tail as tokens arrive instead of retaining the previous line;
|
|
135
|
+
- in 2-line mode, the previous and latest thinking lines share the same text indentation; if the latest line overflows, both rows follow its newest tail as tokens arrive instead of retaining the previous line;
|
|
129
136
|
- after the task settles, the native `Thinking...` label is restored.
|
|
130
137
|
|
|
131
138
|
```text
|
package/README.zh-CN.md
CHANGED
|
@@ -41,11 +41,11 @@ pi -e npm:pi-open-tui
|
|
|
41
41
|
|
|
42
42
|
可从 [Nerd Fonts 官方下载页](https://www.nerdfonts.com/font-downloads)或 [GitHub 最新版本](https://github.com/ryanoasis/nerd-fonts/releases/latest)下载任意已修补字体。安装后,请在终端配置中选择该字体,并重启终端。
|
|
43
43
|
|
|
44
|
-
默认的 `auto`
|
|
44
|
+
默认的 `auto` 模式检查终端环境,而不是已安装的字体文件。在支持 UTF-8 的交互式 TTY 中,它会使用 Nerd Font 图标,即使 Pi 运行在 runner 或子 shell 中。如果图标显示为方框、乱码或错误符号,请打开 `/open-tui`,在**外观**页选择合适的模式:
|
|
45
45
|
|
|
46
46
|
- `nerd`:终端已配置 Nerd Font 时,强制使用 Nerd Font 图标
|
|
47
47
|
- `ascii`:使用纯文本图标,无需安装修补字体
|
|
48
|
-
- `auto
|
|
48
|
+
- `auto`:在支持 UTF-8 的交互式 TTY 中使用 Nerd Font 图标;非交互式输出、`TERM=dumb` 或明确配置为非 UTF-8 的 locale 使用 ASCII
|
|
49
49
|
|
|
50
50
|
如果已经安装字体,但 `auto` 仍选择 ASCII,请手动切换为 `nerd`。使用 VS Code、Windows Terminal 等应用时,只在操作系统中安装字体还不够,还需要在对应的终端配置中选中该字体。
|
|
51
51
|
|
|
@@ -56,6 +56,7 @@ pi -e npm:pi-open-tui
|
|
|
56
56
|
```json
|
|
57
57
|
{
|
|
58
58
|
"enabled": true,
|
|
59
|
+
"inlineFooter": false,
|
|
59
60
|
"settingsLanguage": "zh",
|
|
60
61
|
"cursorStyle": "block",
|
|
61
62
|
"fullscreen": {
|
|
@@ -66,6 +67,7 @@ pi -e npm:pi-open-tui
|
|
|
66
67
|
},
|
|
67
68
|
"footerSegments": {
|
|
68
69
|
"cwd": true,
|
|
70
|
+
"hostname": false,
|
|
69
71
|
"sessionName": false,
|
|
70
72
|
"gitBranch": true,
|
|
71
73
|
"gitStatus": true,
|
|
@@ -74,7 +76,8 @@ pi -e npm:pi-open-tui
|
|
|
74
76
|
"context": true,
|
|
75
77
|
"tokens": true,
|
|
76
78
|
"cost": true,
|
|
77
|
-
"extensionStatuses": true
|
|
79
|
+
"extensionStatuses": true,
|
|
80
|
+
"capitalizeProviderName": true
|
|
78
81
|
},
|
|
79
82
|
"telemetry": {
|
|
80
83
|
"enabled": true,
|
|
@@ -96,14 +99,18 @@ pi -e npm:pi-open-tui
|
|
|
96
99
|
| 选项 | 可选值 | 说明 |
|
|
97
100
|
| --- | --- | --- |
|
|
98
101
|
| `settingsLanguage` | `en`、`zh` | 切换 `/open-tui` 设置界面的语言 |
|
|
102
|
+
| `inlineFooter` | `true`、`false` | 将两条主要 Footer 信息行移入编辑器上下边框以节省垂直空间,默认关闭;扩展状态行仍显示在编辑器外 |
|
|
99
103
|
| `cursorStyle` | `block`、`bar`、`underline` | `bar` 和 `underline` 需要终端支持光标形状转义序列 |
|
|
100
104
|
| `fullscreen.wheelScrollLines` | `1`-`10` | 全屏模式下滚轮每格滚动的行数,默认值为 `4`;`/open-tui` 中在该项上按 Enter 后直接输入数字(超出范围会自动钳制到 `1`-`10`) |
|
|
101
105
|
| `icons.mode` | `auto`、`nerd`、`ascii` | 控制底栏和遥测通知使用的图标 |
|
|
102
106
|
| `footerSegments` | 布尔开关 | 分别控制底栏中的各项数据 |
|
|
107
|
+
| `footerSegments.capitalizeProviderName` | 布尔开关 | 将底栏中提供商名称的首字母大写;设为 `false` 时保留原始大小写 |
|
|
103
108
|
| `telemetry` | 布尔开关 | 控制遥测总开关和各项指标 |
|
|
104
109
|
| `thinkingPeek.lines` | `0`、`1`、`2` | 关闭、单行或双行思考预览 |
|
|
105
110
|
|
|
106
|
-
`sessionName` 仅在会话有名称时显示;`gitCommit` 会在 detached HEAD 状态下显示短哈希和标签;关闭 `extensionStatuses` 会隐藏整行扩展状态,其中也包括 MCP 状态。
|
|
111
|
+
`sessionName` 仅在会话有名称时显示;`hostname` 会显示主机名的短名称(主机名的第一个标签,例如从 `mba.example.com` 显示为 `mba`),并使用服务器图标;`gitCommit` 会在 detached HEAD 状态下显示短哈希和标签;关闭 `extensionStatuses` 会隐藏整行扩展状态,其中也包括 MCP 状态。
|
|
112
|
+
|
|
113
|
+
开启 `inlineFooter` 后,两条常规 Footer 信息行会移入编辑器边框,从而节省垂直空间。顶部边框左侧显示 Git 分支,右侧信息组以当前目录开头;开启 `sessionName` 时,会话标题也会显示在左侧。Header 和扩展状态行保持独立显示;终端较窄时优先截断低优先级 Footer 数据,保留右侧统计信息和边框角。
|
|
107
114
|
|
|
108
115
|
全屏滚轮速度通过隔离的兼容层写入 Pi 0.84.2 的运行时字段,因为 Pi 尚未提供公开 setter。若后续 Pi 版本不再包含兼容字段,该设置会被忽略并继续使用 Pi 的默认滚动行为。
|
|
109
116
|
|
|
@@ -125,7 +132,7 @@ TPS 的计算方式是:将本次运行中服务商报告的全部 Assistant
|
|
|
125
132
|
|
|
126
133
|
- 模型推理时,思考内容的末尾片段会随 spinner 滚动(`~ think ⠋ …`);
|
|
127
134
|
- 开始输出正文时定格为对勾(`~ think ✓`);
|
|
128
|
-
- 双行模式中,上一条和最新一条思考内容使用相同的文字缩进;如果最新一行超出宽度,则两行随新 Token 持续显示它的最新末尾片段,不再保留上一条;
|
|
135
|
+
- 双行模式中,上一条和最新一条思考内容使用相同的文字缩进;如果最新一行超出宽度,则两行随新 Token 持续显示它的最新末尾片段,不再保留上一条;
|
|
129
136
|
- 任务结束后恢复原生 `Thinking...` 标签。
|
|
130
137
|
|
|
131
138
|
```text
|
|
@@ -15,6 +15,7 @@ export type { IconMode } from "./icons.ts";
|
|
|
15
15
|
|
|
16
16
|
export interface FooterSegments {
|
|
17
17
|
cwd: boolean;
|
|
18
|
+
hostname: boolean;
|
|
18
19
|
sessionName: boolean;
|
|
19
20
|
gitBranch: boolean;
|
|
20
21
|
gitStatus: boolean;
|
|
@@ -24,6 +25,7 @@ export interface FooterSegments {
|
|
|
24
25
|
tokens: boolean;
|
|
25
26
|
cost: boolean;
|
|
26
27
|
extensionStatuses: boolean;
|
|
28
|
+
capitalizeProviderName: boolean;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export interface TelemetryConfig {
|
|
@@ -46,6 +48,7 @@ export interface FullscreenConfig {
|
|
|
46
48
|
|
|
47
49
|
export interface OpenTuiConfig {
|
|
48
50
|
enabled: boolean;
|
|
51
|
+
inlineFooter: boolean;
|
|
49
52
|
settingsLanguage: SettingsLanguage;
|
|
50
53
|
cursorStyle: CursorStyle;
|
|
51
54
|
fullscreen: FullscreenConfig;
|
|
@@ -59,6 +62,7 @@ export interface OpenTuiConfig {
|
|
|
59
62
|
|
|
60
63
|
export const DEFAULT_CONFIG: OpenTuiConfig = {
|
|
61
64
|
enabled: true,
|
|
65
|
+
inlineFooter: false,
|
|
62
66
|
settingsLanguage: "en",
|
|
63
67
|
cursorStyle: "block",
|
|
64
68
|
fullscreen: {
|
|
@@ -69,6 +73,7 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
|
|
|
69
73
|
},
|
|
70
74
|
footerSegments: {
|
|
71
75
|
cwd: true,
|
|
76
|
+
hostname: false,
|
|
72
77
|
sessionName: false,
|
|
73
78
|
gitBranch: true,
|
|
74
79
|
gitStatus: true,
|
|
@@ -78,6 +83,7 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
|
|
|
78
83
|
tokens: true,
|
|
79
84
|
cost: true,
|
|
80
85
|
extensionStatuses: true,
|
|
86
|
+
capitalizeProviderName: true,
|
|
81
87
|
},
|
|
82
88
|
telemetry: {
|
|
83
89
|
enabled: true,
|
|
@@ -147,6 +153,9 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
|
|
|
147
153
|
const raw = readFileSync(path, "utf8");
|
|
148
154
|
const parsed: unknown = JSON.parse(raw);
|
|
149
155
|
const config = deepMerge(DEFAULT_CONFIG, parsed);
|
|
156
|
+
if (typeof config.inlineFooter !== "boolean") {
|
|
157
|
+
config.inlineFooter = DEFAULT_CONFIG.inlineFooter;
|
|
158
|
+
}
|
|
150
159
|
if (config.settingsLanguage !== "en" && config.settingsLanguage !== "zh") {
|
|
151
160
|
config.settingsLanguage = DEFAULT_CONFIG.settingsLanguage;
|
|
152
161
|
}
|
|
@@ -30,6 +30,16 @@ interface WorkingStatusIndicator {
|
|
|
30
30
|
renderSpinnerInBorder(width: number): string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface InlineFooterLine {
|
|
34
|
+
left: string;
|
|
35
|
+
right: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface InlineFooterRenderer {
|
|
39
|
+
enabled(): boolean;
|
|
40
|
+
render(width: number): { top: InlineFooterLine; bottom: InlineFooterLine } | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
interface HiddenThinkingLabelComponent {
|
|
34
44
|
children: unknown[];
|
|
35
45
|
setHiddenThinkingLabel(label: string): void;
|
|
@@ -132,11 +142,59 @@ function roundedBorder(
|
|
|
132
142
|
return paint(`${corners[0]}${"─".repeat(Math.max(0, width - 2))}${corners[1]}`);
|
|
133
143
|
}
|
|
134
144
|
|
|
145
|
+
function trimTrailingSpaces(text: string): string {
|
|
146
|
+
const ansiSuffix = text.match(/(?:\x1b\[[0-?]*[ -/]*[@-~])+$/)?.[0] ?? "";
|
|
147
|
+
const content = ansiSuffix ? text.slice(0, -ansiSuffix.length) : text;
|
|
148
|
+
return `${content.replace(/ +$/, "")}${ansiSuffix}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function inlineBorder(
|
|
152
|
+
width: number,
|
|
153
|
+
kind: "top" | "bottom",
|
|
154
|
+
paint: (s: string) => string,
|
|
155
|
+
renderLine: (width: number) => InlineFooterLine | undefined,
|
|
156
|
+
sourceLine?: string,
|
|
157
|
+
indicator?: WorkingStatusIndicator,
|
|
158
|
+
): string {
|
|
159
|
+
if (width < 2) return paint(truncateToWidth(kind === "top" ? "╭╮" : "╰╯", width, ""));
|
|
160
|
+
|
|
161
|
+
const corners = kind === "top" ? (["╭", "╮"] as const) : (["╰", "╯"] as const);
|
|
162
|
+
const contentWidth = width - 2;
|
|
163
|
+
const plain = sourceLine ? stripAnsi(sourceLine) : "";
|
|
164
|
+
const scrollMatch = plain.match(/([↑↓]\s+\d+\s+more)/);
|
|
165
|
+
const right = scrollMatch ? paint(` ${scrollMatch[1]} `) : "";
|
|
166
|
+
let left = paint("─");
|
|
167
|
+
|
|
168
|
+
if (kind === "top" && indicator) {
|
|
169
|
+
const statusBudget = Math.max(1, contentWidth - visibleWidth(right) - 7);
|
|
170
|
+
let status = indicator.renderInBorder(statusBudget);
|
|
171
|
+
if (visibleWidth(status) > statusBudget) status = indicator.renderSpinnerInBorder(statusBudget);
|
|
172
|
+
const leftBudget = Math.max(0, contentWidth - visibleWidth(right) - 1);
|
|
173
|
+
status = truncateToWidth(status, Math.max(0, leftBudget - 4), "");
|
|
174
|
+
if (visibleWidth(status) > 0) left = `${paint("── ")}${status}${paint(" ")}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const lineBudget = Math.max(0, contentWidth - visibleWidth(left) - visibleWidth(right) - 6);
|
|
178
|
+
const line = renderLine(lineBudget);
|
|
179
|
+
if (!line) return roundedBorder(width, kind, paint, sourceLine, indicator);
|
|
180
|
+
const leftContent = trimTrailingSpaces(line.left);
|
|
181
|
+
const rightContent = trimTrailingSpaces(line.right);
|
|
182
|
+
if (visibleWidth(leftContent) === 0 && visibleWidth(rightContent) === 0) {
|
|
183
|
+
return roundedBorder(width, kind, paint, sourceLine, indicator);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const leftCell = leftContent ? ` ${leftContent} ` : "";
|
|
187
|
+
const rightCell = rightContent ? ` ${rightContent} ` : "";
|
|
188
|
+
const fill = Math.max(1, contentWidth - visibleWidth(left) - visibleWidth(leftCell) - visibleWidth(rightCell) - visibleWidth(right) - 1);
|
|
189
|
+
return `${paint(corners[0])}${left}${leftCell}${paint("─".repeat(fill))}${rightCell}${right}${paint("─")}${paint(corners[1])}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
135
192
|
export class OpenTuiEditor extends CustomEditor {
|
|
136
193
|
readonly embedWorkingStatus = true;
|
|
137
194
|
private readonly getRail: () => string;
|
|
138
195
|
private readonly getBorder: (s: string) => string;
|
|
139
196
|
private embeddedWorkingStatusIndicator: WorkingStatusIndicator | undefined;
|
|
197
|
+
private readonly inlineFooter: InlineFooterRenderer | undefined;
|
|
140
198
|
private cursorStyle: CursorStyle;
|
|
141
199
|
private previewHardwareCursor = false;
|
|
142
200
|
|
|
@@ -145,9 +203,11 @@ export class OpenTuiEditor extends CustomEditor {
|
|
|
145
203
|
editorTheme: EditorTheme,
|
|
146
204
|
keybindings: KeybindingsManager,
|
|
147
205
|
cursorStyle: CursorStyle = "block",
|
|
206
|
+
inlineFooter?: InlineFooterRenderer,
|
|
148
207
|
) {
|
|
149
208
|
super(tui, editorTheme, keybindings, { paddingX: 0 });
|
|
150
209
|
this.cursorStyle = cursorStyle;
|
|
210
|
+
this.inlineFooter = inlineFooter;
|
|
151
211
|
configureCursor(tui, cursorStyle);
|
|
152
212
|
// ponytail: route the frame through this.borderColor so Pi can recolor it
|
|
153
213
|
// via updateEditorBorderColor() — bash mode ("! " prefix → green) and
|
|
@@ -185,6 +245,11 @@ export class OpenTuiEditor extends CustomEditor {
|
|
|
185
245
|
const renderedLines = super.render(width);
|
|
186
246
|
if (this.cursorStyle === "block") return renderedLines;
|
|
187
247
|
|
|
248
|
+
// Pi re-applies settings.showHardwareCursor after session_start (/reload,
|
|
249
|
+
// /new, session switches). Non-block styles have no software cursor left to
|
|
250
|
+
// fall back on, so re-assert the hardware cursor instead of losing it.
|
|
251
|
+
if (!this.tui.getShowHardwareCursor()) configureCursor(this.tui, this.cursorStyle);
|
|
252
|
+
|
|
188
253
|
// A focused overlay suppresses the editor's cursor marker. Preserve its
|
|
189
254
|
// position only for the live settings preview, then clear it on refocus.
|
|
190
255
|
let cursorMarker = this.previewHardwareCursor && !this.focused ? CURSOR_MARKER : "";
|
|
@@ -207,7 +272,12 @@ export class OpenTuiEditor extends CustomEditor {
|
|
|
207
272
|
const bottomIdx = findBottomBorderIndex(baseLines);
|
|
208
273
|
|
|
209
274
|
const result: string[] = [];
|
|
210
|
-
|
|
275
|
+
const inlineFooter = this.inlineFooter?.enabled() === true;
|
|
276
|
+
const renderInlineLine = (kind: "top" | "bottom", budget: number): InlineFooterLine | undefined =>
|
|
277
|
+
this.inlineFooter?.render(budget)?.[kind];
|
|
278
|
+
result.push(inlineFooter
|
|
279
|
+
? inlineBorder(width, "top", borderPaint, (budget) => renderInlineLine("top", budget), baseLines[0], this.embeddedWorkingStatusIndicator)
|
|
280
|
+
: roundedBorder(width, "top", borderPaint, baseLines[0], this.embeddedWorkingStatusIndicator));
|
|
211
281
|
|
|
212
282
|
for (let i = 1; i < bottomIdx; i++) {
|
|
213
283
|
const line = baseLines[i] ?? "";
|
|
@@ -218,7 +288,9 @@ export class OpenTuiEditor extends CustomEditor {
|
|
|
218
288
|
}
|
|
219
289
|
}
|
|
220
290
|
|
|
221
|
-
result.push(
|
|
291
|
+
result.push(inlineFooter
|
|
292
|
+
? inlineBorder(width, "bottom", borderPaint, (budget) => renderInlineLine("bottom", budget), baseLines[bottomIdx])
|
|
293
|
+
: roundedBorder(width, "bottom", borderPaint, baseLines[bottomIdx]));
|
|
222
294
|
|
|
223
295
|
for (let i = bottomIdx + 1; i < baseLines.length; i++) {
|
|
224
296
|
result.push(baseLines[i]!);
|
|
@@ -233,6 +305,7 @@ export function installEditor(
|
|
|
233
305
|
ctx: ExtensionContext,
|
|
234
306
|
cursorStyle: CursorStyle = "block",
|
|
235
307
|
wheelScrollLines = DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
|
|
308
|
+
inlineFooter?: InlineFooterRenderer,
|
|
236
309
|
) {
|
|
237
310
|
let activeTui: TUI | undefined;
|
|
238
311
|
let activeEditor: OpenTuiEditor | undefined;
|
|
@@ -250,7 +323,7 @@ export function installEditor(
|
|
|
250
323
|
hiddenThinkingTarget = undefined;
|
|
251
324
|
applyFullscreenWheelScrollLines(tui, currentWheelScrollLines);
|
|
252
325
|
previousHardwareCursor = tui.getShowHardwareCursor();
|
|
253
|
-
activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle);
|
|
326
|
+
activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle, inlineFooter);
|
|
254
327
|
return activeEditor;
|
|
255
328
|
});
|
|
256
329
|
return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { hostname as osHostname } from "node:os";
|
|
2
3
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
4
|
import type { OpenTuiConfig } from "./config.ts";
|
|
4
5
|
import type { IconGlyphs } from "./icons.ts";
|
|
@@ -25,6 +26,10 @@ import {
|
|
|
25
26
|
import type { FooterState, ModelMeta, UsageTotals } from "./state.ts";
|
|
26
27
|
import { getUsageTotals } from "./state.ts";
|
|
27
28
|
|
|
29
|
+
export function shortHostname(hostname: string): string {
|
|
30
|
+
return hostname.split(".")[0] ?? "";
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
function renderBar(theme: Theme, pct: number, barWidth: number, ascii: boolean): string {
|
|
29
34
|
const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
|
|
30
35
|
const empty = barWidth - filled;
|
|
@@ -197,132 +202,241 @@ export interface FooterHooks {
|
|
|
197
202
|
scheduleGitRefresh: () => void;
|
|
198
203
|
}
|
|
199
204
|
|
|
205
|
+
interface FooterDataLike {
|
|
206
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface InlineFooterLine {
|
|
210
|
+
left: string;
|
|
211
|
+
right: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface InlineFooterLines {
|
|
215
|
+
top: InlineFooterLine;
|
|
216
|
+
bottom: InlineFooterLine;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Split the bottom border content the way `alignRight` splits the plain row:
|
|
221
|
+
* the right block (statistics) survives, the left one (model) shrinks to an
|
|
222
|
+
* ellipsis and only then disappears. Keeps the assembled border inside its
|
|
223
|
+
* budget so editor-side truncation never eats the right corner.
|
|
224
|
+
*/
|
|
225
|
+
function fitInlineLine(left: string, right: string, width: number, theme: Theme): InlineFooterLine {
|
|
226
|
+
const rightWidth = visibleWidth(right);
|
|
227
|
+
if (rightWidth > width) {
|
|
228
|
+
return { left: "", right: truncateToWidth(right, width, theme.fg("dim", "...")) };
|
|
229
|
+
}
|
|
230
|
+
const availableForLeft = width - rightWidth - 1;
|
|
231
|
+
if (availableForLeft <= 0) return { left: "", right };
|
|
232
|
+
return { left: truncateToWidth(left, availableForLeft, theme.fg("dim", "...")), right };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface FooterHandle {
|
|
236
|
+
renderInline(width: number): InlineFooterLines | undefined;
|
|
237
|
+
cleanup(): void;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function fitInlineSegments(
|
|
241
|
+
parts: readonly PrioritizedSegment[],
|
|
242
|
+
width: number,
|
|
243
|
+
theme: Theme,
|
|
244
|
+
): string {
|
|
245
|
+
const separator = theme.fg("dim", " · ");
|
|
246
|
+
const separatorReserve = Math.max(0, parts.length - 1) * Math.max(0, visibleWidth(separator) - 1);
|
|
247
|
+
return fitSegmentsByPriority(
|
|
248
|
+
parts,
|
|
249
|
+
Math.max(0, width - separatorReserve),
|
|
250
|
+
theme.fg("dim", "..."),
|
|
251
|
+
).join(separator);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function renderFooterContent(
|
|
255
|
+
ctx: ExtensionContext,
|
|
256
|
+
getState: () => FooterState,
|
|
257
|
+
getConfig: () => OpenTuiConfig,
|
|
258
|
+
getModelMeta: () => ModelMeta,
|
|
259
|
+
theme: Theme,
|
|
260
|
+
footerData: FooterDataLike,
|
|
261
|
+
width: number,
|
|
262
|
+
): { mainLines: [string, string]; inlineLines: InlineFooterLines; extensionLines: string[] } {
|
|
263
|
+
if (width <= 0) {
|
|
264
|
+
return {
|
|
265
|
+
mainLines: ["", ""],
|
|
266
|
+
inlineLines: {
|
|
267
|
+
top: { left: "", right: "" },
|
|
268
|
+
bottom: { left: "", right: "" },
|
|
269
|
+
},
|
|
270
|
+
extensionLines: [],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const state = getState();
|
|
275
|
+
const config = getConfig();
|
|
276
|
+
const glyphs = resolveGlyphs(config.icons.mode);
|
|
277
|
+
const segments = config.footerSegments;
|
|
278
|
+
const meta = getModelMeta();
|
|
279
|
+
const totals = getUsageTotals(ctx);
|
|
280
|
+
|
|
281
|
+
const leftParts: PrioritizedSegment[] = [];
|
|
282
|
+
const inlineTopLeftParts: PrioritizedSegment[] = [];
|
|
283
|
+
const inlineTopRightParts: PrioritizedSegment[] = [];
|
|
284
|
+
if (segments.cwd) {
|
|
285
|
+
const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
|
|
286
|
+
const cwd = formatCwd(ctx.sessionManager.getCwd());
|
|
287
|
+
const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
|
|
288
|
+
const accent = (text: string) => theme.fg("accent", text);
|
|
289
|
+
leftParts.push({
|
|
290
|
+
text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
|
|
291
|
+
compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
|
|
292
|
+
priority: 0,
|
|
293
|
+
truncate: (_text, maxWidth, ellipsis) => {
|
|
294
|
+
const pathWidth = maxWidth - visibleWidth(cwdPrefix);
|
|
295
|
+
if (pathWidth <= visibleWidth(ellipsis)) {
|
|
296
|
+
return truncateToWidth(`${cwdPrefix}${accent(basenamePath(cwd))}`, maxWidth, ellipsis);
|
|
297
|
+
}
|
|
298
|
+
return `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), pathWidth))}`;
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
302
|
+
}
|
|
303
|
+
if (segments.hostname) {
|
|
304
|
+
const shortHost = shortHostname(osHostname());
|
|
305
|
+
if (shortHost) {
|
|
306
|
+
leftParts.push({
|
|
307
|
+
text: `${theme.fg("dim", glyphs.host)} ${theme.fg("accent", shortHost)}`,
|
|
308
|
+
priority: 1,
|
|
309
|
+
});
|
|
310
|
+
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
314
|
+
if (sessionName) {
|
|
315
|
+
const sessionPart: PrioritizedSegment = {
|
|
316
|
+
text: `${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
|
|
317
|
+
priority: 2,
|
|
318
|
+
};
|
|
319
|
+
if (segments.sessionName) {
|
|
320
|
+
leftParts.push(sessionPart);
|
|
321
|
+
inlineTopLeftParts.push(sessionPart);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
|
|
325
|
+
if (gitSeg) {
|
|
326
|
+
leftParts.push({ text: gitSeg, priority: 3 });
|
|
327
|
+
inlineTopLeftParts.push(leftParts.at(-1)!);
|
|
328
|
+
}
|
|
329
|
+
if (segments.runtime) {
|
|
330
|
+
const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
|
|
331
|
+
if (runtimeSeg) {
|
|
332
|
+
leftParts.push({ text: runtimeSeg, priority: 4 });
|
|
333
|
+
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const timerSeg = renderTimerSegment(theme, state, glyphs);
|
|
337
|
+
if (timerSeg) {
|
|
338
|
+
leftParts.push({ text: timerSeg, priority: 1 });
|
|
339
|
+
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// The context bar competes with the left segments for the same row:
|
|
343
|
+
// full bar first, then the compact icon+pct form, then dropped.
|
|
344
|
+
let contextText = "";
|
|
345
|
+
let contextCompact: string | undefined;
|
|
346
|
+
if (segments.context) {
|
|
347
|
+
contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
|
|
348
|
+
const compact = renderContextCompact(theme, ctx, glyphs);
|
|
349
|
+
if (compact && visibleWidth(compact) < visibleWidth(contextText)) {
|
|
350
|
+
contextCompact = compact;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const allParts: PrioritizedSegment[] = [...leftParts];
|
|
354
|
+
if (contextText) {
|
|
355
|
+
// ponytail: priority 4 = sheds with runtime, before git/timer/cwd.
|
|
356
|
+
allParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
|
|
357
|
+
// Context stays at the far right; cwd is the first item in this group.
|
|
358
|
+
inlineTopRightParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
|
|
362
|
+
const fittedContext = contextText ? fitted.pop() ?? "" : "";
|
|
363
|
+
const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
|
|
364
|
+
|
|
365
|
+
const inlineLeftBudget = Math.floor(width * 0.45);
|
|
366
|
+
const inlineTopLeft = fitInlineSegments(inlineTopLeftParts, inlineLeftBudget, theme);
|
|
367
|
+
const inlineRightBudget = Math.max(0, width - visibleWidth(inlineTopLeft) - (inlineTopLeft ? 1 : 0));
|
|
368
|
+
const inlineTop: InlineFooterLine = {
|
|
369
|
+
left: inlineTopLeft,
|
|
370
|
+
right: fitInlineSegments(inlineTopRightParts, inlineRightBudget, theme),
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const modelParts: string[] = [];
|
|
374
|
+
modelParts.push(theme.fg("mdLink", glyphs.model));
|
|
375
|
+
if (meta.provider && meta.provider !== "Unknown") {
|
|
376
|
+
modelParts.push(theme.fg(providerColor(ctx.model?.provider ?? "none"), meta.provider));
|
|
377
|
+
}
|
|
378
|
+
modelParts.push(theme.fg("text", meta.model));
|
|
379
|
+
if (meta.effort && meta.effort !== "off") {
|
|
380
|
+
modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
|
|
381
|
+
}
|
|
382
|
+
const modelBlock = modelParts.join(theme.fg("dim", " · "));
|
|
383
|
+
const statsBlock = renderStatsBlock(theme, totals, glyphs, segments);
|
|
384
|
+
const inlineBottom = fitInlineLine(modelBlock, statsBlock, width, theme);
|
|
385
|
+
const line2 = alignRight(inlineBottom.left, inlineBottom.right, width, theme);
|
|
386
|
+
const mainLines: [string, string] = [line1, line2]
|
|
387
|
+
.map((line) => truncateToWidth(line, width, theme.fg("dim", "..."))) as [string, string];
|
|
388
|
+
const extensionLines = segments.extensionStatuses
|
|
389
|
+
? renderExtensionStatusLines(theme, footerData.getExtensionStatuses(), glyphs, width)
|
|
390
|
+
: [];
|
|
391
|
+
return { mainLines, inlineLines: { top: inlineTop, bottom: inlineBottom }, extensionLines };
|
|
392
|
+
}
|
|
393
|
+
|
|
200
394
|
export function installFooter(
|
|
201
395
|
ctx: ExtensionContext,
|
|
202
396
|
getState: () => FooterState,
|
|
203
397
|
getConfig: () => OpenTuiConfig,
|
|
204
398
|
getModelMeta: () => ModelMeta,
|
|
205
399
|
hooks: FooterHooks,
|
|
206
|
-
):
|
|
400
|
+
): FooterHandle {
|
|
401
|
+
let renderInline: (width: number) => InlineFooterLines | undefined = () => undefined;
|
|
402
|
+
|
|
207
403
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
208
404
|
hooks.setRequestRender(() => tui.requestRender());
|
|
209
405
|
const unsubBranch = footerData.onBranchChange(() => {
|
|
210
406
|
hooks.scheduleGitRefresh();
|
|
211
407
|
tui.requestRender();
|
|
212
408
|
});
|
|
409
|
+
const getContent = (width: number) => renderFooterContent(
|
|
410
|
+
ctx,
|
|
411
|
+
getState,
|
|
412
|
+
getConfig,
|
|
413
|
+
getModelMeta,
|
|
414
|
+
theme,
|
|
415
|
+
footerData,
|
|
416
|
+
width,
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
renderInline = (width) => getContent(width).inlineLines;
|
|
213
420
|
|
|
214
421
|
return {
|
|
215
422
|
dispose() {
|
|
216
423
|
unsubBranch();
|
|
217
424
|
hooks.setRequestRender(undefined);
|
|
425
|
+
renderInline = () => undefined;
|
|
218
426
|
},
|
|
219
427
|
invalidate() {},
|
|
220
428
|
render(width: number): string[] {
|
|
221
429
|
if (width <= 0) return [""];
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
const glyphs = resolveGlyphs(config.icons.mode);
|
|
225
|
-
const segments = config.footerSegments;
|
|
226
|
-
const meta = getModelMeta();
|
|
227
|
-
|
|
228
|
-
const totals = getUsageTotals(ctx);
|
|
229
|
-
|
|
230
|
-
const leftParts: PrioritizedSegment[] = [];
|
|
231
|
-
if (segments.cwd) {
|
|
232
|
-
const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
|
|
233
|
-
const cwd = formatCwd(ctx.sessionManager.getCwd());
|
|
234
|
-
const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
|
|
235
|
-
const accent = (text: string) => theme.fg("accent", text);
|
|
236
|
-
leftParts.push({
|
|
237
|
-
text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
|
|
238
|
-
compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
|
|
239
|
-
priority: 0,
|
|
240
|
-
truncate: (_text, maxWidth, ellipsis) => {
|
|
241
|
-
const pathWidth = maxWidth - visibleWidth(cwdPrefix);
|
|
242
|
-
if (pathWidth <= visibleWidth(ellipsis)) {
|
|
243
|
-
return truncateToWidth(`${cwdPrefix}${accent(basenamePath(cwd))}`, maxWidth, ellipsis);
|
|
244
|
-
}
|
|
245
|
-
return `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), pathWidth))}`;
|
|
246
|
-
},
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
if (segments.sessionName) {
|
|
250
|
-
const sessionName = ctx.sessionManager.getSessionName();
|
|
251
|
-
if (sessionName) {
|
|
252
|
-
leftParts.push({
|
|
253
|
-
text: `${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
|
|
254
|
-
priority: 2,
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
|
|
259
|
-
if (gitSeg) leftParts.push({ text: gitSeg, priority: 3 });
|
|
260
|
-
if (segments.runtime) {
|
|
261
|
-
const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
|
|
262
|
-
if (runtimeSeg) leftParts.push({ text: runtimeSeg, priority: 4 });
|
|
263
|
-
}
|
|
264
|
-
const timerSeg = renderTimerSegment(theme, state, glyphs);
|
|
265
|
-
if (timerSeg) leftParts.push({ text: timerSeg, priority: 1 });
|
|
266
|
-
|
|
267
|
-
// The context bar competes with the left segments for the same row:
|
|
268
|
-
// full bar first, then the compact icon+pct form, then dropped.
|
|
269
|
-
let contextText = "";
|
|
270
|
-
let contextCompact: string | undefined;
|
|
271
|
-
if (segments.context) {
|
|
272
|
-
contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
|
|
273
|
-
const compact = renderContextCompact(theme, ctx, glyphs);
|
|
274
|
-
if (compact && visibleWidth(compact) < visibleWidth(contextText)) {
|
|
275
|
-
contextCompact = compact;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
const allParts: PrioritizedSegment[] = [...leftParts];
|
|
279
|
-
if (contextText) {
|
|
280
|
-
// ponytail: priority 4 = sheds with runtime, before git/timer/cwd.
|
|
281
|
-
allParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
|
|
285
|
-
const fittedContext = contextText ? fitted.pop() ?? "" : "";
|
|
286
|
-
const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
|
|
287
|
-
|
|
288
|
-
const modelParts: string[] = [];
|
|
289
|
-
modelParts.push(theme.fg("mdLink", glyphs.model));
|
|
290
|
-
if (meta.provider && meta.provider !== "Unknown") {
|
|
291
|
-
modelParts.push(theme.fg(providerColor(ctx.model?.provider ?? "none"), meta.provider));
|
|
292
|
-
}
|
|
293
|
-
modelParts.push(theme.fg("text", meta.model));
|
|
294
|
-
if (meta.effort && meta.effort !== "off") {
|
|
295
|
-
modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
|
|
296
|
-
}
|
|
297
|
-
const modelBlock = modelParts.join(theme.fg("dim", " · "));
|
|
298
|
-
|
|
299
|
-
const statsBlock = renderStatsBlock(
|
|
300
|
-
theme,
|
|
301
|
-
totals,
|
|
302
|
-
glyphs,
|
|
303
|
-
segments,
|
|
304
|
-
);
|
|
305
|
-
|
|
306
|
-
const line2 = alignRight(modelBlock, statsBlock, width, theme);
|
|
307
|
-
|
|
308
|
-
const mainLines = [line1, line2]
|
|
309
|
-
.map((line) => truncateToWidth(line, width, theme.fg("dim", "...")));
|
|
310
|
-
return segments.extensionStatuses
|
|
311
|
-
? [
|
|
312
|
-
...mainLines,
|
|
313
|
-
...renderExtensionStatusLines(
|
|
314
|
-
theme,
|
|
315
|
-
footerData.getExtensionStatuses(),
|
|
316
|
-
glyphs,
|
|
317
|
-
width,
|
|
318
|
-
),
|
|
319
|
-
]
|
|
320
|
-
: mainLines;
|
|
430
|
+
const { mainLines, extensionLines } = getContent(width);
|
|
431
|
+
return getConfig().inlineFooter ? extensionLines : [...mainLines, ...extensionLines];
|
|
321
432
|
},
|
|
322
433
|
};
|
|
323
434
|
});
|
|
324
435
|
|
|
325
|
-
return
|
|
326
|
-
|
|
436
|
+
return {
|
|
437
|
+
renderInline: (width) => renderInline(width),
|
|
438
|
+
cleanup() {
|
|
439
|
+
ctx.ui.setFooter(undefined);
|
|
440
|
+
},
|
|
327
441
|
};
|
|
328
442
|
}
|
|
@@ -2,6 +2,7 @@ export type IconMode = "auto" | "nerd" | "ascii";
|
|
|
2
2
|
|
|
3
3
|
export interface IconGlyphs {
|
|
4
4
|
cwd: string;
|
|
5
|
+
host: string;
|
|
5
6
|
session: string;
|
|
6
7
|
git: string;
|
|
7
8
|
working: string;
|
|
@@ -31,6 +32,7 @@ export interface IconGlyphs {
|
|
|
31
32
|
|
|
32
33
|
const NERD_GLYPHS: IconGlyphs = {
|
|
33
34
|
cwd: "",
|
|
35
|
+
host: "",
|
|
34
36
|
session: "",
|
|
35
37
|
git: "",
|
|
36
38
|
working: "",
|
|
@@ -64,6 +66,7 @@ const NERD_GLYPHS: IconGlyphs = {
|
|
|
64
66
|
// avoid collisions with the git-status set {= S ! A ? r x ^ v}.
|
|
65
67
|
const ASCII_GLYPHS: IconGlyphs = {
|
|
66
68
|
cwd: "@",
|
|
69
|
+
host: "h",
|
|
67
70
|
session: "s",
|
|
68
71
|
git: "*",
|
|
69
72
|
working: "o",
|
|
@@ -91,33 +94,13 @@ const ASCII_GLYPHS: IconGlyphs = {
|
|
|
91
94
|
deleted: "x",
|
|
92
95
|
};
|
|
93
96
|
|
|
94
|
-
const NERD_FONT_TERMINALS = new Set([
|
|
95
|
-
"iTerm.app",
|
|
96
|
-
"Ghostty",
|
|
97
|
-
"WezTerm",
|
|
98
|
-
"kitty",
|
|
99
|
-
"rio",
|
|
100
|
-
"tabby",
|
|
101
|
-
"WindowsTerminal",
|
|
102
|
-
"vscode",
|
|
103
|
-
]);
|
|
104
|
-
|
|
105
97
|
export function detectNerdFont(): boolean {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (process.env.TERM === "xterm-kitty") return true;
|
|
113
|
-
|
|
114
|
-
// Windows Terminal sets WT_SESSION (not TERM_PROGRAM)
|
|
115
|
-
if (process.env.WT_SESSION) return true;
|
|
116
|
-
|
|
117
|
-
// VS Code integrated terminal
|
|
118
|
-
if (process.env.TERM_PROGRAM === "vscode") return true;
|
|
119
|
-
|
|
120
|
-
return false;
|
|
98
|
+
// TTY and locale checks are the only reliable signals available here. The
|
|
99
|
+
// terminal emulator owns font selection, so auto mode is optimistic once
|
|
100
|
+
// output is an interactive UTF-8 TTY.
|
|
101
|
+
if (process.env.TERM === "dumb" || process.stdout.isTTY !== true) return false;
|
|
102
|
+
const locale = [process.env.LC_ALL, process.env.LC_CTYPE, process.env.LANG].find(Boolean);
|
|
103
|
+
return locale === undefined || /utf-?8/i.test(locale);
|
|
121
104
|
}
|
|
122
105
|
|
|
123
106
|
export function resolveIconMode(mode: IconMode): "nerd" | "ascii" {
|
|
@@ -23,10 +23,10 @@ import {
|
|
|
23
23
|
type PeekState,
|
|
24
24
|
} from "./peek.ts";
|
|
25
25
|
|
|
26
|
-
type PendingUiChange = "install" | "uninstall";
|
|
27
|
-
const HIDDEN_THINKING_HORIZONTAL_PADDING = 2;
|
|
28
|
-
// Pi's fullscreen transcript can reserve one more column for its scrollbar.
|
|
29
|
-
const HIDDEN_THINKING_SCROLLBAR_RESERVE = 1;
|
|
26
|
+
type PendingUiChange = "install" | "uninstall";
|
|
27
|
+
const HIDDEN_THINKING_HORIZONTAL_PADDING = 2;
|
|
28
|
+
// Pi's fullscreen transcript can reserve one more column for its scrollbar.
|
|
29
|
+
const HIDDEN_THINKING_SCROLLBAR_RESERVE = 1;
|
|
30
30
|
|
|
31
31
|
export function getPendingUiChange(enabled: boolean, active: boolean): PendingUiChange | undefined {
|
|
32
32
|
if (enabled === active) return undefined;
|
|
@@ -57,10 +57,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
57
57
|
let editor: ReturnType<typeof installEditor> | undefined;
|
|
58
58
|
let pendingUiChange: PendingUiChange | undefined;
|
|
59
59
|
|
|
60
|
-
// thinking-peek state (rendered by Pi inside its hidden-thinking block)
|
|
61
|
-
const peek: PeekState = createPeekState();
|
|
62
|
-
let peekFrame = 0;
|
|
63
|
-
let peekSettleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
60
|
+
// thinking-peek state (rendered by Pi inside its hidden-thinking block)
|
|
61
|
+
const peek: PeekState = createPeekState();
|
|
62
|
+
let peekFrame = 0;
|
|
63
|
+
let peekSettleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
64
64
|
let peekTaskEpoch = 0; // bumped at every agent task boundary
|
|
65
65
|
let peekLabelActive = false;
|
|
66
66
|
|
|
@@ -78,47 +78,47 @@ export default function (pi: ExtensionAPI) {
|
|
|
78
78
|
/** Pi decides whether this label is visible; our toggle only controls updates. */
|
|
79
79
|
const isPeekEnabled = () => sessionLifecycle.isCurrent() && config.enabled && config.thinkingPeek.lines > 0 && active;
|
|
80
80
|
|
|
81
|
-
/** Keep each native hidden-thinking label row within Pi's narrowest transcript width. */
|
|
82
|
-
const hiddenThinkingLabelWidth = (): number => {
|
|
83
|
-
if (!editor) throw new Error("Open TUI editor is not installed");
|
|
84
|
-
return Math.max(
|
|
85
|
-
1,
|
|
86
|
-
editor.getViewportWidth() - HIDDEN_THINKING_HORIZONTAL_PADDING - HIDDEN_THINKING_SCROLLBAR_RESERVE,
|
|
87
|
-
);
|
|
88
|
-
};
|
|
81
|
+
/** Keep each native hidden-thinking label row within Pi's narrowest transcript width. */
|
|
82
|
+
const hiddenThinkingLabelWidth = (): number => {
|
|
83
|
+
if (!editor) throw new Error("Open TUI editor is not installed");
|
|
84
|
+
return Math.max(
|
|
85
|
+
1,
|
|
86
|
+
editor.getViewportWidth() - HIDDEN_THINKING_HORIZONTAL_PADDING - HIDDEN_THINKING_SCROLLBAR_RESERVE,
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
89
|
|
|
90
90
|
const setPeekLabel = (ctx?: ExtensionContext): void => {
|
|
91
91
|
if (!isPeekEnabled() || peek.phase === "idle") return;
|
|
92
92
|
const target = ctx ?? lastCtx;
|
|
93
93
|
if (!target || !isTuiContext(target)) return;
|
|
94
|
-
if (!editor) throw new Error("Open TUI editor is not installed");
|
|
95
|
-
editor.setLatestHiddenThinkingLabel(
|
|
96
|
-
buildPeekLabel(
|
|
97
|
-
peek,
|
|
98
|
-
peekFrame,
|
|
99
|
-
resolveGlyphs(config.icons.mode),
|
|
100
|
-
hiddenThinkingLabelWidth(),
|
|
101
|
-
config.thinkingPeek.lines,
|
|
102
|
-
),
|
|
103
|
-
);
|
|
94
|
+
if (!editor) throw new Error("Open TUI editor is not installed");
|
|
95
|
+
editor.setLatestHiddenThinkingLabel(
|
|
96
|
+
buildPeekLabel(
|
|
97
|
+
peek,
|
|
98
|
+
peekFrame,
|
|
99
|
+
resolveGlyphs(config.icons.mode),
|
|
100
|
+
hiddenThinkingLabelWidth(),
|
|
101
|
+
config.thinkingPeek.lines,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
104
|
peekLabelActive = true;
|
|
105
105
|
};
|
|
106
106
|
|
|
107
107
|
const clearPeekLabel = (ctx?: ExtensionContext): void => {
|
|
108
|
-
if (!peekLabelActive) return;
|
|
109
|
-
const target = ctx ?? lastCtx;
|
|
110
|
-
if (!target || !isTuiContext(target)) return;
|
|
111
|
-
// The global reset is safe here: every completed message returns to Pi's native label.
|
|
112
|
-
target.ui.setHiddenThinkingLabel();
|
|
108
|
+
if (!peekLabelActive) return;
|
|
109
|
+
const target = ctx ?? lastCtx;
|
|
110
|
+
if (!target || !isTuiContext(target)) return;
|
|
111
|
+
// The global reset is safe here: every completed message returns to Pi's native label.
|
|
112
|
+
target.ui.setHiddenThinkingLabel();
|
|
113
113
|
peekLabelActive = false;
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
-
/** Reset peek state to idle and cancel any scheduled cleanup. */
|
|
117
|
-
const resetPeek = () => {
|
|
118
|
-
peek.phase = "idle";
|
|
119
|
-
peek.tail = "";
|
|
120
|
-
editor?.resetHiddenThinkingLabelTarget();
|
|
121
|
-
if (peekSettleTimer) {
|
|
116
|
+
/** Reset peek state to idle and cancel any scheduled cleanup. */
|
|
117
|
+
const resetPeek = () => {
|
|
118
|
+
peek.phase = "idle";
|
|
119
|
+
peek.tail = "";
|
|
120
|
+
editor?.resetHiddenThinkingLabelTarget();
|
|
121
|
+
if (peekSettleTimer) {
|
|
122
122
|
clearTimeout(peekSettleTimer);
|
|
123
123
|
peekSettleTimer = undefined;
|
|
124
124
|
}
|
|
@@ -131,11 +131,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
131
131
|
}
|
|
132
132
|
if (!active) {
|
|
133
133
|
cleanupHeader = installHeader(pi, ctx);
|
|
134
|
-
|
|
134
|
+
const footer = installFooter(
|
|
135
135
|
ctx,
|
|
136
136
|
() => state,
|
|
137
137
|
() => config,
|
|
138
|
-
() => getModelMeta(ctx, getThinkingLevel),
|
|
138
|
+
() => getModelMeta(ctx, getThinkingLevel, config.footerSegments.capitalizeProviderName),
|
|
139
139
|
{
|
|
140
140
|
setRequestRender: (fn) => {
|
|
141
141
|
requestFooterRender = fn ?? undefined;
|
|
@@ -145,7 +145,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
145
145
|
},
|
|
146
146
|
},
|
|
147
147
|
);
|
|
148
|
-
|
|
148
|
+
cleanupFooter = footer.cleanup;
|
|
149
|
+
editor = installEditor(
|
|
150
|
+
pi,
|
|
151
|
+
ctx,
|
|
152
|
+
config.cursorStyle,
|
|
153
|
+
config.fullscreen.wheelScrollLines,
|
|
154
|
+
{
|
|
155
|
+
enabled: () => config.inlineFooter,
|
|
156
|
+
render: footer.renderInline,
|
|
157
|
+
},
|
|
158
|
+
);
|
|
149
159
|
active = true;
|
|
150
160
|
}
|
|
151
161
|
};
|
|
@@ -292,22 +302,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
292
302
|
pi.on("message_update", (event, ctx) => {
|
|
293
303
|
turnTelemetry.handle(event);
|
|
294
304
|
if (!sessionLifecycle.isCurrent() || isAgentIdle(ctx)) return;
|
|
295
|
-
if (!isPeekEnabled()) return;
|
|
296
|
-
const message = event.message;
|
|
297
|
-
if (!message || message.role !== "assistant") return;
|
|
298
|
-
if (peek.phase === "done") return;
|
|
299
|
-
const parts = collectPeekParts(message.content);
|
|
300
|
-
const next = reducePeek(peek, parts);
|
|
305
|
+
if (!isPeekEnabled()) return;
|
|
306
|
+
const message = event.message;
|
|
307
|
+
if (!message || message.role !== "assistant") return;
|
|
308
|
+
if (peek.phase === "done") return;
|
|
309
|
+
const parts = collectPeekParts(message.content);
|
|
310
|
+
const next = reducePeek(peek, parts);
|
|
301
311
|
const changed = next.phase !== peek.phase || next.tail !== peek.tail;
|
|
302
312
|
peek.phase = next.phase;
|
|
303
|
-
peek.tail = next.tail;
|
|
304
|
-
if (!changed) return;
|
|
305
|
-
if (peek.phase === "thinking") {
|
|
306
|
-
peekFrame++;
|
|
307
|
-
setPeekLabel(ctx);
|
|
308
|
-
} else if (peek.phase === "done") {
|
|
309
|
-
setPeekLabel(ctx);
|
|
310
|
-
}
|
|
313
|
+
peek.tail = next.tail;
|
|
314
|
+
if (!changed) return;
|
|
315
|
+
if (peek.phase === "thinking") {
|
|
316
|
+
peekFrame++;
|
|
317
|
+
setPeekLabel(ctx);
|
|
318
|
+
} else if (peek.phase === "done") {
|
|
319
|
+
setPeekLabel(ctx);
|
|
320
|
+
}
|
|
311
321
|
});
|
|
312
322
|
|
|
313
323
|
pi.on("tool_execution_start", (event) => {
|
|
@@ -346,10 +356,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
346
356
|
pi.on("message_end", (event, ctx) => {
|
|
347
357
|
turnTelemetry.handle(event);
|
|
348
358
|
if (!sessionLifecycle.isCurrent()) return;
|
|
349
|
-
if (event.message?.role === "assistant" && peek.phase === "thinking") {
|
|
350
|
-
// Sub-message finished (answer text or a tool call): freeze the peek line.
|
|
351
|
-
peek.phase = "done";
|
|
352
|
-
setPeekLabel(ctx);
|
|
359
|
+
if (event.message?.role === "assistant" && peek.phase === "thinking") {
|
|
360
|
+
// Sub-message finished (answer text or a tool call): freeze the peek line.
|
|
361
|
+
peek.phase = "done";
|
|
362
|
+
setPeekLabel(ctx);
|
|
353
363
|
}
|
|
354
364
|
invalidateUsageCache();
|
|
355
365
|
refreshInteractiveState(ctx);
|
|
@@ -33,12 +33,14 @@ const COPY = {
|
|
|
33
33
|
hint: "Tab/Shift+Tab/←/→: tabs · ↑/↓: move · Enter/Space: change · Enter on wheel speed: type 1-10 · Esc/q: close",
|
|
34
34
|
labels: {
|
|
35
35
|
enabled: "Enabled",
|
|
36
|
+
inlineFooter: "Inline footer",
|
|
36
37
|
thinkingPeek: "Thinking peek",
|
|
37
38
|
language: "Language",
|
|
38
39
|
wheelScrollLines: "Mouse wheel speed",
|
|
39
40
|
cursorStyle: "Cursor style",
|
|
40
41
|
iconMode: "Icon mode",
|
|
41
42
|
cwd: "CWD",
|
|
43
|
+
hostname: "Hostname",
|
|
42
44
|
sessionName: "Session name",
|
|
43
45
|
gitBranch: "Git branch",
|
|
44
46
|
gitStatus: "Git status",
|
|
@@ -48,6 +50,7 @@ const COPY = {
|
|
|
48
50
|
tokens: "Tokens",
|
|
49
51
|
cost: "Cost",
|
|
50
52
|
extensionStatuses: "Extension status line",
|
|
53
|
+
capitalizeProviderName: "Capitalize provider name",
|
|
51
54
|
totalDuration: "Total duration",
|
|
52
55
|
tokenCounts: "Token counts",
|
|
53
56
|
stallDetails: "Stall details",
|
|
@@ -70,12 +73,14 @@ const COPY = {
|
|
|
70
73
|
hint: "Tab/Shift+Tab/←/→:切页 · ↑/↓:移动 · Enter/Space:更改 · 滚轮速度项 Enter 输入 1-10 · Esc/q:关闭",
|
|
71
74
|
labels: {
|
|
72
75
|
enabled: "启用",
|
|
76
|
+
inlineFooter: "内联底栏",
|
|
73
77
|
thinkingPeek: "思考预览",
|
|
74
78
|
language: "语言",
|
|
75
79
|
wheelScrollLines: "鼠标滚轮速度",
|
|
76
80
|
cursorStyle: "光标样式",
|
|
77
81
|
iconMode: "图标模式",
|
|
78
82
|
cwd: "当前目录",
|
|
83
|
+
hostname: "主机名",
|
|
79
84
|
sessionName: "会话名",
|
|
80
85
|
gitBranch: "Git 分支",
|
|
81
86
|
gitStatus: "Git 状态",
|
|
@@ -85,6 +90,7 @@ const COPY = {
|
|
|
85
90
|
tokens: "Token",
|
|
86
91
|
cost: "费用",
|
|
87
92
|
extensionStatuses: "扩展状态行",
|
|
93
|
+
capitalizeProviderName: "提供商名称首字母大写",
|
|
88
94
|
totalDuration: "总耗时",
|
|
89
95
|
tokenCounts: "Token 数量",
|
|
90
96
|
stallDetails: "停顿详情",
|
|
@@ -181,6 +187,7 @@ function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
|
|
|
181
187
|
currentValue: copy.values.wheelLines(config.fullscreen.wheelScrollLines),
|
|
182
188
|
},
|
|
183
189
|
{ id: "thinkingPeek", label: copy.labels.thinkingPeek, currentValue: formatThinkingPeekLines(config.thinkingPeek.lines, copy) },
|
|
190
|
+
{ id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) },
|
|
184
191
|
];
|
|
185
192
|
}
|
|
186
193
|
|
|
@@ -196,6 +203,7 @@ function buildSegmentsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
|
|
|
196
203
|
const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
|
|
197
204
|
return [
|
|
198
205
|
{ id: "cwd", label: copy.labels.cwd, currentValue: flag(segs.cwd) },
|
|
206
|
+
{ id: "hostname", label: copy.labels.hostname, currentValue: flag(segs.hostname) },
|
|
199
207
|
{ id: "sessionName", label: copy.labels.sessionName, currentValue: flag(segs.sessionName) },
|
|
200
208
|
{ id: "gitBranch", label: copy.labels.gitBranch, currentValue: flag(segs.gitBranch) },
|
|
201
209
|
{ id: "gitStatus", label: copy.labels.gitStatus, currentValue: flag(segs.gitStatus) },
|
|
@@ -205,6 +213,7 @@ function buildSegmentsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
|
|
|
205
213
|
{ id: "tokens", label: copy.labels.tokens, currentValue: flag(segs.tokens) },
|
|
206
214
|
{ id: "cost", label: copy.labels.cost, currentValue: flag(segs.cost) },
|
|
207
215
|
{ id: "extensionStatuses", label: copy.labels.extensionStatuses, currentValue: flag(segs.extensionStatuses) },
|
|
216
|
+
{ id: "capitalizeProviderName", label: copy.labels.capitalizeProviderName, currentValue: flag(segs.capitalizeProviderName) },
|
|
208
217
|
];
|
|
209
218
|
}
|
|
210
219
|
|
|
@@ -239,6 +248,7 @@ function handleSettingChange(
|
|
|
239
248
|
): OpenTuiConfig {
|
|
240
249
|
if (tab === "features") {
|
|
241
250
|
if (itemId === "enabled") return toggleEnabled(config);
|
|
251
|
+
if (itemId === "inlineFooter") return { ...config, inlineFooter: !config.inlineFooter };
|
|
242
252
|
if (itemId === "settingsLanguage") return toggleLanguage(config);
|
|
243
253
|
if (itemId === "thinkingPeek") return cycleThinkingPeek(config);
|
|
244
254
|
}
|
|
@@ -374,7 +384,7 @@ class SettingsUi implements SettingsUiHandle {
|
|
|
374
384
|
description: editing || this.compact ? undefined : item.currentValue,
|
|
375
385
|
} as SelectItem;
|
|
376
386
|
});
|
|
377
|
-
this.selectList = new SelectList(items, Math.min(items.length,
|
|
387
|
+
this.selectList = new SelectList(items, Math.min(items.length, 12), {
|
|
378
388
|
selectedPrefix: (t) => this.theme.fg("accent", t),
|
|
379
389
|
selectedText: (t) => this.theme.fg("accent", t),
|
|
380
390
|
description: (t) => this.theme.fg("muted", t),
|
|
@@ -94,8 +94,9 @@ export interface ModelMeta {
|
|
|
94
94
|
export function getModelMeta(
|
|
95
95
|
ctx: ExtensionContext,
|
|
96
96
|
getThinkingLevel: () => string,
|
|
97
|
+
capitalizeProviderName: boolean,
|
|
97
98
|
): ModelMeta {
|
|
98
|
-
const provider = formatProviderLabel(ctx.model?.provider);
|
|
99
|
+
const provider = formatProviderLabel(ctx.model?.provider, capitalizeProviderName);
|
|
99
100
|
const model = ctx.model?.name ?? ctx.model?.id ?? "no-model";
|
|
100
101
|
const reasoning = ctx.model?.reasoning ?? false;
|
|
101
102
|
const effort = reasoning ? getThinkingLevel() : undefined;
|
|
@@ -97,9 +97,9 @@ export function formatModelLabel(model: { provider?: string; id?: string } | nul
|
|
|
97
97
|
return model.provider ? `${model.provider}/${model.id}` : model.id;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
export function formatProviderLabel(provider: string | undefined): string {
|
|
100
|
+
export function formatProviderLabel(provider: string | undefined, capitalize: boolean): string {
|
|
101
101
|
if (!provider) return "Unknown";
|
|
102
|
-
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
102
|
+
return capitalize ? provider.charAt(0).toUpperCase() + provider.slice(1) : provider;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
export function alignRight(left: string, right: string, width: number, theme: Theme): string {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-open-tui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "A polished TUI for Pi coding agent: animated logo header, Starship-style footer, rounded editor with model metadata, and prompt-box user messages.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|