mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 02:22:55 +08:00
Compare commits
9 Commits
cloudcli-l
...
feat/add-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3a4ab8a45 | ||
|
|
dcd7044258 | ||
|
|
655501faba | ||
|
|
7c6d00ee93 | ||
|
|
84fadad662 | ||
|
|
5c14e08493 | ||
|
|
f188648a2a | ||
|
|
cdf1a04e26 | ||
|
|
048c671b13 |
@@ -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
|
||||
|
||||
|
||||
|
||||
92
.github/workflows/release.yml
vendored
92
.github/workflows/release.yml
vendored
@@ -20,7 +20,82 @@ permissions:
|
||||
# to immutable commit SHAs. The trailing comments keep the original major tag
|
||||
# visible for maintenance context.
|
||||
jobs:
|
||||
build-macos-semantic-helper:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runs_on: macos-15
|
||||
target_dir: darwin-arm64
|
||||
- runs_on: macos-15-intel
|
||||
target_dir: darwin-x64
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Build macOS semantic helper
|
||||
run: node scripts/build-computer-semantics.mjs
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
- name: Verify macOS semantic helper target
|
||||
run: test -x "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics"
|
||||
- name: Stage macOS semantic helper artifact
|
||||
run: |
|
||||
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
|
||||
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics" "semantic-helper-artifact/${{ matrix.target_dir }}/"
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: semantic-helper-${{ matrix.target_dir }}
|
||||
path: semantic-helper-artifact/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows-semantic-helper:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runs_on: windows-2025
|
||||
target_dir: win32-x64
|
||||
- runs_on: windows-11-arm
|
||||
target_dir: win32-arm64
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Build Windows semantic helper
|
||||
run: node scripts/build-computer-semantics.mjs
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
- name: Verify Windows semantic helper target
|
||||
shell: bash
|
||||
run: test -f "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe"
|
||||
- name: Stage Windows semantic helper artifact
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
|
||||
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe" "semantic-helper-artifact/${{ matrix.target_dir }}/"
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: semantic-helper-${{ matrix.target_dir }}
|
||||
path: semantic-helper-artifact/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs:
|
||||
- build-macos-semantic-helper
|
||||
- build-windows-semantic-helper
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -43,6 +118,23 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
||||
with:
|
||||
pattern: semantic-helper-*
|
||||
path: server/modules/computer-use/semantics/bin
|
||||
merge-multiple: true
|
||||
|
||||
- name: Restore semantic helper permissions
|
||||
run: find server/modules/computer-use/semantics/bin -path '*/darwin-*/CloudCLISemantics' -type f -exec chmod 755 {} +
|
||||
|
||||
- name: Verify bundled semantic helpers
|
||||
run: |
|
||||
test -x server/modules/computer-use/semantics/bin/darwin-arm64/CloudCLISemantics
|
||||
test -x server/modules/computer-use/semantics/bin/darwin-x64/CloudCLISemantics
|
||||
test -f server/modules/computer-use/semantics/bin/win32-x64/CloudCLISemantics.exe
|
||||
test -f server/modules/computer-use/semantics/bin/win32-arm64/CloudCLISemantics.exe
|
||||
find server/modules/computer-use/semantics/bin -maxdepth 2 -type f -print
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
ARGS="--ci --increment=${{ inputs.increment }}"
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -3,18 +3,6 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [1.35.1](https://github.com/siteboon/claudecodeui/compare/v1.35.0...v1.35.1) (2026-07-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* preview video on new tab ([#933](https://github.com/siteboon/claudecodeui/issues/933)) ([2ebe64f](https://github.com/siteboon/claudecodeui/commit/2ebe64f21874f45f6c8747310be874ae7342c61c))
|
||||
* remove obsolete semantic helper release jobs ([1e16f1f](https://github.com/siteboon/claudecodeui/commit/1e16f1f0854e347aa333434638d64f2b167d9a9d))
|
||||
* resolve mobile shell issues ([#923](https://github.com/siteboon/claudecodeui/issues/923)) ([b6cf333](https://github.com/siteboon/claudecodeui/commit/b6cf33308da996f8169580a4b5b74e3c5f38e447))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* remove computer use ([6761f31](https://github.com/siteboon/claudecodeui/commit/6761f31a56fe82d82c7e0c079b4891e7d5a81817))
|
||||
|
||||
## [1.35.0](https://github.com/siteboon/claudecodeui/compare/v1.34.0...v1.35.0) (2026-06-29)
|
||||
|
||||
### New Features
|
||||
|
||||
19
README.md
19
README.md
@@ -74,6 +74,12 @@ The fastest way to get started — no local setup required. Get a fully managed,
|
||||
|
||||
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
|
||||
|
||||
### Desktop App
|
||||
|
||||
Download the latest macOS or Windows desktop app from the **[GitHub Releases](https://github.com/siteboon/claudecodeui/releases)** page.
|
||||
|
||||
Use the desktop app to open CloudCLI Cloud environments, switch between local and remote workspaces, copy mobile/browser URLs, and keep Local CloudCLI available from your menu bar or tray. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
|
||||
|
||||
### Self-Hosted (Open source)
|
||||
|
||||
#### npm
|
||||
@@ -105,16 +111,6 @@ npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
||||
|
||||
Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options.
|
||||
|
||||
### Desktop Companion App
|
||||
|
||||
CloudCLI Desktop is an optional native companion for CloudCLI Cloud and Local CloudCLI. It ships from this repository's GitHub Releases and keeps CloudCLI available from your menu bar or tray.
|
||||
|
||||
- **[macOS](https://cloudcli.ai/download/macos)**
|
||||
- **[Windows](https://cloudcli.ai/download/windows)**
|
||||
- **[Download page](https://cloudcli.ai/download)** · **[GitHub Releases and checksums](https://github.com/siteboon/claudecodeui/releases)**
|
||||
|
||||
Use it to open CloudCLI Cloud environments, switch between local and remote workspaces, and copy mobile/browser URLs. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -129,8 +125,7 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self
|
||||
| **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required |
|
||||
| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
|
||||
| **Machine needs to stay on** | Yes | Yes | No |
|
||||
| **Mobile access** | Any browser on your network | Any browser on your network | Any device |
|
||||
| **Desktop companion** | Optional. Choose Local CloudCLI | Optional. Choose Local CloudCLI | Optional. Opens cloud environments |
|
||||
| **Mobile access** | Any browser on your network | Any browser on your network | Any device, native app coming |
|
||||
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
||||
| **File explorer and Git** | Yes | Yes | Yes |
|
||||
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
|
||||
|
||||
45
docs/hermes-gateway.md
Normal file
45
docs/hermes-gateway.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Hermes Gateway Controls
|
||||
|
||||
CloudCLI can manage the Hermes Gateway process from **Settings -> Agents -> Hermes -> Gateway**.
|
||||
|
||||
The gateway is optional. Normal CloudCLI chat and automation should continue to use `POST /api/agent` with `provider: "hermes"`. The gateway is for long-running Hermes integrations such as Telegram, Discord, WhatsApp, and other messaging surfaces configured by Hermes.
|
||||
|
||||
## What The Gateway Tab Does
|
||||
|
||||
- Shows whether Hermes is installed and whether the gateway is running.
|
||||
- Starts Hermes with `hermes gateway run` in the current CloudCLI server environment.
|
||||
- Stops or restarts a gateway process started by CloudCLI.
|
||||
- Opens `hermes gateway setup` in the terminal for platform configuration.
|
||||
- Shows detected Hermes profiles for visibility.
|
||||
- Shows recent logs from the gateway process managed by CloudCLI.
|
||||
|
||||
## What It Does Not Do
|
||||
|
||||
- It does not expose the Hermes HTTP gateway as a raw public API.
|
||||
- It does not add a new authentication model.
|
||||
- It does not replace the CloudCLI Agent API.
|
||||
- It does not create Docker containers or require Docker.
|
||||
|
||||
## API Boundaries
|
||||
|
||||
CloudCLI has two separate surfaces:
|
||||
|
||||
- **CloudCLI Agent API**: `POST /api/agent`
|
||||
Use this for programmatic CloudCLI tasks, including Hermes tasks. It supports `projectPath`, `githubUrl`, sessions, branches, and other CloudCLI workflow options.
|
||||
|
||||
- **Hermes Gateway controls**: `/api/providers/hermes/gateway/*`
|
||||
These are browser-authenticated UI control endpoints used by the settings page.
|
||||
|
||||
The gateway controls intentionally stay inside the authenticated provider API. They are not a customer-facing replacement for `POST /api/agent`.
|
||||
|
||||
## Hosted Environments
|
||||
|
||||
In hosted or containerized environments, CloudCLI runs the gateway in foreground mode because system service managers such as systemd or launchd may not be available. This matches Hermes' recommended foreground mode for containers and similar runtimes.
|
||||
|
||||
If Docker is not available, the Gateway tab still works for the local Hermes process. Docker is only relevant for advanced deployments where a user chooses to run Hermes separately.
|
||||
|
||||
## References
|
||||
|
||||
- [Hermes Agent releases](https://github.com/NousResearch/hermes-agent/releases) for current gateway and messaging platform capabilities.
|
||||
- [Hermes hooks documentation](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks) for non-interactive gateway runs and hook approval behavior.
|
||||
- [Hermes environment variables](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) for Docker-image-specific gateway supervision details.
|
||||
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" />
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.35.1",
|
||||
"version": "1.35.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.35.1",
|
||||
"version": "1.35.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.35.1",
|
||||
"version": "1.35.0",
|
||||
"productName": "CloudCLI",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
BIN
public/icons/hermes-agent.png
Normal file
BIN
public/icons/hermes-agent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
@@ -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.
|
||||
|
||||
407
server/hermes-cli.js
Normal file
407
server/hermes-cli.js
Normal file
@@ -0,0 +1,407 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
import {
|
||||
clearApprovalsForSession,
|
||||
getPendingApprovalsForSession,
|
||||
registerApproval,
|
||||
resolveToolApproval,
|
||||
unregisterApproval,
|
||||
} from './shared/tool-approval-registry.js';
|
||||
import { hermesConnectionManager } from './hermes/acp-client.js';
|
||||
|
||||
const PROVIDER = 'hermes';
|
||||
const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
|
||||
const activeHermesSessions = new Map();
|
||||
// Session ids whose run was aborted; the terminal `complete` is emitted by
|
||||
// handleChatAbort, so the runtime must not also emit a "completed" one.
|
||||
const abortedSessionIds = new Set();
|
||||
|
||||
function createRequestId() {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
function readSessionId(result) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return result.sessionId
|
||||
|| result.session_id
|
||||
|| result.id
|
||||
|| result.session?.id
|
||||
|| result.session?.sessionId
|
||||
|| result.session?.session_id
|
||||
|| null;
|
||||
}
|
||||
|
||||
function readStopReason(result) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return result.stopReason || result.stop_reason || result.reason || null;
|
||||
}
|
||||
|
||||
function buildPromptParams(sessionId, command) {
|
||||
return {
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: command }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildSessionSetupParams(sessionId, workingDir) {
|
||||
return {
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
cwd: workingDir,
|
||||
mcpServers: [],
|
||||
};
|
||||
}
|
||||
|
||||
function canLoadSession(connection) {
|
||||
return connection?.initializeResult?.agentCapabilities?.loadSession === true;
|
||||
}
|
||||
|
||||
function findPermissionOption(options, kinds, fallbackOptionIds = []) {
|
||||
if (!Array.isArray(options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const kind of kinds) {
|
||||
const match = options.find((option) => option?.kind === kind);
|
||||
if (match?.optionId) {
|
||||
return match.optionId;
|
||||
}
|
||||
}
|
||||
|
||||
for (const optionId of fallbackOptionIds) {
|
||||
const match = options.find((option) => option?.optionId === optionId);
|
||||
if (match?.optionId) {
|
||||
return match.optionId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createPermissionDecision(decision, options = []) {
|
||||
if (!decision) {
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
}
|
||||
|
||||
if (decision.cancelled) {
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
}
|
||||
|
||||
if (decision.allow) {
|
||||
const optionId = decision.rememberEntry
|
||||
? findPermissionOption(options, ['allow_always', 'allow_session'], ['allow_always', 'allow_session'])
|
||||
: findPermissionOption(options, ['allow_once'], ['allow_once']);
|
||||
|
||||
if (!optionId) {
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const denyOptionId = findPermissionOption(options, ['reject_once', 'deny', 'reject_always'], ['deny', 'reject_once', 'reject_always']);
|
||||
if (denyOptionId) {
|
||||
return {
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: denyOptionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: { outcome: 'cancelled' },
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForPermission(ws, params, capturedSessionId, sessionSummary) {
|
||||
const requestId = createRequestId();
|
||||
const toolCall = params?.toolCall || params?.tool_call || {};
|
||||
const toolName = params?.toolName
|
||||
|| params?.tool_name
|
||||
|| params?.name
|
||||
|| params?.tool?.name
|
||||
|| toolCall.title
|
||||
|| 'HermesTool';
|
||||
const input = params?.input
|
||||
?? params?.arguments
|
||||
?? params?.toolInput
|
||||
?? params?.tool_input
|
||||
?? toolCall.rawInput
|
||||
?? toolCall.raw_input
|
||||
?? toolCall;
|
||||
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'permission_request',
|
||||
requestId,
|
||||
toolName,
|
||||
input,
|
||||
sessionId: capturedSessionId,
|
||||
provider: PROVIDER,
|
||||
}));
|
||||
|
||||
return new Promise((resolve) => {
|
||||
registerApproval(requestId, {
|
||||
sessionId: capturedSessionId,
|
||||
provider: PROVIDER,
|
||||
meta: {
|
||||
toolName,
|
||||
input,
|
||||
context: params,
|
||||
sessionName: sessionSummary,
|
||||
receivedAt: new Date(),
|
||||
},
|
||||
resolver: (decision) => {
|
||||
unregisterApproval(requestId);
|
||||
resolve(createPermissionDecision(decision, params?.options));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function spawnHermes(command, options = {}, ws) {
|
||||
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
|
||||
const workingDir = cwd || projectPath || process.cwd();
|
||||
const requestedModel = model === HERMES_CONFIGURED_MODEL ? undefined : model;
|
||||
let capturedSessionId = sessionId || null;
|
||||
let sessionCreatedSent = false;
|
||||
let completeSent = false;
|
||||
let activeKey = capturedSessionId || `pending-${createRequestId()}`;
|
||||
|
||||
const notifyTerminalState = ({ error = null, stopReason = 'completed' } = {}) => {
|
||||
const finalSessionId = capturedSessionId || sessionId || activeKey;
|
||||
if (!error) {
|
||||
notifyRunStopped({
|
||||
userId: ws?.userId || null,
|
||||
provider: PROVIDER,
|
||||
sessionId: finalSessionId,
|
||||
sessionName: sessionSummary,
|
||||
stopReason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
notifyRunFailed({
|
||||
userId: ws?.userId || null,
|
||||
provider: PROVIDER,
|
||||
sessionId: finalSessionId,
|
||||
sessionName: sessionSummary,
|
||||
error,
|
||||
});
|
||||
};
|
||||
|
||||
const registerSession = (nextSessionId, connection) => {
|
||||
if (!nextSessionId || capturedSessionId === nextSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeHermesSessions.has(activeKey)) {
|
||||
activeHermesSessions.delete(activeKey);
|
||||
}
|
||||
activeKey = nextSessionId;
|
||||
capturedSessionId = nextSessionId;
|
||||
activeHermesSessions.set(activeKey, {
|
||||
connection,
|
||||
sessionId: capturedSessionId,
|
||||
status: 'active',
|
||||
aborted: false,
|
||||
ws,
|
||||
sessionSummary,
|
||||
});
|
||||
|
||||
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||
ws.setSessionId(capturedSessionId);
|
||||
}
|
||||
|
||||
if (!sessionId && !sessionCreatedSent) {
|
||||
sessionCreatedSent = true;
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'session_created',
|
||||
newSessionId: capturedSessionId,
|
||||
sessionId: capturedSessionId,
|
||||
provider: PROVIDER,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await providerModelsService.resolveResumeModel(PROVIDER, sessionId, requestedModel);
|
||||
const connection = await hermesConnectionManager.getConnection(workingDir);
|
||||
activeHermesSessions.set(activeKey, {
|
||||
connection,
|
||||
sessionId: capturedSessionId,
|
||||
status: 'active',
|
||||
aborted: false,
|
||||
ws,
|
||||
sessionSummary,
|
||||
});
|
||||
|
||||
const unregisterPermissionHandler = connection.registerRequestHandler('session/request_permission', (params) => {
|
||||
const permissionSessionId = params?.sessionId || params?.session_id || null;
|
||||
const active = permissionSessionId
|
||||
? activeHermesSessions.get(permissionSessionId)
|
||||
: activeHermesSessions.get(activeKey);
|
||||
if (!active) {
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
}
|
||||
|
||||
return waitForPermission(
|
||||
active.ws,
|
||||
params,
|
||||
active.sessionId || permissionSessionId || capturedSessionId,
|
||||
active.sessionSummary || sessionSummary,
|
||||
);
|
||||
});
|
||||
|
||||
const updateHandler = (params) => {
|
||||
const updateSessionId = params?.sessionId || params?.session_id || null;
|
||||
if (capturedSessionId && updateSessionId && updateSessionId !== capturedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
registerSession(updateSessionId, connection);
|
||||
const normalized = sessionsService.normalizeMessage(PROVIDER, params, capturedSessionId || updateSessionId || null);
|
||||
for (const msg of normalized) {
|
||||
ws.send(msg);
|
||||
}
|
||||
};
|
||||
|
||||
connection.on('session/update', updateHandler);
|
||||
|
||||
try {
|
||||
let sessionResult;
|
||||
if (sessionId && canLoadSession(connection)) {
|
||||
try {
|
||||
sessionResult = await connection.request('session/load', buildSessionSetupParams(sessionId, workingDir));
|
||||
} catch {
|
||||
sessionResult = { sessionId };
|
||||
}
|
||||
} else {
|
||||
sessionResult = await connection.request('session/new', buildSessionSetupParams(null, workingDir));
|
||||
}
|
||||
|
||||
registerSession(readSessionId(sessionResult) || sessionId, connection);
|
||||
if (!capturedSessionId) {
|
||||
throw new Error('Hermes ACP did not return a session id.');
|
||||
}
|
||||
const promptResult = await connection.request('session/prompt', buildPromptParams(capturedSessionId, command));
|
||||
const finalSessionId = capturedSessionId || readSessionId(promptResult) || sessionId || activeKey;
|
||||
const stopReason = readStopReason(promptResult) || 'completed';
|
||||
const active = activeHermesSessions.get(finalSessionId) || activeHermesSessions.get(activeKey);
|
||||
|
||||
if (promptResult?.usage || promptResult?.tokenUsage || promptResult?.token_usage) {
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'status',
|
||||
text: 'token_budget',
|
||||
tokenBudget: promptResult.usage || promptResult.tokenUsage || promptResult.token_usage,
|
||||
sessionId: finalSessionId,
|
||||
provider: PROVIDER,
|
||||
}));
|
||||
}
|
||||
|
||||
const abortedById = abortedSessionIds.delete(finalSessionId);
|
||||
const abortedByKey = abortedSessionIds.delete(activeKey);
|
||||
const wasAborted = Boolean(active?.aborted || abortedById || abortedByKey);
|
||||
|
||||
if (!completeSent && !wasAborted) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: PROVIDER, sessionId: finalSessionId, exitCode: 0 }));
|
||||
}
|
||||
activeHermesSessions.delete(finalSessionId);
|
||||
activeHermesSessions.delete(activeKey);
|
||||
clearApprovalsForSession(finalSessionId);
|
||||
notifyTerminalState({ stopReason: wasAborted ? 'aborted' : stopReason });
|
||||
} finally {
|
||||
connection.off('session/update', updateHandler);
|
||||
unregisterPermissionHandler();
|
||||
}
|
||||
} catch (error) {
|
||||
const finalSessionId = capturedSessionId || sessionId || activeKey;
|
||||
const abortedById = abortedSessionIds.delete(finalSessionId);
|
||||
const abortedByKey = abortedSessionIds.delete(activeKey);
|
||||
activeHermesSessions.delete(finalSessionId);
|
||||
activeHermesSessions.delete(activeKey);
|
||||
clearApprovalsForSession(finalSessionId);
|
||||
|
||||
// A cancelled session/prompt rejects here; its aborted terminal `complete`
|
||||
// is sent by handleChatAbort, so don't surface the cancellation as an error.
|
||||
if (abortedById || abortedByKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const installed = await providerAuthService.isProviderInstalled(PROVIDER);
|
||||
const errorContent = !installed
|
||||
? 'Hermes ACP is not installed. Install Hermes and ensure hermes-acp is on PATH.'
|
||||
: error instanceof Error ? error.message : String(error);
|
||||
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'error',
|
||||
content: errorContent,
|
||||
sessionId: finalSessionId,
|
||||
provider: PROVIDER,
|
||||
}));
|
||||
if (!completeSent) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: PROVIDER, sessionId: finalSessionId, exitCode: 1 }));
|
||||
}
|
||||
notifyTerminalState({ error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function abortHermesSession(providerSessionId) {
|
||||
const active = activeHermesSessions.get(providerSessionId);
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
active.aborted = true;
|
||||
active.status = 'aborted';
|
||||
abortedSessionIds.add(providerSessionId);
|
||||
if (active.sessionId) {
|
||||
abortedSessionIds.add(active.sessionId);
|
||||
}
|
||||
for (const approval of getPendingApprovalsForSession(active.sessionId || providerSessionId)) {
|
||||
resolveToolApproval(approval.requestId, { cancelled: true });
|
||||
}
|
||||
|
||||
try {
|
||||
active.connection.notify('session/cancel', { sessionId: active.sessionId || providerSessionId });
|
||||
} catch {
|
||||
// If Hermes already finished, the caller still sees the run as aborted.
|
||||
}
|
||||
activeHermesSessions.delete(providerSessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function isHermesSessionActive(sessionId) {
|
||||
return activeHermesSessions.has(sessionId);
|
||||
}
|
||||
|
||||
function getActiveHermesSessions() {
|
||||
return Array.from(activeHermesSessions.keys());
|
||||
}
|
||||
|
||||
export {
|
||||
spawnHermes,
|
||||
abortHermesSession,
|
||||
isHermesSessionActive,
|
||||
getActiveHermesSessions,
|
||||
createPermissionDecision,
|
||||
};
|
||||
287
server/hermes/acp-client.js
Normal file
287
server/hermes/acp-client.js
Normal file
@@ -0,0 +1,287 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
class AcpClient extends EventEmitter {
|
||||
constructor({ command = process.env.HERMES_CLI_PATH || 'hermes acp', cwd = process.cwd(), env = process.env } = {}) {
|
||||
super();
|
||||
const commandParts = command.trim().split(/\s+/);
|
||||
this.command = commandParts.shift() || 'hermes';
|
||||
this.args = commandParts;
|
||||
this.cwd = cwd;
|
||||
this.env = env;
|
||||
this.process = null;
|
||||
this.nextId = 1;
|
||||
this.pending = new Map();
|
||||
this.buffer = '';
|
||||
this.requestHandlers = new Map();
|
||||
this.initialized = false;
|
||||
this.initializeResult = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.process) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.process = spawnFunction(this.command, this.args, {
|
||||
cwd: this.cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...this.env },
|
||||
});
|
||||
|
||||
this.process.stdout.on('data', (chunk) => this.handleData(chunk));
|
||||
this.process.stderr.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
if (text.trim()) {
|
||||
this.emit('stderr', text);
|
||||
}
|
||||
});
|
||||
this.process.on('error', (error) => this.rejectAll(error));
|
||||
this.process.on('close', (code, signal) => {
|
||||
this.rejectAll(new Error(`hermes-acp exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`));
|
||||
this.emit('close', { code, signal });
|
||||
this.process = null;
|
||||
this.initialized = false;
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.start();
|
||||
this.initializeResult = await this.request('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: {
|
||||
readTextFile: false,
|
||||
writeTextFile: false,
|
||||
},
|
||||
terminal: false,
|
||||
},
|
||||
clientInfo: {
|
||||
name: 'CloudCLI',
|
||||
title: 'CloudCLI',
|
||||
version: '1.0.0',
|
||||
},
|
||||
});
|
||||
this.initialized = true;
|
||||
this.notify('initialized', {});
|
||||
}
|
||||
|
||||
onRequest(method, handler) {
|
||||
this.requestHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
registerRequestHandler(method, handler) {
|
||||
const handlers = this.requestHandlers.get(method) || new Set();
|
||||
handlers.add(handler);
|
||||
this.requestHandlers.set(method, handlers);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
this.requestHandlers.delete(method);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
request(method, params) {
|
||||
this.start();
|
||||
const id = this.nextId;
|
||||
this.nextId += 1;
|
||||
|
||||
const payload = { jsonrpc: '2.0', id, method, params };
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject, method, params });
|
||||
this.writeMessage(payload);
|
||||
});
|
||||
}
|
||||
|
||||
notify(method, params) {
|
||||
this.start();
|
||||
this.writeMessage({ jsonrpc: '2.0', method, params });
|
||||
}
|
||||
|
||||
writeMessage(payload) {
|
||||
if (!this.process || !this.process.stdin || this.process.stdin.destroyed) {
|
||||
throw new Error('hermes-acp process is not running');
|
||||
}
|
||||
const line = `${JSON.stringify(payload)}\n`;
|
||||
this.process.stdin.write(line);
|
||||
}
|
||||
|
||||
handleData(chunk) {
|
||||
this.buffer += chunk.toString();
|
||||
|
||||
while (this.buffer.length > 0) {
|
||||
if (this.buffer.startsWith('Content-Length:')) {
|
||||
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) {
|
||||
return;
|
||||
}
|
||||
const header = this.buffer.slice(0, headerEnd);
|
||||
const match = header.match(/Content-Length:\s*(\d+)/i);
|
||||
if (!match) {
|
||||
this.buffer = this.buffer.slice(headerEnd + 4);
|
||||
continue;
|
||||
}
|
||||
const length = Number(match[1]);
|
||||
const messageStart = headerEnd + 4;
|
||||
if (this.buffer.length < messageStart + length) {
|
||||
return;
|
||||
}
|
||||
const raw = this.buffer.slice(messageStart, messageStart + length);
|
||||
this.buffer = this.buffer.slice(messageStart + length);
|
||||
this.dispatchRaw(raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newlineIndex = this.buffer.indexOf('\n');
|
||||
if (newlineIndex === -1) {
|
||||
return;
|
||||
}
|
||||
const raw = this.buffer.slice(0, newlineIndex).trim();
|
||||
this.buffer = this.buffer.slice(newlineIndex + 1);
|
||||
if (raw) {
|
||||
this.dispatchRaw(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatchRaw(raw) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
this.emit('error', error);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.dispatchMessage(message);
|
||||
}
|
||||
|
||||
async dispatchMessage(message) {
|
||||
if (Object.prototype.hasOwnProperty.call(message, 'id') && (message.result !== undefined || message.error !== undefined)) {
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) {
|
||||
const messageText = message.error.message || JSON.stringify(message.error);
|
||||
const error = new Error(`ACP ${pending.method} failed: ${messageText}`);
|
||||
error.code = message.error.code;
|
||||
error.data = message.error.data;
|
||||
error.method = pending.method;
|
||||
error.params = pending.params;
|
||||
pending.reject(error);
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(message, 'id') && message.method) {
|
||||
const handler = this.requestHandlers.get(message.method);
|
||||
if (!handler) {
|
||||
this.writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: { code: -32601, message: `No handler for ${message.method}` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = handler instanceof Set
|
||||
? await this.dispatchRequestHandlers(handler, message.params)
|
||||
: await handler(message.params);
|
||||
this.writeMessage({ jsonrpc: '2.0', id: message.id, result });
|
||||
} catch (error) {
|
||||
this.writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: { code: -32000, message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method) {
|
||||
this.emit(message.method, message.params);
|
||||
this.emit('notification', { method: message.method, params: message.params });
|
||||
}
|
||||
}
|
||||
|
||||
rejectAll(error) {
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
async dispatchRequestHandlers(handlers, params) {
|
||||
let fallbackResult = null;
|
||||
let sawHandler = false;
|
||||
for (const handler of Array.from(handlers).reverse()) {
|
||||
sawHandler = true;
|
||||
const result = await handler(params);
|
||||
const outcome = result?.outcome?.outcome;
|
||||
if (outcome !== 'cancelled') {
|
||||
return result;
|
||||
}
|
||||
fallbackResult = result;
|
||||
}
|
||||
if (sawHandler && fallbackResult) {
|
||||
return fallbackResult;
|
||||
}
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.process) {
|
||||
return;
|
||||
}
|
||||
this.process.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
|
||||
class HermesConnectionManager {
|
||||
constructor() {
|
||||
this.connections = new Map();
|
||||
}
|
||||
|
||||
async getConnection(cwd) {
|
||||
const key = cwd || process.cwd();
|
||||
let connection = this.connections.get(key);
|
||||
if (!connection) {
|
||||
connection = new AcpClient({ cwd: key });
|
||||
connection.on('close', () => {
|
||||
this.connections.delete(key);
|
||||
});
|
||||
this.connections.set(key, connection);
|
||||
}
|
||||
await connection.initialize();
|
||||
return connection;
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
for (const connection of this.connections.values()) {
|
||||
connection.close();
|
||||
}
|
||||
this.connections.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const hermesConnectionManager = new HermesConnectionManager();
|
||||
|
||||
export {
|
||||
AcpClient,
|
||||
HermesConnectionManager,
|
||||
hermesConnectionManager,
|
||||
};
|
||||
@@ -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 is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await this.checkCredentials();
|
||||
return {
|
||||
provider: 'hermes',
|
||||
installed,
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.email,
|
||||
method: credentials.method ?? 'managed_by_hermes',
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkCredentials(): Promise<{ authenticated: boolean; email: string | null; method: string | null }> {
|
||||
if (this.hasKnownProviderEnv(process.env)) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'env' };
|
||||
}
|
||||
|
||||
const hermesHome = path.join(os.homedir(), '.hermes');
|
||||
try {
|
||||
const authJson = readObjectRecord(JSON.parse(await readFile(path.join(hermesHome, 'auth.json'), 'utf8')));
|
||||
if (
|
||||
readOptionalString(authJson?.apiKey)
|
||||
|| readOptionalString(authJson?.api_key)
|
||||
|| readOptionalString(authJson?.token)
|
||||
|| readOptionalString(authJson?.access_token)
|
||||
|| readOptionalString(authJson?.refresh_token)
|
||||
) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: readOptionalString(authJson?.email) ?? 'Hermes Auth',
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to dotenv check.
|
||||
}
|
||||
|
||||
try {
|
||||
const envContent = await readFile(path.join(hermesHome, '.env'), 'utf8');
|
||||
if (this.hasKnownProviderEnv(this.parseEnvFile(envContent))) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'env_file' };
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
|
||||
try {
|
||||
const configContent = await readFile(path.join(hermesHome, 'config.yaml'), 'utf8');
|
||||
if (/^\s*api_key\s*:\s*["']?[^"'#\s]+/m.test(configContent)) {
|
||||
return { authenticated: true, email: 'Hermes Config', method: 'config_file' };
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
|
||||
return { authenticated: false, email: null, method: null };
|
||||
}
|
||||
|
||||
private parseEnvFile(content: string): Record<string, string> {
|
||||
const parsed: Record<string, string> = {};
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const value = line.slice(separatorIndex + 1).trim().replace(/^['"]|['"]$/g, '');
|
||||
if (key && value) {
|
||||
parsed[key] = value;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private hasKnownProviderEnv(env: Record<string, string | undefined>): boolean {
|
||||
const keys = [
|
||||
'HERMES_API_KEY',
|
||||
'NOUS_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GLM_API_KEY',
|
||||
'KIMI_API_KEY',
|
||||
'MINIMAX_API_KEY',
|
||||
'MINIMAX_CN_API_KEY',
|
||||
'HF_TOKEN',
|
||||
'NVIDIA_API_KEY',
|
||||
'ARCEEAI_API_KEY',
|
||||
'OLLAMA_API_KEY',
|
||||
'KILOCODE_API_KEY',
|
||||
'GITHUB_TOKEN',
|
||||
];
|
||||
return keys.some((key) => Boolean(env[key]?.trim()));
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
151
server/modules/providers/list/hermes/hermes-models.provider.ts
Normal file
151
server/modules/providers/list/hermes/hermes-models.provider.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import { readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
export const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
|
||||
|
||||
export const HERMES_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: HERMES_CONFIGURED_MODEL,
|
||||
label: 'Use Hermes default',
|
||||
description: 'Uses the provider and model selected in Hermes.',
|
||||
},
|
||||
],
|
||||
DEFAULT: HERMES_CONFIGURED_MODEL,
|
||||
};
|
||||
|
||||
const HERMES_CONFIG_PATH = path.join(os.homedir(), '.hermes', 'config.yaml');
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function stripScalar(raw: string): string | null {
|
||||
let value = raw.trim();
|
||||
// Drop an unquoted trailing comment.
|
||||
if (!value.startsWith('"') && !value.startsWith("'")) {
|
||||
const comment = value.search(/\s#/);
|
||||
if (comment >= 0) {
|
||||
value = value.slice(0, comment).trim();
|
||||
}
|
||||
}
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
return value.trim() || null;
|
||||
}
|
||||
|
||||
const indentOf = (line: string): number => line.length - line.replace(/^\s+/, '').length;
|
||||
|
||||
// Minimal, indentation-aware reader for the flat `key: value` and one-level
|
||||
// nested (`section:`\n` key: value`) shapes used by ~/.hermes/config.yaml.
|
||||
// Avoids the fragile single-regex lookahead that could terminate a section
|
||||
// early and silently miss the configured model.
|
||||
export function readYamlPath(content: string, pathParts: string[]): string | null {
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
if (pathParts.length === 1) {
|
||||
const re = new RegExp(`^\\s*${escapeRegex(pathParts[0])}\\s*:\\s*(.*)$`);
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
const match = line.match(re);
|
||||
if (match) return stripScalar(match[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const [section, key] = pathParts;
|
||||
const sectionRe = new RegExp(`^(\\s*)${escapeRegex(section)}\\s*:\\s*$`);
|
||||
const keyRe = new RegExp(`^\\s*${escapeRegex(key)}\\s*:\\s*(.*)$`);
|
||||
let sectionIndent: number | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
|
||||
if (sectionIndent === null) {
|
||||
const match = line.match(sectionRe);
|
||||
if (match) sectionIndent = match[1].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Left the nested block once indentation returns to the section level or less.
|
||||
if (indentOf(line) <= sectionIndent) {
|
||||
sectionIndent = line.match(sectionRe)?.[1].length ?? null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(keyRe);
|
||||
if (match) return stripScalar(match[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export class HermesProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
const activeModel = await this.readConfiguredModel();
|
||||
if (!activeModel) {
|
||||
return HERMES_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
return {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: HERMES_CONFIGURED_MODEL,
|
||||
label: 'Use Hermes default',
|
||||
description: `Uses the provider and model selected in Hermes. Current config: ${activeModel}`,
|
||||
},
|
||||
],
|
||||
DEFAULT: HERMES_CONFIGURED_MODEL,
|
||||
};
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
|
||||
const configured = await this.readConfiguredModel();
|
||||
return { model: configured ?? HERMES_CONFIGURED_MODEL };
|
||||
}
|
||||
|
||||
async changeActiveModel(input: ProviderChangeActiveModelInput): Promise<ProviderSessionActiveModelChange> {
|
||||
if (input.model === HERMES_CONFIGURED_MODEL) {
|
||||
return {
|
||||
provider: 'hermes',
|
||||
sessionId: input.sessionId,
|
||||
supported: true,
|
||||
changed: false,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'hermes',
|
||||
sessionId: input.sessionId,
|
||||
supported: false,
|
||||
changed: false,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async readConfiguredModel(): Promise<string | null> {
|
||||
try {
|
||||
const content = await readFile(HERMES_CONFIG_PATH, 'utf8');
|
||||
return readOptionalString(readYamlPath(content, ['model', 'default']))
|
||||
?? readOptionalString(readYamlPath(content, ['model']))
|
||||
?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
381
server/modules/providers/list/hermes/hermes-sessions.provider.ts
Normal file
381
server/modules/providers/list/hermes/hermes-sessions.provider.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import fsSync from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import {
|
||||
createNormalizedMessage,
|
||||
generateMessageId,
|
||||
normalizeProviderTimestamp,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
sliceTailPage,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'hermes';
|
||||
const HERMES_DB_PATH = path.join(os.homedir(), '.hermes', 'state.db');
|
||||
|
||||
type HermesMessageRow = {
|
||||
id: number;
|
||||
role: string;
|
||||
content: string | null;
|
||||
tool_call_id: string | null;
|
||||
tool_calls: string | null;
|
||||
tool_name: string | null;
|
||||
timestamp: number;
|
||||
reasoning: string | null;
|
||||
reasoning_content: string | null;
|
||||
finish_reason: string | null;
|
||||
};
|
||||
|
||||
function formatContent(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function readUpdateType(raw: AnyRecord): string {
|
||||
return readOptionalString(raw.type)
|
||||
?? readOptionalString(raw.kind)
|
||||
?? readOptionalString(raw.sessionUpdate)
|
||||
?? readOptionalString(raw.session_update)
|
||||
?? readOptionalString(raw.update)
|
||||
?? readOptionalString(raw.event)
|
||||
?? '';
|
||||
}
|
||||
|
||||
function readEventSessionId(raw: AnyRecord, sessionId: string | null): string | null {
|
||||
return readOptionalString(raw.sessionId) ?? readOptionalString(raw.session_id) ?? sessionId;
|
||||
}
|
||||
|
||||
function readTextContent(value: unknown): string | null {
|
||||
const direct = readOptionalString(value);
|
||||
if (direct !== undefined) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map((entry) => readTextContent(entry))
|
||||
.filter((entry): entry is string => Boolean(entry?.trim()));
|
||||
return parts.length > 0 ? parts.join('') : null;
|
||||
}
|
||||
|
||||
const record = readObjectRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestedContent = record.content;
|
||||
const nestedText = nestedContent === value ? null : readTextContent(nestedContent);
|
||||
|
||||
return readOptionalString(record.text)
|
||||
?? readOptionalString(record.content)
|
||||
?? nestedText
|
||||
?? readOptionalString(record.delta)
|
||||
?? readOptionalString(record.rawOutput)
|
||||
?? readOptionalString(record.raw_output)
|
||||
?? readOptionalString(record.output)
|
||||
?? null;
|
||||
}
|
||||
|
||||
function readToolPayload(raw: AnyRecord): AnyRecord {
|
||||
return readObjectRecord(raw.toolCall)
|
||||
?? readObjectRecord(raw.tool_call)
|
||||
?? readObjectRecord(raw.tool)
|
||||
?? raw;
|
||||
}
|
||||
|
||||
function normalizeHermesEvent(rawMessage: unknown, sessionId: string | null, history = false): NormalizedMessage[] {
|
||||
const envelope = readObjectRecord(rawMessage);
|
||||
if (!envelope) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nestedUpdate = readObjectRecord(envelope.update);
|
||||
const raw = nestedUpdate ? { ...nestedUpdate, sessionId: envelope.sessionId ?? envelope.session_id ?? sessionId } : envelope;
|
||||
|
||||
const type = readUpdateType(raw);
|
||||
const eventSessionId = readEventSessionId(raw, sessionId);
|
||||
const timestamp = normalizeProviderTimestamp(raw.timestamp ?? raw.time ?? raw.createdAt ?? raw.created_at);
|
||||
const baseId = readOptionalString(raw.id) ?? readOptionalString(raw.messageId) ?? readOptionalString(raw.message_id) ?? generateMessageId(PROVIDER);
|
||||
|
||||
if (['agent_message_chunk', 'assistant_message_chunk', 'message_delta', 'text_delta', 'text'].includes(type)) {
|
||||
const content = readTextContent(raw.content)
|
||||
?? readOptionalString(raw.text)
|
||||
?? readOptionalString(raw.delta)
|
||||
?? readTextContent(readObjectRecord(raw.message)?.content)
|
||||
?? '';
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: history ? 'text' : 'stream_delta',
|
||||
role: history ? 'assistant' : undefined,
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (['agent_message', 'assistant_message', 'message'].includes(type)) {
|
||||
const role = readOptionalString(raw.role) === 'user' ? 'user' : 'assistant';
|
||||
const content = readTextContent(raw.content)
|
||||
?? readOptionalString(raw.text)
|
||||
?? readTextContent(readObjectRecord(raw.message)?.content)
|
||||
?? '';
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: history ? 'text' : role === 'assistant' ? 'stream_delta' : 'text',
|
||||
role: history || role === 'user' ? role : undefined,
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (['agent_thought_chunk', 'thought_delta', 'thinking', 'reasoning'].includes(type)) {
|
||||
const content = readTextContent(raw.content) ?? readOptionalString(raw.text) ?? readOptionalString(raw.delta) ?? '';
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (['tool_call', 'tool_use', 'tool_call_start'].includes(type)) {
|
||||
const tool = readToolPayload(raw);
|
||||
const toolId = readOptionalString(raw.toolCallId)
|
||||
?? readOptionalString(raw.tool_call_id)
|
||||
?? readOptionalString(raw.toolId)
|
||||
?? readOptionalString(tool.toolCallId)
|
||||
?? readOptionalString(tool.tool_call_id)
|
||||
?? readOptionalString(tool.toolId)
|
||||
?? readOptionalString(tool.id)
|
||||
?? baseId;
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: readOptionalString(raw.toolName)
|
||||
?? readOptionalString(raw.tool_name)
|
||||
?? readOptionalString(raw.title)
|
||||
?? readOptionalString(raw.name)
|
||||
?? readOptionalString(tool?.name)
|
||||
?? readOptionalString(tool?.title)
|
||||
?? 'Tool',
|
||||
toolInput: raw.rawInput
|
||||
?? raw.raw_input
|
||||
?? raw.input
|
||||
?? raw.arguments
|
||||
?? raw.params
|
||||
?? tool?.rawInput
|
||||
?? tool?.raw_input
|
||||
?? tool?.input
|
||||
?? tool?.arguments
|
||||
?? {},
|
||||
toolId,
|
||||
})];
|
||||
}
|
||||
|
||||
if (['tool_call_update', 'tool_result', 'tool_call_result', 'tool_call_done'].includes(type)) {
|
||||
const tool = readToolPayload(raw);
|
||||
const content = readTextContent(raw.content)
|
||||
?? readTextContent(raw.rawOutput)
|
||||
?? readTextContent(raw.raw_output)
|
||||
?? readTextContent(raw.output)
|
||||
?? readTextContent(raw.result)
|
||||
?? readTextContent(tool.rawOutput)
|
||||
?? readTextContent(tool.raw_output)
|
||||
?? readTextContent(tool.output)
|
||||
?? readTextContent(tool.result)
|
||||
?? '';
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: readOptionalString(raw.toolCallId)
|
||||
?? readOptionalString(raw.tool_call_id)
|
||||
?? readOptionalString(raw.toolId)
|
||||
?? readOptionalString(tool.toolCallId)
|
||||
?? readOptionalString(tool.tool_call_id)
|
||||
?? readOptionalString(tool.toolId)
|
||||
?? readOptionalString(tool.id)
|
||||
?? '',
|
||||
content: content || formatContent(raw.delta ?? ''),
|
||||
isError: Boolean(raw.error) || raw.status === 'error' || raw.status === 'failed',
|
||||
toolUseResult: raw.result ?? raw.output ?? raw.rawOutput ?? raw.raw_output ?? tool.result ?? tool.output ?? tool.rawOutput ?? tool.raw_output,
|
||||
})];
|
||||
}
|
||||
|
||||
if (type === 'plan') {
|
||||
const content = readOptionalString(raw.content) ?? readOptionalString(raw.text) ?? formatContent(raw.plan);
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'status',
|
||||
text: 'plan',
|
||||
summary: content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown Hermes error',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseJsonArray(value: string | null): unknown[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readHermesHistoryFromDatabase(sessionId: string): NormalizedMessage[] {
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
if (!fsSync.existsSync(HERMES_DB_PATH)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const db = new Database(HERMES_DB_PATH, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, role, content, tool_call_id, tool_calls, tool_name, timestamp, reasoning, reasoning_content, finish_reason
|
||||
FROM messages
|
||||
WHERE session_id = ? AND active = 1
|
||||
ORDER BY timestamp ASC, id ASC
|
||||
`).all(sessionId) as HermesMessageRow[];
|
||||
|
||||
for (const row of rows) {
|
||||
const timestamp = new Date(row.timestamp * 1000).toISOString();
|
||||
const baseId = `hermes-${sessionId}-${row.id}`;
|
||||
|
||||
const reasoning = row.reasoning_content || row.reasoning;
|
||||
if (reasoning?.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}-thinking`,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: reasoning,
|
||||
}));
|
||||
}
|
||||
|
||||
for (const toolCall of parseJsonArray(row.tool_calls)) {
|
||||
const call = readObjectRecord(toolCall);
|
||||
const fn = readObjectRecord(call?.function);
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}-tool-${readOptionalString(call?.id) ?? normalized.length}`,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: readOptionalString(fn?.name) ?? readOptionalString(call?.name) ?? 'Tool',
|
||||
toolInput: fn?.arguments ?? call?.arguments ?? {},
|
||||
toolId: readOptionalString(call?.id) ?? `${baseId}-tool`,
|
||||
}));
|
||||
}
|
||||
|
||||
if (row.role === 'tool') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}-result`,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: row.tool_call_id ?? '',
|
||||
content: row.content ?? '',
|
||||
isError: row.finish_reason === 'error',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.content?.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: row.role === 'user' ? 'user' : 'assistant',
|
||||
content: row.content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export class HermesSessionsProvider implements IProviderSessions {
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
return normalizeHermesEvent(rawMessage, sessionId);
|
||||
}
|
||||
|
||||
async fetchHistory(sessionId: string, options: FetchHistoryOptions = {}): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
const row = sessionsDb.getSessionById(sessionId) ?? sessionsDb.getSessionByProviderSessionId(sessionId);
|
||||
const messages = readHermesHistoryFromDatabase(row?.provider_session_id ?? sessionId);
|
||||
|
||||
const start = Math.max(0, offset);
|
||||
const pageLimit = limit === null ? null : Math.max(0, limit);
|
||||
const page = sliceTailPage(messages, pageLimit, start);
|
||||
return {
|
||||
messages: page.page,
|
||||
total: messages.length,
|
||||
hasMore: page.hasMore,
|
||||
offset: start,
|
||||
limit: pageLimit,
|
||||
};
|
||||
}
|
||||
}
|
||||
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(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import express, { type Request, type Response } from 'express';
|
||||
|
||||
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
||||
import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js';
|
||||
import { hermesGatewayService } from '@/modules/providers/services/hermes-gateway.service.js';
|
||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
|
||||
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
||||
@@ -279,6 +280,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 +330,7 @@ const parseProvider = (value: unknown): LLMProvider => {
|
||||
|| normalized === 'cursor'
|
||||
|| normalized === 'gemini'
|
||||
|| normalized === 'opencode'
|
||||
|| normalized === 'hermes'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
@@ -297,6 +341,18 @@ const parseProvider = (value: unknown): LLMProvider => {
|
||||
});
|
||||
};
|
||||
|
||||
const parseHermesProvider = (value: unknown): 'hermes' => {
|
||||
const provider = parseProvider(value);
|
||||
if (provider !== 'hermes') {
|
||||
throw new AppError('Gateway controls are only available for Hermes.', {
|
||||
code: 'HERMES_GATEWAY_UNSUPPORTED_PROVIDER',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
||||
const parseSessionRenameSummary = (payload: unknown): string => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new AppError('Request body must be an object.', {
|
||||
@@ -441,6 +497,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',
|
||||
@@ -523,6 +650,51 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- Hermes gateway routes -----------------
|
||||
router.get(
|
||||
'/:provider/gateway/status',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
const status = await hermesGatewayService.getStatus();
|
||||
res.json(createApiSuccessResponse(status));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:provider/gateway/logs',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
res.json(createApiSuccessResponse({ logs: hermesGatewayService.getLogs() }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/gateway/start',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
const status = await hermesGatewayService.start();
|
||||
res.json(createApiSuccessResponse(status));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/gateway/stop',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
const status = await hermesGatewayService.stop();
|
||||
res.json(createApiSuccessResponse(status));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/gateway/restart',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
const status = await hermesGatewayService.restart();
|
||||
res.json(createApiSuccessResponse(status));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- Session routes -----------------
|
||||
/**
|
||||
* Session gateway entry point: allocates the stable app-facing session id for
|
||||
|
||||
317
server/modules/providers/services/hermes-gateway.service.ts
Normal file
317
server/modules/providers/services/hermes-gateway.service.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { spawn, execFile, type ChildProcess } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const gatewayCommandParts = (process.env.HERMES_GATEWAY_COMMAND || '').trim().split(/\s+/).filter(Boolean);
|
||||
const fallbackHermesCommand = (
|
||||
process.env.HERMES_COMMAND_PATH
|
||||
|| process.env.HERMES_CLI_PATH
|
||||
|| 'hermes'
|
||||
).trim().split(/\s+/)[0] || 'hermes';
|
||||
const HERMES_COMMAND = gatewayCommandParts[0] || fallbackHermesCommand;
|
||||
const HERMES_BASE_ARGS = gatewayCommandParts.slice(1);
|
||||
|
||||
const MAX_LOG_LINES = 300;
|
||||
const COMMAND_TIMEOUT_MS = 10_000;
|
||||
|
||||
type CommandResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type HermesGatewayProfile = {
|
||||
name: string;
|
||||
current: boolean;
|
||||
model: string | null;
|
||||
gateway: string | null;
|
||||
alias: string | null;
|
||||
distribution: string | null;
|
||||
};
|
||||
|
||||
export type HermesGatewayStatus = {
|
||||
installed: boolean;
|
||||
command: string;
|
||||
version: string | null;
|
||||
running: boolean;
|
||||
managedByCloudCLI: boolean;
|
||||
state: 'running' | 'stopped' | 'unknown';
|
||||
statusOutput: string;
|
||||
profiles: HermesGatewayProfile[];
|
||||
logs: string[];
|
||||
lastExit: {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
at: string;
|
||||
} | null;
|
||||
commands: {
|
||||
setup: string;
|
||||
run: string;
|
||||
};
|
||||
};
|
||||
|
||||
const removeAnsi = (value: string): string => value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
|
||||
|
||||
const compactOutput = (result: CommandResult): string => (
|
||||
[result.stdout, result.stderr].filter(Boolean).join('\n').trim()
|
||||
);
|
||||
|
||||
const parseGatewayRunning = (output: string, managedByCloudCLI: boolean): boolean => {
|
||||
if (managedByCloudCLI) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = output.toLowerCase();
|
||||
if (/\b(not running|stopped|inactive|failed)\b/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /\b(running|active)\b/.test(normalized);
|
||||
};
|
||||
|
||||
const parseProfiles = (output: string): HermesGatewayProfile[] => {
|
||||
const profiles: HermesGatewayProfile[] = [];
|
||||
for (const rawLine of removeAnsi(output).split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('Profile ') || /^[-─\s]+$/.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = line.startsWith('◆');
|
||||
const cleaned = line.replace(/^[◆*]\s*/, '').trim();
|
||||
const columns = cleaned.split(/\s{2,}/).map((column) => column.trim());
|
||||
const [name, model, gateway, alias, distribution] = columns;
|
||||
if (!name || name.toLowerCase() === 'profile') {
|
||||
continue;
|
||||
}
|
||||
|
||||
profiles.push({
|
||||
name,
|
||||
current,
|
||||
model: model && model !== '—' ? model : null,
|
||||
gateway: gateway && gateway !== '—' ? gateway : null,
|
||||
alias: alias && alias !== '—' ? alias : null,
|
||||
distribution: distribution && distribution !== '—' ? distribution : null,
|
||||
});
|
||||
}
|
||||
|
||||
return profiles;
|
||||
};
|
||||
|
||||
class HermesGatewayService {
|
||||
private gatewayProcess: ChildProcess | null = null;
|
||||
private readonly logs: string[] = [];
|
||||
private lastExit: HermesGatewayStatus['lastExit'] = null;
|
||||
|
||||
async getStatus(): Promise<HermesGatewayStatus> {
|
||||
const versionResult = await this.runHermes(['--version'], { timeout: 5000 });
|
||||
const installed = versionResult.exitCode === 0;
|
||||
const version = installed ? compactOutput(versionResult).split(/\r?\n/)[0] || null : null;
|
||||
|
||||
const statusResult = installed
|
||||
? await this.runHermes(['gateway', 'status'], { timeout: COMMAND_TIMEOUT_MS })
|
||||
: { stdout: '', stderr: versionResult.error || 'Hermes is not installed.', exitCode: 1 };
|
||||
const profilesResult = installed
|
||||
? await this.runHermes(['profile', 'list'], { timeout: COMMAND_TIMEOUT_MS })
|
||||
: { stdout: '', stderr: '', exitCode: 1 };
|
||||
|
||||
const statusOutput = compactOutput(statusResult);
|
||||
const managedByCloudCLI = this.isManagedProcessRunning();
|
||||
const running = parseGatewayRunning(statusOutput, managedByCloudCLI);
|
||||
|
||||
return {
|
||||
installed,
|
||||
command: this.commandPrefix().join(' '),
|
||||
version,
|
||||
running,
|
||||
managedByCloudCLI,
|
||||
state: running ? 'running' : statusOutput ? 'stopped' : 'unknown',
|
||||
statusOutput,
|
||||
profiles: parseProfiles(profilesResult.stdout),
|
||||
logs: this.getLogs(),
|
||||
lastExit: this.lastExit,
|
||||
commands: {
|
||||
setup: [...this.commandPrefix(), 'gateway', 'setup'].join(' '),
|
||||
run: [...this.commandPrefix(), 'gateway', 'run'].join(' '),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<HermesGatewayStatus> {
|
||||
await this.assertInstalled();
|
||||
if (this.isManagedProcessRunning()) {
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
const currentStatus = await this.getStatus();
|
||||
if (currentStatus.running) {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
const args = [...HERMES_BASE_ARGS, 'gateway', 'run', '--accept-hooks'];
|
||||
this.appendLog(`[cloudcli] starting Hermes gateway: ${HERMES_COMMAND} ${args.join(' ')}`);
|
||||
this.lastExit = null;
|
||||
const child = spawn(HERMES_COMMAND, args, {
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_ACCEPT_HOOKS: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
this.gatewayProcess = child;
|
||||
|
||||
child.stdout?.on('data', (chunk) => this.appendLog(String(chunk)));
|
||||
child.stderr?.on('data', (chunk) => this.appendLog(String(chunk)));
|
||||
child.on('error', (error) => {
|
||||
this.appendLog(`[cloudcli] gateway process error: ${error.message}`);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
this.lastExit = {
|
||||
code,
|
||||
signal,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
this.appendLog(`[cloudcli] gateway exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`);
|
||||
this.gatewayProcess = null;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
|
||||
if (!this.isManagedProcessRunning()) {
|
||||
throw new AppError('Hermes gateway exited before it could start.', {
|
||||
code: 'HERMES_GATEWAY_START_FAILED',
|
||||
statusCode: 500,
|
||||
details: {
|
||||
logs: this.getLogs().slice(-20),
|
||||
lastExit: this.lastExit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async stop(): Promise<HermesGatewayStatus> {
|
||||
if (this.isManagedProcessRunning() && this.gatewayProcess) {
|
||||
this.appendLog('[cloudcli] stopping managed Hermes gateway');
|
||||
await this.stopManagedProcess();
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
await this.runHermes(['gateway', 'stop'], { timeout: COMMAND_TIMEOUT_MS });
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async restart(): Promise<HermesGatewayStatus> {
|
||||
if (this.isManagedProcessRunning()) {
|
||||
await this.stopManagedProcess();
|
||||
} else {
|
||||
await this.runHermes(['gateway', 'stop'], { timeout: COMMAND_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
return this.start();
|
||||
}
|
||||
|
||||
getLogs(): string[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
|
||||
private async assertInstalled(): Promise<void> {
|
||||
const result = await this.runHermes(['--version'], { timeout: 5000 });
|
||||
if (result.exitCode !== 0) {
|
||||
throw new AppError('Hermes is not installed or is not available on PATH.', {
|
||||
code: 'HERMES_NOT_INSTALLED',
|
||||
statusCode: 400,
|
||||
details: compactOutput(result),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private isManagedProcessRunning(): boolean {
|
||||
return Boolean(this.gatewayProcess && !this.gatewayProcess.killed && this.gatewayProcess.exitCode === null);
|
||||
}
|
||||
|
||||
private async stopManagedProcess(): Promise<void> {
|
||||
const child = this.gatewayProcess;
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
child.once('exit', () => resolve());
|
||||
});
|
||||
child.kill('SIGTERM');
|
||||
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
if (this.gatewayProcess === child && this.isManagedProcessRunning()) {
|
||||
this.appendLog('[cloudcli] gateway did not stop after SIGTERM; sending SIGKILL');
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private async runHermes(args: string[], options: { timeout: number }): Promise<CommandResult> {
|
||||
try {
|
||||
const result = await execFileAsync(HERMES_COMMAND, [...HERMES_BASE_ARGS, ...args], {
|
||||
timeout: options.timeout,
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_ACCEPT_HOOKS: '1',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
exitCode: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
const execError = error as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number | string | null;
|
||||
};
|
||||
|
||||
return {
|
||||
stdout: execError.stdout ?? '',
|
||||
stderr: execError.stderr ?? '',
|
||||
exitCode: typeof execError.code === 'number' ? execError.code : 1,
|
||||
error: execError.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private appendLog(chunk: string): void {
|
||||
const lines = removeAnsi(chunk)
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logs.push(...lines.map((line) => `${new Date().toISOString()} ${line}`));
|
||||
if (this.logs.length > MAX_LOG_LINES) {
|
||||
this.logs.splice(0, this.logs.length - MAX_LOG_LINES);
|
||||
}
|
||||
}
|
||||
|
||||
private commandPrefix(): string[] {
|
||||
return [HERMES_COMMAND, ...HERMES_BASE_ARGS];
|
||||
}
|
||||
}
|
||||
|
||||
export const hermesGatewayService = new HermesGatewayService();
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -109,6 +109,12 @@ function resolveResumeSessionId(
|
||||
return resolvedSessionId;
|
||||
}
|
||||
|
||||
function getHermesShellCommand(): string {
|
||||
return (process.env.HERMES_COMMAND_PATH || process.env.HERMES_CLI_PATH || 'hermes')
|
||||
.trim()
|
||||
.split(/\s+/)[0] || 'hermes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves provider command line for plain shell and agent-backed shell modes.
|
||||
*/
|
||||
@@ -161,6 +167,14 @@ function buildShellCommand(
|
||||
return initialCommand || 'opencode';
|
||||
}
|
||||
|
||||
if (provider === 'hermes') {
|
||||
const command = initialCommand || getHermesShellCommand();
|
||||
if (resumeSessionId) {
|
||||
return `${command} --resume "${resumeSessionId}"`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
const command = initialCommand || 'claude';
|
||||
if (resumeSessionId) {
|
||||
if (os.platform() === 'win32') {
|
||||
@@ -481,6 +495,8 @@ export function handleShellConnection(
|
||||
? 'Gemini'
|
||||
: provider === 'opencode'
|
||||
? 'OpenCode'
|
||||
: provider === 'hermes'
|
||||
? 'Hermes'
|
||||
: 'Claude';
|
||||
welcomeMsg = hasSession && resumeSessionId
|
||||
? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n`
|
||||
|
||||
@@ -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
|
||||
@@ -996,6 +998,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 === HERMES_CONFIGURED_MODEL ? undefined : model
|
||||
}, 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,12 +8,8 @@ export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
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,48 +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}
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 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,37 +1,30 @@
|
||||
import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../constants/branding';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
const loadingDotAnimationDelays = ['0s', '0.15s', '0.3s'];
|
||||
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" role="status" aria-live="polite">
|
||||
<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 text-2xl font-bold tracking-tight text-foreground"
|
||||
style={{ fontFamily: CLOUDCLI_WORDMARK_FONT_FAMILY }}
|
||||
>
|
||||
CloudCLI
|
||||
</h1>
|
||||
<p className="sr-only">Loading authentication state…</p>
|
||||
<div aria-hidden className="flex items-center justify-center gap-2">
|
||||
<h1 className="mb-2 text-2xl font-bold text-foreground">CloudCLI</h1>
|
||||
|
||||
<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;
|
||||
@@ -336,6 +337,8 @@ export function useChatComposerState({
|
||||
? geminiModel
|
||||
: provider === 'opencode'
|
||||
? opencodeModel
|
||||
: provider === 'hermes'
|
||||
? undefined
|
||||
: claudeModel,
|
||||
tokenUsage: tokenBudget,
|
||||
};
|
||||
@@ -703,6 +706,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 +734,8 @@ export function useChatComposerState({
|
||||
? geminiModel
|
||||
: provider === 'opencode'
|
||||
? opencodeModel
|
||||
: provider === 'hermes'
|
||||
? undefined
|
||||
: claudeModel;
|
||||
|
||||
// One message shape for every provider. The backend resolves the
|
||||
|
||||
@@ -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;
|
||||
@@ -391,6 +417,15 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
||||
model: string,
|
||||
sessionId?: string | null,
|
||||
) => {
|
||||
if (targetProvider === 'hermes') {
|
||||
setStoredProviderModel(targetProvider, model);
|
||||
return {
|
||||
scope: 'default' as const,
|
||||
changed: false,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
|
||||
if (!normalizedSessionId) {
|
||||
setStoredProviderModel(targetProvider, model);
|
||||
@@ -434,6 +469,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,27 +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}
|
||||
aria-label={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
||||
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" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatComposer
|
||||
<ChatComposer
|
||||
pendingPermissionRequests={pendingPermissionRequests}
|
||||
handlePermissionDecision={handlePermissionDecision}
|
||||
handleGrantToolPermission={handleGrantToolPermission}
|
||||
@@ -389,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}
|
||||
@@ -423,7 +421,6 @@ function ChatInterface({
|
||||
onTextareaPaste={handlePaste}
|
||||
onTextareaScrollSync={syncInputOverlayScroll}
|
||||
onTextareaInput={handleTextareaInput}
|
||||
isInputFocused={isInputFocused}
|
||||
onInputFocusChange={handleInputFocusChange}
|
||||
placeholder={t('input.placeholder', {
|
||||
provider:
|
||||
@@ -435,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-[54.25rem] -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-[54.25rem]">
|
||||
<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-[54.25rem]">
|
||||
{!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-[54.25rem] 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(
|
||||
@@ -186,7 +198,7 @@ export default function ProviderSelectionEmptyState({
|
||||
if (!selectedSession && !currentSessionId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-4">
|
||||
<div className="w-full max-w-[34.25rem]">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
||||
{t("providerSelection.title")}
|
||||
@@ -277,15 +289,11 @@ export default function ProviderSelectionEmptyState({
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">{model.label}</div>
|
||||
{/*
|
||||
// * Temporarly commented out because the description of models from claude
|
||||
// * was a bit inconsistent. Will return it back when it becomes more consistent.
|
||||
*/}
|
||||
{/* {model.description && (
|
||||
{model.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{model.description}
|
||||
</div>
|
||||
)} */}
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
||||
@@ -319,6 +327,10 @@ export default function ProviderSelectionEmptyState({
|
||||
model: opencodeModel,
|
||||
defaultValue: "Ready with OpenCode {{model}}",
|
||||
}),
|
||||
hermes: t("providerSelection.readyPrompt.hermes", {
|
||||
model: provider === "hermes" ? currentModelLabel : hermesModel,
|
||||
defaultValue: "Ready with Hermes {{model}}",
|
||||
}),
|
||||
}[provider]
|
||||
}
|
||||
</p>
|
||||
@@ -352,7 +364,7 @@ export default function ProviderSelectionEmptyState({
|
||||
if (selectedSession) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="max-w-[34.25rem] px-6 text-center">
|
||||
<div className="max-w-md px-6 text-center">
|
||||
<p className="mb-1.5 text-lg font-semibold text-foreground">
|
||||
{t("session.continue.title")}
|
||||
</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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -46,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,
|
||||
|
||||
@@ -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' : ''}`} />
|
||||
|
||||
14
src/components/llm-logo-provider/HermesLogo.tsx
Normal file
14
src/components/llm-logo-provider/HermesLogo.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
type HermesLogoProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function HermesLogo({ className = 'w-5 h-5' }: HermesLogoProps) {
|
||||
return (
|
||||
<img
|
||||
className={`${className} block object-contain`}
|
||||
src="/icons/hermes-agent.png"
|
||||
alt="Hermes"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 title={getSessionTitle(selectedSession)} 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}
|
||||
@@ -175,16 +168,13 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-5 rounded-xl border border-destructive/30 bg-destructive/10 p-3.5"
|
||||
>
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div className="mt-6 rounded-lg border border-red-300 bg-red-100 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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}
|
||||
@@ -199,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 ? (
|
||||
<>
|
||||
@@ -217,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 ? (
|
||||
<>
|
||||
@@ -235,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" title={statusText}>{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>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from '../types';
|
||||
|
||||
type ProviderAuthStatusPayload = {
|
||||
installed?: boolean;
|
||||
authenticated?: boolean;
|
||||
email?: string | null;
|
||||
method?: string | null;
|
||||
@@ -34,6 +35,7 @@ const toProviderAuthStatus = (
|
||||
payload: ProviderAuthStatusPayload,
|
||||
fallbackError: string | null = null,
|
||||
): ProviderAuthStatus => ({
|
||||
installed: Boolean(payload.installed),
|
||||
authenticated: Boolean(payload.authenticated),
|
||||
email: payload.email ?? null,
|
||||
method: payload.method ?? null,
|
||||
@@ -78,6 +80,7 @@ export function useProviderAuthStatus(
|
||||
|
||||
if (!response.ok) {
|
||||
const status: ProviderAuthStatus = {
|
||||
installed: false,
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
@@ -95,6 +98,7 @@ export function useProviderAuthStatus(
|
||||
} catch (caughtError) {
|
||||
console.error(`Error checking ${provider} auth status:`, caughtError);
|
||||
const status: ProviderAuthStatus = {
|
||||
installed: false,
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LLMProvider } from '../../types/app';
|
||||
|
||||
export type ProviderAuthStatus = {
|
||||
installed: boolean;
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
@@ -10,7 +11,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,12 +19,14 @@ export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
|
||||
codex: '/api/providers/codex/auth/status',
|
||||
gemini: '/api/providers/gemini/auth/status',
|
||||
opencode: '/api/providers/opencode/auth/status',
|
||||
hermes: '/api/providers/hermes/auth/status',
|
||||
};
|
||||
|
||||
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
|
||||
claude: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
cursor: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
codex: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
gemini: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
opencode: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
claude: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
|
||||
cursor: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
|
||||
codex: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
|
||||
gemini: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
|
||||
opencode: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
|
||||
hermes: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ProviderAuthStatus } from '../../provider-auth/types';
|
||||
|
||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'voice' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about';
|
||||
export type AgentProvider = LLMProvider;
|
||||
export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills';
|
||||
export type AgentCategory = 'account' | 'permissions' | 'gateway' | 'mcp' | 'skills';
|
||||
export type ProjectSortOrder = 'name' | 'date';
|
||||
export type SaveStatus = 'success' | 'error' | null;
|
||||
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Cloud, ExternalLink, MessageSquare, Star, Users } from 'lucide-react';
|
||||
import { ExternalLink, MessageSquare, Star } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../../constants/branding';
|
||||
import { IS_PLATFORM } from '../../../../constants/config';
|
||||
import { useVersionCheck } from '../../../../hooks/useVersionCheck';
|
||||
import PremiumFeatureCard from '../PremiumFeatureCard';
|
||||
import { Cloud, Users } from 'lucide-react';
|
||||
|
||||
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
|
||||
const DISCORD_URL = 'https://discord.gg/buxwujPNRE';
|
||||
@@ -41,12 +40,7 @@ export default function AboutTab() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-base font-semibold text-foreground"
|
||||
style={{ fontFamily: CLOUDCLI_WORDMARK_FONT_FAMILY }}
|
||||
>
|
||||
CloudCLI
|
||||
</span>
|
||||
<span className="text-base font-semibold text-foreground">CloudCLI</span>
|
||||
<a
|
||||
href={releasesUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -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({
|
||||
@@ -65,6 +72,7 @@ export default function AgentListItem({
|
||||
}: AgentListItemProps) {
|
||||
const config = agentConfig[agentId];
|
||||
const colors = colorClasses[config.color];
|
||||
const isReady = agentId !== 'hermes' && authStatus.authenticated;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -80,7 +88,7 @@ export default function AgentListItem({
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate text-xs font-medium">{config.name}</span>
|
||||
{authStatus.authenticated && (
|
||||
{isReady && (
|
||||
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
|
||||
)}
|
||||
</div>
|
||||
@@ -100,10 +108,10 @@ export default function AgentListItem({
|
||||
>
|
||||
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{config.name}</span>
|
||||
{authStatus.authenticated ? (
|
||||
{isReady ? (
|
||||
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
|
||||
) : authStatus.loading ? (
|
||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-muted-foreground/30 animate-pulse" />
|
||||
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-muted-foreground/30" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -25,33 +25,39 @@ export default function AgentsSettingsTab({
|
||||
const visibleCategories = useMemo<AgentCategory[]>(() => (
|
||||
selectedAgent === 'opencode'
|
||||
? ['account', 'permissions', 'mcp']
|
||||
: ['account', 'permissions', 'mcp', 'skills']
|
||||
: selectedAgent === 'hermes'
|
||||
? ['account', 'gateway', 'mcp', 'skills']
|
||||
: ['account', 'permissions', 'mcp', 'skills']
|
||||
), [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 +66,7 @@ export default function AgentsSettingsTab({
|
||||
providerAuthStatus.cursor,
|
||||
providerAuthStatus.gemini,
|
||||
providerAuthStatus.opencode,
|
||||
providerAuthStatus.hermes,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SkillsProject } from '../../../../../skills/types';
|
||||
import { ProviderSkills } from '../../../../../skills';
|
||||
|
||||
import AccountContent from './content/AccountContent';
|
||||
import GatewayContent from './content/GatewayContent';
|
||||
import PermissionsContent from './content/PermissionsContent';
|
||||
|
||||
export default function AgentCategoryContentSection({
|
||||
@@ -29,6 +30,12 @@ export default function AgentCategoryContentSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCategory === 'gateway' && selectedAgent === 'hermes' && (
|
||||
<GatewayContent
|
||||
onOpenSetup={agentContextById.hermes.onLogin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCategory === 'permissions' && selectedAgent === 'claude' && (
|
||||
<PermissionsContent
|
||||
agent="claude"
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function AgentCategoryTabsSection({
|
||||
>
|
||||
{category === 'account' && t('tabs.account')}
|
||||
{category === 'permissions' && t('tabs.permissions')}
|
||||
{category === 'gateway' && t('tabs.gateway', { defaultValue: 'Gateway' })}
|
||||
{category === 'mcp' && t('tabs.mcpServers')}
|
||||
{category === 'skills' && t('tabs.skills', {
|
||||
defaultValue: selectedAgent === 'opencode' ? 'Shared Skills' : 'Skills',
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user