pi-git-graph-sidebar 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yuxiang-gao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # pi-git-graph-sidebar
2
+
3
+ A VS Code Git Graph-style sidebar overlay for the [Pi coding agent](https://github.com/earendil-works/pi-coding-agent) TUI.
4
+
5
+ It opens an interactive right-side graph of your repository history using `git log --graph --decorate --oneline --all`.
6
+
7
+ ## Features
8
+
9
+ - Right-side responsive TUI overlay/sidebar
10
+ - ASCII commit graph across all refs
11
+ - Branch/decorator display from Git
12
+ - Keyboard navigation
13
+ - Refresh without closing the sidebar
14
+ - Works as a Pi package installed from npm or GitHub
15
+
16
+ ## Preview
17
+
18
+ ```text
19
+ ╭──────────────── Git Graph ────────────────╮
20
+ │branch main • 120/120 │
21
+ │cwd /path/to/repo │
22
+ ├───────────────────────────────────────────┤
23
+ │› * a1b2c3d (HEAD -> main, origin/main) ...│
24
+ │ * d4e5f6a Add feature │
25
+ │ | * 123abcd (feature/x) Try variant │
26
+ │ |/ │
27
+ │ * 987fedc Initial commit │
28
+ ├───────────────────────────────────────────┤
29
+ │↑↓/jk scroll • r refresh • q/esc close │
30
+ ╰───────────────────────────────────────────╯
31
+ ```
32
+
33
+ ## Installation
34
+
35
+ ### From npm
36
+
37
+ ```bash
38
+ pi install npm:pi-git-graph-sidebar
39
+ ```
40
+
41
+ Then restart Pi or run `/reload` in an existing Pi TUI session.
42
+
43
+ ### From GitHub
44
+
45
+ ```bash
46
+ pi install git:github.com/yuxiang-gao/pi-git-graph-sidebar
47
+ ```
48
+
49
+ ### Try without installing
50
+
51
+ ```bash
52
+ pi -e npm:pi-git-graph-sidebar
53
+ # or
54
+ pi -e git:github.com/yuxiang-gao/pi-git-graph-sidebar
55
+ ```
56
+
57
+ ### Manual local install
58
+
59
+ ```bash
60
+ mkdir -p ~/.pi/agent/extensions
61
+ curl -L \
62
+ https://raw.githubusercontent.com/yuxiang-gao/pi-git-graph-sidebar/main/extensions/git-graph-sidebar.ts \
63
+ -o ~/.pi/agent/extensions/git-graph-sidebar.ts
64
+ ```
65
+
66
+ Restart Pi or run `/reload`.
67
+
68
+ ## Usage
69
+
70
+ Open the sidebar:
71
+
72
+ ```text
73
+ /git-graph
74
+ ```
75
+
76
+ Open with a custom commit limit:
77
+
78
+ ```text
79
+ /git-graph 200
80
+ ```
81
+
82
+ Keyboard shortcut:
83
+
84
+ ```text
85
+ ctrl+shift+g
86
+ ```
87
+
88
+ Keyboard controls inside the sidebar:
89
+
90
+ | Key | Action |
91
+ | --- | --- |
92
+ | `↑` / `k` | Move up |
93
+ | `↓` / `j` | Move down |
94
+ | `PgUp` / `ctrl+u` | Page up |
95
+ | `PgDn` / `ctrl+d` | Page down |
96
+ | `g` | Jump to top |
97
+ | `G` | Jump to bottom |
98
+ | `r` | Refresh graph |
99
+ | `q` / `esc` | Close sidebar |
100
+
101
+ ## Notes
102
+
103
+ - Requires `git` on `PATH`.
104
+ - The sidebar is visible only when the terminal is at least 90 columns wide.
105
+ - The default commit limit is 120; the maximum accepted limit is 500.
106
+ - Pi packages run extension code with local system access. Review code before installing third-party packages.
107
+
108
+ ## Development
109
+
110
+ Clone and run Pi with the local package:
111
+
112
+ ```bash
113
+ git clone https://github.com/yuxiang-gao/pi-git-graph-sidebar.git
114
+ cd pi-git-graph-sidebar
115
+ pi -e .
116
+ ```
117
+
118
+ Or install the local checkout globally:
119
+
120
+ ```bash
121
+ pi install /absolute/path/to/pi-git-graph-sidebar
122
+ ```
123
+
124
+ ## License
125
+
126
+ MIT
@@ -0,0 +1,309 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component, TUI } from "@earendil-works/pi-tui";
3
+
4
+ const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
5
+
6
+ function visibleWidth(text: string): number {
7
+ return Array.from(text.replace(ANSI_PATTERN, "")).length;
8
+ }
9
+
10
+ function truncateToWidth(text: string, width: number, ellipsis = "…"): string {
11
+ if (width <= 0) return "";
12
+ if (visibleWidth(text) <= width) return text;
13
+ const plain = text.replace(ANSI_PATTERN, "");
14
+ const suffix = visibleWidth(ellipsis) < width ? ellipsis : "";
15
+ return `${Array.from(plain).slice(0, Math.max(0, width - visibleWidth(suffix))).join("")}${suffix}`;
16
+ }
17
+
18
+ function isKey(data: string, key: string): boolean {
19
+ const aliases: Record<string, string[]> = {
20
+ escape: ["\x1b"],
21
+ up: ["\x1b[A", "\x1bOA"],
22
+ down: ["\x1b[B", "\x1bOB"],
23
+ pageUp: ["\x1b[5~"],
24
+ pageDown: ["\x1b[6~"],
25
+ home: ["\x1b[H", "\x1bOH", "\x1b[1~"],
26
+ end: ["\x1b[F", "\x1bOF", "\x1b[4~"],
27
+ ctrlC: ["\x03"],
28
+ ctrlD: ["\x04"],
29
+ ctrlU: ["\x15"],
30
+ };
31
+ return aliases[key]?.includes(data) ?? data === key;
32
+ }
33
+
34
+ type GitGraphData = {
35
+ cwd: string;
36
+ branch: string;
37
+ lines: string[];
38
+ error?: string;
39
+ loadedAt: Date;
40
+ };
41
+
42
+ const DEFAULT_LIMIT = 120;
43
+ const MAX_LIMIT = 500;
44
+
45
+ function parseLimit(args: string): number {
46
+ const trimmed = args.trim();
47
+ if (!trimmed) return DEFAULT_LIMIT;
48
+ const parsed = Number.parseInt(trimmed, 10);
49
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIMIT;
50
+ return Math.min(parsed, MAX_LIMIT);
51
+ }
52
+
53
+ async function loadGitGraph(pi: ExtensionAPI, cwd: string, limit: number): Promise<GitGraphData> {
54
+ const common = { cwd, loadedAt: new Date() };
55
+ const root = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 3000 });
56
+ if (root.code !== 0) {
57
+ return {
58
+ ...common,
59
+ branch: "",
60
+ lines: [],
61
+ error: "Not inside a git repository.",
62
+ };
63
+ }
64
+
65
+ const branchResult = await pi.exec("git", ["branch", "--show-current"], { cwd, timeout: 3000 });
66
+ const branch = branchResult.stdout.trim() || "detached";
67
+ const log = await pi.exec(
68
+ "git",
69
+ [
70
+ "log",
71
+ "--graph",
72
+ "--decorate=short",
73
+ "--oneline",
74
+ "--abbrev-commit",
75
+ "--date-order",
76
+ "--all",
77
+ `-n${limit}`,
78
+ ],
79
+ { cwd, timeout: 8000 },
80
+ );
81
+
82
+ if (log.code !== 0) {
83
+ return {
84
+ ...common,
85
+ branch,
86
+ lines: [],
87
+ error: log.stderr.trim() || "git log failed.",
88
+ };
89
+ }
90
+
91
+ return {
92
+ ...common,
93
+ branch,
94
+ lines: log.stdout.split("\n").filter((line) => line.trim().length > 0),
95
+ };
96
+ }
97
+
98
+ class GitGraphSidebar implements Component {
99
+ private offset = 0;
100
+ private selected = 0;
101
+ private loading = false;
102
+ private message: string | undefined;
103
+ private readonly pi: ExtensionAPI;
104
+ private readonly tui: TUI;
105
+ private readonly theme: Theme;
106
+ private readonly cwd: string;
107
+ private readonly limit: number;
108
+ private data: GitGraphData;
109
+ private readonly done: () => void;
110
+
111
+ constructor(
112
+ pi: ExtensionAPI,
113
+ tui: TUI,
114
+ theme: Theme,
115
+ cwd: string,
116
+ limit: number,
117
+ data: GitGraphData,
118
+ done: () => void,
119
+ ) {
120
+ this.pi = pi;
121
+ this.tui = tui;
122
+ this.theme = theme;
123
+ this.cwd = cwd;
124
+ this.limit = limit;
125
+ this.data = data;
126
+ this.done = done;
127
+ }
128
+
129
+ handleInput(data: string): void {
130
+ if (isKey(data, "escape") || isKey(data, "q") || isKey(data, "ctrlC")) {
131
+ this.done();
132
+ return;
133
+ }
134
+ if (isKey(data, "up") || isKey(data, "k")) {
135
+ this.selected = Math.max(0, this.selected - 1);
136
+ this.ensureSelectedVisible();
137
+ this.tui.requestRender();
138
+ return;
139
+ }
140
+ if (isKey(data, "down") || isKey(data, "j")) {
141
+ this.selected = Math.min(Math.max(0, this.data.lines.length - 1), this.selected + 1);
142
+ this.ensureSelectedVisible();
143
+ this.tui.requestRender();
144
+ return;
145
+ }
146
+ if (isKey(data, "pageUp") || isKey(data, "ctrlU")) {
147
+ this.selected = Math.max(0, this.selected - 10);
148
+ this.ensureSelectedVisible();
149
+ this.tui.requestRender();
150
+ return;
151
+ }
152
+ if (isKey(data, "pageDown") || isKey(data, "ctrlD")) {
153
+ this.selected = Math.min(Math.max(0, this.data.lines.length - 1), this.selected + 10);
154
+ this.ensureSelectedVisible();
155
+ this.tui.requestRender();
156
+ return;
157
+ }
158
+ if (isKey(data, "home") || isKey(data, "g")) {
159
+ this.selected = 0;
160
+ this.offset = 0;
161
+ this.tui.requestRender();
162
+ return;
163
+ }
164
+ if (isKey(data, "end") || isKey(data, "G")) {
165
+ this.selected = Math.max(0, this.data.lines.length - 1);
166
+ this.ensureSelectedVisible();
167
+ this.tui.requestRender();
168
+ return;
169
+ }
170
+ if (isKey(data, "r")) {
171
+ void this.refresh();
172
+ }
173
+ }
174
+
175
+ render(width: number): string[] {
176
+ const innerWidth = Math.max(1, width - 2);
177
+ const border = (text: string) => this.theme.fg("border", text);
178
+ const pad = (text: string) => {
179
+ const truncated = truncateToWidth(text, innerWidth, "…");
180
+ return `${truncated}${" ".repeat(Math.max(0, innerWidth - visibleWidth(truncated)))}`;
181
+ };
182
+ const row = (text: string) => `${border("│")}${pad(text)}${border("│")}`;
183
+ const lines: string[] = [];
184
+
185
+ const title = this.theme.fg("accent", this.theme.bold(" Git Graph "));
186
+ const titleWidth = visibleWidth(" Git Graph ");
187
+ const left = "─".repeat(Math.max(0, Math.floor((innerWidth - titleWidth) / 2)));
188
+ const right = "─".repeat(Math.max(0, innerWidth - titleWidth - left.length));
189
+ lines.push(`${border("╭" + left)}${title}${border(right + "╮")}`);
190
+ lines.push(row(`${this.theme.fg("dim", "branch")} ${this.theme.fg("success", this.data.branch || "-")} ${this.theme.fg("dim", `• ${this.data.lines.length}/${this.limit}`)}`));
191
+ lines.push(row(this.theme.fg("dim", `cwd ${this.cwd}`)));
192
+ lines.push(border("├") + border("─".repeat(innerWidth)) + border("┤"));
193
+
194
+ if (this.loading) {
195
+ lines.push(row(`${this.theme.fg("accent", "⟳")} refreshing…`));
196
+ }
197
+ if (this.message) {
198
+ lines.push(row(this.theme.fg("warning", this.message)));
199
+ }
200
+ if (this.data.error) {
201
+ lines.push(row(this.theme.fg("error", this.data.error)));
202
+ } else if (this.data.lines.length === 0) {
203
+ lines.push(row(this.theme.fg("muted", "No commits found.")));
204
+ } else {
205
+ const headerLines = lines.length;
206
+ const footerLines = 3;
207
+ const visibleRows = Math.max(4, 28 - headerLines - footerLines);
208
+ this.clampScroll(visibleRows);
209
+ const shown = this.data.lines.slice(this.offset, this.offset + visibleRows);
210
+ for (let i = 0; i < shown.length; i++) {
211
+ const absoluteIndex = this.offset + i;
212
+ const marker = absoluteIndex === this.selected ? this.theme.fg("accent", "› ") : " ";
213
+ const content = this.colorizeCommitLine(shown[i]!);
214
+ const text = absoluteIndex === this.selected ? this.theme.bg("selectedBg", `${marker}${content}`) : `${marker}${content}`;
215
+ lines.push(row(text));
216
+ }
217
+ }
218
+
219
+ lines.push(border("├") + border("─".repeat(innerWidth)) + border("┤"));
220
+ lines.push(row(this.theme.fg("dim", "↑↓/jk scroll • r refresh • q/esc close")));
221
+ lines.push(border("╰") + border("─".repeat(innerWidth)) + border("╯"));
222
+ return lines;
223
+ }
224
+
225
+ invalidate(): void {}
226
+
227
+ private async refresh(): Promise<void> {
228
+ if (this.loading) return;
229
+ this.loading = true;
230
+ this.message = undefined;
231
+ this.tui.requestRender();
232
+ try {
233
+ this.data = await loadGitGraph(this.pi, this.cwd, this.limit);
234
+ this.selected = Math.min(this.selected, Math.max(0, this.data.lines.length - 1));
235
+ this.message = `refreshed ${this.data.loadedAt.toLocaleTimeString()}`;
236
+ } catch (error) {
237
+ this.message = error instanceof Error ? error.message : String(error);
238
+ } finally {
239
+ this.loading = false;
240
+ this.tui.requestRender();
241
+ }
242
+ }
243
+
244
+ private ensureSelectedVisible(): void {
245
+ const visibleRows = 20;
246
+ if (this.selected < this.offset) this.offset = this.selected;
247
+ if (this.selected >= this.offset + visibleRows) this.offset = this.selected - visibleRows + 1;
248
+ }
249
+
250
+ private clampScroll(visibleRows: number): void {
251
+ if (this.selected < this.offset) this.offset = this.selected;
252
+ if (this.selected >= this.offset + visibleRows) this.offset = this.selected - visibleRows + 1;
253
+ const maxOffset = Math.max(0, this.data.lines.length - visibleRows);
254
+ this.offset = Math.min(Math.max(0, this.offset), maxOffset);
255
+ }
256
+
257
+ private colorizeCommitLine(line: string): string {
258
+ const match = line.match(/^([*|\\/ ]*)([0-9a-f]{5,})(.*)$/);
259
+ if (!match) return this.theme.fg("toolOutput", line);
260
+ const graph = this.theme.fg("accent", match[1] ?? "");
261
+ const hash = this.theme.fg("warning", match[2] ?? "");
262
+ const rest = this.colorizeRefs(match[3] ?? "");
263
+ return `${graph}${hash}${rest}`;
264
+ }
265
+
266
+ private colorizeRefs(text: string): string {
267
+ return text.replace(/\(([^)]+)\)/g, (_match, refs: string) => this.theme.fg("success", `(${refs})`));
268
+ }
269
+ }
270
+
271
+ async function showGitGraph(pi: ExtensionAPI, ctx: ExtensionCommandContext, args: string): Promise<void> {
272
+ if (!ctx.hasUI) {
273
+ ctx.ui.notify("Git graph sidebar requires interactive TUI mode.", "warning");
274
+ return;
275
+ }
276
+ const limit = parseLimit(args);
277
+ const data = await loadGitGraph(pi, ctx.cwd, limit);
278
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new GitGraphSidebar(pi, tui, theme, ctx.cwd, limit, data, done), {
279
+ overlay: true,
280
+ overlayOptions: {
281
+ anchor: "right-center",
282
+ width: "42%",
283
+ minWidth: 48,
284
+ maxHeight: "95%",
285
+ margin: { right: 1 },
286
+ visible: (termWidth) => termWidth >= 90,
287
+ },
288
+ });
289
+ }
290
+
291
+ export default function registerGitGraphSidebar(pi: ExtensionAPI): void {
292
+ pi.registerCommand("git-graph", {
293
+ description: "Open a VS Code Git Graph-style sidebar for the current repository",
294
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
295
+ await showGitGraph(pi, ctx, args);
296
+ },
297
+ });
298
+
299
+ pi.registerShortcut("ctrl+shift+g", {
300
+ description: "Open git graph sidebar",
301
+ handler: async (ctx: ExtensionCommandContext) => {
302
+ await showGitGraph(pi, ctx, "");
303
+ },
304
+ });
305
+
306
+ pi.on("session_start", (_event, ctx) => {
307
+ if (ctx.hasUI) ctx.ui.setStatus("git-graph", ctx.ui.theme.fg("dim", "⇧⌃G graph"));
308
+ });
309
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-git-graph-sidebar",
3
+ "version": "0.1.0",
4
+ "description": "A VS Code Git Graph-style sidebar overlay for the Pi coding agent TUI.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "git",
10
+ "git-graph",
11
+ "tui"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "yuxiang-gao",
15
+ "pi": {
16
+ "extensions": [
17
+ "./extensions"
18
+ ]
19
+ },
20
+ "peerDependencies": {
21
+ "@earendil-works/pi-coding-agent": "*",
22
+ "@earendil-works/pi-tui": "*"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/yuxiang-gao/pi-git-graph-sidebar.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/yuxiang-gao/pi-git-graph-sidebar/issues"
30
+ },
31
+ "homepage": "https://github.com/yuxiang-gao/pi-git-graph-sidebar#readme",
32
+ "files": [
33
+ "extensions",
34
+ "README.md",
35
+ "LICENSE"
36
+ ]
37
+ }