feat(tui): git view, project-scoped task stats, polished layout/theme

- Add a Git interactive view (hotkey [t]/[4]): branch + ahead/behind,
  recent commits with detail strip, staged/unstaged/untracked files
  panel, branches list, worktrees panel, and a [P] push modal with
  pre-flight commit list and capture of stdout/stderr. ←→ cycles
  status → branches → worktrees → commits → changes; ↑↓ navigates
  rows in the focused list. 5s poll while mounted.
- Project-scoped task stats: BoardView pushes its selected project
  path into the controller; refreshTUIStats now reads from a per-
  project TaskStore via a shared getProjectStore helper. Project
  switch triggers an immediate refresh via onBoardScopeChange.
- Unified MainHeader used by status and interactive modes — section
  tabs ([1]–[5]) and interactive view tabs ([b]/[a]/[g]/[t]) always
  visible; previously the interactive header replaced the main one.
- Logs panel keeps its size when expanding a single entry.
- 'f' cycles severity filter from any panel in status mode.
- BoardView: explicit cross-view shortcuts so g/a/t always switch
  views regardless of input-handler ordering.
- BoardView column overlap fix: flexShrink={0} on structural rows.
- TaskCard always shows the title with wrap="wrap"; single short-id
  pill, no duplicate id-as-title fallback.
- Stats panel reorg: StatRow helper, bold section headers, narrow-
  width wrap for Heap and Memory trailing fragments.
- Lighter blue palette: cyanBright fg accents / cyan active bg /
  logo gradient whiteBright → white → cyanBright → cyan → blue.
- System stats sampler: RSS, heap (V8 limit-aware color), external,
  CPU%, load avg, system used/free. Heap thresholds scale off
  --max-old-space-size automatically.
- Test fixture updated for the new InteractiveData.git block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-24 23:32:45 -07:00
parent 8c9dfc2f56
commit 07d7bac165
10 changed files with 1431 additions and 212 deletions

View File

@@ -63,6 +63,34 @@ function makeInteractiveData(opts: {
getSettings: async () => settings,
updateSettings: async (_partial: Partial<SettingsValues>) => {},
listModels: () => models,
git: {
getStatus: async () => ({
branch: "main",
detached: false,
ahead: 0,
behind: 0,
staged: [],
unstaged: [],
untracked: [],
remoteUrl: "",
lastFetchAt: null,
}),
listCommits: async () => [],
showCommit: async () => ({
sha: "",
shortSha: "",
subject: "",
authorName: "",
relativeTime: "",
isoTime: "",
body: "",
stat: "",
}),
listBranches: async () => [],
listWorktrees: async () => [],
push: async () => ({ success: true, output: "" }),
fetch: async () => ({ success: true, output: "" }),
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,10 @@ export class DashboardTUI {
systemInfo: SystemInfo | null = null;
taskStats: TaskStats | null = null;
systemStats: SystemStats | null = null;
// When set, dashboard.ts refreshes task stats from this project path
// instead of the launch cwd. Mirrors BoardView's selected project.
boardScopedProjectPath: string | null = null;
private boardScopeListener: ((path: string | null) => void) | null = null;
settings: SettingsValues | null = null;
callbacks: TUICallbacks | null = null;
isRunning = false;
@@ -132,6 +136,20 @@ export class DashboardTUI {
this.notify();
}
setBoardScopedProjectPath(path: string | null): void {
if (this.boardScopedProjectPath === path) return;
this.boardScopedProjectPath = path;
this.boardScopeListener?.(path);
this.notify();
}
onBoardScopeChange(listener: (path: string | null) => void): () => void {
this.boardScopeListener = listener;
return () => {
if (this.boardScopeListener === listener) this.boardScopeListener = null;
};
}
/** Sample process memory + CPU% in-place. Called from the sampler timer. */
sampleSystemStats(): void {
const mem = process.memoryUsage();

View File

@@ -17,4 +17,9 @@ export type {
InteractiveData,
ProjectItem,
TaskItem,
GitStatus,
GitCommit,
GitCommitDetail,
GitBranch,
GitWorktree,
} from "./state.js";

View File

@@ -8,7 +8,7 @@ export type SectionId = "logs" | "system" | "utilities" | "stats" | "settings";
export type AppMode = "status" | "interactive";
export type InteractiveView = "board" | "agents" | "settings";
export type InteractiveView = "board" | "agents" | "settings" | "git";
export interface SystemInfo {
host: string;
@@ -124,6 +124,50 @@ export interface ModelItem {
contextWindow: number;
}
// ── Git view types ────────────────────────────────────────────────────────────
export interface GitStatus {
branch: string;
detached: boolean;
ahead: number;
behind: number;
staged: Array<{ status: string; path: string }>;
unstaged: Array<{ status: string; path: string }>;
untracked: Array<{ path: string }>;
remoteUrl: string;
lastFetchAt: number | null;
}
export interface GitCommit {
sha: string;
shortSha: string;
subject: string;
authorName: string;
relativeTime: string;
isoTime: string;
}
export interface GitCommitDetail extends GitCommit {
body: string;
stat: string;
}
export interface GitBranch {
name: string;
shortSha: string;
relativeTime: string;
isCurrent: boolean;
upstreamTrack: string;
}
export interface GitWorktree {
path: string;
branch: string;
sha: string;
isCurrent: boolean;
isLocked: boolean;
}
export interface InteractiveData {
listProjects: () => Promise<ProjectItem[]>;
listTasks: (projectPath: string) => Promise<TaskItem[]>;
@@ -135,6 +179,15 @@ export interface InteractiveData {
getSettings: () => Promise<SettingsValues>;
updateSettings: (partial: Partial<SettingsValues>) => Promise<void>;
listModels: () => ModelItem[];
git: {
getStatus: (projectPath: string) => Promise<GitStatus>;
listCommits: (projectPath: string, limit?: number) => Promise<GitCommit[]>;
showCommit: (projectPath: string, sha: string) => Promise<GitCommitDetail>;
listBranches: (projectPath: string) => Promise<GitBranch[]>;
listWorktrees: (projectPath: string) => Promise<GitWorktree[]>;
push: (projectPath: string) => Promise<{ success: boolean; output: string }>;
fetch: (projectPath: string) => Promise<{ success: boolean; output: string }>;
};
}
// ── Dashboard state (mutable, shared between controller and App) ───────────────

View File

@@ -1,5 +1,8 @@
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import { execFile as execFileCb } from "node:child_process";
import { promisify } from "node:util";
import { stat } from "node:fs/promises";
import {
TaskStore,
AutomationStore,
@@ -42,7 +45,7 @@ import {
resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo } from "./dashboard-tui/index.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree } from "./dashboard-tui/index.js";
// Re-export for backward compatibility with tests
export { promptForPort };
@@ -311,6 +314,188 @@ function setDiagnosticStoreListenerCheck(check: () => Record<string, number>): v
diagnosticStoreListenerCheck = check;
}
const execFileAsync = promisify(execFileCb);
async function gitExec(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 4 * 1024 * 1024 });
return stdout;
}
async function buildGitStatus(projectPath: string): Promise<GitStatus> {
const [sbOut, remoteOut] = await Promise.allSettled([
gitExec(projectPath, ["status", "-sb", "--porcelain=v1"]),
gitExec(projectPath, ["remote", "get-url", "origin"]),
]);
const sbRaw = sbOut.status === "fulfilled" ? sbOut.value : "";
const remoteUrl = remoteOut.status === "fulfilled" ? remoteOut.value.trim() : "";
const lines = sbRaw.split("\n");
const header = lines[0] ?? "";
let branch = "HEAD";
let detached = false;
let ahead = 0;
let behind = 0;
const noCommitMatch = header.match(/^## No commits yet on (.+)$/);
if (noCommitMatch) {
branch = noCommitMatch[1] ?? "HEAD";
} else {
const branchMatch = header.match(/^## ([^.]+?)(?:\.\.\.(\S+?)(?:\s+\[ahead (\d+)(?:, behind (\d+))?\]|\s+\[behind (\d+)\])?)?$/);
if (branchMatch) {
branch = branchMatch[1] ?? "HEAD";
ahead = parseInt(branchMatch[3] ?? "0", 10);
behind = parseInt(branchMatch[4] ?? branchMatch[5] ?? "0", 10);
} else if (header.startsWith("## HEAD (no branch)")) {
detached = true;
branch = "HEAD";
}
}
const staged: GitStatus["staged"] = [];
const unstaged: GitStatus["unstaged"] = [];
const untracked: GitStatus["untracked"] = [];
for (const line of lines.slice(1)) {
if (line.length < 3) continue;
const x = line[0] ?? " ";
const y = line[1] ?? " ";
const path = line.slice(3);
if (x === "?" && y === "?") {
untracked.push({ path });
} else {
if (x !== " " && x !== "?") staged.push({ status: x, path });
if (y !== " " && y !== "?") unstaged.push({ status: y, path });
}
}
let lastFetchAt: number | null = null;
try {
const fetchHead = await stat(`${projectPath}/.git/FETCH_HEAD`);
lastFetchAt = fetchHead.mtimeMs;
} catch {
// no fetch head yet
}
return { branch, detached, ahead, behind, staged, unstaged, untracked, remoteUrl, lastFetchAt };
}
async function buildGitCommits(projectPath: string, limit = 15): Promise<GitCommit[]> {
const sep = "\x1f";
const recSep = "\x1e";
const fmt = [`%H`, `%h`, `%s`, `%an`, `%ar`, `%aI`].join(sep);
let out = "";
try {
out = await gitExec(projectPath, ["log", `--max-count=${limit}`, `--format=${fmt}${recSep}`]);
} catch {
return [];
}
return out.split(recSep).flatMap((rec) => {
const parts = rec.trim().split(sep);
if (parts.length < 6 || !parts[0]) return [];
return [{
sha: parts[0] ?? "",
shortSha: parts[1] ?? "",
subject: parts[2] ?? "",
authorName: parts[3] ?? "",
relativeTime: parts[4] ?? "",
isoTime: parts[5] ?? "",
}];
});
}
async function buildGitCommitDetail(projectPath: string, sha: string): Promise<GitCommitDetail> {
const sep = "\x1f";
const fmt = [`%H`, `%h`, `%s`, `%an`, `%ar`, `%aI`, `%b`].join(sep);
const [showOut, statOut] = await Promise.allSettled([
gitExec(projectPath, ["show", `--format=${fmt}`, "--no-patch", sha]),
gitExec(projectPath, ["show", "--stat", "--format=", sha]),
]);
const raw = showOut.status === "fulfilled" ? showOut.value.trim() : "";
const parts = raw.split(sep);
return {
sha: parts[0] ?? sha,
shortSha: parts[1] ?? sha.slice(0, 7),
subject: parts[2] ?? "",
authorName: parts[3] ?? "",
relativeTime: parts[4] ?? "",
isoTime: parts[5] ?? "",
body: (parts[6] ?? "").trim(),
stat: statOut.status === "fulfilled" ? statOut.value.trim() : "",
};
}
async function buildGitBranches(projectPath: string): Promise<GitBranch[]> {
let out = "";
try {
out = await gitExec(projectPath, [
"for-each-ref",
"--sort=-committerdate",
"refs/heads",
"--format=%(refname:short)|%(objectname:short)|%(committerdate:relative)|%(upstream:track)|%(HEAD)",
]);
} catch {
return [];
}
return out.trim().split("\n").flatMap((line) => {
if (!line) return [];
const parts = line.split("|");
return [{
name: parts[0] ?? "",
shortSha: parts[1] ?? "",
relativeTime: parts[2] ?? "",
upstreamTrack: parts[3] ?? "",
isCurrent: (parts[4] ?? "") === "*",
}];
});
}
async function buildGitWorktrees(projectPath: string): Promise<GitWorktree[]> {
let out = "";
try {
out = await gitExec(projectPath, ["worktree", "list", "--porcelain"]);
} catch {
return [];
}
const worktrees: GitWorktree[] = [];
let current: Partial<GitWorktree> & { rawPath?: string } = {};
let isFirst = true;
for (const line of out.split("\n")) {
if (line.startsWith("worktree ")) {
if (current.rawPath) {
worktrees.push({
path: current.rawPath,
branch: current.branch ?? "HEAD",
sha: current.sha ?? "",
isCurrent: current.isCurrent ?? false,
isLocked: current.isLocked ?? false,
});
}
current = { rawPath: line.slice(9), isCurrent: isFirst };
isFirst = false;
} else if (line.startsWith("HEAD ")) {
current.sha = line.slice(5);
} else if (line.startsWith("branch ")) {
current.branch = line.slice(7).replace("refs/heads/", "");
} else if (line === "locked") {
current.isLocked = true;
} else if (line.startsWith("locked ")) {
current.isLocked = true;
}
}
if (current.rawPath) {
worktrees.push({
path: current.rawPath,
branch: current.branch ?? "HEAD",
sha: current.sha ?? "",
isCurrent: current.isCurrent ?? false,
isLocked: current.isLocked ?? false,
});
}
return worktrees;
}
async function resolveRuntimeProjectPath(): Promise<string> {
try {
return (await resolveProject(undefined)).projectPath;
@@ -511,8 +696,22 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
let tuiRefreshPending = false;
let tuiRefreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Per-project task stores for the BoardView's scoped stats. Shared with the
// interactiveData wiring below so we don't re-init SQLite on each refresh.
const projectStores = new Map<string, TaskStore>();
async function getProjectStore(projectPath: string): Promise<TaskStore> {
const cached = projectStores.get(projectPath);
if (cached) return cached;
const projectStore = projectPath === cwd ? store : new TaskStore(projectPath);
if (projectPath !== cwd) await projectStore.init();
projectStores.set(projectPath, projectStore);
return projectStore;
}
/**
* Debounced refresh of TUI stats - batches rapid task updates
* Debounced refresh of TUI stats - batches rapid task updates.
* If the BoardView has a scoped project path set on the controller,
* read tasks from that project's store instead of the launch cwd.
*/
async function refreshTUIStats(): Promise<void> {
if (!tui || !isTTY) return;
@@ -523,7 +722,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
tuiRefreshPending = true;
try {
const tasks = await store.listTasks({ slim: true, includeArchived: false });
const scopedPath = tui.boardScopedProjectPath;
const taskStore = scopedPath ? await getProjectStore(scopedPath) : store;
const tasks = await taskStore.listTasks({ slim: true, includeArchived: false });
const counts = new Map<string, number>();
for (const task of tasks) {
counts.set(task.column, (counts.get(task.column) ?? 0) + 1);
@@ -585,6 +786,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}, 500); // 500ms debounce
}
// Refresh stats immediately when the BoardView changes its selected project
// (so the Stats panel reflects the new project without waiting for an event).
if (tui) {
tui.onBoardScopeChange(() => {
void refreshTUIStats();
});
}
const handlers: Array<{
target: NodeJS.EventEmitter;
event: string | symbol;
@@ -1524,19 +1733,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// are cached so repeated panel switches don't re-init SQLite.
if (centralCoreForMesh) {
const centralCore = centralCoreForMesh;
const projectStores = new Map<string, TaskStore>();
tui.setInteractiveData({
listProjects: async () => {
const projects = await centralCore.listProjects();
return projects.map((p) => ({ id: p.id, name: p.name, path: p.path }));
},
listTasks: async (projectPath: string) => {
let projectStore = projectStores.get(projectPath);
if (!projectStore) {
projectStore = projectPath === cwd ? store : new TaskStore(projectPath);
if (projectPath !== cwd) await projectStore.init();
projectStores.set(projectPath, projectStore);
}
const projectStore = await getProjectStore(projectPath);
const tasks = await projectStore.listTasks({ slim: true, includeArchived: false });
return tasks.map((t) => ({
id: t.id,
@@ -1547,12 +1750,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}));
},
createTask: async (projectPath: string, input: { title: string; description?: string }) => {
let projectStore = projectStores.get(projectPath);
if (!projectStore) {
projectStore = projectPath === cwd ? store : new TaskStore(projectPath);
if (projectPath !== cwd) await projectStore.init();
projectStores.set(projectPath, projectStore);
}
const projectStore = await getProjectStore(projectPath);
const created = await projectStore.createTask({
title: input.title,
description: input.description ?? input.title,
@@ -1636,6 +1834,31 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
contextWindow: m.contextWindow ?? 0,
}));
},
git: {
getStatus: (projectPath: string) => buildGitStatus(projectPath),
listCommits: (projectPath: string, limit?: number) => buildGitCommits(projectPath, limit),
showCommit: (projectPath: string, sha: string) => buildGitCommitDetail(projectPath, sha),
listBranches: (projectPath: string) => buildGitBranches(projectPath),
listWorktrees: (projectPath: string) => buildGitWorktrees(projectPath),
push: async (projectPath: string) => {
try {
const { stdout, stderr } = await execFileAsync("git", ["push"], { cwd: projectPath, maxBuffer: 4 * 1024 * 1024 });
return { success: true, output: (stdout + stderr).trim() };
} catch (err) {
const msg = err instanceof Error ? (err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }).stderr ?? err.message : String(err);
return { success: false, output: msg.trim() };
}
},
fetch: async (projectPath: string) => {
try {
const { stdout, stderr } = await execFileAsync("git", ["fetch"], { cwd: projectPath, maxBuffer: 4 * 1024 * 1024 });
return { success: true, output: (stdout + stderr).trim() };
} catch (err) {
const msg = err instanceof Error ? (err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }).stderr ?? err.message : String(err);
return { success: false, output: msg.trim() };
}
},
},
});
}