pi-open-tui 0.3.3 → 0.3.5

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,6 +1,6 @@
1
- import {
2
- CustomEditor,
3
- type ExtensionAPI,
1
+ import {
2
+ CustomEditor,
3
+ type ExtensionAPI,
4
4
  type ExtensionContext,
5
5
  type KeybindingsManager,
6
6
  } from "@earendil-works/pi-coding-agent";
@@ -23,40 +23,45 @@ const CURSOR_STYLE_SEQUENCES: Partial<Record<CursorStyle, string>> = {
23
23
  bar: "\x1b[6 q",
24
24
  underline: "\x1b[4 q",
25
25
  };
26
- const DEFAULT_CURSOR_STYLE_SEQUENCE = "\x1b[0 q";
27
-
28
- interface HiddenThinkingLabelComponent {
29
- children: unknown[];
30
- setHiddenThinkingLabel(label: string): void;
31
- setHideThinkingBlock(hide: boolean): void;
32
- updateContent(message: unknown, isStreaming?: boolean): void;
33
- }
34
-
35
- function isHiddenThinkingLabelComponent(value: object): value is HiddenThinkingLabelComponent {
36
- const component = value as unknown as Record<string, unknown>;
37
- return Array.isArray(component.children)
38
- && typeof component.setHiddenThinkingLabel === "function"
39
- && typeof component.setHideThinkingBlock === "function"
40
- && typeof component.updateContent === "function";
41
- }
42
-
43
- /** Ponytail until Pi exposes a per-message hidden-thinking label API. */
44
- function findLatestHiddenThinkingLabel(tui: TUI): HiddenThinkingLabelComponent {
45
- const pending: unknown[] = [tui];
46
- const visited = new Set<object>();
47
- let latest: HiddenThinkingLabelComponent | undefined;
48
- while (pending.length > 0) {
49
- const value = pending.pop();
50
- if (!value || typeof value !== "object" || visited.has(value)) continue;
51
- visited.add(value);
52
- if (isHiddenThinkingLabelComponent(value)) latest = value;
53
- const children = (value as { children?: unknown }).children;
54
- if (!Array.isArray(children)) continue;
55
- for (let i = children.length - 1; i >= 0; i--) pending.push(children[i]);
56
- }
57
- if (!latest) throw new Error("Unable to find the active hidden-thinking component");
58
- return latest;
59
- }
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
+ }
60
65
 
61
66
  function removeSoftwareCursor(line: string, cursorMarker = ""): string {
62
67
  return line.replace(/\x1b\[7m([\s\S]*?)\x1b\[0m/g, (_match, cursor: string) => {
@@ -78,10 +83,42 @@ function roundedBorder(
78
83
  kind: "top" | "bottom",
79
84
  paint: (s: string) => string,
80
85
  sourceLine?: string,
86
+ indicator?: WorkingStatusIndicator,
81
87
  ): string {
82
88
  if (width < 2) return paint(truncateToWidth(kind === "top" ? "╭╮" : "╰╯", width, ""));
83
89
  const corners = kind === "top" ? (["╭", "╮"] as const) : (["╰", "╯"] as const);
84
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
+
85
122
  if (sourceLine) {
86
123
  const plain = stripAnsi(sourceLine);
87
124
  const scrollMatch = plain.match(/([↑↓]\s+\d+\s+more)/);
@@ -96,8 +133,10 @@ function roundedBorder(
96
133
  }
97
134
 
98
135
  export class OpenTuiEditor extends CustomEditor {
136
+ readonly embedWorkingStatus = true;
99
137
  private readonly getRail: () => string;
100
138
  private readonly getBorder: (s: string) => string;
139
+ private embeddedWorkingStatusIndicator: WorkingStatusIndicator | undefined;
101
140
  private cursorStyle: CursorStyle;
102
141
  private previewHardwareCursor = false;
103
142
 
@@ -122,6 +161,11 @@ export class OpenTuiEditor extends CustomEditor {
122
161
  super.setPaddingX(0);
123
162
  }
124
163
 
164
+ setWorkingStatusIndicator(indicator: WorkingStatusIndicator | undefined): void {
165
+ this.embeddedWorkingStatusIndicator = indicator;
166
+ this.tui.requestRender();
167
+ }
168
+
125
169
  setCursorStyle(cursorStyle: CursorStyle, blockHardwareCursor = false): void {
126
170
  const styleChanged = cursorStyle !== this.cursorStyle;
127
171
  this.previewHardwareCursor = cursorStyle !== "block";
@@ -163,7 +207,7 @@ export class OpenTuiEditor extends CustomEditor {
163
207
  const bottomIdx = findBottomBorderIndex(baseLines);
164
208
 
165
209
  const result: string[] = [];
166
- result.push(roundedBorder(width, "top", borderPaint, baseLines[0]));
210
+ result.push(roundedBorder(width, "top", borderPaint, baseLines[0], this.embeddedWorkingStatusIndicator));
167
211
 
168
212
  for (let i = 1; i < bottomIdx; i++) {
169
213
  const line = baseLines[i] ?? "";
@@ -192,51 +236,51 @@ export function installEditor(
192
236
  ) {
193
237
  let activeTui: TUI | undefined;
194
238
  let activeEditor: OpenTuiEditor | undefined;
195
- let previousHardwareCursor: boolean | undefined;
196
- let currentCursorStyle = cursorStyle;
197
- let currentWheelScrollLines = wheelScrollLines;
198
- let hiddenThinkingTarget: HiddenThinkingLabelComponent | undefined;
199
- const getActiveTui = (): TUI => {
200
- if (!activeTui) throw new Error("Open TUI editor is not mounted");
201
- return activeTui;
202
- };
203
-
204
- ctx.ui.setEditorComponent((tui, editorTheme, keybindings) => {
205
- activeTui = tui;
206
- hiddenThinkingTarget = undefined;
207
- applyFullscreenWheelScrollLines(tui, currentWheelScrollLines);
239
+ let previousHardwareCursor: boolean | undefined;
240
+ let currentCursorStyle = cursorStyle;
241
+ let currentWheelScrollLines = wheelScrollLines;
242
+ let hiddenThinkingTarget: HiddenThinkingLabelComponent | undefined;
243
+ const getActiveTui = (): TUI => {
244
+ if (!activeTui) throw new Error("Open TUI editor is not mounted");
245
+ return activeTui;
246
+ };
247
+
248
+ ctx.ui.setEditorComponent((tui, editorTheme, keybindings) => {
249
+ activeTui = tui;
250
+ hiddenThinkingTarget = undefined;
251
+ applyFullscreenWheelScrollLines(tui, currentWheelScrollLines);
208
252
  previousHardwareCursor = tui.getShowHardwareCursor();
209
253
  activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle);
210
254
  return activeEditor;
211
- });
212
- return {
213
- getViewportWidth(): number {
214
- const columns = getActiveTui().terminal.columns;
215
- if (typeof columns !== "number" || !Number.isFinite(columns)) {
216
- throw new Error("Open TUI editor terminal has an invalid width");
217
- }
218
- return Math.max(1, Math.floor(columns));
219
- },
220
- setLatestHiddenThinkingLabel(label: string): void {
221
- const tui = getActiveTui();
222
- hiddenThinkingTarget ??= findLatestHiddenThinkingLabel(tui);
223
- hiddenThinkingTarget.setHiddenThinkingLabel(label);
224
- tui.requestRender();
225
- },
226
- resetHiddenThinkingLabelTarget(): void {
227
- hiddenThinkingTarget = undefined;
228
- },
229
- setCursorStyle(nextCursorStyle: CursorStyle): void {
230
- currentCursorStyle = nextCursorStyle;
231
- activeEditor?.setCursorStyle(nextCursorStyle, previousHardwareCursor);
255
+ });
256
+ return {
257
+ getViewportWidth(): number {
258
+ const columns = getActiveTui().terminal.columns;
259
+ if (typeof columns !== "number" || !Number.isFinite(columns)) {
260
+ throw new Error("Open TUI editor terminal has an invalid width");
261
+ }
262
+ return Math.max(1, Math.floor(columns));
263
+ },
264
+ setLatestHiddenThinkingLabel(label: string): void {
265
+ const tui = getActiveTui();
266
+ hiddenThinkingTarget ??= findLatestHiddenThinkingLabel(tui);
267
+ hiddenThinkingTarget.setHiddenThinkingLabel(label);
268
+ tui.requestRender();
269
+ },
270
+ resetHiddenThinkingLabelTarget(): void {
271
+ hiddenThinkingTarget = undefined;
272
+ },
273
+ setCursorStyle(nextCursorStyle: CursorStyle): void {
274
+ currentCursorStyle = nextCursorStyle;
275
+ activeEditor?.setCursorStyle(nextCursorStyle, previousHardwareCursor);
232
276
  },
233
277
  setWheelScrollLines(nextWheelScrollLines: number): void {
234
278
  currentWheelScrollLines = nextWheelScrollLines;
235
279
  if (activeTui) applyFullscreenWheelScrollLines(activeTui, currentWheelScrollLines);
236
280
  },
237
- cleanup(): void {
238
- hiddenThinkingTarget = undefined;
239
- ctx.ui.setEditorComponent(undefined);
281
+ cleanup(): void {
282
+ hiddenThinkingTarget = undefined;
283
+ ctx.ui.setEditorComponent(undefined);
240
284
  if (activeTui) {
241
285
  if (currentCursorStyle !== "block") activeTui.terminal.write(DEFAULT_CURSOR_STYLE_SEQUENCE);
242
286
  if (previousHardwareCursor !== undefined) activeTui.setShowHardwareCursor(previousHardwareCursor);
@@ -15,7 +15,6 @@ import {
15
15
  formatCwd,
16
16
  formatDuration,
17
17
  formatInputBreakdown,
18
- formatProviderLabel,
19
18
  providerColor,
20
19
  sanitizeStatus,
21
20
  stressColor,
@@ -1,157 +1,151 @@
1
- import { execFile } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { promisify } from "node:util";
5
-
6
- const execFileAsync = promisify(execFile);
7
- const GIT_TIMEOUT_MS = 2000;
8
-
9
- export interface GitCommitInfo {
10
- oid: string | null;
11
- detached: boolean;
12
- tag: string | null;
13
- }
14
-
15
- export interface GitStatus {
16
- branch: string | undefined;
17
- ahead: number;
18
- behind: number;
19
- modified: number;
20
- untracked: number;
21
- staged: number;
22
- stashed: number;
23
- conflicted: number;
24
- renamed: number;
25
- deleted: number;
26
- commit: GitCommitInfo | null;
27
- }
28
-
29
- export function emptyGitStatus(): GitStatus {
30
- return {
31
- branch: undefined,
32
- ahead: 0,
33
- behind: 0,
34
- modified: 0,
35
- untracked: 0,
36
- staged: 0,
37
- stashed: 0,
38
- conflicted: 0,
39
- renamed: 0,
40
- deleted: 0,
41
- commit: null,
42
- };
43
- }
44
-
45
- async function gitExec(args: string[], cwd: string): Promise<string | null> {
46
- try {
47
- const { stdout } = await execFileAsync("git", args, {
48
- cwd,
49
- timeout: GIT_TIMEOUT_MS,
50
- maxBuffer: 1024 * 1024,
51
- });
52
- return stdout;
53
- } catch {
54
- return null;
55
- }
56
- }
57
-
58
- export async function readGitStatus(
59
- cwd: string,
60
- options: { readCommit?: boolean; readTag?: boolean; readCounts?: boolean } = {},
61
- ): Promise<GitStatus> {
62
- if (!existsSync(join(cwd, ".git"))) {
63
- return emptyGitStatus();
64
- }
65
-
66
- const stdout = await gitExec(
67
- ["status", "--porcelain=v1", "--branch", "--show-stash"],
68
- cwd,
69
- );
70
- if (stdout === null) {
71
- return emptyGitStatus();
72
- }
73
-
74
- const status = emptyGitStatus();
75
- const lines = stdout.split("\n");
76
- let stashSupported = true;
77
-
78
- for (const line of lines) {
79
- if (line.startsWith("## ")) {
80
- const branchPart = line.slice(3);
81
- const detached = branchPart.startsWith("HEAD (no branch)");
82
- if (detached) {
83
- status.branch = undefined;
84
- status.commit = { oid: null, detached: true, tag: null };
85
- } else {
86
- const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(ahead|behind) (\d+)\])?$/);
87
- if (branchMatch) {
88
- status.branch = branchMatch[1];
89
- if (branchMatch[3] === "ahead") status.ahead = parseInt(branchMatch[4]!, 10);
90
- if (branchMatch[3] === "behind") status.behind = parseInt(branchMatch[4]!, 10);
91
- }
92
- }
93
- continue;
94
- }
95
-
96
- if (line.startsWith("# stash ")) {
97
- const stashCount = parseInt(line.slice(8).trim(), 10);
98
- if (!Number.isNaN(stashCount)) {
99
- status.stashed = stashCount;
100
- stashSupported = true;
101
- }
102
- continue;
103
- }
104
-
105
- // ponytail: skip file-entry counting when only branch/commit display is on;
106
- // the first `git status` call itself can't be split, but the stash fallback below can.
107
- if (options.readCounts === false) continue;
108
- if (line.length < 3) continue;
109
- const x = line[0]!;
110
- const y = line[1]!;
111
-
112
- if (x === "U" || y === "U" || (x === "C" && y === "C")) status.conflicted++;
113
- else if (x === "?" && y === "?") status.untracked++;
114
- else if (x === "R") status.renamed++;
115
- else if (x === "D" || y === "D") status.deleted++;
116
- else {
117
- if (x !== " " && x !== "?") status.staged++;
118
- if (y === "M" || y === "D") status.modified++;
119
- }
120
- }
121
-
122
- if (options.readCounts !== false && stashSupported && status.stashed === 0 && !stdout.includes("# stash")) {
123
- const stashOut = await gitExec(["stash", "list", "--count"], cwd);
124
- if (stashOut !== null) {
125
- const count = parseInt(stashOut.trim(), 10);
126
- if (!Number.isNaN(count)) status.stashed = count;
127
- }
128
- }
129
-
130
- if (options.readCommit && status.commit?.detached) {
131
- const oid = await gitExec(["rev-parse", "HEAD"], cwd);
132
- if (oid) {
133
- status.commit.oid = oid.trim();
134
- }
135
- if (options.readTag) {
136
- const tag = await gitExec(["describe", "--tags", "--exact-match", "HEAD"], cwd);
137
- if (tag) {
138
- status.commit.tag = tag.trim();
139
- }
140
- }
141
- }
142
-
143
- return status;
144
- }
145
-
146
- export function hasGitChanges(s: GitStatus): boolean {
147
- return (
148
- s.modified > 0 ||
149
- s.untracked > 0 ||
150
- s.staged > 0 ||
151
- s.conflicted > 0 ||
152
- s.renamed > 0 ||
153
- s.deleted > 0 ||
154
- s.ahead > 0 ||
155
- s.behind > 0
156
- );
157
- }
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+ const GIT_TIMEOUT_MS = 2000;
6
+
7
+ export interface GitCommitInfo {
8
+ oid: string | null;
9
+ detached: boolean;
10
+ tag: string | null;
11
+ }
12
+
13
+ export interface GitStatus {
14
+ branch: string | undefined;
15
+ ahead: number;
16
+ behind: number;
17
+ modified: number;
18
+ untracked: number;
19
+ staged: number;
20
+ stashed: number;
21
+ conflicted: number;
22
+ renamed: number;
23
+ deleted: number;
24
+ commit: GitCommitInfo | null;
25
+ }
26
+
27
+ export function emptyGitStatus(): GitStatus {
28
+ return {
29
+ branch: undefined,
30
+ ahead: 0,
31
+ behind: 0,
32
+ modified: 0,
33
+ untracked: 0,
34
+ staged: 0,
35
+ stashed: 0,
36
+ conflicted: 0,
37
+ renamed: 0,
38
+ deleted: 0,
39
+ commit: null,
40
+ };
41
+ }
42
+
43
+ async function gitExec(args: string[], cwd: string): Promise<string | null> {
44
+ try {
45
+ const { stdout } = await execFileAsync("git", args, {
46
+ cwd,
47
+ timeout: GIT_TIMEOUT_MS,
48
+ maxBuffer: 1024 * 1024,
49
+ });
50
+ return stdout;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export async function readGitStatus(
57
+ cwd: string,
58
+ options: { readCommit?: boolean; readTag?: boolean; readCounts?: boolean } = {},
59
+ ): Promise<GitStatus> {
60
+ const stdout = await gitExec(
61
+ ["status", "--porcelain=v1", "--branch", "--show-stash"],
62
+ cwd,
63
+ );
64
+ if (stdout === null) {
65
+ return emptyGitStatus();
66
+ }
67
+
68
+ const status = emptyGitStatus();
69
+ const lines = stdout.split("\n");
70
+ let stashSupported = true;
71
+
72
+ for (const line of lines) {
73
+ if (line.startsWith("## ")) {
74
+ const branchPart = line.slice(3);
75
+ const detached = branchPart.startsWith("HEAD (no branch)");
76
+ if (detached) {
77
+ status.branch = undefined;
78
+ status.commit = { oid: null, detached: true, tag: null };
79
+ } else {
80
+ const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(ahead|behind) (\d+)\])?$/);
81
+ if (branchMatch) {
82
+ status.branch = branchMatch[1];
83
+ if (branchMatch[3] === "ahead") status.ahead = parseInt(branchMatch[4]!, 10);
84
+ if (branchMatch[3] === "behind") status.behind = parseInt(branchMatch[4]!, 10);
85
+ }
86
+ }
87
+ continue;
88
+ }
89
+
90
+ if (line.startsWith("# stash ")) {
91
+ const stashCount = parseInt(line.slice(8).trim(), 10);
92
+ if (!Number.isNaN(stashCount)) {
93
+ status.stashed = stashCount;
94
+ stashSupported = true;
95
+ }
96
+ continue;
97
+ }
98
+
99
+ // ponytail: skip file-entry counting when only branch/commit display is on;
100
+ // the first `git status` call itself can't be split, but the stash fallback below can.
101
+ if (options.readCounts === false) continue;
102
+ if (line.length < 3) continue;
103
+ const x = line[0]!;
104
+ const y = line[1]!;
105
+
106
+ if (x === "U" || y === "U" || (x === "C" && y === "C")) status.conflicted++;
107
+ else if (x === "?" && y === "?") status.untracked++;
108
+ else if (x === "R") status.renamed++;
109
+ else if (x === "D" || y === "D") status.deleted++;
110
+ else {
111
+ if (x !== " " && x !== "?") status.staged++;
112
+ if (y === "M" || y === "D") status.modified++;
113
+ }
114
+ }
115
+
116
+ if (options.readCounts !== false && stashSupported && status.stashed === 0 && !stdout.includes("# stash")) {
117
+ const stashOut = await gitExec(["stash", "list", "--count"], cwd);
118
+ if (stashOut !== null) {
119
+ const count = parseInt(stashOut.trim(), 10);
120
+ if (!Number.isNaN(count)) status.stashed = count;
121
+ }
122
+ }
123
+
124
+ if (options.readCommit && status.commit?.detached) {
125
+ const oid = await gitExec(["rev-parse", "HEAD"], cwd);
126
+ if (oid) {
127
+ status.commit.oid = oid.trim();
128
+ }
129
+ if (options.readTag) {
130
+ const tag = await gitExec(["describe", "--tags", "--exact-match", "HEAD"], cwd);
131
+ if (tag) {
132
+ status.commit.tag = tag.trim();
133
+ }
134
+ }
135
+ }
136
+
137
+ return status;
138
+ }
139
+
140
+ export function hasGitChanges(s: GitStatus): boolean {
141
+ return (
142
+ s.modified > 0 ||
143
+ s.untracked > 0 ||
144
+ s.staged > 0 ||
145
+ s.conflicted > 0 ||
146
+ s.renamed > 0 ||
147
+ s.deleted > 0 ||
148
+ s.ahead > 0 ||
149
+ s.behind > 0
150
+ );
151
+ }
@@ -3,7 +3,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import type { GitStatus } from "./git.ts";
4
4
  import { emptyGitStatus } from "./git.ts";
5
5
  import type { RuntimeInfo } from "./runtime.ts";
6
- import { finiteOrZero, fmtTokens, formatProviderLabel } from "./utils.ts";
6
+ import { finiteOrZero, formatProviderLabel } from "./utils.ts";
7
7
 
8
8
  export interface FooterState {
9
9
  git: GitStatus;
@@ -239,6 +239,7 @@ export function effortColor(level: ThinkingLevel | string | undefined): ThemeCol
239
239
  }
240
240
 
241
241
  export function isEditorBorderLine(line: string): boolean {
242
+ if (typeof line !== "string") return false;
242
243
  const plain = stripAnsi(line);
243
244
  if (/^─+$/.test(plain)) return true;
244
245
  if (/^─*\s*[↑↓]\s+\d+\s+more\s*─*$/.test(plain)) return true;
@@ -247,7 +248,8 @@ export function isEditorBorderLine(line: string): boolean {
247
248
 
248
249
  export function findBottomBorderIndex(lines: string[]): number {
249
250
  for (let i = lines.length - 1; i >= 1; i--) {
250
- if (isEditorBorderLine(lines[i]!)) return i;
251
+ const line = lines[i];
252
+ if (line !== undefined && isEditorBorderLine(line)) return i;
251
253
  }
252
254
  return Math.max(0, lines.length - 1);
253
255
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-tui",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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": {