pi-open-tui 0.2.3 → 0.2.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.
@@ -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);
@@ -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";
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "pi-open-tui",
3
- "version": "0.2.3",
3
+ "version": "0.2.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. Combines the best of pi-haiku, pi-claude-code-tui, and pi-zentui.",
5
5
  "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/OldSuns/pi-open-tui.git"
9
+ },
6
10
  "keywords": [
7
11
  "pi-package",
8
12
  "pi",