mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-01 01:23:06 +08:00
Compare commits
4 Commits
feat/desig
...
feat/add-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f188648a2a | ||
|
|
cdf1a04e26 | ||
|
|
048c671b13 | ||
|
|
2ebe64f218 |
@@ -28,6 +28,9 @@ 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
|
||||
# =============================================================================
|
||||
@@ -42,4 +45,3 @@ HOST=0.0.0.0
|
||||
VITE_CONTEXT_WINDOW=160000
|
||||
CONTEXT_WINDOW=160000
|
||||
|
||||
|
||||
|
||||
10
index.html
10
index.html
@@ -6,15 +6,7 @@
|
||||
<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" />
|
||||
|
||||
|
||||
@@ -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>, or <code>codex</code> (default: <code>claude</code>)</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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>stream</code></td>
|
||||
@@ -834,6 +834,7 @@ 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() {
|
||||
|
||||
@@ -29,9 +29,14 @@ 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.
|
||||
@@ -64,7 +69,7 @@ function waitForToolApproval(requestId, options = {}) {
|
||||
let timeout;
|
||||
|
||||
const cleanup = () => {
|
||||
pendingToolApprovals.delete(requestId);
|
||||
unregisterApproval(requestId);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (signal && abortHandler) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
@@ -96,21 +101,15 @@ function waitForToolApproval(requestId, options = {}) {
|
||||
const resolver = (decision) => {
|
||||
finalize(decision);
|
||||
};
|
||||
// Attach metadata for getPendingApprovalsForSession lookup
|
||||
if (metadata) {
|
||||
Object.assign(resolver, metadata);
|
||||
}
|
||||
pendingToolApprovals.set(requestId, resolver);
|
||||
registerApproval(requestId, {
|
||||
resolver,
|
||||
sessionId: metadata?._sessionId ?? null,
|
||||
provider: 'claude',
|
||||
meta: metadata ?? {},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -846,28 +845,6 @@ 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.
|
||||
|
||||
398
server/hermes-cli.js
Normal file
398
server/hermes-cli.js
Normal file
@@ -0,0 +1,398 @@
|
||||
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, model) {
|
||||
const params = {
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: command }],
|
||||
};
|
||||
if (model) {
|
||||
params.modelId = model;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
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 {
|
||||
const resolvedModel = 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) {
|
||||
try {
|
||||
sessionResult = await connection.request('session/load', { sessionId, cwd: workingDir });
|
||||
} catch {
|
||||
sessionResult = { sessionId };
|
||||
}
|
||||
} else {
|
||||
sessionResult = await connection.request('session/new', {
|
||||
cwd: workingDir,
|
||||
});
|
||||
}
|
||||
|
||||
registerSession(readSessionId(sessionResult) || sessionId, connection);
|
||||
const promptResult = await connection.request('session/prompt', buildPromptParams(capturedSessionId, command, resolvedModel));
|
||||
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,
|
||||
};
|
||||
279
server/hermes/acp-client.js
Normal file
279
server/hermes/acp-client.js
Normal file
@@ -0,0 +1,279 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
await this.request('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientInfo: {
|
||||
name: 'CloudCLI',
|
||||
version: '1.0.0',
|
||||
},
|
||||
capabilities: {
|
||||
fs: false,
|
||||
terminal: false,
|
||||
session: {
|
||||
requestPermission: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
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 });
|
||||
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) {
|
||||
pending.reject(new Error(message.error.message || JSON.stringify(message.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,
|
||||
};
|
||||
@@ -41,6 +41,10 @@ import {
|
||||
spawnOpenCode,
|
||||
abortOpenCodeSession,
|
||||
} from './opencode-cli.js';
|
||||
import {
|
||||
spawnHermes,
|
||||
abortHermesSession,
|
||||
} from './hermes-cli.js';
|
||||
import sessionManager from './sessionManager.js';
|
||||
import {
|
||||
stripAnsiSequences,
|
||||
@@ -118,6 +122,7 @@ const wss = createWebSocketServer(server, {
|
||||
codex: queryCodex,
|
||||
gemini: spawnGemini,
|
||||
opencode: spawnOpenCode,
|
||||
hermes: spawnHermes,
|
||||
},
|
||||
abortFns: {
|
||||
claude: abortClaudeSDKSession,
|
||||
@@ -125,6 +130,7 @@ const wss = createWebSocketServer(server, {
|
||||
codex: abortCodexSession,
|
||||
gemini: abortGeminiSession,
|
||||
opencode: abortOpenCodeSession,
|
||||
hermes: abortHermesSession,
|
||||
},
|
||||
resolveToolApproval,
|
||||
getPendingApprovalsForSession,
|
||||
|
||||
135
server/modules/providers/list/hermes/hermes-auth.provider.ts
Normal file
135
server/modules/providers/list/hermes/hermes-auth.provider.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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 ACP is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await this.checkCredentials();
|
||||
return {
|
||||
provider: 'hermes',
|
||||
installed,
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.email,
|
||||
method: credentials.method,
|
||||
error: credentials.authenticated ? undefined : 'Hermes credentials were not found',
|
||||
};
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
296
server/modules/providers/list/hermes/hermes-mcp.provider.ts
Normal file
296
server/modules/providers/list/hermes/hermes-mcp.provider.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
152
server/modules/providers/list/hermes/hermes-models.provider.ts
Normal file
152
server/modules/providers/list/hermes/hermes-models.provider.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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 {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
readOptionalString,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
|
||||
|
||||
export const HERMES_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: HERMES_CONFIGURED_MODEL,
|
||||
label: 'Configured in Hermes',
|
||||
description: 'Uses the provider and model selected with `hermes model`.',
|
||||
},
|
||||
],
|
||||
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;
|
||||
}
|
||||
|
||||
const options = [
|
||||
{ value: activeModel, label: activeModel },
|
||||
...HERMES_FALLBACK_MODELS.OPTIONS,
|
||||
];
|
||||
|
||||
return {
|
||||
OPTIONS: options,
|
||||
DEFAULT: activeModel,
|
||||
};
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
|
||||
const configured = await this.readConfiguredModel();
|
||||
if (configured) {
|
||||
return { model: configured };
|
||||
}
|
||||
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
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 writeProviderSessionActiveModelChange('hermes', input);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
307
server/modules/providers/list/hermes/hermes-sessions.provider.ts
Normal file
307
server/modules/providers/list/hermes/hermes-sessions.provider.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
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 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 = readOptionalString(raw.content)
|
||||
?? readOptionalString(raw.text)
|
||||
?? readOptionalString(raw.delta)
|
||||
?? readOptionalString(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 = readOptionalString(raw.content)
|
||||
?? readOptionalString(raw.text)
|
||||
?? readOptionalString(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 = readOptionalString(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 = readObjectRecord(raw.tool);
|
||||
const toolId = readOptionalString(raw.toolCallId) ?? readOptionalString(raw.tool_call_id) ?? readOptionalString(raw.toolId) ?? 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)
|
||||
?? 'Tool',
|
||||
toolInput: raw.rawInput ?? raw.raw_input ?? raw.input ?? raw.arguments ?? raw.params ?? tool?.input ?? {},
|
||||
toolId,
|
||||
})];
|
||||
}
|
||||
|
||||
if (['tool_call_update', 'tool_result', 'tool_call_result', 'tool_call_done'].includes(type)) {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: readOptionalString(raw.toolCallId) ?? readOptionalString(raw.tool_call_id) ?? readOptionalString(raw.toolId) ?? '',
|
||||
content: formatContent(raw.output ?? raw.result ?? raw.content ?? raw.delta ?? ''),
|
||||
isError: Boolean(raw.error) || raw.status === 'error',
|
||||
})];
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
181
server/modules/providers/list/hermes/hermes-skills.provider.ts
Normal file
181
server/modules/providers/list/hermes/hermes-skills.provider.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
27
server/modules/providers/list/hermes/hermes.provider.ts
Normal file
27
server/modules/providers/list/hermes/hermes.provider.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -13,6 +14,7 @@ const providers: Record<LLMProvider, IProvider> = {
|
||||
cursor: new CursorProvider(),
|
||||
gemini: new GeminiProvider(),
|
||||
opencode: new OpenCodeProvider(),
|
||||
hermes: new HermesProvider(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -279,6 +279,48 @@ 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 (
|
||||
@@ -287,6 +329,7 @@ const parseProvider = (value: unknown): LLMProvider => {
|
||||
|| normalized === 'cursor'
|
||||
|| normalized === 'gemini'
|
||||
|| normalized === 'opencode'
|
||||
|| normalized === 'hermes'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
@@ -441,6 +484,77 @@ 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',
|
||||
|
||||
@@ -75,6 +75,15 @@ 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,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ export const sessionSynchronizerService = {
|
||||
cursor: 0,
|
||||
gemini: 0,
|
||||
opencode: 0,
|
||||
hermes: 0,
|
||||
};
|
||||
const failures: string[] = [];
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ 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 = [
|
||||
@@ -81,6 +85,10 @@ 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');
|
||||
}
|
||||
|
||||
@@ -4,7 +4,29 @@ 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 = {
|
||||
/**
|
||||
@@ -14,8 +36,7 @@ export const providerSkillsService = {
|
||||
providerName: string,
|
||||
options?: ProviderSkillListOptions,
|
||||
): Promise<ProviderSkill[]> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.skills.listSkills(options);
|
||||
return getProviderSkills(providerName).listSkills(options);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -25,8 +46,44 @@ export const providerSkillsService = {
|
||||
providerName: string,
|
||||
input: ProviderSkillCreateInput,
|
||||
): Promise<ProviderSkill[]> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.skills.addSkills(input);
|
||||
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));
|
||||
},
|
||||
|
||||
async removeProviderSkill(
|
||||
|
||||
@@ -341,7 +341,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
assert.equal(globalResult.length, 5);
|
||||
assert.equal(globalResult.length, 6);
|
||||
assert.ok(globalResult.every((entry) => entry.created === true));
|
||||
|
||||
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
|
||||
@@ -356,6 +356,11 @@ 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']);
|
||||
|
||||
@@ -377,4 +382,3 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -10,12 +10,14 @@ 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.
|
||||
@@ -636,7 +638,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'
|
||||
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' | 'hermes'
|
||||
* Default: 'claude'
|
||||
*
|
||||
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
|
||||
@@ -754,7 +756,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', or 'opencode'
|
||||
* - provider must be 'claude', 'cursor', 'codex', 'gemini', 'opencode', or 'hermes'
|
||||
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
|
||||
* - branchName must pass Git naming rules (if provided)
|
||||
*
|
||||
@@ -862,8 +864,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
return res.status(400).json({ error: 'message is required' });
|
||||
}
|
||||
|
||||
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' });
|
||||
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"' });
|
||||
}
|
||||
|
||||
// Validate GitHub branch/PR creation requirements
|
||||
@@ -944,6 +946,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
const codexModels = (await providerModelsService.getProviderModels('codex')).models;
|
||||
const geminiModels = (await providerModelsService.getProviderModels('gemini')).models;
|
||||
const opencodeModels = (await providerModelsService.getProviderModels('opencode')).models;
|
||||
const hermesModels = (await providerModelsService.getProviderModels('hermes')).models;
|
||||
|
||||
// Start the appropriate session
|
||||
if (provider === 'claude') {
|
||||
@@ -996,6 +999,15 @@ 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 || (hermesModels.DEFAULT === HERMES_CONFIGURED_MODEL ? undefined : hermesModels.DEFAULT)
|
||||
}, writer);
|
||||
}
|
||||
|
||||
// Handle GitHub branch and PR creation after successful agent completion
|
||||
|
||||
@@ -15,7 +15,7 @@ const APP_ROOT = findAppRoot(__dirname);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode"];
|
||||
const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode", "hermes"];
|
||||
|
||||
const MODEL_PROVIDER_LABELS = {
|
||||
claude: "Claude",
|
||||
@@ -23,6 +23,7 @@ const MODEL_PROVIDER_LABELS = {
|
||||
codex: "Codex",
|
||||
gemini: "Gemini",
|
||||
opencode: "OpenCode",
|
||||
hermes: "Hermes",
|
||||
};
|
||||
|
||||
const readModelProvider = (value) => {
|
||||
|
||||
@@ -5,6 +5,10 @@ import type {
|
||||
McpScope,
|
||||
NormalizedMessage,
|
||||
ProviderSkill,
|
||||
ProviderSkillRegistryActionResult,
|
||||
ProviderSkillRegistryInstallInput,
|
||||
ProviderSkillRegistrySearchOptions,
|
||||
ProviderSkillRegistrySearchResult,
|
||||
ProviderSkillListOptions,
|
||||
ProviderAuthStatus,
|
||||
ProviderChangeActiveModelInput,
|
||||
@@ -116,6 +120,21 @@ 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>;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
|
||||
83
server/shared/tool-approval-registry.js
Normal file
83
server/shared/tool-approval-registry.js
Normal file
@@ -0,0 +1,83 @@
|
||||
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,
|
||||
};
|
||||
@@ -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';
|
||||
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'hermes';
|
||||
|
||||
/**
|
||||
* One selectable model row in a provider model catalog.
|
||||
@@ -365,6 +365,32 @@ 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.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
type AuthErrorAlertProps = {
|
||||
errorMessage: string;
|
||||
};
|
||||
@@ -10,9 +8,8 @@ export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
type AuthInputFieldProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -12,14 +8,13 @@ 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. Password fields gain a show/hide visibility toggle.
|
||||
* the field correctly.
|
||||
*/
|
||||
export default function AuthInputField({
|
||||
id,
|
||||
@@ -31,49 +26,24 @@ 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.5 block text-sm font-medium text-foreground">
|
||||
<label htmlFor={id} className="mb-1 block text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
const loadingDotAnimationDelays = ['0s', '0.15s', '0.3s'];
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
const loadingDotAnimationDelays = ['0s', '0.1s', '0.2s'];
|
||||
|
||||
export default function AuthLoadingScreen() {
|
||||
return (
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<h1 className="mb-4 font-serif text-2xl font-bold tracking-tight text-foreground">CloudCLI</h1>
|
||||
<h1 className="mb-2 text-2xl font-bold text-foreground">CloudCLI</h1>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
{loadingDotAnimationDelays.map((delay) => (
|
||||
<div
|
||||
key={delay}
|
||||
className="h-2 w-2 animate-bounce rounded-full bg-primary"
|
||||
className="h-2 w-2 animate-bounce rounded-full bg-blue-500"
|
||||
style={{ animationDelay: delay }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { IS_PLATFORM } from '../../../constants/config';
|
||||
|
||||
type AuthScreenLayoutProps = {
|
||||
@@ -17,38 +18,29 @@ export default function AuthScreenLayout({
|
||||
logo,
|
||||
}: AuthScreenLayoutProps) {
|
||||
return (
|
||||
<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="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="text-center">
|
||||
<div className="mb-5 flex justify-center">
|
||||
<div className="mb-4 flex justify-center">
|
||||
{logo ?? (
|
||||
<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 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>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
||||
<p className="mt-2 text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">{children}</div>
|
||||
{children}
|
||||
|
||||
<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 className="text-center">
|
||||
<p className="text-sm text-muted-foreground">{footerText}</p>
|
||||
</div>
|
||||
|
||||
{!IS_PLATFORM && (
|
||||
<div className="mt-4 flex items-center justify-center gap-1.5">
|
||||
<div className="flex items-center justify-center gap-1.5 pt-2">
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -70,7 +69,6 @@ export default function LoginForm() {
|
||||
placeholder={t('login.placeholders.username')}
|
||||
isDisabled={isSubmitting}
|
||||
autoComplete="username"
|
||||
icon={User}
|
||||
/>
|
||||
|
||||
<AuthInputField
|
||||
@@ -82,7 +80,6 @@ export default function LoginForm() {
|
||||
isDisabled={isSubmitting}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
icon={Lock}
|
||||
/>
|
||||
|
||||
<AuthErrorAlert errorMessage={errorMessage} />
|
||||
@@ -90,16 +87,9 @@ export default function LoginForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('login.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('login.submit')
|
||||
)}
|
||||
{isSubmitting ? t('login.loading') : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
</AuthScreenLayout>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -86,6 +85,7 @@ 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,10 +94,9 @@ export default function SetupForm() {
|
||||
label="Username"
|
||||
value={formState.username}
|
||||
onChange={(value) => updateField('username', value)}
|
||||
placeholder="Choose a username"
|
||||
placeholder="Enter your username"
|
||||
isDisabled={isSubmitting}
|
||||
autoComplete="username"
|
||||
icon={User}
|
||||
/>
|
||||
|
||||
<AuthInputField
|
||||
@@ -106,11 +105,10 @@ export default function SetupForm() {
|
||||
label="Password"
|
||||
value={formState.password}
|
||||
onChange={(value) => updateField('password', value)}
|
||||
placeholder="Create a password"
|
||||
placeholder="Enter your password"
|
||||
isDisabled={isSubmitting}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
icon={Lock}
|
||||
/>
|
||||
|
||||
<AuthInputField
|
||||
@@ -119,33 +117,20 @@ export default function SetupForm() {
|
||||
label="Confirm Password"
|
||||
value={formState.confirmPassword}
|
||||
onChange={(value) => updateField('confirmPassword', value)}
|
||||
placeholder="Re-enter your password"
|
||||
placeholder="Confirm 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="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"
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Setting up...
|
||||
</>
|
||||
) : (
|
||||
'Create Account'
|
||||
)}
|
||||
{isSubmitting ? 'Setting up...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
</AuthScreenLayout>
|
||||
|
||||
@@ -39,6 +39,7 @@ interface UseChatComposerStateArgs {
|
||||
codexModel: string;
|
||||
geminiModel: string;
|
||||
opencodeModel: string;
|
||||
hermesModel: string;
|
||||
isLoading: boolean;
|
||||
canAbortSession: boolean;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
@@ -173,6 +174,7 @@ export function useChatComposerState({
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
hermesModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
@@ -336,6 +338,8 @@ export function useChatComposerState({
|
||||
? geminiModel
|
||||
: provider === 'opencode'
|
||||
? opencodeModel
|
||||
: provider === 'hermes'
|
||||
? (hermesModel === '__hermes_configured_model__' ? undefined : hermesModel)
|
||||
: claudeModel,
|
||||
tokenUsage: tokenBudget,
|
||||
};
|
||||
@@ -391,6 +395,7 @@ export function useChatComposerState({
|
||||
cursorModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
hermesModel,
|
||||
handleBuiltInCommand,
|
||||
handleCustomCommand,
|
||||
input,
|
||||
@@ -703,6 +708,8 @@ export function useChatComposerState({
|
||||
? 'gemini-settings'
|
||||
: provider === 'opencode'
|
||||
? 'opencode-settings'
|
||||
: provider === 'hermes'
|
||||
? 'hermes-settings'
|
||||
: 'claude-settings';
|
||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||
if (savedSettings) {
|
||||
@@ -729,6 +736,8 @@ export function useChatComposerState({
|
||||
? geminiModel
|
||||
: provider === 'opencode'
|
||||
? opencodeModel
|
||||
: provider === 'hermes'
|
||||
? (hermesModel === '__hermes_configured_model__' ? undefined : hermesModel)
|
||||
: claudeModel;
|
||||
|
||||
// One message shape for every provider. The backend resolves the
|
||||
@@ -774,6 +783,7 @@ export function useChatComposerState({
|
||||
executeCommand,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
hermesModel,
|
||||
isLoading,
|
||||
onSessionProcessing,
|
||||
onSessionEstablished,
|
||||
|
||||
@@ -15,6 +15,7 @@ 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__',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,7 @@ const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
|
||||
codex: ['default', 'acceptEdits', 'bypassPermissions'],
|
||||
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
opencode: ['default'],
|
||||
hermes: ['default'],
|
||||
};
|
||||
|
||||
type ProviderCapabilities = {
|
||||
@@ -93,6 +95,9 @@ 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
|
||||
@@ -141,12 +146,20 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenCodeModel(model);
|
||||
localStorage.setItem('opencode-model', model);
|
||||
if (targetProvider === 'opencode') {
|
||||
setOpenCodeModel(model);
|
||||
localStorage.setItem('opencode-model', model);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetProvider === 'hermes') {
|
||||
setHermesModel(model);
|
||||
localStorage.setItem('hermes-model', model);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
|
||||
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
|
||||
const requestId = providerModelsRequestIdRef.current + 1;
|
||||
providerModelsRequestIdRef.current = requestId;
|
||||
const isHardRefresh = options.bypassCache === true;
|
||||
@@ -324,6 +337,19 @@ 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;
|
||||
@@ -434,6 +460,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
hermesModel,
|
||||
setHermesModel,
|
||||
permissionMode,
|
||||
setPermissionMode,
|
||||
pendingPermissionRequests,
|
||||
|
||||
@@ -18,6 +18,7 @@ interface UseChatSessionStateArgs {
|
||||
selectedSession: ProjectSession | null;
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
autoScrollToBottom?: boolean;
|
||||
externalMessageUpdate?: number;
|
||||
newSessionTrigger?: number;
|
||||
processingSessions?: SessionActivityMap;
|
||||
@@ -95,6 +96,7 @@ export function useChatSessionState({
|
||||
selectedSession,
|
||||
ws,
|
||||
sendMessage,
|
||||
autoScrollToBottom,
|
||||
externalMessageUpdate,
|
||||
newSessionTrigger,
|
||||
processingSessions,
|
||||
@@ -119,7 +121,6 @@ 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);
|
||||
@@ -184,7 +185,6 @@ export function useChatSessionState({
|
||||
setShowLoadAllOverlay(false);
|
||||
setViewHiddenCount(0);
|
||||
setSearchTarget(null);
|
||||
wasNearTopRef.current = false;
|
||||
searchScrollActiveRef.current = false;
|
||||
topLoadLockRef.current = false;
|
||||
pendingScrollRestoreRef.current = null;
|
||||
@@ -336,34 +336,12 @@ export function useChatSessionState({
|
||||
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
||||
limit: MESSAGES_PER_PAGE,
|
||||
});
|
||||
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;
|
||||
}
|
||||
if (!slot || slot.serverMessages.length === 0) 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;
|
||||
@@ -379,25 +357,8 @@ 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;
|
||||
@@ -406,7 +367,7 @@ export function useChatSessionState({
|
||||
const didLoad = await loadOlderMessages(container);
|
||||
if (didLoad) topLoadLockRef.current = true;
|
||||
}
|
||||
}, [hasMoreMessages, isNearBottom, loadOlderMessages]);
|
||||
}, [isNearBottom, loadOlderMessages]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
|
||||
@@ -425,7 +386,6 @@ export function useChatSessionState({
|
||||
}
|
||||
topLoadLockRef.current = false;
|
||||
pendingScrollRestoreRef.current = null;
|
||||
wasNearTopRef.current = false;
|
||||
setIsUserScrolledUp(false);
|
||||
}, [selectedProject?.projectId, selectedSession?.id]);
|
||||
|
||||
@@ -532,7 +492,6 @@ export function useChatSessionState({
|
||||
setLoadAllJustFinished(false);
|
||||
setShowLoadAllOverlay(false);
|
||||
setViewHiddenCount(0);
|
||||
wasNearTopRef.current = false;
|
||||
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||
|
||||
@@ -587,7 +546,7 @@ export function useChatSessionState({
|
||||
if (!isProcessing) {
|
||||
await sessionStore.refreshFromServer(selectedSession.id);
|
||||
|
||||
if (isNearBottom()) {
|
||||
if (Boolean(autoScrollToBottom) && isNearBottom()) {
|
||||
setTimeout(() => scrollToBottom(), 200);
|
||||
}
|
||||
}
|
||||
@@ -598,6 +557,7 @@ export function useChatSessionState({
|
||||
|
||||
reloadExternalMessages();
|
||||
}, [
|
||||
autoScrollToBottom,
|
||||
externalMessageUpdate,
|
||||
isNearBottom,
|
||||
scrollToBottom,
|
||||
@@ -729,9 +689,10 @@ export function useChatSessionState({
|
||||
}, [chatMessages, visibleMessageCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
|
||||
if (!autoScrollToBottom && scrollContainerRef.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -739,8 +700,8 @@ export function useChatSessionState({
|
||||
if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return;
|
||||
if (searchScrollActiveRef.current) return;
|
||||
|
||||
if (!isUserScrolledUp) {
|
||||
setTimeout(() => scrollToBottom(), 50);
|
||||
if (autoScrollToBottom) {
|
||||
if (!isUserScrolledUp) setTimeout(() => scrollToBottom(), 50);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -750,7 +711,7 @@ export function useChatSessionState({
|
||||
const newHeight = container.scrollHeight;
|
||||
const heightDiff = newHeight - prevHeight;
|
||||
if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff;
|
||||
}, [chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
|
||||
}, [autoScrollToBottom, chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
@@ -759,8 +720,23 @@ export function useChatSessionState({
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
// "Load all" overlay visibility is driven by scroll-to-top in handleScroll;
|
||||
// timers are cleared on session change via the reset effect above.
|
||||
// "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]);
|
||||
|
||||
const loadAllMessages = useCallback(async () => {
|
||||
if (!selectedSession || !selectedProject) return;
|
||||
@@ -770,10 +746,6 @@ 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;
|
||||
@@ -800,11 +772,7 @@ export function useChatSessionState({
|
||||
|
||||
setLoadAllJustFinished(true);
|
||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||
loadAllFinishedTimerRef.current = setTimeout(() => {
|
||||
setLoadAllJustFinished(false);
|
||||
setShowLoadAllOverlay(false);
|
||||
loadAllFinishedTimerRef.current = null;
|
||||
}, 2500);
|
||||
loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000);
|
||||
} else {
|
||||
allMessagesLoadedRef.current = false;
|
||||
setShowLoadAllOverlay(false);
|
||||
|
||||
@@ -24,6 +24,7 @@ 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;
|
||||
@@ -79,6 +80,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
||||
onFileOpen,
|
||||
createDiff,
|
||||
selectedProject,
|
||||
autoExpandTools = false,
|
||||
showRawParameters = false,
|
||||
rawToolInput,
|
||||
isSubagentContainer,
|
||||
@@ -149,8 +151,8 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
||||
output={output}
|
||||
isError={Boolean(toolResult?.isError)}
|
||||
status={toolStatus !== 'completed' ? toolStatus : undefined}
|
||||
// Commands stay collapsed by default; only failures auto-expand so they
|
||||
// remain visible.
|
||||
// Commands stay collapsed by default (even consecutive ones); only
|
||||
// failures auto-expand so they remain visible.
|
||||
defaultOpen={false}
|
||||
/>
|
||||
);
|
||||
@@ -197,7 +199,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
||||
<PlanDisplay
|
||||
title={title}
|
||||
content={contentProps.content || ''}
|
||||
defaultOpen={displayConfig.defaultOpen ?? false}
|
||||
defaultOpen={displayConfig.defaultOpen ?? autoExpandTools}
|
||||
isStreaming={isStreaming}
|
||||
showRawParameters={mode === 'input' && showRawParameters}
|
||||
rawContent={rawToolInput}
|
||||
@@ -214,7 +216,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
||||
|
||||
const defaultOpen = displayConfig.defaultOpen !== undefined
|
||||
? displayConfig.defaultOpen
|
||||
: false;
|
||||
: autoExpandTools;
|
||||
|
||||
const contentProps = displayConfig.getContentProps?.(parsedData, {
|
||||
selectedProject,
|
||||
|
||||
@@ -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'
|
||||
: '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'
|
||||
: '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'
|
||||
}`}
|
||||
>
|
||||
{/* 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'
|
||||
: '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'
|
||||
: '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'
|
||||
}`}
|
||||
>
|
||||
<kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${
|
||||
|
||||
@@ -126,8 +126,10 @@ 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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChatMessage } from '../types/types';
|
||||
|
||||
export const TOOL_GROUP_THRESHOLD = 2;
|
||||
export const TOOL_GROUP_THRESHOLD = 3;
|
||||
|
||||
export interface ToolGroupItem {
|
||||
_isGroup: true;
|
||||
@@ -19,17 +19,7 @@ function isGroupableToolMessage(message: ChatMessage): message is ChatMessage &
|
||||
return Boolean(message.isToolUse && message.toolName && !message.isSubagentContainer);
|
||||
}
|
||||
|
||||
// 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[] {
|
||||
export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[] {
|
||||
const items: MessageListItem[] = [];
|
||||
let index = 0;
|
||||
|
||||
@@ -45,22 +35,13 @@ export function groupConsecutiveTools(
|
||||
const run: ChatMessage[] = [message];
|
||||
let nextIndex = index + 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;
|
||||
while (
|
||||
nextIndex < messages.length &&
|
||||
isGroupableToolMessage(messages[nextIndex]) &&
|
||||
messages[nextIndex].toolName === message.toolName
|
||||
) {
|
||||
run.push(messages[nextIndex]);
|
||||
nextIndex += 1;
|
||||
}
|
||||
|
||||
if (run.length >= TOOL_GROUP_THRESHOLD) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -31,8 +30,10 @@ function ChatInterface({
|
||||
onNavigateToSession,
|
||||
onSessionEstablished,
|
||||
onShowSettings,
|
||||
autoExpandTools,
|
||||
showRawParameters,
|
||||
showThinking,
|
||||
autoScrollToBottom,
|
||||
sendByCtrlEnter,
|
||||
externalMessageUpdate,
|
||||
newSessionTrigger,
|
||||
@@ -74,6 +75,8 @@ function ChatInterface({
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
hermesModel,
|
||||
setHermesModel,
|
||||
permissionMode,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
@@ -123,6 +126,7 @@ function ChatInterface({
|
||||
selectedSession,
|
||||
ws,
|
||||
sendMessage,
|
||||
autoScrollToBottom,
|
||||
externalMessageUpdate,
|
||||
newSessionTrigger,
|
||||
processingSessions,
|
||||
@@ -183,7 +187,7 @@ function ChatInterface({
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused,
|
||||
isInputFocused: _isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
showCostModal,
|
||||
@@ -199,6 +203,7 @@ function ChatInterface({
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
hermesModel,
|
||||
isLoading: isProcessing,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
@@ -291,7 +296,9 @@ function ChatInterface({
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude');
|
||||
: provider === 'hermes'
|
||||
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
|
||||
: t('messageTypes.claude');
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -332,6 +339,8 @@ function ChatInterface({
|
||||
setGeminiModel={setGeminiModel}
|
||||
opencodeModel={opencodeModel}
|
||||
setOpenCodeModel={setOpenCodeModel}
|
||||
hermesModel={hermesModel}
|
||||
setHermesModel={setHermesModel}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelsLoading={providerModelsLoading}
|
||||
tasksEnabled={tasksEnabled}
|
||||
@@ -354,26 +363,13 @@ function ChatInterface({
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={handleGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
/>
|
||||
|
||||
<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
|
||||
<ChatComposer
|
||||
pendingPermissionRequests={pendingPermissionRequests}
|
||||
handlePermissionDecision={handlePermissionDecision}
|
||||
handleGrantToolPermission={handleGrantToolPermission}
|
||||
@@ -388,6 +384,9 @@ function ChatInterface({
|
||||
onToggleCommandMenu={handleToggleCommandMenu}
|
||||
hasInput={Boolean(input.trim())}
|
||||
onClearInput={handleClearInput}
|
||||
isUserScrolledUp={isUserScrolledUp}
|
||||
hasMessages={chatMessages.length > 0}
|
||||
onScrollToBottom={scrollToBottomAndReset}
|
||||
onSubmit={handleSubmit}
|
||||
isDragActive={isDragActive}
|
||||
attachedImages={attachedImages}
|
||||
@@ -422,7 +421,6 @@ function ChatInterface({
|
||||
onTextareaPaste={handlePaste}
|
||||
onTextareaScrollSync={syncInputOverlayScroll}
|
||||
onTextareaInput={handleTextareaInput}
|
||||
isInputFocused={isInputFocused}
|
||||
onInputFocusChange={handleInputFocusChange}
|
||||
placeholder={t('input.placeholder', {
|
||||
provider:
|
||||
@@ -434,12 +432,13 @@ function ChatInterface({
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude'),
|
||||
: provider === 'hermes'
|
||||
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
|
||||
: t('messageTypes.claude'),
|
||||
})}
|
||||
isTextareaExpanded={isTextareaExpanded}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QuickSettingsPanel />
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
||||
type ActivityIndicatorProps = {
|
||||
activity: SessionActivity | null;
|
||||
onAbort?: () => void;
|
||||
isInputFocused?: boolean;
|
||||
};
|
||||
|
||||
const ACTION_KEYS = [
|
||||
@@ -19,7 +18,6 @@ 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
|
||||
@@ -28,31 +26,11 @@ const EXIT_ANIMATION_MS = 220;
|
||||
* session has an entry in the processing map; it disappears the instant that
|
||||
* entry is removed.
|
||||
*/
|
||||
export default function ActivityIndicator({ activity, onAbort, isInputFocused = false }: ActivityIndicatorProps) {
|
||||
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [renderedActivity, setRenderedActivity] = useState<SessionActivity | null>(activity);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const startedAt = renderedActivity?.startedAt ?? null;
|
||||
const startedAt = activity?.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)));
|
||||
@@ -61,10 +39,10 @@ export default function ActivityIndicator({ activity, onAbort, isInputFocused =
|
||||
return () => clearInterval(timer);
|
||||
}, [startedAt]);
|
||||
|
||||
if (!renderedActivity) return null;
|
||||
if (!activity) return null;
|
||||
|
||||
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
||||
const label = (renderedActivity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
|
||||
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
|
||||
.replace(/\.+$/, '');
|
||||
|
||||
const minutes = Math.floor(elapsedSeconds / 60);
|
||||
@@ -72,31 +50,19 @@ export default function ActivityIndicator({ activity, onAbort, isInputFocused =
|
||||
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={`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>
|
||||
<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>
|
||||
|
||||
{renderedActivity.canInterrupt && onAbort && (
|
||||
{activity.canInterrupt && onAbort && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className={`${tabSurfaceClassName} pointer-events-auto gap-1.5 text-muted-foreground hover:bg-card hover:text-destructive`}
|
||||
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"
|
||||
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
|
||||
>
|
||||
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
RefObject,
|
||||
TouchEvent,
|
||||
} from 'react';
|
||||
import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react';
|
||||
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react';
|
||||
|
||||
import { useVoiceInput } from '../../hooks/useVoiceInput';
|
||||
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
|
||||
@@ -68,6 +68,9 @@ 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[];
|
||||
@@ -98,7 +101,6 @@ 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;
|
||||
@@ -120,6 +122,9 @@ export default function ChatComposer({
|
||||
onToggleCommandMenu,
|
||||
hasInput,
|
||||
onClearInput,
|
||||
isUserScrolledUp,
|
||||
hasMessages,
|
||||
onScrollToBottom,
|
||||
onSubmit,
|
||||
isDragActive,
|
||||
attachedImages,
|
||||
@@ -150,7 +155,6 @@ export default function ChatComposer({
|
||||
onTextareaPaste,
|
||||
onTextareaScrollSync,
|
||||
onTextareaInput,
|
||||
isInputFocused = false,
|
||||
onInputFocusChange,
|
||||
placeholder,
|
||||
isTextareaExpanded,
|
||||
@@ -197,18 +201,15 @@ 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 relative flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6">
|
||||
<div className="chat-composer-shell flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
||||
{!hasPendingPermissions && (
|
||||
<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>
|
||||
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
|
||||
)}
|
||||
|
||||
{pendingPermissionRequests.length > 0 && (
|
||||
<div className="mx-auto mb-3 max-w-3xl">
|
||||
<div className="mx-auto mb-3 max-w-4xl">
|
||||
<PermissionRequestsBanner
|
||||
pendingPermissionRequests={pendingPermissionRequests}
|
||||
handlePermissionDecision={handlePermissionDecision}
|
||||
@@ -217,7 +218,19 @@ export default function ChatComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasQuestionPanel && <div className="relative mx-auto max-w-3xl">
|
||||
{!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>
|
||||
)}
|
||||
{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) => (
|
||||
@@ -258,10 +271,7 @@ export default function ChatComposer({
|
||||
<PromptInput
|
||||
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
|
||||
status={isLoading ? 'streaming' : 'ready'}
|
||||
className={[
|
||||
isTextareaExpanded ? 'chat-input-expanded' : '',
|
||||
hasActivityIndicator ? 'rounded-t-none' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
className={isTextareaExpanded ? 'chat-input-expanded' : ''}
|
||||
{...getRootProps()}
|
||||
>
|
||||
{isDragActive && (
|
||||
@@ -339,7 +349,7 @@ export default function ChatComposer({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onModeSwitch}
|
||||
className={`inline-flex h-8 items-center rounded-lg border px-2 text-xs font-medium transition-all duration-200 sm:px-2.5 ${
|
||||
className={`rounded-lg border p-2 text-xs font-medium transition-all duration-200 sm:px-2.5 sm:py-1 ${
|
||||
permissionMode === 'default'
|
||||
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
|
||||
: permissionMode === 'acceptEdits'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||
|
||||
import type { ChatMessage } from '../../types/types';
|
||||
@@ -15,7 +15,6 @@ 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>;
|
||||
@@ -40,6 +39,8 @@ 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;
|
||||
@@ -62,6 +63,7 @@ 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;
|
||||
@@ -89,6 +91,8 @@ function ChatMessagesPane({
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
hermesModel,
|
||||
setHermesModel,
|
||||
providerModelCatalog,
|
||||
providerModelsLoading,
|
||||
tasksEnabled,
|
||||
@@ -111,59 +115,48 @@ function ChatMessagesPane({
|
||||
onFileOpen,
|
||||
onShowSettings,
|
||||
onGrantToolPermission,
|
||||
autoExpandTools,
|
||||
showRawParameters,
|
||||
showThinking,
|
||||
selectedProject,
|
||||
}: ChatMessagesPaneProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const groupedVisibleMessages = useMemo(
|
||||
() => groupConsecutiveTools(visibleMessages, Boolean(showThinking)),
|
||||
[visibleMessages, showThinking],
|
||||
);
|
||||
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]);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
return keys;
|
||||
}, [groupedVisibleMessages]);
|
||||
|
||||
const getMessageKey = useCallback(
|
||||
(message: ChatMessage) =>
|
||||
messageKeyMap.get(message) ?? getIntrinsicMessageKey(message) ?? 'message-generated',
|
||||
[messageKeyMap],
|
||||
);
|
||||
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;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onWheel={onWheel}
|
||||
onTouchMove={onTouchMove}
|
||||
className="chat-messages-pane relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-3 sm:py-4"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
@@ -188,6 +181,8 @@ function ChatMessagesPane({
|
||||
setGeminiModel={setGeminiModel}
|
||||
opencodeModel={opencodeModel}
|
||||
setOpenCodeModel={setOpenCodeModel}
|
||||
hermesModel={hermesModel}
|
||||
setHermesModel={setHermesModel}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelsLoading={providerModelsLoading}
|
||||
tasksEnabled={tasksEnabled}
|
||||
@@ -219,13 +214,35 @@ function ChatMessagesPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LoadAllMessagesOverlay
|
||||
showLoadAllOverlay={showLoadAllOverlay}
|
||||
isLoadingAllMessages={isLoadingAllMessages}
|
||||
loadAllJustFinished={loadAllJustFinished}
|
||||
totalMessages={totalMessages}
|
||||
onLoadAllMessages={loadAllMessages}
|
||||
/>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Legacy message count indicator (for non-paginated view) */}
|
||||
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
|
||||
@@ -262,6 +279,7 @@ function ChatMessagesPane({
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
@@ -282,6 +300,7 @@ function ChatMessagesPane({
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
@@ -292,7 +311,6 @@ function ChatMessagesPane({
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { CSSProperties, ReactElement } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import {
|
||||
CornerDownLeft,
|
||||
Folder,
|
||||
@@ -78,7 +77,6 @@ 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 || ''}`;
|
||||
@@ -94,9 +92,8 @@ 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.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
|
||||
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: `${anchorBottom}px`,
|
||||
@@ -107,7 +104,7 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
|
||||
maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
|
||||
};
|
||||
}
|
||||
const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
|
||||
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
|
||||
const clampedLeft = Math.max(
|
||||
MENU_EDGE_GAP,
|
||||
Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP),
|
||||
@@ -219,14 +216,12 @@ 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 renderInPortal(
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="command-menu command-menu-empty border border-border bg-popover/95 text-sm text-muted-foreground"
|
||||
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"
|
||||
style={{
|
||||
...menuBaseStyle,
|
||||
...menuPosition,
|
||||
@@ -242,20 +237,20 @@ export default function CommandMenu({
|
||||
);
|
||||
}
|
||||
|
||||
return renderInPortal(
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="listbox"
|
||||
aria-label="Available commands"
|
||||
className="command-menu border border-border bg-popover/95 text-popover-foreground"
|
||||
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"
|
||||
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-muted-foreground">
|
||||
<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">
|
||||
<span>{namespaceLabels[namespace] || namespace}</span>
|
||||
<span className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
<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">
|
||||
{(groupedCommands[namespace] || []).length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -273,15 +268,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-primary/30 bg-primary/10 shadow-sm'
|
||||
: 'border-transparent bg-transparent hover:border-border hover:bg-accent'
|
||||
? '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'
|
||||
}`}
|
||||
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-primary" />
|
||||
<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={`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} />
|
||||
@@ -289,20 +284,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-foreground"
|
||||
className="min-w-0 truncate font-mono text-[13px] font-semibold text-gray-950 dark:text-slate-50"
|
||||
title={command.name}
|
||||
>
|
||||
{command.name}
|
||||
</span>
|
||||
{command.metadata?.type && (
|
||||
<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">
|
||||
<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">
|
||||
{command.metadata.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{command.description && (
|
||||
<div
|
||||
className="truncate whitespace-nowrap text-[12px] leading-4 text-muted-foreground"
|
||||
className="truncate whitespace-nowrap text-[12px] leading-4 text-gray-500 dark:text-slate-400"
|
||||
title={command.description}
|
||||
>
|
||||
{command.description}
|
||||
@@ -310,7 +305,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-primary/30 bg-card text-primary shadow-sm">
|
||||
<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">
|
||||
<CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -63,6 +63,7 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
hermes: 'Hermes',
|
||||
};
|
||||
|
||||
const FALLBACK_COMMANDS: CommandEntry[] = [
|
||||
@@ -565,41 +566,46 @@ export default function CommandResultModal({
|
||||
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
|
||||
|
||||
<div
|
||||
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'
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
<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%)]" />
|
||||
|
||||
<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 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"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,11 @@ 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, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { oneDark } 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;
|
||||
@@ -60,7 +59,6 @@ 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);
|
||||
@@ -98,7 +96,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
||||
}
|
||||
})
|
||||
}
|
||||
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"
|
||||
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"
|
||||
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||
>
|
||||
@@ -134,20 +132,17 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
||||
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={isDarkMode ? oneDark : oneLight}
|
||||
style={oneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
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' }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -159,10 +154,6 @@ 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}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useMemo, useRef } from 'react';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
@@ -30,6 +30,7 @@ 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;
|
||||
@@ -44,7 +45,7 @@ type InteractiveOption = {
|
||||
|
||||
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
||||
|
||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
((prevMessage.type === 'assistant') ||
|
||||
@@ -52,6 +53,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
(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 || '')),
|
||||
@@ -70,6 +72,32 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
!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);
|
||||
|
||||
@@ -87,7 +115,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
/* 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 font-serif text-sm">
|
||||
<div dir="auto" className="whitespace-pre-wrap break-words text-sm">
|
||||
{message.content}
|
||||
</div>
|
||||
{message.images && message.images.length > 0 && (
|
||||
@@ -138,7 +166,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
🔧
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-foreground">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-white">
|
||||
<SessionProviderLogo provider={provider} className="h-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
@@ -155,6 +183,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: provider === 'hermes'
|
||||
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
|
||||
: t('messageTypes.claude'))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,7 +196,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col">
|
||||
<Markdown className="prose prose-sm max-w-none font-serif dark:prose-invert">
|
||||
<Markdown className="prose prose-sm max-w-none dark:prose-invert">
|
||||
{String(message.displayText || '')}
|
||||
</Markdown>
|
||||
</div>
|
||||
@@ -182,6 +212,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
onFileOpen={onFileOpen}
|
||||
createDiff={createDiff}
|
||||
selectedProject={selectedProject}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
|
||||
isSubagentContainer={message.isSubagentContainer}
|
||||
@@ -204,7 +235,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
<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 font-serif dark:prose-invert">
|
||||
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
||||
{String(message.toolResult.content || '')}
|
||||
</Markdown>
|
||||
</div>
|
||||
@@ -221,6 +252,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
onFileOpen={onFileOpen}
|
||||
createDiff={createDiff}
|
||||
selectedProject={selectedProject}
|
||||
autoExpandTools={autoExpandTools}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -312,7 +344,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
<Reasoning defaultOpen={false}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
<Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
|
||||
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
|
||||
{message.content}
|
||||
</Markdown>
|
||||
<div className="mt-3 flex items-center text-[11px]">
|
||||
@@ -347,15 +379,15 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
|
||||
return (
|
||||
<div className="my-2">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<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-border bg-muted">
|
||||
<div className="overflow-hidden rounded-lg border border-gray-600/30 bg-gray-800 dark:border-gray-700 dark:bg-gray-900">
|
||||
<pre className="overflow-x-auto p-4">
|
||||
<code className="block whitespace-pre font-mono text-sm text-foreground">
|
||||
<code className="block whitespace-pre font-mono text-sm text-gray-100 dark:text-gray-200">
|
||||
{formatted}
|
||||
</code>
|
||||
</pre>
|
||||
@@ -369,7 +401,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
|
||||
// Normal rendering for non-JSON content
|
||||
return message.type === 'assistant' ? (
|
||||
<Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
|
||||
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
|
||||
{content}
|
||||
</Markdown>
|
||||
) : (
|
||||
@@ -400,4 +432,3 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
});
|
||||
|
||||
export default MessageComponent;
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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';
|
||||
|
||||
@@ -51,32 +49,9 @@ 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(
|
||||
() => [
|
||||
{
|
||||
@@ -108,28 +83,18 @@ const MessageCopyControl = ({
|
||||
}, [defaultFormat]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return;
|
||||
|
||||
// Close when clicking outside both the control and the portaled menu.
|
||||
// Close the dropdown when clicking anywhere outside this control.
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
if (!isDropdownOpen) return;
|
||||
const target = event.target as Node;
|
||||
if (dropdownRef.current?.contains(target) || menuRef.current?.contains(target)) {
|
||||
return;
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
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]);
|
||||
|
||||
@@ -205,9 +170,8 @@ const MessageCopyControl = ({
|
||||
{canSelectCopyFormat && (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => (isDropdownOpen ? setIsDropdownOpen(false) : openDropdown())}
|
||||
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
||||
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' })}
|
||||
@@ -222,12 +186,8 @@ const MessageCopyControl = ({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={menuStyle}
|
||||
className="min-w-36 rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||
>
|
||||
{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">
|
||||
{copyFormatOptions.map((option) => {
|
||||
const isSelected = option.format === selectedFormat;
|
||||
return (
|
||||
@@ -236,16 +196,15 @@ const MessageCopyControl = ({
|
||||
type="button"
|
||||
onClick={() => handleFormatChange(option.format)}
|
||||
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-foreground hover:bg-accent'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-xs font-medium">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -29,6 +29,7 @@ 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 =
|
||||
@@ -50,6 +51,8 @@ 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;
|
||||
@@ -79,11 +82,13 @@ 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;
|
||||
}
|
||||
|
||||
@@ -92,6 +97,7 @@ 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";
|
||||
}
|
||||
|
||||
@@ -111,6 +117,8 @@ export default function ProviderSelectionEmptyState({
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
hermesModel,
|
||||
setHermesModel,
|
||||
providerModelCatalog,
|
||||
providerModelsLoading,
|
||||
tasksEnabled,
|
||||
@@ -140,6 +148,7 @@ export default function ProviderSelectionEmptyState({
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
hermesModel,
|
||||
);
|
||||
|
||||
const currentModelLabel = useMemo(() => {
|
||||
@@ -164,12 +173,15 @@ 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],
|
||||
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel, setHermesModel],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
@@ -319,6 +331,10 @@ export default function ProviderSelectionEmptyState({
|
||||
model: opencodeModel,
|
||||
defaultValue: "Ready with OpenCode {{model}}",
|
||||
}),
|
||||
hermes: t("providerSelection.readyPrompt.hermes", {
|
||||
model: hermesModel,
|
||||
defaultValue: "Ready with Hermes {{model}}",
|
||||
}),
|
||||
}[provider]
|
||||
}
|
||||
</p>
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryP
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
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"
|
||||
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"
|
||||
title={`${usedTokens.toLocaleString()} tokens used`}
|
||||
aria-label="Show token usage"
|
||||
>
|
||||
|
||||
@@ -22,6 +22,7 @@ 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;
|
||||
@@ -65,6 +66,7 @@ export default function ToolGroupContainer({
|
||||
onFileOpen,
|
||||
onShowSettings,
|
||||
onGrantToolPermission,
|
||||
autoExpandTools,
|
||||
showRawParameters,
|
||||
showThinking,
|
||||
selectedProject,
|
||||
@@ -131,6 +133,7 @@ export default function ToolGroupContainer({
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const CODE_EDITOR_STORAGE_KEYS = {
|
||||
theme: 'codeEditorTheme',
|
||||
wordWrap: 'codeEditorWordWrap',
|
||||
showMinimap: 'codeEditorShowMinimap',
|
||||
lineNumbers: 'codeEditorLineNumbers',
|
||||
@@ -6,6 +7,7 @@ export const CODE_EDITOR_STORAGE_KEYS = {
|
||||
} as const;
|
||||
|
||||
export const CODE_EDITOR_DEFAULTS = {
|
||||
isDarkMode: true,
|
||||
wordWrap: false,
|
||||
minimapEnabled: true,
|
||||
showLineNumbers: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '../../../utils/api';
|
||||
import type { CodeEditorFile } from '../types/types';
|
||||
import { isBinaryFile } from '../utils/binaryFile';
|
||||
import { getPreviewKind } from '../utils/previewableFile';
|
||||
|
||||
type UseCodeEditorDocumentParams = {
|
||||
file: CodeEditorFile;
|
||||
@@ -23,6 +24,9 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isBinary, setIsBinary] = useState(false);
|
||||
// Some binaries (images, PDFs, audio, video) can be rendered natively, so the
|
||||
// editor shows an inline preview instead of the generic binary placeholder.
|
||||
const previewKind = getPreviewKind(file.name);
|
||||
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
|
||||
// the fallback to `projectPath` preserves older callers that didn't yet
|
||||
// propagate the identifier.
|
||||
@@ -38,8 +42,19 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
setLoading(true);
|
||||
setIsBinary(false);
|
||||
|
||||
// Natively previewable media (image/pdf/audio/video) is rendered by
|
||||
// CodeEditorMediaPreview, so there is nothing to read as text here.
|
||||
// Clear any buffer left over from a previously opened text file so a
|
||||
// stray save can't write stale content over the binary file.
|
||||
if (getPreviewKind(file.name)) {
|
||||
setContent('');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file is binary by extension
|
||||
if (isBinaryFile(file.name)) {
|
||||
setContent('');
|
||||
setIsBinary(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -76,6 +91,12 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Preview-only and binary files have no editable text buffer; never write
|
||||
// them back (e.g. via Cmd/Ctrl+S) or we'd corrupt the file on disk.
|
||||
if (previewKind || isBinaryFile(fileName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
@@ -109,7 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, filePath, fileProjectId]);
|
||||
}, [content, filePath, fileProjectId, previewKind, fileName]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
@@ -134,6 +155,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
saveSuccess,
|
||||
saveError,
|
||||
isBinary,
|
||||
previewKind,
|
||||
fileProjectId,
|
||||
handleSave,
|
||||
handleDownload,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,15 @@ 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) {
|
||||
@@ -24,6 +33,7 @@ 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)
|
||||
@@ -33,13 +43,18 @@ export const useCodeEditorSettings = () => {
|
||||
));
|
||||
const [fontSize, setFontSize] = useState(readFontSize);
|
||||
|
||||
// Keep legacy behavior where the editor writes wrap settings directly.
|
||||
// Keep legacy behavior where the editor writes theme and wrap settings directly.
|
||||
useEffect(() => {
|
||||
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
|
||||
}, [isDarkMode]);
|
||||
|
||||
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));
|
||||
@@ -56,6 +71,8 @@ export const useCodeEditorSettings = () => {
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
setIsDarkMode,
|
||||
wordWrap,
|
||||
setWordWrap,
|
||||
minimapEnabled,
|
||||
|
||||
63
src/components/code-editor/utils/previewableFile.ts
Normal file
63
src/components/code-editor/utils/previewableFile.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Some binary files can't be edited as text, but the browser can still render
|
||||
// them natively (images, PDFs, audio, video). For those we show an inline
|
||||
// preview instead of the generic "binary file" placeholder. Anything not listed
|
||||
// here (zip, exe, avi, mkv, fonts, ...) falls through to the binary message.
|
||||
|
||||
export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio';
|
||||
|
||||
// Single source of truth: every extension the browser can preview, mapped to the
|
||||
// MIME type we apply when the server response has a missing/generic Content-Type.
|
||||
// The preview kind is derived from the MIME type so the two never drift apart.
|
||||
// Formats browsers generally can't play (avi, mkv, flv, wmv) are intentionally
|
||||
// absent and keep the binary fallback.
|
||||
const EXTENSION_MIME: Record<string, string> = {
|
||||
// Images
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
webp: 'image/webp',
|
||||
ico: 'image/x-icon',
|
||||
bmp: 'image/bmp',
|
||||
avif: 'image/avif',
|
||||
apng: 'image/apng',
|
||||
// PDF
|
||||
pdf: 'application/pdf',
|
||||
// Video
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
ogv: 'video/ogg',
|
||||
mov: 'video/quicktime',
|
||||
m4v: 'video/x-m4v',
|
||||
// Audio
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
flac: 'audio/flac',
|
||||
opus: 'audio/opus',
|
||||
oga: 'audio/ogg',
|
||||
ogg: 'audio/ogg',
|
||||
weba: 'audio/webm',
|
||||
};
|
||||
|
||||
const extensionOf = (filename: string): string => filename.split('.').pop()?.toLowerCase() ?? '';
|
||||
|
||||
const kindForMime = (mime: string): PreviewKind | null => {
|
||||
if (mime === 'application/pdf') return 'pdf';
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getPreviewKind = (filename: string): PreviewKind | null => {
|
||||
const mime = EXTENSION_MIME[extensionOf(filename)];
|
||||
return mime ? kindForMime(mime) : null;
|
||||
};
|
||||
|
||||
// MIME type to fall back to when the server returns no/generic Content-Type.
|
||||
// Returns undefined for non-previewable extensions.
|
||||
export const getPreviewMimeType = (filename: string): string | undefined =>
|
||||
EXTENSION_MIME[extensionOf(filename)];
|
||||
@@ -1,10 +1,10 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { unifiedMergeView } from '@codemirror/merge';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { useMemo, useState } from 'react';
|
||||
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';
|
||||
@@ -12,11 +12,13 @@ import type { CodeEditorFile } from '../types/types';
|
||||
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
||||
import { getEditorStyles } from '../utils/editorStyles';
|
||||
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
||||
|
||||
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
||||
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
||||
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
||||
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
||||
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
|
||||
import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview';
|
||||
|
||||
type CodeEditorProps = {
|
||||
file: CodeEditorFile;
|
||||
@@ -43,10 +45,8 @@ 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,
|
||||
@@ -61,6 +61,8 @@ export default function CodeEditor({
|
||||
saveSuccess,
|
||||
saveError,
|
||||
isBinary,
|
||||
previewKind,
|
||||
fileProjectId,
|
||||
handleSave,
|
||||
handleDownload,
|
||||
} = useCodeEditorDocument({
|
||||
@@ -73,6 +75,29 @@ export default function CodeEditor({
|
||||
return extension === 'md' || extension === 'markdown';
|
||||
}, [file.name]);
|
||||
|
||||
const isHtmlPreviewFile = useMemo(() => {
|
||||
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||
return extension === 'html' || extension === 'htm';
|
||||
}, [file.name]);
|
||||
|
||||
const openHtmlPreview = useCallback(() => {
|
||||
const previewWindow = window.open('', '_blank');
|
||||
if (!previewWindow) return;
|
||||
|
||||
previewWindow.opener = null;
|
||||
previewWindow.document.title = file.name;
|
||||
previewWindow.document.body.style.margin = '0';
|
||||
|
||||
const iframe = previewWindow.document.createElement('iframe');
|
||||
iframe.title = file.name;
|
||||
iframe.sandbox.add('allow-forms', 'allow-modals', 'allow-popups', 'allow-scripts');
|
||||
iframe.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;background:white';
|
||||
|
||||
iframe.srcdoc = content;
|
||||
|
||||
previewWindow.document.body.appendChild(iframe);
|
||||
}, [content, file.name]);
|
||||
|
||||
const minimapExtension = useMemo(
|
||||
() => (
|
||||
createMinimapExtension({
|
||||
@@ -165,6 +190,30 @@ export default function CodeEditor({
|
||||
);
|
||||
}
|
||||
|
||||
// Natively previewable media (image/pdf/audio/video) is rendered inline
|
||||
// instead of showing the generic "cannot be displayed" placeholder.
|
||||
if (previewKind) {
|
||||
return (
|
||||
<CodeEditorMediaPreview
|
||||
file={file}
|
||||
kind={previewKind}
|
||||
projectId={fileProjectId}
|
||||
isSidebar={isSidebar}
|
||||
isFullscreen={isFullscreen}
|
||||
onClose={onClose}
|
||||
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
|
||||
labels={{
|
||||
loading: t('filePreview.loading', 'Loading preview...'),
|
||||
error: t('filePreview.error', 'Unable to display this file.'),
|
||||
openInNewTab: t('filePreview.openInNewTab', 'Open in new tab'),
|
||||
fullscreen: t('actions.fullscreen', 'Fullscreen'),
|
||||
exitFullscreen: t('actions.exitFullscreen', 'Exit fullscreen'),
|
||||
close: t('actions.close', 'Close'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Binary file display
|
||||
if (isBinary) {
|
||||
return (
|
||||
@@ -200,10 +249,12 @@ export default function CodeEditor({
|
||||
isSidebar={isSidebar}
|
||||
isFullscreen={isFullscreen}
|
||||
isMarkdownFile={isMarkdownFile}
|
||||
isHtmlPreviewFile={isHtmlPreviewFile}
|
||||
markdownPreview={markdownPreview}
|
||||
saving={saving}
|
||||
saveSuccess={saveSuccess}
|
||||
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
||||
onOpenHtmlPreview={openHtmlPreview}
|
||||
onOpenSettings={() => paletteOps.openSettings('appearance')}
|
||||
onDownload={handleDownload}
|
||||
onSave={handleSave}
|
||||
@@ -213,6 +264,7 @@ export default function CodeEditor({
|
||||
showingChanges: t('header.showingChanges'),
|
||||
editMarkdown: t('actions.editMarkdown'),
|
||||
previewMarkdown: t('actions.previewMarkdown'),
|
||||
previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'),
|
||||
settings: t('toolbar.settings'),
|
||||
download: t('actions.download'),
|
||||
save: t('actions.save'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
||||
|
||||
import type { CodeEditorFile } from '../../types/types';
|
||||
|
||||
type CodeEditorHeaderProps = {
|
||||
@@ -6,10 +7,12 @@ type CodeEditorHeaderProps = {
|
||||
isSidebar: boolean;
|
||||
isFullscreen: boolean;
|
||||
isMarkdownFile: boolean;
|
||||
isHtmlPreviewFile: boolean;
|
||||
markdownPreview: boolean;
|
||||
saving: boolean;
|
||||
saveSuccess: boolean;
|
||||
onToggleMarkdownPreview: () => void;
|
||||
onOpenHtmlPreview: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onDownload: () => void;
|
||||
onSave: () => void;
|
||||
@@ -19,6 +22,7 @@ type CodeEditorHeaderProps = {
|
||||
showingChanges: string;
|
||||
editMarkdown: string;
|
||||
previewMarkdown: string;
|
||||
previewHtml: string;
|
||||
settings: string;
|
||||
download: string;
|
||||
save: string;
|
||||
@@ -35,10 +39,12 @@ export default function CodeEditorHeader({
|
||||
isSidebar,
|
||||
isFullscreen,
|
||||
isMarkdownFile,
|
||||
isHtmlPreviewFile,
|
||||
markdownPreview,
|
||||
saving,
|
||||
saveSuccess,
|
||||
onToggleMarkdownPreview,
|
||||
onOpenHtmlPreview,
|
||||
onOpenSettings,
|
||||
onDownload,
|
||||
onSave,
|
||||
@@ -82,6 +88,17 @@ export default function CodeEditorHeader({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isHtmlPreviewFile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenHtmlPreview}
|
||||
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
title={labels.previewHtml}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { authenticatedFetch } from '../../../../utils/api';
|
||||
import type { CodeEditorFile } from '../../types/types';
|
||||
import { getPreviewMimeType, type PreviewKind } from '../../utils/previewableFile';
|
||||
|
||||
type CodeEditorMediaPreviewProps = {
|
||||
file: CodeEditorFile;
|
||||
kind: PreviewKind;
|
||||
// DB projectId used to build the raw-content URL; falls back to projectPath
|
||||
// for older callers, mirroring useCodeEditorDocument.
|
||||
projectId?: string;
|
||||
isSidebar: boolean;
|
||||
isFullscreen: boolean;
|
||||
onClose: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
labels: {
|
||||
loading: string;
|
||||
error: string;
|
||||
openInNewTab: string;
|
||||
fullscreen: string;
|
||||
exitFullscreen: string;
|
||||
close: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Reject a "PDF" whose bytes aren't actually a PDF before handing it to the
|
||||
// same-origin iframe, so a mislabeled HTML/SVG file can't run in the app origin.
|
||||
const PDF_HEADER_SCAN_BYTES = 1024;
|
||||
|
||||
const looksLikePdf = async (blob: Blob): Promise<boolean> => {
|
||||
const header = await blob.slice(0, PDF_HEADER_SCAN_BYTES).arrayBuffer();
|
||||
// PDFs must contain the "%PDF-" marker at the very start of the file.
|
||||
return new TextDecoder('latin1').decode(header).includes('%PDF-');
|
||||
};
|
||||
|
||||
export default function CodeEditorMediaPreview({
|
||||
file,
|
||||
kind,
|
||||
projectId,
|
||||
isSidebar,
|
||||
isFullscreen,
|
||||
onClose,
|
||||
onToggleFullscreen,
|
||||
labels,
|
||||
}: CodeEditorMediaPreviewProps) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Identifies which file the current `url` was loaded for. Rendering is gated on
|
||||
// this so a blob from a previously-opened file can never show under the new
|
||||
// file (the editor reuses this component instance across files).
|
||||
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
||||
const sourceKey = `${projectId ?? ''}:${file.path}:${kind}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setUrl(null);
|
||||
setLoadedKey(null);
|
||||
setError(labels.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let objectUrl: string | null = null;
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadMedia = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setUrl(null);
|
||||
|
||||
// The content endpoint requires the auth header, so we fetch the bytes
|
||||
// ourselves and hand the media element a blob URL instead of a bare src.
|
||||
// Fetching a blob (rather than streaming) also lets <video>/<audio> seek.
|
||||
const contentUrl = `/api/projects/${projectId}/files/content?path=${encodeURIComponent(file.path)}`;
|
||||
const response = await authenticatedFetch(contentUrl, { signal: controller.signal });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// Pick the MIME type to expose to the browser. Preserve a valid
|
||||
// Content-Type from the server, but supply an extension-specific
|
||||
// default when it is missing or generic (application/octet-stream),
|
||||
// otherwise formats like webm/ogg/flac/svg won't render.
|
||||
const fallbackMime = getPreviewMimeType(file.name);
|
||||
const isGenericType = !blob.type || blob.type === 'application/octet-stream';
|
||||
const isMislabeledVideo = kind === 'video' && Boolean(fallbackMime) && !blob.type.startsWith('video/');
|
||||
let outType = isGenericType || isMislabeledVideo ? (fallbackMime ?? blob.type) : blob.type;
|
||||
|
||||
if (kind === 'pdf') {
|
||||
// The PDF renders in a same-origin <iframe>, so verify the bytes are
|
||||
// really a PDF and pin the type to application/pdf. That forces the
|
||||
// browser's PDF handler and prevents a mislabeled HTML/SVG file from
|
||||
// executing scripts in the app's origin.
|
||||
if (!(await looksLikePdf(blob))) {
|
||||
throw new Error('File is not a valid PDF');
|
||||
}
|
||||
outType = 'application/pdf';
|
||||
}
|
||||
|
||||
const typed = outType && outType !== blob.type ? new Blob([blob], { type: outType }) : blob;
|
||||
objectUrl = URL.createObjectURL(typed);
|
||||
|
||||
// The cleanup may have already run (deps changed during an await), in
|
||||
// which case it revoked nothing because objectUrl was still null. Don't
|
||||
// publish a URL the cleanup will never revoke — drop it ourselves.
|
||||
if (controller.signal.aborted) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setUrl(objectUrl);
|
||||
setLoadedKey(sourceKey);
|
||||
} catch (loadError: unknown) {
|
||||
if (loadError instanceof Error && loadError.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('Error loading preview:', loadError);
|
||||
setError(labels.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadMedia();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [file.path, file.name, projectId, kind, sourceKey, labels.error]);
|
||||
|
||||
// Only expose the blob once it matches the file currently being shown, so a
|
||||
// stale URL from the previous file is never rendered during a switch.
|
||||
const currentUrl = url && loadedKey === sourceKey ? url : null;
|
||||
|
||||
// SVGs render safely inline via <img> (scripts don't execute there), but the
|
||||
// open-in-new-tab link is a top-level navigation. A blob URL inherits the
|
||||
// app's origin, so a user-controlled SVG with an embedded <script> would run
|
||||
// as same-origin script. Withhold the new-tab action for SVGs.
|
||||
const isSvg = getPreviewMimeType(file.name) === 'image/svg+xml';
|
||||
const canOpenInNewTab = Boolean(currentUrl) && !isSvg;
|
||||
|
||||
const renderMedia = () => {
|
||||
if (!currentUrl) return null;
|
||||
switch (kind) {
|
||||
case 'image':
|
||||
return (
|
||||
<img
|
||||
src={currentUrl}
|
||||
alt={file.name}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
);
|
||||
case 'pdf':
|
||||
// Not sandboxed on purpose: the browser's built-in PDF viewer refuses to
|
||||
// load inside a sandboxed frame (any `sandbox` value yields a broken
|
||||
// viewer). Script execution is instead prevented upstream by validating
|
||||
// the PDF magic bytes and pinning the blob's MIME type to application/pdf.
|
||||
return <iframe src={currentUrl} title={file.name} className="h-full w-full border-0 bg-white" />;
|
||||
case 'video':
|
||||
return (
|
||||
<video src={currentUrl} controls className="max-h-full max-w-full" autoPlay={false}>
|
||||
{labels.error}
|
||||
</video>
|
||||
);
|
||||
case 'audio':
|
||||
return (
|
||||
<div className="flex w-full max-w-xl flex-col items-center gap-4 px-6">
|
||||
<p className="max-w-full truncate text-sm text-muted-foreground">{file.name}</p>
|
||||
<audio src={currentUrl} controls className="w-full">
|
||||
{labels.error}
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const previewBody = (
|
||||
<div className="relative flex h-full w-full flex-col items-center justify-center bg-muted/30 p-2">
|
||||
{loading && (
|
||||
<div className="text-sm text-muted-foreground">{labels.loading}</div>
|
||||
)}
|
||||
|
||||
{!loading && currentUrl && renderMedia()}
|
||||
|
||||
{!loading && !currentUrl && (
|
||||
<div className="flex flex-col items-center gap-3 p-8 text-center text-muted-foreground">
|
||||
<p className="text-sm">{error || labels.error}</p>
|
||||
<p className="break-all text-xs">{file.path}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const headerActions = (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{canOpenInNewTab && currentUrl && (
|
||||
<a
|
||||
href={currentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
aria-label={labels.openInNewTab}
|
||||
title={labels.openInNewTab}
|
||||
>
|
||||
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{!isSidebar && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFullscreen}
|
||||
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
aria-label={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
aria-label={labels.close}
|
||||
title={labels.close}
|
||||
>
|
||||
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const header = (
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
|
||||
</div>
|
||||
{headerActions}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isSidebar) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
{header}
|
||||
{previewBody}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerClassName = isFullscreen
|
||||
? 'fixed inset-0 z-[9999] bg-background flex flex-col'
|
||||
: 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';
|
||||
|
||||
const innerClassName = isFullscreen
|
||||
? 'bg-background flex flex-col w-full h-full'
|
||||
: 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]';
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<div className={innerClassName}>
|
||||
{header}
|
||||
{previewBody}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark as prismOneDark, oneLight as prismOneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { copyTextToClipboard } from '../../../../../utils/clipboard';
|
||||
import { useTheme } from '../../../../../contexts/ThemeContext';
|
||||
|
||||
type MarkdownCodeBlockProps = {
|
||||
inline?: boolean;
|
||||
@@ -17,7 +16,6 @@ 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);
|
||||
@@ -52,22 +50,20 @@ export default function MarkdownCodeBlock({
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
})}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={isDarkMode ? prismOneDark : prismOneLight}
|
||||
style={prismOneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: '0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||
...(isDarkMode ? {} : { background: 'hsl(var(--muted))' }),
|
||||
}}
|
||||
codeTagProps={{ style: isDarkMode ? {} : { background: 'transparent' } }}
|
||||
>
|
||||
{rawContent}
|
||||
</SyntaxHighlighter>
|
||||
|
||||
@@ -12,9 +12,6 @@ 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}
|
||||
|
||||
@@ -189,7 +189,7 @@ export default function GitPanelHeader({
|
||||
<button
|
||||
onClick={requestPublishConfirmation}
|
||||
disabled={anyPending}
|
||||
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"
|
||||
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"
|
||||
title={`Publish "${currentBranch}" to ${remoteName}`}
|
||||
>
|
||||
<Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />
|
||||
|
||||
16
src/components/llm-logo-provider/HermesLogo.tsx
Normal file
16
src/components/llm-logo-provider/HermesLogo.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
type HermesLogoProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function HermesLogo({ className = 'w-5 h-5' }: HermesLogoProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Hermes">
|
||||
<rect width="24" height="24" rx="6" fill="#047857" />
|
||||
<path
|
||||
d="M6.2 6.5h2.4v4.3h6.8V6.5h2.4v11h-2.4v-4.6H8.6v4.6H6.2v-11Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path d="M9.3 4.7h5.4l-1.2 1.2h-3L9.3 4.7Z" fill="#A7F3D0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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 = {
|
||||
@@ -30,5 +31,9 @@ export default function SessionProviderLogo({
|
||||
return <OpenCodeLogo className={className} />;
|
||||
}
|
||||
|
||||
if (provider === 'hermes') {
|
||||
return <HermesLogo className={className} />;
|
||||
}
|
||||
|
||||
return <ClaudeLogo className={className} />;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ function MainContent({
|
||||
newSessionTrigger,
|
||||
}: MainContentProps) {
|
||||
const { preferences } = useUiPreferences();
|
||||
const { showRawParameters, showThinking, sendByCtrlEnter } = preferences;
|
||||
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
|
||||
|
||||
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
|
||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
|
||||
@@ -170,8 +170,10 @@ function MainContent({
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onSessionEstablished={onSessionEstablished}
|
||||
onShowSettings={onShowSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
externalMessageUpdate={externalMessageUpdate}
|
||||
newSessionTrigger={newSessionTrigger}
|
||||
|
||||
@@ -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="truncate text-sm font-semibold leading-tight text-foreground">
|
||||
<h2 className="scrollbar-hide overflow-x-auto whitespace-nowrap text-sm font-semibold leading-tight text-foreground">
|
||||
{getSessionTitle(selectedSession)}
|
||||
</h2>
|
||||
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
hermes: 'Hermes',
|
||||
};
|
||||
|
||||
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
|
||||
@@ -14,6 +15,7 @@ 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[]> = {
|
||||
@@ -22,6 +24,7 @@ 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'];
|
||||
@@ -29,11 +32,12 @@ 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-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',
|
||||
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',
|
||||
};
|
||||
|
||||
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
||||
@@ -42,6 +46,7 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_MCP_FORM: McpFormState = {
|
||||
|
||||
@@ -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-primary" />
|
||||
<Server className="h-5 w-5 text-purple-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FolderOpen, Globe, X } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Input } from '../../../../shared/view/ui';
|
||||
@@ -120,8 +119,8 @@ export default function McpServerFormModal({
|
||||
const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider];
|
||||
const showCodexOnlyFields = provider === 'codex' && !isGlobalMode;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4">
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] 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>
|
||||
@@ -419,7 +418,7 @@ export default function McpServerFormModal({
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !canSubmit}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting
|
||||
? t('mcpForm.actions.saving')
|
||||
@@ -430,7 +429,6 @@ export default function McpServerFormModal({
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,18 +148,11 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-2xl">
|
||||
<OnboardingStepProgress currentStep={currentStep} />
|
||||
|
||||
<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">
|
||||
<div className="rounded-lg border border-border bg-card p-8 shadow-lg">
|
||||
{currentStep === 0 ? (
|
||||
<GitConfigurationStep
|
||||
gitName={gitName}
|
||||
@@ -176,12 +169,12 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between border-t border-border pt-5">
|
||||
<div className="mt-8 flex items-center justify-between border-t border-border pt-6">
|
||||
<button
|
||||
onClick={handlePreviousStep}
|
||||
disabled={currentStep === 0 || isSubmitting}
|
||||
@@ -196,7 +189,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
||||
<button
|
||||
onClick={handleNextStep}
|
||||
disabled={!isCurrentStepValid || isSubmitting}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
@@ -214,7 +207,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
disabled={isSubmitting}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
@@ -232,7 +225,6 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,26 +31,26 @@ export default function AgentConnectionCard({
|
||||
: status.error || 'Not connected';
|
||||
|
||||
return (
|
||||
<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}`}>
|
||||
<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}`}>
|
||||
<SessionProviderLogo provider={provider} className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 font-medium text-foreground">
|
||||
{title}
|
||||
{status.authenticated && <Check className="h-3.5 w-3.5 flex-shrink-0 text-emerald-500" />}
|
||||
{status.authenticated && <Check className="h-4 w-4 text-green-500" />}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{statusText}</div>
|
||||
<div className="text-xs text-muted-foreground">{statusText}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.authenticated && !status.loading && (
|
||||
<button
|
||||
onClick={onLogin}
|
||||
className={`${loginButtonClassName} flex-shrink-0 rounded-lg px-4 py-1.5 text-sm font-medium text-white transition-colors`}
|
||||
className={`${loginButtonClassName} rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors`}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
|
||||
@@ -51,15 +51,15 @@ export default function AgentConnectionsStep({
|
||||
onOpenProviderLogin,
|
||||
}: AgentConnectionsStepProps) {
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
Login to one or more AI coding assistants. All are optional.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="-mr-1 max-h-[38vh] space-y-2 overflow-y-auto pr-1">
|
||||
<div className="space-y-3">
|
||||
{providerCards.map((providerCard) => (
|
||||
<AgentConnectionCard
|
||||
key={providerCard.provider}
|
||||
@@ -74,7 +74,9 @@ export default function AgentConnectionsStep({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">You can configure these later in Settings.</p>
|
||||
<div className="pt-2 text-center text-sm text-muted-foreground">
|
||||
<p>You can configure these later in Settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,13 +16,13 @@ export default function GitConfigurationStep({
|
||||
onGitEmailChange,
|
||||
}: GitConfigurationStepProps) {
|
||||
return (
|
||||
<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 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>
|
||||
<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">
|
||||
<h2 className="mb-2 text-2xl font-bold text-foreground">Git Configuration</h2>
|
||||
<p className="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-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"
|
||||
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"
|
||||
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-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"
|
||||
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"
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
|
||||
@@ -11,7 +11,7 @@ const onboardingSteps = [
|
||||
|
||||
export default function OnboardingStepProgress({ currentStep }: OnboardingStepProgressProps) {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<div className="mb-8">
|
||||
<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-10 w-10 items-center justify-center rounded-full border-2 transition-all duration-200 ${
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 transition-colors duration-200 ${
|
||||
isCompleted
|
||||
? 'border-emerald-500 bg-emerald-500 text-white shadow-lg shadow-emerald-500/25'
|
||||
? 'border-green-500 bg-green-500 text-white'
|
||||
: isActive
|
||||
? 'border-primary bg-primary text-primary-foreground shadow-lg shadow-primary/25'
|
||||
: 'border-border bg-card text-muted-foreground'
|
||||
? 'border-blue-600 bg-blue-600 text-white'
|
||||
: 'border-border bg-background text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? <Check className="h-5 w-5" /> : <Icon className="h-5 w-5" />}
|
||||
{isCompleted ? <Check className="h-6 w-6" /> : <Icon className="h-6 w-6" />}
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 text-center">
|
||||
<div className="mt-2 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-emerald-500' : 'bg-border'}`} />
|
||||
<div className={`mx-2 h-0.5 flex-1 transition-colors duration-200 ${isCompleted ? 'bg-green-500' : 'bg-border'}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ export type ProviderAuthStatus = {
|
||||
|
||||
export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
|
||||
|
||||
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
|
||||
|
||||
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
|
||||
claude: '/api/providers/claude/auth/status',
|
||||
@@ -18,6 +18,7 @@ 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 => ({
|
||||
@@ -26,4 +27,5 @@ export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuth
|
||||
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 },
|
||||
hermes: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ type ProviderLoginModalProps = {
|
||||
provider?: LLMProvider;
|
||||
onComplete?: (exitCode: number) => void;
|
||||
customCommand?: string;
|
||||
customTitle?: string;
|
||||
isAuthenticated?: boolean;
|
||||
};
|
||||
|
||||
@@ -41,6 +42,10 @@ const getProviderCommand = ({
|
||||
return 'opencode auth login';
|
||||
}
|
||||
|
||||
if (provider === 'hermes') {
|
||||
return 'hermes model';
|
||||
}
|
||||
|
||||
return 'gemini status';
|
||||
};
|
||||
|
||||
@@ -49,6 +54,7 @@ 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';
|
||||
};
|
||||
|
||||
@@ -58,6 +64,7 @@ export default function ProviderLoginModal({
|
||||
provider = 'claude',
|
||||
onComplete,
|
||||
customCommand,
|
||||
customTitle,
|
||||
isAuthenticated = false,
|
||||
}: ProviderLoginModalProps) {
|
||||
if (!isOpen) {
|
||||
@@ -65,7 +72,7 @@ export default function ProviderLoginModal({
|
||||
}
|
||||
|
||||
const command = getProviderCommand({ provider, customCommand, isAuthenticated });
|
||||
const title = getProviderTitle(provider);
|
||||
const title = customTitle || getProviderTitle(provider);
|
||||
|
||||
const handleComplete = (exitCode: number) => {
|
||||
onComplete?.(exitCode);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
Brain,
|
||||
Eye,
|
||||
Languages,
|
||||
Maximize2,
|
||||
Mic,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { PreferenceToggleItem } from './types';
|
||||
|
||||
export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';
|
||||
@@ -15,7 +16,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-muted/60 hover:bg-accent transition-colors border border-transparent hover:border-border';
|
||||
'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';
|
||||
|
||||
export const TOGGLE_ROW_CLASS = `${SETTING_ROW_CLASS} cursor-pointer`;
|
||||
|
||||
@@ -23,6 +24,11 @@ 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',
|
||||
@@ -35,6 +41,14 @@ 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',
|
||||
|
||||
@@ -2,8 +2,10 @@ import type { CSSProperties } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type PreferenceToggleKey =
|
||||
| 'autoExpandTools'
|
||||
| 'showRawParameters'
|
||||
| 'showThinking'
|
||||
| 'autoScrollToBottom'
|
||||
| 'sendByCtrlEnter'
|
||||
| 'voiceEnabled';
|
||||
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
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';
|
||||
|
||||
@@ -49,11 +48,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-foreground">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
{isDarkMode ? (
|
||||
<Moon className="h-4 w-4 text-muted-foreground" />
|
||||
<Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4 text-muted-foreground" />
|
||||
<Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
{t('quickSettings.darkMode')}
|
||||
</span>
|
||||
@@ -66,9 +65,13 @@ 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-muted-foreground">
|
||||
<p className="ml-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('quickSettings.sendByCtrlEnterDescription')}
|
||||
</p>
|
||||
</QuickSettingsSection>
|
||||
|
||||
@@ -5,9 +5,9 @@ export default function QuickSettingsPanelHeader() {
|
||||
const { t } = useTranslation('settings');
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<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" />
|
||||
{t('quickSettings.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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';
|
||||
@@ -24,11 +22,15 @@ 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,
|
||||
|
||||
@@ -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-muted-foreground">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{title}
|
||||
</h4>
|
||||
{children}
|
||||
|
||||
@@ -17,8 +17,8 @@ function QuickSettingsToggleRow({
|
||||
}: QuickSettingsToggleRowProps) {
|
||||
return (
|
||||
<label className={TOGGLE_ROW_CLASS}>
|
||||
<span className="flex items-center gap-2 text-sm text-foreground">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<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" />
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
|
||||
@@ -39,12 +39,13 @@ 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'];
|
||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
|
||||
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,
|
||||
|
||||
@@ -86,6 +86,7 @@ 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',
|
||||
@@ -163,6 +164,8 @@ 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,
|
||||
@@ -230,8 +233,10 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openLoginForProvider = useCallback((provider: AgentProvider) => {
|
||||
const openLoginForProvider = useCallback((provider: AgentProvider, customCommand?: string, customTitle?: string) => {
|
||||
setLoginProvider(provider);
|
||||
setLoginCommand(customCommand);
|
||||
setLoginTitle(customTitle);
|
||||
setShowLoginModal(true);
|
||||
}, []);
|
||||
|
||||
@@ -329,6 +334,7 @@ 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));
|
||||
@@ -415,6 +421,8 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
showLoginModal,
|
||||
setShowLoginModal,
|
||||
loginProvider,
|
||||
loginCommand,
|
||||
loginTitle,
|
||||
handleLoginComplete,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export type CursorPermissionsState = {
|
||||
};
|
||||
|
||||
export type CodeEditorSettingsState = {
|
||||
theme: 'dark' | 'light';
|
||||
wordWrap: boolean;
|
||||
showMinimap: boolean;
|
||||
lineNumbers: boolean;
|
||||
|
||||
@@ -58,6 +58,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
showLoginModal,
|
||||
setShowLoginModal,
|
||||
loginProvider,
|
||||
loginCommand,
|
||||
loginTitle,
|
||||
handleLoginComplete,
|
||||
} = useSettingsController({
|
||||
isOpen,
|
||||
@@ -168,6 +170,7 @@ 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)}
|
||||
@@ -231,6 +234,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
onClose={() => setShowLoginModal(false)}
|
||||
provider={loginProvider || 'claude'}
|
||||
onComplete={handleLoginComplete}
|
||||
customCommand={loginCommand}
|
||||
customTitle={loginTitle}
|
||||
isAuthenticated={isAuthenticated}
|
||||
/>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -21,6 +22,7 @@ export default function AppearanceSettingsTab({
|
||||
projectSortOrder,
|
||||
onProjectSortOrderChange,
|
||||
codeEditorSettings,
|
||||
onCodeEditorThemeChange,
|
||||
onCodeEditorWordWrapChange,
|
||||
onCodeEditorShowMinimapChange,
|
||||
onCodeEditorLineNumbersChange,
|
||||
@@ -67,6 +69,17 @@ 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')}
|
||||
|
||||
@@ -12,7 +12,7 @@ type AgentListItemProps = {
|
||||
|
||||
type AgentConfig = {
|
||||
name: string;
|
||||
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc';
|
||||
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc' | 'emerald';
|
||||
};
|
||||
|
||||
const agentConfig: Record<AgentProvider, AgentConfig> = {
|
||||
@@ -36,6 +36,10 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
|
||||
name: 'OpenCode',
|
||||
color: 'zinc',
|
||||
},
|
||||
hermes: {
|
||||
name: 'Hermes',
|
||||
color: 'emerald',
|
||||
},
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
@@ -54,6 +58,9 @@ const colorClasses = {
|
||||
zinc: {
|
||||
dot: 'bg-zinc-500',
|
||||
},
|
||||
emerald: {
|
||||
dot: 'bg-emerald-600',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default function AgentListItem({
|
||||
|
||||
@@ -29,29 +29,33 @@ export default function AgentsSettingsTab({
|
||||
), [selectedAgent]);
|
||||
|
||||
const visibleAgents = useMemo<AgentProvider[]>(() => {
|
||||
return ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
return ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
|
||||
}, []);
|
||||
|
||||
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
|
||||
claude: {
|
||||
authStatus: providerAuthStatus.claude,
|
||||
onLogin: () => onProviderLogin('claude'),
|
||||
onLogin: (customCommand, customTitle) => onProviderLogin('claude', customCommand, customTitle),
|
||||
},
|
||||
cursor: {
|
||||
authStatus: providerAuthStatus.cursor,
|
||||
onLogin: () => onProviderLogin('cursor'),
|
||||
onLogin: (customCommand, customTitle) => onProviderLogin('cursor', customCommand, customTitle),
|
||||
},
|
||||
codex: {
|
||||
authStatus: providerAuthStatus.codex,
|
||||
onLogin: () => onProviderLogin('codex'),
|
||||
onLogin: (customCommand, customTitle) => onProviderLogin('codex', customCommand, customTitle),
|
||||
},
|
||||
gemini: {
|
||||
authStatus: providerAuthStatus.gemini,
|
||||
onLogin: () => onProviderLogin('gemini'),
|
||||
onLogin: (customCommand, customTitle) => onProviderLogin('gemini', customCommand, customTitle),
|
||||
},
|
||||
opencode: {
|
||||
authStatus: providerAuthStatus.opencode,
|
||||
onLogin: () => onProviderLogin('opencode'),
|
||||
onLogin: (customCommand, customTitle) => onProviderLogin('opencode', customCommand, customTitle),
|
||||
},
|
||||
hermes: {
|
||||
authStatus: providerAuthStatus.hermes,
|
||||
onLogin: (customCommand, customTitle) => onProviderLogin('hermes', customCommand, customTitle),
|
||||
},
|
||||
}), [
|
||||
onProviderLogin,
|
||||
@@ -60,6 +64,7 @@ export default function AgentsSettingsTab({
|
||||
providerAuthStatus.cursor,
|
||||
providerAuthStatus.gemini,
|
||||
providerAuthStatus.opencode,
|
||||
providerAuthStatus.hermes,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
hermes: 'Hermes',
|
||||
};
|
||||
|
||||
export default function AgentSelectorSection({
|
||||
@@ -25,7 +26,8 @@ export default function AgentSelectorSection({
|
||||
agent === 'claude' ? 'bg-blue-500' :
|
||||
agent === 'cursor' ? 'bg-purple-500' :
|
||||
agent === 'gemini' ? 'bg-indigo-500' :
|
||||
agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60';
|
||||
agent === 'opencode' ? 'bg-zinc-500' :
|
||||
agent === 'hermes' ? 'bg-emerald-600' : 'bg-foreground/60';
|
||||
|
||||
return (
|
||||
<Pill
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { LogIn } from 'lucide-react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
Layers3,
|
||||
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';
|
||||
@@ -7,7 +13,7 @@ import type { AgentProvider, AuthStatus } from '../../../../../types/types';
|
||||
type AccountContentProps = {
|
||||
agent: AgentProvider;
|
||||
authStatus: AuthStatus;
|
||||
onLogin: () => void;
|
||||
onLogin: (customCommand?: string, customTitle?: string) => void;
|
||||
};
|
||||
|
||||
type AgentVisualConfig = {
|
||||
@@ -63,8 +69,59 @@ 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;
|
||||
description: string;
|
||||
command: string;
|
||||
title: string;
|
||||
icon: typeof Layers3;
|
||||
};
|
||||
|
||||
type HermesActionGroup = {
|
||||
title: string;
|
||||
actions: HermesAction[];
|
||||
};
|
||||
|
||||
const hermesActionGroups: HermesActionGroup[] = [
|
||||
{
|
||||
title: 'Setup',
|
||||
actions: [
|
||||
{
|
||||
label: 'Provider setup',
|
||||
description: 'Configure provider credentials and the active model.',
|
||||
command: 'hermes model',
|
||||
title: 'Hermes Provider Setup',
|
||||
icon: Layers3,
|
||||
},
|
||||
{
|
||||
label: 'Credential pools',
|
||||
description: 'Manage API keys and OAuth credentials.',
|
||||
command: 'hermes auth',
|
||||
title: 'Hermes Credential Pools',
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
label: 'ACP check',
|
||||
description: 'Validate the Hermes ACP adapter.',
|
||||
command: 'hermes acp --check',
|
||||
title: 'Hermes ACP Check',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const config = agentConfig[agent];
|
||||
@@ -133,7 +190,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onLogin}
|
||||
onClick={() => onLogin()}
|
||||
className={`${config.buttonClass} text-white`}
|
||||
size="sm"
|
||||
>
|
||||
@@ -144,6 +201,43 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agent === 'hermes' && (
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<div className={`mb-3 font-medium ${config.textClass}`}>
|
||||
{t('agents.hermes.actions.title', { defaultValue: 'Hermes tools' })}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{hermesActionGroups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<div className={`mb-2 text-xs font-semibold uppercase ${config.subtextClass}`}>
|
||||
{group.title}
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{group.actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
key={action.command}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto justify-start gap-3 border-border/70 bg-background/70 px-3 py-2 text-left"
|
||||
onClick={() => onLogin(action.command, action.title)}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">{action.label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{action.description}</span>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authStatus.error && (
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
|
||||
export type AgentContext = {
|
||||
authStatus: AuthStatus;
|
||||
onLogin: () => void;
|
||||
onLogin: (customCommand?: string, customTitle?: string) => 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) => void;
|
||||
onProviderLogin: (provider: AgentProvider, customCommand?: string, customTitle?: string) => void;
|
||||
claudePermissions: ClaudePermissionsState;
|
||||
onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
|
||||
cursorPermissions: CursorPermissionsState;
|
||||
|
||||
@@ -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 md:hidden"
|
||||
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"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -5,6 +5,9 @@ import type {
|
||||
ApiResponse,
|
||||
ProviderSkill,
|
||||
ProviderSkillCreatePayload,
|
||||
ProviderSkillRegistryActionResponse,
|
||||
ProviderSkillRegistryResult,
|
||||
ProviderSkillRegistrySearchResponse,
|
||||
ProviderSkillsResponse,
|
||||
SkillsProject,
|
||||
SkillsProvider,
|
||||
@@ -197,6 +200,50 @@ 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}`;
|
||||
@@ -221,6 +268,10 @@ 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]);
|
||||
@@ -250,7 +301,10 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
|
||||
setIsLoadingProjectScopes(false);
|
||||
setLoadError(null);
|
||||
|
||||
let nextSkills = cachedEntry && !options.force ? cachedEntry.skills : [];
|
||||
// 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 firstError: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -319,12 +373,86 @@ 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(() => {
|
||||
@@ -342,7 +470,15 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
|
||||
isLoadingProjectScopes,
|
||||
loadError,
|
||||
saveStatus,
|
||||
registryResults,
|
||||
registryError,
|
||||
registryStatus,
|
||||
registryBusyKey,
|
||||
addSkills,
|
||||
refreshSkills,
|
||||
searchRegistry,
|
||||
installRegistrySkill,
|
||||
uninstallRegistrySkill,
|
||||
runRegistryMaintenance,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,28 @@ 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;
|
||||
|
||||
@@ -2,14 +2,17 @@ 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';
|
||||
@@ -62,6 +65,7 @@ const PROVIDER_NAMES: Record<SkillsProvider, string> = {
|
||||
cursor: 'Cursor',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
hermes: 'Hermes',
|
||||
};
|
||||
|
||||
const PROVIDER_SKILL_PATHS: Record<Exclude<SkillsProvider, 'opencode'>, string> = {
|
||||
@@ -69,8 +73,30 @@ 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',
|
||||
@@ -209,13 +235,23 @@ 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);
|
||||
|
||||
@@ -227,6 +263,9 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
||||
setSubmitError(null);
|
||||
setIsSubmitting(false);
|
||||
setSearchQuery('');
|
||||
setRegistryQuery('');
|
||||
setIsAddDialogOpen(false);
|
||||
setAddMode('upload');
|
||||
}, [selectedProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -354,6 +393,7 @@ 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 {
|
||||
@@ -361,6 +401,231 @@ 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="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-muted/15 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
Hub Maintenance
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{HERMES_SKILL_ACTIONS.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
key={action.action}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-muted-foreground hover:text-foreground"
|
||||
title={action.description}
|
||||
disabled={registryBusyKey === action.action}
|
||||
onClick={() => void runRegistryMaintenance(action.action)}
|
||||
>
|
||||
{registryBusyKey === action.action
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <Icon className="h-4 w-4" />}
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{registryResults.length > 0 && (
|
||||
<div className="grid max-h-[320px] gap-2 overflow-y-auto pr-1">
|
||||
{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>
|
||||
)}
|
||||
</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">
|
||||
@@ -376,160 +641,127 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
||||
</div>
|
||||
</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 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>
|
||||
</div>
|
||||
|
||||
<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.
|
||||
{isAddDialogOpen && (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close add skill dialog"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setIsAddDialogOpen(false)}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="add-skill-title"
|
||||
className="relative z-[10001] max-h-[90vh] w-full max-w-3xl overflow-y-auto rounded-xl border bg-popover text-popover-foreground shadow-lg"
|
||||
>
|
||||
<div className="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 id="add-skill-title" 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.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
<Button
|
||||
<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="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full sm:w-auto"
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm transition-colors',
|
||||
addMode === 'upload'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => setAddMode('upload')}
|
||||
>
|
||||
<FileUp className="h-4 w-4" />
|
||||
Choose Files
|
||||
</Button>
|
||||
<Button
|
||||
Upload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
className="w-full sm:w-auto"
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm transition-colors',
|
||||
addMode === 'hub'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => setAddMode('hub')}
|
||||
>
|
||||
<FolderUp className="h-4 w-4" />
|
||||
Choose Folder
|
||||
</Button>
|
||||
<Compass className="h-4 w-4" />
|
||||
Skills Hub
|
||||
</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-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="space-y-4 p-4">
|
||||
{addMode === 'hub' && hermesHubPanel ? hermesHubPanel : uploadPanel}
|
||||
|
||||
<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>
|
||||
{(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>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Card className="min-w-0 border-border/70 bg-background/80 shadow-sm">
|
||||
<CardHeader className="border-b border-border/60">
|
||||
|
||||
@@ -41,7 +41,7 @@ export const ThemeProvider = ({ children }) => {
|
||||
|
||||
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||
if (themeColorMeta) {
|
||||
themeColorMeta.setAttribute('content', '#141414'); // Dark background color (hsl(0 0% 8%))
|
||||
themeColorMeta.setAttribute('content', '#0c1117'); // Dark background color (hsl(222.2 84% 4.9%))
|
||||
}
|
||||
} 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', '#f6f4ef'); // Light background color (warm cream)
|
||||
themeColorMeta.setAttribute('content', '#ffffff'); // Light background color
|
||||
}
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useReducer, useRef } from 'react';
|
||||
|
||||
type UiPreferences = {
|
||||
autoExpandTools: boolean;
|
||||
showRawParameters: boolean;
|
||||
showThinking: boolean;
|
||||
autoScrollToBottom: boolean;
|
||||
sendByCtrlEnter: boolean;
|
||||
sidebarVisible: boolean;
|
||||
voiceEnabled: boolean;
|
||||
@@ -32,8 +34,10 @@ type UiPreferencesAction =
|
||||
| ResetPreferencesAction;
|
||||
|
||||
const DEFAULTS: UiPreferences = {
|
||||
autoExpandTools: false,
|
||||
showRawParameters: false,
|
||||
showThinking: true,
|
||||
autoScrollToBottom: true,
|
||||
sendByCtrlEnter: false,
|
||||
sidebarVisible: true,
|
||||
voiceEnabled: false,
|
||||
|
||||
@@ -54,11 +54,14 @@
|
||||
"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": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user