ui: welcome screen, queue input, interverntions

frdel committed Jan 31, 2026 at 19:37 UTC 4aaf7af34a00d65db777bbb0a84ee7d19826c008
6 files changed +173 -10
agent.py
+7
@@ -393,6 +393,7 @@ class Agent:
393 await self.call_extensions(
394 "message_loop_start", loop_data=self.loop_data
395 )
396 + await self.handle_intervention()
397
398 try:
399 # prepare LLM chain (model, system, history)
@@ -402,6 +403,8 @@ class Agent:
403 await self.call_extensions(
404 "before_main_llm_call", loop_data=self.loop_data
405 )
406 + await self.handle_intervention()
407 +
408
409 async def reasoning_callback(chunk: str, full: str):
410 await self.handle_intervention()
@@ -444,11 +447,14 @@ class Agent:
447 response_callback=stream_callback,
448 reasoning_callback=reasoning_callback,
449 )
450 + await self.handle_intervention(agent_response)
451
452 # Notify extensions to finalize their stream filters
453 await self.call_extensions(
454 "reasoning_stream_end", loop_data=self.loop_data
455 )
456 + await self.handle_intervention(agent_response)
457 +
458 await self.call_extensions(
459 "response_stream_end", loop_data=self.loop_data
460 )
@@ -581,6 +587,7 @@ class Agent:
587 "Critical error occurred, retrying..."
588 )
589 await asyncio.sleep(delay)
590 + await self.handle_intervention()
591 agent_facing_error = self.read_prompt(
592 "fw.msg_critical_error.md", error_message=error_message
593 )
python/extensions/banners/_30_system_resources.py new
+110
@@ -0,0 +1,110 @@
1 +from python.helpers.extension import Extension
2 +import os
3 +import psutil
4 +
5 +
6 +class SystemResourcesCheck(Extension):
7 + async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
8 + try:
9 + cpu_percent = psutil.cpu_percent(interval=0.1)
10 + except Exception:
11 + cpu_percent = None
12 +
13 + load_avg = self._get_load_average()
14 +
15 + try:
16 + vm = psutil.virtual_memory()
17 + ram_percent = vm.percent
18 + except Exception:
19 + ram_percent = None
20 +
21 + disk_percent, disk_path = self._get_disk_usage_percent()
22 +
23 + try:
24 + net = psutil.net_io_counters()
25 + net_sent = self._format_bytes(net.bytes_sent)
26 + net_recv = self._format_bytes(net.bytes_recv)
27 + except Exception:
28 + net_sent = "N/A"
29 + net_recv = "N/A"
30 +
31 + load_value = "N/A"
32 + if load_avg:
33 + la1, la5, la15 = load_avg
34 + load_value = f"{la1:.2f} / {la5:.2f} / {la15:.2f}"
35 +
36 + disk_value = "N/A"
37 + if disk_percent is not None:
38 + disk_value = f"{disk_percent:.0f}% ({disk_path})"
39 +
40 + cpu_value = "N/A" if cpu_percent is None else f"{cpu_percent:.0f}%"
41 + ram_value = "N/A" if ram_percent is None else f"{ram_percent:.0f}%"
42 +
43 + cpu_bar = self._bar_html(cpu_percent)
44 + ram_bar = self._bar_html(ram_percent)
45 + disk_bar = self._bar_html(disk_percent)
46 +
47 + banners.append({
48 + "id": "system-resources",
49 + "type": "info",
50 + "priority": 10,
51 + "title": "System Resources",
52 + "html": (
53 + "<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:12px 14px;\">"
54 + "<div style=\"grid-column:1 / -1;display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;align-items:center;\">"
55 + f"<div style=\"font-size:12px;letter-spacing:.08em;text-transform:uppercase;opacity:.7\">CPU</div>"
56 + f"<div style=\"display:flex;gap:10px;align-items:center;\"><div style=\"font-weight:700\">{cpu_value}</div>{cpu_bar}</div>"
57 + f"<div style=\"font-size:12px;letter-spacing:.08em;text-transform:uppercase;opacity:.7\">RAM</div>"
58 + f"<div style=\"display:flex;gap:10px;align-items:center;\"><div style=\"font-weight:700\">{ram_value}</div>{ram_bar}</div>"
59 + f"<div style=\"font-size:12px;letter-spacing:.08em;text-transform:uppercase;opacity:.7\">Disk</div>"
60 + f"<div style=\"display:flex;gap:10px;align-items:center;\"><div style=\"font-weight:700\">{disk_value}</div>{disk_bar}</div>"
61 + "</div>"
62 + f"<div style=\"padding-top:2px;\"><div style=\"font-size:12px;letter-spacing:.08em;text-transform:uppercase;opacity:.7;margin-bottom:4px;\">Load (1/5/15)</div><div style=\"font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;opacity:.85\">{load_value}</div></div>"
63 + f"<div style=\"padding-top:2px;\"><div style=\"font-size:12px;letter-spacing:.08em;text-transform:uppercase;opacity:.7;margin-bottom:4px;\">Net (since boot)</div><div style=\"font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;opacity:.85\">{net_sent} sent / {net_recv} recv</div></div>"
64 + "</div>"
65 + ),
66 + "dismissible": False,
67 + "source": "backend",
68 + })
69 +
70 + def _bar_html(self, percent: float | None) -> str:
71 + if percent is None:
72 + return ""
73 +
74 + p = max(0.0, min(100.0, float(percent)))
75 + if p >= 85:
76 + color = "#ef4444"
77 + elif p >= 70:
78 + color = "#f59e0b"
79 + else:
80 + color = "#22c55e"
81 +
82 + return (
83 + "<div style=\"flex:1;min-width:120px;max-width:220px;height:8px;border-radius:999px;"
84 + "background:rgba(255,255,255,.10);overflow:hidden;\">"
85 + f"<div style=\"height:100%;width:{p:.0f}%;background:{color};border-radius:999px;\"></div>"
86 + "</div>"
87 + )
88 +
89 + def _get_load_average(self) -> tuple[float, float, float] | None:
90 + try:
91 + return os.getloadavg()
92 + except Exception:
93 + return None
94 +
95 + def _get_disk_usage_percent(self) -> tuple[float | None, str]:
96 + for path in ["/", os.path.expanduser("~")]:
97 + try:
98 + usage = psutil.disk_usage(path)
99 + return usage.percent, path
100 + except Exception:
101 + continue
102 + return None, "/"
103 +
104 + def _format_bytes(self, value: int) -> str:
105 + size = float(value)
106 + for unit in ["B", "KB", "MB", "GB", "TB", "PB"]:
107 + if size < 1024:
108 + return f"{size:.1f} {unit}"
109 + size /= 1024
110 + return f"{size:.1f} EB"
webui/components/chat/input/chat-bar-input.html
+4 -2
@@ -5,6 +5,7 @@
5 import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
6 import { store as fullScreenStore } from "/components/modals/full-screen-input/full-screen-store.js";
7 import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
8 + import { store as chatInputStore } from "/components/chat/input/input-store.js";
9 </script>
10 </head>
11 <body>
@@ -21,8 +22,9 @@
22
23 <!-- Container for textarea and expand button -->
24 <div id="chat-input-container" style="position: relative;">
24 - <textarea id="chat-input" placeholder="Type your message here..." rows="1"
25 -+ @keydown.enter="if (!$event.shiftKey && !$event.isComposing && $event.keyCode !== 229) { $event.preventDefault(); $store.chatInput.sendMessage(); }"
25 + <textarea id="chat-input" :placeholder="$store.chatInput.inputPlaceholder" rows="1"
26 + x-effect="$el.placeholder = $store.chatInput.inputPlaceholder"
27 + @keydown.enter="if (!$event.shiftKey && !$event.isComposing && $event.keyCode !== 229) { $event.preventDefault(); $store.chatInput.sendMessage(); }"
28 @input="$store.chatInput.adjustTextareaHeight()"></textarea>
29 <button id="expand-button" @click="$store.fullScreenInputModal.openModal()" aria-label="Expand input">
30 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
webui/components/chat/input/input-store.js
+11
@@ -8,6 +8,17 @@ import { store as attachmentsStore } from "/components/chat/attachments/attachme
8 const model = {
9 paused: false,
10
11 + get inputPlaceholder() {
12 + const input = document.getElementById("chat-input");
13 + const hasTypedText = !!input?.value?.trim();
14 + const hasAttachments = (attachmentsStore?.attachments?.length || 0) > 0;
15 + const hasQueue = !!messageQueueStore?.hasQueue;
16 +
17 + if (hasQueue && !hasTypedText && !hasAttachments)
18 + return "Press Enter to send queued messages";
19 + return "Type your message here...";
20 + },
21 +
22 // Computed: send button icon type
23 get sendButtonIcon() {
24 const input = document.getElementById("chat-input");
webui/components/welcome/welcome-screen.html
+39 -6
@@ -397,22 +397,22 @@
397 }
398
399 .welcome-actions {
400 - grid-template-columns: repeat(2, 1fr);
401 - gap: 1rem;
402 - margin-bottom: 2rem;
400 + grid-template-columns: repeat(4, 1fr);
401 + gap: 0.75rem;
402 + margin-bottom: 1.5rem;
403 }
404
405 .welcome-action-card {
406 - padding: 1rem;
406 aspect-ratio: 1 / 1;
407 + padding: 0.5rem;
408 }
409
410 .welcome-action-icon {
411 - font-size: 2rem;
411 + font-size: 1.6rem;
412 }
413
414 .welcome-action-title {
415 - font-size: 1.1rem;
415 + font-size: 0.85rem;
416 }
417
418 .welcome-banners {
@@ -431,6 +431,39 @@
431 font-size: 0.8rem;
432 }
433 }
434 +
435 + @media (max-width: 520px) {
436 + .welcome-actions {
437 + grid-template-columns: 1fr;
438 + gap: 0.5rem;
439 + }
440 +
441 + .welcome-action-card {
442 + aspect-ratio: auto;
443 + padding: 0.5rem 0.75rem;
444 + flex-direction: row;
445 + justify-content: flex-start;
446 + align-items: center;
447 + text-align: left;
448 + border-radius: 10px;
449 + min-height: 44px;
450 + }
451 +
452 + .welcome-action-icon {
453 + margin-bottom: 0;
454 + margin-right: 0.5rem;
455 + font-size: 1.35rem;
456 + line-height: 1;
457 + display: flex;
458 + align-items: center;
459 + }
460 +
461 + .welcome-action-title {
462 + font-size: 0.95rem;
463 + margin: 0;
464 + line-height: 1.2;
465 + }
466 + }
467 </style>
468 </body>
469
webui/components/welcome/welcome-store.js
+2 -2
@@ -93,12 +93,12 @@ const model = {
93 const bannerMap = new Map();
94
95 for (const banner of frontendBanners) {
96 - if (banner.id && !dismissed.has(banner.id)) {
96 + if (banner.id && (banner.dismissible === false || !dismissed.has(banner.id))) {
97 bannerMap.set(banner.id, banner);
98 }
99 }
100 for (const banner of backendBanners) {
101 - if (banner.id && !dismissed.has(banner.id)) {
101 + if (banner.id && (banner.dismissible === false || !dismissed.has(banner.id))) {
102 bannerMap.set(banner.id, banner);
103 }
104 }