pi-open-tui 0.2.2 → 0.2.4

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.
@@ -3,7 +3,6 @@ import {
3
3
  type ExtensionAPI,
4
4
  type ExtensionContext,
5
5
  type KeybindingsManager,
6
- type Theme,
7
6
  } from "@earendil-works/pi-coding-agent";
8
7
  import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
9
8
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
@@ -45,11 +44,13 @@ export class OpenTuiEditor extends CustomEditor {
45
44
  tui: TUI,
46
45
  editorTheme: EditorTheme,
47
46
  keybindings: KeybindingsManager,
48
- getUiTheme: () => Theme,
49
47
  ) {
50
48
  super(tui, editorTheme, keybindings, { paddingX: 0 });
51
- this.getRail = () => getUiTheme().fg("accent", "│");
52
- this.getBorder = (s: string) => getUiTheme().fg("borderMuted", s);
49
+ // ponytail: route the frame through this.borderColor so Pi can recolor it
50
+ // via updateEditorBorderColor() bash mode ("! " prefix → green) and
51
+ // thinking-level borders both flow through this one property.
52
+ this.getRail = () => this.borderColor("│");
53
+ this.getBorder = (s: string) => this.borderColor(s);
53
54
  }
54
55
 
55
56
  override setPaddingX(_padding: number): void {
@@ -62,9 +63,8 @@ export class OpenTuiEditor extends CustomEditor {
62
63
 
63
64
  const rail = this.getRail();
64
65
  const borderPaint = this.getBorder;
65
- const railWidth = 2;
66
-
67
- const innerWidth = Math.max(0, width - railWidth);
66
+ // ponytail: 1-char rail + 1-char gap on each side = 4 chars of chrome.
67
+ const innerWidth = Math.max(0, width - 4);
68
68
  const baseLines = super.render(innerWidth);
69
69
  const bottomIdx = findBottomBorderIndex(baseLines);
70
70
 
@@ -74,9 +74,9 @@ export class OpenTuiEditor extends CustomEditor {
74
74
  for (let i = 1; i < bottomIdx; i++) {
75
75
  const line = baseLines[i] ?? "";
76
76
  if (isEditorBorderLine(line)) {
77
- result.push(`${rail} ${fillLine("", innerWidth)}`);
77
+ result.push(`${rail} ${fillLine("", innerWidth)} ${rail}`);
78
78
  } else {
79
- result.push(`${rail} ${fillLine(line, innerWidth)}`);
79
+ result.push(`${rail} ${fillLine(line, innerWidth)} ${rail}`);
80
80
  }
81
81
  }
82
82
 
@@ -91,9 +91,8 @@ export class OpenTuiEditor extends CustomEditor {
91
91
  }
92
92
 
93
93
  export function installEditor(_pi: ExtensionAPI, ctx: ExtensionContext): () => void {
94
- const getUiTheme = () => ctx.ui.theme;
95
94
  ctx.ui.setEditorComponent((tui, editorTheme, keybindings) =>
96
- new OpenTuiEditor(tui, editorTheme, keybindings, getUiTheme),
95
+ new OpenTuiEditor(tui, editorTheme, keybindings),
97
96
  );
98
97
  return () => {
99
98
  ctx.ui.setEditorComponent(undefined);
@@ -9,6 +9,7 @@ import {
9
9
  alignRight,
10
10
  cacheHitColor,
11
11
  effortColor,
12
+ fitSegmentsByPriority,
12
13
  fmtTokens,
13
14
  formatCwd,
14
15
  formatDuration,
@@ -16,6 +17,7 @@ import {
16
17
  providerColor,
17
18
  sanitizeStatus,
18
19
  stressColor,
20
+ truncatePath,
19
21
  } from "./utils.ts";
20
22
  import type { FooterState, ModelMeta, UsageTotals } from "./state.ts";
21
23
  import { getUsageTotals } from "./state.ts";
@@ -39,35 +41,37 @@ function renderGitSegment(
39
41
  git: GitStatus,
40
42
  glyphs: IconGlyphs,
41
43
  segments: OpenTuiConfig["footerSegments"],
44
+ maxBranchLen = 20,
42
45
  ): string {
43
46
  const parts: string[] = [];
44
47
  if (segments.gitBranch) {
45
48
  if (git.branch) {
46
49
  parts.push(theme.fg("mdLink", glyphs.git));
47
- parts.push(theme.fg("success", git.branch));
50
+ parts.push(theme.fg("mdLink", truncatePath(git.branch, maxBranchLen)));
48
51
  } else if (git.commit?.detached) {
49
- parts.push(theme.fg("mdLink", glyphs.git));
50
- parts.push(theme.fg("success", "HEAD"));
51
- if (segments.gitCommit && git.commit.oid) {
52
+ parts.push(theme.fg("warning", glyphs.git));
53
+ parts.push(theme.fg("warning", "HEAD"));
54
+ if (git.commit.oid) {
52
55
  const shortHash = git.commit.oid.slice(0, 7);
53
56
  const tag = git.commit.tag ? ` ${git.commit.tag}` : "";
54
- parts.push(theme.fg("success", `(${shortHash}${tag})`));
57
+ parts.push(theme.fg("dim", `${shortHash}${tag}`));
55
58
  }
56
59
  }
57
60
  }
58
61
 
59
62
  if (segments.gitStatus) {
60
63
  const statusIcons: string[] = [];
64
+ // ponytail: always show count — `!1` not `!`, so 1 vs 100 is distinguishable.
61
65
  const addStatus = (count: number, glyph: string, color: ThemeColor) => {
62
- if (count > 0) statusIcons.push(theme.fg(color, `${glyph}${count > 1 ? count : ""}`));
66
+ if (count > 0) statusIcons.push(theme.fg(color, `${glyph}${count}`));
63
67
  };
64
68
  addStatus(git.conflicted, glyphs.conflicted, "error");
65
- addStatus(git.stashed, glyphs.stashed, "muted");
66
69
  addStatus(git.deleted, glyphs.deleted, "error");
67
- addStatus(git.renamed, glyphs.renamed, "warning");
68
70
  addStatus(git.modified, glyphs.modified, "warning");
71
+ addStatus(git.renamed, glyphs.renamed, "warning");
69
72
  addStatus(git.staged, glyphs.staged, "success");
70
73
  addStatus(git.untracked, glyphs.untracked, "muted");
74
+ addStatus(git.stashed, glyphs.stashed, "muted");
71
75
 
72
76
  if (git.ahead > 0 && git.behind > 0) {
73
77
  statusIcons.push(theme.fg("warning", `${glyphs.diverged}${git.ahead}/${git.behind}`));
@@ -128,7 +132,7 @@ function renderContextBar(
128
132
  const ctxText = `${theme.fg("text", fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(contextWindow))}`;
129
133
  const contextIcon = theme.fg(stressColor(contextPct), glyphs.context);
130
134
  const reserved = visibleWidth(contextIcon) + visibleWidth(pctText) + visibleWidth(ctxText) + 5 + 2;
131
- const barWidth = Math.max(6, Math.min(16, width - reserved));
135
+ const barWidth = Math.max(4, Math.min(12, width - reserved));
132
136
  return `${contextIcon} ${renderBar(theme, contextPct, barWidth, resolveIconMode(iconMode) === "ascii")} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
133
137
  }
134
138
 
@@ -209,25 +213,32 @@ export function installFooter(
209
213
 
210
214
  const totals = getUsageTotals(ctx);
211
215
 
212
- const leftParts: string[] = [];
216
+ const leftParts: { text: string; priority: number }[] = [];
213
217
  if (segments.cwd) {
214
- leftParts.push(`${theme.fg("mdLink", glyphs.cwd)} ${theme.fg("accent", formatCwd(ctx.sessionManager.getCwd()))}`);
218
+ const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
219
+ leftParts.push({
220
+ text: `${theme.fg("mdLink", glyphs.cwd)} ${theme.fg("accent", truncatePath(formatCwd(ctx.sessionManager.getCwd()), maxCwd))}`,
221
+ priority: 0,
222
+ });
215
223
  }
216
224
  const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
217
- if (gitSeg) leftParts.push(gitSeg);
225
+ if (gitSeg) leftParts.push({ text: gitSeg, priority: 3 });
218
226
  if (segments.runtime) {
219
227
  const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
220
- if (runtimeSeg) leftParts.push(runtimeSeg);
228
+ if (runtimeSeg) leftParts.push({ text: runtimeSeg, priority: 1 });
221
229
  }
222
230
  const timerSeg = renderTimerSegment(theme, state, glyphs);
223
- if (timerSeg) leftParts.push(timerSeg);
231
+ if (timerSeg) leftParts.push({ text: timerSeg, priority: 2 });
224
232
 
225
233
  let rightBlock = "";
226
234
  if (segments.context) {
227
235
  rightBlock = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
228
236
  }
229
237
 
230
- const line1 = alignRight(leftParts.join(" "), rightBlock, width, theme);
238
+ const rightW = visibleWidth(rightBlock);
239
+ const availLeft = Math.max(0, width - rightW - (rightBlock ? 1 : 0));
240
+ const fittedLeft = fitSegmentsByPriority(leftParts, availLeft, theme.fg("dim", "..."));
241
+ const line1 = alignRight(fittedLeft.join(" "), rightBlock, width, theme);
231
242
 
232
243
  const modelParts: string[] = [];
233
244
  modelParts.push(theme.fg("mdLink", glyphs.model));
@@ -83,7 +83,7 @@ export async function readGitStatus(
83
83
  status.branch = undefined;
84
84
  status.commit = { oid: null, detached: true, tag: null };
85
85
  } else {
86
- const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(ahead|behind) (\d+)\])?/);
86
+ const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(ahead|behind) (\d+)\])?$/);
87
87
  if (branchMatch) {
88
88
  status.branch = branchMatch[1];
89
89
  if (branchMatch[3] === "ahead") status.ahead = parseInt(branchMatch[4]!, 10);
@@ -1,9 +1,19 @@
1
1
  import { VERSION, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { Component, TUI } from "@earendil-works/pi-tui";
3
- import { center, truncateToWidth } from "./utils.ts";
3
+ import {
4
+ center,
5
+ collectPiCommandNames,
6
+ formatCwd,
7
+ formatModelLabel,
8
+ formatThinkingLabel,
9
+ headerColumnWidths,
10
+ padRight,
11
+ pickSlashCommandTips,
12
+ truncateToWidth,
13
+ visibleWidth,
14
+ } from "./utils.ts";
4
15
 
5
16
  const LOGO_CELL = "███";
6
- const LOGO_ANIMATION_MS = 80;
7
17
 
8
18
  type LogoColor = "panel" | "cyan" | "red" | "green" | "orange" | "white" | "flash" | "brand";
9
19
  type LogoFrame = { phase: number; active: "left" | "top" | "right" | "none"; ax: number; ay: number; flash: boolean; white: boolean };
@@ -116,50 +126,107 @@ function renderLogo(frameIndex: number, paintBrand: (text: string) => string): s
116
126
  });
117
127
  }
118
128
 
129
+ function borderLine(
130
+ left: string,
131
+ label: string,
132
+ right: string,
133
+ width: number,
134
+ paint: (text: string) => string,
135
+ ): string {
136
+ if (width <= 1) return "";
137
+ if (width < 8 || label.length === 0) {
138
+ return paint(truncateToWidth(left + "─".repeat(Math.max(0, width - 2)) + right, width, ""));
139
+ }
140
+
141
+ const before = "─── ";
142
+ const after = " ─────";
143
+ const fixedWidth = visibleWidth(before) + visibleWidth(label) + visibleWidth(after);
144
+ const fill = Math.max(0, width - 2 - fixedWidth);
145
+ return `${paint(left)}${paint(before)}${label}${paint(after)}${paint("─".repeat(fill))}${paint(right)}`;
146
+ }
147
+
148
+ function boxedLine(content: string, width: number, paint: (text: string) => string): string {
149
+ if (width <= 2) return truncateToWidth(content, width, "");
150
+ return `${paint("│")}${padRight(content, width - 2)}${paint("│")}`;
151
+ }
152
+
153
+ function twoColumn(
154
+ left: string,
155
+ right: string,
156
+ leftWidth: number,
157
+ rightWidth: number,
158
+ paint: (text: string) => string,
159
+ ): string {
160
+ return `${padRight(left, leftWidth)} ${paint("│")} ${padRight(right, rightWidth, "…")}`;
161
+ }
162
+
119
163
  export class OpenTuiHeader implements Component {
120
- private frame = 0;
121
- private readonly timer: ReturnType<typeof setInterval>;
164
+ private readonly pi: ExtensionAPI;
122
165
  private readonly ctx: ExtensionContext;
166
+ private readonly frame = LOGO_FRAMES.length - 1;
167
+ private readonly tipCommands: string[];
123
168
 
124
- constructor(_pi: ExtensionAPI, ctx: ExtensionContext, tui: TUI) {
169
+ constructor(pi: ExtensionAPI, ctx: ExtensionContext, _tui: TUI) {
170
+ this.pi = pi;
125
171
  this.ctx = ctx;
126
- this.timer = setInterval(() => {
127
- if (this.frame < LOGO_FRAMES.length - 1) {
128
- this.frame++;
129
- tui.requestRender();
130
- } else {
131
- clearInterval(this.timer);
132
- }
133
- }, LOGO_ANIMATION_MS);
134
- this.timer.unref?.();
172
+ const pool = collectPiCommandNames(pi.getCommands());
173
+ this.tipCommands = pickSlashCommandTips(pool, {
174
+ fixed: ["open-tui"],
175
+ count: 3,
176
+ });
135
177
  }
136
178
 
137
179
  render(width: number): string[] {
138
180
  const theme = this.ctx.ui.theme;
139
181
  const paint = (s: string) => theme.fg("accent", s);
140
182
  const muted = (s: string) => theme.fg("muted", s);
183
+ const dim = (s: string) => theme.fg("dim", s);
141
184
  const bold = (s: string) => theme.bold(s);
142
185
 
143
186
  if (width < 24) return [paint(`Pi v${VERSION}`)];
144
187
 
145
- const lines: string[] = [];
146
- lines.push(bold(theme.fg("accent", "pi")) + " " + muted(`v${VERSION}`));
147
- lines.push("");
148
-
149
- for (const logoLine of renderLogo(this.frame, paint)) {
150
- lines.push(center(logoLine, width));
188
+ const innerWidth = width - 2;
189
+ const { leftWidth, rightWidth, useTips } = headerColumnWidths(innerWidth);
190
+ const model = formatModelLabel(this.ctx.model);
191
+ const effort = formatThinkingLabel(this.pi.getThinkingLevel());
192
+ const cwd = formatCwd(this.ctx.cwd);
193
+
194
+ const leftLines = [
195
+ ...renderLogo(this.frame, paint).map((line) => center(line, leftWidth)),
196
+ center(bold("Let's build something great"), leftWidth),
197
+ center(muted(`${model} · ${effort}`), leftWidth),
198
+ center(dim(cwd), leftWidth),
199
+ ];
200
+
201
+ const tipDivider = paint("─".repeat(Math.max(8, Math.min(rightWidth, 22))));
202
+ const [cmd0 = "", cmd1 = "", cmd2 = "", cmd3 = ""] = this.tipCommands;
203
+ const tipLines = [
204
+ "",
205
+ paint(bold("Welcome")),
206
+ muted("Ask Pi anything"),
207
+ tipDivider,
208
+ paint(bold("Commands")),
209
+ muted(cmd0),
210
+ muted(cmd1),
211
+ muted(cmd2),
212
+ muted(cmd3),
213
+ "",
214
+ ];
215
+
216
+ const lines = [borderLine("╭", `${paint("Pi")} v${VERSION}`, "╮", width, paint)];
217
+ for (let i = 0; i < leftLines.length; i++) {
218
+ const content = useTips
219
+ ? twoColumn(leftLines[i] ?? "", tipLines[i] ?? "", leftWidth, rightWidth, paint)
220
+ : padRight(leftLines[i] ?? "", leftWidth);
221
+ lines.push(boxedLine(content, width, paint));
151
222
  }
152
-
153
- lines.push(center(bold("Let's build something great"), width));
154
-
223
+ lines.push(borderLine("╰", "", "╯", width, paint));
155
224
  return lines.map((line) => truncateToWidth(line, width, ""));
156
225
  }
157
226
 
158
227
  invalidate(): void {}
159
228
 
160
- dispose(): void {
161
- clearInterval(this.timer);
162
- }
229
+ dispose(): void {}
163
230
  }
164
231
 
165
232
  export function installHeader(pi: ExtensionAPI, ctx: ExtensionContext): () => void {
@@ -101,7 +101,7 @@ export default function (pi: ExtensionAPI) {
101
101
  const generation = sessionLifecycle.currentGeneration();
102
102
  const cwd = ctx.cwd;
103
103
  const git = await readGitStatus(cwd, {
104
- readCommit: config.footerSegments.gitCommit,
104
+ readCommit: true,
105
105
  readTag: config.footerSegments.gitCommit,
106
106
  });
107
107
  if (!sessionLifecycle.isCurrent(generation)) return;
@@ -24,6 +24,26 @@ export function formatCwd(cwd: string): string {
24
24
  return rel === "" ? "~" : `~${sep}${rel}`;
25
25
  }
26
26
 
27
+ export function truncatePath(path: string, maxLen: number): string {
28
+ if (path.length <= maxLen) return path;
29
+ if (maxLen <= 3) return "...".slice(0, maxLen);
30
+ const sepChar = path.includes("/") ? "/" : "\\";
31
+ const parts = path.split(/[\\/]/);
32
+ if (parts.length <= 2) return path.slice(0, maxLen - 3) + "...";
33
+ // Keep first segment (e.g. ~) and as many trailing segments as fit.
34
+ const tail: string[] = [];
35
+ let tailLen = 0;
36
+ for (let i = parts.length - 1; i >= 1; i--) {
37
+ const seg = parts[i]!;
38
+ if (tailLen + seg.length + 4 > maxLen) break;
39
+ tail.unshift(seg);
40
+ tailLen += seg.length + 1;
41
+ }
42
+ const head = parts[0]!;
43
+ const result = `${head}${sepChar}...${sepChar}${tail.join(sepChar)}`;
44
+ return result.length > maxLen ? result.slice(0, maxLen - 3) + "..." : result;
45
+ }
46
+
27
47
  export function fmtTokens(n: number): string {
28
48
  if (n < 1000) return n.toString();
29
49
  if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
@@ -70,6 +90,51 @@ export function alignRight(left: string, right: string, width: number, theme: Th
70
90
  return truncatedLeft ? truncatedLeft + " " + right : right;
71
91
  }
72
92
 
93
+ export type PrioritizedSegment = {
94
+ text: string;
95
+ priority: number;
96
+ };
97
+
98
+ /**
99
+ * Pack segments into maxWidth, shrinking/dropping lowest-priority segments first.
100
+ * Higher priority = survives longer. Returns the surviving segment texts in
101
+ * original order, space-joined. Each segment is either kept whole, truncated
102
+ * with ellipsis, or dropped entirely.
103
+ */
104
+ export function fitSegmentsByPriority(
105
+ segs: readonly PrioritizedSegment[],
106
+ maxW: number,
107
+ ellipsis = "...",
108
+ ): string[] {
109
+ const items = segs.map((s) => ({ text: s.text, priority: s.priority, w: visibleWidth(s.text) }));
110
+ const totalW = () => {
111
+ const active = items.filter((it) => it.text !== "");
112
+ return active.reduce((a, it) => a + it.w, 0) + Math.max(0, active.length - 1);
113
+ };
114
+ while (totalW() > maxW) {
115
+ let target = -1;
116
+ for (let i = 0; i < items.length; i++) {
117
+ if (items[i].text !== "" && (target === -1 || items[i].priority < items[target].priority)) {
118
+ target = i;
119
+ }
120
+ }
121
+ if (target === -1) break;
122
+ const others = items.filter((_, i) => i !== target && items[i].text !== "");
123
+ const otherW = others.reduce((a, it) => a + it.w, 0) + Math.max(0, others.length - 1);
124
+ const avail = maxW - otherW - (others.length > 0 ? 1 : 0);
125
+ if (avail <= visibleWidth(ellipsis)) {
126
+ items[target].text = "";
127
+ items[target].w = 0;
128
+ } else if (avail < items[target].w) {
129
+ items[target].text = truncateToWidth(items[target].text, avail, ellipsis);
130
+ items[target].w = visibleWidth(items[target].text);
131
+ } else {
132
+ break;
133
+ }
134
+ }
135
+ return items.filter((it) => it.text !== "").map((it) => it.text);
136
+ }
137
+
73
138
  export function stressColor(value: number, warn = 70, danger = 90): ThemeColor {
74
139
  if (value >= danger) return "error";
75
140
  if (value >= warn) return "warning";
@@ -155,3 +220,110 @@ export function sanitizeStatus(text: string): string {
155
220
  .replace(/ +/g, " ")
156
221
  .trim();
157
222
  }
223
+
224
+ export function formatThinkingLabel(level: string): string {
225
+ if (level === "off") return "thinking off";
226
+ return `${level} effort`;
227
+ }
228
+
229
+ export const PI_BUILTIN_SLASH_COMMAND_NAMES = [
230
+ "settings",
231
+ "model",
232
+ "scoped-models",
233
+ "export",
234
+ "import",
235
+ "share",
236
+ "copy",
237
+ "name",
238
+ "session",
239
+ "changelog",
240
+ "hotkeys",
241
+ "fork",
242
+ "clone",
243
+ "tree",
244
+ "trust",
245
+ "login",
246
+ "logout",
247
+ "new",
248
+ "compact",
249
+ "resume",
250
+ "reload",
251
+ "quit",
252
+ ] as const;
253
+
254
+ export function collectPiCommandNames(sessionCommands: readonly { name: string }[]): string[] {
255
+ const names = new Set<string>(PI_BUILTIN_SLASH_COMMAND_NAMES);
256
+ for (const command of sessionCommands) {
257
+ if (command.name) names.add(command.name);
258
+ }
259
+ return [...names];
260
+ }
261
+
262
+ export function pickSlashCommandTips(
263
+ availableNames: readonly string[],
264
+ options: {
265
+ fixed?: readonly string[];
266
+ count?: number;
267
+ exclude?: readonly string[];
268
+ random?: () => number;
269
+ } = {},
270
+ ): string[] {
271
+ const fixed = [...(options.fixed ?? [])];
272
+ const count = options.count ?? 3;
273
+ const exclude = new Set<string>([...(options.exclude ?? []), ...fixed]);
274
+ const random = options.random ?? Math.random;
275
+
276
+ const pool = [...new Set(availableNames.map((n) => n.trim()).filter(Boolean))].filter(
277
+ (name) => !exclude.has(name),
278
+ );
279
+
280
+ for (let i = pool.length - 1; i > 0; i--) {
281
+ const j = Math.floor(random() * (i + 1));
282
+ const tmp = pool[i]!;
283
+ pool[i] = pool[j]!;
284
+ pool[j] = tmp;
285
+ }
286
+
287
+ const picked = pool.slice(0, Math.max(0, count));
288
+ return [...fixed, ...picked].map((name) => (name.startsWith("/") ? name : `/${name}`));
289
+ }
290
+
291
+ export const MIN_LEFT_WIDTH = 28;
292
+ export const MIN_TIPS_WIDTH = 16;
293
+ export const MAX_TIPS_WIDTH = 28;
294
+ const COLUMN_GAP = 3;
295
+
296
+ export function headerColumnWidths(
297
+ innerWidth: number,
298
+ minTipsWidth = MIN_TIPS_WIDTH,
299
+ maxTipsWidth = MAX_TIPS_WIDTH,
300
+ minLeftWidth = MIN_LEFT_WIDTH,
301
+ ): { leftWidth: number; rightWidth: number; useTips: boolean } {
302
+ if (innerWidth <= 0) {
303
+ return { leftWidth: 0, rightWidth: 0, useTips: false };
304
+ }
305
+
306
+ const gap = COLUMN_GAP;
307
+ if (innerWidth < minLeftWidth + gap + minTipsWidth) {
308
+ return { leftWidth: innerWidth, rightWidth: 0, useTips: false };
309
+ }
310
+
311
+ let rightWidth = Math.min(maxTipsWidth, Math.max(minTipsWidth, Math.round(innerWidth * 0.28)));
312
+ let leftWidth = innerWidth - gap - rightWidth;
313
+
314
+ if (leftWidth < minLeftWidth) {
315
+ leftWidth = minLeftWidth;
316
+ rightWidth = innerWidth - gap - leftWidth;
317
+ }
318
+
319
+ if (leftWidth <= rightWidth) {
320
+ leftWidth = Math.ceil((innerWidth - gap) * 0.65);
321
+ rightWidth = innerWidth - gap - leftWidth;
322
+ }
323
+
324
+ if (rightWidth < minTipsWidth || leftWidth < minLeftWidth) {
325
+ return { leftWidth: innerWidth, rightWidth: 0, useTips: false };
326
+ }
327
+
328
+ return { leftWidth, rightWidth, useTips: true };
329
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-tui",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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. Combines the best of pi-haiku, pi-claude-code-tui, and pi-zentui.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -55,6 +55,8 @@
55
55
  "allowScripts": {
56
56
  "protobufjs@7.6.5": true,
57
57
  "protobufjs@7.6.4": true,
58
- "@google/genai@1.52.0": true
58
+ "@google/genai@1.52.0": true,
59
+ "koffi@2.16.2": true,
60
+ "tree-sitter-bash@0.25.1": true
59
61
  }
60
62
  }