Compare commits

...

1 Commits

Author SHA1 Message Date
40493ae284 Add toast notification for wrapped long URLs in terminal
All checks were successful
Build App / build-linux (push) Successful in 2m40s
Build App / build-windows (push) Successful in 3m40s
PTY hard-wraps long URLs (e.g. OAuth) with \r\n at column width, breaking
xterm.js link detection. This adds a UrlDetector that reassembles wrapped
URLs from the output stream and shows a non-intrusive floating toast with
an "Open" button. Auto-dismisses after 30s, no terminal layout impact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 08:29:43 -08:00
4 changed files with 231 additions and 5 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
@@ -6,6 +6,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal";
import { UrlDetector } from "../../lib/urlDetector";
import UrlToast from "./UrlToast";
interface Props {
sessionId: string;
@@ -14,11 +16,15 @@ interface Props {
export default function TerminalView({ sessionId, active }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const terminalContainerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const webglRef = useRef<WebglAddon | null>(null);
const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, resize, onOutput, onExit } = useTerminal();
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
useEffect(() => {
if (!containerRef.current) return;
@@ -82,9 +88,13 @@ export default function TerminalView({ sessionId, active }: Props) {
// Handle backend output -> terminal
let aborted = false;
const detector = new UrlDetector((url) => setDetectedUrl(url));
detectorRef.current = detector;
const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return;
term.write(data);
detector.feed(data);
}).then((unlisten) => {
if (aborted) unlisten();
return unlisten;
@@ -116,6 +126,8 @@ export default function TerminalView({ sessionId, active }: Props) {
return () => {
aborted = true;
detector.dispose();
detectorRef.current = null;
inputDisposable.dispose();
outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.());
@@ -160,11 +172,39 @@ export default function TerminalView({ sessionId, active }: Props) {
}
}, [active]);
// Auto-dismiss toast after 30 seconds
useEffect(() => {
if (!detectedUrl) return;
const timer = setTimeout(() => setDetectedUrl(null), 30_000);
return () => clearTimeout(timer);
}, [detectedUrl]);
const handleOpenUrl = useCallback(() => {
if (detectedUrl) {
openUrl(detectedUrl).catch((e) =>
console.error("Failed to open URL:", e),
);
setDetectedUrl(null);
}
}, [detectedUrl]);
return (
<div
ref={containerRef}
className={`w-full h-full ${active ? "" : "hidden"}`}
style={{ padding: "8px" }}
/>
ref={terminalContainerRef}
className={`w-full h-full relative ${active ? "" : "hidden"}`}
>
{detectedUrl && (
<UrlToast
url={detectedUrl}
onOpen={handleOpenUrl}
onDismiss={() => setDetectedUrl(null)}
/>
)}
<div
ref={containerRef}
className="w-full h-full"
style={{ padding: "8px" }}
/>
</div>
);
}

View File

@@ -0,0 +1,101 @@
interface Props {
url: string;
onOpen: () => void;
onDismiss: () => void;
}
export default function UrlToast({ url, onOpen, onDismiss }: Props) {
return (
<div
className="animate-slide-down"
style={{
position: "absolute",
top: 12,
left: "50%",
transform: "translateX(-50%)",
zIndex: 40,
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: "var(--bg-secondary)",
border: "1px solid var(--border-color)",
borderRadius: 8,
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
maxWidth: "min(90%, 600px)",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginBottom: 2,
}}
>
Long URL detected
</div>
<div
style={{
fontSize: 12,
fontFamily: "monospace",
color: "var(--text-primary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{url}
</div>
</div>
<button
onClick={onOpen}
style={{
padding: "4px 12px",
fontSize: 12,
fontWeight: 600,
color: "#fff",
background: "var(--accent)",
border: "none",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "var(--accent-hover)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "var(--accent)")
}
>
Open
</button>
<button
onClick={onDismiss}
style={{
padding: "2px 6px",
fontSize: 14,
lineHeight: 1,
color: "var(--text-secondary)",
background: "transparent",
border: "none",
borderRadius: 4,
cursor: "pointer",
flexShrink: 0,
}}
onMouseEnter={(e) =>
(e.currentTarget.style.color = "var(--text-primary)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.color = "var(--text-secondary)")
}
aria-label="Dismiss"
>
</button>
</div>
);
}

View File

@@ -46,3 +46,10 @@ body {
::-webkit-scrollbar-thumb:hover {
background: var(--border-color);
}
/* Toast slide-down animation */
@keyframes slide-down {
from { opacity: 0; transform: translate(-50%, -8px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
.animate-slide-down { animation: slide-down 0.2s ease-out; }

View File

@@ -0,0 +1,78 @@
/**
* Detects long URLs that span multiple hard-wrapped lines in PTY output.
*
* The Linux PTY hard-wraps long lines with \r\n at the terminal column width,
* which breaks xterm.js WebLinksAddon URL detection. This class reassembles
* those wrapped URLs and fires a callback for ones >= 100 chars.
*/
const ANSI_RE =
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
const MAX_BUFFER = 8 * 1024; // 8 KB rolling buffer cap
const DEBOUNCE_MS = 300;
const MIN_URL_LENGTH = 100;
export type UrlCallback = (url: string) => void;
export class UrlDetector {
private decoder = new TextDecoder();
private buffer = "";
private timer: ReturnType<typeof setTimeout> | null = null;
private lastEmitted = "";
private callback: UrlCallback;
constructor(callback: UrlCallback) {
this.callback = callback;
}
/** Feed raw PTY output chunks. */
feed(data: Uint8Array): void {
this.buffer += this.decoder.decode(data, { stream: true });
// Cap buffer to avoid unbounded growth
if (this.buffer.length > MAX_BUFFER) {
this.buffer = this.buffer.slice(-MAX_BUFFER);
}
// Debounce — scan after 300 ms of silence
if (this.timer !== null) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = null;
this.scan();
}, DEBOUNCE_MS);
}
private scan(): void {
const clean = this.buffer.replace(ANSI_RE, "");
const lines = clean.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/https?:\/\/[^\s'"]+/);
if (!match) continue;
// Start with the URL fragment found on this line
let url = match[0];
// Concatenate subsequent continuation lines (non-empty, no spaces, no leading whitespace)
for (let j = i + 1; j < lines.length; j++) {
const next = lines[j];
if (!next || next.startsWith(" ") || next.includes(" ")) break;
url += next;
i = j; // skip this line in the outer loop
}
if (url.length >= MIN_URL_LENGTH && url !== this.lastEmitted) {
this.lastEmitted = url;
this.callback(url);
}
}
}
dispose(): void {
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
}
}