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:
parent
9c1cac130a
commit
fec0601722
|
|
@ -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)}`),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
37
server.py
37
server.py
|
|
@ -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
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Reference in New Issue