Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github-dark.min.css" crossorigin="anonymous">
<script type="module" crossorigin src="/assets/index-DaQuLdIF.js"></script>
<script type="module" crossorigin src="/assets/index-CMwCQXu3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dw-PwATl.css">
</head>
<body>
Expand Down
16 changes: 14 additions & 2 deletions lib/tui-gateway-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class TuiGatewayBridge {
this._readyReject = null;
this._sidMap = new Map(); // internal TUI sid → canonical DB session_key
this._canonicalSid = null; // active canonical session ID
this._internalSid = null; // active internal TUI sid, sent on approval.respond
}

// ── Lifecycle ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -424,6 +425,7 @@ class TuiGatewayBridge {
}
}

this._internalSid = internalSid;
await this.request('prompt.submit', { session_id: internalSid, text: message });
return { session_id: canonicalSid };
}
Expand Down Expand Up @@ -482,16 +484,26 @@ class TuiGatewayBridge {
}
}
console.log(`[TUI:${this.profile}] chat.send → prompt.submit(${internalSid})`);
this._internalSid = internalSid;
await this.request('prompt.submit', { session_id: internalSid, text: message });
return { session_id: this._canonicalSid };
}

// Translate WS protocol into the keys the gateway's JSON-RPC actually reads:
// - clarify.respond / sudo.respond / secret.respond → _respond() reads
// params["answer"] / "password" / "value"
// - approval.respond → _sess() requires session_id; choice is "approve"|"deny"
async respondClarify(request_id, text, choice) {
return this.request('clarify.respond', { request_id, text, choice });
const answer = (choice ?? text ?? '');
return this.request('clarify.respond', { request_id, answer });
}

async respondApproval(approve, command) {
return this.request('approval.respond', { approve, command });
return this.request('approval.respond', {
session_id: this._internalSid,
choice: approve ? 'approve' : 'deny',
command,
});
}

async respondSudo(request_id, password) {
Expand Down
107 changes: 58 additions & 49 deletions src/js/chat/gateway.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { state, t, wsClient } from '../core/state.js';;
import { addToolCallCard, finalizeToolCard, highlightCodeBlocks, renderChatContent, swapOptimisticMessage, updateStreamContent, updateToolProgress } from './cli.js';
import { handleArtifact, playChatComplete, refreshChatSidebar, reloadCurrentSessionMessages, sendChatMessage, updateChatHeader, updateQueueBadge, updateToolCountUI } from './core.js';
import { closeModal, showModal } from '../components/modal.js';
import { showModal } from '../components/modal.js';
import { escapeHtml } from '../core/utils.js';

async function sendViaGatewayAPI(text, profile, sessionId, contentDiv, messagesDiv, startTime) {
Expand Down Expand Up @@ -412,76 +412,85 @@ function handleSubagentEvent(type, payload) {
}
}

function showClarifyModal(question, choices, requestId) {
// The four agent-prompted modals use showModal's {message, inputs, buttons}
// shape from src/js/components/modal.js. A null result (click-outside or
// Cancel) sends a negative response so the agent unblocks and the chat
// lock clears naturally via chat.done/chat.error.
async function showClarifyModal(question, choices, requestId) {
if (!choices || choices.length === 0) {
// Open-ended: text input
showModal({
const result = await showModal({
title: 'Clarification Needed',
body: `<p>${escapeHtml(question)}</p><input type="text" id="clarify-input" class="modal-input" placeholder="Your answer..." style="width:100%;margin-top:12px;padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--bg-card);color:var(--fg);">`,
confirmText: 'Submit',
onConfirm: () => {
const input = document.getElementById('clarify-input');
if (input) wsClient.clarifyRespond(requestId, input.value);
}
message: `<p>${escapeHtml(question)}</p>`,
inputs: [{ type: 'text', placeholder: 'Your answer...' }],
buttons: [
{ text: 'Cancel', value: null },
{ text: 'Submit', primary: true, value: 'ok' },
],
});
if (!result || result.action === null) {
wsClient.clarifyRespond(requestId, '');
} else {
wsClient.clarifyRespond(requestId, result.inputs?.[0] || '');
}
} else {
// Multiple choice
const buttons = choices.map(c =>
`<button class="modal-choice-btn" data-choice="${escapeHtml(c)}" style="display:block;width:100%;margin:4px 0;padding:10px;text-align:left;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;color:var(--fg);cursor:pointer;">${escapeHtml(c)}</button>`
).join('');
showModal({
const result = await showModal({
title: 'Clarification Needed',
body: `<p>${escapeHtml(question)}</p><div style="margin-top:12px;">${buttons}</div>`,
showCancel: false,
confirmText: null,
onRender: () => {
document.querySelectorAll('.modal-choice-btn').forEach(btn => {
btn.onclick = () => {
wsClient.clarifyRespond(requestId, null, btn.dataset.choice);
closeModal();
};
});
}
message: `<p>${escapeHtml(question)}</p>`,
buttons: choices.map(c => ({ text: c, value: c })),
});
if (!result) {
wsClient.clarifyRespond(requestId, null, null);
} else {
wsClient.clarifyRespond(requestId, null, result.action);
}
}
}

function showApprovalModal(command, description) {
showModal({
async function showApprovalModal(command, description) {
const result = await showModal({
title: 'Approval Required',
body: `
message: `
<p>${escapeHtml(description || 'The agent wants to run a command:')}</p>
<pre style="margin-top:12px;padding:12px;background:var(--bg-dark);border-radius:8px;overflow-x:auto;"><code>${escapeHtml(command)}</code></pre>
`,
confirmText: '✅ Approve',
cancelText: '❌ Deny',
onConfirm: () => wsClient.approvalRespond(true, command),
onCancel: () => wsClient.approvalRespond(false, command),
buttons: [
{ text: '❌ Deny', value: false },
{ text: '✅ Approve', primary: true, value: true },
],
});
if (result?.action === true) {
wsClient.approvalRespond(true, command);
} else {
wsClient.approvalRespond(false, command);
}
}

function showSudoModal(requestId) {
showModal({
async function showSudoModal(requestId) {
const result = await showModal({
title: 'Sudo Password Required',
body: `<p data-i18n="auto.theAgentNeedsSudoPrivilegesEnterYourPassword">The agent needs sudo privileges. Enter your password:</p><input type="password" id="sudo-input" class="modal-input" placeholder="Password" style="width:100%;margin-top:12px;padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--bg-card);color:var(--fg);">`,
confirmText: 'Submit',
onConfirm: () => {
const input = document.getElementById('sudo-input');
if (input) wsClient.sudoRespond(requestId, input.value);
}
message: '<p data-i18n="auto.theAgentNeedsSudoPrivilegesEnterYourPassword">The agent needs sudo privileges. Enter your password:</p>',
inputs: [{ type: 'password', placeholder: 'Password' }],
buttons: [
{ text: 'Cancel', value: null },
{ text: 'Submit', primary: true, value: 'ok' },
],
});
const password = (result && result.action !== null) ? (result.inputs?.[0] || '') : '';
wsClient.sudoRespond(requestId, password);
}

function showSecretModal(envVar, prompt, requestId) {
showModal({
async function showSecretModal(envVar, prompt, requestId) {
const result = await showModal({
title: `Secret Required: ${escapeHtml(envVar)}`,
body: `<p>${escapeHtml(prompt || `Enter value for ${envVar}:`)}</p><input type="password" id="secret-input" class="modal-input" placeholder="Value" style="width:100%;margin-top:12px;padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--bg-card);color:var(--fg);">`,
confirmText: 'Submit',
onConfirm: () => {
const input = document.getElementById('secret-input');
if (input) wsClient.secretRespond(requestId, input.value);
}
message: `<p>${escapeHtml(prompt || `Enter value for ${envVar}:`)}</p>`,
inputs: [{ type: 'password', placeholder: 'Value' }],
buttons: [
{ text: 'Cancel', value: null },
{ text: 'Submit', primary: true, value: 'ok' },
],
});
const value = (result && result.action !== null) ? (result.inputs?.[0] || '') : '';
wsClient.secretRespond(requestId, value);
}

function ensureThinkingPanel(messagesDiv) {
Expand Down
60 changes: 60 additions & 0 deletions test/chat-approval-modal.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');

const ROOT = path.resolve(__dirname, '..');

function read(relPath) {
return fs.readFileSync(path.join(ROOT, relPath), 'utf8');
}

function extractFunction(source, name) {
const re = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*\\{`, 'm');
const match = re.exec(source);
assert.ok(match, `missing function ${name}`);
let i = match.index + match[0].length - 1;
let depth = 0;
for (; i < source.length; i += 1) {
const ch = source[i];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) return source.slice(match.index, i + 1);
}
}
throw new Error(`unterminated function ${name}`);
}

test('Approval Required modal is actionable with approve and deny controls', () => {
const gatewaySource = read('src/js/chat/gateway.js');
const modalSource = read('src/js/components/modal.js');
const approval = extractFunction(gatewaySource, 'showApprovalModal');
const showModal = extractFunction(modalSource, 'showModal');

const approvalUsesModernButtons =
/buttons\s*:\s*\[/.test(approval)
&& /approvalRespond\(\s*true\s*,\s*command\s*\)/.test(approval)
&& /approvalRespond\(\s*false\s*,\s*command\s*\)/.test(approval);

const showModalSupportsLegacyApprovalApi =
/body/.test(showModal)
&& /confirmText/.test(showModal)
&& /cancelText/.test(showModal)
&& /onConfirm/.test(showModal)
&& /onCancel/.test(showModal);

assert.ok(
approvalUsesModernButtons || showModalSupportsLegacyApprovalApi,
'showApprovalModal currently passes body/confirmText/cancelText/onConfirm/onCancel; showModal must support that API or approval must use buttons[] with approve/deny actions',
);
});

test('Approval Required modal includes command and description in its body', () => {
const gatewaySource = read('src/js/chat/gateway.js');
const approval = extractFunction(gatewaySource, 'showApprovalModal');

assert.match(approval, /description/);
assert.match(approval, /command/);
assert.match(approval, /escapeHtml\(command\)/);
});
Loading