Add a Terminal page to the dashboard

Adds a real shell terminal to the dashboard sidebar: POST
/api/terminal/run executes a command via subprocess in a
server-tracked working directory (with cd support), and GET
/api/terminal/session returns the current cwd. The frontend renders
a scrollback panel with command history (up/down arrows) styled to
match the existing chat UI.

Local-only power feature: it executes arbitrary shell commands, same
trust model as the existing agent CLIs the dashboard already shells
out to.
This commit is contained in:
Claude 2026-07-05 22:15:47 +00:00
parent 9c1cac130a
commit fec0601722
6 changed files with 188 additions and 0 deletions

View File

@ -52,6 +52,9 @@ const api = {
discoverStandards: () => api.post('/api/standards/discover'),
chat: (agent, message, controller) => api.post('/api/chat', { agent, message }, controller),
getChatHistory: () => api.get('/api/chat/history'),
// Terminal
getTerminalSession: () => api.get('/api/terminal/session'),
runTerminalCommand: (command) => api.post('/api/terminal/run', { command }),
// Kanban
getKanbanBoard: (status) => api.get(status ? `/api/kanban/board?status=${encodeURIComponent(status)}` : '/api/kanban/board'),
getKanbanTask: (id) => api.get(`/api/kanban/tasks/${encodeURIComponent(id)}`),

View File

@ -33,6 +33,7 @@
<div class="sidebar-nav">
<div class="sidebar-section"><div class="sidebar-section-label">Primary</div></div>
<a href="#chat" class="nav-item" data-page="chat"><span class="nav-icon">💬</span><span class="nav-label">AI Chat</span></a>
<a href="#terminal" class="nav-item" data-page="terminal"><span class="nav-icon"></span><span class="nav-label">Terminal</span></a>
<a href="#dashboard" class="nav-item active" data-page="dashboard"><span class="nav-icon"></span><span class="nav-label">Dashboard</span></a>
<div class="sidebar-section"><div class="sidebar-section-label">Agents</div></div>
<a href="#skills" class="nav-item" data-page="skills"><span class="nav-icon"></span><span class="nav-label">Skills</span><span class="nav-badge" id="skillCount">0</span></a>

114
dashboard/pages/terminal.js Normal file
View File

@ -0,0 +1,114 @@
async function renderTerminal() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">Terminal</h1>
<p class="page-subtitle">Run shell commands directly on this machine</p>
</div>
<div class="btn-group">
<button class="btn" onclick="clearTerminal()">🗑 Clear</button>
</div>
</div>
<div class="terminal-panel" onclick="focusTerminalInput()">
<div id="terminalOutput" class="terminal-output"></div>
<div class="terminal-input-row">
<span class="terminal-prompt" id="terminalPrompt">$</span>
<input id="terminalInput" class="terminal-input" type="text" autocomplete="off" spellcheck="false" onkeydown="handleTerminalKey(event)">
</div>
</div>
`;
window._terminalHistory = window._terminalHistory || [];
window._terminalHistoryIndex = window._terminalHistory.length;
window._terminalBusy = false;
try {
const session = await api.getTerminalSession();
updateTerminalPrompt(session.cwd);
} catch {
appendTerminalLine('Could not reach the terminal backend.', 'stderr');
}
document.getElementById('terminalInput').focus();
}
function focusTerminalInput() {
const input = document.getElementById('terminalInput');
if (input) input.focus();
}
function updateTerminalPrompt(cwd) {
const prompt = document.getElementById('terminalPrompt');
if (prompt) prompt.textContent = `${cwd} $`;
}
function appendTerminalLine(text, kind = '') {
const output = document.getElementById('terminalOutput');
if (!output || !text) return;
const line = document.createElement('div');
if (kind) line.className = `terminal-line-${kind}`;
line.textContent = text;
output.appendChild(line);
output.scrollTop = output.scrollHeight;
}
function clearTerminal() {
const output = document.getElementById('terminalOutput');
if (output) output.innerHTML = '';
}
async function handleTerminalKey(e) {
const input = e.target;
if (e.key === 'ArrowUp') {
e.preventDefault();
if (window._terminalHistoryIndex > 0) {
window._terminalHistoryIndex--;
input.value = window._terminalHistory[window._terminalHistoryIndex] || '';
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (window._terminalHistoryIndex < window._terminalHistory.length - 1) {
window._terminalHistoryIndex++;
input.value = window._terminalHistory[window._terminalHistoryIndex] || '';
} else {
window._terminalHistoryIndex = window._terminalHistory.length;
input.value = '';
}
return;
}
if (e.key !== 'Enter' || window._terminalBusy) return;
const command = input.value.trim();
input.value = '';
if (!command) return;
window._terminalHistory.push(command);
window._terminalHistoryIndex = window._terminalHistory.length;
appendTerminalLine(command, 'cmd');
if (command === 'clear' || command === 'cls') {
clearTerminal();
return;
}
window._terminalBusy = true;
input.disabled = true;
try {
const r = await api.runTerminalCommand(command);
if (r.stdout) appendTerminalLine(r.stdout.replace(/\n$/, ''));
if (r.stderr) appendTerminalLine(r.stderr.replace(/\n$/, ''), 'stderr');
if (r.timed_out) appendTerminalLine('Command timed out after 60s.', 'info');
updateTerminalPrompt(r.cwd);
} catch (err) {
appendTerminalLine(`Error: ${err.message}`, 'stderr');
} finally {
window._terminalBusy = false;
input.disabled = false;
input.focus();
}
}

View File

@ -821,6 +821,38 @@ pre {
.chat-message { max-width: 95%; }
}
/* Terminal */
.terminal-panel {
flex: 1; display: flex; flex-direction: column;
min-height: 0;
background: #0a0e14;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow);
font-family: var(--font-mono);
}
.terminal-output {
flex: 1; overflow-y: auto; padding: 16px;
font-size: 13px; line-height: 1.6;
color: #c9d1d9; white-space: pre-wrap; word-break: break-word;
}
.terminal-line-cmd { color: #7ee787; }
.terminal-line-cmd::before { content: '$ '; color: #58a6ff; }
.terminal-line-stderr { color: #ff7b72; }
.terminal-line-info { color: var(--text-muted); font-style: italic; }
.terminal-input-row {
display: flex; align-items: center; gap: 8px;
padding: 10px 16px;
background: #0d1117;
border-top: 1px solid var(--border);
}
.terminal-prompt { color: #58a6ff; font-size: 13px; white-space: nowrap; }
.terminal-input {
flex: 1; background: transparent; border: none; outline: none;
color: #c9d1d9; font-family: var(--font-mono); font-size: 13px;
}
/* ─── v0.2.0 UI Modernization ─── */
/* Glass card variant */

View File

@ -123,6 +123,7 @@ const PAGE_TITLES = {
settings: { title: 'Settings', breadcrumb: 'Configuration' },
'setup-wizard': { title: 'Setup Wizard', breadcrumb: 'Guided configuration' },
chat: { title: 'AI Chat', breadcrumb: 'Multi-agent terminal' },
terminal: { title: 'Terminal', breadcrumb: 'Local shell access' },
kanban: { title: 'Kanban Board', breadcrumb: 'Multi-agent task management' },
goals: { title: 'Goals', breadcrumb: 'Project targets and progress' },
journal: { title: 'Journal', breadcrumb: 'Daily entries and notes' },

View File

@ -99,6 +99,9 @@ class ChatRequest(BaseModel):
agent: str
message: str
class TerminalRunRequest(BaseModel):
command: str
# ─── Helper Functions ─────────────────────────────────────────────
def read_file(path: Path):
@ -662,6 +665,40 @@ def chat(req: ChatRequest):
def get_chat_history():
return load_chat_history()
# ─── Routes: Terminal ─────────────────────────────────────────────
_terminal_cwd = str(BASE_DIR)
@app.get("/api/terminal/session")
def get_terminal_session():
return {"cwd": _terminal_cwd}
@app.post("/api/terminal/run")
def run_terminal_command(req: TerminalRunRequest):
global _terminal_cwd
command = req.command.strip()
if not command:
return {"cwd": _terminal_cwd, "stdout": "", "stderr": "", "returncode": 0, "timed_out": False}
if command == "cd" or command.startswith("cd "):
target = command[2:].strip() or str(Path.home())
new_dir = (Path(_terminal_cwd) / target).resolve() if not Path(target).is_absolute() else Path(target).resolve()
if not new_dir.is_dir():
return {"cwd": _terminal_cwd, "stdout": "", "stderr": f"cd: no such directory: {target}", "returncode": 1, "timed_out": False}
_terminal_cwd = str(new_dir)
append_audit({"action": "terminal_command", "command": "cd", "cwd": _terminal_cwd})
return {"cwd": _terminal_cwd, "stdout": "", "stderr": "", "returncode": 0, "timed_out": False}
try:
r = subprocess.run(
command, shell=True, cwd=_terminal_cwd,
capture_output=True, text=True, timeout=60,
)
append_audit({"action": "terminal_command", "command": command[:200], "cwd": _terminal_cwd})
return {"cwd": _terminal_cwd, "stdout": r.stdout, "stderr": r.stderr, "returncode": r.returncode, "timed_out": False}
except subprocess.TimeoutExpired:
return {"cwd": _terminal_cwd, "stdout": "", "stderr": "Command timed out after 60s.", "returncode": -1, "timed_out": True}
# ═══════════════════════════════════════════════════════════════════
# v0.2.0 — New Feature Endpoints
# ═══════════════════════════════════════════════════════════════════