Merge dev into main #24

Merged
jimmy merged 1 commits from dev into main 2026-03-06 15:21:12 +08:00
Showing only changes of commit aaabb8a373 - Show all commits
+78 -40
View File
@@ -1,5 +1,5 @@
<script>
import { onDestroy } from "svelte";
import { onMount, onDestroy } from "svelte";
import { VoiceSession } from "../lib/voice.js";
const hosts = [
@@ -10,13 +10,54 @@
{ id: "claude-dev", label: "Claude Dev" },
];
const DARK_THEME = {
background: "#0f172a", foreground: "#e2e8f0", cursor: "#6366f1",
cursorAccent: "#0f172a", selectionBackground: "#334155",
black: "#0f172a", red: "#f43f5e", green: "#10b981",
yellow: "#f59e0b", blue: "#3b82f6", magenta: "#a855f7",
cyan: "#06b6d4", white: "#e2e8f0",
};
const LIGHT_THEME = {
background: "#f8fafc", foreground: "#0f172a", cursor: "#4f46e5",
cursorAccent: "#f8fafc", selectionBackground: "#cbd5e1",
black: "#0f172a", red: "#e11d48", green: "#059669",
yellow: "#d97706", blue: "#2563eb", magenta: "#9333ea",
cyan: "#0891b2", white: "#334155",
};
let tabs = $state([]);
let activeTab = $state(null);
let showMenu = $state(false);
let voiceTick = $state(0);
let isDark = $state(false);
let tabCounter = 0;
const tabData = new Map();
function currentTheme() {
return isDark ? DARK_THEME : LIGHT_THEME;
}
function applyThemeToTerminals() {
const theme = currentTheme();
tabData.forEach((d) => {
d.term?.options && (d.term.options.theme = theme);
});
}
function syncTheme() {
isDark = document.documentElement.classList.contains("dark");
applyThemeToTerminals();
}
const themeObserver = new MutationObserver((mutations) => {
if (mutations.some((m) => m.attributeName === "class")) syncTheme();
});
onMount(() => {
syncTheme();
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
});
function cleanName(name) {
return (name || "image")
.replace(/[^a-zA-Z0-9._-]/g, "-")
@@ -106,13 +147,7 @@
cursorBlink: true, fontSize: 13,
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', 'Menlo', monospace",
lineHeight: 1.4,
theme: {
background: "#0f172a", foreground: "#e2e8f0", cursor: "#6366f1",
cursorAccent: "#0f172a", selectionBackground: "#334155",
black: "#0f172a", red: "#f43f5e", green: "#10b981",
yellow: "#f59e0b", blue: "#3b82f6", magenta: "#a855f7",
cyan: "#06b6d4", white: "#e2e8f0",
},
theme: currentTheme(),
});
const fit = new FitAddon();
term.loadAddon(fit);
@@ -182,25 +217,28 @@
function activeVoice() { return voiceTick >= 0 && tabData.get(activeTab)?.voice; }
onDestroy(() => tabData.forEach((d) => {
d.voice?.destroy();
d.ro?.disconnect();
d.term?.element?.removeEventListener("dragover", d.handleDragOver);
d.term?.element?.removeEventListener("drop", d.handleDrop);
d.term?.textarea?.removeEventListener("paste", d.handlePaste);
d.ws?.close();
d.term?.dispose();
}));
onDestroy(() => {
themeObserver.disconnect();
tabData.forEach((d) => {
d.voice?.destroy();
d.ro?.disconnect();
d.term?.element?.removeEventListener("dragover", d.handleDragOver);
d.term?.element?.removeEventListener("drop", d.handleDrop);
d.term?.textarea?.removeEventListener("paste", d.handlePaste);
d.ws?.close();
d.term?.dispose();
});
});
</script>
<div class={activeVoice()?.voiceMode ? 'flex flex-col h-[calc(100vh-4rem)] overflow-hidden gap-1' : 'space-y-2'}>
{#if !activeVoice()?.voiceMode}
<h1 class="text-2xl font-bold text-surface-900 tracking-tight">Terminal</h1>
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100 tracking-tight">Terminal</h1>
{/if}
<div class="flex items-center gap-1 border-b border-surface-700 pb-1 relative">
<div class="flex items-center gap-1 border-b border-surface-300 dark:border-surface-700 pb-1 relative">
{#each tabs as tab (tab.id)}
<button
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-lg transition-colors {activeTab === tab.id ? 'bg-surface-800 text-surface-100' : 'text-surface-400 hover:text-surface-200 hover:bg-surface-800/50'}"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-lg transition-colors {activeTab === tab.id ? 'bg-surface-200 dark:bg-surface-800 text-surface-900 dark:text-surface-100' : 'text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800/50'}"
onclick={() => activeTab = tab.id}
>
{#if tab.connected}
@@ -222,14 +260,14 @@
{/each}
<div class="relative">
<button
class="px-2 py-1 text-surface-400 hover:text-surface-100 hover:bg-surface-800 rounded text-lg leading-none"
class="px-2 py-1 text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:bg-surface-200 dark:hover:bg-surface-800 rounded text-lg leading-none"
onclick={() => showMenu = !showMenu}
>+</button>
{#if showMenu}
<div class="absolute top-full left-0 mt-1 bg-surface-800 border border-surface-600 rounded-lg shadow-xl z-10 py-1 min-w-[140px]">
<div class="absolute top-full left-0 mt-1 bg-surface-100 dark:bg-surface-800 border border-surface-300 dark:border-surface-600 rounded-lg shadow-xl z-10 py-1 min-w-[140px]">
{#each hosts as h}
<button
class="block w-full text-left px-3 py-1.5 text-sm text-surface-200 hover:bg-surface-700"
class="block w-full text-left px-3 py-1.5 text-sm text-surface-800 dark:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
onclick={() => addTab(h.id)}
>{h.label}</button>
{/each}
@@ -240,7 +278,7 @@
{@const v = activeVoice()}
<div class="ml-auto">
<button
class="px-2 py-1 rounded text-sm transition-colors {v.voiceMode ? 'text-rose-400' : v.sttAvailable ? 'text-surface-500 hover:text-surface-300' : 'text-surface-700 cursor-not-allowed'}"
class="px-2 py-1 rounded text-sm transition-colors {v.voiceMode ? 'text-rose-400' : v.sttAvailable ? 'text-surface-600 dark:text-surface-500 hover:text-surface-800 dark:hover:text-surface-300' : 'text-surface-300 dark:text-surface-700 cursor-not-allowed'}"
title={!v.sttAvailable ? 'STT not supported' : v.voiceMode ? 'Voice mode on' : 'Enter voice mode'}
disabled={!v.sttAvailable}
onclick={() => { if (!v.voiceMode) v.enterVoiceMode(); else v.exitVoiceMode(); voiceTick++; }}
@@ -251,12 +289,12 @@
{/if}
</div>
<p class="text-xs text-surface-500">
Tip: drag & drop or paste images into the active terminal tab to upload them to <code class="text-surface-400">/tmp/claude-images</code>.
<p class="text-xs text-surface-600 dark:text-surface-500">
Tip: drag & drop or paste images into the active terminal tab to upload them to <code class="text-surface-700 dark:text-surface-400">/tmp/claude-images</code>.
</p>
{#if !tabs.length}
<div class="flex items-center justify-center h-[calc(100vh-12rem)] text-surface-500 text-sm">
<div class="flex items-center justify-center h-[calc(100vh-12rem)] text-surface-600 dark:text-surface-500 text-sm">
Click + to open a terminal
</div>
{/if}
@@ -265,21 +303,21 @@
<div class="{activeTab === tab.id ? '' : 'hidden'} {activeVoice()?.voiceMode ? 'flex-1 min-h-0' : ''}">
<div
use:initTerminal={tab}
class="h-full rounded-xl overflow-hidden shadow-lg border border-surface-700 transition-all duration-300"
style="background: #0f172a; {activeVoice()?.voiceMode ? '' : 'height: calc(100vh - 10rem);'}"
class="h-full rounded-xl overflow-hidden shadow-lg border border-surface-300 dark:border-surface-700 bg-surface-50 dark:bg-[#0f172a] transition-all duration-300"
style="{activeVoice()?.voiceMode ? '' : 'height: calc(100vh - 10rem);'}"
></div>
</div>
{/each}
{#if activeVoice()?.voiceMode}
{@const v = activeVoice()}
<div class="mt-1 shrink-0 rounded-xl bg-surface-800/95 border border-surface-700 shadow-xl overflow-hidden animate-slide-up">
<div class="mt-1 shrink-0 rounded-xl bg-surface-100/95 dark:bg-surface-800/95 border border-surface-300 dark:border-surface-700 shadow-xl overflow-hidden animate-slide-up">
<!-- Header -->
<div class="flex items-center justify-between px-3 py-1.5 border-b border-surface-700/50">
<span class="text-xs font-medium text-surface-300">Voice Mode</span>
<div class="flex items-center justify-between px-3 py-1.5 border-b border-surface-300/50 dark:border-surface-700/50">
<span class="text-xs font-medium text-surface-700 dark:text-surface-300">Voice Mode</span>
<div class="flex items-center gap-2">
<select
class="bg-surface-700 text-surface-300 text-xs rounded px-1 py-0.5 outline-none max-w-[120px]"
class="bg-surface-200 dark:bg-surface-700 text-surface-800 dark:text-surface-300 text-xs rounded px-1 py-0.5 outline-none max-w-[120px]"
onchange={(e) => { v.setVoice(+e.target.value); voiceTick++; }}
>
{#each v.voices as voice, i}
@@ -287,11 +325,11 @@
{/each}
</select>
<button
class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-400 hover:text-surface-200 hover:bg-surface-700"
class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
onclick={() => { v.cycleLang(); voiceTick++; }}
>{v.langLabel}</button>
<button
class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-400 hover:text-surface-200 hover:bg-surface-700"
class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
onclick={() => { v.cycleTalkMode(); voiceTick++; }}
>{v.modeLabel}</button>
<button
@@ -305,7 +343,7 @@
{#if v.talkMode === "hold"}
<button
aria-label="Hold to talk"
class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-700 text-surface-300 hover:bg-surface-600'}"
class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600'}"
onpointerdown={() => { v.startHold(); voiceTick++; }}
onpointerup={() => { v.releaseHold(); voiceTick++; }}
onpointerleave={() => { if (v.listening) { v.releaseHold(); voiceTick++; } }}
@@ -315,21 +353,21 @@
{:else if v.talkMode === "tap"}
<button
aria-label="Tap to talk"
class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-700 text-surface-300 hover:bg-surface-600'}"
class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600'}"
onclick={() => { v.tapMic(); voiceTick++; }}
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</button>
{:else}
<div class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center {v.listening ? 'bg-emerald-500/20 text-emerald-400 animate-pulse' : 'bg-surface-700 text-surface-500'}">
<div class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center {v.listening ? 'bg-emerald-500/20 text-emerald-400 animate-pulse' : 'bg-surface-200 dark:bg-surface-700 text-surface-500'}">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</div>
{/if}
<div class="flex-1 min-w-0">
{#if v.transcript}
<p class="text-sm text-surface-400 italic truncate">"{v.transcript}"</p>
<p class="text-sm text-surface-600 dark:text-surface-400 italic truncate">"{v.transcript}"</p>
{:else}
<p class="text-xs text-surface-600">{v.speaking ? 'Speaking...' : v.listening ? 'Listening...' : 'Ready'}</p>
<p class="text-xs text-surface-500 dark:text-surface-600">{v.speaking ? 'Speaking...' : v.listening ? 'Listening...' : 'Ready'}</p>
{/if}
</div>
</div>