Compare commits

...

8 Commits

Author SHA1 Message Date
Haileyesus
1995a411d2 chore: add comments explaining host normalization 2026-03-11 16:05:26 +03:00
Haileyesus
468ab599be refactor: rename PORT to SERVER_PORT for clarity 2026-03-11 15:55:42 +03:00
Haileyesus
f3b25bbbab fix: use shared network hosts configuration for better proxy setup
- Normalize all localhost variants to 'localhost' for consistent proxy
configuration in Vite and server setup.
- use one source of truth for network hosts functions by moving them to
a shared
- log production and development urls
2026-03-11 15:45:11 +03:00
Haileyesus
0049ff51ee fix: use src hostname for redirecting to Vite in development
Previously, the server redirected to Vite using `localhost` as the hostname.
Even if the user was using HOST="0.0.0.0", if they connected to server from
another device on the same network using `http://<host_ip>:3001`, the
server would redirect them to `http://localhost:5173`, which would not
work since `localhost` would resolve to the client's machine instead of the server.
2026-03-11 14:35:44 +03:00
Haileyesus
6fa552f157 fix: remove --host from npm run server command
Running `vite --host` exposes the dev server on all interfaces. However,
we should expose it on all interfaces only when `HOST` is set to `0.0.0.0`.
Otherwise, we should assume the user wants to bind to a host of their choice
and not expose the server on the network.
2026-03-11 13:55:28 +03:00
Igor Zarubin
621853cbfb feat(i18n): localize plugin settings for all languages (#515)
* chore(gitignore): add .worktrees/ to .gitignore

* fix(gitignore): add .worktrees/ to .gitignore

* feat(i18n): localize plugin settings

- Add missing mainTabs.plugins key in Russian locale.
- Add useTranslation to PluginSettingsTab and MobileNav.
- Add pluginSettings translations for en, ru, ja, ko, zh-CN.
- Localize the mobile navigation More button.

* fix: remove Japanese symbols in Rorean translate

* fix: fix Korean typo and localize starter plugin error

* fix(plugins): localize toggle labels and fix translation issues

* refactor(plugins): extract inline onToggle to named handleToggle

* fix(plugins): localize repo input aria-label and "tab" badge
- Replace hardcoded aria-label with t('pluginSettings.installAriaLabel')
- Replace hardcoded "tab" badge text with t('pluginSettings.tab')
- Add missing keys to all settings.json locale files

* fix(plugins): localize "running" status badge
2026-03-11 10:09:54 +03:00
patrickmwatson
4d8fb6e30a fix: session reconnect catch-up, always-on input, frozen session recovery (#524)
- WebSocketContext: emit 'websocket-reconnected' on onopen when it's a reconnect
  (hasConnectedRef tracks first-connect vs. subsequent reconnects).
- useChatRealtimeHandlers: handle 'websocket-reconnected' via onWebSocketReconnect
  callback; added to globalMessageTypes to bypass sessionId mismatch checks.
- ChatInterface: on reconnect, re-fetch JSONL session history so messages missed
  during iOS background are shown immediately. Also resets isLoading and
  canAbortSession so a dead/restarted session no longer freezes the UI forever.
- ChatComposer: remove disabled={isLoading} from textarea — users can always
  type regardless of processing state; submit button still prevents double-send.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 10:06:57 +03:00
Haile
a77f213dd5 fix: numerous bugs (#528)
* fix(shell): copy terminal selections from xterm buffer

The shell was delegating Cmd/Ctrl+C to document.execCommand('copy'),
which copied the rendered DOM selection instead of xterm's logical
buffer text. Wrapped values like login URLs could pick up row
whitespace or line breaks and break when pasted.

Route keyboard copy through terminal.getSelection() and the shared
clipboard helper. Also intercept native copy events on the terminal
container so mouse selection and browser copy actions use the same
normalized terminal text.

Remove the copy listener during teardown to avoid leaking handlers
across terminal reinitialization.

* fix(shell): restore terminal focus when switching to the shell tab

Pass shell activity state from MainContent through StandaloneShell and use it
inside Shell to explicitly focus the xterm instance once the terminal is both
initialized and connected.

Previously, switching to the Shell tab left focus on the tab button because
isActive was being ignored and the terminal never called focus() after the tab
activation lifecycle completed. As a result, users had to click inside the
terminal before keyboard input would be accepted.

This change wires isActive through the shell stack, removes the unused prop
handling in Shell, and adds a focus effect that runs when the shell becomes
active and ready. The effect uses both requestAnimationFrame and a zero-delay
timeout so focus is applied reliably after rendering and connection state
updates settle.

This restores immediate typing when opening the shell tab and also improves the
reconnect path by re-focusing the terminal after the shell connection is ready.

* fix(shell): remove fallback command for codex and claude session resumes

The `|| claude` and `|| codex` fallback commands were causing errors as they are not valid commands.

* fix: use fallback while resuming codex and claude sessions for linux and windows

* feat(git): add revert latest local commit action in git panel

Add a complete revert-local-commit flow so users can undo the most recent
local commit directly from the Git header, placed before the refresh icon.

Backend
- add POST /api/git/revert-local-commit endpoint in server/routes/git.js
- validate project input and repository state before executing git operations
- revert latest commit with `git reset --soft HEAD~1` to keep changes staged
- handle initial-commit edge case by deleting HEAD ref when no parent exists
- return clear success and error responses for UI consumption

Frontend
- add useRevertLocalCommit hook to encapsulate API call and loading state
- wire hook into GitPanel and refresh git data after successful revert
- add new toolbar action in GitPanelHeader before refresh icon
- route action through existing confirmation modal flow
- disable action while request is in flight and show activity indicator

Shared UI and typing updates
- extend ConfirmActionType with `revertLocalCommit`
- add confirmation title, label, and style mappings for new action
- render RotateCcw icon for revert action in ConfirmActionModal

Result
- users can safely undo the latest local commit from the UI
- reverted commit changes remain staged for immediate recommit/edit workflows

* fix: run cursor with --trust if workspace trust prompt is detected, and retry once

* fix(git): handle repositories without commits across status and remote flows

Improve git route behavior for repositories initialized with `git init` but with
no commits yet. Previously, several routes called `git rev-parse --abbrev-ref HEAD`,
which fails before the first commit and caused noisy console errors plus a broken
Git panel state.

What changed
- add `getGitErrorDetails` helper to normalize git process failure text
- add `isMissingHeadRevisionError` helper to detect no-HEAD/no-revision cases
- add `getCurrentBranchName` helper:
  - uses `git symbolic-ref --short HEAD` first (works before first commit)
  - falls back to `git rev-parse --abbrev-ref HEAD` for detached HEAD and edge cases
- add `repositoryHasCommits` helper using `git rev-parse --verify HEAD`

Status route improvements
- replace inline branch/HEAD error handling with shared helpers
- keep returning valid branch + `hasCommits: false` for fresh repositories

Remote status improvements
- avoid hard failure when repository has no commits
- return a safe, non-error payload with:
  - `hasUpstream: false`
  - `ahead: 0`, `behind: 0`
  - detected remote name when remotes exist
  - message: "Repository has no commits yet"
- preserve existing upstream detection behavior for repositories with commits

Consistency updates
- switch fetch/pull/push/publish branch lookup to shared `getCurrentBranchName`
  to ensure the same branch-resolution behavior everywhere

Result
- `git init` repositories no longer trigger `rev-parse HEAD` ambiguity failures
- Git panel remains usable before the first commit
- backend branch detection is centralized and consistent across git operations

* fix(git): resolve file paths against repo root for paths with spaces

Fix path resolution for git file operations when project directories include spaces
or when API calls are issued from subdirectories inside a repository.

Problem
- operations like commit/discard/diff could receive file paths that were valid from
  repo root but were executed from a nested cwd
- this produced pathspec errors like:
  - warning: could not open directory '4/4/'
  - fatal: pathspec '4/hello_world.ts' did not match any files

Root cause
- file arguments were passed directly to git commands using the project cwd
- inconsistent path forms (repo-root-relative vs cwd-relative) were not normalized

Changes
- remove unsafe fallback decode in `getActualProjectPath`; fail explicitly when the
  real project path cannot be resolved
- add repository/file-path helpers:
  - `getRepositoryRootPath`
  - `normalizeRepositoryRelativeFilePath`
  - `parseStatusFilePaths`
  - `buildFilePathCandidates`
  - `resolveRepositoryFilePath`
- update file-based git endpoints to resolve paths before executing commands:
  - GET `/diff`
  - GET `/file-with-diff`
  - POST `/commit`
  - POST `/generate-commit-message`
  - POST `/discard`
  - POST `/delete-untracked`
- stage/restore/reset operations now use `--` before pathspecs for safer argument
  separation

Behavioral impact
- git operations now work reliably for repositories under directories containing spaces
- file operations are consistent even when project cwd is a subdirectory of repo root
- endpoint responses continue to preserve existing payload shapes

Verification
- syntax check: `node --check server/routes/git.js`
- typecheck: `npm run typecheck`
- reproduced failing scenario in a temp path with spaces; confirmed root-resolved
  path staging succeeds where subdir-cwd pathspec previously failed

* fix(git-ui): prevent large commit diffs from freezing the history tab

Harden commit diff loading/rendering so opening a very large commit no longer hangs
the browser tab.

Problem
- commit history diff viewer rendered every diff line as a React node
- very large commits could create thousands of nodes and lock the UI thread
- backend always returned full commit patch payloads, amplifying frontend pressure

Backend safeguards
- add `COMMIT_DIFF_CHARACTER_LIMIT` (500,000 chars) in git routes
- update GET `/api/git/commit-diff` to truncate oversized diff payloads
- include `isTruncated` flag in response for observability/future UI handling
- append truncation marker text when server-side limit is applied

Frontend safeguards
- update `GitDiffViewer` to use bounded preview rendering:
  - character cap: 200,000
  - line cap: 1,500
- move diff preprocessing into `useMemo` for stable, one-pass preview computation
- show a clear "Large diff preview" notice when truncation is active

Impact
- commit diff expansion remains responsive even for high-change commits
- UI still shows useful diff content while avoiding tab lockups
- changes apply to shared diff viewer usage and improve resilience broadly

Validation
- `node --check server/routes/git.js`
- `npm run typecheck`
- `npx eslint src/components/git-panel/view/shared/GitDiffViewer.tsx`

* fix(cursor-chat): stabilize first-run UX and clean cursor message rendering

Fix three Cursor chat regressions observed on first message runs:

1. Full-screen UI refresh/flicker after first response.
2. Internal wrapper tags rendered in user messages.
3. Duplicate assistant message on response finalization.

Root causes
- Project refresh from chat completion used the global loading path,
  toggling app-level loading UI.
- Cursor history conversion rendered raw internal wrapper payloads
  as user-visible message text.
- Cursor response handling could finalize through overlapping stream/
  result paths, and stdout chunk parsing could split JSON lines.

Changes
- Added non-blocking project refresh plumbing for chat/session flows.
- Introduced fetch options in useProjectsState (showLoadingState flag).
- Added refreshProjectsSilently() to update metadata without global loading UI.
- Wired window.refreshProjects to refreshProjectsSilently in AppContent.

- Added Cursor user-message sanitization during history conversion.
- Added extractCursorUserQuery() to keep only <user_query> payload.
- Added sanitizeCursorUserMessageText() to strip internal wrappers:
  <user_info>, <agent_skills>, <available_skills>,
  <environment_context>, <environment_info>.
- Applied sanitization only for role === 'user' in
  convertCursorSessionMessages().

- Hardened Cursor backend stream parsing and finalization.
- Added line-buffered stdout parser for chunk-split JSON payloads.
- Flushed trailing unterminated stdout line on process close.
- Removed redundant content_block_stop emission on Cursor result.

- Added frontend duplicate guard in cursor-result handling.
- Skips a second assistant bubble when final result text equals
  already-rendered streamed content.

Code comments
- Added focused comments describing silent refresh behavior,
  tag stripping rationale, duplicate guard behavior, and line buffering.

Validation
- ESLint passes for touched files.
- Production build succeeds.

Files
- server/cursor-cli.js
- src/components/app/AppContent.tsx
- src/components/chat/hooks/useChatRealtimeHandlers.ts
- src/components/chat/utils/messageTransforms.ts
- src/hooks/useProjectsState.ts

---------

Co-authored-by: Haileyesus <something@gmail.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-03-11 00:16:11 +01:00
38 changed files with 1129 additions and 331 deletions

View File

@@ -17,7 +17,7 @@
# Backend server port (Express API + WebSocket server) # Backend server port (Express API + WebSocket server)
#API server #API server
PORT=3001 SERVER_PORT=3001
#Frontend port #Frontend port
VITE_PORT=5173 VITE_PORT=5173

3
.gitignore vendored
View File

@@ -135,3 +135,6 @@ tasks/
!src/i18n/locales/en/tasks.json !src/i18n/locales/en/tasks.json
!src/i18n/locales/ja/tasks.json !src/i18n/locales/ja/tasks.json
!src/i18n/locales/ru/tasks.json !src/i18n/locales/ru/tasks.json
# Git worktrees
.worktrees/

View File

@@ -70,7 +70,7 @@
npx @siteboon/claude-code-ui npx @siteboon/claude-code-ui
``` ```
サーバーが起動し、`http://localhost:3001`(または設定した PORTでアクセスできます。 サーバーが起動し、`http://localhost:3001`(または設定した SERVER_PORTでアクセスできます。
**再起動**: サーバーを停止した後、同じ `npx` コマンドを再度実行するだけです **再起動**: サーバーを停止した後、同じ `npx` コマンドを再度実行するだけです
### グローバルインストール(定期的に使用する場合) ### グローバルインストール(定期的に使用する場合)

View File

@@ -70,7 +70,7 @@
npx @siteboon/claude-code-ui npx @siteboon/claude-code-ui
``` ```
서버가 시작되면 `http://localhost:3001` (또는 설정한 PORT)에서 접근할 수 있습니다. 서버가 시작되면 `http://localhost:3001` (또는 설정한 SERVER_PORT)에서 접근할 수 있습니다.
**재시작**: 서버를 중지한 후 동일한 `npx` 명령을 다시 실행하면 됩니다 **재시작**: 서버를 중지한 후 동일한 `npx` 명령을 다시 실행하면 됩니다
### 전역 설치 (정기적 사용 시) ### 전역 설치 (정기적 사용 시)

View File

@@ -70,7 +70,7 @@
npx @siteboon/claude-code-ui npx @siteboon/claude-code-ui
``` ```
服务器将启动并可通过 `http://localhost:3001`(或您配置的 PORT)访问。 服务器将启动并可通过 `http://localhost:3001`(或您配置的 SERVER_PORT)访问。
**重启**: 停止服务器后只需再次运行相同的 `npx` 命令 **重启**: 停止服务器后只需再次运行相同的 `npx` 命令

View File

@@ -26,7 +26,7 @@
"scripts": { "scripts": {
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"", "dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
"server": "node server/index.js", "server": "node server/index.js",
"client": "vite --host", "client": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",

View File

@@ -110,7 +110,7 @@ function showStatus() {
// Environment variables // Environment variables
console.log(`\n${c.info('[INFO]')} Configuration:`); console.log(`\n${c.info('[INFO]')} Configuration:`);
console.log(` PORT: ${c.bright(process.env.PORT || '3001')} ${c.dim(process.env.PORT ? '' : '(default)')}`); console.log(` SERVER_PORT: ${c.bright(process.env.SERVER_PORT || '3001')} ${c.dim(process.env.SERVER_PORT ? '' : '(default)')}`);
console.log(` DATABASE_PATH: ${c.dim(process.env.DATABASE_PATH || '(using default location)')}`); console.log(` DATABASE_PATH: ${c.dim(process.env.DATABASE_PATH || '(using default location)')}`);
console.log(` CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`); console.log(` CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`);
console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`); console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`);
@@ -134,7 +134,7 @@ function showStatus() {
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`); console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`);
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`); console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`);
console.log(` ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`); console.log(` ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`);
console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.PORT || '3001'}\n`); console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.SERVER_PORT || '3001'}\n`);
} }
// Show help // Show help
@@ -169,7 +169,7 @@ Examples:
$ cloudcli status # Show configuration $ cloudcli status # Show configuration
Environment Variables: Environment Variables:
PORT Set server port (default: 3001) SERVER_PORT Set server port (default: 3001)
DATABASE_PATH Set custom database location DATABASE_PATH Set custom database location
CLAUDE_CLI_PATH Set custom Claude CLI path CLAUDE_CLI_PATH Set custom Claude CLI path
CONTEXT_WINDOW Set context window size (default: 160000) CONTEXT_WINDOW Set context window size (default: 160000)
@@ -260,9 +260,9 @@ function parseArgs(args) {
const arg = args[i]; const arg = args[i];
if (arg === '--port' || arg === '-p') { if (arg === '--port' || arg === '-p') {
parsed.options.port = args[++i]; parsed.options.serverPort = args[++i];
} else if (arg.startsWith('--port=')) { } else if (arg.startsWith('--port=')) {
parsed.options.port = arg.split('=')[1]; parsed.options.serverPort = arg.split('=')[1];
} else if (arg === '--database-path') { } else if (arg === '--database-path') {
parsed.options.databasePath = args[++i]; parsed.options.databasePath = args[++i];
} else if (arg.startsWith('--database-path=')) { } else if (arg.startsWith('--database-path=')) {
@@ -285,8 +285,8 @@ async function main() {
const { command, options } = parseArgs(args); const { command, options } = parseArgs(args);
// Apply CLI options to environment variables // Apply CLI options to environment variables
if (options.port) { if (options.serverPort) {
process.env.PORT = options.port; process.env.SERVER_PORT = options.serverPort;
} }
if (options.databasePath) { if (options.databasePath) {
process.env.DATABASE_PATH = options.databasePath; process.env.DATABASE_PATH = options.databasePath;

View File

@@ -1,20 +1,33 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn'; import crossSpawn from 'cross-spawn';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
// Use cross-spawn on Windows for better command execution // Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
let activeCursorProcesses = new Map(); // Track active processes by session ID let activeCursorProcesses = new Map(); // Track active processes by session ID
const WORKSPACE_TRUST_PATTERNS = [
/workspace trust required/i,
/do you trust the contents of this directory/i,
/working with untrusted contents/i,
/pass --trust,\s*--yolo,\s*or -f/i
];
function isWorkspaceTrustPrompt(text = '') {
if (!text || typeof text !== 'string') {
return false;
}
return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text));
}
async function spawnCursor(command, options = {}, ws) { async function spawnCursor(command, options = {}, ws) {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = options; const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event let sessionCreatedSent = false; // Track if we've already sent session-created event
let messageBuffer = ''; // Buffer for accumulating assistant messages let hasRetriedWithTrust = false;
let settled = false;
// Use tools settings passed from frontend, or defaults // Use tools settings passed from frontend, or defaults
const settings = toolsSettings || { const settings = toolsSettings || {
@@ -23,36 +36,56 @@ async function spawnCursor(command, options = {}, ws) {
}; };
// Build Cursor CLI command // Build Cursor CLI command
const args = []; const baseArgs = [];
// Build flags allowing both resume and prompt together (reply in existing session) // Build flags allowing both resume and prompt together (reply in existing session)
// Treat presence of sessionId as intention to resume, regardless of resume flag // Treat presence of sessionId as intention to resume, regardless of resume flag
if (sessionId) { if (sessionId) {
args.push('--resume=' + sessionId); baseArgs.push('--resume=' + sessionId);
} }
if (command && command.trim()) { if (command && command.trim()) {
// Provide a prompt (works for both new and resumed sessions) // Provide a prompt (works for both new and resumed sessions)
args.push('-p', command); baseArgs.push('-p', command);
// Add model flag if specified (only meaningful for new sessions; harmless on resume) // Add model flag if specified (only meaningful for new sessions; harmless on resume)
if (!sessionId && model) { if (!sessionId && model) {
args.push('--model', model); baseArgs.push('--model', model);
} }
// Request streaming JSON when we are providing a prompt // Request streaming JSON when we are providing a prompt
args.push('--output-format', 'stream-json'); baseArgs.push('--output-format', 'stream-json');
} }
// Add skip permissions flag if enabled // Add skip permissions flag if enabled
if (skipPermissions || settings.skipPermissions) { if (skipPermissions || settings.skipPermissions) {
args.push('-f'); baseArgs.push('-f');
console.log('⚠️ Using -f flag (skip permissions)'); console.log('Using -f flag (skip permissions)');
} }
// Use cwd (actual project directory) instead of projectPath // Use cwd (actual project directory) instead of projectPath
const workingDir = cwd || projectPath || process.cwd(); const workingDir = cwd || projectPath || process.cwd();
// Store process reference for potential abort
const processKey = capturedSessionId || Date.now().toString();
const settleOnce = (callback) => {
if (settled) {
return;
}
settled = true;
callback();
};
const runCursorProcess = (args, runReason = 'initial') => {
const isTrustRetry = runReason === 'trust-retry';
let runSawWorkspaceTrustPrompt = false;
let stdoutLineBuffer = '';
if (isTrustRetry) {
console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
}
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' ')); console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
console.log('Working directory:', workingDir); console.log('Working directory:', workingDir);
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume); console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
@@ -63,21 +96,28 @@ async function spawnCursor(command, options = {}, ws) {
env: { ...process.env } // Inherit all environment variables env: { ...process.env } // Inherit all environment variables
}); });
// Store process reference for potential abort
const processKey = capturedSessionId || Date.now().toString();
activeCursorProcesses.set(processKey, cursorProcess); activeCursorProcesses.set(processKey, cursorProcess);
// Handle stdout (streaming JSON responses) const shouldSuppressForTrustRetry = (text) => {
cursorProcess.stdout.on('data', (data) => { if (hasRetriedWithTrust || args.includes('--trust')) {
const rawOutput = data.toString(); return false;
console.log('📤 Cursor CLI stdout:', rawOutput); }
if (!isWorkspaceTrustPrompt(text)) {
return false;
}
const lines = rawOutput.split('\n').filter(line => line.trim()); runSawWorkspaceTrustPrompt = true;
return true;
};
const processCursorOutputLine = (line) => {
if (!line || !line.trim()) {
return;
}
for (const line of lines) {
try { try {
const response = JSON.parse(line); const response = JSON.parse(line);
console.log('📄 Parsed JSON response:', response); console.log('Parsed JSON response:', response);
// Handle different message types // Handle different message types
switch (response.type) { switch (response.type) {
@@ -86,7 +126,7 @@ async function spawnCursor(command, options = {}, ws) {
// Capture session ID // Capture session ID
if (response.session_id && !capturedSessionId) { if (response.session_id && !capturedSessionId) {
capturedSessionId = response.session_id; capturedSessionId = response.session_id;
console.log('📝 Captured session ID:', capturedSessionId); console.log('Captured session ID:', capturedSessionId);
// Update process key with captured session ID // Update process key with captured session ID
if (processKey !== capturedSessionId) { if (processKey !== capturedSessionId) {
@@ -133,7 +173,6 @@ async function spawnCursor(command, options = {}, ws) {
// Accumulate assistant message chunks // Accumulate assistant message chunks
if (response.message && response.message.content && response.message.content.length > 0) { if (response.message && response.message.content && response.message.content.length > 0) {
const textContent = response.message.content[0].text; const textContent = response.message.content[0].text;
messageBuffer += textContent;
// Send as Claude-compatible format for frontend // Send as Claude-compatible format for frontend
ws.send({ ws.send({
@@ -154,18 +193,9 @@ async function spawnCursor(command, options = {}, ws) {
// Session complete // Session complete
console.log('Cursor session result:', response); console.log('Cursor session result:', response);
// Send final message if we have buffered content // Do not emit an extra content_block_stop here.
if (messageBuffer) { // The UI already finalizes the streaming message in cursor-result handling,
ws.send({ // and emitting both can produce duplicate assistant messages.
type: 'claude-response',
data: {
type: 'content_block_stop'
},
sessionId: capturedSessionId || sessionId || null
});
}
// Send completion event
ws.send({ ws.send({
type: 'cursor-result', type: 'cursor-result',
sessionId: capturedSessionId || sessionId, sessionId: capturedSessionId || sessionId,
@@ -183,7 +213,12 @@ async function spawnCursor(command, options = {}, ws) {
}); });
} }
} catch (parseError) { } catch (parseError) {
console.log('📄 Non-JSON response:', line); console.log('Non-JSON response:', line);
if (shouldSuppressForTrustRetry(line)) {
return;
}
// If not JSON, send as raw text // If not JSON, send as raw text
ws.send({ ws.send({
type: 'cursor-output', type: 'cursor-output',
@@ -191,15 +226,35 @@ async function spawnCursor(command, options = {}, ws) {
sessionId: capturedSessionId || sessionId || null sessionId: capturedSessionId || sessionId || null
}); });
} }
} };
// Handle stdout (streaming JSON responses)
cursorProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
console.log('Cursor CLI stdout:', rawOutput);
// Stream chunks can split JSON objects across packets; keep trailing partial line.
stdoutLineBuffer += rawOutput;
const completeLines = stdoutLineBuffer.split(/\r?\n/);
stdoutLineBuffer = completeLines.pop() || '';
completeLines.forEach((line) => {
processCursorOutputLine(line.trim());
});
}); });
// Handle stderr // Handle stderr
cursorProcess.stderr.on('data', (data) => { cursorProcess.stderr.on('data', (data) => {
console.error('Cursor CLI stderr:', data.toString()); const stderrText = data.toString();
console.error('Cursor CLI stderr:', stderrText);
if (shouldSuppressForTrustRetry(stderrText)) {
return;
}
ws.send({ ws.send({
type: 'cursor-error', type: 'cursor-error',
error: data.toString(), error: stderrText,
sessionId: capturedSessionId || sessionId || null sessionId: capturedSessionId || sessionId || null
}); });
}); });
@@ -208,10 +263,26 @@ async function spawnCursor(command, options = {}, ws) {
cursorProcess.on('close', async (code) => { cursorProcess.on('close', async (code) => {
console.log(`Cursor CLI process exited with code ${code}`); console.log(`Cursor CLI process exited with code ${code}`);
// Clean up process reference
const finalSessionId = capturedSessionId || sessionId || processKey; const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId); activeCursorProcesses.delete(finalSessionId);
// Flush any final unterminated stdout line before completion handling.
if (stdoutLineBuffer.trim()) {
processCursorOutputLine(stdoutLineBuffer.trim());
stdoutLineBuffer = '';
}
if (
runSawWorkspaceTrustPrompt &&
code !== 0 &&
!hasRetriedWithTrust &&
!args.includes('--trust')
) {
hasRetriedWithTrust = true;
runCursorProcess([...args, '--trust'], 'trust-retry');
return;
}
ws.send({ ws.send({
type: 'claude-complete', type: 'claude-complete',
sessionId: finalSessionId, sessionId: finalSessionId,
@@ -220,9 +291,9 @@ async function spawnCursor(command, options = {}, ws) {
}); });
if (code === 0) { if (code === 0) {
resolve(); settleOnce(() => resolve());
} else { } else {
reject(new Error(`Cursor CLI exited with code ${code}`)); settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));
} }
}); });
@@ -240,18 +311,21 @@ async function spawnCursor(command, options = {}, ws) {
sessionId: capturedSessionId || sessionId || null sessionId: capturedSessionId || sessionId || null
}); });
reject(error); settleOnce(() => reject(error));
}); });
// Close stdin since Cursor doesn't need interactive input // Close stdin since Cursor doesn't need interactive input
cursorProcess.stdin.end(); cursorProcess.stdin.end();
};
runCursorProcess(baseArgs, 'initial');
}); });
} }
function abortCursorSession(sessionId) { function abortCursorSession(sessionId) {
const process = activeCursorProcesses.get(sessionId); const process = activeCursorProcesses.get(sessionId);
if (process) { if (process) {
console.log(`🛑 Aborting Cursor session: ${sessionId}`); console.log(`Aborting Cursor session: ${sessionId}`);
process.kill('SIGTERM'); process.kill('SIGTERM');
activeCursorProcesses.delete(sessionId); activeCursorProcesses.delete(sessionId);
return true; return true;

View File

@@ -31,7 +31,7 @@ const c = {
dim: (text) => `${colors.dim}${text}${colors.reset}`, dim: (text) => `${colors.dim}${text}${colors.reset}`,
}; };
console.log('PORT from env:', process.env.PORT); console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
import express from 'express'; import express from 'express';
import { WebSocketServer, WebSocket } from 'ws'; import { WebSocketServer, WebSocket } from 'ws';
@@ -69,6 +69,7 @@ import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-proces
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js'; import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js'; import { IS_PLATFORM } from './constants/config.js';
import { getConnectableHost } from '../shared/networkHosts.js';
const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini']; const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini'];
@@ -1730,8 +1731,14 @@ function handleShellConnection(ws) {
shellCommand = 'cursor-agent'; shellCommand = 'cursor-agent';
} }
} else if (provider === 'codex') { } else if (provider === 'codex') {
// Use codex command; attempt to resume and fall back to a new session when the resume fails.
if (hasSession && sessionId) { if (hasSession && sessionId) {
if (os.platform() === 'win32') {
// PowerShell syntax for fallback
shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
} else {
shellCommand = `codex resume "${sessionId}" || codex`; shellCommand = `codex resume "${sessionId}" || codex`;
}
} else { } else {
shellCommand = 'codex'; shellCommand = 'codex';
} }
@@ -1765,7 +1772,11 @@ function handleShellConnection(ws) {
// Claude (default provider) // Claude (default provider)
const command = initialCommand || 'claude'; const command = initialCommand || 'claude';
if (hasSession && sessionId) { if (hasSession && sessionId) {
if (os.platform() === 'win32') {
shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
} else {
shellCommand = `claude --resume "${sessionId}" || claude`; shellCommand = `claude --resume "${sessionId}" || claude`;
}
} else { } else {
shellCommand = command; shellCommand = command;
} }
@@ -2391,7 +2402,8 @@ app.get('*', (req, res) => {
res.sendFile(indexPath); res.sendFile(indexPath);
} else { } else {
// In development, redirect to Vite dev server only if dist doesn't exist // In development, redirect to Vite dev server only if dist doesn't exist
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`); const redirectHost = getConnectableHost(req.hostname);
res.redirect(`${req.protocol}://${redirectHost}:${VITE_PORT}`);
} }
}); });
@@ -2479,10 +2491,10 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
}); });
} }
const PORT = process.env.PORT || 3001; const SERVER_PORT = process.env.SERVER_PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0'; const HOST = process.env.HOST || '0.0.0.0';
// Show localhost in URL when binding to all interfaces (0.0.0.0 isn't a connectable address) const DISPLAY_HOST = getConnectableHost(HOST);
const DISPLAY_HOST = HOST === '0.0.0.0' ? 'localhost' : HOST; const VITE_PORT = process.env.VITE_PORT || 5173;
// Initialize database and start server // Initialize database and start server
async function startServer() { async function startServer() {
@@ -2496,13 +2508,15 @@ async function startServer() {
// Log Claude implementation mode // Log Claude implementation mode
console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`); console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
console.log(`${c.info('[INFO]')} Running in ${c.bright(isProduction ? 'PRODUCTION' : 'DEVELOPMENT')} mode`); console.log('');
if (!isProduction) { if (isProduction) {
console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`); console.log(`${c.info('[INFO]')} To run in production mode, go to http://${DISPLAY_HOST}:${SERVER_PORT}`);
} }
server.listen(PORT, HOST, async () => { console.log(`${c.info('[INFO]')} To run in development mode with hot-module replacement, go to http://${DISPLAY_HOST}:${VITE_PORT}`);
server.listen(SERVER_PORT, HOST, async () => {
const appInstallPath = path.join(__dirname, '..'); const appInstallPath = path.join(__dirname, '..');
console.log(''); console.log('');
@@ -2510,7 +2524,7 @@ async function startServer() {
console.log(` ${c.bright('Claude Code UI Server - Ready')}`); console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
console.log(c.dim('═'.repeat(63))); console.log(c.dim('═'.repeat(63)));
console.log(''); console.log('');
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + PORT)}`); console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + SERVER_PORT)}`);
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`); console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`); console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`);
console.log(''); console.log('');

View File

@@ -7,6 +7,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js'; import { spawnCursor } from '../cursor-cli.js';
const router = express.Router(); const router = express.Router();
const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
function spawnAsync(command, args, options = {}) { function spawnAsync(command, args, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -107,8 +108,7 @@ async function getActualProjectPath(projectName) {
projectPath = await extractProjectDirectory(projectName); projectPath = await extractProjectDirectory(projectName);
} catch (error) { } catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error); console.error(`Error extracting project directory for ${projectName}:`, error);
// Fallback to the old method throw new Error(`Unable to resolve project path for "${projectName}"`);
projectPath = projectName.replace(/-/g, '/');
} }
return validateProjectPath(projectPath); return validateProjectPath(projectPath);
} }
@@ -166,6 +166,127 @@ async function validateGitRepository(projectPath) {
} }
} }
function getGitErrorDetails(error) {
return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;
}
function isMissingHeadRevisionError(error) {
const errorDetails = getGitErrorDetails(error).toLowerCase();
return errorDetails.includes('unknown revision')
|| errorDetails.includes('ambiguous argument')
|| errorDetails.includes('needed a single revision')
|| errorDetails.includes('bad revision');
}
async function getCurrentBranchName(projectPath) {
try {
// symbolic-ref works even when the repository has no commits.
const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });
const branchName = stdout.trim();
if (branchName) {
return branchName;
}
} catch (error) {
// Fall back to rev-parse for detached HEAD and older git edge cases.
}
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
return stdout.trim();
}
async function repositoryHasCommits(projectPath) {
try {
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
return true;
} catch (error) {
if (isMissingHeadRevisionError(error)) {
return false;
}
throw error;
}
}
async function getRepositoryRootPath(projectPath) {
const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
return stdout.trim();
}
function normalizeRepositoryRelativeFilePath(filePath) {
return String(filePath)
.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/^\/+/, '')
.trim();
}
function parseStatusFilePaths(statusOutput) {
return statusOutput
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim())
.map((line) => {
const statusPath = line.substring(3);
const renamedFilePath = statusPath.split(' -> ')[1];
return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);
})
.filter(Boolean);
}
function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));
const candidates = [normalizedFilePath];
if (
projectRelativePath
&& projectRelativePath !== '.'
&& !normalizedFilePath.startsWith(`${projectRelativePath}/`)
) {
candidates.push(`${projectRelativePath}/${normalizedFilePath}`);
}
return Array.from(new Set(candidates.filter(Boolean)));
}
async function resolveRepositoryFilePath(projectPath, filePath) {
validateFilePath(filePath);
const repositoryRootPath = await getRepositoryRootPath(projectPath);
const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);
for (const candidateFilePath of candidateFilePaths) {
const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });
if (stdout.trim()) {
return {
repositoryRootPath,
repositoryRelativeFilePath: candidateFilePath,
};
}
}
// If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files.
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
if (!normalizedFilePath.includes('/')) {
const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });
const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);
const suffixMatches = changedFilePaths.filter(
(changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),
);
if (suffixMatches.length === 1) {
return {
repositoryRootPath,
repositoryRelativeFilePath: suffixMatches[0],
};
}
}
return {
repositoryRootPath,
repositoryRelativeFilePath: candidateFilePaths[0],
};
}
// Get git status for a project // Get git status for a project
router.get('/status', async (req, res) => { router.get('/status', async (req, res) => {
const { project } = req.query; const { project } = req.query;
@@ -180,21 +301,8 @@ router.get('/status', async (req, res) => {
// Validate git repository // Validate git repository
await validateGitRepository(projectPath); await validateGitRepository(projectPath);
// Get current branch - handle case where there are no commits yet const branch = await getCurrentBranchName(projectPath);
let branch = 'main'; const hasCommits = await repositoryHasCommits(projectPath);
let hasCommits = true;
try {
const { stdout: branchOutput } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
branch = branchOutput.trim();
} catch (error) {
// No HEAD exists - repository has no commits yet
if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) {
hasCommits = false;
branch = 'main';
} else {
throw error;
}
}
// Get git status // Get git status
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath }); const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
@@ -256,46 +364,64 @@ router.get('/diff', async (req, res) => {
// Validate git repository // Validate git repository
await validateGitRepository(projectPath); await validateGitRepository(projectPath);
// Validate file path const {
validateFilePath(file, projectPath); repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check if file is untracked or deleted // Check if file is untracked or deleted
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
const isUntracked = statusOutput.startsWith('??'); const isUntracked = statusOutput.startsWith('??');
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
let diff; let diff;
if (isUntracked) { if (isUntracked) {
// For untracked files, show the entire file content as additions // For untracked files, show the entire file content as additions
const filePath = path.join(projectPath, file); const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath); const stats = await fs.stat(filePath);
if (stats.isDirectory()) { if (stats.isDirectory()) {
// For directories, show a simple message // For directories, show a simple message
diff = `Directory: ${file}\n(Cannot show diff for directories)`; diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`;
} else { } else {
const fileContent = await fs.readFile(filePath, 'utf-8'); const fileContent = await fs.readFile(filePath, 'utf-8');
const lines = fileContent.split('\n'); const lines = fileContent.split('\n');
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` + diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\n@@ -0,0 +1,${lines.length} @@\n` +
lines.map(line => `+${line}`).join('\n'); lines.map(line => `+${line}`).join('\n');
} }
} else if (isDeleted) { } else if (isDeleted) {
// For deleted files, show the entire file content from HEAD as deletions // For deleted files, show the entire file content from HEAD as deletions
const { stdout: fileContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); const { stdout: fileContent } = await spawnAsync(
'git',
['show', `HEAD:${repositoryRelativeFilePath}`],
{ cwd: repositoryRootPath },
);
const lines = fileContent.split('\n'); const lines = fileContent.split('\n');
diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` + diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
lines.map(line => `-${line}`).join('\n'); lines.map(line => `-${line}`).join('\n');
} else { } else {
// Get diff for tracked files // Get diff for tracked files
// First check for unstaged changes (working tree vs index) // First check for unstaged changes (working tree vs index)
const { stdout: unstagedDiff } = await spawnAsync('git', ['diff', '--', file], { cwd: projectPath }); const { stdout: unstagedDiff } = await spawnAsync(
'git',
['diff', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
if (unstagedDiff) { if (unstagedDiff) {
// Show unstaged changes if they exist // Show unstaged changes if they exist
diff = stripDiffHeaders(unstagedDiff); diff = stripDiffHeaders(unstagedDiff);
} else { } else {
// If no unstaged changes, check for staged changes (index vs HEAD) // If no unstaged changes, check for staged changes (index vs HEAD)
const { stdout: stagedDiff } = await spawnAsync('git', ['diff', '--cached', '--', file], { cwd: projectPath }); const { stdout: stagedDiff } = await spawnAsync(
'git',
['diff', '--cached', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
diff = stripDiffHeaders(stagedDiff) || ''; diff = stripDiffHeaders(stagedDiff) || '';
} }
} }
@@ -321,11 +447,17 @@ router.get('/file-with-diff', async (req, res) => {
// Validate git repository // Validate git repository
await validateGitRepository(projectPath); await validateGitRepository(projectPath);
// Validate file path const {
validateFilePath(file, projectPath); repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check file status // Check file status
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
const isUntracked = statusOutput.startsWith('??'); const isUntracked = statusOutput.startsWith('??');
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
@@ -334,12 +466,16 @@ router.get('/file-with-diff', async (req, res) => {
if (isDeleted) { if (isDeleted) {
// For deleted files, get content from HEAD // For deleted files, get content from HEAD
const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); const { stdout: headContent } = await spawnAsync(
'git',
['show', `HEAD:${repositoryRelativeFilePath}`],
{ cwd: repositoryRootPath },
);
oldContent = headContent; oldContent = headContent;
currentContent = headContent; // Show the deleted content in editor currentContent = headContent; // Show the deleted content in editor
} else { } else {
// Get current file content // Get current file content
const filePath = path.join(projectPath, file); const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath); const stats = await fs.stat(filePath);
if (stats.isDirectory()) { if (stats.isDirectory()) {
@@ -352,7 +488,11 @@ router.get('/file-with-diff', async (req, res) => {
if (!isUntracked) { if (!isUntracked) {
// Get the old content from HEAD for tracked files // Get the old content from HEAD for tracked files
try { try {
const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); const { stdout: headContent } = await spawnAsync(
'git',
['show', `HEAD:${repositoryRelativeFilePath}`],
{ cwd: repositoryRootPath },
);
oldContent = headContent; oldContent = headContent;
} catch (error) { } catch (error) {
// File might be newly added to git (staged but not committed) // File might be newly added to git (staged but not committed)
@@ -430,15 +570,16 @@ router.post('/commit', async (req, res) => {
// Validate git repository // Validate git repository
await validateGitRepository(projectPath); await validateGitRepository(projectPath);
const repositoryRootPath = await getRepositoryRootPath(projectPath);
// Stage selected files // Stage selected files
for (const file of files) { for (const file of files) {
validateFilePath(file, projectPath); const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
await spawnAsync('git', ['add', file], { cwd: projectPath }); await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
} }
// Commit with message // Commit with message
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: projectPath }); const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });
res.json({ success: true, output: stdout }); res.json({ success: true, output: stdout });
} catch (error) { } catch (error) {
@@ -447,6 +588,53 @@ router.post('/commit', async (req, res) => {
} }
}); });
// Revert latest local commit (keeps changes staged)
router.post('/revert-local-commit', async (req, res) => {
const { project } = req.body;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
try {
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
} catch (error) {
return res.status(400).json({
error: 'No local commit to revert',
details: 'This repository has no commit yet.',
});
}
try {
// Soft reset rewinds one commit while preserving all file changes in the index.
await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
} catch (error) {
const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
const isInitialCommit = errorDetails.includes('HEAD~1') &&
(errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
if (!isInitialCommit) {
throw error;
}
// Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
}
res.json({
success: true,
output: 'Latest local commit reverted successfully. Changes were kept staged.',
});
} catch (error) {
console.error('Git revert local commit error:', error);
res.status(500).json({ error: error.message });
}
});
// Get list of branches // Get list of branches
router.get('/branches', async (req, res) => { router.get('/branches', async (req, res) => {
const { project } = req.query; const { project } = req.query;
@@ -610,7 +798,12 @@ router.get('/commit-diff', async (req, res) => {
{ cwd: projectPath } { cwd: projectPath }
); );
res.json({ diff: stdout }); const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;
const diff = isTruncated
? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...`
: stdout;
res.json({ diff, isTruncated });
} catch (error) { } catch (error) {
console.error('Git commit diff error:', error); console.error('Git commit diff error:', error);
res.json({ error: error.message }); res.json({ error: error.message });
@@ -632,18 +825,20 @@ router.post('/generate-commit-message', async (req, res) => {
try { try {
const projectPath = await getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
const repositoryRootPath = await getRepositoryRootPath(projectPath);
// Get diff for selected files // Get diff for selected files
let diffContext = ''; let diffContext = '';
for (const file of files) { for (const file of files) {
try { try {
validateFilePath(file, projectPath); const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
const { stdout } = await spawnAsync( const { stdout } = await spawnAsync(
'git', ['diff', 'HEAD', '--', file], 'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
{ cwd: projectPath } { cwd: repositoryRootPath }
); );
if (stdout) { if (stdout) {
diffContext += `\n--- ${file} ---\n${stdout}`; diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`;
} }
} catch (error) { } catch (error) {
console.error(`Error getting diff for ${file}:`, error); console.error(`Error getting diff for ${file}:`, error);
@@ -655,14 +850,15 @@ router.post('/generate-commit-message', async (req, res) => {
// Try to get content of untracked files // Try to get content of untracked files
for (const file of files) { for (const file of files) {
try { try {
const filePath = path.join(projectPath, file); const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath); const stats = await fs.stat(filePath);
if (!stats.isDirectory()) { if (!stats.isDirectory()) {
const content = await fs.readFile(filePath, 'utf-8'); const content = await fs.readFile(filePath, 'utf-8');
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`; diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
} else { } else {
diffContext += `\n--- ${file} (new directory) ---\n`; diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`;
} }
} catch (error) { } catch (error) {
console.error(`Error reading file ${file}:`, error); console.error(`Error reading file ${file}:`, error);
@@ -831,9 +1027,30 @@ router.get('/remote-status', async (req, res) => {
const projectPath = await getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath); await validateGitRepository(projectPath);
// Get current branch const branch = await getCurrentBranchName(projectPath);
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const hasCommits = await repositoryHasCommits(projectPath);
const branch = currentBranch.trim();
const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });
const remotes = remoteOutput.trim().split('\n').filter(r => r.trim());
const hasRemote = remotes.length > 0;
const fallbackRemoteName = hasRemote
? (remotes.includes('origin') ? 'origin' : remotes[0])
: null;
// Repositories initialized with `git init` can have a branch but no commits.
// Return a non-error state so the UI can show the initial-commit workflow.
if (!hasCommits) {
return res.json({
hasRemote,
hasUpstream: false,
branch,
remoteName: fallbackRemoteName,
ahead: 0,
behind: 0,
isUpToDate: false,
message: 'Repository has no commits yet'
});
}
// Check if there's a remote tracking branch (smart detection) // Check if there's a remote tracking branch (smart detection)
let trackingBranch; let trackingBranch;
@@ -843,25 +1060,11 @@ router.get('/remote-status', async (req, res) => {
trackingBranch = stdout.trim(); trackingBranch = stdout.trim();
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin") remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
} catch (error) { } catch (error) {
// No upstream branch configured - but check if we have remotes
let hasRemote = false;
let remoteName = null;
try {
const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
const remotes = stdout.trim().split('\n').filter(r => r.trim());
if (remotes.length > 0) {
hasRemote = true;
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
}
} catch (remoteError) {
// No remotes configured
}
return res.json({ return res.json({
hasRemote, hasRemote,
hasUpstream: false, hasUpstream: false,
branch, branch,
remoteName, remoteName: fallbackRemoteName,
message: 'No remote tracking branch configured' message: 'No remote tracking branch configured'
}); });
} }
@@ -903,8 +1106,7 @@ router.post('/fetch', async (req, res) => {
await validateGitRepository(projectPath); await validateGitRepository(projectPath);
// Get current branch and its upstream remote // Get current branch and its upstream remote
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const branch = await getCurrentBranchName(projectPath);
const branch = currentBranch.trim();
let remoteName = 'origin'; // fallback let remoteName = 'origin'; // fallback
try { try {
@@ -945,8 +1147,7 @@ router.post('/pull', async (req, res) => {
await validateGitRepository(projectPath); await validateGitRepository(projectPath);
// Get current branch and its upstream remote // Get current branch and its upstream remote
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const branch = await getCurrentBranchName(projectPath);
const branch = currentBranch.trim();
let remoteName = 'origin'; // fallback let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback let remoteBranch = branch; // fallback
@@ -1014,8 +1215,7 @@ router.post('/push', async (req, res) => {
await validateGitRepository(projectPath); await validateGitRepository(projectPath);
// Get current branch and its upstream remote // Get current branch and its upstream remote
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const branch = await getCurrentBranchName(projectPath);
const branch = currentBranch.trim();
let remoteName = 'origin'; // fallback let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback let remoteBranch = branch; // fallback
@@ -1089,8 +1289,7 @@ router.post('/publish', async (req, res) => {
validateBranchName(branch); validateBranchName(branch);
// Get current branch to verify it matches the requested branch // Get current branch to verify it matches the requested branch
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const currentBranchName = await getCurrentBranchName(projectPath);
const currentBranchName = currentBranch.trim();
if (currentBranchName !== branch) { if (currentBranchName !== branch) {
return res.status(400).json({ return res.status(400).json({
@@ -1164,12 +1363,17 @@ router.post('/discard', async (req, res) => {
try { try {
const projectPath = await getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath); await validateGitRepository(projectPath);
const {
// Validate file path repositoryRootPath,
validateFilePath(file, projectPath); repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check file status to determine correct discard command // Check file status to determine correct discard command
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
if (!statusOutput.trim()) { if (!statusOutput.trim()) {
return res.status(400).json({ error: 'No changes to discard for this file' }); return res.status(400).json({ error: 'No changes to discard for this file' });
@@ -1179,7 +1383,7 @@ router.post('/discard', async (req, res) => {
if (status === '??') { if (status === '??') {
// Untracked file or directory - delete it // Untracked file or directory - delete it
const filePath = path.join(projectPath, file); const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath); const stats = await fs.stat(filePath);
if (stats.isDirectory()) { if (stats.isDirectory()) {
@@ -1189,13 +1393,13 @@ router.post('/discard', async (req, res) => {
} }
} else if (status.includes('M') || status.includes('D')) { } else if (status.includes('M') || status.includes('D')) {
// Modified or deleted file - restore from HEAD // Modified or deleted file - restore from HEAD
await spawnAsync('git', ['restore', file], { cwd: projectPath }); await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
} else if (status.includes('A')) { } else if (status.includes('A')) {
// Added file - unstage it // Added file - unstage it
await spawnAsync('git', ['reset', 'HEAD', file], { cwd: projectPath }); await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
} }
res.json({ success: true, message: `Changes discarded for ${file}` }); res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
} catch (error) { } catch (error) {
console.error('Git discard error:', error); console.error('Git discard error:', error);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -1213,12 +1417,17 @@ router.post('/delete-untracked', async (req, res) => {
try { try {
const projectPath = await getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath); await validateGitRepository(projectPath);
const {
// Validate file path repositoryRootPath,
validateFilePath(file, projectPath); repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check if file is actually untracked // Check if file is actually untracked
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
if (!statusOutput.trim()) { if (!statusOutput.trim()) {
return res.status(400).json({ error: 'File is not untracked or does not exist' }); return res.status(400).json({ error: 'File is not untracked or does not exist' });
@@ -1231,16 +1440,16 @@ router.post('/delete-untracked', async (req, res) => {
} }
// Delete the untracked file or directory // Delete the untracked file or directory
const filePath = path.join(projectPath, file); const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath); const stats = await fs.stat(filePath);
if (stats.isDirectory()) { if (stats.isDirectory()) {
// Use rm with recursive option for directories // Use rm with recursive option for directories
await fs.rm(filePath, { recursive: true, force: true }); await fs.rm(filePath, { recursive: true, force: true });
res.json({ success: true, message: `Untracked directory ${file} deleted successfully` }); res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` });
} else { } else {
await fs.unlink(filePath); await fs.unlink(filePath);
res.json({ success: true, message: `Untracked file ${file} deleted successfully` }); res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });
} }
} catch (error) { } catch (error) {
console.error('Git delete untracked error:', error); console.error('Git delete untracked error:', error);

View File

@@ -529,7 +529,7 @@ router.get('/next/:projectName', async (req, res) => {
// Fallback to loading tasks and finding next one locally // Fallback to loading tasks and finding next one locally
// Use localhost to bypass proxy for internal server-to-server calls // Use localhost to bypass proxy for internal server-to-server calls
const tasksResponse = await fetch(`http://localhost:${process.env.PORT || 3001}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, { const tasksResponse = await fetch(`http://localhost:${process.env.SERVER_PORT || 3001}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, {
headers: { headers: {
'Authorization': req.headers.authorization 'Authorization': req.headers.authorization
} }

22
shared/networkHosts.js Normal file
View File

@@ -0,0 +1,22 @@
export function isWildcardHost(host) {
return host === '0.0.0.0' || host === '::';
}
export function isLoopbackHost(host) {
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
}
export function normalizeLoopbackHost(host) {
if (!host) {
return host;
}
return isLoopbackHost(host) ? 'localhost' : host;
}
// Use localhost for connectable loopback and wildcard addresses in browser-facing URLs.
export function getConnectableHost(host) {
if (!host) {
return 'localhost';
}
return isWildcardHost(host) || isLoopbackHost(host) ? 'localhost' : host;
}

View File

@@ -40,7 +40,7 @@ export default function AppContent() {
setIsInputFocused, setIsInputFocused,
setShowSettings, setShowSettings,
openSettings, openSettings,
fetchProjects, refreshProjectsSilently,
sidebarSharedProps, sidebarSharedProps,
} = useProjectsState({ } = useProjectsState({
sessionId, sessionId,
@@ -51,14 +51,16 @@ export default function AppContent() {
}); });
useEffect(() => { useEffect(() => {
window.refreshProjects = fetchProjects; // Expose a non-blocking refresh for chat/session flows.
// Full loading refreshes are still available through direct fetchProjects calls.
window.refreshProjects = refreshProjectsSilently;
return () => { return () => {
if (window.refreshProjects === fetchProjects) { if (window.refreshProjects === refreshProjectsSilently) {
delete window.refreshProjects; delete window.refreshProjects;
} }
}; };
}, [fetchProjects]); }, [refreshProjectsSilently]);
useEffect(() => { useEffect(() => {
window.openSettings = openSettings; window.openSettings = openSettings;

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react'; import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
MessageSquare, MessageSquare,
Folder, Folder,
@@ -37,6 +38,7 @@ type MobileNavProps = {
}; };
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) { export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
const { t } = useTranslation(['common', 'settings']);
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled); const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const { plugins } = usePlugins(); const { plugins } = usePlugins();
@@ -126,8 +128,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
e.preventDefault(); e.preventDefault();
setMoreOpen((v) => !v); setMoreOpen((v) => !v);
}} }}
className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${ className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isPluginActive || moreOpen
isPluginActive || moreOpen
? 'text-primary' ? 'text-primary'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
}`} }`}
@@ -142,7 +143,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
strokeWidth={isPluginActive ? 2.4 : 1.8} strokeWidth={isPluginActive ? 2.4 : 1.8}
/> />
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}> <span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}>
More {t('settings:pluginSettings.morePlugins')}
</span> </span>
</button> </button>
@@ -157,8 +158,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
<button <button
key={p.name} key={p.name}
onClick={() => selectPlugin(p.name)} onClick={() => selectPlugin(p.name)}
className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${ className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${isActive
isActive
? 'bg-primary/8 text-primary' ? 'bg-primary/8 text-primary'
: 'text-foreground hover:bg-muted/60' : 'text-foreground hover:bg-muted/60'
}`} }`}

View File

@@ -48,6 +48,7 @@ interface UseChatRealtimeHandlersArgs {
onSessionNotProcessing?: (sessionId?: string | null) => void; onSessionNotProcessing?: (sessionId?: string | null) => void;
onReplaceTemporarySession?: (sessionId?: string | null) => void; onReplaceTemporarySession?: (sessionId?: string | null) => void;
onNavigateToSession?: (sessionId: string) => void; onNavigateToSession?: (sessionId: string) => void;
onWebSocketReconnect?: () => void;
} }
const appendStreamingChunk = ( const appendStreamingChunk = (
@@ -113,6 +114,7 @@ export function useChatRealtimeHandlers({
onSessionNotProcessing, onSessionNotProcessing,
onReplaceTemporarySession, onReplaceTemporarySession,
onNavigateToSession, onNavigateToSession,
onWebSocketReconnect,
}: UseChatRealtimeHandlersArgs) { }: UseChatRealtimeHandlersArgs) {
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null); const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
@@ -136,7 +138,7 @@ export function useChatRealtimeHandlers({
: null; : null;
const messageType = String(latestMessage.type); const messageType = String(latestMessage.type);
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created']; const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'websocket-reconnected'];
const isGlobalMessage = globalMessageTypes.includes(messageType); const isGlobalMessage = globalMessageTypes.includes(messageType);
const lifecycleMessageTypes = new Set([ const lifecycleMessageTypes = new Set([
'claude-complete', 'claude-complete',
@@ -300,6 +302,11 @@ export function useChatRealtimeHandlers({
} }
break; break;
case 'websocket-reconnected':
// WebSocket dropped and reconnected — re-fetch session history to catch up on missed messages
onWebSocketReconnect?.();
break;
case 'token-budget': case 'token-budget':
if (latestMessage.data) { if (latestMessage.data) {
setTokenBudget(latestMessage.data); setTokenBudget(latestMessage.data);
@@ -692,14 +699,28 @@ export function useChatRealtimeHandlers({
const updated = [...previous]; const updated = [...previous];
const lastIndex = updated.length - 1; const lastIndex = updated.length - 1;
const last = updated[lastIndex]; const last = updated[lastIndex];
const normalizedTextResult = textResult.trim();
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
const finalContent = const finalContent =
textResult && textResult.trim() normalizedTextResult
? textResult ? textResult
: `${last.content || ''}${pendingChunk || ''}`; : `${last.content || ''}${pendingChunk || ''}`;
// Clone the message instead of mutating in place so React can reliably detect state updates. // Clone the message instead of mutating in place so React can reliably detect state updates.
updated[lastIndex] = { ...last, content: finalContent, isStreaming: false }; updated[lastIndex] = { ...last, content: finalContent, isStreaming: false };
} else if (textResult && textResult.trim()) { } else if (normalizedTextResult) {
const lastAssistantText =
last && last.type === 'assistant' && !last.isToolUse
? String(last.content || '').trim()
: '';
// Cursor can emit the same final text through both streaming and result payloads.
// Skip adding a second assistant bubble when the final text is unchanged.
const isDuplicateFinalText = lastAssistantText === normalizedTextResult;
if (isDuplicateFinalText) {
return updated;
}
updated.push({ updated.push({
type: resultData.is_error ? 'error' : 'assistant', type: resultData.is_error ? 'error' : 'assistant',
content: textResult, content: textResult,

View File

@@ -34,6 +34,48 @@ const normalizeToolInput = (value: unknown): string => {
} }
}; };
const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [
/<user_info>[\s\S]*?<\/user_info>/gi,
/<agent_skills>[\s\S]*?<\/agent_skills>/gi,
/<available_skills>[\s\S]*?<\/available_skills>/gi,
/<environment_context>[\s\S]*?<\/environment_context>/gi,
/<environment_info>[\s\S]*?<\/environment_info>/gi,
];
const extractCursorUserQuery = (rawText: string): string => {
const userQueryMatches = [...rawText.matchAll(/<user_query>([\s\S]*?)<\/user_query>/gi)];
if (userQueryMatches.length === 0) {
return '';
}
return userQueryMatches
.map((match) => (match[1] || '').trim())
.filter(Boolean)
.join('\n')
.trim();
};
const sanitizeCursorUserMessageText = (rawText: string): string => {
const decodedText = decodeHtmlEntities(rawText || '').trim();
if (!decodedText) {
return '';
}
// Cursor stores user-visible text inside <user_query> and prepends hidden context blocks
// (<user_info>, <agent_skills>, etc). We only render the actual query in chat history.
const extractedUserQuery = extractCursorUserQuery(decodedText);
if (extractedUserQuery) {
return extractedUserQuery;
}
let sanitizedText = decodedText;
CURSOR_INTERNAL_USER_BLOCK_PATTERNS.forEach((pattern) => {
sanitizedText = sanitizedText.replace(pattern, '');
});
return sanitizedText.trim();
};
const toAbsolutePath = (projectPath: string, filePath?: string) => { const toAbsolutePath = (projectPath: string, filePath?: string) => {
if (!filePath) { if (!filePath) {
return filePath; return filePath;
@@ -321,6 +363,10 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s
console.log('Error parsing blob content:', error); console.log('Error parsing blob content:', error);
} }
if (role === 'user') {
text = sanitizeCursorUserMessageText(text);
}
if (text && text.trim()) { if (text && text.trim()) {
const message: ChatMessage = { const message: ChatMessage = {
type: role, type: role,

View File

@@ -109,6 +109,7 @@ function ChatInterface({
scrollToBottom, scrollToBottom,
scrollToBottomAndReset, scrollToBottomAndReset,
handleScroll, handleScroll,
loadSessionMessages,
} = useChatSessionState({ } = useChatSessionState({
selectedProject, selectedProject,
selectedSession, selectedSession,
@@ -197,6 +198,23 @@ function ChatInterface({
setPendingPermissionRequests, setPendingPermissionRequests,
}); });
// On WebSocket reconnect, re-fetch the current session's messages from JSONL so missed
// streaming events (e.g. from long tool calls while iOS had the tab backgrounded) are shown.
// Also reset isLoading — if the server restarted or the session died mid-stream, the client
// would be stuck in "Processing..." forever without this reset.
const handleWebSocketReconnect = useCallback(async () => {
if (!selectedProject || !selectedSession) return;
const provider = (localStorage.getItem('selected-provider') as any) || 'claude';
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, provider);
if (messages && messages.length > 0) {
setChatMessages(messages);
}
// Reset loading state — if the session is still active, new WebSocket messages will
// set it back to true. If it died, this clears the permanent frozen state.
setIsLoading(false);
setCanAbortSession(false);
}, [selectedProject, selectedSession, loadSessionMessages, setChatMessages, setIsLoading, setCanAbortSession]);
useChatRealtimeHandlers({ useChatRealtimeHandlers({
latestMessage, latestMessage,
provider, provider,
@@ -219,6 +237,7 @@ function ChatInterface({
onSessionNotProcessing, onSessionNotProcessing,
onReplaceTemporarySession, onReplaceTemporarySession,
onNavigateToSession, onNavigateToSession,
onWebSocketReconnect: handleWebSocketReconnect,
}); });
useEffect(() => { useEffect(() => {

View File

@@ -301,8 +301,7 @@ export default function ChatComposer({
onBlur={() => onInputFocusChange?.(false)} onBlur={() => onInputFocusChange?.(false)}
onInput={onTextareaInput} onInput={onTextareaInput}
placeholder={placeholder} placeholder={placeholder}
disabled={isLoading} className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none disabled:opacity-50 sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
style={{ height: '50px' }} style={{ height: '50px' }}
/> />

View File

@@ -31,6 +31,7 @@ export const CONFIRMATION_TITLES: Record<ConfirmActionType, string> = {
pull: 'Confirm Pull', pull: 'Confirm Pull',
push: 'Confirm Push', push: 'Confirm Push',
publish: 'Publish Branch', publish: 'Publish Branch',
revertLocalCommit: 'Revert Local Commit',
}; };
export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = { export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
@@ -40,6 +41,7 @@ export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
pull: 'Pull', pull: 'Pull',
push: 'Push', push: 'Push',
publish: 'Publish', publish: 'Publish',
revertLocalCommit: 'Revert Commit',
}; };
export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = { export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
@@ -49,6 +51,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
pull: 'bg-green-600 hover:bg-green-700', pull: 'bg-green-600 hover:bg-green-700',
push: 'bg-orange-600 hover:bg-orange-700', push: 'bg-orange-600 hover:bg-orange-700',
publish: 'bg-purple-600 hover:bg-purple-700', publish: 'bg-purple-600 hover:bg-purple-700',
revertLocalCommit: 'bg-yellow-600 hover:bg-yellow-700',
}; };
export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, string> = { export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, string> = {
@@ -58,6 +61,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, stri
pull: 'bg-yellow-100 dark:bg-yellow-900/30', pull: 'bg-yellow-100 dark:bg-yellow-900/30',
push: 'bg-yellow-100 dark:bg-yellow-900/30', push: 'bg-yellow-100 dark:bg-yellow-900/30',
publish: 'bg-yellow-100 dark:bg-yellow-900/30', publish: 'bg-yellow-100 dark:bg-yellow-900/30',
revertLocalCommit: 'bg-yellow-100 dark:bg-yellow-900/30',
}; };
export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = { export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
@@ -67,4 +71,5 @@ export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
pull: 'text-yellow-600 dark:text-yellow-400', pull: 'text-yellow-600 dark:text-yellow-400',
push: 'text-yellow-600 dark:text-yellow-400', push: 'text-yellow-600 dark:text-yellow-400',
publish: 'text-yellow-600 dark:text-yellow-400', publish: 'text-yellow-600 dark:text-yellow-400',
revertLocalCommit: 'text-yellow-600 dark:text-yellow-400',
}; };

View File

@@ -0,0 +1,48 @@
import { useCallback, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { GitOperationResponse } from '../types/types';
type UseRevertLocalCommitOptions = {
projectName: string | null;
onSuccess?: () => void;
};
async function readJson<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}
export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalCommitOptions) {
const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false);
const revertLatestLocalCommit = useCallback(async () => {
if (!projectName) {
return;
}
setIsRevertingLocalCommit(true);
try {
const response = await authenticatedFetch('/api/git/revert-local-commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project: projectName }),
});
const data = await readJson<GitOperationResponse>(response);
if (!data.success) {
console.error('Revert local commit failed:', data.error || data.details || 'Unknown error');
return;
}
onSuccess?.();
} catch (error) {
console.error('Error reverting local commit:', error);
} finally {
setIsRevertingLocalCommit(false);
}
}, [onSuccess, projectName]);
return {
isRevertingLocalCommit,
revertLatestLocalCommit,
};
}

View File

@@ -3,7 +3,7 @@ import type { Project } from '../../../types/app';
export type GitPanelView = 'changes' | 'history'; export type GitPanelView = 'changes' | 'history';
export type FileStatusCode = 'M' | 'A' | 'D' | 'U'; export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked'; export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish'; export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit';
export type FileDiffInfo = { export type FileDiffInfo = {
old_string: string; old_string: string;

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useGitPanelController } from '../hooks/useGitPanelController'; import { useGitPanelController } from '../hooks/useGitPanelController';
import { useRevertLocalCommit } from '../hooks/useRevertLocalCommit';
import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types'; import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
import ChangesView from '../view/changes/ChangesView'; import ChangesView from '../view/changes/ChangesView';
import HistoryView from '../view/history/HistoryView'; import HistoryView from '../view/history/HistoryView';
@@ -49,6 +50,11 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
onFileOpen, onFileOpen,
}); });
const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({
projectName: selectedProject?.name ?? null,
onSuccess: refreshAll,
});
const executeConfirmedAction = useCallback(async () => { const executeConfirmedAction = useCallback(async () => {
if (!confirmAction) { if (!confirmAction) {
return; return;
@@ -85,7 +91,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
isPulling={isPulling} isPulling={isPulling}
isPushing={isPushing} isPushing={isPushing}
isPublishing={isPublishing} isPublishing={isPublishing}
isRevertingLocalCommit={isRevertingLocalCommit}
onRefresh={refreshAll} onRefresh={refreshAll}
onRevertLocalCommit={revertLatestLocalCommit}
onSwitchBranch={switchBranch} onSwitchBranch={switchBranch}
onCreateBranch={createBranch} onCreateBranch={createBranch}
onFetch={handleFetch} onFetch={handleFetch}

View File

@@ -1,4 +1,4 @@
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, Upload } from 'lucide-react'; import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { ConfirmationRequest, GitRemoteStatus } from '../types/types'; import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
import NewBranchModal from './modals/NewBranchModal'; import NewBranchModal from './modals/NewBranchModal';
@@ -14,7 +14,9 @@ type GitPanelHeaderProps = {
isPulling: boolean; isPulling: boolean;
isPushing: boolean; isPushing: boolean;
isPublishing: boolean; isPublishing: boolean;
isRevertingLocalCommit: boolean;
onRefresh: () => void; onRefresh: () => void;
onRevertLocalCommit: () => Promise<void>;
onSwitchBranch: (branchName: string) => Promise<boolean>; onSwitchBranch: (branchName: string) => Promise<boolean>;
onCreateBranch: (branchName: string) => Promise<boolean>; onCreateBranch: (branchName: string) => Promise<boolean>;
onFetch: () => Promise<void>; onFetch: () => Promise<void>;
@@ -35,7 +37,9 @@ export default function GitPanelHeader({
isPulling, isPulling,
isPushing, isPushing,
isPublishing, isPublishing,
isRevertingLocalCommit,
onRefresh, onRefresh,
onRevertLocalCommit,
onSwitchBranch, onSwitchBranch,
onCreateBranch, onCreateBranch,
onFetch, onFetch,
@@ -88,6 +92,14 @@ export default function GitPanelHeader({
}); });
}; };
const requestRevertLocalCommitConfirmation = () => {
onRequestConfirmation({
type: 'revertLocalCommit',
message: 'Revert the latest local commit? This removes the commit but keeps its changes staged.',
onConfirm: onRevertLocalCommit,
});
};
const handleSwitchBranch = async (branchName: string) => { const handleSwitchBranch = async (branchName: string) => {
try { try {
const success = await onSwitchBranch(branchName); const success = await onSwitchBranch(branchName);
@@ -240,6 +252,17 @@ export default function GitPanelHeader({
</> </>
)} )}
<button
onClick={requestRevertLocalCommitConfirmation}
disabled={isRevertingLocalCommit}
className={`rounded-lg transition-colors hover:bg-accent disabled:opacity-50 ${isMobile ? 'p-1' : 'p-1.5'}`}
title="Revert latest local commit"
>
<RotateCcw
className={`text-muted-foreground ${isRevertingLocalCommit ? 'animate-pulse' : ''} ${isMobile ? 'h-3 w-3' : 'h-4 w-4'}`}
/>
</button>
<button <button
onClick={onRefresh} onClick={onRefresh}
disabled={isLoading} disabled={isLoading}

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Check, Download, Trash2, Upload } from 'lucide-react'; import { Check, Download, RotateCcw, Trash2, Upload } from 'lucide-react';
import { import {
CONFIRMATION_ACTION_LABELS, CONFIRMATION_ACTION_LABELS,
CONFIRMATION_BUTTON_CLASSES, CONFIRMATION_BUTTON_CLASSES,
@@ -27,6 +27,10 @@ function renderConfirmActionIcon(actionType: ConfirmationRequest['type']) {
return <Download className="h-4 w-4" />; return <Download className="h-4 w-4" />;
} }
if (actionType === 'revertLocalCommit') {
return <RotateCcw className="h-4 w-4" />;
}
return <Upload className="h-4 w-4" />; return <Upload className="h-4 w-4" />;
} }

View File

@@ -1,10 +1,38 @@
import { useMemo } from 'react';
type GitDiffViewerProps = { type GitDiffViewerProps = {
diff: string | null; diff: string | null;
isMobile: boolean; isMobile: boolean;
wrapText: boolean; wrapText: boolean;
}; };
const PREVIEW_CHARACTER_LIMIT = 200_000;
const PREVIEW_LINE_LIMIT = 1_500;
type DiffPreview = {
lines: string[];
isCharacterTruncated: boolean;
isLineTruncated: boolean;
};
function buildDiffPreview(diff: string): DiffPreview {
const isCharacterTruncated = diff.length > PREVIEW_CHARACTER_LIMIT;
const previewText = isCharacterTruncated ? diff.slice(0, PREVIEW_CHARACTER_LIMIT) : diff;
const previewLines = previewText.split('\n');
const isLineTruncated = previewLines.length > PREVIEW_LINE_LIMIT;
return {
lines: isLineTruncated ? previewLines.slice(0, PREVIEW_LINE_LIMIT) : previewLines,
isCharacterTruncated,
isLineTruncated,
};
}
export default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewerProps) { export default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewerProps) {
// Render a bounded preview to keep huge commit diffs from freezing the UI thread.
const preview = useMemo(() => buildDiffPreview(diff || ''), [diff]);
const isPreviewTruncated = preview.isCharacterTruncated || preview.isLineTruncated;
if (!diff) { if (!diff) {
return ( return (
<div className="p-4 text-center text-sm text-muted-foreground"> <div className="p-4 text-center text-sm text-muted-foreground">
@@ -35,7 +63,12 @@ export default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewe
return ( return (
<div className="diff-viewer"> <div className="diff-viewer">
{diff.split('\n').map((line, index) => renderDiffLine(line, index))} {isPreviewTruncated && (
<div className="mb-2 rounded-md border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
Large diff preview: rendering is limited to keep the tab responsive.
</div>
)}
{preview.lines.map((line, index) => renderDiffLine(line, index))}
</div> </div>
); );
} }

View File

@@ -146,7 +146,12 @@ function MainContent({
{activeTab === 'shell' && ( {activeTab === 'shell' && (
<div className="h-full w-full overflow-hidden"> <div className="h-full w-full overflow-hidden">
<StandaloneShell project={selectedProject} session={selectedSession} showHeader={false} /> <StandaloneShell
project={selectedProject}
session={selectedSession}
showHeader={false}
isActive={activeTab === 'shell'}
/>
</div> </div>
)} )}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react'; import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
import { usePlugins } from '../../../contexts/PluginsContext'; import { usePlugins } from '../../../contexts/PluginsContext';
import type { Plugin } from '../../../contexts/PluginsContext'; import type { Plugin } from '../../../contexts/PluginsContext';
@@ -32,7 +33,7 @@ function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onCh
} }
/* ─── Server Dot ────────────────────────────────────────────────────────── */ /* ─── Server Dot ────────────────────────────────────────────────────────── */
function ServerDot({ running }: { running: boolean }) { function ServerDot({ running, t }: { running: boolean; t: any }) {
if (!running) return null; if (!running) return null;
return ( return (
<span className="relative flex items-center gap-1.5"> <span className="relative flex items-center gap-1.5">
@@ -41,7 +42,7 @@ function ServerDot({ running }: { running: boolean }) {
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" /> <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span> </span>
<span className="font-mono text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400"> <span className="font-mono text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
running {t('pluginSettings.runningStatus')}
</span> </span>
</span> </span>
); );
@@ -71,6 +72,7 @@ function PluginCard({
onCancelUninstall, onCancelUninstall,
updateError, updateError,
}: PluginCardProps) { }: PluginCardProps) {
const { t } = useTranslation('settings');
const accentColor = plugin.enabled const accentColor = plugin.enabled
? 'bg-emerald-500' ? 'bg-emerald-500'
: 'bg-muted-foreground/20'; : 'bg-muted-foreground/20';
@@ -108,7 +110,7 @@ function PluginCard({
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"> <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{plugin.slot} {plugin.slot}
</span> </span>
<ServerDot running={!!plugin.serverRunning} /> <ServerDot running={!!plugin.serverRunning} t={t} />
</div> </div>
{plugin.description && ( {plugin.description && (
<p className="mt-1 text-sm leading-snug text-muted-foreground"> <p className="mt-1 text-sm leading-snug text-muted-foreground">
@@ -143,8 +145,8 @@ function PluginCard({
<button <button
onClick={onUpdate} onClick={onUpdate}
disabled={updating || !plugin.repoUrl} disabled={updating || !plugin.repoUrl}
title={plugin.repoUrl ? 'Pull latest from git' : 'No git remote — update not available'} title={plugin.repoUrl ? t('pluginSettings.pullLatest') : t('pluginSettings.noGitRemote')}
aria-label={`Update ${plugin.displayName}`} aria-label={t('pluginSettings.pullLatest')}
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40" className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
> >
{updating ? ( {updating ? (
@@ -156,10 +158,9 @@ function PluginCard({
<button <button
onClick={onUninstall} onClick={onUninstall}
title={confirmingUninstall ? 'Click again to confirm' : 'Uninstall plugin'} title={confirmingUninstall ? t('pluginSettings.confirmUninstall') : t('pluginSettings.uninstallPlugin')}
aria-label={`Uninstall ${plugin.displayName}`} aria-label={t('pluginSettings.uninstallPlugin')}
className={`rounded p-1.5 transition-colors ${ className={`rounded p-1.5 transition-colors ${confirmingUninstall
confirmingUninstall
? 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30' ? 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30'
: 'text-muted-foreground hover:bg-muted hover:text-red-500' : 'text-muted-foreground hover:bg-muted hover:text-red-500'
}`} }`}
@@ -167,7 +168,7 @@ function PluginCard({
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} ariaLabel={`${plugin.enabled ? 'Disable' : 'Enable'} ${plugin.displayName}`} /> <ToggleSwitch checked={plugin.enabled} onChange={onToggle} ariaLabel={`${plugin.enabled ? t('pluginSettings.disable') : t('pluginSettings.enable')} ${plugin.displayName}`} />
</div> </div>
</div> </div>
@@ -175,20 +176,20 @@ function PluginCard({
{confirmingUninstall && ( {confirmingUninstall && (
<div className="mt-3 flex items-center justify-between gap-3 rounded border border-red-200 bg-red-50 px-3 py-2 dark:border-red-800/50 dark:bg-red-950/30"> <div className="mt-3 flex items-center justify-between gap-3 rounded border border-red-200 bg-red-50 px-3 py-2 dark:border-red-800/50 dark:bg-red-950/30">
<span className="text-sm text-red-600 dark:text-red-400"> <span className="text-sm text-red-600 dark:text-red-400">
Remove <span className="font-semibold">{plugin.displayName}</span>? This cannot be undone. {t('pluginSettings.confirmUninstallMessage', { name: plugin.displayName })}
</span> </span>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<button <button
onClick={onCancelUninstall} onClick={onCancelUninstall}
className="rounded border border-border px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" className="rounded border border-border px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
> >
Cancel {t('pluginSettings.cancel')}
</button> </button>
<button <button
onClick={onUninstall} onClick={onUninstall}
className="rounded border border-red-300 px-2.5 py-1 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30" className="rounded border border-red-300 px-2.5 py-1 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30"
> >
Remove {t('pluginSettings.remove')}
</button> </button>
</div> </div>
</div> </div>
@@ -208,6 +209,8 @@ function PluginCard({
/* ─── Starter Plugin Card ───────────────────────────────────────────────── */ /* ─── Starter Plugin Card ───────────────────────────────────────────────── */
function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) { function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
const { t } = useTranslation('settings');
return ( 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="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="w-[3px] flex-shrink-0 bg-blue-500/30" />
@@ -220,17 +223,17 @@ function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; i
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-foreground"> <span className="text-sm font-semibold leading-none text-foreground">
Project Stats {t('pluginSettings.starterPlugin.name')}
</span> </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"> <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">
starter {t('pluginSettings.starterPlugin.badge')}
</span> </span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"> <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
tab {t('pluginSettings.tab')}
</span> </span>
</div> </div>
<p className="mt-1 text-sm leading-snug text-muted-foreground"> <p className="mt-1 text-sm leading-snug text-muted-foreground">
File counts, lines of code, file-type breakdown, and recent activity for your project. {t('pluginSettings.starterPlugin.description')}
</p> </p>
<a <a
href={STARTER_PLUGIN_URL} href={STARTER_PLUGIN_URL}
@@ -253,7 +256,7 @@ function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; i
) : ( ) : (
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
)} )}
{installing ? 'Installing' : 'Install'} {installing ? t('pluginSettings.installing') : t('pluginSettings.starterPlugin.install')}
</button> </button>
</div> </div>
</div> </div>
@@ -263,6 +266,7 @@ function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; i
/* ─── Main Component ────────────────────────────────────────────────────── */ /* ─── Main Component ────────────────────────────────────────────────────── */
export default function PluginSettingsTab() { export default function PluginSettingsTab() {
const { t } = useTranslation('settings');
const { plugins, loading, installPlugin, uninstallPlugin, updatePlugin, togglePlugin } = const { plugins, loading, installPlugin, uninstallPlugin, updatePlugin, togglePlugin } =
usePlugins(); usePlugins();
@@ -279,7 +283,7 @@ export default function PluginSettingsTab() {
setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; }); setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
const result = await updatePlugin(name); const result = await updatePlugin(name);
if (!result.success) { if (!result.success) {
setUpdateErrors((prev) => ({ ...prev, [name]: result.error || 'Update failed' })); setUpdateErrors((prev) => ({ ...prev, [name]: result.error || t('pluginSettings.updateFailed') }));
} }
setUpdatingPlugins((prev) => { const next = new Set(prev); next.delete(name); return next; }); setUpdatingPlugins((prev) => { const next = new Set(prev); next.delete(name); return next; });
}; };
@@ -292,7 +296,7 @@ export default function PluginSettingsTab() {
if (result.success) { if (result.success) {
setGitUrl(''); setGitUrl('');
} else { } else {
setInstallError(result.error || 'Installation failed'); setInstallError(result.error || t('pluginSettings.installFailed'));
} }
setInstalling(false); setInstalling(false);
}; };
@@ -302,7 +306,7 @@ export default function PluginSettingsTab() {
setInstallError(null); setInstallError(null);
const result = await installPlugin(STARTER_PLUGIN_URL); const result = await installPlugin(STARTER_PLUGIN_URL);
if (!result.success) { if (!result.success) {
setInstallError(result.error || 'Installation failed'); setInstallError(result.error || t('pluginSettings.installFailed'));
} }
setInstallingStarter(false); setInstallingStarter(false);
}; };
@@ -316,7 +320,7 @@ export default function PluginSettingsTab() {
if (result.success) { if (result.success) {
setConfirmUninstall(null); setConfirmUninstall(null);
} else { } else {
setInstallError(result.error || 'Uninstall failed'); setInstallError(result.error || t('pluginSettings.uninstallFailed'));
setConfirmUninstall(null); setConfirmUninstall(null);
} }
}; };
@@ -328,17 +332,10 @@ export default function PluginSettingsTab() {
{/* Header */} {/* Header */}
<div> <div>
<h3 className="mb-1 text-base font-semibold text-foreground"> <h3 className="mb-1 text-base font-semibold text-foreground">
Plugins {t('pluginSettings.title')}
</h3> </h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Extend the interface with custom plugins. Install from{' '} {t('pluginSettings.description')}
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
git
</code>{' '}
or drop a folder in{' '}
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
~/.claude-code-ui/plugins/
</code>
</p> </p>
</div> </div>
@@ -354,8 +351,8 @@ export default function PluginSettingsTab() {
setGitUrl(e.target.value); setGitUrl(e.target.value);
setInstallError(null); setInstallError(null);
}} }}
placeholder="https://github.com/user/my-plugin" placeholder={t('pluginSettings.installPlaceholder')}
aria-label="Plugin git repository URL" aria-label={t('pluginSettings.installAriaLabel')}
className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none" className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') void handleInstall(); if (e.key === 'Enter') void handleInstall();
@@ -369,7 +366,7 @@ export default function PluginSettingsTab() {
{installing ? ( {installing ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
'Install' t('pluginSettings.installButton')
)} )}
</button> </button>
</div> </div>
@@ -381,7 +378,7 @@ export default function PluginSettingsTab() {
<p className="-mt-4 flex items-start gap-1.5 text-xs leading-snug text-muted-foreground/50"> <p className="-mt-4 flex items-start gap-1.5 text-xs leading-snug text-muted-foreground/50">
<ShieldAlert className="mt-px h-3 w-3 flex-shrink-0" /> <ShieldAlert className="mt-px h-3 w-3 flex-shrink-0" />
<span> <span>
Only install plugins whose source code you have reviewed or from authors you trust. {t('pluginSettings.securityWarning')}
</span> </span>
</p> </p>
@@ -394,18 +391,26 @@ export default function PluginSettingsTab() {
{loading ? ( {loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Scanning plugins {t('pluginSettings.scanningPlugins')}
</div> </div>
) : plugins.length === 0 ? ( ) : plugins.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">No plugins installed</p> <p className="py-8 text-center text-sm text-muted-foreground">{t('pluginSettings.noPluginsInstalled')}</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{plugins.map((plugin, index) => ( {plugins.map((plugin, index) => {
const handleToggle = async (enabled: boolean) => {
const r = await togglePlugin(plugin.name, enabled);
if (!r.success) {
setInstallError(r.error || t('pluginSettings.toggleFailed'));
}
};
return (
<PluginCard <PluginCard
key={plugin.name} key={plugin.name}
plugin={plugin} plugin={plugin}
index={index} index={index}
onToggle={(enabled) => void togglePlugin(plugin.name, enabled).then(r => { if (!r.success) setInstallError(r.error || 'Toggle failed'); })} onToggle={(enabled) => void handleToggle(enabled)}
onUpdate={() => void handleUpdate(plugin.name)} onUpdate={() => void handleUpdate(plugin.name)}
onUninstall={() => void handleUninstall(plugin.name)} onUninstall={() => void handleUninstall(plugin.name)}
updating={updatingPlugins.has(plugin.name)} updating={updatingPlugins.has(plugin.name)}
@@ -413,7 +418,8 @@ export default function PluginSettingsTab() {
onCancelUninstall={() => setConfirmUninstall(null)} onCancelUninstall={() => setConfirmUninstall(null)}
updateError={updateErrors[plugin.name] ?? null} updateError={updateErrors[plugin.name] ?? null}
/> />
))} );
})}
</div> </div>
)} )}
@@ -422,7 +428,7 @@ export default function PluginSettingsTab() {
<div className="flex min-w-0 items-center gap-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">
Build your own plugin {t('pluginSettings.buildYourOwn')}
</span> </span>
</div> </div>
<div className="flex flex-shrink-0 items-center gap-3"> <div className="flex flex-shrink-0 items-center gap-3">
@@ -432,7 +438,7 @@ export default function PluginSettingsTab() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground" className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
> >
Starter <ExternalLink className="h-2.5 w-2.5" /> {t('pluginSettings.starter')} <ExternalLink className="h-2.5 w-2.5" />
</a> </a>
<span className="text-muted-foreground/20">·</span> <span className="text-muted-foreground/20">·</span>
<a <a
@@ -441,7 +447,7 @@ export default function PluginSettingsTab() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground" className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
> >
Docs <ExternalLink className="h-2.5 w-2.5" /> {t('pluginSettings.docs')} <ExternalLink className="h-2.5 w-2.5" />
</a> </a>
</div> </div>
</div> </div>

View File

@@ -11,6 +11,7 @@ import {
TERMINAL_OPTIONS, TERMINAL_OPTIONS,
TERMINAL_RESIZE_DELAY_MS, TERMINAL_RESIZE_DELAY_MS,
} from '../constants/constants'; } from '../constants/constants';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { isCodexLoginCommand } from '../utils/auth'; import { isCodexLoginCommand } from '../utils/auth';
import { sendSocketMessage } from '../utils/socket'; import { sendSocketMessage } from '../utils/socket';
import { ensureXtermFocusStyles } from '../utils/terminalStyles'; import { ensureXtermFocusStyles } from '../utils/terminalStyles';
@@ -103,6 +104,37 @@ export function useShellTerminal({
nextTerminal.open(terminalContainerRef.current); nextTerminal.open(terminalContainerRef.current);
const copyTerminalSelection = async () => {
const selection = nextTerminal.getSelection();
if (!selection) {
return false;
}
return copyTextToClipboard(selection);
};
const handleTerminalCopy = (event: ClipboardEvent) => {
if (!nextTerminal.hasSelection()) {
return;
}
const selection = nextTerminal.getSelection();
if (!selection) {
return;
}
event.preventDefault();
if (event.clipboardData) {
event.clipboardData.setData('text/plain', selection);
return;
}
void copyTextToClipboard(selection);
};
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
nextTerminal.attachCustomKeyEventHandler((event) => { nextTerminal.attachCustomKeyEventHandler((event) => {
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current) const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
? CODEX_DEVICE_AUTH_URL ? CODEX_DEVICE_AUTH_URL
@@ -132,7 +164,7 @@ export function useShellTerminal({
) { ) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
document.execCommand('copy'); void copyTerminalSelection();
return false; return false;
} }
@@ -211,6 +243,7 @@ export function useShellTerminal({
resizeObserver.observe(terminalContainerRef.current); resizeObserver.observe(terminalContainerRef.current);
return () => { return () => {
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
resizeObserver.disconnect(); resizeObserver.disconnect();
if (resizeTimeoutRef.current !== null) { if (resizeTimeoutRef.current !== null) {
window.clearTimeout(resizeTimeoutRef.current); window.clearTimeout(resizeTimeoutRef.current);

View File

@@ -40,7 +40,7 @@ export default function Shell({
onProcessComplete = null, onProcessComplete = null,
minimal = false, minimal = false,
autoConnect = false, autoConnect = false,
isActive, isActive = true,
}: ShellProps) { }: ShellProps) {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const [isRestarting, setIsRestarting] = useState(false); const [isRestarting, setIsRestarting] = useState(false);
@@ -48,9 +48,6 @@ export default function Shell({
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onOutputRef = useRef<(() => void) | null>(null); const onOutputRef = useRef<(() => void) | null>(null);
// Keep the public API stable for existing callers that still pass `isActive`.
void isActive;
const { const {
terminalContainerRef, terminalContainerRef,
terminalRef, terminalRef,
@@ -157,6 +154,24 @@ export default function Shell({
} }
}, [isConnected]); }, [isConnected]);
useEffect(() => {
if (!isActive || !isInitialized || !isConnected) {
return;
}
const focusTerminal = () => {
terminalRef.current?.focus();
};
const animationFrameId = window.requestAnimationFrame(focusTerminal);
const timeoutId = window.setTimeout(focusTerminal, 0);
return () => {
window.cancelAnimationFrame(animationFrameId);
window.clearTimeout(timeoutId);
};
}, [isActive, isConnected, isInitialized, terminalRef]);
const sendInput = useCallback( const sendInput = useCallback(
(data: string) => { (data: string) => {
sendSocketMessage(wsRef.current, { type: 'input', data }); sendSocketMessage(wsRef.current, { type: 'input', data });

View File

@@ -9,6 +9,7 @@ type StandaloneShellProps = {
session?: ProjectSession | null; session?: ProjectSession | null;
command?: string | null; command?: string | null;
isPlainShell?: boolean | null; isPlainShell?: boolean | null;
isActive?: boolean;
autoConnect?: boolean; autoConnect?: boolean;
onComplete?: ((exitCode: number) => void) | null; onComplete?: ((exitCode: number) => void) | null;
onClose?: (() => void) | null; onClose?: (() => void) | null;
@@ -24,6 +25,7 @@ export default function StandaloneShell({
session = null, session = null,
command = null, command = null,
isPlainShell = null, isPlainShell = null,
isActive = true,
autoConnect = true, autoConnect = true,
onComplete = null, onComplete = null,
onClose = null, onClose = null,
@@ -64,6 +66,7 @@ export default function StandaloneShell({
selectedSession={session} selectedSession={session}
initialCommand={command} initialCommand={command}
isPlainShell={shouldUsePlainShell} isPlainShell={shouldUsePlainShell}
isActive={isActive}
onProcessComplete={handleProcessComplete} onProcessComplete={handleProcessComplete}
minimal={minimal} minimal={minimal}
autoConnect={minimal ? true : autoConnect} autoConnect={minimal ? true : autoConnect}

View File

@@ -29,6 +29,7 @@ const buildWebSocketUrl = (token: string | null) => {
const useWebSocketProviderState = (): WebSocketContextType => { const useWebSocketProviderState = (): WebSocketContextType => {
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const unmountedRef = useRef(false); // Track if component is unmounted const unmountedRef = useRef(false); // Track if component is unmounted
const hasConnectedRef = useRef(false); // Track if we've ever connected (to detect reconnects)
const [latestMessage, setLatestMessage] = useState<any>(null); const [latestMessage, setLatestMessage] = useState<any>(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null); const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -61,6 +62,11 @@ const useWebSocketProviderState = (): WebSocketContextType => {
websocket.onopen = () => { websocket.onopen = () => {
setIsConnected(true); setIsConnected(true);
wsRef.current = websocket; wsRef.current = websocket;
if (hasConnectedRef.current) {
// This is a reconnect — signal so components can catch up on missed messages
setLatestMessage({ type: 'websocket-reconnected', timestamp: Date.now() });
}
hasConnectedRef.current = true;
}; };
websocket.onmessage = (event) => { websocket.onmessage = (event) => {

View File

@@ -18,6 +18,10 @@ type UseProjectsStateArgs = {
activeSessions: Set<string>; activeSessions: Set<string>;
}; };
type FetchProjectsOptions = {
showLoadingState?: boolean;
};
const serialize = (value: unknown) => JSON.stringify(value ?? null); const serialize = (value: unknown) => JSON.stringify(value ?? null);
const projectsHaveChanges = ( const projectsHaveChanges = (
@@ -152,9 +156,11 @@ export function useProjectsState({
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchProjects = useCallback(async () => { const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {
try { try {
if (showLoadingState) {
setIsLoadingProjects(true); setIsLoadingProjects(true);
}
const response = await api.projects(); const response = await api.projects();
const projectData = (await response.json()) as Project[]; const projectData = (await response.json()) as Project[];
@@ -170,10 +176,17 @@ export function useProjectsState({
} catch (error) { } catch (error) {
console.error('Error fetching projects:', error); console.error('Error fetching projects:', error);
} finally { } finally {
if (showLoadingState) {
setIsLoadingProjects(false); setIsLoadingProjects(false);
} }
}
}, []); }, []);
const refreshProjectsSilently = useCallback(async () => {
// Keep chat view stable while still syncing sidebar/session metadata in background.
await fetchProjects({ showLoadingState: false });
}, [fetchProjects]);
const openSettings = useCallback((tab = 'tools') => { const openSettings = useCallback((tab = 'tools') => {
setSettingsInitialTab(tab); setSettingsInitialTab(tab);
setShowSettings(true); setShowSettings(true);
@@ -547,6 +560,7 @@ export function useProjectsState({
setShowSettings, setShowSettings,
openSettings, openSettings,
fetchProjects, fetchProjects,
refreshProjectsSilently,
sidebarSharedProps, sidebarSharedProps,
handleProjectSelect, handleProjectSelect,
handleSessionSelect, handleSessionSelect,

View File

@@ -434,5 +434,41 @@
"title": "About Codex MCP", "title": "About Codex MCP",
"description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources." "description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources."
} }
},
"pluginSettings": {
"title": "Plugins",
"description": "Extend the interface with custom plugins. Install from git or drop a folder in ~/.claude-code-ui/plugins/",
"installPlaceholder": "https://github.com/user/my-plugin",
"installButton": "Install",
"installing": "Installing…",
"securityWarning": "Only install plugins whose source code you have reviewed or from authors you trust.",
"scanningPlugins": "Scanning plugins…",
"noPluginsInstalled": "No plugins installed",
"pullLatest": "Pull latest from git",
"noGitRemote": "No git remote — update not available",
"uninstallPlugin": "Uninstall plugin",
"confirmUninstall": "Click again to confirm",
"confirmUninstallMessage": "Remove {{name}}? This cannot be undone.",
"cancel": "Cancel",
"remove": "Remove",
"updateFailed": "Update failed",
"installFailed": "Installation failed",
"uninstallFailed": "Uninstall failed",
"toggleFailed": "Toggle failed",
"buildYourOwn": "Build your own plugin",
"starter": "Starter",
"docs": "Docs",
"starterPlugin": {
"name": "Project Stats",
"badge": "starter",
"description": "File counts, lines of code, file-type breakdown, and recent activity for your project.",
"install": "Install"
},
"morePlugins": "More",
"enable": "Enable",
"disable": "Disable",
"installAriaLabel": "Plugin git repository URL",
"tab": "tab",
"runningStatus": "running"
} }
} }

View File

@@ -434,5 +434,41 @@
"title": "Codex MCPについて", "title": "Codex MCPについて",
"description": "Codexはstdioベースのツールサーバーをサポートしています。追加のツールやリソースでCodexの機能を拡張するサーバーを追加できます。" "description": "Codexはstdioベースのツールサーバーをサポートしています。追加のツールやリソースでCodexの機能を拡張するサーバーを追加できます。"
} }
},
"pluginSettings": {
"title": "プラグイン",
"description": "カスタムプラグインでインターフェースを拡張します。gitからインストールするか、~/.claude-code-ui/plugins/ にフォルダを配置してください。",
"installPlaceholder": "https://github.com/user/my-plugin",
"installButton": "インストール",
"installing": "インストール中…",
"securityWarning": "信頼できる作成者のプラグイン、またはソースコードを確認済みのプラグインのみをインストールしてください。",
"scanningPlugins": "プラグインをスキャン中…",
"noPluginsInstalled": "プラグインがインストールされていません",
"pullLatest": "gitから最新を取得",
"noGitRemote": "リモートgitリポジトリがありません — アップデート不可",
"uninstallPlugin": "プラグインを削除",
"confirmUninstall": "クリックして確定",
"confirmUninstallMessage": "{{name}} を削除しますか?この操作は取り消せません。",
"cancel": "キャンセル",
"remove": "削除",
"updateFailed": "アップデートに失敗しました",
"installFailed": "インストールに失敗しました",
"uninstallFailed": "削除に失敗しました",
"toggleFailed": "切り替えに失敗しました",
"buildYourOwn": "プラグインを自作する",
"starter": "スターター",
"docs": "ドキュメント",
"starterPlugin": {
"name": "プロジェクト統計",
"badge": "スターター",
"description": "プロジェクトのファイル数、コード行数、ファイルタイプの内訳、最近のアクティビティを表示します。",
"install": "インストール"
},
"morePlugins": "詳細",
"enable": "有効にする",
"disable": "無効にする",
"installAriaLabel": "プラグインのgitリポジトリURL",
"tab": "タブ",
"runningStatus": "実行中"
} }
} }

View File

@@ -434,5 +434,41 @@
"title": "Codex MCP 정보", "title": "Codex MCP 정보",
"description": "Codex는 stdio 기반 MCP 서버를 지원합니다. 추가 도구와 리소스로 Codex의 기능을 확장하는 서버를 추가할 수 있습니다." "description": "Codex는 stdio 기반 MCP 서버를 지원합니다. 추가 도구와 리소스로 Codex의 기능을 확장하는 서버를 추가할 수 있습니다."
} }
},
"pluginSettings": {
"title": "플러그인",
"description": "커스텀 플러그인으로 인터페이스를 확장하세요. git에서 설치하거나 ~/.claude-code-ui/plugins/ 폴더에 직접 추가할 수 있습니다.",
"installPlaceholder": "https://github.com/user/my-plugin",
"installButton": "설치",
"installing": "설치 중…",
"securityWarning": "소스 코드를 검토했거나 신뢰할 수 있는 작성자의 플러그인만 설치하세요.",
"scanningPlugins": "플러그인 스캔 중…",
"noPluginsInstalled": "설치된 플러그인이 없습니다",
"pullLatest": "git에서 최신 버전 가져오기",
"noGitRemote": "git 리모트가 없음 — 업데이트 불가",
"uninstallPlugin": "플러그인 삭제",
"confirmUninstall": "다시 클릭하여 확인",
"confirmUninstallMessage": "{{name}} 플러그인을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"cancel": "취소",
"remove": "삭제",
"updateFailed": "업데이트 실패",
"installFailed": "설치 실패",
"uninstallFailed": "삭제 실패",
"toggleFailed": "토글 실패",
"buildYourOwn": "나만의 플러그인 만들기",
"starter": "스타터",
"docs": "문서",
"starterPlugin": {
"name": "프로젝트 통계",
"badge": "스타터",
"description": "프로젝트의 파일 수, 코드 라인 수, 파일 유형별 분석 및 최근 활동을 확인합니다.",
"install": "설치"
},
"morePlugins": "더 보기",
"enable": "활성화",
"disable": "비활성화",
"installAriaLabel": "플러그인 git 저장소 URL",
"tab": "탭",
"runningStatus": "실행 중"
} }
} }

View File

@@ -104,7 +104,8 @@
"appearance": "Внешний вид", "appearance": "Внешний вид",
"git": "Git", "git": "Git",
"apiTokens": "API и токены", "apiTokens": "API и токены",
"tasks": "Задачи" "tasks": "Задачи",
"plugins": "Плагины"
}, },
"appearanceSettings": { "appearanceSettings": {
"darkMode": { "darkMode": {
@@ -433,5 +434,41 @@
"title": "О Codex MCP", "title": "О Codex MCP",
"description": "Codex поддерживает MCP серверы на основе stdio. Вы можете добавлять серверы, которые расширяют возможности Codex дополнительными инструментами и ресурсами." "description": "Codex поддерживает MCP серверы на основе stdio. Вы можете добавлять серверы, которые расширяют возможности Codex дополнительными инструментами и ресурсами."
} }
},
"pluginSettings": {
"title": "Плагины",
"description": "Расширяйте интерфейс с помощью кастомных плагинов. Установите из git или добавьте папку в ~/.claude-code-ui/plugins/",
"installPlaceholder": "https://github.com/user/my-plugin",
"installButton": "Установить",
"installing": "Установка…",
"securityWarning": "Устанавливайте только те плагины, исходный код которых вы проверили или от авторов, которым вы доверяете.",
"scanningPlugins": "Сканирование плагинов…",
"noPluginsInstalled": "Плагины не установлены",
"pullLatest": "Получить обновления из git",
"noGitRemote": "Нет удаленного git-репозитория — обновление недоступно",
"uninstallPlugin": "Удалить плагин",
"confirmUninstall": "Нажмите еще раз для подтверждения",
"confirmUninstallMessage": "Удалить {{name}}? Это действие нельзя отменить.",
"cancel": "Отмена",
"remove": "Удалить",
"updateFailed": "Ошибка обновления",
"installFailed": "Ошибка установки",
"uninstallFailed": "Ошибка удаления",
"toggleFailed": "Ошибка переключения",
"buildYourOwn": "Создайте свой плагин",
"starter": "Шаблон",
"docs": "Документация",
"starterPlugin": {
"name": "Статистика проекта",
"badge": "шаблон",
"description": "Количество файлов, строк кода, разбивка по типам файлов и недавняя активность в вашем проекте.",
"install": "Установить"
},
"morePlugins": "Ещё",
"enable": "Включить",
"disable": "Выключить",
"installAriaLabel": "URL git-репозитория плагина",
"tab": "вкладка",
"runningStatus": "запущен"
} }
} }

View File

@@ -434,5 +434,41 @@
"title": "关于 Codex MCP", "title": "关于 Codex MCP",
"description": "Codex 支持基于 stdio 的 MCP 服务器。您可以添加服务器,通过额外的工具和资源来扩展 Codex 的功能。" "description": "Codex 支持基于 stdio 的 MCP 服务器。您可以添加服务器,通过额外的工具和资源来扩展 Codex 的功能。"
} }
},
"pluginSettings": {
"title": "插件",
"description": "通过自定义插件扩展界面。从 git 安装或直接将文件夹放入 ~/.claude-code-ui/plugins/",
"installPlaceholder": "https://github.com/user/my-plugin",
"installButton": "安装",
"installing": "安装中…",
"securityWarning": "仅安装您已审查过源代码或信任作者的插件。",
"scanningPlugins": "正在扫描插件…",
"noPluginsInstalled": "未安装插件",
"pullLatest": "从 git 拉取最新内容",
"noGitRemote": "无 git 远程仓库 — 无法更新",
"uninstallPlugin": "卸载插件",
"confirmUninstall": "再次点击确认",
"confirmUninstallMessage": "移除 {{name}}?此操作无法撤销。",
"cancel": "取消",
"remove": "移除",
"updateFailed": "更新失败",
"installFailed": "安装失败",
"uninstallFailed": "卸载失败",
"toggleFailed": "切换失败",
"buildYourOwn": "构建您自己的插件",
"starter": "入门模板",
"docs": "文档",
"starterPlugin": {
"name": "项目统计",
"badge": "入门",
"description": "查看项目的文件数、代码行数、文件类型分布以及最近活动。",
"install": "安装"
},
"morePlugins": "更多",
"enable": "启用",
"disable": "禁用",
"installAriaLabel": "插件 git 仓库 URL",
"tab": "标签",
"runningStatus": "运行中"
} }
} }

View File

@@ -1,15 +1,20 @@
import { defineConfig, loadEnv } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { getConnectableHost, normalizeLoopbackHost } from './shared/networkHosts.js'
export default defineConfig(({ command, mode }) => { export default defineConfig(({ mode }) => {
// Load env file based on `mode` in the current working directory. // Load env file based on `mode` in the current working directory.
const env = loadEnv(mode, process.cwd(), '') const env = loadEnv(mode, process.cwd(), '')
const host = env.HOST || '0.0.0.0' const configuredHost = env.HOST || '0.0.0.0'
// When binding to all interfaces (0.0.0.0), proxy should connect to localhost // if the host is not a loopback address, it should be used directly.
// Otherwise, proxy to the specific host the backend is bound to // This allows the vite server to EXPOSE all interfaces when the host
const proxyHost = host === '0.0.0.0' ? 'localhost' : host // is set to '0.0.0.0' or '::', while still using 'localhost' for browser
const port = env.PORT || 3001 // URLs and proxy targets.
const host = normalizeLoopbackHost(configuredHost)
const proxyHost = getConnectableHost(configuredHost)
const serverPort = env.SERVER_PORT || 3001
return { return {
plugins: [react()], plugins: [react()],
@@ -17,13 +22,13 @@ export default defineConfig(({ command, mode }) => {
host, host,
port: parseInt(env.VITE_PORT) || 5173, port: parseInt(env.VITE_PORT) || 5173,
proxy: { proxy: {
'/api': `http://${proxyHost}:${port}`, '/api': `http://${proxyHost}:${serverPort}`,
'/ws': { '/ws': {
target: `ws://${proxyHost}:${port}`, target: `ws://${proxyHost}:${serverPort}`,
ws: true ws: true
}, },
'/shell': { '/shell': {
target: `ws://${proxyHost}:${port}`, target: `ws://${proxyHost}:${serverPort}`,
ws: true ws: true
} }
} }