Loading...
Loading...
AI-native terminal emulator & IDE built with Tauri, React, and Rust
npx skill4agent add aradotso/hermes-skills hermes-ide-terminalSkill by ara.so — Hermes Skills collection.
# Visit https://www.hermes-ide.com/download
# Choose your platform: macOS (Intel/Apple Silicon), Windows, Linux (.deb/.AppImage/.rpm)# Check versions
node --version # 18+
rustc --version # 1.70+# Linux (Debian/Ubuntu)
sudo apt install libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
# macOS
xcode-select --install
# Windows
# Install Visual Studio Build Tools with "Desktop development with C++"
# Install WebView2 Runtime from Microsoftgit clone https://github.com/hermes-hq/hermes-ide.git
cd hermes-ide
npm install
npm run tauri dev # Development mode with hot-reload
npm run tauri build # Production buildReact Frontend (TypeScript) ← Tauri IPC Bridge → Rust Backend
UI & State PTY, DB, Scannersrc/src-tauri/src/api/src/terminal/claude# Install Claude CLI first
npm install -g @anthropic-ai/claude-cli
# Authenticate (choose one method)
claude auth login # Pro/Max account
claude auth api-key $ANTHROPIC_API_KEY # API key// Project scanning happens automatically when you:
// 1. Open a terminal in a directory
// 2. Attach a project via sidebar
// 3. Use Cmd/Ctrl+Shift+P → "Scan Project"
// Detected information includes:
// - Languages and frameworks
// - Project structure
// - Dependencies
// - Git repository info{
"shell": {
"program": "/bin/zsh",
"args": ["-l"]
},
"environment": {
"TERM": "xterm-256color",
"COLORTERM": "truecolor"
},
"fontSize": 14,
"fontFamily": "JetBrains Mono, monospace"
}// Create new session
// Cmd/Ctrl+T
// Switch sessions
// Cmd/Ctrl+1-9 (first 9 sessions)
// Or use sidebar
// Split panes
// Cmd/Ctrl+D (horizontal)
// Cmd/Ctrl+Shift+D (vertical)
// Close session
// Cmd/Ctrl+W# All git operations available via UI
# - View staged/unstaged/untracked files
# - Click files to see inline diffs
# - Stage/unstage with checkboxes
# - Commit with message input
# - Push/Pull buttons
# Or use git commands directly in terminal
git add .
git commit -m "feat: add new feature"
git push origin main# Start typing and ghost text appears
git com█
# Suggestion: git commit -m "message"
# Accept with Tab or Right Arrow
# Reject with Esc or continue typing# Open Prompt Composer: Cmd/Ctrl+Shift+P
# Type natural language instructions:
"Create a new React component called UserProfile with TypeScript"
"Fix all eslint errors in src/"
"Run tests and commit if they pass"
# Hermes executes commands and tracks progress# Open: Cmd/Ctrl+K
# Fuzzy search for:
# - Commands
# - Settings
# - Projects
# - Sessions
# - Git operations// src/components/Terminal.tsx
import React, { useEffect, useRef } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
export const TerminalComponent: React.FC = () => {
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
useEffect(() => {
if (!terminalRef.current) return;
const term = new Terminal({
fontFamily: 'JetBrains Mono, monospace',
fontSize: 14,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalRef.current);
fitAddon.fit();
xtermRef.current = term;
return () => {
term.dispose();
};
}, []);
return <div ref={terminalRef} className="terminal-container" />;
};// src-tauri/src/pty/mod.rs
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use std::sync::Arc;
use tokio::sync::Mutex;
pub struct PtySession {
pub id: String,
pty: Arc<Mutex<Box<dyn portable_pty::MasterPty + Send>>>,
writer: Arc<Mutex<Box<dyn std::io::Write + Send>>>,
}
impl PtySession {
pub fn new(shell: &str, cwd: &str) -> Result<Self, Box<dyn std::error::Error>> {
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})?;
let mut cmd = CommandBuilder::new(shell);
cmd.cwd(cwd);
let _child = pair.slave.spawn_command(cmd)?;
let writer = pair.master.take_writer()?;
Ok(Self {
id: uuid::Uuid::new_v4().to_string(),
pty: Arc::new(Mutex::new(pair.master)),
writer: Arc::new(Mutex::new(writer)),
})
}
pub async fn write(&self, data: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let mut writer = self.writer.lock().await;
writer.write_all(data)?;
writer.flush()?;
Ok(())
}
}// src-tauri/src/commands.rs
use tauri::command;
#[command]
pub async fn create_terminal_session(
shell: String,
cwd: String,
) -> Result<String, String> {
let session = PtySession::new(&shell, &cwd)
.map_err(|e| e.to_string())?;
let session_id = session.id.clone();
// Store session in global state
// ...
Ok(session_id)
}
#[command]
pub async fn write_to_terminal(
session_id: String,
data: String,
) -> Result<(), String> {
// Get session from global state
// ...
session.write(data.as_bytes())
.await
.map_err(|e| e.to_string())?;
Ok(())
}// src/api/terminal.ts
import { invoke } from '@tauri-apps/api/tauri';
export interface TerminalSession {
id: string;
cwd: string;
shell: string;
}
export async function createTerminalSession(
shell: string,
cwd: string
): Promise<string> {
return await invoke<string>('create_terminal_session', {
shell,
cwd,
});
}
export async function writeToTerminal(
sessionId: string,
data: string
): Promise<void> {
await invoke('write_to_terminal', {
sessionId,
data,
});
}
export async function resizeTerminal(
sessionId: string,
rows: number,
cols: number
): Promise<void> {
await invoke('resize_terminal', {
sessionId,
rows,
cols,
});
}// src/state/AppContext.tsx
import React, { createContext, useReducer } from 'react';
interface AppState {
sessions: TerminalSession[];
activeSessionId: string | null;
projects: Project[];
}
type AppAction =
| { type: 'ADD_SESSION'; session: TerminalSession }
| { type: 'REMOVE_SESSION'; sessionId: string }
| { type: 'SET_ACTIVE_SESSION'; sessionId: string };
const appReducer = (state: AppState, action: AppAction): AppState => {
switch (action.type) {
case 'ADD_SESSION':
return {
...state,
sessions: [...state.sessions, action.session],
};
case 'REMOVE_SESSION':
return {
...state,
sessions: state.sessions.filter(s => s.id !== action.sessionId),
};
case 'SET_ACTIVE_SESSION':
return {
...state,
activeSessionId: action.sessionId,
};
default:
return state;
}
};
export const AppContext = createContext<{
state: AppState;
dispatch: React.Dispatch<AppAction>;
} | null>(null);
export const AppProvider: React.FC = ({ children }) => {
const [state, dispatch] = useReducer(appReducer, {
sessions: [],
activeSessionId: null,
projects: [],
});
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
};// src/hooks/useTerminal.ts
import { useContext, useCallback } from 'react';
import { AppContext } from '../state/AppContext';
import { createTerminalSession, writeToTerminal } from '../api/terminal';
export const useTerminal = () => {
const context = useContext(AppContext);
if (!context) throw new Error('useTerminal must be used within AppProvider');
const { state, dispatch } = context;
const createSession = useCallback(async (shell: string, cwd: string) => {
const sessionId = await createTerminalSession(shell, cwd);
dispatch({
type: 'ADD_SESSION',
session: { id: sessionId, shell, cwd },
});
dispatch({ type: 'SET_ACTIVE_SESSION', sessionId });
return sessionId;
}, [dispatch]);
const write = useCallback(async (data: string) => {
if (!state.activeSessionId) return;
await writeToTerminal(state.activeSessionId, data);
}, [state.activeSessionId]);
return {
sessions: state.sessions,
activeSessionId: state.activeSessionId,
createSession,
write,
};
};// src-tauri/src/project/scanner.rs
use std::path::Path;
use walkdir::WalkDir;
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
pub struct ProjectContext {
pub languages: Vec<String>,
pub frameworks: Vec<String>,
pub dependencies: Vec<String>,
pub structure: String,
}
pub fn scan_project(path: &Path) -> Result<ProjectContext, Box<dyn std::error::Error>> {
let mut languages = Vec::new();
let mut frameworks = Vec::new();
let mut dependencies = Vec::new();
// Detect package.json (Node.js)
if path.join("package.json").exists() {
languages.push("JavaScript".to_string());
let package_json = std::fs::read_to_string(path.join("package.json"))?;
let package: serde_json::Value = serde_json::from_str(&package_json)?;
if let Some(deps) = package.get("dependencies") {
if deps.get("react").is_some() {
frameworks.push("React".to_string());
}
if deps.get("vue").is_some() {
frameworks.push("Vue".to_string());
}
}
}
// Detect Cargo.toml (Rust)
if path.join("Cargo.toml").exists() {
languages.push("Rust".to_string());
}
// Count files by type for structure summary
let mut file_counts = std::collections::HashMap::new();
for entry in WalkDir::new(path).max_depth(3) {
if let Ok(entry) = entry {
if let Some(ext) = entry.path().extension() {
*file_counts.entry(ext.to_string_lossy().to_string()).or_insert(0) += 1;
}
}
}
Ok(ProjectContext {
languages,
frameworks,
dependencies,
structure: format!("{:?}", file_counts),
})
}# Run all tests
npm run test
# Run with coverage
npm run test:coverage
# Watch mode
npm run test:watch// src/components/__tests__/Terminal.test.tsx
import { render, screen } from '@testing-library/react';
import { TerminalComponent } from '../Terminal';
describe('TerminalComponent', () => {
it('renders terminal container', () => {
render(<TerminalComponent />);
const container = screen.getByClassName('terminal-container');
expect(container).toBeInTheDocument();
});
});cd src-tauri
cargo test
cargo test -- --nocapture # Show output#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pty_session_creation() {
let session = PtySession::new("/bin/bash", "/tmp");
assert!(session.is_ok());
let session = session.unwrap();
assert!(!session.id.is_empty());
}
#[tokio::test]
async fn test_write_to_pty() {
let session = PtySession::new("/bin/bash", "/tmp").unwrap();
let result = session.write(b"echo test\n").await;
assert!(result.is_ok());
}
}webkit2gtk not found# Linux
sudo apt install libwebkit2gtk-4.1-dev
# If still failing, check pkg-config
pkg-config --modversion webkit2gtk-4.1Rust toolchain not foundcurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/envnode-gypnpm install --global windows-build-tools
npm config set msvs_version 2019// Check terminal element is mounted
useEffect(() => {
console.log('Terminal ref:', terminalRef.current);
if (!terminalRef.current) {
console.error('Terminal container not found');
return;
}
// ...
}, []);// Add timeout to PTY operations
use tokio::time::{timeout, Duration};
let result = timeout(
Duration::from_secs(5),
session.write(data)
).await;
if result.is_err() {
eprintln!("PTY write timed out");
}# Check SSH agent
ssh-add -l
# Or use Git Credential Manager
git config --global credential.helper manager
# Set environment variable in Hermes settings
# GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa"# Check terminal rendering settings
# Reduce font size or disable ligatures
# Limit scrollback buffer size in settings// Ensure proper cleanup in useEffect
useEffect(() => {
const term = new Terminal();
// ...
return () => {
term.dispose(); // Critical!
};
}, []);# Claude API configuration
export ANTHROPIC_API_KEY=sk-ant-...
# Custom shell
export HERMES_SHELL=/bin/zsh
# Debug logging
export RUST_LOG=debug
export HERMES_DEBUG=1
# Custom config directory
export HERMES_CONFIG_DIR=$HOME/.config/hermes# Fork and clone
git clone https://github.com/YOUR_USERNAME/hermes-ide.git
cd hermes-ide
# Create feature branch
git checkout -b feature/my-feature
# Make changes and test
npm run tauri dev
npm run test
cd src-tauri && cargo test
# Type check
npx tsc --noEmit
# Commit (follows conventional commits)
git commit -m "feat: add new terminal feature"
# Push and open PR
git push origin feature/my-feature
# Sign CLA when prompted