Skip to content

Commit 624476f

Browse files
Copilotsawka
andauthored
Add terminal cursor style/blink config with block-level overrides (#2933)
Adds support for configuring terminal cursor style and blink behavior in terminal blocks, with hierarchical resolution (block metadata → connection overrides → global settings). New keys are `term:cursor` (`block`/`underline`/`bar`, default `block`) and `term:cursorblink` (`bool`, default `false`). - **Config surface: new terminal keys** - Added to global settings schema/types: - `pkg/wconfig/settingsconfig.go` - Added to block metadata typing: - `pkg/waveobj/wtypemeta.go` - Added default values: - `pkg/wconfig/defaultconfig/settings.json` - `"term:cursor": "block"` - `"term:cursorblink": false` - **Frontend terminal behavior (xterm options)** - `frontend/app/view/term/termwrap.ts` - Added `setCursorStyle()` with value normalization (`underline`/`bar` else fallback `block`) - Added `setCursorBlink()` - Applies both options on terminal construction via `getOverrideConfigAtom(...)` - `frontend/app/view/term/term-model.ts` - Subscribes to `term:cursor` and `term:cursorblink` override atoms - Propagates live updates to `term.options.cursorStyle` / `term.options.cursorBlink` - Cleans up subscriptions in `dispose()` - **Generated artifacts** - Regenerated config/type outputs after Go type additions: - `schema/settings.json` - `pkg/wconfig/metaconsts.go` - `pkg/waveobj/metaconsts.go` - `frontend/types/gotypes.d.ts` - **Docs** - Updated config reference and default config example: - `docs/docs/config.mdx` ```ts // termwrap.ts this.setCursorStyle(globalStore.get(getOverrideConfigAtom(this.blockId, "term:cursor"))); this.setCursorBlink(globalStore.get(getOverrideConfigAtom(this.blockId, "term:cursorblink")) ?? false); // term-model.ts (live updates) const termCursorAtom = getOverrideConfigAtom(blockId, "term:cursor"); this.termCursorUnsubFn = globalStore.sub(termCursorAtom, () => { this.termRef.current?.setCursorStyle(globalStore.get(termCursorAtom)); }); ``` <!-- START COPILOT CODING AGENT TIPS --> --- 🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> Co-authored-by: sawka <mike@commandline.dev>
1 parent 96ad7a5 commit 624476f

File tree

14 files changed

+163
-9
lines changed

14 files changed

+163
-9
lines changed

.github/workflows/copilot-setup-steps.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ on:
77
pull_request:
88
paths: [.github/workflows/copilot-setup-steps.yml]
99

10-
env:
11-
GO_VERSION: "1.25.6"
12-
NODE_VERSION: 22
10+
# Note: global env vars are NOT used here — they are not reliable in all
11+
# GitHub Actions contexts (e.g. Copilot setup steps). Values are inlined
12+
# directly into each step that needs them.
1313

1414
jobs:
1515
copilot-setup-steps:
@@ -23,12 +23,12 @@ jobs:
2323
# Go + Node versions match your helper
2424
- uses: actions/setup-go@v6
2525
with:
26-
go-version: ${{ env.GO_VERSION }}
26+
go-version: "1.25.6"
2727
cache-dependency-path: go.sum
2828

2929
- uses: actions/setup-node@v6
3030
with:
31-
node-version: ${{ env.NODE_VERSION }}
31+
node-version: 22
3232
cache: npm
3333
cache-dependency-path: package-lock.json
3434

docs/docs/config.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ wsh editconfig
6969
| term:allowbracketedpaste | bool | allow bracketed paste mode in terminal (default false) |
7070
| term:shiftenternewline | bool | when enabled, Shift+Enter sends escape sequence + newline (\u001b\n) instead of carriage return, useful for claude code and similar AI coding tools (default false) |
7171
| term:macoptionismeta | bool | on macOS, treat the Option key as Meta key for terminal keybindings (default false) |
72+
| term:cursor <VersionBadge version="v0.14" /> | string | terminal cursor style. valid values are `block` (default), `underline`, and `bar` |
73+
| term:cursorblink <VersionBadge version="v0.14" /> | bool | when enabled, terminal cursor blinks (default false) |
7274
| term:bellsound <VersionBadge version="v0.14" /> | bool | when enabled, plays the system beep sound when the terminal bell (BEL character) is received (default false) |
7375
| term:bellindicator <VersionBadge version="v0.14" /> | bool | when enabled, shows a visual indicator in the tab when the terminal bell is received (default false) |
7476
| term:durable <VersionBadge version="v0.14" /> | bool | makes remote terminal sessions durable across network disconnects (defaults to false) |
@@ -145,6 +147,8 @@ For reference, this is the current default configuration (v0.14.0):
145147
"telemetry:enabled": true,
146148
"term:bellsound": false,
147149
"term:bellindicator": false,
150+
"term:cursor": "block",
151+
"term:cursorblink": false,
148152
"term:copyonselect": true,
149153
"term:durable": false,
150154
"waveai:showcloudmodes": true,

eslint.config.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,13 @@ export default [
7373

7474
{
7575
rules: {
76-
"@typescript-eslint/no-unused-vars": "warn",
76+
"@typescript-eslint/no-unused-vars": [
77+
"warn",
78+
{
79+
argsIgnorePattern: "^_$",
80+
varsIgnorePattern: "^_$",
81+
},
82+
],
7783
"prefer-const": "warn",
7884
"no-empty": "warn",
7985
},

frontend/app/view/term/term-model.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ export class TermViewModel implements ViewModel {
7777
blockJobStatusVersionTs: number;
7878
blockJobStatusUnsubFn: () => void;
7979
termBPMUnsubFn: () => void;
80+
termCursorUnsubFn: () => void;
81+
termCursorBlinkUnsubFn: () => void;
8082
isCmdController: jotai.Atom<boolean>;
8183
isRestarting: jotai.PrimitiveAtom<boolean>;
8284
termDurableStatus: jotai.Atom<BlockJobStatusData | null>;
@@ -376,6 +378,18 @@ export class TermViewModel implements ViewModel {
376378
this.termRef.current.terminal.options.ignoreBracketedPasteMode = !allowBPM;
377379
}
378380
});
381+
const termCursorAtom = getOverrideConfigAtom(blockId, "term:cursor");
382+
this.termCursorUnsubFn = globalStore.sub(termCursorAtom, () => {
383+
if (this.termRef.current?.terminal) {
384+
this.termRef.current.setCursorStyle(globalStore.get(termCursorAtom));
385+
}
386+
});
387+
const termCursorBlinkAtom = getOverrideConfigAtom(blockId, "term:cursorblink");
388+
this.termCursorBlinkUnsubFn = globalStore.sub(termCursorBlinkAtom, () => {
389+
if (this.termRef.current?.terminal) {
390+
this.termRef.current.setCursorBlink(globalStore.get(termCursorBlinkAtom) ?? false);
391+
}
392+
});
379393
}
380394

381395
getShellIntegrationIconButton(get: jotai.Getter): IconButtonDecl | null {
@@ -521,6 +535,8 @@ export class TermViewModel implements ViewModel {
521535
this.shellProcStatusUnsubFn?.();
522536
this.blockJobStatusUnsubFn?.();
523537
this.termBPMUnsubFn?.();
538+
this.termCursorUnsubFn?.();
539+
this.termCursorBlinkUnsubFn?.();
524540
}
525541

526542
giveFocus(): boolean {
@@ -1017,6 +1033,91 @@ export class TermViewModel implements ViewModel {
10171033
});
10181034
},
10191035
});
1036+
const overrideCursor = blockData?.meta?.["term:cursor"] as string | null | undefined;
1037+
const overrideCursorBlink = blockData?.meta?.["term:cursorblink"] as boolean | null | undefined;
1038+
const isCursorDefault = overrideCursor == null && overrideCursorBlink == null;
1039+
// normalize for comparison: null/undefined/"block" all mean "block"
1040+
const effectiveCursor = overrideCursor === "underline" || overrideCursor === "bar" ? overrideCursor : "block";
1041+
const effectiveCursorBlink = overrideCursorBlink === true;
1042+
const cursorSubMenu: ContextMenuItem[] = [
1043+
{
1044+
label: "Default",
1045+
type: "checkbox",
1046+
checked: isCursorDefault,
1047+
click: () => {
1048+
RpcApi.SetMetaCommand(TabRpcClient, {
1049+
oref: WOS.makeORef("block", this.blockId),
1050+
meta: { "term:cursor": null, "term:cursorblink": null },
1051+
});
1052+
},
1053+
},
1054+
{
1055+
label: "Block",
1056+
type: "checkbox",
1057+
checked: !isCursorDefault && effectiveCursor === "block" && !effectiveCursorBlink,
1058+
click: () => {
1059+
RpcApi.SetMetaCommand(TabRpcClient, {
1060+
oref: WOS.makeORef("block", this.blockId),
1061+
meta: { "term:cursor": "block", "term:cursorblink": false },
1062+
});
1063+
},
1064+
},
1065+
{
1066+
label: "Block (Blinking)",
1067+
type: "checkbox",
1068+
checked: !isCursorDefault && effectiveCursor === "block" && effectiveCursorBlink,
1069+
click: () => {
1070+
RpcApi.SetMetaCommand(TabRpcClient, {
1071+
oref: WOS.makeORef("block", this.blockId),
1072+
meta: { "term:cursor": "block", "term:cursorblink": true },
1073+
});
1074+
},
1075+
},
1076+
{
1077+
label: "Bar",
1078+
type: "checkbox",
1079+
checked: !isCursorDefault && effectiveCursor === "bar" && !effectiveCursorBlink,
1080+
click: () => {
1081+
RpcApi.SetMetaCommand(TabRpcClient, {
1082+
oref: WOS.makeORef("block", this.blockId),
1083+
meta: { "term:cursor": "bar", "term:cursorblink": false },
1084+
});
1085+
},
1086+
},
1087+
{
1088+
label: "Bar (Blinking)",
1089+
type: "checkbox",
1090+
checked: !isCursorDefault && effectiveCursor === "bar" && effectiveCursorBlink,
1091+
click: () => {
1092+
RpcApi.SetMetaCommand(TabRpcClient, {
1093+
oref: WOS.makeORef("block", this.blockId),
1094+
meta: { "term:cursor": "bar", "term:cursorblink": true },
1095+
});
1096+
},
1097+
},
1098+
{
1099+
label: "Underline",
1100+
type: "checkbox",
1101+
checked: !isCursorDefault && effectiveCursor === "underline" && !effectiveCursorBlink,
1102+
click: () => {
1103+
RpcApi.SetMetaCommand(TabRpcClient, {
1104+
oref: WOS.makeORef("block", this.blockId),
1105+
meta: { "term:cursor": "underline", "term:cursorblink": false },
1106+
});
1107+
},
1108+
},
1109+
{
1110+
label: "Underline (Blinking)",
1111+
type: "checkbox",
1112+
checked: !isCursorDefault && effectiveCursor === "underline" && effectiveCursorBlink,
1113+
click: () => {
1114+
RpcApi.SetMetaCommand(TabRpcClient, {
1115+
oref: WOS.makeORef("block", this.blockId),
1116+
meta: { "term:cursor": "underline", "term:cursorblink": true },
1117+
});
1118+
},
1119+
},
1120+
];
10201121
fullMenu.push({
10211122
label: "Themes",
10221123
submenu: submenu,
@@ -1025,6 +1126,10 @@ export class TermViewModel implements ViewModel {
10251126
label: "Font Size",
10261127
submenu: fontSizeSubMenu,
10271128
});
1129+
fullMenu.push({
1130+
label: "Cursor",
1131+
submenu: cursorSubMenu,
1132+
});
10281133
fullMenu.push({
10291134
label: "Transparency",
10301135
submenu: transparencySubMenu,

frontend/app/view/term/term.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import * as jotai from "jotai";
2020
import * as React from "react";
2121
import { TermStickers } from "./termsticker";
2222
import { TermThemeUpdater } from "./termtheme";
23-
import { computeTheme } from "./termutil";
23+
import { computeTheme, normalizeCursorStyle } from "./termutil";
2424
import { TermWrap } from "./termwrap";
2525
import "./xterm.css";
2626

@@ -275,6 +275,8 @@ const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) =>
275275
}
276276
const termAllowBPM = globalStore.get(model.termBPMAtom) ?? true;
277277
const termMacOptionIsMeta = globalStore.get(termMacOptionIsMetaAtom) ?? false;
278+
const termCursorStyle = normalizeCursorStyle(globalStore.get(getOverrideConfigAtom(blockId, "term:cursor")));
279+
const termCursorBlink = globalStore.get(getOverrideConfigAtom(blockId, "term:cursorblink")) ?? false;
278280
const wasFocused = model.termRef.current != null && globalStore.get(model.nodeModel.isFocused);
279281
const termWrap = new TermWrap(
280282
tabModel.tabId,
@@ -292,6 +294,8 @@ const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) =>
292294
allowProposedApi: true, // Required by @xterm/addon-search to enable search functionality and decorations
293295
ignoreBracketedPasteMode: !termAllowBPM,
294296
macOptionIsMeta: termMacOptionIsMeta,
297+
cursorStyle: termCursorStyle,
298+
cursorBlink: termCursorBlink,
295299
},
296300
{
297301
keydownHandler: model.handleTerminalKeydown.bind(model),

frontend/app/view/term/termutil.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import { colord } from "colord";
1010

1111
export type GenClipboardItem = { text?: string; image?: Blob };
1212

13+
export function normalizeCursorStyle(cursorStyle: string): TermTypes.Terminal["options"]["cursorStyle"] {
14+
if (cursorStyle === "underline" || cursorStyle === "bar") {
15+
return cursorStyle;
16+
}
17+
return "block";
18+
}
19+
1320
function applyTransparencyToColor(hexColor: string, transparency: number): string {
1421
const alpha = 1 - transparency; // transparency is already 0-1
1522
return colord(hexColor).alpha(alpha).toHex();
@@ -34,7 +41,7 @@ export function computeTheme(
3441
themeCopy.selectionBackground = applyTransparencyToColor(themeCopy.selectionBackground, termTransparency);
3542
}
3643
}
37-
let bgcolor = themeCopy.background;
44+
const bgcolor = themeCopy.background;
3845
themeCopy.background = "#00000000";
3946
return [themeCopy, bgcolor];
4047
}

frontend/app/view/term/termwrap.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
handleOsc7Command,
3535
type ShellIntegrationStatus,
3636
} from "./osc-handlers";
37-
import { bufferLinesToText, createTempFileFromBlob, extractAllClipboardData } from "./termutil";
37+
import { bufferLinesToText, createTempFileFromBlob, extractAllClipboardData, normalizeCursorStyle } from "./termutil";
3838

3939
const dlog = debug("wave:termwrap");
4040

@@ -211,6 +211,14 @@ export class TermWrap {
211211
return this.blockId;
212212
}
213213

214+
setCursorStyle(cursorStyle: string) {
215+
this.terminal.options.cursorStyle = normalizeCursorStyle(cursorStyle);
216+
}
217+
218+
setCursorBlink(cursorBlink: boolean) {
219+
this.terminal.options.cursorBlink = cursorBlink ?? false;
220+
}
221+
214222
resetCompositionState() {
215223
this.isComposing = false;
216224
this.composingData = "";

frontend/types/gotypes.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,6 +1094,8 @@ declare global {
10941094
"term:allowbracketedpaste"?: boolean;
10951095
"term:shiftenternewline"?: boolean;
10961096
"term:macoptionismeta"?: boolean;
1097+
"term:cursor"?: string;
1098+
"term:cursorblink"?: boolean;
10971099
"term:conndebug"?: string;
10981100
"term:bellsound"?: boolean;
10991101
"term:bellindicator"?: boolean;
@@ -1287,6 +1289,8 @@ declare global {
12871289
"term:allowbracketedpaste"?: boolean;
12881290
"term:shiftenternewline"?: boolean;
12891291
"term:macoptionismeta"?: boolean;
1292+
"term:cursor"?: string;
1293+
"term:cursorblink"?: boolean;
12901294
"term:bellsound"?: boolean;
12911295
"term:bellindicator"?: boolean;
12921296
"term:durable"?: boolean;

pkg/waveobj/metaconsts.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ const (
116116
MetaKey_TermAllowBracketedPaste = "term:allowbracketedpaste"
117117
MetaKey_TermShiftEnterNewline = "term:shiftenternewline"
118118
MetaKey_TermMacOptionIsMeta = "term:macoptionismeta"
119+
MetaKey_TermCursor = "term:cursor"
120+
MetaKey_TermCursorBlink = "term:cursorblink"
119121
MetaKey_TermConnDebug = "term:conndebug"
120122
MetaKey_TermBellSound = "term:bellsound"
121123
MetaKey_TermBellIndicator = "term:bellindicator"

pkg/waveobj/wtypemeta.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ type MetaTSType struct {
120120
TermAllowBracketedPaste *bool `json:"term:allowbracketedpaste,omitempty"`
121121
TermShiftEnterNewline *bool `json:"term:shiftenternewline,omitempty"`
122122
TermMacOptionIsMeta *bool `json:"term:macoptionismeta,omitempty"`
123+
TermCursor string `json:"term:cursor,omitempty"`
124+
TermCursorBlink *bool `json:"term:cursorblink,omitempty"`
123125
TermConnDebug string `json:"term:conndebug,omitempty"` // null, info, debug
124126
TermBellSound *bool `json:"term:bellsound,omitempty"`
125127
TermBellIndicator *bool `json:"term:bellindicator,omitempty"`

0 commit comments

Comments
 (0)