From 59225439985a0a6645c730e8b09c0cfde46eaecf Mon Sep 17 00:00:00 2001 From: Nick Karpov Date: Mon, 2 Mar 2026 12:03:11 -0800 Subject: [PATCH] feat(terminal-types): add configurable defaults and ordering --- README.md | 4 +- public/app/sessionTypesModel.js | 57 +++++++++++--- public/app/state.js | 1 + src/terminalTypes/registry.ts | 86 ++++++++++++++++---- src/terminalTypes/types.ts | 1 + terminal-types/README.md | 9 +++ test/terminalTypes.registry.test.ts | 117 ++++++++++++++++++++++++++++ 7 files changed, 250 insertions(+), 25 deletions(-) create mode 100644 test/terminalTypes.registry.test.ts diff --git a/README.md b/README.md index 70a2454..e4c002d 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,14 @@ When running inside a Databricks App, terminal commands execute under the app's Session types are discovered dynamically from `terminal-types/*` at startup. -- Built-in default: `terminal` (base shell, no extra launch script) +- Built-in fallback default: `terminal` (base shell, no extra launch script) - Custom type folder contract: - `terminal-types//type.json` - `terminal-types//launch.sh` - `type.json` can include optional `icon` (unicode/custom glyph string) for CLI-style picker display. - `type.json` can include optional `authPolicy` (`both` default, or pinned `user` / `m2m`). +- `type.json` can include optional `default: true` to make that type the default session type. +- `type.json` can include optional integer `order` to control picker/tab ordering (lower first). - Included profiles in this repo: `claude`, `codex`, `pi` (plus built-in `terminal`). - Bundled logo font assets live under `public/assets/terminal-icons` (source SVGs in `assets/terminal-icons/src`). - Type launch scripts run on top of the base terminal runtime/auth model. diff --git a/public/app/sessionTypesModel.js b/public/app/sessionTypesModel.js index 8865982..28d917b 100644 --- a/public/app/sessionTypesModel.js +++ b/public/app/sessionTypesModel.js @@ -6,16 +6,47 @@ function normalizeAuthPolicy(policy) { return policy === "user" || policy === "m2m" ? policy : "both"; } -function sortedSessionTypes(list) { - return [...list].sort((a, b) => { - if (a.default) { - return -1; - } - if (b.default) { - return 1; +function normalizeOrder(value) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + + return Math.trunc(value); +} + +function hasExplicitOrder(type) { + return typeof type.order === "number" && Number.isFinite(type.order); +} + +function compareSessionTypes(a, b) { + const aOrdered = hasExplicitOrder(a); + const bOrdered = hasExplicitOrder(b); + + if (aOrdered && bOrdered) { + const orderDiff = a.order - b.order; + if (orderDiff !== 0) { + return orderDiff; } - return String(a.name || a.id).localeCompare(String(b.name || b.id)); - }); + } + + if (aOrdered !== bOrdered) { + return aOrdered ? -1 : 1; + } + + if (a.default !== b.default) { + return a.default ? -1 : 1; + } + + const byName = String(a.name || a.id).localeCompare(String(b.name || b.id)); + if (byName !== 0) { + return byName; + } + + return String(a.id).localeCompare(String(b.id)); +} + +function sortedSessionTypes(list) { + return [...list].sort(compareSessionTypes); } function fallbackType(typeId) { @@ -28,6 +59,7 @@ function fallbackType(typeId) { icon: undefined, authPolicy: "both", default: false, + order: undefined, builtIn: false, }; } @@ -54,6 +86,7 @@ export function createSessionTypesModel(state) { icon: typeof type.icon === "string" && type.icon.length > 0 ? type.icon : undefined, authPolicy: normalizeAuthPolicy(type.authPolicy), default: Boolean(type.default), + order: normalizeOrder(type.order), builtIn: Boolean(type.builtIn), })), ); @@ -85,7 +118,11 @@ export function createSessionTypesModel(state) { defaultTypeId() { const found = state.sessionTypes.find((type) => type.default); - return found ? found.id : "terminal"; + if (found) { + return found.id; + } + + return state.sessionTypes[0]?.id || "terminal"; }, typeLogo(type) { diff --git a/public/app/state.js b/public/app/state.js index 1dfb2f1..43a6773 100644 --- a/public/app/state.js +++ b/public/app/state.js @@ -9,6 +9,7 @@ const DEFAULT_SESSION_TYPES = [ icon: "⌂", authPolicy: "both", default: true, + order: 0, builtIn: true, }, ]; diff --git a/src/terminalTypes/registry.ts b/src/terminalTypes/registry.ts index e2d8b48..10c4fe2 100644 --- a/src/terminalTypes/registry.ts +++ b/src/terminalTypes/registry.ts @@ -31,6 +31,8 @@ const terminalTypeManifestSchema = z.object({ badge: z.string().min(1).max(24).optional(), icon: z.string().min(1).max(8).optional(), authPolicy: z.enum(authPolicyValues).optional(), + default: z.boolean().optional(), + order: z.number().int().min(-1_000_000).max(1_000_000).optional(), entrypoint: z.string().min(1).max(200).optional(), }); @@ -40,6 +42,7 @@ class InMemoryTerminalTypeRegistry implements TerminalTypeRegistry { constructor( private readonly types: ResolvedTerminalType[], private readonly typeById: Map, + private readonly defaultType: ResolvedTerminalType, ) {} listTypes(): TerminalType[] { @@ -56,7 +59,7 @@ class InMemoryTerminalTypeRegistry implements TerminalTypeRegistry { } getDefaultType(): ResolvedTerminalType { - return { ...BASE_TERMINAL_TYPE }; + return { ...this.defaultType }; } } @@ -78,11 +81,69 @@ async function isEntrypointFile(candidatePath: string): Promise { } } +function hasExplicitOrder(type: ResolvedTerminalType): boolean { + return typeof type.order === "number" && Number.isFinite(type.order); +} + +function compareTerminalTypes(a: ResolvedTerminalType, b: ResolvedTerminalType): number { + const aOrdered = hasExplicitOrder(a); + const bOrdered = hasExplicitOrder(b); + + if (aOrdered && bOrdered) { + const orderDiff = (a.order as number) - (b.order as number); + if (orderDiff !== 0) { + return orderDiff; + } + } + + if (aOrdered !== bOrdered) { + return aOrdered ? -1 : 1; + } + + if (a.default !== b.default) { + return a.default ? -1 : 1; + } + + const byName = a.name.localeCompare(b.name); + if (byName !== 0) { + return byName; + } + + return a.id.localeCompare(b.id); +} + +function resolveDefaults( + types: ResolvedTerminalType[], + terminalTypesRoot: string, + logger: Logger, +): ResolvedTerminalType[] { + const customDefaults = types.filter((type) => !type.builtIn && type.default); + if (customDefaults.length === 0) { + return types.map((type) => ({ ...type })); + } + + const selected = [...customDefaults].sort(compareTerminalTypes)[0]; + + if (customDefaults.length > 1) { + logger.warn("terminal_types.multiple_defaults", { + terminalTypesRoot, + selectedId: selected.id, + defaultIds: customDefaults.map((type) => type.id), + }); + } + + return types.map((type) => ({ + ...type, + default: type.id === selected.id, + })); +} + export async function loadTerminalTypeRegistry( terminalTypesRoot: string, logger: Logger, ): Promise { - const map = new Map([[BASE_TERMINAL_TYPE.id, BASE_TERMINAL_TYPE]]); + const baseType = { ...BASE_TERMINAL_TYPE }; + const map = new Map([[baseType.id, baseType]]); let entries: import("node:fs").Dirent[] = []; @@ -96,7 +157,7 @@ export async function loadTerminalTypeRegistry( terminalTypesRoot, }); - return new InMemoryTerminalTypeRegistry([BASE_TERMINAL_TYPE], map); + return new InMemoryTerminalTypeRegistry([baseType], map, baseType); } for (const entry of entries) { @@ -154,27 +215,24 @@ export async function loadTerminalTypeRegistry( badge: manifest.badge || id, icon: manifest.icon, authPolicy: manifest.authPolicy || "both", + default: Boolean(manifest.default), + order: manifest.order, builtIn: false, - default: false, entrypointPath, }); } - const types = [...map.values()].sort((a, b) => { - if (a.default) { - return -1; - } - if (b.default) { - return 1; - } - return a.name.localeCompare(b.name); - }); + const resolvedTypes = resolveDefaults([...map.values()], terminalTypesRoot, logger); + const types = [...resolvedTypes].sort(compareTerminalTypes); + const typeById = new Map(types.map((type) => [type.id, type])); + const defaultType = types.find((type) => type.default) || baseType; logger.info("terminal_types.loaded", { terminalTypesRoot, count: types.length, customCount: Math.max(0, types.length - 1), + defaultTypeId: defaultType.id, }); - return new InMemoryTerminalTypeRegistry(types, map); + return new InMemoryTerminalTypeRegistry(types, typeById, defaultType); } diff --git a/src/terminalTypes/types.ts b/src/terminalTypes/types.ts index a951b03..6330a77 100644 --- a/src/terminalTypes/types.ts +++ b/src/terminalTypes/types.ts @@ -8,6 +8,7 @@ export type TerminalType = { icon?: string; authPolicy: TerminalTypeAuthPolicy; default: boolean; + order?: number; builtIn: boolean; }; diff --git a/terminal-types/README.md b/terminal-types/README.md index 158b11e..2745b8e 100644 --- a/terminal-types/README.md +++ b/terminal-types/README.md @@ -25,6 +25,8 @@ terminal-types// "badge": "claude", "icon": "✶", "authPolicy": "both", + "default": false, + "order": 20, "entrypoint": "launch.sh" } ``` @@ -40,6 +42,13 @@ Fields: - `both` (default): users can toggle between `m2m` and `user` - `user`: pinned to `user` mode (toggle disabled) - `m2m`: pinned to `m2m` mode (toggle disabled) +- `default` (optional): marks this type as the default session type + - if multiple types set `default: true`, the app selects one by ordering rules and logs a warning + - if omitted for all custom types, built-in `terminal` remains default +- `order` (optional): integer ordering hint for picker/tab ordering + - lower values appear first + - ordered types appear before unordered types + - tie-breakers: `default` then name - `entrypoint` (optional): launch script path relative to type folder, default `launch.sh` ## `launch.sh` diff --git a/test/terminalTypes.registry.test.ts b/test/terminalTypes.registry.test.ts new file mode 100644 index 0000000..8501545 --- /dev/null +++ b/test/terminalTypes.registry.test.ts @@ -0,0 +1,117 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { Logger } from "../src/logging/logger.js"; +import { loadTerminalTypeRegistry } from "../src/terminalTypes/registry.js"; + +type TypeManifest = { + id?: string; + name: string; + description?: string; + badge?: string; + icon?: string; + authPolicy?: "both" | "user" | "m2m"; + default?: boolean; + order?: number; + entrypoint?: string; +}; + +function makeLogger(): Logger { + return new Logger({ + appName: "databricks-app-terminal-test", + level: "error", + }); +} + +async function writeTerminalType(root: string, folderName: string, manifest: TypeManifest): Promise { + const typeDir = path.join(root, folderName); + await fs.mkdir(typeDir, { recursive: true }); + + await fs.writeFile(path.join(typeDir, "type.json"), JSON.stringify(manifest, null, 2)); + + const entrypoint = manifest.entrypoint || "launch.sh"; + await fs.writeFile(path.join(typeDir, entrypoint), "#!/usr/bin/env bash\n"); +} + +test("custom default terminal type overrides built-in terminal default", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "dbx-terminal-types-")); + t.after(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + await writeTerminalType(root, "caspersai", { + name: "CaspersAI", + authPolicy: "user", + default: true, + }); + + const registry = await loadTerminalTypeRegistry(root, makeLogger()); + + const defaultType = registry.getDefaultType(); + assert.equal(defaultType.id, "caspersai"); + + const listed = registry.listTypes(); + const defaults = listed.filter((type) => type.default); + assert.equal(defaults.length, 1); + assert.equal(defaults[0]?.id, "caspersai"); + + const terminal = listed.find((type) => type.id === "terminal"); + assert.equal(terminal?.default, false); +}); + +test("order field controls session type ordering", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "dbx-terminal-types-")); + t.after(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + await writeTerminalType(root, "third", { + name: "Third", + order: 30, + }); + + await writeTerminalType(root, "first", { + name: "First", + order: -10, + }); + + await writeTerminalType(root, "unordered", { + name: "Unordered", + }); + + const registry = await loadTerminalTypeRegistry(root, makeLogger()); + const ids = registry.listTypes().map((type) => type.id); + + assert.deepEqual(ids.slice(0, 4), ["first", "third", "terminal", "unordered"]); +}); + +test("multiple custom defaults resolve to one selected by ordering", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "dbx-terminal-types-")); + t.after(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + await writeTerminalType(root, "slow-default", { + name: "Slow Default", + default: true, + order: 100, + }); + + await writeTerminalType(root, "fast-default", { + name: "Fast Default", + default: true, + order: 1, + }); + + const registry = await loadTerminalTypeRegistry(root, makeLogger()); + + const defaultType = registry.getDefaultType(); + assert.equal(defaultType.id, "fast-default"); + + const listed = registry.listTypes(); + const defaults = listed.filter((type) => type.default); + assert.equal(defaults.length, 1); + assert.equal(defaults[0]?.id, "fast-default"); +});