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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-id>/type.json`
- `terminal-types/<type-id>/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.
Expand Down
57 changes: 47 additions & 10 deletions public/app/sessionTypesModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -28,6 +59,7 @@ function fallbackType(typeId) {
icon: undefined,
authPolicy: "both",
default: false,
order: undefined,
builtIn: false,
};
}
Expand All @@ -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),
})),
);
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions public/app/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const DEFAULT_SESSION_TYPES = [
icon: "⌂",
authPolicy: "both",
default: true,
order: 0,
builtIn: true,
},
];
Expand Down
86 changes: 72 additions & 14 deletions src/terminalTypes/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand All @@ -40,6 +42,7 @@ class InMemoryTerminalTypeRegistry implements TerminalTypeRegistry {
constructor(
private readonly types: ResolvedTerminalType[],
private readonly typeById: Map<string, ResolvedTerminalType>,
private readonly defaultType: ResolvedTerminalType,
) {}

listTypes(): TerminalType[] {
Expand All @@ -56,7 +59,7 @@ class InMemoryTerminalTypeRegistry implements TerminalTypeRegistry {
}

getDefaultType(): ResolvedTerminalType {
return { ...BASE_TERMINAL_TYPE };
return { ...this.defaultType };
}
}

Expand All @@ -78,11 +81,69 @@ async function isEntrypointFile(candidatePath: string): Promise<boolean> {
}
}

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<TerminalTypeRegistry> {
const map = new Map<string, ResolvedTerminalType>([[BASE_TERMINAL_TYPE.id, BASE_TERMINAL_TYPE]]);
const baseType = { ...BASE_TERMINAL_TYPE };
const map = new Map<string, ResolvedTerminalType>([[baseType.id, baseType]]);

let entries: import("node:fs").Dirent[] = [];

Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions src/terminalTypes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type TerminalType = {
icon?: string;
authPolicy: TerminalTypeAuthPolicy;
default: boolean;
order?: number;
builtIn: boolean;
};

Expand Down
9 changes: 9 additions & 0 deletions terminal-types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ terminal-types/<type-id>/
"badge": "claude",
"icon": "✶",
"authPolicy": "both",
"default": false,
"order": 20,
"entrypoint": "launch.sh"
}
```
Expand All @@ -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`
Expand Down
Loading