diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3cfaf11..96be6d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,20 @@ All notable changes to bbcom are documented here. The format is based on
## [Unreleased]
+## [1.2.1] - 2026-09-12
+
+### Added
+
+- Serial sessions support updating connection settings while connected.
+
+### Fixed
+
+- Bundle JetBrains Mono Regular and Bold for consistent offline terminal fonts.
+- Wait for terminal fonts before measuring character cells and replaying output;
+ use system monospace on font load failure and skip setup after unmounting.
+- Adjust Shell font sizing and line spacing and disable ligatures and kerning
+ to keep serial output aligned.
+
## [1.2.0] - 2026-09-08
### Changed
diff --git a/Cargo.lock b/Cargo.lock
index 0a28013..562baa3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -280,7 +280,7 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bbcom"
-version = "1.2.0"
+version = "1.2.1"
dependencies = [
"base64 0.22.1",
"bbcom-contracts",
diff --git a/index.html b/index.html
index f818df5..423c563 100644
--- a/index.html
+++ b/index.html
@@ -13,7 +13,13 @@
document, fall back to the read-only v1 app-settings blob. main.ts mounts
into #app and replaces the skeleton, so there is no layout shift.
-->
-
+
+
+
diff --git a/src/features/sessions/ui/SessionToolbar.vue b/src/features/sessions/ui/SessionToolbar.vue
index 0876fae..04e9f4c 100644
--- a/src/features/sessions/ui/SessionToolbar.vue
+++ b/src/features/sessions/ui/SessionToolbar.vue
@@ -13,12 +13,15 @@
:capture-paused="captureIsPaused"
:can-clear="captureCanClear"
:sending-break="sendingBreak"
+ :reconfiguring="reconfiguring"
+ :settings-disabled="settingsDisabled"
@connect="$emit('connect')"
@disconnect="$emit('disconnect')"
@rebind="$emit('rebind')"
@clear="$emit('clear')"
@toggle-pause="$emit('toggle-pause')"
@send-break="$emit('send-break')"
+ @settings="$emit('settings')"
/>
@@ -119,6 +122,8 @@ const props = withDefaults(
viewMode: SessionViewMode;
/** True while MCUmgr owns the port; connection controls must stay locked. */
connectionLocked?: boolean;
+ reconfiguring?: boolean;
+ settingsDisabled?: boolean;
}>(),
{ captureHasData: undefined, capturePaused: undefined },
);
@@ -136,6 +141,7 @@ const emit = defineEmits<{
export: [];
'show-conflicting-session': [sessionId: string];
rebind: [];
+ settings: [];
}>();
const appStore = useAppStore();
diff --git a/src/features/sessions/ui/SessionView.vue b/src/features/sessions/ui/SessionView.vue
index 80dd4f4..1c97288 100644
--- a/src/features/sessions/ui/SessionView.vue
+++ b/src/features/sessions/ui/SessionView.vue
@@ -25,6 +25,8 @@
:connection-conflict="runtime.connectionFailure.value?.conflict"
:needs-rebind="Boolean(rebindMetadata)"
:sending-break="runtime.sendingBreak.value"
+ :reconfiguring="runtime.reconfiguring.value"
+ :settings-disabled="!mutationPolicy.userMutationsAllowed.value"
:is-exporting="isExporting"
:view-mode="viewMode"
:connection-locked="runtime.mcumgr.busy.value"
@@ -40,6 +42,15 @@
@export="openExportDialog"
@show-conflicting-session="showConflictingSession"
@rebind="rebindDialogVisible = true"
+ @settings="serialSettingsVisible = true"
+ />
+
catalog.workspaceRebindBySessionId.value[props.session.id] ?? null,
);
+async function saveSerialSettings(config: PortConfig): Promise {
+ if (await runtime.updatePortConfig(config)) serialSettingsVisible.value = false;
+}
+
async function connect() {
await runtime.connect();
}
diff --git a/src/features/terminal/__tests__/serial-shell-panel.test.ts b/src/features/terminal/__tests__/serial-shell-panel.test.ts
index a6d0aeb..bc91773 100644
--- a/src/features/terminal/__tests__/serial-shell-panel.test.ts
+++ b/src/features/terminal/__tests__/serial-shell-panel.test.ts
@@ -2,8 +2,9 @@
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
-import { shallowMount } from '@vue/test-utils';
+import { flushPromises, shallowMount } from '@vue/test-utils';
import type { SerialShellConfig } from '@/types';
+import { logger } from '@/lib/logger';
const xtermMocks = vi.hoisted(() => {
const state: {
@@ -79,9 +80,32 @@ const config: SerialShellConfig = {
backspace: 'bs',
};
+const loadFont = vi.fn();
+const originalFonts = Object.getOwnPropertyDescriptor(document, 'fonts');
+
+function mountShell() {
+ const shell = {
+ replay: vi.fn(() => ''),
+ onOutput: vi.fn(() => vi.fn()),
+ onReset: vi.fn(() => vi.fn()),
+ handleTerminalData: vi.fn(),
+ clear: vi.fn(),
+ };
+ const wrapper = shallowMount(SerialShellPanel, {
+ props: { sessionId: 'session-shell', config, isConnected: true, shell },
+ });
+ return { wrapper, shell };
+}
+
beforeEach(() => {
xtermMocks.state.constructorOptions = null;
xtermMocks.state.customKeyHandler = null;
+ loadFont.mockReset().mockResolvedValue([]);
+ Object.defineProperty(document, 'fonts', {
+ configurable: true,
+ value: { load: loadFont },
+ });
+ document.documentElement.style.setProperty('--font-mono', '"JetBrains Mono", monospace');
vi.stubGlobal(
'ResizeObserver',
class {
@@ -92,25 +116,17 @@ beforeEach(() => {
});
afterEach(() => {
+ if (originalFonts) Object.defineProperty(document, 'fonts', originalFonts);
+ else Reflect.deleteProperty(document, 'fonts');
+ document.documentElement.style.removeProperty('--font-mono');
+ vi.restoreAllMocks();
vi.unstubAllGlobals();
});
-test('Shell terminal sends physical Enter directly without changing RX rendering semantics', () => {
- const handleTerminalData = vi.fn();
- const wrapper = shallowMount(SerialShellPanel, {
- props: {
- sessionId: 'session-shell',
- config,
- isConnected: true,
- shell: {
- replay: () => '',
- onOutput: () => () => undefined,
- onReset: () => () => undefined,
- handleTerminalData,
- clear: vi.fn(),
- },
- },
- });
+test('Shell terminal sends physical Enter directly without changing RX rendering semantics', async () => {
+ const { wrapper, shell } = mountShell();
+ const { handleTerminalData } = shell;
+ await flushPromises();
expect(xtermMocks.state.constructorOptions).not.toHaveProperty('convertEol');
expect(xtermMocks.state.customKeyHandler).not.toBeNull();
@@ -127,3 +143,53 @@ test('Shell terminal sends physical Enter directly without changing RX rendering
wrapper.unmount();
});
+
+test('Shell waits for regular and bold fonts before measuring cells and replaying output', async () => {
+ const regular = Promise.withResolvers();
+ const bold = Promise.withResolvers();
+ loadFont.mockReturnValueOnce(regular.promise).mockReturnValueOnce(bold.promise);
+ const { wrapper, shell } = mountShell();
+
+ expect(xtermMocks.state.constructorOptions).toBeNull();
+ expect(shell.replay).not.toHaveBeenCalled();
+ regular.resolve([]);
+ await flushPromises();
+ expect(xtermMocks.state.constructorOptions).toBeNull();
+
+ bold.resolve([]);
+ await flushPromises();
+ expect(xtermMocks.state.constructorOptions?.fontFamily).toBe('"JetBrains Mono", monospace');
+ expect(shell.replay).toHaveBeenCalledOnce();
+ expect(shell.onOutput).toHaveBeenCalledOnce();
+ expect(shell.onReset).toHaveBeenCalledOnce();
+ wrapper.unmount();
+ expect(shell.onOutput.mock.results[0]?.value).toHaveBeenCalledOnce();
+ expect(shell.onReset.mock.results[0]?.value).toHaveBeenCalledOnce();
+});
+
+test('Shell closed during font loading never creates a terminal or subscribes to output', async () => {
+ const pending = Promise.withResolvers();
+ loadFont.mockReturnValue(pending.promise);
+ const { wrapper, shell } = mountShell();
+ wrapper.unmount();
+
+ pending.resolve([]);
+ await flushPromises();
+ expect(xtermMocks.state.constructorOptions).toBeNull();
+ expect(shell.replay).not.toHaveBeenCalled();
+ expect(shell.onOutput).not.toHaveBeenCalled();
+ expect(shell.onReset).not.toHaveBeenCalled();
+});
+
+test('Shell remains usable with system monospace if a bundled font fails to load', async () => {
+ const error = new Error('Font unavailable');
+ loadFont.mockRejectedValue(error);
+ const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
+ const { wrapper, shell } = mountShell();
+ await flushPromises();
+
+ expect(xtermMocks.state.constructorOptions?.fontFamily).toBe('monospace');
+ expect(shell.onOutput).toHaveBeenCalledOnce();
+ expect(warn).toHaveBeenCalledWith('Shell font failed to load; using system monospace', error);
+ wrapper.unmount();
+});
diff --git a/src/features/terminal/ui/SerialShellPanel.vue b/src/features/terminal/ui/SerialShellPanel.vue
index f73cf81..eb6912d 100644
--- a/src/features/terminal/ui/SerialShellPanel.vue
+++ b/src/features/terminal/ui/SerialShellPanel.vue
@@ -163,6 +163,7 @@ import {
import type { SessionRuntimeShellController } from '@/features/sessions/runtime/session-runtime-controller';
import { useAppStore } from '@/features/settings/store/app-store';
import { t } from '@/lib/i18n';
+import { logger } from '@/lib/logger';
import type { SerialShellConfig } from '@/types';
const props = defineProps<{
@@ -196,6 +197,7 @@ let searchAddon: SearchAddon | null = null;
let resizeObserver: ResizeObserver | null = null;
let stopOutput: (() => void) | null = null;
let stopReset: (() => void) | null = null;
+let disposed = false;
const newlineChoices = [
{ label: t('shell.newline.none'), value: 'none' as const },
@@ -287,15 +289,32 @@ function handleCustomKey(event: KeyboardEvent): boolean {
return true;
}
-onMounted(() => {
+onMounted(async () => {
const host = terminalHost.value;
if (!host) return;
+ const fontSize = 14;
+ let fontFamily = cssVariable('--font-mono') || 'monospace';
+ try {
+ // xterm caches cell measurements on open. Load both weights first so
+ // fallback metrics cannot leave gaps or misalign ANSI bold output.
+ await Promise.all([
+ document.fonts.load(`400 ${fontSize}px ${fontFamily}`),
+ document.fonts.load(`700 ${fontSize}px ${fontFamily}`),
+ ]);
+ } catch (error) {
+ logger.warn('Shell font failed to load; using system monospace', error);
+ fontFamily = 'monospace';
+ }
+ if (disposed || terminalHost.value !== host) return;
const term = new Terminal({
cursorBlink: true,
scrollback: 5_000,
- fontFamily: cssVariable('--font-mono') || 'monospace',
- fontSize: 13,
- lineHeight: 1.35,
+ fontFamily,
+ fontSize,
+ fontWeight: 400,
+ fontWeightBold: 700,
+ letterSpacing: 0,
+ lineHeight: 1.25,
theme: terminalTheme(),
});
fitAddon = new FitAddon();
@@ -323,6 +342,7 @@ onActivated(() => {
});
onBeforeUnmount(() => {
+ disposed = true;
stopOutput?.();
stopOutput = null;
stopReset?.();
@@ -521,6 +541,9 @@ function findPrevious(): void {
.shell-body :deep(.xterm) {
height: 100%;
+ font-variant-ligatures: none;
+ font-kerning: none;
+ text-rendering: auto;
}
.shell-body :deep(.xterm-viewport) {
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 4a6df34..faf054d 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -2,14 +2,44 @@ import type { DisplayMode } from '@/types';
import type { ExportFormat as ContractExportFormat } from '@/generated/ipc-contracts';
export const BAUD_RATES = [
+ { label: '50', value: 50 },
+ { label: '75', value: 75 },
+ { label: '110', value: 110 },
+ { label: '134', value: 134 },
+ { label: '150', value: 150 },
+ { label: '200', value: 200 },
+ { label: '300', value: 300 },
+ { label: '600', value: 600 },
+ { label: '1200', value: 1200 },
+ { label: '1800', value: 1800 },
+ { label: '2400', value: 2400 },
+ { label: '4800', value: 4800 },
+ { label: '7200', value: 7200 },
{ label: '9600', value: 9600 },
+ { label: '14400', value: 14400 },
{ label: '19200', value: 19200 },
+ { label: '28800', value: 28800 },
{ label: '38400', value: 38400 },
+ { label: '56000', value: 56000 },
{ label: '57600', value: 57600 },
+ { label: '76800', value: 76800 },
{ label: '115200', value: 115200 },
+ { label: '128000', value: 128000 },
+ { label: '153600', value: 153600 },
{ label: '230400', value: 230400 },
+ { label: '256000', value: 256000 },
{ label: '460800', value: 460800 },
+ { label: '500000', value: 500000 },
+ { label: '576000', value: 576000 },
{ label: '921600', value: 921600 },
+ { label: '1000000', value: 1000000 },
+ { label: '1152000', value: 1152000 },
+ { label: '1500000', value: 1500000 },
+ { label: '2000000', value: 2000000 },
+ { label: '2500000', value: 2500000 },
+ { label: '3000000', value: 3000000 },
+ { label: '3500000', value: 3500000 },
+ { label: '4000000', value: 4000000 },
];
export const DATA_BITS_OPTIONS = [
diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts
index 3dbb6e1..8133510 100644
--- a/src/lib/locales/en.ts
+++ b/src/lib/locales/en.ts
@@ -239,6 +239,12 @@ const en: Catalog = {
'serial.signals': 'Signals',
'serial.signalControl': 'Signal control',
'serial.signalHint': 'DTR/RTS are used for Arduino reset, ESP32 boot modes, and similar flows',
+ 'serial.settings.title': 'Serial settings',
+ 'serial.settings.open': 'Edit serial settings',
+ 'serial.settings.liveHint': 'Changes are applied immediately without closing the session.',
+ 'serial.settings.nextOpenHint': 'Changes will be used the next time this session connects.',
+ 'serial.settings.updated': 'Serial settings updated',
+ 'serial.settings.updateFailed': 'Could not update serial settings: {error}',
'serial.summary': 'Current connection summary',
'serial.rate': 'Rate',
'serial.format': 'Format',
diff --git a/src/lib/locales/zh.ts b/src/lib/locales/zh.ts
index b53c041..affed5a 100644
--- a/src/lib/locales/zh.ts
+++ b/src/lib/locales/zh.ts
@@ -222,6 +222,12 @@ const zh: Catalog = {
'serial.signals': '信号',
'serial.signalControl': '信号控制',
'serial.signalHint': 'DTR/RTS 用于 Arduino 复位、ESP32 启动模式等',
+ 'serial.settings.title': '串口设置',
+ 'serial.settings.open': '修改串口设置',
+ 'serial.settings.liveHint': '保存后立即生效,无需断开当前会话。',
+ 'serial.settings.nextOpenHint': '设置将在此会话下次连接时生效。',
+ 'serial.settings.updated': '串口设置已更新',
+ 'serial.settings.updateFailed': '串口设置更新失败:{error}',
'serial.summary': '当前连接摘要',
'serial.rate': '速率',
'serial.format': '格式',