Compare commits

...

10 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
ccfae53233 feat(site): rewrite landing page — Claude Code Command Center
Reposition cladm from "monitor & launcher" to "command center" with
embedded PTY grid, tabbed workspaces, and pane controls. New hero
animation shows picker → grid transition. JSX workspace mockup with
4 active panes, traffic-light buttons, and live terminal content.
Updated features, two-mode controls, and metadata throughout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:00:37 +00:00
Alejandro Gutiérrez
1a99a83fd7 fix: reorder pane buttons — blue folder, gap, green/yellow, red close
Right corner order is now: [●]blue ─ [●]green [●]red─╮
Folder is leftmost (separated by gap), close is rightmost near corner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:07:03 +00:00
Alejandro Gutiérrez
24a20d4fe5 feat: move folder button to pane top border as blue [●]
Blue [●] button sits rightmost on each pane's top border, separated
inward by ─ from the close/expand traffic-light buttons. Removes the
old [▸] from the subtitle row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:57:08 +00:00
Alejandro Gutiérrez
f821010dc9 feat: add open-folder button [▸] to pane subtitle row
Cyan [▸] button on each pane's subtitle row opens the project folder
in Finder. Path text truncates with … prefix when too long.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:56:16 +00:00
Alejandro Gutiérrez
a2a9283451 Merge branch 'ft-tmux' — direct PTY grid, inline tabs, select mode
Replaces tmux dependency with direct PTY grid rendering:
- Embedded terminal panes with weighted grid layout
- Tabbed sessions with inline pane names and status icons
- Double-click select mode with full scrollback buffer
- Alt+key passthrough, click-to-expand, add-pane mode
2026-02-28 18:13:42 +00:00
Alejandro Gutiérrez
e0f1a08098 feat: inline pane tabs, add-pane mode, and grid chrome cleanup
- Tab bar now shows pane names inline with status icons instead of
  generic tab names with counts, eliminating the separate pane list row
- chromeTop reduced from 4 to 3, gaining one extra row of content space
- Add-pane mode (Ctrl+N) lets users add panes to existing tabs from picker
- Picker tab bar updated to match inline pane name format
- Session formatters and launch actions updated for branch switching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:13:25 +00:00
Alejandro Gutiérrez
9cf18f5740 fix: PTY stdin error, double-click select with full buffer, alt+key passthrough
- Remove proc.stdin.on() call (Bun FileSink has no .on method), add
  try/catch and proc.killed guards to write/resize
- Double-click enters select mode instead of shift+click, with yellow
  banner and Esc-to-exit hint for discoverability
- Select mode now dumps full scrollback buffer (up to 5000 lines) so
  users can scroll up and copy old conversation text
- Pass all Alt+key combos through input parser to PTY (fixes
  Alt+Backspace word deletion and other Alt shortcuts)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:54:41 +00:00
Alejandro Gutiérrez
059004b6f2 fix: space after pane status icons, prevent repeated idle notification sounds
- Add space between status icon and project name in pane list for readability
- Add notifiedIdle set to checkTransitions to prevent re-triggering sound
  after busySessions fluctuations during the same idle period

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:46:20 +00:00
Alejandro Gutiérrez
c2e8fcaa94 feat: framed buttons, select mode isolation, shift+click select, pane status icons
- Replace bare ● and × with framed [●] and [×] for better click targets
- Select mode now clears screen and shows only focused pane content
- Shift+click auto-enters select mode for native text selection
- Ctrl+S keyboard shortcut to toggle select mode from any grid view
- Pane list shows status icons: ● green=running, ◉ yellow=idle, ○ dim=unknown
- Update hit-testing regions for new 3-char framed button positions
- Add ctrl+s select hint to all grid header modes and expanded footer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:38:22 +00:00
Alejandro Gutiérrez
1e105cd950 fix: buffer split mouse sequences, widen grid buttons, fix [object Object] in tab bar
- Buffer partial escape sequences in stdin handler so split SGR mouse
  events don't leak garbage characters into PTY panes
- Widen pane button hit areas from 1 char to 2-4 chars each; add title
  row click-to-expand; widen tab close/add buttons and pane list targets
- Fix [object Object] rendering in picker tab bar and pane list caused
  by OpenTUI's t`` tag not handling StyledText interpolation; add st()
  helper that concatenates StyledText by merging chunk arrays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:29:44 +00:00
18 changed files with 1453 additions and 523 deletions

View File

@@ -14,9 +14,9 @@ const pixel = Silkscreen({
});
export const metadata: Metadata = {
title: "cladm — Monitor & launch Claude Code sessions",
title: "cladm — Claude Code Command Center",
description:
"Multi-project Claude Code session monitor. Track busy/idle status in real time, see usage costs, get notified when Claude finishes, and launch everything in parallel.",
"Multiproject workspace for Claude Code. Embedded terminal grid with tabbed workspaces, pane controls, real-time status tracking, usage monitoring, and full keyboard-driven workflow.",
icons: {
icon: [
{ url: "/favicon.ico", sizes: "32x32" },
@@ -26,8 +26,8 @@ export const metadata: Metadata = {
apple: "/apple-touch-icon.png",
},
openGraph: {
title: "cladm",
description: "Monitor & launch Claude Code sessions across all your projects",
title: "cladm — Claude Code Command Center",
description: "Manage all your Claude Code sessions from one terminal. Embedded PTY grid, tabbed workspaces, live monitoring, and pane controls.",
url: "https://claudm.com",
siteName: "cladm",
type: "website",
@@ -36,14 +36,14 @@ export const metadata: Metadata = {
url: "/og-image.png",
width: 1200,
height: 630,
alt: "cladm — Monitor & launch Claude Code sessions",
alt: "cladm — Claude Code Command Center",
},
],
},
twitter: {
card: "summary_large_image",
title: "cladm",
description: "Monitor & launch Claude Code sessions across all your projects",
title: "cladm — Claude Code Command Center",
description: "Manage all your Claude Code sessions from one terminal. Embedded PTY grid, tabbed workspaces, live monitoring, and pane controls.",
images: ["/og-image.png"],
},
};

View File

@@ -11,10 +11,7 @@ import {
NetworkIcon,
GamepadIcon,
BlocksIcon,
ArrowRightIcon,
ExternalLinkIcon,
LinkedinIcon,
MailIcon,
SpaceInvadersIcon,
EyeIcon,
BellIcon,
@@ -90,13 +87,56 @@ function FeatureBlock({
);
}
function GridPaneMockup({
name,
status,
elapsed,
children,
focused,
}: {
name: string;
status: "busy" | "idle";
elapsed?: string;
children: React.ReactNode;
focused?: boolean;
}) {
return (
<div className={`bg-bg border ${focused ? "border-accent" : "border-border"}`}>
{/* Pane title bar */}
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-surface-2/60">
<div className="font-[family-name:var(--font-mono)] text-[10px] flex items-center gap-1.5">
{status === "busy" ? (
<span className="text-green"></span>
) : (
<>
<span className="text-yellow"></span>
{elapsed && <span className="text-dim">{elapsed}</span>}
</>
)}
<span className="text-text">{name}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-cyan text-[8px]"></span>
<span className="text-dim text-[8px]"></span>
<span className="text-[#27c93f] text-[8px]"></span>
<span className="text-[#ff5f56] text-[8px]"></span>
</div>
</div>
{/* Pane content */}
<div className="p-3 font-[family-name:var(--font-mono)] text-[10px] text-dim leading-relaxed">
{children}
</div>
</div>
);
}
export default function Home() {
return (
<div className="min-h-screen bg-bg selection:bg-accent/30">
<SubscribeModal />
{/* ══════ HERO ══════ */}
<section className="relative overflow-hidden scanlines">
{/* Grid background */}
<div
className="absolute inset-0 opacity-[0.04]"
style={{
@@ -142,14 +182,13 @@ export default function Home() {
</h1>
<p className="font-[family-name:var(--font-pixel)] text-accent text-lg md:text-xl mb-5">
MULTI-PROJECT CLAUDE CODE MONITOR
CLAUDE CODE COMMAND CENTER
</p>
<p className="font-[family-name:var(--font-mono)] text-dim text-sm max-w-md leading-relaxed mb-8">
Track all your Claude Code sessions in one place. See
busy/idle status in real time, monitor usage costs, get
notified when Claude finishes, and launch everything in
parallel Terminal windows.
Manage all your Claude Code sessions from one terminal.
An embedded PTY grid with tabbed workspaces, pane controls,
real-time status tracking, and full keyboard-driven workflow.
</p>
{/* Install command */}
@@ -169,7 +208,7 @@ export default function Home() {
</p>
</div>
{/* Right — terminal cascade */}
{/* Right — terminal cascade: picker → grid */}
<div className="flex-1 w-full max-w-xl">
<TerminalCascade />
</div>
@@ -186,50 +225,185 @@ export default function Home() {
</div>
</section>
{/* ══════ DEMO GIF ══════ */}
{/* ══════ THE WORKSPACE ══════ */}
<section className="max-w-5xl mx-auto px-6 py-20">
<div className="text-center mb-10">
<div className="text-center mb-4">
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-3">
// SEE IT IN ACTION
// THE WORKSPACE
</h2>
<p className="font-[family-name:var(--font-mono)] text-dim text-xs max-w-2xl mx-auto leading-relaxed">
Every Claude Code session runs in an embedded terminal pane no separate windows.
See all your projects at once, switch focus with a click, and never lose track of what Claude is doing.
</p>
</div>
<TerminalWindow title="cladm">
<Image
src="/demo.gif"
alt="cladm demo showing project navigation"
width={980}
height={500}
className="w-full"
unoptimized
/>
</TerminalWindow>
{/* Grid workspace mockup */}
<div className="mt-10">
<div className="pixel-border bg-surface overflow-hidden">
{/* Tab bar */}
<div className="flex items-center bg-surface-2 border-b-2 border-border">
<div className="px-4 py-2 border-b-2 border-accent font-[family-name:var(--font-mono)] text-xs">
<span className="text-green"></span>
<span className="text-text"> acme-api</span>
<span className="text-dim"> · </span>
<span className="text-yellow"></span>
<span className="text-text"> quantum-dash</span>
</div>
<div className="px-4 py-2 font-[family-name:var(--font-mono)] text-xs text-dim border-b-2 border-transparent">
<span className="text-green"></span>
<span> ml-pipeline</span>
<span className="text-dim"> · </span>
<span className="text-green"></span>
<span> infra-k8s</span>
</div>
<div className="ml-auto px-3 py-2 font-[family-name:var(--font-mono)] text-[10px] text-dim">
<span className="text-accent">+</span> add pane
</div>
</div>
{/* Pane grid */}
<div className="grid grid-cols-2 gap-[2px] p-[2px]">
{/* Pane 1: acme-api */}
<GridPaneMockup name="acme-api" status="busy" focused>
<div className="text-green mb-1">&gt; I&apos;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&apos;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">&gt; 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">&gt; 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&apos;s running at a glance.
</p>
</div>
<div className="text-center">
<div className="font-[family-name:var(--font-pixel)] text-accent text-xs uppercase tracking-wider mb-2">
Pane Controls
</div>
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] leading-relaxed">
Traffic-light buttons on every pane: close, expand, minimize, plus a folder-open button. Fully mouse-driven.
</p>
</div>
</div>
</section>
<PixelDivider />
{/* ══════ SCREENSHOTS ══════ */}
{/* ══════ SMART PICKER ══════ */}
<section className="max-w-5xl mx-auto px-6 py-16">
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
// SCREENSHOTS
</h2>
<div className="text-center mb-4">
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-3">
// THE SMART PICKER
</h2>
<p className="font-[family-name:var(--font-mono)] text-dim text-xs max-w-2xl mx-auto leading-relaxed">
It starts with a smart project picker. cladm reads{" "}
<code className="text-accent">~/.claude/history.jsonl</code> to discover every project
you&apos;ve used with Claude Code git branch, sync status, dirty state, session history, stack detection all loaded in parallel.
Select what you need, hit Enter, and the grid workspace takes over.
</p>
</div>
<div className="space-y-16">
{/* Main view */}
<div className="mt-8">
<TerminalWindow title="cladm — 8 projects">
<Image
src="/demo.gif"
alt="cladm smart picker showing project navigation and selection"
width={980}
height={500}
className="w-full"
unoptimized
/>
</TerminalWindow>
</div>
{/* Picker screenshots */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-12">
<div>
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-4 mb-3">
<div className="h-[2px] flex-1 bg-border" />
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider whitespace-nowrap">
PROJECT LIST
</h3>
<div className="h-[2px] flex-1 bg-border" />
</div>
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-6">
All your projects sorted by recent Claude usage. Git branch, sync
status, dirty state, session count, and auto-detected stack at a
glance.
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] text-center mb-4">
Sorted by recent Claude usage. Git metadata, session count, and stack tags at a glance.
</p>
<TerminalWindow title="cladm — 8 projects">
<TerminalWindow title="cladm — project list">
<Image
src="/screenshot-main.png"
alt="cladm main project list view"
@@ -240,25 +414,21 @@ export default function Home() {
</TerminalWindow>
</div>
{/* Expanded view */}
<div>
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-4 mb-3">
<div className="h-[2px] flex-1 bg-border" />
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider whitespace-nowrap">
EXPANDED VIEW
</h3>
<div className="h-[2px] flex-1 bg-border" />
</div>
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-6">
Press <Keycap>&rarr;</Keycap> to expand. Browse branches, see
session conversations with last prompt and Claude&apos;s response.
Running sessions show <span className="text-green"> running</span> or{" "}
<span className="text-yellow"> idle</span> status inline. Resume any session directly.
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] text-center mb-4">
Browse branches, past sessions with conversation previews. Resume any session directly.
</p>
<TerminalWindow title="cladm — 2 selected (1 branch switch)">
<TerminalWindow title="cladm — expanded">
<Image
src="/screenshot-expanded.png"
alt="cladm expanded view with sessions and branches"
alt="cladm expanded view with sessions"
width={980}
height={600}
className="w-full"
@@ -273,104 +443,83 @@ export default function Home() {
{/* ══════ LIVE MONITORING ══════ */}
<section className="max-w-5xl mx-auto px-6 py-16">
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
// LIVE SESSION MONITORING
// REAL-TIME STATUS
</h2>
<div className="max-w-2xl mx-auto">
<div className="pixel-border bg-surface p-6">
<p className="font-[family-name:var(--font-mono)] text-dim text-xs leading-relaxed mb-5">
cladm detects all running Claude Code sessions across every project and shows their real-time status.
When any session finishes, a sound plays and the dock icon bounces so you never miss it, even across dozens of parallel sessions.
</p>
<div className="space-y-3 font-[family-name:var(--font-mono)] text-xs">
<div className="flex items-center gap-3">
<span className="text-green text-base"></span>
<span className="text-text">Busy</span>
<span className="text-dim"> Claude is actively processing</span>
</div>
<div className="flex items-center gap-3">
<span className="text-yellow text-base"></span>
<span className="text-dim">3m</span>
<span className="text-text">Idle</span>
<span className="text-dim"> Claude finished 3 min ago, waiting for input</span>
</div>
<div className="flex items-center gap-3">
<span className="text-dim text-base"></span>
<span className="text-text ml-[22px]">No session</span>
<span className="text-dim"> No active Claude process</span>
</div>
</div>
<div className="mt-5 pt-4 border-t border-border">
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] leading-relaxed">
Detection reads the tail of each session&apos;s JSONL in{" "}
<code className="text-accent">~/.claude/projects/</code>. A session is
busy if the file was written recently OR the last assistant message
has a pending tool call. This prevents false idle triggers during
long-running tools and subtasks.
</p>
</div>
</div>
</div>
</section>
<PixelDivider />
{/* ══════ USAGE + IDLE PANELS ══════ */}
<section className="max-w-5xl mx-auto px-6 py-16">
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
// USAGE & IDLE PANELS
</h2>
<div className="space-y-12 max-w-4xl mx-auto">
{/* Usage panel screenshot */}
<div>
<div className="flex items-center gap-4 mb-4">
<div className="h-[2px] flex-1 bg-border" />
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider whitespace-nowrap">
USAGE TRACKING
<div className="max-w-3xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Status indicators */}
<div className="pixel-border bg-surface p-6">
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider mb-4">
Session Status
</h3>
<div className="h-[2px] flex-1 bg-border" />
<div className="space-y-3 font-[family-name:var(--font-mono)] text-xs">
<div className="flex items-center gap-3">
<span className="text-green text-base"></span>
<span className="text-text">Busy</span>
<span className="text-dim"> Claude is actively working</span>
</div>
<div className="flex items-center gap-3">
<span className="text-yellow text-base"></span>
<span className="text-dim">3m</span>
<span className="text-text">Idle</span>
<span className="text-dim"> waiting for your input</span>
</div>
<div className="flex items-center gap-3">
<span className="text-dim text-base"></span>
<span className="text-text ml-[22px]">No session</span>
<span className="text-dim"> not running</span>
</div>
</div>
<div className="mt-4 pt-3 border-t border-border">
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] leading-relaxed">
Status visible in both picker rows and grid pane headers.
Sound + dock bounce on idle transitions.
</p>
</div>
</div>
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-6">
Press <Keycap>u</Keycap> to toggle. Tracks session (5h window), weekly
all-model and sonnet-only costs against configurable plan limits, plus monthly totals.
</p>
<TerminalWindow title="cladm — usage panel">
<Image
src="/screenshot-usage.png"
alt="cladm usage tracking panel with session, weekly, and monthly cost bars"
width={980}
height={500}
className="w-full"
/>
</TerminalWindow>
</div>
{/* Idle sessions screenshot */}
<div>
<div className="flex items-center gap-4 mb-4">
<div className="h-[2px] flex-1 bg-border" />
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider whitespace-nowrap">
IDLE SESSIONS
{/* Usage tracking */}
<div className="pixel-border bg-surface p-6">
<h3 className="font-[family-name:var(--font-pixel)] text-text text-xs uppercase tracking-wider mb-4">
Usage Tracking
</h3>
<div className="h-[2px] flex-1 bg-border" />
<div className="space-y-3 font-[family-name:var(--font-mono)] text-[10px]">
<div>
<div className="flex justify-between mb-1">
<span className="text-dim">session (5h)</span>
<span className="text-text">$2.40 / $5.00</span>
</div>
<div className="h-2 bg-bg border border-border">
<div className="h-full bg-accent" style={{ width: "48%" }} />
</div>
</div>
<div>
<div className="flex justify-between mb-1">
<span className="text-dim">weekly all-model</span>
<span className="text-text">$18.50 / $100</span>
</div>
<div className="h-2 bg-bg border border-border">
<div className="h-full bg-green" style={{ width: "18.5%" }} />
</div>
</div>
<div>
<div className="flex justify-between mb-1">
<span className="text-dim">monthly total</span>
<span className="text-text">$67.20</span>
</div>
<div className="h-2 bg-bg border border-border">
<div className="h-full bg-cyan" style={{ width: "33.6%" }} />
</div>
</div>
</div>
<div className="mt-4 pt-3 border-t border-border">
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] leading-relaxed">
Press <Keycap>u</Keycap> in picker mode. Tracks session, weekly,
and monthly costs against configurable plan limits.
</p>
</div>
</div>
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-6">
Press <Keycap>i</Keycap> to toggle. Shows sessions waiting for your
input, sorted by most recently idle. Press Enter to focus a session&apos;s
Terminal tab directly.
</p>
<TerminalWindow title="cladm — idle sessions (2)">
<Image
src="/screenshot-idle.png"
alt="cladm idle sessions panel showing waiting sessions with elapsed time"
width={980}
height={500}
className="w-full"
/>
</TerminalWindow>
</div>
</div>
</section>
@@ -384,81 +533,84 @@ export default function Home() {
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<FeatureBlock
icon={<TerminalIcon size={28} />}
title="EMBEDDED GRID"
desc="Run multiple Claude Code sessions side by side in a tiled terminal grid. Each pane is a real PTY with full I/O — no separate windows needed."
/>
<FeatureBlock
icon={<BlocksIcon size={28} />}
title="TABBED WORKSPACES"
desc="Group sessions into named tabs. Inline pane indicators show project names and busy/idle status at a glance."
/>
<FeatureBlock
icon={<GamepadIcon size={28} />}
title="PANE CONTROLS"
desc="Traffic-light buttons on every pane: close, minimize, expand to full screen. Blue button opens the project folder."
/>
<FeatureBlock
icon={<SearchIcon size={28} />}
title="SELECT MODE"
desc="Double-click any pane to enter select mode. Copy text from the full scrollback buffer — up to 5,000 lines of history."
/>
<FeatureBlock
icon={<EyeIcon size={28} />}
title="LIVE MONITORING"
desc="Track all Claude sessions across every project. Busy/idle status updates in real time with elapsed timers."
desc="Track busy/idle status across all sessions in real time. Elapsed timers show how long each session has been waiting."
/>
<FeatureBlock
icon={<TrendingUpIcon size={28} />}
title="USAGE TRACKING"
desc="Session, weekly, and monthly cost bars. Track all-model and sonnet-only usage against configurable plan limits."
desc="Session, weekly, and monthly cost bars against configurable plan limits. Track all-model and sonnet-only usage."
/>
<FeatureBlock
icon={<BellIcon size={28} />}
title="NOTIFICATIONS"
desc="Sound + dock bounce when any session finishes. Never miss a completed task across dozens of parallel sessions."
/>
<FeatureBlock
icon={<ThunderIcon size={28} />}
title="FOCUS SESSION"
desc="Press Enter on any idle session to instantly focus its Terminal tab. Flash animation highlights the window."
/>
<FeatureBlock
icon={<SearchIcon size={28} />}
title="AUTO-DISCOVERY"
desc="Reads ~/.claude/history.jsonl to find every project you've used with Claude Code. No config needed."
/>
<FeatureBlock
icon={<NetworkIcon size={28} />}
title="GIT METADATA"
desc="Branch, sync status (ahead/behind), last commit, dirty state — all loaded in parallel per project."
/>
<FeatureBlock
icon={<FolderIcon size={28} />}
title="SESSION BROWSER"
desc="Expand any project to browse past sessions. See conversation previews and resume directly."
title="AUTO-DISCOVERY"
desc="Reads ~/.claude/history.jsonl to find every project. Git branch, sync status, dirty state — all loaded in parallel."
/>
<FeatureBlock
icon={<TerminalIcon size={28} />}
title="PARALLEL LAUNCH"
desc="Select multiple projects and hit Enter. Each opens in a new Terminal.app window simultaneously."
/>
<FeatureBlock
icon={<BlocksIcon size={28} />}
title="STACK DETECTION"
desc="Auto-detects project stack: TypeScript, Python, Rust, Go, Docker, and more from config files."
icon={<ThunderIcon size={28} />}
title="DIRECT PTY"
desc="Native pseudo-terminal management via forkpty(). No tmux dependency. Zero configuration. Just works."
/>
</div>
</section>
<PixelDivider />
{/* ══════ KEYBINDINGS ══════ */}
{/* ══════ CONTROLS ══════ */}
<section className="max-w-5xl mx-auto px-6 py-16">
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
// CONTROLS
</h2>
<div className="max-w-2xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{/* Picker mode */}
<div className="pixel-border bg-surface p-6">
<div className="grid grid-cols-2 gap-y-3 font-[family-name:var(--font-mono)] text-xs">
<h3 className="font-[family-name:var(--font-pixel)] text-accent text-xs uppercase tracking-wider mb-4 text-center">
Picker Mode
</h3>
<div className="grid grid-cols-2 gap-y-2 font-[family-name:var(--font-mono)] text-xs">
{[
["↑ ↓", "Navigate"],
["Space", "Toggle selection"],
["Space", "Toggle select"],
["→", "Expand project"],
["←", "Collapse"],
["Enter", "Launch selected / focus session"],
["i", "Toggle idle sessions panel"],
["u", "Toggle usage panel"],
["/", "Filter projects"],
["Enter", "Launch grid"],
["/", "Filter"],
["a", "Select all"],
["n", "Deselect all"],
["s", "Cycle sort mode"],
["f", "Open folder in Finder"],
["g", "Go to active session"],
["PgUp PgDn", "Jump 15 rows"],
["q / Esc", "Quit"],
["s", "Cycle sort"],
["u", "Usage panel"],
["i", "Idle sessions"],
["f", "Open folder"],
["g", "Go to session"],
["q", "Quit"],
].map(([key, desc]) => (
<div key={key} className="contents">
<div className="text-accent">{key}</div>
@@ -467,12 +619,58 @@ export default function Home() {
))}
</div>
</div>
{/* Grid mode */}
<div className="pixel-border bg-surface p-6">
<h3 className="font-[family-name:var(--font-pixel)] text-accent text-xs uppercase tracking-wider mb-4 text-center">
Grid Mode
</h3>
<div className="grid grid-cols-2 gap-y-2 font-[family-name:var(--font-mono)] text-xs">
{[
["Click", "Focus pane"],
["Dbl-click", "Select mode"],
["Alt+1-9", "Switch tab"],
["Alt+n/p", "Next/prev tab"],
["+ button", "Add pane"],
["Esc", "Back to picker"],
].map(([key, desc]) => (
<div key={key} className="contents">
<div className="text-accent">{key}</div>
<div className="text-dim">{desc}</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-border">
<div className="font-[family-name:var(--font-pixel)] text-text text-[10px] uppercase tracking-wider mb-2">
Pane Buttons
</div>
<div className="grid grid-cols-2 gap-y-2 font-[family-name:var(--font-mono)] text-xs">
{[
["● blue", "Open folder"],
["● green", "Expand pane"],
["● yellow", "Minimize"],
["● red", "Close pane"],
].map(([key, desc], i) => (
<div key={key} className="contents">
<div className={
i === 0 ? "text-cyan" :
i === 1 ? "text-[#27c93f]" :
i === 2 ? "text-yellow" :
"text-[#ff5f56]"
}>{key}</div>
<div className="text-dim">{desc}</div>
</div>
))}
</div>
</div>
</div>
</div>
</section>
<PixelDivider />
{/* ══════ INSTALL ══════ */}
{/* ══════ QUICK START ══════ */}
<section className="max-w-5xl mx-auto px-6 py-16">
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
// QUICK START
@@ -514,91 +712,13 @@ export default function Home() {
<div className="mt-6 text-center">
<p className="font-[family-name:var(--font-mono)] text-dim text-xs">
Or try with mock data:{" "}
Try with mock data:{" "}
<code className="text-yellow">cladm --demo</code>
</p>
</div>
</div>
</section>
{/* ══════ LAUNCH RESULT ══════ */}
<section className="max-w-5xl mx-auto px-6 py-16">
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-4 text-center">
// HIT ENTER
</h2>
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-10 max-w-lg mx-auto">
Select your projects, press Enter, and watch them all launch in
parallel. Each project opens a fresh Claude Code session in its own
Terminal window.
</p>
<div className="flex flex-col md:flex-row items-center gap-6">
{/* Mini cladm picker */}
<div className="flex-1 w-full">
<TerminalWindow title="cladm — 3 selected">
<div className="p-3 font-[family-name:var(--font-mono)] text-[10px] leading-relaxed">
<div className="text-dim mb-1">
{" PROJECT BRANCH LAST USE"}
</div>
<div className="bg-[#283457] px-1">
<span className="text-green"></span>
<span className="text-green"> []</span>
<span className="text-text">
{" "}
acme-api{" "}
</span>
<span className="text-magenta">main</span>
<span className="text-cyan">{" "}25m ago</span>
</div>
<div className="px-1">
<span className="text-yellow"></span>
<span className="text-dim">2m</span>
<span className="text-green">[]</span>
<span className="text-text"> quantum-dashboard{" "}</span>
<span className="text-magenta">feat/cha</span>
<span className="text-cyan">{" "}1h ago</span>
</div>
<div className="px-1">
<span className="text-green"></span>
<span className="text-green"> []</span>
<span className="text-text"> ml-pipeline{" "}</span>
<span className="text-magenta">exp/bert</span>
<span className="text-cyan">{" "}just now</span>
</div>
<div className="px-1">
<span className="text-dim"></span>
<span className="text-dim"> [ ]</span>
<span className="text-dim"> pixel-engine{" "}develop{" "}3h ago</span>
</div>
</div>
</TerminalWindow>
</div>
{/* Arrow */}
<div className="font-[family-name:var(--font-pixel)] text-accent text-2xl flex-shrink-0 rotate-90 md:rotate-0">
&gt;&gt;&gt;
</div>
{/* Claude Code terminals */}
<div className="flex-1 w-full">
<div className="relative">
{/* Stacked terminal windows effect */}
<div className="absolute top-3 left-3 right-[-3px] bottom-[-3px] border-2 border-border bg-surface-2 opacity-40" />
<div className="absolute top-[6px] left-[6px] right-[-6px] bottom-[-6px] border-2 border-border bg-surface-2 opacity-20" />
<TerminalWindow title="claude — acme-api">
<Image
src="/claude-terminal.webp"
alt="Claude Code session launched in Terminal"
width={960}
height={518}
className="w-full"
/>
</TerminalWindow>
</div>
</div>
</div>
</section>
{/* ══════ NEWSLETTER ══════ */}
<section className="max-w-5xl mx-auto px-6 py-16">
<div className="max-w-md mx-auto">
@@ -665,7 +785,7 @@ export default function Home() {
</div>
</div>
<div className="mt-6 font-[family-name:var(--font-mono)] text-dim text-[10px] text-center">
Built with Bun + OpenTUI. Pixel art by the cladm creatures.
Built with Bun + OpenTUI. Direct PTY grid, no tmux. Pixel art by the cladm creatures.
</div>
</div>
</footer>

View File

@@ -1,7 +1,6 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import Image from "next/image";
const projects = [
{ name: "acme-api", branch: "main", time: "25m ago", status: "busy" as const },
@@ -10,13 +9,13 @@ const projects = [
];
type Phase =
| "typing" // cladm console visible, cursor selecting projects
| "selecting" // checkboxes toggling on one by one
| "enter" // "Enter" flash, cladm fades
| "cascade" // terminals fly in
| "hold" // terminals visible
| "fadeout" // everything fades, restart
| "pause"; // brief gap before loop
| "typing"
| "selecting"
| "enter"
| "grid"
| "hold"
| "fadeout"
| "pause";
export function TerminalCascade() {
const [phase, setPhase] = useState<Phase>("typing");
@@ -27,31 +26,18 @@ export function TerminalCascade() {
setSelectedCount(0);
setPhase("typing");
// Typing/appear cladm console
const t1 = setTimeout(() => setPhase("selecting"), 800);
// Toggle checkboxes one by one
const t2 = setTimeout(() => setSelectedCount(1), 1200);
const t3 = setTimeout(() => setSelectedCount(2), 1600);
const t4 = setTimeout(() => setSelectedCount(3), 2000);
// Enter pressed
const t5 = setTimeout(() => setPhase("enter"), 2600);
// Cascade terminals in
const t6 = setTimeout(() => setPhase("cascade"), 3200);
// Hold
const t7 = setTimeout(() => setPhase("hold"), 3800);
// Fade out
const t8 = setTimeout(() => setPhase("fadeout"), 6200);
// Pause then restart
const t6 = setTimeout(() => setPhase("grid"), 3400);
const t7 = setTimeout(() => setPhase("hold"), 4000);
const t8 = setTimeout(() => setPhase("fadeout"), 7000);
const t9 = setTimeout(() => {
setPhase("pause");
setCycle((c) => c + 1);
}, 7000);
}, 7800);
return [t1, t2, t3, t4, t5, t6, t7, t8, t9];
}, []);
@@ -65,19 +51,18 @@ export function TerminalCascade() {
return () => clearTimeout(start);
}, [cycle, runCycle]);
const showCladm = phase === "typing" || phase === "selecting" || phase === "enter";
const showCascade = phase === "cascade" || phase === "hold" || phase === "fadeout";
const showPicker = phase === "typing" || phase === "selecting" || phase === "enter";
const showGrid = phase === "grid" || phase === "hold" || phase === "fadeout";
return (
<div className="relative w-full min-h-[340px]">
{/* ── CLADM console (cause) ── */}
<div className="relative w-full min-h-[360px]">
{/* ── Picker (select projects) ── */}
<div
className={`transition-all duration-500 ${
showCladm ? "opacity-100 scale-100" : "opacity-0 scale-95 pointer-events-none absolute inset-0"
showPicker ? "opacity-100 scale-100" : "opacity-0 scale-95 pointer-events-none absolute inset-0"
}`}
>
<div className="pixel-border bg-surface overflow-hidden">
{/* Title bar */}
<div className="flex items-center gap-2 px-4 py-2 bg-surface-2 border-b-2 border-border">
<div className="w-3 h-3 bg-[#ff5f56]" />
<div className="w-3 h-3 bg-[#ffbd2e]" />
@@ -86,7 +71,6 @@ export function TerminalCascade() {
cladm {selectedCount} selected
</span>
</div>
{/* Project rows */}
<div className="p-3 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed">
<div className="text-dim mb-1 text-[10px]">
{" PROJECT BRANCH LAST USE"}
@@ -121,15 +105,14 @@ export function TerminalCascade() {
<span></span><span> </span>[ ] pixel-engine{" "}develop{" "}3h ago
</div>
{/* Enter hint */}
<div className="mt-3 pt-2 border-t border-border text-[10px]">
{phase === "enter" ? (
<span className="text-accent font-bold cascade-flash">
Launching 3 projects...
Launching 3 sessions into grid...
</span>
) : (
<span className="text-dim">
navigate · space toggle · enter launch
navigate · space toggle · enter launch grid
</span>
)}
</div>
@@ -137,45 +120,92 @@ export function TerminalCascade() {
</div>
</div>
{/* ── Terminal cascade (effect) ── */}
{/* ── Grid workspace (result) ── */}
<div
className={`transition-opacity duration-500 ${
showCascade ? "opacity-100" : "opacity-0 pointer-events-none absolute inset-0"
className={`transition-opacity duration-600 ${
showGrid ? "opacity-100" : "opacity-0 pointer-events-none absolute inset-0"
}`}
>
<div className="relative h-[320px]">
{projects.map((proj, i) => (
<div
key={`term-${proj.name}-${cycle}`}
className={`absolute left-0 right-0 border-2 bg-surface overflow-hidden
${phase === "cascade" || phase === "hold" ? "cascade-in" : ""}
${phase === "hold" && i === projects.length - 1 ? "cascade-glow" : ""}`}
style={{
animationDelay: `${i * 0.2}s`,
top: `${i * 80}px`,
marginLeft: `${i * 16}px`,
marginRight: `${(projects.length - 1 - i) * 16}px`,
zIndex: i + 1,
borderColor: "var(--color-border)",
}}
>
<div className="flex items-center gap-1.5 px-3 py-1 bg-surface-2 border-b border-border">
<div className="w-[7px] h-[7px] bg-[#ff5f56]" />
<div className="w-[7px] h-[7px] bg-[#ffbd2e]" />
<div className="w-[7px] h-[7px] bg-[#27c93f]" />
<span className="ml-2 font-[family-name:var(--font-mono)] text-dim text-[9px] truncate">
claude {proj.name}
</span>
</div>
<Image
src="/claude-welcome.png"
alt="Claude Code welcome screen"
width={570}
height={260}
className="w-full h-auto"
/>
<div className={`pixel-border bg-surface overflow-hidden ${phase === "hold" ? "cascade-glow" : phase === "grid" ? "cascade-in" : ""}`}>
{/* Tab bar */}
<div className="flex items-center bg-surface-2 border-b-2 border-border">
<div className="px-3 py-1.5 border-b-2 border-accent font-[family-name:var(--font-mono)] text-[9px]">
<span className="text-green"></span>
<span className="text-text"> acme-api</span>
<span className="text-dim"> · </span>
<span className="text-yellow"></span>
<span className="text-text"> quantum-dash</span>
</div>
))}
<div className="px-3 py-1.5 font-[family-name:var(--font-mono)] text-[9px] text-dim border-b-2 border-transparent">
<span className="text-green"></span>
<span> ml-pipeline</span>
</div>
</div>
{/* Pane grid */}
<div className="grid grid-cols-2 gap-px bg-border">
{/* Pane 1: acme-api (busy) */}
<div className="bg-surface">
<div className="flex items-center justify-between px-2 py-[3px] border-b border-border">
<div className="font-[family-name:var(--font-mono)] text-[8px]">
<span className="text-green"></span>
<span className="text-text"> acme-api</span>
</div>
<div className="flex items-center gap-[3px]">
<span className="text-cyan text-[6px]"></span>
<span className="text-[#27c93f] text-[6px]"></span>
<span className="text-[#ff5f56] text-[6px]"></span>
</div>
</div>
<div className="p-2 font-[family-name:var(--font-mono)] text-[8px] text-dim leading-[1.6] h-[85px]">
<div className="text-green">&gt; I&apos;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">&gt; Building BERT fine-tuning pipeline</div>
<div>Processing dataset: train.jsonl</div>
<div>Epoch 3/10 <span className="text-accent"></span><span className="text-border"></span> 30%</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -4,6 +4,67 @@ import { ensureGridView, createNewGridTab, switchToGridTab } from "../grid/view-
import { loadSessions } from "../data/sessions"
import { createSession } from "../pty/session-manager"
export async function doAddPane() {
const targetTabId = app.addPaneTargetTabId
if (!targetTabId || !app.directGrid) return
if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) return
type LaunchItem = { path: string; name: string; sessionId?: string; targetBranch?: string }
const items: LaunchItem[] = []
for (const [path] of app.selectedProjects) {
const project = app.projects.find(p => p.path === path)
if (!project) continue
const targetBranch = app.selectedBranches.get(path)
const needsBranch = targetBranch && targetBranch !== project.branch
if (!project.sessions) {
project.sessions = await loadSessions(project.path)
project.sessionCount = project.sessions.length
}
const lastSessionId = project.sessions[0]?.id
items.push({ path, name: project.name, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined })
}
for (const project of app.projects) {
if (!project.sessions) continue
for (const session of project.sessions) {
if (app.selectedSessions.has(session.id)) {
const targetBranch = app.selectedBranches.get(project.path)
const needsBranch = targetBranch && targetBranch !== project.branch
items.push({ path: project.path, name: project.name, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined })
}
}
}
if (items.length === 0) return
const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40
const totalPanes = items.length + (app.directGrid.getTabPaneCount(targetTabId) || 0)
const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3
const rows = Math.ceil(totalPanes / cols)
const paneW = Math.max(Math.floor(termW / cols) - 2, 20)
const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6)
for (const item of items) {
const session = await createSession({
projectPath: item.path,
projectName: item.name,
sessionId: item.sessionId,
targetBranch: item.targetBranch,
width: paneW,
height: paneH,
})
await app.directGrid.addPane(session, targetTabId)
}
app.selectedProjects.clear()
app.selectedSessions.clear()
app.selectedBranches.clear()
app.addPaneTargetTabId = null
switchToGridTab(targetTabId)
}
export async function doLaunch() {
if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) return
if (app.demoMode) {

View File

@@ -3,7 +3,7 @@
// Each pane renders independently via PTY capture push callbacks.
import { DirectPane } from "./direct-pane"
import { startCapture, stopCapture, resizeCapture, resetHash, getLatestFrame, scrollPane, getScrollOffset } from "../pty/capture"
import { startCapture, stopCapture, resizeCapture, resetHash, getLatestFrame, getFullBuffer, scrollPane, getScrollOffset } from "../pty/capture"
import { writeToSession, resizeSession, killSession, type PtySession } from "../pty/session-manager"
import { app, type GridTab } from "../lib/state"
@@ -84,10 +84,15 @@ export class DirectGridRenderer {
// Tab bar hit-test regions (col ranges for each tab)
private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = []
private tabCloseHitRegions: { tabId: number, startCol: number, endCol: number }[] = []
private tabBarAddBtnCol = -1
// Pane list hit-test regions (row 2)
// Pane name hit-test regions (inline in tab bar, row 1)
private paneListHitRegions: { tabId: number, paneIndex: number, startCol: number, endCol: number }[] = []
// Pending close state
private _pendingCloseTabId = -1
private _pendingCloseTimer: ReturnType<typeof setTimeout> | null = null
constructor(rawWrite: (s: string) => boolean) {
this.writeRaw = rawWrite
}
@@ -164,6 +169,7 @@ export class DirectGridRenderer {
}
}
this.repositionAll()
setTimeout(() => this.forceRedrawAll(), 100)
this.titleTimer = setInterval(() => this.refreshTitles(), 1000)
}
@@ -179,18 +185,46 @@ export class DirectGridRenderer {
get activeTabId() { return this._activeTabId }
enterSelectMode() {
if (!this.isExpanded) return
this._selectMode = true
this.writeRaw("\x1b[?1000l\x1b[?1006l")
this.writeRaw(SHOW_CURSOR)
this.drawChrome()
this.drawSelectView()
}
exitSelectMode() {
this._selectMode = false
this.writeRaw("\x1b[?1000h\x1b[?1006h")
this.writeRaw(HIDE_CURSOR)
this.drawChrome()
this.writeRaw(HIDE_CURSOR + CLEAR)
this.forceRedrawAll()
}
private drawSelectView() {
const pane = this.focusedPane
if (!pane) return
const termW = process.stdout.columns || 120
const lines = getFullBuffer(pane.session.name) ?? []
const color = getColor(pane.session.colorIndex)
// Banner + all buffer lines dumped as plain text (terminal handles native scrollback)
let out = SYNC_START + CLEAR
// Banner row
const bannerBg = hexBg("#e0af68")
const bannerFg = "\x1b[38;2;0;0;0m"
const bannerText = " SELECTION MODE "
const hint = " Esc to exit "
const pad = Math.max(0, termW - bannerText.length - hint.length)
out += `\x1b[1;1H${bannerBg}${bannerFg}${BOLD}${bannerText}${" ".repeat(pad)}${hint}${RESET}`
// Project name on row 2
out += `\x1b[2;1H${hexFg(color)}${BOLD}${pane.session.projectName}${RESET} ${DIM}drag to select │ cmd+c copy │ scroll up for history${RESET}`
// Dump full buffer starting row 3 — native terminal scrollback handles overflow
for (let r = 0; r < lines.length; r++) {
out += `\x1b[${r + 3};1H${lines[r]}\x1b[0m`
}
out += SYNC_END
this.writeRaw(out)
}
expandPane(index?: number) {
@@ -253,6 +287,48 @@ export class DirectGridRenderer {
this.tabSoftExpand.delete(tabId)
}
// ─── Tab close (double-click confirm) ────────────────
get pendingCloseTabId() { return this._pendingCloseTabId }
requestCloseTab(tabId: number): "pending" | "closed" {
if (this._pendingCloseTabId === tabId) {
// Second click — execute close
this.cancelPendingClose()
this.closeTab(tabId)
return "closed"
}
// First click — mark pending
this.cancelPendingClose()
this._pendingCloseTabId = tabId
this._pendingCloseTimer = setTimeout(() => {
this._pendingCloseTabId = -1
this._pendingCloseTimer = null
this.drawChrome()
}, 2000)
this.drawChrome()
return "pending"
}
closeTab(tabId: number): number {
const tabIdx = app.gridTabs.findIndex(t => t.id === tabId)
if (tabIdx < 0) return -1
this.removeTab(tabId)
app.gridTabs.splice(tabIdx, 1)
return tabIdx
}
cancelPendingClose() {
if (this._pendingCloseTimer) {
clearTimeout(this._pendingCloseTimer)
this._pendingCloseTimer = null
}
if (this._pendingCloseTabId !== -1) {
this._pendingCloseTabId = -1
this.drawChrome()
}
}
setActiveTab(tabId: number) {
if (this._activeTabId === tabId) return
// Detach current tab's panes
@@ -274,6 +350,7 @@ export class DirectGridRenderer {
if (this.running) {
this.writeRaw(CLEAR)
this.repositionAll()
setTimeout(() => this.forceRedrawAll(), 100)
}
}
@@ -292,28 +369,33 @@ export class DirectGridRenderer {
}
// Check if a click hit a button on the top border. Returns action + pane index.
checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab" | "panefocus", paneIndex: number, tabId?: number } | null {
// Tab bar check (row 1)
// Hit areas are widened beyond the visible dot characters to make clicking easier.
checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab" | "panefocus" | "closetab" | "closepane" | "openfolder", paneIndex: number, tabId?: number } | null {
// Tab bar check (row 1) — includes inline pane names
if (row === 1) {
// Check close buttons first — widened ±1 around the × character
for (const region of this.tabCloseHitRegions) {
if (col >= region.startCol - 1 && col <= region.endCol + 1) {
return { action: "closetab", paneIndex: -1, tabId: region.tabId }
}
}
// Pane names (inline in tabs) — check before tab regions since they're more specific
for (const region of this.paneListHitRegions) {
if (col >= region.startCol - 1 && col <= region.endCol + 1) {
return { action: "panefocus", paneIndex: region.paneIndex, tabId: region.tabId }
}
}
for (const region of this.tabBarHitRegions) {
if (col >= region.startCol && col <= region.endCol) {
return { action: "tab", paneIndex: -1, tabId: region.tabId }
}
}
if (this.tabBarAddBtnCol > 0 && col >= this.tabBarAddBtnCol && col <= this.tabBarAddBtnCol + 2) {
// [+] button — widened ±1
if (this.tabBarAddBtnCol > 0 && col >= this.tabBarAddBtnCol - 1 && col <= this.tabBarAddBtnCol + 3) {
return { action: "newtab", paneIndex: -1 }
}
return null
}
// Pane list check (row 2)
if (row === 2) {
for (const region of this.paneListHitRegions) {
if (col >= region.startCol && col <= region.endCol) {
return { action: "panefocus", paneIndex: region.paneIndex, tabId: region.tabId }
}
}
return null
}
const indicesToCheck = this.isExpanded ? [this._expandedIndex] : this.panes.map((_, i) => i)
for (const i of indicesToCheck) {
@@ -321,21 +403,33 @@ export class DirectGridRenderer {
const bx = dp.screenX - 1
const by = dp.screenY - 3
const bw = dp.width + 2
const btnRow = by
if (row !== btnRow) continue
// Top border row — framed [●] buttons
// Order from right: ─[●] ─[●] [●] [●]─╮ = blue folder, gap, green/yellow, red close
if (row === by) {
// close (red): rightmost, positions bw-5..bw-3 (before ─╮)
if (col >= bx + bw - 5 && col <= bx + bw - 3) return { action: this.isExpanded ? "closepane" : "closepane", paneIndex: i }
if (this.isExpanded) {
// Layout: ...─[●] ─[●] [●] [●]─╮
// min (yellow): bw-9..bw-7
if (col >= bx + bw - 9 && col <= bx + bw - 7) return { action: "min", paneIndex: i }
// sel (green): bw-13..bw-11
if (col >= bx + bw - 13 && col <= bx + bw - 11) return { action: "sel", paneIndex: i }
// folder (blue): bw-18..bw-16 (after ─ gap)
if (col >= bx + bw - 18 && col <= bx + bw - 16) return { action: "openfolder", paneIndex: i }
} else {
// Layout: ...─[●] ─[●] [●]─╮
// max (green): bw-9..bw-7
if (col >= bx + bw - 9 && col <= bx + bw - 7) return { action: "max", paneIndex: i }
// folder (blue): bw-14..bw-12 (after ─ gap)
if (col >= bx + bw - 14 && col <= bx + bw - 12) return { action: "openfolder", paneIndex: i }
}
continue
}
if (this.isExpanded) {
const minRight = bx + bw - 2
const minLeft = minRight - 4
if (col >= minLeft && col <= minRight) return { action: "min", paneIndex: i }
const selRight = minLeft - 2
const selLeft = selRight - 4
if (col >= selLeft && col <= selRight) return { action: "sel", paneIndex: i }
} else {
const btnLeft = bx + bw - 7
const btnRight = bx + bw - 3
if (col >= btnLeft && col <= btnRight) return { action: "max", paneIndex: i }
// Title row (by+1) — click to expand/focus
if (row === by + 1 && !this.isExpanded) {
return { action: "max", paneIndex: i }
}
}
return null
@@ -374,6 +468,10 @@ export class DirectGridRenderer {
this.drawPane(idx, lines)
}
this.repositionAll()
// Force-redraw all panes after a short delay to catch initial frames
// that may have arrived before attach or been cleared by repositionAll
setTimeout(() => this.forceRedrawAll(), 200)
}
return info
@@ -464,7 +562,7 @@ export class DirectGridRenderer {
if (n === 0) return false
const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40
const chromeTop = 4
const chromeTop = 3
const { cols } = this.calcGrid(n)
const rows = Math.ceil(n / cols)
const cellW = Math.floor(termW / cols)
@@ -496,7 +594,7 @@ export class DirectGridRenderer {
if (n === 0) return -1
const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40
const chromeTop = 4
const chromeTop = 3
const { cols } = this.calcGrid(n)
const rows = Math.ceil(n / cols)
const cellW = Math.floor(termW / cols)
@@ -510,19 +608,16 @@ export class DirectGridRenderer {
// ─── Chrome ────────────────────────────────────────────
drawChrome() {
if (!this.running) return
if (!this.running || this._selectMode) return
const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40
let out = SYNC_START
// Tab bar (row 1)
// Tab bar (row 1) — includes inline pane names
out += this.drawTabBar(termW)
// Pane list (row 2)
out += this.drawPaneList(termW)
// Header (row 3)
// Header (row 2)
const n = this.panes.length
const fi = this._focusIndex + 1
let headerLeft: string, headerRight: string
@@ -531,15 +626,15 @@ export class DirectGridRenderer {
headerRight = `${DIM}drag to select │ cmd+c copy │ ${BOLD}Esc${RESET}${DIM} exit select${RESET}`
} else if (this.isExpanded) {
headerLeft = ` ${BOLD}cladm grid${RESET}${hexFg("#7dcfff")}${BOLD}EXPANDED${RESET}${fi}/${n}`
headerRight = `${DIM}click ${BOLD}[SEL]${RESET}${DIM} select text │ click ${BOLD}[MIN]${RESET}${DIM} restore │ ctrl+space picker${RESET}`
headerRight = `${DIM}${hexFg("#f7768e")}[●]${RESET}${DIM} close │ ${hexFg("#e0af68")}[]${RESET}${DIM} restore │ ${hexFg("#9ece6a")}[]${RESET}${DIM} select │ ctrl+space picker${RESET}`
} else if (this.isSoftExpanded) {
headerLeft = ` ${BOLD}cladm grid${RESET}${hexFg("#bb9af7")}${BOLD}FOCUS${RESET}${fi}/${n}`
headerRight = `${DIM}click pane to focus │ click ${BOLD}[MAX]${RESET}${DIM} fullscreen │ ctrl+e toggle${RESET}`
headerRight = `${DIM}click pane to focus │ ${hexFg("#9ece6a")}[]${RESET}${DIM} fullscreen │ ctrl+s select │ ctrl+e toggle${RESET}`
} else {
headerLeft = ` ${BOLD}cladm grid${RESET}${n} sessions │ focus: ${fi}/${n}`
headerRight = `${DIM}shift+arrows nav │ click ${BOLD}[MAX]${RESET}${DIM} expand │ ctrl+space picker │ ctrl+w close${RESET}`
headerRight = `${DIM}shift+arrows nav │ ${hexFg("#f7768e")}[●]${RESET}${DIM} close ${hexFg("#9ece6a")}[]${RESET}${DIM} expand │ ctrl+s select │ ctrl+space picker${RESET}`
}
out += `\x1b[3;1H\x1b[${termW}X${headerLeft} ${headerRight}`
out += `\x1b[2;1H\x1b[${termW}X${headerLeft} ${headerRight}`
// Pane borders + titles
if (this.isExpanded) {
@@ -556,7 +651,7 @@ export class DirectGridRenderer {
out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg("#9ece6a")}${BOLD}SELECT MODE${RESET} ${DIM}drag to select text │ cmd+c to copy │ press ${BOLD}Esc${RESET}${DIM} to exit${RESET}`
} else if (this.isExpanded && pane) {
const color = getColor(pane.session.colorIndex)
out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}${RESET} ${BOLD}${pane.session.projectName}${RESET} ${DIM}expanded │ Esc or [MIN] to restore grid${RESET}`
out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}${RESET} ${BOLD}${pane.session.projectName}${RESET} ${DIM}expanded │ ctrl+s select │ Esc or ${hexFg("#e0af68")}[●]${RESET}${DIM} to restore grid${RESET}`
} else if (pane) {
const color = getColor(pane.session.colorIndex)
const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : ""
@@ -572,87 +667,138 @@ export class DirectGridRenderer {
private drawTabBar(termW: number): string {
this.tabBarHitRegions = []
this.tabCloseHitRegions = []
this.paneListHitRegions = []
this.tabBarAddBtnCol = -1
let out = `\x1b[1;1H\x1b[${termW}X `
let col = 2
const RED_FG = hexFg("#f7768e")
const TAB_BG_ACTIVE = hexBg("#24283b")
const TAB_BORDER = hexFg("#3b4261")
let out = `\x1b[1;1H\x1b[${termW}X`
let col = 1
// Picker tab (id = -1, meaning: switch to picker)
const pickerActive = app.viewMode === "picker"
const pickerLabel = pickerActive ? `${CYAN_FG}${BOLD}● Picker${RESET}` : `${DIM}○ Picker${RESET}`
out += pickerLabel + ` ${DIM}${RESET} `
this.tabBarHitRegions.push({ tabId: -1, startCol: col, endCol: col + 7 })
col += 11 // "● Picker │ "
if (pickerActive) {
out += `${TAB_BORDER}${RESET}${TAB_BG_ACTIVE} ${CYAN_FG}${BOLD}● Picker${RESET}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}${RESET}`
} else {
out += ` ${DIM}○ Picker${RESET} `
}
const pickerStart = pickerActive ? col + 1 : col + 1
this.tabBarHitRegions.push({ tabId: -1, startCol: pickerStart, endCol: pickerStart + 7 })
col += 10
// Grid tabs
// Grid tabs — inline pane names instead of tab names
for (const tab of app.gridTabs) {
const isActive = this._activeTabId === tab.id && app.viewMode === "grid"
const hasIdle = this.hasIdleInTab(tab.id)
const count = this.getTabPaneCount(tab.id)
const label = `${tab.name} (${count})`
const isPending = this._pendingCloseTabId === tab.id
const tabPanes = this.tabPanes.get(tab.id) ?? []
let tabText: string
if (isActive) {
tabText = `${CYAN_FG}${BOLD}${label}${RESET}`
} else if (hasIdle) {
tabText = `${YELLOW_FG}${label}${RESET}`
} else {
tabText = `${DIM}${label}${RESET}`
// Build pane name list for this tab
const paneLabels: { name: string, color: string, status: PaneStatus, isFocused: boolean }[] = []
for (let pi = 0; pi < tabPanes.length; pi++) {
const p = tabPanes[pi]!
const name = p.session.projectName
const short = name.length > 14 ? name.slice(0, 12) + "…" : name
paneLabels.push({
name: short,
color: getColor(p.session.colorIndex),
status: p.status,
isFocused: isActive && this._focusIndex === pi,
})
}
const startCol = col
out += tabText + ` ${DIM}${RESET} `
const visLen = 2 + label.length // "● " + label
this.tabBarHitRegions.push({ tabId: tab.id, startCol, endCol: startCol + visLen - 1 })
col += visLen + 3 // + " │ "
// Close button text
const closeText = isPending ? `${RED_FG}${BOLD}[●]${RESET}` : `${DIM}[×]${RESET}`
const closeVisLen = 3
const tabStartCol = col
if (isActive) {
// Active tab: ╭ ● pane1 · ◉ pane2 ×
out += `${TAB_BORDER}${RESET}${TAB_BG_ACTIVE} `
col += 2 // ╭ + space
for (let pi = 0; pi < paneLabels.length; pi++) {
const pl = paneLabels[pi]!
let icon: string
if (pl.status === "busy") icon = `${hexFg("#9ece6a")}${RESET}`
else if (pl.status === "idle") icon = `${hexFg("#e0af68")}${RESET}`
else icon = `${DIM}${RESET}`
const paneStartCol = col
if (pl.isFocused) {
out += `${TAB_BG_ACTIVE}${icon} ${hexFg(pl.color)}${BOLD}${pl.name}${RESET}`
} else {
out += `${TAB_BG_ACTIVE}${icon} ${DIM}${pl.name}${RESET}`
}
col += 2 + pl.name.length // icon + space + name
this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol: paneStartCol, endCol: col - 1 })
if (pi < paneLabels.length - 1) {
out += `${TAB_BG_ACTIVE}${DIM} · ${RESET}`
col += 3
}
}
if (paneLabels.length === 0) {
out += `${TAB_BG_ACTIVE}${DIM}empty${RESET}`
col += 5
}
out += `${TAB_BG_ACTIVE} ${closeText}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}${RESET}`
const closeStartCol = col + 1
col += 1 + closeVisLen + 1 + 1 // space + [×] + space + ╮
this.tabCloseHitRegions.push({ tabId: tab.id, startCol: closeStartCol, endCol: closeStartCol + closeVisLen - 1 })
this.tabBarHitRegions.push({ tabId: tab.id, startCol: tabStartCol, endCol: col - 1 })
} else {
// Inactive tab: ○ pane1 · pane2 ×
const hasIdle = this.hasIdleInTab(tab.id)
out += ` `
col += 1
for (let pi = 0; pi < paneLabels.length; pi++) {
const pl = paneLabels[pi]!
let icon: string
if (pl.status === "idle") icon = `${YELLOW_FG}${RESET}`
else if (pl.status === "busy") icon = `${DIM}${RESET}`
else icon = `${DIM}${RESET}`
const paneStartCol = col
out += `${icon} ${DIM}${pl.name}${RESET}`
col += 2 + pl.name.length
this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol: paneStartCol, endCol: col - 1 })
if (pi < paneLabels.length - 1) {
out += `${DIM} · ${RESET}`
col += 3
}
}
if (paneLabels.length === 0) {
out += `${DIM}empty${RESET}`
col += 5
}
out += ` ${closeText} ${DIM}${RESET}`
const closeStartCol = col + 1
col += 1 + closeVisLen + 1 + 1 // space + [×] + space + │
this.tabCloseHitRegions.push({ tabId: tab.id, startCol: closeStartCol, endCol: closeStartCol + closeVisLen - 1 })
this.tabBarHitRegions.push({ tabId: tab.id, startCol: tabStartCol, endCol: col - 1 })
}
}
// [+] button
out += `${DIM}[+]${RESET}`
out += ` ${DIM}[+]${RESET}`
col += 1
this.tabBarAddBtnCol = col
col += 3
return out
}
private drawPaneList(termW: number): string {
this.paneListHitRegions = []
let out = `\x1b[2;1H\x1b[${termW}X `
let col = 3
// Show panes across all tabs, grouped by tab
for (const tab of app.gridTabs) {
const tabPanes = this.tabPanes.get(tab.id) ?? []
if (tabPanes.length === 0) continue
for (let pi = 0; pi < tabPanes.length; pi++) {
const pane = tabPanes[pi]!
const isFocused = this._activeTabId === tab.id && this._focusIndex === pi
const name = pane.session.projectName
const short = name.length > 14 ? name.slice(0, 12) + "…" : name
const color = getColor(pane.session.colorIndex)
const startCol = col
if (isFocused) {
out += `${hexFg(color)}${BOLD}${short}${RESET}`
} else {
out += `${DIM}${short}${RESET}`
}
col += short.length
this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol, endCol: col - 1 })
if (pi < tabPanes.length - 1) {
out += `${DIM} · ${RESET}`
col += 3
}
}
out += `${DIM}${RESET}`
col += 5
}
return out
}
private drawPaneBorder(index: number): string {
const pane = this.panes[index]!
@@ -682,16 +828,25 @@ export class DirectGridRenderer {
let out = ""
// Top border with buttons
// Top border with traffic-light buttons — framed for visibility
const RED_BTN = `${hexFg("#f7768e")}[●]${RESET}` // close pane
const YELLOW_BTN = `${hexFg("#e0af68")}[●]${RESET}` // minimize / collapse
const GREEN_BTN = `${hexFg("#9ece6a")}[●]${RESET}` // expand / maximize
const DIM_BTN = `${DIM}[●]${RESET}`
const BLUE_BTN = `${hexFg("#7dcfff")}[●]${RESET}` // open folder
let btnSection: string
let btnVisibleLen: number
if (this.isExpanded) {
const selColor = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}` : `${DIM}`
btnSection = `${RESET}${selColor}[SEL]${RESET}${borderColor}${hz}${RESET}${hexFg("#7dcfff")}[MIN]${RESET}${borderColor}`
btnVisibleLen = 5 + 1 + 5
// Expanded: folder · gap · select · minimize · close
const selBtn = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}[●]${RESET}` : DIM_BTN
btnSection = `${borderColor}${hz}${RESET}${BLUE_BTN}${borderColor} ${hz}${RESET}${selBtn} ${YELLOW_BTN} ${RED_BTN}${borderColor}`
btnVisibleLen = 1 + 3 + 1 + 1 + 3 + 1 + 3 + 1 + 3 // ─[●] ─[●] [●] [●]
} else {
btnSection = `${RESET}${DIM}[MAX]${RESET}${borderColor}`
btnVisibleLen = 5
// Grid: folder · gap · expand · close
btnSection = `${borderColor}${hz}${RESET}${BLUE_BTN}${borderColor} ${hz}${RESET}${GREEN_BTN} ${RED_BTN}${borderColor}`
btnVisibleLen = 1 + 3 + 1 + 1 + 3 + 1 + 3 // ─[●] ─[●] [●]
}
const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1)
out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}`
@@ -733,6 +888,10 @@ export class DirectGridRenderer {
// ─── Content rendering ─────────────────────────────────
private drawPane(index: number, lines: string[]) {
if (this._selectMode) {
if (index === this._focusIndex) this.drawSelectView()
return
}
if (this.isExpanded && index !== this._expandedIndex) return
const pane = this.panes[index]
if (!pane) return
@@ -740,6 +899,17 @@ export class DirectGridRenderer {
this.writeRaw(SYNC_START + frame + SYNC_END)
}
forceRedrawAll() {
if (!this.running) return
for (let i = 0; i < this.panes.length; i++) {
const pane = this.panes[i]!
resetHash(`dp_${pane.session.name}`)
const frame = getLatestFrame(pane.session.name)
if (frame) this.drawPane(i, frame.lines)
}
this.drawChrome()
}
// ─── Input ─────────────────────────────────────────────
sendInputToFocused(rawSequence: string) {
@@ -817,7 +987,7 @@ export class DirectGridRenderer {
const n = count ?? this.panes.length
const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40
const chromeTop = 4 // row 1 = tab bar, row 2 = pane list, row 3 = header, content starts row 4
const chromeTop = 3 // row 1 = tab bar (with inline panes), row 2 = header, content starts row 3
const { cols, rows } = this.calcGrid(n)
const cellW = Math.floor(termW / cols)
const cellH = Math.floor((termH - chromeTop - 1) / rows) // -1 for footer
@@ -843,7 +1013,7 @@ export class DirectGridRenderer {
repositionAll() {
const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40
const chromeTop = 4
const chromeTop = 3
if (this.isExpanded) {
// Fullscreen: expanded pane gets all space

View File

@@ -314,6 +314,7 @@ export function updateProjectSessions(projects: Project[], sessions: Map<string,
const IDLE_SOUND_DELAY_MS = 10_000
const pendingIdle = new Map<string, number>() // path → timestamp when first went idle
const notifiedIdle = new Set<string>() // paths already notified — prevents re-trigger
export function checkTransitions(
projects: Project[],
@@ -325,20 +326,26 @@ export function checkTransitions(
const prev = prevBusy.get(project.path) || 0
const isIdle = project.busySessions === 0 && project.activeSessions > 0
if (prev > 0 && isIdle && !pendingIdle.has(project.path)) {
if (!isIdle) {
// Not idle — clear notification state so next idle transition can fire
notifiedIdle.delete(project.path)
pendingIdle.delete(project.path)
continue
}
// Already notified for this idle period — skip
if (notifiedIdle.has(project.path)) continue
if (prev > 0 && !pendingIdle.has(project.path)) {
// Just transitioned busy→idle — start the delay timer
pendingIdle.set(project.path, now)
}
if (pendingIdle.has(project.path)) {
if (!isIdle) {
// Went busy again — false alarm, cancel
pendingIdle.delete(project.path)
} else if (now - pendingIdle.get(project.path)! >= IDLE_SOUND_DELAY_MS) {
// Confirmed idle for 10+ seconds
transitioned.push(project.name)
pendingIdle.delete(project.path)
}
if (pendingIdle.has(project.path) && now - pendingIdle.get(project.path)! >= IDLE_SOUND_DELAY_MS) {
// Confirmed idle for 10+ seconds — notify once
transitioned.push(project.name)
pendingIdle.delete(project.path)
notifiedIdle.add(project.path)
}
}
return transitioned

119
src/data/session-store.ts Normal file
View 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
}

View File

@@ -19,6 +19,7 @@ import { app } from "./lib/state"
import { updateAll, rebuildDisplayRows, updateUsagePanel, updateColumnHeaders } from "./ui/panels"
import { stdinHandler } from "./input/handlers"
import { resizeGridPanes } from "./grid/view-switch"
import { loadSavedSession, extractSessionState, saveSessionSync } from "./data/session-store"
function refreshMockSessions(projects: Project[]) {
generateMockActiveSessions(projects)
@@ -55,6 +56,9 @@ async function main() {
app.sortedIndices = app.projects.map((_, i) => i)
rebuildDisplayRows()
// Load saved session for restore hint
app.savedSession = await loadSavedSession()
// Save raw stdout.write BEFORE OpenTUI intercepts it
app.rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean
@@ -64,6 +68,11 @@ async function main() {
useMouse: false,
onDestroy: () => {
app.destroyed = true
// Save session state before cleanup
try {
const state = extractSessionState()
if (state) saveSessionSync(state)
} catch (err) { console.error("[session-save]", err) }
if (app.monitorInterval) { clearInterval(app.monitorInterval); app.monitorInterval = null }
if (app.directGrid) app.directGrid.destroyAll()
stopAllCaptures()
@@ -178,7 +187,7 @@ async function main() {
getUsageSummary().then(u => {
app.cachedUsage = u
updateUsagePanel()
}).catch(() => {})
}).catch(err => console.error("[usage]", err))
// Resize PTY panes when terminal window is resized
process.stdout.on("resize", () => {
@@ -211,7 +220,7 @@ async function main() {
try {
app.cachedUsage = await getUsageSummary()
updateUsagePanel()
} catch {}
} catch (err) { console.error("[usage-poll]", err) }
}
if (app.demoMode) {

View File

@@ -3,7 +3,7 @@ import { app } from "../lib/state"
import { updateAll, rebuildDisplayRows, applySortMode, updateTabBar } from "../ui/panels"
import { extractKeyboardInput, extractMouseEvents } from "./parser"
import { switchToGrid, switchToGridTab, createNewGridTab } from "../grid/view-switch"
import { doLaunch } from "../actions/launch"
import { doLaunch, doAddPane } from "../actions/launch"
import { launchSelections } from "../actions/launcher"
import { loadSessions } from "../data/sessions"
import { loadBranches } from "../data/git"
@@ -11,6 +11,7 @@ import { generateMockSessions, generateMockBranches } from "../data/mock"
import { focusTerminalByPath, populateMockSessionStatus } from "../data/monitor"
import { stopAllCaptures } from "../pty/capture"
import type { DisplayRow } from "../lib/types"
import { extractSessionState, saveSessionSync, restoreSession } from "../data/session-store"
// ─── Constants ───────────────────────────────────────────────────────
@@ -216,27 +217,68 @@ export function handlePickerClick(_col: number, screenRow: number) {
function handlePickerTabBarClick(col: number, screenRow: number) {
// Tab bar is at row 1 in picker (rendered as OpenTUI text)
if (screenRow !== 1) return false
// Hit test against tab bar positions (approximate, since OpenTUI renders it)
// We compute positions similar to the grid tab bar
let c = 2
// Picker tab
const pickerEnd = c + 7
// Hit test against tab bar positions — Chrome-style layout
// Picker: ╭ ● Picker ╮ = cols 1..10 (active) or ○ Picker = cols 1..10
let c = 1
const pickerEnd = c + 10
if (col >= c && col <= pickerEnd) return false // already on picker
c += 11
c = 11
for (const tab of app.gridTabs) {
const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0
const label = `${tab.name} (${count})`
const visLen = 2 + label.length
if (col >= c && col < c + visLen) {
switchToGridTab(tab.id)
return true
const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id
// Build inline pane name list to calculate width
const tabPanes = app.directGrid?.getTabPanes(tab.id) ?? []
const paneNames = tabPanes.map(p => {
const name = p.session.projectName
return name.length > 14 ? name.slice(0, 12) + "…" : name
})
const inlineLabel = paneNames.length > 0 ? paneNames.join(" · ") : "empty"
const visLen = 2 + inlineLabel.length // "● " + label
const dg = app.directGrid
if (isActive) {
// Active: ╭ ● panes ×
const labelStart = c + 2
const labelEnd = labelStart + visLen - 1
const closeCol = labelEnd + 2
const totalVis = 1 + 1 + visLen + 1 + 1 + 1 + 1
if (col === closeCol && dg) {
const result = dg.requestCloseTab(tab.id)
if (result === "closed") updateAll()
else { updateTabBar(); app.renderer.requestRender() }
return true
}
if (col >= labelStart && col <= labelEnd) {
switchToGridTab(tab.id)
return true
}
c += totalVis
} else {
// Inactive: sp ● panes sp × sp │
const labelStart = c + 1
const labelEnd = labelStart + visLen - 1
const closeCol = labelEnd + 2
const totalVis = 1 + visLen + 1 + 1 + 1 + 1
if (col === closeCol && dg) {
const result = dg.requestCloseTab(tab.id)
if (result === "closed") updateAll()
else { updateTabBar(); app.renderer.requestRender() }
return true
}
if (col >= labelStart && col <= labelEnd) {
switchToGridTab(tab.id)
return true
}
c += totalVis
}
c += visLen + 3
}
// [+] button
if (col >= c && col <= c + 2) {
if (col >= c + 1 && col <= c + 3) {
createNewGridTab()
return true
}
@@ -302,6 +344,27 @@ export async function handleKeypress(key: KeyEvent) {
case "g": {
const row = app.displayRows[app.cursor]
const project = app.projects[row.projectIndex]
// Try grid pane navigation first
if (app.directGrid && app.gridTabs.length > 0) {
const targetSessionId = row.type === "session" && project.sessions
? project.sessions[row.sessionIndex!]?.id
: undefined
for (const tab of app.gridTabs) {
const panes = app.directGrid.getTabPanes(tab.id)
const paneIdx = targetSessionId
? panes.findIndex(p => p.session.projectPath === project.path && p.session.sessionId === targetSessionId)
: panes.findIndex(p => p.session.projectPath === project.path)
if (paneIdx >= 0) {
switchToGridTab(tab.id)
app.directGrid.setFocus(paneIdx)
return
}
}
}
// Fallback: external terminal
if (project.activeSessions > 0) {
const sid = row.type === "session" && project.sessions
? project.sessions[row.sessionIndex!]?.id
@@ -344,7 +407,18 @@ export async function handleKeypress(key: KeyEvent) {
case "return": {
const hasSelections = app.selectedProjects.size > 0 || app.selectedSessions.size > 0
if (hasSelections) {
doLaunch()
if (app.addPaneTargetTabId !== null) {
doAddPane()
} else {
doLaunch()
}
break
}
if (app.addPaneTargetTabId !== null) {
// In add-pane mode with no explicit selections, add cursor item
const addRow = app.displayRows[app.cursor]
if (addRow) app.selectedProjects.set(app.projects[addRow.projectIndex].path, 1)
doAddPane()
break
}
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) {
@@ -379,10 +453,58 @@ export async function handleKeypress(key: KeyEvent) {
break
}
case "q":
case "r": {
if (app.restoreMode === "pending") {
// Second press: restore with resume
const saved = app.savedSession
if (saved) {
app.restoreMode = null
await restoreSession(saved, true)
return
}
} else if (app.savedSession) {
app.restoreMode = "pending"
}
break
}
case "R": {
if (app.restoreMode === "pending") {
// Shift+R: restore fresh (no sessionIds)
const saved = app.savedSession
if (saved) {
app.restoreMode = null
await restoreSession(saved, false)
return
}
}
break
}
case "escape":
if (app.addPaneTargetTabId !== null) {
// Cancel add-pane mode, return to grid
const returnTabId = app.addPaneTargetTabId
app.addPaneTargetTabId = null
app.selectedProjects.clear()
app.selectedSessions.clear()
app.selectedBranches.clear()
switchToGridTab(returnTabId)
return
}
if (app.restoreMode === "pending") {
app.restoreMode = null
break
}
// fall through to quit
case "q":
app.destroyed = true
if (app.monitorInterval) clearInterval(app.monitorInterval)
// Save session before exit
try {
const state = extractSessionState()
if (state) saveSessionSync(state)
} catch {}
stopAllCaptures()
process.stdout.write("\x1b[?1006l")
process.stdout.write("\x1b[?1000l")
@@ -394,7 +516,7 @@ export async function handleKeypress(key: KeyEvent) {
}
updateAll()
} catch {}
} catch (err) { console.error("[handleKeypress]", err) }
}
// ─── Grid input ──────────────────────────────────────────────────────
@@ -422,6 +544,13 @@ export async function handleGridInput(rawSequence: string): Promise<boolean> {
return true
}
// Ctrl+S → toggle select mode (disable mouse tracking for native text selection)
if (rawSequence === "\x13") {
if (app.directGrid.selectMode) app.directGrid.exitSelectMode()
else app.directGrid.enterSelectMode()
return true
}
// Ctrl+E → toggle click-to-expand
if (rawSequence === "\x05") {
app.clickExpand = !app.clickExpand
@@ -440,8 +569,13 @@ export async function handleGridInput(rawSequence: string): Promise<boolean> {
if (rawSequence === "\x1bn") { handleNextTab(); return true }
if (rawSequence === "\x1bp") { handlePrevTab(); return true }
// Ctrl+N / Ctrl+P → focus next/prev pane
if (rawSequence === "\x0e") { app.directGrid.focusNext(); return true }
// Ctrl+N → add pane to current tab (enter picker in add-pane mode)
if (rawSequence === "\x0e") {
app.addPaneTargetTabId = app.directGrid.activeTabId
switchToPicker()
return true
}
// Ctrl+P → focus prev pane
if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true }
// Ctrl+F → open folder
@@ -504,6 +638,24 @@ export function switchToPicker() {
app.renderer.requestRender()
}
// ─── Double-click detection ──────────────────────────────────────────
let _lastClickTime = 0
let _lastClickCol = 0
let _lastClickRow = 0
const DOUBLE_CLICK_MS = 400
const DOUBLE_CLICK_DIST = 2
function isDoubleClick(col: number, row: number): boolean {
const now = Date.now()
const dt = now - _lastClickTime
const dist = Math.abs(col - _lastClickCol) + Math.abs(row - _lastClickRow)
_lastClickTime = now
_lastClickCol = col
_lastClickRow = row
return dt < DOUBLE_CLICK_MS && dist <= DOUBLE_CLICK_DIST
}
// ─── Stdin: grid mode ────────────────────────────────────────────────
function processGridInput(str: string) {
@@ -519,11 +671,57 @@ function processGridInput(str: string) {
if (me.btn === 64) { dg.sendScrollToFocused("up", 3); continue }
if (me.btn === 65) { dg.sendScrollToFocused("down", 3); continue }
if (me.btn === 0 && !me.release) {
// Double-click → enter select mode for native text selection
if (isDoubleClick(me.col, me.row)) { dg.enterSelectMode(); return }
const btn = dg.checkButtonClick(me.col, me.row)
if (btn?.action === "max") dg.expandPane(btn.paneIndex)
else if (btn?.action === "min") dg.collapsePane()
else if (btn?.action === "sel") dg.enterSelectMode()
if (btn?.action === "closetab" && btn.tabId !== undefined) {
const result = dg.requestCloseTab(btn.tabId)
if (result === "closed") {
// Tab was closed — switch to adjacent or picker
if (app.gridTabs.length > 0) {
const currentTabId = dg.activeTabId
if (btn.tabId === currentTabId) {
// Closed the active tab — switch to first available
switchToGridTab(app.gridTabs[0].id)
} else {
dg.drawChrome()
}
} else {
switchToPicker()
}
}
}
else if (btn?.action === "closepane") {
dg.cancelPendingClose()
const pane = dg.paneCount > btn.paneIndex ? dg.getTabPanes(dg.activeTabId)[btn.paneIndex] : null
if (pane) {
if (dg.isExpanded) dg.collapsePane()
if (dg.isSoftExpanded) dg.softCollapsePane()
dg.removePane(pane.session.name)
if (dg.paneCount === 0) {
const currentTabId = dg.activeTabId
const tabIdx = app.gridTabs.findIndex(t => t.id === currentTabId)
dg.removeTab(currentTabId)
app.gridTabs.splice(tabIdx, 1)
if (app.gridTabs.length > 0) {
const prevIdx = Math.max(0, tabIdx - 1)
switchToGridTab(app.gridTabs[prevIdx].id)
} else {
switchToPicker()
}
}
}
}
else if (btn?.action === "max") { dg.cancelPendingClose(); dg.expandPane(btn.paneIndex) }
else if (btn?.action === "min") { dg.cancelPendingClose(); dg.collapsePane() }
else if (btn?.action === "sel") { dg.cancelPendingClose(); dg.enterSelectMode() }
else if (btn?.action === "openfolder") {
dg.cancelPendingClose()
const p = dg.getTabPanes(dg.activeTabId)[btn.paneIndex]
if (p) Bun.spawn(["open", p.session.projectPath])
}
else if (btn?.action === "tab") {
dg.cancelPendingClose()
if (btn.tabId === -1) {
// Switch to picker
app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === dg.activeTabId)
@@ -532,14 +730,16 @@ function processGridInput(str: string) {
switchToGridTab(btn.tabId)
}
}
else if (btn?.action === "newtab") createNewGridTab()
else if (btn?.action === "newtab") { dg.cancelPendingClose(); createNewGridTab() }
else if (btn?.action === "panefocus" && btn.tabId !== undefined) {
dg.cancelPendingClose()
// Click on pane name in pane list → switch to that tab and focus the pane
switchToGridTab(btn.tabId)
dg.setFocus(btn.paneIndex)
if (app.clickExpand) dg.softExpandPane(btn.paneIndex)
}
else {
dg.cancelPendingClose()
// Pane body click
if (app.clickExpand && !dg.isExpanded) {
const clickedIdx = dg.getPaneIndexAtClick(me.col, me.row)
@@ -630,10 +830,76 @@ function processPickerInput(str: string) {
}
}
// ─── Stdin entry point ───────────────────────────────────────────────
// ─── Stdin buffering ─────────────────────────────────────────────────
// SGR mouse sequences (\x1b[<btn;col;rowM) can be split across stdin
// data events. Buffer partial escape sequences so fragments don't leak
// into the PTY as garbage characters.
export function stdinHandler(data: string | Buffer) {
const str = typeof data === "string" ? data : data.toString("utf8")
let _pending = ""
let _timer: ReturnType<typeof setTimeout> | null = null
function dispatch(str: string) {
if (app.viewMode === "grid" && app.directGrid) processGridInput(str)
else processPickerInput(str)
}
function flushPending() {
_timer = null
if (_pending) {
const p = _pending
_pending = ""
dispatch(p)
}
}
// Returns index of a trailing partial escape sequence, or -1 if complete.
function trailingPartialEsc(data: string): number {
for (let i = data.length - 1; i >= 0 && i >= data.length - 30; i--) {
if (data.charCodeAt(i) !== 0x1b) continue
const ch = data[i + 1]
// Lone ESC at end
if (ch === undefined) return i
// CSI: \x1b[ — check for final byte
if (ch === "[") {
let j = i + 2
while (j < data.length && data.charCodeAt(j) >= 0x30 && data.charCodeAt(j) <= 0x3f) j++
while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) <= 0x2f) j++
if (j >= data.length) return i // no final byte yet — partial
continue
}
// OSC/DCS/APC/PM — need ST terminator
if (ch === "]" || ch === "P" || ch === "_" || ch === "^") {
let terminated = false
for (let j = i + 2; j < data.length; j++) {
if (data[j] === "\x07") { terminated = true; break }
if (data[j] === "\x1b" && data[j + 1] === "\\") { terminated = true; break }
}
if (!terminated) return i
continue
}
// SS3 (\x1bO) needs one more byte
if (ch === "O" && i + 2 >= data.length) return i
continue
}
return -1
}
// ─── Stdin entry point ───────────────────────────────────────────────
export function stdinHandler(data: string | Buffer) {
if (_timer) { clearTimeout(_timer); _timer = null }
const str = typeof data === "string" ? data : data.toString("utf8")
const full = _pending + str
_pending = ""
const idx = trailingPartialEsc(full)
if (idx >= 0) {
_pending = full.slice(idx)
const ready = full.slice(0, idx)
if (ready) dispatch(ready)
_timer = setTimeout(flushPending, 8)
return
}
dispatch(full)
}

View File

@@ -65,8 +65,10 @@ export function extractKeyboardInput(data: string): string {
i += 3; continue
}
// Alt+digit (1-9) and Alt+letter (n, p) — keep as keyboard shortcuts
if ((next >= "1" && next <= "9") || next === "n" || next === "p") {
// Alt+key combos: \x1b + printable/control char — pass through to PTY
// Includes Alt+Backspace (\x1b\x7f), Alt+digits, Alt+letters, etc.
const nc = data.charCodeAt(i + 1)
if ((nc >= 0x20 && nc <= 0x7e) || nc === 0x7f) {
keyboard += data.slice(i, i + 2)
i += 2; continue
}

View File

@@ -1,6 +1,6 @@
import type { CliRenderer } from "@opentui/core"
import type { BoxRenderable, TextRenderable, ScrollBoxRenderable } from "@opentui/core"
import type { Project, DisplayRow } from "./types"
import type { Project, DisplayRow, SavedSession } from "./types"
import type { DirectGridRenderer } from "../components/direct-grid"
import type { UsageSummary } from "../data/usage"
import type { IdleSessionInfo } from "../data/monitor"
@@ -47,6 +47,9 @@ export const app = {
nextTabId: 1, // auto-increment for tab ids
clickExpand: true, // click-to-expand feature toggle
lastGridTabIndex: 0, // last active grid tab for Ctrl+Space toggle
savedSession: null as SavedSession | null,
restoreMode: null as "pending" | null,
addPaneTargetTabId: null as number | null,
// UI refs (set during init)
renderer: null as unknown as CliRenderer,

20
src/lib/styled.ts Normal file
View 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)
}

View File

@@ -20,6 +20,15 @@ export function elapsedCompact(ms: number): string {
return `${Math.floor(sec / 86400)}d`
}
export function timeAgoShort(ms: number): string {
if (!ms) return ""
const diff = Math.floor((Date.now() - ms) / 1000)
if (diff < 60) return "0m ago"
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`

View File

@@ -46,3 +46,24 @@ export interface DisplayRow {
sessionIndex?: number
branchName?: string
}
export interface SavedPane {
projectPath: string
projectName: string
sessionId?: string
targetBranch?: string
}
export interface SavedTab {
id: number
name: string
panes: SavedPane[]
}
export interface SavedSession {
version: 1
savedAt: number
activeTabIndex: number
nextTabId: number
tabs: SavedTab[]
}

View File

@@ -195,6 +195,14 @@ class VtScreen {
}
}
// Get full buffer: all scrollback lines + current screen (for select mode)
getAllLines(): string[] {
const lines: string[] = []
for (const row of this.scrollback) lines.push(this.renderRow(row))
for (let r = 0; r < this.height; r++) lines.push(this.renderRow(this.cells[r]))
return lines
}
// Get screen as lines with embedded ANSI SGR codes (like tmux capture-pane -e)
// When scrollOffset > 0, shows scrollback history mixed with screen content
getLines(): string[] {
@@ -613,6 +621,12 @@ export function getLatestFrame(sessionName: string): CaptureResult | null {
}
}
export function getFullBuffer(sessionName: string): string[] | null {
const state = panes.get(sessionName)
if (!state) return null
return state.screen.getAllLines()
}
export function stopCapture(sessionName: string): void {
const state = panes.get(sessionName)
if (!state) return

View File

@@ -100,18 +100,18 @@ export function killSession(name: string): void {
export function resizeSession(name: string, width: number, height: number): void {
const session = sessions.get(name)
if (!session || !session.alive) return
if (!session || !session.alive || session.proc.killed) return
session.width = width
session.height = height
// Send APC resize command: \x1b_R<rows>;<cols>\x1b\\
const resizeCmd = `\x1b_R${height};${width}\x1b\\`
session.proc.stdin.write(resizeCmd)
try { session.proc.stdin.write(resizeCmd) } catch {}
}
export function writeToSession(name: string, data: string): void {
const session = sessions.get(name)
if (!session || !session.alive) return
session.proc.stdin.write(data)
if (!session || !session.alive || session.proc.killed) return
try { session.proc.stdin.write(data) } catch {}
}
export function isAlive(name: string): boolean {

View File

@@ -22,7 +22,7 @@ export function fmtSyncIndicator(ahead: number, behind: number): string {
return parts.join("")
}
const TAB_COLORS = [
export const TAB_COLORS = [
cyan, // 1
green, // 2
yellow, // 3
@@ -34,6 +34,33 @@ const TAB_COLORS = [
(s: string) => fg("#b4f9f8")(s), // 9
]
function getGridTabBadges(projectPath: string): string {
if (!app.directGrid || app.gridTabs.length === 0) return ""
const badges: string[] = []
for (const tab of app.gridTabs) {
const panes = app.directGrid.getTabPanes(tab.id)
if (panes.some(p => p.session.projectPath === projectPath)) {
const displayIdx = app.gridTabs.indexOf(tab) + 1
const color = TAB_COLORS[(displayIdx - 1) % TAB_COLORS.length]!
badges.push(color(`T${displayIdx}`))
}
}
return badges.length > 0 ? badges.join("") + " " : ""
}
function getSessionGridTabBadge(projectPath: string, sessionId: string): string {
if (!app.directGrid || app.gridTabs.length === 0) return ""
for (const tab of app.gridTabs) {
const panes = app.directGrid.getTabPanes(tab.id)
if (panes.some(p => p.session.projectPath === projectPath && p.session.sessionId === sessionId)) {
const displayIdx = app.gridTabs.indexOf(tab) + 1
const color = TAB_COLORS[(displayIdx - 1) % TAB_COLORS.length]!
return " " + color(`T${displayIdx}`)
}
}
return ""
}
function fmtTabCheck(tabNum: number | undefined) {
if (tabNum === undefined) return " "
const color = TAB_COLORS[(tabNum - 1) % TAB_COLORS.length]!
@@ -83,7 +110,8 @@ export function fmtProjectRow(project: import("../lib/types").Project, isSelecte
else if (ca.includes("d ago")) claudeCol = green(ca.padEnd(9))
else claudeCol = dim(ca.padEnd(9))
return t` ${activeDot}${activeTag}[${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim(
const gridBadge = getGridTabBadges(project.path)
return t` ${activeDot}${activeTag}${gridBadge}[${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim(
(project.commitAge || "-").padEnd(10)
)}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim(
String(project.sessionCount).padStart(3)
@@ -120,17 +148,19 @@ export function fmtSessionRow(
: session.lastAssistantMsg
: "(no text response)"
const tabBadge = session.id ? getSessionGridTabBadge(project.path, session.id) : ""
if (status === "busy") {
return t` ${green("●")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
size.padEnd(7)
)} ${fg(ACCENT)('"' + title + '"')} ${green("running")}
)} ${fg(ACCENT)('"' + title + '"')} ${green("running")}${tabBadge}
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
}
if (status === "idle") {
return t` ${yellow("◉")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
size.padEnd(7)
)} ${fg(ACCENT)('"' + title + '"')} ${yellow("idle")}
)} ${fg(ACCENT)('"' + title + '"')} ${yellow("idle")}${tabBadge}
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
}

View File

@@ -9,12 +9,14 @@ import {
yellow,
cyan,
magenta,
red,
} from "@opentui/core"
import { st } from "../lib/styled"
import { app } from "../lib/state"
import { CURSOR_BG, ACTIVE_BG, ACCENT } from "../lib/theme"
import { getSessionStatus, getIdleSessions } from "../data/monitor"
import { formatCost, formatWindow, makeBar, pct, PLAN_LIMITS } from "../data/usage"
import { timeAgo, formatSize, elapsedCompact } from "../lib/time"
import { timeAgo, formatSize, elapsedCompact, timeAgoShort } from "../lib/time"
import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters"
// ─── Display rows ────────────────────────────────────────────────────
@@ -81,7 +83,7 @@ export function updatePaneList() {
return
}
let content = t` `
const parts: Parameters<typeof st> = [t` `]
let first = true
for (const tab of app.gridTabs) {
const tabPanes = app.directGrid.getTabPanes(tab.id)
@@ -93,59 +95,84 @@ export function updatePaneList() {
const short = name.length > 14 ? name.slice(0, 12) + "…" : name
const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi
if (!first) content = t`${content}${dim(" · ")}`
if (isFocused) {
content = t`${content}${bold(short)}`
} else {
content = t`${content}${dim(short)}`
}
// Status icon: ● green=running, ◉ yellow=idle, ○ dim=unknown
const statusIcon = pane.status === "busy" ? green("● ")
: pane.status === "idle" ? yellow("◉ ")
: dim("○ ")
if (!first) parts.push(dim(" · "))
parts.push(statusIcon)
parts.push(isFocused ? bold(short) : dim(short))
first = false
}
content = t`${content}${dim(" │ ")}`
parts.push(dim(" │ "))
first = true
}
app.paneListText.content = content
app.paneListText.content = st(...parts)
}
export function updateTabBar() {
if (!app.tabBarText) return
// Build tab bar segments using styled text
const sep = dim(" │ ")
const pickerActive = app.viewMode === "picker"
const pickerTab = pickerActive ? t`${cyan("●")} ${bold("Picker")}` : t`${dim("○ Picker")}`
const sep = dim(" │ ")
// Start with picker
let content = t` ${pickerTab}`
// Chrome-style: active tab gets visual emphasis
const parts: Parameters<typeof st> = []
if (pickerActive) {
parts.push(t` ${dim("╭")} ${cyan("●")} ${bold("Picker")} ${dim("╮")}`)
} else {
parts.push(t` ${dim("○ Picker")} `)
}
// Grid tabs
// Grid tabs — inline pane names
for (const tab of app.gridTabs) {
const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0
const hasIdle = app.directGrid?.hasIdleInTab(tab.id) ?? false
const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id
const label = `${tab.name} (${count})`
const isPending = app.directGrid?.pendingCloseTabId === tab.id
const closeBtn = isPending ? t` ${red(bold("[●]"))}` : t` ${dim("[×]")}`
// Build inline pane name list
const tabPanes = app.directGrid?.getTabPanes(tab.id) ?? []
const paneNames = tabPanes.map(p => {
const name = p.session.projectName
return name.length > 14 ? name.slice(0, 12) + "…" : name
})
const inlineLabel = paneNames.length > 0 ? paneNames.join(" · ") : "empty"
if (isActive) {
content = t`${content}${sep}${cyan("●")} ${bold(label)}`
parts.push(dim("╭"), t` ${cyan("●")} ${bold(inlineLabel)}`, closeBtn, t` ${dim("╮")}`)
} else if (hasIdle) {
content = t`${content}${sep}${yellow("◉")} ${label}`
parts.push(t` ${yellow("◉")} ${dim(inlineLabel)}`, closeBtn, " ", sep)
} else {
content = t`${content}${sep}${dim("○ " + label)}`
parts.push(t` ${dim("○ " + inlineLabel)}`, closeBtn, " ", sep)
}
}
content = t`${content}${sep}${dim("[+]")}`
app.tabBarText.content = content
parts.push(t` ${dim("[+]")}`)
app.tabBarText.content = st(...parts)
}
// ─── Header / Footer ─────────────────────────────────────────────────
export function updateHeader() {
const total = app.selectedProjects.size + app.selectedSessions.size
const modeLabel = app.demoMode ? " [DEMO]" : ""
// Add-pane mode: show target tab context
if (app.addPaneTargetTabId !== null) {
const targetTab = app.gridTabs.find(t => t.id === app.addPaneTargetTabId)
const tabName = targetTab?.name ?? `Tab ${app.addPaneTargetTabId}`
app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)}${cyan(bold(`Adding to: ${tabName}`))}${String(total)} selected ${dim(
`sort: ${app.sortLabels[app.sortMode]}${app.projects.length} projects`
)}`
return
}
// Count distinct tab groups
const tabGroups = new Set(app.selectedProjects.values())
const tabNote = tabGroups.size > 1 ? `${tabGroups.size} tabs` : ""
const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : ""
const modeLabel = app.demoMode ? " [DEMO]" : ""
const activeCount = app.projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0)
const busyCount = app.projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0)
const idleCount = activeCount - busyCount
@@ -167,13 +194,34 @@ export function updateColumnHeaders() {
export function updateFooter() {
const gridHint = app.directGrid && app.directGrid.totalPaneCount > 0 ? " │ ^space grid" : ""
// Restore mode: show choice prompt
if (app.restoreMode === "pending") {
app.footerText.content = t` ${yellow("Restore session?")} ${dim("r resume │ R fresh │ esc cancel")}`
return
}
// Saved session hint
let restoreHint = ""
if (app.savedSession) {
const ago = timeAgoShort(app.savedSession.savedAt)
const paneCount = app.savedSession.tabs.reduce((sum, t) => sum + t.panes.length, 0)
restoreHint = ` │ r restore (${paneCount}p, ${ago})`
}
// Add-pane mode: simplified footer
if (app.addPaneTargetTabId !== null) {
app.footerText.content = t` ${dim("↑↓ nav │ space select │ → expand │ ← collapse │ enter add │ esc cancel")}`
return
}
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) {
app.footerText.content = t` ${dim(
"↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + gridHint
"↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + gridHint + restoreHint
)}`
} else {
app.footerText.content = t` ${dim(
"↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" + gridHint
"↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" + gridHint + restoreHint
)}`
}
}
@@ -310,9 +358,10 @@ export function updatePreview() {
const selNote = selBranch === br.name
? t` ${green("Selected")} — will launch with: ${dim(`-p "switch to branch ${br.name}, stash if needed"`)}`
: t` ${dim("Press space to select this branch for launch")}`
app.previewText.content = t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync}
app.previewText.content = st(
t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync}
${dim("Last commit:")} ${br.lastCommitAge}${br.lastCommitMsg}
${selNote}`
`, selNote)
}
} else {
app.previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)}