pi-tui-footer 1.2.4 → 1.2.6

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.
@@ -1,10 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
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
4
  import type { IconMode } from "./icons.ts";
9
5
 
10
6
  export type SettingsLanguage = "en" | "zh";
@@ -42,17 +38,12 @@ export interface ThinkingPeekConfig {
42
38
  lines: ThinkingPeekLines;
43
39
  }
44
40
 
45
- export interface FullscreenConfig {
46
- wheelScrollLines: number;
47
- }
48
-
49
41
  export interface OpenTuiConfig {
50
42
  enabled: boolean;
51
43
  roundedTools: boolean;
52
44
  inlineFooter: boolean;
53
45
  settingsLanguage: SettingsLanguage;
54
46
  cursorStyle: CursorStyle;
55
- fullscreen: FullscreenConfig;
56
47
  icons: {
57
48
  mode: IconMode;
58
49
  };
@@ -61,17 +52,12 @@ export interface OpenTuiConfig {
61
52
  thinkingPeek: ThinkingPeekConfig;
62
53
  }
63
54
 
64
- export type PiTuiConfig = OpenTuiConfig;
65
-
66
55
  export const DEFAULT_CONFIG: OpenTuiConfig = {
67
56
  enabled: true,
68
57
  roundedTools: true,
69
58
  inlineFooter: false,
70
59
  settingsLanguage: "en",
71
60
  cursorStyle: "block",
72
- fullscreen: {
73
- wheelScrollLines: DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
74
- },
75
61
  icons: {
76
62
  mode: "auto",
77
63
  },
@@ -105,11 +91,7 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
105
91
 
106
92
  export function getConfigPath(): string {
107
93
  const agentDir = getAgentDir();
108
- const piTuiPath = join(agentDir, "pi-tui.json");
109
- const openTuiPath = join(agentDir, "open-tui.json");
110
- if (existsSync(piTuiPath)) return piTuiPath;
111
- if (existsSync(openTuiPath)) return openTuiPath;
112
- return piTuiPath;
94
+ return join(agentDir, "pi-tui.json");
113
95
  }
114
96
 
115
97
  function normalizeThinkingPeekLines(value: unknown): ThinkingPeekLines {
@@ -161,27 +143,23 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
161
143
  const raw = readFileSync(path, "utf8");
162
144
  const parsed: unknown = JSON.parse(raw);
163
145
  const config = deepMerge(DEFAULT_CONFIG, parsed);
146
+ if (typeof config.inlineFooter !== "boolean") {
147
+ config.inlineFooter = DEFAULT_CONFIG.inlineFooter;
148
+ }
149
+ if (typeof config.roundedTools !== "boolean") {
150
+ config.roundedTools = DEFAULT_CONFIG.roundedTools;
151
+ }
164
152
  if (config.settingsLanguage !== "en" && config.settingsLanguage !== "zh") {
165
153
  config.settingsLanguage = DEFAULT_CONFIG.settingsLanguage;
166
154
  }
167
155
  if (config.cursorStyle !== "block" && config.cursorStyle !== "bar" && config.cursorStyle !== "underline") {
168
156
  config.cursorStyle = DEFAULT_CONFIG.cursorStyle;
169
157
  }
170
- config.fullscreen.wheelScrollLines = normalizeFullscreenWheelScrollLines(
171
- config.fullscreen.wheelScrollLines,
172
- DEFAULT_CONFIG.fullscreen.wheelScrollLines,
173
- );
174
158
  if (typeof config.thinkingPeek !== "object" || config.thinkingPeek === null || Array.isArray(config.thinkingPeek)) {
175
159
  config.thinkingPeek = structuredClone(DEFAULT_CONFIG.thinkingPeek);
176
160
  } else {
177
161
  config.thinkingPeek.lines = normalizeThinkingPeekLines(config.thinkingPeek.lines);
178
162
  }
179
- if (typeof config.inlineFooter !== "boolean") {
180
- config.inlineFooter = DEFAULT_CONFIG.inlineFooter;
181
- }
182
- if (typeof config.roundedTools !== "boolean") {
183
- config.roundedTools = DEFAULT_CONFIG.roundedTools;
184
- }
185
163
  return config;
186
164
  } catch (err) {
187
165
  notify?.(`pi-tui config parse error: ${err instanceof Error ? err.message : String(err)}`, "warning");
@@ -7,10 +7,6 @@ import {
7
7
  import type { EditorTheme, TuiMouseEvent, TuiMouseEventResult, TUI } from "@earendil-works/pi-tui";
8
8
  import { CURSOR_MARKER, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
9
  import type { CursorStyle } from "./config.ts";
10
- import {
11
- applyFullscreenWheelScrollLines,
12
- DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
13
- } from "./fullscreen-scroll.ts";
14
10
  import { findBottomBorderIndex, isEditorBorderLine, stripAnsi } from "./utils.ts";
15
11
 
16
12
  function fillLine(content: string, width: number): string {
@@ -316,14 +312,12 @@ export function installEditor(
316
312
  _pi: ExtensionAPI,
317
313
  ctx: ExtensionContext,
318
314
  cursorStyle: CursorStyle = "block",
319
- wheelScrollLines = DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
320
315
  inlineFooter?: InlineFooterRenderer,
321
316
  ) {
322
317
  let activeTui: TUI | undefined;
323
318
  let activeEditor: OpenTuiEditor | undefined;
324
319
  let previousHardwareCursor: boolean | undefined;
325
320
  let currentCursorStyle = cursorStyle;
326
- let currentWheelScrollLines = wheelScrollLines;
327
321
  let hiddenThinkingTarget: HiddenThinkingLabelComponent | undefined;
328
322
  const getActiveTui = (): TUI => {
329
323
  if (!activeTui) throw new Error("Open TUI editor is not mounted");
@@ -333,7 +327,6 @@ export function installEditor(
333
327
  ctx.ui.setEditorComponent((tui, editorTheme, keybindings) => {
334
328
  activeTui = tui;
335
329
  hiddenThinkingTarget = undefined;
336
- applyFullscreenWheelScrollLines(tui, currentWheelScrollLines);
337
330
  previousHardwareCursor = tui.getShowHardwareCursor();
338
331
  activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle, inlineFooter);
339
332
  return activeEditor;
@@ -359,10 +352,6 @@ export function installEditor(
359
352
  currentCursorStyle = nextCursorStyle;
360
353
  activeEditor?.setCursorStyle(nextCursorStyle, previousHardwareCursor);
361
354
  },
362
- setWheelScrollLines(nextWheelScrollLines: number): void {
363
- currentWheelScrollLines = nextWheelScrollLines;
364
- if (activeTui) applyFullscreenWheelScrollLines(activeTui, currentWheelScrollLines);
365
- },
366
355
  cleanup(): void {
367
356
  hiddenThinkingTarget = undefined;
368
357
  ctx.ui.setEditorComponent(undefined);
@@ -16,8 +16,9 @@ import {
16
16
  formatCwd,
17
17
  formatDuration,
18
18
  formatInputBreakdown,
19
+ hasAnsiStyles,
19
20
  providerColor,
20
- sanitizeStatus,
21
+ sanitizeStatusPreservingStyles,
21
22
  stressColor,
22
23
  truncateBranch,
23
24
  truncatePath,
@@ -181,6 +182,18 @@ function renderStatsBlock(
181
182
  return stats.join(` ${theme.fg("dim", "|")} `);
182
183
  }
183
184
 
185
+ const ANSI_RESET = "\x1b[0m";
186
+
187
+ /**
188
+ * Keep the colours an extension applied to its own status text. Plain text stays
189
+ * muted so unstyled statuses keep the quiet footer look. The trailing reset
190
+ * stops an unclosed style from tinting the separator and the next status.
191
+ */
192
+ function renderStatusText(theme: Theme, status: string): string {
193
+ if (!hasAnsiStyles(status)) return theme.fg("muted", status);
194
+ return `${status}${ANSI_RESET}`;
195
+ }
196
+
184
197
  function renderExtensionStatusLines(
185
198
  theme: Theme,
186
199
  extensionStatuses: ReadonlyMap<string, string>,
@@ -189,12 +202,12 @@ function renderExtensionStatusLines(
189
202
  ): string[] {
190
203
  const statuses = Array.from(extensionStatuses.entries())
191
204
  .sort(([a], [b]) => a.localeCompare(b))
192
- .map(([, text]) => sanitizeStatus(text))
205
+ .map(([, text]) => sanitizeStatusPreservingStyles(text))
193
206
  .filter((text) => text.length > 0);
194
207
  if (statuses.length === 0) return [];
195
208
 
196
209
  const separator = ` ${theme.fg("dim", "|")} `;
197
- const statusText = statuses.map((status) => theme.fg("muted", status)).join(separator);
210
+ const statusText = statuses.map((status) => renderStatusText(theme, status)).join(separator);
198
211
  const line = `${theme.fg("mdLink", glyphs.extensions)} ${statusText}`;
199
212
  return wrapTextWithAnsi(line, width);
200
213
  }
@@ -1,4 +1,4 @@
1
- export type IconMode = "auto" | "nerd" | "ascii";
1
+ export type IconMode = "auto" | "nerd" | "unicode" | "ascii";
2
2
 
3
3
  export interface IconGlyphs {
4
4
  cwd: string;
@@ -94,6 +94,47 @@ const ASCII_GLYPHS: IconGlyphs = {
94
94
  deleted: "x",
95
95
  };
96
96
 
97
+ // Portable Unicode icon set for terminals without a Nerd Font. Keeps the
98
+ // footer icon-like instead of dropping to letters: emoji glyphs (folder,
99
+ // branch, laptop, bulb, floppy, plug, hourglass, bolt) render through the
100
+ // client terminal's emoji fallback at 2 columns, which matches
101
+ // pi-tui's visibleWidth for RGI emoji; the remaining symbols are
102
+ // single-width and covered by DejaVu Sans Mono, JetBrains Mono, Noto Sans
103
+ // Mono and Liberation Mono. Glyph choices are easy to adjust per slot.
104
+ const UNICODE_GLYPHS: IconGlyphs = {
105
+ cwd: "📁",
106
+ host: "⌂",
107
+ session: "🔖",
108
+ git: "🌿",
109
+ working: "◷",
110
+ done: "✓",
111
+ context: "≡",
112
+ model: "💻",
113
+ thinking: "💡",
114
+ input: "↑",
115
+ output: "↓",
116
+ cacheHit: "💾",
117
+ cost: "$",
118
+ speed: "⚡",
119
+ latency: "⌛",
120
+ stall: "⚠",
121
+ extensions: "🔌",
122
+ ahead: "↑",
123
+ behind: "↓",
124
+ diverged: "↕",
125
+ conflicted: "=",
126
+ stashed: "$",
127
+ modified: "!",
128
+ staged: "+",
129
+ untracked: "?",
130
+ renamed: "→",
131
+ deleted: "✗",
132
+ };
133
+
134
+ function isSshSession(): boolean {
135
+ return Boolean(process.env.SSH_TTY || process.env.SSH_CONNECTION);
136
+ }
137
+
97
138
  export function detectNerdFont(): boolean {
98
139
  // TTY and locale checks are the only reliable signals available here. The
99
140
  // terminal emulator owns font selection, so auto mode is optimistic once
@@ -103,14 +144,23 @@ export function detectNerdFont(): boolean {
103
144
  return locale === undefined || /utf-?8/i.test(locale);
104
145
  }
105
146
 
106
- export function resolveIconMode(mode: IconMode): "nerd" | "ascii" {
147
+ export function resolveIconMode(mode: IconMode): "nerd" | "unicode" | "ascii" {
107
148
  if (mode === "nerd") return "nerd";
108
149
  if (mode === "ascii") return "ascii";
109
- return detectNerdFont() ? "nerd" : "ascii";
150
+ if (mode === "unicode") return "unicode";
151
+ if (!detectNerdFont()) return "ascii";
152
+ // Auto cannot know whether the terminal that renders this session ships a
153
+ // Nerd Font (see #38). Local terminals usually do; SSH clients usually do
154
+ // not, because the font lives on the client device, so portable Unicode
155
+ // symbols are the safer default there. Terminals that do have a Nerd Font
156
+ // can opt back in with an explicit icons.mode of "nerd".
157
+ if (isSshSession()) return "unicode";
158
+ return "nerd";
110
159
  }
111
160
 
112
161
  export function resolveGlyphs(mode: IconMode): IconGlyphs {
113
162
  const resolved = resolveIconMode(mode);
163
+ if (resolved === "unicode") return UNICODE_GLYPHS;
114
164
  return resolved === "nerd" ? NERD_GLYPHS : ASCII_GLYPHS;
115
165
  }
116
166
 
@@ -185,6 +235,6 @@ const RUNTIME_ASCII_SYMBOLS: Record<string, string> = {
185
235
  };
186
236
 
187
237
  export function runtimeSymbol(name: string, mode: IconMode): string {
188
- if (resolveIconMode(mode) === "ascii") return RUNTIME_ASCII_SYMBOLS[name] ?? name;
238
+ if (resolveIconMode(mode) !== "nerd") return RUNTIME_ASCII_SYMBOLS[name] ?? name;
189
239
  return RUNTIME_SYMBOLS[name] ?? "";
190
240
  }
@@ -4,7 +4,7 @@
4
4
  * thinking peek preview, rounded tool frames, and turn telemetry.
5
5
  *
6
6
  * Integrated from:
7
- * - pi-open-tui (v0.3.9, MIT, by OldSun, https://github.com/OldSuns/pi-open-tui)
7
+ * - pi-open-tui (v0.3.11, MIT, by OldSun, https://github.com/OldSuns/pi-open-tui)
8
8
  * - pi-rounded-tools (v0.1.3, MIT, by OrionPax, https://github.com/orionpax1997/pi-rounded-tools)
9
9
  *
10
10
  * Security audit notes:
@@ -17,7 +17,7 @@
17
17
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
18
  import { type OpenTuiConfig, DEFAULT_CONFIG, ensureConfigExists, loadConfig, saveConfig } from "./config.ts";
19
19
  import { installEditor } from "./editor.ts";
20
- import { installFooter, type FooterHandle } from "./footer.ts";
20
+ import { installFooter } from "./footer.ts";
21
21
  import { installHeader } from "./header.ts";
22
22
  import { emptyGitStatus, readGitStatus } from "./git.ts";
23
23
  import { readRuntimeInfo } from "./runtime.ts";
@@ -71,7 +71,7 @@ export default function (pi: ExtensionAPI) {
71
71
  let requestFooterRender: (() => void) | undefined;
72
72
  let workingTimer: ReturnType<typeof setInterval> | undefined;
73
73
  let cleanupHeader: (() => void) | undefined;
74
- let cleanupFooter: FooterHandle | undefined;
74
+ let cleanupFooter: (() => void) | undefined;
75
75
  let editor: ReturnType<typeof installEditor> | undefined;
76
76
  let pendingUiChange: PendingUiChange | undefined;
77
77
 
@@ -164,12 +164,11 @@ export default function (pi: ExtensionAPI) {
164
164
  },
165
165
  },
166
166
  );
167
- cleanupFooter = footer;
167
+ cleanupFooter = footer.cleanup;
168
168
  editor = installEditor(
169
169
  pi,
170
170
  ctx,
171
171
  config.cursorStyle,
172
- config.fullscreen.wheelScrollLines,
173
172
  {
174
173
  enabled: () => config.inlineFooter,
175
174
  render: footer.renderInline,
@@ -183,7 +182,7 @@ export default function (pi: ExtensionAPI) {
183
182
  if (!isTuiContext(ctx)) return;
184
183
  if (active) {
185
184
  cleanupHeader?.();
186
- cleanupFooter?.cleanup();
185
+ cleanupFooter?.();
187
186
  editor?.cleanup();
188
187
  cleanupHeader = undefined;
189
188
  cleanupFooter = undefined;
@@ -423,7 +422,6 @@ export default function (pi: ExtensionAPI) {
423
422
  const wasEnabled = config.enabled;
424
423
  const wasRoundedTools = config.roundedTools;
425
424
  const cursorStyleChanged = config.cursorStyle !== newConfig.cursorStyle;
426
- const wheelScrollLinesChanged = config.fullscreen.wheelScrollLines !== newConfig.fullscreen.wheelScrollLines;
427
425
  const thinkingPeekLinesChanged = config.thinkingPeek.lines !== newConfig.thinkingPeek.lines;
428
426
  saveConfig(newConfig);
429
427
  config = newConfig;
@@ -436,9 +434,6 @@ export default function (pi: ExtensionAPI) {
436
434
  if (cursorStyleChanged && active && editor) {
437
435
  editor.setCursorStyle(newConfig.cursorStyle);
438
436
  }
439
- if (wheelScrollLinesChanged && active && editor) {
440
- editor.setWheelScrollLines(newConfig.fullscreen.wheelScrollLines);
441
- }
442
437
  if (lastCtx) {
443
438
  pendingUiChange = getPendingUiChange(newConfig.enabled, active);
444
439
  }
@@ -4,7 +4,7 @@
4
4
  * Merged 2026-08 from the standalone local extension rounded-tools.ts (a
5
5
  * localized copy of npm:pi-rounded-tools@0.1.3, MIT, by OrionPax,
6
6
  * https://github.com/orionpax1997/pi-rounded-tools), integrated into pi-tui
7
- * (based on https://github.com/OldSuns/pi-open-tui v0.3.9, MIT, by
7
+ * (based on https://github.com/OldSuns/pi-open-tui v0.3.11, MIT, by
8
8
  * OldSun) and gated behind `roundedTools` in pi-tui config
9
9
  * (~/.pi/agent/pi-tui.json, default on).
10
10
  *
@@ -1,7 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  Box,
4
- Input,
5
4
  Key,
6
5
  matchesKey,
7
6
  SelectList,
@@ -11,10 +10,6 @@ import {
11
10
  Text,
12
11
  } from "@earendil-works/pi-tui";
13
12
  import type { CursorStyle, IconMode, OpenTuiConfig, SettingsLanguage, ThinkingPeekLines } from "./config.ts";
14
- import {
15
- DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
16
- normalizeFullscreenWheelScrollLines,
17
- } from "./fullscreen-scroll.ts";
18
13
 
19
14
  interface SettingItem {
20
15
  id: string;
@@ -30,14 +25,13 @@ const COPY = {
30
25
  en: {
31
26
  title: "Pi TUI Settings",
32
27
  tabs: { features: "General", icons: "Appearance", segments: "Footer", telemetry: "Telemetry" },
33
- hint: "Tab/Shift+Tab/←/→: tabs · ↑/↓: move · Enter/Space: change · Enter on wheel speed: type 1-10 · Esc/q: close",
28
+ hint: "Tab/Shift+Tab/←/→: tabs · ↑/↓: move · Enter/Space: change · Esc/q: close",
34
29
  labels: {
35
30
  enabled: "Enabled",
36
31
  roundedTools: "Rounded tool frames",
37
32
  inlineFooter: "Inline footer",
38
33
  thinkingPeek: "Thinking peek",
39
34
  language: "Language",
40
- wheelScrollLines: "Mouse wheel speed",
41
35
  cursorStyle: "Cursor style",
42
36
  iconMode: "Icon mode",
43
37
  cwd: "CWD",
@@ -62,23 +56,20 @@ const COPY = {
62
56
  off: "Off",
63
57
  thinkingPeek: { off: "Off", one: "1 line", two: "2 lines" },
64
58
  languages: { en: "English", zh: "简体中文" },
65
- wheelLines: (count: number) => `${count} ${count === 1 ? "line" : "lines"} / notch`,
66
- wheelPrompt: (count: number) => `Wheel scroll lines per notch, 1-10 (current: ${count}). Enter: apply · Esc: cancel`,
67
59
  cursorStyles: { block: "Block", bar: "Bar", underline: "Underline" },
68
- icons: { auto: "Auto", nerd: "Nerd", ascii: "ASCII" },
60
+ icons: { auto: "Auto", nerd: "Nerd", unicode: "Unicode", ascii: "ASCII" },
69
61
  },
70
62
  },
71
63
  zh: {
72
64
  title: "Pi TUI 设置",
73
65
  tabs: { features: "常规", icons: "外观", segments: "Footer", telemetry: "遥测" },
74
- hint: "Tab/Shift+Tab/←/→:切页 · ↑/↓:移动 · Enter/Space:更改 · 滚轮速度项 Enter 输入 1-10 · Esc/q:关闭",
66
+ hint: "Tab/Shift+Tab/←/→:切页 · ↑/↓:移动 · Enter/Space:更改 · Esc/q:关闭",
75
67
  labels: {
76
68
  enabled: "启用",
77
69
  roundedTools: "圆角工具边框",
78
70
  inlineFooter: "内联底栏",
79
71
  thinkingPeek: "思考预览",
80
72
  language: "语言",
81
- wheelScrollLines: "鼠标滚轮速度",
82
73
  cursorStyle: "光标样式",
83
74
  iconMode: "图标模式",
84
75
  cwd: "当前目录",
@@ -103,10 +94,8 @@ const COPY = {
103
94
  off: "关闭",
104
95
  thinkingPeek: { off: "关闭", one: "单行", two: "双行" },
105
96
  languages: { en: "English", zh: "简体中文" },
106
- wheelLines: (count: number) => `每格 ${count} 行`,
107
- wheelPrompt: (count: number) => `滚轮每格滚动行数(当前 ${count},范围 1-10),输入后 Enter 应用 · Esc 取消`,
108
97
  cursorStyles: { block: "块", bar: "竖线", underline: "下划线" },
109
- icons: { auto: "自动", nerd: "Nerd", ascii: "ASCII" },
98
+ icons: { auto: "自动", nerd: "Nerd", unicode: "Unicode", ascii: "ASCII" },
110
99
  },
111
100
  },
112
101
  } as const;
@@ -129,7 +118,7 @@ function toggleSetting(config: OpenTuiConfig, key: keyof OpenTuiConfig["footerSe
129
118
  }
130
119
 
131
120
  function cycleIconMode(config: OpenTuiConfig): OpenTuiConfig {
132
- const order: IconMode[] = ["auto", "nerd", "ascii"];
121
+ const order: IconMode[] = ["auto", "nerd", "unicode", "ascii"];
133
122
  const currentIdx = order.indexOf(config.icons.mode);
134
123
  const next = order[(currentIdx + 1) % order.length]!;
135
124
  return { ...config, icons: { mode: next } };
@@ -150,19 +139,6 @@ function cycleCursorStyle(config: OpenTuiConfig): OpenTuiConfig {
150
139
  return { ...config, cursorStyle: next };
151
140
  }
152
141
 
153
- function setWheelScrollLines(config: OpenTuiConfig, raw: string): OpenTuiConfig | undefined {
154
- if (!/^\d+$/.test(raw)) return undefined;
155
- const parsed = Number(raw);
156
- const bounded = Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER;
157
- return {
158
- ...config,
159
- fullscreen: {
160
- ...config.fullscreen,
161
- wheelScrollLines: normalizeFullscreenWheelScrollLines(bounded, DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES),
162
- },
163
- };
164
- }
165
-
166
142
  function toggleTelemetry(config: OpenTuiConfig, key: keyof OpenTuiConfig["telemetry"]): OpenTuiConfig {
167
143
  return {
168
144
  ...config,
@@ -183,14 +159,9 @@ function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
183
159
  return [
184
160
  { id: "enabled", label: copy.labels.enabled, currentValue: flag(config.enabled) },
185
161
  { id: "roundedTools", label: copy.labels.roundedTools, currentValue: flag(config.roundedTools) },
186
- { id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) },
187
162
  { id: "settingsLanguage", label: copy.labels.language, currentValue: copy.values.languages[config.settingsLanguage] },
188
- {
189
- id: "wheelScrollLines",
190
- label: copy.labels.wheelScrollLines,
191
- currentValue: copy.values.wheelLines(config.fullscreen.wheelScrollLines),
192
- },
193
163
  { id: "thinkingPeek", label: copy.labels.thinkingPeek, currentValue: formatThinkingPeekLines(config.thinkingPeek.lines, copy) },
164
+ { id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) },
194
165
  ];
195
166
  }
196
167
 
@@ -275,23 +246,6 @@ interface SettingsUiHandle {
275
246
  handleInput: (data: string) => void;
276
247
  }
277
248
 
278
- function insertComponentAfter(list: Component, child: Component, index: () => number): Component {
279
- // SelectList renders one line per item; splice the editor into that output.
280
- return {
281
- render(width: number): string[] {
282
- const lines = list.render(width);
283
- const insertAt = index();
284
- if (insertAt < 0 || insertAt >= lines.length) return lines;
285
- const childLines = child.render(width);
286
- return [...lines.slice(0, insertAt + 1), ...childLines, ...lines.slice(insertAt + 1)];
287
- },
288
- invalidate(): void {
289
- list.invalidate();
290
- child.invalidate();
291
- },
292
- };
293
- }
294
-
295
249
  class SettingsUi implements SettingsUiHandle {
296
250
  private tab: Tab = "features";
297
251
  private config: OpenTuiConfig;
@@ -304,7 +258,6 @@ class SettingsUi implements SettingsUiHandle {
304
258
  private cachedWidth: number | undefined;
305
259
  private cachedLines: string[] | undefined;
306
260
  private compact = false;
307
- private wheelInput: Input | undefined;
308
261
 
309
262
  constructor(
310
263
  theme: Theme,
@@ -329,35 +282,11 @@ class SettingsUi implements SettingsUiHandle {
329
282
 
330
283
  private applySetting(itemId: string): void {
331
284
  this.selectedItemByTab[this.tab] = itemId;
332
- if (this.tab === "features" && itemId === "wheelScrollLines") {
333
- this.openWheelInput();
334
- this.invalidate();
335
- return;
336
- }
337
285
  this.config = handleSettingChange(this.tab, itemId, this.config);
338
286
  this.onChange(this.config);
339
287
  this.rebuild(itemId);
340
288
  }
341
289
 
342
- private openWheelInput(): void {
343
- const input = new Input();
344
- input.onSubmit = (value) => {
345
- const next = setWheelScrollLines(this.config, value);
346
- this.wheelInput = undefined;
347
- if (next) {
348
- this.config = next;
349
- this.onChange(this.config);
350
- }
351
- this.rebuild("wheelScrollLines");
352
- };
353
- input.onEscape = () => {
354
- this.wheelInput = undefined;
355
- this.rebuild("wheelScrollLines");
356
- };
357
- this.wheelInput = input;
358
- this.rebuild("wheelScrollLines");
359
- }
360
-
361
290
  private switchTab(offset: number): void {
362
291
  const idx = TABS.indexOf(this.tab);
363
292
  this.tab = TABS[(idx + offset + TABS.length) % TABS.length]!;
@@ -377,17 +306,11 @@ class SettingsUi implements SettingsUiHandle {
377
306
  this.container.addChild(new Text(tabBar, 1, 0));
378
307
  this.container.addChild(new Text(this.theme.fg("dim", copy.hint), 1, 0));
379
308
 
380
- const editingWheel = this.tab === "features" && this.wheelInput !== undefined;
381
- const items = buildItems(this.tab, this.config).map((item) => {
382
- const editing = editingWheel && item.id === "wheelScrollLines";
383
- return {
384
- value: item.id,
385
- label: editing
386
- ? (this.compact ? `${item.label}:` : item.label)
387
- : (this.compact ? `${item.label}: ${item.currentValue}` : item.label),
388
- description: editing || this.compact ? undefined : item.currentValue,
389
- } as SelectItem;
390
- });
309
+ const items = buildItems(this.tab, this.config).map((item) => ({
310
+ value: item.id,
311
+ label: this.compact ? `${item.label}: ${item.currentValue}` : item.label,
312
+ description: this.compact ? undefined : item.currentValue,
313
+ } as SelectItem));
391
314
  this.selectList = new SelectList(items, Math.min(items.length, 12), {
392
315
  selectedPrefix: (t) => this.theme.fg("accent", t),
393
316
  selectedText: (t) => this.theme.fg("accent", t),
@@ -409,31 +332,13 @@ class SettingsUi implements SettingsUiHandle {
409
332
  this.selectList.onCancel = () => {
410
333
  this.onClose();
411
334
  };
412
- if (editingWheel) {
413
- this.wheelInput!.focused = true;
414
- const wheelInputGroup = new Box(4, 0);
415
- wheelInputGroup.addChild(new Text(
416
- this.theme.fg("muted", copy.values.wheelPrompt(this.config.fullscreen.wheelScrollLines)),
417
- 0,
418
- 0,
419
- ));
420
- wheelInputGroup.addChild(this.wheelInput!);
421
- const selectedIndex = () => items.findIndex((item) => item.value === this.selectList.getSelectedItem()?.value);
422
- this.container.addChild(insertComponentAfter(this.selectList, wheelInputGroup, selectedIndex));
423
- } else {
424
- this.container.addChild(this.selectList);
425
- }
335
+ this.container.addChild(this.selectList);
426
336
 
427
337
  this.cachedWidth = undefined;
428
338
  this.cachedLines = undefined;
429
339
  }
430
340
 
431
341
  handleInput(data: string): void {
432
- if (this.wheelInput && this.tab === "features") {
433
- this.wheelInput.handleInput(data);
434
- this.invalidate();
435
- return;
436
- }
437
342
  if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
438
343
  this.switchTab(1);
439
344
  this.invalidate();
@@ -484,7 +389,7 @@ export function registerSettingsCommand(
484
389
  onOverlayClosed?: () => void;
485
390
  },
486
391
  ): void {
487
- const commandDef = {
392
+ pi.registerCommand("pi-tui", {
488
393
  description: "Open the Pi TUI settings UI",
489
394
  handler: async (_args: string, ctx: ExtensionContext) => {
490
395
  if (!ctx.hasUI) return;
@@ -509,7 +414,5 @@ export function registerSettingsCommand(
509
414
  // cannot strand the overlay without keyboard input.
510
415
  hooks.onOverlayClosed?.();
511
416
  },
512
- };
513
- pi.registerCommand("pi-tui", commandDef);
514
- pi.registerCommand("open-tui", commandDef);
417
+ });
515
418
  }
@@ -296,6 +296,28 @@ export function sanitizeStatus(text: string): string {
296
296
  .trim();
297
297
  }
298
298
 
299
+ // One status token is an SGR sequence, another escape sequence, or one control
300
+ // character. SGR carries colours. Everything else moves the cursor or rewrites
301
+ // the terminal, so it must never reach the footer row.
302
+ const STATUS_TOKEN = /(?:\x1b\[|\x9b)[0-?]*[ -/]*m|(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]|(?:\x1bP|\x90|\x1bX|\x98|\x1b\^|\x9e|\x1b_|\x9f)[\s\S]*?(?:\x1b\\|\x9c|$)|(?:\x1b\]|\x9d)[\s\S]*?(?:\x07|\x1b\\|\x9c|$)|\x1b[@-Z\\_]|[\u0000-\u001f\u007f-\u009f]/g;
303
+ const SGR_TOKEN = /^(?:\x1b\[|\x9b)[0-9;:]*m$/;
304
+ const SGR_SEQUENCE = /(?:\x1b\[|\x9b)[0-9;:]*m/;
305
+
306
+ /**
307
+ * Sanitize a status text the same way {@link sanitizeStatus} does, but keep the
308
+ * SGR sequences the extension set. Extensions colour their own status text with
309
+ * `ctx.ui.theme.fg()`. Stripping those codes renders every status grey.
310
+ */
311
+ export function sanitizeStatusPreservingStyles(text: string): string {
312
+ const cleaned = text.replace(STATUS_TOKEN, (token) => (SGR_TOKEN.test(token) ? token : " "));
313
+ return cleaned.replace(/ {2,}/g, " ").trim();
314
+ }
315
+
316
+ /** True when the text carries SGR colour or style sequences. */
317
+ export function hasAnsiStyles(text: string): boolean {
318
+ return SGR_SEQUENCE.test(text);
319
+ }
320
+
299
321
  export function formatThinkingLabel(level: string): string {
300
322
  if (level === "off") return "thinking off";
301
323
  return `${level} effort`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tui-footer",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "description": "pi extension: TUI footer/header with git status, telemetry, rounded tools, editor and settings enhancements",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -1,34 +0,0 @@
1
- import type { TUI } from "@earendil-works/pi-tui";
2
-
3
- export const MIN_FULLSCREEN_WHEEL_SCROLL_LINES = 1;
4
- export const MAX_FULLSCREEN_WHEEL_SCROLL_LINES = 10;
5
- export const DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES = 4;
6
-
7
- export function normalizeFullscreenWheelScrollLines(
8
- value: unknown,
9
- fallback = DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
10
- ): number {
11
- if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
12
- return Math.min(
13
- MAX_FULLSCREEN_WHEEL_SCROLL_LINES,
14
- Math.max(MIN_FULLSCREEN_WHEEL_SCROLL_LINES, Math.floor(value)),
15
- );
16
- }
17
-
18
- type FullscreenWheelTui = TUI & { wheelScrollLines?: unknown };
19
-
20
- /**
21
- * Pi 0.84.2 keeps this constructor option in a private runtime field. Remove
22
- * this shim once Pi exposes a public setter; incompatible versions are no-ops.
23
- */
24
- export function applyFullscreenWheelScrollLines(tui: TUI, value: number): boolean {
25
- try {
26
- const fullscreenTui = tui as FullscreenWheelTui;
27
- if (fullscreenTui.mode !== "fullscreen" || typeof fullscreenTui.wheelScrollLines !== "number") {
28
- return false;
29
- }
30
- return Reflect.set(fullscreenTui, "wheelScrollLines", normalizeFullscreenWheelScrollLines(value));
31
- } catch {
32
- return false;
33
- }
34
- }