Skip to content

Commit 4fe103b

Browse files
joshkclaudenshoes
authored
Let Option key compose characters in the device console (#3028)
Fixes #1942. On a Mac, Option is the third-level shift on most non-US layouts: German, Nordic and French users type `[ ] { } | @ \ ~` with it. Both terminals set `macOptionIsMeta: true`, which tells xterm to claim Option before the browser can compose anything. With that flag on, xterm skips the composition path and resolves the keypress through a hardcoded US-layout keycode table instead. German ⌥5 has keyCode 53, the table says `5`, and the device receives `ESC 5` rather than `[`. That flag also bypasses `_compositionHelper`, so dead keys break with it. The flag is now off by default, which is also what VS Code's integrated terminal does. Because that costs Mac users `M-b` / `M-f` / `M-d` in the IEx line editor, there's a `⌥ as Meta` checkbox above the terminal to turn it back on, in the same spirit as iTerm2's "Left Option key" setting. The preference is stored per browser rather than per account: which modifier Option is depends on the keyboard you're sitting at, not on who you're signed in as, so a German Mac at work and a US keyboard at home need different answers. xterm reads `macOptionIsMeta` on every keystroke, so the toggle applies without reconnecting the channel. The checkbox renders hidden and is only revealed on a Mac, since the option does nothing anywhere else. Both controls now sit in one positioned row, so the fullscreen JS toggles the wrapper instead of maintaining a second set of shifting coordinates. `defaultTermOptions` was copy-pasted between the console and local shell hooks, and the bug existed in both. Shared setup moves to `assets/js/helpers/terminal.js` so the next keyboard change is made once. ### Not fixed here Non-ASCII output is still corrupted on the way back from the device, which is a separate bug in `extty`: `redraw_prompt` decodes a UTF-8 binary byte-wise, so `∞` returns as `â` plus two C1 controls. Fix is in jjcarstens/extty#16, and reaches devices once extty releases and `nerves_hub_link` picks it up. I verified both halves together against a device running that branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Nate Shoemaker <nshoes@users.noreply.github.com>
1 parent d744928 commit 4fe103b

5 files changed

Lines changed: 167 additions & 141 deletions

File tree

assets/js/helpers/terminal.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { Terminal } from "@xterm/xterm"
2+
import { WebglAddon } from "@xterm/addon-webgl"
3+
import { WebLinksAddon } from "@xterm/addon-web-links"
4+
import { FitAddon } from "@xterm/addon-fit"
5+
6+
// `macOptionIsMeta` is only honoured on a Mac, so mirror xterm's own platform
7+
// check rather than showing a toggle that does nothing everywhere else.
8+
const isMac = () =>
9+
["Macintosh", "MacIntel", "MacPPC", "Mac68K"].includes(navigator.platform)
10+
11+
// Stored per browser rather than per account: which modifier Option is depends
12+
// on the keyboard you're sitting at, not on who you're signed in as.
13+
const OPTION_AS_META_KEY = "terminalOptionAsMeta"
14+
15+
const optionAsMeta = () => localStorage.getItem(OPTION_AS_META_KEY) === "true"
16+
17+
const defaultTermOptions = {
18+
cursorBlink: true,
19+
cursorStyle: "bar",
20+
// Option is the third-level shift on most non-US Mac layouts, so leaving it
21+
// alone is what keeps `[ ] { } | @ \ ~` typeable. Claiming it as Meta routes
22+
// the keypress through xterm's US-only keycode table instead, which turns
23+
// German Opt+5 into `ESC 5` rather than `[`. Users who'd rather have `M-b`
24+
// and `M-f` opt back in with the toggle above the terminal.
25+
macOptionIsMeta: false,
26+
fontFamily: "Ubuntu Mono, courier-new, courier, monospace",
27+
fontSize: 14,
28+
theme: {
29+
foreground: "#FFFAF4",
30+
background: "#0E1019",
31+
selectionBackground: "#48B9C7",
32+
black: "#232323",
33+
brightBlack: "#444444",
34+
red: "#D82036",
35+
brightRed: "#FF2740",
36+
green: "#8CE10B",
37+
brightGreen: "#ABE15B",
38+
yellow: "#FFB900",
39+
brightYellow: "#FFD242",
40+
blue: "#007AD8",
41+
brightBlue: "#0092FF",
42+
magenta: "#6D43A6",
43+
brightMagenta: "#9A5FEB",
44+
cyan: "#00D8EB",
45+
brightCyan: "#67FFF0",
46+
white: "#FFFFFF",
47+
brightWhite: "#FFFFFF"
48+
}
49+
}
50+
51+
export const debounce = (func, time = 100) => {
52+
let timer
53+
return function(event) {
54+
if (timer) clearTimeout(timer)
55+
timer = setTimeout(func, time, event)
56+
}
57+
}
58+
59+
// Builds the terminal shared by the device console and the local shell, opens
60+
// it in `elementId` and sizes it to the container.
61+
export const createTerminal = elementId => {
62+
// use previous scrollback if available, default to 1000 lines
63+
const storedScrollback = parseInt(localStorage.getItem("scrollback"))
64+
const scrollback = Number.isSafeInteger(storedScrollback)
65+
? storedScrollback
66+
: 1000
67+
68+
const term = new Terminal({
69+
...defaultTermOptions,
70+
scrollback,
71+
macOptionIsMeta: optionAsMeta()
72+
})
73+
74+
const fitAddon = new FitAddon()
75+
term.loadAddon(fitAddon)
76+
term.loadAddon(new WebglAddon())
77+
term.loadAddon(new WebLinksAddon())
78+
79+
term.open(document.getElementById(elementId))
80+
81+
fitAddon.fit()
82+
term.focus()
83+
84+
return { term, fitAddon }
85+
}
86+
87+
// The toggle renders hidden and is only revealed on a Mac. xterm reads
88+
// `macOptionIsMeta` on every keystroke, so flipping it applies straight away
89+
// without reconnecting the channel. Returns a teardown function.
90+
export const setupOptionAsMetaToggle = term => {
91+
const toggle = document.getElementById("option-as-meta")
92+
93+
if (!toggle || !isMac()) return () => {}
94+
95+
toggle.checked = optionAsMeta()
96+
toggle.closest("label").classList.replace("hidden", "flex")
97+
98+
const onChange = () => {
99+
localStorage.setItem(OPTION_AS_META_KEY, toggle.checked)
100+
term.options.macOptionIsMeta = toggle.checked
101+
term.focus()
102+
}
103+
104+
toggle.addEventListener("change", onChange)
105+
106+
return () => toggle.removeEventListener("change", onChange)
107+
}

assets/js/hooks/console.js

Lines changed: 8 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,10 @@
11
import { Socket } from "phoenix"
2-
import { Terminal } from "@xterm/xterm"
3-
import { WebglAddon } from "@xterm/addon-webgl"
4-
import { WebLinksAddon } from "@xterm/addon-web-links"
5-
import { FitAddon } from "@xterm/addon-fit"
62
import semver from "semver"
7-
8-
const defaultTermOptions = {
9-
cursorBlink: true,
10-
cursorStyle: "bar",
11-
macOptionIsMeta: true,
12-
fontFamily: "Ubuntu Mono, courier-new, courier, monospace",
13-
fontSize: 14,
14-
theme: {
15-
foreground: "#FFFAF4",
16-
background: "#0E1019",
17-
selectionBackground: "#48B9C7",
18-
black: "#232323",
19-
brightBlack: "#444444",
20-
red: "#D82036",
21-
brightRed: "#FF2740",
22-
green: "#8CE10B",
23-
brightGreen: "#ABE15B",
24-
yellow: "#FFB900",
25-
brightYellow: "#FFD242",
26-
blue: "#007AD8",
27-
brightBlue: "#0092FF",
28-
magenta: "#6D43A6",
29-
brightMagenta: "#9A5FEB",
30-
cyan: "#00D8EB",
31-
brightCyan: "#67FFF0",
32-
white: "#FFFFFF",
33-
brightWhite: "#FFFFFF"
34-
}
35-
}
36-
37-
const debounce = (func, time = 100) => {
38-
let timer
39-
return function(event) {
40-
if (timer) clearTimeout(timer)
41-
timer = setTimeout(func, time, event)
42-
}
43-
}
3+
import {
4+
createTerminal,
5+
debounce,
6+
setupOptionAsMetaToggle
7+
} from "../helpers/terminal.js"
448

459
const resizeContent = (term, channel) => {
4610
channel.push("window_size", { height: term.rows, width: term.cols })
@@ -60,23 +24,9 @@ export default {
6024
{},
6125
)
6226

63-
// init terminal, load addons
64-
// use previous scrollback if available, default to 1000 lines
65-
const storedScrollback = parseInt(localStorage.getItem("scrollback"))
66-
const scrollback = Number.isSafeInteger(storedScrollback)
67-
? storedScrollback
68-
: 1000
69-
const term = new Terminal({ ...defaultTermOptions, scrollback })
70-
71-
const fitAddon = new FitAddon()
72-
term.loadAddon(fitAddon)
73-
term.loadAddon(new WebglAddon())
74-
term.loadAddon(new WebLinksAddon())
75-
76-
term.open(document.getElementById("console"))
27+
const { term, fitAddon } = createTerminal("console")
7728

78-
fitAddon.fit()
79-
term.focus()
29+
this.teardownOptionAsMetaToggle = setupOptionAsMetaToggle(term)
8030

8131
this.resizeEventListener = () => {
8232
fitAddon.fit()
@@ -240,6 +190,7 @@ export default {
240190
},
241191
destroyed() {
242192
window.removeEventListener("resize", this.resizeEventListener)
193+
this.teardownOptionAsMetaToggle()
243194
this.socket.disconnect()
244195
}
245196
}

assets/js/hooks/localShell.js

Lines changed: 8 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,9 @@
11
import { Socket } from "phoenix"
2-
import { Terminal } from "@xterm/xterm"
3-
import { WebglAddon } from "@xterm/addon-webgl"
4-
import { WebLinksAddon } from "@xterm/addon-web-links"
5-
import { FitAddon } from "@xterm/addon-fit"
6-
7-
const defaultTermOptions = {
8-
cursorBlink: true,
9-
cursorStyle: "bar",
10-
macOptionIsMeta: true,
11-
fontFamily: "Ubuntu Mono, courier-new, courier, monospace",
12-
fontSize: 14,
13-
theme: {
14-
foreground: "#FFFAF4",
15-
background: "#0E1019",
16-
selectionBackground: "#48B9C7",
17-
black: "#232323",
18-
brightBlack: "#444444",
19-
red: "#D82036",
20-
brightRed: "#FF2740",
21-
green: "#8CE10B",
22-
brightGreen: "#ABE15B",
23-
yellow: "#FFB900",
24-
brightYellow: "#FFD242",
25-
blue: "#007AD8",
26-
brightBlue: "#0092FF",
27-
magenta: "#6D43A6",
28-
brightMagenta: "#9A5FEB",
29-
cyan: "#00D8EB",
30-
brightCyan: "#67FFF0",
31-
white: "#FFFFFF",
32-
brightWhite: "#FFFFFF"
33-
}
34-
}
35-
36-
const debounce = (func, time = 100) => {
37-
let timer
38-
return function(event) {
39-
if (timer) clearTimeout(timer)
40-
timer = setTimeout(func, time, event)
41-
}
42-
}
2+
import {
3+
createTerminal,
4+
debounce,
5+
setupOptionAsMetaToggle
6+
} from "../helpers/terminal.js"
437

448
const resizeContent = (term, channel) => {
459
channel.push("window_size", { rows: term.rows, cols: term.cols })
@@ -59,24 +23,9 @@ export default {
5923
{},
6024
)
6125

62-
// init terminal, load addons
63-
// use previous scrollback if available, default to 1000 lines
64-
const storedScrollback = parseInt(localStorage.getItem("scrollback"))
65-
const scrollback = Number.isSafeInteger(storedScrollback)
66-
? storedScrollback
67-
: 1000
68-
69-
const term = new Terminal({ ...defaultTermOptions, scrollback })
70-
71-
const fitAddon = new FitAddon()
72-
term.loadAddon(fitAddon)
73-
term.loadAddon(new WebglAddon())
74-
term.loadAddon(new WebLinksAddon())
75-
76-
term.open(document.getElementById("local-shell"))
26+
const { term, fitAddon } = createTerminal("local-shell")
7727

78-
fitAddon.fit()
79-
term.focus()
28+
this.teardownOptionAsMetaToggle = setupOptionAsMetaToggle(term)
8029

8130
this.resizeEventListener = () => {
8231
fitAddon.fit()
@@ -129,6 +78,7 @@ export default {
12978
},
13079
destroyed() {
13180
window.removeEventListener("resize", this.resizeEventListener)
81+
this.teardownOptionAsMetaToggle()
13282
this.socket.disconnect()
13383
}
13484
}

lib/nerves_hub_web/components/device_page/console_tab.ex

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,13 @@ defmodule NervesHubWeb.Components.DevicePage.ConsoleTab do
8686
# disable h-full
8787
|> JS.toggle_class("h-full", to: "#console")
8888

89-
# Fullscreen/Close button
89+
# Terminal controls (the option-as-meta toggle and the fullscreen/close button)
9090
# disable right-16
91-
|> JS.toggle_class("right-16", to: "#fullscreen")
92-
|> JS.toggle_class("right-4", to: "#fullscreen")
91+
|> JS.toggle_class("right-16", to: "#terminal-controls")
92+
|> JS.toggle_class("right-4", to: "#terminal-controls")
9393
# disable top-8
94-
|> JS.toggle_class("top-8", to: "#fullscreen")
95-
|> JS.toggle_class("top-4", to: "#fullscreen")
94+
|> JS.toggle_class("top-8", to: "#terminal-controls")
95+
|> JS.toggle_class("top-4", to: "#terminal-controls")
9696
|> JS.toggle_class("hidden", to: "#fullscreen svg")
9797
end
9898

@@ -142,14 +142,23 @@ defmodule NervesHubWeb.Components.DevicePage.ConsoleTab do
142142
</h1>
143143
</div>
144144
</div>
145-
<button id="fullscreen" class="absolute top-8 right-16 z-20 cursor-pointer rounded-full bg-neutral-900 hover:scale-[1.1]" phx-click={toggle_fullscreen()} title="Toggle fullscreen">
146-
<svg class="stroke-neutral-50" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
147-
<path d="M15 19H19M19 19V15M19 19L15 15M9 5H5M5 5V9M5 5L9 9M15 5H19M19 5V9M19 5L15 9M9 19H5M5 19V15M5 19L9 15" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" />
148-
</svg>
149-
<svg class="hidden stroke-neutral-50" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
150-
<path d="M12 12L7 7M12 12L17 17M12 12L17 7M12 12L7 17" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" />
151-
</svg>
152-
</button>
145+
<div id="terminal-controls" phx-update="ignore" class="absolute top-8 right-16 z-20 flex items-center gap-3">
146+
<label
147+
class="hidden cursor-pointer items-center gap-2 rounded-full bg-neutral-900 px-3 py-1.5 text-xs text-neutral-50"
148+
title="Send ⌥ (Option) to the device as Meta, for shortcuts such as M-b and M-f. Leave this off to type characters like [ ] { } | on non-US keyboard layouts."
149+
>
150+
<input type="checkbox" id="option-as-meta" class="border-base-700 checked:bg-primary text-base-400 size-3.5 rounded focus:ring-0" />
151+
<span>⌥ as Meta</span>
152+
</label>
153+
<button id="fullscreen" class="cursor-pointer rounded-full bg-neutral-900 hover:scale-[1.1]" phx-click={toggle_fullscreen()} title="Toggle fullscreen">
154+
<svg class="stroke-neutral-50" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
155+
<path d="M15 19H19M19 19V15M19 19L15 15M9 5H5M5 5V9M5 5L9 9M15 5H19M19 5V9M19 5L15 9M9 19H5M5 19V15M5 19L9 15" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" />
156+
</svg>
157+
<svg class="hidden stroke-neutral-50" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
158+
<path d="M12 12L7 7M12 12L17 17M12 12L17 7M12 12L7 17" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" />
159+
</svg>
160+
</button>
161+
</div>
153162
</div>
154163
<div :if={authorized?(:"device:console", @current_scope) && !online?} class="text-medium flex grow items-center justify-center gap-6 p-6 font-mono">
155164
The device console isn't currently available.

lib/nerves_hub_web/components/device_page/local_shell_tab.ex

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,13 @@ defmodule NervesHubWeb.Components.DevicePage.LocalShellTab do
6969
# disable h-full
7070
|> JS.toggle_class("h-full", to: "#local-shell")
7171

72-
# Fullscreen/Close button
72+
# Terminal controls (the option-as-meta toggle and the fullscreen/close button)
7373
# disable right-16
74-
|> JS.toggle_class("right-16", to: "#fullscreen")
75-
|> JS.toggle_class("right-4", to: "#fullscreen")
74+
|> JS.toggle_class("right-16", to: "#terminal-controls")
75+
|> JS.toggle_class("right-4", to: "#terminal-controls")
7676
# disable top-8
77-
|> JS.toggle_class("top-8", to: "#fullscreen")
78-
|> JS.toggle_class("top-4", to: "#fullscreen")
77+
|> JS.toggle_class("top-8", to: "#terminal-controls")
78+
|> JS.toggle_class("top-4", to: "#terminal-controls")
7979
|> JS.toggle_class("hidden", to: "#fullscreen svg")
8080
end
8181

@@ -130,14 +130,23 @@ defmodule NervesHubWeb.Components.DevicePage.LocalShellTab do
130130
</h1>
131131
</div>
132132
</div>
133-
<button id="fullscreen" class="absolute top-8 right-16 z-20 cursor-pointer rounded-full bg-neutral-900 hover:scale-[1.1]" phx-click={toggle_shell_fullscreen()} title="Toggle fullscreen">
134-
<svg class="stroke-neutral-50" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
135-
<path d="M15 19H19M19 19V15M19 19L15 15M9 5H5M5 5V9M5 5L9 9M15 5H19M19 5V9M19 5L15 9M9 19H5M5 19V15M5 19L9 15" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" />
136-
</svg>
137-
<svg class="hidden stroke-neutral-50" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
138-
<path d="M12 12L7 7M12 12L17 17M12 12L17 7M12 12L7 17" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" />
139-
</svg>
140-
</button>
133+
<div id="terminal-controls" phx-update="ignore" class="absolute top-8 right-16 z-20 flex items-center gap-3">
134+
<label
135+
class="hidden cursor-pointer items-center gap-2 rounded-full bg-neutral-900 px-3 py-1.5 text-xs text-neutral-50"
136+
title="Send ⌥ (Option) to the device as Meta, for shortcuts such as M-b and M-f. Leave this off to type characters like [ ] { } | on non-US keyboard layouts."
137+
>
138+
<input type="checkbox" id="option-as-meta" class="border-base-700 checked:bg-primary text-base-400 size-3.5 rounded focus:ring-0" />
139+
<span>⌥ as Meta</span>
140+
</label>
141+
<button id="fullscreen" class="cursor-pointer rounded-full bg-neutral-900 hover:scale-[1.1]" phx-click={toggle_shell_fullscreen()} title="Toggle fullscreen">
142+
<svg class="stroke-neutral-50" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
143+
<path d="M15 19H19M19 19V15M19 19L15 15M9 5H5M5 5V9M5 5L9 9M15 5H19M19 5V9M19 5L15 9M9 19H5M5 19V15M5 19L9 15" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" />
144+
</svg>
145+
<svg class="hidden stroke-neutral-50" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
146+
<path d="M12 12L7 7M12 12L17 17M12 12L17 7M12 12L7 17" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" />
147+
</svg>
148+
</button>
149+
</div>
141150
</div>
142151
<div :if={not @shell_enabled?} class="text-medium flex grow flex-col items-center justify-center gap-6 p-6 font-mono">
143152
<p>The device local shell isn't currently enabled.</p>

0 commit comments

Comments
 (0)