Add toast notification for wrapped long URLs in terminal
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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
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 { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
|
import { UrlDetector } from "../../lib/urlDetector";
|
||||||
|
import UrlToast from "./UrlToast";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -14,11 +16,15 @@ interface Props {
|
|||||||
|
|
||||||
export default function TerminalView({ sessionId, active }: Props) {
|
export default function TerminalView({ sessionId, active }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitRef = useRef<FitAddon | null>(null);
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
const webglRef = useRef<WebglAddon | null>(null);
|
const webglRef = useRef<WebglAddon | null>(null);
|
||||||
|
const detectorRef = useRef<UrlDetector | null>(null);
|
||||||
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
||||||
|
|
||||||
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
@@ -82,9 +88,13 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
// Handle backend output -> terminal
|
// Handle backend output -> terminal
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
|
const detector = new UrlDetector((url) => setDetectedUrl(url));
|
||||||
|
detectorRef.current = detector;
|
||||||
|
|
||||||
const outputPromise = onOutput(sessionId, (data) => {
|
const outputPromise = onOutput(sessionId, (data) => {
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
term.write(data);
|
term.write(data);
|
||||||
|
detector.feed(data);
|
||||||
}).then((unlisten) => {
|
}).then((unlisten) => {
|
||||||
if (aborted) unlisten();
|
if (aborted) unlisten();
|
||||||
return unlisten;
|
return unlisten;
|
||||||
@@ -116,6 +126,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
aborted = true;
|
aborted = true;
|
||||||
|
detector.dispose();
|
||||||
|
detectorRef.current = null;
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
outputPromise.then((fn) => fn?.());
|
outputPromise.then((fn) => fn?.());
|
||||||
exitPromise.then((fn) => fn?.());
|
exitPromise.then((fn) => fn?.());
|
||||||
@@ -160,11 +172,39 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
}, [active]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={terminalContainerRef}
|
||||||
className={`w-full h-full ${active ? "" : "hidden"}`}
|
className={`w-full h-full relative ${active ? "" : "hidden"}`}
|
||||||
style={{ padding: "8px" }}
|
>
|
||||||
/>
|
{detectedUrl && (
|
||||||
|
<UrlToast
|
||||||
|
url={detectedUrl}
|
||||||
|
onOpen={handleOpenUrl}
|
||||||
|
onDismiss={() => setDetectedUrl(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{ padding: "8px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
101
app/src/components/terminal/UrlToast.tsx
Normal file
101
app/src/components/terminal/UrlToast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,3 +46,10 @@ body {
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--border-color);
|
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; }
|
||||||
|
|||||||
78
app/src/lib/urlDetector.ts
Normal file
78
app/src/lib/urlDetector.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user