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"> <script lang="ts">
import { onMount } from 'svelte';
import SessionList from './lib/components/Sidebar/SessionList.svelte'; import SessionList from './lib/components/Sidebar/SessionList.svelte';
import TilingGrid from './lib/components/Layout/TilingGrid.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> </script>
<aside class="sidebar"> <aside class="sidebar">

View file

@ -3,15 +3,25 @@
interface Props { interface Props {
title: string; title: string;
status?: 'idle' | 'running' | 'error' | 'done';
onClose?: () => void;
children: Snippet; children: Snippet;
} }
let { title, children }: Props = $props(); let { title, status = 'idle', onClose, children }: Props = $props();
</script> </script>
<div class="pane-container"> <div class="pane-container">
<div class="pane-header"> <div class="pane-header">
<span class="pane-title">{title}</span> <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>
<div class="pane-content"> <div class="pane-content">
{@render children()} {@render children()}
@ -26,12 +36,14 @@
border-radius: var(--border-radius); border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);
height: 100%;
} }
.pane-header { .pane-header {
height: var(--pane-header-height); height: var(--pane-header-height);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
padding: 0 10px; padding: 0 10px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@ -47,8 +59,39 @@
text-overflow: ellipsis; 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 { .pane-content {
flex: 1; flex: 1;
overflow: auto; overflow: hidden;
min-height: 0;
} }
</style> </style>

View file

@ -1,33 +1,84 @@
<script lang="ts"> <script lang="ts">
import PaneContainer from './PaneContainer.svelte'; 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 let gridTemplate = $derived(getGridTemplate());
// For now: single empty pane as placeholder let panes = $derived(getPanes());
</script> </script>
<div class="tiling-grid"> <div
<PaneContainer title="Welcome"> class="tiling-grid"
<div class="welcome"> style:grid-template-columns={gridTemplate.columns}
style:grid-template-rows={gridTemplate.rows}
>
{#if panes.length === 0}
<div class="empty-state">
<h1>BTerminal v2</h1> <h1>BTerminal v2</h1>
<p>Claude Agent Mission Control</p> <p>Claude Agent Mission Control</p>
<div class="status"> <p class="hint">Press <kbd>Ctrl+N</kbd> to open a terminal</p>
<span class="badge">Phase 1 — Scaffold</span>
</div>
</div> </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> </div>
<style> <style>
.tiling-grid { .tiling-grid {
display: grid; display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
gap: var(--pane-gap); gap: var(--pane-gap);
height: 100%; height: 100%;
padding: var(--pane-gap); 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; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -37,22 +88,33 @@
color: var(--text-muted); color: var(--text-muted);
} }
.welcome h1 { .empty-state h1 {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
} }
.welcome p { .empty-state p { font-size: 14px; }
font-size: 14px;
.hint {
margin-top: 8px;
font-size: 12px;
color: var(--ctp-overlay0);
} }
.badge { kbd {
background: var(--bg-surface); background: var(--bg-surface);
color: var(--accent); padding: 2px 6px;
padding: 4px 12px; border-radius: 3px;
border-radius: var(--border-radius); font-size: 11px;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 12px; font-size: 12px;
margin-top: 8px;
} }
</style> </style>

View file

@ -1,27 +1,127 @@
<script lang="ts"> <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> </script>
<div class="session-list"> <div class="session-list">
<div class="header"> <div class="header">
<h2>Sessions</h2> <h2>Sessions</h2>
<button class="new-btn" onclick={newTerminal} title="New terminal (Ctrl+N)">+</button>
</div> </div>
<div class="empty-state">
<p>No sessions yet.</p> <div class="layout-presets">
<p class="hint">Phase 2 will add terminal sessions.</p> {#each presets as p}
<button
class="preset-btn"
class:active={preset === p}
onclick={() => setPreset(p)}
title={p}
>{p}</button>
{/each}
</div> </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> </div>
<style> <style>
.session-list { .session-list {
padding: 12px; padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
} }
.header h2 { .header h2 {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); 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 { .empty-state {
@ -36,4 +136,62 @@
font-size: 11px; font-size: 11px;
color: var(--ctp-overlay0); 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> </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 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; id: string;
sessionId: string; type: PaneType;
row: number; title: string;
col: number; shell?: string;
rowSpan: number; cwd?: string;
colSpan: number; args?: string[];
focused: boolean;
} }
let panes = $state<Pane[]>([]);
let activePreset = $state<LayoutPreset>('1-col'); 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; 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; activePreset = preset;
} }
export function getPanes() { function autoPreset(): void {
return panes; 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;
} }