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,