Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/api/src/namespaces.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,6 +155,15 @@ export interface InputBoxOptions {
placeHolder?: string;
/** Mask the input, for secrets. */
password?: boolean;
/**
* Validate the current value on every keystroke (Task 3.1, design.md
* §12). A returned string is shown as a validation message and blocks
* `showInputBox`'s promise from resolving on accept (Enter); `undefined`
* means the current value is valid. Called once with the initial `value`
* (or `""`) when the input box opens, so a required-field validator can
* report immediately rather than only after the first keystroke.
*/
validateInput?: (value: string) => string | undefined;
}

/** Which side of the status bar an item renders on, and its sort
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/keymapState.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,25 @@ test("a malformed raw user entry is skipped rather than thrown", () => {
expect(log.entries().some((e) => e.level === "warning")).toBe(true);
});

test("a defaults layer, when given, is present from the very first getTable() call and outranks nothing (lowest precedence)", () => {
const log = createHostLog();
const state = createKeymapState(log, [{ key: "escape", command: "modal.close", when: "quickPickFocus" }]);

const resolved = state.getTable().lookup("escape", (key) => key === "quickPickFocus");
expect(resolved?.command).toBe("modal.close");
expect(resolved?.layer).toBe("defaults");
});

test("user entries outrank a defaults-layer binding on the same key", () => {
const log = createHostLog();
const state = createKeymapState(log, [{ key: "ctrl+s", command: "modal.accept" }]);
state.setUserEntries([{ key: "ctrl+s", command: "user.save" }]);

const resolved = state.getTable().lookup("ctrl+s", () => undefined);
expect(resolved?.command).toBe("user.save");
expect(resolved?.layer).toBe("user");
});

test("later setUserEntries calls fully replace the previous user layer", () => {
const log = createHostLog();
const state = createKeymapState(log);
Expand Down
52 changes: 29 additions & 23 deletions packages/cli/src/keymapState.ts
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,30 @@
/**
* Keeps the layered `BindingTable` up to date across the CLI's startup
* phases (Req 4.1-4.3; design.md §6.2; CodeRabbit's Phase 2 plan): the
* sync phase builds one with whatever is known synchronously (nothing
* `defaults`/`fallback` have no source yet, see below), `ConfigService`'s
* sync phase builds one with whatever is known synchronously (`defaults`
* see below — and an empty `fallback`), `ConfigService`'s
* `onKeybindingsChange` hook rebuilds it with the `user` layer once the
* user's `keybindings.json` has loaded (and again on every live reload),
* and the deferred phase rebuilds it again once `loadExtensions`'s
* `extensionKeybindings` are known.
*
* **`defaults`/`fallback` are `[]` today, deliberately.** `KeymapLayers`
* (`@tecode/core`'s `bindingTable.ts`) requires all four layers regardless
* of which are populated yet:
* - `defaults` — core commands' own default bindings. No core command
* contributes one yet (editor-core's movement/editing commands are
* Phase 2 tasks, command-palette's `ctrl+shift+p`/`ctrl+p` are Phase 3) —
* there is nothing to seed this layer with until those land.
* - `fallback` — the terminal-capability fallback overlay (Req 4.7).
* `terminalCapabilities.ts`'s stub result feeds this once Task 4.2 wires
* real detection; until then it stays empty, exactly like
* `bindingTable.ts`'s own TSDoc says it may.
* **`defaults` — core commands' own default bindings** (Task 3.1): fixed
* at construction via {@link createKeymapState}'s second parameter, never
* mutated afterward (unlike `user`/`extension`, which change over the
* app's lifetime) — core commands' own bindings are static data known at
* startup, not something that reloads. `main.ts`'s composition root passes
* `@tecode/core`'s `MODAL_DEFAULT_KEYBINDINGS` (`modal.selectNext`/
* `selectPrevious`/`accept`/`close`, Req 10.1) as this task's first real
* occupant of a layer `bindingTable.ts` has reserved since Task 1.5.
* Defaults to `[]` for a caller with nothing to seed it with (every test
* that predates Task 3.1) — `KeymapLayers` (`@tecode/core`'s
* `bindingTable.ts`) requires all four layers regardless of which are
* populated.
*
* `@tecode/core` has no OpenTUI key-event pipeline consuming this table
* yet (routing key input into editing is tasks.md's Task 2.2) — this
* module's job for Task 1.15 is only to keep the table itself correctly
* assembled and rebuildable end to end, the same way `ui/slotRegistry.ts`
* is kept live before any view consumes it.
* **`fallback` is `[]` today, deliberately** — the terminal-capability
* fallback overlay (Req 4.7). `terminalCapabilities.ts`'s stub result
* feeds this once Task 4.2 wires real detection; until then it stays
* empty, exactly like `bindingTable.ts`'s own TSDoc says it may.
*/

import { createBindingTable, type BindingTable, type HostLog } from "@tecode/core";
Expand All@@ -50,17 +50,23 @@ export interface KeymapState {
setExtensionEntries(entries: readonly KeybindingContribution[]): void;
}

/** Build a {@link KeymapState} (Req 4.1-4.3). Starts with every layer
* empty; `getTable()` is always safe to call, even before either setter
* has ever run. */
export function createKeymapState(log: HostLog): KeymapState {
/** Build a {@link KeymapState} (Req 4.1-4.3). `defaults` seeds the
* `defaults` layer once and for all (this module's TSDoc) — omit it (or
* pass `[]`) for the pre-Task-3.1 behavior of an empty defaults layer.
* `user`/`extension` start empty regardless; `getTable()` is always safe to
* call, even before either setter has ever run. */
export function createKeymapState(
log: HostLog,
defaults: readonly KeybindingContribution[] = [],
): KeymapState {
const defaultEntries = defaults.slice();
let userEntries: KeybindingContribution[] = [];
let extensionEntries: KeybindingContribution[] = [];
let table = build();

function build(): BindingTable {
return createBindingTable(
{ defaults: [], fallback: [], extension: extensionEntries, user: userEntries },
{ defaults: defaultEntries, fallback: [], extension: extensionEntries, user: userEntries },
{ log },
);
}
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/main.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,7 +88,16 @@ test("buildAssemblyRoot wires every core service and registers the 'tecode' modu
expect(root.slotRegistry).toBeDefined();
expect(root.layoutState).toBeDefined();
expect(root.theme.colors).toBeDefined();
expect(root.keymap.getTable().entries().size).toBe(0);
// Task 3.1: the `defaults` layer is no longer empty — `modal.*`'s 4
// keybindings (`down`/`up`/`return`/`escape`) are seeded synchronously
// by `createKeymapState(log, MODAL_DEFAULT_KEYBINDINGS)`, ahead of any
// extension/user layer.
expect(root.keymap.getTable().entries().size).toBe(4);
const resolvedModalClose = root.keymap
.getTable()
.lookup("escape", (key) => key === "quickPickFocus" || key === "inputBoxFocus");
expect(resolvedModalClose?.command).toBe("modal.close");
expect(resolvedModalClose?.layer).toBe("defaults");
expect(root.hostRef.current).toBeUndefined();

// Task 2.6's theme wiring: the registry always has the built-in base
Expand Down
61 changes: 58 additions & 3 deletions packages/cli/src/main.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,16 +18,20 @@ import {
createHostLog,
createLanguageRegistry,
createLayoutStateService,
createModalService,
createNoopStatusSink,
createSlotRegistry,
createTecodeApi,
createThemeRegistry,
createThemeService,
createThemeSettingsWriter,
createWebTreeSitterParserBackend,
createWindowMessageService,
loadExtensions,
MODAL_DEFAULT_KEYBINDINGS,
pathToUri,
registerCoreConfiguration,
registerModalCommands,
registerTecodeAlias,
registerThemeSelectCommand,
wireEditorLangIdContext,
Expand All@@ -48,11 +52,13 @@ import {
type LanguageRegistry,
type LayoutStateService,
type LoadExtensionsResult,
type ModalService,
type PendingThemeContribution,
type SlotRegistry,
type StatusSink,
type ThemeRegistry,
type ThemeService,
type WindowMessageService,
} from "@tecode/core";
import {
builtinLanguageGrammarAssets,
Expand DownExpand Up@@ -259,6 +265,21 @@ export interface AssemblyRoot {
* Disposed alongside every other startup-owned subscription in
* {@link wireProcessExit}. */
editorLangIdSync: Disposable;
/** The core-owned modal overlay's state/logic (Task 3.1, Req 10.1,
* design.md §12) — backs the real `tecode.window.showQuickPick`/
* `showInputBox` (via `api` above) and the rendered `ModalOverlay`
* sibling (`renderShell.tsx`'s `ShellRenderDeps.modalService`). */
modalService: ModalService;
/** The `modal.*` commands' registration (Task 3.1, `ui/modalCommands.
* ts`) — disposed alongside every other startup-owned subscription in
* {@link wireProcessExit}. */
modalCommands: Disposable;
/** Backs the real `tecode.window.showMessage`/`setStatusBarItem` (Task
* 3.1, Req 10.1, `ui/windowMessageService.ts`) against the SAME
* `slotRegistry` above. Disposed alongside every other startup-owned
* subscription in {@link wireProcessExit} so a pending `showMessage`
* notice doesn't linger past shutdown. */
windowMessageService: WindowMessageService;
/**
* Forward-reference box for the extension host (this module's TSDoc,
* "Forward-referenced host wiring"): `undefined` until the deferred
Expand DownExpand Up@@ -379,7 +400,11 @@ export function buildAssemblyRoot(
});
const fs = createFileSystem({ log });

const keymap = createKeymapState(log);
// `MODAL_DEFAULT_KEYBINDINGS` (Task 3.1, `ui/modalCommands.ts`) is this
// codebase's first real occupant of the `defaults` layer
// (`keymapState.ts`'s TSDoc) — core-owned bindings, not an extension
// manifest's.
const keymap = createKeymapState(log, MODAL_DEFAULT_KEYBINDINGS);
const config = createConfigService({
log,
sink,
Expand DownExpand Up@@ -473,6 +498,15 @@ export function buildAssemblyRoot(
// `editorSession` itself is shared above.
const findService = createFindService({ editorSession });

// Task 3.1's core-owned modal overlay layer (Req 10.1, design.md §12):
// built before `createTecodeApi` so the REAL `tecode.window.
// showQuickPick`/`showInputBox` can be wired against it below, exactly
// like `editorSession`/`findService` above. `windowMessageService` backs
// `showMessage`/`setStatusBarItem` against the SAME `slotRegistry`
// instance the rendered `Shell`'s `StatusBar` reads from.
const modalService = createModalService();
const windowMessageService = createWindowMessageService({ slotRegistry });

const api = createTecodeApi({
commands,
documents,
Expand All@@ -487,18 +521,29 @@ export function buildAssemblyRoot(
themeRegistry,
themeService,
languageRegistry,
modalService,
windowMessageService,
});

// Must run before any extension module is imported (see this function's
// TSDoc).
registerTecodeAlias(api);

// The `modal.*` commands + their default keybindings (Task 3.1, `ui/
// modalCommands.ts`'s TSDoc): registered directly on `commands`, NOT
// through an extension manifest — the modal overlay is core-owned
// infrastructure `theme.select`/`editor-core`'s find widget already
// depend on existing. The default keybindings themselves were already
// fed into `keymap`'s `defaults` layer above, ahead of `config`'s own
// construction.
const modalCommands = registerModalCommands(commands, modalService);

// `theme.select` (Req 7.5, `ui/themeSelectCommand.ts`'s TSDoc): a
// PRIVILEGED registration straight on `commands`, closing over
// `themeService`'s preview/commit/revert directly — no equivalent exists
// on `tecode.themes` (extensions never get this). `showQuickPick` comes
// from `api.window` — still `createWindowStub`'s inert stub until Task
// 3.1's real quick-pick UI lands (that module's TSDoc).
// from `api.window`, now genuinely backed by `modalService` above (Task
// 3.1) — live, real quick-pick UI, not a stub.
const themeSelectCommand = registerThemeSelectCommand(commands, {
themeRegistry,
themeService,
Expand DownExpand Up@@ -557,6 +602,9 @@ export function buildAssemblyRoot(
highlightService,
editorInputRouter,
editorLangIdSync,
modalService,
modalCommands,
windowMessageService,
hostRef,
};
}
Expand DownExpand Up@@ -722,6 +770,9 @@ function wireProcessExit(root: AssemblyRoot): void {
root.editorLangIdSync.dispose();
root.themeConfigSync.dispose();
root.themeSelectCommand.dispose();
root.modalCommands.dispose();
root.modalService.dispose();
root.windowMessageService.dispose();
root.highlightService.dispose();
root.languageRegistry.dispose();
await root.hostRef.current?.disposeAll();
Expand DownExpand Up@@ -821,6 +872,7 @@ export async function runTecode(
highlightService: root.highlightService,
chordMachine: root.chordMachine,
editorInputRouter: root.editorInputRouter,
modalService: root.modalService,
});

const firstFrameMs = performance.now() - startedAt;
Expand DownExpand Up@@ -861,6 +913,9 @@ export async function runTecode(
root.editorLangIdSync.dispose();
root.themeConfigSync.dispose();
root.themeSelectCommand.dispose();
root.modalCommands.dispose();
root.modalService.dispose();
root.windowMessageService.dispose();
root.highlightService.dispose();
root.languageRegistry.dispose();
await deferred.extensionHost.disposeAll();
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/renderShell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@ import { createRoot } from "@opentui/react";
import type { ResolvedTheme } from "@tecode/api";
import {
ContextFocusTracker,
ModalOverlay,
Shell,
ThemeProvider,
type ChordStateMachine,
Expand All@@ -26,6 +27,7 @@ import {
type FindService,
type HighlightService,
type LayoutStateService,
type ModalService,
type SlotRegistry,
type ThemeService,
} from "@tecode/core";
Expand DownExpand Up@@ -96,6 +98,14 @@ export interface ShellRenderDeps {
* See {@link chordMachine}'s TSDoc for when the listener is actually
* wired. */
editorInputRouter?: Pick<EditorInputRouter, "routeKeyEvent">;
/** The core-owned modal overlay's state/logic (Task 3.1, Req 10.1,
* design.md §12) — when given, rendered as the LAST sibling of `<Shell>`,
* inside the same `<ThemeProvider>`/`<ContextFocusTracker>`, via
* `ModalOverlay` (`ui/modalOverlay.tsx`). Optional, matching every other
* service dependency above: a caller/test that omits it renders `<Shell>`
* alone, with no modal overlay at all (not even an inert one) — exactly
* the pre-Task-3.1 behavior. */
modalService?: Pick<ModalService, "getState" | "onDidChange" | "setFilter" | "setInputValue">;
}

/** The render seam's shape: resolves once "first frame" has happened (see
Expand DownExpand Up@@ -135,6 +145,11 @@ export const renderShellToTerminal: RenderShell = async (deps) => {
findService={deps.findService}
highlightService={deps.highlightService}
/>
{/* LAST sibling of <Shell> (Task 3.1, `ui/modalOverlay.tsx`'s
* TSDoc's "Mount point") — omitted entirely (not even an inert
* render) when no `modalService` is given, matching every other
* optional-dependency fallback in this module. */}
{deps.modalService ? <ModalOverlay modalService={deps.modalService} /> : null}
</ContextFocusTracker>
</ThemeProvider>,
);
Expand Down
Loading
Loading