Skip to content
Closed
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
36 changes: 34 additions & 2 deletions crates/openfang-api/static/index_body.html
Original file line number Diff line number Diff line change
Expand Up @@ -670,10 +670,10 @@ <h3 style="margin:0 0 8px;font-size:16px;font-weight:600">Select an agent to sta
</template>
</div>
<!-- Audio player for TTS results -->
<div x-show="tool._audioFile" style="padding:8px 12px">
<div x-show="tool._audioFile && tool._audioFile.length" style="padding:8px 12px">
<div class="audio-player">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>
<span class="text-xs" x-text="'Audio: ' + tool._audioFile.split('/').pop()"></span>
<span class="text-xs" x-text="'Audio: ' + (tool._audioFile ? tool._audioFile.split('/').pop() : '')"></span>
<span class="text-xs text-dim" x-show="tool._audioDuration" x-text="'~' + Math.round((tool._audioDuration || 0) / 1000) + 's'"></span>
</div>
</div>
Expand Down Expand Up @@ -775,6 +775,38 @@ <h3 style="margin:0 0 8px;font-size:16px;font-weight:600">Select an agent to sta
</button>
</template>
</div>
<!-- Approval notifications -->
<div x-show="$store.app.pendingApprovalCount > 0" class="approval-notifications" style="border-top:1px solid var(--border);padding:8px 12px;background:var(--bg-alt);">
<div style="display:flex;align-items:center;gap:8px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--warn)" stroke-width="2" style="flex-shrink:0;"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>
<span class="text-sm font-medium" style="color:var(--warn);">Pending Approvals</span>
<span class="badge badge-warn" x-text="$store.app.pendingApprovalCount"></span>
</div>
<div class="approval-list" style="margin-top:8px;max-height:120px;overflow-y:auto;">
<template x-for="approval in $store.app.pendingApprovals" :key="approval.id">
<div class="approval-item" style="padding:6px 8px;border-radius:4px;margin-bottom:4px;background:var(--bg);display:flex;align-items:center;gap:8px;" :class="{ selected: $store.app.selectedApprovalId === approval.id }" @click="$store.app.selectedApprovalId = approval.id">
<div style="flex-shrink:0;width:16px;height:16px;border-radius:2px;" :style="'background:' + (approval.risk_level === 'Critical' ? 'var(--danger)' : approval.risk_level === 'High' ? 'var(--warn)' : approval.risk_level === 'Medium' ? 'var(--yellow)' : 'var(--success)')"></div>
<div style="flex:1;min-width:0;">
<div class="text-sm" style="color:var(--text-primary);" x-text="approval.description"></div>
<div class="text-xs" style="color:var(--text-dim);" x-text="'Risk: ' + approval.risk_level + ' | Agent: ' + approval.agent_id"></div>
</div>
<div style="display:flex;gap:4px;">
<button class="btn btn-xs btn-success" @click.stop="approveTool(approval.id)" title="Approve">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/></svg>
</button>
<button class="btn btn-xs btn-danger" @click.stop="rejectTool(approval.id)" title="Reject">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18"/><path d="M6 6l12 12"/></svg>
</button>
</div>
</div>
</template>
</div>
<div class="text-xs" style="color:var(--text-dim);margin-top:6px;padding-left:24px;">
<kbd style="background:var(--bg);border:1px solid var(--border);padding:2px 6px;border-radius:3px;font-size:10px;">↑↓</kbd> Navigate
<kbd style="background:var(--bg);border:1px solid var(--border);padding:2px 6px;border-radius:3px;font-size:10px;">A</kbd> Approve
<kbd style="background:var(--bg);border:1px solid var(--border);padding:2px 6px;border-radius:3px;font-size:10px;">R</kbd> Reject
</div>
</div>
<!-- Footer: model switcher + tokens + queue + tips -->
<div class="input-footer">
<div class="flex items-center gap-2">
Expand Down
7 changes: 7 additions & 0 deletions crates/openfang-api/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ document.addEventListener('alpine:init', function() {
version: '0.1.0',
agentCount: 0,
pendingApprovalCount: 0,
pendingApprovals: [],
selectedApprovalId: null,
lastPendingApprovalSignature: '',
pendingAgent: null,
focusMode: localStorage.getItem('openfang-focus') === 'true',
Expand Down Expand Up @@ -168,8 +170,13 @@ document.addEventListener('alpine:init', function() {
if (pending.length > 0 && signature !== this.lastPendingApprovalSignature && typeof OpenFangToast !== 'undefined') {
OpenFangToast.warn('An agent is waiting for approval. Open Approvals to review.');
}
this.pendingApprovals = pending;
this.pendingApprovalCount = pending.length;
this.lastPendingApprovalSignature = signature;
// Initialize selected approval if not set and we have pending approvals
if (pending.length > 0 && !this.selectedApprovalId) {
this.selectedApprovalId = pending[0].id;
}
} catch(e) { /* silent */ }
},

Expand Down
80 changes: 79 additions & 1 deletion crates/openfang-api/static/js/pages/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,41 @@ function chatPage() {
e.preventDefault();
self.toggleSearch();
}
// Approval navigation (when approvals are pending)
if (self.currentAgent && $store.app.pendingApprovalCount > 0) {
// Arrow keys for approval selection
if (e.key === 'ArrowUp') {
e.preventDefault();
var currentIdx = $store.app.pendingApprovals.findIndex(function(a) { return a.id === $store.app.selectedApprovalId; });
if (currentIdx > 0) {
$store.app.selectedApprovalId = $store.app.pendingApprovals[currentIdx - 1].id;
} else if ($store.app.pendingApprovals.length > 0) {
$store.app.selectedApprovalId = $store.app.pendingApprovals[$store.app.pendingApprovals.length - 1].id;
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
var currentIdx = $store.app.pendingApprovals.findIndex(function(a) { return a.id === $store.app.selectedApprovalId; });
if (currentIdx >= 0 && currentIdx < $store.app.pendingApprovals.length - 1) {
$store.app.selectedApprovalId = $store.app.pendingApprovals[currentIdx + 1].id;
} else if ($store.app.pendingApprovals.length > 0) {
$store.app.selectedApprovalId = $store.app.pendingApprovals[0].id;
}
}
// A key for approve
else if (e.key === 'a' || e.key === 'A') {
e.preventDefault();
if ($store.app.selectedApprovalId) {
self.approveTool($store.app.selectedApprovalId);
}
}
// R key for reject
else if (e.key === 'r' || e.key === 'R') {
e.preventDefault();
if ($store.app.selectedApprovalId) {
self.rejectTool($store.app.selectedApprovalId);
}
}
}
});

// Load session + session list when agent changes
Expand Down Expand Up @@ -1257,6 +1292,49 @@ function chatPage() {
},

renderMarkdown: renderMarkdown,
escapeHtml: escapeHtml
escapeHtml: escapeHtml,

// Approval functions
approveTool: async function(approvalId) {
try {
var response = await OpenFangAPI.post('/api/approvals/' + approvalId + '/approve', {});
if (response.status === 'ok') {
// Remove from pending list
this.removeApproval(approvalId);
this.showToast('Approved: ' + (this.getApproval(approvalId)?.description || 'action'), 'success');
} else {
this.showToast('Approval failed: ' + (response.error || 'Unknown error'), 'danger');
}
} catch (e) {
this.showToast('Approval error: ' + (e.message || 'Network error'), 'danger');
}
},

rejectTool: async function(approvalId) {
try {
var response = await OpenFangAPI.post('/api/approvals/' + approvalId + '/reject', {});
if (response.status === 'ok') {
// Remove from pending list
this.removeApproval(approvalId);
this.showToast('Rejected: ' + (this.getApproval(approvalId)?.description || 'action'), 'warn');
} else {
this.showToast('Rejection failed: ' + (response.error || 'Unknown error'), 'danger');
}
} catch (e) {
this.showToast('Rejection error: ' + (e.message || 'Network error'), 'danger');
}
},

getApproval: function(approvalId) {
return $store.app.pendingApprovals.find(function(a) { return a.id === approvalId; });
},

removeApproval: function(approvalId) {
$store.app.pendingApprovals = $store.app.pendingApprovals.filter(function(a) { return a.id !== approvalId; });
$store.app.pendingApprovalCount = $store.app.pendingApprovals.length;
if ($store.app.selectedApprovalId === approvalId) {
$store.app.selectedApprovalId = null;
}
}
};
}
31 changes: 31 additions & 0 deletions crates/openfang-cli/src/tui/chat_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,37 @@ impl StandaloneChat {
ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
ChatAction::OpenModelPicker => self.open_model_picker(),
ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
ChatAction::ApproveTool(approval_id) => {
self.handle_approval_action(&approval_id, true);
}
ChatAction::RejectTool(approval_id) => {
self.handle_approval_action(&approval_id, false);
}
ChatAction::SelectApproval(idx) => {
self.chat.selected_approval_idx = Some(idx);
}
}
}

fn handle_approval_action(&mut self, approval_id: &str, approve: bool) {
// Find and remove the approval from the UI
let approval = self.chat.pending_approvals.iter()
.find(|a| a.id == approval_id)
.cloned();

if let Some(approval) = approval {
self.chat.remove_approval(approval_id);

// Here you would typically call the kernel API to approve/reject
// For now, we'll just show a status message
let action = if approve { "approved" } else { "rejected" };
self.chat.status_msg = Some(format!(
"{} {} for {}",
approval.tool_name, action, approval.agent_id
));

// In a real implementation, you would call:
// self.approve_tool_execution(approval_id, approve);
}
}

Expand Down
31 changes: 31 additions & 0 deletions crates/openfang-cli/src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,15 @@ impl App {
chat::ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
chat::ChatAction::OpenModelPicker => self.open_model_picker(),
chat::ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
chat::ChatAction::ApproveTool(approval_id) => {
self.handle_approval_action(&approval_id, true);
}
chat::ChatAction::RejectTool(approval_id) => {
self.handle_approval_action(&approval_id, false);
}
chat::ChatAction::SelectApproval(idx) => {
self.chat.selected_approval_idx = Some(idx);
}
}
}

Expand Down Expand Up @@ -2361,6 +2370,27 @@ impl App {
let bar = Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG_CARD));
frame.render_widget(bar, area);
}

fn handle_approval_action(&mut self, approval_id: &str, approve: bool) {
// Find and remove the approval from the UI
let approval = self.chat.pending_approvals.iter()
.find(|a| a.id == approval_id)
.cloned();

if let Some(approval) = approval {
self.chat.remove_approval(approval_id);

// Show feedback in the chat
let action = if approve { "approved" } else { "rejected" };
self.chat.push_message(
chat::Role::System,
format!("✓ {} {} for {}", approval.tool_name, action, approval.agent_id)
);

// In a real implementation, you would call the kernel API here
// to actually approve/reject the tool execution
}
}
}

/// Draw a one-line toast at the bottom of the screen.
Expand Down Expand Up @@ -2405,6 +2435,7 @@ pub fn run(config: Option<PathBuf>) {

// ── Main loop ────────────────────────────────────────────────────────────
// Draw first, then block on events. This ensures the first frame appears

// immediately, before any event processing.
while !app.should_quit {
terminal
Expand Down
Loading
Loading