|
|
@@ -1,428 +0,0 @@
|
|
|
-<!DOCTYPE html>
|
|
|
-<html lang="en">
|
|
|
-<head>
|
|
|
- <pstm-uri>/tasks/{task_id}/terminal</pstm-uri>
|
|
|
- <pstm-template>main</pstm-template>
|
|
|
- <script>
|
|
|
- if (window.statum) {
|
|
|
- (function () {
|
|
|
- 'use strict';
|
|
|
-
|
|
|
- var connected = false;
|
|
|
-
|
|
|
- // Free the browser's per-origin SSE budget (HTTP/1.1 caps
|
|
|
- // ~6 connections): a hidden terminal tab nobody watches
|
|
|
- // must not starve Statum's realtime channel. Reconnect on
|
|
|
- // focus — the stream replays and the client dedupes by
|
|
|
- // seq/id.
|
|
|
- document.addEventListener('visibilitychange', function () {
|
|
|
- if (!activeSource) return;
|
|
|
- if (document.visibilityState === 'hidden') {
|
|
|
- try { activeSource.close(); } catch (e) { }
|
|
|
- } else if (connected) {
|
|
|
- try { activeSource.close(); } catch (e) { }
|
|
|
- connect(activeSession, activeRaw);
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- // The plan-12 session-picker fix: the Statum entrypoint
|
|
|
- // fetch never carries this page's own ?session_id= to
|
|
|
- // the server, so the server-side selection was stuck on
|
|
|
- // its default and only the first-rendered session showed.
|
|
|
- // The client therefore owns the selection: it reads
|
|
|
- // ?session_id= from its own URL (falling back to the
|
|
|
- // server default — the latest session), picks the
|
|
|
- // matching session row's raw flag, and (re)connects
|
|
|
- // whenever the choice changes.
|
|
|
- function requestedSessionId(state) {
|
|
|
- var match = /[?&]session_id=(\d+)/.exec(window.location.search || '');
|
|
|
- if (match) {
|
|
|
- var requested = parseInt(match[1], 10);
|
|
|
- var rows = state.sessions || [];
|
|
|
- for (var i = 0; i < rows.length; i++) {
|
|
|
- if (parseInt(rows[i].id, 10) === requested) return requested;
|
|
|
- }
|
|
|
- }
|
|
|
- return state.session_id;
|
|
|
- }
|
|
|
-
|
|
|
- function sessionRaw(state, sessionId) {
|
|
|
- var rows = state.sessions || [];
|
|
|
- for (var i = 0; i < rows.length; i++) {
|
|
|
- if (parseInt(rows[i].id, 10) === parseInt(sessionId, 10)) {
|
|
|
- return !!rows[i].raw;
|
|
|
- }
|
|
|
- }
|
|
|
- return !!state.has_raw_log;
|
|
|
- }
|
|
|
-
|
|
|
- function highlightPicker(sessionId) {
|
|
|
- var select = document.getElementById('session-select');
|
|
|
- if (select) select.value = String(parseInt(sessionId, 10));
|
|
|
- }
|
|
|
-
|
|
|
- // Plan 14 B4: the dropdown + resume-info panel read the
|
|
|
- // per-session rows (newest first) from state; the panel
|
|
|
- // shows the selected session's notice — the opening
|
|
|
- // brief for the first session, the stored resume_notice
|
|
|
- // for resumed ones — rendered as markdown.
|
|
|
- window.terminalSessionNotice = function (state) {
|
|
|
- var desired = requestedSessionId(state);
|
|
|
- var rows = (state && state.sessions) || [];
|
|
|
- for (var i = 0; i < rows.length; i++) {
|
|
|
- if (parseInt(rows[i].id, 10) === parseInt(desired, 10)) {
|
|
|
- return rows[i].notice || '';
|
|
|
- }
|
|
|
- }
|
|
|
- return '';
|
|
|
- };
|
|
|
-
|
|
|
- window.terminalPickSession = function (sessionId) {
|
|
|
- var task = statum.read('terminal');
|
|
|
- window.location.href = '/tasks/' + (task && task.task_id)
|
|
|
- + '/terminal?session_id=' + parseInt(sessionId, 10);
|
|
|
- };
|
|
|
-
|
|
|
- statum.addStateListener('terminal', function () {
|
|
|
- var state = statum.read('terminal');
|
|
|
- if (!state || !state.exists || !(state.session_id > 0)) return;
|
|
|
- // Floating "comment on selection" covers both
|
|
|
- // terminal flavours: the raw PTY log (#term-raw) and
|
|
|
- // the legacy entry log (#term-log).
|
|
|
- if (window.initQuoteComments) {
|
|
|
- window.initQuoteComments({
|
|
|
- matches: function (node) {
|
|
|
- if (!node.closest) {
|
|
|
- return false;
|
|
|
- }
|
|
|
- return node.closest('#term-log') != null
|
|
|
- || node.closest('#term-raw') != null;
|
|
|
- },
|
|
|
- action: function () { return state.comment; }
|
|
|
- });
|
|
|
- }
|
|
|
- var desired = requestedSessionId(state);
|
|
|
- if (!connected || desired !== activeSession) {
|
|
|
- connect(desired, sessionRaw(state, desired));
|
|
|
- highlightPicker(desired);
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- // ANSI-to-HTML for the raw PTY stream (B8): SGR sequences
|
|
|
- // become styled spans (bold, italic, 8/16/256-colour fg+bg,
|
|
|
- // reset); every other CSI code is stripped. Chunks may split
|
|
|
- // an escape sequence mid-code, so partial sequences carry
|
|
|
- // across events in `carry`. Falls back to plain text when
|
|
|
- // anything unexpected happens.
|
|
|
- function makeAnsiRenderer() {
|
|
|
- var carry = '';
|
|
|
- var state = null; // {bold, italic, fg, bg}
|
|
|
- var CSI = /\x1b\[([0-9;:]*)([A-Za-z])/;
|
|
|
-
|
|
|
- // Base 16 tuned for the dark terminal background: the
|
|
|
- // stock VGA palette (blue #000080 etc.) is unreadable
|
|
|
- // here, so both halves use lifted, dark-theme-friendly
|
|
|
- // values (VS Code-style).
|
|
|
- var BASE = ['#666666', '#cd3131', '#0dbc79', '#e5e510',
|
|
|
- '#2472c8', '#bc3fbc', '#29b8db', '#e5e5e5',
|
|
|
- '#8c8c8c', '#f14c4c', '#23d18b', '#f5f543',
|
|
|
- '#3b8eea', '#d670d6', '#43d4e8', '#ffffff'];
|
|
|
- var LEVELS = [0, 95, 135, 175, 215, 255];
|
|
|
- function colour256(n) {
|
|
|
- if (n < 16) return BASE[n];
|
|
|
- if (n < 232) {
|
|
|
- var cube = n - 16;
|
|
|
- return 'rgb(' + LEVELS[(cube / 36 | 0) % 6] + ','
|
|
|
- + LEVELS[(cube / 6 | 0) % 6] + ','
|
|
|
- + LEVELS[cube % 6] + ')';
|
|
|
- }
|
|
|
- var grey = 8 + 10 * (n - 232);
|
|
|
- return 'rgb(' + grey + ',' + grey + ',' + grey + ')';
|
|
|
- }
|
|
|
-
|
|
|
- function appendStyled(container, text) {
|
|
|
- if (!state || text.length === 0) {
|
|
|
- container.appendChild(document.createTextNode(text));
|
|
|
- return;
|
|
|
- }
|
|
|
- var span = document.createElement('span');
|
|
|
- if (state.bold) span.style.fontWeight = 'bold';
|
|
|
- if (state.italic) span.style.fontStyle = 'italic';
|
|
|
- if (state.fg != null) span.style.color = colour256(state.fg);
|
|
|
- if (state.bg != null) span.style.backgroundColor = colour256(state.bg);
|
|
|
- span.textContent = text;
|
|
|
- container.appendChild(span);
|
|
|
- }
|
|
|
-
|
|
|
- function applySgr(params) {
|
|
|
- var parts = params.split(';');
|
|
|
- for (var i = 0; i < parts.length; i++) {
|
|
|
- var inner = parts[i].split(':');
|
|
|
- for (var j = 0; j < inner.length; j++) {
|
|
|
- if (inner[j] === '') inner[j] = '0';
|
|
|
- }
|
|
|
- var n = parseInt(inner[0], 10);
|
|
|
- if (isNaN(n)) continue;
|
|
|
- if (n === 0) {
|
|
|
- state = null;
|
|
|
- } else if (n === 1) {
|
|
|
- state = state || {};
|
|
|
- state.bold = true;
|
|
|
- } else if (n === 3) {
|
|
|
- state = state || {};
|
|
|
- state.italic = true;
|
|
|
- } else if (n === 22) {
|
|
|
- if (state) state.bold = false;
|
|
|
- } else if (n === 23) {
|
|
|
- if (state) state.italic = false;
|
|
|
- } else if (n >= 30 && n <= 37) {
|
|
|
- state = state || {};
|
|
|
- state.fg = n - 30;
|
|
|
- } else if (n === 39) {
|
|
|
- if (state) state.fg = null;
|
|
|
- } else if (n >= 40 && n <= 47) {
|
|
|
- state = state || {};
|
|
|
- state.bg = n - 40;
|
|
|
- } else if (n === 49) {
|
|
|
- if (state) state.bg = null;
|
|
|
- } else if (n >= 90 && n <= 97) {
|
|
|
- state = state || {};
|
|
|
- state.fg = n - 90 + 8;
|
|
|
- } else if (n >= 100 && n <= 107) {
|
|
|
- state = state || {};
|
|
|
- state.bg = n - 100 + 8;
|
|
|
- } else if (n === 38 || n === 48) {
|
|
|
- var mode = inner[1] !== undefined ? parseInt(inner[1], 10)
|
|
|
- : (parseInt(parts[i + 1], 10) || 5);
|
|
|
- var value = inner[2] !== undefined ? parseInt(inner[2], 10)
|
|
|
- : parseInt(parts[i + 2], 10);
|
|
|
- if (mode === 5 && !isNaN(value) && value >= 0 && value <= 255) {
|
|
|
- state = state || {};
|
|
|
- if (n === 38) state.fg = value; else state.bg = value;
|
|
|
- i += 2;
|
|
|
- } else if (mode === 2) {
|
|
|
- i += 4; // truecolor: skip r;g;b unstyled
|
|
|
- } else {
|
|
|
- i += 2; // unsupported form: skip its args
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- return {
|
|
|
- render: function (text, container) {
|
|
|
- try {
|
|
|
- carry += text;
|
|
|
- while (carry.length > 0) {
|
|
|
- if (carry.charAt(0) !== '\x1b') {
|
|
|
- var next = carry.indexOf('\x1b');
|
|
|
- var plain = next < 0 ? carry : carry.substring(0, next);
|
|
|
- carry = next < 0 ? '' : carry.substring(next);
|
|
|
- appendStyled(container, plain);
|
|
|
- continue;
|
|
|
- }
|
|
|
- var match = CSI.exec(carry);
|
|
|
- if (match === null) {
|
|
|
- // Partial sequence: wait for the next
|
|
|
- // chunk — unless it clearly ran away.
|
|
|
- if (carry.length > 32) {
|
|
|
- carry = carry.substring(1);
|
|
|
- }
|
|
|
- return;
|
|
|
- }
|
|
|
- if (match[2] === 'm') {
|
|
|
- applySgr(match[1]);
|
|
|
- } // every other CSI code: stripped
|
|
|
- carry = carry.substring(match.index + match[0].length);
|
|
|
- }
|
|
|
- } catch (error) {
|
|
|
- // Graceful fallback: never lose the bytes.
|
|
|
- container.appendChild(document.createTextNode(text));
|
|
|
- carry = '';
|
|
|
- state = null;
|
|
|
- }
|
|
|
- }
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- var activeSource = null;
|
|
|
- var activeSession = null;
|
|
|
- var activeRaw = false;
|
|
|
-
|
|
|
- function connect(sessionId, rawMode) {
|
|
|
- connected = true;
|
|
|
- if (activeSource) {
|
|
|
- try { activeSource.close(); } catch (e) { }
|
|
|
- activeSource = null;
|
|
|
- }
|
|
|
- activeSession = sessionId;
|
|
|
- activeRaw = rawMode;
|
|
|
- // Both log containers live in the DOM (the raw flag
|
|
|
- // is per-session and the client owns the selection):
|
|
|
- // the active mode shows, the other hides.
|
|
|
- var rawLog = document.getElementById('term-raw');
|
|
|
- var typedLog = document.getElementById('term-log');
|
|
|
- var log = rawMode ? rawLog : typedLog;
|
|
|
- if (rawLog) rawLog.style.display = rawMode ? '' : 'none';
|
|
|
- if (typedLog) typedLog.style.display = rawMode ? 'none' : '';
|
|
|
- if (!log) return;
|
|
|
- var placeholder = log.querySelector('.term-placeholder');
|
|
|
- if (!placeholder) {
|
|
|
- log.textContent = '';
|
|
|
- var fresh = document.createElement('span');
|
|
|
- fresh.className = 'muted term-placeholder';
|
|
|
- fresh.textContent = 'Connecting…';
|
|
|
- log.appendChild(fresh);
|
|
|
- }
|
|
|
- var source = new EventSource('/terminal-stream/' + sessionId);
|
|
|
- activeSource = source;
|
|
|
- var ansi = rawMode ? makeAnsiRenderer() : null;
|
|
|
- var lastId = 0;
|
|
|
- var lastSeq = 0;
|
|
|
- source.onmessage = function (event) {
|
|
|
- if (event.data === 'ping') return;
|
|
|
- var entry = null;
|
|
|
- try { entry = JSON.parse(event.data); } catch (e) { return; }
|
|
|
- if (!entry) return;
|
|
|
- if (rawMode) {
|
|
|
- // Raw PTY log: one unstyled <pre> of concatenated
|
|
|
- // chunks, ANSI-styled client-side (SGR only).
|
|
|
- if (entry.type !== 'raw' || entry.seq <= lastSeq) return;
|
|
|
- lastSeq = entry.seq;
|
|
|
- var ph = log.querySelector('.term-placeholder');
|
|
|
- if (ph) ph.remove();
|
|
|
- var near = log.scrollHeight - log.scrollTop - log.clientHeight < 48;
|
|
|
- ansi.render(entry.text, log);
|
|
|
- if (near) log.scrollTop = log.scrollHeight;
|
|
|
- return;
|
|
|
- }
|
|
|
- if (entry.id <= lastId) return;
|
|
|
- lastId = entry.id;
|
|
|
- var placeholder2 = log.querySelector('.term-placeholder');
|
|
|
- if (placeholder2) placeholder2.remove();
|
|
|
- var nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 48;
|
|
|
- appendEntry(log, entry);
|
|
|
- if (nearBottom) log.scrollTop = log.scrollHeight;
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- function appendEntry(log, entry) {
|
|
|
- // Only render bash entries — llm-comment entries duplicate
|
|
|
- // the # lines already present in the bash command_text
|
|
|
- var type = entry.type || 'command';
|
|
|
-
|
|
|
- // Recoverable rejections (bash -n / max_tokens) — a
|
|
|
- // distinct non-fatal style; the agent retries on its own
|
|
|
- if (type === 'rejected') {
|
|
|
- var ph = log.querySelector('.term-placeholder');
|
|
|
- if (ph) ph.remove();
|
|
|
- if (entry.command && entry.command.length > 0) {
|
|
|
- var script = document.createElement('span');
|
|
|
- script.className = 'term-stdin';
|
|
|
- script.textContent = '$ ' + entry.command + '\n';
|
|
|
- log.appendChild(script);
|
|
|
- }
|
|
|
- var reason = document.createElement('span');
|
|
|
- reason.className = 'term-rejected';
|
|
|
- reason.textContent = '[rejected] ' + (entry.output || 'recoverable rejection') + '\n';
|
|
|
- log.appendChild(reason);
|
|
|
- log.appendChild(document.createTextNode('\n'));
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (type !== 'bash' && type !== 'command') return;
|
|
|
-
|
|
|
- // stdin: the LLM's response sent to bash — render per-line
|
|
|
- if (entry.command && entry.command.length > 0) {
|
|
|
- var lines = entry.command.split('\n');
|
|
|
- for (var i = 0; i < lines.length; i++) {
|
|
|
- var line = lines[i];
|
|
|
- if (line.length === 0) continue;
|
|
|
- var span = document.createElement('span');
|
|
|
- if (line.charAt(0) === '#') {
|
|
|
- span.className = 'term-think';
|
|
|
- span.textContent = line + '\n';
|
|
|
- } else {
|
|
|
- span.className = 'term-stdin';
|
|
|
- span.textContent = '$ ' + line + '\n';
|
|
|
- }
|
|
|
- log.appendChild(span);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // piped input (saddle query)
|
|
|
- if (entry.input && entry.input.length > 0) {
|
|
|
- var inp = document.createElement('span');
|
|
|
- inp.className = 'term-stdin';
|
|
|
- inp.textContent = entry.input + '\n';
|
|
|
- log.appendChild(inp);
|
|
|
- }
|
|
|
-
|
|
|
- // stdout/stderr
|
|
|
- if (entry.output && entry.output.length > 0) {
|
|
|
- var out = document.createElement('span');
|
|
|
- out.className = (entry.exit_code !== 0 && entry.exit_code !== -1)
|
|
|
- ? 'term-stderr' : 'term-stdout';
|
|
|
- out.textContent = entry.output + '\n';
|
|
|
- log.appendChild(out);
|
|
|
- }
|
|
|
-
|
|
|
- log.appendChild(document.createTextNode('\n'));
|
|
|
- }
|
|
|
- })();
|
|
|
- }
|
|
|
- </script>
|
|
|
-</head>
|
|
|
-<body>
|
|
|
- <section>
|
|
|
- <h2>Terminal — <span stm-text="terminal.task_title">task</span></h2>
|
|
|
- <p stm-if="terminal != null && !terminal.exists" class="muted">Task not found.</p>
|
|
|
-
|
|
|
- <div stm-if="terminal != null && terminal.exists">
|
|
|
- <p><a stm-attribute.href="'/tasks/' + terminal.task_id" stm-text="'Back to task #' + terminal.task_id">task</a>
|
|
|
- · session <strong stm-text="terminal.session_id">id</strong></p>
|
|
|
-
|
|
|
- <!-- Plan 14 B4: the session picker is an options dropdown
|
|
|
- (newest first); above the terminal, a 200px
|
|
|
- vertical-scroll panel shows what the agent last
|
|
|
- received for the selected session — the opening brief
|
|
|
- for session 1, the stored resume notice for later
|
|
|
- ones (markdown, graceful empty state). -->
|
|
|
- <p stm-if="terminal.sessions.length > 0">Session:
|
|
|
- <select id="session-select" onchange="window.terminalPickSession(this.value)">
|
|
|
- <option stm-for-session-in="terminal.sessions" stm-attribute.value="session.id"
|
|
|
- stm-attribute.data-session-id="session.id"
|
|
|
- stm-text="session.label">session</option>
|
|
|
- </select></p>
|
|
|
-
|
|
|
- <div class="resume-notice-panel" stm-if="terminal.sessions.length > 0">
|
|
|
- <div class="md" stm-if="window.terminalSessionNotice(terminal).length > 0"
|
|
|
- stm-markdown="window.terminalSessionNotice(terminal)">notice</div>
|
|
|
- <p class="muted" stm-if="window.terminalSessionNotice(terminal).length == 0">
|
|
|
- No opening brief or resume notice recorded for this session.</p>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- Both containers stay in the DOM: the per-session raw
|
|
|
- flag drives which one the client shows (see the
|
|
|
- picker script above). -->
|
|
|
- <pre class="terminal-plain terminal-raw" stm-if="terminal.sessions.length > 0" id="term-raw"><span class="muted term-placeholder">Connecting…</span></pre>
|
|
|
- <div class="terminal-plain" stm-if="terminal.sessions.length > 0" id="term-log" style="display:none">
|
|
|
- <span class="muted term-placeholder">Connecting…</span>
|
|
|
- </div>
|
|
|
- <p stm-if="terminal.sessions.length == 0" class="muted">No sessions recorded for this task yet.</p>
|
|
|
-
|
|
|
- <dialog id="quote-comment-dialog">
|
|
|
- <h3>Comment on selected text</h3>
|
|
|
- <blockquote id="quote-text" class="term-comment-quote"></blockquote>
|
|
|
- <form stm-action="terminal.comment">
|
|
|
- <input type="hidden" name="quote">
|
|
|
- <input type="hidden" name="return_to">
|
|
|
- <textarea name="body" rows="4" placeholder="Your comment…" style="width:100%; margin:0.5em 0;"></textarea>
|
|
|
- <button type="submit" class="primary">Send comment</button>
|
|
|
- <button type="button" onclick="document.getElementById('quote-comment-dialog').close()">Cancel</button>
|
|
|
- </form>
|
|
|
- </dialog>
|
|
|
- </div>
|
|
|
- </section>
|
|
|
-</body>
|
|
|
-</html>
|