Compare commits

..

20 Commits

Author SHA1 Message Date
Simos Mikelatos
35d1b636ff Merge branch 'main' into feat/design-improvements-and-minor-bug-fixes 2026-06-30 22:57:53 +02:00
Haileyesus
770404c701 fix: remove unnecessary auto expand tools 2026-06-30 16:12:23 +03:00
Haileyesus
858bed609a fix(chat): correct invalid dark-mode hover on AskUserQuestion options 2026-06-30 15:29:18 +03:00
Haileyesus
1a7f0291b2 style(auth): modernize login, setup, and onboarding screens 2026-06-30 15:01:50 +03:00
Haileyesus
7d5bd753d4 style: improve thinking and stop button placements 2026-06-30 15:01:50 +03:00
Haileyesus
eed37b51d4 fix: align activity indicator with composer input width
Wrap ActivityIndicator in the same mx-auto max-w-3xl container as the
text input so the "Analyzing…" label and Stop button stay within the
input's boundaries instead of spanning the full window width.
2026-06-30 15:01:50 +03:00
Haileyesus
5798246135 style(ui): use Merriweather serif for chat text and Encode Sans for the rest of the UI 2026-06-30 15:01:49 +03:00
Haileyesus
a7b455aeac style(mcp): remove purple accents and portal the server form modal
- Replace the purple provider-button colors, heading icon, and form
  submit button with the primary token (no purple in the MCP UI)
- Portal the add/edit MCP server modal to document.body so its fixed
  overlay covers the full viewport, fixing the white band at the top
  caused by the Settings dialog's transformed tab content becoming the
  containing block
2026-06-30 15:01:49 +03:00
Haileyesus
c420c6d63e fix(chat): header ellipsis, Codex logo on light theme, portal copy menu
- MainContentTitle: truncate the session title with an ellipsis instead
  of horizontal-scrolling it
- MessageComponent: use text-foreground for the provider logo chip so the
  currentColor Codex/OpenAI mark is visible on the light theme
- MessageCopyControl: render the copy-format dropdown in a portal so it
  escapes the chat message's `contain: paint` clip box; anchor it to the
  trigger, flip above near the viewport bottom, close on scroll/resize
2026-06-30 15:01:48 +03:00
Haileyesus
54a062baa6 style(chat): unify composer toolbar heights and declutter slash-command modal
- Composer: give the permission-mode and token-usage buttons a fixed
  h-8 so every bottom-toolbar control shares one height
- CommandResultModal: replace the blue gradient header (gradient fill,
  glow blobs, blue eyebrow + icon chip) with a clean neutral header on
  popover/muted tokens
2026-06-30 15:01:48 +03:00
Haileyesus
9090e73478 fix: use app theme for code editor 2026-06-30 15:01:47 +03:00
Haileyesus
032258b260 style(ui): rework light/dark theme to make it visually consistent
Rework the color system around warm neutrals and route hardcoded
surfaces through theme tokens for consistency.

- Theme tokens (index.css, ThemeContext): warm cream light mode and
  neutral charcoal dark mode, replacing the pure-white/blue-tinted
  palette; update PWA theme-color meta
- Code blocks: soft grey background in light mode via
  oneLight/oneDark, and drop the Tailwind Typography <pre> shell that
  framed the highlighter in a dark box
- Dropdowns/panels: convert CommandMenu, Quick Settings, and the JSON
  response block from hardcoded gray/slate to popover/muted/border
  tokens
- Git panel: Publish button purple -> primary blue
- Composer: drop top padding so the input sits flush with the thread
2026-06-30 15:01:20 +03:00
Haileyesus
e71f3bf3f6 fix(chat): unify messages and composer into centered column
Constrain both ChatMessagesPane content and ChatComposer to the same
max-w-3xl centered column. Previously only
the composer had a max-width, causing messages to fill the full width
while the input stayed narrow, making them visually misaligned with
large empty gutters on either side.
2026-06-30 14:59:03 +03:00
Haileyesus
19b59e701e fix(chat): remove auto scroll quick setting 2026-06-29 22:38:16 +03:00
Haileyesus
37ef891945 fix(chat): hide load all prompt after final page 2026-06-29 22:27:59 +03:00
Haileyesus
f363127427 fix(chat): refine load all overlay behavior 2026-06-29 22:22:46 +03:00
Haileyesus
dc1580dae7 fix: update command menu positioning 2026-06-29 21:38:39 +03:00
Haileyesus
4c6e9178f6 fix(chat): stabilize message scroll controls 2026-06-29 21:37:50 +03:00
Haileyesus
a9e24e7071 fix(chat): group continuous same-tool runs more consistently
Consecutive tool calls (Edit, Read, Grep, etc.) grouped inconsistently:

- The group threshold was 3, so a run of only 2 calls stayed ungrouped
  while a run of 3 collapsed — making two back-to-back edits look
  different from three.
- A run was broken by any interleaved message, including ones that render
  nothing (reasoning hidden when showThinking is off). Providers like
  Codex interleave hidden reasoning between tool calls, so visually
  continuous edits intermittently failed to group.

Lower TOOL_GROUP_THRESHOLD to 2 and skip non-rendered messages when
extending a run, so any 2+ consecutive same-tool calls collapse reliably.
ChatMessagesPane now passes showThinking into groupConsecutiveTools.
2026-06-29 15:41:52 +03:00
Haileyesus
2cd1200081 fix(shell): hide prompt options on desktop 2026-06-29 15:27:55 +03:00
115 changed files with 1041 additions and 3970 deletions

View File

@@ -28,9 +28,6 @@ HOST=0.0.0.0
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
# CLAUDE_CLI_PATH=claude
# Uncomment the following line if you want a custom Hermes ACP launcher
# HERMES_CLI_PATH=hermes acp
# =============================================================================
# DATABASE CONFIGURATION
# =============================================================================
@@ -45,3 +42,4 @@ HOST=0.0.0.0
VITE_CONTEXT_WINDOW=160000
CONTEXT_WINDOW=160000

View File

@@ -6,7 +6,15 @@
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<title>CloudCLI UI</title>
<!-- Fonts: Encode Sans (UI) + Merriweather (chat) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Encode+Sans:wght@400;500;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400;1,700&display=swap"
rel="stylesheet"
/>
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />

View File

@@ -524,7 +524,7 @@
<td><code>provider</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td><code>claude</code>, <code>cursor</code>, <code>codex</code>, <code>gemini</code>, <code>opencode</code>, or <code>hermes</code> (default: <code>claude</code>)</td>
<td><code>claude</code>, <code>cursor</code>, or <code>codex</code> (default: <code>claude</code>)</td>
</tr>
<tr>
<td><code>stream</code></td>
@@ -834,7 +834,6 @@ data: {"type":"done"}</code></pre>
{ id: 'gemini', name: 'Google' },
{ id: 'cursor', name: 'Cursor' },
{ id: 'opencode', name: 'OpenCode' },
{ id: 'hermes', name: 'Nous Research' },
];
async function populateModels() {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -29,14 +29,9 @@ import {
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import {
getPendingApprovalsForSession,
registerApproval,
resolveToolApproval,
unregisterApproval,
} from './shared/tool-approval-registry.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
// Sessions cancelled via abort-session. The abort handler already sent the
// terminal `complete` (aborted: true) to the client, so the run loop must not
// emit a second one when its generator winds down.
@@ -69,7 +64,7 @@ function waitForToolApproval(requestId, options = {}) {
let timeout;
const cleanup = () => {
unregisterApproval(requestId);
pendingToolApprovals.delete(requestId);
if (timeout) clearTimeout(timeout);
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler);
@@ -101,15 +96,21 @@ function waitForToolApproval(requestId, options = {}) {
const resolver = (decision) => {
finalize(decision);
};
registerApproval(requestId, {
resolver,
sessionId: metadata?._sessionId ?? null,
provider: 'claude',
meta: metadata ?? {},
});
// Attach metadata for getPendingApprovalsForSession lookup
if (metadata) {
Object.assign(resolver, metadata);
}
pendingToolApprovals.set(requestId, resolver);
});
}
function resolveToolApproval(requestId, decision) {
const resolver = pendingToolApprovals.get(requestId);
if (resolver) {
resolver(decision);
}
}
// Match stored permission entries against a tool + input combo.
// This only supports exact tool names and the Bash(command:*) shorthand
// used by the UI; it intentionally does not implement full glob semantics,
@@ -845,6 +846,28 @@ function getActiveClaudeSDKSessions() {
return getAllSessions();
}
/**
* Get pending tool approvals for a specific session.
* @param {string} sessionId - The session ID
* @returns {Array} Array of pending permission request objects
*/
function getPendingApprovalsForSession(sessionId) {
const pending = [];
for (const [requestId, resolver] of pendingToolApprovals.entries()) {
if (resolver._sessionId === sessionId) {
pending.push({
requestId,
toolName: resolver._toolName || 'UnknownTool',
input: resolver._input,
context: resolver._context,
sessionId,
receivedAt: resolver._receivedAt || new Date(),
});
}
}
return pending;
}
/**
* Reconnect a session's WebSocketWriter to a new raw WebSocket.
* Called when client reconnects (e.g. page refresh) while SDK is still running.

View File

@@ -1,407 +0,0 @@
import crypto from 'node:crypto';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import {
clearApprovalsForSession,
getPendingApprovalsForSession,
registerApproval,
resolveToolApproval,
unregisterApproval,
} from './shared/tool-approval-registry.js';
import { hermesConnectionManager } from './hermes/acp-client.js';
const PROVIDER = 'hermes';
const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
const activeHermesSessions = new Map();
// Session ids whose run was aborted; the terminal `complete` is emitted by
// handleChatAbort, so the runtime must not also emit a "completed" one.
const abortedSessionIds = new Set();
function createRequestId() {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return crypto.randomBytes(16).toString('hex');
}
function readSessionId(result) {
if (!result || typeof result !== 'object') {
return null;
}
return result.sessionId
|| result.session_id
|| result.id
|| result.session?.id
|| result.session?.sessionId
|| result.session?.session_id
|| null;
}
function readStopReason(result) {
if (!result || typeof result !== 'object') {
return null;
}
return result.stopReason || result.stop_reason || result.reason || null;
}
function buildPromptParams(sessionId, command) {
return {
sessionId,
prompt: [{ type: 'text', text: command }],
};
}
function buildSessionSetupParams(sessionId, workingDir) {
return {
...(sessionId ? { sessionId } : {}),
cwd: workingDir,
mcpServers: [],
};
}
function canLoadSession(connection) {
return connection?.initializeResult?.agentCapabilities?.loadSession === true;
}
function findPermissionOption(options, kinds, fallbackOptionIds = []) {
if (!Array.isArray(options)) {
return null;
}
for (const kind of kinds) {
const match = options.find((option) => option?.kind === kind);
if (match?.optionId) {
return match.optionId;
}
}
for (const optionId of fallbackOptionIds) {
const match = options.find((option) => option?.optionId === optionId);
if (match?.optionId) {
return match.optionId;
}
}
return null;
}
function createPermissionDecision(decision, options = []) {
if (!decision) {
return { outcome: { outcome: 'cancelled' } };
}
if (decision.cancelled) {
return { outcome: { outcome: 'cancelled' } };
}
if (decision.allow) {
const optionId = decision.rememberEntry
? findPermissionOption(options, ['allow_always', 'allow_session'], ['allow_always', 'allow_session'])
: findPermissionOption(options, ['allow_once'], ['allow_once']);
if (!optionId) {
return { outcome: { outcome: 'cancelled' } };
}
return {
outcome: {
outcome: 'selected',
optionId,
},
};
}
const denyOptionId = findPermissionOption(options, ['reject_once', 'deny', 'reject_always'], ['deny', 'reject_once', 'reject_always']);
if (denyOptionId) {
return {
outcome: {
outcome: 'selected',
optionId: denyOptionId,
},
};
}
return {
outcome: { outcome: 'cancelled' },
};
}
async function waitForPermission(ws, params, capturedSessionId, sessionSummary) {
const requestId = createRequestId();
const toolCall = params?.toolCall || params?.tool_call || {};
const toolName = params?.toolName
|| params?.tool_name
|| params?.name
|| params?.tool?.name
|| toolCall.title
|| 'HermesTool';
const input = params?.input
?? params?.arguments
?? params?.toolInput
?? params?.tool_input
?? toolCall.rawInput
?? toolCall.raw_input
?? toolCall;
ws.send(createNormalizedMessage({
kind: 'permission_request',
requestId,
toolName,
input,
sessionId: capturedSessionId,
provider: PROVIDER,
}));
return new Promise((resolve) => {
registerApproval(requestId, {
sessionId: capturedSessionId,
provider: PROVIDER,
meta: {
toolName,
input,
context: params,
sessionName: sessionSummary,
receivedAt: new Date(),
},
resolver: (decision) => {
unregisterApproval(requestId);
resolve(createPermissionDecision(decision, params?.options));
},
});
});
}
async function spawnHermes(command, options = {}, ws) {
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
const workingDir = cwd || projectPath || process.cwd();
const requestedModel = model === HERMES_CONFIGURED_MODEL ? undefined : model;
let capturedSessionId = sessionId || null;
let sessionCreatedSent = false;
let completeSent = false;
let activeKey = capturedSessionId || `pending-${createRequestId()}`;
const notifyTerminalState = ({ error = null, stopReason = 'completed' } = {}) => {
const finalSessionId = capturedSessionId || sessionId || activeKey;
if (!error) {
notifyRunStopped({
userId: ws?.userId || null,
provider: PROVIDER,
sessionId: finalSessionId,
sessionName: sessionSummary,
stopReason,
});
return;
}
notifyRunFailed({
userId: ws?.userId || null,
provider: PROVIDER,
sessionId: finalSessionId,
sessionName: sessionSummary,
error,
});
};
const registerSession = (nextSessionId, connection) => {
if (!nextSessionId || capturedSessionId === nextSessionId) {
return;
}
if (activeHermesSessions.has(activeKey)) {
activeHermesSessions.delete(activeKey);
}
activeKey = nextSessionId;
capturedSessionId = nextSessionId;
activeHermesSessions.set(activeKey, {
connection,
sessionId: capturedSessionId,
status: 'active',
aborted: false,
ws,
sessionSummary,
});
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
}
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(createNormalizedMessage({
kind: 'session_created',
newSessionId: capturedSessionId,
sessionId: capturedSessionId,
provider: PROVIDER,
}));
}
};
try {
await providerModelsService.resolveResumeModel(PROVIDER, sessionId, requestedModel);
const connection = await hermesConnectionManager.getConnection(workingDir);
activeHermesSessions.set(activeKey, {
connection,
sessionId: capturedSessionId,
status: 'active',
aborted: false,
ws,
sessionSummary,
});
const unregisterPermissionHandler = connection.registerRequestHandler('session/request_permission', (params) => {
const permissionSessionId = params?.sessionId || params?.session_id || null;
const active = permissionSessionId
? activeHermesSessions.get(permissionSessionId)
: activeHermesSessions.get(activeKey);
if (!active) {
return { outcome: { outcome: 'cancelled' } };
}
return waitForPermission(
active.ws,
params,
active.sessionId || permissionSessionId || capturedSessionId,
active.sessionSummary || sessionSummary,
);
});
const updateHandler = (params) => {
const updateSessionId = params?.sessionId || params?.session_id || null;
if (capturedSessionId && updateSessionId && updateSessionId !== capturedSessionId) {
return;
}
registerSession(updateSessionId, connection);
const normalized = sessionsService.normalizeMessage(PROVIDER, params, capturedSessionId || updateSessionId || null);
for (const msg of normalized) {
ws.send(msg);
}
};
connection.on('session/update', updateHandler);
try {
let sessionResult;
if (sessionId && canLoadSession(connection)) {
try {
sessionResult = await connection.request('session/load', buildSessionSetupParams(sessionId, workingDir));
} catch {
sessionResult = { sessionId };
}
} else {
sessionResult = await connection.request('session/new', buildSessionSetupParams(null, workingDir));
}
registerSession(readSessionId(sessionResult) || sessionId, connection);
if (!capturedSessionId) {
throw new Error('Hermes ACP did not return a session id.');
}
const promptResult = await connection.request('session/prompt', buildPromptParams(capturedSessionId, command));
const finalSessionId = capturedSessionId || readSessionId(promptResult) || sessionId || activeKey;
const stopReason = readStopReason(promptResult) || 'completed';
const active = activeHermesSessions.get(finalSessionId) || activeHermesSessions.get(activeKey);
if (promptResult?.usage || promptResult?.tokenUsage || promptResult?.token_usage) {
ws.send(createNormalizedMessage({
kind: 'status',
text: 'token_budget',
tokenBudget: promptResult.usage || promptResult.tokenUsage || promptResult.token_usage,
sessionId: finalSessionId,
provider: PROVIDER,
}));
}
const abortedById = abortedSessionIds.delete(finalSessionId);
const abortedByKey = abortedSessionIds.delete(activeKey);
const wasAborted = Boolean(active?.aborted || abortedById || abortedByKey);
if (!completeSent && !wasAborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: PROVIDER, sessionId: finalSessionId, exitCode: 0 }));
}
activeHermesSessions.delete(finalSessionId);
activeHermesSessions.delete(activeKey);
clearApprovalsForSession(finalSessionId);
notifyTerminalState({ stopReason: wasAborted ? 'aborted' : stopReason });
} finally {
connection.off('session/update', updateHandler);
unregisterPermissionHandler();
}
} catch (error) {
const finalSessionId = capturedSessionId || sessionId || activeKey;
const abortedById = abortedSessionIds.delete(finalSessionId);
const abortedByKey = abortedSessionIds.delete(activeKey);
activeHermesSessions.delete(finalSessionId);
activeHermesSessions.delete(activeKey);
clearApprovalsForSession(finalSessionId);
// A cancelled session/prompt rejects here; its aborted terminal `complete`
// is sent by handleChatAbort, so don't surface the cancellation as an error.
if (abortedById || abortedByKey) {
return;
}
const installed = await providerAuthService.isProviderInstalled(PROVIDER);
const errorContent = !installed
? 'Hermes ACP is not installed. Install Hermes and ensure hermes-acp is on PATH.'
: error instanceof Error ? error.message : String(error);
ws.send(createNormalizedMessage({
kind: 'error',
content: errorContent,
sessionId: finalSessionId,
provider: PROVIDER,
}));
if (!completeSent) {
completeSent = true;
ws.send(createCompleteMessage({ provider: PROVIDER, sessionId: finalSessionId, exitCode: 1 }));
}
notifyTerminalState({ error });
throw error;
}
}
async function abortHermesSession(providerSessionId) {
const active = activeHermesSessions.get(providerSessionId);
if (!active) {
return false;
}
active.aborted = true;
active.status = 'aborted';
abortedSessionIds.add(providerSessionId);
if (active.sessionId) {
abortedSessionIds.add(active.sessionId);
}
for (const approval of getPendingApprovalsForSession(active.sessionId || providerSessionId)) {
resolveToolApproval(approval.requestId, { cancelled: true });
}
try {
active.connection.notify('session/cancel', { sessionId: active.sessionId || providerSessionId });
} catch {
// If Hermes already finished, the caller still sees the run as aborted.
}
activeHermesSessions.delete(providerSessionId);
return true;
}
function isHermesSessionActive(sessionId) {
return activeHermesSessions.has(sessionId);
}
function getActiveHermesSessions() {
return Array.from(activeHermesSessions.keys());
}
export {
spawnHermes,
abortHermesSession,
isHermesSessionActive,
getActiveHermesSessions,
createPermissionDecision,
};

View File

@@ -1,287 +0,0 @@
import { EventEmitter } from 'node:events';
import { spawn } from 'node:child_process';
import crossSpawn from 'cross-spawn';
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
class AcpClient extends EventEmitter {
constructor({ command = process.env.HERMES_CLI_PATH || 'hermes acp', cwd = process.cwd(), env = process.env } = {}) {
super();
const commandParts = command.trim().split(/\s+/);
this.command = commandParts.shift() || 'hermes';
this.args = commandParts;
this.cwd = cwd;
this.env = env;
this.process = null;
this.nextId = 1;
this.pending = new Map();
this.buffer = '';
this.requestHandlers = new Map();
this.initialized = false;
this.initializeResult = null;
}
start() {
if (this.process) {
return;
}
this.process = spawnFunction(this.command, this.args, {
cwd: this.cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...this.env },
});
this.process.stdout.on('data', (chunk) => this.handleData(chunk));
this.process.stderr.on('data', (chunk) => {
const text = chunk.toString();
if (text.trim()) {
this.emit('stderr', text);
}
});
this.process.on('error', (error) => this.rejectAll(error));
this.process.on('close', (code, signal) => {
this.rejectAll(new Error(`hermes-acp exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`));
this.emit('close', { code, signal });
this.process = null;
this.initialized = false;
});
}
async initialize() {
if (this.initialized) {
return;
}
this.start();
this.initializeResult = await this.request('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: {
readTextFile: false,
writeTextFile: false,
},
terminal: false,
},
clientInfo: {
name: 'CloudCLI',
title: 'CloudCLI',
version: '1.0.0',
},
});
this.initialized = true;
this.notify('initialized', {});
}
onRequest(method, handler) {
this.requestHandlers.set(method, handler);
}
registerRequestHandler(method, handler) {
const handlers = this.requestHandlers.get(method) || new Set();
handlers.add(handler);
this.requestHandlers.set(method, handlers);
return () => {
handlers.delete(handler);
if (handlers.size === 0) {
this.requestHandlers.delete(method);
}
};
}
request(method, params) {
this.start();
const id = this.nextId;
this.nextId += 1;
const payload = { jsonrpc: '2.0', id, method, params };
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject, method, params });
this.writeMessage(payload);
});
}
notify(method, params) {
this.start();
this.writeMessage({ jsonrpc: '2.0', method, params });
}
writeMessage(payload) {
if (!this.process || !this.process.stdin || this.process.stdin.destroyed) {
throw new Error('hermes-acp process is not running');
}
const line = `${JSON.stringify(payload)}\n`;
this.process.stdin.write(line);
}
handleData(chunk) {
this.buffer += chunk.toString();
while (this.buffer.length > 0) {
if (this.buffer.startsWith('Content-Length:')) {
const headerEnd = this.buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) {
return;
}
const header = this.buffer.slice(0, headerEnd);
const match = header.match(/Content-Length:\s*(\d+)/i);
if (!match) {
this.buffer = this.buffer.slice(headerEnd + 4);
continue;
}
const length = Number(match[1]);
const messageStart = headerEnd + 4;
if (this.buffer.length < messageStart + length) {
return;
}
const raw = this.buffer.slice(messageStart, messageStart + length);
this.buffer = this.buffer.slice(messageStart + length);
this.dispatchRaw(raw);
continue;
}
const newlineIndex = this.buffer.indexOf('\n');
if (newlineIndex === -1) {
return;
}
const raw = this.buffer.slice(0, newlineIndex).trim();
this.buffer = this.buffer.slice(newlineIndex + 1);
if (raw) {
this.dispatchRaw(raw);
}
}
}
dispatchRaw(raw) {
let message;
try {
message = JSON.parse(raw);
} catch (error) {
this.emit('error', error);
return;
}
void this.dispatchMessage(message);
}
async dispatchMessage(message) {
if (Object.prototype.hasOwnProperty.call(message, 'id') && (message.result !== undefined || message.error !== undefined)) {
const pending = this.pending.get(message.id);
if (!pending) {
return;
}
this.pending.delete(message.id);
if (message.error) {
const messageText = message.error.message || JSON.stringify(message.error);
const error = new Error(`ACP ${pending.method} failed: ${messageText}`);
error.code = message.error.code;
error.data = message.error.data;
error.method = pending.method;
error.params = pending.params;
pending.reject(error);
} else {
pending.resolve(message.result);
}
return;
}
if (Object.prototype.hasOwnProperty.call(message, 'id') && message.method) {
const handler = this.requestHandlers.get(message.method);
if (!handler) {
this.writeMessage({
jsonrpc: '2.0',
id: message.id,
error: { code: -32601, message: `No handler for ${message.method}` },
});
return;
}
try {
const result = handler instanceof Set
? await this.dispatchRequestHandlers(handler, message.params)
: await handler(message.params);
this.writeMessage({ jsonrpc: '2.0', id: message.id, result });
} catch (error) {
this.writeMessage({
jsonrpc: '2.0',
id: message.id,
error: { code: -32000, message: error instanceof Error ? error.message : String(error) },
});
}
return;
}
if (message.method) {
this.emit(message.method, message.params);
this.emit('notification', { method: message.method, params: message.params });
}
}
rejectAll(error) {
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
}
async dispatchRequestHandlers(handlers, params) {
let fallbackResult = null;
let sawHandler = false;
for (const handler of Array.from(handlers).reverse()) {
sawHandler = true;
const result = await handler(params);
const outcome = result?.outcome?.outcome;
if (outcome !== 'cancelled') {
return result;
}
fallbackResult = result;
}
if (sawHandler && fallbackResult) {
return fallbackResult;
}
return { outcome: { outcome: 'cancelled' } };
}
close() {
if (!this.process) {
return;
}
this.process.kill('SIGTERM');
}
}
class HermesConnectionManager {
constructor() {
this.connections = new Map();
}
async getConnection(cwd) {
const key = cwd || process.cwd();
let connection = this.connections.get(key);
if (!connection) {
connection = new AcpClient({ cwd: key });
connection.on('close', () => {
this.connections.delete(key);
});
this.connections.set(key, connection);
}
await connection.initialize();
return connection;
}
closeAll() {
for (const connection of this.connections.values()) {
connection.close();
}
this.connections.clear();
}
}
const hermesConnectionManager = new HermesConnectionManager();
export {
AcpClient,
HermesConnectionManager,
hermesConnectionManager,
};

View File

@@ -41,10 +41,6 @@ import {
spawnOpenCode,
abortOpenCodeSession,
} from './opencode-cli.js';
import {
spawnHermes,
abortHermesSession,
} from './hermes-cli.js';
import sessionManager from './sessionManager.js';
import {
stripAnsiSequences,
@@ -122,7 +118,6 @@ const wss = createWebSocketServer(server, {
codex: queryCodex,
gemini: spawnGemini,
opencode: spawnOpenCode,
hermes: spawnHermes,
},
abortFns: {
claude: abortClaudeSDKSession,
@@ -130,7 +125,6 @@ const wss = createWebSocketServer(server, {
codex: abortCodexSession,
gemini: abortGeminiSession,
opencode: abortOpenCodeSession,
hermes: abortHermesSession,
},
resolveToolApproval,
getPendingApprovalsForSession,

View File

@@ -1,135 +0,0 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
export class HermesProviderAuth implements IProviderAuth {
private checkInstalled(): boolean {
const cliPath = process.env.HERMES_CLI_PATH || 'hermes acp';
const [command, ...args] = cliPath.trim().split(/\s+/);
try {
const result = spawn.sync(command || 'hermes', [...args, '--version'], { stdio: 'ignore', timeout: 5000 });
return result.error ? false : result.status === 0 || result.status === null;
} catch {
return false;
}
}
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
provider: 'hermes',
installed: false,
authenticated: false,
email: null,
method: null,
error: 'Hermes is not installed',
};
}
const credentials = await this.checkCredentials();
return {
provider: 'hermes',
installed,
authenticated: credentials.authenticated,
email: credentials.email,
method: credentials.method ?? 'managed_by_hermes',
error: undefined,
};
}
private async checkCredentials(): Promise<{ authenticated: boolean; email: string | null; method: string | null }> {
if (this.hasKnownProviderEnv(process.env)) {
return { authenticated: true, email: 'API Key Auth', method: 'env' };
}
const hermesHome = path.join(os.homedir(), '.hermes');
try {
const authJson = readObjectRecord(JSON.parse(await readFile(path.join(hermesHome, 'auth.json'), 'utf8')));
if (
readOptionalString(authJson?.apiKey)
|| readOptionalString(authJson?.api_key)
|| readOptionalString(authJson?.token)
|| readOptionalString(authJson?.access_token)
|| readOptionalString(authJson?.refresh_token)
) {
return {
authenticated: true,
email: readOptionalString(authJson?.email) ?? 'Hermes Auth',
method: 'credentials_file',
};
}
} catch {
// Fall through to dotenv check.
}
try {
const envContent = await readFile(path.join(hermesHome, '.env'), 'utf8');
if (this.hasKnownProviderEnv(this.parseEnvFile(envContent))) {
return { authenticated: true, email: 'API Key Auth', method: 'env_file' };
}
} catch {
// Fall through.
}
try {
const configContent = await readFile(path.join(hermesHome, 'config.yaml'), 'utf8');
if (/^\s*api_key\s*:\s*["']?[^"'#\s]+/m.test(configContent)) {
return { authenticated: true, email: 'Hermes Config', method: 'config_file' };
}
} catch {
// Fall through.
}
return { authenticated: false, email: null, method: null };
}
private parseEnvFile(content: string): Record<string, string> {
const parsed: Record<string, string> = {};
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex <= 0) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
const value = line.slice(separatorIndex + 1).trim().replace(/^['"]|['"]$/g, '');
if (key && value) {
parsed[key] = value;
}
}
return parsed;
}
private hasKnownProviderEnv(env: Record<string, string | undefined>): boolean {
const keys = [
'HERMES_API_KEY',
'NOUS_API_KEY',
'OPENROUTER_API_KEY',
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'GOOGLE_API_KEY',
'GEMINI_API_KEY',
'GLM_API_KEY',
'KIMI_API_KEY',
'MINIMAX_API_KEY',
'MINIMAX_CN_API_KEY',
'HF_TOKEN',
'NVIDIA_API_KEY',
'ARCEEAI_API_KEY',
'OLLAMA_API_KEY',
'KILOCODE_API_KEY',
'GITHUB_TOKEN',
];
return keys.some((key) => Boolean(env[key]?.trim()));
}
}

View File

@@ -1,296 +0,0 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
} from '@/shared/utils.js';
const yamlScalar = (value: unknown): string => {
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (value === null) {
return 'null';
}
return JSON.stringify(String(value));
};
const parseYamlScalar = (value: string): unknown => {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
if (trimmed === 'null') {
return null;
}
if (trimmed === 'true') {
return true;
}
if (trimmed === 'false') {
return false;
}
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith('\'') && trimmed.endsWith('\''))) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed.slice(1, -1);
}
}
if (
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|| (trimmed.startsWith('{') && trimmed.endsWith('}'))
) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed.replace(/\s+#.*$/, '').trim();
};
const getIndent = (line: string): number => line.match(/^\s*/)?.[0].length ?? 0;
const parseYamlArray = (
lines: string[],
startIndex: number,
indent: number,
): { value: unknown[]; nextIndex: number } => {
const value: unknown[] = [];
let index = startIndex;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
if (getIndent(line) !== indent || !line.trimStart().startsWith('- ')) {
break;
}
value.push(parseYamlScalar(line.trimStart().slice(2)));
index += 1;
}
return { value, nextIndex: index };
};
const parseYamlMap = (
lines: string[],
startIndex: number,
indent: number,
): { value: Record<string, unknown>; nextIndex: number } => {
const value: Record<string, unknown> = {};
let index = startIndex;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
const currentIndent = getIndent(line);
if (currentIndent < indent) {
break;
}
if (currentIndent > indent) {
index += 1;
continue;
}
const match = line.slice(indent).match(/^([^:#]+):(?:\s*(.*))?$/);
if (!match) {
index += 1;
continue;
}
const key = match[1].trim();
const raw = match[2]?.trim() ?? '';
if (raw) {
value[key] = parseYamlScalar(raw);
index += 1;
continue;
}
const nextLine = lines[index + 1];
if (nextLine && getIndent(nextLine) > indent && nextLine.trimStart().startsWith('- ')) {
const parsed = parseYamlArray(lines, index + 1, getIndent(nextLine));
value[key] = parsed.value;
index = parsed.nextIndex;
continue;
}
const parsed = parseYamlMap(lines, index + 1, indent + 2);
value[key] = parsed.value;
index = parsed.nextIndex;
}
return { value, nextIndex: index };
};
const readYamlConfig = async (filePath: string): Promise<string> => {
try {
return await readFile(filePath, 'utf8');
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return '';
}
throw error;
}
};
const readMcpServers = async (filePath: string): Promise<Record<string, unknown>> => {
const content = await readYamlConfig(filePath);
const lines = content.split(/\r?\n/);
const sectionIndex = lines.findIndex((line) => /^mcp_servers\s*:\s*$/.test(line));
if (sectionIndex === -1) {
return {};
}
const parsed = parseYamlMap(lines, sectionIndex + 1, 2);
return readObjectRecord(parsed.value) ?? {};
};
const serializeYamlMap = (value: Record<string, unknown>, indent = 0): string[] => {
const lines: string[] = [];
for (const [key, rawValue] of Object.entries(value)) {
if (rawValue === undefined) {
continue;
}
const prefix = `${' '.repeat(indent)}${key}:`;
if (Array.isArray(rawValue)) {
lines.push(prefix);
for (const item of rawValue) {
lines.push(`${' '.repeat(indent + 2)}- ${yamlScalar(item)}`);
}
continue;
}
const nested = readObjectRecord(rawValue);
if (nested) {
lines.push(prefix);
lines.push(...serializeYamlMap(nested, indent + 2));
continue;
}
lines.push(`${prefix} ${yamlScalar(rawValue)}`);
}
return lines;
};
const replaceMcpServersSection = (content: string, servers: Record<string, unknown>): string => {
const lines = content.split(/\r?\n/);
const sectionIndex = lines.findIndex((line) => /^mcp_servers\s*:\s*$/.test(line));
const serialized = ['mcp_servers:', ...serializeYamlMap(servers, 2)];
if (sectionIndex === -1) {
const prefix = content.trimEnd();
return `${prefix ? `${prefix}\n\n` : ''}${serialized.join('\n')}\n`;
}
let endIndex = sectionIndex + 1;
while (endIndex < lines.length) {
const line = lines[endIndex];
if (line.trim() && getIndent(line) === 0) {
break;
}
endIndex += 1;
}
lines.splice(sectionIndex, endIndex - sectionIndex, ...serialized);
return `${lines.join('\n').trimEnd()}\n`;
};
const writeMcpServers = async (filePath: string, servers: Record<string, unknown>): Promise<void> => {
const content = await readYamlConfig(filePath);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, replaceMcpServersSection(content, servers), 'utf8');
};
export class HermesMcpProvider extends McpProvider {
constructor() {
super('hermes', ['user', 'project'], ['stdio', 'http']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.hermes', 'config.yaml')
: path.join(workspacePath, '.hermes', 'config.yaml');
return readMcpServers(filePath);
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.hermes', 'config.yaml')
: path.join(workspacePath, '.hermes', 'config.yaml');
await writeMcpServers(filePath, servers);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
cwd: input.cwd,
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http/sse MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
type: input.transport,
url: input.url,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(scope: McpScope, name: string, rawConfig: unknown): ProviderMcpServer | null {
const config = readObjectRecord(rawConfig);
if (!config) {
return null;
}
if (typeof config.command === 'string') {
return {
provider: 'hermes',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
cwd: readOptionalString(config.cwd),
};
}
if (typeof config.url === 'string') {
return {
provider: 'hermes',
name,
scope,
transport: 'http',
url: config.url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -1,151 +0,0 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { IProviderModels } from '@/shared/interfaces.js';
import type {
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
import { readOptionalString } from '@/shared/utils.js';
export const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
export const HERMES_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{
value: HERMES_CONFIGURED_MODEL,
label: 'Use Hermes default',
description: 'Uses the provider and model selected in Hermes.',
},
],
DEFAULT: HERMES_CONFIGURED_MODEL,
};
const HERMES_CONFIG_PATH = path.join(os.homedir(), '.hermes', 'config.yaml');
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function stripScalar(raw: string): string | null {
let value = raw.trim();
// Drop an unquoted trailing comment.
if (!value.startsWith('"') && !value.startsWith("'")) {
const comment = value.search(/\s#/);
if (comment >= 0) {
value = value.slice(0, comment).trim();
}
}
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
return value.trim() || null;
}
const indentOf = (line: string): number => line.length - line.replace(/^\s+/, '').length;
// Minimal, indentation-aware reader for the flat `key: value` and one-level
// nested (`section:`\n` key: value`) shapes used by ~/.hermes/config.yaml.
// Avoids the fragile single-regex lookahead that could terminate a section
// early and silently miss the configured model.
export function readYamlPath(content: string, pathParts: string[]): string | null {
const lines = content.split(/\r?\n/);
if (pathParts.length === 1) {
const re = new RegExp(`^\\s*${escapeRegex(pathParts[0])}\\s*:\\s*(.*)$`);
for (const line of lines) {
if (!line.trim() || line.trim().startsWith('#')) continue;
const match = line.match(re);
if (match) return stripScalar(match[1]);
}
return null;
}
const [section, key] = pathParts;
const sectionRe = new RegExp(`^(\\s*)${escapeRegex(section)}\\s*:\\s*$`);
const keyRe = new RegExp(`^\\s*${escapeRegex(key)}\\s*:\\s*(.*)$`);
let sectionIndent: number | null = null;
for (const line of lines) {
if (!line.trim() || line.trim().startsWith('#')) continue;
if (sectionIndent === null) {
const match = line.match(sectionRe);
if (match) sectionIndent = match[1].length;
continue;
}
// Left the nested block once indentation returns to the section level or less.
if (indentOf(line) <= sectionIndent) {
sectionIndent = line.match(sectionRe)?.[1].length ?? null;
continue;
}
const match = line.match(keyRe);
if (match) return stripScalar(match[1]);
}
return null;
}
export class HermesProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
const activeModel = await this.readConfiguredModel();
if (!activeModel) {
return HERMES_FALLBACK_MODELS;
}
return {
OPTIONS: [
{
value: HERMES_CONFIGURED_MODEL,
label: 'Use Hermes default',
description: `Uses the provider and model selected in Hermes. Current config: ${activeModel}`,
},
],
DEFAULT: HERMES_CONFIGURED_MODEL,
};
}
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
const configured = await this.readConfiguredModel();
return { model: configured ?? HERMES_CONFIGURED_MODEL };
}
async changeActiveModel(input: ProviderChangeActiveModelInput): Promise<ProviderSessionActiveModelChange> {
if (input.model === HERMES_CONFIGURED_MODEL) {
return {
provider: 'hermes',
sessionId: input.sessionId,
supported: true,
changed: false,
model: null,
};
}
return {
provider: 'hermes',
sessionId: input.sessionId,
supported: false,
changed: false,
model: null,
};
}
private async readConfiguredModel(): Promise<string | null> {
try {
const content = await readFile(HERMES_CONFIG_PATH, 'utf8');
return readOptionalString(readYamlPath(content, ['model', 'default']))
?? readOptionalString(readYamlPath(content, ['model']))
?? null;
} catch {
return null;
}
}
}

View File

@@ -1,110 +0,0 @@
import fsSync from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
import { normalizeSessionName } from '@/shared/utils.js';
type HermesSessionRow = {
id: string;
cwd: string | null;
title: string | null;
started_at: number | null;
ended_at: number | null;
message_count: number | null;
};
const HERMES_DB_PATH = path.join(os.homedir(), '.hermes', 'state.db');
function unixSecondsToIso(value: number | null | undefined): string {
if (!value || !Number.isFinite(value)) {
return new Date().toISOString();
}
return new Date(value * 1000).toISOString();
}
function openHermesDatabase(): Database.Database | null {
if (!fsSync.existsSync(HERMES_DB_PATH)) {
return null;
}
return new Database(HERMES_DB_PATH, { readonly: true, fileMustExist: true });
}
export class HermesSessionSynchronizer implements IProviderSessionSynchronizer {
private readonly provider = 'hermes' as const;
async synchronize(since?: Date): Promise<number> {
const db = openHermesDatabase();
if (!db) {
return 0;
}
try {
const rows = since
? db.prepare(`
SELECT id, cwd, title, started_at, ended_at, message_count
FROM sessions
WHERE COALESCE(ended_at, started_at) >= ?
ORDER BY COALESCE(ended_at, started_at) ASC
`).all(Math.floor(since.getTime() / 1000)) as HermesSessionRow[]
: db.prepare(`
SELECT id, cwd, title, started_at, ended_at, message_count
FROM sessions
ORDER BY COALESCE(ended_at, started_at) ASC
`).all() as HermesSessionRow[];
let processed = 0;
for (const row of rows) {
if (this.upsertRow(row)) {
processed += 1;
}
}
return processed;
} finally {
db.close();
}
}
async synchronizeFile(filePath: string): Promise<string | null> {
if (path.resolve(filePath) !== HERMES_DB_PATH) {
return null;
}
const db = openHermesDatabase();
if (!db) {
return null;
}
try {
const row = db.prepare(`
SELECT id, cwd, title, started_at, ended_at, message_count
FROM sessions
ORDER BY COALESCE(ended_at, started_at) DESC
LIMIT 1
`).get() as HermesSessionRow | undefined;
return row && this.upsertRow(row) ? row.id : null;
} finally {
db.close();
}
}
private upsertRow(row: HermesSessionRow): boolean {
if (!row.id || !row.cwd) {
return false;
}
sessionsDb.createSession(
row.id,
this.provider,
row.cwd,
normalizeSessionName(row.title ?? undefined, 'Untitled Hermes Session'),
unixSecondsToIso(row.started_at),
unixSecondsToIso(row.ended_at ?? row.started_at),
HERMES_DB_PATH,
);
return true;
}
}

View File

@@ -1,381 +0,0 @@
import fsSync from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import {
createNormalizedMessage,
generateMessageId,
normalizeProviderTimestamp,
readObjectRecord,
readOptionalString,
sliceTailPage,
} from '@/shared/utils.js';
const PROVIDER = 'hermes';
const HERMES_DB_PATH = path.join(os.homedir(), '.hermes', 'state.db');
type HermesMessageRow = {
id: number;
role: string;
content: string | null;
tool_call_id: string | null;
tool_calls: string | null;
tool_name: string | null;
timestamp: number;
reasoning: string | null;
reasoning_content: string | null;
finish_reason: string | null;
};
function formatContent(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function readUpdateType(raw: AnyRecord): string {
return readOptionalString(raw.type)
?? readOptionalString(raw.kind)
?? readOptionalString(raw.sessionUpdate)
?? readOptionalString(raw.session_update)
?? readOptionalString(raw.update)
?? readOptionalString(raw.event)
?? '';
}
function readEventSessionId(raw: AnyRecord, sessionId: string | null): string | null {
return readOptionalString(raw.sessionId) ?? readOptionalString(raw.session_id) ?? sessionId;
}
function readTextContent(value: unknown): string | null {
const direct = readOptionalString(value);
if (direct !== undefined) {
return direct;
}
if (Array.isArray(value)) {
const parts = value
.map((entry) => readTextContent(entry))
.filter((entry): entry is string => Boolean(entry?.trim()));
return parts.length > 0 ? parts.join('') : null;
}
const record = readObjectRecord(value);
if (!record) {
return null;
}
const nestedContent = record.content;
const nestedText = nestedContent === value ? null : readTextContent(nestedContent);
return readOptionalString(record.text)
?? readOptionalString(record.content)
?? nestedText
?? readOptionalString(record.delta)
?? readOptionalString(record.rawOutput)
?? readOptionalString(record.raw_output)
?? readOptionalString(record.output)
?? null;
}
function readToolPayload(raw: AnyRecord): AnyRecord {
return readObjectRecord(raw.toolCall)
?? readObjectRecord(raw.tool_call)
?? readObjectRecord(raw.tool)
?? raw;
}
function normalizeHermesEvent(rawMessage: unknown, sessionId: string | null, history = false): NormalizedMessage[] {
const envelope = readObjectRecord(rawMessage);
if (!envelope) {
return [];
}
const nestedUpdate = readObjectRecord(envelope.update);
const raw = nestedUpdate ? { ...nestedUpdate, sessionId: envelope.sessionId ?? envelope.session_id ?? sessionId } : envelope;
const type = readUpdateType(raw);
const eventSessionId = readEventSessionId(raw, sessionId);
const timestamp = normalizeProviderTimestamp(raw.timestamp ?? raw.time ?? raw.createdAt ?? raw.created_at);
const baseId = readOptionalString(raw.id) ?? readOptionalString(raw.messageId) ?? readOptionalString(raw.message_id) ?? generateMessageId(PROVIDER);
if (['agent_message_chunk', 'assistant_message_chunk', 'message_delta', 'text_delta', 'text'].includes(type)) {
const content = readTextContent(raw.content)
?? readOptionalString(raw.text)
?? readOptionalString(raw.delta)
?? readTextContent(readObjectRecord(raw.message)?.content)
?? '';
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: history ? 'text' : 'stream_delta',
role: history ? 'assistant' : undefined,
content,
})];
}
if (['agent_message', 'assistant_message', 'message'].includes(type)) {
const role = readOptionalString(raw.role) === 'user' ? 'user' : 'assistant';
const content = readTextContent(raw.content)
?? readOptionalString(raw.text)
?? readTextContent(readObjectRecord(raw.message)?.content)
?? '';
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: history ? 'text' : role === 'assistant' ? 'stream_delta' : 'text',
role: history || role === 'user' ? role : undefined,
content,
})];
}
if (['agent_thought_chunk', 'thought_delta', 'thinking', 'reasoning'].includes(type)) {
const content = readTextContent(raw.content) ?? readOptionalString(raw.text) ?? readOptionalString(raw.delta) ?? '';
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'thinking',
content,
})];
}
if (['tool_call', 'tool_use', 'tool_call_start'].includes(type)) {
const tool = readToolPayload(raw);
const toolId = readOptionalString(raw.toolCallId)
?? readOptionalString(raw.tool_call_id)
?? readOptionalString(raw.toolId)
?? readOptionalString(tool.toolCallId)
?? readOptionalString(tool.tool_call_id)
?? readOptionalString(tool.toolId)
?? readOptionalString(tool.id)
?? baseId;
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_use',
toolName: readOptionalString(raw.toolName)
?? readOptionalString(raw.tool_name)
?? readOptionalString(raw.title)
?? readOptionalString(raw.name)
?? readOptionalString(tool?.name)
?? readOptionalString(tool?.title)
?? 'Tool',
toolInput: raw.rawInput
?? raw.raw_input
?? raw.input
?? raw.arguments
?? raw.params
?? tool?.rawInput
?? tool?.raw_input
?? tool?.input
?? tool?.arguments
?? {},
toolId,
})];
}
if (['tool_call_update', 'tool_result', 'tool_call_result', 'tool_call_done'].includes(type)) {
const tool = readToolPayload(raw);
const content = readTextContent(raw.content)
?? readTextContent(raw.rawOutput)
?? readTextContent(raw.raw_output)
?? readTextContent(raw.output)
?? readTextContent(raw.result)
?? readTextContent(tool.rawOutput)
?? readTextContent(tool.raw_output)
?? readTextContent(tool.output)
?? readTextContent(tool.result)
?? '';
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_result',
toolId: readOptionalString(raw.toolCallId)
?? readOptionalString(raw.tool_call_id)
?? readOptionalString(raw.toolId)
?? readOptionalString(tool.toolCallId)
?? readOptionalString(tool.tool_call_id)
?? readOptionalString(tool.toolId)
?? readOptionalString(tool.id)
?? '',
content: content || formatContent(raw.delta ?? ''),
isError: Boolean(raw.error) || raw.status === 'error' || raw.status === 'failed',
toolUseResult: raw.result ?? raw.output ?? raw.rawOutput ?? raw.raw_output ?? tool.result ?? tool.output ?? tool.rawOutput ?? tool.raw_output,
})];
}
if (type === 'plan') {
const content = readOptionalString(raw.content) ?? readOptionalString(raw.text) ?? formatContent(raw.plan);
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'status',
text: 'plan',
summary: content,
})];
}
if (type === 'error') {
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'error',
content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown Hermes error',
})];
}
return [];
}
function parseJsonArray(value: string | null): unknown[] {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function readHermesHistoryFromDatabase(sessionId: string): NormalizedMessage[] {
const normalized: NormalizedMessage[] = [];
if (!fsSync.existsSync(HERMES_DB_PATH)) {
return normalized;
}
const db = new Database(HERMES_DB_PATH, { readonly: true, fileMustExist: true });
try {
const rows = db.prepare(`
SELECT id, role, content, tool_call_id, tool_calls, tool_name, timestamp, reasoning, reasoning_content, finish_reason
FROM messages
WHERE session_id = ? AND active = 1
ORDER BY timestamp ASC, id ASC
`).all(sessionId) as HermesMessageRow[];
for (const row of rows) {
const timestamp = new Date(row.timestamp * 1000).toISOString();
const baseId = `hermes-${sessionId}-${row.id}`;
const reasoning = row.reasoning_content || row.reasoning;
if (reasoning?.trim()) {
normalized.push(createNormalizedMessage({
id: `${baseId}-thinking`,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'thinking',
content: reasoning,
}));
}
for (const toolCall of parseJsonArray(row.tool_calls)) {
const call = readObjectRecord(toolCall);
const fn = readObjectRecord(call?.function);
normalized.push(createNormalizedMessage({
id: `${baseId}-tool-${readOptionalString(call?.id) ?? normalized.length}`,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_use',
toolName: readOptionalString(fn?.name) ?? readOptionalString(call?.name) ?? 'Tool',
toolInput: fn?.arguments ?? call?.arguments ?? {},
toolId: readOptionalString(call?.id) ?? `${baseId}-tool`,
}));
}
if (row.role === 'tool') {
normalized.push(createNormalizedMessage({
id: `${baseId}-result`,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_result',
toolId: row.tool_call_id ?? '',
content: row.content ?? '',
isError: row.finish_reason === 'error',
}));
continue;
}
if (row.content?.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'text',
role: row.role === 'user' ? 'user' : 'assistant',
content: row.content,
}));
}
}
} finally {
db.close();
}
return normalized;
}
export class HermesSessionsProvider implements IProviderSessions {
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
return normalizeHermesEvent(rawMessage, sessionId);
}
async fetchHistory(sessionId: string, options: FetchHistoryOptions = {}): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
const row = sessionsDb.getSessionById(sessionId) ?? sessionsDb.getSessionByProviderSessionId(sessionId);
const messages = readHermesHistoryFromDatabase(row?.provider_session_id ?? sessionId);
const start = Math.max(0, offset);
const pageLimit = limit === null ? null : Math.max(0, limit);
const page = sliceTailPage(messages, pageLimit, start);
return {
messages: page.page,
total: messages.length,
hasMore: page.hasMore,
offset: start,
limit: pageLimit,
};
}
}

View File

@@ -1,181 +0,0 @@
import os from 'node:os';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
import type {
ProviderSkillRegistryActionResult,
ProviderSkillRegistryInstallInput,
ProviderSkillRegistrySearchOptions,
ProviderSkillRegistrySearchResult,
ProviderSkillSource,
} from '@/shared/types.js';
import { AppError, addUniqueProviderSkillSource, readObjectRecord, readOptionalString } from '@/shared/utils.js';
const execFileAsync = promisify(execFile);
const HERMES_COMMAND =
(process.env.HERMES_COMMAND_PATH || process.env.HERMES_CLI_PATH || 'hermes').trim().split(/\s+/)[0] || 'hermes';
const HERMES_SKILLS_TIMEOUT_MS = 45_000;
const HERMES_SKILLS_MAX_BUFFER = 1024 * 1024 * 8;
function normalizeSearchResult(value: unknown): ProviderSkillRegistrySearchResult | null {
const record = readObjectRecord(value);
if (!record) {
return null;
}
const name = readOptionalString(record.name);
const identifier = readOptionalString(record.identifier);
if (!name || !identifier) {
return null;
}
return {
name,
identifier,
source: readOptionalString(record.source) ?? undefined,
trustLevel: readOptionalString(record.trust_level) ?? readOptionalString(record.trustLevel) ?? undefined,
description: readOptionalString(record.description) ?? undefined,
};
}
export class HermesSkillsProvider extends SkillsProvider {
constructor() {
super('hermes');
}
async searchRegistry(
query: string,
options: ProviderSkillRegistrySearchOptions = {},
): Promise<ProviderSkillRegistrySearchResult[]> {
const normalizedQuery = query.trim();
if (!normalizedQuery) {
return [];
}
const args = ['skills', 'search', normalizedQuery, '--json'];
const source = options.source?.trim();
if (source) {
args.push('--source', source);
}
if (options.limit && Number.isFinite(options.limit)) {
args.push('--limit', String(Math.max(1, Math.min(Math.floor(options.limit), 50))));
}
const result = await this.runHermes(args);
try {
const parsed = JSON.parse(result.stdout);
return Array.isArray(parsed)
? parsed.map(normalizeSearchResult).filter((entry): entry is ProviderSkillRegistrySearchResult => Boolean(entry))
: [];
} catch (error) {
throw new AppError('Hermes returned invalid skill search JSON.', {
code: 'HERMES_SKILL_SEARCH_PARSE_FAILED',
statusCode: 502,
details: error instanceof Error ? error.message : String(error),
});
}
}
async installRegistrySkill(input: ProviderSkillRegistryInstallInput): Promise<ProviderSkillRegistryActionResult> {
const identifier = input.identifier.trim();
if (!identifier) {
throw new AppError('identifier is required.', {
code: 'HERMES_SKILL_IDENTIFIER_REQUIRED',
statusCode: 400,
});
}
const args = ['skills', 'install', identifier, '--yes'];
if (input.category?.trim()) {
args.push('--category', input.category.trim());
}
if (input.name?.trim()) {
args.push('--name', input.name.trim());
}
if (input.force) {
args.push('--force');
}
return this.runHermes(args);
}
async uninstallRegistrySkill(name: string): Promise<ProviderSkillRegistryActionResult> {
const normalizedName = name.trim();
if (!normalizedName) {
throw new AppError('name is required.', {
code: 'HERMES_SKILL_NAME_REQUIRED',
statusCode: 400,
});
}
return this.runHermes(['skills', 'uninstall', normalizedName]);
}
async checkRegistryUpdates(): Promise<ProviderSkillRegistryActionResult> {
return this.runHermes(['skills', 'check']);
}
async updateRegistrySkills(): Promise<ProviderSkillRegistryActionResult> {
return this.runHermes(['skills', 'update']);
}
async auditRegistrySkills(): Promise<ProviderSkillRegistryActionResult> {
return this.runHermes(['skills', 'audit']);
}
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
const sources: ProviderSkillSource[] = [];
const seenRootDirs = new Set<string>();
addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'repo',
rootDir: path.join(workspacePath, '.hermes', 'skills'),
commandPrefix: '/',
recursive: true,
});
addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'user',
rootDir: path.join(os.homedir(), '.hermes', 'skills'),
commandPrefix: '/',
recursive: true,
});
return sources;
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.hermes', 'skills'),
commandPrefix: '/',
recursive: true,
};
}
private async runHermes(args: string[]): Promise<ProviderSkillRegistryActionResult> {
try {
const { stdout, stderr } = await execFileAsync(HERMES_COMMAND, args, {
timeout: HERMES_SKILLS_TIMEOUT_MS,
maxBuffer: HERMES_SKILLS_MAX_BUFFER,
env: process.env,
});
return { ok: true, stdout, stderr };
} catch (error) {
const maybeError = error as Error & {
stdout?: string;
stderr?: string;
code?: number | string;
};
throw new AppError(maybeError.stderr || maybeError.message || 'Hermes skill command failed.', {
code: 'HERMES_SKILL_COMMAND_FAILED',
statusCode: 502,
details: {
exitCode: maybeError.code,
stdout: maybeError.stdout,
stderr: maybeError.stderr,
},
});
}
}
}

View File

@@ -1,27 +0,0 @@
import { HermesProviderAuth } from '@/modules/providers/list/hermes/hermes-auth.provider.js';
import { HermesMcpProvider } from '@/modules/providers/list/hermes/hermes-mcp.provider.js';
import { HermesProviderModels } from '@/modules/providers/list/hermes/hermes-models.provider.js';
import { HermesSessionSynchronizer } from '@/modules/providers/list/hermes/hermes-session-synchronizer.provider.js';
import { HermesSessionsProvider } from '@/modules/providers/list/hermes/hermes-sessions.provider.js';
import { HermesSkillsProvider } from '@/modules/providers/list/hermes/hermes-skills.provider.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import type {
IProviderAuth,
IProviderModels,
IProviderSessionSynchronizer,
IProviderSkills,
IProviderSessions,
} from '@/shared/interfaces.js';
export class HermesProvider extends AbstractProvider {
readonly models: IProviderModels = new HermesProviderModels();
readonly mcp = new HermesMcpProvider();
readonly auth: IProviderAuth = new HermesProviderAuth();
readonly skills: IProviderSkills = new HermesSkillsProvider();
readonly sessions: IProviderSessions = new HermesSessionsProvider();
readonly sessionSynchronizer: IProviderSessionSynchronizer = new HermesSessionSynchronizer();
constructor() {
super('hermes');
}
}

View File

@@ -2,7 +2,6 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
import { HermesProvider } from '@/modules/providers/list/hermes/hermes.provider.js';
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
import type { IProvider } from '@/shared/interfaces.js';
import type { LLMProvider } from '@/shared/types.js';
@@ -14,7 +13,6 @@ const providers: Record<LLMProvider, IProvider> = {
cursor: new CursorProvider(),
gemini: new GeminiProvider(),
opencode: new OpenCodeProvider(),
hermes: new HermesProvider(),
};
/**

View File

@@ -279,48 +279,6 @@ const parseProviderSkillCreatePayload = (payload: unknown): ProviderSkillCreateI
return { entries };
};
const parseSkillRegistryLimit = (value: unknown): number => {
const raw = readOptionalQueryString(value);
if (!raw) {
return 10;
}
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed)) {
throw new AppError('limit must be a valid integer.', {
code: 'INVALID_QUERY_PARAMETER',
statusCode: 400,
});
}
return Math.max(1, Math.min(parsed, 50));
};
const parseSkillRegistryInstallPayload = (payload: unknown) => {
if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const body = payload as Record<string, unknown>;
const identifier = readOptionalQueryString(body.identifier);
if (!identifier) {
throw new AppError('identifier is required.', {
code: 'SKILL_IDENTIFIER_REQUIRED',
statusCode: 400,
});
}
return {
identifier,
category: readOptionalQueryString(body.category),
name: readOptionalQueryString(body.name),
force: body.force === true,
};
};
const parseProvider = (value: unknown): LLMProvider => {
const normalized = normalizeProviderParam(value);
if (
@@ -329,7 +287,6 @@ const parseProvider = (value: unknown): LLMProvider => {
|| normalized === 'cursor'
|| normalized === 'gemini'
|| normalized === 'opencode'
|| normalized === 'hermes'
) {
return normalized;
}
@@ -484,77 +441,6 @@ router.delete(
}),
);
router.get(
'/:provider/skills/registry/search',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const query = readOptionalQueryString(req.query.query);
if (!query) {
throw new AppError('query is required.', {
code: 'SKILL_SEARCH_QUERY_REQUIRED',
statusCode: 400,
});
}
const results = await providerSkillsService.searchSkillRegistry(provider, query, {
source: readOptionalQueryString(req.query.source),
limit: parseSkillRegistryLimit(req.query.limit),
});
res.json(createApiSuccessResponse({ provider, results }));
}),
);
router.post(
'/:provider/skills/registry/install',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const result = await providerSkillsService.installRegistrySkill(
provider,
parseSkillRegistryInstallPayload(req.body),
);
res.status(201).json(createApiSuccessResponse({ provider, result }));
}),
);
router.post(
'/:provider/skills/registry/check',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const result = await providerSkillsService.checkRegistryUpdates(provider);
res.json(createApiSuccessResponse({ provider, result }));
}),
);
router.post(
'/:provider/skills/registry/update',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const result = await providerSkillsService.updateRegistrySkills(provider);
res.json(createApiSuccessResponse({ provider, result }));
}),
);
router.post(
'/:provider/skills/registry/audit',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const result = await providerSkillsService.auditRegistrySkills(provider);
res.json(createApiSuccessResponse({ provider, result }));
}),
);
router.delete(
'/:provider/skills/registry/:name',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const result = await providerSkillsService.uninstallRegistrySkill(
provider,
readPathParam(req.params.name, 'name'),
);
res.json(createApiSuccessResponse({ provider, result }));
}),
);
// ----------------- MCP routes -----------------
router.get(
'/:provider/mcp/servers',

View File

@@ -75,15 +75,6 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsPermissionRequests: false,
supportsTokenUsage: true,
},
hermes: {
provider: 'hermes',
permissionModes: ['default'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: true,
supportsTokenUsage: false,
},
};
/**

View File

@@ -23,7 +23,6 @@ export const sessionSynchronizerService = {
cursor: 0,
gemini: 0,
opencode: 0,
hermes: 0,
};
const failures: string[] = [];

View File

@@ -39,10 +39,6 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
provider: 'opencode',
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
},
{
provider: 'hermes',
rootPath: path.join(os.homedir(), '.hermes'),
},
];
const WATCHER_IGNORED_PATTERNS = [
@@ -85,10 +81,6 @@ function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
return path.basename(filePath) === 'opencode.db';
}
if (provider === 'hermes') {
return path.basename(filePath) === 'state.db';
}
if (provider === 'gemini') {
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
}

View File

@@ -4,29 +4,7 @@ import type {
ProviderSkillCreateInput,
ProviderSkillListOptions,
ProviderSkillRemoveInput,
ProviderSkillRegistryActionResult,
ProviderSkillRegistryInstallInput,
ProviderSkillRegistrySearchOptions,
ProviderSkillRegistrySearchResult,
} from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
const getProviderSkills = (providerName: string) => providerRegistry.resolveProvider(providerName).skills;
const requireSkillRegistryMethod = <TMethod extends keyof ReturnType<typeof getProviderSkills>>(
providerName: string,
methodName: TMethod,
): NonNullable<ReturnType<typeof getProviderSkills>[TMethod]> => {
const skills = getProviderSkills(providerName);
const method = skills[methodName];
if (typeof method !== 'function') {
throw new AppError(`${providerName} does not support skill registry operations.`, {
code: 'PROVIDER_SKILL_REGISTRY_UNSUPPORTED',
statusCode: 400,
});
}
return method as NonNullable<ReturnType<typeof getProviderSkills>[TMethod]>;
};
export const providerSkillsService = {
/**
@@ -36,7 +14,8 @@ export const providerSkillsService = {
providerName: string,
options?: ProviderSkillListOptions,
): Promise<ProviderSkill[]> {
return getProviderSkills(providerName).listSkills(options);
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.listSkills(options);
},
/**
@@ -46,44 +25,8 @@ export const providerSkillsService = {
providerName: string,
input: ProviderSkillCreateInput,
): Promise<ProviderSkill[]> {
return getProviderSkills(providerName).addSkills(input);
},
async searchSkillRegistry(
providerName: string,
query: string,
options?: ProviderSkillRegistrySearchOptions,
): Promise<ProviderSkillRegistrySearchResult[]> {
const searchRegistry = requireSkillRegistryMethod(providerName, 'searchRegistry');
return searchRegistry.call(getProviderSkills(providerName), query, options);
},
async installRegistrySkill(
providerName: string,
input: ProviderSkillRegistryInstallInput,
): Promise<ProviderSkillRegistryActionResult> {
const installRegistrySkill = requireSkillRegistryMethod(providerName, 'installRegistrySkill');
return installRegistrySkill.call(getProviderSkills(providerName), input);
},
async uninstallRegistrySkill(providerName: string, name: string): Promise<ProviderSkillRegistryActionResult> {
const uninstallRegistrySkill = requireSkillRegistryMethod(providerName, 'uninstallRegistrySkill');
return uninstallRegistrySkill.call(getProviderSkills(providerName), name);
},
async checkRegistryUpdates(providerName: string): Promise<ProviderSkillRegistryActionResult> {
const checkRegistryUpdates = requireSkillRegistryMethod(providerName, 'checkRegistryUpdates');
return checkRegistryUpdates.call(getProviderSkills(providerName));
},
async updateRegistrySkills(providerName: string): Promise<ProviderSkillRegistryActionResult> {
const updateRegistrySkills = requireSkillRegistryMethod(providerName, 'updateRegistrySkills');
return updateRegistrySkills.call(getProviderSkills(providerName));
},
async auditRegistrySkills(providerName: string): Promise<ProviderSkillRegistryActionResult> {
const auditRegistrySkills = requireSkillRegistryMethod(providerName, 'auditRegistrySkills');
return auditRegistrySkills.call(getProviderSkills(providerName));
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.addSkills(input);
},
async removeProviderSkill(

View File

@@ -341,7 +341,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
workspacePath,
});
assert.equal(globalResult.length, 6);
assert.equal(globalResult.length, 5);
assert.ok(globalResult.every((entry) => entry.created === true));
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
@@ -356,11 +356,6 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);
const hermesProject = await fs.readFile(path.join(workspacePath, '.hermes', 'config.yaml'), 'utf8');
assert.match(hermesProject, /^mcp_servers:\n/m);
assert.match(hermesProject, /^\s+global-http:\n/m);
assert.match(hermesProject, /^\s+url: "https:\/\/global\.example\.com\/mcp"\n/m);
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
@@ -382,3 +377,4 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
await fs.rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -109,12 +109,6 @@ function resolveResumeSessionId(
return resolvedSessionId;
}
function getHermesShellCommand(): string {
return (process.env.HERMES_COMMAND_PATH || process.env.HERMES_CLI_PATH || 'hermes')
.trim()
.split(/\s+/)[0] || 'hermes';
}
/**
* Resolves provider command line for plain shell and agent-backed shell modes.
*/
@@ -167,14 +161,6 @@ function buildShellCommand(
return initialCommand || 'opencode';
}
if (provider === 'hermes') {
const command = initialCommand || getHermesShellCommand();
if (resumeSessionId) {
return `${command} --resume "${resumeSessionId}"`;
}
return command;
}
const command = initialCommand || 'claude';
if (resumeSessionId) {
if (os.platform() === 'win32') {
@@ -495,8 +481,6 @@ export function handleShellConnection(
? 'Gemini'
: provider === 'opencode'
? 'OpenCode'
: provider === 'hermes'
? 'Hermes'
: 'Claude';
welcomeMsg = hasSession && resumeSessionId
? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n`

View File

@@ -10,14 +10,12 @@ import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js';
import { spawnGemini } from '../gemini-cli.js';
import { spawnOpenCode } from '../opencode-cli.js';
import { spawnHermes } from '../hermes-cli.js';
import { Octokit } from '@octokit/rest';
import { providerModelsService } from '../modules/providers/services/provider-models.service.js';
import { IS_PLATFORM } from '../constants/config.js';
import { normalizeProjectPath } from '../shared/utils.js';
const router = express.Router();
const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
/**
* Middleware to authenticate agent API requests.
@@ -638,7 +636,7 @@ class ResponseCollector {
* - Source for auto-generated branch names (if createBranch=true and no branchName)
* - Fallback for PR title if no commits are made
*
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' | 'hermes'
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode'
* Default: 'claude'
*
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
@@ -756,7 +754,7 @@ class ResponseCollector {
* Input Validations (400 Bad Request):
* - Either githubUrl OR projectPath must be provided (not neither)
* - message must be non-empty string
* - provider must be 'claude', 'cursor', 'codex', 'gemini', 'opencode', or 'hermes'
* - provider must be 'claude', 'cursor', 'codex', 'gemini', or 'opencode'
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
* - branchName must pass Git naming rules (if provided)
*
@@ -864,8 +862,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
return res.status(400).json({ error: 'message is required' });
}
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", "opencode", or "hermes"' });
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' });
}
// Validate GitHub branch/PR creation requirements
@@ -998,15 +996,6 @@ router.post('/', validateExternalApiKey, async (req, res) => {
sessionId: sessionId || null,
model: model || opencodeModels.DEFAULT
}, writer);
} else if (provider === 'hermes') {
console.log('Starting Hermes ACP session');
await spawnHermes(message.trim(), {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model === HERMES_CONFIGURED_MODEL ? undefined : model
}, writer);
}
// Handle GitHub branch and PR creation after successful agent completion

View File

@@ -15,7 +15,7 @@ const APP_ROOT = findAppRoot(__dirname);
const router = express.Router();
const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode", "hermes"];
const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode"];
const MODEL_PROVIDER_LABELS = {
claude: "Claude",
@@ -23,7 +23,6 @@ const MODEL_PROVIDER_LABELS = {
codex: "Codex",
gemini: "Gemini",
opencode: "OpenCode",
hermes: "Hermes",
};
const readModelProvider = (value) => {

View File

@@ -5,10 +5,6 @@ import type {
McpScope,
NormalizedMessage,
ProviderSkill,
ProviderSkillRegistryActionResult,
ProviderSkillRegistryInstallInput,
ProviderSkillRegistrySearchOptions,
ProviderSkillRegistrySearchResult,
ProviderSkillListOptions,
ProviderAuthStatus,
ProviderChangeActiveModelInput,
@@ -120,21 +116,6 @@ export interface IProviderSkills {
removeSkill(
input: ProviderSkillRemoveInput,
): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }>;
searchRegistry?(
query: string,
options?: ProviderSkillRegistrySearchOptions,
): Promise<ProviderSkillRegistrySearchResult[]>;
installRegistrySkill?(input: ProviderSkillRegistryInstallInput): Promise<ProviderSkillRegistryActionResult>;
uninstallRegistrySkill?(name: string): Promise<ProviderSkillRegistryActionResult>;
checkRegistryUpdates?(): Promise<ProviderSkillRegistryActionResult>;
updateRegistrySkills?(): Promise<ProviderSkillRegistryActionResult>;
auditRegistrySkills?(): Promise<ProviderSkillRegistryActionResult>;
}
// ---------------------------

View File

@@ -1,83 +0,0 @@
const pendingApprovals = new Map();
const APPROVAL_MAX_AGE_MS = 30 * 60 * 1000;
// Drop approvals whose run died without resolving them (WS disconnect, process
// crash) so their captured payloads/closures don't accumulate unbounded.
function sweepExpiredApprovals(now = Date.now()) {
for (const [requestId, entry] of pendingApprovals) {
const receivedAt = entry.receivedAt instanceof Date ? entry.receivedAt.getTime() : 0;
if (receivedAt && now - receivedAt > APPROVAL_MAX_AGE_MS) {
pendingApprovals.delete(requestId);
}
}
}
function clearApprovalsForSession(sessionId) {
if (!sessionId) {
return;
}
for (const [requestId, entry] of pendingApprovals) {
if (entry.sessionId === sessionId) {
pendingApprovals.delete(requestId);
}
}
}
function registerApproval(requestId, { resolver, sessionId = null, provider = null, meta = {} } = {}) {
if (!requestId || typeof resolver !== 'function') {
return;
}
sweepExpiredApprovals();
pendingApprovals.set(requestId, {
resolver,
sessionId,
provider,
meta,
receivedAt: meta.receivedAt || meta._receivedAt || new Date(),
});
}
function unregisterApproval(requestId) {
pendingApprovals.delete(requestId);
}
function resolveToolApproval(requestId, decision) {
const entry = pendingApprovals.get(requestId);
if (!entry) {
return false;
}
entry.resolver(decision);
return true;
}
function getPendingApprovalsForSession(sessionId) {
const pending = [];
for (const [requestId, entry] of pendingApprovals.entries()) {
if (entry.sessionId !== sessionId) {
continue;
}
pending.push({
requestId,
toolName: entry.meta.toolName || entry.meta._toolName || 'UnknownTool',
input: entry.meta.input ?? entry.meta._input,
context: entry.meta.context ?? entry.meta._context,
sessionId,
provider: entry.provider,
receivedAt: entry.receivedAt,
});
}
return pending;
}
export {
registerApproval,
unregisterApproval,
resolveToolApproval,
getPendingApprovalsForSession,
clearApprovalsForSession,
};

View File

@@ -65,7 +65,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
* Use this as the source of truth whenever a function or payload needs to identify
* a specific LLM integration.
*/
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'hermes';
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
/**
* One selectable model row in a provider model catalog.
@@ -365,32 +365,6 @@ export type ProviderSkillRemoveInput = {
directoryName: string;
};
export type ProviderSkillRegistrySearchOptions = {
source?: string;
limit?: number;
};
export type ProviderSkillRegistrySearchResult = {
name: string;
identifier: string;
source?: string;
trustLevel?: string;
description?: string;
};
export type ProviderSkillRegistryInstallInput = {
identifier: string;
category?: string;
name?: string;
force?: boolean;
};
export type ProviderSkillRegistryActionResult = {
ok: boolean;
stdout: string;
stderr: string;
};
/**
* Normalized skill record returned by provider skill adapters.
*

View File

@@ -1,3 +1,5 @@
import { AlertCircle } from 'lucide-react';
type AuthErrorAlertProps = {
errorMessage: string;
};
@@ -8,8 +10,9 @@ export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {
}
return (
<div className="rounded-md border border-red-300 bg-red-100 p-3 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
<div className="flex items-start gap-2.5 rounded-xl border border-destructive/30 bg-destructive/10 p-3 text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<p className="text-sm leading-relaxed">{errorMessage}</p>
</div>
);
}

View File

@@ -1,3 +1,7 @@
import { useState } from 'react';
import type { ComponentType } from 'react';
import { Eye, EyeOff } from 'lucide-react';
type AuthInputFieldProps = {
id: string;
label: string;
@@ -8,13 +12,14 @@ type AuthInputFieldProps = {
type?: 'text' | 'password' | 'email';
name?: string;
autoComplete?: string;
icon?: ComponentType<{ className?: string }>;
};
/**
* A labelled input field for authentication forms.
* Renders a `<label>` / `<input>` pair and forwards browser autofill hints
* (`name`, `autoComplete`) so that password managers can identify and fill
* the field correctly.
* the field correctly. Password fields gain a show/hide visibility toggle.
*/
export default function AuthInputField({
id,
@@ -26,24 +31,49 @@ export default function AuthInputField({
type = 'text',
name,
autoComplete,
icon: Icon,
}: AuthInputFieldProps) {
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const isPasswordField = type === 'password';
const resolvedType = isPasswordField && isPasswordVisible ? 'text' : type;
return (
<div>
<label htmlFor={id} className="mb-1 block text-sm font-medium text-foreground">
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-foreground">
{label}
</label>
<input
id={id}
type={type}
name={name ?? id}
autoComplete={autoComplete}
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={placeholder}
required
disabled={isDisabled}
/>
<div className="group relative">
{Icon && (
<Icon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground transition-colors group-focus-within:text-primary" />
)}
<input
id={id}
type={resolvedType}
name={name ?? id}
autoComplete={autoComplete}
value={value}
onChange={(event) => onChange(event.target.value)}
className={`w-full rounded-xl border border-border bg-background/60 py-2.5 text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 hover:border-foreground/20 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60 ${
Icon ? 'pl-10' : 'pl-3.5'
} ${isPasswordField ? 'pr-11' : 'pr-3.5'}`}
placeholder={placeholder}
required
disabled={isDisabled}
/>
{isPasswordField && (
<button
type="button"
onClick={() => setIsPasswordVisible((previous) => !previous)}
disabled={isDisabled}
tabIndex={-1}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
className="absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-60"
>
{isPasswordVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
)}
</div>
</div>
);
}

View File

@@ -1,30 +1,30 @@
import { MessageSquare } from 'lucide-react';
const loadingDotAnimationDelays = ['0s', '0.1s', '0.2s'];
const loadingDotAnimationDelays = ['0s', '0.15s', '0.3s'];
export default function AuthLoadingScreen() {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="text-center">
<div className="mb-4 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
<MessageSquare className="h-8 w-8 text-primary-foreground" />
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
<div aria-hidden className="pointer-events-none absolute inset-0">
<div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
</div>
<div className="relative text-center">
<div className="mb-5 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-primary/80 shadow-lg shadow-primary/25 ring-1 ring-inset ring-white/20">
<img src="/logo.svg" alt="CloudCLI" className="h-9 w-9" />
</div>
</div>
<h1 className="mb-2 text-2xl font-bold text-foreground">CloudCLI</h1>
<h1 className="mb-4 font-serif text-2xl font-bold tracking-tight text-foreground">CloudCLI</h1>
<div className="flex items-center justify-center space-x-2">
<div className="flex items-center justify-center gap-2">
{loadingDotAnimationDelays.map((delay) => (
<div
key={delay}
className="h-2 w-2 animate-bounce rounded-full bg-blue-500"
className="h-2 w-2 animate-bounce rounded-full bg-primary"
style={{ animationDelay: delay }}
/>
))}
</div>
<p className="mt-2 text-muted-foreground">Loading...</p>
</div>
</div>
);

View File

@@ -1,5 +1,4 @@
import type { ReactNode } from 'react';
import { MessageSquare } from 'lucide-react';
import { IS_PLATFORM } from '../../../constants/config';
type AuthScreenLayoutProps = {
@@ -18,29 +17,38 @@ export default function AuthScreenLayout({
logo,
}: AuthScreenLayoutProps) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="space-y-6 rounded-lg border border-border bg-card p-8 shadow-lg">
<div className="relative h-screen overflow-y-auto bg-background">
{/* Ambient, on-brand backdrop that gives the screen depth without
competing with the card content. Fixed so it stays put while the
form scrolls on short viewports. */}
<div aria-hidden className="pointer-events-none fixed inset-0">
<div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
<div className="absolute -bottom-32 -left-24 h-[26rem] w-[26rem] rounded-full bg-primary/5 blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(hsl(var(--foreground)/0.04)_1px,transparent_1px)] [background-size:22px_22px] opacity-60" />
</div>
<div className="relative mx-auto flex min-h-full w-full max-w-md items-center justify-center p-4 py-8">
<div className="w-full rounded-2xl border border-border/70 bg-card/90 p-8 shadow-[0_24px_60px_-20px_hsl(var(--foreground)/0.18)] ring-1 ring-foreground/5 backdrop-blur-xl sm:p-10">
<div className="text-center">
<div className="mb-4 flex justify-center">
<div className="mb-5 flex justify-center">
{logo ?? (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
<MessageSquare className="h-8 w-8 text-primary-foreground" />
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-primary/80 shadow-lg shadow-primary/25 ring-1 ring-inset ring-white/20">
<img src="/logo.svg" alt="CloudCLI" className="h-9 w-9" />
</div>
)}
</div>
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
<p className="mt-2 text-muted-foreground">{description}</p>
<h1 className="font-serif text-3xl font-bold tracking-tight text-foreground">{title}</h1>
<p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-muted-foreground">{description}</p>
</div>
{children}
<div className="mt-8">{children}</div>
<div className="text-center">
<p className="text-sm text-muted-foreground">{footerText}</p>
<div className="mt-6 border-t border-border/60 pt-5 text-center">
<p className="text-xs leading-relaxed text-muted-foreground">{footerText}</p>
</div>
{!IS_PLATFORM && (
<div className="flex items-center justify-center gap-1.5 pt-2">
<div className="mt-4 flex items-center justify-center gap-1.5">
<svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import type { FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Lock, User } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField';
@@ -69,6 +70,7 @@ export default function LoginForm() {
placeholder={t('login.placeholders.username')}
isDisabled={isSubmitting}
autoComplete="username"
icon={User}
/>
<AuthInputField
@@ -80,6 +82,7 @@ export default function LoginForm() {
isDisabled={isSubmitting}
type="password"
autoComplete="current-password"
icon={Lock}
/>
<AuthErrorAlert errorMessage={errorMessage} />
@@ -87,9 +90,16 @@ export default function LoginForm() {
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 hover:shadow-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:ring-offset-2 focus:ring-offset-card active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? t('login.loading') : t('login.submit')}
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('login.loading')}
</>
) : (
t('login.submit')
)}
</button>
</form>
</AuthScreenLayout>

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import type { FormEvent } from 'react';
import { Loader2, Lock, ShieldCheck, User } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField';
@@ -85,7 +86,6 @@ export default function SetupForm() {
title="Welcome to CloudCLI"
description="Set up your account to get started"
footerText="This is a single-user system. Only one account can be created."
logo={<img src="/logo.svg" alt="CloudCLI" className="h-16 w-16" />}
>
<form onSubmit={handleSubmit} className="space-y-4">
<AuthInputField
@@ -94,9 +94,10 @@ export default function SetupForm() {
label="Username"
value={formState.username}
onChange={(value) => updateField('username', value)}
placeholder="Enter your username"
placeholder="Choose a username"
isDisabled={isSubmitting}
autoComplete="username"
icon={User}
/>
<AuthInputField
@@ -105,10 +106,11 @@ export default function SetupForm() {
label="Password"
value={formState.password}
onChange={(value) => updateField('password', value)}
placeholder="Enter your password"
placeholder="Create a password"
isDisabled={isSubmitting}
type="password"
autoComplete="new-password"
icon={Lock}
/>
<AuthInputField
@@ -117,20 +119,33 @@ export default function SetupForm() {
label="Confirm Password"
value={formState.confirmPassword}
onChange={(value) => updateField('confirmPassword', value)}
placeholder="Confirm your password"
placeholder="Re-enter your password"
isDisabled={isSubmitting}
type="password"
autoComplete="new-password"
icon={ShieldCheck}
/>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5" />
At least 3 characters for username, 6 for password.
</p>
<AuthErrorAlert errorMessage={errorMessage} />
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 hover:shadow-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:ring-offset-2 focus:ring-offset-card active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? 'Setting up...' : 'Create Account'}
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Setting up...
</>
) : (
'Create Account'
)}
</button>
</form>
</AuthScreenLayout>

View File

@@ -39,7 +39,6 @@ interface UseChatComposerStateArgs {
codexModel: string;
geminiModel: string;
opencodeModel: string;
hermesModel: string;
isLoading: boolean;
canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null;
@@ -337,8 +336,6 @@ export function useChatComposerState({
? geminiModel
: provider === 'opencode'
? opencodeModel
: provider === 'hermes'
? undefined
: claudeModel,
tokenUsage: tokenBudget,
};
@@ -706,8 +703,6 @@ export function useChatComposerState({
? 'gemini-settings'
: provider === 'opencode'
? 'opencode-settings'
: provider === 'hermes'
? 'hermes-settings'
: 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
@@ -734,8 +729,6 @@ export function useChatComposerState({
? geminiModel
: provider === 'opencode'
? opencodeModel
: provider === 'hermes'
? undefined
: claudeModel;
// One message shape for every provider. The backend resolves the

View File

@@ -15,7 +15,6 @@ const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
codex: 'gpt-5.4',
gemini: 'gemini-3.1-pro-preview',
opencode: 'anthropic/claude-sonnet-4-5',
hermes: '__hermes_configured_model__',
};
/**
@@ -30,7 +29,6 @@ const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
codex: ['default', 'acceptEdits', 'bypassPermissions'],
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
opencode: ['default'],
hermes: ['default'],
};
type ProviderCapabilities = {
@@ -95,9 +93,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [opencodeModel, setOpenCodeModel] = useState<string>(() => {
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
});
const [hermesModel, setHermesModel] = useState<string>(() => {
return localStorage.getItem('hermes-model') || FALLBACK_DEFAULT_MODEL.hermes;
});
/**
* Backend-owned capability matrix keyed by provider. Drives the permission
@@ -146,20 +141,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return;
}
if (targetProvider === 'opencode') {
setOpenCodeModel(model);
localStorage.setItem('opencode-model', model);
return;
}
if (targetProvider === 'hermes') {
setHermesModel(model);
localStorage.setItem('hermes-model', model);
}
setOpenCodeModel(model);
localStorage.setItem('opencode-model', model);
}, []);
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
const requestId = providerModelsRequestIdRef.current + 1;
providerModelsRequestIdRef.current = requestId;
const isHardRefresh = options.bypassCache === true;
@@ -337,19 +324,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}
}, [providerModelCatalog.opencode, opencodeModel]);
useEffect(() => {
const hermes = providerModelCatalog.hermes;
if (hermes) {
const next = pickStoredOrCurrent('hermes-model', hermesModel, hermes);
if (next !== hermesModel) {
setHermesModel(next);
}
if (localStorage.getItem('hermes-model') !== next) {
localStorage.setItem('hermes-model', next);
}
}
}, [providerModelCatalog.hermes, hermesModel]);
useEffect(() => {
if (!selectedSession?.id) {
return;
@@ -417,15 +391,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
model: string,
sessionId?: string | null,
) => {
if (targetProvider === 'hermes') {
setStoredProviderModel(targetProvider, model);
return {
scope: 'default' as const,
changed: false,
model,
};
}
const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
if (!normalizedSessionId) {
setStoredProviderModel(targetProvider, model);
@@ -469,8 +434,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
permissionMode,
setPermissionMode,
pendingPermissionRequests,

View File

@@ -18,7 +18,6 @@ interface UseChatSessionStateArgs {
selectedSession: ProjectSession | null;
ws: WebSocket | null;
sendMessage: (message: unknown) => void;
autoScrollToBottom?: boolean;
externalMessageUpdate?: number;
newSessionTrigger?: number;
processingSessions?: SessionActivityMap;
@@ -96,7 +95,6 @@ export function useChatSessionState({
selectedSession,
ws,
sendMessage,
autoScrollToBottom,
externalMessageUpdate,
newSessionTrigger,
processingSessions,
@@ -121,6 +119,7 @@ export function useChatSessionState({
const [viewHiddenCount, setViewHiddenCount] = useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const wasNearTopRef = useRef(false);
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
const searchScrollActiveRef = useRef(false);
const isLoadingSessionRef = useRef(false);
@@ -185,6 +184,7 @@ export function useChatSessionState({
setShowLoadAllOverlay(false);
setViewHiddenCount(0);
setSearchTarget(null);
wasNearTopRef.current = false;
searchScrollActiveRef.current = false;
topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null;
@@ -336,12 +336,34 @@ export function useChatSessionState({
const slot = await sessionStore.fetchMore(selectedSession.id, {
limit: MESSAGES_PER_PAGE,
});
if (!slot || slot.serverMessages.length === 0) return false;
if (!slot) return false;
if (slot.serverMessages.length === 0) {
if (!slot.hasMore) {
setHasMoreMessages(false);
allMessagesLoadedRef.current = true;
setAllMessagesLoaded(true);
if (loadAllOverlayTimerRef.current) {
clearTimeout(loadAllOverlayTimerRef.current);
loadAllOverlayTimerRef.current = null;
}
setShowLoadAllOverlay(false);
}
return false;
}
pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop };
setHasMoreMessages(slot.hasMore);
setTotalMessages(slot.total);
setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE);
if (!slot.hasMore) {
allMessagesLoadedRef.current = true;
setAllMessagesLoaded(true);
if (loadAllOverlayTimerRef.current) {
clearTimeout(loadAllOverlayTimerRef.current);
loadAllOverlayTimerRef.current = null;
}
setShowLoadAllOverlay(false);
}
return true;
} finally {
isLoadingMoreRef.current = false;
@@ -357,8 +379,25 @@ export function useChatSessionState({
const nearBottom = isNearBottom();
setIsUserScrolledUp(!nearBottom);
const scrolledNearTop = container.scrollTop < 100;
// "Load all" prompt: appear (with fade-in) when the user reaches the top
if (scrolledNearTop && hasMoreMessages && !allMessagesLoadedRef.current) {
if (!wasNearTopRef.current) {
wasNearTopRef.current = true;
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
setShowLoadAllOverlay(true);
loadAllOverlayTimerRef.current = setTimeout(() => {
setShowLoadAllOverlay(false);
loadAllOverlayTimerRef.current = null;
}, 2500);
}
} else if (!scrolledNearTop) {
wasNearTopRef.current = false;
}
if (!allMessagesLoadedRef.current) {
const scrolledNearTop = container.scrollTop < 100;
if (!scrolledNearTop) { topLoadLockRef.current = false; return; }
if (topLoadLockRef.current) {
if (container.scrollTop > 20) topLoadLockRef.current = false;
@@ -367,7 +406,7 @@ export function useChatSessionState({
const didLoad = await loadOlderMessages(container);
if (didLoad) topLoadLockRef.current = true;
}
}, [isNearBottom, loadOlderMessages]);
}, [hasMoreMessages, isNearBottom, loadOlderMessages]);
useLayoutEffect(() => {
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
@@ -386,6 +425,7 @@ export function useChatSessionState({
}
topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null;
wasNearTopRef.current = false;
setIsUserScrolledUp(false);
}, [selectedProject?.projectId, selectedSession?.id]);
@@ -492,6 +532,7 @@ export function useChatSessionState({
setLoadAllJustFinished(false);
setShowLoadAllOverlay(false);
setViewHiddenCount(0);
wasNearTopRef.current = false;
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
@@ -546,7 +587,7 @@ export function useChatSessionState({
if (!isProcessing) {
await sessionStore.refreshFromServer(selectedSession.id);
if (Boolean(autoScrollToBottom) && isNearBottom()) {
if (isNearBottom()) {
setTimeout(() => scrollToBottom(), 200);
}
}
@@ -557,7 +598,6 @@ export function useChatSessionState({
reloadExternalMessages();
}, [
autoScrollToBottom,
externalMessageUpdate,
isNearBottom,
scrollToBottom,
@@ -689,10 +729,9 @@ export function useChatSessionState({
}, [chatMessages, visibleMessageCount]);
useEffect(() => {
if (!autoScrollToBottom && scrollContainerRef.current) {
const container = scrollContainerRef.current;
scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
}
const container = scrollContainerRef.current;
if (!container) return;
scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
});
useEffect(() => {
@@ -700,8 +739,8 @@ export function useChatSessionState({
if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return;
if (searchScrollActiveRef.current) return;
if (autoScrollToBottom) {
if (!isUserScrolledUp) setTimeout(() => scrollToBottom(), 50);
if (!isUserScrolledUp) {
setTimeout(() => scrollToBottom(), 50);
return;
}
@@ -711,7 +750,7 @@ export function useChatSessionState({
const newHeight = container.scrollHeight;
const heightDiff = newHeight - prevHeight;
if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff;
}, [autoScrollToBottom, chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
}, [chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
useEffect(() => {
const container = scrollContainerRef.current;
@@ -720,23 +759,8 @@ export function useChatSessionState({
return () => container.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
// "Load all" overlay
const prevLoadingRef = useRef(false);
useEffect(() => {
const wasLoading = prevLoadingRef.current;
prevLoadingRef.current = isLoadingMoreMessages;
if (wasLoading && !isLoadingMoreMessages && hasMoreMessages) {
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
setShowLoadAllOverlay(true);
loadAllOverlayTimerRef.current = setTimeout(() => setShowLoadAllOverlay(false), 2000);
}
if (!hasMoreMessages && !isLoadingMoreMessages) {
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
setShowLoadAllOverlay(false);
}
return () => { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); };
}, [isLoadingMoreMessages, hasMoreMessages]);
// "Load all" overlay visibility is driven by scroll-to-top in handleScroll;
// timers are cleared on session change via the reset effect above.
const loadAllMessages = useCallback(async () => {
if (!selectedSession || !selectedProject) return;
@@ -746,6 +770,10 @@ export function useChatSessionState({
isLoadingMoreRef.current = true;
setIsLoadingAllMessages(true);
setShowLoadAllOverlay(true);
if (loadAllOverlayTimerRef.current) {
clearTimeout(loadAllOverlayTimerRef.current);
loadAllOverlayTimerRef.current = null;
}
const container = scrollContainerRef.current;
const previousScrollHeight = container ? container.scrollHeight : 0;
@@ -772,7 +800,11 @@ export function useChatSessionState({
setLoadAllJustFinished(true);
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000);
loadAllFinishedTimerRef.current = setTimeout(() => {
setLoadAllJustFinished(false);
setShowLoadAllOverlay(false);
loadAllFinishedTimerRef.current = null;
}, 2500);
} else {
allMessagesLoadedRef.current = false;
setShowLoadAllOverlay(false);

View File

@@ -24,7 +24,6 @@ interface ToolRendererProps {
onFileOpen?: (filePath: string, diffInfo?: any) => void;
createDiff?: (oldStr: string, newStr: string) => DiffLine[];
selectedProject?: Project | null;
autoExpandTools?: boolean;
showRawParameters?: boolean;
rawToolInput?: string;
isSubagentContainer?: boolean;
@@ -80,7 +79,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
onFileOpen,
createDiff,
selectedProject,
autoExpandTools = false,
showRawParameters = false,
rawToolInput,
isSubagentContainer,
@@ -151,8 +149,8 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
output={output}
isError={Boolean(toolResult?.isError)}
status={toolStatus !== 'completed' ? toolStatus : undefined}
// Commands stay collapsed by default (even consecutive ones); only
// failures auto-expand so they remain visible.
// Commands stay collapsed by default; only failures auto-expand so they
// remain visible.
defaultOpen={false}
/>
);
@@ -199,7 +197,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
<PlanDisplay
title={title}
content={contentProps.content || ''}
defaultOpen={displayConfig.defaultOpen ?? autoExpandTools}
defaultOpen={displayConfig.defaultOpen ?? false}
isStreaming={isStreaming}
showRawParameters={mode === 'input' && showRawParameters}
rawContent={rawToolInput}
@@ -216,7 +214,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
const defaultOpen = displayConfig.defaultOpen !== undefined
? displayConfig.defaultOpen
: autoExpandTools;
: false;
const contentProps = displayConfig.getContentProps?.(parsedData, {
selectedProject,

View File

@@ -229,7 +229,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
isSelected
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
: 'dark:hover:bg-gray-750/50 border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600 dark:hover:bg-gray-700/40'
}`}
>
{/* Keyboard hint */}
@@ -277,7 +277,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
isOtherOn
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
: 'dark:hover:bg-gray-750/50 border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
: 'border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600 dark:hover:bg-gray-700/40'
}`}
>
<kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${

View File

@@ -126,10 +126,8 @@ export interface ChatInterfaceProps {
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
onShowSettings?: () => void;
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
autoScrollToBottom?: boolean;
sendByCtrlEnter?: boolean;
externalMessageUpdate?: number;
newSessionTrigger?: number;

View File

@@ -1,6 +1,6 @@
import type { ChatMessage } from '../types/types';
export const TOOL_GROUP_THRESHOLD = 3;
export const TOOL_GROUP_THRESHOLD = 2;
export interface ToolGroupItem {
_isGroup: true;
@@ -19,7 +19,17 @@ function isGroupableToolMessage(message: ChatMessage): message is ChatMessage &
return Boolean(message.isToolUse && message.toolName && !message.isSubagentContainer);
}
export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[] {
// Messages that render nothing (e.g. reasoning hidden when showThinking is off)
// shouldn't split an otherwise-continuous run of the same tool — providers like
// Codex interleave hidden reasoning between consecutive tool calls.
function rendersNothing(message: ChatMessage, showThinking: boolean): boolean {
return Boolean(message.isThinking && !showThinking);
}
export function groupConsecutiveTools(
messages: ChatMessage[],
showThinking: boolean = true,
): MessageListItem[] {
const items: MessageListItem[] = [];
let index = 0;
@@ -35,13 +45,22 @@ export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[
const run: ChatMessage[] = [message];
let nextIndex = index + 1;
while (
nextIndex < messages.length &&
isGroupableToolMessage(messages[nextIndex]) &&
messages[nextIndex].toolName === message.toolName
) {
run.push(messages[nextIndex]);
nextIndex += 1;
while (nextIndex < messages.length) {
const candidate = messages[nextIndex];
// Skip invisible interleaved messages so they don't break the run.
if (rendersNothing(candidate, showThinking)) {
nextIndex += 1;
continue;
}
if (isGroupableToolMessage(candidate) && candidate.toolName === message.toolName) {
run.push(candidate);
nextIndex += 1;
continue;
}
break;
}
if (run.length >= TOOL_GROUP_THRESHOLD) {

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { ArrowDownIcon } from 'lucide-react';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useWebSocket } from '../../../contexts/WebSocketContext';
@@ -30,10 +31,8 @@ function ChatInterface({
onNavigateToSession,
onSessionEstablished,
onShowSettings,
autoExpandTools,
showRawParameters,
showThinking,
autoScrollToBottom,
sendByCtrlEnter,
externalMessageUpdate,
newSessionTrigger,
@@ -75,8 +74,6 @@ function ChatInterface({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
permissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
@@ -126,7 +123,6 @@ function ChatInterface({
selectedSession,
ws,
sendMessage,
autoScrollToBottom,
externalMessageUpdate,
newSessionTrigger,
processingSessions,
@@ -187,7 +183,7 @@ function ChatInterface({
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
isInputFocused: _isInputFocused,
isInputFocused,
commandModalPayload,
closeCommandModal,
showCostModal,
@@ -203,7 +199,6 @@ function ChatInterface({
codexModel,
geminiModel,
opencodeModel,
hermesModel,
isLoading: isProcessing,
canAbortSession,
tokenBudget,
@@ -296,9 +291,7 @@ function ChatInterface({
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: provider === 'hermes'
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
: t('messageTypes.claude');
: t('messageTypes.claude');
return (
<div className="flex h-full items-center justify-center">
@@ -339,8 +332,6 @@ function ChatInterface({
setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel}
hermesModel={hermesModel}
setHermesModel={setHermesModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
tasksEnabled={tasksEnabled}
@@ -363,13 +354,26 @@ function ChatInterface({
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={handleGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
/>
<ChatComposer
<div className="relative flex-shrink-0">
{isUserScrolledUp && chatMessages.length > 0 && (
<div className="pointer-events-none absolute -top-11 left-0 right-0 z-20 flex justify-center">
<button
type="button"
onClick={scrollToBottomAndReset}
className="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
>
<ArrowDownIcon className="h-4 w-4" />
</button>
</div>
)}
<ChatComposer
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
@@ -384,9 +388,6 @@ function ChatInterface({
onToggleCommandMenu={handleToggleCommandMenu}
hasInput={Boolean(input.trim())}
onClearInput={handleClearInput}
isUserScrolledUp={isUserScrolledUp}
hasMessages={chatMessages.length > 0}
onScrollToBottom={scrollToBottomAndReset}
onSubmit={handleSubmit}
isDragActive={isDragActive}
attachedImages={attachedImages}
@@ -421,6 +422,7 @@ function ChatInterface({
onTextareaPaste={handlePaste}
onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput}
isInputFocused={isInputFocused}
onInputFocusChange={handleInputFocusChange}
placeholder={t('input.placeholder', {
provider:
@@ -432,13 +434,12 @@ function ChatInterface({
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: provider === 'hermes'
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
: t('messageTypes.claude'),
: t('messageTypes.claude'),
})}
isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter}
/>
</div>
</div>
<QuickSettingsPanel />

View File

@@ -7,6 +7,7 @@ import type { SessionActivity } from '../../../../hooks/useSessionProtection';
type ActivityIndicatorProps = {
activity: SessionActivity | null;
onAbort?: () => void;
isInputFocused?: boolean;
};
const ACTION_KEYS = [
@@ -18,6 +19,7 @@ const ACTION_KEYS = [
'claudeStatus.actions.reasoning',
];
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const EXIT_ANIMATION_MS = 220;
/**
* Minimal response-in-progress indicator, in the spirit of the inline status
@@ -26,11 +28,31 @@ const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working',
* session has an entry in the processing map; it disappears the instant that
* entry is removed.
*/
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
export default function ActivityIndicator({ activity, onAbort, isInputFocused = false }: ActivityIndicatorProps) {
const { t } = useTranslation('chat');
const startedAt = activity?.startedAt ?? null;
const [renderedActivity, setRenderedActivity] = useState<SessionActivity | null>(activity);
const [isExiting, setIsExiting] = useState(false);
const startedAt = renderedActivity?.startedAt ?? null;
const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => {
if (activity) {
setRenderedActivity(activity);
setIsExiting(false);
return;
}
if (!renderedActivity) return;
setIsExiting(true);
const timer = setTimeout(() => {
setRenderedActivity(null);
setIsExiting(false);
}, EXIT_ANIMATION_MS);
return () => clearTimeout(timer);
}, [activity, renderedActivity]);
useEffect(() => {
if (startedAt === null) return;
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
@@ -39,10 +61,10 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
return () => clearInterval(timer);
}, [startedAt]);
if (!activity) return null;
if (!renderedActivity) return null;
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
const label = (renderedActivity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
.replace(/\.+$/, '');
const minutes = Math.floor(elapsedSeconds / 60);
@@ -50,19 +72,31 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
const elapsedLabel = minutes < 1
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
const tabSurfaceClassName = [
'chat-activity-tab inline-flex h-8 items-center rounded-b-none rounded-t-lg border border-b-0 bg-card px-3 text-xs transition-all duration-200',
isInputFocused
? 'border-primary/30 shadow-[0_-1px_2px_hsl(var(--foreground)/0.08),1px_0_2px_hsl(var(--foreground)/0.06),-1px_0_2px_hsl(var(--foreground)/0.06)]'
: 'border-border/50 shadow-[0_-1px_1px_hsl(var(--foreground)/0.04),1px_0_1px_hsl(var(--foreground)/0.03),-1px_0_1px_hsl(var(--foreground)/0.03)]',
].join(' ');
return (
<div className="animate-in fade-in mb-2 w-full duration-300">
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
<Shimmer className="text-xs font-medium">{`${label}`}</Shimmer>
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
<div
className={`pointer-events-none bg-transparent ${
isExiting ? 'chat-activity-exit' : 'chat-activity-enter'
}`}
>
<div className="flex items-end justify-between gap-2">
<div className={`${tabSurfaceClassName} gap-2`}>
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
<Shimmer className="font-medium">{`${label}`}</Shimmer>
<span className="tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
</div>
{activity.canInterrupt && onAbort && (
{renderedActivity.canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
className={`${tabSurfaceClassName} pointer-events-auto gap-1.5 text-muted-foreground hover:bg-card hover:text-destructive`}
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
>
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>

View File

@@ -11,7 +11,7 @@ import type {
RefObject,
TouchEvent,
} from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react';
import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react';
import { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
@@ -68,9 +68,6 @@ interface ChatComposerProps {
onToggleCommandMenu: () => void;
hasInput: boolean;
onClearInput: () => void;
isUserScrolledUp: boolean;
hasMessages: boolean;
onScrollToBottom: () => void;
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
isDragActive: boolean;
attachedImages: File[];
@@ -101,6 +98,7 @@ interface ChatComposerProps {
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
isInputFocused?: boolean;
onInputFocusChange?: (focused: boolean) => void;
placeholder: string;
isTextareaExpanded: boolean;
@@ -122,9 +120,6 @@ export default function ChatComposer({
onToggleCommandMenu,
hasInput,
onClearInput,
isUserScrolledUp,
hasMessages,
onScrollToBottom,
onSubmit,
isDragActive,
attachedImages,
@@ -155,6 +150,7 @@ export default function ChatComposer({
onTextareaPaste,
onTextareaScrollSync,
onTextareaInput,
isInputFocused = false,
onInputFocusChange,
placeholder,
isTextareaExpanded,
@@ -201,15 +197,18 @@ export default function ChatComposer({
// Hide the thinking/status bar while any permission request is pending
const hasPendingPermissions = pendingPermissionRequests.length > 0;
const hasActivityIndicator = Boolean(activity && !hasPendingPermissions);
return (
<div className="chat-composer-shell flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
<div className="chat-composer-shell relative flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6">
{!hasPendingPermissions && (
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 translate-y-px bg-transparent sm:w-[calc(100%-2rem)]">
<ActivityIndicator activity={activity} onAbort={onAbortSession} isInputFocused={isInputFocused} />
</div>
)}
{pendingPermissionRequests.length > 0 && (
<div className="mx-auto mb-3 max-w-4xl">
<div className="mx-auto mb-3 max-w-3xl">
<PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
@@ -218,19 +217,7 @@ export default function ChatComposer({
</div>
)}
{!hasQuestionPanel && <div className="relative mx-auto max-w-4xl">
{isUserScrolledUp && hasMessages && (
<div className="absolute -top-10 left-0 right-0 z-10 flex justify-center">
<button
type="button"
onClick={onScrollToBottom}
className="flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
>
<ArrowDownIcon className="h-4 w-4" />
</button>
</div>
)}
{!hasQuestionPanel && <div className="relative mx-auto max-w-3xl">
{showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
{filteredFiles.map((file, index) => (
@@ -271,7 +258,10 @@ export default function ChatComposer({
<PromptInput
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
status={isLoading ? 'streaming' : 'ready'}
className={isTextareaExpanded ? 'chat-input-expanded' : ''}
className={[
isTextareaExpanded ? 'chat-input-expanded' : '',
hasActivityIndicator ? 'rounded-t-none' : '',
].filter(Boolean).join(' ')}
{...getRootProps()}
>
{isDragActive && (
@@ -349,7 +339,7 @@ export default function ChatComposer({
<button
type="button"
onClick={onModeSwitch}
className={`rounded-lg border p-2 text-xs font-medium transition-all duration-200 sm:px-2.5 sm:py-1 ${
className={`inline-flex h-8 items-center rounded-lg border px-2 text-xs font-medium transition-all duration-200 sm:px-2.5 ${
permissionMode === 'default'
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
: permissionMode === 'acceptEdits'

View File

@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { memo, useCallback, useMemo, useRef } from 'react';
import { memo, useCallback, useMemo } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react';
import type { ChatMessage } from '../../types/types';
@@ -15,6 +15,7 @@ import { groupConsecutiveTools, isToolGroupItem } from '../../utils/toolGrouping
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import ToolGroupContainer from './ToolGroupContainer';
import LoadAllMessagesOverlay from './LoadAllMessagesOverlay';
interface ChatMessagesPaneProps {
scrollContainerRef: RefObject<HTMLDivElement>;
@@ -39,8 +40,6 @@ interface ChatMessagesPaneProps {
setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
hermesModel: string;
setHermesModel: (model: string) => void;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelsLoading: boolean;
tasksEnabled: boolean;
@@ -63,7 +62,6 @@ interface ChatMessagesPaneProps {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject: Project;
@@ -91,8 +89,6 @@ function ChatMessagesPane({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
providerModelCatalog,
providerModelsLoading,
tasksEnabled,
@@ -115,48 +111,59 @@ function ChatMessagesPane({
onFileOpen,
onShowSettings,
onGrantToolPermission,
autoExpandTools,
showRawParameters,
showThinking,
selectedProject,
}: ChatMessagesPaneProps) {
const { t } = useTranslation('chat');
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
const allocatedKeysRef = useRef<Set<string>>(new Set());
const generatedMessageKeyCounterRef = useRef(0);
const groupedVisibleMessages = useMemo(() => groupConsecutiveTools(visibleMessages), [visibleMessages]);
const groupedVisibleMessages = useMemo(
() => groupConsecutiveTools(visibleMessages, Boolean(showThinking)),
[visibleMessages, showThinking],
);
// Keep keys stable across prepends so existing MessageComponent instances retain local state.
const getMessageKey = useCallback((message: ChatMessage) => {
const existingKey = messageKeyMapRef.current.get(message);
if (existingKey) {
return existingKey;
// Stable, deterministic keys for the messages rendered this pass.
//
// `normalizedToChatMessages` rebuilds fresh ChatMessage objects on every store
// update, so caching keys by object identity (or via a cross-render allocation
// Set) minted a brand-new key for the *same* logical message on each prepend —
// remounting the whole list, which disconnects the scroll-restore anchor and
// reflows heights, jumping the viewport to the bottom. Deriving keys purely
// from this render's ordered messages (intrinsic key, disambiguated by
// occurrence index on collision) yields the same key for the same message
// order, so React preserves existing DOM nodes and component state on prepend.
const messageKeyMap = useMemo(() => {
const keys = new WeakMap<ChatMessage, string>();
const occurrences = new Map<string, number>();
const assign = (message: ChatMessage) => {
const intrinsicKey = getIntrinsicMessageKey(message) ?? 'message-generated';
const seen = occurrences.get(intrinsicKey) ?? 0;
occurrences.set(intrinsicKey, seen + 1);
keys.set(message, seen === 0 ? intrinsicKey : `${intrinsicKey}__${seen}`);
};
for (const item of groupedVisibleMessages) {
if (isToolGroupItem(item)) {
item.messages.forEach(assign);
} else {
assign(item);
}
}
return keys;
}, [groupedVisibleMessages]);
const intrinsicKey = getIntrinsicMessageKey(message);
let candidateKey = intrinsicKey;
if (!candidateKey || allocatedKeysRef.current.has(candidateKey)) {
do {
generatedMessageKeyCounterRef.current += 1;
candidateKey = intrinsicKey
? `${intrinsicKey}-${generatedMessageKeyCounterRef.current}`
: `message-generated-${generatedMessageKeyCounterRef.current}`;
} while (allocatedKeysRef.current.has(candidateKey));
}
allocatedKeysRef.current.add(candidateKey);
messageKeyMapRef.current.set(message, candidateKey);
return candidateKey;
}, []);
const getMessageKey = useCallback(
(message: ChatMessage) =>
messageKeyMap.get(message) ?? getIntrinsicMessageKey(message) ?? 'message-generated',
[messageKeyMap],
);
return (
<div
ref={scrollContainerRef}
onWheel={onWheel}
onTouchMove={onTouchMove}
className="chat-messages-pane relative min-h-0 flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
className="chat-messages-pane relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-3 sm:py-4"
>
<div className="mx-auto w-full max-w-3xl space-y-3 px-4 sm:space-y-4">
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center space-x-2">
@@ -181,8 +188,6 @@ function ChatMessagesPane({
setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel}
hermesModel={hermesModel}
setHermesModel={setHermesModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
tasksEnabled={tasksEnabled}
@@ -214,35 +219,13 @@ function ChatMessagesPane({
</div>
)}
{/* Floating "Load all messages" overlay */}
{(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && (
<div className="pointer-events-none sticky top-2 z-20 flex justify-center">
{loadAllJustFinished ? (
<div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
<span>{t('session.messages.allLoaded')}</span>
</div>
) : (
<button
className="pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600"
onClick={loadAllMessages}
disabled={isLoadingAllMessages}
>
{isLoadingAllMessages && (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
)}
<span>
{isLoadingAllMessages
? t('session.messages.loadingAll')
: <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</>
}
</span>
</button>
)}
</div>
)}
<LoadAllMessagesOverlay
showLoadAllOverlay={showLoadAllOverlay}
isLoadingAllMessages={isLoadingAllMessages}
loadAllJustFinished={loadAllJustFinished}
totalMessages={totalMessages}
onLoadAllMessages={loadAllMessages}
/>
{/* Legacy message count indicator (for non-paginated view) */}
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
@@ -279,7 +262,6 @@ function ChatMessagesPane({
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
@@ -300,7 +282,6 @@ function ChatMessagesPane({
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
@@ -311,6 +292,7 @@ function ChatMessagesPane({
})()}
</>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import type { CSSProperties, ReactElement } from 'react';
import {
CornerDownLeft,
Folder,
@@ -77,6 +78,7 @@ const namespaceAccentClasses: Record<string, string> = {
const MENU_EDGE_GAP = 16;
const MENU_MAX_HEIGHT = 360;
const MENU_MIN_HEIGHT = 160;
const getCommandKey = (command: CommandMenuCommand) =>
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
@@ -92,8 +94,9 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
if (typeof window === 'undefined') {
return { position: 'fixed', top: '16px', left: '16px' };
}
const maxAnchorBottom = Math.max(MENU_EDGE_GAP, window.innerHeight - MENU_EDGE_GAP - MENU_MIN_HEIGHT);
if (window.innerWidth < 640) {
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
return {
position: 'fixed',
bottom: `${anchorBottom}px`,
@@ -104,7 +107,7 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
};
}
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
const clampedLeft = Math.max(
MENU_EDGE_GAP,
Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP),
@@ -216,12 +219,14 @@ export default function CommandMenu({
: ['builtin', 'skill', 'project', 'user', 'other'];
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
const renderInPortal = (node: ReactElement) =>
typeof document === 'undefined' ? node : createPortal(node, document.body);
if (commands.length === 0) {
return (
return renderInPortal(
<div
ref={menuRef}
className="command-menu command-menu-empty border border-gray-200 bg-white/95 text-sm text-gray-500 dark:border-gray-700/80 dark:bg-gray-900/95 dark:text-gray-400"
className="command-menu command-menu-empty border border-border bg-popover/95 text-sm text-muted-foreground"
style={{
...menuBaseStyle,
...menuPosition,
@@ -237,20 +242,20 @@ export default function CommandMenu({
);
}
return (
return renderInPortal(
<div
ref={menuRef}
role="listbox"
aria-label="Available commands"
className="command-menu border border-gray-200/90 bg-white/95 text-gray-900 dark:border-slate-700/80 dark:bg-slate-950/95 dark:text-slate-100"
className="command-menu border border-border bg-popover/95 text-popover-foreground"
style={{ ...menuBaseStyle, ...menuPosition, opacity: 1, transform: 'translateY(0)' }}
>
{orderedNamespaces.map((namespace) => (
<div key={namespace} className="command-group">
{orderedNamespaces.length > 1 && (
<div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400">
<div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
<span>{namespaceLabels[namespace] || namespace}</span>
<span className="rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] text-gray-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
<span className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{(groupedCommands[namespace] || []).length}
</span>
</div>
@@ -268,15 +273,15 @@ export default function CommandMenu({
aria-selected={isSelected}
className={`command-item group relative mb-1 flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-all ${
isSelected
? 'border-sky-200 bg-sky-50 shadow-sm dark:border-cyan-400/30 dark:bg-cyan-400/10'
: 'border-transparent bg-transparent hover:border-gray-200 hover:bg-gray-50/90 dark:hover:border-slate-700 dark:hover:bg-slate-900/80'
? 'border-primary/30 bg-primary/10 shadow-sm'
: 'border-transparent bg-transparent hover:border-border hover:bg-accent'
}`}
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
onMouseDown={(event) => event.preventDefault()}
>
{isSelected && (
<span className="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-sky-500 dark:bg-cyan-300" />
<span className="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-primary" />
)}
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${accentClass}`}>
<NamespaceIcon aria-hidden="true" size={14} strokeWidth={2.2} />
@@ -284,20 +289,20 @@ export default function CommandMenu({
<div className="min-w-0 flex-1 pr-1">
<div className={`flex min-w-0 items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
<span
className="min-w-0 truncate font-mono text-[13px] font-semibold text-gray-950 dark:text-slate-50"
className="min-w-0 truncate font-mono text-[13px] font-semibold text-foreground"
title={command.name}
>
{command.name}
</span>
{command.metadata?.type && (
<span className="command-metadata-badge shrink-0 rounded border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
<span className="command-metadata-badge shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground shadow-sm">
{command.metadata.type}
</span>
)}
</div>
{command.description && (
<div
className="truncate whitespace-nowrap text-[12px] leading-4 text-gray-500 dark:text-slate-400"
className="truncate whitespace-nowrap text-[12px] leading-4 text-muted-foreground"
title={command.description}
>
{command.description}
@@ -305,7 +310,7 @@ export default function CommandMenu({
)}
</div>
{isSelected && (
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-sky-200 bg-white text-sky-600 shadow-sm dark:border-cyan-400/30 dark:bg-slate-950 dark:text-cyan-200">
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-primary/30 bg-card text-primary shadow-sm">
<CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} />
</span>
)}

View File

@@ -63,7 +63,6 @@ const PROVIDER_LABELS: Record<string, string> = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
hermes: 'Hermes',
};
const FALLBACK_COMMANDS: CommandEntry[] = [
@@ -566,46 +565,41 @@ export default function CommandResultModal({
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
<div
className={`relative shrink-0 overflow-hidden border-b border-border/70 bg-gradient-to-br from-primary/15 via-background to-muted/40 ${
isModelsModal ? 'px-4 pb-3 pt-3 sm:px-5 sm:pb-4 sm:pt-4' : 'px-4 pb-4 pt-4 sm:px-6 sm:pb-5 sm:pt-5'
className={`flex shrink-0 items-start justify-between gap-3 border-b border-border bg-popover ${
isModelsModal ? 'px-4 py-3 sm:px-5 sm:py-4' : 'px-4 py-4 sm:px-6 sm:py-5'
}`}
>
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-primary/20 blur-3xl" />
<div className="pointer-events-none absolute right-0 top-0 h-full w-1/2 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.16),transparent_58%)]" />
<div className="relative flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3 sm:items-center">
<div
className={`rounded-2xl border border-primary/30 bg-primary/10 text-primary shadow-sm ${
isModelsModal ? 'p-2.5' : 'p-3'
}`}
>
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
</div>
<div className="min-w-0">
<p className="text-[12px] font-bold uppercase tracking-[0.22em] text-primary">
{activeMeta?.eyebrow}
</p>
<p className={`mt-1 font-semibold tracking-tight text-foreground ${isModelsModal ? 'text-xl sm:text-2xl' : 'text-xl sm:text-2xl'}`}>
{activeMeta?.title}
</p>
<p className={`mt-1 max-w-2xl ${isModelsModal ? 'text-sm leading-5 text-foreground/75' : 'text-sm leading-5 text-muted-foreground'}`}>
{activeMeta?.subtitle}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:bg-background/70 hover:text-foreground"
aria-label="Close command result modal"
<div className="flex min-w-0 items-center gap-3">
<div
className={`flex shrink-0 items-center justify-center rounded-xl border border-border bg-muted text-foreground ${
isModelsModal ? 'h-9 w-9' : 'h-10 w-10'
}`}
>
<X className="h-4 w-4" />
</Button>
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
</div>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{activeMeta?.eyebrow}
</p>
<p className="mt-0.5 text-lg font-semibold tracking-tight text-foreground sm:text-xl">
{activeMeta?.title}
</p>
<p className="mt-0.5 max-w-2xl text-sm leading-5 text-muted-foreground">
{activeMeta?.subtitle}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 shrink-0 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close command result modal"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">

View File

@@ -0,0 +1,68 @@
import { useTranslation } from 'react-i18next';
const loadAllOverlayAnimationStyle = `
@keyframes loadAllOverlayAutoFade {
0%, 80% { opacity: 1; }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.load-all-overlay-auto-fade {
animation: none !important;
}
}
`;
interface LoadAllMessagesOverlayProps {
showLoadAllOverlay: boolean;
isLoadingAllMessages: boolean;
loadAllJustFinished: boolean;
totalMessages: number;
onLoadAllMessages: () => void;
}
export default function LoadAllMessagesOverlay({
showLoadAllOverlay,
isLoadingAllMessages,
loadAllJustFinished,
totalMessages,
onLoadAllMessages,
}: LoadAllMessagesOverlayProps) {
const { t } = useTranslation('chat');
if (!showLoadAllOverlay && !isLoadingAllMessages && !loadAllJustFinished) {
return null;
}
return (
<div
className={`pointer-events-none sticky top-2 z-20 flex justify-center ${!isLoadingAllMessages ? 'load-all-overlay-auto-fade' : ''}`}
style={!isLoadingAllMessages ? { animation: 'loadAllOverlayAutoFade 2500ms ease forwards' } : undefined}
>
<style>{loadAllOverlayAnimationStyle}</style>
{loadAllJustFinished ? (
<div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
<span>{t('session.messages.allLoaded')}</span>
</div>
) : (
<button
className="pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600"
onClick={onLoadAllMessages}
disabled={isLoadingAllMessages}
>
{isLoadingAllMessages && (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
)}
<span>
{isLoadingAllMessages
? t('session.messages.loadingAll')
: <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</>}
</span>
</button>
)}
</div>
);
}

View File

@@ -4,11 +4,12 @@ import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTranslation } from 'react-i18next';
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
import { useTheme } from '../../../../contexts/ThemeContext';
type MarkdownProps = {
children: React.ReactNode;
@@ -59,6 +60,7 @@ type CodeBlockProps = {
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
const { t } = useTranslation('chat');
const { isDarkMode } = useTheme();
const [copied, setCopied] = useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(raw);
@@ -96,7 +98,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
}
})
}
className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 focus:opacity-100 active:opacity-100 group-hover:opacity-100"
className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 px-2 py-1 text-xs text-foreground/80 opacity-0 transition-opacity hover:bg-muted focus:opacity-100 active:opacity-100 group-hover:opacity-100"
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
>
@@ -132,17 +134,20 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
<SyntaxHighlighter
language={language}
style={oneDark}
style={isDarkMode ? oneDark : oneLight}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
borderRadius: '0.75rem',
fontSize: '0.875rem',
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
// ChatGPT-style soft grey block in light mode; keep oneDark's own bg in dark.
...(isDarkMode ? {} : { background: 'hsl(var(--muted))' }),
}}
codeTagProps={{
style: {
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
...(isDarkMode ? {} : { background: 'transparent' }),
},
}}
>
@@ -154,6 +159,10 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
const markdownComponents = {
code: CodeBlock,
// CodeBlock renders its own syntax-highlighted <pre>; this passthrough stops
// react-markdown (and Tailwind Typography) from wrapping it in a second,
// dark-themed <pre> shell that would frame the block.
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
{children}

View File

@@ -1,4 +1,4 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
@@ -30,7 +30,6 @@ type MessageComponentProps = {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject?: Project | null;
@@ -45,7 +44,7 @@ type InteractiveOption = {
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') ||
@@ -53,7 +52,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
(prevMessage.type === 'tool') ||
(prevMessage.type === 'error'));
const messageRef = useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')),
@@ -72,32 +70,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
!message.isThinking;
useEffect(() => {
const node = messageRef.current;
if (!autoExpandTools || !node || !message.isToolUse) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isExpanded) {
setIsExpanded(true);
const details = node.querySelectorAll<HTMLDetailsElement>('details');
details.forEach((detail) => {
detail.open = true;
});
}
});
},
{ threshold: 0.1 }
);
observer.observe(node);
return () => {
observer.unobserve(node);
};
}, [autoExpandTools, isExpanded, message.isToolUse]);
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
@@ -115,7 +87,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
/* User message bubble on the right */
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
<div dir="auto" className="whitespace-pre-wrap break-words text-sm">
<div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
{message.content}
</div>
{message.images && message.images.length > 0 && (
@@ -166,7 +138,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
🔧
</div>
) : (
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-white">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-foreground">
<SessionProviderLogo provider={provider} className="h-full w-full" />
</div>
)}
@@ -183,8 +155,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: provider === 'hermes'
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
: t('messageTypes.claude'))}
</div>
</div>
@@ -196,7 +166,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<>
<div className="flex flex-col">
<div className="flex flex-col">
<Markdown className="prose prose-sm max-w-none dark:prose-invert">
<Markdown className="prose prose-sm max-w-none font-serif dark:prose-invert">
{String(message.displayText || '')}
</Markdown>
</div>
@@ -212,7 +182,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
isSubagentContainer={message.isSubagentContainer}
@@ -235,7 +204,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
</div>
<div className="relative text-sm text-red-900 dark:text-red-100">
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
<Markdown className="prose prose-sm prose-red max-w-none font-serif dark:prose-invert">
{String(message.toolResult.content || '')}
</Markdown>
</div>
@@ -252,7 +221,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
autoExpandTools={autoExpandTools}
/>
</div>
)
@@ -344,7 +312,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<Reasoning defaultOpen={false}>
<ReasoningTrigger />
<ReasoningContent>
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
<Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
{message.content}
</Markdown>
<div className="mt-3 flex items-center text-[11px]">
@@ -379,15 +347,15 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
return (
<div className="my-2">
<div className="mb-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="font-medium">{t('json.response')}</span>
</div>
<div className="overflow-hidden rounded-lg border border-gray-600/30 bg-gray-800 dark:border-gray-700 dark:bg-gray-900">
<div className="overflow-hidden rounded-lg border border-border bg-muted">
<pre className="overflow-x-auto p-4">
<code className="block whitespace-pre font-mono text-sm text-gray-100 dark:text-gray-200">
<code className="block whitespace-pre font-mono text-sm text-foreground">
{formatted}
</code>
</pre>
@@ -401,7 +369,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
// Normal rendering for non-JSON content
return message.type === 'assistant' ? (
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
<Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
{content}
</Markdown>
) : (
@@ -432,3 +400,4 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
});
export default MessageComponent;

View File

@@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { copyTextToClipboard } from '../../../../utils/clipboard';
@@ -49,9 +51,32 @@ const MessageCopyControl = ({
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
const [copied, setCopied] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
const dropdownRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// The dropdown is rendered in a portal so it escapes the chat message's
// `contain: paint` box (which would otherwise clip it). Anchor it to the
// trigger, flipping above when there isn't room below.
const openDropdown = () => {
const rect = triggerRef.current?.getBoundingClientRect();
if (rect) {
const ESTIMATED_MENU_HEIGHT = 84;
const openUp = rect.bottom + ESTIMATED_MENU_HEIGHT + 8 > window.innerHeight;
setMenuStyle({
position: 'fixed',
right: Math.max(8, window.innerWidth - rect.right),
zIndex: 1000,
...(openUp
? { bottom: window.innerHeight - rect.top + 4 }
: { top: rect.bottom + 4 }),
});
}
setIsDropdownOpen(true);
};
const copyFormatOptions: CopyFormatOption[] = useMemo(
() => [
{
@@ -83,18 +108,28 @@ const MessageCopyControl = ({
}, [defaultFormat]);
useEffect(() => {
// Close the dropdown when clicking anywhere outside this control.
if (!isDropdownOpen) return;
// Close when clicking outside both the control and the portaled menu.
const closeOnOutsideClick = (event: MouseEvent) => {
if (!isDropdownOpen) return;
const target = event.target as Node;
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
setIsDropdownOpen(false);
if (dropdownRef.current?.contains(target) || menuRef.current?.contains(target)) {
return;
}
setIsDropdownOpen(false);
};
// The menu is fixed-positioned; close it if the page scrolls so it can't
// detach from the trigger.
const closeOnScroll = () => setIsDropdownOpen(false);
window.addEventListener('mousedown', closeOnOutsideClick);
window.addEventListener('scroll', closeOnScroll, true);
window.addEventListener('resize', closeOnScroll);
return () => {
window.removeEventListener('mousedown', closeOnOutsideClick);
window.removeEventListener('scroll', closeOnScroll, true);
window.removeEventListener('resize', closeOnScroll);
};
}, [isDropdownOpen]);
@@ -170,8 +205,9 @@ const MessageCopyControl = ({
{canSelectCopyFormat && (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setIsDropdownOpen((prev) => !prev)}
onClick={() => (isDropdownOpen ? setIsDropdownOpen(false) : openDropdown())}
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
@@ -186,8 +222,12 @@ const MessageCopyControl = ({
</svg>
</button>
{isDropdownOpen && (
<div className="absolute left-auto top-full z-30 mt-1 min-w-36 rounded-md border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
{isDropdownOpen && createPortal(
<div
ref={menuRef}
style={menuStyle}
className="min-w-36 rounded-md border border-border bg-popover p-1 shadow-lg"
>
{copyFormatOptions.map((option) => {
const isSelected = option.format === selectedFormat;
return (
@@ -196,15 +236,16 @@ const MessageCopyControl = ({
type="button"
onClick={() => handleFormatChange(option.format)}
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'
? 'bg-accent text-foreground'
: 'text-foreground hover:bg-accent'
}`}
>
<span className="block text-xs font-medium">{option.label}</span>
</button>
);
})}
</div>
</div>,
document.body,
)}
</>
)}

View File

@@ -29,7 +29,6 @@ const PROVIDER_META: { id: LLMProvider; name: string }[] = [
{ id: "gemini", name: "Google" },
{ id: "cursor", name: "Cursor" },
{ id: "opencode", name: "OpenCode" },
{ id: "hermes", name: "Hermes" },
];
const MOD_KEY =
@@ -51,8 +50,6 @@ type ProviderSelectionEmptyStateProps = {
setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
hermesModel: string;
setHermesModel: (model: string) => void;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelsLoading: boolean;
tasksEnabled: boolean;
@@ -82,13 +79,11 @@ function getCurrentModel(
co: string,
g: string,
o: string,
h: string,
) {
if (p === "claude") return c;
if (p === "codex") return co;
if (p === "gemini") return g;
if (p === "opencode") return o;
if (p === "hermes") return h;
return cu;
}
@@ -97,7 +92,6 @@ function getProviderDisplayName(p: LLMProvider) {
if (p === "cursor") return "Cursor";
if (p === "codex") return "Codex";
if (p === "opencode") return "OpenCode";
if (p === "hermes") return "Hermes";
return "Gemini";
}
@@ -117,8 +111,6 @@ export default function ProviderSelectionEmptyState({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
providerModelCatalog,
providerModelsLoading,
tasksEnabled,
@@ -148,7 +140,6 @@ export default function ProviderSelectionEmptyState({
codexModel,
geminiModel,
opencodeModel,
hermesModel,
);
const currentModelLabel = useMemo(() => {
@@ -173,15 +164,12 @@ export default function ProviderSelectionEmptyState({
} else if (providerId === "opencode") {
setOpenCodeModel(modelValue);
localStorage.setItem("opencode-model", modelValue);
} else if (providerId === "hermes") {
setHermesModel(modelValue);
localStorage.setItem("hermes-model", modelValue);
} else {
setCursorModel(modelValue);
localStorage.setItem("cursor-model", modelValue);
}
},
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel, setHermesModel],
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel],
);
const handleModelSelect = useCallback(
@@ -289,11 +277,15 @@ export default function ProviderSelectionEmptyState({
>
<div className="min-w-0 flex-1">
<div className="truncate">{model.label}</div>
{model.description && (
{/*
// * Temporarly commented out because the description of models from claude
// * was a bit inconsistent. Will return it back when it becomes more consistent.
*/}
{/* {model.description && (
<div className="truncate text-xs text-muted-foreground">
{model.description}
</div>
)}
)} */}
</div>
{isSelected && (
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
@@ -327,10 +319,6 @@ export default function ProviderSelectionEmptyState({
model: opencodeModel,
defaultValue: "Ready with OpenCode {{model}}",
}),
hermes: t("providerSelection.readyPrompt.hermes", {
model: provider === "hermes" ? currentModelLabel : hermesModel,
defaultValue: "Ready with Hermes {{model}}",
}),
}[provider]
}
</p>

View File

@@ -43,7 +43,7 @@ export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryP
<button
type="button"
onClick={onClick}
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
title={`${usedTokens.toLocaleString()} tokens used`}
aria-label="Show token usage"
>

View File

@@ -22,7 +22,6 @@ interface ToolGroupContainerProps {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject?: Project | null;
@@ -66,7 +65,6 @@ export default function ToolGroupContainer({
onFileOpen,
onShowSettings,
onGrantToolPermission,
autoExpandTools,
showRawParameters,
showThinking,
selectedProject,
@@ -133,7 +131,6 @@ export default function ToolGroupContainer({
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}

View File

@@ -1,5 +1,4 @@
export const CODE_EDITOR_STORAGE_KEYS = {
theme: 'codeEditorTheme',
wordWrap: 'codeEditorWordWrap',
showMinimap: 'codeEditorShowMinimap',
lineNumbers: 'codeEditorLineNumbers',
@@ -7,7 +6,6 @@ export const CODE_EDITOR_STORAGE_KEYS = {
} as const;
export const CODE_EDITOR_DEFAULTS = {
isDarkMode: true,
wordWrap: false,
minimapEnabled: true,
showLineNumbers: true,

View File

@@ -5,15 +5,6 @@ import {
CODE_EDITOR_STORAGE_KEYS,
} from '../constants/settings';
const readTheme = () => {
const savedTheme = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.theme);
if (!savedTheme) {
return CODE_EDITOR_DEFAULTS.isDarkMode;
}
return savedTheme === 'dark';
};
const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
const value = localStorage.getItem(storageKey);
if (value === null) {
@@ -33,7 +24,6 @@ const readFontSize = () => {
};
export const useCodeEditorSettings = () => {
const [isDarkMode, setIsDarkMode] = useState(readTheme);
const [wordWrap, setWordWrap] = useState(readWordWrap);
const [minimapEnabled, setMinimapEnabled] = useState(() => (
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
@@ -43,18 +33,13 @@ export const useCodeEditorSettings = () => {
));
const [fontSize, setFontSize] = useState(readFontSize);
// Keep legacy behavior where the editor writes theme and wrap settings directly.
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
// Keep legacy behavior where the editor writes wrap settings directly.
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
}, [wordWrap]);
useEffect(() => {
const refreshFromStorage = () => {
setIsDarkMode(readTheme());
setWordWrap(readWordWrap());
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
@@ -71,8 +56,6 @@ export const useCodeEditorSettings = () => {
}, []);
return {
isDarkMode,
setIsDarkMode,
wordWrap,
setWordWrap,
minimapEnabled,

View File

@@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import { useTheme } from '../../../contexts/ThemeContext';
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
@@ -45,8 +46,10 @@ export default function CodeEditor({
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
const [markdownPreview, setMarkdownPreview] = useState(false);
// The code editor follows the app-wide theme; it has no theme of its own.
const { isDarkMode } = useTheme();
const {
isDarkMode,
wordWrap,
minimapEnabled,
showLineNumbers,

View File

@@ -1,8 +1,9 @@
import { useState } from 'react';
import type { ComponentProps } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { oneDark as prismOneDark, oneLight as prismOneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { copyTextToClipboard } from '../../../../../utils/clipboard';
import { useTheme } from '../../../../../contexts/ThemeContext';
type MarkdownCodeBlockProps = {
inline?: boolean;
@@ -16,6 +17,7 @@ export default function MarkdownCodeBlock({
node: _node,
...props
}: MarkdownCodeBlockProps) {
const { isDarkMode } = useTheme();
const [copied, setCopied] = useState(false);
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(rawContent);
@@ -50,20 +52,22 @@ export default function MarkdownCodeBlock({
setTimeout(() => setCopied(false), 2000);
}
})}
className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 group-hover:opacity-100"
className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 px-2 py-1 text-xs text-foreground/80 opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<SyntaxHighlighter
language={language}
style={prismOneDark}
style={isDarkMode ? prismOneDark : prismOneLight}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
borderRadius: '0.75rem',
fontSize: '0.875rem',
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
...(isDarkMode ? {} : { background: 'hsl(var(--muted))' }),
}}
codeTagProps={{ style: isDarkMode ? {} : { background: 'transparent' } }}
>
{rawContent}
</SyntaxHighlighter>

View File

@@ -12,6 +12,9 @@ type MarkdownPreviewProps = {
const markdownPreviewComponents: Components = {
code: MarkdownCodeBlock,
// MarkdownCodeBlock renders its own highlighted <pre>; passthrough prevents a
// second Typography-styled <pre> shell from framing it.
pre: ({ children }) => <>{children}</>,
blockquote: ({ children }) => (
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
{children}

View File

@@ -189,7 +189,7 @@ export default function GitPanelHeader({
<button
onClick={requestPublishConfirmation}
disabled={anyPending}
className="flex items-center gap-1 rounded-lg bg-purple-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-purple-700 disabled:opacity-50"
className="flex items-center gap-1 rounded-lg bg-primary px-2.5 py-1 text-sm text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
title={`Publish "${currentBranch}" to ${remoteName}`}
>
<Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />

View File

@@ -1,14 +0,0 @@
type HermesLogoProps = {
className?: string;
};
export default function HermesLogo({ className = 'w-5 h-5' }: HermesLogoProps) {
return (
<img
className={`${className} block object-contain`}
src="/icons/hermes-agent.png"
alt="Hermes"
loading="lazy"
/>
);
}

View File

@@ -3,7 +3,6 @@ import ClaudeLogo from './ClaudeLogo';
import CodexLogo from './CodexLogo';
import CursorLogo from './CursorLogo';
import GeminiLogo from './GeminiLogo';
import HermesLogo from './HermesLogo';
import OpenCodeLogo from './OpenCodeLogo';
type SessionProviderLogoProps = {
@@ -31,9 +30,5 @@ export default function SessionProviderLogo({
return <OpenCodeLogo className={className} />;
}
if (provider === 'hermes') {
return <HermesLogo className={className} />;
}
return <ClaudeLogo className={className} />;
}

View File

@@ -54,7 +54,7 @@ function MainContent({
newSessionTrigger,
}: MainContentProps) {
const { preferences } = useUiPreferences();
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
const { showRawParameters, showThinking, sendByCtrlEnter } = preferences;
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
@@ -170,10 +170,8 @@ function MainContent({
onNavigateToSession={onNavigateToSession}
onSessionEstablished={onSessionEstablished}
onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
externalMessageUpdate={externalMessageUpdate}
newSessionTrigger={newSessionTrigger}

View File

@@ -70,7 +70,7 @@ export default function MainContentTitle({
<div className="min-w-0 flex-1">
{activeTab === 'chat' && selectedSession ? (
<div className="min-w-0">
<h2 className="scrollbar-hide overflow-x-auto whitespace-nowrap text-sm font-semibold leading-tight text-foreground">
<h2 className="truncate text-sm font-semibold leading-tight text-foreground">
{getSessionTitle(selectedSession)}
</h2>
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>

View File

@@ -6,7 +6,6 @@ export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
hermes: 'Hermes',
};
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
@@ -15,7 +14,6 @@ export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
codex: ['user', 'project'],
gemini: ['user', 'project'],
opencode: ['user', 'project'],
hermes: ['user', 'project'],
};
export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
@@ -24,7 +22,6 @@ export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
codex: ['stdio', 'http'],
gemini: ['stdio', 'http', 'sse'],
opencode: ['stdio', 'http'],
hermes: ['stdio', 'http'],
};
export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
@@ -32,12 +29,11 @@ export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http'];
export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
claude: 'bg-purple-600 text-white hover:bg-purple-700',
cursor: 'bg-purple-600 text-white hover:bg-purple-700',
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
hermes: 'bg-emerald-700 text-white hover:bg-emerald-800 dark:bg-emerald-600 dark:hover:bg-emerald-700',
claude: 'bg-primary text-primary-foreground hover:bg-primary/90',
cursor: 'bg-primary text-primary-foreground hover:bg-primary/90',
codex: 'bg-primary text-primary-foreground hover:bg-primary/90',
gemini: 'bg-primary text-primary-foreground hover:bg-primary/90',
opencode: 'bg-primary text-primary-foreground hover:bg-primary/90',
};
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
@@ -46,7 +42,6 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
codex: true,
gemini: true,
opencode: false,
hermes: false,
};
export const DEFAULT_MCP_FORM: McpFormState = {

View File

@@ -135,7 +135,7 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-purple-500" />
<Server className="h-5 w-5 text-primary" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{description}</p>

View File

@@ -1,4 +1,5 @@
import { FolderOpen, Globe, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../../shared/view/ui';
@@ -119,8 +120,8 @@ export default function McpServerFormModal({
const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider];
const showCodexOnlyFields = provider === 'codex' && !isGlobalMode;
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
return createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
<div className="flex items-center justify-between border-b border-border p-4">
<h3 className="text-lg font-medium text-foreground">{modalTitle}</h3>
@@ -418,7 +419,7 @@ export default function McpServerFormModal({
<Button
type="submit"
disabled={isSubmitting || !canSubmit}
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isSubmitting
? t('mcpForm.actions.saving')
@@ -429,6 +430,7 @@ export default function McpServerFormModal({
</div>
</form>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -148,11 +148,18 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
return (
<>
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-2xl">
<div className="relative h-screen overflow-y-auto bg-background">
<div aria-hidden className="pointer-events-none fixed inset-0">
<div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
<div className="absolute -bottom-32 -left-24 h-[26rem] w-[26rem] rounded-full bg-primary/5 blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(hsl(var(--foreground)/0.04)_1px,transparent_1px)] [background-size:22px_22px] opacity-60" />
</div>
<div className="relative mx-auto flex min-h-full w-full max-w-2xl items-center justify-center p-4">
<div className="w-full py-6">
<OnboardingStepProgress currentStep={currentStep} />
<div className="rounded-lg border border-border bg-card p-8 shadow-lg">
<div className="rounded-2xl border border-border/70 bg-card/90 p-6 shadow-[0_24px_60px_-20px_hsl(var(--foreground)/0.18)] ring-1 ring-foreground/5 backdrop-blur-xl">
{currentStep === 0 ? (
<GitConfigurationStep
gitName={gitName}
@@ -169,12 +176,12 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
)}
{errorMessage && (
<div className="mt-6 rounded-lg border border-red-300 bg-red-100 p-4 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
<div className="mt-5 rounded-xl border border-destructive/30 bg-destructive/10 p-3.5">
<p className="text-sm text-destructive">{errorMessage}</p>
</div>
)}
<div className="mt-8 flex items-center justify-between border-t border-border pt-6">
<div className="mt-6 flex items-center justify-between border-t border-border pt-5">
<button
onClick={handlePreviousStep}
disabled={currentStep === 0 || isSubmitting}
@@ -189,7 +196,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
<button
onClick={handleNextStep}
disabled={!isCurrentStepValid || isSubmitting}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400"
className="flex items-center gap-2 rounded-xl bg-primary px-6 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"
>
{isSubmitting ? (
<>
@@ -207,7 +214,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
<button
onClick={handleFinish}
disabled={isSubmitting}
className="flex items-center gap-2 rounded-lg bg-green-600 px-6 py-3 font-medium text-white transition-colors duration-200 hover:bg-green-700 disabled:cursor-not-allowed disabled:bg-green-400"
className="flex items-center gap-2 rounded-xl bg-emerald-600 px-6 py-2.5 font-medium text-white shadow-lg shadow-emerald-600/25 transition-all duration-200 hover:bg-emerald-700 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"
>
{isSubmitting ? (
<>
@@ -225,6 +232,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -31,26 +31,26 @@ export default function AgentConnectionCard({
: status.error || 'Not connected';
return (
<div className={`rounded-lg border p-4 transition-colors ${containerClassName}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${iconContainerClassName}`}>
<div className={`rounded-xl border px-3 py-2.5 transition-colors ${containerClassName}`}>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full ${iconContainerClassName}`}>
<SessionProviderLogo provider={provider} className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2 font-medium text-foreground">
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{title}
{status.authenticated && <Check className="h-4 w-4 text-green-500" />}
{status.authenticated && <Check className="h-3.5 w-3.5 flex-shrink-0 text-emerald-500" />}
</div>
<div className="text-xs text-muted-foreground">{statusText}</div>
<div className="truncate text-xs text-muted-foreground">{statusText}</div>
</div>
</div>
{!status.authenticated && !status.loading && (
<button
onClick={onLogin}
className={`${loginButtonClassName} rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors`}
className={`${loginButtonClassName} flex-shrink-0 rounded-lg px-4 py-1.5 text-sm font-medium text-white transition-colors`}
>
Login
</button>

View File

@@ -51,15 +51,15 @@ export default function AgentConnectionsStep({
onOpenProviderLogin,
}: AgentConnectionsStepProps) {
return (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="mb-2 text-2xl font-bold text-foreground">Connect Your AI Agents</h2>
<p className="text-muted-foreground">
<div className="space-y-4">
<div className="text-center">
<h2 className="font-serif text-xl font-bold tracking-tight text-foreground">Connect Your AI Agents</h2>
<p className="mx-auto mt-1 max-w-sm text-sm leading-relaxed text-muted-foreground">
Login to one or more AI coding assistants. All are optional.
</p>
</div>
<div className="space-y-3">
<div className="-mr-1 max-h-[38vh] space-y-2 overflow-y-auto pr-1">
{providerCards.map((providerCard) => (
<AgentConnectionCard
key={providerCard.provider}
@@ -74,9 +74,7 @@ export default function AgentConnectionsStep({
))}
</div>
<div className="pt-2 text-center text-sm text-muted-foreground">
<p>You can configure these later in Settings.</p>
</div>
<p className="text-center text-xs text-muted-foreground">You can configure these later in Settings.</p>
</div>
);
}

View File

@@ -16,13 +16,13 @@ export default function GitConfigurationStep({
onGitEmailChange,
}: GitConfigurationStepProps) {
return (
<div className="space-y-6">
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
<GitBranch className="h-8 w-8 text-blue-600 dark:text-blue-400" />
<div className="space-y-5">
<div className="text-center">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 ring-1 ring-inset ring-primary/20">
<GitBranch className="h-7 w-7 text-primary" />
</div>
<h2 className="mb-2 text-2xl font-bold text-foreground">Git Configuration</h2>
<p className="text-muted-foreground">
<h2 className="font-serif text-xl font-bold tracking-tight text-foreground">Git Configuration</h2>
<p className="mx-auto mt-1 max-w-sm text-sm leading-relaxed text-muted-foreground">
Configure your git identity to ensure proper attribution for commits.
</p>
</div>
@@ -38,7 +38,7 @@ export default function GitConfigurationStep({
id="gitName"
value={gitName}
onChange={(event) => onGitNameChange(event.target.value)}
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full rounded-xl border border-border bg-background/60 px-4 py-2.5 text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 hover:border-foreground/20 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="John Doe"
required
disabled={isSubmitting}
@@ -56,7 +56,7 @@ export default function GitConfigurationStep({
id="gitEmail"
value={gitEmail}
onChange={(event) => onGitEmailChange(event.target.value)}
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full rounded-xl border border-border bg-background/60 px-4 py-2.5 text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 hover:border-foreground/20 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="john@example.com"
required
disabled={isSubmitting}

View File

@@ -11,7 +11,7 @@ const onboardingSteps = [
export default function OnboardingStepProgress({ currentStep }: OnboardingStepProgressProps) {
return (
<div className="mb-8">
<div className="mb-5">
<div className="flex items-center justify-between">
{onboardingSteps.map((step, index) => {
const isCompleted = index < currentStep;
@@ -22,18 +22,18 @@ export default function OnboardingStepProgress({ currentStep }: OnboardingStepPr
<div key={step.title} className="contents">
<div className="flex flex-1 flex-col items-center">
<div
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 transition-colors duration-200 ${
className={`flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all duration-200 ${
isCompleted
? 'border-green-500 bg-green-500 text-white'
? 'border-emerald-500 bg-emerald-500 text-white shadow-lg shadow-emerald-500/25'
: isActive
? 'border-blue-600 bg-blue-600 text-white'
: 'border-border bg-background text-muted-foreground'
? 'border-primary bg-primary text-primary-foreground shadow-lg shadow-primary/25'
: 'border-border bg-card text-muted-foreground'
}`}
>
{isCompleted ? <Check className="h-6 w-6" /> : <Icon className="h-6 w-6" />}
{isCompleted ? <Check className="h-5 w-5" /> : <Icon className="h-5 w-5" />}
</div>
<div className="mt-2 text-center">
<div className="mt-1.5 text-center">
<p className={`text-sm font-medium ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}>
{step.title}
</p>
@@ -42,7 +42,7 @@ export default function OnboardingStepProgress({ currentStep }: OnboardingStepPr
</div>
{index < onboardingSteps.length - 1 && (
<div className={`mx-2 h-0.5 flex-1 transition-colors duration-200 ${isCompleted ? 'bg-green-500' : 'bg-border'}`} />
<div className={`mx-2 h-0.5 flex-1 transition-colors duration-200 ${isCompleted ? 'bg-emerald-500' : 'bg-border'}`} />
)}
</div>
);

View File

@@ -12,7 +12,6 @@ import type {
} from '../types';
type ProviderAuthStatusPayload = {
installed?: boolean;
authenticated?: boolean;
email?: string | null;
method?: string | null;
@@ -35,7 +34,6 @@ const toProviderAuthStatus = (
payload: ProviderAuthStatusPayload,
fallbackError: string | null = null,
): ProviderAuthStatus => ({
installed: Boolean(payload.installed),
authenticated: Boolean(payload.authenticated),
email: payload.email ?? null,
method: payload.method ?? null,
@@ -80,7 +78,6 @@ export function useProviderAuthStatus(
if (!response.ok) {
const status: ProviderAuthStatus = {
installed: false,
authenticated: false,
email: null,
method: null,
@@ -98,7 +95,6 @@ export function useProviderAuthStatus(
} catch (caughtError) {
console.error(`Error checking ${provider} auth status:`, caughtError);
const status: ProviderAuthStatus = {
installed: false,
authenticated: false,
email: null,
method: null,

View File

@@ -1,7 +1,6 @@
import type { LLMProvider } from '../../types/app';
export type ProviderAuthStatus = {
installed: boolean;
authenticated: boolean;
email: string | null;
method: string | null;
@@ -11,7 +10,7 @@ export type ProviderAuthStatus = {
export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
claude: '/api/providers/claude/auth/status',
@@ -19,14 +18,12 @@ export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
codex: '/api/providers/codex/auth/status',
gemini: '/api/providers/gemini/auth/status',
opencode: '/api/providers/opencode/auth/status',
hermes: '/api/providers/hermes/auth/status',
};
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
claude: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
cursor: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
codex: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
gemini: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
opencode: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
hermes: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
claude: { authenticated: false, email: null, method: null, error: null, loading },
cursor: { authenticated: false, email: null, method: null, error: null, loading },
codex: { authenticated: false, email: null, method: null, error: null, loading },
gemini: { authenticated: false, email: null, method: null, error: null, loading },
opencode: { authenticated: false, email: null, method: null, error: null, loading },
});

View File

@@ -9,7 +9,6 @@ type ProviderLoginModalProps = {
provider?: LLMProvider;
onComplete?: (exitCode: number) => void;
customCommand?: string;
customTitle?: string;
isAuthenticated?: boolean;
};
@@ -42,10 +41,6 @@ const getProviderCommand = ({
return 'opencode auth login';
}
if (provider === 'hermes') {
return 'hermes model';
}
return 'gemini status';
};
@@ -54,7 +49,6 @@ const getProviderTitle = (provider: LLMProvider) => {
if (provider === 'cursor') return 'Cursor CLI Login';
if (provider === 'codex') return 'Codex CLI Login';
if (provider === 'opencode') return 'OpenCode CLI Login';
if (provider === 'hermes') return 'Hermes Agent Setup';
return 'Gemini CLI Configuration';
};
@@ -64,7 +58,6 @@ export default function ProviderLoginModal({
provider = 'claude',
onComplete,
customCommand,
customTitle,
isAuthenticated = false,
}: ProviderLoginModalProps) {
if (!isOpen) {
@@ -72,7 +65,7 @@ export default function ProviderLoginModal({
}
const command = getProviderCommand({ provider, customCommand, isAuthenticated });
const title = customTitle || getProviderTitle(provider);
const title = getProviderTitle(provider);
const handleComplete = (exitCode: number) => {
onComplete?.(exitCode);

View File

@@ -1,11 +1,10 @@
import {
ArrowDown,
Brain,
Eye,
Languages,
Maximize2,
Mic,
} from 'lucide-react';
import type { PreferenceToggleItem } from './types';
export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';
@@ -16,7 +15,7 @@ export const HANDLE_POSITION_MAX = 90;
export const DRAG_THRESHOLD_PX = 5;
export const SETTING_ROW_CLASS =
'flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600';
'flex items-center justify-between p-3 rounded-lg bg-muted/60 hover:bg-accent transition-colors border border-transparent hover:border-border';
export const TOGGLE_ROW_CLASS = `${SETTING_ROW_CLASS} cursor-pointer`;
@@ -24,11 +23,6 @@ export const CHECKBOX_CLASS =
'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600';
export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
{
key: 'autoExpandTools',
labelKey: 'quickSettings.autoExpandTools',
icon: Maximize2,
},
{
key: 'showRawParameters',
labelKey: 'quickSettings.showRawParameters',
@@ -41,14 +35,6 @@ export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
},
];
export const VIEW_OPTION_TOGGLES: PreferenceToggleItem[] = [
{
key: 'autoScrollToBottom',
labelKey: 'quickSettings.autoScrollToBottom',
icon: ArrowDown,
},
];
export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
{
key: 'sendByCtrlEnter',

View File

@@ -2,10 +2,8 @@ import type { CSSProperties } from 'react';
import type { LucideIcon } from 'lucide-react';
export type PreferenceToggleKey =
| 'autoExpandTools'
| 'showRawParameters'
| 'showThinking'
| 'autoScrollToBottom'
| 'sendByCtrlEnter'
| 'voiceEnabled';

View File

@@ -1,18 +1,19 @@
import { Moon, Sun } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { DarkModeToggle } from '../../../shared/view/ui';
import LanguageSelector from '../../../shared/view/ui/LanguageSelector';
import {
INPUT_SETTING_TOGGLES,
SETTING_ROW_CLASS,
TOOL_DISPLAY_TOGGLES,
VIEW_OPTION_TOGGLES,
} from '../constants';
import type {
PreferenceToggleItem,
PreferenceToggleKey,
QuickSettingsPreferences,
} from '../types';
import QuickSettingsSection from './QuickSettingsSection';
import QuickSettingsToggleRow from './QuickSettingsToggleRow';
@@ -48,11 +49,11 @@ export default function QuickSettingsContent({
<div className="flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4">
<QuickSettingsSection title={t('quickSettings.sections.appearance')}>
<div className={SETTING_ROW_CLASS}>
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<span className="flex items-center gap-2 text-sm text-foreground">
{isDarkMode ? (
<Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
<Moon className="h-4 w-4 text-muted-foreground" />
) : (
<Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" />
<Sun className="h-4 w-4 text-muted-foreground" />
)}
{t('quickSettings.darkMode')}
</span>
@@ -65,13 +66,9 @@ export default function QuickSettingsContent({
{renderToggleRows(TOOL_DISPLAY_TOGGLES)}
</QuickSettingsSection>
<QuickSettingsSection title={t('quickSettings.sections.viewOptions')}>
{renderToggleRows(VIEW_OPTION_TOGGLES)}
</QuickSettingsSection>
<QuickSettingsSection title={t('quickSettings.sections.inputSettings')}>
{renderToggleRows(inputSettingToggles)}
<p className="ml-3 text-xs text-gray-500 dark:text-gray-400">
<p className="ml-3 text-xs text-muted-foreground">
{t('quickSettings.sendByCtrlEnterDescription')}
</p>
</QuickSettingsSection>

View File

@@ -5,9 +5,9 @@ export default function QuickSettingsPanelHeader() {
const { t } = useTranslation('settings');
return (
<div className="border-b border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900">
<h3 className="flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-white">
<Settings2 className="h-5 w-5 text-gray-600 dark:text-gray-400" />
<div className="border-b border-border bg-muted/40 p-4">
<h3 className="flex items-center gap-2 text-lg font-semibold text-foreground">
<Settings2 className="h-5 w-5 text-muted-foreground" />
{t('quickSettings.title')}
</h3>
</div>

View File

@@ -1,10 +1,12 @@
import { useCallback, useMemo, useState } from 'react';
import type { MouseEvent as ReactMouseEvent } from 'react';
import { useDeviceSettings } from '../../../hooks/useDeviceSettings';
import { useUiPreferences } from '../../../hooks/useUiPreferences';
import { useTheme } from '../../../contexts/ThemeContext';
import { useQuickSettingsDrag } from '../hooks/useQuickSettingsDrag';
import type { PreferenceToggleKey, QuickSettingsPreferences } from '../types';
import QuickSettingsContent from './QuickSettingsContent';
import QuickSettingsHandle from './QuickSettingsHandle';
import QuickSettingsPanelHeader from './QuickSettingsPanelHeader';
@@ -22,15 +24,11 @@ export default function QuickSettingsPanelView() {
} = useQuickSettingsDrag({ isMobile });
const quickSettingsPreferences = useMemo<QuickSettingsPreferences>(() => ({
autoExpandTools: preferences.autoExpandTools,
showRawParameters: preferences.showRawParameters,
showThinking: preferences.showThinking,
autoScrollToBottom: preferences.autoScrollToBottom,
sendByCtrlEnter: preferences.sendByCtrlEnter,
voiceEnabled: preferences.voiceEnabled,
}), [
preferences.autoExpandTools,
preferences.autoScrollToBottom,
preferences.sendByCtrlEnter,
preferences.showRawParameters,
preferences.showThinking,

View File

@@ -13,7 +13,7 @@ export default function QuickSettingsSection({
}: QuickSettingsSectionProps) {
return (
<div className={`space-y-2 ${className}`}>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</h4>
{children}

View File

@@ -17,8 +17,8 @@ function QuickSettingsToggleRow({
}: QuickSettingsToggleRowProps) {
return (
<label className={TOGGLE_ROW_CLASS}>
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Icon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
<span className="flex items-center gap-2 text-sm text-foreground">
<Icon className="h-4 w-4 text-muted-foreground" />
{label}
</span>
<input

View File

@@ -39,13 +39,12 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
export const DEFAULT_SAVE_STATUS = null;
export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
theme: 'dark',
wordWrap: false,
showMinimap: true,
lineNumbers: true,

View File

@@ -86,7 +86,6 @@ const toCodexPermissionMode = (value: unknown): CodexPermissionMode => {
};
const readCodeEditorSettings = (): CodeEditorSettingsState => ({
theme: localStorage.getItem('codeEditorTheme') === 'light' ? 'light' : 'dark',
wordWrap: localStorage.getItem('codeEditorWordWrap') === 'true',
showMinimap: localStorage.getItem('codeEditorShowMinimap') !== 'false',
lineNumbers: localStorage.getItem('codeEditorLineNumbers') !== 'false',
@@ -164,8 +163,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
const [showLoginModal, setShowLoginModal] = useState(false);
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
const [loginCommand, setLoginCommand] = useState<string | undefined>(undefined);
const [loginTitle, setLoginTitle] = useState<string | undefined>(undefined);
const {
providerAuthStatus,
checkProviderAuthStatus,
@@ -233,10 +230,8 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
}
}, []);
const openLoginForProvider = useCallback((provider: AgentProvider, customCommand?: string, customTitle?: string) => {
const openLoginForProvider = useCallback((provider: AgentProvider) => {
setLoginProvider(provider);
setLoginCommand(customCommand);
setLoginTitle(customTitle);
setShowLoginModal(true);
}, []);
@@ -334,7 +329,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
}, [notificationPreferences.channels.sound]);
useEffect(() => {
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
localStorage.setItem('codeEditorShowMinimap', String(codeEditorSettings.showMinimap));
localStorage.setItem('codeEditorLineNumbers', String(codeEditorSettings.lineNumbers));
@@ -421,8 +415,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
showLoginModal,
setShowLoginModal,
loginProvider,
loginCommand,
loginTitle,
handleLoginComplete,
};
}

View File

@@ -47,7 +47,6 @@ export type CursorPermissionsState = {
};
export type CodeEditorSettingsState = {
theme: 'dark' | 'light';
wordWrap: boolean;
showMinimap: boolean;
lineNumbers: boolean;

View File

@@ -58,8 +58,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
showLoginModal,
setShowLoginModal,
loginProvider,
loginCommand,
loginTitle,
handleLoginComplete,
} = useSettingsController({
isOpen,
@@ -170,7 +168,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
projectSortOrder={projectSortOrder}
onProjectSortOrderChange={setProjectSortOrder}
codeEditorSettings={codeEditorSettings}
onCodeEditorThemeChange={(value) => updateCodeEditorSetting('theme', value)}
onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)}
onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)}
onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)}
@@ -234,8 +231,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
onClose={() => setShowLoginModal(false)}
provider={loginProvider || 'claude'}
onComplete={handleLoginComplete}
customCommand={loginCommand}
customTitle={loginTitle}
isAuthenticated={isAuthenticated}
/>

View File

@@ -11,7 +11,6 @@ type AppearanceSettingsTabProps = {
projectSortOrder: ProjectSortOrder;
onProjectSortOrderChange: (value: ProjectSortOrder) => void;
codeEditorSettings: CodeEditorSettingsState;
onCodeEditorThemeChange: (value: 'dark' | 'light') => void;
onCodeEditorWordWrapChange: (value: boolean) => void;
onCodeEditorShowMinimapChange: (value: boolean) => void;
onCodeEditorLineNumbersChange: (value: boolean) => void;
@@ -22,7 +21,6 @@ export default function AppearanceSettingsTab({
projectSortOrder,
onProjectSortOrderChange,
codeEditorSettings,
onCodeEditorThemeChange,
onCodeEditorWordWrapChange,
onCodeEditorShowMinimapChange,
onCodeEditorLineNumbersChange,
@@ -69,17 +67,6 @@ export default function AppearanceSettingsTab({
<SettingsSection title={t('appearanceSettings.codeEditor.title')}>
<SettingsCard divided>
<SettingsRow
label={t('appearanceSettings.codeEditor.theme.label')}
description={t('appearanceSettings.codeEditor.theme.description')}
>
<DarkModeToggle
checked={codeEditorSettings.theme === 'dark'}
onToggle={(enabled) => onCodeEditorThemeChange(enabled ? 'dark' : 'light')}
ariaLabel={t('appearanceSettings.codeEditor.theme.label')}
/>
</SettingsRow>
<SettingsRow
label={t('appearanceSettings.codeEditor.wordWrap.label')}
description={t('appearanceSettings.codeEditor.wordWrap.description')}

View File

@@ -12,7 +12,7 @@ type AgentListItemProps = {
type AgentConfig = {
name: string;
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc' | 'emerald';
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc';
};
const agentConfig: Record<AgentProvider, AgentConfig> = {
@@ -36,10 +36,6 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
name: 'OpenCode',
color: 'zinc',
},
hermes: {
name: 'Hermes',
color: 'emerald',
},
};
const colorClasses = {
@@ -58,9 +54,6 @@ const colorClasses = {
zinc: {
dot: 'bg-zinc-500',
},
emerald: {
dot: 'bg-emerald-600',
},
} as const;
export default function AgentListItem({
@@ -72,7 +65,6 @@ export default function AgentListItem({
}: AgentListItemProps) {
const config = agentConfig[agentId];
const colors = colorClasses[config.color];
const isReady = agentId !== 'hermes' && authStatus.authenticated;
if (isMobile) {
return (
@@ -88,7 +80,7 @@ export default function AgentListItem({
<div className="flex items-center justify-center gap-1.5">
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
<span className="truncate text-xs font-medium">{config.name}</span>
{isReady && (
{authStatus.authenticated && (
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
)}
</div>
@@ -108,10 +100,10 @@ export default function AgentListItem({
>
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
<span>{config.name}</span>
{isReady ? (
{authStatus.authenticated ? (
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
) : authStatus.loading ? (
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-muted-foreground/30" />
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-muted-foreground/30 animate-pulse" />
) : null}
</button>
);

View File

@@ -29,33 +29,29 @@ export default function AgentsSettingsTab({
), [selectedAgent]);
const visibleAgents = useMemo<AgentProvider[]>(() => {
return ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
return ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
}, []);
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
claude: {
authStatus: providerAuthStatus.claude,
onLogin: (customCommand, customTitle) => onProviderLogin('claude', customCommand, customTitle),
onLogin: () => onProviderLogin('claude'),
},
cursor: {
authStatus: providerAuthStatus.cursor,
onLogin: (customCommand, customTitle) => onProviderLogin('cursor', customCommand, customTitle),
onLogin: () => onProviderLogin('cursor'),
},
codex: {
authStatus: providerAuthStatus.codex,
onLogin: (customCommand, customTitle) => onProviderLogin('codex', customCommand, customTitle),
onLogin: () => onProviderLogin('codex'),
},
gemini: {
authStatus: providerAuthStatus.gemini,
onLogin: (customCommand, customTitle) => onProviderLogin('gemini', customCommand, customTitle),
onLogin: () => onProviderLogin('gemini'),
},
opencode: {
authStatus: providerAuthStatus.opencode,
onLogin: (customCommand, customTitle) => onProviderLogin('opencode', customCommand, customTitle),
},
hermes: {
authStatus: providerAuthStatus.hermes,
onLogin: (customCommand, customTitle) => onProviderLogin('hermes', customCommand, customTitle),
onLogin: () => onProviderLogin('opencode'),
},
}), [
onProviderLogin,
@@ -64,7 +60,6 @@ export default function AgentsSettingsTab({
providerAuthStatus.cursor,
providerAuthStatus.gemini,
providerAuthStatus.opencode,
providerAuthStatus.hermes,
]);
useEffect(() => {

View File

@@ -9,7 +9,6 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
hermes: 'Hermes',
};
export default function AgentSelectorSection({
@@ -26,8 +25,7 @@ export default function AgentSelectorSection({
agent === 'claude' ? 'bg-blue-500' :
agent === 'cursor' ? 'bg-purple-500' :
agent === 'gemini' ? 'bg-indigo-500' :
agent === 'opencode' ? 'bg-zinc-500' :
agent === 'hermes' ? 'bg-emerald-600' : 'bg-foreground/60';
agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60';
return (
<Pill

View File

@@ -1,11 +1,5 @@
import {
KeyRound,
Layers3,
LogIn,
type LucideIcon,
} from 'lucide-react';
import { LogIn } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge, Button } from '../../../../../../../shared/view/ui';
import SessionProviderLogo from '../../../../../../llm-logo-provider/SessionProviderLogo';
import type { AgentProvider, AuthStatus } from '../../../../../types/types';
@@ -13,7 +7,7 @@ import type { AgentProvider, AuthStatus } from '../../../../../types/types';
type AccountContentProps = {
agent: AgentProvider;
authStatus: AuthStatus;
onLogin: (customCommand?: string, customTitle?: string) => void;
onLogin: () => void;
};
type AgentVisualConfig = {
@@ -69,49 +63,11 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
subtextClass: 'text-zinc-700 dark:text-zinc-300',
buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600',
},
hermes: {
name: 'Hermes',
description: 'Nous Research Hermes Agent',
bgClass: 'bg-emerald-50 dark:bg-emerald-900/20',
borderClass: 'border-emerald-200 dark:border-emerald-800',
textClass: 'text-emerald-950 dark:text-emerald-100',
subtextClass: 'text-emerald-700 dark:text-emerald-300',
buttonClass: 'bg-emerald-700 hover:bg-emerald-800 active:bg-emerald-900',
},
};
type HermesAction = {
label: string;
buttonLabel: string;
description: string;
command: string;
title: string;
icon: LucideIcon;
};
const hermesActions: HermesAction[] = [
{
label: 'Model',
buttonLabel: 'Configure',
description: 'Choose the provider and model Hermes should use.',
command: 'hermes model',
title: 'Configure Hermes Model',
icon: Layers3,
},
{
label: 'Credentials',
buttonLabel: 'Manage',
description: 'Update credential pools and API keys.',
command: 'hermes auth',
title: 'Hermes Credentials',
icon: KeyRound,
},
];
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
const { t } = useTranslation('settings');
const config = agentConfig[agent];
const isHermes = agent === 'hermes';
return (
<div className="space-y-6">
@@ -128,121 +84,74 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
</div>
<div className={`${config.bgClass} border ${config.borderClass} rounded-lg p-4`}>
{isHermes ? (
<div className="space-y-4">
<div>
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex-1">
<div className={`font-medium ${config.textClass}`}>
{t('agents.hermes.configuration.title', { defaultValue: 'Hermes configuration' })}
{t('agents.connectionStatus')}
</div>
<div className={`text-sm ${config.subtextClass}`}>
{t('agents.hermes.configuration.description', {
defaultValue: 'Models and credentials are managed by Hermes.',
})}
</div>
</div>
<div className="overflow-hidden rounded-lg border border-border/60 bg-background/70">
{hermesActions.map((action, index) => {
const Icon = action.icon;
return (
<div
key={action.command}
className={`flex flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center sm:justify-between ${
index > 0 ? 'border-t border-border/60' : ''
}`}
>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{action.label}</div>
<div className="mt-0.5 text-sm text-muted-foreground">{action.description}</div>
</div>
</div>
<Button
type="button"
variant={index === 0 ? 'default' : 'outline'}
size="sm"
className={index === 0 ? `${config.buttonClass} w-full text-white sm:w-auto` : 'w-full sm:w-auto'}
onClick={() => onLogin(action.command, action.title)}
>
{action.buttonLabel}
</Button>
</div>
);
})}
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex-1">
<div className={`font-medium ${config.textClass}`}>
{t('agents.connectionStatus')}
</div>
<div className={`text-sm ${config.subtextClass}`}>
{authStatus.loading ? (
t('agents.authStatus.checkingAuth')
) : authStatus.authenticated ? (
t('agents.authStatus.loggedInAs', {
email: authStatus.email || t('agents.authStatus.authenticatedUser'),
})
) : (
t('agents.authStatus.notConnected')
)}
</div>
</div>
<div>
{authStatus.loading ? (
<Badge variant="secondary" className="bg-muted">
{t('agents.authStatus.checking')}
</Badge>
t('agents.authStatus.checkingAuth')
) : authStatus.authenticated ? (
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
{t('agents.authStatus.connected')}
</Badge>
t('agents.authStatus.loggedInAs', {
email: authStatus.email || t('agents.authStatus.authenticatedUser'),
})
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
{t('agents.authStatus.disconnected')}
</Badge>
t('agents.authStatus.notConnected')
)}
</div>
</div>
{authStatus.method !== 'api_key' && (
<div className="border-t border-border/50 pt-4">
<div className="flex items-center justify-between">
<div>
<div className={`font-medium ${config.textClass}`}>
{authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
</div>
<div className={`text-sm ${config.subtextClass}`}>
{authStatus.authenticated
? t('agents.login.reAuthDescription')
: t('agents.login.description', { agent: config.name })}
</div>
</div>
<Button
onClick={() => onLogin()}
className={`${config.buttonClass} text-white`}
size="sm"
>
<LogIn className="mr-2 h-4 w-4" />
{authStatus.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
</Button>
</div>
</div>
)}
{authStatus.error && (
<div className="border-t border-border/50 pt-4">
<div className="text-sm text-red-600 dark:text-red-400">
{t('agents.error', { error: authStatus.error })}
</div>
</div>
)}
<div>
{authStatus.loading ? (
<Badge variant="secondary" className="bg-muted">
{t('agents.authStatus.checking')}
</Badge>
) : authStatus.authenticated ? (
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
{t('agents.authStatus.connected')}
</Badge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
{t('agents.authStatus.disconnected')}
</Badge>
)}
</div>
</div>
)}
{authStatus.method !== 'api_key' && (
<div className="border-t border-border/50 pt-4">
<div className="flex items-center justify-between">
<div>
<div className={`font-medium ${config.textClass}`}>
{authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
</div>
<div className={`text-sm ${config.subtextClass}`}>
{authStatus.authenticated
? t('agents.login.reAuthDescription')
: t('agents.login.description', { agent: config.name })}
</div>
</div>
<Button
onClick={onLogin}
className={`${config.buttonClass} text-white`}
size="sm"
>
<LogIn className="mr-2 h-4 w-4" />
{authStatus.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
</Button>
</div>
</div>
)}
{authStatus.error && (
<div className="border-t border-border/50 pt-4">
<div className="text-sm text-red-600 dark:text-red-400">
{t('agents.error', { error: authStatus.error })}
</div>
</div>
)}
</div>
</div>
</div>
);

View File

@@ -11,7 +11,7 @@ import type {
export type AgentContext = {
authStatus: AuthStatus;
onLogin: (customCommand?: string, customTitle?: string) => void;
onLogin: () => void;
};
export type AgentContextByProvider = Record<AgentProvider, AgentContext>;
@@ -19,7 +19,7 @@ export type ProviderAuthStatusByProvider = Record<AgentProvider, AuthStatus>;
export type AgentsSettingsTabProps = {
providerAuthStatus: ProviderAuthStatusByProvider;
onProviderLogin: (provider: AgentProvider, customCommand?: string, customTitle?: string) => void;
onProviderLogin: (provider: AgentProvider) => void;
claudePermissions: ClaudePermissionsState;
onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
cursorPermissions: CursorPermissionsState;

View File

@@ -310,7 +310,7 @@ export default function Shell({
{cliPromptOptions && isConnected && (
<div
className="absolute inset-x-0 bottom-0 z-10 border-t border-gray-700/80 bg-gray-800/95 px-3 py-2 backdrop-blur-sm"
className="absolute inset-x-0 bottom-0 z-10 border-t border-gray-700/80 bg-gray-800/95 px-3 py-2 backdrop-blur-sm md:hidden"
onMouseDown={(e) => e.preventDefault()}
>
<div className="flex flex-wrap items-center gap-2">

View File

@@ -5,9 +5,6 @@ import type {
ApiResponse,
ProviderSkill,
ProviderSkillCreatePayload,
ProviderSkillRegistryActionResponse,
ProviderSkillRegistryResult,
ProviderSkillRegistrySearchResponse,
ProviderSkillsResponse,
SkillsProject,
SkillsProvider,
@@ -200,50 +197,6 @@ const saveProviderSkills = async (
return (data.data.skills || []).map((skill) => normalizeSkill(provider, skill));
};
const searchProviderSkillRegistry = async (
provider: SkillsProvider,
query: string,
limit = 10,
): Promise<ProviderSkillRegistryResult[]> => {
const params = new URLSearchParams({ query, limit: String(limit) });
const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/search?${params.toString()}`);
const data = await toResponseJson<ApiResponse<ProviderSkillRegistrySearchResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to search skill registry'));
}
return data.data.results || [];
};
const runProviderSkillRegistryAction = async (
provider: SkillsProvider,
action: 'install' | 'check' | 'update' | 'audit',
payload?: Record<string, unknown>,
): Promise<ProviderSkillRegistryActionResponse['result']> => {
const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/${action}`, {
method: 'POST',
body: payload ? JSON.stringify(payload) : undefined,
});
const data = await toResponseJson<ApiResponse<ProviderSkillRegistryActionResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, `Failed to run ${action}`));
}
return data.data.result;
};
const uninstallProviderSkillRegistrySkill = async (
provider: SkillsProvider,
name: string,
): Promise<ProviderSkillRegistryActionResponse['result']> => {
const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
const data = await toResponseJson<ApiResponse<ProviderSkillRegistryActionResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to uninstall skill'));
}
return data.data.result;
};
const getCacheKey = (provider: SkillsProvider, projects: ProjectTarget[]): string => {
const projectKey = projects.map((project) => project.path).sort().join('|');
return `${provider}:${projectKey}`;
@@ -268,10 +221,6 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
const [registryResults, setRegistryResults] = useState<ProviderSkillRegistryResult[]>([]);
const [registryError, setRegistryError] = useState<string | null>(null);
const [registryStatus, setRegistryStatus] = useState<string | null>(null);
const [registryBusyKey, setRegistryBusyKey] = useState<string | null>(null);
const activeLoadIdRef = useRef(0);
const projectTargets = useMemo(() => createProjectTargets(currentProjects), [currentProjects]);
@@ -301,10 +250,7 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
setIsLoadingProjectScopes(false);
setLoadError(null);
// Build the authoritative list from the fresh fetches only. The cache still
// feeds instant display above, but seeding the merge from it would let
// skills deleted out-of-band survive the union and never get pruned.
let nextSkills: ProviderSkill[] = [];
let nextSkills = cachedEntry && !options.force ? cachedEntry.skills : [];
let firstError: string | null = null;
try {
@@ -373,86 +319,12 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
}
}, [refreshSkills, selectedProvider]);
const searchRegistry = useCallback(async (query: string) => {
const normalizedQuery = query.trim();
if (!normalizedQuery) {
setRegistryResults([]);
setRegistryError(null);
return;
}
setRegistryBusyKey('search');
setRegistryError(null);
setRegistryStatus(null);
try {
setRegistryResults(await searchProviderSkillRegistry(selectedProvider, normalizedQuery, 12));
} catch (error) {
setRegistryError(error instanceof Error ? error.message : 'Failed to search skill registry');
} finally {
setRegistryBusyKey((current) => (current === 'search' ? null : current));
}
}, [selectedProvider]);
const installRegistrySkill = useCallback(async (identifier: string) => {
setRegistryBusyKey(`install:${identifier}`);
setRegistryError(null);
setRegistryStatus(null);
try {
await runProviderSkillRegistryAction(selectedProvider, 'install', { identifier });
clearProviderSkillCache(selectedProvider);
await refreshSkills({ force: true });
setRegistryStatus('Skill installed.');
} catch (error) {
setRegistryError(error instanceof Error ? error.message : 'Failed to install skill');
} finally {
setRegistryBusyKey((current) => (current === `install:${identifier}` ? null : current));
}
}, [refreshSkills, selectedProvider]);
const uninstallRegistrySkill = useCallback(async (name: string) => {
setRegistryBusyKey(`uninstall:${name}`);
setRegistryError(null);
setRegistryStatus(null);
try {
await uninstallProviderSkillRegistrySkill(selectedProvider, name);
clearProviderSkillCache(selectedProvider);
await refreshSkills({ force: true });
setRegistryStatus('Skill uninstalled.');
} catch (error) {
setRegistryError(error instanceof Error ? error.message : 'Failed to uninstall skill');
} finally {
setRegistryBusyKey((current) => (current === `uninstall:${name}` ? null : current));
}
}, [refreshSkills, selectedProvider]);
const runRegistryMaintenance = useCallback(async (action: 'check' | 'update' | 'audit') => {
setRegistryBusyKey(action);
setRegistryError(null);
setRegistryStatus(null);
try {
const result = await runProviderSkillRegistryAction(selectedProvider, action);
if (action === 'update' || action === 'audit') {
clearProviderSkillCache(selectedProvider);
await refreshSkills({ force: true });
}
setRegistryStatus((result.stdout || result.stderr || `${action} completed.`).trim());
} catch (error) {
setRegistryError(error instanceof Error ? error.message : `Failed to run ${action}`);
} finally {
setRegistryBusyKey((current) => (current === action ? null : current));
}
}, [refreshSkills, selectedProvider]);
useEffect(() => {
void refreshSkills();
}, [refreshSkills]);
useEffect(() => {
setSaveStatus(null);
setRegistryResults([]);
setRegistryError(null);
setRegistryStatus(null);
setRegistryBusyKey(null);
}, [selectedProvider]);
useEffect(() => {
@@ -470,15 +342,7 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
isLoadingProjectScopes,
loadError,
saveStatus,
registryResults,
registryError,
registryStatus,
registryBusyKey,
addSkills,
refreshSkills,
searchRegistry,
installRegistrySkill,
uninstallRegistrySkill,
runRegistryMaintenance,
};
}

View File

@@ -43,28 +43,6 @@ export type ProviderSkillsResponse = {
skills: Array<Partial<ProviderSkill>>;
};
export type ProviderSkillRegistryResult = {
name: string;
identifier: string;
source?: string;
trustLevel?: string;
description?: string;
};
export type ProviderSkillRegistrySearchResponse = {
provider: SkillsProvider;
results: ProviderSkillRegistryResult[];
};
export type ProviderSkillRegistryActionResponse = {
provider: SkillsProvider;
result: {
ok: boolean;
stdout: string;
stderr: string;
};
};
export type ApiSuccessResponse<T> = {
success: true;
data: T;

View File

@@ -2,24 +2,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import {
CheckCircle2,
Compass,
FileCode2,
FileText,
FileUp,
FolderUp,
Loader2,
Plus,
RefreshCw,
Search,
Upload,
Wrench,
X,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils';
import {
ActionMenu,
Badge,
Button,
Card,
@@ -27,9 +23,6 @@ import {
CardDescription,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogTitle,
Input,
} from '../../../shared/view/ui';
import { useProviderSkills } from '../hooks/useProviderSkills';
@@ -69,7 +62,6 @@ const PROVIDER_NAMES: Record<SkillsProvider, string> = {
cursor: 'Cursor',
gemini: 'Gemini',
opencode: 'OpenCode',
hermes: 'Hermes',
};
const PROVIDER_SKILL_PATHS: Record<Exclude<SkillsProvider, 'opencode'>, string> = {
@@ -77,30 +69,8 @@ const PROVIDER_SKILL_PATHS: Record<Exclude<SkillsProvider, 'opencode'>, string>
codex: '~/.agents/skills/<skill-name>/SKILL.md',
cursor: '~/.cursor/skills/<skill-name>/SKILL.md',
gemini: '~/.gemini/skills/<skill-name>/SKILL.md',
hermes: '~/.hermes/skills/<skill-name>/SKILL.md',
};
const HERMES_SKILL_ACTIONS = [
{
label: 'Check Updates',
description: 'Check installed hub skills.',
action: 'check' as const,
icon: RefreshCw,
},
{
label: 'Update Hub Skills',
description: 'Apply available hub updates.',
action: 'update' as const,
icon: Upload,
},
{
label: 'Audit Installed',
description: 'Re-scan installed hub skills.',
action: 'audit' as const,
icon: CheckCircle2,
},
];
const SCOPE_LABELS: Record<SkillsScope, string> = {
user: 'User',
plugin: 'Plugin',
@@ -239,23 +209,13 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
isLoadingProjectScopes,
loadError,
saveStatus,
registryResults,
registryError,
registryStatus,
registryBusyKey,
addSkills,
refreshSkills,
searchRegistry,
installRegistrySkill,
runRegistryMaintenance,
} = useProviderSkills({ selectedProvider, currentProjects });
const [queuedFiles, setQueuedFiles] = useState<QueuedSkillFile[]>([]);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [registryQuery, setRegistryQuery] = useState('');
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [addMode, setAddMode] = useState<'upload' | 'hub'>('upload');
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
@@ -267,9 +227,6 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
setSubmitError(null);
setIsSubmitting(false);
setSearchQuery('');
setRegistryQuery('');
setIsAddDialogOpen(false);
setAddMode('upload');
}, [selectedProvider]);
useEffect(() => {
@@ -397,7 +354,6 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
})));
await addSkills({ entries });
setQueuedFiles([]);
setIsAddDialogOpen(false);
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Failed to import skills');
} finally {
@@ -405,230 +361,6 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
}
}, [addSkills, queuedFiles]);
const uploadPanel = (
<div className="space-y-4">
<div className="rounded-lg border border-border/60 bg-muted/15 p-3">
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Install Path</div>
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
</div>
<div
{...getRootProps()}
className={cn(
'rounded-xl border border-dashed p-4 transition-colors sm:p-5',
isDragActive
? 'border-foreground/40 bg-muted/35'
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
)}
>
<input
ref={fileInputRef}
type="file"
accept=".md,text/markdown"
multiple
className="hidden"
onChange={(event) => {
handleDrop(Array.from(event.target.files ?? []));
event.target.value = '';
}}
/>
<input
ref={folderInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
handleFolderSelection(Array.from(event.target.files ?? []));
event.target.value = '';
}}
/>
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center">
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
<div className="space-y-1">
<div className="text-sm font-medium text-foreground">Drop `.md` files or skill folders here</div>
<div className="text-sm text-muted-foreground">
Upload standalone definitions or choose a full folder to include scripts, references, and assets.
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="w-full sm:w-auto"
>
<FileUp className="h-4 w-4" />
Choose Files
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => folderInputRef.current?.click()}
className="w-full sm:w-auto"
>
<FolderUp className="h-4 w-4" />
Choose Folder
</Button>
</div>
</div>
</div>
{queuedFiles.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">Queued Files</div>
<div className="grid gap-2">
{queuedFiles.map((queuedFile) => (
<div
key={queuedFile.id}
className="flex flex-col gap-3 rounded-lg border border-border/70 bg-background/70 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
<div className="text-xs text-muted-foreground">
{queuedFile.kind === 'folder'
? `${queuedFile.files.length} files`
: 'Markdown file'}
{' · '}
{formatFileSize(queuedFile.size)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full sm:w-auto"
onClick={() => {
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
}}
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Button
type="button"
onClick={() => void handleUploadInstall()}
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skills'}
</Button>
<span className="text-xs text-muted-foreground">
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
</span>
</div>
</div>
);
const hermesHubPanel = selectedProvider === 'hermes' ? (
<div className="flex min-h-full flex-col gap-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
value={registryQuery}
onChange={(event) => setRegistryQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
void searchRegistry(registryQuery);
}
}}
placeholder="Search Hermes skills..."
aria-label="Search Hermes skills registry"
className="h-9 w-full pl-9"
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={!registryQuery.trim() || registryBusyKey === 'search'}
onClick={() => void searchRegistry(registryQuery)}
>
{registryBusyKey === 'search' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
Search
</Button>
<ActionMenu
label="Maintenance"
icon={Wrench}
className="w-full sm:w-auto"
triggerClassName="w-full sm:w-auto"
items={HERMES_SKILL_ACTIONS.map((action) => ({
key: action.action,
label: action.label,
description: action.description,
icon: action.icon,
loading: registryBusyKey === action.action,
disabled: Boolean(registryBusyKey && registryBusyKey !== action.action),
onSelect: () => void runRegistryMaintenance(action.action),
}))}
/>
</div>
{registryResults.length > 0 && (
<div className="grid gap-2">
{registryResults.map((result) => (
<div
key={result.identifier}
className="flex flex-col gap-3 rounded-lg border border-border/70 bg-background/70 p-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{result.name}</span>
{result.source && (
<Badge variant="outline" className="rounded-full text-[10px]">{result.source}</Badge>
)}
{result.trustLevel && (
<Badge variant="secondary" className="rounded-full text-[10px]">{result.trustLevel}</Badge>
)}
</div>
<div className="mt-1 break-all font-mono text-xs text-muted-foreground">{result.identifier}</div>
{result.description && (
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{result.description}</div>
)}
</div>
<Button
type="button"
size="sm"
className="w-full sm:w-auto"
disabled={registryBusyKey === `install:${result.identifier}`}
onClick={() => void installRegistrySkill(result.identifier)}
>
{registryBusyKey === `install:${result.identifier}`
? <Loader2 className="h-4 w-4 animate-spin" />
: <Upload className="h-4 w-4" />}
Install
</Button>
</div>
))}
</div>
)}
{registryResults.length === 0 && (
<div className="flex min-h-[220px] flex-1 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-8 text-center">
<div className="max-w-sm space-y-2">
<Compass className="mx-auto h-6 w-6 text-muted-foreground" />
<div className="text-sm font-medium text-foreground">Search the Hermes Skills Hub</div>
<div className="text-sm text-muted-foreground">
Find installable Hermes skills by name, provider, source, or task.
</div>
</div>
</div>
)}
</div>
) : null;
return (
<div className="min-w-0 space-y-4 overflow-x-hidden">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
@@ -644,114 +376,160 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
type="button"
size="sm"
className="w-full sm:w-auto"
onClick={() => {
setAddMode('upload');
setIsAddDialogOpen(true);
}}
>
<Plus className="h-4 w-4" />
Add Skill
</Button>
<Button
onClick={() => void refreshSkills({ force: true })}
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={isLoading || isLoadingProjectScopes}
>
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
Refresh
</Button>
</div>
<Button
onClick={() => void refreshSkills({ force: true })}
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={isLoading || isLoadingProjectScopes}
>
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
Refresh
</Button>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent
wrapperClassName="z-[10000]"
className="flex h-[calc(100vh-2rem)] max-h-[760px] w-[calc(100vw-2rem)] max-w-4xl flex-col overflow-hidden p-0 sm:h-[720px]"
>
<DialogTitle>Add {providerName} Skill</DialogTitle>
<div className="flex-shrink-0 border-b border-border/60 px-4 py-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground">
{addMode === 'hub' ? <Compass className="h-4 w-4" /> : <FileUp className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-medium text-foreground">Add {providerName} Skill</div>
<div className="mt-1 text-sm text-muted-foreground">
{selectedProvider === 'hermes'
? 'Upload a local skill or install one from the Hermes Skills Hub.'
: 'Upload a markdown skill file or a complete skill folder.'}
<Card className="min-w-0 overflow-hidden border-border/70 bg-background shadow-sm">
<CardHeader className="space-y-3 border-b border-border/60 bg-muted/20">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="text-sm font-medium text-foreground">Upload Skills</div>
<div className="min-w-0 rounded-2xl border border-border/60 bg-background/70 p-3">
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Install Path</div>
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 p-4">
<div className="space-y-4">
<div
{...getRootProps()}
className={cn(
'rounded-3xl border border-dashed p-4 transition-colors sm:p-5',
isDragActive
? 'border-foreground/40 bg-muted/35'
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
)}
>
<input
ref={fileInputRef}
type="file"
accept=".md,text/markdown"
multiple
className="hidden"
onChange={(event) => {
handleDrop(Array.from(event.target.files ?? []));
event.target.value = '';
}}
/>
<input
ref={folderInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
handleFolderSelection(Array.from(event.target.files ?? []));
event.target.value = '';
}}
/>
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center sm:py-6">
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
<div className="space-y-1">
<div className="text-sm font-medium text-foreground">Drop `.md` files or skill folders here</div>
<div className="text-sm text-muted-foreground">
Upload standalone definitions or choose a full folder to include its scripts, references, and assets.
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="w-full sm:w-auto"
>
<FileUp className="h-4 w-4" />
Choose Files
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => folderInputRef.current?.click()}
className="w-full sm:w-auto"
>
<FolderUp className="h-4 w-4" />
Choose Folder
</Button>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
aria-label="Close add skill dialog"
onClick={() => setIsAddDialogOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{selectedProvider === 'hermes' && (
<div className="mt-4 inline-flex rounded-lg border border-border/70 bg-muted/20 p-1">
<Button
type="button"
variant={addMode === 'upload' ? 'secondary' : 'ghost'}
size="sm"
className="h-8 shadow-none"
onClick={() => setAddMode('upload')}
>
<FileUp className="h-4 w-4" />
Upload
</Button>
<Button
type="button"
variant={addMode === 'hub' ? 'secondary' : 'ghost'}
size="sm"
className="h-8 shadow-none"
onClick={() => setAddMode('hub')}
>
<Compass className="h-4 w-4" />
Skills Hub
</Button>
{queuedFiles.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">Queued Files</div>
<div className="grid gap-2">
{queuedFiles.map((queuedFile) => (
<div
key={queuedFile.id}
className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/70 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
<div className="text-xs text-muted-foreground">
{queuedFile.kind === 'folder'
? `${queuedFile.files.length} files`
: 'Markdown file'}
{' · '}
{formatFileSize(queuedFile.size)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full sm:w-auto"
onClick={() => {
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
}}
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Button
type="button"
onClick={() => void handleUploadInstall()}
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skills'}
</Button>
<span className="text-xs text-muted-foreground">
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{addMode === 'hub' && hermesHubPanel ? hermesHubPanel : uploadPanel}
</div>
{(submitError || loadError) && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{submitError || loadError}
</div>
)}
<div className="min-h-14 flex-shrink-0 border-t border-border/60 px-4 py-3">
{(submitError || loadError || registryError || registryStatus || saveStatus === 'success') && (
<div className={cn(
'rounded-lg border px-3 py-2 text-sm',
submitError || loadError || registryError
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
)}>
{submitError || loadError || registryError || registryStatus || 'Skills saved successfully.'}
</div>
)}
</div>
</DialogContent>
</Dialog>
{saveStatus === 'success' && !isAddDialogOpen && (
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-4 w-4" />
Skills saved successfully.
</div>
)}
{saveStatus === 'success' && (
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-4 w-4" />
Skills saved successfully.
</div>
)}
</CardContent>
</Card>
<Card className="min-w-0 border-border/70 bg-background/80 shadow-sm">
<CardHeader className="border-b border-border/60">

View File

@@ -41,7 +41,7 @@ export const ThemeProvider = ({ children }) => {
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute('content', '#0c1117'); // Dark background color (hsl(222.2 84% 4.9%))
themeColorMeta.setAttribute('content', '#141414'); // Dark background color (hsl(0 0% 8%))
}
} else {
document.documentElement.classList.remove('dark');
@@ -55,7 +55,7 @@ export const ThemeProvider = ({ children }) => {
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute('content', '#ffffff'); // Light background color
themeColorMeta.setAttribute('content', '#f6f4ef'); // Light background color (warm cream)
}
}
}, [isDarkMode]);

View File

@@ -1,10 +1,8 @@
import { useEffect, useReducer, useRef } from 'react';
type UiPreferences = {
autoExpandTools: boolean;
showRawParameters: boolean;
showThinking: boolean;
autoScrollToBottom: boolean;
sendByCtrlEnter: boolean;
sidebarVisible: boolean;
voiceEnabled: boolean;
@@ -34,10 +32,8 @@ type UiPreferencesAction =
| ResetPreferencesAction;
const DEFAULTS: UiPreferences = {
autoExpandTools: false,
showRawParameters: false,
showThinking: true,
autoScrollToBottom: true,
sendByCtrlEnter: false,
sidebarVisible: true,
voiceEnabled: false,

View File

@@ -54,14 +54,11 @@
"sections": {
"appearance": "Darstellung",
"toolDisplay": "Werkzeuganzeige",
"viewOptions": "Anzeigeoptionen",
"inputSettings": "Eingabeeinstellungen"
},
"darkMode": "Darkmode",
"autoExpandTools": "Werkzeuge automatisch erweitern",
"showRawParameters": "Rohe Parameter anzeigen",
"showThinking": "Denken anzeigen",
"autoScrollToBottom": "Automatisch nach unten scrollen",
"sendByCtrlEnter": "Mit Strg+Enter senden",
"sendByCtrlEnterDescription": "Wenn aktiviert, sendet Strg+Enter die Nachricht anstelle von Enter. Dies ist nützlich für IME-Benutzer:innen, um versehentliches Senden zu vermeiden.",
"dragHandle": {

View File

@@ -70,14 +70,11 @@
"sections": {
"appearance": "Appearance",
"toolDisplay": "Tool Display",
"viewOptions": "View Options",
"inputSettings": "Input Settings"
},
"darkMode": "Dark Mode",
"autoExpandTools": "Auto-expand tools",
"showRawParameters": "Show raw parameters",
"showThinking": "Show thinking",
"autoScrollToBottom": "Auto-scroll to bottom",
"sendByCtrlEnter": "Send by Ctrl+Enter",
"voiceEnabled": "Voice (mic + read aloud)",
"sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.",

Some files were not shown because too many files have changed in this diff Show More