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:
Hibryda 2026-03-06 15:09:52 +01:00
parent 1d028c67f7
commit f27543d8d8
5 changed files with 324 additions and 12 deletions

View file

@ -68,25 +68,37 @@
}
});
async function startQuery(text: string) {
let followUpPrompt = $state('');
async function startQuery(text: string, resume = false) {
if (!text.trim()) return;
const ready = await isAgentReady();
if (!ready) {
createAgentSession(sessionId, text);
if (!resume) createAgentSession(sessionId, text);
const { updateAgentStatus } = await import('../../stores/agents.svelte');
updateAgentStatus(sessionId, 'error', 'Sidecar not ready — agent features unavailable');
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({
session_id: sessionId,
prompt: text,
cwd,
max_turns: 50,
resume_session_id: resumeId,
});
inputPrompt = '';
followUpPrompt = '';
}
function handleSubmit(e: Event) {
@ -123,6 +135,17 @@
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
$effect(() => {
if (session?.messages.length) {
@ -172,12 +195,12 @@
</button>
</div>
{#if showTree && session}
<AgentTree {session} />
<AgentTree {session} onNodeClick={handleTreeNodeClick} />
{/if}
{/if}
<div class="messages" bind:this={scrollContainer} onscroll={handleScroll}>
{#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'}
<div class="msg-init">
<span class="label">Session started</span>
@ -241,6 +264,22 @@
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollToBottom(); }}>Scroll to bottom</button>
{/if}
</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'}
<div class="error-bar">
<span>Error: {session.error ?? 'Unknown'}</span>
@ -250,6 +289,22 @@
</button>
{/if}
</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}
</div>
{/if}
@ -633,4 +688,40 @@
.done-bar { color: var(--ctp-green); }
.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>

View file

@ -1,5 +1,5 @@
<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';
interface Props {
@ -19,7 +19,7 @@
// Layout constants
const NODE_W = 100;
const NODE_H = 32;
const NODE_H = 40;
const H_GAP = 24;
const V_GAP = 12;
@ -124,19 +124,30 @@
<!-- Status dot -->
<circle
cx={layout.x + 10}
cy={layout.y + NODE_H / 2}
cy={layout.y + NODE_H / 2 - 4}
r="3"
fill={statusColor(layout.node.status)}
/>
<!-- Label -->
<text
x={layout.x + 18}
y={layout.y + NODE_H / 2 + 1}
y={layout.y + NODE_H / 2 - 4}
fill="var(--text-primary)"
font-size="10"
font-family="var(--font-mono)"
dominant-baseline="middle"
>{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>
<!-- Recurse children -->

View file

@ -8,6 +8,7 @@
getPanes,
getGridTemplate,
getPaneGridArea,
getActivePreset,
focusPane,
removePane,
} from '../../stores/layout.svelte';
@ -18,16 +19,128 @@
let panes = $derived(getPanes());
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]) {
detachPane(pane);
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>
<div
class="tiling-grid"
style:grid-template-columns={gridTemplate.columns}
style:grid-template-rows={gridTemplate.rows}
class:dragging
bind:this={gridEl}
style:grid-template-columns={columns}
style:grid-template-rows={rows}
>
{#if panes.length === 0}
<div class="empty-state">
@ -91,12 +204,41 @@
{/if}
</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>
.tiling-grid {
display: grid;
gap: var(--pane-gap);
height: 100%;
padding: var(--pane-gap);
position: relative;
}
.tiling-grid.dragging {
user-select: none;
}
.pane-slot {
@ -150,4 +292,27 @@
color: var(--text-muted);
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>

View file

@ -4,7 +4,7 @@
import { CanvasAddon } from '@xterm/addon-canvas';
import { FitAddon } from '@xterm/addon-fit';
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 '@xterm/xterm/css/xterm.css';
@ -24,6 +24,7 @@
let unlistenData: UnlistenFn | null = null;
let unlistenExit: UnlistenFn | null = null;
let resizeObserver: ResizeObserver | null = null;
let unsubTheme: (() => void) | null = null;
onMount(async () => {
term = new Terminal({
@ -59,6 +60,24 @@
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
term.onData((data) => {
if (ptyId) writePty(ptyId, data);
@ -80,10 +99,16 @@
}, 100);
});
resizeObserver.observe(terminalEl);
// Hot-swap theme when flavor changes
unsubTheme = onThemeChange(() => {
term.options.theme = getXtermTheme();
});
});
onDestroy(async () => {
resizeObserver?.disconnect();
unsubTheme?.();
unlistenData?.();
unlistenExit?.();
if (ptyId) {

View file

@ -10,6 +10,17 @@ import {
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 {
return currentFlavor;
}
@ -22,6 +33,15 @@ export function getXtermTheme(): XtermTheme {
export async function setFlavor(flavor: CatppuccinFlavor): Promise<void> {
currentFlavor = 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 {
await setSetting('theme', flavor);
} catch (e) {