fix closed terminal feedback loop + add some addons

This commit is contained in:
lda
2026-07-15 22:17:38 +07:00 Verified
parent eaa77fa54e
commit 27fc515fe7
5 changed files with 1457 additions and 2789 deletions
+3
View File
@@ -17,6 +17,9 @@
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode-graphemes": "^0.4.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+1370 -2753
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ClipboardAddon } from "@xterm/addon-clipboard"; import { ClipboardAddon } from "@xterm/addon-clipboard";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Terminal as XTerm } from "@xterm/xterm"; import { Terminal as XTerm } from "@xterm/xterm";
import { Eraser, PlugZap, RotateCcw, Square, Terminal } from "lucide-react"; import { Eraser, PlugZap, RotateCcw, Square, Terminal } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -274,7 +277,12 @@ export function TerminalPage({
scrollbarSliderHoverBackground: "#6b7c8dcc", scrollbarSliderHoverBackground: "#6b7c8dcc",
scrollbarSliderActiveBackground: "#8294a6", scrollbarSliderActiveBackground: "#8294a6",
}, },
allowProposedApi: true, // this for unicode11 addon. FOR SOME reason xtermjs doesnt tell me this in the readme.
}); });
terminal.loadAddon(new Unicode11Addon());
terminal.unicode.activeVersion = "11";
terminal.loadAddon(new UnicodeGraphemesAddon());
terminal.loadAddon(new WebLinksAddon());
const fit = new FitAddon(); const fit = new FitAddon();
terminal.loadAddon(new ClipboardAddon()); terminal.loadAddon(new ClipboardAddon());
terminal.loadAddon(fit); terminal.loadAddon(fit);
+1
View File
@@ -545,6 +545,7 @@
fitted canvas, so keep it on the same surface as the terminal theme. */ fitted canvas, so keep it on the same surface as the terminal theme. */
background-color: #0b1117; background-color: #0b1117;
overscroll-behavior: contain; overscroll-behavior: contain;
scrollbar-width: none;
} }
.terminal-surface .xterm-scrollable-element > .scrollbar, .terminal-surface .xterm-scrollable-element > .scrollbar,
+53 -14
View File
@@ -56,7 +56,7 @@ impl TerminalState {
let contents = screen.rows(0, cols).next().unwrap_or_default(); let contents = screen.rows(0, cols).next().unwrap_or_default();
physical_rows.push(( physical_rows.push((
screen.rows_formatted(0, cols).next().unwrap_or_default(), screen.rows_formatted(0, cols).next().unwrap_or_default(),
UnicodeWidthStr::width(contents.as_str()).min(usize::from(cols)), UnicodeWidthStr::width_cjk(contents.as_str()).min(usize::from(cols)),
screen.row_wrapped(0), screen.row_wrapped(0),
)); ));
} }
@@ -71,7 +71,7 @@ impl TerminalState {
.rows_formatted(0, cols) .rows_formatted(0, cols)
.nth(usize::from(row)) .nth(usize::from(row))
.unwrap_or_default(), .unwrap_or_default(),
UnicodeWidthStr::width(contents.as_str()).min(usize::from(cols)), UnicodeWidthStr::width_cjk(contents.as_str()).min(usize::from(cols)),
screen.row_wrapped(row), screen.row_wrapped(row),
) )
})); }));
@@ -173,16 +173,17 @@ impl TerminalManager {
let active = Arc::downgrade(&self.active); let active = Arc::downgrade(&self.active);
let events = self.events.clone(); let events = self.events.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(err) = let result = run_terminal(&config, &terminal_id, rows, cols, cancel_rx, relay_rx).await;
run_terminal(&config, &terminal_id, rows, cols, cancel_rx, relay_rx).await // Inventory is authoritative. Remove the stopped worker before
{ // notifying the control session, which may immediately resync it.
remove_completed(&active, terminal_id.as_str());
if let Err(err) = result {
warn!(terminal_id = %terminal_id, error = %err, "terminal worker failed"); warn!(terminal_id = %terminal_id, error = %err, "terminal worker failed");
let _ = events.send(TerminalManagerEvent { let _ = events.send(TerminalManagerEvent {
terminal_id: terminal_id.clone(), terminal_id: terminal_id.clone(),
error: err.to_string(), error: err.to_string(),
}); });
} }
remove_completed(&active, terminal_id.as_str());
}); });
Ok(()) Ok(())
} }
@@ -196,20 +197,21 @@ impl TerminalManager {
} }
pub fn resume(&self, terminal_id: &TerminalId, relay_token: String) -> Result<()> { pub fn resume(&self, terminal_id: &TerminalId, relay_token: String) -> Result<()> {
let active = self.active.lock().expect("terminal manager poisoned"); let mut active = self.active.lock().expect("terminal manager poisoned");
let session = active let session = active
.get(terminal_id.as_str()) .get(terminal_id.as_str())
.with_context(|| format!("terminal session {terminal_id} is not active"))?; .with_context(|| format!("terminal session {terminal_id} is not active"))?;
session if session.relay_credentials.send(relay_token).is_err() {
.relay_credentials active.remove(terminal_id.as_str());
.send(relay_token) anyhow::bail!("terminal session {terminal_id} has stopped");
.map_err(|_| anyhow::anyhow!("terminal session {terminal_id} has stopped")) }
Ok(())
} }
pub fn sessions(&self) -> Vec<AgentTerminalSession> { pub fn sessions(&self) -> Vec<AgentTerminalSession> {
self.active let mut active = self.active.lock().expect("terminal manager poisoned");
.lock() active.retain(|_, terminal| !terminal.relay_credentials.is_closed());
.expect("terminal manager poisoned") active
.iter() .iter()
.filter_map(|(terminal_id, active)| { .filter_map(|(terminal_id, active)| {
TerminalId::new(terminal_id.clone()) TerminalId::new(terminal_id.clone())
@@ -605,6 +607,27 @@ fn validate_size(rows: u16, cols: u16) -> Result<()> {
mod tests { mod tests {
use super::*; use super::*;
fn manager_with_stopped_terminal(terminal_id: &str) -> TerminalManager {
let (cancel, cancel_rx) = oneshot::channel();
drop(cancel_rx);
let (relay_credentials, relay_rx) = mpsc::unbounded_channel();
drop(relay_rx);
let (events, event_rx) = mpsc::unbounded_channel();
drop(event_rx);
TerminalManager {
active: Arc::new(Mutex::new(HashMap::from([(
terminal_id.to_string(),
ActiveTerminal {
cancel,
relay_credentials,
created_at_unix: 42,
},
)]))),
max_sessions: 2,
events,
}
}
#[test] #[test]
fn terminal_url_uses_dedicated_agent_path() { fn terminal_url_uses_dedicated_agent_path() {
let id = TerminalId::new("term-1").expect("terminal id"); let id = TerminalId::new("term-1").expect("terminal id");
@@ -615,6 +638,22 @@ mod tests {
); );
} }
#[test]
fn failed_resume_removes_stopped_terminal_from_inventory() {
let manager = manager_with_stopped_terminal("stopped-terminal");
let terminal_id = TerminalId::new("stopped-terminal").expect("terminal id");
assert!(manager.resume(&terminal_id, "replacement".into()).is_err());
assert!(manager.sessions().is_empty());
}
#[test]
fn inventory_prunes_terminal_with_stopped_worker() {
let manager = manager_with_stopped_terminal("stopped-terminal");
assert!(manager.sessions().is_empty());
}
#[test] #[test]
fn snapshot_reconstructs_screen_content_and_cursor() { fn snapshot_reconstructs_screen_content_and_cursor() {
let mut state = TerminalState::new(24, 80); let mut state = TerminalState::new(24, 80);