From 08cb089b89299f2bbf4e5f31092396b94af43c7f Mon Sep 17 00:00:00 2001 From: bluzername Date: Sat, 10 Oct 2026 07:51:51 +0300 Subject: [PATCH] feat(terminal): add cursor shape and blink to Settings Issue #620 asks for a saved cursor preference: today the terminal always start at a blinking block, with no setting for it. The issue's own comment give the exact xterm.js hook (term.options.cursorStyle, term.options.cursorBlink, applied live like theme and font already are, no remount needed). This add the smaller first version the issue itself allow: shape (block, bar, underline) and blink (on, off), saved per device. A program in the pane that sets its own cursor style with DECSCUSR can still change it after, same as today, since whether herdr's attach stream pass that sequence through is not established yet (the issue's own "not checked yet" note) and the "allow applications to override" toggle depend on it. sanitizeSettings gets new red-then-green tests for the two fields, and the three translation files get the new strings (checked by the existing i18n test, which fail on a missing or stale entry). --- CHANGELOG.md | 3 +++ src/App.tsx | 2 ++ src/components/PaneTerminal.tsx | 20 ++++++++++++++++++-- src/components/SettingsDialog.tsx | 9 ++++++++- src/lib/i18n.ja.ts | 6 ++++++ src/lib/i18n.ko.ts | 6 ++++++ src/lib/i18n.zh.ts | 6 ++++++ src/lib/settings.test.ts | 13 +++++++++++++ src/lib/settings.ts | 13 +++++++++++++ 9 files changed, 75 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0430bd199a..25bcee387e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] ### Added +- **Settings → Terminal: cursor shape and blink.** Choose block, bar or underline, and turn + blinking on or off, for every terminal pane. A program in the pane that sets its own cursor + style (DECSCUSR) can still change it, as before this setting existed. - **Settings → Agent integrations** lists herdr's integration for each agent it supports and says whether it is installed, older than the running herdr, or missing. Agents found on the PC come first. A missing or outdated one shows its `herdr integration install …` command with a diff --git a/src/App.tsx b/src/App.tsx index 475c9ff03d..c39e930740 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -919,6 +919,8 @@ export function App() { view={view} autoSelected={autoSelected} terminalFontSize={settings.terminalFontSize} + terminalCursorStyle={settings.terminalCursorStyle} + terminalCursorBlink={settings.terminalCursorBlink} terminalWheelSpeed={settings.terminalWheelSpeed} terminalFontFamily={settings.terminalFontFamily} theme={resolvedTheme} diff --git a/src/components/PaneTerminal.tsx b/src/components/PaneTerminal.tsx index 1071c302a6..0d433ba33b 100644 --- a/src/components/PaneTerminal.tsx +++ b/src/components/PaneTerminal.tsx @@ -32,7 +32,7 @@ import { Composer } from "./Composer.tsx"; import { PendingMessages } from "./PendingMessages.tsx"; import type { AgentStatus, ClientRole, ConversationMetadata, InteractivePrompt, ServerMessage } from "../../shared/protocol.ts"; import type { PaneView } from "../lib/actions.ts"; -import { chatLaneLength, useSettings, terminalTheme, type Palette, type ResolvedTheme } from "../lib/settings.ts"; +import { chatLaneLength, useSettings, terminalTheme, type Palette, type ResolvedTheme, type TerminalCursorStyle } from "../lib/settings.ts"; import { loadFontStack, TERMINAL_FONT_STACK, terminalFontStack } from "../lib/fontFamily.ts"; import { useT } from "../lib/i18n.ts"; import { isAppShortcut } from "../lib/shortcuts.ts"; @@ -80,6 +80,10 @@ export interface PaneTerminalProps { autoSelected?: boolean; /** xterm font size (settings) */ terminalFontSize: number; + /** xterm cursorStyle (settings); a program's own DECSCUSR still changes it, as before this setting existed */ + terminalCursorStyle: TerminalCursorStyle; + /** xterm cursorBlink (settings) */ + terminalCursorBlink: boolean; /** mouse reports sent per wheel event (settings): 1 is xterm's own one report */ terminalWheelSpeed: number; /** fonts tried before the built-in stack (settings); "" keeps the built-in one */ @@ -119,6 +123,8 @@ export function PaneTerminal({ view, autoSelected = false, terminalFontSize, + terminalCursorStyle, + terminalCursorBlink, terminalWheelSpeed, terminalFontFamily, theme, @@ -382,7 +388,8 @@ export function PaneTerminal({ const linkPressed = (event: MouseEvent): boolean => event.button === 0 && !term.hasSelection(); const term = new Terminal({ convertEol: false, - cursorBlink: true, + cursorStyle: terminalCursorStyle, + cursorBlink: terminalCursorBlink, // xterm keeps no scrollback: the attach stream lives in the alternate screen and herdr // owns scrollback (wheel and touch go to it). With scrollback on, the fit addon reserves // a scrollbar column - 15px by fallback wherever scrollbars are overlays - and the last @@ -1416,6 +1423,15 @@ export function PaneTerminal({ if (term) term.options.theme = terminalTheme(theme, palette); }, [theme, palette]); + // the cursor's shape and blink follow too; a program's own DECSCUSR still overrides them until + // the next change here, as it already could when they were fixed + useEffect(() => { + const term = termRef.current; + if (!term) return; + term.options.cursorStyle = terminalCursorStyle; + term.options.cursorBlink = terminalCursorBlink; + }, [terminalCursorStyle, terminalCursorBlink]); + // the font follows too, and a font change moves the grid. xterm measures the cell (and the DOM // renderer its glyph widths) when fontFamily or fontSize changes, and setting the same value // again changes nothing: so a chosen family is set only once its faces loaded, or the cell would diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index ec6e27072d..d9de24a9e9 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -7,7 +7,7 @@ import type { AppActions } from "../lib/actions.ts"; import { useInstallPrompt } from "../lib/install.ts"; import { SHORTCUTS, formatKeys, isMacPlatform, shortcutDisplayKeys, shortcutKeys, shortcutConflict } from "../lib/shortcuts.ts"; import { isReservedShortcutKey } from "../lib/shortcutBindings.ts"; -import { CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_WIDTHS, chatFontSize, DEFAULT_SETTINGS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, TERMINAL_FONT_MAX, TERMINAL_FONT_MIN, TERMINAL_WHEEL_SPEED_MAX, TERMINAL_WHEEL_SPEED_MIN, DICTATION_LANGUAGES, VOICE_BUTTONS, useSettings, forgetPaneViews, type DictationLanguage, type VoiceButton } from "../lib/settings.ts"; +import { CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_WIDTHS, chatFontSize, DEFAULT_SETTINGS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, TERMINAL_FONT_MAX, TERMINAL_FONT_MIN, TERMINAL_WHEEL_SPEED_MAX, TERMINAL_WHEEL_SPEED_MIN, TERMINAL_CURSOR_STYLES, DICTATION_LANGUAGES, VOICE_BUTTONS, useSettings, forgetPaneViews, type DictationLanguage, type TerminalCursorStyle, type VoiceButton } from "../lib/settings.ts"; import { LANGUAGE_NAMES, LANGUAGE_SETTINGS, useLocale, useT } from "../lib/i18n.ts"; import { useFocusTrap } from "../lib/useFocusTrap.ts"; import { KeyBarSettings } from "./KeyBarSettings.tsx"; @@ -258,6 +258,13 @@ function TerminalPage({ keyBarButtonRef, onEditKeyBar }: { keyBarButtonRef: RefO update({ terminalFontFamily })} /> + + update({ terminalCursorStyle })} + options={TERMINAL_CURSOR_STYLES.map((style) => ({ value: style, label: t(style === "block" ? "Block" : style === "bar" ? "Bar" : "Underline") }))} /> + + + update({ terminalCursorBlink })} /> + update({ terminalWheelSpeed })} /> diff --git a/src/lib/i18n.ja.ts b/src/lib/i18n.ja.ts index d1f61e1ea1..4051ce0500 100644 --- a/src/lib/i18n.ja.ts +++ b/src/lib/i18n.ja.ts @@ -53,6 +53,12 @@ export const JA: Record = { "Applied to every terminal pane": "すべてのターミナルペインに適用されます", "Decrease terminal font size": "ターミナルの文字を小さく", "Increase terminal font size": "ターミナルの文字を大きく", + "Cursor shape": "カーソルの形", + "A program in the pane can still change this with its own cursor control sequence": "ペイン内のプログラムは、独自のカーソル制御シーケンスでこの設定を変更できます", + "Block": "ブロック", + "Bar": "バー", + "Underline": "アンダーライン", + "Blinking cursor": "カーソルの点滅", "Wheel scroll speed": "ホイールのスクロール速度", "How far one turn of the wheel scrolls the terminal": "ホイール1回でターミナルが動く量", "Slower wheel scrolling": "ホイールスクロールを遅く", diff --git a/src/lib/i18n.ko.ts b/src/lib/i18n.ko.ts index 2139fd56e9..fa082bd6db 100644 --- a/src/lib/i18n.ko.ts +++ b/src/lib/i18n.ko.ts @@ -51,6 +51,12 @@ export const KO: Record = { "Applied to every terminal pane": "모든 터미널 패널에 적용됩니다", "Decrease terminal font size": "터미널 글자 작게", "Increase terminal font size": "터미널 글자 크게", + "Cursor shape": "커서 모양", + "A program in the pane can still change this with its own cursor control sequence": "패널 안의 프로그램이 자체 커서 제어 시퀀스로 이 설정을 바꿀 수 있습니다", + "Block": "블록", + "Bar": "막대", + "Underline": "밑줄", + "Blinking cursor": "커서 깜빡임", "Wheel scroll speed": "휠 스크롤 속도", "How far one turn of the wheel scrolls the terminal": "휠을 한 번 굴릴 때 터미널이 움직이는 정도", "Slower wheel scrolling": "휠 스크롤 느리게", diff --git a/src/lib/i18n.zh.ts b/src/lib/i18n.zh.ts index a54839539f..ad11630667 100644 --- a/src/lib/i18n.zh.ts +++ b/src/lib/i18n.zh.ts @@ -55,6 +55,12 @@ export const ZH: Record = { "Applied to every terminal pane": "应用于所有终端窗格", "Decrease terminal font size": "减小终端字号", "Increase terminal font size": "增大终端字号", + "Cursor shape": "光标形状", + "A program in the pane can still change this with its own cursor control sequence": "窗格内的程序仍可以用自己的光标控制序列改变这个设置", + "Block": "方块", + "Bar": "竖线", + "Underline": "下划线", + "Blinking cursor": "光标闪烁", "Wheel scroll speed": "滚轮滚动速度", "How far one turn of the wheel scrolls the terminal": "滚轮滚动一次时终端移动的距离", "Slower wheel scrolling": "滚轮滚动更慢", diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 8dcc5d3335..0cf7bea655 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -27,6 +27,19 @@ it("keeps the screen wake lock off until this device explicitly enables it", () expect(sanitizeSettings({ terminalWheelSpeed: "3" }).terminalWheelSpeed).toBe(1); }); +it("keeps the terminal cursor at a block that blinks unless a saved shape or blink says otherwise", () => { + expect(DEFAULT_SETTINGS.terminalCursorStyle).toBe("block"); + expect(DEFAULT_SETTINGS.terminalCursorBlink).toBe(true); + expect(sanitizeSettings({}).terminalCursorStyle).toBe("block"); + expect(sanitizeSettings({}).terminalCursorBlink).toBe(true); + expect(sanitizeSettings({ terminalCursorStyle: "bar" }).terminalCursorStyle).toBe("bar"); + expect(sanitizeSettings({ terminalCursorStyle: "underline" }).terminalCursorStyle).toBe("underline"); + // a value no shape answers for falls back, never breaking the UI on a stale or hand-edited record + expect(sanitizeSettings({ terminalCursorStyle: "pipe" }).terminalCursorStyle).toBe("block"); + expect(sanitizeSettings({ terminalCursorBlink: false }).terminalCursorBlink).toBe(false); + expect(sanitizeSettings({ terminalCursorBlink: "false" }).terminalCursorBlink).toBe(true); +}); + it("keeps sidebar rows on two lines unless one line was chosen", () => { expect(sanitizeSettings({}).sidebarRows).toBe("two"); expect(sanitizeSettings({ sidebarRows: "one" }).sidebarRows).toBe("one"); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 7b2d95c992..30d282c974 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -47,6 +47,9 @@ export const DICTATION_LANGUAGES = [ export type DictationLanguage = "auto" | (typeof DICTATION_LANGUAGES)[number]; /** the lens a pane opens in until it is switched there: auto is chat for an agent on a touch screen, else terminal */ export type DefaultView = "auto" | "chat" | "terminal"; +/** the terminal cursor's shape (xterm's cursorStyle); a program can still change it with DECSCUSR */ +export type TerminalCursorStyle = "block" | "bar" | "underline"; +export const TERMINAL_CURSOR_STYLES: readonly TerminalCursorStyle[] = ["block", "bar", "underline"]; import { sanitizeShortcutOverrides, type ShortcutOverrides } from "./shortcutBindings.ts"; @@ -69,6 +72,10 @@ export interface Settings { palette: Palette; /** xterm font size in px */ terminalFontSize: number; + /** xterm cursorStyle; a program's own DECSCUSR still changes it, as before this setting existed */ + terminalCursorStyle: TerminalCursorStyle; + /** xterm cursorBlink */ + terminalCursorBlink: boolean; /** mouse reports sent to herdr per wheel event in the terminal: 1 is what xterm sends by itself */ terminalWheelSpeed: number; /** fonts tried before the built-in terminal stack, as a CSS font-family list; "" keeps the built-in one */ @@ -145,6 +152,8 @@ export const DEFAULT_SETTINGS: Settings = { quietOpenedDone: false, palette: "amber", terminalFontSize: 13, + terminalCursorStyle: "block", + terminalCursorBlink: true, terminalWheelSpeed: 1, terminalFontFamily: "", paneClipboard: true, @@ -296,6 +305,10 @@ export function sanitizeSettings(raw: unknown): Settings { quietOpenedDone: record["quietOpenedDone"] === true, palette: record["palette"] === "amber" || record["palette"] === "report" || record["palette"] === "charcoal" || record["palette"] === "catppuccin" || record["palette"] === "lilac" ? record["palette"] : DEFAULT_SETTINGS.palette, terminalFontSize: typeof font === "number" && Number.isFinite(font) ? clampFont(font) : DEFAULT_SETTINGS.terminalFontSize, + terminalCursorStyle: TERMINAL_CURSOR_STYLES.includes(record["terminalCursorStyle"] as TerminalCursorStyle) + ? record["terminalCursorStyle"] as TerminalCursorStyle + : DEFAULT_SETTINGS.terminalCursorStyle, + terminalCursorBlink: typeof record["terminalCursorBlink"] === "boolean" ? record["terminalCursorBlink"] : DEFAULT_SETTINGS.terminalCursorBlink, terminalWheelSpeed: typeof record["terminalWheelSpeed"] === "number" && Number.isFinite(record["terminalWheelSpeed"]) ? Math.min(TERMINAL_WHEEL_SPEED_MAX, Math.max(TERMINAL_WHEEL_SPEED_MIN, Math.round(record["terminalWheelSpeed"]))) : DEFAULT_SETTINGS.terminalWheelSpeed,