feat(v2): add copy/paste, theme hot-swap, tree enhancements, session resume, drag-resize
- TerminalPane: Ctrl+Shift+C/V copy/paste via attachCustomKeyEventHandler - TerminalPane: subscribe to onThemeChange() for live theme hot-swap - theme.svelte.ts: callback registry (onThemeChange) notifies listeners on setFlavor() - AgentPane: session resume with follow-up prompt and resume_session_id - AgentPane: tree node click scrolls to corresponding message (scrollIntoView) - AgentTree: subtree cost display below each node, NODE_H 32->40 - TilingGrid: pane drag-resize via splitter overlays with mouse drag (10-90% clamping)
This commit is contained in:
parent
1d028c67f7
commit
f27543d8d8
5 changed files with 324 additions and 12 deletions
|
|
@ -68,25 +68,37 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function startQuery(text: string) {
|
let followUpPrompt = $state('');
|
||||||
|
|
||||||
|
async function startQuery(text: string, resume = false) {
|
||||||
if (!text.trim()) return;
|
if (!text.trim()) return;
|
||||||
|
|
||||||
const ready = await isAgentReady();
|
const ready = await isAgentReady();
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
createAgentSession(sessionId, text);
|
if (!resume) createAgentSession(sessionId, text);
|
||||||
const { updateAgentStatus } = await import('../../stores/agents.svelte');
|
const { updateAgentStatus } = await import('../../stores/agents.svelte');
|
||||||
updateAgentStatus(sessionId, 'error', 'Sidecar not ready — agent features unavailable');
|
updateAgentStatus(sessionId, 'error', 'Sidecar not ready — agent features unavailable');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
createAgentSession(sessionId, text);
|
const resumeId = resume ? session?.sdkSessionId : undefined;
|
||||||
|
|
||||||
|
if (!resume) {
|
||||||
|
createAgentSession(sessionId, text);
|
||||||
|
} else {
|
||||||
|
const { updateAgentStatus } = await import('../../stores/agents.svelte');
|
||||||
|
updateAgentStatus(sessionId, 'starting');
|
||||||
|
}
|
||||||
|
|
||||||
await queryAgent({
|
await queryAgent({
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
prompt: text,
|
prompt: text,
|
||||||
cwd,
|
cwd,
|
||||||
max_turns: 50,
|
max_turns: 50,
|
||||||
|
resume_session_id: resumeId,
|
||||||
});
|
});
|
||||||
inputPrompt = '';
|
inputPrompt = '';
|
||||||
|
followUpPrompt = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: Event) {
|
function handleSubmit(e: Event) {
|
||||||
|
|
@ -123,6 +135,17 @@
|
||||||
autoScroll = scrollHeight - scrollTop - clientHeight < 50;
|
autoScroll = scrollHeight - scrollTop - clientHeight < 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTreeNodeClick(nodeId: string) {
|
||||||
|
if (!scrollContainer || !session) return;
|
||||||
|
// Find the message whose tool_call has this toolUseId
|
||||||
|
const msg = session.messages.find(
|
||||||
|
m => m.type === 'tool_call' && (m.content as ToolCallContent).toolUseId === nodeId
|
||||||
|
);
|
||||||
|
if (!msg) return;
|
||||||
|
autoScroll = false;
|
||||||
|
scrollContainer.querySelector('#msg-' + CSS.escape(msg.id))?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-scroll when new messages arrive
|
// Auto-scroll when new messages arrive
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (session?.messages.length) {
|
if (session?.messages.length) {
|
||||||
|
|
@ -172,12 +195,12 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if showTree && session}
|
{#if showTree && session}
|
||||||
<AgentTree {session} />
|
<AgentTree {session} onNodeClick={handleTreeNodeClick} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<div class="messages" bind:this={scrollContainer} onscroll={handleScroll}>
|
<div class="messages" bind:this={scrollContainer} onscroll={handleScroll}>
|
||||||
{#each session.messages as msg (msg.id)}
|
{#each session.messages as msg (msg.id)}
|
||||||
<div class="message msg-{msg.type}">
|
<div class="message msg-{msg.type}" id="msg-{msg.id}">
|
||||||
{#if msg.type === 'init'}
|
{#if msg.type === 'init'}
|
||||||
<div class="msg-init">
|
<div class="msg-init">
|
||||||
<span class="label">Session started</span>
|
<span class="label">Session started</span>
|
||||||
|
|
@ -241,6 +264,22 @@
|
||||||
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollToBottom(); }}>Scroll to bottom</button>
|
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollToBottom(); }}>Scroll to bottom</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if session.sdkSessionId}
|
||||||
|
<div class="follow-up">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="follow-up-input"
|
||||||
|
bind:value={followUpPrompt}
|
||||||
|
placeholder="Follow up..."
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' && followUpPrompt.trim()) {
|
||||||
|
startQuery(followUpPrompt, true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button class="follow-up-btn" onclick={() => startQuery(followUpPrompt, true)} disabled={!followUpPrompt.trim()}>Send</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else if session.status === 'error'}
|
{:else if session.status === 'error'}
|
||||||
<div class="error-bar">
|
<div class="error-bar">
|
||||||
<span>Error: {session.error ?? 'Unknown'}</span>
|
<span>Error: {session.error ?? 'Unknown'}</span>
|
||||||
|
|
@ -250,6 +289,22 @@
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if session.sdkSessionId}
|
||||||
|
<div class="follow-up">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="follow-up-input"
|
||||||
|
bind:value={followUpPrompt}
|
||||||
|
placeholder="Retry or follow up..."
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' && followUpPrompt.trim()) {
|
||||||
|
startQuery(followUpPrompt, true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button class="follow-up-btn" onclick={() => startQuery(followUpPrompt, true)} disabled={!followUpPrompt.trim()}>Send</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -633,4 +688,40 @@
|
||||||
|
|
||||||
.done-bar { color: var(--ctp-green); }
|
.done-bar { color: var(--ctp-green); }
|
||||||
.error-bar { color: var(--ctp-red); }
|
.error-bar { color: var(--ctp-red); }
|
||||||
|
|
||||||
|
.follow-up {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-up-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-up-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-up-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-up-btn:hover { opacity: 0.9; }
|
||||||
|
.follow-up-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { buildAgentTree, type AgentTreeNode } from '../../utils/agent-tree';
|
import { buildAgentTree, subtreeCost, type AgentTreeNode } from '../../utils/agent-tree';
|
||||||
import type { AgentSession } from '../../stores/agents.svelte';
|
import type { AgentSession } from '../../stores/agents.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
|
|
||||||
// Layout constants
|
// Layout constants
|
||||||
const NODE_W = 100;
|
const NODE_W = 100;
|
||||||
const NODE_H = 32;
|
const NODE_H = 40;
|
||||||
const H_GAP = 24;
|
const H_GAP = 24;
|
||||||
const V_GAP = 12;
|
const V_GAP = 12;
|
||||||
|
|
||||||
|
|
@ -124,19 +124,30 @@
|
||||||
<!-- Status dot -->
|
<!-- Status dot -->
|
||||||
<circle
|
<circle
|
||||||
cx={layout.x + 10}
|
cx={layout.x + 10}
|
||||||
cy={layout.y + NODE_H / 2}
|
cy={layout.y + NODE_H / 2 - 4}
|
||||||
r="3"
|
r="3"
|
||||||
fill={statusColor(layout.node.status)}
|
fill={statusColor(layout.node.status)}
|
||||||
/>
|
/>
|
||||||
<!-- Label -->
|
<!-- Label -->
|
||||||
<text
|
<text
|
||||||
x={layout.x + 18}
|
x={layout.x + 18}
|
||||||
y={layout.y + NODE_H / 2 + 1}
|
y={layout.y + NODE_H / 2 - 4}
|
||||||
fill="var(--text-primary)"
|
fill="var(--text-primary)"
|
||||||
font-size="10"
|
font-size="10"
|
||||||
font-family="var(--font-mono)"
|
font-family="var(--font-mono)"
|
||||||
dominant-baseline="middle"
|
dominant-baseline="middle"
|
||||||
>{truncateLabel(layout.node.label, 10)}</text>
|
>{truncateLabel(layout.node.label, 10)}</text>
|
||||||
|
<!-- Subtree cost -->
|
||||||
|
{#if subtreeCost(layout.node) > 0}
|
||||||
|
<text
|
||||||
|
x={layout.x + 18}
|
||||||
|
y={layout.y + NODE_H / 2 + 9}
|
||||||
|
fill="var(--ctp-yellow)"
|
||||||
|
font-size="8"
|
||||||
|
font-family="var(--font-mono)"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
>${subtreeCost(layout.node).toFixed(4)}</text>
|
||||||
|
{/if}
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Recurse children -->
|
<!-- Recurse children -->
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
getPanes,
|
getPanes,
|
||||||
getGridTemplate,
|
getGridTemplate,
|
||||||
getPaneGridArea,
|
getPaneGridArea,
|
||||||
|
getActivePreset,
|
||||||
focusPane,
|
focusPane,
|
||||||
removePane,
|
removePane,
|
||||||
} from '../../stores/layout.svelte';
|
} from '../../stores/layout.svelte';
|
||||||
|
|
@ -18,16 +19,128 @@
|
||||||
let panes = $derived(getPanes());
|
let panes = $derived(getPanes());
|
||||||
let detached = isDetachedMode();
|
let detached = isDetachedMode();
|
||||||
|
|
||||||
|
// Custom column/row sizes (overrides preset when user drags)
|
||||||
|
let customColumns = $state<string | null>(null);
|
||||||
|
let customRows = $state<string | null>(null);
|
||||||
|
let gridEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
// Reset custom sizes when preset changes
|
||||||
|
let prevPreset = '';
|
||||||
|
$effect(() => {
|
||||||
|
const p = getActivePreset();
|
||||||
|
if (prevPreset && p !== prevPreset) {
|
||||||
|
customColumns = null;
|
||||||
|
customRows = null;
|
||||||
|
}
|
||||||
|
prevPreset = p;
|
||||||
|
});
|
||||||
|
|
||||||
|
let columns = $derived(customColumns ?? gridTemplate.columns);
|
||||||
|
let rows = $derived(customRows ?? gridTemplate.rows);
|
||||||
|
|
||||||
|
// Determine splitter positions based on preset
|
||||||
|
let colCount = $derived(gridTemplate.columns.split(' ').length);
|
||||||
|
let rowCount = $derived(gridTemplate.rows.split(' ').length);
|
||||||
|
|
||||||
function handleDetach(pane: typeof panes[0]) {
|
function handleDetach(pane: typeof panes[0]) {
|
||||||
detachPane(pane);
|
detachPane(pane);
|
||||||
removePane(pane.id);
|
removePane(pane.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drag resize logic
|
||||||
|
let dragging = $state(false);
|
||||||
|
|
||||||
|
function getColSplitterX(ci: number): number {
|
||||||
|
if (!gridEl) return 0;
|
||||||
|
const rect = gridEl.getBoundingClientRect();
|
||||||
|
const cols = columns.split(' ').map(s => parseFloat(s));
|
||||||
|
const total = cols.reduce((a, b) => a + b, 0);
|
||||||
|
let frac = 0;
|
||||||
|
for (let i = 0; i <= ci; i++) frac += cols[i] / total;
|
||||||
|
return rect.left + frac * rect.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowSplitterY(ri: number): number {
|
||||||
|
if (!gridEl) return 0;
|
||||||
|
const rect = gridEl.getBoundingClientRect();
|
||||||
|
const rws = rows.split(' ').map(s => parseFloat(s));
|
||||||
|
const total = rws.reduce((a, b) => a + b, 0);
|
||||||
|
let frac = 0;
|
||||||
|
for (let i = 0; i <= ri; i++) frac += rws[i] / total;
|
||||||
|
return rect.top + frac * rect.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startColDrag(colIndex: number, e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
if (!gridEl) return;
|
||||||
|
|
||||||
|
const rect = gridEl.getBoundingClientRect();
|
||||||
|
const totalWidth = rect.width;
|
||||||
|
|
||||||
|
function onMove(ev: MouseEvent) {
|
||||||
|
const relX = ev.clientX - rect.left;
|
||||||
|
const ratio = Math.max(0.1, Math.min(0.9, relX / totalWidth));
|
||||||
|
if (colCount === 2) {
|
||||||
|
customColumns = `${ratio}fr ${1 - ratio}fr`;
|
||||||
|
} else if (colCount === 3) {
|
||||||
|
if (colIndex === 0) {
|
||||||
|
const remaining = 1 - ratio;
|
||||||
|
customColumns = `${ratio}fr ${remaining / 2}fr ${remaining / 2}fr`;
|
||||||
|
} else {
|
||||||
|
// Get current first column ratio or default
|
||||||
|
const parts = (customColumns ?? gridTemplate.columns).split(' ');
|
||||||
|
const first = parseFloat(parts[0]) / parts.reduce((s, p) => s + parseFloat(p), 0);
|
||||||
|
const relRatio = (relX / totalWidth - first) / (1 - first);
|
||||||
|
const adj = Math.max(0.1, Math.min(0.9, relRatio));
|
||||||
|
customColumns = `${first}fr ${(1 - first) * adj}fr ${(1 - first) * (1 - adj)}fr`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
dragging = false;
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRowDrag(_rowIndex: number, e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
if (!gridEl) return;
|
||||||
|
|
||||||
|
const rect = gridEl.getBoundingClientRect();
|
||||||
|
const totalHeight = rect.height;
|
||||||
|
|
||||||
|
function onMove(ev: MouseEvent) {
|
||||||
|
const relY = ev.clientY - rect.top;
|
||||||
|
const ratio = Math.max(0.1, Math.min(0.9, relY / totalHeight));
|
||||||
|
if (rowCount === 2) {
|
||||||
|
customRows = `${ratio}fr ${1 - ratio}fr`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
dragging = false;
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="tiling-grid"
|
class="tiling-grid"
|
||||||
style:grid-template-columns={gridTemplate.columns}
|
class:dragging
|
||||||
style:grid-template-rows={gridTemplate.rows}
|
bind:this={gridEl}
|
||||||
|
style:grid-template-columns={columns}
|
||||||
|
style:grid-template-rows={rows}
|
||||||
>
|
>
|
||||||
{#if panes.length === 0}
|
{#if panes.length === 0}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
|
|
@ -91,12 +204,41 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Splitter overlays (outside grid to avoid layout interference) -->
|
||||||
|
{#if panes.length > 1 && gridEl}
|
||||||
|
{#each { length: colCount - 1 } as _, ci}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="splitter splitter-col"
|
||||||
|
style:left="{gridEl ? getColSplitterX(ci) : 0}px"
|
||||||
|
style:top="{gridEl?.getBoundingClientRect().top ?? 0}px"
|
||||||
|
style:height="{gridEl?.getBoundingClientRect().height ?? 0}px"
|
||||||
|
onmousedown={(e) => startColDrag(ci, e)}
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
{#each { length: rowCount - 1 } as _, ri}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="splitter splitter-row"
|
||||||
|
style:top="{gridEl ? getRowSplitterY(ri) : 0}px"
|
||||||
|
style:left="{gridEl?.getBoundingClientRect().left ?? 0}px"
|
||||||
|
style:width="{gridEl?.getBoundingClientRect().width ?? 0}px"
|
||||||
|
onmousedown={(e) => startRowDrag(ri, e)}
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.tiling-grid {
|
.tiling-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--pane-gap);
|
gap: var(--pane-gap);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: var(--pane-gap);
|
padding: var(--pane-gap);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiling-grid.dragging {
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane-slot {
|
.pane-slot {
|
||||||
|
|
@ -150,4 +292,27 @@
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.splitter) {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.splitter:hover), :global(.splitter:active) {
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.splitter-col) {
|
||||||
|
width: 6px;
|
||||||
|
margin-left: -3px;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.splitter-row) {
|
||||||
|
height: 6px;
|
||||||
|
margin-top: -3px;
|
||||||
|
cursor: row-resize;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import { CanvasAddon } from '@xterm/addon-canvas';
|
import { CanvasAddon } from '@xterm/addon-canvas';
|
||||||
import { FitAddon } from '@xterm/addon-fit';
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
import { spawnPty, writePty, resizePty, killPty, onPtyData, onPtyExit } from '../../adapters/pty-bridge';
|
import { spawnPty, writePty, resizePty, killPty, onPtyData, onPtyExit } from '../../adapters/pty-bridge';
|
||||||
import { getXtermTheme } from '../../stores/theme.svelte';
|
import { getXtermTheme, onThemeChange } from '../../stores/theme.svelte';
|
||||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
let unlistenData: UnlistenFn | null = null;
|
let unlistenData: UnlistenFn | null = null;
|
||||||
let unlistenExit: UnlistenFn | null = null;
|
let unlistenExit: UnlistenFn | null = null;
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let unsubTheme: (() => void) | null = null;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
term = new Terminal({
|
term = new Terminal({
|
||||||
|
|
@ -59,6 +60,24 @@
|
||||||
onExit?.();
|
onExit?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Copy/paste via Ctrl+Shift+C/V
|
||||||
|
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.type === 'keydown') {
|
||||||
|
if (e.key === 'C') {
|
||||||
|
const selection = term.getSelection();
|
||||||
|
if (selection) navigator.clipboard.writeText(selection);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (e.key === 'V') {
|
||||||
|
navigator.clipboard.readText().then(text => {
|
||||||
|
if (text && ptyId) writePty(ptyId, text);
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// Forward keyboard input to PTY
|
// Forward keyboard input to PTY
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
if (ptyId) writePty(ptyId, data);
|
if (ptyId) writePty(ptyId, data);
|
||||||
|
|
@ -80,10 +99,16 @@
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
resizeObserver.observe(terminalEl);
|
resizeObserver.observe(terminalEl);
|
||||||
|
|
||||||
|
// Hot-swap theme when flavor changes
|
||||||
|
unsubTheme = onThemeChange(() => {
|
||||||
|
term.options.theme = getXtermTheme();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(async () => {
|
onDestroy(async () => {
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
|
unsubTheme?.();
|
||||||
unlistenData?.();
|
unlistenData?.();
|
||||||
unlistenExit?.();
|
unlistenExit?.();
|
||||||
if (ptyId) {
|
if (ptyId) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,17 @@ import {
|
||||||
|
|
||||||
let currentFlavor = $state<CatppuccinFlavor>('mocha');
|
let currentFlavor = $state<CatppuccinFlavor>('mocha');
|
||||||
|
|
||||||
|
/** Registered theme-change listeners */
|
||||||
|
const themeChangeCallbacks = new Set<() => void>();
|
||||||
|
|
||||||
|
/** Register a callback invoked after every flavor change. Returns an unsubscribe function. */
|
||||||
|
export function onThemeChange(callback: () => void): () => void {
|
||||||
|
themeChangeCallbacks.add(callback);
|
||||||
|
return () => {
|
||||||
|
themeChangeCallbacks.delete(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getCurrentFlavor(): CatppuccinFlavor {
|
export function getCurrentFlavor(): CatppuccinFlavor {
|
||||||
return currentFlavor;
|
return currentFlavor;
|
||||||
}
|
}
|
||||||
|
|
@ -22,6 +33,15 @@ export function getXtermTheme(): XtermTheme {
|
||||||
export async function setFlavor(flavor: CatppuccinFlavor): Promise<void> {
|
export async function setFlavor(flavor: CatppuccinFlavor): Promise<void> {
|
||||||
currentFlavor = flavor;
|
currentFlavor = flavor;
|
||||||
applyCssVariables(flavor);
|
applyCssVariables(flavor);
|
||||||
|
// Notify all listeners (e.g. open xterm.js terminals)
|
||||||
|
for (const cb of themeChangeCallbacks) {
|
||||||
|
try {
|
||||||
|
cb();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Theme change callback error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setSetting('theme', flavor);
|
await setSetting('theme', flavor);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue