A terminal panel beside an agent chat

A real shell on the chat's own connection, opened and closed like the
inspector and never beside it. The modes govern the model; what a person
types is theirs, since they hold the credential and could open the same
shell with an ssh client. The model cannot see the panel -- a button
copies the output you choose into the composer.

The session outlives the socket: closing the panel leaves a build
running, and coming back reattaches with the scrollback. Two tabs share
one shell and the smaller window decides the size. It ends on an idle
timeout, on deleting the chat, on disabling, moving or deleting the
connection, and on a restart -- which says why rather than quietly
opening a fresh shell that has lost the working directory.

The nginx template's `Connection ""` is right for SSE and fails every
WebSocket handshake, so `location /` now uses a `map $http_upgrade`;
update.sh grows a drift check for it, because the only symptom on a
stale vhost is a panel that cannot connect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 01:44:07 +02:00
parent 246be1fa8e
commit 5117168454
39 changed files with 2981 additions and 34 deletions
@@ -177,6 +177,53 @@
</div>
</section>
<section class="card">
<h2 class="card__title">The terminal</h2>
<p class="field__hint">
A panel beside an agent chat holding an interactive shell on that chat's
own connection. What somebody types there is <em>theirs</em>: the modes and
the two lists above govern the model, not the person at the keyboard, who
could open the same shell with an ssh client. The model cannot see the
panel; sending it something is a button they press.
</p>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="terminal_enabled" value="true"
{{ 'checked' if values.terminal_enabled }}>
<span>Allow the terminal panel</span>
</label>
<p class="field__hint">
People also need the <strong>Open a terminal</strong> permission.
{{ terminal_count }} shell{{ '' if terminal_count == 1 else 's' }} open right now.
</p>
</div>
<div class="field">
<label class="field__label" for="terminal_idle_timeout">Close a shell after</label>
<input class="input" id="terminal_idle_timeout" name="terminal_idle_timeout"
value="{{ values.terminal_idle_timeout }}" inputmode="numeric">
<p class="field__hint">
Seconds with nobody watching <em>and</em> nothing typed. Closing the
panel does not end the session — a build carries on and is still there
on the way back — so this is what eventually ends one.
</p>
</div>
<div class="field">
<label class="field__label" for="terminal_max_sessions">Most shells at once</label>
<input class="input" id="terminal_max_sessions" name="terminal_max_sessions"
value="{{ values.terminal_max_sessions }}" inputmode="numeric">
</div>
<div class="field">
<label class="field__label" for="terminal_max_per_user">Most shells per person</label>
<input class="input" id="terminal_max_per_user" name="terminal_max_per_user"
value="{{ values.terminal_max_per_user }}" inputmode="numeric">
<p class="field__hint">
One per chat. Each holds an SSH connection open on the far machine.
</p>
</div>
</section>
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save changes</button>
</div>
@@ -0,0 +1,41 @@
{% from "_macros.html" import icon %}
{#
The terminal panel: a fourth child of .shell, to the left of the inspector and
never open beside it. Empty on a page load -- xterm is created the first time
the panel is opened, so the 280KB it costs is paid by somebody who asked for a
shell rather than by everyone who opened a chat.
What is typed here is not run past the chat's mode or its allow and deny
lists. Those govern the model, which reads pages and files it did not write;
the person at the keyboard holds the credential and could open the same shell
with an ssh client.
#}
<aside class="terminal" id="terminal" hidden aria-label="Terminal"
data-terminal
data-url="/api/chats/{{ chat.id }}/terminal/ws"
data-label="{{ agent_profile.name if agent_profile else 'this connection' }}"
data-dir="{{ chat.project_dir }}">
<div class="terminal__header">
<h2 class="terminal__title">
{{ icon("terminal", "icon--sm") }}
<span>{{ agent_profile.name if agent_profile else "Terminal" }}</span>
<span class="terminal__where" data-terminal-where>{{ chat.project_dir }}</span>
</h2>
<button class="btn btn--icon btn--sm" type="button" data-terminal-send
title="Put the selection, or the last of the output, into the message box"
aria-label="Send to chat">
{{ icon("arrow-up", "icon--sm") }}
</button>
<button class="btn btn--icon btn--sm" type="button" data-toggle="#terminal"
aria-label="Close terminal">
{{ icon("x", "icon--sm") }}
</button>
</div>
<div class="terminal__screen" data-terminal-screen></div>
<div class="terminal__status">
<span class="terminal__message" data-terminal-message>Connecting…</span>
<span>Ctrl+Shift+C / V</span>
</div>
</aside>
+30 -2
View File
@@ -5,6 +5,9 @@
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
{% if terminal_enabled %}
<link rel="stylesheet" href="{{ url_for('static', path='vendor/xterm.css') }}">
{% endif %}
{% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %}
@@ -98,9 +101,20 @@
</button>
{% endif %}
{% if terminal_enabled %}
{# To the left of the inspector, and never open beside it: see the
toggle group in app.js. #}
<button class="btn btn--icon" type="button" aria-label="Terminal"
title="Open a shell on {{ agent_profile.name if agent_profile else 'this connection' }}"
aria-expanded="false" data-toggle="#terminal" data-toggle-group="side">
{{ icon("terminal") }}
</button>
{% endif %}
{% if chat and user.is_admin %}
<button class="btn btn--icon" type="button" aria-label="Inspect this chat"
title="Inspect this chat" aria-expanded="false" data-toggle="#inspector">
title="Inspect this chat" aria-expanded="false" data-toggle="#inspector"
data-toggle-group="side">
{{ icon("search") }}
</button>
{% endif %}
@@ -253,9 +267,23 @@
{% endif %}
</main>
{# A third child of .shell, mirroring the sidebar opposite it. #}
{# Third and fourth children of .shell, mirroring the sidebar opposite. The
terminal comes first so it sits to the left of the inspector. #}
{% if terminal_enabled %}
{% include "chat/_terminal.html" %}
{% endif %}
{% if chat and user.is_admin %}
{% include "chat/_inspector.html" %}
{% endif %}
</div>
{% endblock %}
{% block scripts %}
{% if terminal_enabled %}
{# Only where it can be used. xterm is nearly three times everything else
vendored, so a plain chat must never load it. #}
<script src="{{ url_for('static', path='vendor/xterm.js') }}" defer></script>
<script src="{{ url_for('static', path='vendor/xterm-addon-fit.js') }}" defer></script>
<script src="{{ url_for('static', path='js/terminal.js') }}" defer></script>
{% endif %}
{% endblock %}
@@ -89,6 +89,10 @@
<rect x="3.5" y="14" width="17" height="6" rx="1.8"/>
<path d="M7 7h.01M7 17h.01"/>
</symbol>
<symbol id="i-terminal" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<path d="m7.5 9.5 3 2.5-3 2.5M13 15h4"/>
</symbol>
<symbol id="i-sliders" viewBox="0 0 24 24">
<path d="M4 8h10M18 8h2M4 16h4M12 16h8"/>
<circle cx="16" cy="8" r="2"/><circle cx="10" cy="16" r="2"/>