Bump version to 1.3.32

This commit is contained in:
GitHub Copilot
2026-02-26 19:56:58 +00:00
parent 2d9cb7f062
commit 3d83b4b104
7 changed files with 252 additions and 84 deletions
+1 -1
View File
@@ -1 +1 @@
1.3.31 1.3.32
+1 -6
View File
@@ -74,9 +74,7 @@ func (t *Tracker) Feed(data []byte) error {
if len(t.screen.Dirty) > 0 { if len(t.screen.Dirty) > 0 {
t.changeCounter++ t.changeCounter++
// Clear dirty set so subsequent feeds detect new changes // Clear dirty set so subsequent feeds detect new changes
for k := range t.screen.Dirty { clear(t.screen.Dirty)
delete(t.screen.Dirty, k)
}
} }
return nil return nil
} }
@@ -108,9 +106,6 @@ func (t *Tracker) Snapshot() Snapshot {
for col := 0; col < t.screen.Columns; col++ { for col := 0; col < t.screen.Columns; col++ {
raw := t.screen.Buffer[row][col] raw := t.screen.Buffer[row][col]
data := raw.Data data := raw.Data
if data == "" {
data = " "
}
line[col] = Cell{ line[col] = Cell{
Data: data, Data: data,
FG: colorToString(raw.Attr.Fg), FG: colorToString(raw.Attr.Fg),
+55 -11
View File
@@ -40,30 +40,74 @@ func RenderTerminalPNG(
return nil, nil return nil, nil
} }
bgColor := mustParseHexColor(background) bgFillColor := mustParseHexColor(background)
img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight)) img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight))
draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src) draw.Draw(img, img.Bounds(), &image.Uniform{bgFillColor}, image.Point{}, draw.Src)
// Cache parsed colors to avoid per-cell hex parsing overhead on large terminals.
colorCache := map[string]color.RGBA{}
parseColor := func(hex string) color.RGBA {
if c, ok := colorCache[hex]; ok {
return c
}
c := mustParseHexColor(hex)
colorCache[hex] = c
return c
}
for rowIdx := 0; rowIdx < len(buffer); rowIdx++ { for rowIdx := 0; rowIdx < len(buffer); rowIdx++ {
row := buffer[rowIdx] row := buffer[rowIdx]
rectY := pngPadding + rowIdx*cellHeight rectY := pngPadding + rowIdx*cellHeight
for col := 0; col < len(row); col++ { for col := 0; col < len(row); col++ {
cell := row[col] cell := row[col]
if cell.Data == "" && cell.BG == "" { charData := cell.Data
// Fast-path skip for visually empty cells (default background, no reverse video).
if (charData == "" || charData == " ") && !cell.Reverse &&
(cell.BG == "" || strings.EqualFold(cell.BG, "default")) {
continue continue
} }
x := pngPadding + col*pngCharWidth x := pngPadding + col*pngCharWidth
fg := colorToHex(cell.FG, true, palette, foreground, background) fgHex := colorToHex(cell.FG, true, palette, foreground, background)
bg := colorToHex(cell.BG, false, palette, foreground, background) bgHex := colorToHex(cell.BG, false, palette, foreground, background)
if cell.Reverse { if cell.Reverse {
fg, bg = bg, fg fgHex, bgHex = bgHex, fgHex
} }
bgColor := mustParseHexColor(bg)
coverage := uint8(0) // If there's no glyph, only render background when it differs from the image background.
if cell.Data != "" { if charData == "" || charData == " " {
coverage = coverageForRune(firstRune(cell.Data)) if bgHex == background {
continue
}
draw.Draw(
img,
image.Rect(x, rectY, x+pngCharWidth, rectY+cellHeight),
&image.Uniform{parseColor(bgHex)},
image.Point{},
draw.Src,
)
continue
} }
cellColor := blendColors(mustParseHexColor(fg), bgColor, coverage)
bgColor := parseColor(bgHex)
fgColor := parseColor(fgHex)
coverage := coverageForRune(firstRune(charData))
var cellColor color.RGBA
switch coverage {
case 0:
cellColor = bgColor
case 255:
cellColor = fgColor
default:
cellColor = blendColors(fgColor, bgColor, coverage)
}
// Skip drawing default background cells (background already filled).
if cellColor == bgFillColor {
continue
}
draw.Draw( draw.Draw(
img, img,
image.Rect(x, rectY, x+pngCharWidth, rectY+cellHeight), image.Rect(x, rectY, x+pngCharWidth, rectY+cellHeight),
+97 -49
View File
@@ -2,6 +2,7 @@ package webterm
import ( import (
"bufio" "bufio"
"bytes"
"compress/gzip" "compress/gzip"
"context" "context"
"crypto/sha1" "crypto/sha1"
@@ -555,14 +556,36 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
} }
stdinQueue := make(chan stdinWrite, wsSendQueueMax) stdinQueue := make(chan stdinWrite, wsSendQueueMax)
defer close(stdinQueue) defer close(stdinQueue)
// Coalesce small stdin writes (e.g. key repeats) to reduce syscall and locking overhead.
const stdinCoalesceMaxBytes = 4 * 1024
go func() { go func() {
var buf bytes.Buffer
for write := range stdinQueue { for write := range stdinQueue {
if !write.session.SendBytes([]byte(write.data)) { buf.Reset()
buf.WriteString(write.data)
for buf.Len() < stdinCoalesceMaxBytes {
select {
case next := <-stdinQueue:
buf.WriteString(next.data)
default:
goto flush
}
}
flush:
if !write.session.SendBytes(buf.Bytes()) {
log.Printf("stdin write failed route=%s remote=%s", routeKey, r.RemoteAddr) log.Printf("stdin write failed route=%s remote=%s", routeKey, r.RemoteAddr)
} }
} }
}() }()
// Allocate the timeout timer once; avoid time.After() per stdin message.
stdinTimer := time.NewTimer(stdinWriteTimeout)
if !stdinTimer.Stop() {
<-stdinTimer.C
}
defer stdinTimer.Stop()
for { for {
messageType, payload, err := conn.ReadMessage() messageType, payload, err := conn.ReadMessage()
if err != nil { if err != nil {
@@ -589,12 +612,32 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
if len(envelope) > 1 { if len(envelope) > 1 {
data, _ = envelope[1].(string) data, _ = envelope[1].(string)
} }
write := stdinWrite{session: session, data: data}
select { select {
case stdinQueue <- stdinWrite{session: session, data: data}: case stdinQueue <- write:
case <-time.After(stdinWriteTimeout): // queued
log.Printf("stdin queue saturated route=%s remote=%s: disconnecting client", routeKey, r.RemoteAddr) default:
sendJSON([]any{"error", "Input backlog detected"}) // Queue is full; wait briefly for it to drain.
return if !stdinTimer.Stop() {
select {
case <-stdinTimer.C:
default:
}
}
stdinTimer.Reset(stdinWriteTimeout)
select {
case stdinQueue <- write:
if !stdinTimer.Stop() {
select {
case <-stdinTimer.C:
default:
}
}
case <-stdinTimer.C:
log.Printf("stdin queue saturated route=%s remote=%s: disconnecting client", routeKey, r.RemoteAddr)
sendJSON([]any{"error", "Input backlog detected"})
return
}
} }
} }
case "resize": case "resize":
@@ -1168,7 +1211,9 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
const etagBySlug = {}; const etagBySlug = {};
const refreshQueue = []; const refreshQueue = [];
const queuedRefresh = {}; const queuedRefresh = {};
let screenshotRequestInFlight = false; // Allow limited parallelism when fetching thumbnails so large dashboards update faster.
const MAX_SCREENSHOT_CONCURRENCY = Math.max(2, Math.min(4, Math.floor((navigator.hardwareConcurrency || 4) / 2)));
let screenshotRequestsInFlight = 0;
const grid = document.getElementById('grid'); const grid = document.getElementById('grid');
const subtitle = document.getElementById('subtitle'); const subtitle = document.getElementById('subtitle');
@@ -1452,48 +1497,51 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
window.addEventListener('blur', onDashboardFocusChanged); window.addEventListener('blur', onDashboardFocusChanged);
function processRefreshQueue() { function processRefreshQueue() {
if (screenshotRequestInFlight || refreshQueue.length === 0 || !dashboardCanRequestScreenshots()) return; if (refreshQueue.length === 0 || !dashboardCanRequestScreenshots()) return;
const slug = refreshQueue.shift();
delete queuedRefresh[slug]; while (screenshotRequestsInFlight < MAX_SCREENSHOT_CONCURRENCY && refreshQueue.length > 0) {
const card = cardsBySlug[slug]; const slug = refreshQueue.shift();
if (!card || !card.img) { delete queuedRefresh[slug];
setTimeout(processRefreshQueue, 0); const card = cardsBySlug[slug];
return; if (!card || !card.img) {
continue;
}
screenshotRequestsInFlight++;
const url = screenshotEndpoint + '?route_key=' + encodeURIComponent(slug);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const headers = {};
if (etagBySlug[slug]) {
headers['If-None-Match'] = etagBySlug[slug];
}
fetch(url, { cache: 'no-cache', headers, signal: controller.signal })
.then((resp) => {
const nextETag = resp.headers.get('ETag');
if (nextETag) {
etagBySlug[slug] = nextETag;
}
if (resp.status === 304) return null;
if (!resp.ok) throw new Error('screenshot fetch failed');
return resp.blob();
})
.then((blob) => {
if (!blob) return;
const currentCard = cardsBySlug[slug];
if (!currentCard || !currentCard.img) return;
const previous = activeObjectURLBySlug[slug];
if (previous) URL.revokeObjectURL(previous);
const objectURL = URL.createObjectURL(blob);
activeObjectURLBySlug[slug] = objectURL;
currentCard.img.src = objectURL;
})
.catch(() => {})
.finally(() => {
clearTimeout(timeout);
screenshotRequestsInFlight--;
setTimeout(processRefreshQueue, 0);
});
} }
screenshotRequestInFlight = true;
const url = screenshotEndpoint + '?route_key=' + encodeURIComponent(slug);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const headers = {};
if (etagBySlug[slug]) {
headers['If-None-Match'] = etagBySlug[slug];
}
fetch(url, { cache: 'no-cache', headers, signal: controller.signal })
.then((resp) => {
const nextETag = resp.headers.get('ETag');
if (nextETag) {
etagBySlug[slug] = nextETag;
}
if (resp.status === 304) return null;
if (!resp.ok) throw new Error('screenshot fetch failed');
return resp.blob();
})
.then((blob) => {
if (!blob) return;
const currentCard = cardsBySlug[slug];
if (!currentCard || !currentCard.img) return;
const previous = activeObjectURLBySlug[slug];
if (previous) URL.revokeObjectURL(previous);
const objectURL = URL.createObjectURL(blob);
activeObjectURLBySlug[slug] = objectURL;
currentCard.img.src = objectURL;
})
.catch(() => {})
.finally(() => {
clearTimeout(timeout);
screenshotRequestInFlight = false;
setTimeout(processRefreshQueue, 0);
});
} }
function queueTileRefresh(slug) { function queueTileRefresh(slug) {
@@ -1570,7 +1618,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
grid.innerHTML = ''; grid.innerHTML = '';
cardsBySlug = {}; cardsBySlug = {};
refreshQueue.length = 0; refreshQueue.length = 0;
screenshotRequestInFlight = false; screenshotRequestsInFlight = 0;
for (const key in queuedRefresh) { for (const key in queuedRefresh) {
delete queuedRefresh[key]; delete queuedRefresh[key];
} }
File diff suppressed because one or more lines are too long
+69 -10
View File
@@ -14,6 +14,12 @@ const MAX_MESSAGE_QUEUE_SIZE = 1000;
const RESOURCE_CLEANUP_INTERVAL_MS = 30_000; const RESOURCE_CLEANUP_INTERVAL_MS = 30_000;
/** Maximum bytes to buffer while the tab is hidden (256 KB) */ /** Maximum bytes to buffer while the tab is hidden (256 KB) */
const MAX_HIDDEN_BUFFER_BYTES = 256 * 1024; const MAX_HIDDEN_BUFFER_BYTES = 256 * 1024;
/** Batch stdin writes to reduce per-keystroke overhead and avoid WS/PTY backlogs */
const STDIN_BATCH_DELAY_MS = 10;
/** Flush stdin batch when it gets large (e.g. paste) */
const STDIN_BATCH_MAX_CHARS = 8192;
const BELL_EMOJI = "🔔"; const BELL_EMOJI = "🔔";
/** Shared Ghostty WASM instance (loaded once, reused across all terminals) */ /** Shared Ghostty WASM instance (loaded once, reused across all terminals) */
@@ -663,6 +669,11 @@ class WebTerminal {
private lastMessageAt = 0; private lastMessageAt = 0;
private lastPongAt = 0; private lastPongAt = 0;
private messageQueue: [string, unknown][] = []; private messageQueue: [string, unknown][] = [];
// Stdin batching (coalesces key repeats into fewer WS frames)
private pendingStdin = "";
private pendingStdinTimer: number | undefined;
private lastValidSize: { cols: number; rows: number } | null = null; private lastValidSize: { cols: number; rows: number } | null = null;
private mobileInput: HTMLTextAreaElement | null = null; private mobileInput: HTMLTextAreaElement | null = null;
private mobileKeybar: HTMLElement | null = null; private mobileKeybar: HTMLElement | null = null;
@@ -828,7 +839,7 @@ class WebTerminal {
// Handle terminal input // Handle terminal input
this.terminal.onData((data) => { this.terminal.onData((data) => {
this.clearBellState(); this.clearBellState();
this.send(["stdin", data]); this.sendStdin(data);
}); });
this.terminal.onBell(() => { this.terminal.onBell(() => {
@@ -947,7 +958,7 @@ class WebTerminal {
if (!text) { if (!text) {
return; return;
} }
this.send(["stdin", applyMobileModifiers(text)]); this.sendStdin(applyMobileModifiers(text));
textarea.value = ""; textarea.value = "";
this.deactivateModifiers(); this.deactivateModifiers();
this.pendingCtrl = false; this.pendingCtrl = false;
@@ -1000,7 +1011,7 @@ class WebTerminal {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const toSend = isAlt ? applyAltModifier(ctrlApplied) : ctrlApplied; const toSend = isAlt ? applyAltModifier(ctrlApplied) : ctrlApplied;
this.send(["stdin", toSend]); // Ctrl+A=0x01, Ctrl+C=0x03, etc. this.sendStdin(toSend); // Ctrl+A=0x01, Ctrl+C=0x03, etc.
this.deactivateModifiers(); // Clear modifiers after physical Ctrl+key this.deactivateModifiers(); // Clear modifiers after physical Ctrl+key
return; return;
} }
@@ -1010,7 +1021,7 @@ class WebTerminal {
if (fnApplied) { if (fnApplied) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.send(["stdin", fnApplied]); this.sendStdin(fnApplied);
this.deactivateModifiers(); this.deactivateModifiers();
return; return;
} }
@@ -1049,7 +1060,7 @@ class WebTerminal {
if (seq) { if (seq) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.send(["stdin", isAlt ? applyAltModifier(seq) : seq]); this.sendStdin(isAlt ? applyAltModifier(seq) : seq);
// Always clear modifiers after any key // Always clear modifiers after any key
this.deactivateModifiers(); this.deactivateModifiers();
} }
@@ -1077,7 +1088,7 @@ class WebTerminal {
const toSend = applyModifiers(event.key, useShift, useCtrl, useAlt, useFn); const toSend = applyModifiers(event.key, useShift, useCtrl, useAlt, useFn);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.send(["stdin", toSend]); this.sendStdin(toSend);
handled = true; handled = true;
} else { } else {
let seq: string | null = null; let seq: string | null = null;
@@ -1120,7 +1131,7 @@ class WebTerminal {
if (seq) { if (seq) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.send(["stdin", useAlt ? applyAltModifier(seq) : seq]); this.sendStdin(useAlt ? applyAltModifier(seq) : seq);
handled = true; handled = true;
} }
} }
@@ -1319,7 +1330,7 @@ class WebTerminal {
key = applyAltModifier(key); key = applyAltModifier(key);
} }
this.send(["stdin", key]); this.sendStdin(key);
this.deactivateModifiers(); this.deactivateModifiers();
}); });
}); });
@@ -1535,7 +1546,7 @@ class WebTerminal {
/** Validate terminal dimensions */ /** Validate terminal dimensions */
private isValidSize(cols: number, rows: number): boolean { private isValidSize(cols: number, rows: number): boolean {
return cols >= 2 && cols <= 500 && rows >= 1 && rows <= 200; return cols >= 2 && cols <= 500 && rows >= 1 && rows <= 500;
} }
/** Connect to WebSocket server */ /** Connect to WebSocket server */
@@ -1557,7 +1568,8 @@ class WebTerminal {
this.element.classList.add("-connected"); this.element.classList.add("-connected");
this.element.classList.remove("-disconnected"); this.element.classList.remove("-disconnected");
// Process any queued messages // Flush any batched stdin and process queued messages
this.flushStdin();
this.processMessageQueue(); this.processMessageQueue();
// Send initial size // Send initial size
@@ -1723,8 +1735,50 @@ class WebTerminal {
this.connect(); this.connect();
} }
/** Queue stdin data for batched sending */
private sendStdin(data: string): void {
if (!data) {
return;
}
this.pendingStdin += data;
// Flush immediately for large payloads (e.g. paste) to avoid excessive buffering.
if (this.pendingStdin.length >= STDIN_BATCH_MAX_CHARS) {
this.flushStdin();
return;
}
if (this.pendingStdinTimer) {
return;
}
this.pendingStdinTimer = window.setTimeout(() => {
this.pendingStdinTimer = undefined;
this.flushStdin();
}, STDIN_BATCH_DELAY_MS);
}
private flushStdin(): void {
if (this.pendingStdinTimer) {
clearTimeout(this.pendingStdinTimer);
this.pendingStdinTimer = undefined;
}
if (!this.pendingStdin) {
return;
}
const chunk = this.pendingStdin;
this.pendingStdin = "";
this.send(["stdin", chunk]);
}
/** Send message to server with queueing support */ /** Send message to server with queueing support */
private send(message: [string, unknown]): void { private send(message: [string, unknown]): void {
// Preserve ordering: flush any pending stdin before non-stdin messages (resize/ping/etc).
if (message[0] !== "stdin" && this.pendingStdin) {
this.flushStdin();
}
if (this.messageQueue.length >= MAX_MESSAGE_QUEUE_SIZE) { if (this.messageQueue.length >= MAX_MESSAGE_QUEUE_SIZE) {
this.messageQueue = this.messageQueue.slice(-Math.floor(MAX_MESSAGE_QUEUE_SIZE / 2)); this.messageQueue = this.messageQueue.slice(-Math.floor(MAX_MESSAGE_QUEUE_SIZE / 2));
console.warn("[webterm] Message queue overflow; trimmed old messages"); console.warn("[webterm] Message queue overflow; trimmed old messages");
@@ -1775,6 +1829,11 @@ class WebTerminal {
dispose(): void { dispose(): void {
this.stopResourceCleanup(); this.stopResourceCleanup();
this.stopHeartbeatWatchdog(); this.stopHeartbeatWatchdog();
if (this.pendingStdinTimer) {
clearTimeout(this.pendingStdinTimer);
this.pendingStdinTimer = undefined;
}
this.pendingStdin = "";
if (this.resizeDebounceTimer) { if (this.resizeDebounceTimer) {
clearTimeout(this.resizeDebounceTimer); clearTimeout(this.resizeDebounceTimer);
this.resizeDebounceTimer = undefined; this.resizeDebounceTimer = undefined;
+23 -1
View File
@@ -71,18 +71,40 @@ func RenderTerminalSVG(
for col := 0; col < len(row); col++ { for col := 0; col < len(row); col++ {
cell := row[col] cell := row[col]
charData := cell.Data charData := cell.Data
if charData == "" {
blankGlyph := charData == "" || charData == " "
defaultBG := cell.BG == "" || strings.EqualFold(cell.BG, "default")
// If there's no glyph, no underline, no reverse, and the background is default,
// this cell is visually empty.
if blankGlyph && !cell.Underscore && !cell.Reverse && defaultBG {
continue continue
} }
x := 10 + float64(col)*charWidth x := 10 + float64(col)*charWidth
fg := colorToHex(cell.FG, true, palette, foreground, background) fg := colorToHex(cell.FG, true, palette, foreground, background)
bg := colorToHex(cell.BG, false, palette, foreground, background) bg := colorToHex(cell.BG, false, palette, foreground, background)
if cell.Reverse { if cell.Reverse {
fg, bg = bg, fg fg, bg = bg, fg
} }
// Render background even for empty cells (important for reverse video / colored spans).
if bg != background { if bg != background {
b.WriteString(fmt.Sprintf(`<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"/>`, x, rectY, charWidth+0.5, cellHeight+0.5, bg)) b.WriteString(fmt.Sprintf(`<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"/>`, x, rectY, charWidth+0.5, cellHeight+0.5, bg))
} }
// Only render text for visible glyphs (or underlined blanks).
if charData == "" {
if cell.Underscore {
charData = " "
} else {
continue
}
}
if charData == " " && !cell.Underscore {
// Space glyphs are visually empty; keep background only.
continue
}
attrs := []string{fmt.Sprintf(`x="%.1f"`, x)} attrs := []string{fmt.Sprintf(`x="%.1f"`, x)}
if fg != foreground { if fg != foreground {
attrs = append(attrs, `fill="`+fg+`"`) attrs = append(attrs, `fill="`+fg+`"`)