agent-orchestrator/ui-electrobun/src/mainview/TerminalTabs.svelte

248 lines
6.3 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import Terminal from './Terminal.svelte';
interface Props {
projectId: string;
accent?: string;
}
let { projectId, accent = 'var(--ctp-mauve)' }: Props = $props();
interface TermTab {
id: string;
title: string;
}
// svelte-ignore state_referenced_locally
const initialId = projectId;
const firstTabId = `${initialId}-t1`;
let tabs = $state<TermTab[]>([{ id: firstTabId, title: 'shell 1' }]);
let activeTabId = $state<string>(firstTabId);
let expanded = $state(true);
let counter = $state(2);
let mounted = $state<Set<string>>(new Set([firstTabId]));
function blurTerminal() {
// Force-blur xterm canvas so UI buttons become clickable
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
function addTab() {
blurTerminal();
const id = `${projectId}-t${counter}`;
tabs = [...tabs, { id, title: `shell ${counter}` }];
counter++;
activeTabId = id;
mounted = new Set([...mounted, id]);
}
function closeTab(id: string, e: MouseEvent) {
e.stopPropagation();
blurTerminal();
const idx = tabs.findIndex(t => t.id === id);
tabs = tabs.filter(t => t.id !== id);
if (activeTabId === id) {
const next = tabs[Math.min(idx, tabs.length - 1)];
activeTabId = next?.id ?? '';
}
const m = new Set(mounted);
m.delete(id);
mounted = m;
}
function activateTab(id: string) {
blurTerminal();
activeTabId = id;
if (!mounted.has(id)) mounted = new Set([...mounted, id]);
if (!expanded) expanded = true;
}
function toggleExpand() {
blurTerminal();
expanded = !expanded;
}
</script>
<!-- Wrapper: uses flex to push tab bar to bottom when terminal is collapsed -->
<div class="term-wrapper" style="--accent: {accent}">
<!-- Tab bar: always visible, acts as divider -->
<div class="term-bar" role="toolbar" aria-label="Terminal tabs" tabindex="-1" onmousedown={blurTerminal}>
<button
class="expand-btn"
onclick={toggleExpand}
title={expanded ? 'Collapse terminal' : 'Expand terminal'}
>
<svg
class="chevron"
class:open={expanded}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 6 15 12 9 18"/>
</svg>
</button>
<div class="term-tabs" role="tablist">
{#each tabs as tab (tab.id)}
<!-- div+role="tab" allows a nested <button> for the close action -->
<div
class="term-tab"
class:active={activeTabId === tab.id}
role="tab"
tabindex={activeTabId === tab.id ? 0 : -1}
aria-selected={activeTabId === tab.id}
onclick={() => activateTab(tab.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') activateTab(tab.id); }}
>
<span class="tab-label">{tab.title}</span>
{#if tabs.length > 1}
<button
class="tab-close"
aria-label="Close {tab.title}"
onclick={(e) => closeTab(tab.id, e)}
>×</button>
{/if}
</div>
{/each}
<button class="tab-add" onclick={() => addTab()}>+</button>
</div>
</div>
<!-- Terminal panes: only rendered when expanded -->
{#if expanded}
<div class="term-panes">
{#each tabs as tab (tab.id)}
{#if mounted.has(tab.id)}
<div class="term-pane" style:display={activeTabId === tab.id ? 'flex' : 'none'}>
<Terminal sessionId={tab.id} />
</div>
{/if}
{/each}
</div>
{/if}
</div>
<style>
.term-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* Tab bar */
.term-bar {
display: flex;
align-items: stretch;
height: 1.75rem;
background: var(--ctp-mantle);
border-top: 1px solid var(--ctp-surface0);
flex-shrink: 0;
z-index: 5;
}
.expand-btn {
width: 1.75rem;
flex-shrink: 0;
background: transparent;
border: none;
border-right: 1px solid var(--ctp-surface0);
color: var(--ctp-overlay1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.expand-btn:hover { color: var(--ctp-text); }
.expand-btn svg { width: 0.75rem; height: 0.75rem; transition: transform 0.15s; }
.chevron.open { transform: rotate(90deg); }
.term-tabs {
display: flex;
align-items: stretch;
flex: 1;
overflow-x: auto;
scrollbar-width: none;
gap: 0.125rem;
padding: 0 0.25rem;
}
.term-tabs::-webkit-scrollbar { display: none; }
.term-tab {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.5rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--ctp-subtext0);
font-size: 0.6875rem;
cursor: pointer;
white-space: nowrap;
margin-bottom: -1px;
font-family: inherit;
}
.term-tab:hover { color: var(--ctp-text); }
.term-tab.active { color: var(--ctp-text); border-bottom-color: var(--accent); }
.tab-label { pointer-events: none; }
.tab-close {
width: 0.875rem;
height: 0.875rem;
border-radius: 0.2rem;
font-size: 0.6875rem;
color: var(--ctp-overlay0);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
/* Reset <button> defaults */
background: transparent;
border: none;
padding: 0;
font-family: inherit;
line-height: 1;
}
.tab-close:hover { background: var(--ctp-surface1); color: var(--ctp-red); }
.tab-add {
align-self: center;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
background: transparent;
border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem;
color: var(--ctp-overlay1);
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin-left: 0.125rem;
}
.tab-add:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
/* Terminal panes — fill remaining space below tab bar */
.term-panes {
flex: 1;
min-height: 8rem;
position: relative;
}
.term-pane {
position: absolute;
inset: 0;
flex-direction: column;
}
</style>