When the dashboard TUI collapses to single-pane mode (narrow width or short terminal), show a horizontal log strip at the bottom of the screen while the active section renders on top. The split is content-driven: the top pane gets exactly the rows it needs to render its section without truncating (computed from SystemPanel's chip wrap at the current width, or each panel's known row count for Stats/Utilities/Settings), and the log strip absorbs every remaining row to maximize log visibility. Down-arrow shifts sub-focus into the log strip with the same key bindings as the dedicated Logs section (j/k, Home/G, Enter to expand, w to wrap, c to copy, f to filter, mouse wheel). Up-arrow at the top of the strip or Esc returns focus to the main pane. Right/Left/Tab continue to cycle sections, so the dedicated full-screen Logs view is unchanged. The split auto-disables if the log strip would get fewer than 6 rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
import type { LogEntry } from "./log-ring-buffer.js";
|
|
|
|
// ── Public types shared across the whole dashboard-tui module ─────────────────
|
|
|
|
export type { LogEntry };
|
|
|
|
export type SectionId = "logs" | "system" | "utilities" | "stats" | "settings";
|
|
|
|
export type AppMode = "status" | "interactive";
|
|
|
|
export type InteractiveView = "board" | "agents" | "settings" | "git" | "files";
|
|
|
|
export interface SystemInfo {
|
|
host: string;
|
|
port: number;
|
|
baseUrl: string;
|
|
authEnabled: boolean;
|
|
authToken?: string;
|
|
tokenizedUrl?: string;
|
|
engineMode: "dev" | "active" | "paused";
|
|
fileWatcher: boolean;
|
|
startTimeMs: number;
|
|
}
|
|
|
|
export interface TaskStats {
|
|
total: number;
|
|
byColumn: Record<string, number>;
|
|
active: number;
|
|
agents: {
|
|
idle: number;
|
|
active: number;
|
|
running: number;
|
|
error: number;
|
|
};
|
|
}
|
|
|
|
export interface SystemStats {
|
|
rss: number;
|
|
heapUsed: number;
|
|
heapTotal: number;
|
|
heapLimit: number;
|
|
external: number;
|
|
arrayBuffers: number;
|
|
cpuPercent: number;
|
|
loadAvg: [number, number, number];
|
|
cpuCount: number;
|
|
systemTotalMem: number;
|
|
systemFreeMem: number;
|
|
pid: number;
|
|
nodeVersion: string;
|
|
platform: string;
|
|
}
|
|
|
|
export type RemoteProvider = "tailscale" | "cloudflare";
|
|
|
|
export interface RemoteStatus {
|
|
provider: RemoteProvider | null;
|
|
state: "stopped" | "starting" | "running" | "error";
|
|
url: string | null;
|
|
lastError: string | null;
|
|
}
|
|
|
|
export interface RemoteTokenResult {
|
|
token?: string;
|
|
maskedToken?: string;
|
|
tokenType: "persistent" | "short-lived";
|
|
expiresAt: string | null;
|
|
}
|
|
|
|
export interface RemoteQrPayload {
|
|
url: string;
|
|
expiresAt: string | null;
|
|
format: "text" | "image/svg" | "terminal";
|
|
data?: string;
|
|
}
|
|
|
|
export interface RemoteSettingsSnapshot {
|
|
activeProvider: RemoteProvider | null;
|
|
tailscaleEnabled: boolean;
|
|
cloudflareEnabled: boolean;
|
|
shortLivedEnabled: boolean;
|
|
shortLivedTtlMs: number;
|
|
}
|
|
|
|
export interface SettingsValues {
|
|
maxConcurrent: number;
|
|
maxWorktrees: number;
|
|
autoMerge: boolean;
|
|
mergeStrategy: string;
|
|
pollIntervalMs: number;
|
|
enginePaused: boolean;
|
|
globalPause: boolean;
|
|
remoteActiveProvider: RemoteProvider | null;
|
|
remoteShortLivedEnabled: boolean;
|
|
remoteShortLivedTtlMs: number;
|
|
remoteSettingsSnapshot?: RemoteSettingsSnapshot;
|
|
remoteStatus?: RemoteStatus;
|
|
}
|
|
|
|
export interface UtilityAction {
|
|
id: string;
|
|
label: string;
|
|
key: string;
|
|
description: string;
|
|
}
|
|
|
|
export interface TUICallbacks {
|
|
onRefreshStats: () => Promise<void>;
|
|
onClearLogs: () => void;
|
|
onTogglePause: (paused: boolean) => Promise<SettingsValues>;
|
|
/** Persist vitest memory-guard settings to global settings so they
|
|
* survive across dashboard restarts. Optional — when undefined, the
|
|
* controller treats them as session-local. */
|
|
onPersistVitestKillSettings?: (
|
|
partial: { enabled?: boolean; thresholdPct?: number },
|
|
) => Promise<void>;
|
|
}
|
|
|
|
// Slim project shape used by interactive mode
|
|
export interface ProjectItem {
|
|
id: string;
|
|
name: string;
|
|
path: string;
|
|
}
|
|
|
|
// Slim task shape used by interactive mode
|
|
export interface TaskItem {
|
|
id: string;
|
|
title?: string;
|
|
description: string;
|
|
column: string;
|
|
agentState?: string;
|
|
}
|
|
|
|
// Slim agent shape for Agents view list
|
|
export interface AgentItem {
|
|
id: string;
|
|
name: string;
|
|
state: string;
|
|
role: string;
|
|
taskId?: string;
|
|
lastHeartbeatAt?: string;
|
|
}
|
|
|
|
// Slim heartbeat run for agent detail
|
|
export interface AgentRunItem {
|
|
id: string;
|
|
startedAt: string;
|
|
endedAt: string | null;
|
|
status: string;
|
|
triggerDetail?: string;
|
|
invocationSource?: string;
|
|
stdoutExcerpt?: string;
|
|
stderrExcerpt?: string;
|
|
resultJson?: Record<string, unknown>;
|
|
// Optional synthetic log lines for tests / alternate data providers.
|
|
logs?: string[];
|
|
}
|
|
|
|
// Slim agent detail shape for Agents view detail panel
|
|
export interface AgentDetailItem extends AgentItem {
|
|
title?: string;
|
|
capabilities: string[];
|
|
recentRuns: AgentRunItem[];
|
|
}
|
|
|
|
// Slim model shape for Settings view models subsection
|
|
export interface ModelItem {
|
|
id: string;
|
|
name: string;
|
|
provider: string;
|
|
contextWindow: number;
|
|
}
|
|
|
|
// ── File explorer types ───────────────────────────────────────────────────────
|
|
|
|
export interface FileEntry {
|
|
name: string;
|
|
path: string; // relative to project root
|
|
isDirectory: boolean;
|
|
size: number; // bytes; 0 for dirs
|
|
modifiedAt: string; // ISO
|
|
}
|
|
|
|
export interface FileReadResult {
|
|
content: string | null; // null if binary or too-large
|
|
isBinary: boolean;
|
|
tooLarge: boolean;
|
|
size: number;
|
|
modifiedAt: string;
|
|
lineCount: number;
|
|
}
|
|
|
|
// ── Task detail + streaming types ────────────────────────────────────────────
|
|
|
|
export interface TaskStep {
|
|
index: number;
|
|
name: string;
|
|
status: "pending" | "running" | "done" | "skipped" | "failed";
|
|
startedAt?: string;
|
|
endedAt?: string;
|
|
}
|
|
|
|
export interface TaskLogEntry {
|
|
timestamp: string; // ISO-8601
|
|
level: "info" | "warn" | "error" | "debug";
|
|
text: string;
|
|
source?: string; // e.g. "executor" / "agent" / step name
|
|
}
|
|
|
|
export interface TaskDetailData {
|
|
id: string;
|
|
title?: string;
|
|
description: string;
|
|
column: string;
|
|
agentState?: string;
|
|
branch?: string;
|
|
worktree?: string;
|
|
currentStepIndex?: number;
|
|
steps: TaskStep[];
|
|
recentLogs: TaskLogEntry[]; // last ~200 entries on initial load
|
|
}
|
|
|
|
export type TaskEvent =
|
|
| { kind: "step:updated"; step: TaskStep }
|
|
| { kind: "log:appended"; entry: TaskLogEntry }
|
|
| { kind: "task:updated"; task: TaskDetailData };
|
|
|
|
// ── 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[]>;
|
|
createTask: (projectPath: string, input: { title: string; description?: string }) => Promise<TaskItem>;
|
|
listAgents: () => Promise<AgentItem[]>;
|
|
getAgentDetail: (id: string) => Promise<AgentDetailItem | null>;
|
|
updateAgentState: (id: string, state: string) => Promise<void>;
|
|
deleteAgent: (id: string) => Promise<void>;
|
|
getSettings: () => Promise<SettingsValues>;
|
|
updateSettings: (partial: Partial<SettingsValues>) => Promise<void>;
|
|
listModels: () => ModelItem[];
|
|
remote: {
|
|
getSettings: () => Promise<RemoteSettingsSnapshot>;
|
|
getStatus: () => Promise<RemoteStatus>;
|
|
activateProvider: (provider: RemoteProvider) => Promise<void>;
|
|
startTunnel: () => Promise<void>;
|
|
stopTunnel: () => Promise<void>;
|
|
regeneratePersistentToken: () => Promise<RemoteTokenResult>;
|
|
generateShortLivedToken: (ttlMs: number) => Promise<RemoteTokenResult>;
|
|
getRemoteUrl: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
|
|
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number, format?: "text" | "terminal" | "image/svg") => Promise<RemoteQrPayload>;
|
|
};
|
|
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 }>;
|
|
};
|
|
files: {
|
|
listDirectory: (projectPath: string, relativePath: string) => Promise<FileEntry[]>;
|
|
readFile: (projectPath: string, relativePath: string) => Promise<FileReadResult>;
|
|
};
|
|
tasks: {
|
|
// Initial fetch when the detail screen mounts — includes steps + recent logs.
|
|
getTaskDetail: (projectPath: string, taskId: string) => Promise<TaskDetailData | null>;
|
|
// Subscribe to live step-change and log-append events for a single task.
|
|
// Returns an unsubscribe function.
|
|
subscribeTaskEvents: (
|
|
projectPath: string,
|
|
taskId: string,
|
|
handler: (event: TaskEvent) => void,
|
|
) => () => void;
|
|
};
|
|
}
|
|
|
|
// ── Update check status (surfaced in the TUI header/splash) ──────────────────
|
|
|
|
export interface UpdateStatus {
|
|
updateAvailable: boolean;
|
|
currentVersion: string;
|
|
latestVersion: string;
|
|
}
|
|
|
|
// ── Dashboard state (mutable, shared between controller and App) ───────────────
|
|
|
|
export interface DashboardState {
|
|
activeSection: SectionId;
|
|
logEntries: LogEntry[];
|
|
systemInfo: SystemInfo | null;
|
|
taskStats: TaskStats | null;
|
|
systemStats: SystemStats | null;
|
|
settings: SettingsValues | null;
|
|
callbacks: TUICallbacks | null;
|
|
showHelp: boolean;
|
|
logsSeverityFilter: "all" | LogEntry["level"];
|
|
logsWrapEnabled: boolean;
|
|
logsExpandedMode: boolean;
|
|
selectedLogIndex: number;
|
|
logsViewportStart: number;
|
|
// When the narrow single-pane main view is split horizontally to show a
|
|
// log strip at the bottom, this flag indicates whether the bottom log
|
|
// pane has key focus (vs. the top main panel).
|
|
narrowLogSplitFocused: boolean;
|
|
loadingStatus: string;
|
|
mode: AppMode;
|
|
interactiveData: InteractiveData | null;
|
|
interactiveView: InteractiveView;
|
|
interactiveInputLocked: boolean;
|
|
autoKillVitestOnPressure: boolean;
|
|
vitestKillThreshold: number;
|
|
updateStatus: UpdateStatus | null;
|
|
// Transient flash shown after the user copies a log entry. `at` is a
|
|
// monotonic timestamp so the view can render "Copied!" briefly before the
|
|
// controller clears it via setTimeout.
|
|
clipboardFlash: { ok: boolean; at: number } | null;
|
|
// Latest remote tunnel status, polled by the controller while
|
|
// `interactiveData.remote` is available. Used to surface tunnel state
|
|
// (state/url) globally in the TUI header.
|
|
remoteStatus: RemoteStatus | null;
|
|
// Whether xterm mouse reporting is currently enabled. When true, the
|
|
// controller decodes wheel events into log/list scrolling. When false,
|
|
// the terminal owns the mouse — needed for native click-drag selection
|
|
// under tmux, where Shift-bypass is intercepted by tmux itself.
|
|
mouseEnabled: boolean;
|
|
}
|
|
|
|
// Order matches the visual layout in StatusModeGrid: System (top), Logs
|
|
// (middle), then the bottom row left-to-right (Stats, Utilities, Settings).
|
|
// Both Tab/Shift+Tab (PANEL_ORDER in app.tsx) and ←/→ (cycleSection) use
|
|
// this same order so panel navigation matches what the user sees.
|
|
export const SECTION_ORDER: SectionId[] = ["system", "logs", "stats", "utilities", "settings"];
|
|
|
|
export function createInitialState(): DashboardState {
|
|
return {
|
|
activeSection: "system",
|
|
logEntries: [],
|
|
systemInfo: null,
|
|
taskStats: null,
|
|
systemStats: null,
|
|
settings: null,
|
|
callbacks: null,
|
|
showHelp: false,
|
|
logsSeverityFilter: "all",
|
|
logsWrapEnabled: false,
|
|
logsExpandedMode: false,
|
|
selectedLogIndex: 0,
|
|
logsViewportStart: 0,
|
|
narrowLogSplitFocused: false,
|
|
loadingStatus: "Starting…",
|
|
mode: "status",
|
|
interactiveData: null,
|
|
interactiveView: "board",
|
|
interactiveInputLocked: false,
|
|
autoKillVitestOnPressure: true,
|
|
vitestKillThreshold: 0.9,
|
|
updateStatus: null,
|
|
clipboardFlash: null,
|
|
remoteStatus: null,
|
|
mouseEnabled: false,
|
|
};
|
|
}
|