agent/terminal: inline answer form under ask → operator rows (#666)

When an agent calls mcp__hyperhive__ask with to==operator (the
default), the rich tool-use renderer now mounts an empty
ask-answer-inline-slot inside the expanded ask row and enqueues a
loose-ends refresh. A new reconcileAskBinds() walks waiting slots
on every loose-ends update, matches each against pending
operator-bound questions by question text, and injects the same
inline answer form the side panel uses (buildAnswerForm → POST to
the host dashboard answer-question endpoint). When a question
resolves, the form gets replaced by a struck-through [answered ✓]
tag so the scrollback reflects the closed state.

Lets the operator respond to agent questions inline in the live
terminal without context-switching to the loose-ends side panel or
the dashboard Y3R C4LL tab.

Refreshes loose-ends both on tool_use render (best-effort, may
miss the question before the broker persists it) and on the
matching tool_result (the right moment — MCP has just returned the
assigned id). Slots are pruned on /clear and defensively filtered
for isConnected on each reconcile.

closes #666
This commit is contained in:
iris 2026-05-30 21:40:04 +02:00 committed by Mara
commit e056e82d6d
3 changed files with 194 additions and 1 deletions

View file

@ -43,6 +43,7 @@ parent's negative pull.
| `.tool-use` (flat) | `→ Name args…` | cyan | tool_use w/o rich renderer | stream-json |
| `.tool-use` `<details>` | `Write/Edit <path> · +N` (no `→`) | cyan, body is +/- diff | `renderRichToolUse` Write/Edit | stream-json |
| `.tool-use` `<details open>` | `send → to · NL`, `ask → to`, `answer #id` | cyan, body is markdown | rich renderer for send / ask / answer | stream-json |
| `.tool-use .ask-answer-inline-slot` | (sub-block under `ask → operator`) | inherits row | inline answer form bound by `reconcileAskBinds` to the loose-end | #666 |
| `.tool-result` (flat) | `← <txt>` | muted | short `tool_result` (≤120c, non-recv) | stream-json |
| `.tool-result-block` `<details>` | `Nl · headline` | muted, body is text | long generic `tool_result` | stream-json |
| `.tool-result-block` `<details open>` | `recv ← <txt>` | muted, body is markdown | `tool_result` correlated to a prior `recv` tool_use via id | stream-json |
@ -104,6 +105,26 @@ isn't in the built-in `fmtToolUse` switch:
This keeps `mcp__matrix__send_message` and similar from
dumping raw JSON.
## Inline ask-operator answer (#666)
When an agent calls `mcp__hyperhive__ask` with `to == "operator"`
(default), the rich tool-use renderer mounts an empty
`<div class="ask-answer-inline-slot">` inside the row's expanded
body, then enqueues a loose-ends refresh. `reconcileAskBinds()`
runs on every loose-ends refresh, matches each waiting slot
against pending operator-bound questions by question text, and
injects an inline `.answer-form` (textarea + send button bound to
`/answer-question/<id>` on the host dashboard) into the matching
slot. When a question subsequently resolves (operator answered via
the inline form, the side-panel form, or the dashboard Y3R C4LL
tab), the same reconciler replaces the form with a struck-through
`[answered ✓]` tag so the scrollback reflects the closed state.
Lets the operator answer mid-flow without context-switching to the
loose-ends side panel or the dashboard tab. Side panel + dashboard
forms remain — they're the same `buildAnswerForm` factory, three
mount points for the same POST.
## Dashboard side (not covered here)
The main dashboard's message-flow pane is a different

View file

@ -552,6 +552,67 @@ pre.diff {
.agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; }
.agent-inbox .answer-status { color: var(--muted); align-self: center; }
/* #666: inline answer slot mounted under each `ask operator` row
in the live terminal stream. Mirrors the side-panel `.answer-form`
look-and-feel same textarea, same send button so the operator
doesn't have to context-switch between "answering in the panel" and
"answering inline". Empty slot collapses to nothing (no margin) so
pre-bind rows stay tidy; populated slot gets a thin top divider to
separate the question body from the form. Resolved tag (the
struck-through `[answered ]`) replaces the form once the
question's been answered. */
.live .ask-answer-inline-slot:empty { display: none; }
.live .ask-answer-inline-slot {
margin-top: 0.5em;
padding-top: 0.5em;
border-top: 1px dashed var(--border);
}
.live .ask-answer-inline-slot .answer-form {
display: flex;
gap: 0.4em;
align-items: flex-start;
}
.live .ask-answer-inline-slot .answer-form textarea {
flex: 1;
font-family: inherit;
font-size: inherit;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.3em;
resize: vertical;
}
.live .ask-answer-inline-slot .answer-form button {
font-family: inherit;
font-size: inherit;
background: var(--bg-elev);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.3em 0.7em;
cursor: pointer;
white-space: nowrap;
}
.live .ask-answer-inline-slot .answer-form button:hover:not(:disabled) {
border-color: var(--purple);
color: var(--purple);
}
.live .ask-answer-inline-slot .answer-form button:disabled {
opacity: 0.5;
cursor: default;
}
.live .ask-answer-inline-slot .answer-status {
color: var(--muted);
align-self: center;
}
.live .ask-answer-inline-slot .ask-answered-tag {
color: var(--muted);
font-style: italic;
text-decoration: line-through;
text-decoration-color: var(--muted);
}
/* #559: "mark all read" header row sits above the recent-messages
list in the inbox side-panel flyout. Same look as the answer-form
button (mauve hover, bg-elev background) so they read as part of

View file

@ -488,6 +488,10 @@ window.marked = marked;
return true;
case '/clear':
termAPI.clear();
// #666: detached `ask → operator` rows no longer have a live
// mount-point in the DOM — drop their slots so subsequent
// loose-ends reconciliation doesn't walk dead references.
pendingAskBinds.length = 0;
termAPI.row('note', '· terminal cleared (local view only — server history kept)');
return true;
case '/cancel':
@ -681,6 +685,19 @@ window.marked = marked;
let lastLooseEnds = [];
let lastInbox = [];
/** #666: ask-operator inline-answer slots awaiting a question id.
* Each entry is the empty `<div class="ask-answer-inline-slot">`
* element appended to an `ask → operator` row by
* `renderRichToolUse`. `reconcileAskBinds()` walks the list on
* every loose-ends refresh, looking up the matching pending
* question by `kind=question` + `target == 'operator'` (or
* missing broker omits target for operator-bound asks) +
* question text match against the slot's stashed `_askQuestion`.
* Slots stay in the array even after binding so the reconciler
* can flip them back to "answered" if the question later
* resolves. */
const pendingAskBinds = [];
function buildLooseEndsList(threads) {
// Returns the <div> the side panel renders. The structural shape
// mirrors the legacy <details>-collapsible block — same CSS rules
@ -750,11 +767,69 @@ window.marked = marked;
if (pill) pill.hidden = threads.length === 0;
Panel.refresh('loose-ends', 'loose ends · ' + threads.length,
buildLooseEndsList(threads));
// #666: wire inline answer forms into any `ask → operator` rows
// that have been waiting for their broker-assigned question id.
reconcileAskBinds();
}
/** #666: for each `ask operator` row mounted by renderRichToolUse,
* look up the matching pending question in `lastLooseEnds` (by
* target=operator + question text) and inject an inline answer
* form. If a previously-bound slot's question has been resolved
* (no longer in the pending list), replace the form with a small
* `[answered ✓]` tag so the scrollback reflects the closed state.
* Slots whose question never appears (e.g. agent cancelled the
* ask, or the question is older than the loose-ends fetch
* retention) stay empty the operator can still answer via the
* side panel, no regression. */
function reconcileAskBinds() {
if (!pendingAskBinds.length) return;
// Build an index of pending operator-bound questions by text.
// Note: an agent could in theory ask the same question text
// twice; the loose-ends list is small and the broker assigns
// distinct ids — we match the first unbound slot to the first
// unmatched pending question to keep the pairing stable.
const pending = lastLooseEnds.filter(
(t) => t.kind === 'question' && (!t.target || t.target === 'operator'),
);
// Defensive prune: drop slots whose DOM node has been removed
// (e.g. via a future "clear single row" affordance) so we don't
// process detached references on every refresh.
for (let i = pendingAskBinds.length - 1; i >= 0; i--) {
if (!pendingAskBinds[i].isConnected) pendingAskBinds.splice(i, 1);
}
// Track which pending question ids have already been claimed by
// an existing bound slot so we don't double-bind on refresh.
const claimed = new Set();
for (const slot of pendingAskBinds) {
if (slot._boundId) claimed.add(slot._boundId);
}
for (const slot of pendingAskBinds) {
if (slot._boundId) {
// Already bound — check if the question got resolved.
const stillPending = pending.some((q) => q.id === slot._boundId);
if (!stillPending && !slot._resolvedShown) {
slot.innerHTML = '';
slot.appendChild(el('span', { class: 'ask-answered-tag' }, '[answered ✓]'));
slot._resolvedShown = true;
}
continue;
}
// Find the first matching pending question we haven't claimed yet.
const q = pending.find((p) => p.question === slot._askQuestion && !claimed.has(p.id));
if (!q) continue;
claimed.add(q.id);
slot._boundId = q.id;
slot.innerHTML = '';
slot.appendChild(buildAnswerForm(q.id));
}
}
// Inline "answer as operator" form for a question loose-end. POSTs to
// the host dashboard (core backend), never this agent's socket — the
// core is the only place that can stamp `operator` as the answerer.
// Used by both the loose-ends side panel (`buildLooseEndsList`) and
// the in-stream ask-row binder (`reconcileAskBinds`, #666).
function buildAnswerForm(id) {
const wrap = el('div', { class: 'answer-form' });
const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' });
@ -1259,9 +1334,36 @@ window.marked = marked;
const to = input.to || 'operator';
const q = String(input.question || '');
const lines = q.split('\n').length;
return detailsOpenMd(api, 'tool-use',
const d = detailsOpenMd(api, 'tool-use',
'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
q);
// #666: when the ask targets the operator, mount an inline
// answer slot in the live terminal so the operator doesn't
// need to open the loose-ends side panel (or jump to Y3R C4LL
// on the dashboard) to respond. The slot starts empty and
// gets populated by `reconcileAskBinds()` once the
// loose-ends fetch identifies a matching pending question
// (by asker == this agent + question text). Resolved
// questions render as a struck-through [answered ✓] tag
// instead of a form.
if (to === 'operator') {
const slot = el('div', { class: 'ask-answer-inline-slot' });
// Stash the question text on the slot so the reconciler
// can match against `lastLooseEnds` entries without
// walking the row's text content. Options/multi can be
// surfaced later when the harness emits them on the
// tool_result; phase A is pure text-match.
slot._askQuestion = q;
d.appendChild(slot);
pendingAskBinds.push(slot);
// Mid-turn refresh — the standard `turn_end` refresh
// won't fire until the agent's turn finishes; we want
// the form to show up as soon as the ask lands. Cheap
// best-effort (silent on failure).
if (!api.fromHistory) refreshLooseEnds();
else reconcileAskBinds();
}
return d;
}
if (name === 'mcp__hyperhive__answer') {
const id = input.id != null ? String(input.id) : '?';
@ -1285,6 +1387,15 @@ window.marked = marked;
: (c.content || '');
const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null;
const isMessageBearing = sourceName === 'mcp__hyperhive__recv';
// #666: when an ask's tool_result lands the broker has just
// persisted the question with its assigned id. Refresh loose
// ends so `reconcileAskBinds` finds the new entry and mounts
// the inline answer form under the rendered ask row. Skipped
// during history replay (the question's likely long-resolved;
// turn_end refresh on cold-load already covers reconciliation).
if (sourceName === 'mcp__hyperhive__ask' && !api.fromHistory) {
refreshLooseEnds();
}
const trimmed = txt.replace(/\s+/g, ' ').trim();
const summaryBody = (() => {
if (!trimmed) return '(empty)';