Compare commits

..

1 Commits

Author SHA1 Message Date
simosmik
7b3bc54d1b feat: unified message architecture with provider adapters and session store
- Add provider adapter layer (server/providers/) with registry pattern
    - Claude, Cursor, Codex, Gemini adapters normalize native formats to NormalizedMessage
    - Shared types.js defines ProviderAdapter interface and message kinds
    - Registry enables polymorphic provider lookup

  - Add unified REST endpoint: GET /api/sessions/:id/messages?provider=...
    - Replaces four provider-specific message endpoints with one
    - Delegates to provider adapters via registry

  - Add frontend session-keyed store (useSessionStore)
    - Per-session Map with serverMessages/realtimeMessages/merged
    - Dedup by ID, stale threshold for re-fetch, background session accumulation
    - No localStorage for messages — backend JSONL is source of truth

  - Add normalizedToChatMessages converter (useChatMessages)
    - Converts NormalizedMessage[] to existing ChatMessage[] UI format

  - Wire unified store into ChatInterface, useChatSessionState, useChatRealtimeHandlers
    - Session switch uses store cache for instant render
    - Background WebSocket messages routed to correct session slot
2026-03-19 00:03:07 +00:00
15 changed files with 581 additions and 868 deletions

View File

@@ -1,50 +0,0 @@
name: Release
on:
workflow_dispatch:
inputs:
increment:
description: 'Version bump: patch, minor, major, or explicit (e.g. 1.27.0)'
required: true
default: 'patch'
type: string
release_name:
description: 'Custom release name (optional, defaults to "CloudCLI UI vX.Y.Z")'
required: false
type: string
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }}
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
- name: git config
run: |
git config user.name "${GITHUB_ACTOR}"
git config user.email "${GITHUB_ACTOR}@users.noreply.github.com"
- run: npm ci
- name: Release
run: |
ARGS="--ci --increment=${{ inputs.increment }}"
if [ -n "${{ inputs.release_name }}" ]; then
ARGS="$ARGS --github.releaseName=\"${{ inputs.release_name }}\""
fi
npx release-it $ARGS
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -3,51 +3,6 @@
All notable changes to CloudCLI UI will be documented in this file. All notable changes to CloudCLI UI will be documented in this file.
## [1.27.1](https://github.com/siteboon/claudecodeui/compare/v1.26.3...v1.27.1) (2026-03-29)
### Bug Fixes
* prevent split on undefined[#491](https://github.com/siteboon/claudecodeui/issues/491) ([#563](https://github.com/siteboon/claudecodeui/issues/563)) ([b54cdf8](https://github.com/siteboon/claudecodeui/commit/b54cdf8168fc224e9907796e4229ae8ed34e6885))
### Maintenance
* add release-it github action ([42a1313](https://github.com/siteboon/claudecodeui/commit/42a131389a6954df0d2c3bedd2cb6d3406c5ebc1))
* add terminal plugin in the plugins list ([004135e](https://github.com/siteboon/claudecodeui/commit/004135ef0187023e1da29c4a7137a28a42ebf9af))
* release tokens ([f1063fd](https://github.com/siteboon/claudecodeui/commit/f1063fd33964ccb517f5ebcdd14526ed162e1138))
* relicense to AGPL-3.0-or-later ([27cd124](https://github.com/siteboon/claudecodeui/commit/27cd12432b7d3237981f86acd9cc99532d843d4a))
## [1.26.3](https://github.com/siteboon/claudecodeui/compare/v1.26.2...v1.26.3) (2026-03-22)
## [1.26.2](https://github.com/siteboon/claudecodeui/compare/v1.26.0...v1.26.2) (2026-03-21)
### Bug Fixes
* change SW cache mechanism ([17d6ec5](https://github.com/siteboon/claudecodeui/commit/17d6ec54af18d333c8b04d2ffc64793e688d996e))
* claude auth changes and adding copy on mobile ([a41d2c7](https://github.com/siteboon/claudecodeui/commit/a41d2c713e87d56f23d5884585b4bb43c43a250a))
## [1.26.0](https://github.com/siteboon/claudecodeui/compare/v1.25.2...v1.26.0) (2026-03-20)
### New Features
* add German (Deutsch) language support ([#525](https://github.com/siteboon/claudecodeui/issues/525)) ([a7299c6](https://github.com/siteboon/claudecodeui/commit/a7299c68237908c752d504c2e8eea91570a30203))
* add WebSocket proxy for plugin backends ([#553](https://github.com/siteboon/claudecodeui/issues/553)) ([88c60b7](https://github.com/siteboon/claudecodeui/commit/88c60b70b031798d51ce26c8f080a0f64d824b05))
* Browser autofill support for login form ([#521](https://github.com/siteboon/claudecodeui/issues/521)) ([72ff134](https://github.com/siteboon/claudecodeui/commit/72ff134b315b7a1d602f3cc7dd60d47c1c1c34af))
* git panel redesign ([#535](https://github.com/siteboon/claudecodeui/issues/535)) ([adb3a06](https://github.com/siteboon/claudecodeui/commit/adb3a06d7e66a6d2dbcdfb501615e617178314af))
* introduce notification system and claude notifications ([#450](https://github.com/siteboon/claudecodeui/issues/450)) ([45e71a0](https://github.com/siteboon/claudecodeui/commit/45e71a0e73b368309544165e4dcf8b7fd014e8dd))
* **refactor:** move plugins to typescript ([#557](https://github.com/siteboon/claudecodeui/issues/557)) ([612390d](https://github.com/siteboon/claudecodeui/commit/612390db536417e2f68c501329bfccf5c6795e45))
* unified message architecture with provider adapters and session store ([#558](https://github.com/siteboon/claudecodeui/issues/558)) ([a4632dc](https://github.com/siteboon/claudecodeui/commit/a4632dc4cec228a8febb7c5bae4807c358963678))
### Bug Fixes
* detect Claude auth from settings env ([#527](https://github.com/siteboon/claudecodeui/issues/527)) ([95bcee0](https://github.com/siteboon/claudecodeui/commit/95bcee0ec459f186d52aeffe100ac1a024e92909))
* remove /exit command from claude login flow during onboarding ([#552](https://github.com/siteboon/claudecodeui/issues/552)) ([4de8b78](https://github.com/siteboon/claudecodeui/commit/4de8b78c6db5d8c2c402afce0f0b4cc16d5b6496))
### Documentation
* add German language link to all README files ([#534](https://github.com/siteboon/claudecodeui/issues/534)) ([1d31c3e](https://github.com/siteboon/claudecodeui/commit/1d31c3ec8309b433a041f3099955addc8c136c35))
* **readme:** hotfix and improve for README.jp.md ([#550](https://github.com/siteboon/claudecodeui/issues/550)) ([7413c2c](https://github.com/siteboon/claudecodeui/commit/7413c2c78422c308ac949e6a83c3e9216b24b649))
* **README:** update translations with CloudCLI branding and feature restructuring ([#544](https://github.com/siteboon/claudecodeui/issues/544)) ([14aef73](https://github.com/siteboon/claudecodeui/commit/14aef73cc6085fbb519fe64aea7cac80b7d51285))
## [1.25.2](https://github.com/siteboon/claudecodeui/compare/v1.25.0...v1.25.2) (2026-03-11) ## [1.25.2](https://github.com/siteboon/claudecodeui/compare/v1.25.0...v1.25.2) (2026-03-11)
### New Features ### New Features

View File

@@ -153,4 +153,4 @@ This automatically:
## License ## License
By contributing, you agree that your contributions will be licensed under the [AGPL-3.0-or-later License](LICENSE), including the additional terms specified in Section 7 of the LICENSE file. By contributing, you agree that your contributions will be licensed under the [GPL-3.0 License](LICENSE).

785
LICENSE

File diff suppressed because it is too large Load Diff

13
NOTICE
View File

@@ -1,13 +0,0 @@
CloudCLI UI
Copyright 2025-2026 Siteboon AI B.V. and contributors
This software is licensed under the GNU Affero General Public License v3.0
or later (AGPL-3.0-or-later). See the LICENSE file for the full license text,
including additional terms under Section 7.
Originally developed by Siteboon AI B.V. (https://github.com/siteboon/claudecodeui).
Contributions by Siteboon AI B.V. prior to commit 004135ef were originally
published under GPL-3.0 and are hereby relicensed to AGPL-3.0-or-later.
Contributions by other authors prior to that commit remain under GPL-3.0
and are incorporated into this work as permitted by GPL-3.0 Section 13.

View File

@@ -213,11 +213,9 @@ Yes, for self-hosted. CloudCLI UI reads from and writes to the same `~/.claude`
## License ## License
GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later) — see [LICENSE](LICENSE) for the full text, including additional terms under Section 7. GNU General Public License v3.0 - see [LICENSE](LICENSE) file for details.
This project is open source and free to use, modify, and distribute under the AGPL-3.0-or-later license. If you modify this software and run it as a network service, you must make your modified source code available to users of that service. This project is open source and free to use, modify, and distribute under the GPL v3 license.
CloudCLI UI - (https://cloudcli.ai).
## Acknowledgments ## Acknowledgments

6
package-lock.json generated
View File

@@ -1,14 +1,14 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.27.1", "version": "1.25.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.27.1", "version": "1.25.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.59", "@anthropic-ai/claude-agent-sdk": "^0.2.59",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.27.1", "version": "1.25.2",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "server/index.js", "main": "server/index.js",
@@ -46,7 +46,7 @@
"mobile" "mobile"
], ],
"author": "CloudCLI UI Contributors", "author": "CloudCLI UI Contributors",
"license": "AGPL-3.0-or-later", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.59", "@anthropic-ai/claude-agent-sdk": "^0.2.59",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",

View File

@@ -1,8 +1,8 @@
// Service Worker for Claude Code UI PWA // Service Worker for Claude Code UI PWA
// Cache only manifest (needed for PWA install). HTML and JS are never pre-cached const CACHE_NAME = 'claude-ui-v1';
// so a rebuild + refresh always picks up the latest assets.
const CACHE_NAME = 'claude-ui-v2';
const urlsToCache = [ const urlsToCache = [
'/',
'/index.html',
'/manifest.json' '/manifest.json'
]; ];
@@ -10,63 +10,44 @@ const urlsToCache = [
self.addEventListener('install', event => { self.addEventListener('install', event => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME) caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache)) .then(cache => {
return cache.addAll(urlsToCache);
})
); );
self.skipWaiting(); self.skipWaiting();
}); });
// Fetch event — network-first for everything except hashed assets // Fetch event
self.addEventListener('fetch', event => { self.addEventListener('fetch', event => {
const url = event.request.url; // Never cache API requests or WebSocket upgrades
if (event.request.url.includes('/api/') || event.request.url.includes('/ws')) {
// Never intercept API requests or WebSocket upgrades
if (url.includes('/api/') || url.includes('/ws')) {
return; return;
} }
// Navigation requests (HTML) — always go to network, no caching
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => caches.match('/manifest.json').then(() =>
new Response('<h1>Offline</h1><p>Please check your connection.</p>', {
headers: { 'Content-Type': 'text/html' }
})
))
);
return;
}
// Hashed assets (JS/CSS in /assets/) — cache-first since filenames change per build
if (url.includes('/assets/')) {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
return response;
});
})
);
return;
}
// Everything else — network-first
event.respondWith( event.respondWith(
fetch(event.request).catch(() => caches.match(event.request)) caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request);
}
)
); );
}); });
// Activate event — purge old caches // Activate event
self.addEventListener('activate', event => { self.addEventListener('activate', event => {
event.waitUntil( event.waitUntil(
caches.keys().then(cacheNames => caches.keys().then(cacheNames => {
Promise.all( return Promise.all(
cacheNames cacheNames.map(cacheName => {
.filter(name => name !== CACHE_NAME) if (cacheName !== CACHE_NAME) {
.map(name => caches.delete(name)) return caches.delete(cacheName);
) }
) })
);
})
); );
self.clients.claim(); self.clients.claim();
}); });

View File

@@ -33,12 +33,7 @@ export const ToolDiffViewer: React.FC<ToolDiffViewerProps> = ({
: 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400'; : 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400';
const diffLines = useMemo( const diffLines = useMemo(
() => { () => createDiff(oldContent, newContent),
if (oldContent === undefined || newContent === undefined) {
return [];
}
return createDiff(oldContent, newContent)
},
[createDiff, oldContent, newContent] [createDiff, oldContent, newContent]
); );

View File

@@ -6,7 +6,6 @@ import type { Plugin } from '../../../contexts/PluginsContext';
import PluginIcon from './PluginIcon'; import PluginIcon from './PluginIcon';
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter'; const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */ /* ─── Toggle Switch ─────────────────────────────────────────────────────── */
function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) { function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
@@ -265,67 +264,6 @@ function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; i
); );
} }
/* ─── Terminal Plugin Card ──────────────────────────────────────────────── */
function TerminalPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
const { t } = useTranslation('settings');
return (
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
<div className="min-w-0 flex-1 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M7 8l4 4-4 4"/>
<line x1="13" y1="16" x2="17" y2="16"/>
</svg>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-foreground">
{t('pluginSettings.terminalPlugin.name')}
</span>
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
{t('pluginSettings.terminalPlugin.badge')}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t('pluginSettings.tab')}
</span>
</div>
<p className="mt-1 text-sm leading-snug text-muted-foreground">
{t('pluginSettings.terminalPlugin.description')}
</p>
<a
href={TERMINAL_PLUGIN_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
>
<GitBranch className="h-3 w-3" />
cloudcli-ai/cloudcli-plugin-terminal
</a>
</div>
</div>
<button
onClick={onInstall}
disabled={installing}
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{installing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{installing ? t('pluginSettings.installing') : t('pluginSettings.terminalPlugin.install')}
</button>
</div>
</div>
</div>
);
}
/* ─── Main Component ────────────────────────────────────────────────────── */ /* ─── Main Component ────────────────────────────────────────────────────── */
export default function PluginSettingsTab() { export default function PluginSettingsTab() {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
@@ -335,7 +273,6 @@ export default function PluginSettingsTab() {
const [gitUrl, setGitUrl] = useState(''); const [gitUrl, setGitUrl] = useState('');
const [installing, setInstalling] = useState(false); const [installing, setInstalling] = useState(false);
const [installingStarter, setInstallingStarter] = useState(false); const [installingStarter, setInstallingStarter] = useState(false);
const [installingTerminal, setInstallingTerminal] = useState(false);
const [installError, setInstallError] = useState<string | null>(null); const [installError, setInstallError] = useState<string | null>(null);
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null); const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set()); const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
@@ -374,16 +311,6 @@ export default function PluginSettingsTab() {
setInstallingStarter(false); setInstallingStarter(false);
}; };
const handleInstallTerminal = async () => {
setInstallingTerminal(true);
setInstallError(null);
const result = await installPlugin(TERMINAL_PLUGIN_URL);
if (!result.success) {
setInstallError(result.error || t('pluginSettings.installFailed'));
}
setInstallingTerminal(false);
};
const handleUninstall = async (name: string) => { const handleUninstall = async (name: string) => {
if (confirmUninstall !== name) { if (confirmUninstall !== name) {
setConfirmUninstall(name); setConfirmUninstall(name);
@@ -399,7 +326,6 @@ export default function PluginSettingsTab() {
}; };
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats'); const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
const hasTerminalInstalled = plugins.some((p) => p.name === 'web-terminal');
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -456,16 +382,9 @@ export default function PluginSettingsTab() {
</span> </span>
</p> </p>
{/* Official plugin suggestions — above the list */} {/* Starter plugin suggestion — above the list */}
{!loading && (!hasStarterInstalled || !hasTerminalInstalled) && ( {!loading && !hasStarterInstalled && (
<div className="space-y-2"> <StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />
{!hasStarterInstalled && (
<StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />
)}
{!hasTerminalInstalled && (
<TerminalPluginCard onInstall={handleInstallTerminal} installing={installingTerminal} />
)}
</div>
)} )}
{/* Plugin List */} {/* Plugin List */}
@@ -504,30 +423,33 @@ export default function PluginSettingsTab() {
</div> </div>
)} )}
{/* Starter plugin */} {/* Build your own */}
<div className="flex items-center justify-center gap-3 border-t border-border/50 pt-2"> <div className="flex items-center justify-between gap-4 border-t border-border/50 pt-2">
<BookOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/40" /> <div className="flex min-w-0 items-center gap-2">
<span className="text-xs text-muted-foreground/60"> <BookOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/40" />
{t('pluginSettings.starterPluginLabel')} <span className="text-xs text-muted-foreground/60">
</span> {t('pluginSettings.buildYourOwn')}
<span className="text-muted-foreground/20">·</span> </span>
<a </div>
href={STARTER_PLUGIN_URL} <div className="flex flex-shrink-0 items-center gap-3">
target="_blank" <a
rel="noopener noreferrer" href={STARTER_PLUGIN_URL}
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground" target="_blank"
> rel="noopener noreferrer"
{t('pluginSettings.starter')} <ExternalLink className="h-2.5 w-2.5" /> className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
</a> >
<span className="text-muted-foreground/20">·</span> {t('pluginSettings.starter')} <ExternalLink className="h-2.5 w-2.5" />
<a </a>
href="https://cloudcli.ai/docs/plugin-overview" <span className="text-muted-foreground/20">·</span>
target="_blank" <a
rel="noopener noreferrer" href="https://cloudcli.ai/docs/plugin-overview"
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground" target="_blank"
> rel="noopener noreferrer"
{t('pluginSettings.docs')} <ExternalLink className="h-2.5 w-2.5" /> className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
</a> >
{t('pluginSettings.docs')} <ExternalLink className="h-2.5 w-2.5" />
</a>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -24,7 +24,7 @@ type ProviderLoginModalProps = {
const getProviderCommand = ({ const getProviderCommand = ({
provider, provider,
customCommand, customCommand,
isAuthenticated: _isAuthenticated, isAuthenticated,
}: { }: {
provider: CliProvider; provider: CliProvider;
customCommand?: string; customCommand?: string;
@@ -35,7 +35,10 @@ const getProviderCommand = ({
} }
if (provider === 'claude') { if (provider === 'claude') {
return 'claude --dangerously-skip-permissions /login'; if (isAuthenticated) {
return 'claude setup-token --dangerously-skip-permissions';
}
return 'claude /login --dangerously-skip-permissions';
} }
if (provider === 'cursor') { if (provider === 'cursor') {

View File

@@ -207,23 +207,15 @@ export default function Shell({
if (minimal) { if (minimal) {
return ( return (
<> <ShellMinimalView
<ShellMinimalView terminalContainerRef={terminalContainerRef}
terminalContainerRef={terminalContainerRef} authUrl={authUrl}
authUrl={authUrl} authUrlVersion={authUrlVersion}
authUrlVersion={authUrlVersion} initialCommand={initialCommand}
initialCommand={initialCommand} isConnected={isConnected}
isConnected={isConnected} openAuthUrlInBrowser={openAuthUrlInBrowser}
openAuthUrlInBrowser={openAuthUrlInBrowser} copyAuthUrlToClipboard={copyAuthUrlToClipboard}
copyAuthUrlToClipboard={copyAuthUrlToClipboard} />
/>
<TerminalShortcutsPanel
wsRef={wsRef}
terminalRef={terminalRef}
isConnected={isConnected}
bottomOffset="bottom-0"
/>
</>
); );
} }

View File

@@ -1,65 +1,61 @@
import { type MutableRefObject, useCallback, useState } from 'react'; import { type MutableRefObject, useState, useCallback, useEffect, useRef } from 'react';
import { import {
Clipboard, ChevronLeft,
ChevronRight,
Keyboard,
ArrowDownToLine, ArrowDownToLine,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { Terminal } from '@xterm/xterm'; import type { Terminal } from '@xterm/xterm';
import { sendSocketMessage } from '../../utils/socket'; import { sendSocketMessage } from '../../utils/socket';
type Shortcut = const SHORTCUTS = [
| { type: 'key'; id: string; label: string; sequence: string } { id: 'escape', labelKey: 'escape', sequence: '\x1b', hint: 'Esc' },
| { type: 'modifier'; id: string; label: string; modifier: 'ctrl' | 'alt' } { id: 'tab', labelKey: 'tab', sequence: '\t', hint: 'Tab' },
| { type: 'arrow'; id: string; sequence: string; icon: 'up' | 'down' | 'left' | 'right' }; { id: 'shift-tab', labelKey: 'shiftTab', sequence: '\x1b[Z', hint: '\u21e7Tab' },
{ id: 'arrow-up', labelKey: 'arrowUp', sequence: '\x1b[A', hint: '\u2191' },
const MOBILE_KEYS: Shortcut[] = [ { id: 'arrow-down', labelKey: 'arrowDown', sequence: '\x1b[B', hint: '\u2193' },
{ type: 'key', id: 'esc', label: 'Esc', sequence: '\x1b' }, ] as const;
{ type: 'key', id: 'tab', label: 'Tab', sequence: '\t' },
{ type: 'key', id: 'shift-tab', label: '\u21e7Tab', sequence: '\x1b[Z' },
{ type: 'modifier', id: 'ctrl', label: 'CTRL', modifier: 'ctrl' },
{ type: 'modifier', id: 'alt', label: 'ALT', modifier: 'alt' },
{ type: 'arrow', id: 'arrow-up', sequence: '\x1b[A', icon: 'up' },
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
];
const ARROW_ICONS = {
up: ArrowUp,
down: ArrowDown,
left: ArrowLeft,
right: ArrowRight,
} as const;
type TerminalShortcutsPanelProps = { type TerminalShortcutsPanelProps = {
wsRef: MutableRefObject<WebSocket | null>; wsRef: MutableRefObject<WebSocket | null>;
terminalRef: MutableRefObject<Terminal | null>; terminalRef: MutableRefObject<Terminal | null>;
isConnected: boolean; isConnected: boolean;
bottomOffset?: string;
}; };
const preventFocusSteal = (e: React.PointerEvent) => e.preventDefault(); const preventFocusSteal = (e: React.PointerEvent) => e.preventDefault();
const KEY_BTN =
'shrink-0 rounded-md border border-gray-600 bg-gray-700 px-2.5 py-1.5 text-xs font-medium text-gray-100 transition-colors select-none active:bg-blue-600 active:text-white active:border-blue-600 disabled:cursor-not-allowed disabled:opacity-40';
const KEY_BTN_ACTIVE =
'shrink-0 rounded-md border border-blue-500 bg-blue-600 px-2.5 py-1.5 text-xs font-medium text-white transition-colors select-none disabled:cursor-not-allowed disabled:opacity-40';
const ICON_BTN =
'shrink-0 rounded-md border border-gray-600 bg-gray-700 p-1.5 text-gray-100 transition-colors select-none active:bg-blue-600 active:text-white active:border-blue-600 disabled:cursor-not-allowed disabled:opacity-40';
export default function TerminalShortcutsPanel({ export default function TerminalShortcutsPanel({
wsRef, wsRef,
terminalRef, terminalRef,
isConnected, isConnected,
bottomOffset = 'bottom-14',
}: TerminalShortcutsPanelProps) { }: TerminalShortcutsPanelProps) {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const [ctrlActive, setCtrlActive] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [altActive, setAltActive] = useState(false); const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);
const handleToggle = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
const handleShortcutAction = useCallback((action: () => void) => {
action();
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
closeTimeoutRef.current = setTimeout(() => setIsOpen(false), 50);
}, []);
const sendInput = useCallback( const sendInput = useCallback(
(data: string) => { (data: string) => {
@@ -72,120 +68,103 @@ export default function TerminalShortcutsPanel({
terminalRef.current?.scrollToBottom(); terminalRef.current?.scrollToBottom();
}, [terminalRef]); }, [terminalRef]);
const pasteFromClipboard = useCallback(async () => {
if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) {
return;
}
try {
const text = await navigator.clipboard.readText();
if (text.length > 0) {
sendInput(text);
}
} catch {
// Ignore clipboard permission errors.
}
}, [sendInput]);
const handleKeyPress = useCallback(
(seq: string) => {
let finalSeq = seq;
if (ctrlActive && seq.length === 1) {
const code = seq.toLowerCase().charCodeAt(0);
if (code >= 97 && code <= 122) {
finalSeq = String.fromCharCode(code - 96);
}
setCtrlActive(false);
}
if (altActive && seq.length === 1) {
finalSeq = '\x1b' + finalSeq;
setAltActive(false);
}
sendInput(finalSeq);
},
[ctrlActive, altActive, sendInput],
);
return ( return (
<div className={`pointer-events-none fixed inset-x-0 ${bottomOffset} z-20 px-2 md:hidden`}> <>
<div className="pointer-events-auto flex items-center gap-1 overflow-x-auto rounded-lg border border-gray-700/80 bg-gray-900/95 px-1.5 py-1.5 shadow-lg backdrop-blur-sm [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> {/* Pull Tab */}
<button <button
type="button" type="button"
onPointerDown={preventFocusSteal} onPointerDown={preventFocusSteal}
onClick={() => { onClick={handleToggle}
void pasteFromClipboard(); className={`fixed ${
}} isOpen ? 'right-64' : 'right-0'
disabled={!isConnected} } z-50 cursor-pointer rounded-l-md border border-gray-200 bg-white p-2 shadow-lg transition-all duration-150 ease-out hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700`}
className={ICON_BTN} style={{ top: '50%', transform: 'translateY(-50%)' }}
title={t('terminalShortcuts.paste', { defaultValue: 'Paste' })} aria-label={
aria-label={t('terminalShortcuts.paste', { defaultValue: 'Paste' })} isOpen
> ? t('terminalShortcuts.handle.closePanel')
<Clipboard className="h-4 w-4" /> : t('terminalShortcuts.handle.openPanel')
</button> }
>
{isOpen ? (
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
) : (
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
)}
</button>
{MOBILE_KEYS.map((key) => { {/* Panel */}
if (key.type === 'modifier') { <div
const isActive = key.modifier === 'ctrl' ? ctrlActive : altActive; className={`fixed right-0 top-0 z-40 h-full w-64 transform border-l border-border bg-background shadow-xl transition-transform duration-150 ease-out ${
const toggle = isOpen ? 'translate-x-0' : 'translate-x-full'
key.modifier === 'ctrl' }`}
? () => setCtrlActive((v) => !v) >
: () => setAltActive((v) => !v); <div className="flex h-full flex-col">
return ( {/* Header */}
<button <div className="border-b border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900">
type="button" <h3 className="flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-white">
key={key.id} <Keyboard className="h-5 w-5 text-gray-600 dark:text-gray-400" />
onPointerDown={preventFocusSteal} {t('terminalShortcuts.title')}
onClick={toggle} </h3>
disabled={!isConnected} </div>
className={isActive ? KEY_BTN_ACTIVE : KEY_BTN}
>
{key.label}
</button>
);
}
if (key.type === 'arrow') { {/* Content — conditionally rendered so buttons remount with clean CSS states */}
const Icon = ARROW_ICONS[key.icon]; {isOpen && (
return ( <div className="flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4">
<button {/* Shortcut Keys */}
type="button" <div className="space-y-2">
key={key.id} <h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
onPointerDown={preventFocusSteal} {t('terminalShortcuts.sectionKeys')}
onClick={() => sendInput(key.sequence)} </h4>
disabled={!isConnected} {SHORTCUTS.map((shortcut) => (
className={ICON_BTN} <button
> type="button"
<Icon className="h-4 w-4" /> key={shortcut.id}
</button> onPointerDown={preventFocusSteal}
); onClick={() => handleShortcutAction(() => sendInput(shortcut.sequence))}
} disabled={!isConnected}
className="flex w-full items-center justify-between rounded-lg border border-transparent bg-gray-50 p-3 transition-colors hover:border-gray-300 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-gray-800 dark:hover:border-gray-600 dark:hover:bg-gray-700"
>
<span className="text-sm text-gray-900 dark:text-white">
{t(`terminalShortcuts.${shortcut.labelKey}`)}
</span>
<kbd className="rounded border border-gray-300 bg-gray-200 px-2 py-0.5 font-mono text-xs text-gray-600 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
{shortcut.hint}
</kbd>
</button>
))}
</div>
return ( {/* Navigation */}
<button <div className="space-y-2">
type="button" <h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
key={key.id} {t('terminalShortcuts.sectionNavigation')}
onPointerDown={preventFocusSteal} </h4>
onClick={() => handleKeyPress(key.sequence)} <button
disabled={!isConnected} type="button"
className={KEY_BTN} onPointerDown={preventFocusSteal}
> onClick={() => handleShortcutAction(scrollToBottom)}
{key.label} disabled={!isConnected}
</button> className="flex w-full items-center justify-between rounded-lg border border-transparent bg-gray-50 p-3 transition-colors hover:border-gray-300 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-gray-800 dark:hover:border-gray-600 dark:hover:bg-gray-700"
); >
})} <span className="text-sm text-gray-900 dark:text-white">
{t('terminalShortcuts.scrollDown')}
<button </span>
type="button" <ArrowDownToLine className="h-4 w-4 text-gray-600 dark:text-gray-400" />
onPointerDown={preventFocusSteal} </button>
onClick={scrollToBottom} </div>
disabled={!isConnected} </div>
className={ICON_BTN} )}
title={t('terminalShortcuts.scrollDown')} </div>
aria-label={t('terminalShortcuts.scrollDown')}
>
<ArrowDownToLine className="h-4 w-4" />
</button>
</div> </div>
</div>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 z-30 bg-background/80 backdrop-blur-sm transition-opacity duration-150 ease-out"
onPointerDown={preventFocusSteal}
onClick={handleToggle}
/>
)}
</>
); );
} }

View File

@@ -476,7 +476,7 @@
"installFailed": "Installation failed", "installFailed": "Installation failed",
"uninstallFailed": "Uninstall failed", "uninstallFailed": "Uninstall failed",
"toggleFailed": "Toggle failed", "toggleFailed": "Toggle failed",
"starterPluginLabel": "Starter Plugin", "buildYourOwn": "Build your own plugin",
"starter": "Starter", "starter": "Starter",
"docs": "Docs", "docs": "Docs",
"starterPlugin": { "starterPlugin": {
@@ -485,12 +485,6 @@
"description": "File counts, lines of code, file-type breakdown, and recent activity for your project.", "description": "File counts, lines of code, file-type breakdown, and recent activity for your project.",
"install": "Install" "install": "Install"
}, },
"terminalPlugin": {
"name": "Terminal",
"badge": "official",
"description": "Integrated terminal with full shell access directly within the interface.",
"install": "Install"
},
"morePlugins": "More", "morePlugins": "More",
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",