diff --git a/src/ursa_dashboard/app.py b/src/ursa_dashboard/app.py
index 020bd462..8621bc9a 100644
--- a/src/ursa_dashboard/app.py
+++ b/src/ursa_dashboard/app.py
@@ -1606,6 +1606,8 @@ async def ui_workspace_all(
settings: null,
_renderTimers: { stdout: null, stderr: null },
_logToken: 0,
+ _followLogs: { stdout: true, stderr: true },
+ _pendingWhilePaused: { stdout: false, stderr: false },
};
function escHtml(s) {
@@ -2041,12 +2043,18 @@ async def ui_workspace_all(
const key = (which === 'stderr') ? 'stderr' : 'stdout';
if (state._renderTimers[key]) return;
state._renderTimers[key] = setTimeout(() => {
- state._renderTimers[key] = null;
- const el = (key === 'stderr') ? $('#stderrLog') : $('#stdoutLog');
- if (!el) return;
- const raw = (key === 'stderr') ? state.logs.stderr : state.logs.stdout;
- el.innerHTML = ansiToHtml(raw);
- el.scrollTop = el.scrollHeight;
+ state._renderTimers[key] = null;
+ const el = (key === 'stderr') ? $('#stderrLog') : $('#stdoutLog');
+ if (!el) return;
+
+ if (!state._followLogs[key]) {
+ state._pendingWhilePaused[key] = true;
+ return;
+ }
+
+ const raw = (key === 'stderr') ? state.logs.stderr : state.logs.stdout;
+ el.innerHTML = ansiToHtml(raw);
+ el.scrollTop = el.scrollHeight;
}, 60);
}
@@ -2065,23 +2073,55 @@ async def ui_workspace_all(
return out.join('');
}
+ // this helps w/ Rich console animations
+ function applyCarriageReturns(existing, incoming) {
+ let out = existing;
+ let lineStart = out.lastIndexOf('\n') + 1;
+
+ for (let i = 0; i < incoming.length; i++) {
+ const ch = incoming[i];
+
+ if (ch === '\r') {
+ // Move cursor to start of current line.
+ lineStart = out.lastIndexOf('\n') + 1;
+ out = out.slice(0, lineStart);
+ continue;
+ }
+
+ out += ch;
+
+ if (ch === '\n') {
+ lineStart = out.length;
+ }
+ }
+
+ return out;
+ }
+
+ function trimToLastLines(text, maxLines) {
+ text = String(text ?? '');
+ maxLines = Number(maxLines);
+
+ if (!Number.isFinite(maxLines) || maxLines <= 0) return '';
+
+ const lines = text.split('\n');
+ if (lines.length <= maxLines) return text;
+ return lines.slice(-maxLines).join('\n');
+ }
+
function appendLog(stream, text) {
- const cap = 250000;
+ const capLines = state.settings?.ui?.stdout_buffer_lines ?? 5000;
const key = (stream === 'stderr') ? 'stderr' : 'stdout';
text = String(text ?? '');
- // Some console UIs emit backspaces for spinners.
text = stripBackspaces(text);
- // Make progress-style output readable even without a real terminal.
- text = text.replace(/\r(?!\n)/g, '\n');
-
if (key === 'stderr') {
- state.logs.stderr = (state.logs.stderr + text);
- if (state.logs.stderr.length > cap) state.logs.stderr = state.logs.stderr.slice(-cap);
+ state.logs.stderr = applyCarriageReturns(state.logs.stderr, text);
+ state.logs.stderr = trimToLastLines(state.logs.stderr, capLines);
} else {
- state.logs.stdout = (state.logs.stdout + text);
- if (state.logs.stdout.length > cap) state.logs.stdout = state.logs.stdout.slice(-cap);
+ state.logs.stdout = applyCarriageReturns(state.logs.stdout, text);
+ state.logs.stdout = trimToLastLines(state.logs.stdout, capLines);
}
scheduleLogRender(key);
@@ -2098,8 +2138,9 @@ async def ui_workspace_all(
let after = 0;
let pages = 0;
const limit = 5000;
+ const maxPages = 50;
- while (pages < 50) {
+ while (pages < maxPages) {
if (token !== state._logToken) return;
const res = await api('GET', `/runs/${encodeURIComponent(runId)}/events?after_seq=${after}&limit=${limit}`);
const events = res.events || [];
@@ -2269,66 +2310,80 @@ async def ui_workspace_all(
}
}
+
function renderSessions() {
const list = $('#sessionList');
if (!list) return;
list.innerHTML = '';
for (const s of state.sessions) {
- const row = document.createElement('div');
- row.className = 'sessionRow';
-
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'histItem' + (state.activeSessionId === s.session_id ? ' selected' : '');
- btn.onclick = () => loadSession(s.session_id);
-
- const title = document.createElement('div');
- title.textContent = s.title || s.session_id;
-
- const meta = document.createElement('div');
- meta.className = 'muted small';
- const agentName = state.agentsById.get(s.agent_id)?.display_name || s.agent_id;
- const active = s.active_run_id ? ' (running)' : '';
- meta.textContent = `${agentName} \u00b7 ${fmtTime(s.updated_at)}${active}`;
-
- btn.appendChild(title);
- btn.appendChild(meta);
-
- const renameBtn = document.createElement('button');
- renameBtn.type = 'button';
- renameBtn.className = 'sessActBtn';
- renameBtn.textContent = 'Rename';
- renameBtn.title = 'Rename session';
- renameBtn.onclick = async (e) => {
+ const row = document.createElement('div');
+ row.className = 'sessionRow';
+
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ const isRunning = !!s.active_run_id;
+ btn.className = 'histItem' + (state.activeSessionId === s.session_id ? ' selected' : '') + (isRunning ? ' running' : ' idle');
+ btn.onclick = () => loadSession(s.session_id);
+
+ const title = document.createElement('div');
+ title.className = 'sessionTitle';
+ title.textContent = s.title || s.session_id;
+
+ const meta = document.createElement('div');
+ meta.className = 'muted small sessionMeta';
+
+ const agentName = state.agentsById.get(s.agent_id)?.display_name || s.agent_id;
+ const stateClass = isRunning ? 'running' : 'idle';
+ const stateText = isRunning ? 'Running' : 'Idle';
+
+ meta.innerHTML = `
+
+ ${stateText}
+
+ ·
+ ${escHtml(agentName)}
+ ·
+ ${escHtml(fmtTime(s.updated_at))}
+ `;
+
+ btn.appendChild(title);
+ btn.appendChild(meta);
+
+ const renameBtn = document.createElement('button');
+ renameBtn.type = 'button';
+ renameBtn.className = 'sessActBtn';
+ renameBtn.textContent = 'Rename';
+ renameBtn.title = 'Rename session';
+ renameBtn.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
const cur = s.title || '';
const next = prompt('Rename session', cur);
if (next === null) return;
await renameSession(s.session_id, next);
- };
-
- const delBtn = document.createElement('button');
- delBtn.type = 'button';
- delBtn.className = 'sessActBtn danger';
- delBtn.textContent = 'Delete';
- delBtn.title = s.active_run_id ? 'Cannot delete while a run is active' : 'Delete session';
- delBtn.disabled = !!s.active_run_id;
- delBtn.onclick = async (e) => {
+ };
+
+ const delBtn = document.createElement('button');
+ delBtn.type = 'button';
+ delBtn.className = 'sessActBtn danger';
+ delBtn.textContent = 'Delete';
+ delBtn.title = s.active_run_id ? 'Cannot delete while a run is active' : 'Delete session';
+ delBtn.disabled = !!s.active_run_id;
+ delBtn.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
await deleteSessionUI(s.session_id);
- };
+ };
- row.appendChild(btn);
- row.appendChild(renameBtn);
- row.appendChild(delBtn);
- list.appendChild(row);
+ row.appendChild(btn);
+ row.appendChild(renameBtn);
+ row.appendChild(delBtn);
+ list.appendChild(row);
}
if (!state.sessions.length) {
- list.innerHTML = '
No sessions yet. Start one from the Agents list.
';
+ list.innerHTML = 'No sessions yet. Start one from the Agents list.
';
}
}
@@ -2337,15 +2392,23 @@ async def ui_workspace_all(
const meta = $('#activeSessionMeta');
const msgs = $('#sessionMessages');
const wsTitle = $('#workspaceTitle');
+ const badge = $('#activeSessionStateBadge');
if (!state.activeSession) {
- if (title) title.textContent = 'No session selected';
- if (meta) meta.textContent = '';
- if (msgs) msgs.innerHTML = 'Pick an agent to start a new session, or select a session on the left.
';
- if (wsTitle) wsTitle.textContent = 'Session artifacts';
- setStatus('');
- clearRunView();
- return;
+ if (title) title.textContent = 'No session selected';
+ if (meta) meta.textContent = '';
+ if (msgs) msgs.innerHTML = 'Pick an agent to start a new session, or select a session on the left.
';
+ if (wsTitle) wsTitle.textContent = 'Session artifacts';
+
+ if (badge) {
+ badge.style.display = 'none';
+ badge.className = 'sessionStatus idle';
+ badge.innerHTML = '
-
No session selected
+
+
No session selected
+
+
+ Idle
+
+
@@ -3324,6 +3569,8 @@ async def ui_dashboard() -> HTMLResponse:
+
+
@@ -3335,46 +3582,93 @@ async def ui_dashboard() -> HTMLResponse:
Close
-
-
LLM
-
-
-
-
The dashboard does not store API keys. Set the key in the dashboard server environment and reference its variable name here.
-
-
-
-
-
-
-
-
MCP tools
-
- MCP servers configured here will be started in the worker subprocess and their tools will be attached to the ExecutionAgent (and the executor inside the Planning + Execution Workflow) for new runs.
+
+
+ User Interface
+ LLM
+ Runner
+ MCP tools
-
-
Configured servers
-
-
-
Click a server name to edit it.
+
+
+
+
User Interface
+
+
Theme
+
+ System
+ Light
+ Dark
+
+
+
+
+
+ Follow your OS/browser theme, or force light or dark mode for the dashboard.
+
+
+
+
STDOUT buffer lines
+
+
+
+
+
+ Number of log lines kept in the browser for the STDOUT pane. Higher values preserve more scrollback but can make the page heavier for very long runs.
+
+
+
-
-
-
-
Server config (JSON)
-
-
+
+
+
LLM
+
+
+
+
The dashboard does not store API keys. Set the key in the dashboard server environment and reference its variable name here.
+
+
+
+
-
-
Add/Update
-
Remove
-
Clear
-
+
+
+
+
+
MCP tools
+
+ MCP servers configured here will be started in the worker subprocess and their tools will be attached to the ExecutionAgent (and the executor inside the Planning + Execution Workflow) for new runs.
+
+
+
+
Configured servers
+
+
+
Click a server name to edit it.
+
+
+
+
+
+
Server config (JSON)
+
+
+
+
+
Add/Update
+
Remove
+
Clear
+
+
+
+
@@ -3388,6 +3682,9 @@ async def ui_dashboard() -> HTMLResponse:
+
+
+
"""
html_doc = f"""
diff --git a/src/ursa_dashboard/settings.py b/src/ursa_dashboard/settings.py
index c353e27f..d928b66e 100644
--- a/src/ursa_dashboard/settings.py
+++ b/src/ursa_dashboard/settings.py
@@ -74,6 +74,11 @@ def _validate_servers(cls, v: Any) -> dict[str, Any]:
return v
+class UISettings(BaseModel):
+ theme: str = "system" # e.g. dark/light mode
+ stdout_buffer_lines: int = Field(default=20_000, ge=5_000, le=100_000_000)
+
+
class GlobalSettings(BaseModel):
"""Global settings that apply to new runs only."""
@@ -81,6 +86,7 @@ class GlobalSettings(BaseModel):
llm: LLMSettings = Field(default_factory=LLMSettings)
runner: RunnerSettings = Field(default_factory=RunnerSettings)
mcp: MCPSettings = Field(default_factory=MCPSettings)
+ ui: UISettings = Field(default_factory=UISettings)
class SettingsStore: