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;
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue