Compare commits
10 Commits
c722112a7f
...
ccfae53233
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccfae53233 | ||
|
|
1a99a83fd7 | ||
|
|
24a20d4fe5 | ||
|
|
f821010dc9 | ||
|
|
a2a9283451 | ||
|
|
e0f1a08098 | ||
|
|
9cf18f5740 | ||
|
|
059004b6f2 | ||
|
|
c2e8fcaa94 | ||
|
|
1e105cd950 |
@@ -14,9 +14,9 @@ const pixel = Silkscreen({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "cladm — Monitor & launch Claude Code sessions",
|
||||
title: "cladm — Claude Code Command Center",
|
||||
description:
|
||||
"Multi-project Claude Code session monitor. Track busy/idle status in real time, see usage costs, get notified when Claude finishes, and launch everything in parallel.",
|
||||
"Multiproject workspace for Claude Code. Embedded terminal grid with tabbed workspaces, pane controls, real-time status tracking, usage monitoring, and full keyboard-driven workflow.",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico", sizes: "32x32" },
|
||||
@@ -26,8 +26,8 @@ export const metadata: Metadata = {
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
openGraph: {
|
||||
title: "cladm",
|
||||
description: "Monitor & launch Claude Code sessions across all your projects",
|
||||
title: "cladm — Claude Code Command Center",
|
||||
description: "Manage all your Claude Code sessions from one terminal. Embedded PTY grid, tabbed workspaces, live monitoring, and pane controls.",
|
||||
url: "https://claudm.com",
|
||||
siteName: "cladm",
|
||||
type: "website",
|
||||
@@ -36,14 +36,14 @@ export const metadata: Metadata = {
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "cladm — Monitor & launch Claude Code sessions",
|
||||
alt: "cladm — Claude Code Command Center",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "cladm",
|
||||
description: "Monitor & launch Claude Code sessions across all your projects",
|
||||
title: "cladm — Claude Code Command Center",
|
||||
description: "Manage all your Claude Code sessions from one terminal. Embedded PTY grid, tabbed workspaces, live monitoring, and pane controls.",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
NetworkIcon,
|
||||
GamepadIcon,
|
||||
BlocksIcon,
|
||||
ArrowRightIcon,
|
||||
ExternalLinkIcon,
|
||||
LinkedinIcon,
|
||||
MailIcon,
|
||||
SpaceInvadersIcon,
|
||||
EyeIcon,
|
||||
BellIcon,
|
||||
@@ -90,13 +87,56 @@ function FeatureBlock({
|
||||
);
|
||||
}
|
||||
|
||||
function GridPaneMockup({
|
||||
name,
|
||||
status,
|
||||
elapsed,
|
||||
children,
|
||||
focused,
|
||||
}: {
|
||||
name: string;
|
||||
status: "busy" | "idle";
|
||||
elapsed?: string;
|
||||
children: React.ReactNode;
|
||||
focused?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`bg-bg border ${focused ? "border-accent" : "border-border"}`}>
|
||||
{/* Pane title bar */}
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-surface-2/60">
|
||||
<div className="font-[family-name:var(--font-mono)] text-[10px] flex items-center gap-1.5">
|
||||
{status === "busy" ? (
|
||||
<span className="text-green">●</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-yellow">◉</span>
|
||||
{elapsed && <span className="text-dim">{elapsed}</span>}
|
||||
</>
|
||||
)}
|
||||
<span className="text-text">{name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-cyan text-[8px]">●</span>
|
||||
<span className="text-dim text-[8px]">─</span>
|
||||
<span className="text-[#27c93f] text-[8px]">●</span>
|
||||
<span className="text-[#ff5f56] text-[8px]">●</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Pane content */}
|
||||
<div className="p-3 font-[family-name:var(--font-mono)] text-[10px] text-dim leading-relaxed">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="min-h-screen bg-bg selection:bg-accent/30">
|
||||
<SubscribeModal />
|
||||
|
||||
{/* ══════ HERO ══════ */}
|
||||
<section className="relative overflow-hidden scanlines">
|
||||
{/* Grid background */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.04]"
|
||||
style={{
|
||||
@@ -142,14 +182,13 @@ export default function Home() {
|
||||
</h1>
|
||||
|
||||
<p className="font-[family-name:var(--font-pixel)] text-accent text-lg md:text-xl mb-5">
|
||||
MULTI-PROJECT CLAUDE CODE MONITOR
|
||||
CLAUDE CODE COMMAND CENTER
|
||||
</p>
|
||||
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-sm max-w-md leading-relaxed mb-8">
|
||||
Track all your Claude Code sessions in one place. See
|
||||
busy/idle status in real time, monitor usage costs, get
|
||||
notified when Claude finishes, and launch everything in
|
||||
parallel Terminal windows.
|
||||
Manage all your Claude Code sessions from one terminal.
|
||||
An embedded PTY grid with tabbed workspaces, pane controls,
|
||||
real-time status tracking, and full keyboard-driven workflow.
|
||||
</p>
|
||||
|
||||
{/* Install command */}
|
||||
@@ -169,7 +208,7 @@ export default function Home() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right — terminal cascade */}
|
||||
{/* Right — terminal cascade: picker → grid */}
|
||||
<div className="flex-1 w-full max-w-xl">
|
||||
<TerminalCascade />
|
||||
</div>
|
||||
@@ -186,50 +225,185 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ══════ DEMO GIF ══════ */}
|
||||
{/* ══════ THE WORKSPACE ══════ */}
|
||||
<section className="max-w-5xl mx-auto px-6 py-20">
|
||||
<div className="text-center mb-10">
|
||||
<div className="text-center mb-4">
|
||||
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-3">
|
||||
// SEE IT IN ACTION
|
||||
// THE WORKSPACE
|
||||
</h2>
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs max-w-2xl mx-auto leading-relaxed">
|
||||
Every Claude Code session runs in an embedded terminal pane — no separate windows.
|
||||
See all your projects at once, switch focus with a click, and never lose track of what Claude is doing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TerminalWindow title="cladm">
|
||||
<Image
|
||||
src="/demo.gif"
|
||||
alt="cladm demo showing project navigation"
|
||||
width={980}
|
||||
height={500}
|
||||
className="w-full"
|
||||
unoptimized
|
||||
/>
|
||||
</TerminalWindow>
|
||||
{/* Grid workspace mockup */}
|
||||
<div className="mt-10">
|
||||
<div className="pixel-border bg-surface overflow-hidden">
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center bg-surface-2 border-b-2 border-border">
|
||||
<div className="px-4 py-2 border-b-2 border-accent font-[family-name:var(--font-mono)] text-xs">
|
||||
<span className="text-green">●</span>
|
||||
<span className="text-text"> acme-api</span>
|
||||
<span className="text-dim"> · </span>
|
||||
<span className="text-yellow">◉</span>
|
||||
<span className="text-text"> quantum-dash</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 font-[family-name:var(--font-mono)] text-xs text-dim border-b-2 border-transparent">
|
||||
<span className="text-green">●</span>
|
||||
<span> ml-pipeline</span>
|
||||
<span className="text-dim"> · </span>
|
||||
<span className="text-green">●</span>
|
||||
<span> infra-k8s</span>
|
||||
</div>
|
||||
<div className="ml-auto px-3 py-2 font-[family-name:var(--font-mono)] text-[10px] text-dim">
|
||||
<span className="text-accent">+</span> add pane
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pane grid */}
|
||||
<div className="grid grid-cols-2 gap-[2px] p-[2px]">
|
||||
{/* Pane 1: acme-api */}
|
||||
<GridPaneMockup name="acme-api" status="busy" focused>
|
||||
<div className="text-green mb-1">> I'll analyze the authentication module and</div>
|
||||
<div className="text-green">{" "}fix the token refresh bug you mentioned.</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-accent">⏺</span> Reading src/auth/token.ts
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-accent">⏺</span> Reading src/auth/middleware.ts
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-accent">⏺</span> Grep: refreshToken pattern
|
||||
</div>
|
||||
<div className="text-green mt-1">
|
||||
Found 3 files with stale token logic.
|
||||
<span className="cursor-blink text-accent">_</span>
|
||||
</div>
|
||||
</GridPaneMockup>
|
||||
|
||||
{/* Pane 2: quantum-dash */}
|
||||
<GridPaneMockup name="quantum-dash" status="idle" elapsed="4m">
|
||||
<div className="text-text">I've updated the chart component to use</div>
|
||||
<div className="text-text">the new streaming data format. Changes:</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-green">✓</span> src/components/chart.tsx
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-green">✓</span> src/hooks/useChartData.ts
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-green">✓</span> src/types/stream.d.ts
|
||||
</div>
|
||||
<div className="mt-2 text-yellow">Waiting for your input...</div>
|
||||
</GridPaneMockup>
|
||||
|
||||
{/* Pane 3: ml-pipeline */}
|
||||
<GridPaneMockup name="ml-pipeline" status="busy">
|
||||
<div className="text-green">> Building the BERT fine-tuning pipeline</div>
|
||||
<div className="text-green">{" "}with the new training dataset.</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-accent">⏺</span> Writing src/train.py
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
Processing: epoch 3/10{" "}
|
||||
<span className="text-accent">████████</span>
|
||||
<span className="text-border">░░░░░░░░░░░░</span>{" "}
|
||||
<span className="text-text">30%</span>
|
||||
</div>
|
||||
</GridPaneMockup>
|
||||
|
||||
{/* Pane 4: infra-k8s */}
|
||||
<GridPaneMockup name="infra-k8s" status="busy">
|
||||
<div className="text-green">> Updating the Kubernetes deployment</div>
|
||||
<div className="text-green">{" "}manifests for staging.</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-accent">⏺</span> Reading k8s/staging/deployment.yaml
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-accent">⏺</span> Reading k8s/staging/service.yaml
|
||||
</div>
|
||||
<div className="mt-1 text-green">
|
||||
Scaling replicas 2 → 4 for load test
|
||||
<span className="cursor-blink text-accent">_</span>
|
||||
</div>
|
||||
</GridPaneMockup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature callouts */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-10">
|
||||
<div className="text-center">
|
||||
<div className="font-[family-name:var(--font-pixel)] text-accent text-xs uppercase tracking-wider mb-2">
|
||||
Embedded PTY Grid
|
||||
</div>
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] leading-relaxed">
|
||||
Each pane runs a real pseudo-terminal via forkpty(). Full I/O, ANSI colors, resize — no tmux needed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-[family-name:var(--font-pixel)] text-accent text-xs uppercase tracking-wider mb-2">
|
||||
Tabbed Workspaces
|
||||
</div>
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] leading-relaxed">
|
||||
Group sessions into tabs. Inline pane names with status icons show what's running at a glance.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-[family-name:var(--font-pixel)] text-accent text-xs uppercase tracking-wider mb-2">
|
||||
Pane Controls
|
||||
</div>
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] leading-relaxed">
|
||||
Traffic-light buttons on every pane: close, expand, minimize, plus a folder-open button. Fully mouse-driven.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PixelDivider />
|
||||
|
||||
{/* ══════ SCREENSHOTS ══════ */}
|
||||
{/* ══════ SMART PICKER ══════ */}
|
||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
||||
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
|
||||
// SCREENSHOTS
|
||||
</h2>
|
||||
<div className="text-center mb-4">
|
||||
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-3">
|
||||
// THE SMART PICKER
|
||||
</h2>
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs max-w-2xl mx-auto leading-relaxed">
|
||||
It starts with a smart project picker. cladm reads{" "}
|
||||
<code className="text-accent">~/.claude/history.jsonl</code> to discover every project
|
||||
you've used with Claude Code — git branch, sync status, dirty state, session history, stack detection — all loaded in parallel.
|
||||
Select what you need, hit Enter, and the grid workspace takes over.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-16">
|
||||
{/* Main view */}
|
||||
<div className="mt-8">
|
||||
<TerminalWindow title="cladm — 8 projects">
|
||||
<Image
|
||||
src="/demo.gif"
|
||||
alt="cladm smart picker showing project navigation and selection"
|
||||
width={980}
|
||||
height={500}
|
||||
className="w-full"
|
||||
unoptimized
|
||||
/>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
|
||||
{/* Picker screenshots */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-12">
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div className="h-[2px] flex-1 bg-border" />
|
||||
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider whitespace-nowrap">
|
||||
PROJECT LIST
|
||||
</h3>
|
||||
<div className="h-[2px] flex-1 bg-border" />
|
||||
</div>
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-6">
|
||||
All your projects sorted by recent Claude usage. Git branch, sync
|
||||
status, dirty state, session count, and auto-detected stack at a
|
||||
glance.
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] text-center mb-4">
|
||||
Sorted by recent Claude usage. Git metadata, session count, and stack tags at a glance.
|
||||
</p>
|
||||
<TerminalWindow title="cladm — 8 projects">
|
||||
<TerminalWindow title="cladm — project list">
|
||||
<Image
|
||||
src="/screenshot-main.png"
|
||||
alt="cladm main project list view"
|
||||
@@ -240,25 +414,21 @@ export default function Home() {
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
|
||||
{/* Expanded view */}
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div className="h-[2px] flex-1 bg-border" />
|
||||
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider whitespace-nowrap">
|
||||
EXPANDED VIEW
|
||||
</h3>
|
||||
<div className="h-[2px] flex-1 bg-border" />
|
||||
</div>
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-6">
|
||||
Press <Keycap>→</Keycap> to expand. Browse branches, see
|
||||
session conversations with last prompt and Claude's response.
|
||||
Running sessions show <span className="text-green">● running</span> or{" "}
|
||||
<span className="text-yellow">◉ idle</span> status inline. Resume any session directly.
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] text-center mb-4">
|
||||
Browse branches, past sessions with conversation previews. Resume any session directly.
|
||||
</p>
|
||||
<TerminalWindow title="cladm — 2 selected (1 branch switch)">
|
||||
<TerminalWindow title="cladm — expanded">
|
||||
<Image
|
||||
src="/screenshot-expanded.png"
|
||||
alt="cladm expanded view with sessions and branches"
|
||||
alt="cladm expanded view with sessions"
|
||||
width={980}
|
||||
height={600}
|
||||
className="w-full"
|
||||
@@ -273,104 +443,83 @@ export default function Home() {
|
||||
{/* ══════ LIVE MONITORING ══════ */}
|
||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
||||
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
|
||||
// LIVE SESSION MONITORING
|
||||
// REAL-TIME STATUS
|
||||
</h2>
|
||||
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="pixel-border bg-surface p-6">
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs leading-relaxed mb-5">
|
||||
cladm detects all running Claude Code sessions across every project and shows their real-time status.
|
||||
When any session finishes, a sound plays and the dock icon bounces — so you never miss it, even across dozens of parallel sessions.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 font-[family-name:var(--font-mono)] text-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-green text-base">●</span>
|
||||
<span className="text-text">Busy</span>
|
||||
<span className="text-dim">— Claude is actively processing</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-yellow text-base">◉</span>
|
||||
<span className="text-dim">3m</span>
|
||||
<span className="text-text">Idle</span>
|
||||
<span className="text-dim">— Claude finished 3 min ago, waiting for input</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-dim text-base">○</span>
|
||||
<span className="text-text ml-[22px]">No session</span>
|
||||
<span className="text-dim">— No active Claude process</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 pt-4 border-t border-border">
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] leading-relaxed">
|
||||
Detection reads the tail of each session's JSONL in{" "}
|
||||
<code className="text-accent">~/.claude/projects/</code>. A session is
|
||||
busy if the file was written recently OR the last assistant message
|
||||
has a pending tool call. This prevents false idle triggers during
|
||||
long-running tools and subtasks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PixelDivider />
|
||||
|
||||
{/* ══════ USAGE + IDLE PANELS ══════ */}
|
||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
||||
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
|
||||
// USAGE & IDLE PANELS
|
||||
</h2>
|
||||
|
||||
<div className="space-y-12 max-w-4xl mx-auto">
|
||||
{/* Usage panel screenshot */}
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="h-[2px] flex-1 bg-border" />
|
||||
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider whitespace-nowrap">
|
||||
USAGE TRACKING
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Status indicators */}
|
||||
<div className="pixel-border bg-surface p-6">
|
||||
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider mb-4">
|
||||
Session Status
|
||||
</h3>
|
||||
<div className="h-[2px] flex-1 bg-border" />
|
||||
<div className="space-y-3 font-[family-name:var(--font-mono)] text-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-green text-base">●</span>
|
||||
<span className="text-text">Busy</span>
|
||||
<span className="text-dim">— Claude is actively working</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-yellow text-base">◉</span>
|
||||
<span className="text-dim">3m</span>
|
||||
<span className="text-text">Idle</span>
|
||||
<span className="text-dim">— waiting for your input</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-dim text-base">○</span>
|
||||
<span className="text-text ml-[22px]">No session</span>
|
||||
<span className="text-dim">— not running</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] leading-relaxed">
|
||||
Status visible in both picker rows and grid pane headers.
|
||||
Sound + dock bounce on idle transitions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-6">
|
||||
Press <Keycap>u</Keycap> to toggle. Tracks session (5h window), weekly
|
||||
all-model and sonnet-only costs against configurable plan limits, plus monthly totals.
|
||||
</p>
|
||||
<TerminalWindow title="cladm — usage panel">
|
||||
<Image
|
||||
src="/screenshot-usage.png"
|
||||
alt="cladm usage tracking panel with session, weekly, and monthly cost bars"
|
||||
width={980}
|
||||
height={500}
|
||||
className="w-full"
|
||||
/>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
|
||||
{/* Idle sessions screenshot */}
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="h-[2px] flex-1 bg-border" />
|
||||
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider whitespace-nowrap">
|
||||
IDLE SESSIONS
|
||||
{/* Usage tracking */}
|
||||
<div className="pixel-border bg-surface p-6">
|
||||
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider mb-4">
|
||||
Usage Tracking
|
||||
</h3>
|
||||
<div className="h-[2px] flex-1 bg-border" />
|
||||
<div className="space-y-3 font-[family-name:var(--font-mono)] text-[10px]">
|
||||
<div>
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-dim">session (5h)</span>
|
||||
<span className="text-text">$2.40 / $5.00</span>
|
||||
</div>
|
||||
<div className="h-2 bg-bg border border-border">
|
||||
<div className="h-full bg-accent" style={{ width: "48%" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-dim">weekly all-model</span>
|
||||
<span className="text-text">$18.50 / $100</span>
|
||||
</div>
|
||||
<div className="h-2 bg-bg border border-border">
|
||||
<div className="h-full bg-green" style={{ width: "18.5%" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-dim">monthly total</span>
|
||||
<span className="text-text">$67.20</span>
|
||||
</div>
|
||||
<div className="h-2 bg-bg border border-border">
|
||||
<div className="h-full bg-cyan" style={{ width: "33.6%" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] leading-relaxed">
|
||||
Press <Keycap>u</Keycap> in picker mode. Tracks session, weekly,
|
||||
and monthly costs against configurable plan limits.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-6">
|
||||
Press <Keycap>i</Keycap> to toggle. Shows sessions waiting for your
|
||||
input, sorted by most recently idle. Press Enter to focus a session's
|
||||
Terminal tab directly.
|
||||
</p>
|
||||
<TerminalWindow title="cladm — idle sessions (2)">
|
||||
<Image
|
||||
src="/screenshot-idle.png"
|
||||
alt="cladm idle sessions panel showing waiting sessions with elapsed time"
|
||||
width={980}
|
||||
height={500}
|
||||
className="w-full"
|
||||
/>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -384,81 +533,84 @@ export default function Home() {
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<FeatureBlock
|
||||
icon={<TerminalIcon size={28} />}
|
||||
title="EMBEDDED GRID"
|
||||
desc="Run multiple Claude Code sessions side by side in a tiled terminal grid. Each pane is a real PTY with full I/O — no separate windows needed."
|
||||
/>
|
||||
<FeatureBlock
|
||||
icon={<BlocksIcon size={28} />}
|
||||
title="TABBED WORKSPACES"
|
||||
desc="Group sessions into named tabs. Inline pane indicators show project names and busy/idle status at a glance."
|
||||
/>
|
||||
<FeatureBlock
|
||||
icon={<GamepadIcon size={28} />}
|
||||
title="PANE CONTROLS"
|
||||
desc="Traffic-light buttons on every pane: close, minimize, expand to full screen. Blue button opens the project folder."
|
||||
/>
|
||||
<FeatureBlock
|
||||
icon={<SearchIcon size={28} />}
|
||||
title="SELECT MODE"
|
||||
desc="Double-click any pane to enter select mode. Copy text from the full scrollback buffer — up to 5,000 lines of history."
|
||||
/>
|
||||
<FeatureBlock
|
||||
icon={<EyeIcon size={28} />}
|
||||
title="LIVE MONITORING"
|
||||
desc="Track all Claude sessions across every project. Busy/idle status updates in real time with elapsed timers."
|
||||
desc="Track busy/idle status across all sessions in real time. Elapsed timers show how long each session has been waiting."
|
||||
/>
|
||||
<FeatureBlock
|
||||
icon={<TrendingUpIcon size={28} />}
|
||||
title="USAGE TRACKING"
|
||||
desc="Session, weekly, and monthly cost bars. Track all-model and sonnet-only usage against configurable plan limits."
|
||||
desc="Session, weekly, and monthly cost bars against configurable plan limits. Track all-model and sonnet-only usage."
|
||||
/>
|
||||
<FeatureBlock
|
||||
icon={<BellIcon size={28} />}
|
||||
title="NOTIFICATIONS"
|
||||
desc="Sound + dock bounce when any session finishes. Never miss a completed task across dozens of parallel sessions."
|
||||
/>
|
||||
<FeatureBlock
|
||||
icon={<ThunderIcon size={28} />}
|
||||
title="FOCUS SESSION"
|
||||
desc="Press Enter on any idle session to instantly focus its Terminal tab. Flash animation highlights the window."
|
||||
/>
|
||||
<FeatureBlock
|
||||
icon={<SearchIcon size={28} />}
|
||||
title="AUTO-DISCOVERY"
|
||||
desc="Reads ~/.claude/history.jsonl to find every project you've used with Claude Code. No config needed."
|
||||
/>
|
||||
<FeatureBlock
|
||||
icon={<NetworkIcon size={28} />}
|
||||
title="GIT METADATA"
|
||||
desc="Branch, sync status (ahead/behind), last commit, dirty state — all loaded in parallel per project."
|
||||
/>
|
||||
<FeatureBlock
|
||||
icon={<FolderIcon size={28} />}
|
||||
title="SESSION BROWSER"
|
||||
desc="Expand any project to browse past sessions. See conversation previews and resume directly."
|
||||
title="AUTO-DISCOVERY"
|
||||
desc="Reads ~/.claude/history.jsonl to find every project. Git branch, sync status, dirty state — all loaded in parallel."
|
||||
/>
|
||||
<FeatureBlock
|
||||
icon={<TerminalIcon size={28} />}
|
||||
title="PARALLEL LAUNCH"
|
||||
desc="Select multiple projects and hit Enter. Each opens in a new Terminal.app window simultaneously."
|
||||
/>
|
||||
<FeatureBlock
|
||||
icon={<BlocksIcon size={28} />}
|
||||
title="STACK DETECTION"
|
||||
desc="Auto-detects project stack: TypeScript, Python, Rust, Go, Docker, and more from config files."
|
||||
icon={<ThunderIcon size={28} />}
|
||||
title="DIRECT PTY"
|
||||
desc="Native pseudo-terminal management via forkpty(). No tmux dependency. Zero configuration. Just works."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PixelDivider />
|
||||
|
||||
{/* ══════ KEYBINDINGS ══════ */}
|
||||
{/* ══════ CONTROLS ══════ */}
|
||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
||||
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
|
||||
// CONTROLS
|
||||
</h2>
|
||||
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
||||
{/* Picker mode */}
|
||||
<div className="pixel-border bg-surface p-6">
|
||||
<div className="grid grid-cols-2 gap-y-3 font-[family-name:var(--font-mono)] text-xs">
|
||||
<h3 className="font-[family-name:var(--font-pixel)] text-accent text-xs uppercase tracking-wider mb-4 text-center">
|
||||
Picker Mode
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-y-2 font-[family-name:var(--font-mono)] text-xs">
|
||||
{[
|
||||
["↑ ↓", "Navigate"],
|
||||
["Space", "Toggle selection"],
|
||||
["Space", "Toggle select"],
|
||||
["→", "Expand project"],
|
||||
["←", "Collapse"],
|
||||
["Enter", "Launch selected / focus session"],
|
||||
["i", "Toggle idle sessions panel"],
|
||||
["u", "Toggle usage panel"],
|
||||
["/", "Filter projects"],
|
||||
["Enter", "Launch grid"],
|
||||
["/", "Filter"],
|
||||
["a", "Select all"],
|
||||
["n", "Deselect all"],
|
||||
["s", "Cycle sort mode"],
|
||||
["f", "Open folder in Finder"],
|
||||
["g", "Go to active session"],
|
||||
["PgUp PgDn", "Jump 15 rows"],
|
||||
["q / Esc", "Quit"],
|
||||
["s", "Cycle sort"],
|
||||
["u", "Usage panel"],
|
||||
["i", "Idle sessions"],
|
||||
["f", "Open folder"],
|
||||
["g", "Go to session"],
|
||||
["q", "Quit"],
|
||||
].map(([key, desc]) => (
|
||||
<div key={key} className="contents">
|
||||
<div className="text-accent">{key}</div>
|
||||
@@ -467,12 +619,58 @@ export default function Home() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid mode */}
|
||||
<div className="pixel-border bg-surface p-6">
|
||||
<h3 className="font-[family-name:var(--font-pixel)] text-accent text-xs uppercase tracking-wider mb-4 text-center">
|
||||
Grid Mode
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-y-2 font-[family-name:var(--font-mono)] text-xs">
|
||||
{[
|
||||
["Click", "Focus pane"],
|
||||
["Dbl-click", "Select mode"],
|
||||
["Alt+1-9", "Switch tab"],
|
||||
["Alt+n/p", "Next/prev tab"],
|
||||
["+ button", "Add pane"],
|
||||
["Esc", "Back to picker"],
|
||||
].map(([key, desc]) => (
|
||||
<div key={key} className="contents">
|
||||
<div className="text-accent">{key}</div>
|
||||
<div className="text-dim">{desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<div className="font-[family-name:var(--font-pixel)] text-text text-[10px] uppercase tracking-wider mb-2">
|
||||
Pane Buttons
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-y-2 font-[family-name:var(--font-mono)] text-xs">
|
||||
{[
|
||||
["● blue", "Open folder"],
|
||||
["● green", "Expand pane"],
|
||||
["● yellow", "Minimize"],
|
||||
["● red", "Close pane"],
|
||||
].map(([key, desc], i) => (
|
||||
<div key={key} className="contents">
|
||||
<div className={
|
||||
i === 0 ? "text-cyan" :
|
||||
i === 1 ? "text-[#27c93f]" :
|
||||
i === 2 ? "text-yellow" :
|
||||
"text-[#ff5f56]"
|
||||
}>{key}</div>
|
||||
<div className="text-dim">{desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PixelDivider />
|
||||
|
||||
{/* ══════ INSTALL ══════ */}
|
||||
{/* ══════ QUICK START ══════ */}
|
||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
||||
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
|
||||
// QUICK START
|
||||
@@ -514,91 +712,13 @@ export default function Home() {
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs">
|
||||
Or try with mock data:{" "}
|
||||
Try with mock data:{" "}
|
||||
<code className="text-yellow">cladm --demo</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ══════ LAUNCH RESULT ══════ */}
|
||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
||||
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-4 text-center">
|
||||
// HIT ENTER
|
||||
</h2>
|
||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-10 max-w-lg mx-auto">
|
||||
Select your projects, press Enter, and watch them all launch in
|
||||
parallel. Each project opens a fresh Claude Code session in its own
|
||||
Terminal window.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-6">
|
||||
{/* Mini cladm picker */}
|
||||
<div className="flex-1 w-full">
|
||||
<TerminalWindow title="cladm — 3 selected">
|
||||
<div className="p-3 font-[family-name:var(--font-mono)] text-[10px] leading-relaxed">
|
||||
<div className="text-dim mb-1">
|
||||
{" PROJECT BRANCH LAST USE"}
|
||||
</div>
|
||||
<div className="bg-[#283457] px-1">
|
||||
<span className="text-green">●</span>
|
||||
<span className="text-green"> [✓]</span>
|
||||
<span className="text-text">
|
||||
{" "}
|
||||
acme-api{" "}
|
||||
</span>
|
||||
<span className="text-magenta">main</span>
|
||||
<span className="text-cyan">{" "}25m ago</span>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<span className="text-yellow">◉</span>
|
||||
<span className="text-dim">2m</span>
|
||||
<span className="text-green">[✓]</span>
|
||||
<span className="text-text"> quantum-dashboard{" "}</span>
|
||||
<span className="text-magenta">feat/cha</span>
|
||||
<span className="text-cyan">{" "}1h ago</span>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<span className="text-green">●</span>
|
||||
<span className="text-green"> [✓]</span>
|
||||
<span className="text-text"> ml-pipeline{" "}</span>
|
||||
<span className="text-magenta">exp/bert</span>
|
||||
<span className="text-cyan">{" "}just now</span>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<span className="text-dim">○</span>
|
||||
<span className="text-dim"> [ ]</span>
|
||||
<span className="text-dim"> pixel-engine{" "}develop{" "}3h ago</span>
|
||||
</div>
|
||||
</div>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="font-[family-name:var(--font-pixel)] text-accent text-2xl flex-shrink-0 rotate-90 md:rotate-0">
|
||||
>>>
|
||||
</div>
|
||||
|
||||
{/* Claude Code terminals */}
|
||||
<div className="flex-1 w-full">
|
||||
<div className="relative">
|
||||
{/* Stacked terminal windows effect */}
|
||||
<div className="absolute top-3 left-3 right-[-3px] bottom-[-3px] border-2 border-border bg-surface-2 opacity-40" />
|
||||
<div className="absolute top-[6px] left-[6px] right-[-6px] bottom-[-6px] border-2 border-border bg-surface-2 opacity-20" />
|
||||
<TerminalWindow title="claude — acme-api">
|
||||
<Image
|
||||
src="/claude-terminal.webp"
|
||||
alt="Claude Code session launched in Terminal"
|
||||
width={960}
|
||||
height={518}
|
||||
className="w-full"
|
||||
/>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ══════ NEWSLETTER ══════ */}
|
||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
||||
<div className="max-w-md mx-auto">
|
||||
@@ -665,7 +785,7 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 font-[family-name:var(--font-mono)] text-dim text-[10px] text-center">
|
||||
Built with Bun + OpenTUI. Pixel art by the cladm creatures.
|
||||
Built with Bun + OpenTUI. Direct PTY grid, no tmux. Pixel art by the cladm creatures.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
const projects = [
|
||||
{ name: "acme-api", branch: "main", time: "25m ago", status: "busy" as const },
|
||||
@@ -10,13 +9,13 @@ const projects = [
|
||||
];
|
||||
|
||||
type Phase =
|
||||
| "typing" // cladm console visible, cursor selecting projects
|
||||
| "selecting" // checkboxes toggling on one by one
|
||||
| "enter" // "Enter" flash, cladm fades
|
||||
| "cascade" // terminals fly in
|
||||
| "hold" // terminals visible
|
||||
| "fadeout" // everything fades, restart
|
||||
| "pause"; // brief gap before loop
|
||||
| "typing"
|
||||
| "selecting"
|
||||
| "enter"
|
||||
| "grid"
|
||||
| "hold"
|
||||
| "fadeout"
|
||||
| "pause";
|
||||
|
||||
export function TerminalCascade() {
|
||||
const [phase, setPhase] = useState<Phase>("typing");
|
||||
@@ -27,31 +26,18 @@ export function TerminalCascade() {
|
||||
setSelectedCount(0);
|
||||
setPhase("typing");
|
||||
|
||||
// Typing/appear cladm console
|
||||
const t1 = setTimeout(() => setPhase("selecting"), 800);
|
||||
|
||||
// Toggle checkboxes one by one
|
||||
const t2 = setTimeout(() => setSelectedCount(1), 1200);
|
||||
const t3 = setTimeout(() => setSelectedCount(2), 1600);
|
||||
const t4 = setTimeout(() => setSelectedCount(3), 2000);
|
||||
|
||||
// Enter pressed
|
||||
const t5 = setTimeout(() => setPhase("enter"), 2600);
|
||||
|
||||
// Cascade terminals in
|
||||
const t6 = setTimeout(() => setPhase("cascade"), 3200);
|
||||
|
||||
// Hold
|
||||
const t7 = setTimeout(() => setPhase("hold"), 3800);
|
||||
|
||||
// Fade out
|
||||
const t8 = setTimeout(() => setPhase("fadeout"), 6200);
|
||||
|
||||
// Pause then restart
|
||||
const t6 = setTimeout(() => setPhase("grid"), 3400);
|
||||
const t7 = setTimeout(() => setPhase("hold"), 4000);
|
||||
const t8 = setTimeout(() => setPhase("fadeout"), 7000);
|
||||
const t9 = setTimeout(() => {
|
||||
setPhase("pause");
|
||||
setCycle((c) => c + 1);
|
||||
}, 7000);
|
||||
}, 7800);
|
||||
|
||||
return [t1, t2, t3, t4, t5, t6, t7, t8, t9];
|
||||
}, []);
|
||||
@@ -65,19 +51,18 @@ export function TerminalCascade() {
|
||||
return () => clearTimeout(start);
|
||||
}, [cycle, runCycle]);
|
||||
|
||||
const showCladm = phase === "typing" || phase === "selecting" || phase === "enter";
|
||||
const showCascade = phase === "cascade" || phase === "hold" || phase === "fadeout";
|
||||
const showPicker = phase === "typing" || phase === "selecting" || phase === "enter";
|
||||
const showGrid = phase === "grid" || phase === "hold" || phase === "fadeout";
|
||||
|
||||
return (
|
||||
<div className="relative w-full min-h-[340px]">
|
||||
{/* ── CLADM console (cause) ── */}
|
||||
<div className="relative w-full min-h-[360px]">
|
||||
{/* ── Picker (select projects) ── */}
|
||||
<div
|
||||
className={`transition-all duration-500 ${
|
||||
showCladm ? "opacity-100 scale-100" : "opacity-0 scale-95 pointer-events-none absolute inset-0"
|
||||
showPicker ? "opacity-100 scale-100" : "opacity-0 scale-95 pointer-events-none absolute inset-0"
|
||||
}`}
|
||||
>
|
||||
<div className="pixel-border bg-surface overflow-hidden">
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-surface-2 border-b-2 border-border">
|
||||
<div className="w-3 h-3 bg-[#ff5f56]" />
|
||||
<div className="w-3 h-3 bg-[#ffbd2e]" />
|
||||
@@ -86,7 +71,6 @@ export function TerminalCascade() {
|
||||
cladm — {selectedCount} selected
|
||||
</span>
|
||||
</div>
|
||||
{/* Project rows */}
|
||||
<div className="p-3 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed">
|
||||
<div className="text-dim mb-1 text-[10px]">
|
||||
{" PROJECT BRANCH LAST USE"}
|
||||
@@ -121,15 +105,14 @@ export function TerminalCascade() {
|
||||
<span>○</span><span> </span>[ ] pixel-engine{" "}develop{" "}3h ago
|
||||
</div>
|
||||
|
||||
{/* Enter hint */}
|
||||
<div className="mt-3 pt-2 border-t border-border text-[10px]">
|
||||
{phase === "enter" ? (
|
||||
<span className="text-accent font-bold cascade-flash">
|
||||
⏎ Launching 3 projects...
|
||||
⏎ Launching 3 sessions into grid...
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-dim">
|
||||
↑↓ navigate · space toggle · enter launch
|
||||
↑↓ navigate · space toggle · enter launch grid
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,45 +120,92 @@ export function TerminalCascade() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Terminal cascade (effect) ── */}
|
||||
{/* ── Grid workspace (result) ── */}
|
||||
<div
|
||||
className={`transition-opacity duration-500 ${
|
||||
showCascade ? "opacity-100" : "opacity-0 pointer-events-none absolute inset-0"
|
||||
className={`transition-opacity duration-600 ${
|
||||
showGrid ? "opacity-100" : "opacity-0 pointer-events-none absolute inset-0"
|
||||
}`}
|
||||
>
|
||||
<div className="relative h-[320px]">
|
||||
{projects.map((proj, i) => (
|
||||
<div
|
||||
key={`term-${proj.name}-${cycle}`}
|
||||
className={`absolute left-0 right-0 border-2 bg-surface overflow-hidden
|
||||
${phase === "cascade" || phase === "hold" ? "cascade-in" : ""}
|
||||
${phase === "hold" && i === projects.length - 1 ? "cascade-glow" : ""}`}
|
||||
style={{
|
||||
animationDelay: `${i * 0.2}s`,
|
||||
top: `${i * 80}px`,
|
||||
marginLeft: `${i * 16}px`,
|
||||
marginRight: `${(projects.length - 1 - i) * 16}px`,
|
||||
zIndex: i + 1,
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-surface-2 border-b border-border">
|
||||
<div className="w-[7px] h-[7px] bg-[#ff5f56]" />
|
||||
<div className="w-[7px] h-[7px] bg-[#ffbd2e]" />
|
||||
<div className="w-[7px] h-[7px] bg-[#27c93f]" />
|
||||
<span className="ml-2 font-[family-name:var(--font-mono)] text-dim text-[9px] truncate">
|
||||
claude — {proj.name}
|
||||
</span>
|
||||
</div>
|
||||
<Image
|
||||
src="/claude-welcome.png"
|
||||
alt="Claude Code welcome screen"
|
||||
width={570}
|
||||
height={260}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
<div className={`pixel-border bg-surface overflow-hidden ${phase === "hold" ? "cascade-glow" : phase === "grid" ? "cascade-in" : ""}`}>
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center bg-surface-2 border-b-2 border-border">
|
||||
<div className="px-3 py-1.5 border-b-2 border-accent font-[family-name:var(--font-mono)] text-[9px]">
|
||||
<span className="text-green">●</span>
|
||||
<span className="text-text"> acme-api</span>
|
||||
<span className="text-dim"> · </span>
|
||||
<span className="text-yellow">◉</span>
|
||||
<span className="text-text"> quantum-dash</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="px-3 py-1.5 font-[family-name:var(--font-mono)] text-[9px] text-dim border-b-2 border-transparent">
|
||||
<span className="text-green">●</span>
|
||||
<span> ml-pipeline</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pane grid */}
|
||||
<div className="grid grid-cols-2 gap-px bg-border">
|
||||
{/* Pane 1: acme-api (busy) */}
|
||||
<div className="bg-surface">
|
||||
<div className="flex items-center justify-between px-2 py-[3px] border-b border-border">
|
||||
<div className="font-[family-name:var(--font-mono)] text-[8px]">
|
||||
<span className="text-green">●</span>
|
||||
<span className="text-text"> acme-api</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[3px]">
|
||||
<span className="text-cyan text-[6px]">●</span>
|
||||
<span className="text-[#27c93f] text-[6px]">●</span>
|
||||
<span className="text-[#ff5f56] text-[6px]">●</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 font-[family-name:var(--font-mono)] text-[8px] text-dim leading-[1.6] h-[85px]">
|
||||
<div className="text-green">> I'll fix the token refresh bug</div>
|
||||
<div>Reading src/auth/token.ts...</div>
|
||||
<div>Reading src/auth/middleware.ts...</div>
|
||||
<div>Grep: refreshToken pattern<span className="cursor-blink text-accent">_</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pane 2: quantum-dash (idle) */}
|
||||
<div className="bg-surface">
|
||||
<div className="flex items-center justify-between px-2 py-[3px] border-b border-border">
|
||||
<div className="font-[family-name:var(--font-mono)] text-[8px]">
|
||||
<span className="text-yellow">◉</span>
|
||||
<span className="text-dim"> 4m </span>
|
||||
<span className="text-text">quantum-dash</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[3px]">
|
||||
<span className="text-cyan text-[6px]">●</span>
|
||||
<span className="text-[#27c93f] text-[6px]">●</span>
|
||||
<span className="text-[#ff5f56] text-[6px]">●</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 font-[family-name:var(--font-mono)] text-[8px] text-dim leading-[1.6] h-[85px]">
|
||||
<div className="text-text">Updated chart component</div>
|
||||
<div className="text-text">New hook: useChartData.ts</div>
|
||||
<div className="text-yellow mt-1">Waiting for input...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pane 3: ml-pipeline (busy, full width) */}
|
||||
<div className="bg-surface col-span-2">
|
||||
<div className="flex items-center justify-between px-2 py-[3px] border-b border-border">
|
||||
<div className="font-[family-name:var(--font-mono)] text-[8px]">
|
||||
<span className="text-green">●</span>
|
||||
<span className="text-text"> ml-pipeline</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[3px]">
|
||||
<span className="text-cyan text-[6px]">●</span>
|
||||
<span className="text-[#27c93f] text-[6px]">●</span>
|
||||
<span className="text-[#ff5f56] text-[6px]">●</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 font-[family-name:var(--font-mono)] text-[8px] text-dim leading-[1.6] h-[65px]">
|
||||
<div className="text-green">> Building BERT fine-tuning pipeline</div>
|
||||
<div>Processing dataset: train.jsonl</div>
|
||||
<div>Epoch 3/10 <span className="text-accent">████████</span><span className="text-border">░░░░░░░░░░░░</span> 30%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,67 @@ import { ensureGridView, createNewGridTab, switchToGridTab } from "../grid/view-
|
||||
import { loadSessions } from "../data/sessions"
|
||||
import { createSession } from "../pty/session-manager"
|
||||
|
||||
export async function doAddPane() {
|
||||
const targetTabId = app.addPaneTargetTabId
|
||||
if (!targetTabId || !app.directGrid) return
|
||||
if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) return
|
||||
|
||||
type LaunchItem = { path: string; name: string; sessionId?: string; targetBranch?: string }
|
||||
const items: LaunchItem[] = []
|
||||
|
||||
for (const [path] of app.selectedProjects) {
|
||||
const project = app.projects.find(p => p.path === path)
|
||||
if (!project) continue
|
||||
const targetBranch = app.selectedBranches.get(path)
|
||||
const needsBranch = targetBranch && targetBranch !== project.branch
|
||||
if (!project.sessions) {
|
||||
project.sessions = await loadSessions(project.path)
|
||||
project.sessionCount = project.sessions.length
|
||||
}
|
||||
const lastSessionId = project.sessions[0]?.id
|
||||
items.push({ path, name: project.name, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined })
|
||||
}
|
||||
|
||||
for (const project of app.projects) {
|
||||
if (!project.sessions) continue
|
||||
for (const session of project.sessions) {
|
||||
if (app.selectedSessions.has(session.id)) {
|
||||
const targetBranch = app.selectedBranches.get(project.path)
|
||||
const needsBranch = targetBranch && targetBranch !== project.branch
|
||||
items.push({ path: project.path, name: project.name, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) return
|
||||
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
const totalPanes = items.length + (app.directGrid.getTabPaneCount(targetTabId) || 0)
|
||||
const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3
|
||||
const rows = Math.ceil(totalPanes / cols)
|
||||
const paneW = Math.max(Math.floor(termW / cols) - 2, 20)
|
||||
const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6)
|
||||
|
||||
for (const item of items) {
|
||||
const session = await createSession({
|
||||
projectPath: item.path,
|
||||
projectName: item.name,
|
||||
sessionId: item.sessionId,
|
||||
targetBranch: item.targetBranch,
|
||||
width: paneW,
|
||||
height: paneH,
|
||||
})
|
||||
await app.directGrid.addPane(session, targetTabId)
|
||||
}
|
||||
|
||||
app.selectedProjects.clear()
|
||||
app.selectedSessions.clear()
|
||||
app.selectedBranches.clear()
|
||||
app.addPaneTargetTabId = null
|
||||
switchToGridTab(targetTabId)
|
||||
}
|
||||
|
||||
export async function doLaunch() {
|
||||
if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) return
|
||||
if (app.demoMode) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Each pane renders independently via PTY capture push callbacks.
|
||||
|
||||
import { DirectPane } from "./direct-pane"
|
||||
import { startCapture, stopCapture, resizeCapture, resetHash, getLatestFrame, scrollPane, getScrollOffset } from "../pty/capture"
|
||||
import { startCapture, stopCapture, resizeCapture, resetHash, getLatestFrame, getFullBuffer, scrollPane, getScrollOffset } from "../pty/capture"
|
||||
import { writeToSession, resizeSession, killSession, type PtySession } from "../pty/session-manager"
|
||||
import { app, type GridTab } from "../lib/state"
|
||||
|
||||
@@ -84,10 +84,15 @@ export class DirectGridRenderer {
|
||||
|
||||
// Tab bar hit-test regions (col ranges for each tab)
|
||||
private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = []
|
||||
private tabCloseHitRegions: { tabId: number, startCol: number, endCol: number }[] = []
|
||||
private tabBarAddBtnCol = -1
|
||||
// Pane list hit-test regions (row 2)
|
||||
// Pane name hit-test regions (inline in tab bar, row 1)
|
||||
private paneListHitRegions: { tabId: number, paneIndex: number, startCol: number, endCol: number }[] = []
|
||||
|
||||
// Pending close state
|
||||
private _pendingCloseTabId = -1
|
||||
private _pendingCloseTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(rawWrite: (s: string) => boolean) {
|
||||
this.writeRaw = rawWrite
|
||||
}
|
||||
@@ -164,6 +169,7 @@ export class DirectGridRenderer {
|
||||
}
|
||||
}
|
||||
this.repositionAll()
|
||||
setTimeout(() => this.forceRedrawAll(), 100)
|
||||
this.titleTimer = setInterval(() => this.refreshTitles(), 1000)
|
||||
}
|
||||
|
||||
@@ -179,18 +185,46 @@ export class DirectGridRenderer {
|
||||
get activeTabId() { return this._activeTabId }
|
||||
|
||||
enterSelectMode() {
|
||||
if (!this.isExpanded) return
|
||||
this._selectMode = true
|
||||
this.writeRaw("\x1b[?1000l\x1b[?1006l")
|
||||
this.writeRaw(SHOW_CURSOR)
|
||||
this.drawChrome()
|
||||
this.drawSelectView()
|
||||
}
|
||||
|
||||
exitSelectMode() {
|
||||
this._selectMode = false
|
||||
this.writeRaw("\x1b[?1000h\x1b[?1006h")
|
||||
this.writeRaw(HIDE_CURSOR)
|
||||
this.drawChrome()
|
||||
this.writeRaw(HIDE_CURSOR + CLEAR)
|
||||
this.forceRedrawAll()
|
||||
}
|
||||
|
||||
private drawSelectView() {
|
||||
const pane = this.focusedPane
|
||||
if (!pane) return
|
||||
const termW = process.stdout.columns || 120
|
||||
const lines = getFullBuffer(pane.session.name) ?? []
|
||||
const color = getColor(pane.session.colorIndex)
|
||||
|
||||
// Banner + all buffer lines dumped as plain text (terminal handles native scrollback)
|
||||
let out = SYNC_START + CLEAR
|
||||
|
||||
// Banner row
|
||||
const bannerBg = hexBg("#e0af68")
|
||||
const bannerFg = "\x1b[38;2;0;0;0m"
|
||||
const bannerText = " SELECTION MODE "
|
||||
const hint = " Esc to exit "
|
||||
const pad = Math.max(0, termW - bannerText.length - hint.length)
|
||||
out += `\x1b[1;1H${bannerBg}${bannerFg}${BOLD}${bannerText}${" ".repeat(pad)}${hint}${RESET}`
|
||||
|
||||
// Project name on row 2
|
||||
out += `\x1b[2;1H${hexFg(color)}${BOLD}${pane.session.projectName}${RESET} ${DIM}drag to select │ cmd+c copy │ scroll up for history${RESET}`
|
||||
|
||||
// Dump full buffer starting row 3 — native terminal scrollback handles overflow
|
||||
for (let r = 0; r < lines.length; r++) {
|
||||
out += `\x1b[${r + 3};1H${lines[r]}\x1b[0m`
|
||||
}
|
||||
out += SYNC_END
|
||||
this.writeRaw(out)
|
||||
}
|
||||
|
||||
expandPane(index?: number) {
|
||||
@@ -253,6 +287,48 @@ export class DirectGridRenderer {
|
||||
this.tabSoftExpand.delete(tabId)
|
||||
}
|
||||
|
||||
// ─── Tab close (double-click confirm) ────────────────
|
||||
|
||||
get pendingCloseTabId() { return this._pendingCloseTabId }
|
||||
|
||||
requestCloseTab(tabId: number): "pending" | "closed" {
|
||||
if (this._pendingCloseTabId === tabId) {
|
||||
// Second click — execute close
|
||||
this.cancelPendingClose()
|
||||
this.closeTab(tabId)
|
||||
return "closed"
|
||||
}
|
||||
// First click — mark pending
|
||||
this.cancelPendingClose()
|
||||
this._pendingCloseTabId = tabId
|
||||
this._pendingCloseTimer = setTimeout(() => {
|
||||
this._pendingCloseTabId = -1
|
||||
this._pendingCloseTimer = null
|
||||
this.drawChrome()
|
||||
}, 2000)
|
||||
this.drawChrome()
|
||||
return "pending"
|
||||
}
|
||||
|
||||
closeTab(tabId: number): number {
|
||||
const tabIdx = app.gridTabs.findIndex(t => t.id === tabId)
|
||||
if (tabIdx < 0) return -1
|
||||
this.removeTab(tabId)
|
||||
app.gridTabs.splice(tabIdx, 1)
|
||||
return tabIdx
|
||||
}
|
||||
|
||||
cancelPendingClose() {
|
||||
if (this._pendingCloseTimer) {
|
||||
clearTimeout(this._pendingCloseTimer)
|
||||
this._pendingCloseTimer = null
|
||||
}
|
||||
if (this._pendingCloseTabId !== -1) {
|
||||
this._pendingCloseTabId = -1
|
||||
this.drawChrome()
|
||||
}
|
||||
}
|
||||
|
||||
setActiveTab(tabId: number) {
|
||||
if (this._activeTabId === tabId) return
|
||||
// Detach current tab's panes
|
||||
@@ -274,6 +350,7 @@ export class DirectGridRenderer {
|
||||
if (this.running) {
|
||||
this.writeRaw(CLEAR)
|
||||
this.repositionAll()
|
||||
setTimeout(() => this.forceRedrawAll(), 100)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,28 +369,33 @@ export class DirectGridRenderer {
|
||||
}
|
||||
|
||||
// Check if a click hit a button on the top border. Returns action + pane index.
|
||||
checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab" | "panefocus", paneIndex: number, tabId?: number } | null {
|
||||
// Tab bar check (row 1)
|
||||
// Hit areas are widened beyond the visible dot characters to make clicking easier.
|
||||
checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab" | "panefocus" | "closetab" | "closepane" | "openfolder", paneIndex: number, tabId?: number } | null {
|
||||
// Tab bar check (row 1) — includes inline pane names
|
||||
if (row === 1) {
|
||||
// Check close buttons first — widened ±1 around the × character
|
||||
for (const region of this.tabCloseHitRegions) {
|
||||
if (col >= region.startCol - 1 && col <= region.endCol + 1) {
|
||||
return { action: "closetab", paneIndex: -1, tabId: region.tabId }
|
||||
}
|
||||
}
|
||||
// Pane names (inline in tabs) — check before tab regions since they're more specific
|
||||
for (const region of this.paneListHitRegions) {
|
||||
if (col >= region.startCol - 1 && col <= region.endCol + 1) {
|
||||
return { action: "panefocus", paneIndex: region.paneIndex, tabId: region.tabId }
|
||||
}
|
||||
}
|
||||
for (const region of this.tabBarHitRegions) {
|
||||
if (col >= region.startCol && col <= region.endCol) {
|
||||
return { action: "tab", paneIndex: -1, tabId: region.tabId }
|
||||
}
|
||||
}
|
||||
if (this.tabBarAddBtnCol > 0 && col >= this.tabBarAddBtnCol && col <= this.tabBarAddBtnCol + 2) {
|
||||
// [+] button — widened ±1
|
||||
if (this.tabBarAddBtnCol > 0 && col >= this.tabBarAddBtnCol - 1 && col <= this.tabBarAddBtnCol + 3) {
|
||||
return { action: "newtab", paneIndex: -1 }
|
||||
}
|
||||
return null
|
||||
}
|
||||
// Pane list check (row 2)
|
||||
if (row === 2) {
|
||||
for (const region of this.paneListHitRegions) {
|
||||
if (col >= region.startCol && col <= region.endCol) {
|
||||
return { action: "panefocus", paneIndex: region.paneIndex, tabId: region.tabId }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const indicesToCheck = this.isExpanded ? [this._expandedIndex] : this.panes.map((_, i) => i)
|
||||
for (const i of indicesToCheck) {
|
||||
@@ -321,21 +403,33 @@ export class DirectGridRenderer {
|
||||
const bx = dp.screenX - 1
|
||||
const by = dp.screenY - 3
|
||||
const bw = dp.width + 2
|
||||
const btnRow = by
|
||||
|
||||
if (row !== btnRow) continue
|
||||
// Top border row — framed [●] buttons
|
||||
// Order from right: ─[●] ─[●] [●] [●]─╮ = blue folder, gap, green/yellow, red close
|
||||
if (row === by) {
|
||||
// close (red): rightmost, positions bw-5..bw-3 (before ─╮)
|
||||
if (col >= bx + bw - 5 && col <= bx + bw - 3) return { action: this.isExpanded ? "closepane" : "closepane", paneIndex: i }
|
||||
if (this.isExpanded) {
|
||||
// Layout: ...─[●] ─[●] [●] [●]─╮
|
||||
// min (yellow): bw-9..bw-7
|
||||
if (col >= bx + bw - 9 && col <= bx + bw - 7) return { action: "min", paneIndex: i }
|
||||
// sel (green): bw-13..bw-11
|
||||
if (col >= bx + bw - 13 && col <= bx + bw - 11) return { action: "sel", paneIndex: i }
|
||||
// folder (blue): bw-18..bw-16 (after ─ gap)
|
||||
if (col >= bx + bw - 18 && col <= bx + bw - 16) return { action: "openfolder", paneIndex: i }
|
||||
} else {
|
||||
// Layout: ...─[●] ─[●] [●]─╮
|
||||
// max (green): bw-9..bw-7
|
||||
if (col >= bx + bw - 9 && col <= bx + bw - 7) return { action: "max", paneIndex: i }
|
||||
// folder (blue): bw-14..bw-12 (after ─ gap)
|
||||
if (col >= bx + bw - 14 && col <= bx + bw - 12) return { action: "openfolder", paneIndex: i }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.isExpanded) {
|
||||
const minRight = bx + bw - 2
|
||||
const minLeft = minRight - 4
|
||||
if (col >= minLeft && col <= minRight) return { action: "min", paneIndex: i }
|
||||
const selRight = minLeft - 2
|
||||
const selLeft = selRight - 4
|
||||
if (col >= selLeft && col <= selRight) return { action: "sel", paneIndex: i }
|
||||
} else {
|
||||
const btnLeft = bx + bw - 7
|
||||
const btnRight = bx + bw - 3
|
||||
if (col >= btnLeft && col <= btnRight) return { action: "max", paneIndex: i }
|
||||
// Title row (by+1) — click to expand/focus
|
||||
if (row === by + 1 && !this.isExpanded) {
|
||||
return { action: "max", paneIndex: i }
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -374,6 +468,10 @@ export class DirectGridRenderer {
|
||||
this.drawPane(idx, lines)
|
||||
}
|
||||
this.repositionAll()
|
||||
|
||||
// Force-redraw all panes after a short delay to catch initial frames
|
||||
// that may have arrived before attach or been cleared by repositionAll
|
||||
setTimeout(() => this.forceRedrawAll(), 200)
|
||||
}
|
||||
|
||||
return info
|
||||
@@ -464,7 +562,7 @@ export class DirectGridRenderer {
|
||||
if (n === 0) return false
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
const chromeTop = 4
|
||||
const chromeTop = 3
|
||||
const { cols } = this.calcGrid(n)
|
||||
const rows = Math.ceil(n / cols)
|
||||
const cellW = Math.floor(termW / cols)
|
||||
@@ -496,7 +594,7 @@ export class DirectGridRenderer {
|
||||
if (n === 0) return -1
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
const chromeTop = 4
|
||||
const chromeTop = 3
|
||||
const { cols } = this.calcGrid(n)
|
||||
const rows = Math.ceil(n / cols)
|
||||
const cellW = Math.floor(termW / cols)
|
||||
@@ -510,19 +608,16 @@ export class DirectGridRenderer {
|
||||
// ─── Chrome ────────────────────────────────────────────
|
||||
|
||||
drawChrome() {
|
||||
if (!this.running) return
|
||||
if (!this.running || this._selectMode) return
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
|
||||
let out = SYNC_START
|
||||
|
||||
// Tab bar (row 1)
|
||||
// Tab bar (row 1) — includes inline pane names
|
||||
out += this.drawTabBar(termW)
|
||||
|
||||
// Pane list (row 2)
|
||||
out += this.drawPaneList(termW)
|
||||
|
||||
// Header (row 3)
|
||||
// Header (row 2)
|
||||
const n = this.panes.length
|
||||
const fi = this._focusIndex + 1
|
||||
let headerLeft: string, headerRight: string
|
||||
@@ -531,15 +626,15 @@ export class DirectGridRenderer {
|
||||
headerRight = `${DIM}drag to select │ cmd+c copy │ ${BOLD}Esc${RESET}${DIM} exit select${RESET}`
|
||||
} else if (this.isExpanded) {
|
||||
headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#7dcfff")}${BOLD}EXPANDED${RESET} │ ${fi}/${n}`
|
||||
headerRight = `${DIM}click ${BOLD}[SEL]${RESET}${DIM} select text │ click ${BOLD}[MIN]${RESET}${DIM} restore │ ctrl+space picker${RESET}`
|
||||
headerRight = `${DIM}${hexFg("#f7768e")}[●]${RESET}${DIM} close │ ${hexFg("#e0af68")}[●]${RESET}${DIM} restore │ ${hexFg("#9ece6a")}[●]${RESET}${DIM} select │ ctrl+space picker${RESET}`
|
||||
} else if (this.isSoftExpanded) {
|
||||
headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#bb9af7")}${BOLD}FOCUS${RESET} │ ${fi}/${n}`
|
||||
headerRight = `${DIM}click pane to focus │ click ${BOLD}[MAX]${RESET}${DIM} fullscreen │ ctrl+e toggle${RESET}`
|
||||
headerRight = `${DIM}click pane to focus │ ${hexFg("#9ece6a")}[●]${RESET}${DIM} fullscreen │ ctrl+s select │ ctrl+e toggle${RESET}`
|
||||
} else {
|
||||
headerLeft = ` ${BOLD}cladm grid${RESET} — ${n} sessions │ focus: ${fi}/${n}`
|
||||
headerRight = `${DIM}shift+arrows nav │ click ${BOLD}[MAX]${RESET}${DIM} expand │ ctrl+space picker │ ctrl+w close${RESET}`
|
||||
headerRight = `${DIM}shift+arrows nav │ ${hexFg("#f7768e")}[●]${RESET}${DIM} close ${hexFg("#9ece6a")}[●]${RESET}${DIM} expand │ ctrl+s select │ ctrl+space picker${RESET}`
|
||||
}
|
||||
out += `\x1b[3;1H\x1b[${termW}X${headerLeft} ${headerRight}`
|
||||
out += `\x1b[2;1H\x1b[${termW}X${headerLeft} ${headerRight}`
|
||||
|
||||
// Pane borders + titles
|
||||
if (this.isExpanded) {
|
||||
@@ -556,7 +651,7 @@ export class DirectGridRenderer {
|
||||
out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg("#9ece6a")}${BOLD}SELECT MODE${RESET} ${DIM}drag to select text │ cmd+c to copy │ press ${BOLD}Esc${RESET}${DIM} to exit${RESET}`
|
||||
} else if (this.isExpanded && pane) {
|
||||
const color = getColor(pane.session.colorIndex)
|
||||
out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET} ${DIM}expanded │ Esc or [MIN] to restore grid${RESET}`
|
||||
out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET} ${DIM}expanded │ ctrl+s select │ Esc or ${hexFg("#e0af68")}[●]${RESET}${DIM} to restore grid${RESET}`
|
||||
} else if (pane) {
|
||||
const color = getColor(pane.session.colorIndex)
|
||||
const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : ""
|
||||
@@ -572,87 +667,138 @@ export class DirectGridRenderer {
|
||||
|
||||
private drawTabBar(termW: number): string {
|
||||
this.tabBarHitRegions = []
|
||||
this.tabCloseHitRegions = []
|
||||
this.paneListHitRegions = []
|
||||
this.tabBarAddBtnCol = -1
|
||||
|
||||
let out = `\x1b[1;1H\x1b[${termW}X `
|
||||
let col = 2
|
||||
const RED_FG = hexFg("#f7768e")
|
||||
const TAB_BG_ACTIVE = hexBg("#24283b")
|
||||
const TAB_BORDER = hexFg("#3b4261")
|
||||
|
||||
let out = `\x1b[1;1H\x1b[${termW}X`
|
||||
let col = 1
|
||||
|
||||
// Picker tab (id = -1, meaning: switch to picker)
|
||||
const pickerActive = app.viewMode === "picker"
|
||||
const pickerLabel = pickerActive ? `${CYAN_FG}${BOLD}● Picker${RESET}` : `${DIM}○ Picker${RESET}`
|
||||
out += pickerLabel + ` ${DIM}│${RESET} `
|
||||
this.tabBarHitRegions.push({ tabId: -1, startCol: col, endCol: col + 7 })
|
||||
col += 11 // "● Picker │ "
|
||||
if (pickerActive) {
|
||||
out += `${TAB_BORDER}╭${RESET}${TAB_BG_ACTIVE} ${CYAN_FG}${BOLD}● Picker${RESET}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}╮${RESET}`
|
||||
} else {
|
||||
out += ` ${DIM}○ Picker${RESET} `
|
||||
}
|
||||
const pickerStart = pickerActive ? col + 1 : col + 1
|
||||
this.tabBarHitRegions.push({ tabId: -1, startCol: pickerStart, endCol: pickerStart + 7 })
|
||||
col += 10
|
||||
|
||||
// Grid tabs
|
||||
// Grid tabs — inline pane names instead of tab names
|
||||
for (const tab of app.gridTabs) {
|
||||
const isActive = this._activeTabId === tab.id && app.viewMode === "grid"
|
||||
const hasIdle = this.hasIdleInTab(tab.id)
|
||||
const count = this.getTabPaneCount(tab.id)
|
||||
const label = `${tab.name} (${count})`
|
||||
const isPending = this._pendingCloseTabId === tab.id
|
||||
const tabPanes = this.tabPanes.get(tab.id) ?? []
|
||||
|
||||
let tabText: string
|
||||
if (isActive) {
|
||||
tabText = `${CYAN_FG}${BOLD}● ${label}${RESET}`
|
||||
} else if (hasIdle) {
|
||||
tabText = `${YELLOW_FG}◉ ${label}${RESET}`
|
||||
} else {
|
||||
tabText = `${DIM}○ ${label}${RESET}`
|
||||
// Build pane name list for this tab
|
||||
const paneLabels: { name: string, color: string, status: PaneStatus, isFocused: boolean }[] = []
|
||||
for (let pi = 0; pi < tabPanes.length; pi++) {
|
||||
const p = tabPanes[pi]!
|
||||
const name = p.session.projectName
|
||||
const short = name.length > 14 ? name.slice(0, 12) + "…" : name
|
||||
paneLabels.push({
|
||||
name: short,
|
||||
color: getColor(p.session.colorIndex),
|
||||
status: p.status,
|
||||
isFocused: isActive && this._focusIndex === pi,
|
||||
})
|
||||
}
|
||||
|
||||
const startCol = col
|
||||
out += tabText + ` ${DIM}│${RESET} `
|
||||
const visLen = 2 + label.length // "● " + label
|
||||
this.tabBarHitRegions.push({ tabId: tab.id, startCol, endCol: startCol + visLen - 1 })
|
||||
col += visLen + 3 // + " │ "
|
||||
// Close button text
|
||||
const closeText = isPending ? `${RED_FG}${BOLD}[●]${RESET}` : `${DIM}[×]${RESET}`
|
||||
const closeVisLen = 3
|
||||
|
||||
const tabStartCol = col
|
||||
|
||||
if (isActive) {
|
||||
// Active tab: ╭ ● pane1 · ◉ pane2 × ╮
|
||||
out += `${TAB_BORDER}╭${RESET}${TAB_BG_ACTIVE} `
|
||||
col += 2 // ╭ + space
|
||||
|
||||
for (let pi = 0; pi < paneLabels.length; pi++) {
|
||||
const pl = paneLabels[pi]!
|
||||
let icon: string
|
||||
if (pl.status === "busy") icon = `${hexFg("#9ece6a")}●${RESET}`
|
||||
else if (pl.status === "idle") icon = `${hexFg("#e0af68")}◉${RESET}`
|
||||
else icon = `${DIM}○${RESET}`
|
||||
|
||||
const paneStartCol = col
|
||||
if (pl.isFocused) {
|
||||
out += `${TAB_BG_ACTIVE}${icon} ${hexFg(pl.color)}${BOLD}${pl.name}${RESET}`
|
||||
} else {
|
||||
out += `${TAB_BG_ACTIVE}${icon} ${DIM}${pl.name}${RESET}`
|
||||
}
|
||||
col += 2 + pl.name.length // icon + space + name
|
||||
this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol: paneStartCol, endCol: col - 1 })
|
||||
|
||||
if (pi < paneLabels.length - 1) {
|
||||
out += `${TAB_BG_ACTIVE}${DIM} · ${RESET}`
|
||||
col += 3
|
||||
}
|
||||
}
|
||||
|
||||
if (paneLabels.length === 0) {
|
||||
out += `${TAB_BG_ACTIVE}${DIM}empty${RESET}`
|
||||
col += 5
|
||||
}
|
||||
|
||||
out += `${TAB_BG_ACTIVE} ${closeText}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}╮${RESET}`
|
||||
const closeStartCol = col + 1
|
||||
col += 1 + closeVisLen + 1 + 1 // space + [×] + space + ╮
|
||||
this.tabCloseHitRegions.push({ tabId: tab.id, startCol: closeStartCol, endCol: closeStartCol + closeVisLen - 1 })
|
||||
this.tabBarHitRegions.push({ tabId: tab.id, startCol: tabStartCol, endCol: col - 1 })
|
||||
} else {
|
||||
// Inactive tab: ○ pane1 · pane2 × │
|
||||
const hasIdle = this.hasIdleInTab(tab.id)
|
||||
out += ` `
|
||||
col += 1
|
||||
|
||||
for (let pi = 0; pi < paneLabels.length; pi++) {
|
||||
const pl = paneLabels[pi]!
|
||||
let icon: string
|
||||
if (pl.status === "idle") icon = `${YELLOW_FG}◉${RESET}`
|
||||
else if (pl.status === "busy") icon = `${DIM}●${RESET}`
|
||||
else icon = `${DIM}○${RESET}`
|
||||
|
||||
const paneStartCol = col
|
||||
out += `${icon} ${DIM}${pl.name}${RESET}`
|
||||
col += 2 + pl.name.length
|
||||
this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol: paneStartCol, endCol: col - 1 })
|
||||
|
||||
if (pi < paneLabels.length - 1) {
|
||||
out += `${DIM} · ${RESET}`
|
||||
col += 3
|
||||
}
|
||||
}
|
||||
|
||||
if (paneLabels.length === 0) {
|
||||
out += `${DIM}empty${RESET}`
|
||||
col += 5
|
||||
}
|
||||
|
||||
out += ` ${closeText} ${DIM}│${RESET}`
|
||||
const closeStartCol = col + 1
|
||||
col += 1 + closeVisLen + 1 + 1 // space + [×] + space + │
|
||||
this.tabCloseHitRegions.push({ tabId: tab.id, startCol: closeStartCol, endCol: closeStartCol + closeVisLen - 1 })
|
||||
this.tabBarHitRegions.push({ tabId: tab.id, startCol: tabStartCol, endCol: col - 1 })
|
||||
}
|
||||
}
|
||||
|
||||
// [+] button
|
||||
out += `${DIM}[+]${RESET}`
|
||||
out += ` ${DIM}[+]${RESET}`
|
||||
col += 1
|
||||
this.tabBarAddBtnCol = col
|
||||
col += 3
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private drawPaneList(termW: number): string {
|
||||
this.paneListHitRegions = []
|
||||
let out = `\x1b[2;1H\x1b[${termW}X `
|
||||
let col = 3
|
||||
|
||||
// Show panes across all tabs, grouped by tab
|
||||
for (const tab of app.gridTabs) {
|
||||
const tabPanes = this.tabPanes.get(tab.id) ?? []
|
||||
if (tabPanes.length === 0) continue
|
||||
|
||||
for (let pi = 0; pi < tabPanes.length; pi++) {
|
||||
const pane = tabPanes[pi]!
|
||||
const isFocused = this._activeTabId === tab.id && this._focusIndex === pi
|
||||
const name = pane.session.projectName
|
||||
const short = name.length > 14 ? name.slice(0, 12) + "…" : name
|
||||
const color = getColor(pane.session.colorIndex)
|
||||
|
||||
const startCol = col
|
||||
if (isFocused) {
|
||||
out += `${hexFg(color)}${BOLD}${short}${RESET}`
|
||||
} else {
|
||||
out += `${DIM}${short}${RESET}`
|
||||
}
|
||||
col += short.length
|
||||
this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol, endCol: col - 1 })
|
||||
|
||||
if (pi < tabPanes.length - 1) {
|
||||
out += `${DIM} · ${RESET}`
|
||||
col += 3
|
||||
}
|
||||
}
|
||||
|
||||
out += `${DIM} │ ${RESET}`
|
||||
col += 5
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private drawPaneBorder(index: number): string {
|
||||
const pane = this.panes[index]!
|
||||
@@ -682,16 +828,25 @@ export class DirectGridRenderer {
|
||||
|
||||
let out = ""
|
||||
|
||||
// Top border with buttons
|
||||
// Top border with traffic-light buttons — framed for visibility
|
||||
const RED_BTN = `${hexFg("#f7768e")}[●]${RESET}` // close pane
|
||||
const YELLOW_BTN = `${hexFg("#e0af68")}[●]${RESET}` // minimize / collapse
|
||||
const GREEN_BTN = `${hexFg("#9ece6a")}[●]${RESET}` // expand / maximize
|
||||
const DIM_BTN = `${DIM}[●]${RESET}`
|
||||
|
||||
const BLUE_BTN = `${hexFg("#7dcfff")}[●]${RESET}` // open folder
|
||||
|
||||
let btnSection: string
|
||||
let btnVisibleLen: number
|
||||
if (this.isExpanded) {
|
||||
const selColor = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}` : `${DIM}`
|
||||
btnSection = `${RESET}${selColor}[SEL]${RESET}${borderColor}${hz}${RESET}${hexFg("#7dcfff")}[MIN]${RESET}${borderColor}`
|
||||
btnVisibleLen = 5 + 1 + 5
|
||||
// Expanded: folder · gap · select · minimize · close
|
||||
const selBtn = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}[●]${RESET}` : DIM_BTN
|
||||
btnSection = `${borderColor}${hz}${RESET}${BLUE_BTN}${borderColor} ${hz}${RESET}${selBtn} ${YELLOW_BTN} ${RED_BTN}${borderColor}`
|
||||
btnVisibleLen = 1 + 3 + 1 + 1 + 3 + 1 + 3 + 1 + 3 // ─[●] ─[●] [●] [●]
|
||||
} else {
|
||||
btnSection = `${RESET}${DIM}[MAX]${RESET}${borderColor}`
|
||||
btnVisibleLen = 5
|
||||
// Grid: folder · gap · expand · close
|
||||
btnSection = `${borderColor}${hz}${RESET}${BLUE_BTN}${borderColor} ${hz}${RESET}${GREEN_BTN} ${RED_BTN}${borderColor}`
|
||||
btnVisibleLen = 1 + 3 + 1 + 1 + 3 + 1 + 3 // ─[●] ─[●] [●]
|
||||
}
|
||||
const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1)
|
||||
out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}`
|
||||
@@ -733,6 +888,10 @@ export class DirectGridRenderer {
|
||||
// ─── Content rendering ─────────────────────────────────
|
||||
|
||||
private drawPane(index: number, lines: string[]) {
|
||||
if (this._selectMode) {
|
||||
if (index === this._focusIndex) this.drawSelectView()
|
||||
return
|
||||
}
|
||||
if (this.isExpanded && index !== this._expandedIndex) return
|
||||
const pane = this.panes[index]
|
||||
if (!pane) return
|
||||
@@ -740,6 +899,17 @@ export class DirectGridRenderer {
|
||||
this.writeRaw(SYNC_START + frame + SYNC_END)
|
||||
}
|
||||
|
||||
forceRedrawAll() {
|
||||
if (!this.running) return
|
||||
for (let i = 0; i < this.panes.length; i++) {
|
||||
const pane = this.panes[i]!
|
||||
resetHash(`dp_${pane.session.name}`)
|
||||
const frame = getLatestFrame(pane.session.name)
|
||||
if (frame) this.drawPane(i, frame.lines)
|
||||
}
|
||||
this.drawChrome()
|
||||
}
|
||||
|
||||
// ─── Input ─────────────────────────────────────────────
|
||||
|
||||
sendInputToFocused(rawSequence: string) {
|
||||
@@ -817,7 +987,7 @@ export class DirectGridRenderer {
|
||||
const n = count ?? this.panes.length
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
const chromeTop = 4 // row 1 = tab bar, row 2 = pane list, row 3 = header, content starts row 4
|
||||
const chromeTop = 3 // row 1 = tab bar (with inline panes), row 2 = header, content starts row 3
|
||||
const { cols, rows } = this.calcGrid(n)
|
||||
const cellW = Math.floor(termW / cols)
|
||||
const cellH = Math.floor((termH - chromeTop - 1) / rows) // -1 for footer
|
||||
@@ -843,7 +1013,7 @@ export class DirectGridRenderer {
|
||||
repositionAll() {
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
const chromeTop = 4
|
||||
const chromeTop = 3
|
||||
|
||||
if (this.isExpanded) {
|
||||
// Fullscreen: expanded pane gets all space
|
||||
|
||||
@@ -314,6 +314,7 @@ export function updateProjectSessions(projects: Project[], sessions: Map<string,
|
||||
|
||||
const IDLE_SOUND_DELAY_MS = 10_000
|
||||
const pendingIdle = new Map<string, number>() // path → timestamp when first went idle
|
||||
const notifiedIdle = new Set<string>() // paths already notified — prevents re-trigger
|
||||
|
||||
export function checkTransitions(
|
||||
projects: Project[],
|
||||
@@ -325,20 +326,26 @@ export function checkTransitions(
|
||||
const prev = prevBusy.get(project.path) || 0
|
||||
const isIdle = project.busySessions === 0 && project.activeSessions > 0
|
||||
|
||||
if (prev > 0 && isIdle && !pendingIdle.has(project.path)) {
|
||||
if (!isIdle) {
|
||||
// Not idle — clear notification state so next idle transition can fire
|
||||
notifiedIdle.delete(project.path)
|
||||
pendingIdle.delete(project.path)
|
||||
continue
|
||||
}
|
||||
|
||||
// Already notified for this idle period — skip
|
||||
if (notifiedIdle.has(project.path)) continue
|
||||
|
||||
if (prev > 0 && !pendingIdle.has(project.path)) {
|
||||
// Just transitioned busy→idle — start the delay timer
|
||||
pendingIdle.set(project.path, now)
|
||||
}
|
||||
|
||||
if (pendingIdle.has(project.path)) {
|
||||
if (!isIdle) {
|
||||
// Went busy again — false alarm, cancel
|
||||
pendingIdle.delete(project.path)
|
||||
} else if (now - pendingIdle.get(project.path)! >= IDLE_SOUND_DELAY_MS) {
|
||||
// Confirmed idle for 10+ seconds
|
||||
transitioned.push(project.name)
|
||||
pendingIdle.delete(project.path)
|
||||
}
|
||||
if (pendingIdle.has(project.path) && now - pendingIdle.get(project.path)! >= IDLE_SOUND_DELAY_MS) {
|
||||
// Confirmed idle for 10+ seconds — notify once
|
||||
transitioned.push(project.name)
|
||||
pendingIdle.delete(project.path)
|
||||
notifiedIdle.add(project.path)
|
||||
}
|
||||
}
|
||||
return transitioned
|
||||
|
||||
119
src/data/session-store.ts
Normal file
119
src/data/session-store.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "fs"
|
||||
import { join, dirname } from "path"
|
||||
import { app } from "../lib/state"
|
||||
import type { SavedSession, SavedTab, SavedPane } from "../lib/types"
|
||||
import { createSession } from "../pty/session-manager"
|
||||
import { ensureGridView, switchToGridTab } from "../grid/view-switch"
|
||||
|
||||
const SESSION_PATH = join(process.env.HOME ?? "", ".config", "cladm", "session.json")
|
||||
|
||||
export function extractSessionState(): SavedSession | null {
|
||||
const dg = app.directGrid
|
||||
if (!dg || app.gridTabs.length === 0) return null
|
||||
|
||||
const tabs: SavedTab[] = []
|
||||
for (const tab of app.gridTabs) {
|
||||
const paneInfos = dg.getTabPanes(tab.id)
|
||||
const panes: SavedPane[] = []
|
||||
for (const p of paneInfos) {
|
||||
if (!p.session.alive) continue
|
||||
panes.push({
|
||||
projectPath: p.session.projectPath,
|
||||
projectName: p.session.projectName,
|
||||
sessionId: p.session.sessionId,
|
||||
targetBranch: p.session.targetBranch,
|
||||
})
|
||||
}
|
||||
if (panes.length > 0) {
|
||||
tabs.push({ id: tab.id, name: tab.name, panes })
|
||||
}
|
||||
}
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
const activeIdx = app.gridTabs.findIndex(t => t.id === dg.activeTabId)
|
||||
return {
|
||||
version: 1,
|
||||
savedAt: Date.now(),
|
||||
activeTabIndex: Math.max(0, activeIdx),
|
||||
nextTabId: app.nextTabId,
|
||||
tabs,
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSessionSync(data: SavedSession): void {
|
||||
const dir = dirname(SESSION_PATH)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(SESSION_PATH, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
export async function loadSavedSession(): Promise<SavedSession | null> {
|
||||
try {
|
||||
const file = Bun.file(SESSION_PATH)
|
||||
if (!await file.exists()) return null
|
||||
const data = await file.json() as SavedSession
|
||||
if (data.version !== 1 || !Array.isArray(data.tabs)) return null
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteSavedSession(): void {
|
||||
try { unlinkSync(SESSION_PATH) } catch {}
|
||||
}
|
||||
|
||||
export async function restoreSession(saved: SavedSession, useResume: boolean): Promise<void> {
|
||||
ensureGridView()
|
||||
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
|
||||
let firstTabId: number | null = null
|
||||
|
||||
for (const savedTab of saved.tabs) {
|
||||
const tabId = app.nextTabId++
|
||||
const tab = { id: tabId, name: savedTab.name }
|
||||
app.gridTabs.push(tab)
|
||||
app.directGrid!.addTab(tab)
|
||||
if (firstTabId === null) firstTabId = tabId
|
||||
|
||||
const validPanes = savedTab.panes.filter(p => existsSync(p.projectPath))
|
||||
const n = validPanes.length
|
||||
const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : 3
|
||||
const rows = Math.ceil(n / cols)
|
||||
const paneW = Math.max(Math.floor(termW / cols) - 2, 20)
|
||||
const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6)
|
||||
|
||||
for (const pane of validPanes) {
|
||||
const session = await createSession({
|
||||
projectPath: pane.projectPath,
|
||||
projectName: pane.projectName,
|
||||
sessionId: useResume ? pane.sessionId : undefined,
|
||||
targetBranch: pane.targetBranch,
|
||||
width: paneW,
|
||||
height: paneH,
|
||||
})
|
||||
await app.directGrid!.addPane(session, tabId)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort tabs by name
|
||||
app.gridTabs.sort((a, b) => {
|
||||
const na = parseInt(a.name.replace(/\D/g, "")) || 0
|
||||
const nb = parseInt(b.name.replace(/\D/g, "")) || 0
|
||||
return na - nb
|
||||
})
|
||||
|
||||
// Switch to saved active tab
|
||||
const targetIdx = Math.min(saved.activeTabIndex, app.gridTabs.length - 1)
|
||||
if (targetIdx >= 0 && app.gridTabs[targetIdx]) {
|
||||
switchToGridTab(app.gridTabs[targetIdx].id)
|
||||
} else if (firstTabId !== null) {
|
||||
switchToGridTab(firstTabId)
|
||||
}
|
||||
|
||||
deleteSavedSession()
|
||||
app.savedSession = null
|
||||
app.restoreMode = null
|
||||
}
|
||||
13
src/index.ts
13
src/index.ts
@@ -19,6 +19,7 @@ import { app } from "./lib/state"
|
||||
import { updateAll, rebuildDisplayRows, updateUsagePanel, updateColumnHeaders } from "./ui/panels"
|
||||
import { stdinHandler } from "./input/handlers"
|
||||
import { resizeGridPanes } from "./grid/view-switch"
|
||||
import { loadSavedSession, extractSessionState, saveSessionSync } from "./data/session-store"
|
||||
|
||||
function refreshMockSessions(projects: Project[]) {
|
||||
generateMockActiveSessions(projects)
|
||||
@@ -55,6 +56,9 @@ async function main() {
|
||||
app.sortedIndices = app.projects.map((_, i) => i)
|
||||
rebuildDisplayRows()
|
||||
|
||||
// Load saved session for restore hint
|
||||
app.savedSession = await loadSavedSession()
|
||||
|
||||
// Save raw stdout.write BEFORE OpenTUI intercepts it
|
||||
app.rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean
|
||||
|
||||
@@ -64,6 +68,11 @@ async function main() {
|
||||
useMouse: false,
|
||||
onDestroy: () => {
|
||||
app.destroyed = true
|
||||
// Save session state before cleanup
|
||||
try {
|
||||
const state = extractSessionState()
|
||||
if (state) saveSessionSync(state)
|
||||
} catch (err) { console.error("[session-save]", err) }
|
||||
if (app.monitorInterval) { clearInterval(app.monitorInterval); app.monitorInterval = null }
|
||||
if (app.directGrid) app.directGrid.destroyAll()
|
||||
stopAllCaptures()
|
||||
@@ -178,7 +187,7 @@ async function main() {
|
||||
getUsageSummary().then(u => {
|
||||
app.cachedUsage = u
|
||||
updateUsagePanel()
|
||||
}).catch(() => {})
|
||||
}).catch(err => console.error("[usage]", err))
|
||||
|
||||
// Resize PTY panes when terminal window is resized
|
||||
process.stdout.on("resize", () => {
|
||||
@@ -211,7 +220,7 @@ async function main() {
|
||||
try {
|
||||
app.cachedUsage = await getUsageSummary()
|
||||
updateUsagePanel()
|
||||
} catch {}
|
||||
} catch (err) { console.error("[usage-poll]", err) }
|
||||
}
|
||||
|
||||
if (app.demoMode) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { app } from "../lib/state"
|
||||
import { updateAll, rebuildDisplayRows, applySortMode, updateTabBar } from "../ui/panels"
|
||||
import { extractKeyboardInput, extractMouseEvents } from "./parser"
|
||||
import { switchToGrid, switchToGridTab, createNewGridTab } from "../grid/view-switch"
|
||||
import { doLaunch } from "../actions/launch"
|
||||
import { doLaunch, doAddPane } from "../actions/launch"
|
||||
import { launchSelections } from "../actions/launcher"
|
||||
import { loadSessions } from "../data/sessions"
|
||||
import { loadBranches } from "../data/git"
|
||||
@@ -11,6 +11,7 @@ import { generateMockSessions, generateMockBranches } from "../data/mock"
|
||||
import { focusTerminalByPath, populateMockSessionStatus } from "../data/monitor"
|
||||
import { stopAllCaptures } from "../pty/capture"
|
||||
import type { DisplayRow } from "../lib/types"
|
||||
import { extractSessionState, saveSessionSync, restoreSession } from "../data/session-store"
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -216,27 +217,68 @@ export function handlePickerClick(_col: number, screenRow: number) {
|
||||
function handlePickerTabBarClick(col: number, screenRow: number) {
|
||||
// Tab bar is at row 1 in picker (rendered as OpenTUI text)
|
||||
if (screenRow !== 1) return false
|
||||
// Hit test against tab bar positions (approximate, since OpenTUI renders it)
|
||||
// We compute positions similar to the grid tab bar
|
||||
let c = 2
|
||||
// Picker tab
|
||||
const pickerEnd = c + 7
|
||||
// Hit test against tab bar positions — Chrome-style layout
|
||||
// Picker: ╭ ● Picker ╮ = cols 1..10 (active) or ○ Picker = cols 1..10
|
||||
let c = 1
|
||||
const pickerEnd = c + 10
|
||||
if (col >= c && col <= pickerEnd) return false // already on picker
|
||||
c += 11
|
||||
c = 11
|
||||
|
||||
for (const tab of app.gridTabs) {
|
||||
const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0
|
||||
const label = `${tab.name} (${count})`
|
||||
const visLen = 2 + label.length
|
||||
if (col >= c && col < c + visLen) {
|
||||
switchToGridTab(tab.id)
|
||||
return true
|
||||
const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id
|
||||
|
||||
// Build inline pane name list to calculate width
|
||||
const tabPanes = app.directGrid?.getTabPanes(tab.id) ?? []
|
||||
const paneNames = tabPanes.map(p => {
|
||||
const name = p.session.projectName
|
||||
return name.length > 14 ? name.slice(0, 12) + "…" : name
|
||||
})
|
||||
const inlineLabel = paneNames.length > 0 ? paneNames.join(" · ") : "empty"
|
||||
const visLen = 2 + inlineLabel.length // "● " + label
|
||||
|
||||
const dg = app.directGrid
|
||||
|
||||
if (isActive) {
|
||||
// Active: ╭ ● panes × ╮
|
||||
const labelStart = c + 2
|
||||
const labelEnd = labelStart + visLen - 1
|
||||
const closeCol = labelEnd + 2
|
||||
const totalVis = 1 + 1 + visLen + 1 + 1 + 1 + 1
|
||||
|
||||
if (col === closeCol && dg) {
|
||||
const result = dg.requestCloseTab(tab.id)
|
||||
if (result === "closed") updateAll()
|
||||
else { updateTabBar(); app.renderer.requestRender() }
|
||||
return true
|
||||
}
|
||||
if (col >= labelStart && col <= labelEnd) {
|
||||
switchToGridTab(tab.id)
|
||||
return true
|
||||
}
|
||||
c += totalVis
|
||||
} else {
|
||||
// Inactive: sp ● panes sp × sp │
|
||||
const labelStart = c + 1
|
||||
const labelEnd = labelStart + visLen - 1
|
||||
const closeCol = labelEnd + 2
|
||||
const totalVis = 1 + visLen + 1 + 1 + 1 + 1
|
||||
|
||||
if (col === closeCol && dg) {
|
||||
const result = dg.requestCloseTab(tab.id)
|
||||
if (result === "closed") updateAll()
|
||||
else { updateTabBar(); app.renderer.requestRender() }
|
||||
return true
|
||||
}
|
||||
if (col >= labelStart && col <= labelEnd) {
|
||||
switchToGridTab(tab.id)
|
||||
return true
|
||||
}
|
||||
c += totalVis
|
||||
}
|
||||
c += visLen + 3
|
||||
}
|
||||
|
||||
// [+] button
|
||||
if (col >= c && col <= c + 2) {
|
||||
if (col >= c + 1 && col <= c + 3) {
|
||||
createNewGridTab()
|
||||
return true
|
||||
}
|
||||
@@ -302,6 +344,27 @@ export async function handleKeypress(key: KeyEvent) {
|
||||
case "g": {
|
||||
const row = app.displayRows[app.cursor]
|
||||
const project = app.projects[row.projectIndex]
|
||||
|
||||
// Try grid pane navigation first
|
||||
if (app.directGrid && app.gridTabs.length > 0) {
|
||||
const targetSessionId = row.type === "session" && project.sessions
|
||||
? project.sessions[row.sessionIndex!]?.id
|
||||
: undefined
|
||||
|
||||
for (const tab of app.gridTabs) {
|
||||
const panes = app.directGrid.getTabPanes(tab.id)
|
||||
const paneIdx = targetSessionId
|
||||
? panes.findIndex(p => p.session.projectPath === project.path && p.session.sessionId === targetSessionId)
|
||||
: panes.findIndex(p => p.session.projectPath === project.path)
|
||||
if (paneIdx >= 0) {
|
||||
switchToGridTab(tab.id)
|
||||
app.directGrid.setFocus(paneIdx)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: external terminal
|
||||
if (project.activeSessions > 0) {
|
||||
const sid = row.type === "session" && project.sessions
|
||||
? project.sessions[row.sessionIndex!]?.id
|
||||
@@ -344,7 +407,18 @@ export async function handleKeypress(key: KeyEvent) {
|
||||
case "return": {
|
||||
const hasSelections = app.selectedProjects.size > 0 || app.selectedSessions.size > 0
|
||||
if (hasSelections) {
|
||||
doLaunch()
|
||||
if (app.addPaneTargetTabId !== null) {
|
||||
doAddPane()
|
||||
} else {
|
||||
doLaunch()
|
||||
}
|
||||
break
|
||||
}
|
||||
if (app.addPaneTargetTabId !== null) {
|
||||
// In add-pane mode with no explicit selections, add cursor item
|
||||
const addRow = app.displayRows[app.cursor]
|
||||
if (addRow) app.selectedProjects.set(app.projects[addRow.projectIndex].path, 1)
|
||||
doAddPane()
|
||||
break
|
||||
}
|
||||
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) {
|
||||
@@ -379,10 +453,58 @@ export async function handleKeypress(key: KeyEvent) {
|
||||
break
|
||||
}
|
||||
|
||||
case "q":
|
||||
case "r": {
|
||||
if (app.restoreMode === "pending") {
|
||||
// Second press: restore with resume
|
||||
const saved = app.savedSession
|
||||
if (saved) {
|
||||
app.restoreMode = null
|
||||
await restoreSession(saved, true)
|
||||
return
|
||||
}
|
||||
} else if (app.savedSession) {
|
||||
app.restoreMode = "pending"
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "R": {
|
||||
if (app.restoreMode === "pending") {
|
||||
// Shift+R: restore fresh (no sessionIds)
|
||||
const saved = app.savedSession
|
||||
if (saved) {
|
||||
app.restoreMode = null
|
||||
await restoreSession(saved, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "escape":
|
||||
if (app.addPaneTargetTabId !== null) {
|
||||
// Cancel add-pane mode, return to grid
|
||||
const returnTabId = app.addPaneTargetTabId
|
||||
app.addPaneTargetTabId = null
|
||||
app.selectedProjects.clear()
|
||||
app.selectedSessions.clear()
|
||||
app.selectedBranches.clear()
|
||||
switchToGridTab(returnTabId)
|
||||
return
|
||||
}
|
||||
if (app.restoreMode === "pending") {
|
||||
app.restoreMode = null
|
||||
break
|
||||
}
|
||||
// fall through to quit
|
||||
case "q":
|
||||
app.destroyed = true
|
||||
if (app.monitorInterval) clearInterval(app.monitorInterval)
|
||||
// Save session before exit
|
||||
try {
|
||||
const state = extractSessionState()
|
||||
if (state) saveSessionSync(state)
|
||||
} catch {}
|
||||
stopAllCaptures()
|
||||
process.stdout.write("\x1b[?1006l")
|
||||
process.stdout.write("\x1b[?1000l")
|
||||
@@ -394,7 +516,7 @@ export async function handleKeypress(key: KeyEvent) {
|
||||
}
|
||||
|
||||
updateAll()
|
||||
} catch {}
|
||||
} catch (err) { console.error("[handleKeypress]", err) }
|
||||
}
|
||||
|
||||
// ─── Grid input ──────────────────────────────────────────────────────
|
||||
@@ -422,6 +544,13 @@ export async function handleGridInput(rawSequence: string): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
|
||||
// Ctrl+S → toggle select mode (disable mouse tracking for native text selection)
|
||||
if (rawSequence === "\x13") {
|
||||
if (app.directGrid.selectMode) app.directGrid.exitSelectMode()
|
||||
else app.directGrid.enterSelectMode()
|
||||
return true
|
||||
}
|
||||
|
||||
// Ctrl+E → toggle click-to-expand
|
||||
if (rawSequence === "\x05") {
|
||||
app.clickExpand = !app.clickExpand
|
||||
@@ -440,8 +569,13 @@ export async function handleGridInput(rawSequence: string): Promise<boolean> {
|
||||
if (rawSequence === "\x1bn") { handleNextTab(); return true }
|
||||
if (rawSequence === "\x1bp") { handlePrevTab(); return true }
|
||||
|
||||
// Ctrl+N / Ctrl+P → focus next/prev pane
|
||||
if (rawSequence === "\x0e") { app.directGrid.focusNext(); return true }
|
||||
// Ctrl+N → add pane to current tab (enter picker in add-pane mode)
|
||||
if (rawSequence === "\x0e") {
|
||||
app.addPaneTargetTabId = app.directGrid.activeTabId
|
||||
switchToPicker()
|
||||
return true
|
||||
}
|
||||
// Ctrl+P → focus prev pane
|
||||
if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true }
|
||||
|
||||
// Ctrl+F → open folder
|
||||
@@ -504,6 +638,24 @@ export function switchToPicker() {
|
||||
app.renderer.requestRender()
|
||||
}
|
||||
|
||||
// ─── Double-click detection ──────────────────────────────────────────
|
||||
|
||||
let _lastClickTime = 0
|
||||
let _lastClickCol = 0
|
||||
let _lastClickRow = 0
|
||||
const DOUBLE_CLICK_MS = 400
|
||||
const DOUBLE_CLICK_DIST = 2
|
||||
|
||||
function isDoubleClick(col: number, row: number): boolean {
|
||||
const now = Date.now()
|
||||
const dt = now - _lastClickTime
|
||||
const dist = Math.abs(col - _lastClickCol) + Math.abs(row - _lastClickRow)
|
||||
_lastClickTime = now
|
||||
_lastClickCol = col
|
||||
_lastClickRow = row
|
||||
return dt < DOUBLE_CLICK_MS && dist <= DOUBLE_CLICK_DIST
|
||||
}
|
||||
|
||||
// ─── Stdin: grid mode ────────────────────────────────────────────────
|
||||
|
||||
function processGridInput(str: string) {
|
||||
@@ -519,11 +671,57 @@ function processGridInput(str: string) {
|
||||
if (me.btn === 64) { dg.sendScrollToFocused("up", 3); continue }
|
||||
if (me.btn === 65) { dg.sendScrollToFocused("down", 3); continue }
|
||||
if (me.btn === 0 && !me.release) {
|
||||
// Double-click → enter select mode for native text selection
|
||||
if (isDoubleClick(me.col, me.row)) { dg.enterSelectMode(); return }
|
||||
const btn = dg.checkButtonClick(me.col, me.row)
|
||||
if (btn?.action === "max") dg.expandPane(btn.paneIndex)
|
||||
else if (btn?.action === "min") dg.collapsePane()
|
||||
else if (btn?.action === "sel") dg.enterSelectMode()
|
||||
if (btn?.action === "closetab" && btn.tabId !== undefined) {
|
||||
const result = dg.requestCloseTab(btn.tabId)
|
||||
if (result === "closed") {
|
||||
// Tab was closed — switch to adjacent or picker
|
||||
if (app.gridTabs.length > 0) {
|
||||
const currentTabId = dg.activeTabId
|
||||
if (btn.tabId === currentTabId) {
|
||||
// Closed the active tab — switch to first available
|
||||
switchToGridTab(app.gridTabs[0].id)
|
||||
} else {
|
||||
dg.drawChrome()
|
||||
}
|
||||
} else {
|
||||
switchToPicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (btn?.action === "closepane") {
|
||||
dg.cancelPendingClose()
|
||||
const pane = dg.paneCount > btn.paneIndex ? dg.getTabPanes(dg.activeTabId)[btn.paneIndex] : null
|
||||
if (pane) {
|
||||
if (dg.isExpanded) dg.collapsePane()
|
||||
if (dg.isSoftExpanded) dg.softCollapsePane()
|
||||
dg.removePane(pane.session.name)
|
||||
if (dg.paneCount === 0) {
|
||||
const currentTabId = dg.activeTabId
|
||||
const tabIdx = app.gridTabs.findIndex(t => t.id === currentTabId)
|
||||
dg.removeTab(currentTabId)
|
||||
app.gridTabs.splice(tabIdx, 1)
|
||||
if (app.gridTabs.length > 0) {
|
||||
const prevIdx = Math.max(0, tabIdx - 1)
|
||||
switchToGridTab(app.gridTabs[prevIdx].id)
|
||||
} else {
|
||||
switchToPicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (btn?.action === "max") { dg.cancelPendingClose(); dg.expandPane(btn.paneIndex) }
|
||||
else if (btn?.action === "min") { dg.cancelPendingClose(); dg.collapsePane() }
|
||||
else if (btn?.action === "sel") { dg.cancelPendingClose(); dg.enterSelectMode() }
|
||||
else if (btn?.action === "openfolder") {
|
||||
dg.cancelPendingClose()
|
||||
const p = dg.getTabPanes(dg.activeTabId)[btn.paneIndex]
|
||||
if (p) Bun.spawn(["open", p.session.projectPath])
|
||||
}
|
||||
else if (btn?.action === "tab") {
|
||||
dg.cancelPendingClose()
|
||||
if (btn.tabId === -1) {
|
||||
// Switch to picker
|
||||
app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === dg.activeTabId)
|
||||
@@ -532,14 +730,16 @@ function processGridInput(str: string) {
|
||||
switchToGridTab(btn.tabId)
|
||||
}
|
||||
}
|
||||
else if (btn?.action === "newtab") createNewGridTab()
|
||||
else if (btn?.action === "newtab") { dg.cancelPendingClose(); createNewGridTab() }
|
||||
else if (btn?.action === "panefocus" && btn.tabId !== undefined) {
|
||||
dg.cancelPendingClose()
|
||||
// Click on pane name in pane list → switch to that tab and focus the pane
|
||||
switchToGridTab(btn.tabId)
|
||||
dg.setFocus(btn.paneIndex)
|
||||
if (app.clickExpand) dg.softExpandPane(btn.paneIndex)
|
||||
}
|
||||
else {
|
||||
dg.cancelPendingClose()
|
||||
// Pane body click
|
||||
if (app.clickExpand && !dg.isExpanded) {
|
||||
const clickedIdx = dg.getPaneIndexAtClick(me.col, me.row)
|
||||
@@ -630,10 +830,76 @@ function processPickerInput(str: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stdin entry point ───────────────────────────────────────────────
|
||||
// ─── Stdin buffering ─────────────────────────────────────────────────
|
||||
// SGR mouse sequences (\x1b[<btn;col;rowM) can be split across stdin
|
||||
// data events. Buffer partial escape sequences so fragments don't leak
|
||||
// into the PTY as garbage characters.
|
||||
|
||||
export function stdinHandler(data: string | Buffer) {
|
||||
const str = typeof data === "string" ? data : data.toString("utf8")
|
||||
let _pending = ""
|
||||
let _timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function dispatch(str: string) {
|
||||
if (app.viewMode === "grid" && app.directGrid) processGridInput(str)
|
||||
else processPickerInput(str)
|
||||
}
|
||||
|
||||
function flushPending() {
|
||||
_timer = null
|
||||
if (_pending) {
|
||||
const p = _pending
|
||||
_pending = ""
|
||||
dispatch(p)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns index of a trailing partial escape sequence, or -1 if complete.
|
||||
function trailingPartialEsc(data: string): number {
|
||||
for (let i = data.length - 1; i >= 0 && i >= data.length - 30; i--) {
|
||||
if (data.charCodeAt(i) !== 0x1b) continue
|
||||
const ch = data[i + 1]
|
||||
// Lone ESC at end
|
||||
if (ch === undefined) return i
|
||||
// CSI: \x1b[ — check for final byte
|
||||
if (ch === "[") {
|
||||
let j = i + 2
|
||||
while (j < data.length && data.charCodeAt(j) >= 0x30 && data.charCodeAt(j) <= 0x3f) j++
|
||||
while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) <= 0x2f) j++
|
||||
if (j >= data.length) return i // no final byte yet — partial
|
||||
continue
|
||||
}
|
||||
// OSC/DCS/APC/PM — need ST terminator
|
||||
if (ch === "]" || ch === "P" || ch === "_" || ch === "^") {
|
||||
let terminated = false
|
||||
for (let j = i + 2; j < data.length; j++) {
|
||||
if (data[j] === "\x07") { terminated = true; break }
|
||||
if (data[j] === "\x1b" && data[j + 1] === "\\") { terminated = true; break }
|
||||
}
|
||||
if (!terminated) return i
|
||||
continue
|
||||
}
|
||||
// SS3 (\x1bO) needs one more byte
|
||||
if (ch === "O" && i + 2 >= data.length) return i
|
||||
continue
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ─── Stdin entry point ───────────────────────────────────────────────
|
||||
|
||||
export function stdinHandler(data: string | Buffer) {
|
||||
if (_timer) { clearTimeout(_timer); _timer = null }
|
||||
const str = typeof data === "string" ? data : data.toString("utf8")
|
||||
const full = _pending + str
|
||||
_pending = ""
|
||||
|
||||
const idx = trailingPartialEsc(full)
|
||||
if (idx >= 0) {
|
||||
_pending = full.slice(idx)
|
||||
const ready = full.slice(0, idx)
|
||||
if (ready) dispatch(ready)
|
||||
_timer = setTimeout(flushPending, 8)
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(full)
|
||||
}
|
||||
|
||||
@@ -65,8 +65,10 @@ export function extractKeyboardInput(data: string): string {
|
||||
i += 3; continue
|
||||
}
|
||||
|
||||
// Alt+digit (1-9) and Alt+letter (n, p) — keep as keyboard shortcuts
|
||||
if ((next >= "1" && next <= "9") || next === "n" || next === "p") {
|
||||
// Alt+key combos: \x1b + printable/control char — pass through to PTY
|
||||
// Includes Alt+Backspace (\x1b\x7f), Alt+digits, Alt+letters, etc.
|
||||
const nc = data.charCodeAt(i + 1)
|
||||
if ((nc >= 0x20 && nc <= 0x7e) || nc === 0x7f) {
|
||||
keyboard += data.slice(i, i + 2)
|
||||
i += 2; continue
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import type { BoxRenderable, TextRenderable, ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { Project, DisplayRow } from "./types"
|
||||
import type { Project, DisplayRow, SavedSession } from "./types"
|
||||
import type { DirectGridRenderer } from "../components/direct-grid"
|
||||
import type { UsageSummary } from "../data/usage"
|
||||
import type { IdleSessionInfo } from "../data/monitor"
|
||||
@@ -47,6 +47,9 @@ export const app = {
|
||||
nextTabId: 1, // auto-increment for tab ids
|
||||
clickExpand: true, // click-to-expand feature toggle
|
||||
lastGridTabIndex: 0, // last active grid tab for Ctrl+Space toggle
|
||||
savedSession: null as SavedSession | null,
|
||||
restoreMode: null as "pending" | null,
|
||||
addPaneTargetTabId: null as number | null,
|
||||
|
||||
// UI refs (set during init)
|
||||
renderer: null as unknown as CliRenderer,
|
||||
|
||||
20
src/lib/styled.ts
Normal file
20
src/lib/styled.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { StyledText } from "@opentui/core"
|
||||
|
||||
type Chunk = { __isChunk: true; text: string; attributes: number; fg?: unknown; bg?: unknown }
|
||||
type StyledPart = string | StyledText | Chunk
|
||||
|
||||
// Concatenate styled text parts into a single StyledText.
|
||||
// OpenTUI's t`` tag doesn't handle StyledText interpolation — it calls
|
||||
// toString() which produces "[object Object]". This helper merges chunks
|
||||
// from multiple t`` results, TextChunks, and plain strings.
|
||||
export function st(...parts: StyledPart[]): StyledText {
|
||||
const chunks: Chunk[] = []
|
||||
for (const p of parts) {
|
||||
if (p instanceof StyledText) chunks.push(...p.chunks)
|
||||
else if (p && typeof p === "object" && "__isChunk" in p) chunks.push(p as Chunk)
|
||||
else if (typeof p === "string") {
|
||||
if (p.length > 0) chunks.push({ __isChunk: true, text: p, attributes: 0 } as Chunk)
|
||||
}
|
||||
}
|
||||
return new StyledText(chunks)
|
||||
}
|
||||
@@ -20,6 +20,15 @@ export function elapsedCompact(ms: number): string {
|
||||
return `${Math.floor(sec / 86400)}d`
|
||||
}
|
||||
|
||||
export function timeAgoShort(ms: number): string {
|
||||
if (!ms) return ""
|
||||
const diff = Math.floor((Date.now() - ms) / 1000)
|
||||
if (diff < 60) return "0m ago"
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
|
||||
@@ -46,3 +46,24 @@ export interface DisplayRow {
|
||||
sessionIndex?: number
|
||||
branchName?: string
|
||||
}
|
||||
|
||||
export interface SavedPane {
|
||||
projectPath: string
|
||||
projectName: string
|
||||
sessionId?: string
|
||||
targetBranch?: string
|
||||
}
|
||||
|
||||
export interface SavedTab {
|
||||
id: number
|
||||
name: string
|
||||
panes: SavedPane[]
|
||||
}
|
||||
|
||||
export interface SavedSession {
|
||||
version: 1
|
||||
savedAt: number
|
||||
activeTabIndex: number
|
||||
nextTabId: number
|
||||
tabs: SavedTab[]
|
||||
}
|
||||
|
||||
@@ -195,6 +195,14 @@ class VtScreen {
|
||||
}
|
||||
}
|
||||
|
||||
// Get full buffer: all scrollback lines + current screen (for select mode)
|
||||
getAllLines(): string[] {
|
||||
const lines: string[] = []
|
||||
for (const row of this.scrollback) lines.push(this.renderRow(row))
|
||||
for (let r = 0; r < this.height; r++) lines.push(this.renderRow(this.cells[r]))
|
||||
return lines
|
||||
}
|
||||
|
||||
// Get screen as lines with embedded ANSI SGR codes (like tmux capture-pane -e)
|
||||
// When scrollOffset > 0, shows scrollback history mixed with screen content
|
||||
getLines(): string[] {
|
||||
@@ -613,6 +621,12 @@ export function getLatestFrame(sessionName: string): CaptureResult | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function getFullBuffer(sessionName: string): string[] | null {
|
||||
const state = panes.get(sessionName)
|
||||
if (!state) return null
|
||||
return state.screen.getAllLines()
|
||||
}
|
||||
|
||||
export function stopCapture(sessionName: string): void {
|
||||
const state = panes.get(sessionName)
|
||||
if (!state) return
|
||||
|
||||
@@ -100,18 +100,18 @@ export function killSession(name: string): void {
|
||||
|
||||
export function resizeSession(name: string, width: number, height: number): void {
|
||||
const session = sessions.get(name)
|
||||
if (!session || !session.alive) return
|
||||
if (!session || !session.alive || session.proc.killed) return
|
||||
session.width = width
|
||||
session.height = height
|
||||
// Send APC resize command: \x1b_R<rows>;<cols>\x1b\\
|
||||
const resizeCmd = `\x1b_R${height};${width}\x1b\\`
|
||||
session.proc.stdin.write(resizeCmd)
|
||||
try { session.proc.stdin.write(resizeCmd) } catch {}
|
||||
}
|
||||
|
||||
export function writeToSession(name: string, data: string): void {
|
||||
const session = sessions.get(name)
|
||||
if (!session || !session.alive) return
|
||||
session.proc.stdin.write(data)
|
||||
if (!session || !session.alive || session.proc.killed) return
|
||||
try { session.proc.stdin.write(data) } catch {}
|
||||
}
|
||||
|
||||
export function isAlive(name: string): boolean {
|
||||
|
||||
@@ -22,7 +22,7 @@ export function fmtSyncIndicator(ahead: number, behind: number): string {
|
||||
return parts.join("")
|
||||
}
|
||||
|
||||
const TAB_COLORS = [
|
||||
export const TAB_COLORS = [
|
||||
cyan, // 1
|
||||
green, // 2
|
||||
yellow, // 3
|
||||
@@ -34,6 +34,33 @@ const TAB_COLORS = [
|
||||
(s: string) => fg("#b4f9f8")(s), // 9
|
||||
]
|
||||
|
||||
function getGridTabBadges(projectPath: string): string {
|
||||
if (!app.directGrid || app.gridTabs.length === 0) return ""
|
||||
const badges: string[] = []
|
||||
for (const tab of app.gridTabs) {
|
||||
const panes = app.directGrid.getTabPanes(tab.id)
|
||||
if (panes.some(p => p.session.projectPath === projectPath)) {
|
||||
const displayIdx = app.gridTabs.indexOf(tab) + 1
|
||||
const color = TAB_COLORS[(displayIdx - 1) % TAB_COLORS.length]!
|
||||
badges.push(color(`T${displayIdx}`))
|
||||
}
|
||||
}
|
||||
return badges.length > 0 ? badges.join("") + " " : ""
|
||||
}
|
||||
|
||||
function getSessionGridTabBadge(projectPath: string, sessionId: string): string {
|
||||
if (!app.directGrid || app.gridTabs.length === 0) return ""
|
||||
for (const tab of app.gridTabs) {
|
||||
const panes = app.directGrid.getTabPanes(tab.id)
|
||||
if (panes.some(p => p.session.projectPath === projectPath && p.session.sessionId === sessionId)) {
|
||||
const displayIdx = app.gridTabs.indexOf(tab) + 1
|
||||
const color = TAB_COLORS[(displayIdx - 1) % TAB_COLORS.length]!
|
||||
return " " + color(`T${displayIdx}`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function fmtTabCheck(tabNum: number | undefined) {
|
||||
if (tabNum === undefined) return " "
|
||||
const color = TAB_COLORS[(tabNum - 1) % TAB_COLORS.length]!
|
||||
@@ -83,7 +110,8 @@ export function fmtProjectRow(project: import("../lib/types").Project, isSelecte
|
||||
else if (ca.includes("d ago")) claudeCol = green(ca.padEnd(9))
|
||||
else claudeCol = dim(ca.padEnd(9))
|
||||
|
||||
return t` ${activeDot}${activeTag}[${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim(
|
||||
const gridBadge = getGridTabBadges(project.path)
|
||||
return t` ${activeDot}${activeTag}${gridBadge}[${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim(
|
||||
(project.commitAge || "-").padEnd(10)
|
||||
)}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim(
|
||||
String(project.sessionCount).padStart(3)
|
||||
@@ -120,17 +148,19 @@ export function fmtSessionRow(
|
||||
: session.lastAssistantMsg
|
||||
: "(no text response)"
|
||||
|
||||
const tabBadge = session.id ? getSessionGridTabBadge(project.path, session.id) : ""
|
||||
|
||||
if (status === "busy") {
|
||||
return t` ${green("●")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
|
||||
size.padEnd(7)
|
||||
)} ${fg(ACCENT)('"' + title + '"')} ${green("running")}
|
||||
)} ${fg(ACCENT)('"' + title + '"')} ${green("running")}${tabBadge}
|
||||
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
|
||||
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
|
||||
}
|
||||
if (status === "idle") {
|
||||
return t` ${yellow("◉")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
|
||||
size.padEnd(7)
|
||||
)} ${fg(ACCENT)('"' + title + '"')} ${yellow("idle")}
|
||||
)} ${fg(ACCENT)('"' + title + '"')} ${yellow("idle")}${tabBadge}
|
||||
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
|
||||
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
|
||||
}
|
||||
|
||||
105
src/ui/panels.ts
105
src/ui/panels.ts
@@ -9,12 +9,14 @@ import {
|
||||
yellow,
|
||||
cyan,
|
||||
magenta,
|
||||
red,
|
||||
} from "@opentui/core"
|
||||
import { st } from "../lib/styled"
|
||||
import { app } from "../lib/state"
|
||||
import { CURSOR_BG, ACTIVE_BG, ACCENT } from "../lib/theme"
|
||||
import { getSessionStatus, getIdleSessions } from "../data/monitor"
|
||||
import { formatCost, formatWindow, makeBar, pct, PLAN_LIMITS } from "../data/usage"
|
||||
import { timeAgo, formatSize, elapsedCompact } from "../lib/time"
|
||||
import { timeAgo, formatSize, elapsedCompact, timeAgoShort } from "../lib/time"
|
||||
import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters"
|
||||
|
||||
// ─── Display rows ────────────────────────────────────────────────────
|
||||
@@ -81,7 +83,7 @@ export function updatePaneList() {
|
||||
return
|
||||
}
|
||||
|
||||
let content = t` `
|
||||
const parts: Parameters<typeof st> = [t` `]
|
||||
let first = true
|
||||
for (const tab of app.gridTabs) {
|
||||
const tabPanes = app.directGrid.getTabPanes(tab.id)
|
||||
@@ -93,59 +95,84 @@ export function updatePaneList() {
|
||||
const short = name.length > 14 ? name.slice(0, 12) + "…" : name
|
||||
const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi
|
||||
|
||||
if (!first) content = t`${content}${dim(" · ")}`
|
||||
if (isFocused) {
|
||||
content = t`${content}${bold(short)}`
|
||||
} else {
|
||||
content = t`${content}${dim(short)}`
|
||||
}
|
||||
// Status icon: ● green=running, ◉ yellow=idle, ○ dim=unknown
|
||||
const statusIcon = pane.status === "busy" ? green("● ")
|
||||
: pane.status === "idle" ? yellow("◉ ")
|
||||
: dim("○ ")
|
||||
|
||||
if (!first) parts.push(dim(" · "))
|
||||
parts.push(statusIcon)
|
||||
parts.push(isFocused ? bold(short) : dim(short))
|
||||
first = false
|
||||
}
|
||||
content = t`${content}${dim(" │ ")}`
|
||||
parts.push(dim(" │ "))
|
||||
first = true
|
||||
}
|
||||
app.paneListText.content = content
|
||||
app.paneListText.content = st(...parts)
|
||||
}
|
||||
|
||||
export function updateTabBar() {
|
||||
if (!app.tabBarText) return
|
||||
|
||||
// Build tab bar segments using styled text
|
||||
const sep = dim(" │ ")
|
||||
const pickerActive = app.viewMode === "picker"
|
||||
const pickerTab = pickerActive ? t`${cyan("●")} ${bold("Picker")}` : t`${dim("○ Picker")}`
|
||||
const sep = dim(" │ ")
|
||||
|
||||
// Start with picker
|
||||
let content = t` ${pickerTab}`
|
||||
// Chrome-style: active tab gets visual emphasis
|
||||
const parts: Parameters<typeof st> = []
|
||||
if (pickerActive) {
|
||||
parts.push(t` ${dim("╭")} ${cyan("●")} ${bold("Picker")} ${dim("╮")}`)
|
||||
} else {
|
||||
parts.push(t` ${dim("○ Picker")} `)
|
||||
}
|
||||
|
||||
// Grid tabs
|
||||
// Grid tabs — inline pane names
|
||||
for (const tab of app.gridTabs) {
|
||||
const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0
|
||||
const hasIdle = app.directGrid?.hasIdleInTab(tab.id) ?? false
|
||||
const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id
|
||||
const label = `${tab.name} (${count})`
|
||||
const isPending = app.directGrid?.pendingCloseTabId === tab.id
|
||||
const closeBtn = isPending ? t` ${red(bold("[●]"))}` : t` ${dim("[×]")}`
|
||||
|
||||
// Build inline pane name list
|
||||
const tabPanes = app.directGrid?.getTabPanes(tab.id) ?? []
|
||||
const paneNames = tabPanes.map(p => {
|
||||
const name = p.session.projectName
|
||||
return name.length > 14 ? name.slice(0, 12) + "…" : name
|
||||
})
|
||||
const inlineLabel = paneNames.length > 0 ? paneNames.join(" · ") : "empty"
|
||||
|
||||
if (isActive) {
|
||||
content = t`${content}${sep}${cyan("●")} ${bold(label)}`
|
||||
parts.push(dim("╭"), t` ${cyan("●")} ${bold(inlineLabel)}`, closeBtn, t` ${dim("╮")}`)
|
||||
} else if (hasIdle) {
|
||||
content = t`${content}${sep}${yellow("◉")} ${label}`
|
||||
parts.push(t` ${yellow("◉")} ${dim(inlineLabel)}`, closeBtn, " ", sep)
|
||||
} else {
|
||||
content = t`${content}${sep}${dim("○ " + label)}`
|
||||
parts.push(t` ${dim("○ " + inlineLabel)}`, closeBtn, " ", sep)
|
||||
}
|
||||
}
|
||||
|
||||
content = t`${content}${sep}${dim("[+]")}`
|
||||
app.tabBarText.content = content
|
||||
parts.push(t` ${dim("[+]")}`)
|
||||
app.tabBarText.content = st(...parts)
|
||||
}
|
||||
|
||||
// ─── Header / Footer ─────────────────────────────────────────────────
|
||||
|
||||
export function updateHeader() {
|
||||
const total = app.selectedProjects.size + app.selectedSessions.size
|
||||
const modeLabel = app.demoMode ? " [DEMO]" : ""
|
||||
|
||||
// Add-pane mode: show target tab context
|
||||
if (app.addPaneTargetTabId !== null) {
|
||||
const targetTab = app.gridTabs.find(t => t.id === app.addPaneTargetTabId)
|
||||
const tabName = targetTab?.name ?? `Tab ${app.addPaneTargetTabId}`
|
||||
app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${cyan(bold(`Adding to: ${tabName}`))} │ ${String(total)} selected ${dim(
|
||||
`sort: ${app.sortLabels[app.sortMode]} │ ${app.projects.length} projects`
|
||||
)}`
|
||||
return
|
||||
}
|
||||
|
||||
// Count distinct tab groups
|
||||
const tabGroups = new Set(app.selectedProjects.values())
|
||||
const tabNote = tabGroups.size > 1 ? ` → ${tabGroups.size} tabs` : ""
|
||||
const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : ""
|
||||
const modeLabel = app.demoMode ? " [DEMO]" : ""
|
||||
const activeCount = app.projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0)
|
||||
const busyCount = app.projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0)
|
||||
const idleCount = activeCount - busyCount
|
||||
@@ -167,13 +194,34 @@ export function updateColumnHeaders() {
|
||||
|
||||
export function updateFooter() {
|
||||
const gridHint = app.directGrid && app.directGrid.totalPaneCount > 0 ? " │ ^space grid" : ""
|
||||
|
||||
// Restore mode: show choice prompt
|
||||
if (app.restoreMode === "pending") {
|
||||
app.footerText.content = t` ${yellow("Restore session?")} ${dim("r resume │ R fresh │ esc cancel")}`
|
||||
return
|
||||
}
|
||||
|
||||
// Saved session hint
|
||||
let restoreHint = ""
|
||||
if (app.savedSession) {
|
||||
const ago = timeAgoShort(app.savedSession.savedAt)
|
||||
const paneCount = app.savedSession.tabs.reduce((sum, t) => sum + t.panes.length, 0)
|
||||
restoreHint = ` │ r restore (${paneCount}p, ${ago})`
|
||||
}
|
||||
|
||||
// Add-pane mode: simplified footer
|
||||
if (app.addPaneTargetTabId !== null) {
|
||||
app.footerText.content = t` ${dim("↑↓ nav │ space select │ → expand │ ← collapse │ enter add │ esc cancel")}`
|
||||
return
|
||||
}
|
||||
|
||||
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) {
|
||||
app.footerText.content = t` ${dim(
|
||||
"↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + gridHint
|
||||
"↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + gridHint + restoreHint
|
||||
)}`
|
||||
} else {
|
||||
app.footerText.content = t` ${dim(
|
||||
"↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" + gridHint
|
||||
"↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" + gridHint + restoreHint
|
||||
)}`
|
||||
}
|
||||
}
|
||||
@@ -310,9 +358,10 @@ export function updatePreview() {
|
||||
const selNote = selBranch === br.name
|
||||
? t` ${green("Selected")} — will launch with: ${dim(`-p "switch to branch ${br.name}, stash if needed"`)}`
|
||||
: t` ${dim("Press space to select this branch for launch")}`
|
||||
app.previewText.content = t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync}
|
||||
app.previewText.content = st(
|
||||
t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync}
|
||||
${dim("Last commit:")} ${br.lastCommitAge} — ${br.lastCommitMsg}
|
||||
${selNote}`
|
||||
`, selNote)
|
||||
}
|
||||
} else {
|
||||
app.previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)}
|
||||
|
||||
Reference in New Issue
Block a user