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/panels/mcp.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SidebarContext } from "../types.ts";
|
|
2
|
+
import { dim, fg, COLORS, panelHeader } from "../colors.ts";
|
|
3
|
+
|
|
4
|
+
export function renderMcpPanel(ctx: SidebarContext, width: number): string[] {
|
|
5
|
+
const servers = ctx.mcpServers;
|
|
6
|
+
if (!servers || servers.length === 0) return [];
|
|
7
|
+
|
|
8
|
+
const lines: string[] = [...panelHeader("MCP Servers", width)];
|
|
9
|
+
|
|
10
|
+
for (const srv of servers) {
|
|
11
|
+
const dot = srv.connected
|
|
12
|
+
? (srv.directCount === srv.totalCount && srv.totalCount > 0
|
|
13
|
+
? fg(COLORS.success, "●")
|
|
14
|
+
: fg(COLORS.warning, "◐"))
|
|
15
|
+
: dim("○");
|
|
16
|
+
|
|
17
|
+
const countStr = srv.totalCount > 0 ? `${srv.directCount}/${srv.totalCount}` : "";
|
|
18
|
+
const tokenStr = srv.directCount > 0 ? ` ~${srv.tokenEstimate.toLocaleString()}` : "";
|
|
19
|
+
const suffix = dim(` ${countStr}${tokenStr}`);
|
|
20
|
+
const suffixVisibleLen = 2 + countStr.length + (srv.directCount > 0 ? 2 + String(srv.tokenEstimate.toLocaleString()).length + 1 : 0);
|
|
21
|
+
|
|
22
|
+
const nameMax = Math.max(0, width - 4 - suffixVisibleLen);
|
|
23
|
+
const name = srv.name.length > nameMax
|
|
24
|
+
? srv.name.slice(0, nameMax - 1) + "…"
|
|
25
|
+
: srv.name;
|
|
26
|
+
|
|
27
|
+
lines.push(dim(" ") + dot + " " + fg(COLORS.accent, name) + suffix);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return lines;
|
|
31
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { SidebarContext } from "../types.ts";
|
|
3
|
+
import { dim, fg, COLORS, panelHeader } from "../colors.ts";
|
|
4
|
+
|
|
5
|
+
const NA = "—";
|
|
6
|
+
|
|
7
|
+
export function renderSessionPanel(ctx: SidebarContext, width: number): string[] {
|
|
8
|
+
const lines: string[] = [...panelHeader("Session", width)];
|
|
9
|
+
|
|
10
|
+
const title = ctx.sessionTitle;
|
|
11
|
+
if (!title) {
|
|
12
|
+
lines.push(dim(" (waiting for first message…)"));
|
|
13
|
+
} else {
|
|
14
|
+
const truncated = truncateToWidth(title, Math.max(0, width - 2), "…");
|
|
15
|
+
lines.push(dim(` ${truncated}`));
|
|
16
|
+
}
|
|
17
|
+
if (ctx.sessionId) {
|
|
18
|
+
lines.push(dim(` ${ctx.sessionId}`));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
lines.push("");
|
|
22
|
+
|
|
23
|
+
// Active tool (live only)
|
|
24
|
+
if (ctx.activeTool) {
|
|
25
|
+
const toolElapsed = Date.now() - ctx.activeTool.startedAt;
|
|
26
|
+
const toolName = truncateToWidth(ctx.activeTool.name, Math.max(0, width - 14), "…");
|
|
27
|
+
lines.push(dim(" tool ") + fg(COLORS.accent, toolName) + dim(` (${formatDuration(toolElapsed)})`));
|
|
28
|
+
lines.push("");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Model
|
|
32
|
+
const thinkLabel = ctx.model
|
|
33
|
+
? (ctx.thinkingLevel && ctx.thinkingLevel !== "off" ? ` - ${ctx.thinkingLevel}` : " - think off")
|
|
34
|
+
: "";
|
|
35
|
+
const modelDisplay = ctx.model
|
|
36
|
+
? truncateToWidth(ctx.model, Math.max(0, width - 10 - thinkLabel.length), "…")
|
|
37
|
+
: NA;
|
|
38
|
+
lines.push(
|
|
39
|
+
dim(" model ") +
|
|
40
|
+
fg(ctx.model ? COLORS.accent : COLORS.muted, modelDisplay) +
|
|
41
|
+
(thinkLabel ? dim(thinkLabel) : "")
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Context
|
|
45
|
+
if (ctx.contextPercent !== null) {
|
|
46
|
+
const pct = ctx.contextPercent;
|
|
47
|
+
const tokens = ctx.contextTokens !== null ? formatK(ctx.contextTokens) : "?";
|
|
48
|
+
const win = ctx.contextWindow !== null ? formatK(ctx.contextWindow) : "?";
|
|
49
|
+
const ctxColor = pct > 90 ? COLORS.warning : pct > 70 ? COLORS.accent : COLORS.header;
|
|
50
|
+
lines.push(dim(" ctx ") + fg(ctxColor, `${tokens} / ${win}`) + dim(` (${pct.toFixed(1)}%)`));
|
|
51
|
+
if (ctx.autoCompactEnabled !== null) {
|
|
52
|
+
const compactColor = pct > 70 ? COLORS.warning : COLORS.muted;
|
|
53
|
+
lines.push(dim(" ") + fg(compactColor, ctx.autoCompactEnabled ? "auto-compact on" : "auto-compact off"));
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
lines.push(dim(" ctx ") + fg(COLORS.muted, NA));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
lines.push("");
|
|
60
|
+
|
|
61
|
+
// Two-column stats
|
|
62
|
+
// Col1: time, last, speed, cost, turns
|
|
63
|
+
// Col2: in, out, total, cache
|
|
64
|
+
const elapsed = Date.now() - ctx.sessionStartMs;
|
|
65
|
+
const avgTps = ctx.liveTps ?? ctx.lastTps;
|
|
66
|
+
const tokenTotal = ctx.tokensIn + ctx.tokensOut + ctx.cacheRead + ctx.cacheWrite;
|
|
67
|
+
const totalIn = ctx.tokensIn + ctx.cacheRead;
|
|
68
|
+
const cacheHitPct = totalIn > 0 ? Math.round((ctx.cacheRead / totalIn) * 100) : null;
|
|
69
|
+
|
|
70
|
+
const col1: [string, string][] = [
|
|
71
|
+
["time", elapsed >= 1000 ? formatDuration(elapsed) : NA],
|
|
72
|
+
["last", ctx.lastTurnMs !== null ? formatDuration(ctx.lastTurnMs) : NA],
|
|
73
|
+
["speed", avgTps !== null ? `${avgTps} tok/s` : NA],
|
|
74
|
+
["turns", ctx.turnCount > 0 ? String(ctx.turnCount) : NA],
|
|
75
|
+
["cost", ctx.sessionCost > 0 ? `$${ctx.sessionCost.toFixed(3)}` : NA],
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const col2: [string, string][] = [
|
|
79
|
+
["in", ctx.tokensIn > 0 ? formatK(ctx.tokensIn) : NA],
|
|
80
|
+
["out", ctx.tokensOut > 0 ? formatK(ctx.tokensOut) : NA],
|
|
81
|
+
["total", tokenTotal > 0 ? formatK(tokenTotal) : NA],
|
|
82
|
+
["cache", cacheHitPct !== null && cacheHitPct > 0 ? `${cacheHitPct}%` : NA],
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const usable = Math.max(0, width - 2);
|
|
86
|
+
const c1W = Math.floor(usable / 2);
|
|
87
|
+
const c2W = usable - c1W;
|
|
88
|
+
const v1W = Math.max(0, c1W - 6); // 5 label + 1 space
|
|
89
|
+
const v2W = Math.max(0, c2W - 6);
|
|
90
|
+
|
|
91
|
+
// Column sub-headers with separator
|
|
92
|
+
const h1 = "Stats".padEnd(c1W);
|
|
93
|
+
const h2 = "Tokens";
|
|
94
|
+
lines.push(dim(" " + h1 + h2));
|
|
95
|
+
lines.push(dim(" " + "─".repeat(Math.max(0, usable - 1))));
|
|
96
|
+
|
|
97
|
+
const rowCount = Math.max(col1.length, col2.length);
|
|
98
|
+
for (let i = 0; i < rowCount; i++) {
|
|
99
|
+
const [l1, v1] = col1[i] ?? ["", ""];
|
|
100
|
+
const [l2, v2] = col2[i] ?? ["", ""];
|
|
101
|
+
const v1s = v1.slice(0, v1W).padEnd(v1W);
|
|
102
|
+
const v2s = v2.slice(0, v2W);
|
|
103
|
+
lines.push(
|
|
104
|
+
dim(" " + l1.padEnd(5) + " ") + fg(COLORS.muted, v1s) +
|
|
105
|
+
dim(l2.padEnd(5) + " ") + fg(COLORS.muted, v2s)
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatK(n: number): string {
|
|
113
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
|
114
|
+
if (n >= 1000) return Math.round(n / 1000) + "k";
|
|
115
|
+
return String(n);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatDuration(ms: number): string {
|
|
119
|
+
const s = Math.floor(ms / 1000);
|
|
120
|
+
const m = Math.floor(s / 60);
|
|
121
|
+
const h = Math.floor(m / 60);
|
|
122
|
+
if (h > 0) return `${h}h${m % 60}m`;
|
|
123
|
+
if (m > 0) return `${m}m${s % 60}s`;
|
|
124
|
+
return `${s}s`;
|
|
125
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { SidebarContext, SubagentEntry } from "../types.ts";
|
|
3
|
+
import {
|
|
4
|
+
dim, fg, COLORS, panelHeader,
|
|
5
|
+
formatDuration, formatRelativeTime, formatTokens, spinnerFrame,
|
|
6
|
+
} from "../colors.ts";
|
|
7
|
+
|
|
8
|
+
function renderSubagentBlock(agent: SubagentEntry, width: number): string[] {
|
|
9
|
+
const lines: string[] = [];
|
|
10
|
+
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
const elapsed = now - agent.startedAt;
|
|
13
|
+
|
|
14
|
+
let statusGlyph: string;
|
|
15
|
+
let agentNameColor: string;
|
|
16
|
+
if (agent.status === "completed") {
|
|
17
|
+
statusGlyph = fg(COLORS.success, "✓");
|
|
18
|
+
agentNameColor = COLORS.success;
|
|
19
|
+
} else if (agent.status === "failed") {
|
|
20
|
+
statusGlyph = fg(COLORS.warning, "✗");
|
|
21
|
+
agentNameColor = COLORS.warning;
|
|
22
|
+
} else {
|
|
23
|
+
statusGlyph = fg(COLORS.accent, spinnerFrame());
|
|
24
|
+
agentNameColor = COLORS.accent;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const nameMax = Math.max(0, width - 2);
|
|
28
|
+
const truncName = truncateToWidth(agent.name, nameMax, "…");
|
|
29
|
+
lines.push(`${statusGlyph} ${fg(agentNameColor, truncName)}`);
|
|
30
|
+
|
|
31
|
+
if (agent.status === "completed" && agent.completedAt !== undefined) {
|
|
32
|
+
const duration = agent.completedAt - agent.startedAt;
|
|
33
|
+
const ago = now - agent.completedAt;
|
|
34
|
+
lines.push(dim(` complete (${formatRelativeTime(ago)})`));
|
|
35
|
+
const meta = `${agent.turns} turns · ${agent.toolCount} tools · ${formatTokens(agent.tokens)} tokens · ${formatDuration(duration)}`;
|
|
36
|
+
lines.push(dim(` ${truncateToWidth(meta, Math.max(0, width - 2), "…")}`));
|
|
37
|
+
} else if (agent.status === "failed") {
|
|
38
|
+
lines.push(dim(` failed (${formatRelativeTime(elapsed)})`));
|
|
39
|
+
const meta = `${agent.turns} turns · ${agent.toolCount} tools · ${formatTokens(agent.tokens)} tokens`;
|
|
40
|
+
lines.push(dim(` ${truncateToWidth(meta, Math.max(0, width - 2), "…")}`));
|
|
41
|
+
} else {
|
|
42
|
+
lines.push(dim(` running (${formatDuration(elapsed)})`));
|
|
43
|
+
const meta = `${agent.turns} turns · ${agent.toolCount} tools · ${formatTokens(agent.tokens)} tokens`;
|
|
44
|
+
lines.push(dim(` ${truncateToWidth(meta, Math.max(0, width - 2), "…")}`));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const recentLog = agent.toolLog.slice(-3);
|
|
48
|
+
for (const entry of recentLog) {
|
|
49
|
+
lines.push(dim(` ${truncateToWidth(entry, Math.max(0, width - 2), "…")}`));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function renderSubagentsPanel(ctx: SidebarContext, width: number): string[] {
|
|
56
|
+
const { subagents } = ctx;
|
|
57
|
+
const running = subagents.filter(a => a.status === "running").length;
|
|
58
|
+
const completed = subagents.filter(a => a.status === "completed").length;
|
|
59
|
+
|
|
60
|
+
const parallelSuffix = running > 1 ? " · parallel" : "";
|
|
61
|
+
const title = `Async Subagents (${completed}/${subagents.length})${parallelSuffix}`;
|
|
62
|
+
const lines: string[] = [...panelHeader(title, width)];
|
|
63
|
+
|
|
64
|
+
if (subagents.length === 0) {
|
|
65
|
+
lines.push(dim(" (no subagents)"));
|
|
66
|
+
return lines;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < subagents.length; i++) {
|
|
70
|
+
if (i > 0) lines.push("");
|
|
71
|
+
lines.push(...renderSubagentBlock(subagents[i], width));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return lines;
|
|
75
|
+
}
|
package/panels/todos.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { SidebarContext, TodoItem, TodoStatus } from "../types.ts";
|
|
3
|
+
import { dim, fg, COLORS, panelHeader } from "../colors.ts";
|
|
4
|
+
|
|
5
|
+
const GLYPHS: Record<TodoStatus, string> = {
|
|
6
|
+
completed: "✓",
|
|
7
|
+
in_progress: "●",
|
|
8
|
+
pending: "○",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const GLYPH_COLORS: Record<TodoStatus, string> = {
|
|
12
|
+
completed: COLORS.success,
|
|
13
|
+
in_progress: COLORS.accent,
|
|
14
|
+
pending: COLORS.muted,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function renderTodoLine(todo: TodoItem, width: number): string {
|
|
18
|
+
const glyph = fg(GLYPH_COLORS[todo.status], GLYPHS[todo.status]);
|
|
19
|
+
const glyphWidth = 1; // all glyphs are 1 visible char
|
|
20
|
+
const spaceAfterGlyph = 1;
|
|
21
|
+
const indent = glyphWidth + spaceAfterGlyph;
|
|
22
|
+
|
|
23
|
+
if (todo.status === "in_progress" && todo.subAction) {
|
|
24
|
+
const subText = ` (${todo.subAction})`;
|
|
25
|
+
const contentMax = Math.max(0, width - indent);
|
|
26
|
+
const fullText = todo.content + subText;
|
|
27
|
+
if (visibleWidth(fullText) <= contentMax) {
|
|
28
|
+
return `${glyph} ${todo.content}${dim(subText)}`;
|
|
29
|
+
}
|
|
30
|
+
const contentTruncated = truncateToWidth(todo.content, Math.max(0, contentMax - 4), "…");
|
|
31
|
+
return `${glyph} ${contentTruncated}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const contentMax = Math.max(0, width - indent);
|
|
35
|
+
const content = truncateToWidth(todo.content, contentMax, "…");
|
|
36
|
+
return `${glyph} ${content}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function renderTodosPanel(ctx: SidebarContext, width: number): string[] {
|
|
40
|
+
const { todos } = ctx;
|
|
41
|
+
const done = todos.filter(t => t.status === "completed").length;
|
|
42
|
+
const title = `Todos (${done}/${todos.length})`;
|
|
43
|
+
const lines: string[] = [...panelHeader(title, width)];
|
|
44
|
+
|
|
45
|
+
if (todos.length === 0) {
|
|
46
|
+
lines.push(dim(" (no todos)"));
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const todo of todos) {
|
|
51
|
+
lines.push(renderTodoLine(todo, width));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { SidebarContext } from "../types.ts";
|
|
3
|
+
import { bold, dim, fg, COLORS, formatDiffStat } from "../colors.ts";
|
|
4
|
+
import type { WorkspaceFile } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
function buildGitStatus(branch: string, ahead: number, untracked: number): string {
|
|
7
|
+
let status = `⎇ ${branch}`;
|
|
8
|
+
if (ahead > 0) status += ` +${ahead}`;
|
|
9
|
+
if (untracked > 0) status += ` ?${untracked}`;
|
|
10
|
+
return status;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function renderWorkspaceHeader(ctx: SidebarContext, width: number): string[] {
|
|
14
|
+
const left = bold(" Workspace");
|
|
15
|
+
const leftPlain = " Workspace";
|
|
16
|
+
|
|
17
|
+
if (!ctx.branch) {
|
|
18
|
+
return [left, dim("─".repeat(Math.max(0, width)))];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const gitStatus = buildGitStatus(ctx.branch, ctx.aheadCount, ctx.untrackedCount);
|
|
22
|
+
const leftLen = visibleWidth(leftPlain);
|
|
23
|
+
let rightLen = visibleWidth(gitStatus);
|
|
24
|
+
const minPadding = 1;
|
|
25
|
+
|
|
26
|
+
// If the right side is too long, truncate it
|
|
27
|
+
let displayStatus = gitStatus;
|
|
28
|
+
if (leftLen + minPadding + rightLen > width) {
|
|
29
|
+
const maxStatusLen = Math.max(0, width - leftLen - minPadding);
|
|
30
|
+
displayStatus = truncateToWidth(gitStatus, maxStatusLen, "…");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
rightLen = visibleWidth(displayStatus);
|
|
34
|
+
const padding = Math.max(1, width - leftLen - rightLen);
|
|
35
|
+
const headerLine = `${left}${" ".repeat(padding)}${dim(displayStatus)}`;
|
|
36
|
+
|
|
37
|
+
return [headerLine, dim("─".repeat(Math.max(0, width)))];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderFileLine(file: WorkspaceFile, width: number): string {
|
|
41
|
+
const stat = formatDiffStat(file.added, file.removed);
|
|
42
|
+
const statLen = visibleWidth(stat);
|
|
43
|
+
const pathMax = Math.max(0, width - statLen - 1);
|
|
44
|
+
const path = truncateToWidth(file.path, pathMax, "…");
|
|
45
|
+
const pathLen = visibleWidth(path);
|
|
46
|
+
const padding = Math.max(1, width - pathLen - statLen);
|
|
47
|
+
return `${path}${" ".repeat(padding)}${fg(COLORS.success, stat)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function renderWorkspacePanel(ctx: SidebarContext, width: number): string[] {
|
|
51
|
+
const lines: string[] = [...renderWorkspaceHeader(ctx, width)];
|
|
52
|
+
|
|
53
|
+
if (!ctx.branch) {
|
|
54
|
+
lines.push(dim(" (not a git repo)"));
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const files = ctx.workspaceFiles.slice(0, 15);
|
|
59
|
+
if (files.length === 0) {
|
|
60
|
+
lines.push(dim(" (clean)"));
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
lines.push(renderFileLine(file, width));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return lines;
|
|
69
|
+
}
|
package/sidebar.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { SidebarContext } from "./types.ts";
|
|
3
|
+
import { renderSessionPanel } from "./panels/session.ts";
|
|
4
|
+
import { renderTodosPanel } from "./panels/todos.ts";
|
|
5
|
+
import { renderSubagentsPanel } from "./panels/subagents.ts";
|
|
6
|
+
import { renderWorkspacePanel } from "./panels/workspace.ts";
|
|
7
|
+
import { renderMcpPanel } from "./panels/mcp.ts";
|
|
8
|
+
|
|
9
|
+
export function renderSidebar(ctx: SidebarContext, width: number): string[] {
|
|
10
|
+
const safeWidth = Math.max(1, width);
|
|
11
|
+
|
|
12
|
+
const allPanels = [
|
|
13
|
+
renderSessionPanel(ctx, safeWidth),
|
|
14
|
+
renderMcpPanel(ctx, safeWidth),
|
|
15
|
+
renderTodosPanel(ctx, safeWidth),
|
|
16
|
+
renderSubagentsPanel(ctx, safeWidth),
|
|
17
|
+
renderWorkspacePanel(ctx, safeWidth),
|
|
18
|
+
];
|
|
19
|
+
// Skip empty panels (MCP panel returns [] when no servers)
|
|
20
|
+
const panels = allPanels.filter(p => p.length > 0);
|
|
21
|
+
|
|
22
|
+
const result: string[] = [];
|
|
23
|
+
for (let i = 0; i < panels.length; i++) {
|
|
24
|
+
if (i > 0) result.push("");
|
|
25
|
+
for (const line of panels[i]) {
|
|
26
|
+
result.push(truncateToWidth(line, safeWidth, "", true));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return result;
|
|
31
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type TodoStatus = "pending" | "in_progress" | "completed";
|
|
2
|
+
|
|
3
|
+
export interface TodoItem {
|
|
4
|
+
id: string;
|
|
5
|
+
content: string;
|
|
6
|
+
status: TodoStatus;
|
|
7
|
+
subAction?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type SubagentStatus = "running" | "completed" | "failed";
|
|
11
|
+
|
|
12
|
+
export interface SubagentEntry {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
status: SubagentStatus;
|
|
16
|
+
startedAt: number;
|
|
17
|
+
completedAt?: number;
|
|
18
|
+
turns: number;
|
|
19
|
+
toolCount: number;
|
|
20
|
+
tokens: number;
|
|
21
|
+
toolLog: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WorkspaceFile {
|
|
25
|
+
path: string;
|
|
26
|
+
added: number;
|
|
27
|
+
removed: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface McpServerInfo {
|
|
31
|
+
name: string;
|
|
32
|
+
directCount: number;
|
|
33
|
+
totalCount: number;
|
|
34
|
+
tokenEstimate: number;
|
|
35
|
+
connected: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SidebarContext {
|
|
39
|
+
sessionTitle: string | null;
|
|
40
|
+
sessionId: string | null;
|
|
41
|
+
todos: TodoItem[];
|
|
42
|
+
subagents: SubagentEntry[];
|
|
43
|
+
branch: string | null;
|
|
44
|
+
aheadCount: number;
|
|
45
|
+
untrackedCount: number;
|
|
46
|
+
workspaceFiles: WorkspaceFile[];
|
|
47
|
+
cwd: string | undefined;
|
|
48
|
+
model: string | null;
|
|
49
|
+
thinkingLevel: string | null;
|
|
50
|
+
contextTokens: number | null;
|
|
51
|
+
contextPercent: number | null;
|
|
52
|
+
contextWindow: number | null;
|
|
53
|
+
tokensIn: number;
|
|
54
|
+
tokensOut: number;
|
|
55
|
+
cacheRead: number;
|
|
56
|
+
cacheWrite: number;
|
|
57
|
+
sessionCost: number;
|
|
58
|
+
turnCount: number;
|
|
59
|
+
activeTool: { name: string; startedAt: number } | null;
|
|
60
|
+
autoCompactEnabled: boolean | null;
|
|
61
|
+
sessionStartMs: number;
|
|
62
|
+
mcpServers: McpServerInfo[];
|
|
63
|
+
modelProvider: string | null;
|
|
64
|
+
liveTps: number | null;
|
|
65
|
+
lastTps: number | null;
|
|
66
|
+
lastTurnMs: number | null;
|
|
67
|
+
}
|
package/workspace.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import type { WorkspaceFile } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export interface WorkspaceData {
|
|
5
|
+
branch: string | null;
|
|
6
|
+
aheadCount: number;
|
|
7
|
+
untrackedCount: number;
|
|
8
|
+
files: WorkspaceFile[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const CACHE_TTL_MS = 2000;
|
|
12
|
+
let cache: { data: WorkspaceData; timestamp: number } | null = null;
|
|
13
|
+
|
|
14
|
+
function run(cmd: string, cwd: string): string {
|
|
15
|
+
try {
|
|
16
|
+
return execSync(cmd, { cwd, encoding: "utf-8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
17
|
+
} catch {
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseBranch(cwd: string): string | null {
|
|
23
|
+
const out = run("git branch --show-current", cwd);
|
|
24
|
+
return out || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseAhead(cwd: string): number {
|
|
28
|
+
const out = run("git rev-list @{u}..HEAD --count", cwd);
|
|
29
|
+
const n = parseInt(out, 10);
|
|
30
|
+
return isNaN(n) ? 0 : n;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseUntracked(cwd: string): number {
|
|
34
|
+
const out = run("git status --porcelain", cwd);
|
|
35
|
+
if (!out) return 0;
|
|
36
|
+
return out.split("\n").filter(l => l.startsWith("??")).length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseNumstat(cwd: string): WorkspaceFile[] {
|
|
40
|
+
const out = run("git diff --numstat HEAD", cwd);
|
|
41
|
+
if (!out) return [];
|
|
42
|
+
|
|
43
|
+
return out
|
|
44
|
+
.split("\n")
|
|
45
|
+
.map(line => {
|
|
46
|
+
const parts = line.split("\t");
|
|
47
|
+
if (parts.length < 3) return null;
|
|
48
|
+
const added = parseInt(parts[0], 10);
|
|
49
|
+
const removed = parseInt(parts[1], 10);
|
|
50
|
+
const path = parts[2].trim();
|
|
51
|
+
if (isNaN(added) || isNaN(removed) || !path) return null;
|
|
52
|
+
return { path, added, removed };
|
|
53
|
+
})
|
|
54
|
+
.filter((f): f is WorkspaceFile => f !== null)
|
|
55
|
+
.sort((a, b) => (b.added + b.removed) - (a.added + a.removed))
|
|
56
|
+
.slice(0, 15);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getWorkspaceData(cwd: string | undefined): WorkspaceData {
|
|
60
|
+
const empty: WorkspaceData = { branch: null, aheadCount: 0, untrackedCount: 0, files: [] };
|
|
61
|
+
if (!cwd) return empty;
|
|
62
|
+
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
if (cache && now - cache.timestamp < CACHE_TTL_MS) return cache.data;
|
|
65
|
+
|
|
66
|
+
const branch = parseBranch(cwd);
|
|
67
|
+
if (!branch) {
|
|
68
|
+
cache = { data: empty, timestamp: now };
|
|
69
|
+
return empty;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const data: WorkspaceData = {
|
|
73
|
+
branch,
|
|
74
|
+
aheadCount: parseAhead(cwd),
|
|
75
|
+
untrackedCount: parseUntracked(cwd),
|
|
76
|
+
files: parseNumstat(cwd),
|
|
77
|
+
};
|
|
78
|
+
cache = { data, timestamp: now };
|
|
79
|
+
return data;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function invalidateWorkspaceCache(): void {
|
|
83
|
+
cache = null;
|
|
84
|
+
}
|