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,