pi-open-tui 0.2.9 → 0.2.11

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,100 +1,100 @@
1
- import {
2
- CustomEditor,
3
- type ExtensionAPI,
4
- type ExtensionContext,
5
- type KeybindingsManager,
6
- } from "@earendil-works/pi-coding-agent";
7
- import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
8
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
- import { findBottomBorderIndex, isEditorBorderLine, stripAnsi } from "./utils.ts";
10
-
11
- function fillLine(content: string, width: number): string {
12
- const truncated = truncateToWidth(content, Math.max(0, width), "");
13
- const pad = " ".repeat(Math.max(0, width - visibleWidth(truncated)));
14
- return `${truncated}${pad}`;
15
- }
16
-
17
- function roundedBorder(
18
- width: number,
19
- kind: "top" | "bottom",
20
- paint: (s: string) => string,
21
- sourceLine?: string,
22
- ): string {
23
- if (width < 2) return paint(truncateToWidth(kind === "top" ? "╭╮" : "╰╯", width, ""));
24
- const corners = kind === "top" ? (["╭", "╮"] as const) : (["╰", "╯"] as const);
25
-
26
- if (sourceLine) {
27
- const plain = stripAnsi(sourceLine);
28
- const scrollMatch = plain.match(/([↑↓]\s+\d+\s+more)/);
29
- if (scrollMatch) {
30
- const label = `─── ${scrollMatch[1]} `;
31
- const fill = Math.max(0, width - 2 - visibleWidth(label));
32
- return paint(`${corners[0]}${label}${"─".repeat(fill)}${corners[1]}`);
33
- }
34
- }
35
-
36
- return paint(`${corners[0]}${"─".repeat(Math.max(0, width - 2))}${corners[1]}`);
37
- }
38
-
39
- export class OpenTuiEditor extends CustomEditor {
40
- private readonly getRail: () => string;
41
- private readonly getBorder: (s: string) => string;
42
-
43
- constructor(
44
- tui: TUI,
45
- editorTheme: EditorTheme,
46
- keybindings: KeybindingsManager,
47
- ) {
48
- super(tui, editorTheme, keybindings, { paddingX: 0 });
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);
54
- }
55
-
56
- override setPaddingX(_padding: number): void {
57
- // The custom rail owns the horizontal inset and keeps one stable text gap.
58
- super.setPaddingX(0);
59
- }
60
-
61
- render(width: number): string[] {
62
- if (width < 4) return super.render(width);
63
-
64
- const rail = this.getRail();
65
- const borderPaint = this.getBorder;
66
- // ponytail: 1-char rail + 1-char gap on each side = 4 chars of chrome.
67
- const innerWidth = Math.max(0, width - 4);
68
- const baseLines = super.render(innerWidth);
69
- const bottomIdx = findBottomBorderIndex(baseLines);
70
-
71
- const result: string[] = [];
72
- result.push(roundedBorder(width, "top", borderPaint, baseLines[0]));
73
-
74
- for (let i = 1; i < bottomIdx; i++) {
75
- const line = baseLines[i] ?? "";
76
- if (isEditorBorderLine(line)) {
77
- result.push(`${rail} ${fillLine("", innerWidth)} ${rail}`);
78
- } else {
79
- result.push(`${rail} ${fillLine(line, innerWidth)} ${rail}`);
80
- }
81
- }
82
-
83
- result.push(roundedBorder(width, "bottom", borderPaint, baseLines[bottomIdx]));
84
-
85
- for (let i = bottomIdx + 1; i < baseLines.length; i++) {
86
- result.push(baseLines[i]!);
87
- }
88
-
89
- return result.map((line) => truncateToWidth(line, width, ""));
90
- }
91
- }
92
-
93
- export function installEditor(_pi: ExtensionAPI, ctx: ExtensionContext): () => void {
94
- ctx.ui.setEditorComponent((tui, editorTheme, keybindings) =>
95
- new OpenTuiEditor(tui, editorTheme, keybindings),
96
- );
97
- return () => {
98
- ctx.ui.setEditorComponent(undefined);
99
- };
100
- }
1
+ import {
2
+ CustomEditor,
3
+ type ExtensionAPI,
4
+ type ExtensionContext,
5
+ type KeybindingsManager,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
8
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import { findBottomBorderIndex, isEditorBorderLine, stripAnsi } from "./utils.ts";
10
+
11
+ function fillLine(content: string, width: number): string {
12
+ const truncated = truncateToWidth(content, Math.max(0, width), "");
13
+ const pad = " ".repeat(Math.max(0, width - visibleWidth(truncated)));
14
+ return `${truncated}${pad}`;
15
+ }
16
+
17
+ function roundedBorder(
18
+ width: number,
19
+ kind: "top" | "bottom",
20
+ paint: (s: string) => string,
21
+ sourceLine?: string,
22
+ ): string {
23
+ if (width < 2) return paint(truncateToWidth(kind === "top" ? "╭╮" : "╰╯", width, ""));
24
+ const corners = kind === "top" ? (["╭", "╮"] as const) : (["╰", "╯"] as const);
25
+
26
+ if (sourceLine) {
27
+ const plain = stripAnsi(sourceLine);
28
+ const scrollMatch = plain.match(/([↑↓]\s+\d+\s+more)/);
29
+ if (scrollMatch) {
30
+ const label = `─── ${scrollMatch[1]} `;
31
+ const fill = Math.max(0, width - 2 - visibleWidth(label));
32
+ return paint(`${corners[0]}${label}${"─".repeat(fill)}${corners[1]}`);
33
+ }
34
+ }
35
+
36
+ return paint(`${corners[0]}${"─".repeat(Math.max(0, width - 2))}${corners[1]}`);
37
+ }
38
+
39
+ export class OpenTuiEditor extends CustomEditor {
40
+ private readonly getRail: () => string;
41
+ private readonly getBorder: (s: string) => string;
42
+
43
+ constructor(
44
+ tui: TUI,
45
+ editorTheme: EditorTheme,
46
+ keybindings: KeybindingsManager,
47
+ ) {
48
+ super(tui, editorTheme, keybindings, { paddingX: 0 });
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);
54
+ }
55
+
56
+ override setPaddingX(_padding: number): void {
57
+ // The custom rail owns the horizontal inset and keeps one stable text gap.
58
+ super.setPaddingX(0);
59
+ }
60
+
61
+ render(width: number): string[] {
62
+ if (width < 4) return super.render(width);
63
+
64
+ const rail = this.getRail();
65
+ const borderPaint = this.getBorder;
66
+ // ponytail: 1-char rail + 1-char gap on each side = 4 chars of chrome.
67
+ const innerWidth = Math.max(0, width - 4);
68
+ const baseLines = super.render(innerWidth);
69
+ const bottomIdx = findBottomBorderIndex(baseLines);
70
+
71
+ const result: string[] = [];
72
+ result.push(roundedBorder(width, "top", borderPaint, baseLines[0]));
73
+
74
+ for (let i = 1; i < bottomIdx; i++) {
75
+ const line = baseLines[i] ?? "";
76
+ if (isEditorBorderLine(line)) {
77
+ result.push(`${rail} ${fillLine("", innerWidth)} ${rail}`);
78
+ } else {
79
+ result.push(`${rail} ${fillLine(line, innerWidth)} ${rail}`);
80
+ }
81
+ }
82
+
83
+ result.push(roundedBorder(width, "bottom", borderPaint, baseLines[bottomIdx]));
84
+
85
+ for (let i = bottomIdx + 1; i < baseLines.length; i++) {
86
+ result.push(baseLines[i]!);
87
+ }
88
+
89
+ return result.map((line) => truncateToWidth(line, width, ""));
90
+ }
91
+ }
92
+
93
+ export function installEditor(_pi: ExtensionAPI, ctx: ExtensionContext): () => void {
94
+ ctx.ui.setEditorComponent((tui, editorTheme, keybindings) =>
95
+ new OpenTuiEditor(tui, editorTheme, keybindings),
96
+ );
97
+ return () => {
98
+ ctx.ui.setEditorComponent(undefined);
99
+ };
100
+ }
@@ -7,6 +7,7 @@ import type { GitStatus } from "./git.ts";
7
7
  import type { RuntimeInfo } from "./runtime.ts";
8
8
  import {
9
9
  alignRight,
10
+ basenamePath,
10
11
  cacheHitColor,
11
12
  effortColor,
12
13
  fitSegmentsByPriority,
@@ -17,7 +18,9 @@ import {
17
18
  providerColor,
18
19
  sanitizeStatus,
19
20
  stressColor,
21
+ truncateBranch,
20
22
  truncatePath,
23
+ type PrioritizedSegment,
21
24
  } from "./utils.ts";
22
25
  import type { FooterState, ModelMeta, UsageTotals } from "./state.ts";
23
26
  import { getUsageTotals } from "./state.ts";
@@ -36,6 +39,15 @@ function renderBar(theme: Theme, pct: number, barWidth: number, ascii: boolean):
36
39
  );
37
40
  }
38
41
 
42
+ /** Compact context form: icon + percentage, no bar or token counts. */
43
+ function renderContextCompact(theme: Theme, ctx: ExtensionContext, glyphs: IconGlyphs): string {
44
+ const contextUsage = ctx.getContextUsage();
45
+ const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
46
+ if (contextWindow <= 0) return "";
47
+ const contextPct = contextUsage?.percent ?? 0;
48
+ return `${theme.fg(stressColor(contextPct), glyphs.context)} ${theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`)}`;
49
+ }
50
+
39
51
  function renderGitSegment(
40
52
  theme: Theme,
41
53
  git: GitStatus,
@@ -47,7 +59,7 @@ function renderGitSegment(
47
59
  if (segments.gitBranch) {
48
60
  if (git.branch) {
49
61
  parts.push(theme.fg("mdLink", glyphs.git));
50
- parts.push(theme.fg("mdLink", truncatePath(git.branch, maxBranchLen)));
62
+ parts.push(theme.fg("mdLink", truncateBranch(git.branch, maxBranchLen)));
51
63
  } else if (git.commit?.detached) {
52
64
  parts.push(theme.fg("warning", glyphs.git));
53
65
  parts.push(theme.fg("warning", "HEAD"));
@@ -131,6 +143,8 @@ function renderContextBar(
131
143
  const pctText = theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`);
132
144
  const ctxText = `${theme.fg("text", fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(contextWindow))}`;
133
145
  const contextIcon = theme.fg(stressColor(contextPct), glyphs.context);
146
+ // Cap the bar at `width - reserved` (with a floor of 4) so the full form
147
+ // never forces the left segments out before compact/drop logic kicks in.
134
148
  const reserved = visibleWidth(contextIcon) + visibleWidth(pctText) + visibleWidth(ctxText) + 5 + 2;
135
149
  const barWidth = Math.max(4, Math.min(12, width - reserved));
136
150
  return `${contextIcon} ${renderBar(theme, contextPct, barWidth, resolveIconMode(iconMode) === "ascii")} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
@@ -213,32 +227,63 @@ export function installFooter(
213
227
 
214
228
  const totals = getUsageTotals(ctx);
215
229
 
216
- const leftParts: { text: string; priority: number }[] = [];
230
+ const leftParts: PrioritizedSegment[] = [];
217
231
  if (segments.cwd) {
218
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);
219
236
  leftParts.push({
220
- text: `${theme.fg("mdLink", glyphs.cwd)} ${theme.fg("accent", truncatePath(formatCwd(ctx.sessionManager.getCwd()), maxCwd))}`,
237
+ text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
238
+ compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
221
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
+ },
222
247
  });
223
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
+ }
224
258
  const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
225
259
  if (gitSeg) leftParts.push({ text: gitSeg, priority: 3 });
226
260
  if (segments.runtime) {
227
261
  const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
228
- if (runtimeSeg) leftParts.push({ text: runtimeSeg, priority: 1 });
262
+ if (runtimeSeg) leftParts.push({ text: runtimeSeg, priority: 4 });
229
263
  }
230
264
  const timerSeg = renderTimerSegment(theme, state, glyphs);
231
- if (timerSeg) leftParts.push({ text: timerSeg, priority: 2 });
265
+ if (timerSeg) leftParts.push({ text: timerSeg, priority: 1 });
232
266
 
233
- let rightBlock = "";
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;
234
271
  if (segments.context) {
235
- rightBlock = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
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 });
236
282
  }
237
283
 
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);
284
+ const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
285
+ const fittedContext = contextText ? fitted.pop() ?? "" : "";
286
+ const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
242
287
 
243
288
  const modelParts: string[] = [];
244
289
  modelParts.push(theme.fg("mdLink", glyphs.model));
@@ -262,15 +307,17 @@ export function installFooter(
262
307
 
263
308
  const mainLines = [line1, line2]
264
309
  .map((line) => truncateToWidth(line, width, theme.fg("dim", "...")));
265
- return [
266
- ...mainLines,
267
- ...renderExtensionStatusLines(
268
- theme,
269
- footerData.getExtensionStatuses(),
270
- glyphs,
271
- width,
272
- ),
273
- ];
310
+ return segments.extensionStatuses
311
+ ? [
312
+ ...mainLines,
313
+ ...renderExtensionStatusLines(
314
+ theme,
315
+ footerData.getExtensionStatuses(),
316
+ glyphs,
317
+ width,
318
+ ),
319
+ ]
320
+ : mainLines;
274
321
  },
275
322
  };
276
323
  });
@@ -57,7 +57,7 @@ async function gitExec(args: string[], cwd: string): Promise<string | null> {
57
57
 
58
58
  export async function readGitStatus(
59
59
  cwd: string,
60
- options: { readCommit?: boolean; readTag?: boolean } = {},
60
+ options: { readCommit?: boolean; readTag?: boolean; readCounts?: boolean } = {},
61
61
  ): Promise<GitStatus> {
62
62
  if (!existsSync(join(cwd, ".git"))) {
63
63
  return emptyGitStatus();
@@ -102,6 +102,9 @@ export async function readGitStatus(
102
102
  continue;
103
103
  }
104
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;
105
108
  if (line.length < 3) continue;
106
109
  const x = line[0]!;
107
110
  const y = line[1]!;
@@ -116,7 +119,7 @@ export async function readGitStatus(
116
119
  }
117
120
  }
118
121
 
119
- if (stashSupported && status.stashed === 0 && !stdout.includes("# stash")) {
122
+ if (options.readCounts !== false && stashSupported && status.stashed === 0 && !stdout.includes("# stash")) {
120
123
  const stashOut = await gitExec(["stash", "list", "--count"], cwd);
121
124
  if (stashOut !== null) {
122
125
  const count = parseInt(stashOut.trim(), 10);
@@ -2,6 +2,7 @@ export type IconMode = "auto" | "nerd" | "ascii";
2
2
 
3
3
  export interface IconGlyphs {
4
4
  cwd: string;
5
+ session: string;
5
6
  git: string;
6
7
  working: string;
7
8
  done: string;
@@ -30,6 +31,7 @@ export interface IconGlyphs {
30
31
 
31
32
  const NERD_GLYPHS: IconGlyphs = {
32
33
  cwd: "",
34
+ session: "",
33
35
  git: "",
34
36
  working: "",
35
37
  done: "",
@@ -62,10 +64,11 @@ const NERD_GLYPHS: IconGlyphs = {
62
64
  // avoid collisions with the git-status set {= S ! A ? r x ^ v}.
63
65
  const ASCII_GLYPHS: IconGlyphs = {
64
66
  cwd: "@",
67
+ session: "s",
65
68
  git: "*",
66
69
  working: "o",
67
70
  done: "+",
68
- context: "%",
71
+ context: "#",
69
72
  model: "M",
70
73
  thinking: "~",
71
74
  input: "↑",