pi-sidebar-tui 1.2.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 ADDED
@@ -0,0 +1,121 @@
1
+ # pi-sidebar-tui
2
+
3
+ OpenCode-style sidebar TUI extension for [pi coding agent](https://github.com/earendil-works/pi).
4
+
5
+ Displays a real-time sidebar panel inside the pi terminal UI showing session metrics, todo tracking, subagent monitoring, workspace status, and connected MCP servers — inspired by [OpenCode](https://github.com/atanunny/opencode) sidebar.
6
+
7
+ ## Features
8
+
9
+ ### Session Panel
10
+ - Session title (auto-inferred from first user message with LLM fallback, or manually set)
11
+ - Session elapsed time (updated every 30 seconds)
12
+ - Active tool indicator with live elapsed timer
13
+ - Current model name with thinking level (`think:high`, `think:medium`, etc.)
14
+ - Context window usage: `tokens / max_window (pct%)`
15
+ - Auto-compact status indicator
16
+ - Token input/output totals + session cost
17
+ - Cache hit percentage + turn count
18
+ - Live tokens-per-second (tok/s) via 2-second sliding window
19
+ - Session ID display
20
+ - Theme-aware colors (delegates to pi Theme API when available)
21
+
22
+ ### MCP Servers Panel
23
+ - Connected MCP server status with connection indicators
24
+ - Tool count per server (direct/total)
25
+ - Token estimate per server
26
+
27
+ ### Todos Panel
28
+ - Parses todo items from tool calls (`todo` tool)
29
+ - Status glyphs: `○` pending, `●` in progress, `✓` completed
30
+ - Progress counter: `Todos (2/5)`
31
+ - Sub-action annotations for in-progress items
32
+
33
+ ### Async Subagents Panel
34
+ - Tracks dispatched subagents (task/dispatch/agent tools)
35
+ - Status: running (animated spinner), completed, or failed
36
+ - Per-agent: turns, tool count, token usage, elapsed time
37
+ - Last 3 tool calls logged per agent
38
+ - Parallel subagent indicator
39
+
40
+ ### Workspace Panel
41
+ - Git branch with ahead/untracked counts
42
+ - Dirty file listing with diff stats (+N/-N)
43
+ - Current working directory at bottom of sidebar
44
+ - Auto-refreshes on write operations (write/edit/bash/computer tools)
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ # Install as a pi package
50
+ pi install bi0h4z4rd88/pi-sidebar-tui
51
+
52
+ # Or clone manually into your extensions directory
53
+ git clone https://github.com/bi0h4z4rd88/pi-sidebar-tui.git
54
+ ```
55
+
56
+ Add to your pi extensions config. The extension auto-registers via the `"pi"` field in `package.json`.
57
+
58
+ ## Usage
59
+
60
+ The sidebar activates automatically when a session starts. Toggle visibility with:
61
+
62
+ ```
63
+ /sidebar-tui on # Enable sidebar
64
+ /sidebar-tui off # Disable sidebar
65
+ /sidebar-tui toggle # Toggle on/off
66
+ /sidebar-tui width 45 # Set sidebar width (10-120)
67
+ ```
68
+
69
+ Set custom session title:
70
+
71
+ ```
72
+ /session-title "My feature implementation"
73
+ ```
74
+
75
+ ## Requirements
76
+
77
+ - [pi coding agent](https://github.com/earendil-works/pi) >= 0.74.0
78
+ - [pi-tui](https://github.com/earendil-works/pi) >= 0.74.0
79
+
80
+ ## Architecture
81
+
82
+ ```
83
+ pi-sidebar-tui/
84
+ ├── index.ts # Extension entry point, event handlers, state management
85
+ ├── sidebar.ts # Main sidebar renderer (composes all panels)
86
+ ├── compositor.ts # Terminal compositor for right-column layout
87
+ ├── colors.ts # ANSI color helpers, formatting utilities
88
+ ├── types.ts # TypeScript type definitions
89
+ ├── mcp.ts # MCP server discovery and status
90
+ ├── workspace.ts # Git workspace state tracking
91
+ ├── panels/
92
+ │ ├── session.ts # Session metrics panel
93
+ │ ├── todos.ts # Todo tracking panel
94
+ │ ├── subagents.ts # Async subagent monitoring panel
95
+ │ ├── workspace.ts # Git workspace status panel
96
+ │ └── mcp.ts # MCP server status panel
97
+ ├── tests/
98
+ │ ├── panels.test.ts # Panel rendering tests
99
+ │ └── sidebar.test.ts # Sidebar rendering tests
100
+ └── package.json
101
+ ```
102
+
103
+ ## How It Works
104
+
105
+ The extension hooks into pi's event system (`session_start`, `tool_call`, `message_end`, `turn_end`, etc.) to track state and renders a right-column sidebar using a custom `SidebarCompositor` that:
106
+
107
+ 1. Narrows `terminal.columns` so pi renders main content in the left portion
108
+ 2. Paints the sidebar in the rightmost columns after each pi render cycle
109
+ 3. Uses synchronized output (`\x1b[?2026h`) to prevent rendering artifacts
110
+ 4. Saves/restores cursor position (`DECSC`/`DECRC`) around paint
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ npm install
116
+ npm test
117
+ ```
118
+
119
+ ## License
120
+
121
+ MIT
package/colors.ts ADDED
@@ -0,0 +1,88 @@
1
+ // Selective reset: clears bold/dim/italic/underline/fg but NOT background
2
+ const RESET = "\x1b[22;23;24;39m";
3
+ const BOLD = "\x1b[1m";
4
+ const DIM_CODE = "\x1b[2m";
5
+
6
+ function hexToAnsi(color: string): string {
7
+ const h = color.replace("#", "");
8
+ const r = parseInt(h.slice(0, 2), 16);
9
+ const g = parseInt(h.slice(2, 4), 16);
10
+ const b = parseInt(h.slice(4, 6), 16);
11
+ return `\x1b[38;2;${r};${g};${b}m`;
12
+ }
13
+
14
+ // ThemeColor names — matched to pi's Theme.fg() API
15
+ export const COLORS = {
16
+ accent: "accent",
17
+ success: "success",
18
+ warning: "warning",
19
+ header: "text",
20
+ muted: "muted",
21
+ } as const;
22
+
23
+ // Hex fallbacks used when no pi theme is injected (tests, cold start)
24
+ const FALLBACK_HEX: Record<string, string> = {
25
+ accent: "#febc38",
26
+ success: "#5faf5f",
27
+ warning: "#ff9500",
28
+ text: "#00afaf",
29
+ muted: "#6c6c6c",
30
+ };
31
+
32
+ let _piTheme: any = null;
33
+
34
+ export function setPiTheme(t: any): void {
35
+ _piTheme = t;
36
+ }
37
+
38
+ export function bold(text: string): string {
39
+ if (_piTheme) return _piTheme.bold(text);
40
+ return `${BOLD}${text}${RESET}`;
41
+ }
42
+
43
+ export function dim(text: string): string {
44
+ if (_piTheme) return _piTheme.fg("dim", text);
45
+ return `${DIM_CODE}${text}${RESET}`;
46
+ }
47
+
48
+ export function fg(colorName: string, text: string): string {
49
+ if (_piTheme) return _piTheme.fg(colorName, text);
50
+ const hex = FALLBACK_HEX[colorName] ?? "#ffffff";
51
+ return `${hexToAnsi(hex)}${text}${RESET}`;
52
+ }
53
+
54
+ export function formatDuration(ms: number): string {
55
+ const s = Math.floor(ms / 1000);
56
+ const m = Math.floor(s / 60);
57
+ if (m > 0) return `${m}m${s % 60}s`;
58
+ return `${s}s`;
59
+ }
60
+
61
+ export function formatRelativeTime(ms: number): string {
62
+ return `${formatDuration(ms)} ago`;
63
+ }
64
+
65
+ export function formatTokens(n: number): string {
66
+ if (n < 1000) return n.toString();
67
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
68
+ return `${(n / 1_000_000).toFixed(1)}M`;
69
+ }
70
+
71
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
72
+
73
+ export function spinnerFrame(): string {
74
+ return SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
75
+ }
76
+
77
+ export function formatDiffStat(added: number, removed: number): string {
78
+ if (removed > 0) return `+${added} -${removed}`;
79
+ return `+${added}`;
80
+ }
81
+
82
+ export function panelHeader(title: string, width: number): string[] {
83
+ const separatorLen = Math.max(0, width);
84
+ return [
85
+ bold(` ${title}`),
86
+ dim("─".repeat(separatorLen)),
87
+ ];
88
+ }
package/compositor.ts ADDED
@@ -0,0 +1,131 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { SidebarContext } from "./types.ts";
3
+ import { renderSidebar } from "./sidebar.ts";
4
+ import { dim } from "./colors.ts";
5
+
6
+ const SIDEBAR_BG = "\x1b[48;2;0;0;0m"; // black — matches terminal bg, hides scroll flash
7
+ const BG_RESET = "\x1b[49m";
8
+
9
+ function moveCursor(row: number, col: number): string {
10
+ return `\x1b[${row};${col}H`;
11
+ }
12
+
13
+ function descriptorFor(obj: object, key: string): PropertyDescriptor | undefined {
14
+ let target: object | null = obj;
15
+ while (target) {
16
+ const d = Object.getOwnPropertyDescriptor(target, key);
17
+ if (d) return d;
18
+ target = Object.getPrototypeOf(target);
19
+ }
20
+ return undefined;
21
+ }
22
+
23
+ export class SidebarCompositor {
24
+ private tui: any;
25
+ private terminal: any;
26
+ private getCtx: () => SidebarContext;
27
+ private originalColumnsDesc: PropertyDescriptor | undefined;
28
+ private originalDoRender: (() => void) | null = null;
29
+ private originalWrite: (data: string) => void;
30
+ private disposed = false;
31
+
32
+ private readonly sidebarWidth: number;
33
+
34
+ constructor(tui: any, getCtx: () => SidebarContext, sidebarWidth = 40) {
35
+ this.tui = tui;
36
+ this.terminal = tui.terminal;
37
+ this.getCtx = getCtx;
38
+ this.originalWrite = this.terminal.write.bind(this.terminal);
39
+ this.sidebarWidth = sidebarWidth;
40
+ }
41
+
42
+ install(): void {
43
+ // Narrow terminal.columns so pi renders in the left portion only.
44
+ this.originalColumnsDesc = descriptorFor(this.terminal, "columns");
45
+ const origDesc = this.originalColumnsDesc;
46
+ const terminal = this.terminal;
47
+
48
+ Object.defineProperty(terminal, "columns", {
49
+ configurable: true,
50
+ enumerable: true,
51
+ get() {
52
+ const d = origDesc;
53
+ const raw = d?.get ? (d.get.call(terminal) ?? 80) : (typeof d?.value === "number" ? d.value : 80);
54
+ return Math.max(1, raw - 40 - 1);
55
+ },
56
+ });
57
+
58
+ // Paint sidebar after every pi render cycle
59
+ if (typeof this.tui.doRender === "function") {
60
+ this.originalDoRender = this.tui.doRender.bind(this.tui);
61
+ const self = this;
62
+ this.tui.doRender = function () {
63
+ if (self.disposed) { self.originalDoRender?.(); return; }
64
+ self.originalDoRender!();
65
+ self.paint();
66
+ };
67
+ }
68
+ }
69
+
70
+ paint(): void {
71
+ if (this.disposed) return;
72
+ const rawRows = this.terminal.rows;
73
+ const d = this.originalColumnsDesc;
74
+ const rawCols = d?.get ? (d.get.call(this.terminal) ?? 80) : (typeof d?.value === "number" ? d.value : 80);
75
+ const sw = this.sidebarWidth;
76
+ const sepCol = rawCols - sw;
77
+ const sidebarCol = sepCol + 1;
78
+ const ctx = this.getCtx();
79
+ const lines = renderSidebar(ctx, sw);
80
+
81
+ let buf = "\x1b[?2026h"; // begin synchronized output
82
+ buf += "\x1b7"; // save cursor (DECSC)
83
+ buf += "\x1b[?7l"; // disable auto-wrap
84
+
85
+ // Format cwd for bottom row: collapse home dir, truncate from left if needed
86
+ const cwd = ctx.cwd ?? "";
87
+ const home = process.env["HOME"] ?? "";
88
+ const cwdDisplay = home && cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
89
+ const cwdTruncated = visibleWidth(cwdDisplay) > sw - 1
90
+ ? "…" + cwdDisplay.slice(-(sw - 2))
91
+ : cwdDisplay;
92
+ const cwdLine = dim(" " + cwdTruncated);
93
+
94
+ for (let row = 1; row <= rawRows; row++) {
95
+ buf += moveCursor(row, sepCol);
96
+ buf += dim("│");
97
+ buf += moveCursor(row, sidebarCol);
98
+ buf += SIDEBAR_BG;
99
+ if (row === rawRows && cwd) {
100
+ buf += truncateToWidth(cwdLine, sw, "", true);
101
+ } else {
102
+ const line = lines[row - 1];
103
+ buf += line !== undefined
104
+ ? truncateToWidth(line, sw, "", true)
105
+ : " ".repeat(sw);
106
+ }
107
+ buf += BG_RESET;
108
+ }
109
+
110
+ buf += "\x1b[?7h"; // enable auto-wrap
111
+ buf += "\x1b8"; // restore cursor (DECRC)
112
+ buf += "\x1b[?2026l"; // end synchronized output
113
+
114
+ this.originalWrite(buf);
115
+ }
116
+
117
+ dispose(): void {
118
+ if (this.disposed) return;
119
+ this.disposed = true;
120
+
121
+ if (this.originalColumnsDesc) {
122
+ Object.defineProperty(this.terminal, "columns", this.originalColumnsDesc);
123
+ } else {
124
+ Reflect.deleteProperty(this.terminal, "columns");
125
+ }
126
+
127
+ if (this.originalDoRender !== null) {
128
+ this.tui.doRender = this.originalDoRender;
129
+ }
130
+ }
131
+ }