Compare commits

..

12 Commits

Author SHA1 Message Date
viper151
051a6b1e74 chore(release): v1.27.1 2026-03-29 01:15:38 +00:00
simosmik
f1063fd339 chore: release tokens 2026-03-29 01:13:13 +00:00
simosmik
27cd12432b chore: relicense to AGPL-3.0-or-later
Siteboon AI B.V. contributions relicensed from GPL-3.0 to
AGPL-3.0-or-later. Existing community contributions remain
under GPL-3.0, combined per GPL-3.0 Section 13.
Adds Section 7 additional terms (attribution, origin
protection, publicity restriction, trademark).
2026-03-29 00:57:09 +00:00
simosmik
004135ef01 chore: add terminal plugin in the plugins list
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:38:00 +00:00
xiguatoutou
b54cdf8168 fix: prevent split on undefined(#491) (#563) 2026-03-23 20:14:15 +03:00
simosmik
42a131389a chore: add release-it github action 2026-03-22 01:41:21 +00:00
simosmik
ebd1c0db92 chore(release): v1.26.3 2026-03-22 01:10:13 +00:00
simosmik
6d87cc5566 chore(release): v1.26.2 2026-03-21 16:59:38 +00:00
simosmik
17d6ec54af fix: change SW cache mechanism 2026-03-21 16:49:56 +00:00
simosmik
a41d2c713e fix: claude auth changes and adding copy on mobile 2026-03-21 16:40:44 +00:00
simosmik
08a6653b38 chore(release): v1.26.0 2026-03-20 15:42:41 +00:00
Simos Mikelatos
a4632dc4ce feat: unified message architecture with provider adapters and session store (#558)
- 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 16:45:06 +03:00
15 changed files with 872 additions and 585 deletions

50
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
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,6 +3,51 @@
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 [GPL-3.0 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.

789
LICENSE

File diff suppressed because it is too large Load Diff

13
NOTICE Normal file
View File

@@ -0,0 +1,13 @@
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,9 +213,11 @@ Yes, for self-hosted. CloudCLI UI reads from and writes to the same `~/.claude`
## License ## License
GNU General Public License v3.0 - see [LICENSE](LICENSE) file for details. 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.
This project is open source and free to use, modify, and distribute under the GPL v3 license. 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.
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.25.2", "version": "1.27.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.25.2", "version": "1.27.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "GPL-3.0", "license": "AGPL-3.0-or-later",
"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.25.2", "version": "1.27.1",
"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": "GPL-3.0", "license": "AGPL-3.0-or-later",
"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
const CACHE_NAME = 'claude-ui-v1'; // Cache only manifest (needed for PWA install). HTML and JS are never pre-cached
// 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,44 +10,63 @@ const urlsToCache = [
self.addEventListener('install', event => { self.addEventListener('install', event => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME) caches.open(CACHE_NAME)
.then(cache => { .then(cache => cache.addAll(urlsToCache))
return cache.addAll(urlsToCache);
})
); );
self.skipWaiting(); self.skipWaiting();
}); });
// Fetch event // Fetch event — network-first for everything except hashed assets
self.addEventListener('fetch', event => { self.addEventListener('fetch', event => {
// Never cache API requests or WebSocket upgrades const url = event.request.url;
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;
} }
event.respondWith( // Navigation requests (HTML) — always go to network, no caching
caches.match(event.request) if (event.request.mode === 'navigate') {
.then(response => { event.respondWith(
if (response) { 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 response;
} });
return fetch(event.request); })
} );
) return;
}
// Everything else — network-first
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
); );
}); });
// Activate event // Activate event — purge old caches
self.addEventListener('activate', event => { self.addEventListener('activate', event => {
event.waitUntil( event.waitUntil(
caches.keys().then(cacheNames => { caches.keys().then(cacheNames =>
return Promise.all( Promise.all(
cacheNames.map(cacheName => { cacheNames
if (cacheName !== CACHE_NAME) { .filter(name => name !== CACHE_NAME)
return caches.delete(cacheName); .map(name => caches.delete(name))
} )
}) )
);
})
); );
self.clients.claim(); self.clients.claim();
}); });

View File

@@ -33,7 +33,12 @@ 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,6 +6,7 @@ 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 }) {
@@ -264,6 +265,67 @@ 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');
@@ -273,6 +335,7 @@ 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());
@@ -311,6 +374,16 @@ 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);
@@ -326,6 +399,7 @@ 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">
@@ -382,9 +456,16 @@ export default function PluginSettingsTab() {
</span> </span>
</p> </p>
{/* Starter plugin suggestion — above the list */} {/* Official plugin suggestions — above the list */}
{!loading && !hasStarterInstalled && ( {!loading && (!hasStarterInstalled || !hasTerminalInstalled) && (
<StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} /> <div className="space-y-2">
{!hasStarterInstalled && (
<StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />
)}
{!hasTerminalInstalled && (
<TerminalPluginCard onInstall={handleInstallTerminal} installing={installingTerminal} />
)}
</div>
)} )}
{/* Plugin List */} {/* Plugin List */}
@@ -423,33 +504,30 @@ export default function PluginSettingsTab() {
</div> </div>
)} )}
{/* Build your own */} {/* Starter plugin */}
<div className="flex items-center justify-between gap-4 border-t border-border/50 pt-2"> <div className="flex items-center justify-center gap-3 border-t border-border/50 pt-2">
<div className="flex min-w-0 items-center gap-2"> <BookOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/40" />
<BookOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/40" /> <span className="text-xs text-muted-foreground/60">
<span className="text-xs text-muted-foreground/60"> {t('pluginSettings.starterPluginLabel')}
{t('pluginSettings.buildYourOwn')} </span>
</span> <span className="text-muted-foreground/20">·</span>
</div> <a
<div className="flex flex-shrink-0 items-center gap-3"> href={STARTER_PLUGIN_URL}
<a target="_blank"
href={STARTER_PLUGIN_URL} rel="noopener noreferrer"
target="_blank" className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
rel="noopener noreferrer" >
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground" {t('pluginSettings.starter')} <ExternalLink className="h-2.5 w-2.5" />
> </a>
{t('pluginSettings.starter')} <ExternalLink className="h-2.5 w-2.5" /> <span className="text-muted-foreground/20">·</span>
</a> <a
<span className="text-muted-foreground/20">·</span> href="https://cloudcli.ai/docs/plugin-overview"
<a target="_blank"
href="https://cloudcli.ai/docs/plugin-overview" rel="noopener noreferrer"
target="_blank" className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
rel="noopener noreferrer" >
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground" {t('pluginSettings.docs')} <ExternalLink className="h-2.5 w-2.5" />
> </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,10 +35,7 @@ const getProviderCommand = ({
} }
if (provider === 'claude') { if (provider === 'claude') {
if (isAuthenticated) { return 'claude --dangerously-skip-permissions /login';
return 'claude setup-token --dangerously-skip-permissions';
}
return 'claude /login --dangerously-skip-permissions';
} }
if (provider === 'cursor') { if (provider === 'cursor') {

View File

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

View File

@@ -1,61 +1,65 @@
import { type MutableRefObject, useState, useCallback, useEffect, useRef } from 'react'; import { type MutableRefObject, useCallback, useState } from 'react';
import { import {
ChevronLeft, Clipboard,
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';
const SHORTCUTS = [ type Shortcut =
{ id: 'escape', labelKey: 'escape', sequence: '\x1b', hint: 'Esc' }, | { type: 'key'; id: string; label: string; sequence: string }
{ id: 'tab', labelKey: 'tab', sequence: '\t', hint: 'Tab' }, | { type: 'modifier'; id: string; label: string; modifier: 'ctrl' | 'alt' }
{ id: 'shift-tab', labelKey: 'shiftTab', sequence: '\x1b[Z', hint: '\u21e7Tab' }, | { type: 'arrow'; id: string; sequence: string; icon: 'up' | 'down' | 'left' | 'right' };
{ id: 'arrow-up', labelKey: 'arrowUp', sequence: '\x1b[A', hint: '\u2191' },
{ id: 'arrow-down', labelKey: 'arrowDown', sequence: '\x1b[B', hint: '\u2193' }, const MOBILE_KEYS: Shortcut[] = [
] as const; { type: 'key', id: 'esc', label: 'Esc', sequence: '\x1b' },
{ 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 [isOpen, setIsOpen] = useState(false); const [ctrlActive, setCtrlActive] = useState(false);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const [altActive, setAltActive] = useState(false);
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) => {
@@ -68,103 +72,120 @@ export default function TerminalShortcutsPanel({
terminalRef.current?.scrollToBottom(); terminalRef.current?.scrollToBottom();
}, [terminalRef]); }, [terminalRef]);
return ( const pasteFromClipboard = useCallback(async () => {
<> if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) {
{/* Pull Tab */} return;
<button }
type="button"
onPointerDown={preventFocusSteal} try {
onClick={handleToggle} const text = await navigator.clipboard.readText();
className={`fixed ${ if (text.length > 0) {
isOpen ? 'right-64' : 'right-0' sendInput(text);
} 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`} }
style={{ top: '50%', transform: 'translateY(-50%)' }} } catch {
aria-label={ // Ignore clipboard permission errors.
isOpen }
? t('terminalShortcuts.handle.closePanel') }, [sendInput]);
: t('terminalShortcuts.handle.openPanel')
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);
{isOpen ? ( }
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" /> if (altActive && seq.length === 1) {
) : ( finalSeq = '\x1b' + finalSeq;
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" /> setAltActive(false);
)} }
</button> sendInput(finalSeq);
},
[ctrlActive, altActive, sendInput],
);
{/* Panel */} return (
<div <div className={`pointer-events-none fixed inset-x-0 ${bottomOffset} z-20 px-2 md:hidden`}>
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 ${ <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">
isOpen ? 'translate-x-0' : 'translate-x-full' <button
}`} type="button"
>
<div className="flex h-full flex-col">
{/* Header */}
<div className="border-b border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900">
<h3 className="flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-white">
<Keyboard className="h-5 w-5 text-gray-600 dark:text-gray-400" />
{t('terminalShortcuts.title')}
</h3>
</div>
{/* Content — conditionally rendered so buttons remount with clean CSS states */}
{isOpen && (
<div className="flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4">
{/* Shortcut Keys */}
<div className="space-y-2">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t('terminalShortcuts.sectionKeys')}
</h4>
{SHORTCUTS.map((shortcut) => (
<button
type="button"
key={shortcut.id}
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>
{/* Navigation */}
<div className="space-y-2">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t('terminalShortcuts.sectionNavigation')}
</h4>
<button
type="button"
onPointerDown={preventFocusSteal}
onClick={() => handleShortcutAction(scrollToBottom)}
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.scrollDown')}
</span>
<ArrowDownToLine className="h-4 w-4 text-gray-600 dark:text-gray-400" />
</button>
</div>
</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} onPointerDown={preventFocusSteal}
onClick={handleToggle} onClick={() => {
/> void pasteFromClipboard();
)} }}
</> disabled={!isConnected}
className={ICON_BTN}
title={t('terminalShortcuts.paste', { defaultValue: 'Paste' })}
aria-label={t('terminalShortcuts.paste', { defaultValue: 'Paste' })}
>
<Clipboard className="h-4 w-4" />
</button>
{MOBILE_KEYS.map((key) => {
if (key.type === 'modifier') {
const isActive = key.modifier === 'ctrl' ? ctrlActive : altActive;
const toggle =
key.modifier === 'ctrl'
? () => setCtrlActive((v) => !v)
: () => setAltActive((v) => !v);
return (
<button
type="button"
key={key.id}
onPointerDown={preventFocusSteal}
onClick={toggle}
disabled={!isConnected}
className={isActive ? KEY_BTN_ACTIVE : KEY_BTN}
>
{key.label}
</button>
);
}
if (key.type === 'arrow') {
const Icon = ARROW_ICONS[key.icon];
return (
<button
type="button"
key={key.id}
onPointerDown={preventFocusSteal}
onClick={() => sendInput(key.sequence)}
disabled={!isConnected}
className={ICON_BTN}
>
<Icon className="h-4 w-4" />
</button>
);
}
return (
<button
type="button"
key={key.id}
onPointerDown={preventFocusSteal}
onClick={() => handleKeyPress(key.sequence)}
disabled={!isConnected}
className={KEY_BTN}
>
{key.label}
</button>
);
})}
<button
type="button"
onPointerDown={preventFocusSteal}
onClick={scrollToBottom}
disabled={!isConnected}
className={ICON_BTN}
title={t('terminalShortcuts.scrollDown')}
aria-label={t('terminalShortcuts.scrollDown')}
>
<ArrowDownToLine className="h-4 w-4" />
</button>
</div>
</div>
); );
} }

View File

@@ -476,7 +476,7 @@
"installFailed": "Installation failed", "installFailed": "Installation failed",
"uninstallFailed": "Uninstall failed", "uninstallFailed": "Uninstall failed",
"toggleFailed": "Toggle failed", "toggleFailed": "Toggle failed",
"buildYourOwn": "Build your own plugin", "starterPluginLabel": "Starter Plugin",
"starter": "Starter", "starter": "Starter",
"docs": "Docs", "docs": "Docs",
"starterPlugin": { "starterPlugin": {
@@ -485,6 +485,12 @@
"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",