From 78a87577df8971149a9308aeca9b0fdff7c6c286 Mon Sep 17 00:00:00 2001
From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com>
Date: Sun, 9 Aug 2026 08:38:01 +0200
Subject: [PATCH 1/3] Extract replaceToken() helper in consoleTokens
ConsoleAutocomplete's token-replacement logic (truncate to the token
span, append a trailing space at end-of-input) is exactly what Console's
Enter/Tab handling needs too. Pull it into a shared, tested helper
instead of duplicating it.
---
frontend/src/ui/ConsoleAutocomplete.ts | 35 +++++++++-----------------
frontend/src/ui/consoleTokens.test.ts | 24 ++++++++++++++++++
frontend/src/ui/consoleTokens.ts | 26 +++++++++++++++++++
3 files changed, 62 insertions(+), 23 deletions(-)
diff --git a/frontend/src/ui/ConsoleAutocomplete.ts b/frontend/src/ui/ConsoleAutocomplete.ts
index 28a66fe..4fb6922 100644
--- a/frontend/src/ui/ConsoleAutocomplete.ts
+++ b/frontend/src/ui/ConsoleAutocomplete.ts
@@ -1,7 +1,13 @@
import { Dropdown } from '../utils/dropdown';
import { OPENAP_AIRCRAFT_TYPES } from '../data/aircraftTypes';
import { parseSignature, getDisplaySignature } from '../data/CommandSignature';
-import { getArgAtCursor, findAcidContext, findPanContext, AcidContext } from './consoleTokens';
+import {
+ getArgAtCursor,
+ findAcidContext,
+ findPanContext,
+ replaceToken,
+ AcidContext,
+} from './consoleTokens';
import type { CommandDict } from '../data/types';
import type { NavdataSearchResult } from '../data/navdataSearch';
@@ -285,29 +291,12 @@ export class ConsoleAutocomplete {
this.deps.onAfterSelect();
}
- /**
- * Replace the argument token under the cursor with `replacement`. When the
- * cursor is at end-of-input the replacement is followed by a space so the
- * user can keep typing the next argument; mid-input replacements leave
- * whatever followed the token untouched and place the cursor right after
- * the inserted text.
- */
+ /** Replace the argument token under the cursor with `replacement`. */
private replaceTokenAtCursor(input: HTMLInputElement, replacement: string): void {
- const value = input.value;
- const cursorPos = this.getCursorPos(input);
- const { tokenStart, tokenEnd } = getArgAtCursor(value, cursorPos);
-
- const before = value.substring(0, tokenStart);
- const after = value.substring(tokenEnd);
- const atEnd = after.length === 0;
-
- const newValue = atEnd
- ? before + replacement + ' '
- : before + replacement + after;
- const newCursor = tokenStart + replacement.length + (atEnd ? 1 : 0);
-
- input.value = newValue;
- input.setSelectionRange(newCursor, newCursor);
+ const { tokenStart, tokenEnd } = getArgAtCursor(input.value, this.getCursorPos(input));
+ const replaced = replaceToken(input.value, tokenStart, tokenEnd, replacement);
+ input.value = replaced.value;
+ input.setSelectionRange(replaced.cursor, replaced.cursor);
}
private hideAcidDropdown(): void {
diff --git a/frontend/src/ui/consoleTokens.test.ts b/frontend/src/ui/consoleTokens.test.ts
index bc17fb7..a0a9931 100644
--- a/frontend/src/ui/consoleTokens.test.ts
+++ b/frontend/src/ui/consoleTokens.test.ts
@@ -9,6 +9,7 @@ import {
argStartIndex,
findAcidContext,
findPanContext,
+ replaceToken,
} from './consoleTokens';
describe('tokenizeInput', () => {
@@ -177,3 +178,26 @@ describe('findPanContext', () => {
expect(findPanContext('PAN -4', 6)).toBeNull();
});
});
+
+describe('replaceToken', () => {
+ it('appends a trailing space when replacing the token at end-of-input', () => {
+ expect(replaceToken('CRE KL123,A38', 10, 13, 'A388')).toEqual({
+ value: 'CRE KL123,A388 ',
+ cursor: 15,
+ });
+ });
+
+ it('leaves the tail untouched when replacing mid-input', () => {
+ expect(replaceToken('CRE KL123,A38 52 4', 10, 13, 'A388')).toEqual({
+ value: 'CRE KL123,A388 52 4',
+ cursor: 14,
+ });
+ });
+
+ it('replaces the command token, keeping following arguments', () => {
+ expect(replaceToken('MCR 5', 0, 3, 'MCRE')).toEqual({
+ value: 'MCRE 5',
+ cursor: 4,
+ });
+ });
+});
diff --git a/frontend/src/ui/consoleTokens.ts b/frontend/src/ui/consoleTokens.ts
index e6783e9..f72901f 100644
--- a/frontend/src/ui/consoleTokens.ts
+++ b/frontend/src/ui/consoleTokens.ts
@@ -102,6 +102,32 @@ export function getArgAtCursor(value: string, cursorPos: number): ArgAtCursor {
};
}
+export interface TokenReplacement {
+ value: string;
+ cursor: number;
+}
+
+/**
+ * Replace the token spanning [tokenStart, tokenEnd) with `replacement`.
+ * At end-of-input a trailing space is appended so the user can keep
+ * typing the next argument; mid-input the tail is left untouched. The
+ * returned cursor sits right after the inserted text.
+ */
+export function replaceToken(
+ value: string,
+ tokenStart: number,
+ tokenEnd: number,
+ replacement: string
+): TokenReplacement {
+ const before = value.substring(0, tokenStart);
+ const after = value.substring(tokenEnd);
+ const atEnd = after.length === 0;
+ return {
+ value: atEnd ? before + replacement + ' ' : before + replacement + after,
+ cursor: tokenStart + replacement.length + (atEnd ? 1 : 0),
+ };
+}
+
/**
* Find the character index in `value` where the argument at position
* `argIndex` begins (the command token is at conceptual index -1).
From 58ba0be6f9aec2361d90c0cdab22f22a7ac1047b Mon Sep 17 00:00:00 2001
From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com>
Date: Sun, 9 Aug 2026 08:38:09 +0200
Subject: [PATCH 2/3] Fix console Enter key and Tab completion to share
separator rules
Enter duplicated the Send button's submit logic inline and skipped its
trailing-separator trim, so a command finished via the map picker
(which appends a comma after every click) was sent with a dangling
comma. Route Enter through the same submitCurrent() path as Send.
Tab completion split on spaces only and always completed the last
word, so it never matched the comma-separated form the console itself
produces (e.g. "CRE KL123,A38"). Use getArgAtCursor()/replaceToken()
so completion follows the same space-and-comma separator rules as the
rest of the console, and completes the token under the cursor rather
than assuming it's at the end of input.
---
frontend/src/ui/Console.keyboard.test.ts | 167 +++++++++++++++++++++++
frontend/src/ui/Console.ts | 146 +++++++-------------
2 files changed, 220 insertions(+), 93 deletions(-)
create mode 100644 frontend/src/ui/Console.keyboard.test.ts
diff --git a/frontend/src/ui/Console.keyboard.test.ts b/frontend/src/ui/Console.keyboard.test.ts
new file mode 100644
index 0000000..6a10977
--- /dev/null
+++ b/frontend/src/ui/Console.keyboard.test.ts
@@ -0,0 +1,167 @@
+// @vitest-environment happy-dom
+/**
+ * Tests for the console's two keyboard paths that go through
+ * setupEventListeners():
+ *
+ * - Enter must be an exact mirror of the "Send" button, i.e. route through
+ * the shared submitCurrent() path. Before the fix Enter duplicated the
+ * submit logic inline and skipped the trailing-separator trim, so a
+ * command finished via the map picker (which appends a comma after every
+ * click) was sent with a dangling `,`.
+ * - Tab completion must use the console's separator rules (spaces AND
+ * commas) and the cursor position. Before the fix it split on spaces
+ * only and always completed the last word, so the comma-separated form
+ * the app itself produces ("CRE KL123,A38") never completed.
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { Console } from './Console';
+import type { StateManager } from '../core/StateManager';
+
+function setupDom(): void {
+ document.body.innerHTML = `
+
+
+
+
+ BS>
+
+
+
+
+ `;
+}
+
+function input(): HTMLInputElement {
+ return document.getElementById('console-input') as HTMLInputElement;
+}
+
+function press(key: string): void {
+ input().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
+}
+
+/** Type a value and put the cursor at the given position (default: end). */
+function type(value: string, cursor: number = value.length): void {
+ const el = input();
+ el.value = value;
+ el.setSelectionRange(cursor, cursor);
+}
+
+const CMDDICT = {
+ CRE: 'acid,type,lat,lon,hdg,alt,spd',
+ HDG: 'acid,hdg',
+};
+
+function stateManagerStub(): StateManager {
+ return {
+ getCommandDict: () => CMDDICT,
+ getState: () => ({ aircraftData: { id: [] } }),
+ } as unknown as StateManager;
+}
+
+describe('Console Enter key', () => {
+ const app = { sendCommand: vi.fn() };
+
+ beforeEach(() => {
+ localStorage.clear();
+ setupDom();
+ app.sendCommand.mockReset();
+ window.app = app as unknown as Window['app'];
+ new Console();
+ });
+
+ afterEach(() => {
+ delete window.app;
+ document.body.innerHTML = '';
+ });
+
+ it('sends the typed command and clears the input', () => {
+ type('MCRE 5');
+ press('Enter');
+ expect(app.sendCommand).toHaveBeenCalledWith('MCRE 5');
+ expect(input().value).toBe('');
+ });
+
+ it('trims trailing separators, matching the Send button', () => {
+ // The console map picker appends a comma after every click, so this
+ // is exactly what the input holds when a POLY is finished with Enter.
+ type('POLY TEST,52.3,4.2,53.0,4.2,52.6,5.2,');
+ press('Enter');
+ expect(app.sendCommand).toHaveBeenCalledWith(
+ 'POLY TEST,52.3,4.2,53.0,4.2,52.6,5.2'
+ );
+ });
+
+ it('stores the trimmed command in the arrow-key history', () => {
+ type('MCRE 5, ');
+ press('Enter');
+ const saved = JSON.parse(
+ localStorage.getItem('webatm-console-command-history') ?? '[]'
+ );
+ expect(saved).toContain('MCRE 5');
+ });
+
+ it('does nothing for an empty input', () => {
+ type(' ');
+ press('Enter');
+ expect(app.sendCommand).not.toHaveBeenCalled();
+ });
+});
+
+describe('Console Tab completion', () => {
+ const app = { sendCommand: vi.fn() };
+ let konsole: Console;
+
+ beforeEach(() => {
+ localStorage.clear();
+ setupDom();
+ app.sendCommand.mockReset();
+ window.app = app as unknown as Window['app'];
+ konsole = new Console();
+ konsole.setStateManager(stateManagerStub());
+ });
+
+ afterEach(() => {
+ delete window.app;
+ document.body.innerHTML = '';
+ });
+
+ it('completes the aircraft type after a comma separator', () => {
+ type('CRE KL123,A38');
+ press('Tab');
+ expect(input().value).toBe('CRE KL123,A388 ');
+ });
+
+ it('completes the aircraft type after a space separator', () => {
+ type('CRE KL123 A38');
+ press('Tab');
+ expect(input().value).toBe('CRE KL123 A388 ');
+ });
+
+ it('completes the token under the cursor, keeping the tail', () => {
+ const value = 'CRE KL123,A38 52 4';
+ type(value, value.indexOf('A38') + 3); // cursor at end of "A38"
+ press('Tab');
+ expect(input().value).toBe('CRE KL123,A388 52 4');
+ });
+
+ it('completes a unique command prefix from cmddict', () => {
+ type('HD');
+ press('Tab');
+ expect(input().value).toBe('HDG ');
+ });
+
+ it('echoes the alternatives when several types match', () => {
+ type('CRE KL123,A32');
+ press('Tab');
+ expect(input().value).toBe('CRE KL123,A32'); // unchanged
+ const output = document.getElementById('console-output');
+ expect(output?.textContent).toContain('A320');
+ expect(output?.textContent).toContain('A321');
+ });
+
+ it('does not complete types for non-CRE commands', () => {
+ type('HDG KL123,A38');
+ press('Tab');
+ expect(input().value).toBe('HDG KL123,A38');
+ });
+});
diff --git a/frontend/src/ui/Console.ts b/frontend/src/ui/Console.ts
index 84f8d9d..c4bacac 100644
--- a/frontend/src/ui/Console.ts
+++ b/frontend/src/ui/Console.ts
@@ -15,10 +15,15 @@ import {
} from '../data/CommandSignature';
import { searchNavdata } from '../data/navdataSearch';
import { CommandHistory } from './CommandHistory';
-import { argStartIndex, getArgAtCursor } from './consoleTokens';
+import { argStartIndex, getArgAtCursor, replaceToken } from './consoleTokens';
import { CommandListView } from './CommandListView';
import { ConsoleAutocomplete } from './ConsoleAutocomplete';
+/** True on macOS/iOS, where keyboard hints should show ⌘ instead of Ctrl. */
+function isMacPlatform(): boolean {
+ return /Mac|iPhone|iPad|iPod/.test(navigator.platform);
+}
+
export class Console {
private history = new CommandHistory();
private stateManager: StateManager | null = null;
@@ -59,13 +64,9 @@ export class Console {
this.applyPlatformPlaceholder();
}
- /**
- * Swap the placeholder's "Ctrl+K" hint for "⌘+K" on macOS so the
- * always-visible discoverability clue matches the platform.
- */
+ /** Swap the placeholder's "Ctrl+K" hint for "⌘+K" on macOS. */
private applyPlatformPlaceholder(): void {
- const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
- if (!isMac) return;
+ if (!isMacPlatform()) return;
const input = document.getElementById('console-input') as HTMLInputElement | null;
if (!input) return;
input.placeholder = input.placeholder.replace('Ctrl+K', '⌘+K');
@@ -73,12 +74,8 @@ export class Console {
/**
* Create the argument-signature hint row shown directly above the input.
- *
- * Unlike the inline ghost-text suggestion (which only shows the *remaining*
- * args), this row renders the *full* signature for the typed command and
- * highlights the arg the cursor is currently on. Hidden when the input is
- * empty or the command isn't in cmddict. Sits above the input so it
- * doesn't push the layout down as the user types.
+ * Unlike the ghost-text suggestion (remaining args only), this renders the
+ * full signature and highlights the arg the cursor is on.
*/
private createArgHint(): void {
const inputContainer = document.querySelector('.console-input-container');
@@ -177,9 +174,7 @@ export class Console {
this.updateMapPicker();
break;
case 'Enter':
- this.handleCommand(input.value);
- this.history.add(input.value);
- this.resetInput();
+ this.submitCurrent();
break;
case 'Escape':
this.autocomplete.hideTransient();
@@ -233,11 +228,9 @@ export class Console {
});
}
- // Handle the "Send" button as a click-driven mirror of the Enter key.
- // Dismiss the autocomplete dropdown first so a visible suggestion can't
- // swallow the submit, send whatever is currently typed via the shared
- // submitCurrent() path, then return focus to the input so the user can
- // keep typing the next command.
+ // The "Send" button mirrors the Enter key. Dismiss the autocomplete
+ // dropdown first so a visible suggestion can't swallow the submit,
+ // then return focus so the user can keep typing.
const sendButton = document.getElementById('send-command');
if (sendButton) {
sendButton.addEventListener('click', () => {
@@ -270,51 +263,40 @@ export class Console {
return Array.from(allCommands).sort();
}
+ /**
+ * Tab completion for the token under the cursor: the command name on
+ * the first token, the aircraft type on CRE/MCRE's type slot. Uses the
+ * same separator rules (spaces and commas) as the rest of the console.
+ */
private autoComplete(): void {
const input = document.getElementById('console-input') as HTMLInputElement;
- if (!input) return;
+ if (!input || !input.value.trim()) return;
const value = input.value;
+ const { currentArgIndex, partialText, tokenStart, tokenEnd, parts } =
+ getArgAtCursor(value, this.getCursorPos(input));
+ if (partialText.length === 0) return;
- // Don't autocomplete empty input
- if (!value.trim()) return;
-
- const words = value.split(' ');
- const currentWord = words[words.length - 1].toUpperCase();
-
+ const upperPartial = partialText.toUpperCase();
let suggestions: string[] = [];
- if (words.length === 1) {
- // Complete command - use merged command list
- const allCommands = this.getAllCommands();
-
- // Only filter if there's something to match
- if (currentWord.length > 0) {
- suggestions = allCommands.filter(cmd =>
- cmd.startsWith(currentWord)
- );
- }
- } else if (words.length > 1) {
- // Complete aircraft type for CRE/MCRE commands.
- // CRE acid, type, lat, lon, hdg, alt, spd -> type is the 2nd argument
- // MCRE count, type, alt, spd, dest -> type is also the 2nd argument
- const cmd = words[0].toUpperCase();
- if ((cmd === 'CRE' || cmd === 'MCRE') && words.length === 3 && currentWord.length > 0) {
+ if (currentArgIndex === -1) {
+ suggestions = this.getAllCommands().filter(cmd => cmd.startsWith(upperPartial));
+ } else if (currentArgIndex === 1) {
+ // The type slot: 2nd argument of both CRE and MCRE.
+ const cmd = parts[0].toUpperCase();
+ if (cmd === 'CRE' || cmd === 'MCRE') {
suggestions = OPENAP_AIRCRAFT_TYPES.filter(type =>
- type.startsWith(currentWord)
+ type.startsWith(upperPartial)
);
}
}
if (suggestions.length === 1) {
- // Single match - complete it
- words[words.length - 1] = suggestions[0];
- input.value = words.join(' ');
-
- // Move cursor to end
- input.setSelectionRange(input.value.length, input.value.length);
+ const replaced = replaceToken(value, tokenStart, tokenEnd, suggestions[0]);
+ input.value = replaced.value;
+ input.setSelectionRange(replaced.cursor, replaced.cursor);
} else if (suggestions.length > 1) {
- // Multiple matches - show them in console
this.showSuggestions(suggestions);
}
}
@@ -503,13 +485,8 @@ export class Console {
}
/**
- * Update the inline argument-signature hint row beneath the input.
- *
- * Renders one chip per arg in the signature; the chip the cursor sits
- * on gets the `current` modifier so the user can see which argument
- * they're filling in. When the typed command isn't in cmddict (and
- * cmddict has actually loaded), we replace the chips with a pointer to
- * the command palette so the user has a clue when they're stuck.
+ * Render one chip per signature arg, marking the one under the cursor.
+ * Unknown commands get a pointer to the command palette instead.
*/
private updateArgHint(): void {
if (!this.argHint) return;
@@ -596,10 +573,9 @@ export class Console {
message.textContent = 'unknown — press';
this.argHint.appendChild(message);
- const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
const kbd = document.createElement('kbd');
kbd.className = 'cmd-arg-kbd';
- kbd.textContent = isMac ? '⌘' : 'Ctrl';
+ kbd.textContent = isMacPlatform() ? '⌘' : 'Ctrl';
this.argHint.appendChild(kbd);
const plus = document.createElement('span');
@@ -631,12 +607,9 @@ export class Console {
}
/**
- * Replace the console input contents with `text`, focus the input,
- * move the cursor to the end, and refresh all derived UI (suggestion,
- * arg hint, ACID/type autocomplete, map picker).
- *
- * Used by the command palette modal and left panel to drop a selected
- * command name into the input.
+ * Replace the input contents with `text`, focus it with the cursor at
+ * the end, and refresh all derived UI. Used by the command palette and
+ * left panel to drop a selected command name into the input.
*/
public setInputValue(text: string): void {
const input = document.getElementById('console-input') as HTMLInputElement;
@@ -701,14 +674,10 @@ export class Console {
}
/**
- * Check whether the cursor sits on a lat/lon/hdg parameter of the current
- * command according to the BlueSky cmddict signature. When it does,
- * returns a context object consumed by ConsoleMapPicker.
- *
- * For POLY-family commands the cmddict signature ends with `...` to mean
- * "and so on with more lat,lon pairs". We detect that and synthetically
- * extend the params list so the picker stays engaged across many clicks
- * instead of disabling after the first explicit pair.
+ * When the cursor sits on a lat/lon/hdg parameter of the current command
+ * (per the cmddict signature), return a context for ConsoleMapPicker.
+ * Variadic `...` signatures (POLY family) are synthetically extended with
+ * repeating lat,lon pairs so the picker stays engaged across many clicks.
*/
private getGeoContext(value: string, cursorPos: number): GeoContext | null {
const { currentArgIndex, parts } = getArgAtCursor(value, cursorPos);
@@ -740,8 +709,8 @@ export class Console {
if (variadic && tailIsLatLon) {
const latStart = baseParams.length - 2;
const extended = [...baseParams];
- // Push enough pairs that any reasonable polygon fits and the
- // trailing-comma logic in insertGeoValue keeps adding `,`.
+ // Enough pairs that any reasonable polygon fits and insertGeoValue
+ // keeps appending `,`.
const target = Math.max(currentArgIndex + 4, baseParams.length + 40);
while (extended.length < target) {
const offset = (extended.length - latStart) % 2;
@@ -792,24 +761,15 @@ export class Console {
}
/**
- * Insert a value into the console input, overwriting the argument slot(s)
- * starting at `replaceFromArgIndex`. Used by ConsoleMapPicker to drop
- * coordinates or a heading into the command being typed.
- *
- * The replacement is slot-based rather than token-based: if the user has
- * already typed values for lat and lon and then clicks on the map, the
- * picker will pass replaceFromArgIndex = (lat's index) and the full pair,
- * and this method truncates everything from the start of the lat slot
- * onwards before inserting. That way a single click cleanly replaces both
- * coordinates without leaving fragments behind.
+ * Insert a value into the console input, truncating everything from the
+ * argument slot at `replaceFromArgIndex` onward first so a map click
+ * cleanly replaces already-typed coordinates. Used by ConsoleMapPicker.
*
* @param value - The text to insert (e.g. "52.370000,4.900000" or "270")
- * @param replaceFromArgIndex - The argument index to truncate back to.
- * Everything from this slot onward is discarded before the insert.
- * @param argsAdvanced - How many arguments the insertion fills starting
- * at replaceFromArgIndex. Used to decide whether a trailing comma
- * (more args expected) or a trailing space (command complete) is
- * appended. Pass 2 for a lat,lon pair, 1 for a single value.
+ * @param replaceFromArgIndex - The argument slot to truncate back to.
+ * @param argsAdvanced - How many arguments the insertion fills: decides
+ * whether a trailing comma (more args expected) or a space (command
+ * complete) is appended. Pass 2 for a lat,lon pair, 1 for one value.
*/
public insertGeoValue(
value: string,
From 4b5d9e252f63ac60b05fe5bfc8a6dbd4e4a82a4f Mon Sep 17 00:00:00 2001
From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com>
Date: Sun, 9 Aug 2026 08:38:25 +0200
Subject: [PATCH 3/3] Trim gallery.md down to a single Swiss Alps screenshot
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Keep the Matterhorn shot as the representative example and drop the
other seven Valais/Oberland/Léman entries added in #170.
---
docs/gallery.md | 21 ---------------------
1 file changed, 21 deletions(-)
diff --git a/docs/gallery.md b/docs/gallery.md
index 2adaaad..75f7a7c 100644
--- a/docs/gallery.md
+++ b/docs/gallery.md
@@ -128,24 +128,3 @@ frame is captured, so the header reads `Rate: 0x`.

*A320 above Zermatt and the Matterhorn (light theme)*
-
-
-*A350 over the Saas valley (light theme)*
-
-
-*A380 above Macugnaga, with traffic over the Simplon (light theme)*
-
-
-*Fleet over the Bernese Oberland (light theme)*
-
-
-*A320 above Lauterbrunnen, Interlaken behind (light theme)*
-
-
-*B747 above Meiringen and the Brienzersee (light theme)*
-
-
-*Fleet over Lake Geneva and the Rhône valley (light theme)*
-
-
-*A350 above the Rhône valley near Aigle (light theme)*