feat(v2): add tiling layout, sidebar controls, and keyboard shortcuts

- TilingGrid: dynamic CSS Grid with auto-preset based on pane count
- Layout presets: 1-col, 2-col, 3-col, 2x2, master-stack
- PaneContainer: close button, status indicator, focus highlight
- SessionList: new terminal button, layout preset selector, pane list
- Layout store: pane CRUD, focus management, grid template generation
- Keyboard: Ctrl+N new terminal, Ctrl+1-4 focus pane by index
This commit is contained in:
Hibryda 2026-03-05 23:42:41 +01:00
parent bb0e9283fc
commit bfd4021909
5 changed files with 399 additions and 41 deletions

View file

@ -1,6 +1,39 @@
<script lang="ts">
import { onMount } from 'svelte';
import SessionList from './lib/components/Sidebar/SessionList.svelte';
import TilingGrid from './lib/components/Layout/TilingGrid.svelte';
import { addPane, focusPaneByIndex, getPanes } from './lib/stores/layout';
function newTerminal() {
const id = crypto.randomUUID();
const num = getPanes().length + 1;
addPane({
id,
type: 'terminal',
title: `Terminal ${num}`,
});
}
onMount(() => {
function handleKeydown(e: KeyboardEvent) {
// Ctrl+N — new terminal
if (e.ctrlKey && !e.shiftKey && e.key === 'n') {
e.preventDefault();
newTerminal();
return;
}
// Ctrl+1-4 — focus pane by index
if (e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '4') {
e.preventDefault();
focusPaneByIndex(parseInt(e.key) - 1);
return;
}
}
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
</script>
<aside class="sidebar">

View file

@ -3,15 +3,25 @@
interface Props {
title: string;
status?: 'idle' | 'running' | 'error' | 'done';
onClose?: () => void;
children: Snippet;
}
let { title, children }: Props = $props();
let { title, status = 'idle', onClose, children }: Props = $props();
</script>
<div class="pane-container">
<div class="pane-header">
<span class="pane-title">{title}</span>
<div class="pane-controls">
{#if status !== 'idle'}
<span class="status {status}">{status}</span>
{/if}
{#if onClose}
<button class="close-btn" onclick={onClose} title="Close pane">&times;</button>
{/if}
</div>
</div>
<div class="pane-content">
{@render children()}
@ -26,12 +36,14 @@
border-radius: var(--border-radius);
overflow: hidden;
border: 1px solid var(--border);
height: 100%;
}
.pane-header {
height: var(--pane-header-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
@ -47,8 +59,39 @@
text-overflow: ellipsis;
}
.pane-controls {
display: flex;
align-items: center;
gap: 6px;
}
.status {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
}
.status.running { color: var(--ctp-blue); }
.status.error { color: var(--ctp-red); }
.status.done { color: var(--ctp-green); }
.close-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
.close-btn:hover {
color: var(--ctp-red);
}
.pane-content {
flex: 1;
overflow: auto;
overflow: hidden;
min-height: 0;
}
</style>

View file

@ -1,33 +1,84 @@
<script lang="ts">
import PaneContainer from './PaneContainer.svelte';
import TerminalPane from '../Terminal/TerminalPane.svelte';
import {
getPanes,
getGridTemplate,
getPaneGridArea,
focusPane,
removePane,
} from '../../stores/layout';
// Phase 2: dynamic pane management, resize, presets
// For now: single empty pane as placeholder
let gridTemplate = $derived(getGridTemplate());
let panes = $derived(getPanes());
</script>
<div class="tiling-grid">
<PaneContainer title="Welcome">
<div class="welcome">
<div
class="tiling-grid"
style:grid-template-columns={gridTemplate.columns}
style:grid-template-rows={gridTemplate.rows}
>
{#if panes.length === 0}
<div class="empty-state">
<h1>BTerminal v2</h1>
<p>Claude Agent Mission Control</p>
<div class="status">
<span class="badge">Phase 1 — Scaffold</span>
</div>
<p class="hint">Press <kbd>Ctrl+N</kbd> to open a terminal</p>
</div>
</PaneContainer>
{:else}
{#each panes as pane, i (pane.id)}
{@const gridArea = getPaneGridArea(i)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pane-slot"
class:focused={pane.focused}
style:grid-area={gridArea}
onclick={() => focusPane(pane.id)}
>
<PaneContainer
title={pane.title}
status={pane.focused ? 'running' : 'idle'}
onClose={() => removePane(pane.id)}
>
{#if pane.type === 'terminal'}
<TerminalPane
shell={pane.shell}
cwd={pane.cwd}
args={pane.args}
onExit={() => removePane(pane.id)}
/>
{:else}
<div class="placeholder">
<p>{pane.type} pane — coming in Phase 3/4</p>
</div>
{/if}
</PaneContainer>
</div>
{/each}
{/if}
</div>
<style>
.tiling-grid {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
gap: var(--pane-gap);
height: 100%;
padding: var(--pane-gap);
}
.welcome {
.pane-slot {
min-width: 0;
min-height: 0;
border-radius: var(--border-radius);
overflow: hidden;
}
.pane-slot.focused {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
@ -37,22 +88,33 @@
color: var(--text-muted);
}
.welcome h1 {
.empty-state h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
}
.welcome p {
font-size: 14px;
.empty-state p { font-size: 14px; }
.hint {
margin-top: 8px;
font-size: 12px;
color: var(--ctp-overlay0);
}
.badge {
kbd {
background: var(--bg-surface);
color: var(--accent);
padding: 4px 12px;
border-radius: var(--border-radius);
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 12px;
margin-top: 8px;
}
</style>

View file

@ -1,27 +1,127 @@
<script lang="ts">
// Phase 4: session CRUD, groups, types
import {
getPanes,
addPane,
focusPane,
removePane,
getActivePreset,
setPreset,
type LayoutPreset,
} from '../../stores/layout';
let panes = $derived(getPanes());
let preset = $derived(getActivePreset());
const presets: LayoutPreset[] = ['1-col', '2-col', '3-col', '2x2', 'master-stack'];
function newTerminal() {
const id = crypto.randomUUID();
const num = panes.length + 1;
addPane({
id,
type: 'terminal',
title: `Terminal ${num}`,
});
}
</script>
<div class="session-list">
<div class="header">
<h2>Sessions</h2>
<button class="new-btn" onclick={newTerminal} title="New terminal (Ctrl+N)">+</button>
</div>
<div class="empty-state">
<p>No sessions yet.</p>
<p class="hint">Phase 2 will add terminal sessions.</p>
<div class="layout-presets">
{#each presets as p}
<button
class="preset-btn"
class:active={preset === p}
onclick={() => setPreset(p)}
title={p}
>{p}</button>
{/each}
</div>
{#if panes.length === 0}
<div class="empty-state">
<p>No sessions yet.</p>
<p class="hint">Click + or press Ctrl+N</p>
</div>
{:else}
<ul class="pane-list">
{#each panes as pane (pane.id)}
<li class="pane-item" class:focused={pane.focused}>
<button class="pane-btn" onclick={() => focusPane(pane.id)}>
<span class="pane-icon">{pane.type === 'terminal' ? '>' : '#'}</span>
<span class="pane-name">{pane.title}</span>
</button>
<button class="remove-btn" onclick={() => removePane(pane.id)}>&times;</button>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.session-list {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.new-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-primary);
width: 24px;
height: 24px;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.new-btn:hover {
background: var(--bg-surface-hover);
color: var(--accent);
}
.layout-presets {
display: flex;
gap: 2px;
flex-wrap: wrap;
}
.preset-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 9px;
padding: 2px 5px;
border-radius: 3px;
cursor: pointer;
}
.preset-btn:hover { color: var(--text-primary); }
.preset-btn.active {
background: var(--accent);
color: var(--ctp-crust);
border-color: var(--accent);
}
.empty-state {
@ -36,4 +136,62 @@
font-size: 11px;
color: var(--ctp-overlay0);
}
.pane-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
.pane-item {
display: flex;
align-items: center;
border-radius: var(--border-radius);
}
.pane-item.focused {
background: var(--bg-surface);
}
.pane-btn {
flex: 1;
background: none;
border: none;
color: var(--text-secondary);
font-size: 12px;
padding: 4px 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
text-align: left;
}
.pane-btn:hover { color: var(--text-primary); }
.pane-icon {
color: var(--ctp-green);
font-weight: bold;
font-size: 11px;
}
.pane-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 2px 4px;
opacity: 0;
}
.pane-item:hover .remove-btn { opacity: 1; }
.remove-btn:hover { color: var(--ctp-red); }
</style>

View file

@ -1,28 +1,90 @@
// Layout state management — Svelte 5 runes
// Phase 2: pane positions, resize, presets
export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack';
export interface PaneState {
export type PaneType = 'terminal' | 'agent' | 'markdown' | 'empty';
export interface Pane {
id: string;
sessionId: string;
row: number;
col: number;
rowSpan: number;
colSpan: number;
type: PaneType;
title: string;
shell?: string;
cwd?: string;
args?: string[];
focused: boolean;
}
let panes = $state<Pane[]>([]);
let activePreset = $state<LayoutPreset>('1-col');
let panes = $state<PaneState[]>([]);
let focusedPaneId = $state<string | null>(null);
export function getActivePreset() {
export function getPanes(): Pane[] {
return panes;
}
export function getActivePreset(): LayoutPreset {
return activePreset;
}
export function setPreset(preset: LayoutPreset) {
export function getFocusedPaneId(): string | null {
return focusedPaneId;
}
export function addPane(pane: Omit<Pane, 'focused'>): void {
panes.push({ ...pane, focused: false });
focusPane(pane.id);
autoPreset();
}
export function removePane(id: string): void {
panes = panes.filter(p => p.id !== id);
if (focusedPaneId === id) {
focusedPaneId = panes.length > 0 ? panes[0].id : null;
}
autoPreset();
}
export function focusPane(id: string): void {
focusedPaneId = id;
panes = panes.map(p => ({ ...p, focused: p.id === id }));
}
export function focusPaneByIndex(index: number): void {
if (index >= 0 && index < panes.length) {
focusPane(panes[index].id);
}
}
export function setPreset(preset: LayoutPreset): void {
activePreset = preset;
}
export function getPanes() {
return panes;
function autoPreset(): void {
const count = panes.length;
if (count <= 1) activePreset = '1-col';
else if (count === 2) activePreset = '2-col';
else if (count === 3) activePreset = 'master-stack';
else activePreset = '2x2';
}
/** CSS grid-template for current preset */
export function getGridTemplate(): { columns: string; rows: string } {
switch (activePreset) {
case '1-col':
return { columns: '1fr', rows: '1fr' };
case '2-col':
return { columns: '1fr 1fr', rows: '1fr' };
case '3-col':
return { columns: '1fr 1fr 1fr', rows: '1fr' };
case '2x2':
return { columns: '1fr 1fr', rows: '1fr 1fr' };
case 'master-stack':
return { columns: '2fr 1fr', rows: '1fr 1fr' };
}
}
/** For master-stack: first pane spans full height */
export function getPaneGridArea(index: number): string | undefined {
if (activePreset === 'master-stack' && index === 0) {
return '1 / 1 / 3 / 2';
}
return undefined;
}