Compare commits
10 Commits
c722112a7f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccfae53233 | ||
|
|
1a99a83fd7 | ||
|
|
24a20d4fe5 | ||
|
|
f821010dc9 | ||
|
|
a2a9283451 | ||
|
|
e0f1a08098 | ||
|
|
9cf18f5740 | ||
|
|
059004b6f2 | ||
|
|
c2e8fcaa94 | ||
|
|
1e105cd950 |
@@ -14,9 +14,9 @@ const pixel = Silkscreen({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "cladm — Monitor & launch Claude Code sessions",
|
title: "cladm — Claude Code Command Center",
|
||||||
description:
|
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: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: "/favicon.ico", sizes: "32x32" },
|
{ url: "/favicon.ico", sizes: "32x32" },
|
||||||
@@ -26,8 +26,8 @@ export const metadata: Metadata = {
|
|||||||
apple: "/apple-touch-icon.png",
|
apple: "/apple-touch-icon.png",
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "cladm",
|
title: "cladm — Claude Code Command Center",
|
||||||
description: "Monitor & launch Claude Code sessions across all your projects",
|
description: "Manage all your Claude Code sessions from one terminal. Embedded PTY grid, tabbed workspaces, live monitoring, and pane controls.",
|
||||||
url: "https://claudm.com",
|
url: "https://claudm.com",
|
||||||
siteName: "cladm",
|
siteName: "cladm",
|
||||||
type: "website",
|
type: "website",
|
||||||
@@ -36,14 +36,14 @@ export const metadata: Metadata = {
|
|||||||
url: "/og-image.png",
|
url: "/og-image.png",
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
alt: "cladm — Monitor & launch Claude Code sessions",
|
alt: "cladm — Claude Code Command Center",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "cladm",
|
title: "cladm — Claude Code Command Center",
|
||||||
description: "Monitor & launch Claude Code sessions across all your projects",
|
description: "Manage all your Claude Code sessions from one terminal. Embedded PTY grid, tabbed workspaces, live monitoring, and pane controls.",
|
||||||
images: ["/og-image.png"],
|
images: ["/og-image.png"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ import {
|
|||||||
NetworkIcon,
|
NetworkIcon,
|
||||||
GamepadIcon,
|
GamepadIcon,
|
||||||
BlocksIcon,
|
BlocksIcon,
|
||||||
ArrowRightIcon,
|
|
||||||
ExternalLinkIcon,
|
|
||||||
LinkedinIcon,
|
LinkedinIcon,
|
||||||
MailIcon,
|
|
||||||
SpaceInvadersIcon,
|
SpaceInvadersIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
BellIcon,
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-bg selection:bg-accent/30">
|
<div className="min-h-screen bg-bg selection:bg-accent/30">
|
||||||
<SubscribeModal />
|
<SubscribeModal />
|
||||||
|
|
||||||
{/* ══════ HERO ══════ */}
|
{/* ══════ HERO ══════ */}
|
||||||
<section className="relative overflow-hidden scanlines">
|
<section className="relative overflow-hidden scanlines">
|
||||||
{/* Grid background */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-[0.04]"
|
className="absolute inset-0 opacity-[0.04]"
|
||||||
style={{
|
style={{
|
||||||
@@ -142,14 +182,13 @@ export default function Home() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="font-[family-name:var(--font-pixel)] text-accent text-lg md:text-xl mb-5">
|
<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>
|
||||||
|
|
||||||
<p className="font-[family-name:var(--font-mono)] text-dim text-sm max-w-md leading-relaxed mb-8">
|
<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
|
Manage all your Claude Code sessions from one terminal.
|
||||||
busy/idle status in real time, monitor usage costs, get
|
An embedded PTY grid with tabbed workspaces, pane controls,
|
||||||
notified when Claude finishes, and launch everything in
|
real-time status tracking, and full keyboard-driven workflow.
|
||||||
parallel Terminal windows.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Install command */}
|
{/* Install command */}
|
||||||
@@ -169,7 +208,7 @@ export default function Home() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right — terminal cascade */}
|
{/* Right — terminal cascade: picker → grid */}
|
||||||
<div className="flex-1 w-full max-w-xl">
|
<div className="flex-1 w-full max-w-xl">
|
||||||
<TerminalCascade />
|
<TerminalCascade />
|
||||||
</div>
|
</div>
|
||||||
@@ -186,50 +225,185 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ══════ DEMO GIF ══════ */}
|
{/* ══════ THE WORKSPACE ══════ */}
|
||||||
<section className="max-w-5xl mx-auto px-6 py-20">
|
<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">
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<TerminalWindow title="cladm">
|
{/* 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 />
|
||||||
|
|
||||||
|
{/* ══════ SMART PICKER ══════ */}
|
||||||
|
<section className="max-w-5xl mx-auto px-6 py-16">
|
||||||
|
<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="mt-8">
|
||||||
|
<TerminalWindow title="cladm — 8 projects">
|
||||||
<Image
|
<Image
|
||||||
src="/demo.gif"
|
src="/demo.gif"
|
||||||
alt="cladm demo showing project navigation"
|
alt="cladm smart picker showing project navigation and selection"
|
||||||
width={980}
|
width={980}
|
||||||
height={500}
|
height={500}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</TerminalWindow>
|
</TerminalWindow>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<PixelDivider />
|
{/* Picker screenshots */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-12">
|
||||||
{/* ══════ SCREENSHOTS ══════ */}
|
|
||||||
<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="space-y-16">
|
|
||||||
{/* Main view */}
|
|
||||||
<div>
|
<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" />
|
<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">
|
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider whitespace-nowrap">
|
||||||
PROJECT LIST
|
PROJECT LIST
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-[2px] flex-1 bg-border" />
|
<div className="h-[2px] flex-1 bg-border" />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-6">
|
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] text-center mb-4">
|
||||||
All your projects sorted by recent Claude usage. Git branch, sync
|
Sorted by recent Claude usage. Git metadata, session count, and stack tags at a glance.
|
||||||
status, dirty state, session count, and auto-detected stack at a
|
|
||||||
glance.
|
|
||||||
</p>
|
</p>
|
||||||
<TerminalWindow title="cladm — 8 projects">
|
<TerminalWindow title="cladm — project list">
|
||||||
<Image
|
<Image
|
||||||
src="/screenshot-main.png"
|
src="/screenshot-main.png"
|
||||||
alt="cladm main project list view"
|
alt="cladm main project list view"
|
||||||
@@ -240,25 +414,21 @@ export default function Home() {
|
|||||||
</TerminalWindow>
|
</TerminalWindow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded view */}
|
|
||||||
<div>
|
<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" />
|
<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">
|
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider whitespace-nowrap">
|
||||||
EXPANDED VIEW
|
EXPANDED VIEW
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-[2px] flex-1 bg-border" />
|
<div className="h-[2px] flex-1 bg-border" />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-6">
|
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] text-center mb-4">
|
||||||
Press <Keycap>→</Keycap> to expand. Browse branches, see
|
Browse branches, past sessions with conversation previews. Resume any session directly.
|
||||||
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>
|
</p>
|
||||||
<TerminalWindow title="cladm — 2 selected (1 branch switch)">
|
<TerminalWindow title="cladm — expanded">
|
||||||
<Image
|
<Image
|
||||||
src="/screenshot-expanded.png"
|
src="/screenshot-expanded.png"
|
||||||
alt="cladm expanded view with sessions and branches"
|
alt="cladm expanded view with sessions"
|
||||||
width={980}
|
width={980}
|
||||||
height={600}
|
height={600}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -273,104 +443,83 @@ export default function Home() {
|
|||||||
{/* ══════ LIVE MONITORING ══════ */}
|
{/* ══════ LIVE MONITORING ══════ */}
|
||||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
<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">
|
<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>
|
</h2>
|
||||||
|
|
||||||
<div className="max-w-2xl mx-auto">
|
<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">
|
<div className="pixel-border bg-surface p-6">
|
||||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs leading-relaxed mb-5">
|
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider mb-4">
|
||||||
cladm detects all running Claude Code sessions across every project and shows their real-time status.
|
Session Status
|
||||||
When any session finishes, a sound plays and the dock icon bounces — so you never miss it, even across dozens of parallel sessions.
|
</h3>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-3 font-[family-name:var(--font-mono)] text-xs">
|
<div className="space-y-3 font-[family-name:var(--font-mono)] text-xs">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-green text-base">●</span>
|
<span className="text-green text-base">●</span>
|
||||||
<span className="text-text">Busy</span>
|
<span className="text-text">Busy</span>
|
||||||
<span className="text-dim">— Claude is actively processing</span>
|
<span className="text-dim">— Claude is actively working</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-yellow text-base">◉</span>
|
<span className="text-yellow text-base">◉</span>
|
||||||
<span className="text-dim">3m</span>
|
<span className="text-dim">3m</span>
|
||||||
<span className="text-text">Idle</span>
|
<span className="text-text">Idle</span>
|
||||||
<span className="text-dim">— Claude finished 3 min ago, waiting for input</span>
|
<span className="text-dim">— waiting for your input</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-dim text-base">○</span>
|
<span className="text-dim text-base">○</span>
|
||||||
<span className="text-text ml-[22px]">No session</span>
|
<span className="text-text ml-[22px]">No session</span>
|
||||||
<span className="text-dim">— No active Claude process</span>
|
<span className="text-dim">— not running</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4 pt-3 border-t border-border">
|
||||||
<div className="mt-5 pt-4 border-t border-border">
|
|
||||||
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] leading-relaxed">
|
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] leading-relaxed">
|
||||||
Detection reads the tail of each session's JSONL in{" "}
|
Status visible in both picker rows and grid pane headers.
|
||||||
<code className="text-accent">~/.claude/projects/</code>. A session is
|
Sound + dock bounce on idle transitions.
|
||||||
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<PixelDivider />
|
{/* Usage tracking */}
|
||||||
|
<div className="pixel-border bg-surface p-6">
|
||||||
{/* ══════ USAGE + IDLE PANELS ══════ */}
|
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider mb-4">
|
||||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
Usage Tracking
|
||||||
<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
|
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-[2px] flex-1 bg-border" />
|
<div className="space-y-3 font-[family-name:var(--font-mono)] text-[10px]">
|
||||||
</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>
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex justify-between mb-1">
|
||||||
<div className="h-[2px] flex-1 bg-border" />
|
<span className="text-dim">session (5h)</span>
|
||||||
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider whitespace-nowrap">
|
<span className="text-text">$2.40 / $5.00</span>
|
||||||
IDLE SESSIONS
|
|
||||||
</h3>
|
|
||||||
<div className="h-[2px] flex-1 bg-border" />
|
|
||||||
</div>
|
</div>
|
||||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-6">
|
<div className="h-2 bg-bg border border-border">
|
||||||
Press <Keycap>i</Keycap> to toggle. Shows sessions waiting for your
|
<div className="h-full bg-accent" style={{ width: "48%" }} />
|
||||||
input, sorted by most recently idle. Press Enter to focus a session's
|
</div>
|
||||||
Terminal tab directly.
|
</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>
|
</p>
|
||||||
<TerminalWindow title="cladm — idle sessions (2)">
|
</div>
|
||||||
<Image
|
</div>
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -384,81 +533,84 @@ export default function Home() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<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
|
<FeatureBlock
|
||||||
icon={<EyeIcon size={28} />}
|
icon={<EyeIcon size={28} />}
|
||||||
title="LIVE MONITORING"
|
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
|
<FeatureBlock
|
||||||
icon={<TrendingUpIcon size={28} />}
|
icon={<TrendingUpIcon size={28} />}
|
||||||
title="USAGE TRACKING"
|
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
|
<FeatureBlock
|
||||||
icon={<BellIcon size={28} />}
|
icon={<BellIcon size={28} />}
|
||||||
title="NOTIFICATIONS"
|
title="NOTIFICATIONS"
|
||||||
desc="Sound + dock bounce when any session finishes. Never miss a completed task across dozens of parallel sessions."
|
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
|
<FeatureBlock
|
||||||
icon={<FolderIcon size={28} />}
|
icon={<FolderIcon size={28} />}
|
||||||
title="SESSION BROWSER"
|
title="AUTO-DISCOVERY"
|
||||||
desc="Expand any project to browse past sessions. See conversation previews and resume directly."
|
desc="Reads ~/.claude/history.jsonl to find every project. Git branch, sync status, dirty state — all loaded in parallel."
|
||||||
/>
|
/>
|
||||||
<FeatureBlock
|
<FeatureBlock
|
||||||
icon={<TerminalIcon size={28} />}
|
icon={<ThunderIcon size={28} />}
|
||||||
title="PARALLEL LAUNCH"
|
title="DIRECT PTY"
|
||||||
desc="Select multiple projects and hit Enter. Each opens in a new Terminal.app window simultaneously."
|
desc="Native pseudo-terminal management via forkpty(). No tmux dependency. Zero configuration. Just works."
|
||||||
/>
|
|
||||||
<FeatureBlock
|
|
||||||
icon={<BlocksIcon size={28} />}
|
|
||||||
title="STACK DETECTION"
|
|
||||||
desc="Auto-detects project stack: TypeScript, Python, Rust, Go, Docker, and more from config files."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<PixelDivider />
|
<PixelDivider />
|
||||||
|
|
||||||
{/* ══════ KEYBINDINGS ══════ */}
|
{/* ══════ CONTROLS ══════ */}
|
||||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
<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">
|
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
|
||||||
// CONTROLS
|
// CONTROLS
|
||||||
</h2>
|
</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="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"],
|
["↑ ↓", "Navigate"],
|
||||||
["Space", "Toggle selection"],
|
["Space", "Toggle select"],
|
||||||
["→", "Expand project"],
|
["→", "Expand project"],
|
||||||
["←", "Collapse"],
|
["←", "Collapse"],
|
||||||
["Enter", "Launch selected / focus session"],
|
["Enter", "Launch grid"],
|
||||||
["i", "Toggle idle sessions panel"],
|
["/", "Filter"],
|
||||||
["u", "Toggle usage panel"],
|
|
||||||
["/", "Filter projects"],
|
|
||||||
["a", "Select all"],
|
["a", "Select all"],
|
||||||
["n", "Deselect all"],
|
["n", "Deselect all"],
|
||||||
["s", "Cycle sort mode"],
|
["s", "Cycle sort"],
|
||||||
["f", "Open folder in Finder"],
|
["u", "Usage panel"],
|
||||||
["g", "Go to active session"],
|
["i", "Idle sessions"],
|
||||||
["PgUp PgDn", "Jump 15 rows"],
|
["f", "Open folder"],
|
||||||
["q / Esc", "Quit"],
|
["g", "Go to session"],
|
||||||
|
["q", "Quit"],
|
||||||
].map(([key, desc]) => (
|
].map(([key, desc]) => (
|
||||||
<div key={key} className="contents">
|
<div key={key} className="contents">
|
||||||
<div className="text-accent">{key}</div>
|
<div className="text-accent">{key}</div>
|
||||||
@@ -467,12 +619,58 @@ export default function Home() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<PixelDivider />
|
<PixelDivider />
|
||||||
|
|
||||||
{/* ══════ INSTALL ══════ */}
|
{/* ══════ QUICK START ══════ */}
|
||||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
<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">
|
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
|
||||||
// QUICK START
|
// QUICK START
|
||||||
@@ -514,91 +712,13 @@ export default function Home() {
|
|||||||
|
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<p className="font-[family-name:var(--font-mono)] text-dim text-xs">
|
<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>
|
<code className="text-yellow">cladm --demo</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 ══════ */}
|
{/* ══════ NEWSLETTER ══════ */}
|
||||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
<section className="max-w-5xl mx-auto px-6 py-16">
|
||||||
<div className="max-w-md mx-auto">
|
<div className="max-w-md mx-auto">
|
||||||
@@ -665,7 +785,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 font-[family-name:var(--font-mono)] text-dim text-[10px] text-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
const projects = [
|
const projects = [
|
||||||
{ name: "acme-api", branch: "main", time: "25m ago", status: "busy" as const },
|
{ name: "acme-api", branch: "main", time: "25m ago", status: "busy" as const },
|
||||||
@@ -10,13 +9,13 @@ const projects = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type Phase =
|
type Phase =
|
||||||
| "typing" // cladm console visible, cursor selecting projects
|
| "typing"
|
||||||
| "selecting" // checkboxes toggling on one by one
|
| "selecting"
|
||||||
| "enter" // "Enter" flash, cladm fades
|
| "enter"
|
||||||
| "cascade" // terminals fly in
|
| "grid"
|
||||||
| "hold" // terminals visible
|
| "hold"
|
||||||
| "fadeout" // everything fades, restart
|
| "fadeout"
|
||||||
| "pause"; // brief gap before loop
|
| "pause";
|
||||||
|
|
||||||
export function TerminalCascade() {
|
export function TerminalCascade() {
|
||||||
const [phase, setPhase] = useState<Phase>("typing");
|
const [phase, setPhase] = useState<Phase>("typing");
|
||||||
@@ -27,31 +26,18 @@ export function TerminalCascade() {
|
|||||||
setSelectedCount(0);
|
setSelectedCount(0);
|
||||||
setPhase("typing");
|
setPhase("typing");
|
||||||
|
|
||||||
// Typing/appear cladm console
|
|
||||||
const t1 = setTimeout(() => setPhase("selecting"), 800);
|
const t1 = setTimeout(() => setPhase("selecting"), 800);
|
||||||
|
|
||||||
// Toggle checkboxes one by one
|
|
||||||
const t2 = setTimeout(() => setSelectedCount(1), 1200);
|
const t2 = setTimeout(() => setSelectedCount(1), 1200);
|
||||||
const t3 = setTimeout(() => setSelectedCount(2), 1600);
|
const t3 = setTimeout(() => setSelectedCount(2), 1600);
|
||||||
const t4 = setTimeout(() => setSelectedCount(3), 2000);
|
const t4 = setTimeout(() => setSelectedCount(3), 2000);
|
||||||
|
|
||||||
// Enter pressed
|
|
||||||
const t5 = setTimeout(() => setPhase("enter"), 2600);
|
const t5 = setTimeout(() => setPhase("enter"), 2600);
|
||||||
|
const t6 = setTimeout(() => setPhase("grid"), 3400);
|
||||||
// Cascade terminals in
|
const t7 = setTimeout(() => setPhase("hold"), 4000);
|
||||||
const t6 = setTimeout(() => setPhase("cascade"), 3200);
|
const t8 = setTimeout(() => setPhase("fadeout"), 7000);
|
||||||
|
|
||||||
// Hold
|
|
||||||
const t7 = setTimeout(() => setPhase("hold"), 3800);
|
|
||||||
|
|
||||||
// Fade out
|
|
||||||
const t8 = setTimeout(() => setPhase("fadeout"), 6200);
|
|
||||||
|
|
||||||
// Pause then restart
|
|
||||||
const t9 = setTimeout(() => {
|
const t9 = setTimeout(() => {
|
||||||
setPhase("pause");
|
setPhase("pause");
|
||||||
setCycle((c) => c + 1);
|
setCycle((c) => c + 1);
|
||||||
}, 7000);
|
}, 7800);
|
||||||
|
|
||||||
return [t1, t2, t3, t4, t5, t6, t7, t8, t9];
|
return [t1, t2, t3, t4, t5, t6, t7, t8, t9];
|
||||||
}, []);
|
}, []);
|
||||||
@@ -65,19 +51,18 @@ export function TerminalCascade() {
|
|||||||
return () => clearTimeout(start);
|
return () => clearTimeout(start);
|
||||||
}, [cycle, runCycle]);
|
}, [cycle, runCycle]);
|
||||||
|
|
||||||
const showCladm = phase === "typing" || phase === "selecting" || phase === "enter";
|
const showPicker = phase === "typing" || phase === "selecting" || phase === "enter";
|
||||||
const showCascade = phase === "cascade" || phase === "hold" || phase === "fadeout";
|
const showGrid = phase === "grid" || phase === "hold" || phase === "fadeout";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full min-h-[340px]">
|
<div className="relative w-full min-h-[360px]">
|
||||||
{/* ── CLADM console (cause) ── */}
|
{/* ── Picker (select projects) ── */}
|
||||||
<div
|
<div
|
||||||
className={`transition-all duration-500 ${
|
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">
|
<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="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-[#ff5f56]" />
|
||||||
<div className="w-3 h-3 bg-[#ffbd2e]" />
|
<div className="w-3 h-3 bg-[#ffbd2e]" />
|
||||||
@@ -86,7 +71,6 @@ export function TerminalCascade() {
|
|||||||
cladm — {selectedCount} selected
|
cladm — {selectedCount} selected
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Project rows */}
|
|
||||||
<div className="p-3 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed">
|
<div className="p-3 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed">
|
||||||
<div className="text-dim mb-1 text-[10px]">
|
<div className="text-dim mb-1 text-[10px]">
|
||||||
{" PROJECT BRANCH LAST USE"}
|
{" PROJECT BRANCH LAST USE"}
|
||||||
@@ -121,15 +105,14 @@ export function TerminalCascade() {
|
|||||||
<span>○</span><span> </span>[ ] pixel-engine{" "}develop{" "}3h ago
|
<span>○</span><span> </span>[ ] pixel-engine{" "}develop{" "}3h ago
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enter hint */}
|
|
||||||
<div className="mt-3 pt-2 border-t border-border text-[10px]">
|
<div className="mt-3 pt-2 border-t border-border text-[10px]">
|
||||||
{phase === "enter" ? (
|
{phase === "enter" ? (
|
||||||
<span className="text-accent font-bold cascade-flash">
|
<span className="text-accent font-bold cascade-flash">
|
||||||
⏎ Launching 3 projects...
|
⏎ Launching 3 sessions into grid...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-dim">
|
<span className="text-dim">
|
||||||
↑↓ navigate · space toggle · enter launch
|
↑↓ navigate · space toggle · enter launch grid
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -137,45 +120,92 @@ export function TerminalCascade() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Terminal cascade (effect) ── */}
|
{/* ── Grid workspace (result) ── */}
|
||||||
<div
|
<div
|
||||||
className={`transition-opacity duration-500 ${
|
className={`transition-opacity duration-600 ${
|
||||||
showCascade ? "opacity-100" : "opacity-0 pointer-events-none absolute inset-0"
|
showGrid ? "opacity-100" : "opacity-0 pointer-events-none absolute inset-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="relative h-[320px]">
|
<div className={`pixel-border bg-surface overflow-hidden ${phase === "hold" ? "cascade-glow" : phase === "grid" ? "cascade-in" : ""}`}>
|
||||||
{projects.map((proj, i) => (
|
{/* Tab bar */}
|
||||||
<div
|
<div className="flex items-center bg-surface-2 border-b-2 border-border">
|
||||||
key={`term-${proj.name}-${cycle}`}
|
<div className="px-3 py-1.5 border-b-2 border-accent font-[family-name:var(--font-mono)] text-[9px]">
|
||||||
className={`absolute left-0 right-0 border-2 bg-surface overflow-hidden
|
<span className="text-green">●</span>
|
||||||
${phase === "cascade" || phase === "hold" ? "cascade-in" : ""}
|
<span className="text-text"> acme-api</span>
|
||||||
${phase === "hold" && i === projects.length - 1 ? "cascade-glow" : ""}`}
|
<span className="text-dim"> · </span>
|
||||||
style={{
|
<span className="text-yellow">◉</span>
|
||||||
animationDelay: `${i * 0.2}s`,
|
<span className="text-text"> quantum-dash</span>
|
||||||
top: `${i * 80}px`,
|
</div>
|
||||||
marginLeft: `${i * 16}px`,
|
<div className="px-3 py-1.5 font-[family-name:var(--font-mono)] text-[9px] text-dim border-b-2 border-transparent">
|
||||||
marginRight: `${(projects.length - 1 - i) * 16}px`,
|
<span className="text-green">●</span>
|
||||||
zIndex: i + 1,
|
<span> ml-pipeline</span>
|
||||||
borderColor: "var(--color-border)",
|
</div>
|
||||||
}}
|
</div>
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-surface-2 border-b border-border">
|
{/* Pane grid */}
|
||||||
<div className="w-[7px] h-[7px] bg-[#ff5f56]" />
|
<div className="grid grid-cols-2 gap-px bg-border">
|
||||||
<div className="w-[7px] h-[7px] bg-[#ffbd2e]" />
|
{/* Pane 1: acme-api (busy) */}
|
||||||
<div className="w-[7px] h-[7px] bg-[#27c93f]" />
|
<div className="bg-surface">
|
||||||
<span className="ml-2 font-[family-name:var(--font-mono)] text-dim text-[9px] truncate">
|
<div className="flex items-center justify-between px-2 py-[3px] border-b border-border">
|
||||||
claude — {proj.name}
|
<div className="font-[family-name:var(--font-mono)] text-[8px]">
|
||||||
</span>
|
<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>
|
||||||
<Image
|
|
||||||
src="/claude-welcome.png"
|
|
||||||
alt="Claude Code welcome screen"
|
|
||||||
width={570}
|
|
||||||
height={260}
|
|
||||||
className="w-full h-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,67 @@ import { ensureGridView, createNewGridTab, switchToGridTab } from "../grid/view-
|
|||||||
import { loadSessions } from "../data/sessions"
|
import { loadSessions } from "../data/sessions"
|
||||||
import { createSession } from "../pty/session-manager"
|
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() {
|
export async function doLaunch() {
|
||||||
if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) return
|
if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) return
|
||||||
if (app.demoMode) {
|
if (app.demoMode) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// Each pane renders independently via PTY capture push callbacks.
|
// Each pane renders independently via PTY capture push callbacks.
|
||||||
|
|
||||||
import { DirectPane } from "./direct-pane"
|
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 { writeToSession, resizeSession, killSession, type PtySession } from "../pty/session-manager"
|
||||||
import { app, type GridTab } from "../lib/state"
|
import { app, type GridTab } from "../lib/state"
|
||||||
|
|
||||||
@@ -84,10 +84,15 @@ export class DirectGridRenderer {
|
|||||||
|
|
||||||
// Tab bar hit-test regions (col ranges for each tab)
|
// Tab bar hit-test regions (col ranges for each tab)
|
||||||
private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = []
|
private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = []
|
||||||
|
private tabCloseHitRegions: { tabId: number, startCol: number, endCol: number }[] = []
|
||||||
private tabBarAddBtnCol = -1
|
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 }[] = []
|
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) {
|
constructor(rawWrite: (s: string) => boolean) {
|
||||||
this.writeRaw = rawWrite
|
this.writeRaw = rawWrite
|
||||||
}
|
}
|
||||||
@@ -164,6 +169,7 @@ export class DirectGridRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.repositionAll()
|
this.repositionAll()
|
||||||
|
setTimeout(() => this.forceRedrawAll(), 100)
|
||||||
this.titleTimer = setInterval(() => this.refreshTitles(), 1000)
|
this.titleTimer = setInterval(() => this.refreshTitles(), 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,18 +185,46 @@ export class DirectGridRenderer {
|
|||||||
get activeTabId() { return this._activeTabId }
|
get activeTabId() { return this._activeTabId }
|
||||||
|
|
||||||
enterSelectMode() {
|
enterSelectMode() {
|
||||||
if (!this.isExpanded) return
|
|
||||||
this._selectMode = true
|
this._selectMode = true
|
||||||
this.writeRaw("\x1b[?1000l\x1b[?1006l")
|
this.writeRaw("\x1b[?1000l\x1b[?1006l")
|
||||||
this.writeRaw(SHOW_CURSOR)
|
this.writeRaw(SHOW_CURSOR)
|
||||||
this.drawChrome()
|
this.drawSelectView()
|
||||||
}
|
}
|
||||||
|
|
||||||
exitSelectMode() {
|
exitSelectMode() {
|
||||||
this._selectMode = false
|
this._selectMode = false
|
||||||
this.writeRaw("\x1b[?1000h\x1b[?1006h")
|
this.writeRaw("\x1b[?1000h\x1b[?1006h")
|
||||||
this.writeRaw(HIDE_CURSOR)
|
this.writeRaw(HIDE_CURSOR + CLEAR)
|
||||||
this.drawChrome()
|
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) {
|
expandPane(index?: number) {
|
||||||
@@ -253,6 +287,48 @@ export class DirectGridRenderer {
|
|||||||
this.tabSoftExpand.delete(tabId)
|
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) {
|
setActiveTab(tabId: number) {
|
||||||
if (this._activeTabId === tabId) return
|
if (this._activeTabId === tabId) return
|
||||||
// Detach current tab's panes
|
// Detach current tab's panes
|
||||||
@@ -274,6 +350,7 @@ export class DirectGridRenderer {
|
|||||||
if (this.running) {
|
if (this.running) {
|
||||||
this.writeRaw(CLEAR)
|
this.writeRaw(CLEAR)
|
||||||
this.repositionAll()
|
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.
|
// 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 {
|
// Hit areas are widened beyond the visible dot characters to make clicking easier.
|
||||||
// Tab bar check (row 1)
|
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) {
|
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) {
|
for (const region of this.tabBarHitRegions) {
|
||||||
if (col >= region.startCol && col <= region.endCol) {
|
if (col >= region.startCol && col <= region.endCol) {
|
||||||
return { action: "tab", paneIndex: -1, tabId: region.tabId }
|
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 { action: "newtab", paneIndex: -1 }
|
||||||
}
|
}
|
||||||
return null
|
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)
|
const indicesToCheck = this.isExpanded ? [this._expandedIndex] : this.panes.map((_, i) => i)
|
||||||
for (const i of indicesToCheck) {
|
for (const i of indicesToCheck) {
|
||||||
@@ -321,21 +403,33 @@ export class DirectGridRenderer {
|
|||||||
const bx = dp.screenX - 1
|
const bx = dp.screenX - 1
|
||||||
const by = dp.screenY - 3
|
const by = dp.screenY - 3
|
||||||
const bw = dp.width + 2
|
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) {
|
if (this.isExpanded) {
|
||||||
const minRight = bx + bw - 2
|
// Layout: ...─[●] ─[●] [●] [●]─╮
|
||||||
const minLeft = minRight - 4
|
// min (yellow): bw-9..bw-7
|
||||||
if (col >= minLeft && col <= minRight) return { action: "min", paneIndex: i }
|
if (col >= bx + bw - 9 && col <= bx + bw - 7) return { action: "min", paneIndex: i }
|
||||||
const selRight = minLeft - 2
|
// sel (green): bw-13..bw-11
|
||||||
const selLeft = selRight - 4
|
if (col >= bx + bw - 13 && col <= bx + bw - 11) return { action: "sel", paneIndex: i }
|
||||||
if (col >= selLeft && col <= selRight) 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 {
|
} else {
|
||||||
const btnLeft = bx + bw - 7
|
// Layout: ...─[●] ─[●] [●]─╮
|
||||||
const btnRight = bx + bw - 3
|
// max (green): bw-9..bw-7
|
||||||
if (col >= btnLeft && col <= btnRight) return { action: "max", paneIndex: i }
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title row (by+1) — click to expand/focus
|
||||||
|
if (row === by + 1 && !this.isExpanded) {
|
||||||
|
return { action: "max", paneIndex: i }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -374,6 +468,10 @@ export class DirectGridRenderer {
|
|||||||
this.drawPane(idx, lines)
|
this.drawPane(idx, lines)
|
||||||
}
|
}
|
||||||
this.repositionAll()
|
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
|
return info
|
||||||
@@ -464,7 +562,7 @@ export class DirectGridRenderer {
|
|||||||
if (n === 0) return false
|
if (n === 0) return false
|
||||||
const termW = process.stdout.columns || 120
|
const termW = process.stdout.columns || 120
|
||||||
const termH = process.stdout.rows || 40
|
const termH = process.stdout.rows || 40
|
||||||
const chromeTop = 4
|
const chromeTop = 3
|
||||||
const { cols } = this.calcGrid(n)
|
const { cols } = this.calcGrid(n)
|
||||||
const rows = Math.ceil(n / cols)
|
const rows = Math.ceil(n / cols)
|
||||||
const cellW = Math.floor(termW / cols)
|
const cellW = Math.floor(termW / cols)
|
||||||
@@ -496,7 +594,7 @@ export class DirectGridRenderer {
|
|||||||
if (n === 0) return -1
|
if (n === 0) return -1
|
||||||
const termW = process.stdout.columns || 120
|
const termW = process.stdout.columns || 120
|
||||||
const termH = process.stdout.rows || 40
|
const termH = process.stdout.rows || 40
|
||||||
const chromeTop = 4
|
const chromeTop = 3
|
||||||
const { cols } = this.calcGrid(n)
|
const { cols } = this.calcGrid(n)
|
||||||
const rows = Math.ceil(n / cols)
|
const rows = Math.ceil(n / cols)
|
||||||
const cellW = Math.floor(termW / cols)
|
const cellW = Math.floor(termW / cols)
|
||||||
@@ -510,19 +608,16 @@ export class DirectGridRenderer {
|
|||||||
// ─── Chrome ────────────────────────────────────────────
|
// ─── Chrome ────────────────────────────────────────────
|
||||||
|
|
||||||
drawChrome() {
|
drawChrome() {
|
||||||
if (!this.running) return
|
if (!this.running || this._selectMode) return
|
||||||
const termW = process.stdout.columns || 120
|
const termW = process.stdout.columns || 120
|
||||||
const termH = process.stdout.rows || 40
|
const termH = process.stdout.rows || 40
|
||||||
|
|
||||||
let out = SYNC_START
|
let out = SYNC_START
|
||||||
|
|
||||||
// Tab bar (row 1)
|
// Tab bar (row 1) — includes inline pane names
|
||||||
out += this.drawTabBar(termW)
|
out += this.drawTabBar(termW)
|
||||||
|
|
||||||
// Pane list (row 2)
|
// Header (row 2)
|
||||||
out += this.drawPaneList(termW)
|
|
||||||
|
|
||||||
// Header (row 3)
|
|
||||||
const n = this.panes.length
|
const n = this.panes.length
|
||||||
const fi = this._focusIndex + 1
|
const fi = this._focusIndex + 1
|
||||||
let headerLeft: string, headerRight: string
|
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}`
|
headerRight = `${DIM}drag to select │ cmd+c copy │ ${BOLD}Esc${RESET}${DIM} exit select${RESET}`
|
||||||
} else if (this.isExpanded) {
|
} else if (this.isExpanded) {
|
||||||
headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#7dcfff")}${BOLD}EXPANDED${RESET} │ ${fi}/${n}`
|
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) {
|
} else if (this.isSoftExpanded) {
|
||||||
headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#bb9af7")}${BOLD}FOCUS${RESET} │ ${fi}/${n}`
|
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 {
|
} else {
|
||||||
headerLeft = ` ${BOLD}cladm grid${RESET} — ${n} sessions │ focus: ${fi}/${n}`
|
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
|
// Pane borders + titles
|
||||||
if (this.isExpanded) {
|
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}`
|
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) {
|
} else if (this.isExpanded && pane) {
|
||||||
const color = getColor(pane.session.colorIndex)
|
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) {
|
} else if (pane) {
|
||||||
const color = getColor(pane.session.colorIndex)
|
const color = getColor(pane.session.colorIndex)
|
||||||
const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : ""
|
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 {
|
private drawTabBar(termW: number): string {
|
||||||
this.tabBarHitRegions = []
|
this.tabBarHitRegions = []
|
||||||
|
this.tabCloseHitRegions = []
|
||||||
|
this.paneListHitRegions = []
|
||||||
this.tabBarAddBtnCol = -1
|
this.tabBarAddBtnCol = -1
|
||||||
|
|
||||||
|
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 out = `\x1b[1;1H\x1b[${termW}X`
|
||||||
let col = 2
|
let col = 1
|
||||||
|
|
||||||
// Picker tab (id = -1, meaning: switch to picker)
|
// Picker tab (id = -1, meaning: switch to picker)
|
||||||
const pickerActive = app.viewMode === "picker"
|
const pickerActive = app.viewMode === "picker"
|
||||||
const pickerLabel = pickerActive ? `${CYAN_FG}${BOLD}● Picker${RESET}` : `${DIM}○ Picker${RESET}`
|
if (pickerActive) {
|
||||||
out += pickerLabel + ` ${DIM}│${RESET} `
|
out += `${TAB_BORDER}╭${RESET}${TAB_BG_ACTIVE} ${CYAN_FG}${BOLD}● Picker${RESET}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}╮${RESET}`
|
||||||
this.tabBarHitRegions.push({ tabId: -1, startCol: col, endCol: col + 7 })
|
} else {
|
||||||
col += 11 // "● Picker │ "
|
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) {
|
for (const tab of app.gridTabs) {
|
||||||
const isActive = this._activeTabId === tab.id && app.viewMode === "grid"
|
const isActive = this._activeTabId === tab.id && app.viewMode === "grid"
|
||||||
const hasIdle = this.hasIdleInTab(tab.id)
|
const isPending = this._pendingCloseTabId === tab.id
|
||||||
const count = this.getTabPaneCount(tab.id)
|
const tabPanes = this.tabPanes.get(tab.id) ?? []
|
||||||
const label = `${tab.name} (${count})`
|
|
||||||
|
|
||||||
let tabText: string
|
// Build pane name list for this tab
|
||||||
if (isActive) {
|
const paneLabels: { name: string, color: string, status: PaneStatus, isFocused: boolean }[] = []
|
||||||
tabText = `${CYAN_FG}${BOLD}● ${label}${RESET}`
|
for (let pi = 0; pi < tabPanes.length; pi++) {
|
||||||
} else if (hasIdle) {
|
const p = tabPanes[pi]!
|
||||||
tabText = `${YELLOW_FG}◉ ${label}${RESET}`
|
const name = p.session.projectName
|
||||||
} else {
|
const short = name.length > 14 ? name.slice(0, 12) + "…" : name
|
||||||
tabText = `${DIM}○ ${label}${RESET}`
|
paneLabels.push({
|
||||||
|
name: short,
|
||||||
|
color: getColor(p.session.colorIndex),
|
||||||
|
status: p.status,
|
||||||
|
isFocused: isActive && this._focusIndex === pi,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const startCol = col
|
// Close button text
|
||||||
out += tabText + ` ${DIM}│${RESET} `
|
const closeText = isPending ? `${RED_FG}${BOLD}[●]${RESET}` : `${DIM}[×]${RESET}`
|
||||||
const visLen = 2 + label.length // "● " + label
|
const closeVisLen = 3
|
||||||
this.tabBarHitRegions.push({ tabId: tab.id, startCol, endCol: startCol + visLen - 1 })
|
|
||||||
col += visLen + 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
|
// [+] button
|
||||||
out += ` ${DIM}[+]${RESET}`
|
out += ` ${DIM}[+]${RESET}`
|
||||||
|
col += 1
|
||||||
this.tabBarAddBtnCol = col
|
this.tabBarAddBtnCol = col
|
||||||
col += 3
|
col += 3
|
||||||
|
|
||||||
return out
|
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 {
|
private drawPaneBorder(index: number): string {
|
||||||
const pane = this.panes[index]!
|
const pane = this.panes[index]!
|
||||||
@@ -682,16 +828,25 @@ export class DirectGridRenderer {
|
|||||||
|
|
||||||
let out = ""
|
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 btnSection: string
|
||||||
let btnVisibleLen: number
|
let btnVisibleLen: number
|
||||||
if (this.isExpanded) {
|
if (this.isExpanded) {
|
||||||
const selColor = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}` : `${DIM}`
|
// Expanded: folder · gap · select · minimize · close
|
||||||
btnSection = `${RESET}${selColor}[SEL]${RESET}${borderColor}${hz}${RESET}${hexFg("#7dcfff")}[MIN]${RESET}${borderColor}`
|
const selBtn = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}[●]${RESET}` : DIM_BTN
|
||||||
btnVisibleLen = 5 + 1 + 5
|
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 {
|
} else {
|
||||||
btnSection = `${RESET}${DIM}[MAX]${RESET}${borderColor}`
|
// Grid: folder · gap · expand · close
|
||||||
btnVisibleLen = 5
|
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)
|
const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1)
|
||||||
out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}`
|
out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}`
|
||||||
@@ -733,6 +888,10 @@ export class DirectGridRenderer {
|
|||||||
// ─── Content rendering ─────────────────────────────────
|
// ─── Content rendering ─────────────────────────────────
|
||||||
|
|
||||||
private drawPane(index: number, lines: string[]) {
|
private drawPane(index: number, lines: string[]) {
|
||||||
|
if (this._selectMode) {
|
||||||
|
if (index === this._focusIndex) this.drawSelectView()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (this.isExpanded && index !== this._expandedIndex) return
|
if (this.isExpanded && index !== this._expandedIndex) return
|
||||||
const pane = this.panes[index]
|
const pane = this.panes[index]
|
||||||
if (!pane) return
|
if (!pane) return
|
||||||
@@ -740,6 +899,17 @@ export class DirectGridRenderer {
|
|||||||
this.writeRaw(SYNC_START + frame + SYNC_END)
|
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 ─────────────────────────────────────────────
|
// ─── Input ─────────────────────────────────────────────
|
||||||
|
|
||||||
sendInputToFocused(rawSequence: string) {
|
sendInputToFocused(rawSequence: string) {
|
||||||
@@ -817,7 +987,7 @@ export class DirectGridRenderer {
|
|||||||
const n = count ?? this.panes.length
|
const n = count ?? this.panes.length
|
||||||
const termW = process.stdout.columns || 120
|
const termW = process.stdout.columns || 120
|
||||||
const termH = process.stdout.rows || 40
|
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 { cols, rows } = this.calcGrid(n)
|
||||||
const cellW = Math.floor(termW / cols)
|
const cellW = Math.floor(termW / cols)
|
||||||
const cellH = Math.floor((termH - chromeTop - 1) / rows) // -1 for footer
|
const cellH = Math.floor((termH - chromeTop - 1) / rows) // -1 for footer
|
||||||
@@ -843,7 +1013,7 @@ export class DirectGridRenderer {
|
|||||||
repositionAll() {
|
repositionAll() {
|
||||||
const termW = process.stdout.columns || 120
|
const termW = process.stdout.columns || 120
|
||||||
const termH = process.stdout.rows || 40
|
const termH = process.stdout.rows || 40
|
||||||
const chromeTop = 4
|
const chromeTop = 3
|
||||||
|
|
||||||
if (this.isExpanded) {
|
if (this.isExpanded) {
|
||||||
// Fullscreen: expanded pane gets all space
|
// 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 IDLE_SOUND_DELAY_MS = 10_000
|
||||||
const pendingIdle = new Map<string, number>() // path → timestamp when first went idle
|
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(
|
export function checkTransitions(
|
||||||
projects: Project[],
|
projects: Project[],
|
||||||
@@ -325,20 +326,26 @@ export function checkTransitions(
|
|||||||
const prev = prevBusy.get(project.path) || 0
|
const prev = prevBusy.get(project.path) || 0
|
||||||
const isIdle = project.busySessions === 0 && project.activeSessions > 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
|
// Just transitioned busy→idle — start the delay timer
|
||||||
pendingIdle.set(project.path, now)
|
pendingIdle.set(project.path, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingIdle.has(project.path)) {
|
if (pendingIdle.has(project.path) && now - pendingIdle.get(project.path)! >= IDLE_SOUND_DELAY_MS) {
|
||||||
if (!isIdle) {
|
// Confirmed idle for 10+ seconds — notify once
|
||||||
// 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)
|
transitioned.push(project.name)
|
||||||
pendingIdle.delete(project.path)
|
pendingIdle.delete(project.path)
|
||||||
}
|
notifiedIdle.add(project.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return transitioned
|
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 { updateAll, rebuildDisplayRows, updateUsagePanel, updateColumnHeaders } from "./ui/panels"
|
||||||
import { stdinHandler } from "./input/handlers"
|
import { stdinHandler } from "./input/handlers"
|
||||||
import { resizeGridPanes } from "./grid/view-switch"
|
import { resizeGridPanes } from "./grid/view-switch"
|
||||||
|
import { loadSavedSession, extractSessionState, saveSessionSync } from "./data/session-store"
|
||||||
|
|
||||||
function refreshMockSessions(projects: Project[]) {
|
function refreshMockSessions(projects: Project[]) {
|
||||||
generateMockActiveSessions(projects)
|
generateMockActiveSessions(projects)
|
||||||
@@ -55,6 +56,9 @@ async function main() {
|
|||||||
app.sortedIndices = app.projects.map((_, i) => i)
|
app.sortedIndices = app.projects.map((_, i) => i)
|
||||||
rebuildDisplayRows()
|
rebuildDisplayRows()
|
||||||
|
|
||||||
|
// Load saved session for restore hint
|
||||||
|
app.savedSession = await loadSavedSession()
|
||||||
|
|
||||||
// Save raw stdout.write BEFORE OpenTUI intercepts it
|
// Save raw stdout.write BEFORE OpenTUI intercepts it
|
||||||
app.rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean
|
app.rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean
|
||||||
|
|
||||||
@@ -64,6 +68,11 @@ async function main() {
|
|||||||
useMouse: false,
|
useMouse: false,
|
||||||
onDestroy: () => {
|
onDestroy: () => {
|
||||||
app.destroyed = true
|
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.monitorInterval) { clearInterval(app.monitorInterval); app.monitorInterval = null }
|
||||||
if (app.directGrid) app.directGrid.destroyAll()
|
if (app.directGrid) app.directGrid.destroyAll()
|
||||||
stopAllCaptures()
|
stopAllCaptures()
|
||||||
@@ -178,7 +187,7 @@ async function main() {
|
|||||||
getUsageSummary().then(u => {
|
getUsageSummary().then(u => {
|
||||||
app.cachedUsage = u
|
app.cachedUsage = u
|
||||||
updateUsagePanel()
|
updateUsagePanel()
|
||||||
}).catch(() => {})
|
}).catch(err => console.error("[usage]", err))
|
||||||
|
|
||||||
// Resize PTY panes when terminal window is resized
|
// Resize PTY panes when terminal window is resized
|
||||||
process.stdout.on("resize", () => {
|
process.stdout.on("resize", () => {
|
||||||
@@ -211,7 +220,7 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
app.cachedUsage = await getUsageSummary()
|
app.cachedUsage = await getUsageSummary()
|
||||||
updateUsagePanel()
|
updateUsagePanel()
|
||||||
} catch {}
|
} catch (err) { console.error("[usage-poll]", err) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app.demoMode) {
|
if (app.demoMode) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { app } from "../lib/state"
|
|||||||
import { updateAll, rebuildDisplayRows, applySortMode, updateTabBar } from "../ui/panels"
|
import { updateAll, rebuildDisplayRows, applySortMode, updateTabBar } from "../ui/panels"
|
||||||
import { extractKeyboardInput, extractMouseEvents } from "./parser"
|
import { extractKeyboardInput, extractMouseEvents } from "./parser"
|
||||||
import { switchToGrid, switchToGridTab, createNewGridTab } from "../grid/view-switch"
|
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 { launchSelections } from "../actions/launcher"
|
||||||
import { loadSessions } from "../data/sessions"
|
import { loadSessions } from "../data/sessions"
|
||||||
import { loadBranches } from "../data/git"
|
import { loadBranches } from "../data/git"
|
||||||
@@ -11,6 +11,7 @@ import { generateMockSessions, generateMockBranches } from "../data/mock"
|
|||||||
import { focusTerminalByPath, populateMockSessionStatus } from "../data/monitor"
|
import { focusTerminalByPath, populateMockSessionStatus } from "../data/monitor"
|
||||||
import { stopAllCaptures } from "../pty/capture"
|
import { stopAllCaptures } from "../pty/capture"
|
||||||
import type { DisplayRow } from "../lib/types"
|
import type { DisplayRow } from "../lib/types"
|
||||||
|
import { extractSessionState, saveSessionSync, restoreSession } from "../data/session-store"
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -216,27 +217,68 @@ export function handlePickerClick(_col: number, screenRow: number) {
|
|||||||
function handlePickerTabBarClick(col: number, screenRow: number) {
|
function handlePickerTabBarClick(col: number, screenRow: number) {
|
||||||
// Tab bar is at row 1 in picker (rendered as OpenTUI text)
|
// Tab bar is at row 1 in picker (rendered as OpenTUI text)
|
||||||
if (screenRow !== 1) return false
|
if (screenRow !== 1) return false
|
||||||
// Hit test against tab bar positions (approximate, since OpenTUI renders it)
|
// Hit test against tab bar positions — Chrome-style layout
|
||||||
// We compute positions similar to the grid tab bar
|
// Picker: ╭ ● Picker ╮ = cols 1..10 (active) or ○ Picker = cols 1..10
|
||||||
let c = 2
|
let c = 1
|
||||||
// Picker tab
|
const pickerEnd = c + 10
|
||||||
const pickerEnd = c + 7
|
|
||||||
if (col >= c && col <= pickerEnd) return false // already on picker
|
if (col >= c && col <= pickerEnd) return false // already on picker
|
||||||
c += 11
|
c = 11
|
||||||
|
|
||||||
for (const tab of app.gridTabs) {
|
for (const tab of app.gridTabs) {
|
||||||
const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0
|
const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id
|
||||||
const label = `${tab.name} (${count})`
|
|
||||||
const visLen = 2 + label.length
|
// Build inline pane name list to calculate width
|
||||||
if (col >= c && col < c + visLen) {
|
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)
|
switchToGridTab(tab.id)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
c += visLen + 3
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [+] button
|
// [+] button
|
||||||
if (col >= c && col <= c + 2) {
|
if (col >= c + 1 && col <= c + 3) {
|
||||||
createNewGridTab()
|
createNewGridTab()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -302,6 +344,27 @@ export async function handleKeypress(key: KeyEvent) {
|
|||||||
case "g": {
|
case "g": {
|
||||||
const row = app.displayRows[app.cursor]
|
const row = app.displayRows[app.cursor]
|
||||||
const project = app.projects[row.projectIndex]
|
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) {
|
if (project.activeSessions > 0) {
|
||||||
const sid = row.type === "session" && project.sessions
|
const sid = row.type === "session" && project.sessions
|
||||||
? project.sessions[row.sessionIndex!]?.id
|
? project.sessions[row.sessionIndex!]?.id
|
||||||
@@ -344,7 +407,18 @@ export async function handleKeypress(key: KeyEvent) {
|
|||||||
case "return": {
|
case "return": {
|
||||||
const hasSelections = app.selectedProjects.size > 0 || app.selectedSessions.size > 0
|
const hasSelections = app.selectedProjects.size > 0 || app.selectedSessions.size > 0
|
||||||
if (hasSelections) {
|
if (hasSelections) {
|
||||||
|
if (app.addPaneTargetTabId !== null) {
|
||||||
|
doAddPane()
|
||||||
|
} else {
|
||||||
doLaunch()
|
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
|
break
|
||||||
}
|
}
|
||||||
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) {
|
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) {
|
||||||
@@ -379,10 +453,58 @@ export async function handleKeypress(key: KeyEvent) {
|
|||||||
break
|
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":
|
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
|
app.destroyed = true
|
||||||
if (app.monitorInterval) clearInterval(app.monitorInterval)
|
if (app.monitorInterval) clearInterval(app.monitorInterval)
|
||||||
|
// Save session before exit
|
||||||
|
try {
|
||||||
|
const state = extractSessionState()
|
||||||
|
if (state) saveSessionSync(state)
|
||||||
|
} catch {}
|
||||||
stopAllCaptures()
|
stopAllCaptures()
|
||||||
process.stdout.write("\x1b[?1006l")
|
process.stdout.write("\x1b[?1006l")
|
||||||
process.stdout.write("\x1b[?1000l")
|
process.stdout.write("\x1b[?1000l")
|
||||||
@@ -394,7 +516,7 @@ export async function handleKeypress(key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateAll()
|
updateAll()
|
||||||
} catch {}
|
} catch (err) { console.error("[handleKeypress]", err) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Grid input ──────────────────────────────────────────────────────
|
// ─── Grid input ──────────────────────────────────────────────────────
|
||||||
@@ -422,6 +544,13 @@ export async function handleGridInput(rawSequence: string): Promise<boolean> {
|
|||||||
return true
|
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
|
// Ctrl+E → toggle click-to-expand
|
||||||
if (rawSequence === "\x05") {
|
if (rawSequence === "\x05") {
|
||||||
app.clickExpand = !app.clickExpand
|
app.clickExpand = !app.clickExpand
|
||||||
@@ -440,8 +569,13 @@ export async function handleGridInput(rawSequence: string): Promise<boolean> {
|
|||||||
if (rawSequence === "\x1bn") { handleNextTab(); return true }
|
if (rawSequence === "\x1bn") { handleNextTab(); return true }
|
||||||
if (rawSequence === "\x1bp") { handlePrevTab(); return true }
|
if (rawSequence === "\x1bp") { handlePrevTab(); return true }
|
||||||
|
|
||||||
// Ctrl+N / Ctrl+P → focus next/prev pane
|
// Ctrl+N → add pane to current tab (enter picker in add-pane mode)
|
||||||
if (rawSequence === "\x0e") { app.directGrid.focusNext(); return true }
|
if (rawSequence === "\x0e") {
|
||||||
|
app.addPaneTargetTabId = app.directGrid.activeTabId
|
||||||
|
switchToPicker()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Ctrl+P → focus prev pane
|
||||||
if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true }
|
if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true }
|
||||||
|
|
||||||
// Ctrl+F → open folder
|
// Ctrl+F → open folder
|
||||||
@@ -504,6 +638,24 @@ export function switchToPicker() {
|
|||||||
app.renderer.requestRender()
|
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 ────────────────────────────────────────────────
|
// ─── Stdin: grid mode ────────────────────────────────────────────────
|
||||||
|
|
||||||
function processGridInput(str: string) {
|
function processGridInput(str: string) {
|
||||||
@@ -519,11 +671,57 @@ function processGridInput(str: string) {
|
|||||||
if (me.btn === 64) { dg.sendScrollToFocused("up", 3); continue }
|
if (me.btn === 64) { dg.sendScrollToFocused("up", 3); continue }
|
||||||
if (me.btn === 65) { dg.sendScrollToFocused("down", 3); continue }
|
if (me.btn === 65) { dg.sendScrollToFocused("down", 3); continue }
|
||||||
if (me.btn === 0 && !me.release) {
|
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)
|
const btn = dg.checkButtonClick(me.col, me.row)
|
||||||
if (btn?.action === "max") dg.expandPane(btn.paneIndex)
|
if (btn?.action === "closetab" && btn.tabId !== undefined) {
|
||||||
else if (btn?.action === "min") dg.collapsePane()
|
const result = dg.requestCloseTab(btn.tabId)
|
||||||
else if (btn?.action === "sel") dg.enterSelectMode()
|
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") {
|
else if (btn?.action === "tab") {
|
||||||
|
dg.cancelPendingClose()
|
||||||
if (btn.tabId === -1) {
|
if (btn.tabId === -1) {
|
||||||
// Switch to picker
|
// Switch to picker
|
||||||
app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === dg.activeTabId)
|
app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === dg.activeTabId)
|
||||||
@@ -532,14 +730,16 @@ function processGridInput(str: string) {
|
|||||||
switchToGridTab(btn.tabId)
|
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) {
|
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
|
// Click on pane name in pane list → switch to that tab and focus the pane
|
||||||
switchToGridTab(btn.tabId)
|
switchToGridTab(btn.tabId)
|
||||||
dg.setFocus(btn.paneIndex)
|
dg.setFocus(btn.paneIndex)
|
||||||
if (app.clickExpand) dg.softExpandPane(btn.paneIndex)
|
if (app.clickExpand) dg.softExpandPane(btn.paneIndex)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
dg.cancelPendingClose()
|
||||||
// Pane body click
|
// Pane body click
|
||||||
if (app.clickExpand && !dg.isExpanded) {
|
if (app.clickExpand && !dg.isExpanded) {
|
||||||
const clickedIdx = dg.getPaneIndexAtClick(me.col, me.row)
|
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) {
|
let _pending = ""
|
||||||
const str = typeof data === "string" ? data : data.toString("utf8")
|
let _timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function dispatch(str: string) {
|
||||||
if (app.viewMode === "grid" && app.directGrid) processGridInput(str)
|
if (app.viewMode === "grid" && app.directGrid) processGridInput(str)
|
||||||
else processPickerInput(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
|
i += 3; continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alt+digit (1-9) and Alt+letter (n, p) — keep as keyboard shortcuts
|
// Alt+key combos: \x1b + printable/control char — pass through to PTY
|
||||||
if ((next >= "1" && next <= "9") || next === "n" || next === "p") {
|
// 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)
|
keyboard += data.slice(i, i + 2)
|
||||||
i += 2; continue
|
i += 2; continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CliRenderer } from "@opentui/core"
|
import type { CliRenderer } from "@opentui/core"
|
||||||
import type { BoxRenderable, TextRenderable, ScrollBoxRenderable } 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 { DirectGridRenderer } from "../components/direct-grid"
|
||||||
import type { UsageSummary } from "../data/usage"
|
import type { UsageSummary } from "../data/usage"
|
||||||
import type { IdleSessionInfo } from "../data/monitor"
|
import type { IdleSessionInfo } from "../data/monitor"
|
||||||
@@ -47,6 +47,9 @@ export const app = {
|
|||||||
nextTabId: 1, // auto-increment for tab ids
|
nextTabId: 1, // auto-increment for tab ids
|
||||||
clickExpand: true, // click-to-expand feature toggle
|
clickExpand: true, // click-to-expand feature toggle
|
||||||
lastGridTabIndex: 0, // last active grid tab for Ctrl+Space 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)
|
// UI refs (set during init)
|
||||||
renderer: null as unknown as CliRenderer,
|
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`
|
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 {
|
export function formatSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes}B`
|
if (bytes < 1024) return `${bytes}B`
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||||
|
|||||||
@@ -46,3 +46,24 @@ export interface DisplayRow {
|
|||||||
sessionIndex?: number
|
sessionIndex?: number
|
||||||
branchName?: string
|
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)
|
// Get screen as lines with embedded ANSI SGR codes (like tmux capture-pane -e)
|
||||||
// When scrollOffset > 0, shows scrollback history mixed with screen content
|
// When scrollOffset > 0, shows scrollback history mixed with screen content
|
||||||
getLines(): string[] {
|
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 {
|
export function stopCapture(sessionName: string): void {
|
||||||
const state = panes.get(sessionName)
|
const state = panes.get(sessionName)
|
||||||
if (!state) return
|
if (!state) return
|
||||||
|
|||||||
@@ -100,18 +100,18 @@ export function killSession(name: string): void {
|
|||||||
|
|
||||||
export function resizeSession(name: string, width: number, height: number): void {
|
export function resizeSession(name: string, width: number, height: number): void {
|
||||||
const session = sessions.get(name)
|
const session = sessions.get(name)
|
||||||
if (!session || !session.alive) return
|
if (!session || !session.alive || session.proc.killed) return
|
||||||
session.width = width
|
session.width = width
|
||||||
session.height = height
|
session.height = height
|
||||||
// Send APC resize command: \x1b_R<rows>;<cols>\x1b\\
|
// Send APC resize command: \x1b_R<rows>;<cols>\x1b\\
|
||||||
const resizeCmd = `\x1b_R${height};${width}\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 {
|
export function writeToSession(name: string, data: string): void {
|
||||||
const session = sessions.get(name)
|
const session = sessions.get(name)
|
||||||
if (!session || !session.alive) return
|
if (!session || !session.alive || session.proc.killed) return
|
||||||
session.proc.stdin.write(data)
|
try { session.proc.stdin.write(data) } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAlive(name: string): boolean {
|
export function isAlive(name: string): boolean {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function fmtSyncIndicator(ahead: number, behind: number): string {
|
|||||||
return parts.join("")
|
return parts.join("")
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_COLORS = [
|
export const TAB_COLORS = [
|
||||||
cyan, // 1
|
cyan, // 1
|
||||||
green, // 2
|
green, // 2
|
||||||
yellow, // 3
|
yellow, // 3
|
||||||
@@ -34,6 +34,33 @@ const TAB_COLORS = [
|
|||||||
(s: string) => fg("#b4f9f8")(s), // 9
|
(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) {
|
function fmtTabCheck(tabNum: number | undefined) {
|
||||||
if (tabNum === undefined) return " "
|
if (tabNum === undefined) return " "
|
||||||
const color = TAB_COLORS[(tabNum - 1) % TAB_COLORS.length]!
|
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 if (ca.includes("d ago")) claudeCol = green(ca.padEnd(9))
|
||||||
else claudeCol = dim(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.commitAge || "-").padEnd(10)
|
||||||
)}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim(
|
)}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim(
|
||||||
String(project.sessionCount).padStart(3)
|
String(project.sessionCount).padStart(3)
|
||||||
@@ -120,17 +148,19 @@ export function fmtSessionRow(
|
|||||||
: session.lastAssistantMsg
|
: session.lastAssistantMsg
|
||||||
: "(no text response)"
|
: "(no text response)"
|
||||||
|
|
||||||
|
const tabBadge = session.id ? getSessionGridTabBadge(project.path, session.id) : ""
|
||||||
|
|
||||||
if (status === "busy") {
|
if (status === "busy") {
|
||||||
return t` ${green("●")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
|
return t` ${green("●")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
|
||||||
size.padEnd(7)
|
size.padEnd(7)
|
||||||
)} ${fg(ACCENT)('"' + title + '"')} ${green("running")}
|
)} ${fg(ACCENT)('"' + title + '"')} ${green("running")}${tabBadge}
|
||||||
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
|
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
|
||||||
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
|
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
|
||||||
}
|
}
|
||||||
if (status === "idle") {
|
if (status === "idle") {
|
||||||
return t` ${yellow("◉")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
|
return t` ${yellow("◉")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
|
||||||
size.padEnd(7)
|
size.padEnd(7)
|
||||||
)} ${fg(ACCENT)('"' + title + '"')} ${yellow("idle")}
|
)} ${fg(ACCENT)('"' + title + '"')} ${yellow("idle")}${tabBadge}
|
||||||
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
|
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
|
||||||
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
|
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/ui/panels.ts
105
src/ui/panels.ts
@@ -9,12 +9,14 @@ import {
|
|||||||
yellow,
|
yellow,
|
||||||
cyan,
|
cyan,
|
||||||
magenta,
|
magenta,
|
||||||
|
red,
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
|
import { st } from "../lib/styled"
|
||||||
import { app } from "../lib/state"
|
import { app } from "../lib/state"
|
||||||
import { CURSOR_BG, ACTIVE_BG, ACCENT } from "../lib/theme"
|
import { CURSOR_BG, ACTIVE_BG, ACCENT } from "../lib/theme"
|
||||||
import { getSessionStatus, getIdleSessions } from "../data/monitor"
|
import { getSessionStatus, getIdleSessions } from "../data/monitor"
|
||||||
import { formatCost, formatWindow, makeBar, pct, PLAN_LIMITS } from "../data/usage"
|
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"
|
import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters"
|
||||||
|
|
||||||
// ─── Display rows ────────────────────────────────────────────────────
|
// ─── Display rows ────────────────────────────────────────────────────
|
||||||
@@ -81,7 +83,7 @@ export function updatePaneList() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = t` `
|
const parts: Parameters<typeof st> = [t` `]
|
||||||
let first = true
|
let first = true
|
||||||
for (const tab of app.gridTabs) {
|
for (const tab of app.gridTabs) {
|
||||||
const tabPanes = app.directGrid.getTabPanes(tab.id)
|
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 short = name.length > 14 ? name.slice(0, 12) + "…" : name
|
||||||
const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi
|
const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi
|
||||||
|
|
||||||
if (!first) content = t`${content}${dim(" · ")}`
|
// Status icon: ● green=running, ◉ yellow=idle, ○ dim=unknown
|
||||||
if (isFocused) {
|
const statusIcon = pane.status === "busy" ? green("● ")
|
||||||
content = t`${content}${bold(short)}`
|
: pane.status === "idle" ? yellow("◉ ")
|
||||||
} else {
|
: dim("○ ")
|
||||||
content = t`${content}${dim(short)}`
|
|
||||||
}
|
if (!first) parts.push(dim(" · "))
|
||||||
|
parts.push(statusIcon)
|
||||||
|
parts.push(isFocused ? bold(short) : dim(short))
|
||||||
first = false
|
first = false
|
||||||
}
|
}
|
||||||
content = t`${content}${dim(" │ ")}`
|
parts.push(dim(" │ "))
|
||||||
first = true
|
first = true
|
||||||
}
|
}
|
||||||
app.paneListText.content = content
|
app.paneListText.content = st(...parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTabBar() {
|
export function updateTabBar() {
|
||||||
if (!app.tabBarText) return
|
if (!app.tabBarText) return
|
||||||
|
|
||||||
// Build tab bar segments using styled text
|
|
||||||
const sep = dim(" │ ")
|
|
||||||
const pickerActive = app.viewMode === "picker"
|
const pickerActive = app.viewMode === "picker"
|
||||||
const pickerTab = pickerActive ? t`${cyan("●")} ${bold("Picker")}` : t`${dim("○ Picker")}`
|
const sep = dim(" │ ")
|
||||||
|
|
||||||
// Start with picker
|
// Chrome-style: active tab gets visual emphasis
|
||||||
let content = t` ${pickerTab}`
|
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) {
|
for (const tab of app.gridTabs) {
|
||||||
const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0
|
|
||||||
const hasIdle = app.directGrid?.hasIdleInTab(tab.id) ?? false
|
const hasIdle = app.directGrid?.hasIdleInTab(tab.id) ?? false
|
||||||
const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id
|
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) {
|
if (isActive) {
|
||||||
content = t`${content}${sep}${cyan("●")} ${bold(label)}`
|
parts.push(dim("╭"), t` ${cyan("●")} ${bold(inlineLabel)}`, closeBtn, t` ${dim("╮")}`)
|
||||||
} else if (hasIdle) {
|
} else if (hasIdle) {
|
||||||
content = t`${content}${sep}${yellow("◉")} ${label}`
|
parts.push(t` ${yellow("◉")} ${dim(inlineLabel)}`, closeBtn, " ", sep)
|
||||||
} else {
|
} else {
|
||||||
content = t`${content}${sep}${dim("○ " + label)}`
|
parts.push(t` ${dim("○ " + inlineLabel)}`, closeBtn, " ", sep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
content = t`${content}${sep}${dim("[+]")}`
|
parts.push(t` ${dim("[+]")}`)
|
||||||
app.tabBarText.content = content
|
app.tabBarText.content = st(...parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Header / Footer ─────────────────────────────────────────────────
|
// ─── Header / Footer ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export function updateHeader() {
|
export function updateHeader() {
|
||||||
const total = app.selectedProjects.size + app.selectedSessions.size
|
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
|
// Count distinct tab groups
|
||||||
const tabGroups = new Set(app.selectedProjects.values())
|
const tabGroups = new Set(app.selectedProjects.values())
|
||||||
const tabNote = tabGroups.size > 1 ? ` → ${tabGroups.size} tabs` : ""
|
const tabNote = tabGroups.size > 1 ? ` → ${tabGroups.size} tabs` : ""
|
||||||
const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : ""
|
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 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 busyCount = app.projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0)
|
||||||
const idleCount = activeCount - busyCount
|
const idleCount = activeCount - busyCount
|
||||||
@@ -167,13 +194,34 @@ export function updateColumnHeaders() {
|
|||||||
|
|
||||||
export function updateFooter() {
|
export function updateFooter() {
|
||||||
const gridHint = app.directGrid && app.directGrid.totalPaneCount > 0 ? " │ ^space grid" : ""
|
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) {
|
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) {
|
||||||
app.footerText.content = t` ${dim(
|
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 {
|
} else {
|
||||||
app.footerText.content = t` ${dim(
|
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
|
const selNote = selBranch === br.name
|
||||||
? t` ${green("Selected")} — will launch with: ${dim(`-p "switch to branch ${br.name}, stash if needed"`)}`
|
? 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")}`
|
: 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}
|
${dim("Last commit:")} ${br.lastCommitAge} — ${br.lastCommitMsg}
|
||||||
${selNote}`
|
`, selNote)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
app.previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)}
|
app.previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)}
|
||||||
|
|||||||
Reference in New Issue
Block a user