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 +121 -0
- package/colors.ts +88 -0
- package/compositor.ts +131 -0
- package/index.ts +590 -0
- package/mcp.ts +82 -0
- package/package.json +39 -0
- package/panels/mcp.ts +31 -0
- package/panels/session.ts +125 -0
- package/panels/subagents.ts +75 -0
- package/panels/todos.ts +55 -0
- package/panels/workspace.ts +69 -0
- package/sidebar.ts +31 -0
- package/types.ts +67 -0
- package/workspace.ts +84 -0
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
|
+
}
|