@narumitw/pi-statusline 0.42.0 → 0.46.0

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.
package/README.md CHANGED
@@ -109,11 +109,16 @@ Retention priority is highest to lowest:
109
109
  context model branch tools cwd thinking cost provider cache tokens time turn brand
110
110
  ```
111
111
 
112
- Explicit `line_break` entries remain row boundaries. A single segment that is still too wide is
113
- ANSI-safely truncated.
112
+ Explicit `line_break` entries remain row boundaries. If the last remaining segment is itself wider
113
+ than the row, that row renders empty rather than emitting an over-width line.
114
114
 
115
- ### Activity, Git, and PR state
115
+ ### Directory, activity, Git, and PR state
116
116
 
117
+ - `cwd` uses Starship's directory presentation defaults: contract the home directory to `~`, contract
118
+ to the Git repository root when available, then retain at most the last three path components. This
119
+ changes display only; the configured segment list and Pi working directory are untouched.
120
+ - Repository-root discovery is cached with Git status outside footer rendering; a failed root query
121
+ falls back to home/path-component contraction without hiding the segment.
117
122
  - During active work, `tools` shows `💭 thinking` or `⚙ <tool>` with parallel counts.
118
123
  - Activity disappears after the agent settles and resets across session replacement or shutdown.
119
124
  - Clean repositories show no Git counters.
@@ -334,6 +339,7 @@ extensions/pi-statusline/
334
339
  │ ├── index.ts
335
340
  │ ├── statusline.ts
336
341
  │ ├── render.ts
342
+ │ ├── directory.ts
337
343
  │ ├── usage.ts
338
344
  │ ├── powerline.ts
339
345
  │ ├── information-profiles.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-statusline",
3
- "version": "0.42.0",
3
+ "version": "0.46.0",
4
4
  "description": "Pi extension that replaces the footer with an information-rich statusline.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,54 @@
1
+ import { basename, isAbsolute, relative, sep } from "node:path";
2
+ import { sanitizeTerminalText } from "./terminal.js";
3
+
4
+ const DEFAULT_TRUNCATION_LENGTH = 3;
5
+ const DEFAULT_HOME_SYMBOL = "~";
6
+
7
+ /** Apply Starship's default directory presentation without exposing a format language. */
8
+ export function formatDirectoryPath(
9
+ cwd: string,
10
+ homeDir: string | undefined,
11
+ gitRoot: string | undefined,
12
+ ): string {
13
+ const contracted =
14
+ gitRoot && (!homeDir || !samePath(gitRoot, homeDir))
15
+ ? (contractRepositoryPath(cwd, gitRoot) ?? contractPath(cwd, homeDir, DEFAULT_HOME_SYMBOL))
16
+ : contractPath(cwd, homeDir, DEFAULT_HOME_SYMBOL);
17
+ const safeContracted = sanitizeTerminalText(contracted);
18
+ const components = safeContracted.split("/").filter((component) => component.length > 0);
19
+ const displayed =
20
+ components.length > DEFAULT_TRUNCATION_LENGTH
21
+ ? components.slice(-DEFAULT_TRUNCATION_LENGTH).join("/")
22
+ : safeContracted;
23
+ const native = sep === "/" ? displayed : displayed.replaceAll("/", sep);
24
+ return sanitizeTerminalText(native || basename(cwd) || cwd);
25
+ }
26
+
27
+ function toSlashPath(value: string): string {
28
+ return sep === "\\" ? value.replaceAll("\\", "/") : value;
29
+ }
30
+
31
+ function contractPath(path: string, root: string | undefined, replacement: string): string {
32
+ if (!root) return toSlashPath(path);
33
+ const child = relativeWithin(path, root);
34
+ if (child === undefined) return toSlashPath(path);
35
+ return child ? `${replacement}/${child}` : replacement;
36
+ }
37
+
38
+ function contractRepositoryPath(path: string, root: string): string | undefined {
39
+ const child = relativeWithin(path, root);
40
+ if (child === undefined) return undefined;
41
+ const name = basename(root) || toSlashPath(root);
42
+ return child ? `${name}/${child}` : name;
43
+ }
44
+
45
+ function relativeWithin(path: string, root: string): string | undefined {
46
+ const child = relative(root, path);
47
+ if (child === "") return "";
48
+ if (child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) return undefined;
49
+ return toSlashPath(child);
50
+ }
51
+
52
+ function samePath(left: string, right: string): boolean {
53
+ return relative(left, right) === "";
54
+ }
package/src/git-status.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import { isAbsolute } from "node:path";
1
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
3
 
3
4
  const GIT_STATUS_TIMEOUT_MS = 3_000;
4
5
 
5
6
  export interface GitStatusSummary {
7
+ root?: string;
6
8
  ahead: number;
7
9
  behind: number;
8
10
  staged: number;
@@ -14,14 +16,29 @@ export interface GitStatusSummary {
14
16
  export async function readGitStatus(
15
17
  pi: ExtensionAPI,
16
18
  cwd: string,
19
+ signal?: AbortSignal,
17
20
  ): Promise<GitStatusSummary | undefined> {
18
- const result = await pi.exec(
19
- "git",
20
- ["--no-optional-locks", "status", "--porcelain=v1", "--branch", "--untracked-files=normal"],
21
- { cwd, timeout: GIT_STATUS_TIMEOUT_MS },
22
- );
23
- if (result.code !== 0 || result.killed) return undefined;
24
- return parseGitStatusPorcelain(result.stdout);
21
+ const [statusResult, rootResult] = await Promise.all([
22
+ pi.exec(
23
+ "git",
24
+ ["--no-optional-locks", "status", "--porcelain=v1", "--branch", "--untracked-files=normal"],
25
+ { cwd, timeout: GIT_STATUS_TIMEOUT_MS, signal },
26
+ ),
27
+ pi
28
+ .exec("git", ["rev-parse", "--path-format=absolute", "--show-toplevel"], {
29
+ cwd,
30
+ timeout: GIT_STATUS_TIMEOUT_MS,
31
+ signal,
32
+ })
33
+ .catch(() => undefined),
34
+ ]);
35
+ if (statusResult.code !== 0 || statusResult.killed) return undefined;
36
+ const summary = parseGitStatusPorcelain(statusResult.stdout);
37
+ if (rootResult && rootResult.code === 0 && !rootResult.killed) {
38
+ const root = parseGitRoot(rootResult.stdout);
39
+ if (root) summary.root = root;
40
+ }
41
+ return summary;
25
42
  }
26
43
 
27
44
  export function parseGitStatusPorcelain(output: string): GitStatusSummary {
@@ -71,6 +88,11 @@ function isChangedStatus(status: string): boolean {
71
88
  return status !== " " && status !== "?" && status !== "!";
72
89
  }
73
90
 
91
+ export function parseGitRoot(output: string): string | undefined {
92
+ const root = output.split(/\r?\n/u)[0]?.trim();
93
+ return root && isAbsolute(root) ? root : undefined;
94
+ }
95
+
74
96
  export function formatGitStatusSummary(summary: GitStatusSummary | undefined): string {
75
97
  if (!summary) return "";
76
98
  const tokens = [
@@ -111,6 +133,7 @@ export function gitStatusSummaryEqual(
111
133
  ): boolean {
112
134
  if (!left || !right) return left === right;
113
135
  return (
136
+ left.root === right.root &&
114
137
  left.ahead === right.ahead &&
115
138
  left.behind === right.behind &&
116
139
  left.staged === right.staged &&
package/src/render.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { basename } from "node:path";
2
1
  import type {
3
2
  ExtensionAPI,
4
3
  ExtensionContext,
@@ -6,6 +5,7 @@ import type {
6
5
  Theme,
7
6
  ThemeColor,
8
7
  } from "@earendil-works/pi-coding-agent";
8
+ import { formatDirectoryPath } from "./directory.js";
9
9
  import {
10
10
  type ExtensionStatusRuntime,
11
11
  formatExtensionStatuses,
@@ -13,6 +13,7 @@ import {
13
13
  } from "./extension-status.js";
14
14
  import { formatGitBranchValue, type GitStatusSummary } from "./git-status.js";
15
15
  import { renderPowerlineStatusline } from "./powerline.js";
16
+ import { sanitizeTerminalText } from "./terminal.js";
16
17
  import {
17
18
  LINE_BREAK_SEGMENT_NAME,
18
19
  type PowerlineBlockName,
@@ -26,6 +27,7 @@ import { type FooterUsageSummary, summarizeFooterUsage } from "./usage.js";
26
27
 
27
28
  type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
28
29
  export interface RuntimeState extends ExtensionStatusRuntime {
30
+ homeDir?: string;
29
31
  turnCount: number;
30
32
  activeTools: Map<string, number>;
31
33
  isStreaming: boolean;
@@ -138,7 +140,13 @@ function buildSegment(
138
140
  );
139
141
  }
140
142
  case "cwd":
141
- return segment(name, basename(ctx.cwd) || ctx.cwd, config, "accent", "directory");
143
+ return segment(
144
+ name,
145
+ formatDirectoryPath(ctx.cwd, runtime.homeDir, runtime.gitStatus?.root),
146
+ config,
147
+ "accent",
148
+ "directory",
149
+ );
142
150
  case "tools": {
143
151
  const activity = formatToolActivity(runtime);
144
152
  return activity ? segment(name, activity, config, "accent", "runtime") : undefined;
@@ -320,11 +328,11 @@ export function truncateModel(
320
328
  symbol: string,
321
329
  direction: TruncationDirection,
322
330
  ): string {
323
- const safeModel = sanitizeModelDisplay(model);
331
+ const safeModel = sanitizeTerminalText(model);
324
332
  if (length === 0) return safeModel;
325
333
  const graphemes = [...graphemeSegmenter.segment(safeModel)].map(({ segment }) => segment);
326
334
  if (graphemes.length <= length) return safeModel;
327
- const safeSymbol = sanitizeModelDisplay(symbol);
335
+ const safeSymbol = sanitizeTerminalText(symbol);
328
336
 
329
337
  switch (direction) {
330
338
  case "start":
@@ -340,67 +348,6 @@ export function truncateModel(
340
348
  }
341
349
  }
342
350
 
343
- function sanitizeModelDisplay(value: string): string {
344
- let result = "";
345
- for (let index = 0; index < value.length; ) {
346
- const codePoint = value.codePointAt(index) ?? 0;
347
- const character = String.fromCodePoint(codePoint);
348
- if (codePoint === 0x1b || codePoint === 0x9b || codePoint === 0x9d) {
349
- index = skipTerminalEscape(value, index, codePoint);
350
- continue;
351
- }
352
- if (
353
- codePoint <= 0x1f ||
354
- (codePoint >= 0x7f && codePoint <= 0x9f) ||
355
- codePoint === 0x2028 ||
356
- codePoint === 0x2029
357
- ) {
358
- if (
359
- codePoint === 0x09 ||
360
- codePoint === 0x0a ||
361
- codePoint === 0x0d ||
362
- codePoint === 0x85 ||
363
- codePoint === 0x2028 ||
364
- codePoint === 0x2029
365
- ) {
366
- result += " ";
367
- }
368
- index += character.length;
369
- continue;
370
- }
371
- result += character;
372
- index += character.length;
373
- }
374
- return result;
375
- }
376
-
377
- function skipTerminalEscape(value: string, start: number, codePoint: number): number {
378
- let index = start + 1;
379
- const next = value.charCodeAt(index);
380
- const isOsc = codePoint === 0x9d || (codePoint === 0x1b && next === 0x5d);
381
- if (isOsc) {
382
- if (codePoint === 0x1b) index += 1;
383
- while (index < value.length) {
384
- const current = value.charCodeAt(index);
385
- if (current === 0x07 || current === 0x9c) return index + 1;
386
- if (current === 0x1b && value.charCodeAt(index + 1) === 0x5c) return index + 2;
387
- index += 1;
388
- }
389
- return index;
390
- }
391
- const isCsi = codePoint === 0x9b || (codePoint === 0x1b && next === 0x5b);
392
- if (isCsi) {
393
- if (codePoint === 0x1b) index += 1;
394
- while (index < value.length) {
395
- const current = value.charCodeAt(index);
396
- index += 1;
397
- if (current >= 0x40 && current <= 0x7e) break;
398
- }
399
- return index;
400
- }
401
- return Math.min(value.length, start + (codePoint === 0x1b ? 2 : 1));
402
- }
403
-
404
351
  export function shortenModel(model: string): string {
405
352
  return model
406
353
  .replace(/^claude-/, "")
package/src/statusline.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { homedir } from "node:os";
1
2
  import {
2
3
  type ExtensionAPI,
3
4
  type ExtensionContext,
@@ -44,6 +45,7 @@ export default function statusline(pi: ExtensionAPI) {
44
45
  let gitStatusRequestId = 0;
45
46
  let activeGitStatusTarget: { cwd: string; generation: number } | undefined;
46
47
  let gitStatusRefreshInFlight = false;
48
+ let gitStatusAbortController: AbortController | undefined;
47
49
  let gitStatusDebounceTimer: ReturnType<typeof setTimeout> | undefined;
48
50
  let pendingGitStatusRefresh: { cwd: string; generation: number; requestId: number } | undefined;
49
51
 
@@ -62,6 +64,10 @@ export default function statusline(pi: ExtensionAPI) {
62
64
  gitStatusDebounceTimer = undefined;
63
65
  };
64
66
 
67
+ const abortGitStatusRefresh = (reason: string) => {
68
+ gitStatusAbortController?.abort(new DOMException(reason, "AbortError"));
69
+ };
70
+
65
71
  const isActiveGitStatusTarget = (cwd: string, generation: number) =>
66
72
  activeGitStatusTarget?.cwd === cwd &&
67
73
  activeGitStatusTarget.generation === generation &&
@@ -78,14 +84,17 @@ export default function statusline(pi: ExtensionAPI) {
78
84
  }
79
85
 
80
86
  gitStatusRefreshInFlight = true;
87
+ const abortController = new AbortController();
88
+ gitStatusAbortController = abortController;
81
89
  void (async () => {
82
90
  try {
83
- const summary = await readGitStatus(pi, cwd);
91
+ const summary = await readGitStatus(pi, cwd, abortController.signal);
84
92
  if (isCurrentGitStatusRequest(cwd, generation, requestId)) setGitStatus(summary);
85
93
  } catch {
86
94
  if (isCurrentGitStatusRequest(cwd, generation, requestId)) setGitStatus(undefined);
87
95
  } finally {
88
96
  gitStatusRefreshInFlight = false;
97
+ if (gitStatusAbortController === abortController) gitStatusAbortController = undefined;
89
98
  const pending = pendingGitStatusRefresh;
90
99
  pendingGitStatusRefresh = undefined;
91
100
  if (pending) runGitStatusRefresh(pending.cwd, pending.generation, pending.requestId);
@@ -119,7 +128,9 @@ export default function statusline(pi: ExtensionAPI) {
119
128
  menuController = new AbortController();
120
129
  const cwd = ctx.cwd;
121
130
  activeSessionManager = ctx.sessionManager;
131
+ runtime.homeDir = homedir();
122
132
  previewPalettePreset = undefined;
133
+ abortGitStatusRefresh("Statusline session context replaced");
123
134
  clearGitStatusDebounce();
124
135
  activeGitStatusTarget = ctx.mode === "tui" ? { cwd, generation } : undefined;
125
136
  runtime.gitStatus = undefined;
@@ -136,6 +147,7 @@ export default function statusline(pi: ExtensionAPI) {
136
147
  const refreshFooterGitStatus = () => refreshGitStatus(cwd, generation);
137
148
  const branchUnsubscribe = footerData.onBranchChange(() => {
138
149
  runtime.gitStatus = undefined;
150
+ abortGitStatusRefresh("Statusline Git branch changed");
139
151
  clearGitStatusDebounce();
140
152
  refreshFooterGitStatus();
141
153
  tui.requestRender();
@@ -152,6 +164,7 @@ export default function statusline(pi: ExtensionAPI) {
152
164
  clearInterval(clock);
153
165
  if (isActiveGitStatusTarget(cwd, generation)) {
154
166
  activeGitStatusTarget = undefined;
167
+ abortGitStatusRefresh("Statusline footer disposed");
155
168
  clearGitStatusDebounce();
156
169
  pendingGitStatusRefresh = undefined;
157
170
  runtime.gitStatus = undefined;
@@ -229,6 +242,7 @@ export default function statusline(pi: ExtensionAPI) {
229
242
  activeSessionManager = undefined;
230
243
  previewPalettePreset = undefined;
231
244
  activeGitStatusTarget = undefined;
245
+ abortGitStatusRefresh("Statusline session shut down");
232
246
  clearGitStatusDebounce();
233
247
  pendingGitStatusRefresh = undefined;
234
248
  runtime.gitStatus = undefined;
@@ -324,7 +338,9 @@ export {
324
338
  formatGitBranchValue,
325
339
  formatGitStatusSummary,
326
340
  type GitStatusSummary,
341
+ parseGitRoot,
327
342
  parseGitStatusPorcelain,
343
+ readGitStatus,
328
344
  } from "./git-status.js";
329
345
  export {
330
346
  contextColor,
@@ -0,0 +1,60 @@
1
+ export function sanitizeTerminalText(value: string): string {
2
+ let result = "";
3
+ for (let index = 0; index < value.length; ) {
4
+ const codePoint = value.codePointAt(index) ?? 0;
5
+ const character = String.fromCodePoint(codePoint);
6
+ if (codePoint === 0x1b || codePoint === 0x9b || codePoint === 0x9d) {
7
+ index = skipTerminalEscape(value, index, codePoint);
8
+ continue;
9
+ }
10
+ if (
11
+ codePoint <= 0x1f ||
12
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
13
+ codePoint === 0x2028 ||
14
+ codePoint === 0x2029
15
+ ) {
16
+ if (
17
+ codePoint === 0x09 ||
18
+ codePoint === 0x0a ||
19
+ codePoint === 0x0d ||
20
+ codePoint === 0x85 ||
21
+ codePoint === 0x2028 ||
22
+ codePoint === 0x2029
23
+ ) {
24
+ result += " ";
25
+ }
26
+ index += character.length;
27
+ continue;
28
+ }
29
+ result += character;
30
+ index += character.length;
31
+ }
32
+ return result;
33
+ }
34
+
35
+ function skipTerminalEscape(value: string, start: number, codePoint: number): number {
36
+ let index = start + 1;
37
+ const next = value.charCodeAt(index);
38
+ const isOsc = codePoint === 0x9d || (codePoint === 0x1b && next === 0x5d);
39
+ if (isOsc) {
40
+ if (codePoint === 0x1b) index += 1;
41
+ while (index < value.length) {
42
+ const current = value.charCodeAt(index);
43
+ if (current === 0x07 || current === 0x9c) return index + 1;
44
+ if (current === 0x1b && value.charCodeAt(index + 1) === 0x5c) return index + 2;
45
+ index += 1;
46
+ }
47
+ return index;
48
+ }
49
+ const isCsi = codePoint === 0x9b || (codePoint === 0x1b && next === 0x5b);
50
+ if (isCsi) {
51
+ if (codePoint === 0x1b) index += 1;
52
+ while (index < value.length) {
53
+ const current = value.charCodeAt(index);
54
+ index += 1;
55
+ if (current >= 0x40 && current <= 0x7e) break;
56
+ }
57
+ return index;
58
+ }
59
+ return Math.min(value.length, start + (codePoint === 0x1b ? 2 : 1));
60
+ }