Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agents/commands/deslop.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ Apply clean code philosophy to the code, with special emphasis on comments:

**Keep or add comments only when:**
- Explaining *why* a non-obvious decision was made
- Documenting design intent or architectural decisions (e.g., "shared by X and Y paths", "deferred because Z")
- Documenting external constraints or business rules not evident from code
- Warning about non-intuitive behavior or edge cases
- Noting the origin of a pattern when it aids future maintenance (e.g., "VS Code pattern")
- Required for public API documentation (JSDoc, docstrings)

**When in doubt, keep the comment.** Removing a comment that captured intent is destructive — the reasoning is lost and cannot be recovered from code alone. Only remove a comment when you are confident the code *fully* communicates the same information.

## Code Simplification

- Rename variables/functions to be self-documenting instead of adding comments
Expand Down
91 changes: 91 additions & 0 deletions apps/desktop/src/renderer/lib/terminal/appearance/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { ITheme } from "@xterm/xterm";
import { toXtermTheme } from "renderer/stores/theme/utils";
import {
builtInThemes,
DEFAULT_THEME_ID,
getTerminalColors,
} from "shared/themes";

export interface TerminalAppearance {
theme: ITheme;
background: string;
fontFamily: string;
fontSize: number;
}

const GENERIC_FONT_FAMILIES = new Set([
"serif",
"sans-serif",
"monospace",
"cursive",
"fantasy",
"system-ui",
"ui-serif",
"ui-sans-serif",
"ui-monospace",
"ui-rounded",
"emoji",
"math",
"fangsong",
]);

function serializeFontFamilyList(families: string[]): string {
return families
.map((family) =>
GENERIC_FONT_FAMILIES.has(family)
? family
: `"${family.replaceAll('"', '\\"')}"`,
)
.join(", ");
}

export const DEFAULT_TERMINAL_FONT_FAMILIES = [
"JetBrains Mono",
"JetBrainsMono Nerd Font",
"MesloLGM Nerd Font",
"MesloLGM NF",
"MesloLGS NF",
"MesloLGS Nerd Font",
"Hack Nerd Font",
"FiraCode Nerd Font",
"CaskaydiaCove Nerd Font",
"Menlo",
"Monaco",
"Courier New",
"monospace",
] as const;

export const DEFAULT_TERMINAL_FONT_FAMILY = serializeFontFamilyList([
...DEFAULT_TERMINAL_FONT_FAMILIES,
]);

export const DEFAULT_TERMINAL_FONT_SIZE = 14;

/** Reads localStorage theme cache for flash-free first paint. */
export function getDefaultTerminalAppearance(): TerminalAppearance {
const theme = readCachedTerminalTheme();
return {
theme,
background: theme.background ?? "#151110",
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
fontSize: DEFAULT_TERMINAL_FONT_SIZE,
};
}

function readCachedTerminalTheme(): ITheme {
try {
const cachedTerminal = localStorage.getItem("theme-terminal");
if (cachedTerminal) {
return toXtermTheme(JSON.parse(cachedTerminal));
}
const themeId = localStorage.getItem("theme-id") ?? DEFAULT_THEME_ID;
const theme = builtInThemes.find((t) => t.id === themeId);
if (theme) {
return toXtermTheme(getTerminalColors(theme));
}
} catch {}
const defaultTheme = builtInThemes.find((t) => t.id === DEFAULT_THEME_ID);
return defaultTheme
? toXtermTheme(getTerminalColors(defaultTheme))
: { background: "#151110", foreground: "#eae8e6" };
}
57 changes: 57 additions & 0 deletions apps/desktop/src/renderer/lib/terminal/terminal-addons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { ImageAddon } from "@xterm/addon-image";
import { LigaturesAddon } from "@xterm/addon-ligatures";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebglAddon } from "@xterm/addon-webgl";
import type { Terminal as XTerm } from "@xterm/xterm";

// Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern).
let suggestedRendererType: "webgl" | "dom" | undefined;

/**
* Load optional addons onto an already-opened terminal. Returns a cleanup
* function. WebGL is deferred to rAF to avoid racing with xterm's post-open
* viewport sync.
*/
export function loadAddons(terminal: XTerm): () => void {
let disposed = false;
let webglAddon: WebglAddon | null = null;

terminal.loadAddon(new ClipboardAddon());

const unicode11 = new Unicode11Addon();
terminal.loadAddon(unicode11);
terminal.unicode.activeVersion = "11";

terminal.loadAddon(new ImageAddon());

try {
terminal.loadAddon(new LigaturesAddon());
} catch {}

const rafId = requestAnimationFrame(() => {
if (disposed || suggestedRendererType === "dom") return;

try {
webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => {
webglAddon?.dispose();
webglAddon = null;
terminal.refresh(0, terminal.rows - 1);
});
terminal.loadAddon(webglAddon);
} catch {
suggestedRendererType = "dom";
webglAddon = null;
}
});

return () => {
disposed = true;
cancelAnimationFrame(rafId);
try {
webglAddon?.dispose();
Comment thread
Kitenite marked this conversation as resolved.
Resolved
} catch {}
webglAddon = null;
};
}
54 changes: 44 additions & 10 deletions apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { TerminalAppearance } from "./appearance";
import {
attachToContainer,
createRuntime,
detachFromContainer,
disposeRuntime,
type TerminalRuntime,
updateRuntimeAppearance,
} from "./terminal-runtime";
import {
type ConnectionState,
Expand All @@ -16,28 +18,43 @@ import {
} from "./terminal-ws-transport";

interface RegistryEntry {
runtime: TerminalRuntime;
runtime: TerminalRuntime | null;
transport: TerminalTransport;
}

class TerminalRuntimeRegistryImpl {
private entries = new Map<string, RegistryEntry>();

private getOrCreate(terminalId: string): RegistryEntry {
private getOrCreateEntry(terminalId: string): RegistryEntry {
let entry = this.entries.get(terminalId);
if (entry) return entry;

entry = {
runtime: createRuntime(terminalId),
runtime: null,
transport: createTransport(),
};

this.entries.set(terminalId, entry);
return entry;
}

attach(terminalId: string, container: HTMLDivElement, wsUrl: string) {
const { runtime, transport } = this.getOrCreate(terminalId);
attach(
terminalId: string,
container: HTMLDivElement,
wsUrl: string,
appearance: TerminalAppearance,
) {
const entry = this.getOrCreateEntry(terminalId);

if (!entry.runtime) {
entry.runtime = createRuntime(terminalId, appearance);
} else {
// Runtime already exists (reattach) — apply current appearance so
// the first fit uses up-to-date font metrics.
updateRuntimeAppearance(entry.runtime, appearance);
}

const { runtime, transport } = entry;

attachToContainer(runtime, container, () => {
sendResize(transport, runtime.terminal.cols, runtime.terminal.rows);
Expand All @@ -48,18 +65,35 @@ class TerminalRuntimeRegistryImpl {

detach(terminalId: string) {
const entry = this.entries.get(terminalId);
if (!entry) return;
if (!entry?.runtime) return;

detachFromContainer(entry.runtime);
}

updateAppearance(terminalId: string, appearance: TerminalAppearance) {
const entry = this.entries.get(terminalId);
if (!entry?.runtime) return;

const prevCols = entry.runtime.terminal.cols;
const prevRows = entry.runtime.terminal.rows;

updateRuntimeAppearance(entry.runtime, appearance);

// Font changes can alter the grid size — forward to the PTY so the
// backend shell and TUIs see the correct cols/rows.
const { cols, rows } = entry.runtime.terminal;
if (cols !== prevCols || rows !== prevRows) {
sendResize(entry.transport, cols, rows);
}
}

dispose(terminalId: string) {
const entry = this.entries.get(terminalId);
if (!entry) return;

sendDispose(entry.transport);
disposeTransport(entry.transport);
disposeRuntime(entry.runtime);
if (entry.runtime) disposeRuntime(entry.runtime);

this.entries.delete(terminalId);
}
Expand All @@ -79,10 +113,10 @@ class TerminalRuntimeRegistryImpl {
}

onStateChange(terminalId: string, listener: () => void): () => void {
const { transport } = this.getOrCreate(terminalId);
transport.stateListeners.add(listener);
const entry = this.getOrCreateEntry(terminalId);
entry.transport.stateListeners.add(listener);
return () => {
transport.stateListeners.delete(listener);
entry.transport.stateListeners.delete(listener);
};
}
}
Expand Down
Loading
Loading