From 9d9033d3b0a815ccf90759ea6a6ab377d49560a6 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 12:21:37 -0400 Subject: [PATCH 01/25] feat(launcher): add named envs with --env and envs list|new A named env is a gentle-shell-owned agent home under ~/.gentle-shell/envs/ (GENTLE_SHELL_ENVS_DIR overrides the root). `--env ` selects it like --home, is exclusive with the other home selectors, and never creates an unknown env. `envs list` prints envs with their setup state and marks the persisted default; `envs new` creates one with the same bootstrap as a first --home launch. Resume and remediation hints reproduce `--env `. --- bin/gentle-shell.mjs | 145 ++++++++++++++++++++++ docs/readme-reference.md | 17 ++- lib/gentle-shell-envs.ts | 144 +++++++++++++++++++++ lib/gentle-shell-launcher.ts | 47 ++++++- runtime/gentle-shell-envs.mjs | 145 ++++++++++++++++++++++ runtime/gentle-shell-launcher.mjs | 45 ++++++- scripts/build-runtime-modules.mjs | 1 + scripts/verify-package-files.mjs | 2 + tests/gentle-shell-bin.test.ts | 45 +++++++ tests/gentle-shell-envs.test.ts | 138 ++++++++++++++++++++ tests/gentle-shell-launcher.test.ts | 1 + tests/gentle-shell-startup-update.test.ts | 4 +- 12 files changed, 722 insertions(+), 12 deletions(-) create mode 100644 lib/gentle-shell-envs.ts create mode 100644 runtime/gentle-shell-envs.mjs create mode 100644 tests/gentle-shell-envs.test.ts diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index 905599b4b..e77bb40bf 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -105,6 +105,12 @@ import { } from "../runtime/gentle-shell-resume-hint.mjs"; import { bundledGentleAiPaths, GENTLE_AI_VERSION, gentleAiBinaryPath, gentleAiIsolationEnv, PackageLocalGentleAiBinaryMissingError } from "../runtime/gentle-ai-binary.mjs"; import { DEFAULT_THEME_NAME, installIsolatedTuiModeSetting } from "../scripts/install-tui-mode-setting.mjs"; +import { + envDir, + envsRoot, + summarizeEnv, + validateEnvName, +} from "../runtime/gentle-shell-envs.mjs"; import { artifactFor, engramPinVersion } from "../scripts/installer-downloads.mjs"; import { migrateBundledEngram, migrationPending, supportsBundledSessionOwned, MIGRATION_PENDING_NOTICE, MIGRATION_USAGE } from "../scripts/bundled-engram-migration.mjs"; import { shouldCheckStartupUpdate, findStartupUpdate } from "../runtime/gentle-shell-startup-update.mjs"; @@ -406,6 +412,7 @@ function emptyArgs() { link: false, isolated: false, home: undefined, + env: undefined, packageRoot: undefined, help: false, version: false, @@ -689,6 +696,135 @@ async function handleImportPiCommand(args) { process.exitCode = result.failed ? 1 : 0; } +// --- named envs (`gentle-shell envs`, lib/gentle-shell-envs*.ts) ------------- + +function currentEnvsRoot() { + return envsRoot(process.env, homedir()); +} + +function listEnvNames(root) { + let entries; + try { + entries = readdirSync(root, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + return entries + .filter((entry) => entry.isDirectory() && validateEnvName(entry.name) === undefined) + .map((entry) => entry.name) + .sort(); +} + +// Session files live in /sessions//*.jsonl; two levels +// are walked, never deeper, so a huge home cannot stall the manager. +function sessionStats(dir) { + let count = 0; + let last = 0; + const visit = (path, depth) => { + let entries; + try { + entries = readdirSync(path, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const child = join(path, entry.name); + if (entry.isDirectory() && depth < 2) visit(child, depth + 1); + else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + count += 1; + try { + last = Math.max(last, statSync(child).mtimeMs); + } catch { + // Vanished mid-walk: still counted, just undated. + } + } + } + }; + visit(join(dir, "sessions"), 1); + return { count, last: last > 0 ? new Date(last).toISOString() : undefined }; +} + +function readTextOrUndefined(path) { + try { + return readJsonIfExists(path); + } catch { + return undefined; + } +} + +function loadEnvSummaries(root) { + const rawConfig = readRawConfig(resolveConfigPath()); + const defaultHome = typeof rawConfig.home === "string" && rawConfig.home !== "link" && rawConfig.home !== "isolated" ? rawConfig.home : undefined; + const defaultReal = defaultHome === undefined ? undefined : safeRealpath(resolvePath(defaultHome)); + return listEnvNames(root).map((name) => { + const dir = envDir(root, name); + const real = safeRealpath(dir); + const sessions = sessionStats(dir); + return summarizeEnv({ + name, + dir, + isDefault: defaultReal !== undefined && defaultReal === real, + settingsText: readTextOrUndefined(join(dir, "settings.json")), + authText: readTextOrUndefined(join(dir, "auth.json")), + provisioned: provisionedEntry(rawConfig, real), + sessionCount: sessions.count, + lastSessionAt: sessions.last, + }); + }); +} + +// Same bootstrap a first `--home` launch performs, so the env is owned by +// gentle-shell and auto-provisions on its first launch. +async function createEnv(root, name) { + const dir = envDir(root, name); + if (existsSync(dir)) throw new Error(`env "${name}" already exists`); + mkdirSync(root, { recursive: true, mode: 0o700 }); + mkdirSync(dir, { mode: 0o700 }); + await installIsolatedTuiModeSetting(dir); + writeHomeOwnershipMarker({ mode: "path", dir }); + return dir; +} + +function printEnvList(root) { + const envs = loadEnvSummaries(root); + if (envs.length === 0) { + process.stdout.write(`No envs in ${root}. Create one with 'gentle-shell envs new '.\n`); + return; + } + const width = Math.max(...envs.map((env) => env.name.length)); + for (const env of envs) { + process.stdout.write(`${env.isDefault ? "*" : " "} ${env.name.padEnd(width)} ${env.dir} (${env.provisioned})\n`); + } +} + +const ENVS_USAGE = "Usage: gentle-shell envs [list | new ]\n envs list envs\n envs list print every env; * marks the default\n envs new create an env without launching it"; + +async function handleEnvsCommand(commandArgs) { + const root = currentEnvsRoot(); + const [sub, ...rest] = commandArgs; + if (sub === "list" && rest.length === 0) { + printEnvList(root); + return; + } + if (sub === "new" && rest.length === 1) { + const invalid = validateEnvName(rest[0]); + if (invalid !== undefined) fail(`gentle-shell: ${invalid}`, 2); + try { + process.stdout.write(`Created env ${rest[0]} at ${await createEnv(root, rest[0])}\n`); + } catch (error) { + fail(`gentle-shell: ${error.message}`, 1); + } + return; + } + if (sub === "--help" || sub === "-h") { + process.stdout.write(`${ENVS_USAGE}\n`); + return; + } + if (sub !== undefined) fail(`${ENVS_USAGE}`, 2); + printEnvList(root); +} + // Test/development-only override for the setup subcommand's gentle-ai // executable path. Lets a test point at a stub script (or a deliberately // missing path) without touching the real pinned .gentle-ai/v/gentle-ai @@ -1894,6 +2030,10 @@ async function main() { else handleHomeCommand(args.commandArgs); return; } + if (args.command === "envs") { + await handleEnvsCommand(args.commandArgs); + return; + } // The verification input applies to `--version` only (scripts/bundled-install.mjs upgradeBundled). const verifying = args.version && args.command === undefined ? process.env[BUNDLED_VERIFY_ENV] : undefined; const bundled = resolveBundledInstall({ packageRoot, platform: process.platform, fs: bundledFs, verifying }); @@ -1931,6 +2071,11 @@ async function main() { const config = loadConfig(); let home = resolveHome({ args, env: process.env, homedir: homedir(), config }); if (home.mode === "path") home = { ...home, dir: resolvePath(home.dir) }; + // An explicit --env never creates a home: a typo must not silently become + // a new, empty env. A persisted default keeps the ordinary bootstrap. + if (args.env !== undefined && !existsSync(home.dir)) { + fail(`gentle-shell: unknown env "${args.env}" (${home.dir}).\nCreate it with 'gentle-shell envs new ${args.env}' or manage envs with 'gentle-shell envs'.`, 2); + } // Version and help runs (Shell's own or forwarded to Pi) never probe Engram, // ensure the companion, print memory notices or touch provenance. diff --git a/docs/readme-reference.md b/docs/readme-reference.md index fcf08f0d3..064ca7f5a 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -297,6 +297,7 @@ An orphan branch with commits and no parent has no branch point to name as `base ```bash gentle-shell [options] [-- pi-args...] gentle-shell home [link|isolated|] +gentle-shell envs [list | new ] gentle-shell [home selectors] setup [--dry-run] gentle-shell upgrade [--channel release|main] gentle-shell upgrade --rollback @@ -310,12 +311,13 @@ gentle-shell self-uninstall [--dry-run] [--yes] [--include-shared] | `--link` | Home is your own pi home: `GENTLE_SHELL_USER_PI_HOME` when a Gentle Shell session recorded it (so `--link` run from inside an isolated session reaches your original pi, not the isolated home), otherwise `PI_CODING_AGENT_DIR` or `~/.pi/agent`. Reuses your existing pi sign-ins, models, and chats; never writes to its `settings.json`. | | `--isolated` | Home is `GENTLE_SHELL_HOME` or `~/.gentle-shell/agent`, with its own settings in `~/.gentle-shell/gentle-ai` ([config home](#config-home)); a custom `GENTLE_SHELL_HOME` outside a `.gentle-shell` folder keeps `~/.pi/gentle-ai`. No credential seeding. Default when nothing else is configured. | | `--home ` | Home is the given directory. | +| `--env ` | Home is the named env `GENTLE_SHELL_ENVS_DIR/` (default `~/.gentle-shell/envs/`). The env must already exist; an unknown name exits 2 instead of creating a new home. See "Named envs" below. | | `--package-root ` | Force this directory as the gentle-pi package to load, taking over from any conflicting package the target `settings.json` already declares (see "Loading the package" below). | | `--help`, `-h` | Print usage (flags, commands, env vars) and exit 0. | | `--version` | Print `gentle-shell `, `pi `, and `home `, then exit 0. | | `--` | Everything after is forwarded to pi verbatim, even text that looks like a `gentle-shell` flag. | -`--link`, `--isolated`, and `--home` are mutually exclusive; combining two is a usage error, as is `--home` or `--home=` with an empty value. Effective-home precedence: an explicit flag wins, then the persisted `home` subcommand choice, then the `--isolated` default. Every argument gentle-shell does not recognize — `--mode rpc`, `-p "..."`, etc. — is forwarded to pi unchanged. +`--link`, `--isolated`, `--home`, and `--env` are mutually exclusive; combining two is a usage error, as is `--home` or `--home=` with an empty value. Effective-home precedence: an explicit flag wins, then the persisted `home` subcommand choice, then the `--isolated` default. Every argument gentle-shell does not recognize — `--mode rpc`, `-p "..."`, etc. — is forwarded to pi unchanged. ### Config home @@ -368,6 +370,19 @@ It never overwrites, never follows a symbolic link, never copies `packages`, `ex Tradeoff: `--link` shares one credential store with pi; `import-pi` duplicates it. A provider that rotates refresh tokens on use may sign out whichever copy refreshes second; run `/login` again in that home. +### Named envs (`envs` subcommand, `--env`) + +A named env is a separate agent home managed by gentle-shell under `~/.gentle-shell/envs//` (`GENTLE_SHELL_ENVS_DIR` overrides the root). Each env has its own sign-ins, models, packages, settings, sessions, and subagent history. The directory is the registry: there is no separate list. Names are 1-40 lowercase letters, digits, `.`, `_`, or `-`, starting with a letter or digit. + +| Command | Effect | +| --- | --- | +| `gentle-shell envs` | Same as `envs list`. | +| `gentle-shell envs list` | Prints every env and its setup state; `*` marks the default. | +| `gentle-shell envs new ` | Creates the env without launching it. Its first launch installs the companion packages, the same as any new `--home`. | +| `gentle-shell --env [...]` | Launches, sets up (`--env setup`), or runs pi subcommands (`--env install npm:x`) in that env. | + +The default env is stored as the ordinary persisted `home` path, so a plain `gentle-shell` opens it, and `gentle-shell home link|isolated|` replaces it. Envs are separate homes, not sandboxes. They share the repository you work in, `~/.pi/gentle-ai/persona.json`, and `~/.gentle-ai/state.json`, like every other home. `self-uninstall` does not remove `~/.gentle-shell/envs`. + ### Managing packages `gentle-shell install npm:`, `gentle-shell remove ...`, `gentle-shell list`, `gentle-shell update ...`, `gentle-shell config`, and `gentle-shell auth ...` run pi's own commands against the resolved home — the `--isolated` home by default, or your own pi home with `--link`. A launcher flag before the subcommand (`--link`, `--isolated`, `--home `) still selects which home the subcommand runs against. Running `gentle-shell install npm:gentle-pi` inside the isolated home is unnecessary: the launcher already loads the Gentle Shell package itself (see "Loading the package" below). diff --git a/lib/gentle-shell-envs.ts b/lib/gentle-shell-envs.ts new file mode 100644 index 000000000..38e9e53ec --- /dev/null +++ b/lib/gentle-shell-envs.ts @@ -0,0 +1,144 @@ +import { basename, dirname, join, resolve as resolvePath } from "node:path"; + +// Named environments for the gentle-shell launcher: pure, side-effect-free +// helpers over injected env/config/file text. An env is an ordinary managed +// `--home` directory under the envs root; the directory itself is the +// registry, so there is no separate list to drift out of sync. + +export const ENVS_DIR_ENV = "GENTLE_SHELL_ENVS_DIR"; + +const ENV_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,39}$/; + +// Lowercase only, so names stay identical on case-insensitive filesystems; +// the leading character excludes `.`, `..`, hidden dirs and flag-like names. +export function validateEnvName(name: string): string | undefined { + if (ENV_NAME_PATTERN.test(name)) return undefined; + return `invalid env name "${name}": use 1-40 lowercase letters, digits, ".", "_" or "-", starting with a letter or digit`; +} + +export function envsRoot(env: Record, homedir: string): string { + const override = env[ENVS_DIR_ENV]; + return override !== undefined && override.length > 0 ? override : join(homedir, ".gentle-shell", "envs"); +} + +export function envDir(root: string, name: string): string { + return join(root, name); +} + +// The env name a home directory stands for, or undefined when it is not a +// direct, validly named child of the envs root (any other --home path). +export function envNameForDir(root: string, dir: string): string | undefined { + const resolved = resolvePath(dir); + if (dirname(resolved) !== resolvePath(root)) return undefined; + const name = basename(resolved); + return validateEnvName(name) === undefined ? name : undefined; +} + +// --- config.json mutations --------------------------------------------------- +// All return a new object, keep unknown keys, and never mutate their input. +// `aliases` lists every spelling of the env dir (as given and its realpath), +// because provisioned markers are keyed by realpath. + +type RawConfig = Record; + +function provisionedRecord(config: RawConfig): Record | undefined { + const value = config.provisioned; + return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : undefined; +} + +export function setDefaultEnvInConfig(config: RawConfig, dir: string): RawConfig { + return { ...config, home: dir }; +} + +// `newProvisionedKey` is the realpath of `newDir` (provisioned markers are +// realpath-keyed); `home` keeps the plain path so it still names the env. +export function renameEnvInConfig(config: RawConfig, aliases: readonly string[], newDir: string, newProvisionedKey: string = newDir): RawConfig { + const next: RawConfig = { ...config }; + if (typeof config.home === "string" && aliases.includes(config.home)) next.home = newDir; + const provisioned = provisionedRecord(config); + if (provisioned !== undefined) { + const moved: Record = {}; + let marker: unknown; + for (const [key, value] of Object.entries(provisioned)) { + if (aliases.includes(key)) marker ??= value; + else moved[key] = value; + } + if (marker !== undefined) moved[newProvisionedKey] = marker; + next.provisioned = moved; + } + return next; +} + +export function removeEnvFromConfig(config: RawConfig, aliases: readonly string[]): RawConfig { + const next: RawConfig = { ...config }; + if (typeof config.home === "string" && aliases.includes(config.home)) delete next.home; + const provisioned = provisionedRecord(config); + if (provisioned !== undefined) { + next.provisioned = Object.fromEntries(Object.entries(provisioned).filter(([key]) => !aliases.includes(key))); + } + return next; +} + +// --- per-env info ------------------------------------------------------------ + +export interface EnvSummaryInput { + name: string; + dir: string; + isDefault: boolean; + settingsText?: string; + authText?: string; + provisioned?: { gentleAi: string; gentlePi?: string; at: string }; + sessionCount: number; + lastSessionAt?: string; +} + +export interface EnvSummary { + name: string; + dir: string; + isDefault: boolean; + packages: string[]; + // Provider ids only: credential values never leave auth.json. + providers: string[]; + provisioned: string; + sessionCount: number; + lastSessionAt?: string; +} + +function parseObject(text: string | undefined): Record | undefined { + if (text === undefined) return undefined; + try { + const parsed: unknown = JSON.parse(text); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Record) : undefined; + } catch { + return undefined; + } +} + +function packageSources(settingsText: string | undefined): string[] { + const packages = parseObject(settingsText)?.packages; + if (!Array.isArray(packages)) return []; + const sources: string[] = []; + for (const entry of packages) { + if (typeof entry === "string") sources.push(entry); + else if (typeof entry === "object" && entry !== null && typeof (entry as { source?: unknown }).source === "string") { + sources.push((entry as { source: string }).source); + } + } + return sources; +} + +export function summarizeEnv(input: EnvSummaryInput): EnvSummary { + const p = input.provisioned; + const provisioned = + p === undefined ? "not provisioned" : `gentle-ai ${p.gentleAi} · gentle-pi ${p.gentlePi ?? "unknown"} · ${p.at.slice(0, 10)}`; + return { + name: input.name, + dir: input.dir, + isDefault: input.isDefault, + packages: packageSources(input.settingsText), + providers: Object.keys(parseObject(input.authText) ?? {}).sort(), + provisioned, + sessionCount: input.sessionCount, + lastSessionAt: input.lastSessionAt, + }; +} diff --git a/lib/gentle-shell-launcher.ts b/lib/gentle-shell-launcher.ts index 37a47273e..fcbb1c8fe 100644 --- a/lib/gentle-shell-launcher.ts +++ b/lib/gentle-shell-launcher.ts @@ -1,12 +1,13 @@ import { basename, dirname, isAbsolute, join, resolve as resolvePath } from "node:path"; import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection, type ChildPackageInjection } from "./child-package-injection.ts"; import { GENTLE_SHELL_CONFIG_HOME_ENV } from "./gentle-shell-bundled.ts"; +import { envDir, envNameForDir, envsRoot, validateEnvName } from "./gentle-shell-envs.ts"; // The gentle-shell launcher: pure, side-effect-free functions over injected // env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process, // filesystem and child process so this module stays fully unit-testable. -export type LauncherCommand = "home" | "setup" | "upgrade" | "self-uninstall" | "memory"; +export type LauncherCommand = "home" | "envs" | "setup" | "upgrade" | "self-uninstall" | "memory"; // pi's own package-management subcommands (see pi's cli/args.ts printHelp // "Commands" list): each is dispatched by pi itself, before pi's own flag @@ -23,6 +24,8 @@ export interface ParsedLauncherArgs { link: boolean; isolated: boolean; home?: string; + // Named env selected with `--env ` (see lib/gentle-shell-envs.ts). + env?: string; packageRoot?: string; help: boolean; version: boolean; @@ -42,11 +45,12 @@ export interface ParsedLauncherArgs { // untouched as commandArgs — T2 owns interpreting `home link|isolated|`. // `self-uninstall` is not `uninstall`, which stays pi's own alias for `remove`. export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs { - if (argv[0] === "home" || argv[0] === "upgrade" || argv[0] === "self-uninstall" || argv[0] === "memory") { + if (argv[0] === "home" || argv[0] === "envs" || argv[0] === "upgrade" || argv[0] === "self-uninstall" || argv[0] === "memory") { return { link: false, isolated: false, home: undefined, + env: undefined, packageRoot: undefined, help: false, version: false, @@ -61,6 +65,7 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs { let link = false; let isolated = false; let home: string | undefined; + let env: string | undefined; let packageRoot: string | undefined; let help = false; let version = false; @@ -119,6 +124,22 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs { i += 1; continue; } + if (arg === "--env" || arg.startsWith("--env=")) { + const inline = arg.startsWith("--env="); + const value = inline ? arg.slice("--env=".length) : argv[i + 1]; + if (!inline && value !== undefined) i += 1; + if (value === undefined || value.length === 0) { + error = "--env requires a non-empty env name"; + continue; + } + const invalid = validateEnvName(value); + if (invalid !== undefined) { + error = invalid; + continue; + } + env = value; + continue; + } if (arg.startsWith("--package-root=")) { const value = arg.slice("--package-root=".length); if (value.length === 0) { @@ -166,7 +187,13 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs { } if (error === undefined) { - if (link && isolated) { + if (env !== undefined && link) { + error = "--env cannot be combined with --link"; + } else if (env !== undefined && isolated) { + error = "--env cannot be combined with --isolated"; + } else if (env !== undefined && home !== undefined) { + error = "--env cannot be combined with --home"; + } else if (link && isolated) { error = "--link cannot be combined with --isolated"; } else if (link && home !== undefined) { error = "--link cannot be combined with --home"; @@ -175,7 +202,7 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs { } } - return { link, isolated, home, packageRoot, help, version, command, commandArgs, passthrough, piSubcommand, error }; + return { link, isolated, home, env, packageRoot, help, version, command, commandArgs, passthrough, piSubcommand, error }; } // --- home resolution ------------------------------------------------------- @@ -187,6 +214,8 @@ export interface ResolvedHome { mode: HomeMode; dir: string; source: HomeSource; + // Set for a `path` home that is a named env, so hints reproduce `--env`. + env?: string; } // A discriminated union instead of a plain `home: string` field: `resolveHome` @@ -300,11 +329,13 @@ export function resolveHome(input: ResolveHomeInput): ResolvedHome { if (args.link) return { mode: "link", dir: userPiHome(env, homedir), source: "flag" }; if (args.isolated) return { mode: "isolated", dir: isolatedDir(env, homedir), source: "flag" }; if (args.home !== undefined) return { mode: "path", dir: args.home, source: "flag" }; + if (args.env !== undefined) return { mode: "path", dir: envDir(envsRoot(env, homedir), args.env), source: "flag", env: args.env }; if (config !== undefined) { if (config.mode === "link") return { mode: "link", dir: userPiHome(env, homedir), source: "config" }; if (config.mode === "isolated") return { mode: "isolated", dir: isolatedDir(env, homedir), source: "config" }; - return { mode: "path", dir: config.dir, source: "config" }; + const name = envNameForDir(envsRoot(env, homedir), config.dir); + return name === undefined ? { mode: "path", dir: config.dir, source: "config" } : { mode: "path", dir: config.dir, source: "config", env: name }; } return { mode: "isolated", dir: isolatedDir(env, homedir), source: "default" }; @@ -320,6 +351,7 @@ export function resolveHome(input: ResolveHomeInput): ResolvedHome { // gentle-shell's own default when no selector is given. export function homeSelectorFlags(home: ResolvedHome): string[] { if (home.mode === "link") return ["--link"]; + if (home.mode === "path" && home.env !== undefined) return ["--env", home.env]; if (home.mode === "path") return ["--home", home.dir]; return []; } @@ -1290,6 +1322,7 @@ export function helpText(): string { "Usage: gentle-shell [options] [-- pi-args...]", " gentle-shell home [link|isolated|]", " gentle-shell [home selectors] home import-pi [--dry-run]", + " gentle-shell envs [list | new ]", " gentle-shell [home selectors] setup [--dry-run]", " gentle-shell upgrade [--channel release|main]", " gentle-shell upgrade --rollback", @@ -1304,6 +1337,7 @@ export function helpText(): string { " --isolated Use the dedicated ~/.gentle-shell/agent home (default), with its own", " settings in ~/.gentle-shell/gentle-ai (copied once from ~/.pi/gentle-ai).", " --home Use a custom agent home directory.", + " --env Use the named env ~/.gentle-shell/envs/ (must already exist).", " --package-root Force this directory as the gentle-pi package to load, taking over", " from any conflicting package the target settings.json already declares.", " --help, -h Show this help text.", @@ -1316,6 +1350,8 @@ export function helpText(): string { " level when it has none. Never overwrites a file or setting, never copies", " packages, extensions or caches, and never changes your pi home.", " --dry-run only shows the plan. Refuses --link (nothing to import).", + " envs List named envs (* = default); 'envs new ' creates one.", + " Envs are separate homes, not sandboxes.", " setup Provision the resolved home with the gentle-ai companion packages", " (runs the package-local gentle-ai 'install --agent pi --scope global').", " Accepts --dry-run, forwarded to gentle-ai. Accepts a home selector", @@ -1368,6 +1404,7 @@ export function helpText(): string { " inside a .gentle-shell folder, its settings live in the gentle-ai", " folder next to it; elsewhere they stay in ~/.pi/gentle-ai.", " GENTLE_PI_CONFIG_HOME Gentle Shell settings folder for every home, overriding the above.", + " GENTLE_SHELL_ENVS_DIR Directory holding named envs (default: ~/.gentle-shell/envs).", " PI_CODING_AGENT_DIR Directory for the --link home, shared with pi itself.", "", "Every other argument is forwarded to pi unchanged.", diff --git a/runtime/gentle-shell-envs.mjs b/runtime/gentle-shell-envs.mjs new file mode 100644 index 000000000..accc5b99c --- /dev/null +++ b/runtime/gentle-shell-envs.mjs @@ -0,0 +1,145 @@ +// Generated by scripts/build-runtime-modules.mjs. Do not edit. +import { basename, dirname, join, resolve as resolvePath } from "node:path"; + +// Named environments for the gentle-shell launcher: pure, side-effect-free +// helpers over injected env/config/file text. An env is an ordinary managed +// `--home` directory under the envs root; the directory itself is the +// registry, so there is no separate list to drift out of sync. + +export const ENVS_DIR_ENV = "GENTLE_SHELL_ENVS_DIR"; + +const ENV_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,39}$/; + +// Lowercase only, so names stay identical on case-insensitive filesystems; +// the leading character excludes `.`, `..`, hidden dirs and flag-like names. +export function validateEnvName(name ) { + if (ENV_NAME_PATTERN.test(name)) return undefined; + return `invalid env name "${name}": use 1-40 lowercase letters, digits, ".", "_" or "-", starting with a letter or digit`; +} + +export function envsRoot(env , homedir ) { + const override = env[ENVS_DIR_ENV]; + return override !== undefined && override.length > 0 ? override : join(homedir, ".gentle-shell", "envs"); +} + +export function envDir(root , name ) { + return join(root, name); +} + +// The env name a home directory stands for, or undefined when it is not a +// direct, validly named child of the envs root (any other --home path). +export function envNameForDir(root , dir ) { + const resolved = resolvePath(dir); + if (dirname(resolved) !== resolvePath(root)) return undefined; + const name = basename(resolved); + return validateEnvName(name) === undefined ? name : undefined; +} + +// --- config.json mutations --------------------------------------------------- +// All return a new object, keep unknown keys, and never mutate their input. +// `aliases` lists every spelling of the env dir (as given and its realpath), +// because provisioned markers are keyed by realpath. + + + +function provisionedRecord(config ) { + const value = config.provisioned; + return typeof value === "object" && value !== null && !Array.isArray(value) ? (value ) : undefined; +} + +export function setDefaultEnvInConfig(config , dir ) { + return { ...config, home: dir }; +} + +// `newProvisionedKey` is the realpath of `newDir` (provisioned markers are +// realpath-keyed); `home` keeps the plain path so it still names the env. +export function renameEnvInConfig(config , aliases , newDir , newProvisionedKey = newDir) { + const next = { ...config }; + if (typeof config.home === "string" && aliases.includes(config.home)) next.home = newDir; + const provisioned = provisionedRecord(config); + if (provisioned !== undefined) { + const moved = {}; + let marker ; + for (const [key, value] of Object.entries(provisioned)) { + if (aliases.includes(key)) marker ??= value; + else moved[key] = value; + } + if (marker !== undefined) moved[newProvisionedKey] = marker; + next.provisioned = moved; + } + return next; +} + +export function removeEnvFromConfig(config , aliases ) { + const next = { ...config }; + if (typeof config.home === "string" && aliases.includes(config.home)) delete next.home; + const provisioned = provisionedRecord(config); + if (provisioned !== undefined) { + next.provisioned = Object.fromEntries(Object.entries(provisioned).filter(([key]) => !aliases.includes(key))); + } + return next; +} + +// --- per-env info ------------------------------------------------------------ + + + + + + + + + + + + + + + + + + + + + + + + +function parseObject(text ) { + if (text === undefined) return undefined; + try { + const parsed = JSON.parse(text); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed ) : undefined; + } catch { + return undefined; + } +} + +function packageSources(settingsText ) { + const packages = parseObject(settingsText)?.packages; + if (!Array.isArray(packages)) return []; + const sources = []; + for (const entry of packages) { + if (typeof entry === "string") sources.push(entry); + else if (typeof entry === "object" && entry !== null && typeof (entry ).source === "string") { + sources.push((entry ).source); + } + } + return sources; +} + +export function summarizeEnv(input ) { + const p = input.provisioned; + const provisioned = + p === undefined ? "not provisioned" : `gentle-ai ${p.gentleAi} · gentle-pi ${p.gentlePi ?? "unknown"} · ${p.at.slice(0, 10)}`; + return { + name: input.name, + dir: input.dir, + isDefault: input.isDefault, + packages: packageSources(input.settingsText), + providers: Object.keys(parseObject(input.authText) ?? {}).sort(), + provisioned, + sessionCount: input.sessionCount, + lastSessionAt: input.lastSessionAt, + }; +} diff --git a/runtime/gentle-shell-launcher.mjs b/runtime/gentle-shell-launcher.mjs index ad8d14d8f..bf55f7fa0 100644 --- a/runtime/gentle-shell-launcher.mjs +++ b/runtime/gentle-shell-launcher.mjs @@ -2,6 +2,7 @@ import { basename, dirname, isAbsolute, join, resolve as resolvePath } from "node:path"; import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection, } from "./child-package-injection.mjs"; import { GENTLE_SHELL_CONFIG_HOME_ENV } from "./gentle-shell-bundled.mjs"; +import { envDir, envNameForDir, envsRoot, validateEnvName } from "./gentle-shell-envs.mjs"; // The gentle-shell launcher: pure, side-effect-free functions over injected // env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process, @@ -31,6 +32,8 @@ function isPiSubcommand(token ) { + +   @@ -43,11 +46,12 @@ function isPiSubcommand(token ) { // untouched as commandArgs — T2 owns interpreting `home link|isolated|`. // `self-uninstall` is not `uninstall`, which stays pi's own alias for `remove`. export function parseLauncherArgs(argv ) { - if (argv[0] === "home" || argv[0] === "upgrade" || argv[0] === "self-uninstall" || argv[0] === "memory") { + if (argv[0] === "home" || argv[0] === "envs" || argv[0] === "upgrade" || argv[0] === "self-uninstall" || argv[0] === "memory") { return { link: false, isolated: false, home: undefined, + env: undefined, packageRoot: undefined, help: false, version: false, @@ -62,6 +66,7 @@ export function parseLauncherArgs(argv ) { let link = false; let isolated = false; let home ; + let env ; let packageRoot ; let help = false; let version = false; @@ -120,6 +125,22 @@ export function parseLauncherArgs(argv ) { i += 1; continue; } + if (arg === "--env" || arg.startsWith("--env=")) { + const inline = arg.startsWith("--env="); + const value = inline ? arg.slice("--env=".length) : argv[i + 1]; + if (!inline && value !== undefined) i += 1; + if (value === undefined || value.length === 0) { + error = "--env requires a non-empty env name"; + continue; + } + const invalid = validateEnvName(value); + if (invalid !== undefined) { + error = invalid; + continue; + } + env = value; + continue; + } if (arg.startsWith("--package-root=")) { const value = arg.slice("--package-root=".length); if (value.length === 0) { @@ -167,7 +188,13 @@ export function parseLauncherArgs(argv ) { } if (error === undefined) { - if (link && isolated) { + if (env !== undefined && link) { + error = "--env cannot be combined with --link"; + } else if (env !== undefined && isolated) { + error = "--env cannot be combined with --isolated"; + } else if (env !== undefined && home !== undefined) { + error = "--env cannot be combined with --home"; + } else if (link && isolated) { error = "--link cannot be combined with --isolated"; } else if (link && home !== undefined) { error = "--link cannot be combined with --home"; @@ -176,7 +203,7 @@ export function parseLauncherArgs(argv ) { } } - return { link, isolated, home, packageRoot, help, version, command, commandArgs, passthrough, piSubcommand, error }; + return { link, isolated, home, env, packageRoot, help, version, command, commandArgs, passthrough, piSubcommand, error }; } // --- home resolution ------------------------------------------------------- @@ -190,6 +217,8 @@ export function parseLauncherArgs(argv ) { + + // A discriminated union instead of a plain `home: string` field: `resolveHome` // switches on `mode` rather than re-parsing the raw on-disk string, and the // `path` case carries its `dir` explicitly so a "link"/"isolated" string can @@ -301,11 +330,13 @@ export function resolveHome(input ) { if (args.link) return { mode: "link", dir: userPiHome(env, homedir), source: "flag" }; if (args.isolated) return { mode: "isolated", dir: isolatedDir(env, homedir), source: "flag" }; if (args.home !== undefined) return { mode: "path", dir: args.home, source: "flag" }; + if (args.env !== undefined) return { mode: "path", dir: envDir(envsRoot(env, homedir), args.env), source: "flag", env: args.env }; if (config !== undefined) { if (config.mode === "link") return { mode: "link", dir: userPiHome(env, homedir), source: "config" }; if (config.mode === "isolated") return { mode: "isolated", dir: isolatedDir(env, homedir), source: "config" }; - return { mode: "path", dir: config.dir, source: "config" }; + const name = envNameForDir(envsRoot(env, homedir), config.dir); + return name === undefined ? { mode: "path", dir: config.dir, source: "config" } : { mode: "path", dir: config.dir, source: "config", env: name }; } return { mode: "isolated", dir: isolatedDir(env, homedir), source: "default" }; @@ -321,6 +352,7 @@ export function resolveHome(input ) { // gentle-shell's own default when no selector is given. export function homeSelectorFlags(home ) { if (home.mode === "link") return ["--link"]; + if (home.mode === "path" && home.env !== undefined) return ["--env", home.env]; if (home.mode === "path") return ["--home", home.dir]; return []; } @@ -1291,6 +1323,7 @@ export function helpText() { "Usage: gentle-shell [options] [-- pi-args...]", " gentle-shell home [link|isolated|]", " gentle-shell [home selectors] home import-pi [--dry-run]", + " gentle-shell envs [list | new ]", " gentle-shell [home selectors] setup [--dry-run]", " gentle-shell upgrade [--channel release|main]", " gentle-shell upgrade --rollback", @@ -1305,6 +1338,7 @@ export function helpText() { " --isolated Use the dedicated ~/.gentle-shell/agent home (default), with its own", " settings in ~/.gentle-shell/gentle-ai (copied once from ~/.pi/gentle-ai).", " --home Use a custom agent home directory.", + " --env Use the named env ~/.gentle-shell/envs/ (must already exist).", " --package-root Force this directory as the gentle-pi package to load, taking over", " from any conflicting package the target settings.json already declares.", " --help, -h Show this help text.", @@ -1317,6 +1351,8 @@ export function helpText() { " level when it has none. Never overwrites a file or setting, never copies", " packages, extensions or caches, and never changes your pi home.", " --dry-run only shows the plan. Refuses --link (nothing to import).", + " envs List named envs (* = default); 'envs new ' creates one.", + " Envs are separate homes, not sandboxes.", " setup Provision the resolved home with the gentle-ai companion packages", " (runs the package-local gentle-ai 'install --agent pi --scope global').", " Accepts --dry-run, forwarded to gentle-ai. Accepts a home selector", @@ -1369,6 +1405,7 @@ export function helpText() { " inside a .gentle-shell folder, its settings live in the gentle-ai", " folder next to it; elsewhere they stay in ~/.pi/gentle-ai.", " GENTLE_PI_CONFIG_HOME Gentle Shell settings folder for every home, overriding the above.", + " GENTLE_SHELL_ENVS_DIR Directory holding named envs (default: ~/.gentle-shell/envs).", " PI_CODING_AGENT_DIR Directory for the --link home, shared with pi itself.", "", "Every other argument is forwarded to pi unchanged.", diff --git a/scripts/build-runtime-modules.mjs b/scripts/build-runtime-modules.mjs index 52d74d9f0..74d3c71a1 100644 --- a/scripts/build-runtime-modules.mjs +++ b/scripts/build-runtime-modules.mjs @@ -15,6 +15,7 @@ const sources = [ "status-timing-diagnostics", "telemetry-trigger", "child-package-injection", + "gentle-shell-envs", "gentle-shell-launcher", "gentle-shell-bundled", "gentle-shell-startup-update", diff --git a/scripts/verify-package-files.mjs b/scripts/verify-package-files.mjs index 3f5ae7fb9..1d02035ac 100644 --- a/scripts/verify-package-files.mjs +++ b/scripts/verify-package-files.mjs @@ -84,6 +84,7 @@ export const requiredPaths = [ "extensions/skill-registry.ts", "lib/child-package-injection.ts", "lib/gentle-ai-binary.ts", + "lib/gentle-shell-envs.ts", "lib/gentle-shell-launcher.ts", "lib/gentle-shell-bundled.ts", "lib/gentle-shell-startup-update.ts", @@ -101,6 +102,7 @@ export const requiredPaths = [ "lib/telemetry-trigger.ts", "runtime/child-package-injection.mjs", "runtime/gentle-ai-binary.mjs", + "runtime/gentle-shell-envs.mjs", "runtime/gentle-shell-launcher.mjs", "runtime/gentle-shell-bundled.mjs", "runtime/gentle-shell-startup-update.mjs", diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index 9ddcc5f79..b6e219aec 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3513,3 +3513,48 @@ test("gentle-shell setup asks Gentle AI to skip npm:gentle-pi, so nothing is ins assert.deepEqual(JSON.parse(lines[0]), { args: ["install", "--agent", "pi", "--scope", "global"], excluded: "npm:gentle-pi" }); }); +// --- named envs (`envs`, `--env`; lib/gentle-shell-envs.ts) ------------------- + +test("envs new creates a managed, owned env that --env launches into", (t) => { + const f = fixture(t); + const created = run(f.env, ["envs", "new", "work"]); + assert.equal(created.status, 0, created.stderr); + const dir = join(f.home, ".gentle-shell", "envs", "work"); + assert.ok(existsSync(join(dir, ".gentle-shell-home")), "carries the ownership marker"); + const launched = run(f.env, ["--env", "work", "-p", "hi"]); + assert.equal(launched.status, 0, launched.stderr); + assert.equal(JSON.parse(launched.stdout.trim().split("\n").at(-1) ?? "{}").PI_CODING_AGENT_DIR, dir); +}); + +test("--env never creates an unknown env and rejects invalid names", (t) => { + const f = fixture(t); + const unknown = run(f.env, ["--env", "nope"]); + assert.equal(unknown.status, 2); + assert.match(unknown.stderr, /unknown env "nope"/); + assert.equal(existsSync(join(f.home, ".gentle-shell", "envs", "nope")), false); + assert.equal(run(f.env, ["envs", "new", "Bad"]).status, 2); + assert.equal(run(f.env, ["envs", "new", "dup"]).status, 0); + assert.equal(run(f.env, ["envs", "new", "dup"]).status, 1); +}); + +test("envs without a TTY lists envs, marks the default, and honors GENTLE_SHELL_ENVS_DIR", (t) => { + const f = fixture(t); + const envsDir = join(f.root, "custom-envs"); + const env = { ...f.env, GENTLE_SHELL_ENVS_DIR: envsDir }; + assert.equal(run(env, ["envs", "new", "a"]).status, 0); + assert.equal(run(env, ["envs", "new", "b"]).status, 0); + assert.equal(run(env, ["home", join(envsDir, "b")]).status, 0); + const listed = run(env, ["envs"]); + assert.equal(listed.status, 0, listed.stderr); + assert.match(listed.stdout, /^ a /m); + assert.match(listed.stdout, /^\* b /m); + const version = run(env, ["--version"]); + assert.match(version.stdout, new RegExp(`home path ${join(envsDir, "b").replace(/[\\.]/g, "\\$&")}`)); +}); + +test("envs rejects unknown subcommands with usage", (t) => { + const f = fixture(t); + const result = run(f.env, ["envs", "bogus"]); + assert.equal(result.status, 2); + assert.match(result.stderr, /Usage: gentle-shell envs/); +}); diff --git a/tests/gentle-shell-envs.test.ts b/tests/gentle-shell-envs.test.ts new file mode 100644 index 000000000..85c1a2428 --- /dev/null +++ b/tests/gentle-shell-envs.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { join } from "node:path"; +import test from "node:test"; +import { + envDir, + envNameForDir, + envsRoot, + removeEnvFromConfig, + renameEnvInConfig, + setDefaultEnvInConfig, + summarizeEnv, + validateEnvName, +} from "../lib/gentle-shell-envs.ts"; +import { homeSelectorFlags, parseLauncherArgs, resolveHome } from "../lib/gentle-shell-launcher.ts"; + +// --- names and paths ----------------------------------------------------- + +test("validateEnvName accepts lowercase names with dots, dashes and underscores", () => { + for (const name of ["work", "client-a", "v2.1", "a_b", "0x"]) assert.equal(validateEnvName(name), undefined, name); +}); + +test("validateEnvName rejects empty, dot paths, separators, uppercase and overlong names", () => { + for (const name of ["", ".", "..", ".hidden", "-x", "a/b", "a\\b", "Work", "a b", "x".repeat(41)]) { + assert.match(validateEnvName(name) ?? "", /env name/, name); + } +}); + +test("envsRoot honors GENTLE_SHELL_ENVS_DIR, else ~/.gentle-shell/envs", () => { + assert.equal(envsRoot({}, "/h"), join("/h", ".gentle-shell", "envs")); + assert.equal(envsRoot({ GENTLE_SHELL_ENVS_DIR: "/custom" }, "/h"), "/custom"); + assert.equal(envsRoot({ GENTLE_SHELL_ENVS_DIR: "" }, "/h"), join("/h", ".gentle-shell", "envs")); +}); + +test("envNameForDir maps only a direct, validly named child of the root", () => { + const root = join("/h", "envs"); + assert.equal(envNameForDir(root, envDir(root, "work")), "work"); + assert.equal(envNameForDir(root, join(root, "work", "nested")), undefined); + assert.equal(envNameForDir(root, join("/elsewhere", "work")), undefined); + assert.equal(envNameForDir(root, join(root, "Bad Name")), undefined); +}); + +// --- config mutations ---------------------------------------------------- + +test("setDefaultEnvInConfig persists the env dir as home and keeps other keys", () => { + const next = setDefaultEnvInConfig({ home: "link", other: 1 }, "/e/work"); + assert.deepEqual(next, { home: "/e/work", other: 1 }); +}); + +test("renameEnvInConfig moves provisioned markers and the default home for every alias", () => { + const marker = { gentleAi: "1", gentlePi: "2", at: "t" }; + const config = { home: "/e/old", provisioned: { "/real/e/old": marker, "/e/keep": marker }, other: true }; + const next = renameEnvInConfig(config, ["/e/old", "/real/e/old"], "/e/new"); + assert.deepEqual(next, { home: "/e/new", provisioned: { "/e/keep": marker, "/e/new": marker }, other: true }); + assert.equal(config.home, "/e/old", "never mutates its input"); +}); + +test("renameEnvInConfig keys the moved marker by the new realpath when given", () => { + const marker = { gentleAi: "1", at: "t" }; + const next = renameEnvInConfig({ home: "/e/old", provisioned: { "/real/old": marker } }, ["/e/old", "/real/old"], "/e/new", "/real/new"); + assert.deepEqual(next, { home: "/e/new", provisioned: { "/real/new": marker } }); +}); + +test("renameEnvInConfig leaves an unrelated default untouched", () => { + assert.deepEqual(renameEnvInConfig({ home: "isolated" }, ["/e/old"], "/e/new"), { home: "isolated" }); +}); + +test("removeEnvFromConfig drops the env's markers and clears it as default", () => { + const marker = { gentleAi: "1", at: "t" }; + const next = removeEnvFromConfig({ home: "/e/x", provisioned: { "/e/x": marker, "/e/y": marker } }, ["/e/x"]); + assert.deepEqual(next, { provisioned: { "/e/y": marker } }); +}); + +// --- info --------------------------------------------------------------- + +test("summarizeEnv reports packages, provider ids only, provisioning and sessions", () => { + const info = summarizeEnv({ + name: "work", + dir: "/e/work", + isDefault: true, + settingsText: JSON.stringify({ packages: ["npm:gentle-engram", { source: "npm:pi-btw" }, 42] }), + authText: JSON.stringify({ anthropic: { type: "oauth", access: "SECRET" }, openai: { key: "SECRET" } }), + provisioned: { gentleAi: "4.0.0", gentlePi: "4.0.0", at: "2026-05-01T00:00:00.000Z" }, + sessionCount: 3, + lastSessionAt: "2026-05-02T00:00:00.000Z", + }); + assert.deepEqual(info.packages, ["npm:gentle-engram", "npm:pi-btw"]); + assert.deepEqual(info.providers, ["anthropic", "openai"]); + assert.equal(info.provisioned, "gentle-ai 4.0.0 · gentle-pi 4.0.0 · 2026-05-01"); + assert.equal(info.sessionCount, 3); + assert.ok(!JSON.stringify(info).includes("SECRET"), "never leaks credential values"); +}); + +test("summarizeEnv tolerates missing or malformed files", () => { + const info = summarizeEnv({ name: "x", dir: "/e/x", isDefault: false, settingsText: "{", authText: "[]", sessionCount: 0 }); + assert.deepEqual(info.packages, []); + assert.deepEqual(info.providers, []); + assert.equal(info.provisioned, "not provisioned"); + assert.equal(info.lastSessionAt, undefined); +}); + +// --- launcher integration ---------------------------------------------- + +test("parseLauncherArgs captures --env and --env=", () => { + assert.equal(parseLauncherArgs(["--env", "work"]).env, "work"); + assert.equal(parseLauncherArgs(["--env=work", "-p", "hi"]).env, "work"); + assert.deepEqual(parseLauncherArgs(["--env=work", "-p", "hi"]).passthrough, ["-p", "hi"]); +}); + +test("parseLauncherArgs rejects a missing or invalid --env and combinations with other selectors", () => { + assert.match(parseLauncherArgs(["--env"]).error ?? "", /--env requires/); + assert.match(parseLauncherArgs(["--env", "Bad"]).error ?? "", /env name/); + assert.match(parseLauncherArgs(["--env", "a", "--link"]).error ?? "", /--env cannot be combined with --link/); + assert.match(parseLauncherArgs(["--env", "a", "--isolated"]).error ?? "", /--env cannot be combined with --isolated/); + assert.match(parseLauncherArgs(["--env", "a", "--home", "/x"]).error ?? "", /--env cannot be combined with --home/); +}); + +test("parseLauncherArgs treats argv[0] envs as the envs command", () => { + const parsed = parseLauncherArgs(["envs", "list"]); + assert.equal(parsed.command, "envs"); + assert.deepEqual(parsed.commandArgs, ["list"]); + assert.equal(parseLauncherArgs(["-p", "envs"]).command, undefined); +}); + +test("resolveHome resolves --env to its managed dir and reproduces it as --env", () => { + const home = resolveHome({ args: parseLauncherArgs(["--env", "work"]), env: {}, homedir: "/h", config: undefined }); + assert.deepEqual(home, { mode: "path", dir: join("/h", ".gentle-shell", "envs", "work"), source: "flag", env: "work" }); + assert.deepEqual(homeSelectorFlags(home), ["--env", "work"]); +}); + +test("resolveHome names a persisted default that lives in the envs root", () => { + const dir = join("/h", ".gentle-shell", "envs", "work"); + const home = resolveHome({ args: parseLauncherArgs([]), env: {}, homedir: "/h", config: { mode: "path", dir } }); + assert.equal(home.env, "work"); + assert.equal(home.source, "config"); + const plain = resolveHome({ args: parseLauncherArgs([]), env: {}, homedir: "/h", config: { mode: "path", dir: "/other" } }); + assert.equal(plain.env, undefined); + assert.deepEqual(homeSelectorFlags(plain), ["--home", "/other"]); +}); diff --git a/tests/gentle-shell-launcher.test.ts b/tests/gentle-shell-launcher.test.ts index 66a3519fe..11a4c0133 100644 --- a/tests/gentle-shell-launcher.test.ts +++ b/tests/gentle-shell-launcher.test.ts @@ -58,6 +58,7 @@ test("parseLauncherArgs returns all-false defaults for an empty argv", () => { link: false, isolated: false, home: undefined, + env: undefined, packageRoot: undefined, help: false, version: false, diff --git a/tests/gentle-shell-startup-update.test.ts b/tests/gentle-shell-startup-update.test.ts index f85bf6975..cbb11a774 100644 --- a/tests/gentle-shell-startup-update.test.ts +++ b/tests/gentle-shell-startup-update.test.ts @@ -8,13 +8,13 @@ const eligible = { }; test("ordinary bundled interactive launch and resume are eligible", () => { - for (const argv of [[], ["--resume"], ["-r"], ["--continue"], ["-c"]]) { + for (const argv of [[], ["--resume"], ["-r"], ["--continue"], ["-c"], ["--env", "work"]]) { assert.equal(shouldCheckStartupUpdate({ ...eligible, args: parseLauncherArgs(argv) }), true); } }); test("commands, overrides, non-TTY runs and child/offline opt-outs are excluded", () => { - for (const argv of [["upgrade"], ["home"], ["setup"], ["install", "npm:x"], ["--help"], ["--version"], ["--package-root", "/custom"]]) { + for (const argv of [["upgrade"], ["home"], ["envs"], ["setup"], ["install", "npm:x"], ["--help"], ["--version"], ["--package-root", "/custom"]]) { assert.equal(shouldCheckStartupUpdate({ ...eligible, args: parseLauncherArgs(argv) }), false, argv.join(" ")); } for (const overrides of [{ bundled: false }, { inputTTY: false }, { outputTTY: false }, { args: { ...eligible.args, error: "invalid arguments" } }]) { From 9218f6b10ec2f69b603a1efffcab11a36952cb14 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 12:35:09 -0400 Subject: [PATCH 02/25] feat(shell): show the active named env in the header and Status card The launcher now passes the env name to Pi as GENTLE_SHELL_ENV, only for a named env, and clears any inherited value for every other home, so a nested launch never mislabels a different home. The header shows `env ` right after the brand and keeps it through every narrowing stage (also in minimal density); the Status card's Project group shows `Env `. Only a valid env name is ever displayed. --- docs/gentle-shell.md | 4 ++-- docs/readme-reference.md | 2 +- extensions/gentle-shell.ts | 6 +++++- lib/gentle-shell-envs.ts | 11 ++++++++++ lib/gentle-shell-launcher.ts | 9 +++++++- lib/shell-bar.ts | 20 +++++++++++------ runtime/gentle-shell-envs.mjs | 11 ++++++++++ runtime/gentle-shell-launcher.mjs | 9 +++++++- tests/gentle-shell-envs.test.ts | 27 +++++++++++++++++++++++ tests/shell-bar.test.ts | 36 +++++++++++++++++++++++++++++++ 10 files changed, 123 insertions(+), 12 deletions(-) diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index ff17f9116..2311973ad 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -35,9 +35,9 @@ At 140 columns or wider, fullscreen defaults to a live header over a transcript- ✿ Gentle Shell ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium · team ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ``` -- The header carries live session data: session identity on the left (brand, cwd, branch, dirty count, model · effort · profile) and the two live counters right-aligned (the context gauge and session cost). The effort reads `auto` for NaN models that manage their reasoning depth themselves (DeepSeek V4 Flash, Qwen 3.8 Flash, MiMo), with a one-time notice explaining that the selected level does not change the depth. It never shows the working/thinking state or extension statuses — those stay in the prompt indicator and the Status surface. When the terminal is too narrow for everything, segments give way in a fixed order — profile, then effort, then the whole cwd/branch/dirty group — before the counters are touched; below that, only the brand survives, and below that the header renders nothing. +- The header carries live session data: session identity on the left (brand, the named env when launched with `--env`, cwd, branch, dirty count, model · effort · profile) and the two live counters right-aligned (the context gauge and session cost). The effort reads `auto` for NaN models that manage their reasoning depth themselves (DeepSeek V4 Flash, Qwen 3.8 Flash, MiMo), with a one-time notice explaining that the selected level does not change the depth. It never shows the working/thinking state or extension statuses — those stay in the prompt indicator and the Status surface. When the terminal is too narrow for everything, segments give way in a fixed order — profile, then effort, then the whole cwd/branch/dirty group — before the counters are touched; below that, only the brand and the env survive, and below that the header renders nothing. - The right rail scrolls **Status → Changes → TODO**. Cards cache their rendered content until their own digest or an explicit invalidation changes. `neon` keeps the rounded border and accent title; `float` uses a tone background, a full-height `▎` accent, one-column transparent margins, painted padding above and below, and a blank painted separator between heading and body. Warning/error/success panels retain their tone colors. -- The Status card carries Project (cwd, branch, session name, active profile), Changes, and Integrations (other extensions' statuses); its live digest detects changes without requiring an event. Model, effort, context, cost, and the per-model usage table live in the header instead — the header ticks every frame, so duplicating them in a card would just make that card repaint every frame too. +- The Status card carries Project (cwd, named env, branch, session name, active profile), Changes, and Integrations (other extensions' statuses); its live digest detects changes without requiring an event. Model, effort, context, cost, and the per-model usage table live in the header instead — the header ticks every frame, so duplicating them in a card would just make that card repaint every frame too. - Gentle Agents is not part of the rail in any mode: its one card stays above the editor, where it already lived, with fixed right-aligned columns for `model · effort`, tokens, cost, and elapsed, each sized to the widest value among the shown tasks — so the numbers line up vertically even when one row's values are much shorter than another's. A queued task fills only the elapsed column with the word `queued`, leaving the other columns blank rather than overwriting the row. - The sidebar reuses its last frame until something it paints changes, so silent frames stay cheap; a per-section cache means one card's changing digest (or the header's) never forces an unrelated card to redraw. - Header placement is configurable. Above input, the float header has a full-width background, painted padding above and below its content, and a transparent `▔` bottom edge. Below input, the float footer groups Changes (when the session's roots have uncommitted work), header data, and locally owned integration statuses under one upper edge; it never duplicates statuses owned by the rail. diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 064ca7f5a..33d959cd6 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -381,7 +381,7 @@ A named env is a separate agent home managed by gentle-shell under `~/.gentle-sh | `gentle-shell envs new ` | Creates the env without launching it. Its first launch installs the companion packages, the same as any new `--home`. | | `gentle-shell --env [...]` | Launches, sets up (`--env setup`), or runs pi subcommands (`--env install npm:x`) in that env. | -The default env is stored as the ordinary persisted `home` path, so a plain `gentle-shell` opens it, and `gentle-shell home link|isolated|` replaces it. Envs are separate homes, not sandboxes. They share the repository you work in, `~/.pi/gentle-ai/persona.json`, and `~/.gentle-ai/state.json`, like every other home. `self-uninstall` does not remove `~/.gentle-shell/envs`. +Inside a session, the header shows `env ` right after the brand, and it stays there as the header narrows. The Status card's Project group shows `Env `. The launcher passes the name to Pi as `GENTLE_SHELL_ENV`, only for a named env, and clears any inherited value for every other home. The default env is stored as the ordinary persisted `home` path, so a plain `gentle-shell` opens it, and `gentle-shell home link|isolated|` replaces it. Envs are separate homes, not sandboxes. They share the repository you work in, `~/.pi/gentle-ai/persona.json`, and `~/.gentle-ai/state.json`, like every other home. `self-uninstall` does not remove `~/.gentle-shell/envs`. ### Managing packages diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 587485162..db5754655 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -125,6 +125,7 @@ import { installSessionChangeCapture } from "../lib/session-change-capture.ts"; import { SelectionEngine } from "../lib/selection-engine.ts"; import { withOverlayRepaint } from "../lib/overlay-repaint.ts"; import { buildNotificationRows } from "../lib/notification-customize.ts"; +import { activeEnvName } from "../lib/gentle-shell-envs.ts"; // Gentle Shell: the visual layer gentle-pi puts on top of pi. It installs the // status bar, the petal prompt, the working-tree changes widget and overlay, @@ -151,6 +152,8 @@ interface ShellBarComponent { interface BuildOptions { jobs?: RunningJobDisplay[]; profile?: string; + // Named env from the launcher (GENTLE_SHELL_ENV); omitted for other homes. + env?: string; home?: string; dirty?: number; usage?: ProviderUsage; @@ -319,6 +322,7 @@ export function buildShellBarModel( return { cwd: shortenHome(ctx.sessionManager.getCwd(), home), profile: options.profile, + ...(options.env ? { env: options.env } : {}), branch: footerData.getGitBranch(), dirty: options.dirty, sessionName: ctx.sessionManager.getSessionName(), @@ -1916,7 +1920,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p // statuses. The digest is what keeps the fullscreen memo honest, and it // rebuilds the model exactly as the narrow bottom bar does every frame. const footerModel = (): ShellBarModel => ({ - ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile(), jobs: runningJobs }), + ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile(), env: activeEnvName(process.env), jobs: runningJobs }), changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted }, review, }); diff --git a/lib/gentle-shell-envs.ts b/lib/gentle-shell-envs.ts index 38e9e53ec..b29e2ac2b 100644 --- a/lib/gentle-shell-envs.ts +++ b/lib/gentle-shell-envs.ts @@ -7,6 +7,17 @@ import { basename, dirname, join, resolve as resolvePath } from "node:path"; export const ENVS_DIR_ENV = "GENTLE_SHELL_ENVS_DIR"; +// Set by the launcher on the Pi process when the home is a named env, so the +// Gentle Shell UI can name it. Never set for any other home. +export const ENV_NAME_ENV = "GENTLE_SHELL_ENV"; + +// The active env's name for display, or undefined. Only a valid name is +// trusted: the value is shown in the UI, so anything else is ignored. +export function activeEnvName(env: Record): string | undefined { + const name = env[ENV_NAME_ENV]; + return name !== undefined && validateEnvName(name) === undefined ? name : undefined; +} + const ENV_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,39}$/; // Lowercase only, so names stay identical on case-insensitive filesystems; diff --git a/lib/gentle-shell-launcher.ts b/lib/gentle-shell-launcher.ts index fcbb1c8fe..e634ce63a 100644 --- a/lib/gentle-shell-launcher.ts +++ b/lib/gentle-shell-launcher.ts @@ -1,7 +1,9 @@ import { basename, dirname, isAbsolute, join, resolve as resolvePath } from "node:path"; import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection, type ChildPackageInjection } from "./child-package-injection.ts"; import { GENTLE_SHELL_CONFIG_HOME_ENV } from "./gentle-shell-bundled.ts"; -import { envDir, envNameForDir, envsRoot, validateEnvName } from "./gentle-shell-envs.ts"; +import { ENV_NAME_ENV, envDir, envNameForDir, envsRoot, validateEnvName } from "./gentle-shell-envs.ts"; + +export { ENV_NAME_ENV }; // The gentle-shell launcher: pure, side-effect-free functions over injected // env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process, @@ -1124,6 +1126,11 @@ export function buildPiInvocation(input: BuildPiInvocationInput): PiInvocation { const configHome = input.bundled === true ? undefined : standaloneConfigHome(input.home); if (configHome === undefined) delete env[GENTLE_SHELL_CONFIG_HOME_ENV]; else env[GENTLE_SHELL_CONFIG_HOME_ENV] = configHome; + + // Exactly this home's env name: an inherited value (a gentle-shell started + // from inside another env's session) must never label a different home. + if (input.home.env === undefined) delete env[ENV_NAME_ENV]; + else env[ENV_NAME_ENV] = input.home.env; if (childInjection === undefined) delete env[CHILD_PACKAGE_INJECTION_ENV]; else env[CHILD_PACKAGE_INJECTION_ENV] = encodeChildPackageInjection(childInjection); diff --git a/lib/shell-bar.ts b/lib/shell-bar.ts index 26fb4f502..6f3c16194 100644 --- a/lib/shell-bar.ts +++ b/lib/shell-bar.ts @@ -22,6 +22,8 @@ export interface ShellBarModel { review?: ReviewSidebarSnapshot; jobs?: RunningJobDisplay[]; profile?: string; + // The named env this session runs in (gentle-shell --env), if any. + env?: string; changes?: { files: number; added: number; deleted: number; notice?: string }; cwd: string; branch: string | null; @@ -48,6 +50,7 @@ export interface ShellHeaderModel { modelId: string; effort: string | undefined; profile?: string; + env?: string; contextPercent: number | null; costTotal: number; subscription: boolean; @@ -60,6 +63,7 @@ export interface ShellHeaderModel { export function buildShellHeaderModel(model: ShellBarModel, showJobs = false): ShellHeaderModel { const { cwd, branch, dirty, modelId, effort, profile, contextPercent, costTotal, subscription, usage } = model; return { cwd, branch, dirty, modelId, effort, profile, contextPercent, costTotal, subscription, usage, + ...(model.env ? { env: model.env } : {}), ...(showJobs && model.jobs?.length ? { jobs: model.jobs.length } : {}) }; } @@ -229,6 +233,7 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme title: "Project", lines: [ value(model.cwd), + ...(model.env ? [`${label("Env")} ${value(sanitizeStatus(model.env))}`] : []), ...(branch ? [branch] : []), ...(model.sessionName ? [`${label("Session")} ${value(model.sessionName)}`] : []), ...(model.profile ? [`${label("Profile")} ${value(sanitizeStatus(model.profile))}`] : []), @@ -277,18 +282,21 @@ const HEADER_BRAND = "✿ Gentle Shell"; // alive as long as anything can still share the row with the right-aligned // counters. function headerLeftStages(model: ShellHeaderModel, theme: ShellBarTheme, showModelDetails: boolean): string[][] { + // The env stays beside the brand in every stage: which home this session + // writes to matters more than any detail that gives way before it. const brand = theme.fg(ROLE.BRAND, theme.bold(HEADER_BRAND)); + const env = model.env ? [`${theme.fg(ROLE.LABEL, "env")} ${theme.fg(ROLE.VALUE, theme.bold(sanitizeStatus(model.env)))}`] : []; const location = locationSegment(model, theme); - if (!showModelDetails) return [[brand, location], [brand]]; + if (!showModelDetails) return [[brand, ...env, location], [brand, ...env]]; const withEffort = executionSegment(model.modelId, model.effort, theme); const modelOnly = executionSegment(model.modelId, undefined, theme); const withProfile = model.profile ? `${withEffort} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(ROLE.MODEL, sanitizeStatus(model.profile))}` : withEffort; return [ - [brand, location, withProfile], - [brand, location, withEffort], - [brand, location, modelOnly], - [brand, modelOnly], - [brand], + [brand, ...env, location, withProfile], + [brand, ...env, location, withEffort], + [brand, ...env, location, modelOnly], + [brand, ...env, modelOnly], + [brand, ...env], ]; } diff --git a/runtime/gentle-shell-envs.mjs b/runtime/gentle-shell-envs.mjs index accc5b99c..c982de350 100644 --- a/runtime/gentle-shell-envs.mjs +++ b/runtime/gentle-shell-envs.mjs @@ -8,6 +8,17 @@ import { basename, dirname, join, resolve as resolvePath } from "node:path"; export const ENVS_DIR_ENV = "GENTLE_SHELL_ENVS_DIR"; +// Set by the launcher on the Pi process when the home is a named env, so the +// Gentle Shell UI can name it. Never set for any other home. +export const ENV_NAME_ENV = "GENTLE_SHELL_ENV"; + +// The active env's name for display, or undefined. Only a valid name is +// trusted: the value is shown in the UI, so anything else is ignored. +export function activeEnvName(env ) { + const name = env[ENV_NAME_ENV]; + return name !== undefined && validateEnvName(name) === undefined ? name : undefined; +} + const ENV_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,39}$/; // Lowercase only, so names stay identical on case-insensitive filesystems; diff --git a/runtime/gentle-shell-launcher.mjs b/runtime/gentle-shell-launcher.mjs index bf55f7fa0..90a77d109 100644 --- a/runtime/gentle-shell-launcher.mjs +++ b/runtime/gentle-shell-launcher.mjs @@ -2,7 +2,9 @@ import { basename, dirname, isAbsolute, join, resolve as resolvePath } from "node:path"; import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection, } from "./child-package-injection.mjs"; import { GENTLE_SHELL_CONFIG_HOME_ENV } from "./gentle-shell-bundled.mjs"; -import { envDir, envNameForDir, envsRoot, validateEnvName } from "./gentle-shell-envs.mjs"; +import { ENV_NAME_ENV, envDir, envNameForDir, envsRoot, validateEnvName } from "./gentle-shell-envs.mjs"; + +export { ENV_NAME_ENV }; // The gentle-shell launcher: pure, side-effect-free functions over injected // env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process, @@ -1125,6 +1127,11 @@ export function buildPiInvocation(input ) { const configHome = input.bundled === true ? undefined : standaloneConfigHome(input.home); if (configHome === undefined) delete env[GENTLE_SHELL_CONFIG_HOME_ENV]; else env[GENTLE_SHELL_CONFIG_HOME_ENV] = configHome; + + // Exactly this home's env name: an inherited value (a gentle-shell started + // from inside another env's session) must never label a different home. + if (input.home.env === undefined) delete env[ENV_NAME_ENV]; + else env[ENV_NAME_ENV] = input.home.env; if (childInjection === undefined) delete env[CHILD_PACKAGE_INJECTION_ENV]; else env[CHILD_PACKAGE_INJECTION_ENV] = encodeChildPackageInjection(childInjection); diff --git a/tests/gentle-shell-envs.test.ts b/tests/gentle-shell-envs.test.ts index 85c1a2428..2d0590ee1 100644 --- a/tests/gentle-shell-envs.test.ts +++ b/tests/gentle-shell-envs.test.ts @@ -136,3 +136,30 @@ test("resolveHome names a persisted default that lives in the envs root", () => assert.equal(plain.env, undefined); assert.deepEqual(homeSelectorFlags(plain), ["--home", "/other"]); }); + +test("buildPiInvocation names the env for the session and never leaks an inherited one", async () => { + const { buildPiInvocation, ENV_NAME_ENV } = await import("../lib/gentle-shell-launcher.ts"); + const base = { + runtime: { kind: "path" as const, command: "pi", args: [] }, + packageRoot: "/pkg", + declaration: undefined, + takeOver: false, + otherPackagePaths: [], + passthrough: [], + homedir: "/h", + cwd: "/w", + }; + const named = buildPiInvocation({ ...base, home: { mode: "path", dir: "/h/.gentle-shell/envs/work", source: "flag", env: "work" }, baseEnv: {} }); + assert.equal(ENV_NAME_ENV, "GENTLE_SHELL_ENV"); + assert.equal(named.env.GENTLE_SHELL_ENV, "work"); + const plain = buildPiInvocation({ ...base, home: { mode: "isolated", dir: "/h/.gentle-shell/agent", source: "default" }, baseEnv: { GENTLE_SHELL_ENV: "outer" } }); + assert.ok(!("GENTLE_SHELL_ENV" in plain.env), "an outer session's env name never reaches a different home"); +}); + +test("activeEnvName accepts only a valid env name from the environment", async () => { + const { activeEnvName } = await import("../lib/gentle-shell-envs.ts"); + assert.equal(activeEnvName({ GENTLE_SHELL_ENV: "work" }), "work"); + assert.equal(activeEnvName({}), undefined); + assert.equal(activeEnvName({ GENTLE_SHELL_ENV: "" }), undefined); + assert.equal(activeEnvName({ GENTLE_SHELL_ENV: "\x1b[31mevil" }), undefined); +}); diff --git a/tests/shell-bar.test.ts b/tests/shell-bar.test.ts index 6924c0ec5..958386f0c 100644 --- a/tests/shell-bar.test.ts +++ b/tests/shell-bar.test.ts @@ -856,3 +856,39 @@ test("the narrow-layout bottom-only bar stays unchanged in the float style", (t) useCardStyle(t, CARD_STYLE.FLOAT); assert.deepEqual(renderShellBottomOnlyBar(data, theme, 100, "alt+u"), neon); }); + +// --- named env (GENTLE_SHELL_ENV, lib/gentle-shell-envs.ts) ------------------ + +test("buildShellHeaderModel carries the env only when a named env is active", () => { + assert.equal(buildShellHeaderModel(model({ env: "work" })).env, "work"); + assert.ok(!("env" in buildShellHeaderModel(model()))); +}); + +test("renderShellHeaderBar shows the env right after the brand", () => { + const { text } = renderShellHeaderBar(buildShellHeaderModel(model({ env: "work", profile: "team" })), plainTheme, 140); + assert.ok(text.startsWith("✿ Gentle Shell ⟡ env work ⟡ ~/work/gentle-pi main"), text); + const { text: plain } = renderShellHeaderBar(buildShellHeaderModel(model()), plainTheme, 140); + assert.doesNotMatch(plain, /env /); +}); + +test("renderShellHeaderBar keeps the env after profile, effort and location are dropped", () => { + const header = buildShellHeaderModel(model({ env: "work", profile: "team" })); + for (const width of [100, 90, 70]) { + const { text } = renderShellHeaderBar(header, plainTheme, width); + assert.equal(visibleWidth(text), width); + assert.match(text, /^✿ Gentle Shell ⟡ env work/, `width ${width}: ${text}`); + } +}); + +test("minimal density hides the brand but still names the env", () => { + const settings = { ...DEFAULT_VISUAL_SETTINGS, density: "minimal" as const }; + const { text } = renderShellHeaderBar(buildShellHeaderModel(model({ env: "work" })), plainTheme, 140, undefined, settings); + assert.doesNotMatch(text, /Gentle Shell/); + assert.match(text, /^env work/); +}); + +test("sidebar Status card names the env under Project", () => { + const text = renderShellSidebarBar(model({ env: "work" }), plainTheme, 60).join("\n"); + assert.match(text, /Env work/); + assert.doesNotMatch(renderShellSidebarBar(model(), plainTheme, 60).join("\n"), /Env /); +}); From b45cc2516e694762b3b758ab73d8de3a63c5468e Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 12:41:43 -0400 Subject: [PATCH 03/25] fix(uninstall): protect named envs and prove them in a bundled install Named envs live in ~/.gentle-shell/envs, which in a bundled install is inside the prefix folder. Both self-uninstall plans now list the envs root once as "your named envs" under Not touched, instead of a generic "not written by the bundled install" entry or one "custom --home home" line per default/provisioned env. They also refuse a plan that would remove anything inside it: before this, a GENTLE_SHELL_HOME pointing into an env could be removed as an isolated home. A bundled end-to-end test creates an env, launches it through the bundled Pi with GENTLE_SHELL_ENV set, checks the default home is never labelled, and confirms self-uninstall keeps the envs. --- docs/readme-reference.md | 2 +- lib/gentle-shell-uninstall.ts | 21 +++++++++++++++++- runtime/gentle-shell-uninstall.mjs | 21 +++++++++++++++++- tests/gentle-shell-bundled.test.ts | 32 +++++++++++++++++++++++++++- tests/gentle-shell-uninstall.test.ts | 27 +++++++++++++++++++++++ 5 files changed, 99 insertions(+), 4 deletions(-) diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 33d959cd6..c13712f2f 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -381,7 +381,7 @@ A named env is a separate agent home managed by gentle-shell under `~/.gentle-sh | `gentle-shell envs new ` | Creates the env without launching it. Its first launch installs the companion packages, the same as any new `--home`. | | `gentle-shell --env [...]` | Launches, sets up (`--env setup`), or runs pi subcommands (`--env install npm:x`) in that env. | -Inside a session, the header shows `env ` right after the brand, and it stays there as the header narrows. The Status card's Project group shows `Env `. The launcher passes the name to Pi as `GENTLE_SHELL_ENV`, only for a named env, and clears any inherited value for every other home. The default env is stored as the ordinary persisted `home` path, so a plain `gentle-shell` opens it, and `gentle-shell home link|isolated|` replaces it. Envs are separate homes, not sandboxes. They share the repository you work in, `~/.pi/gentle-ai/persona.json`, and `~/.gentle-ai/state.json`, like every other home. `self-uninstall` does not remove `~/.gentle-shell/envs`. +Inside a session, the header shows `env ` right after the brand, and it stays there as the header narrows. The Status card's Project group shows `Env `. The launcher passes the name to Pi as `GENTLE_SHELL_ENV`, only for a named env, and clears any inherited value for every other home. The default env is stored as the ordinary persisted `home` path, so a plain `gentle-shell` opens it, and `gentle-shell home link|isolated|` replaces it. Renaming or deleting an env keeps `config.json` consistent: its setup record and default follow it or are removed. Envs are separate homes, not sandboxes. They share the repository you work in, `~/.pi/gentle-ai/persona.json`, and `~/.gentle-ai/state.json`, like every other home. Named envs work the same in a bundled (standalone) install. There, the envs root shares `~/.gentle-shell` with the prefix and launches use the bundled Pi. Both `self-uninstall` variants list the envs root once, as "your named envs", under *Not touched*, and never remove it. They also refuse a plan that would remove anything inside it, for example a `GENTLE_SHELL_HOME` that points into an env. ### Managing packages diff --git a/lib/gentle-shell-uninstall.ts b/lib/gentle-shell-uninstall.ts index 267ee55e2..2cd0021a4 100644 --- a/lib/gentle-shell-uninstall.ts +++ b/lib/gentle-shell-uninstall.ts @@ -2,6 +2,9 @@ import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmdir import { createInterface } from "node:readline"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { isolatedDir, launcherConfigPath, parseRawLauncherConfig, provisionedEntry, userPiHome } from "./gentle-shell-launcher.ts"; +import { envsRoot } from "./gentle-shell-envs.ts"; + +const NAMED_ENVS_LABEL = "your named envs"; // `gentle-shell self-uninstall`: removes what Gentle Shell itself created, then // the gentle-pi package with the package manager that owns it. Everything is @@ -343,6 +346,14 @@ async function planSelfUninstall(input: SelfUninstallInput, configHome: string, const seen = new Set([comparable(isolatedReal, platform), comparable(canonical(piHome), platform)]); plan.notTouched.push({ path: piHome, label: "your Pi home" }); + // Named envs are user data: their root is listed once, never each env as a + // custom --home home, and the overlap check below protects all of them. + const namedEnvs = envsRoot(env, homedir); + const namedEnvsReal = inspect(namedEnvs, "dir") === "ok" ? canonical(namedEnvs) : undefined; + if (namedEnvsReal !== undefined) { + seen.add(comparable(namedEnvsReal, platform)); + plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); + } const customHomes = [typeof launcherConfig.home === "string" && !["link", "isolated", ""].includes(launcherConfig.home) ? launcherConfig.home : undefined]; const provisioned = launcherConfig.provisioned; if (typeof provisioned === "object" && provisioned !== null && !Array.isArray(provisioned)) customHomes.push(...Object.keys(provisioned)); @@ -350,6 +361,7 @@ async function planSelfUninstall(input: SelfUninstallInput, configHome: string, if (home === undefined || !isAbsolute(home)) continue; const key = comparable(canonical(home), platform); if (seen.has(key)) continue; + if (namedEnvsReal !== undefined && within(namedEnvsReal, canonical(home), platform)) continue; seen.add(key); plan.notTouched.push({ path: home, label: "custom --home home" }); } @@ -690,8 +702,13 @@ function planBundledSelfUninstall(input: BundledSelfUninstallInput): BundledPlan if (windows) consider(join(root, BUNDLED_WINDOWS_MARKER.name), "the bundled install's folder marker", "file", plan.own); consider(join(shellHome, "agent"), "your sign-ins, chats, settings and extensions", "dir", plan.shared); consider(join(shellHome, "config.json"), "the launcher's settings", "file", plan.shared); + const namedEnvs = envsRoot(env, homedir); + const hasNamedEnvs = inspect(namedEnvs, "dir") === "ok"; const known = new Set([...BUNDLED_OWN.map((item) => item.name), ...(windows ? [BUNDLED_WINDOWS_MARKER.name] : []), - ...(same(canonical(shellHome), real) ? ["agent", "config.json"] : [])]); + ...(same(canonical(shellHome), real) ? ["agent", "config.json"] : []), + // Named envs are user data that may share the prefix folder: kept, and + // listed below instead of as an unknown entry. + ...(hasNamedEnvs && same(canonical(dirname(namedEnvs)), real) ? [basename(namedEnvs)] : [])]); for (const name of names) { if (known.has(name)) continue; foreign = true; @@ -699,6 +716,7 @@ function planBundledSelfUninstall(input: BundledSelfUninstallInput): BundledPlan } plan.removeRoot = !foreign; + if (hasNamedEnvs) guards.push({ path: namedEnvs, label: NAMED_ENVS_LABEL, inside: true }); for (const removal of [...plan.own, ...plan.shared]) { const reason = collision(canonical(removal.path), guards, platform); if (reason) plan.refusals.push(`${removal.path} would be removed, but it ${reason}; refusing to remove anything.`); @@ -706,6 +724,7 @@ function planBundledSelfUninstall(input: BundledSelfUninstallInput): BundledPlan plan.notTouched.push({ path: piHome, label: "your Pi home" }, { path: join(homedir, ".pi", "gentle-ai"), label: "Gentle AI's configuration for Pi" }, { path: join(homedir, ".gentle-ai"), label: "Gentle AI's own state" }, { path: join(homedir, ".engram"), label: "Engram's memory" }); + if (hasNamedEnvs) plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); if (env.GENTLE_SHELL_HOME && !same(canonical(env.GENTLE_SHELL_HOME), canonical(join(shellHome, "agent")))) { plan.notTouched.push({ path: env.GENTLE_SHELL_HOME, label: "the Gentle Shell home GENTLE_SHELL_HOME names" }); } diff --git a/runtime/gentle-shell-uninstall.mjs b/runtime/gentle-shell-uninstall.mjs index 4dd9f84f7..797201cef 100644 --- a/runtime/gentle-shell-uninstall.mjs +++ b/runtime/gentle-shell-uninstall.mjs @@ -3,6 +3,9 @@ import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmdir import { createInterface } from "node:readline"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { isolatedDir, launcherConfigPath, parseRawLauncherConfig, provisionedEntry, userPiHome } from "./gentle-shell-launcher.mjs"; +import { envsRoot } from "./gentle-shell-envs.mjs"; + +const NAMED_ENVS_LABEL = "your named envs"; // `gentle-shell self-uninstall`: removes what Gentle Shell itself created, then // the gentle-pi package with the package manager that owns it. Everything is @@ -344,6 +347,14 @@ async function planSelfUninstall(input , configHome , const seen = new Set([comparable(isolatedReal, platform), comparable(canonical(piHome), platform)]); plan.notTouched.push({ path: piHome, label: "your Pi home" }); + // Named envs are user data: their root is listed once, never each env as a + // custom --home home, and the overlap check below protects all of them. + const namedEnvs = envsRoot(env, homedir); + const namedEnvsReal = inspect(namedEnvs, "dir") === "ok" ? canonical(namedEnvs) : undefined; + if (namedEnvsReal !== undefined) { + seen.add(comparable(namedEnvsReal, platform)); + plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); + } const customHomes = [typeof launcherConfig.home === "string" && !["link", "isolated", ""].includes(launcherConfig.home) ? launcherConfig.home : undefined]; const provisioned = launcherConfig.provisioned; if (typeof provisioned === "object" && provisioned !== null && !Array.isArray(provisioned)) customHomes.push(...Object.keys(provisioned)); @@ -351,6 +362,7 @@ async function planSelfUninstall(input , configHome , if (home === undefined || !isAbsolute(home)) continue; const key = comparable(canonical(home), platform); if (seen.has(key)) continue; + if (namedEnvsReal !== undefined && within(namedEnvsReal, canonical(home), platform)) continue; seen.add(key); plan.notTouched.push({ path: home, label: "custom --home home" }); } @@ -691,8 +703,13 @@ function planBundledSelfUninstall(input ) if (windows) consider(join(root, BUNDLED_WINDOWS_MARKER.name), "the bundled install's folder marker", "file", plan.own); consider(join(shellHome, "agent"), "your sign-ins, chats, settings and extensions", "dir", plan.shared); consider(join(shellHome, "config.json"), "the launcher's settings", "file", plan.shared); + const namedEnvs = envsRoot(env, homedir); + const hasNamedEnvs = inspect(namedEnvs, "dir") === "ok"; const known = new Set([...BUNDLED_OWN.map((item) => item.name), ...(windows ? [BUNDLED_WINDOWS_MARKER.name] : []), - ...(same(canonical(shellHome), real) ? ["agent", "config.json"] : [])]); + ...(same(canonical(shellHome), real) ? ["agent", "config.json"] : []), + // Named envs are user data that may share the prefix folder: kept, and + // listed below instead of as an unknown entry. + ...(hasNamedEnvs && same(canonical(dirname(namedEnvs)), real) ? [basename(namedEnvs)] : [])]); for (const name of names) { if (known.has(name)) continue; foreign = true; @@ -700,6 +717,7 @@ function planBundledSelfUninstall(input ) } plan.removeRoot = !foreign; + if (hasNamedEnvs) guards.push({ path: namedEnvs, label: NAMED_ENVS_LABEL, inside: true }); for (const removal of [...plan.own, ...plan.shared]) { const reason = collision(canonical(removal.path), guards, platform); if (reason) plan.refusals.push(`${removal.path} would be removed, but it ${reason}; refusing to remove anything.`); @@ -707,6 +725,7 @@ function planBundledSelfUninstall(input ) plan.notTouched.push({ path: piHome, label: "your Pi home" }, { path: join(homedir, ".pi", "gentle-ai"), label: "Gentle AI's configuration for Pi" }, { path: join(homedir, ".gentle-ai"), label: "Gentle AI's own state" }, { path: join(homedir, ".engram"), label: "Engram's memory" }); + if (hasNamedEnvs) plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); if (env.GENTLE_SHELL_HOME && !same(canonical(env.GENTLE_SHELL_HOME), canonical(join(shellHome, "agent")))) { plan.notTouched.push({ path: env.GENTLE_SHELL_HOME, label: "the Gentle Shell home GENTLE_SHELL_HOME names" }); } diff --git a/tests/gentle-shell-bundled.test.ts b/tests/gentle-shell-bundled.test.ts index f9b686aac..4b645d538 100644 --- a/tests/gentle-shell-bundled.test.ts +++ b/tests/gentle-shell-bundled.test.ts @@ -255,7 +255,7 @@ const FAKE_PI = [ " }", "}", "const pick = ['PATH', 'GENTLE_SHELL_PI', 'GENTLE_PI_AGENTS_PI', 'PI_SKIP_VERSION_CHECK', 'npm_config_prefix', 'npm_config_registry', 'NODE_OPTIONS', 'PI_CODING_AGENT_DIR',", - "\t'GENTLE_SHELL_PREFIX', 'GENTLE_PI_CONFIG_HOME', 'ENGRAM_BIN', 'ENGRAM_URL', 'ENGRAM_SERVER_MODE', 'ENGRAM_SOCKET', 'ENGRAM_STARTUP_POLICY', 'ENGRAM_STARTUP_NOTICE', 'GENTLE_AI_HOME', 'GENTLE_AI_PI_CONFIG_DIR', 'GENTLE_AI_ENGRAM_BIN', 'GENTLE_AI_NO_SELF_UPDATE', 'GH_TOKEN', 'ENGRAM_NO_UPDATE_CHECK'];", + "\t'GENTLE_SHELL_PREFIX', 'GENTLE_PI_CONFIG_HOME', 'ENGRAM_BIN', 'ENGRAM_URL', 'ENGRAM_SERVER_MODE', 'ENGRAM_SOCKET', 'ENGRAM_STARTUP_POLICY', 'ENGRAM_STARTUP_NOTICE', 'GENTLE_AI_HOME', 'GENTLE_AI_PI_CONFIG_DIR', 'GENTLE_AI_ENGRAM_BIN', 'GENTLE_AI_NO_SELF_UPDATE', 'GH_TOKEN', 'ENGRAM_NO_UPDATE_CHECK', 'GENTLE_SHELL_ENV'];", "const record = join(process.env.GENTLE_SHELL_PREFIX ?? '', 'config', 'engram-provenance.json');", "const provenance = (() => { try { return JSON.parse(readFileSync(record, 'utf8')); } catch { return null; } })();", "console.log(JSON.stringify({ cli: process.argv[1], args, provenance, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }));", @@ -1197,3 +1197,33 @@ test("gentle-shell self-uninstall in a bundled install never edits or deletes a assert.match(result.stdout, /does not describe this install's PATH entry/); assert.match(result.stdout, /\(none recorded; see below\)/); }); + +// Named envs (lib/gentle-shell-envs.ts) inside a bundled install: the envs root +// shares ~/.gentle-shell with the prefix, launches use the bundled Pi, and a +// self-uninstall never removes the user's envs. +test("named envs work in a bundled install and survive its self-uninstall", { skip: posixHost }, (t) => { + const prefix = bundledPrefix(t); + const created = prefix.run(["envs", "new", "work"]); + assert.equal(created.status, 0, created.stderr); + const dir = join(prefix.root, "envs", "work"); + assert.ok(existsSync(join(dir, ".gentle-shell-home"))); + assert.match(prefix.run(["envs", "list"]).stdout, /^ {2}work /m); + + const launched = prefix.run(["--env", "work"], { FIXTURE_NO_PROBE: "0" }); + assert.equal(launched.status, 0, launched.stderr); + const seen = lastLaunch(launched); + assert.equal(seen.env.PI_CODING_AGENT_DIR, dir); + assert.equal(seen.env.GENTLE_SHELL_ENV, "work"); + assert.equal(seen.env.GENTLE_SHELL_PI, undefined, "the bundled Pi runs, never GENTLE_SHELL_PI"); + + const plain = lastLaunch(prefix.run([], { GENTLE_SHELL_ENV: "work" })); + assert.equal(plain.env.GENTLE_SHELL_ENV, undefined, "the default home is never labelled as an env"); + + const plan = prefix.run(["self-uninstall", "--dry-run"]).stdout; + assert.ok(plan.includes(`${join(prefix.root, "envs")} (your named envs)`), plan); + assert.doesNotMatch(plan, /envs: not written by the bundled install/); + const removed = prefix.run(["self-uninstall", "--yes"]); + assert.equal(removed.status, 0, removed.stderr); + assert.ok(existsSync(join(dir, ".gentle-shell-home")), "envs are user data and are kept"); + assert.deepEqual(readdirSync(prefix.root).sort(), ["agent", "envs"]); +}); diff --git a/tests/gentle-shell-uninstall.test.ts b/tests/gentle-shell-uninstall.test.ts index 8edfd68b5..b7d5c629a 100644 --- a/tests/gentle-shell-uninstall.test.ts +++ b/tests/gentle-shell-uninstall.test.ts @@ -408,6 +408,33 @@ test("custom --home homes and the user's Pi home are listed as not touched", asy } finally { w.cleanup(); } }); +test("named envs are listed once as not touched and kept, even as the default or provisioned home", async () => { + const w = world(); + try { + const envs = join(w.home, ".gentle-shell", "envs"); + const work = join(envs, "work"); + const personal = join(envs, "personal"); + for (const dir of [work, personal]) write(join(dir, ".gentle-shell-home")); + write(w.paths.launcherConfig, JSON.stringify({ home: work, provisioned: { [personal]: { gentleAi: "4.0.0", at: "now" } } })); + assert.equal(await w.uninstall(["--yes"]), 0); + const plan = w.out.join("\n"); + assert.ok(plan.includes(`${envs} (your named envs)`), plan); + assert.doesNotMatch(plan, /envs[\\/]work +\(custom --home home\)/); + for (const dir of [work, personal]) assert.equal(existsSync(join(dir, ".gentle-shell-home")), true, dir); + } finally { w.cleanup(); } +}); + +test("an isolated home inside the named envs is refused, never removed", async () => { + const w = world(); + try { + const work = join(w.home, ".gentle-shell", "envs", "work"); + write(join(work, ".gentle-shell-home")); + write(join(work, "auth.json"), "{}"); + await refused(w, { GENTLE_SHELL_HOME: work }, /your named envs/); + assert.equal(existsSync(join(work, "auth.json")), true); + } finally { w.cleanup(); } +}); + test("tools the installer may have added are listed with how to remove them, never removed", async () => { for (const platform of ["darwin", "win32"]) { const w = world(); From a7a827b0b38609e35dc1354c24ca14413760234c Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 12:47:35 -0400 Subject: [PATCH 04/25] feat(shell): show the named env as its own Status group above Project --- docs/gentle-shell.md | 2 +- docs/readme-reference.md | 2 +- lib/shell-bar.ts | 3 ++- tests/shell-bar.test.ts | 12 ++++++++---- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index 2311973ad..7ce95583e 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -37,7 +37,7 @@ At 140 columns or wider, fullscreen defaults to a live header over a transcript- - The header carries live session data: session identity on the left (brand, the named env when launched with `--env`, cwd, branch, dirty count, model · effort · profile) and the two live counters right-aligned (the context gauge and session cost). The effort reads `auto` for NaN models that manage their reasoning depth themselves (DeepSeek V4 Flash, Qwen 3.8 Flash, MiMo), with a one-time notice explaining that the selected level does not change the depth. It never shows the working/thinking state or extension statuses — those stay in the prompt indicator and the Status surface. When the terminal is too narrow for everything, segments give way in a fixed order — profile, then effort, then the whole cwd/branch/dirty group — before the counters are touched; below that, only the brand and the env survive, and below that the header renders nothing. - The right rail scrolls **Status → Changes → TODO**. Cards cache their rendered content until their own digest or an explicit invalidation changes. `neon` keeps the rounded border and accent title; `float` uses a tone background, a full-height `▎` accent, one-column transparent margins, painted padding above and below, and a blank painted separator between heading and body. Warning/error/success panels retain their tone colors. -- The Status card carries Project (cwd, named env, branch, session name, active profile), Changes, and Integrations (other extensions' statuses); its live digest detects changes without requiring an event. Model, effort, context, cost, and the per-model usage table live in the header instead — the header ticks every frame, so duplicating them in a card would just make that card repaint every frame too. +- The Status card carries Env (the named env, only when launched with `--env`, shown first), Project (cwd, branch, session name, active profile), Changes, and Integrations (other extensions' statuses); its live digest detects changes without requiring an event. Model, effort, context, cost, and the per-model usage table live in the header instead — the header ticks every frame, so duplicating them in a card would just make that card repaint every frame too. - Gentle Agents is not part of the rail in any mode: its one card stays above the editor, where it already lived, with fixed right-aligned columns for `model · effort`, tokens, cost, and elapsed, each sized to the widest value among the shown tasks — so the numbers line up vertically even when one row's values are much shorter than another's. A queued task fills only the elapsed column with the word `queued`, leaving the other columns blank rather than overwriting the row. - The sidebar reuses its last frame until something it paints changes, so silent frames stay cheap; a per-section cache means one card's changing digest (or the header's) never forces an unrelated card to redraw. - Header placement is configurable. Above input, the float header has a full-width background, painted padding above and below its content, and a transparent `▔` bottom edge. Below input, the float footer groups Changes (when the session's roots have uncommitted work), header data, and locally owned integration statuses under one upper edge; it never duplicates statuses owned by the rail. diff --git a/docs/readme-reference.md b/docs/readme-reference.md index c13712f2f..31ff7fad0 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -381,7 +381,7 @@ A named env is a separate agent home managed by gentle-shell under `~/.gentle-sh | `gentle-shell envs new ` | Creates the env without launching it. Its first launch installs the companion packages, the same as any new `--home`. | | `gentle-shell --env [...]` | Launches, sets up (`--env setup`), or runs pi subcommands (`--env install npm:x`) in that env. | -Inside a session, the header shows `env ` right after the brand, and it stays there as the header narrows. The Status card's Project group shows `Env `. The launcher passes the name to Pi as `GENTLE_SHELL_ENV`, only for a named env, and clears any inherited value for every other home. The default env is stored as the ordinary persisted `home` path, so a plain `gentle-shell` opens it, and `gentle-shell home link|isolated|` replaces it. Renaming or deleting an env keeps `config.json` consistent: its setup record and default follow it or are removed. Envs are separate homes, not sandboxes. They share the repository you work in, `~/.pi/gentle-ai/persona.json`, and `~/.gentle-ai/state.json`, like every other home. Named envs work the same in a bundled (standalone) install. There, the envs root shares `~/.gentle-shell` with the prefix and launches use the bundled Pi. Both `self-uninstall` variants list the envs root once, as "your named envs", under *Not touched*, and never remove it. They also refuse a plan that would remove anything inside it, for example a `GENTLE_SHELL_HOME` that points into an env. +Inside a session, the header shows `env ` right after the brand, and it stays there as the header narrows. The Status card shows an `Env` group with the name, above `Project`. The launcher passes the name to Pi as `GENTLE_SHELL_ENV`, only for a named env, and clears any inherited value for every other home. The default env is stored as the ordinary persisted `home` path, so a plain `gentle-shell` opens it, and `gentle-shell home link|isolated|` replaces it. Renaming or deleting an env keeps `config.json` consistent: its setup record and default follow it or are removed. Envs are separate homes, not sandboxes. They share the repository you work in, `~/.pi/gentle-ai/persona.json`, and `~/.gentle-ai/state.json`, like every other home. Named envs work the same in a bundled (standalone) install. There, the envs root shares `~/.gentle-shell` with the prefix and launches use the bundled Pi. Both `self-uninstall` variants list the envs root once, as "your named envs", under *Not touched*, and never remove it. They also refuse a plan that would remove anything inside it, for example a `GENTLE_SHELL_HOME` that points into an env. ### Managing packages diff --git a/lib/shell-bar.ts b/lib/shell-bar.ts index 6f3c16194..3a80130be 100644 --- a/lib/shell-bar.ts +++ b/lib/shell-bar.ts @@ -229,11 +229,12 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme // this event-driven card keeps only what a footer/model-switch event does // not already refresh every frame. const groups: Array<{ title: string; lines: string[] }> = [ + // The named env comes first: it decides which home this session writes to. + ...(model.env ? [{ title: "Env", lines: [value(sanitizeStatus(model.env))] }] : []), { title: "Project", lines: [ value(model.cwd), - ...(model.env ? [`${label("Env")} ${value(sanitizeStatus(model.env))}`] : []), ...(branch ? [branch] : []), ...(model.sessionName ? [`${label("Session")} ${value(model.sessionName)}`] : []), ...(model.profile ? [`${label("Profile")} ${value(sanitizeStatus(model.profile))}`] : []), diff --git a/tests/shell-bar.test.ts b/tests/shell-bar.test.ts index 958386f0c..97ec82ab4 100644 --- a/tests/shell-bar.test.ts +++ b/tests/shell-bar.test.ts @@ -887,8 +887,12 @@ test("minimal density hides the brand but still names the env", () => { assert.match(text, /^env work/); }); -test("sidebar Status card names the env under Project", () => { - const text = renderShellSidebarBar(model({ env: "work" }), plainTheme, 60).join("\n"); - assert.match(text, /Env work/); - assert.doesNotMatch(renderShellSidebarBar(model(), plainTheme, 60).join("\n"), /Env /); +test("sidebar Status card shows the env as its own group above Project", () => { + const lines = renderShellSidebarBar(model({ env: "work" }), plainTheme, 60).map((line) => line.replace(/[│▎]/g, "").trim()); + const env = lines.indexOf("Env"); + const project = lines.indexOf("Project"); + assert.ok(env >= 0 && project > env, lines.join("\n")); + assert.ok(lines.slice(env + 1, project).includes("work"), lines.join("\n")); + assert.ok(!lines.slice(project).some((line) => /^Env\b/.test(line)), "no Env line left inside Project"); + assert.ok(!renderShellSidebarBar(model(), plainTheme, 60).some((line) => /\bEnv\b/.test(line))); }); From 639b33015d7a9a9fddbbf40aef9364e61c4a5989 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 17:57:30 -0400 Subject: [PATCH 05/25] docs(odd): track named environments --- odd/tasks/named-envs.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 odd/tasks/named-envs.md diff --git a/odd/tasks/named-envs.md b/odd/tasks/named-envs.md new file mode 100644 index 000000000..7db3e91f7 --- /dev/null +++ b/odd/tasks/named-envs.md @@ -0,0 +1,33 @@ +# Named environments (`gentle-shell envs` / `--env`) + +## Objective +Let users keep several named agent homes ("envs"), pick one to open a session in (`gentle-shell --env ` or the `gentle-shell envs` manager), see which one is active, and choose per env which parts of Gentle's own configuration it shares with the base home, like VS Code profiles. + +## Problem and why +A separate home needs `--home ` every time, with no names, no overview of what each home holds, and no visible active home. Gentle's own configuration (`~/.pi/gentle-ai`) is always shared, so homes cannot differ in model profiles, persona, appearance or behavior. + +## Decisions +- Surface: `gentle-shell envs` (a full-screen manager on a TTY, a list otherwise), `envs list|new|config`, and the `--env ` launch flag. +- Storage: managed directories `~/.gentle-shell/envs//` (`GENTLE_SHELL_ENVS_DIR` overrides the root). The directory is the registry. Names are `[a-z0-9][a-z0-9._-]{0,39}`. +- Compatibility first: the install's own `--isolated` home stays the base home and is listed as `Gentle Shell base`. The default reuses the persisted `home` key (`isolated` or an env path). `--env` never creates an unknown env. +- The session shows the active env: `env ` in the header and an `Env` group above `Project` in Status, passed as `GENTLE_SHELL_ENV` only for named envs. +- Per-env Gentle configuration by category (`models` = routing + profiles, `persona`, `appearance`, `behavior`): each one is shared live with the base home, or an own copy in `/gentle-ai/` seeded once (copied from the base home or another env, or empty). Install-level files and repository settings stay shared. Envs without a scope file share everything. +- Out of scope: per-env Engram memory stays shared for now. + +## Delivery +Chained PRs on `main`: +1. `feat/named-envs-core`: `--env`, `envs list|new`, the session indicator, and uninstall protection (bundled and non-bundled). +2. `feat/named-envs-gate`: the `envs` manager with Gentle's visual identity, the base home entry, and `(default)` markers. +3. `feat/named-envs-config`: per-category config resolution (every config-home read names its category, enforced by a drift-guard test), `envs config`, and the VS Code-like form. + +Separate: `feat/installer-download-progress` (TTY download progress for the package-local Gentle AI binary). + +## Evidence +- Test-first for every unit: launcher parsing/resolution, env model, manager reducer/renderer, config scope, bin and bundled-prefix integration. +- Manual checks in a pty: create, default, rename, delete, open, install, the branded gate, the session indicator in a real Pi session, and the config form. +- Real macOS standalone install from the published installer, with these branches overlaid: env CLI, gate, session indicator, uninstall plan ("your named envs"), and an env owning `models` showing its own profile while the base home keeps the shared one. +- No new failures in the touched extension suites compared with `main` (the remaining failures are pre-existing). + +## Known gaps +- Windows is untested: the raw-mode manager, the bundled Windows prefix, and the envs root. +- Prompt history still ignores the active home (#1618), so history mixes across envs until that is fixed. From aacd3813754caed592b4300043618eb3f0a371fd Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 18:32:18 -0400 Subject: [PATCH 06/25] fix(launcher): label an explicit --home env, use the injected env, and protect a symlinked envs root --- extensions/gentle-shell.ts | 2 +- lib/gentle-shell-launcher.ts | 6 +++++- lib/gentle-shell-uninstall.ts | 4 +++- runtime/gentle-shell-launcher.mjs | 6 +++++- runtime/gentle-shell-uninstall.mjs | 4 +++- tests/gentle-shell-envs.test.ts | 9 +++++++++ tests/gentle-shell-uninstall.test.ts | 12 ++++++++++++ 7 files changed, 38 insertions(+), 5 deletions(-) diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index db5754655..6066f7327 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -1920,7 +1920,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p // statuses. The digest is what keeps the fullscreen memo honest, and it // rebuilds the model exactly as the narrow bottom bar does every frame. const footerModel = (): ShellBarModel => ({ - ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile(), env: activeEnvName(process.env), jobs: runningJobs }), + ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile(), env: activeEnvName(env), jobs: runningJobs }), changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted }, review, }); diff --git a/lib/gentle-shell-launcher.ts b/lib/gentle-shell-launcher.ts index e634ce63a..0ebf12d0c 100644 --- a/lib/gentle-shell-launcher.ts +++ b/lib/gentle-shell-launcher.ts @@ -330,7 +330,11 @@ export function resolveHome(input: ResolveHomeInput): ResolvedHome { if (args.link) return { mode: "link", dir: userPiHome(env, homedir), source: "flag" }; if (args.isolated) return { mode: "isolated", dir: isolatedDir(env, homedir), source: "flag" }; - if (args.home !== undefined) return { mode: "path", dir: args.home, source: "flag" }; + if (args.home !== undefined) { + // An explicit --home into the envs root is that env, labelled like --env. + const name = envNameForDir(envsRoot(env, homedir), args.home); + return name === undefined ? { mode: "path", dir: args.home, source: "flag" } : { mode: "path", dir: args.home, source: "flag", env: name }; + } if (args.env !== undefined) return { mode: "path", dir: envDir(envsRoot(env, homedir), args.env), source: "flag", env: args.env }; if (config !== undefined) { diff --git a/lib/gentle-shell-uninstall.ts b/lib/gentle-shell-uninstall.ts index 2cd0021a4..aca2041db 100644 --- a/lib/gentle-shell-uninstall.ts +++ b/lib/gentle-shell-uninstall.ts @@ -349,7 +349,9 @@ async function planSelfUninstall(input: SelfUninstallInput, configHome: string, // Named envs are user data: their root is listed once, never each env as a // custom --home home, and the overlap check below protects all of them. const namedEnvs = envsRoot(env, homedir); - const namedEnvsReal = inspect(namedEnvs, "dir") === "ok" ? canonical(namedEnvs) : undefined; + // A symlinked root still holds the user's envs: protect its real target too. + const namedEnvsState = inspect(namedEnvs, "dir"); + const namedEnvsReal = namedEnvsState === "ok" || (namedEnvsState === "symlink" && inspect(canonical(namedEnvs), "dir") === "ok") ? canonical(namedEnvs) : undefined; if (namedEnvsReal !== undefined) { seen.add(comparable(namedEnvsReal, platform)); plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); diff --git a/runtime/gentle-shell-launcher.mjs b/runtime/gentle-shell-launcher.mjs index 90a77d109..f5594d2ce 100644 --- a/runtime/gentle-shell-launcher.mjs +++ b/runtime/gentle-shell-launcher.mjs @@ -331,7 +331,11 @@ export function resolveHome(input ) { if (args.link) return { mode: "link", dir: userPiHome(env, homedir), source: "flag" }; if (args.isolated) return { mode: "isolated", dir: isolatedDir(env, homedir), source: "flag" }; - if (args.home !== undefined) return { mode: "path", dir: args.home, source: "flag" }; + if (args.home !== undefined) { + // An explicit --home into the envs root is that env, labelled like --env. + const name = envNameForDir(envsRoot(env, homedir), args.home); + return name === undefined ? { mode: "path", dir: args.home, source: "flag" } : { mode: "path", dir: args.home, source: "flag", env: name }; + } if (args.env !== undefined) return { mode: "path", dir: envDir(envsRoot(env, homedir), args.env), source: "flag", env: args.env }; if (config !== undefined) { diff --git a/runtime/gentle-shell-uninstall.mjs b/runtime/gentle-shell-uninstall.mjs index 797201cef..d0ad58dc1 100644 --- a/runtime/gentle-shell-uninstall.mjs +++ b/runtime/gentle-shell-uninstall.mjs @@ -350,7 +350,9 @@ async function planSelfUninstall(input , configHome , // Named envs are user data: their root is listed once, never each env as a // custom --home home, and the overlap check below protects all of them. const namedEnvs = envsRoot(env, homedir); - const namedEnvsReal = inspect(namedEnvs, "dir") === "ok" ? canonical(namedEnvs) : undefined; + // A symlinked root still holds the user's envs: protect its real target too. + const namedEnvsState = inspect(namedEnvs, "dir"); + const namedEnvsReal = namedEnvsState === "ok" || (namedEnvsState === "symlink" && inspect(canonical(namedEnvs), "dir") === "ok") ? canonical(namedEnvs) : undefined; if (namedEnvsReal !== undefined) { seen.add(comparable(namedEnvsReal, platform)); plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); diff --git a/tests/gentle-shell-envs.test.ts b/tests/gentle-shell-envs.test.ts index 2d0590ee1..98d8bde9a 100644 --- a/tests/gentle-shell-envs.test.ts +++ b/tests/gentle-shell-envs.test.ts @@ -163,3 +163,12 @@ test("activeEnvName accepts only a valid env name from the environment", async ( assert.equal(activeEnvName({ GENTLE_SHELL_ENV: "" }), undefined); assert.equal(activeEnvName({ GENTLE_SHELL_ENV: "\x1b[31mevil" }), undefined); }); + +test("resolveHome names an explicit --home that is an env in the envs root", () => { + const dir = join("/h", ".gentle-shell", "envs", "work"); + const home = resolveHome({ args: parseLauncherArgs(["--home", dir]), env: {}, homedir: "/h", config: undefined }); + assert.equal(home.env, "work"); + assert.deepEqual(homeSelectorFlags(home), ["--env", "work"]); + const other = resolveHome({ args: parseLauncherArgs(["--home", "/elsewhere/work"]), env: {}, homedir: "/h", config: undefined }); + assert.equal(other.env, undefined); +}); diff --git a/tests/gentle-shell-uninstall.test.ts b/tests/gentle-shell-uninstall.test.ts index b7d5c629a..67518b3cf 100644 --- a/tests/gentle-shell-uninstall.test.ts +++ b/tests/gentle-shell-uninstall.test.ts @@ -424,6 +424,18 @@ test("named envs are listed once as not touched and kept, even as the default or } finally { w.cleanup(); } }); +test("a symlinked named envs root is still listed as not touched and kept", posixOnly, async () => { + const w = world(); + try { + const real = join(w.root, "real-envs"); + write(join(real, "work", ".gentle-shell-home")); + symlinkSync(real, join(w.home, ".gentle-shell", "envs")); + assert.equal(await w.uninstall(["--yes"]), 0); + assert.ok(w.out.join("\n").includes(`${join(w.home, ".gentle-shell", "envs")} (your named envs)`)); + assert.equal(existsSync(join(real, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + test("an isolated home inside the named envs is refused, never removed", async () => { const w = world(); try { From e4450005322ce5470f7335af9b65ea190edc5b87 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 12:22:02 -0400 Subject: [PATCH 07/25] feat(launcher): manage named envs from a full-screen envs TUI On a terminal, `gentle-shell envs` opens a dependency-free manager (pure reducer/renderer in lib/gentle-shell-envs-tui.ts, raw-mode IO in the bin): select and launch an env, create, rename, delete after typing its name, set the default, run setup, install a package source, and check signed-in providers. The detail pane shows path, setup record, sessions, provider ids (never credentials) and packages. Rename and delete keep config.json's default and setup records consistent; input is handled in order so a key never races a disk write. --- bin/gentle-shell.mjs | 197 ++++++++++++++++++++++- docs/readme-reference.md | 4 +- lib/gentle-shell-envs-tui.ts | 232 +++++++++++++++++++++++++++ lib/gentle-shell-launcher.ts | 6 +- runtime/gentle-shell-envs-tui.mjs | 233 ++++++++++++++++++++++++++++ runtime/gentle-shell-launcher.mjs | 6 +- scripts/build-runtime-modules.mjs | 1 + scripts/verify-package-files.mjs | 2 + tests/gentle-shell-envs-tui.test.ts | 150 ++++++++++++++++++ 9 files changed, 819 insertions(+), 12 deletions(-) create mode 100644 lib/gentle-shell-envs-tui.ts create mode 100644 runtime/gentle-shell-envs-tui.mjs create mode 100644 tests/gentle-shell-envs-tui.test.ts diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index e77bb40bf..d12bbf575 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -107,10 +107,15 @@ import { bundledGentleAiPaths, GENTLE_AI_VERSION, gentleAiBinaryPath, gentleAiIs import { DEFAULT_THEME_NAME, installIsolatedTuiModeSetting } from "../scripts/install-tui-mode-setting.mjs"; import { envDir, + envNameForDir, envsRoot, + removeEnvFromConfig, + renameEnvInConfig, + setDefaultEnvInConfig, summarizeEnv, validateEnvName, } from "../runtime/gentle-shell-envs.mjs"; +import { initialEnvsState, parseKeys, reduceEnvs, renderEnvsScreen } from "../runtime/gentle-shell-envs-tui.mjs"; import { artifactFor, engramPinVersion } from "../scripts/installer-downloads.mjs"; import { migrateBundledEngram, migrationPending, supportsBundledSessionOwned, MIGRATION_PENDING_NOTICE, MIGRATION_USAGE } from "../scripts/bundled-engram-migration.mjs"; import { shouldCheckStartupUpdate, findStartupUpdate } from "../runtime/gentle-shell-startup-update.mjs"; @@ -786,6 +791,48 @@ async function createEnv(root, name) { return dir; } +// Guards every destructive path: only a direct, validly named child of the +// envs root that is a real directory (not a symlink to elsewhere). +function assertManagedEnvDir(root, name) { + const dir = envDir(root, name); + if (envNameForDir(root, dir) !== name) throw new Error(`refusing to touch ${dir}: not a managed env`); + const stat = lstatSync(dir); + if (!stat.isDirectory()) throw new Error(`refusing to touch ${dir}: not a directory`); + return dir; +} + +function renameEnv(root, from, to) { + const oldDir = assertManagedEnvDir(root, from); + const newDir = envDir(root, to); + if (validateEnvName(to) !== undefined) throw new Error(validateEnvName(to)); + if (existsSync(newDir)) throw new Error(`env "${to}" already exists`); + const aliases = [...new Set([oldDir, safeRealpath(oldDir)])]; + renameSync(oldDir, newDir); + const configPath = resolveConfigPath(); + writeRawConfig(configPath, renameEnvInConfig(readRawConfig(configPath), aliases, newDir, safeRealpath(newDir))); +} + +function deleteEnv(root, name) { + const dir = assertManagedEnvDir(root, name); + const aliases = [...new Set([dir, safeRealpath(dir)])]; + rmSync(dir, { recursive: true, force: true }); + const configPath = resolveConfigPath(); + writeRawConfig(configPath, removeEnvFromConfig(readRawConfig(configPath), aliases)); +} + +function setDefaultEnv(root, name) { + const dir = assertManagedEnvDir(root, name); + const configPath = resolveConfigPath(); + writeRawConfig(configPath, setDefaultEnvInConfig(readRawConfig(configPath), dir)); +} + +// Runs this same launcher against one env (setup/install/auth) in the +// foreground, outside the manager's alternate screen. +function runLauncherForEnv(name, extraArgs) { + const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), "--env", name, ...extraArgs], { stdio: "inherit", env: process.env }); + return result.error ? 1 : (result.status ?? 1); +} + function printEnvList(root) { const envs = loadEnvSummaries(root); if (envs.length === 0) { @@ -798,14 +845,15 @@ function printEnvList(root) { } } -const ENVS_USAGE = "Usage: gentle-shell envs [list | new ]\n envs list envs\n envs list print every env; * marks the default\n envs new create an env without launching it"; +const ENVS_USAGE = "Usage: gentle-shell envs [list | new ]\n envs open the env manager (TTY), or list envs\n envs list print every env; * marks the default\n envs new create an env without launching it"; +// Returns the env name to launch, or undefined when nothing should launch. async function handleEnvsCommand(commandArgs) { const root = currentEnvsRoot(); const [sub, ...rest] = commandArgs; if (sub === "list" && rest.length === 0) { printEnvList(root); - return; + return undefined; } if (sub === "new" && rest.length === 1) { const invalid = validateEnvName(rest[0]); @@ -815,14 +863,145 @@ async function handleEnvsCommand(commandArgs) { } catch (error) { fail(`gentle-shell: ${error.message}`, 1); } - return; + return undefined; } if (sub === "--help" || sub === "-h") { process.stdout.write(`${ENVS_USAGE}\n`); - return; + return undefined; } if (sub !== undefined) fail(`${ENVS_USAGE}`, 2); - printEnvList(root); + if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) { + printEnvList(root); + return undefined; + } + return runEnvsManager(root); +} + +// One alternate-screen session: draws `state`, feeds keys to the reducer and +// applies in-place effects through `apply`, and resolves with the first +// effect that needs the real terminal (quit, launch, setup, install, auth). +// The terminal is always restored, including when `apply` throws. +function envsScreenSession(initial, apply) { + const { stdin, stdout } = process; + return new Promise((resolve) => { + let state = initial; + const draw = () => { + // Some ptys report 0x0 until resized; fall back to a usable size. + const lines = renderEnvsScreen(state, stdout.columns || 80, stdout.rows || 24); + stdout.write(`\x1b[H${lines.map((line) => `${line}\x1b[K`).join("\r\n")}\x1b[J`); + }; + const finish = (effect) => { + finished = true; + stdin.off("data", onData); + stdout.off("resize", draw); + stdin.setRawMode(false); + stdin.pause(); + stdout.write("\x1b[?25h\x1b[?1049l"); + resolve({ state, effect }); + }; + // Input is handled strictly in order: a key pressed while `apply` is + // still writing to disk waits for it instead of racing it. + let queue = Promise.resolve(); + let finished = false; + const onData = (data) => { + queue = queue.then(() => handle(data)).catch((error) => { + state = { ...state, message: `Error: ${error.message}` }; + if (!finished) draw(); + }); + }; + const handle = async (data) => { + if (finished) return; + for (const key of parseKeys(data.toString("utf8"))) { + const step = reduceEnvs(state, key); + state = step.state; + if (step.effect === undefined) continue; + const kind = step.effect.kind; + if (kind === "create" || kind === "rename" || kind === "delete" || kind === "set-default") { + state = await apply(state, step.effect); + continue; + } + finish(step.effect); + return; + } + draw(); + }; + stdout.write("\x1b[?1049h\x1b[?25l"); + stdin.setRawMode(true); + stdin.resume(); + stdin.on("data", onData); + stdout.on("resize", draw); + draw(); + }); +} + +function waitForAnyKey(message) { + const { stdin, stdout } = process; + stdout.write(`\n${message}`); + return new Promise((resolve) => { + stdin.setRawMode(true); + stdin.resume(); + stdin.once("data", () => { + stdin.setRawMode(false); + stdin.pause(); + stdout.write("\n"); + resolve(); + }); + }); +} + +async function runEnvsManager(root) { + // Reloads from disk after every change so the view never drifts from it. + const reload = (message, selectName) => initialEnvsState(loadEnvSummaries(root), message, selectName); + const apply = async (state, effect) => { + try { + if (effect.kind === "create") { + await createEnv(root, effect.name); + return reload(`Created ${effect.name}. Press enter to launch it (first launch installs the companion packages).`, effect.name); + } + if (effect.kind === "rename") { + renameEnv(root, effect.from, effect.to); + return reload(`Renamed ${effect.from} to ${effect.to}.`, effect.to); + } + if (effect.kind === "delete") { + deleteEnv(root, effect.name); + return reload(`Deleted ${effect.name}.`); + } + setDefaultEnv(root, effect.name); + return reload(`${effect.name} is now the default: a plain 'gentle-shell' opens it.`, effect.name); + } catch (error) { + return { ...state, message: `Error: ${error.message}` }; + } + }; + + let state = reload(); + for (;;) { + const { state: last, effect } = await envsScreenSession(state, apply); + if (effect.kind === "quit") return undefined; + if (effect.kind === "launch") return effect.name; + let code; + if (effect.kind === "setup") { + process.stdout.write(`gentle-shell: setting up env ${effect.name}\n`); + code = runLauncherForEnv(effect.name, ["setup"]); + } else if (effect.kind === "install") { + code = runLauncherForEnv(effect.name, ["install", effect.source]); + } else { + // Pi signs in from inside a session (/login); outside one it can + // only check readiness, which needs a provider per call. + const providers = last.envs.find((env) => env.name === effect.name)?.providers ?? []; + if (providers.length === 0) { + process.stdout.write(`No providers signed in to ${effect.name}. Launch it and run /login inside the session.\n`); + code = 0; + } else { + code = 0; + for (const provider of providers) { + const providerCode = runLauncherForEnv(effect.name, ["auth", "check", "--provider", provider]); + if (providerCode !== 0) code = providerCode; + } + } + } + await waitForAnyKey(`${code === 0 ? "Done" : `Finished with exit code ${code}`}. Press any key to return to the env manager.`); + state = reload(code === 0 ? undefined : `Last action exited with code ${code}.`, effect.name); + } } // Test/development-only override for the setup subcommand's gentle-ai @@ -2031,8 +2210,12 @@ async function main() { return; } if (args.command === "envs") { - await handleEnvsCommand(args.commandArgs); - return; + const launch = await handleEnvsCommand(args.commandArgs); + if (launch === undefined) return; + // Re-enter the ordinary launch exactly as `gentle-shell --env `, + // so startup checks, provisioning and resume hints all see that argv. + process.argv = [...process.argv.slice(0, 2), "--env", launch]; + return main(); } // The verification input applies to `--version` only (scripts/bundled-install.mjs upgradeBundled). const verifying = args.version && args.command === undefined ? process.env[BUNDLED_VERIFY_ENV] : undefined; diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 31ff7fad0..c66363a6c 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -376,11 +376,13 @@ A named env is a separate agent home managed by gentle-shell under `~/.gentle-sh | Command | Effect | | --- | --- | -| `gentle-shell envs` | Same as `envs list`. | +| `gentle-shell envs` | On a terminal, opens a full-screen env manager. Without a TTY, it behaves like `envs list`. | | `gentle-shell envs list` | Prints every env and its setup state; `*` marks the default. | | `gentle-shell envs new ` | Creates the env without launching it. Its first launch installs the companion packages, the same as any new `--home`. | | `gentle-shell --env [...]` | Launches, sets up (`--env setup`), or runs pi subcommands (`--env install npm:x`) in that env. | +Manager keys: `↑`/`↓` or `j`/`k` select. `enter` launches the selected env. `n` creates an env, `r` renames it, and `d` deletes it after you type its name. `s` makes it the default. `p` runs `setup`, `i` installs a package source, and `a` runs `pi auth check` for each signed-in provider. `q` or `esc` quits. The detail pane shows the path, the setup record, the session count and last session, the signed-in provider ids (never credential values), and the declared packages. + Inside a session, the header shows `env ` right after the brand, and it stays there as the header narrows. The Status card shows an `Env` group with the name, above `Project`. The launcher passes the name to Pi as `GENTLE_SHELL_ENV`, only for a named env, and clears any inherited value for every other home. The default env is stored as the ordinary persisted `home` path, so a plain `gentle-shell` opens it, and `gentle-shell home link|isolated|` replaces it. Renaming or deleting an env keeps `config.json` consistent: its setup record and default follow it or are removed. Envs are separate homes, not sandboxes. They share the repository you work in, `~/.pi/gentle-ai/persona.json`, and `~/.gentle-ai/state.json`, like every other home. Named envs work the same in a bundled (standalone) install. There, the envs root shares `~/.gentle-shell` with the prefix and launches use the bundled Pi. Both `self-uninstall` variants list the envs root once, as "your named envs", under *Not touched*, and never remove it. They also refuse a plan that would remove anything inside it, for example a `GENTLE_SHELL_HOME` that points into an env. ### Managing packages diff --git a/lib/gentle-shell-envs-tui.ts b/lib/gentle-shell-envs-tui.ts new file mode 100644 index 000000000..ccc2eb84c --- /dev/null +++ b/lib/gentle-shell-envs-tui.ts @@ -0,0 +1,232 @@ +import { type EnvSummary, validateEnvName } from "./gentle-shell-envs.ts"; + +// The `gentle-shell envs` manager: a pure key parser, reducer and renderer. +// It is dependency-free on purpose (no pi-tui), because the launcher runs +// before any Pi runtime is resolved. bin/gentle-shell.mjs owns raw-mode +// stdin, the alternate screen, and carrying out the returned effects. + +export type EnvsKey = + | { name: "up" } + | { name: "down" } + | { name: "enter" } + | { name: "backspace" } + | { name: "escape" } + | { name: "ctrl-c" } + | { name: "char"; char: string }; + +export type EnvsEffect = + | { kind: "quit" } + | { kind: "launch"; name: string } + | { kind: "set-default"; name: string } + | { kind: "setup"; name: string } + | { kind: "auth-check"; name: string } + | { kind: "create"; name: string } + | { kind: "rename"; from: string; to: string } + | { kind: "delete"; name: string } + | { kind: "install"; name: string; source: string }; + +type PromptKind = "create" | "rename" | "delete" | "install"; + +export interface EnvsPrompt { + kind: PromptKind; + label: string; + value: string; + // The env the prompt acts on (rename/delete/install). + target?: string; + error?: string; +} + +export interface EnvsState { + envs: EnvSummary[]; + selected: number; + prompt?: EnvsPrompt; + // One-line status shown above the key hints (results, hints). + message?: string; +} + +export function initialEnvsState(envs: EnvSummary[], message?: string, selectName?: string): EnvsState { + const byName = selectName === undefined ? -1 : envs.findIndex((e) => e.name === selectName); + const byDefault = envs.findIndex((e) => e.isDefault); + return { envs, selected: byName >= 0 ? byName : Math.max(0, byDefault), message }; +} + +// --- key parsing ------------------------------------------------------------- + +export function parseKeys(data: string): EnvsKey[] { + const keys: EnvsKey[] = []; + const chars = [...data]; + for (let i = 0; i < chars.length; i += 1) { + const c = chars[i]; + if (c === "\x1b") { + const next = chars[i + 1]; + if (next === "[" || next === "O") { + // CSI / SS3: parameters, then one final byte in @..~. + let j = i + 2; + while (j < chars.length && !/[@-~]/.test(chars[j])) j += 1; + const sequence = chars.slice(i + 2, j + 1).join(""); + if (sequence === "A") keys.push({ name: "up" }); + else if (sequence === "B") keys.push({ name: "down" }); + i = j; + continue; + } + keys.push({ name: "escape" }); + continue; + } + if (c === "\r" || c === "\n") keys.push({ name: "enter" }); + else if (c === "\x7f" || c === "\b") keys.push({ name: "backspace" }); + else if (c === "\x03") keys.push({ name: "ctrl-c" }); + else if (c >= " ") keys.push({ name: "char", char: c }); + } + return keys; +} + +// --- reducer ------------------------------------------------------------------- + +export interface EnvsStep { + state: EnvsState; + effect?: EnvsEffect; +} + +const EMPTY_HINT = "No env selected: press n to create one."; + +function openPrompt(state: EnvsState, prompt: EnvsPrompt): EnvsStep { + return { state: { ...state, prompt, message: undefined } }; +} + +function reducePrompt(state: EnvsState, prompt: EnvsPrompt, key: EnvsKey): EnvsStep { + if (key.name === "escape" || key.name === "ctrl-c") return { state: { ...state, prompt: undefined } }; + if (key.name === "backspace") return { state: { ...state, prompt: { ...prompt, value: [...prompt.value].slice(0, -1).join(""), error: undefined } } }; + if (key.name === "char") return { state: { ...state, prompt: { ...prompt, value: prompt.value + key.char, error: undefined } } }; + if (key.name !== "enter") return { state }; + + const fail = (error: string): EnvsStep => ({ state: { ...state, prompt: { ...prompt, error } } }); + const done = (effect: EnvsEffect): EnvsStep => ({ state: { ...state, prompt: undefined }, effect }); + const value = prompt.value.trim(); + const target = prompt.target ?? ""; + + if (prompt.kind === "create" || prompt.kind === "rename") { + if (prompt.kind === "rename" && value === target) return { state: { ...state, prompt: undefined } }; + const invalid = validateEnvName(value); + if (invalid !== undefined) return fail(invalid); + if (state.envs.some((e) => e.name === value)) return fail(`env "${value}" already exists`); + return done(prompt.kind === "create" ? { kind: "create", name: value } : { kind: "rename", from: target, to: value }); + } + if (prompt.kind === "delete") { + if (value !== target) return fail(`type "${target}" to confirm, or esc to cancel`); + return done({ kind: "delete", name: target }); + } + if (value.length === 0) return fail("enter a package source, e.g. npm:pi-btw"); + return done({ kind: "install", name: target, source: value }); +} + +export function reduceEnvs(state: EnvsState, key: EnvsKey): EnvsStep { + if (state.prompt !== undefined) return reducePrompt(state, state.prompt, key); + + if (key.name === "ctrl-c" || key.name === "escape") return { state, effect: { kind: "quit" } }; + const last = state.envs.length - 1; + const char = key.name === "char" ? key.char : undefined; + if (key.name === "up" || char === "k") return { state: { ...state, selected: Math.max(0, state.selected - 1), message: undefined } }; + if (key.name === "down" || char === "j") return { state: { ...state, selected: Math.max(0, Math.min(last, state.selected + 1)), message: undefined } }; + if (char === "q") return { state, effect: { kind: "quit" } }; + if (char === "n") return openPrompt(state, { kind: "create", label: "New env name", value: "" }); + + const current = state.envs[state.selected]; + const scoped = key.name === "enter" || (char !== undefined && "sparid".includes(char)); + if (!scoped) return { state }; + if (current === undefined) return { state: { ...state, message: EMPTY_HINT } }; + const name = current.name; + if (key.name === "enter") return { state, effect: { kind: "launch", name } }; + if (char === "s") return { state, effect: { kind: "set-default", name } }; + if (char === "p") return { state, effect: { kind: "setup", name } }; + if (char === "a") return { state, effect: { kind: "auth-check", name } }; + if (char === "r") return openPrompt(state, { kind: "rename", label: `Rename "${name}" to`, value: name, target: name }); + if (char === "i") return openPrompt(state, { kind: "install", label: `Install into "${name}"`, value: "", target: name }); + return openPrompt(state, { kind: "delete", label: `Delete "${name}" and all its data? Type its name`, value: "", target: name }); +} + +// --- rendering ----------------------------------------------------------------- + +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; +const INVERSE = "\x1b[7m"; +const RED = "\x1b[31m"; +const ACCENT = "\x1b[35m"; +const RESET = "\x1b[0m"; + +// Truncate to `width` code points (the renderer only emits plain text inside +// a style, so visible width equals code points for the ASCII it controls). +function fit(text: string, width: number): string { + if (width <= 0) return ""; + const chars = [...text]; + if (chars.length <= width) return text; + return width === 1 ? "…" : `${chars.slice(0, width - 1).join("")}…`; +} + +function pad(text: string, width: number): string { + const fitted = fit(text, width); + return fitted + " ".repeat(Math.max(0, width - [...fitted].length)); +} + +function styled(style: string, text: string): string { + return text.length === 0 ? "" : `${style}${text}${RESET}`; +} + +function detailLines(env: EnvSummary | undefined): string[] { + if (env === undefined) return ["No envs yet.", "", "Press n to create your first env."]; + const sessions = `${env.sessionCount} session${env.sessionCount === 1 ? "" : "s"}`; + return [ + `${env.name}${env.isDefault ? " (default)" : ""}`, + "", + `Path ${env.dir}`, + `Setup ${env.provisioned}`, + `Sessions ${sessions}${env.lastSessionAt ? ` · last ${env.lastSessionAt.slice(0, 16).replace("T", " ")}` : ""}`, + `Providers ${env.providers.length > 0 ? env.providers.join(", ") : "none signed in"}`, + `Packages ${env.packages.length > 0 ? "" : "none"}`, + ...env.packages.map((source) => ` · ${source}`), + ]; +} + +const HINTS = "enter launch · n new · r rename · d delete · s default · p setup · i install · a auth · q quit"; + +export function renderEnvsScreen(state: EnvsState, width: number, height: number): string[] { + const w = Math.max(1, width); + const h = Math.max(1, height); + const lines: string[] = []; + + const header = `gentle-shell envs · ${state.envs.length} env${state.envs.length === 1 ? "" : "s"}`; + lines.push(styled(BOLD + ACCENT, pad(header, w))); + + // Footer: status/prompt line plus the key hints. + const footer: string[] = []; + if (state.prompt !== undefined) { + const p = state.prompt; + footer.push(styled(BOLD, pad(`${p.label}: ${p.value}`, w))); + footer.push(p.error === undefined ? styled(DIM, pad("enter confirm · esc cancel", w)) : styled(RED, pad(p.error, w))); + } else { + footer.push(state.message === undefined ? " ".repeat(w) : pad(state.message, w)); + footer.push(styled(DIM, pad(HINTS, w))); + } + + const bodyHeight = Math.max(0, h - 1 - footer.length); + const listWidth = Math.min(28, Math.max(8, Math.floor(w / 3))); + const detailWidth = w - listWidth - 3; + const details = detailLines(state.envs[state.selected]); + // Keep the selection visible when the list is taller than the body. + const offset = Math.max(0, Math.min(state.selected - bodyHeight + 1, state.envs.length - bodyHeight)); + for (let row = 0; row < bodyHeight; row += 1) { + const index = row + Math.max(0, offset); + const env = state.envs[index]; + const label = env === undefined ? "" : `${env.isDefault ? "★" : " "} ${env.name}`; + const selected = env !== undefined && index === state.selected; + // Too narrow for the detail pane: the list alone takes the full width. + const listOnly = detailWidth < 4; + const cell = pad(label, listOnly ? w : listWidth); + const left = selected ? styled(INVERSE, cell) : cell; + lines.push(listOnly ? left : `${left} │ ${pad(details[row] ?? "", detailWidth)}`); + } + + // Tiny terminals: the header and footer still must not exceed `h` lines. + const all = [...lines, ...footer]; + if (all.length > h) return [...all.slice(0, Math.max(0, h - footer.length)), ...footer].slice(-h); + return all; +} diff --git a/lib/gentle-shell-launcher.ts b/lib/gentle-shell-launcher.ts index 0ebf12d0c..0df5a054f 100644 --- a/lib/gentle-shell-launcher.ts +++ b/lib/gentle-shell-launcher.ts @@ -1361,8 +1361,10 @@ export function helpText(): string { " level when it has none. Never overwrites a file or setting, never copies", " packages, extensions or caches, and never changes your pi home.", " --dry-run only shows the plan. Refuses --link (nothing to import).", - " envs List named envs (* = default); 'envs new ' creates one.", - " Envs are separate homes, not sandboxes.", + " envs Manage named envs: on a terminal, a full-screen manager to create,", + " rename, delete, launch, set the default, set up, install into and", + " check sign-ins of each env. 'envs list' prints them (* = default);", + " 'envs new ' creates one. Envs are separate homes, not sandboxes.", " setup Provision the resolved home with the gentle-ai companion packages", " (runs the package-local gentle-ai 'install --agent pi --scope global').", " Accepts --dry-run, forwarded to gentle-ai. Accepts a home selector", diff --git a/runtime/gentle-shell-envs-tui.mjs b/runtime/gentle-shell-envs-tui.mjs new file mode 100644 index 000000000..976d1b189 --- /dev/null +++ b/runtime/gentle-shell-envs-tui.mjs @@ -0,0 +1,233 @@ +// Generated by scripts/build-runtime-modules.mjs. Do not edit. +import { validateEnvName } from "./gentle-shell-envs.mjs"; + +// The `gentle-shell envs` manager: a pure key parser, reducer and renderer. +// It is dependency-free on purpose (no pi-tui), because the launcher runs +// before any Pi runtime is resolved. bin/gentle-shell.mjs owns raw-mode +// stdin, the alternate screen, and carrying out the returned effects. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +export function initialEnvsState(envs , message , selectName ) { + const byName = selectName === undefined ? -1 : envs.findIndex((e) => e.name === selectName); + const byDefault = envs.findIndex((e) => e.isDefault); + return { envs, selected: byName >= 0 ? byName : Math.max(0, byDefault), message }; +} + +// --- key parsing ------------------------------------------------------------- + +export function parseKeys(data ) { + const keys = []; + const chars = [...data]; + for (let i = 0; i < chars.length; i += 1) { + const c = chars[i]; + if (c === "\x1b") { + const next = chars[i + 1]; + if (next === "[" || next === "O") { + // CSI / SS3: parameters, then one final byte in @..~. + let j = i + 2; + while (j < chars.length && !/[@-~]/.test(chars[j])) j += 1; + const sequence = chars.slice(i + 2, j + 1).join(""); + if (sequence === "A") keys.push({ name: "up" }); + else if (sequence === "B") keys.push({ name: "down" }); + i = j; + continue; + } + keys.push({ name: "escape" }); + continue; + } + if (c === "\r" || c === "\n") keys.push({ name: "enter" }); + else if (c === "\x7f" || c === "\b") keys.push({ name: "backspace" }); + else if (c === "\x03") keys.push({ name: "ctrl-c" }); + else if (c >= " ") keys.push({ name: "char", char: c }); + } + return keys; +} + +// --- reducer ------------------------------------------------------------------- + + + + + + +const EMPTY_HINT = "No env selected: press n to create one."; + +function openPrompt(state , prompt ) { + return { state: { ...state, prompt, message: undefined } }; +} + +function reducePrompt(state , prompt , key ) { + if (key.name === "escape" || key.name === "ctrl-c") return { state: { ...state, prompt: undefined } }; + if (key.name === "backspace") return { state: { ...state, prompt: { ...prompt, value: [...prompt.value].slice(0, -1).join(""), error: undefined } } }; + if (key.name === "char") return { state: { ...state, prompt: { ...prompt, value: prompt.value + key.char, error: undefined } } }; + if (key.name !== "enter") return { state }; + + const fail = (error ) => ({ state: { ...state, prompt: { ...prompt, error } } }); + const done = (effect ) => ({ state: { ...state, prompt: undefined }, effect }); + const value = prompt.value.trim(); + const target = prompt.target ?? ""; + + if (prompt.kind === "create" || prompt.kind === "rename") { + if (prompt.kind === "rename" && value === target) return { state: { ...state, prompt: undefined } }; + const invalid = validateEnvName(value); + if (invalid !== undefined) return fail(invalid); + if (state.envs.some((e) => e.name === value)) return fail(`env "${value}" already exists`); + return done(prompt.kind === "create" ? { kind: "create", name: value } : { kind: "rename", from: target, to: value }); + } + if (prompt.kind === "delete") { + if (value !== target) return fail(`type "${target}" to confirm, or esc to cancel`); + return done({ kind: "delete", name: target }); + } + if (value.length === 0) return fail("enter a package source, e.g. npm:pi-btw"); + return done({ kind: "install", name: target, source: value }); +} + +export function reduceEnvs(state , key ) { + if (state.prompt !== undefined) return reducePrompt(state, state.prompt, key); + + if (key.name === "ctrl-c" || key.name === "escape") return { state, effect: { kind: "quit" } }; + const last = state.envs.length - 1; + const char = key.name === "char" ? key.char : undefined; + if (key.name === "up" || char === "k") return { state: { ...state, selected: Math.max(0, state.selected - 1), message: undefined } }; + if (key.name === "down" || char === "j") return { state: { ...state, selected: Math.max(0, Math.min(last, state.selected + 1)), message: undefined } }; + if (char === "q") return { state, effect: { kind: "quit" } }; + if (char === "n") return openPrompt(state, { kind: "create", label: "New env name", value: "" }); + + const current = state.envs[state.selected]; + const scoped = key.name === "enter" || (char !== undefined && "sparid".includes(char)); + if (!scoped) return { state }; + if (current === undefined) return { state: { ...state, message: EMPTY_HINT } }; + const name = current.name; + if (key.name === "enter") return { state, effect: { kind: "launch", name } }; + if (char === "s") return { state, effect: { kind: "set-default", name } }; + if (char === "p") return { state, effect: { kind: "setup", name } }; + if (char === "a") return { state, effect: { kind: "auth-check", name } }; + if (char === "r") return openPrompt(state, { kind: "rename", label: `Rename "${name}" to`, value: name, target: name }); + if (char === "i") return openPrompt(state, { kind: "install", label: `Install into "${name}"`, value: "", target: name }); + return openPrompt(state, { kind: "delete", label: `Delete "${name}" and all its data? Type its name`, value: "", target: name }); +} + +// --- rendering ----------------------------------------------------------------- + +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; +const INVERSE = "\x1b[7m"; +const RED = "\x1b[31m"; +const ACCENT = "\x1b[35m"; +const RESET = "\x1b[0m"; + +// Truncate to `width` code points (the renderer only emits plain text inside +// a style, so visible width equals code points for the ASCII it controls). +function fit(text , width ) { + if (width <= 0) return ""; + const chars = [...text]; + if (chars.length <= width) return text; + return width === 1 ? "…" : `${chars.slice(0, width - 1).join("")}…`; +} + +function pad(text , width ) { + const fitted = fit(text, width); + return fitted + " ".repeat(Math.max(0, width - [...fitted].length)); +} + +function styled(style , text ) { + return text.length === 0 ? "" : `${style}${text}${RESET}`; +} + +function detailLines(env ) { + if (env === undefined) return ["No envs yet.", "", "Press n to create your first env."]; + const sessions = `${env.sessionCount} session${env.sessionCount === 1 ? "" : "s"}`; + return [ + `${env.name}${env.isDefault ? " (default)" : ""}`, + "", + `Path ${env.dir}`, + `Setup ${env.provisioned}`, + `Sessions ${sessions}${env.lastSessionAt ? ` · last ${env.lastSessionAt.slice(0, 16).replace("T", " ")}` : ""}`, + `Providers ${env.providers.length > 0 ? env.providers.join(", ") : "none signed in"}`, + `Packages ${env.packages.length > 0 ? "" : "none"}`, + ...env.packages.map((source) => ` · ${source}`), + ]; +} + +const HINTS = "enter launch · n new · r rename · d delete · s default · p setup · i install · a auth · q quit"; + +export function renderEnvsScreen(state , width , height ) { + const w = Math.max(1, width); + const h = Math.max(1, height); + const lines = []; + + const header = `gentle-shell envs · ${state.envs.length} env${state.envs.length === 1 ? "" : "s"}`; + lines.push(styled(BOLD + ACCENT, pad(header, w))); + + // Footer: status/prompt line plus the key hints. + const footer = []; + if (state.prompt !== undefined) { + const p = state.prompt; + footer.push(styled(BOLD, pad(`${p.label}: ${p.value}`, w))); + footer.push(p.error === undefined ? styled(DIM, pad("enter confirm · esc cancel", w)) : styled(RED, pad(p.error, w))); + } else { + footer.push(state.message === undefined ? " ".repeat(w) : pad(state.message, w)); + footer.push(styled(DIM, pad(HINTS, w))); + } + + const bodyHeight = Math.max(0, h - 1 - footer.length); + const listWidth = Math.min(28, Math.max(8, Math.floor(w / 3))); + const detailWidth = w - listWidth - 3; + const details = detailLines(state.envs[state.selected]); + // Keep the selection visible when the list is taller than the body. + const offset = Math.max(0, Math.min(state.selected - bodyHeight + 1, state.envs.length - bodyHeight)); + for (let row = 0; row < bodyHeight; row += 1) { + const index = row + Math.max(0, offset); + const env = state.envs[index]; + const label = env === undefined ? "" : `${env.isDefault ? "★" : " "} ${env.name}`; + const selected = env !== undefined && index === state.selected; + // Too narrow for the detail pane: the list alone takes the full width. + const listOnly = detailWidth < 4; + const cell = pad(label, listOnly ? w : listWidth); + const left = selected ? styled(INVERSE, cell) : cell; + lines.push(listOnly ? left : `${left} │ ${pad(details[row] ?? "", detailWidth)}`); + } + + // Tiny terminals: the header and footer still must not exceed `h` lines. + const all = [...lines, ...footer]; + if (all.length > h) return [...all.slice(0, Math.max(0, h - footer.length)), ...footer].slice(-h); + return all; +} diff --git a/runtime/gentle-shell-launcher.mjs b/runtime/gentle-shell-launcher.mjs index f5594d2ce..d7db2bfd2 100644 --- a/runtime/gentle-shell-launcher.mjs +++ b/runtime/gentle-shell-launcher.mjs @@ -1362,8 +1362,10 @@ export function helpText() { " level when it has none. Never overwrites a file or setting, never copies", " packages, extensions or caches, and never changes your pi home.", " --dry-run only shows the plan. Refuses --link (nothing to import).", - " envs List named envs (* = default); 'envs new ' creates one.", - " Envs are separate homes, not sandboxes.", + " envs Manage named envs: on a terminal, a full-screen manager to create,", + " rename, delete, launch, set the default, set up, install into and", + " check sign-ins of each env. 'envs list' prints them (* = default);", + " 'envs new ' creates one. Envs are separate homes, not sandboxes.", " setup Provision the resolved home with the gentle-ai companion packages", " (runs the package-local gentle-ai 'install --agent pi --scope global').", " Accepts --dry-run, forwarded to gentle-ai. Accepts a home selector", diff --git a/scripts/build-runtime-modules.mjs b/scripts/build-runtime-modules.mjs index 74d3c71a1..e97404322 100644 --- a/scripts/build-runtime-modules.mjs +++ b/scripts/build-runtime-modules.mjs @@ -16,6 +16,7 @@ const sources = [ "telemetry-trigger", "child-package-injection", "gentle-shell-envs", + "gentle-shell-envs-tui", "gentle-shell-launcher", "gentle-shell-bundled", "gentle-shell-startup-update", diff --git a/scripts/verify-package-files.mjs b/scripts/verify-package-files.mjs index 1d02035ac..dfbbf9636 100644 --- a/scripts/verify-package-files.mjs +++ b/scripts/verify-package-files.mjs @@ -85,6 +85,7 @@ export const requiredPaths = [ "lib/child-package-injection.ts", "lib/gentle-ai-binary.ts", "lib/gentle-shell-envs.ts", + "lib/gentle-shell-envs-tui.ts", "lib/gentle-shell-launcher.ts", "lib/gentle-shell-bundled.ts", "lib/gentle-shell-startup-update.ts", @@ -103,6 +104,7 @@ export const requiredPaths = [ "runtime/child-package-injection.mjs", "runtime/gentle-ai-binary.mjs", "runtime/gentle-shell-envs.mjs", + "runtime/gentle-shell-envs-tui.mjs", "runtime/gentle-shell-launcher.mjs", "runtime/gentle-shell-bundled.mjs", "runtime/gentle-shell-startup-update.mjs", diff --git a/tests/gentle-shell-envs-tui.test.ts b/tests/gentle-shell-envs-tui.test.ts new file mode 100644 index 000000000..067cf0f68 --- /dev/null +++ b/tests/gentle-shell-envs-tui.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { type EnvSummary } from "../lib/gentle-shell-envs.ts"; +import { initialEnvsState, parseKeys, reduceEnvs, renderEnvsScreen, type EnvsKey, type EnvsState } from "../lib/gentle-shell-envs-tui.ts"; + +function env(name: string, extra: Partial = {}): EnvSummary { + return { name, dir: `/e/${name}`, isDefault: false, packages: [], providers: [], provisioned: "not provisioned", sessionCount: 0, ...extra }; +} + +const char = (c: string): EnvsKey => ({ name: "char", char: c }); + +function press(state: EnvsState, ...keys: EnvsKey[]) { + let effect; + for (const key of keys) ({ state, effect } = reduceEnvs(state, key)); + return { state, effect }; +} + +function typed(text: string): EnvsKey[] { + return [...text].map(char); +} + +// --- key parsing ----------------------------------------------------------- + +test("parseKeys decodes arrows, enter, backspace, escape, ctrl-c and printable text", () => { + assert.deepEqual(parseKeys("\x1b[A\x1b[B\r\x7fab\x03"), [ + { name: "up" }, + { name: "down" }, + { name: "enter" }, + { name: "backspace" }, + char("a"), + char("b"), + { name: "ctrl-c" }, + ]); + assert.deepEqual(parseKeys("\x1b"), [{ name: "escape" }]); + assert.deepEqual(parseKeys("\x1bOA\x1bOB"), [{ name: "up" }, { name: "down" }]); + assert.deepEqual(parseKeys("\x1b[5~"), [], "unknown escape sequences are dropped"); +}); + +// --- navigation and direct actions ------------------------------------------- + +test("selection moves with arrows and j/k and is clamped", () => { + const s0 = initialEnvsState([env("a"), env("b")]); + assert.equal(press(s0, { name: "down" }, char("j"), char("j")).state.selected, 1); + assert.equal(press(s0, { name: "down" }, char("k"), { name: "up" }).state.selected, 0); +}); + +test("initial selection starts on the default env", () => { + assert.equal(initialEnvsState([env("a"), env("b", { isDefault: true })]).selected, 1); +}); + +test("enter launches, s sets default, p sets up, a checks auth, q quits", () => { + const s0 = initialEnvsState([env("a"), env("b")]); + const s1 = press(s0, { name: "down" }).state; + assert.deepEqual(press(s1, { name: "enter" }).effect, { kind: "launch", name: "b" }); + assert.deepEqual(press(s1, char("s")).effect, { kind: "set-default", name: "b" }); + assert.deepEqual(press(s1, char("p")).effect, { kind: "setup", name: "b" }); + assert.deepEqual(press(s1, char("a")).effect, { kind: "auth-check", name: "b" }); + assert.deepEqual(press(s1, char("q")).effect, { kind: "quit" }); + assert.deepEqual(press(s1, { name: "escape" }).effect, { kind: "quit" }); + assert.deepEqual(press(s1, { name: "ctrl-c" }).effect, { kind: "quit" }); +}); + +test("env-scoped actions on an empty list do nothing but explain how to create one", () => { + const { state, effect } = press(initialEnvsState([]), { name: "enter" }); + assert.equal(effect, undefined); + assert.match(state.message ?? "", /n/); +}); + +// --- prompts --------------------------------------------------------------- + +test("n opens a name prompt that validates and rejects duplicates before creating", () => { + const s0 = initialEnvsState([env("work")]); + const bad = press(s0, char("n"), ...typed("Work"), { name: "enter" }); + assert.equal(bad.effect, undefined); + assert.match(bad.state.prompt?.error ?? "", /env name/); + const dup = press(s0, char("n"), ...typed("work"), { name: "enter" }); + assert.match(dup.state.prompt?.error ?? "", /already exists/); + const ok = press(s0, char("n"), ...typed("perzonal"), { name: "backspace" }, { name: "backspace" }, ...typed("al"), { name: "enter" }); + assert.deepEqual(ok.effect, { kind: "create", name: "perzonal".slice(0, -2) + "al" }); + assert.equal(ok.state.prompt, undefined); +}); + +test("keys typed into a prompt never trigger list actions", () => { + const { state, effect } = press(initialEnvsState([env("a")]), char("n"), char("q"), char("d"), char("s")); + assert.equal(effect, undefined); + assert.equal(state.prompt?.value, "qds"); +}); + +test("escape cancels a prompt without quitting", () => { + const { state, effect } = press(initialEnvsState([env("a")]), char("n"), char("x"), { name: "escape" }); + assert.equal(effect, undefined); + assert.equal(state.prompt, undefined); +}); + +test("r renames the selected env, prefilled, and a no-op rename just closes", () => { + const s0 = initialEnvsState([env("old"), env("taken")]); + const opened = press(s0, char("r")).state; + assert.equal(opened.prompt?.value, "old"); + assert.equal(press(opened, { name: "enter" }).effect, undefined); + assert.match(press(opened, ...Array(3).fill({ name: "backspace" }), ...typed("taken"), { name: "enter" }).state.prompt?.error ?? "", /already exists/); + assert.deepEqual(press(opened, { name: "backspace" }, char("x"), { name: "enter" }).effect, { kind: "rename", from: "old", to: "olx" }); +}); + +test("d deletes only after the env name is typed exactly", () => { + const s0 = initialEnvsState([env("work")]); + const wrong = press(s0, char("d"), ...typed("wor"), { name: "enter" }); + assert.equal(wrong.effect, undefined); + assert.match(wrong.state.prompt?.error ?? "", /type "work"/); + assert.deepEqual(press(s0, char("d"), ...typed("work"), { name: "enter" }).effect, { kind: "delete", name: "work" }); +}); + +test("i installs a non-empty package source into the selected env", () => { + const s0 = initialEnvsState([env("work")]); + assert.match(press(s0, char("i"), { name: "enter" }).state.prompt?.error ?? "", /source/); + assert.deepEqual(press(s0, char("i"), ...typed("npm:pi-btw"), { name: "enter" }).effect, { kind: "install", name: "work", source: "npm:pi-btw" }); +}); + +// --- rendering --------------------------------------------------------------- + +test("render fits the terminal exactly and shows the list, details and key hints", () => { + const state = initialEnvsState([ + env("work", { isDefault: true, packages: ["npm:gentle-engram"], providers: ["anthropic"], sessionCount: 2, lastSessionAt: "2026-05-02T10:00:00.000Z" }), + env("personal"), + ]); + const lines = renderEnvsScreen(state, 80, 20); + assert.equal(lines.length, 20); + for (const line of lines) assert.ok(stripAnsi(line).length <= 80, `too wide: ${line}`); + const text = lines.map(stripAnsi).join("\n"); + for (const expected of ["work", "personal", "default", "/e/work", "npm:gentle-engram", "anthropic", "2 sessions", "enter"]) { + assert.ok(text.includes(expected), `missing ${expected}`); + } +}); + +test("render shows the prompt with its error, and an empty-state hint", () => { + const prompting = press(initialEnvsState([env("a")]), char("n"), ...typed("Bad"), { name: "enter" }).state; + const text = renderEnvsScreen(prompting, 60, 12).map(stripAnsi).join("\n"); + assert.ok(text.includes("New env name: Bad")); + assert.match(text, /invalid env name/); + assert.match(renderEnvsScreen(initialEnvsState([]), 60, 12).map(stripAnsi).join("\n"), /No envs yet/); +}); + +test("render degrades without throwing on tiny terminals", () => { + const lines = renderEnvsScreen(initialEnvsState([env("a")]), 10, 3); + assert.equal(lines.length, 3); + for (const line of lines) assert.ok(stripAnsi(line).length <= 10); +}); + +function stripAnsi(text: string): string { + return text.replace(/\x1b\[[0-9;]*m/g, ""); +} From 79f44e51703b18939f604098a3636ac27a47f613 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 12:55:18 -0400 Subject: [PATCH 08/25] feat(launcher): give the envs gate Gentle's visual identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gentle-shell envs` is the doorway into Gentle Shell, so it now speaks Gentle's visual language: the braille rose (on tall terminals), the `✿ Gentle Shell vX` title, rounded `╭─ ✿ Title ─╮` cards like the Status card, a rose `❯` selection and `★` default instead of reverse video, and rose key hints that wrap instead of losing actions. The cards fit their content and the whole composition is centered. The Gentle truecolor palette is used only when the terminal supports it and NO_COLOR is unset; otherwise the same layout renders in monochrome. The rose art moves from the startup banner into lib/gentle-brand.ts (pure data, no Pi imports) so the pre-Pi launcher and the banner share one source. --- bin/gentle-shell.mjs | 8 +- extensions/startup-banner.ts | 23 +-- lib/gentle-brand.ts | 44 ++++++ lib/gentle-shell-envs-tui.ts | 221 ++++++++++++++++++++------- runtime/gentle-brand.mjs | 45 ++++++ runtime/gentle-shell-envs-tui.mjs | 223 +++++++++++++++++++++------- scripts/build-runtime-modules.mjs | 1 + scripts/verify-package-files.mjs | 2 + tests/gentle-shell-envs-tui.test.ts | 75 ++++++++++ 9 files changed, 517 insertions(+), 125 deletions(-) create mode 100644 lib/gentle-brand.ts create mode 100644 runtime/gentle-brand.mjs diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index d12bbf575..b1e39abf6 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -883,11 +883,17 @@ async function handleEnvsCommand(commandArgs) { // The terminal is always restored, including when `apply` throws. function envsScreenSession(initial, apply) { const { stdin, stdout } = process; + // Gentle truecolor only where the terminal has it and NO_COLOR is unset; + // otherwise the same layout in monochrome. + const renderOptions = { + color: !process.env.NO_COLOR && stdout.hasColors?.(2 ** 24) === true, + version: ownPackageVersion(), + }; return new Promise((resolve) => { let state = initial; const draw = () => { // Some ptys report 0x0 until resized; fall back to a usable size. - const lines = renderEnvsScreen(state, stdout.columns || 80, stdout.rows || 24); + const lines = renderEnvsScreen(state, stdout.columns || 80, stdout.rows || 24, renderOptions); stdout.write(`\x1b[H${lines.map((line) => `${line}\x1b[K`).join("\r\n")}\x1b[J`); }; const finish = (effect) => { diff --git a/extensions/startup-banner.ts b/extensions/startup-banner.ts index ea1aa5ffb..1e6696c68 100644 --- a/extensions/startup-banner.ts +++ b/extensions/startup-banner.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { resolveAnimationPolicy } from "../lib/animation-policy.ts"; import { PI_SUBCOMMANDS } from "../lib/gentle-shell-launcher.ts"; import { gentlePiConfigHome } from "../lib/agent-home.ts"; +import { ROSE_ART as ROSE_LARGE_RAW } from "../lib/gentle-brand.ts"; export type BannerColor = "pink" | "cyan" | "yellow" | "green"; @@ -42,26 +43,6 @@ const TEXT_LOGO = [ " ▒▒▒▒▒▒", ]; -const ROSE_LARGE_RAW = [ - " ⣠⣾⣷⣶⣦⣤⣤⣄⣠⣄⣀ ⢀⣀⣀", - " ⢀⣴⣿⣿⠿⣋⣭⣭⣯⣭⣍⣭⣿⣟⠛⠛⠿⣿⣷⣄", - " ⢀⣴⣾⡟⢻⣿⡟⠁⣼⣿⠏⣵⢻⣿⣻⣿⣿⢿⡻⣿⣿⣶⡌⢿⣿⣷⣦⣤⡄", - " ⣤⣶⣾⣿⣿⠏ ⠈⢿⣄ ⢹⣏⠠⠟⣾⣿⣿⣿⣿⣿⠷⣏⣼⠟⢡⣿⡟⠋⢻⣿⣿⡄", - " ⠈⣿⣿⣿⣿⡆ ⣽⢧⡘⠈⠳⣦⣍⠛⠛⢦⣉⣴⣛⣫⣭⣴⡟⠋ ⣾⣿⣿⡿", - " ⢀⠹⣿⣿⣿⣷⣤⡄ ⠋ ⠙⢆ ⣠⠴⠟⠛⣛⣛⣛⠟⠋⠁⠺⡇ ⣀⣴⣿⣿⡟⠁", - " ⠈⣀⠈⠛⠷⠿⣿⣿⣷⣤⣀ ⢠⠋ ⠈⠉⠉ ⣠⣴⣥⠾⠛⠉⣰⣿⣷", - " ⠹⣯⣝⠛⠛⠷⢶⣤⣤⣀ ⢀⡠⠖⠋⠉⢉⣀⣀⣴⣾⣿⠿⠟⠃", - " ⠘⠻⢿⣦⣄⡀ ⠉⠛⢦⠠⢊⠤⠴⢒⣛⣛⣩⣽⡿⠟⠁", - " ⠶⢶⣤⣄⡀⠨⠭⠽⠟⣓⢦⣀⠈⢇⡥⠖⠛⠋⠉⠉", - " ⠈⢷ ⠐⠂⢤⣽⣄ ⠰⡎⠙⠳⣄⡀ ⠈⢣⠘⢦⠋", - " ⠈⢳⣀⡒⠉⠉⣉⠙⡲⣽⣄ ⣏⠳⡄ ⠘⡇ ⡾⠁", - " ⠛⠻⢦⣄⣉⡁⣀⣀⣈⣙⣺⣌⡇⢠⢀⡇⡾", - " ⠈⠉ ⠈⠳⡄⣸⢱⠇", - " ⡷⠡⡯⢖⠉", - " ⢀⡴⢪⠔⣉⠔⠋", - " ⠐⠈", -]; - function rgb(r: number, g: number, b: number, text: string): string { return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`; } @@ -665,7 +646,7 @@ export default function (pi: ExtensionAPI) { // clearing stdout here would leave its previous-frame cache out of sync. const bannerConfig = await readBannerConfig(); const palette = BANNER_PALETTES[bannerConfig.color]; - const roseBase = padLines(normalizeAscii(ROSE_LARGE_RAW)); + const roseBase = padLines(normalizeAscii([...ROSE_LARGE_RAW])); const logoBase = padLines(TEXT_LOGO); void warmupLetterStrokes(); diff --git a/lib/gentle-brand.ts b/lib/gentle-brand.ts new file mode 100644 index 000000000..41c9bd5a1 --- /dev/null +++ b/lib/gentle-brand.ts @@ -0,0 +1,44 @@ +// Gentle's visual identity, shared by the Pi startup banner and the launcher's +// pre-Pi screens (the envs gate). Pure data and ANSI helpers only: the +// launcher runs before any Pi package is loaded. + +// The braille rose of the startup banner. +export const ROSE_ART: readonly string[] = Object.freeze([ + " ⣠⣾⣷⣶⣦⣤⣤⣄⣠⣄⣀ ⢀⣀⣀", + " ⢀⣴⣿⣿⠿⣋⣭⣭⣯⣭⣍⣭⣿⣟⠛⠛⠿⣿⣷⣄", + " ⢀⣴⣾⡟⢻⣿⡟⠁⣼⣿⠏⣵⢻⣿⣻⣿⣿⢿⡻⣿⣿⣶⡌⢿⣿⣷⣦⣤⡄", + " ⣤⣶⣾⣿⣿⠏ ⠈⢿⣄ ⢹⣏⠠⠟⣾⣿⣿⣿⣿⣿⠷⣏⣼⠟⢡⣿⡟⠋⢻⣿⣿⡄", + " ⠈⣿⣿⣿⣿⡆ ⣽⢧⡘⠈⠳⣦⣍⠛⠛⢦⣉⣴⣛⣫⣭⣴⡟⠋ ⣾⣿⣿⡿", + " ⢀⠹⣿⣿⣿⣷⣤⡄ ⠋ ⠙⢆ ⣠⠴⠟⠛⣛⣛⣛⠟⠋⠁⠺⡇ ⣀⣴⣿⣿⡟⠁", + " ⠈⣀⠈⠛⠷⠿⣿⣿⣷⣤⣀ ⢠⠋ ⠈⠉⠉ ⣠⣴⣥⠾⠛⠉⣰⣿⣷", + " ⠹⣯⣝⠛⠛⠷⢶⣤⣤⣀ ⢀⡠⠖⠋⠉⢉⣀⣀⣴⣾⣿⠿⠟⠃", + " ⠘⠻⢿⣦⣄⡀ ⠉⠛⢦⠠⢊⠤⠴⢒⣛⣛⣩⣽⡿⠟⠁", + " ⠶⢶⣤⣄⡀⠨⠭⠽⠟⣓⢦⣀⠈⢇⡥⠖⠛⠋⠉⠉", + " ⠈⢷ ⠐⠂⢤⣽⣄ ⠰⡎⠙⠳⣄⡀ ⠈⢣⠘⢦⠋", + " ⠈⢳⣀⡒⠉⠉⣉⠙⡲⣽⣄ ⣏⠳⡄ ⠘⡇ ⡾⠁", + " ⠛⠻⢦⣄⣉⡁⣀⣀⣈⣙⣺⣌⡇⢠⢀⡇⡾", + " ⠈⠉ ⠈⠳⡄⣸⢱⠇", + " ⡷⠡⡯⢖⠉", + " ⢀⡴⢪⠔⣉⠔⠋", + " ⠐⠈", +]); + +export type Rgb = readonly [number, number, number]; + +// The banner's default pink palette (startup-banner BANNER_PALETTES.pink) plus +// the Gentle theme's muted and error tones. +export const GENTLE_COLORS = Object.freeze({ + rose: [255, 118, 195] as Rgb, + label: [200, 100, 160] as Rgb, + value: [255, 140, 210] as Rgb, + dimRose: [95, 30, 60] as Rgb, + muted: [92, 97, 112] as Rgb, + error: [203, 124, 148] as Rgb, +}); + +export const BRAND_NAME = "Gentle Shell"; +export const BRAND_GLYPH = "✿"; + +export function truecolor([r, g, b]: Rgb, text: string): string { + return text.length === 0 ? "" : `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`; +} diff --git a/lib/gentle-shell-envs-tui.ts b/lib/gentle-shell-envs-tui.ts index ccc2eb84c..468f3f207 100644 --- a/lib/gentle-shell-envs-tui.ts +++ b/lib/gentle-shell-envs-tui.ts @@ -1,3 +1,4 @@ +import { BRAND_GLYPH, BRAND_NAME, GENTLE_COLORS, ROSE_ART, type Rgb, truecolor } from "./gentle-brand.ts"; import { type EnvSummary, validateEnvName } from "./gentle-shell-envs.ts"; // The `gentle-shell envs` manager: a pure key parser, reducer and renderer. @@ -145,16 +146,50 @@ export function reduceEnvs(state: EnvsState, key: EnvsKey): EnvsStep { } // --- rendering ----------------------------------------------------------------- +// The envs gate is the first thing a user sees, so it speaks Gentle's visual +// language: the rose, `✿ Gentle Shell`, rounded `╭─ ✿ Title ─╮` cards like +// the Status card, the `❯` selection and rose key hints. Every line is +// exactly the terminal width at most, and the screen is exactly its height. + +export interface EnvsRenderOptions { + // Truecolor Gentle palette; false renders the same layout in monochrome. + color?: boolean; + // gentle-pi version shown next to the brand. + version?: string; +} -const BOLD = "\x1b[1m"; -const DIM = "\x1b[2m"; -const INVERSE = "\x1b[7m"; -const RED = "\x1b[31m"; -const ACCENT = "\x1b[35m"; const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; + +interface Paint { + rose: (text: string) => string; + label: (text: string) => string; + value: (text: string) => string; + frame: (text: string) => string; + muted: (text: string) => string; + error: (text: string) => string; + bold: (text: string) => string; +} + +function painter(color: boolean): Paint { + const tone = (rgb: Rgb) => (text: string) => (color ? truecolor(rgb, text) : text); + return { + rose: tone(GENTLE_COLORS.rose), + label: tone(GENTLE_COLORS.label), + value: tone(GENTLE_COLORS.value), + frame: tone(GENTLE_COLORS.dimRose), + muted: tone(GENTLE_COLORS.muted), + error: tone(GENTLE_COLORS.error), + bold: (text) => (text.length === 0 ? "" : `${BOLD}${text}\x1b[22m`), + }; +} + +// Visible width: the renderer only emits single-cell characters (ASCII, +// box drawing, braille, ✿ ★ ❯ ·) outside of its own SGR sequences. +function visible(text: string): number { + return [...text.replace(/\x1b\[[0-9;]*m/g, "")].length; +} -// Truncate to `width` code points (the renderer only emits plain text inside -// a style, so visible width equals code points for the ASCII it controls). function fit(text: string, width: number): string { if (width <= 0) return ""; const chars = [...text]; @@ -167,66 +202,150 @@ function pad(text: string, width: number): string { return fitted + " ".repeat(Math.max(0, width - [...fitted].length)); } -function styled(style: string, text: string): string { - return text.length === 0 ? "" : `${style}${text}${RESET}`; +function center(styled: string, width: number): string { + const free = Math.max(0, width - visible(styled)); + return " ".repeat(Math.floor(free / 2)) + styled; } -function detailLines(env: EnvSummary | undefined): string[] { - if (env === undefined) return ["No envs yet.", "", "Press n to create your first env."]; - const sessions = `${env.sessionCount} session${env.sessionCount === 1 ? "" : "s"}`; +interface CardRow { + // Plain text, fitted to the card's content width before styling. + text: string; + style?: (text: string) => string; +} + +// A rounded Gentle card exactly `width` columns wide and `height` rows tall. +function card(title: string, rows: CardRow[], width: number, height: number, paint: Paint): string[] { + if (width < 6 || height < 2) return Array.from({ length: Math.max(0, height) }, () => " ".repeat(Math.max(0, width))); + const inner = width - 4; + const heading = fit(`${BRAND_GLYPH} ${title}`, Math.max(1, width - 6)); + const top = `${paint.frame("╭─ ")}${paint.rose(paint.bold(heading))}${paint.frame(` ${"─".repeat(Math.max(0, width - 5 - [...heading].length))}╮`)}`; + const bottom = paint.frame(`╰${"─".repeat(width - 2)}╯`); + const body: string[] = []; + for (let index = 0; index < height - 2; index += 1) { + const row = rows[index]; + const text = pad(row?.text ?? "", inner); + body.push(`${paint.frame("│")} ${row?.style ? row.style(text) : text} ${paint.frame("│")}`); + } + return [top, ...body, bottom]; +} + +function detailRows(env: EnvSummary | undefined, inner: number, paint: Paint): CardRow[] { + if (env === undefined) return [ + { text: "No envs yet.", style: paint.bold }, + { text: "" }, + { text: "Press n to create your first env.", style: paint.label }, + ]; + const sessions = `${env.sessionCount} session${env.sessionCount === 1 ? "" : "s"}${env.lastSessionAt ? ` · last ${env.lastSessionAt.slice(0, 16).replace("T", " ")}` : ""}`; + // Labels in the rose label tone, values plain: one styled span per row. + const field = (label: string, value: string): CardRow => { + const head = `${label.padEnd(10)}`; + const text = pad(`${head}${value}`, inner); + return { text, style: (fitted) => `${paint.label(fitted.slice(0, head.length))}${fitted.slice(head.length)}` }; + }; return [ - `${env.name}${env.isDefault ? " (default)" : ""}`, - "", - `Path ${env.dir}`, - `Setup ${env.provisioned}`, - `Sessions ${sessions}${env.lastSessionAt ? ` · last ${env.lastSessionAt.slice(0, 16).replace("T", " ")}` : ""}`, - `Providers ${env.providers.length > 0 ? env.providers.join(", ") : "none signed in"}`, - `Packages ${env.packages.length > 0 ? "" : "none"}`, - ...env.packages.map((source) => ` · ${source}`), + ...(env.isDefault ? [{ text: "★ Default: a plain gentle-shell opens it", style: paint.rose }] : []), + field("Path", env.dir), + field("Setup", env.provisioned), + field("Sessions", sessions), + field("Sign-ins", env.providers.length > 0 ? env.providers.join(", ") : "none yet: /login inside the session"), + field("Packages", env.packages.length > 0 ? `${env.packages.length}` : "none"), + ...env.packages.map((source) => ({ text: ` · ${source}`, style: paint.muted })), ]; } -const HINTS = "enter launch · n new · r rename · d delete · s default · p setup · i install · a auth · q quit"; +const HINTS: Array<[string, string]> = [ + ["enter", "open"], ["n", "new"], ["r", "rename"], ["d", "delete"], ["s", "default"], + ["p", "setup"], ["i", "install"], ["a", "sign-ins"], ["q", "quit"], +]; -export function renderEnvsScreen(state: EnvsState, width: number, height: number): string[] { - const w = Math.max(1, width); - const h = Math.max(1, height); +// Wrapped, never truncated: every action stays discoverable at any width. +function hintLines(width: number, paint: Paint): string[] { const lines: string[] = []; + let parts: string[] = []; + let used = 0; + for (const [key, action] of HINTS) { + const size = `${key} ${action}`.length; + const extra = (parts.length > 0 ? 3 : 0) + size; + if (parts.length > 0 && used + extra > width) { + lines.push(parts.join(paint.muted(" · "))); + parts = []; + used = 0; + } + parts.push(`${paint.rose(paint.bold(key))} ${paint.label(action)}`); + used += (parts.length > 1 ? 3 : 0) + size; + } + if (parts.length > 0) lines.push(parts.join(paint.muted(" · "))); + return lines; +} - const header = `gentle-shell envs · ${state.envs.length} env${state.envs.length === 1 ? "" : "s"}`; - lines.push(styled(BOLD + ACCENT, pad(header, w))); +export function renderEnvsScreen(state: EnvsState, width: number, height: number, options: EnvsRenderOptions = {}): string[] { + const w = Math.max(1, Math.floor(width)); + const h = Math.max(1, Math.floor(height)); + const paint = painter(options.color === true); - // Footer: status/prompt line plus the key hints. + // Footer: status or prompt, then the key hints. + // Centered under the cards; the prompt is the one left-aligned exception. const footer: string[] = []; if (state.prompt !== undefined) { const p = state.prompt; - footer.push(styled(BOLD, pad(`${p.label}: ${p.value}`, w))); - footer.push(p.error === undefined ? styled(DIM, pad("enter confirm · esc cancel", w)) : styled(RED, pad(p.error, w))); + footer.push(`${paint.rose(paint.bold("❯"))} ${paint.bold(fit(`${p.label}: ${p.value}`, Math.max(0, w - 3)))}${paint.rose("▏")}`); + footer.push(p.error === undefined ? center(paint.muted(fit("enter confirm · esc cancel", w)), w) : center(paint.error(fit(p.error, w)), w)); } else { - footer.push(state.message === undefined ? " ".repeat(w) : pad(state.message, w)); - footer.push(styled(DIM, pad(HINTS, w))); + footer.push(state.message === undefined ? "" : center(paint.value(fit(state.message, w)), w)); + footer.push(...hintLines(Math.min(w, 110), paint).map((line) => center(line, w))); } - const bodyHeight = Math.max(0, h - 1 - footer.length); - const listWidth = Math.min(28, Math.max(8, Math.floor(w / 3))); - const detailWidth = w - listWidth - 3; - const details = detailLines(state.envs[state.selected]); - // Keep the selection visible when the list is taller than the body. - const offset = Math.max(0, Math.min(state.selected - bodyHeight + 1, state.envs.length - bodyHeight)); - for (let row = 0; row < bodyHeight; row += 1) { - const index = row + Math.max(0, offset); - const env = state.envs[index]; - const label = env === undefined ? "" : `${env.isDefault ? "★" : " "} ${env.name}`; - const selected = env !== undefined && index === state.selected; - // Too narrow for the detail pane: the list alone takes the full width. - const listOnly = detailWidth < 4; - const cell = pad(label, listOnly ? w : listWidth); - const left = selected ? styled(INVERSE, cell) : cell; - lines.push(listOnly ? left : `${left} │ ${pad(details[row] ?? "", detailWidth)}`); + // Header: the rose when there is room for it and the cards, then the brand. + const title = `${paint.rose(paint.bold(`${BRAND_GLYPH} ${BRAND_NAME}`))}${options.version ? paint.muted(` v${fit(options.version, 20)}`) : ""}`; + const subtitle = paint.label("Choose an environment to open"); + const header: string[] = []; + const art = ROSE_ART.map((line) => line.replace(/\s+$/u, "")); + const artWidth = Math.max(...art.map((line) => [...line].length)); + if (h >= art.length + 16 && w >= artWidth + 4) { + const lead = Math.floor((w - artWidth) / 2); + header.push(...art.map((line) => " ".repeat(lead) + paint.rose(line)), ""); } + if (h >= 10) header.push(center(title, w), center(subtitle, w), ""); + else header.push(fit(`${BRAND_GLYPH} ${BRAND_NAME}`, w) === `${BRAND_GLYPH} ${BRAND_NAME}` ? title : paint.rose(fit(`${BRAND_GLYPH} ${BRAND_NAME}`, w))); - // Tiny terminals: the header and footer still must not exceed `h` lines. - const all = [...lines, ...footer]; - if (all.length > h) return [...all.slice(0, Math.max(0, h - footer.length)), ...footer].slice(-h); - return all; + // The cards fit their content (with a little air) instead of the whole + // screen, and the block is centered both ways: a gate, not a log. + const blockWidth = Math.min(w, 110); + const lead = " ".repeat(Math.floor((w - blockWidth) / 2)); + const selectedEnv = state.envs[state.selected]; + const sideBySide = blockWidth >= 56; + const listWidth = sideBySide ? Math.min(32, Math.max(20, Math.floor(blockWidth * 0.3))) : blockWidth; + const detailWidth = blockWidth - listWidth - 1; + const details = detailRows(selectedEnv, Math.max(1, detailWidth - 4), paint); + const contentRows = Math.max(state.envs.length || 1, sideBySide ? details.length : 0) + 2; + const available = Math.max(0, h - header.length - footer.length); + const bodyHeight = Math.min(available, Math.max(6, contentRows + 1)); + const body: string[] = []; + if (bodyHeight >= 3 && blockWidth >= 6) { + const visibleRows = bodyHeight - 2; + // Keep the selection visible when the list is taller than the card. + const offset = Math.max(0, Math.min(state.selected - visibleRows + 1, state.envs.length - visibleRows)); + const listRows: CardRow[] = state.envs.slice(offset, offset + visibleRows).map((env, index) => { + const selected = index + offset === state.selected; + const text = `${selected ? "❯" : " "} ${env.isDefault ? "★" : " "} ${env.name}`; + return { text, style: selected ? (fitted: string) => paint.rose(paint.bold(fitted)) : undefined }; + }); + if (state.envs.length === 0) listRows.push({ text: " (none)", style: paint.muted }); + const list = card("Envs", listRows, listWidth, bodyHeight, paint); + if (sideBySide) { + const detail = card(selectedEnv?.name ?? "Welcome", details, detailWidth, bodyHeight, paint); + for (let row = 0; row < bodyHeight; row += 1) body.push(`${lead}${list[row]} ${detail[row]}`); + } else { + body.push(...list.map((line) => lead + line)); + } + } + if (state.prompt !== undefined) footer[0] = lead + footer[0]; + // One composition, centered vertically: brand, cards, status and hints. + const block = [...header, ...body, ...footer]; + const spare = Math.max(0, h - block.length); + const above = Math.floor(spare / 2); + const composed = [...Array.from({ length: above }, () => ""), ...block, ...Array.from({ length: spare - above }, () => "")]; + const clip = (line: string) => (visible(line) > w ? fit(line.replace(/\x1b\[[0-9;]*m/g, ""), w) : line); + const all = composed.length > h ? [...composed.slice(0, Math.max(0, h - footer.length)), ...footer].slice(-h) : composed; + return all.map(clip).map((line) => (line.includes("\x1b[") ? `${line}${RESET}` : line)); } diff --git a/runtime/gentle-brand.mjs b/runtime/gentle-brand.mjs new file mode 100644 index 000000000..f7d4ae8b2 --- /dev/null +++ b/runtime/gentle-brand.mjs @@ -0,0 +1,45 @@ +// Generated by scripts/build-runtime-modules.mjs. Do not edit. +// Gentle's visual identity, shared by the Pi startup banner and the launcher's +// pre-Pi screens (the envs gate). Pure data and ANSI helpers only: the +// launcher runs before any Pi package is loaded. + +// The braille rose of the startup banner. +export const ROSE_ART = Object.freeze([ + " ⣠⣾⣷⣶⣦⣤⣤⣄⣠⣄⣀ ⢀⣀⣀", + " ⢀⣴⣿⣿⠿⣋⣭⣭⣯⣭⣍⣭⣿⣟⠛⠛⠿⣿⣷⣄", + " ⢀⣴⣾⡟⢻⣿⡟⠁⣼⣿⠏⣵⢻⣿⣻⣿⣿⢿⡻⣿⣿⣶⡌⢿⣿⣷⣦⣤⡄", + " ⣤⣶⣾⣿⣿⠏ ⠈⢿⣄ ⢹⣏⠠⠟⣾⣿⣿⣿⣿⣿⠷⣏⣼⠟⢡⣿⡟⠋⢻⣿⣿⡄", + " ⠈⣿⣿⣿⣿⡆ ⣽⢧⡘⠈⠳⣦⣍⠛⠛⢦⣉⣴⣛⣫⣭⣴⡟⠋ ⣾⣿⣿⡿", + " ⢀⠹⣿⣿⣿⣷⣤⡄ ⠋ ⠙⢆ ⣠⠴⠟⠛⣛⣛⣛⠟⠋⠁⠺⡇ ⣀⣴⣿⣿⡟⠁", + " ⠈⣀⠈⠛⠷⠿⣿⣿⣷⣤⣀ ⢠⠋ ⠈⠉⠉ ⣠⣴⣥⠾⠛⠉⣰⣿⣷", + " ⠹⣯⣝⠛⠛⠷⢶⣤⣤⣀ ⢀⡠⠖⠋⠉⢉⣀⣀⣴⣾⣿⠿⠟⠃", + " ⠘⠻⢿⣦⣄⡀ ⠉⠛⢦⠠⢊⠤⠴⢒⣛⣛⣩⣽⡿⠟⠁", + " ⠶⢶⣤⣄⡀⠨⠭⠽⠟⣓⢦⣀⠈⢇⡥⠖⠛⠋⠉⠉", + " ⠈⢷ ⠐⠂⢤⣽⣄ ⠰⡎⠙⠳⣄⡀ ⠈⢣⠘⢦⠋", + " ⠈⢳⣀⡒⠉⠉⣉⠙⡲⣽⣄ ⣏⠳⡄ ⠘⡇ ⡾⠁", + " ⠛⠻⢦⣄⣉⡁⣀⣀⣈⣙⣺⣌⡇⢠⢀⡇⡾", + " ⠈⠉ ⠈⠳⡄⣸⢱⠇", + " ⡷⠡⡯⢖⠉", + " ⢀⡴⢪⠔⣉⠔⠋", + " ⠐⠈", +]); + + + +// The banner's default pink palette (startup-banner BANNER_PALETTES.pink) plus +// the Gentle theme's muted and error tones. +export const GENTLE_COLORS = Object.freeze({ + rose: [255, 118, 195] , + label: [200, 100, 160] , + value: [255, 140, 210] , + dimRose: [95, 30, 60] , + muted: [92, 97, 112] , + error: [203, 124, 148] , +}); + +export const BRAND_NAME = "Gentle Shell"; +export const BRAND_GLYPH = "✿"; + +export function truecolor([r, g, b] , text ) { + return text.length === 0 ? "" : `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`; +} diff --git a/runtime/gentle-shell-envs-tui.mjs b/runtime/gentle-shell-envs-tui.mjs index 976d1b189..3b0657ef2 100644 --- a/runtime/gentle-shell-envs-tui.mjs +++ b/runtime/gentle-shell-envs-tui.mjs @@ -1,4 +1,5 @@ // Generated by scripts/build-runtime-modules.mjs. Do not edit. +import { BRAND_GLYPH, BRAND_NAME, GENTLE_COLORS, ROSE_ART, truecolor } from "./gentle-brand.mjs"; import { validateEnvName } from "./gentle-shell-envs.mjs"; // The `gentle-shell envs` manager: a pure key parser, reducer and renderer. @@ -146,16 +147,50 @@ export function reduceEnvs(state , key ) { } // --- rendering ----------------------------------------------------------------- +// The envs gate is the first thing a user sees, so it speaks Gentle's visual +// language: the rose, `✿ Gentle Shell`, rounded `╭─ ✿ Title ─╮` cards like +// the Status card, the `❯` selection and rose key hints. Every line is +// exactly the terminal width at most, and the screen is exactly its height. + + + + + + + -const BOLD = "\x1b[1m"; -const DIM = "\x1b[2m"; -const INVERSE = "\x1b[7m"; -const RED = "\x1b[31m"; -const ACCENT = "\x1b[35m"; const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; + + + + + + + + + + + +function painter(color ) { + const tone = (rgb ) => (text ) => (color ? truecolor(rgb, text) : text); + return { + rose: tone(GENTLE_COLORS.rose), + label: tone(GENTLE_COLORS.label), + value: tone(GENTLE_COLORS.value), + frame: tone(GENTLE_COLORS.dimRose), + muted: tone(GENTLE_COLORS.muted), + error: tone(GENTLE_COLORS.error), + bold: (text) => (text.length === 0 ? "" : `${BOLD}${text}\x1b[22m`), + }; +} + +// Visible width: the renderer only emits single-cell characters (ASCII, +// box drawing, braille, ✿ ★ ❯ ·) outside of its own SGR sequences. +function visible(text ) { + return [...text.replace(/\x1b\[[0-9;]*m/g, "")].length; +} -// Truncate to `width` code points (the renderer only emits plain text inside -// a style, so visible width equals code points for the ASCII it controls). function fit(text , width ) { if (width <= 0) return ""; const chars = [...text]; @@ -168,66 +203,150 @@ function pad(text , width ) { return fitted + " ".repeat(Math.max(0, width - [...fitted].length)); } -function styled(style , text ) { - return text.length === 0 ? "" : `${style}${text}${RESET}`; +function center(styled , width ) { + const free = Math.max(0, width - visible(styled)); + return " ".repeat(Math.floor(free / 2)) + styled; } -function detailLines(env ) { - if (env === undefined) return ["No envs yet.", "", "Press n to create your first env."]; - const sessions = `${env.sessionCount} session${env.sessionCount === 1 ? "" : "s"}`; + + + + + + +// A rounded Gentle card exactly `width` columns wide and `height` rows tall. +function card(title , rows , width , height , paint ) { + if (width < 6 || height < 2) return Array.from({ length: Math.max(0, height) }, () => " ".repeat(Math.max(0, width))); + const inner = width - 4; + const heading = fit(`${BRAND_GLYPH} ${title}`, Math.max(1, width - 6)); + const top = `${paint.frame("╭─ ")}${paint.rose(paint.bold(heading))}${paint.frame(` ${"─".repeat(Math.max(0, width - 5 - [...heading].length))}╮`)}`; + const bottom = paint.frame(`╰${"─".repeat(width - 2)}╯`); + const body = []; + for (let index = 0; index < height - 2; index += 1) { + const row = rows[index]; + const text = pad(row?.text ?? "", inner); + body.push(`${paint.frame("│")} ${row?.style ? row.style(text) : text} ${paint.frame("│")}`); + } + return [top, ...body, bottom]; +} + +function detailRows(env , inner , paint ) { + if (env === undefined) return [ + { text: "No envs yet.", style: paint.bold }, + { text: "" }, + { text: "Press n to create your first env.", style: paint.label }, + ]; + const sessions = `${env.sessionCount} session${env.sessionCount === 1 ? "" : "s"}${env.lastSessionAt ? ` · last ${env.lastSessionAt.slice(0, 16).replace("T", " ")}` : ""}`; + // Labels in the rose label tone, values plain: one styled span per row. + const field = (label , value ) => { + const head = `${label.padEnd(10)}`; + const text = pad(`${head}${value}`, inner); + return { text, style: (fitted) => `${paint.label(fitted.slice(0, head.length))}${fitted.slice(head.length)}` }; + }; return [ - `${env.name}${env.isDefault ? " (default)" : ""}`, - "", - `Path ${env.dir}`, - `Setup ${env.provisioned}`, - `Sessions ${sessions}${env.lastSessionAt ? ` · last ${env.lastSessionAt.slice(0, 16).replace("T", " ")}` : ""}`, - `Providers ${env.providers.length > 0 ? env.providers.join(", ") : "none signed in"}`, - `Packages ${env.packages.length > 0 ? "" : "none"}`, - ...env.packages.map((source) => ` · ${source}`), + ...(env.isDefault ? [{ text: "★ Default: a plain gentle-shell opens it", style: paint.rose }] : []), + field("Path", env.dir), + field("Setup", env.provisioned), + field("Sessions", sessions), + field("Sign-ins", env.providers.length > 0 ? env.providers.join(", ") : "none yet: /login inside the session"), + field("Packages", env.packages.length > 0 ? `${env.packages.length}` : "none"), + ...env.packages.map((source) => ({ text: ` · ${source}`, style: paint.muted })), ]; } -const HINTS = "enter launch · n new · r rename · d delete · s default · p setup · i install · a auth · q quit"; +const HINTS = [ + ["enter", "open"], ["n", "new"], ["r", "rename"], ["d", "delete"], ["s", "default"], + ["p", "setup"], ["i", "install"], ["a", "sign-ins"], ["q", "quit"], +]; -export function renderEnvsScreen(state , width , height ) { - const w = Math.max(1, width); - const h = Math.max(1, height); +// Wrapped, never truncated: every action stays discoverable at any width. +function hintLines(width , paint ) { const lines = []; + let parts = []; + let used = 0; + for (const [key, action] of HINTS) { + const size = `${key} ${action}`.length; + const extra = (parts.length > 0 ? 3 : 0) + size; + if (parts.length > 0 && used + extra > width) { + lines.push(parts.join(paint.muted(" · "))); + parts = []; + used = 0; + } + parts.push(`${paint.rose(paint.bold(key))} ${paint.label(action)}`); + used += (parts.length > 1 ? 3 : 0) + size; + } + if (parts.length > 0) lines.push(parts.join(paint.muted(" · "))); + return lines; +} - const header = `gentle-shell envs · ${state.envs.length} env${state.envs.length === 1 ? "" : "s"}`; - lines.push(styled(BOLD + ACCENT, pad(header, w))); +export function renderEnvsScreen(state , width , height , options = {}) { + const w = Math.max(1, Math.floor(width)); + const h = Math.max(1, Math.floor(height)); + const paint = painter(options.color === true); - // Footer: status/prompt line plus the key hints. + // Footer: status or prompt, then the key hints. + // Centered under the cards; the prompt is the one left-aligned exception. const footer = []; if (state.prompt !== undefined) { const p = state.prompt; - footer.push(styled(BOLD, pad(`${p.label}: ${p.value}`, w))); - footer.push(p.error === undefined ? styled(DIM, pad("enter confirm · esc cancel", w)) : styled(RED, pad(p.error, w))); + footer.push(`${paint.rose(paint.bold("❯"))} ${paint.bold(fit(`${p.label}: ${p.value}`, Math.max(0, w - 3)))}${paint.rose("▏")}`); + footer.push(p.error === undefined ? center(paint.muted(fit("enter confirm · esc cancel", w)), w) : center(paint.error(fit(p.error, w)), w)); } else { - footer.push(state.message === undefined ? " ".repeat(w) : pad(state.message, w)); - footer.push(styled(DIM, pad(HINTS, w))); + footer.push(state.message === undefined ? "" : center(paint.value(fit(state.message, w)), w)); + footer.push(...hintLines(Math.min(w, 110), paint).map((line) => center(line, w))); } - const bodyHeight = Math.max(0, h - 1 - footer.length); - const listWidth = Math.min(28, Math.max(8, Math.floor(w / 3))); - const detailWidth = w - listWidth - 3; - const details = detailLines(state.envs[state.selected]); - // Keep the selection visible when the list is taller than the body. - const offset = Math.max(0, Math.min(state.selected - bodyHeight + 1, state.envs.length - bodyHeight)); - for (let row = 0; row < bodyHeight; row += 1) { - const index = row + Math.max(0, offset); - const env = state.envs[index]; - const label = env === undefined ? "" : `${env.isDefault ? "★" : " "} ${env.name}`; - const selected = env !== undefined && index === state.selected; - // Too narrow for the detail pane: the list alone takes the full width. - const listOnly = detailWidth < 4; - const cell = pad(label, listOnly ? w : listWidth); - const left = selected ? styled(INVERSE, cell) : cell; - lines.push(listOnly ? left : `${left} │ ${pad(details[row] ?? "", detailWidth)}`); + // Header: the rose when there is room for it and the cards, then the brand. + const title = `${paint.rose(paint.bold(`${BRAND_GLYPH} ${BRAND_NAME}`))}${options.version ? paint.muted(` v${fit(options.version, 20)}`) : ""}`; + const subtitle = paint.label("Choose an environment to open"); + const header = []; + const art = ROSE_ART.map((line) => line.replace(/\s+$/u, "")); + const artWidth = Math.max(...art.map((line) => [...line].length)); + if (h >= art.length + 16 && w >= artWidth + 4) { + const lead = Math.floor((w - artWidth) / 2); + header.push(...art.map((line) => " ".repeat(lead) + paint.rose(line)), ""); } - - // Tiny terminals: the header and footer still must not exceed `h` lines. - const all = [...lines, ...footer]; - if (all.length > h) return [...all.slice(0, Math.max(0, h - footer.length)), ...footer].slice(-h); - return all; + if (h >= 10) header.push(center(title, w), center(subtitle, w), ""); + else header.push(fit(`${BRAND_GLYPH} ${BRAND_NAME}`, w) === `${BRAND_GLYPH} ${BRAND_NAME}` ? title : paint.rose(fit(`${BRAND_GLYPH} ${BRAND_NAME}`, w))); + + // The cards fit their content (with a little air) instead of the whole + // screen, and the block is centered both ways: a gate, not a log. + const blockWidth = Math.min(w, 110); + const lead = " ".repeat(Math.floor((w - blockWidth) / 2)); + const selectedEnv = state.envs[state.selected]; + const sideBySide = blockWidth >= 56; + const listWidth = sideBySide ? Math.min(32, Math.max(20, Math.floor(blockWidth * 0.3))) : blockWidth; + const detailWidth = blockWidth - listWidth - 1; + const details = detailRows(selectedEnv, Math.max(1, detailWidth - 4), paint); + const contentRows = Math.max(state.envs.length || 1, sideBySide ? details.length : 0) + 2; + const available = Math.max(0, h - header.length - footer.length); + const bodyHeight = Math.min(available, Math.max(6, contentRows + 1)); + const body = []; + if (bodyHeight >= 3 && blockWidth >= 6) { + const visibleRows = bodyHeight - 2; + // Keep the selection visible when the list is taller than the card. + const offset = Math.max(0, Math.min(state.selected - visibleRows + 1, state.envs.length - visibleRows)); + const listRows = state.envs.slice(offset, offset + visibleRows).map((env, index) => { + const selected = index + offset === state.selected; + const text = `${selected ? "❯" : " "} ${env.isDefault ? "★" : " "} ${env.name}`; + return { text, style: selected ? (fitted ) => paint.rose(paint.bold(fitted)) : undefined }; + }); + if (state.envs.length === 0) listRows.push({ text: " (none)", style: paint.muted }); + const list = card("Envs", listRows, listWidth, bodyHeight, paint); + if (sideBySide) { + const detail = card(selectedEnv?.name ?? "Welcome", details, detailWidth, bodyHeight, paint); + for (let row = 0; row < bodyHeight; row += 1) body.push(`${lead}${list[row]} ${detail[row]}`); + } else { + body.push(...list.map((line) => lead + line)); + } + } + if (state.prompt !== undefined) footer[0] = lead + footer[0]; + // One composition, centered vertically: brand, cards, status and hints. + const block = [...header, ...body, ...footer]; + const spare = Math.max(0, h - block.length); + const above = Math.floor(spare / 2); + const composed = [...Array.from({ length: above }, () => ""), ...block, ...Array.from({ length: spare - above }, () => "")]; + const clip = (line ) => (visible(line) > w ? fit(line.replace(/\x1b\[[0-9;]*m/g, ""), w) : line); + const all = composed.length > h ? [...composed.slice(0, Math.max(0, h - footer.length)), ...footer].slice(-h) : composed; + return all.map(clip).map((line) => (line.includes("\x1b[") ? `${line}${RESET}` : line)); } diff --git a/scripts/build-runtime-modules.mjs b/scripts/build-runtime-modules.mjs index e97404322..f32ed78b4 100644 --- a/scripts/build-runtime-modules.mjs +++ b/scripts/build-runtime-modules.mjs @@ -16,6 +16,7 @@ const sources = [ "telemetry-trigger", "child-package-injection", "gentle-shell-envs", + "gentle-brand", "gentle-shell-envs-tui", "gentle-shell-launcher", "gentle-shell-bundled", diff --git a/scripts/verify-package-files.mjs b/scripts/verify-package-files.mjs index dfbbf9636..862a150f9 100644 --- a/scripts/verify-package-files.mjs +++ b/scripts/verify-package-files.mjs @@ -85,6 +85,7 @@ export const requiredPaths = [ "lib/child-package-injection.ts", "lib/gentle-ai-binary.ts", "lib/gentle-shell-envs.ts", + "lib/gentle-brand.ts", "lib/gentle-shell-envs-tui.ts", "lib/gentle-shell-launcher.ts", "lib/gentle-shell-bundled.ts", @@ -104,6 +105,7 @@ export const requiredPaths = [ "runtime/child-package-injection.mjs", "runtime/gentle-ai-binary.mjs", "runtime/gentle-shell-envs.mjs", + "runtime/gentle-brand.mjs", "runtime/gentle-shell-envs-tui.mjs", "runtime/gentle-shell-launcher.mjs", "runtime/gentle-shell-bundled.mjs", diff --git a/tests/gentle-shell-envs-tui.test.ts b/tests/gentle-shell-envs-tui.test.ts index 067cf0f68..458fe098a 100644 --- a/tests/gentle-shell-envs-tui.test.ts +++ b/tests/gentle-shell-envs-tui.test.ts @@ -148,3 +148,78 @@ test("render degrades without throwing on tiny terminals", () => { function stripAnsi(text: string): string { return text.replace(/\x1b\[[0-9;]*m/g, ""); } + +// --- Gentle visual identity ------------------------------------------------- + +const ROSE = "\x1b[38;2;255;118;195m"; +const sample = () => initialEnvsState([ + env("work", { isDefault: true, packages: ["npm:gentle-engram"], providers: ["anthropic"], sessionCount: 2 }), + env("personal"), +]); + +test("the gate is branded: Gentle Shell title with version and a call to choose an env", () => { + const text = renderEnvsScreen(sample(), 100, 24, { version: "4.2.0" }).map(stripAnsi).join("\n"); + assert.match(text, /✿ Gentle Shell/); + assert.match(text, /v4\.2\.0/); + assert.match(text, /Choose an environment/); +}); + +test("envs and details render as rounded Gentle cards with the rose title glyph", () => { + const lines = renderEnvsScreen(sample(), 100, 24).map(stripAnsi); + assert.ok(lines.some((line) => /╭─ ✿ Envs ─+╮/.test(line)), lines.join("\n")); + assert.ok(lines.some((line) => /╭─ ✿ work ─+╮/.test(line)), "detail card titled with the selected env"); + assert.ok(lines.some((line) => /^╰─+╯/.test(line))); +}); + +test("the selected env is marked with the Gentle chevron and the default with a star", () => { + const text = renderEnvsScreen(sample(), 100, 24).map(stripAnsi).join("\n"); + assert.match(text, /│ ❯ ★ work/); + assert.match(text, /│ {5}personal/); +}); + +test("color uses the Gentle rose and never reverse video; monochrome emits no truecolor", () => { + const colored = renderEnvsScreen(sample(), 100, 24, { color: true }).join("\n"); + assert.ok(colored.includes(ROSE), "rose accent"); + assert.ok(!colored.includes("\x1b[7m"), "no reverse video"); + const plain = renderEnvsScreen(sample(), 100, 24, { color: false }).join("\n"); + assert.ok(!plain.includes("\x1b[38;2;"), "no truecolor without color"); +}); + +test("tall terminals show the Gentle rose artwork; short ones keep only the title", () => { + const tall = renderEnvsScreen(sample(), 100, 40).map(stripAnsi).join("\n"); + assert.match(tall, /[⣀-⣿]{6}/, "braille rose shown"); + const short = renderEnvsScreen(sample(), 100, 20).map(stripAnsi).join("\n"); + assert.doesNotMatch(short, /[⣀-⣿]{6}/); + assert.match(short, /✿ Gentle Shell/); +}); + +test("the gate is a centered composition: cards fit their content on wide, tall terminals", () => { + const lines = renderEnvsScreen(sample(), 200, 60).map(stripAnsi); + const top = lines.findIndex((line) => line.includes("╭─ ✿ Envs")); + const bottom = lines.findIndex((line, index) => index > top && line.trimStart().startsWith("╰")); + assert.ok(bottom - top < 20, `cards fit content, got ${bottom - top} rows`); + assert.ok(lines[top].indexOf("╭") >= 40, "block centered horizontally"); + assert.ok(lines[top].trimEnd().length - lines[top].indexOf("╭") <= 112, "block width capped"); + assert.ok(lines.findIndex((line) => line.includes("✿ Gentle Shell")) > 5, "vertically centered, not glued to the top"); +}); + +test("key hints wrap instead of losing actions, and sit right under the cards", () => { + for (const width of [80, 60]) { + const lines = renderEnvsScreen(sample(), width, 24).map(stripAnsi); + const text = lines.join("\n"); + for (const hint of ["enter open", "n new", "d delete", "q quit"]) assert.ok(text.includes(hint), `${width}: ${hint}`); + const bottom = lines.findLastIndex((line) => line.trimStart().startsWith("╰")); + const hints = lines.findIndex((line) => line.includes("enter open")); + assert.ok(hints > bottom && hints - bottom <= 3, `${width}: hints right under the cards`); + } +}); + +test("every layout fills the terminal exactly within its width", () => { + for (const [w, h] of [[160, 50], [100, 40], [80, 24], [60, 16], [40, 12], [20, 6], [10, 3]] as const) { + for (const color of [true, false]) { + const lines = renderEnvsScreen(sample(), w, h, { color, version: "4.2.0" }); + assert.equal(lines.length, h, `${w}x${h}`); + for (const line of lines) assert.ok([...stripAnsi(line)].length <= w, `${w}x${h}: ${stripAnsi(line)}`); + } + } +}); From a92876b552b8264248601ef9ff45c557ac6d75e8 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 13:04:45 -0400 Subject: [PATCH 09/25] feat(launcher): list the install's base home in the envs gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate now lists the install's own home first, as `◆ Gentle Shell base`. It is the `--isolated` home (GENTLE_SHELL_HOME or ~/.gentle-shell/agent), the one Gentle Shell always used, so you can go back to it from the doorway. Nothing about it changes: opening it is `--isolated`, making it the default persists `"home": "isolated"` like `gentle-shell home isolated`, and its sessions carry no env label. It cannot be renamed or deleted, and an env may still be named `base`; base and env entries are never matched by name. The star marks exactly what a plain launch opens, following the launcher's own precedence (missing, malformed or "isolated" setting: the base home; a path: that env; "link": nothing listed). `envs list` prints the base home first as `[base]`. --- bin/gentle-shell.mjs | 82 ++++++++++++++++++----------- docs/readme-reference.md | 4 +- lib/gentle-shell-envs-tui.ts | 44 ++++++++++------ lib/gentle-shell-envs.ts | 4 ++ runtime/gentle-shell-envs-tui.mjs | 34 ++++++++---- runtime/gentle-shell-envs.mjs | 4 ++ tests/gentle-shell-bin.test.ts | 21 ++++++++ tests/gentle-shell-envs-tui.test.ts | 44 ++++++++++++++++ 8 files changed, 179 insertions(+), 58 deletions(-) diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index b1e39abf6..2ecdbc2b8 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -40,6 +40,7 @@ import { forceJsonFieldIfAbsentInOriginal, helpText, homeSelectorFlags, + isolatedDir, isSetupCapablePin, launcherConfigPath, MIN_SETUP_GENTLE_AI_VERSION, @@ -758,25 +759,39 @@ function readTextOrUndefined(path) { } } +// The base home first (the install's own `--isolated` home, exactly as the +// launcher resolves it), then the named envs. The default follows the same +// precedence a plain launch uses: a missing, malformed or "isolated" home +// setting opens the base home, "link" opens none of these, a path opens the +// env it names. function loadEnvSummaries(root) { const rawConfig = readRawConfig(resolveConfigPath()); - const defaultHome = typeof rawConfig.home === "string" && rawConfig.home !== "link" && rawConfig.home !== "isolated" ? rawConfig.home : undefined; - const defaultReal = defaultHome === undefined ? undefined : safeRealpath(resolvePath(defaultHome)); - return listEnvNames(root).map((name) => { - const dir = envDir(root, name); + const persisted = loadConfig(); + const defaultReal = persisted?.mode === "path" ? safeRealpath(resolvePath(persisted.dir)) : undefined; + const summarize = (name, dir, extra) => { const real = safeRealpath(dir); const sessions = sessionStats(dir); return summarizeEnv({ name, dir, - isDefault: defaultReal !== undefined && defaultReal === real, settingsText: readTextOrUndefined(join(dir, "settings.json")), authText: readTextOrUndefined(join(dir, "auth.json")), provisioned: provisionedEntry(rawConfig, real), sessionCount: sessions.count, lastSessionAt: sessions.last, + isDefault: defaultReal !== undefined && defaultReal === real, + ...extra, }); - }); + }; + const baseDir = resolvePath(isolatedDir(process.env, homedir())); + const base = summarize("base", baseDir, { base: true, ...(persisted === undefined || persisted.mode === "isolated" ? { isDefault: true } : {}) }); + return [base, ...listEnvNames(root).map((name) => summarize(name, envDir(root, name), {}))]; +} + +// The launcher selector that reproduces an entry: `--isolated` for the base +// home (never an env name, which may even be "base"), `--env ` otherwise. +function entrySelector(target) { + return target.base ? ["--isolated"] : ["--env", target.name]; } // Same bootstrap a first `--home` launch performs, so the env is owned by @@ -828,26 +843,24 @@ function setDefaultEnv(root, name) { // Runs this same launcher against one env (setup/install/auth) in the // foreground, outside the manager's alternate screen. -function runLauncherForEnv(name, extraArgs) { - const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), "--env", name, ...extraArgs], { stdio: "inherit", env: process.env }); +function runLauncherForEnv(target, extraArgs) { + const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), ...entrySelector(target), ...extraArgs], { stdio: "inherit", env: process.env }); return result.error ? 1 : (result.status ?? 1); } function printEnvList(root) { - const envs = loadEnvSummaries(root); - if (envs.length === 0) { - process.stdout.write(`No envs in ${root}. Create one with 'gentle-shell envs new '.\n`); - return; - } - const width = Math.max(...envs.map((env) => env.name.length)); - for (const env of envs) { - process.stdout.write(`${env.isDefault ? "*" : " "} ${env.name.padEnd(width)} ${env.dir} (${env.provisioned})\n`); + const entries = loadEnvSummaries(root); + const label = (env) => (env.base ? "[base]" : env.name); + const width = Math.max(...entries.map((env) => label(env).length)); + for (const env of entries) { + process.stdout.write(`${env.isDefault ? "*" : " "} ${label(env).padEnd(width)} ${env.dir} (${env.provisioned})\n`); } + if (entries.length === 1) process.stdout.write(`No named envs in ${root} yet. Create one with 'gentle-shell envs new '.\n`); } const ENVS_USAGE = "Usage: gentle-shell envs [list | new ]\n envs open the env manager (TTY), or list envs\n envs list print every env; * marks the default\n envs new create an env without launching it"; -// Returns the env name to launch, or undefined when nothing should launch. +// Returns the launcher selector to launch with, or undefined when nothing should launch. async function handleEnvsCommand(commandArgs) { const root = currentEnvsRoot(); const [sub, ...rest] = commandArgs; @@ -972,6 +985,12 @@ async function runEnvsManager(root) { deleteEnv(root, effect.name); return reload(`Deleted ${effect.name}.`); } + if (effect.base) { + // Exactly what `gentle-shell home isolated` persists. + const configPath = resolveConfigPath(); + writeRawConfig(configPath, { ...readRawConfig(configPath), home: "isolated" }); + return reload("The base home is now the default: a plain 'gentle-shell' opens it.", { base: true }); + } setDefaultEnv(root, effect.name); return reload(`${effect.name} is now the default: a plain 'gentle-shell' opens it.`, effect.name); } catch (error) { @@ -983,30 +1002,32 @@ async function runEnvsManager(root) { for (;;) { const { state: last, effect } = await envsScreenSession(state, apply); if (effect.kind === "quit") return undefined; - if (effect.kind === "launch") return effect.name; + if (effect.kind === "launch") return entrySelector(effect); + const what = effect.base ? "the base home" : `env ${effect.name}`; let code; if (effect.kind === "setup") { - process.stdout.write(`gentle-shell: setting up env ${effect.name}\n`); - code = runLauncherForEnv(effect.name, ["setup"]); + process.stdout.write(`gentle-shell: setting up ${what}; downloads can take a few minutes (Ctrl-C cancels)\n`); + code = runLauncherForEnv(effect, ["setup"]); } else if (effect.kind === "install") { - code = runLauncherForEnv(effect.name, ["install", effect.source]); + process.stdout.write(`gentle-shell: installing ${effect.source} into ${what} (Ctrl-C cancels)\n`); + code = runLauncherForEnv(effect, ["install", effect.source]); } else { // Pi signs in from inside a session (/login); outside one it can // only check readiness, which needs a provider per call. - const providers = last.envs.find((env) => env.name === effect.name)?.providers ?? []; + const providers = last.envs.find((env) => (effect.base ? env.base === true : !env.base && env.name === effect.name))?.providers ?? []; if (providers.length === 0) { - process.stdout.write(`No providers signed in to ${effect.name}. Launch it and run /login inside the session.\n`); + process.stdout.write(`No providers signed in to ${what}. Launch it and run /login inside the session.\n`); code = 0; } else { code = 0; for (const provider of providers) { - const providerCode = runLauncherForEnv(effect.name, ["auth", "check", "--provider", provider]); + const providerCode = runLauncherForEnv(effect, ["auth", "check", "--provider", provider]); if (providerCode !== 0) code = providerCode; } } } await waitForAnyKey(`${code === 0 ? "Done" : `Finished with exit code ${code}`}. Press any key to return to the env manager.`); - state = reload(code === 0 ? undefined : `Last action exited with code ${code}.`, effect.name); + state = reload(code === 0 ? undefined : `Last action exited with code ${code}.`, effect.base ? { base: true } : effect.name); } } @@ -2216,11 +2237,12 @@ async function main() { return; } if (args.command === "envs") { - const launch = await handleEnvsCommand(args.commandArgs); - if (launch === undefined) return; - // Re-enter the ordinary launch exactly as `gentle-shell --env `, - // so startup checks, provisioning and resume hints all see that argv. - process.argv = [...process.argv.slice(0, 2), "--env", launch]; + const selector = await handleEnvsCommand(args.commandArgs); + if (selector === undefined) return; + // Re-enter the ordinary launch exactly as `gentle-shell --env ` + // (or `--isolated` for the base home), so startup checks, provisioning + // and resume hints all see that argv. + process.argv = [...process.argv.slice(0, 2), ...selector]; return main(); } // The verification input applies to `--version` only (scripts/bundled-install.mjs upgradeBundled). diff --git a/docs/readme-reference.md b/docs/readme-reference.md index c66363a6c..c18d15746 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -377,10 +377,12 @@ A named env is a separate agent home managed by gentle-shell under `~/.gentle-sh | Command | Effect | | --- | --- | | `gentle-shell envs` | On a terminal, opens a full-screen env manager. Without a TTY, it behaves like `envs list`. | -| `gentle-shell envs list` | Prints every env and its setup state; `*` marks the default. | +| `gentle-shell envs list` | Prints the base home as `[base]`, then every env, each with its setup state; `*` marks what a plain `gentle-shell` opens. | | `gentle-shell envs new ` | Creates the env without launching it. Its first launch installs the companion packages, the same as any new `--home`. | | `gentle-shell --env [...]` | Launches, sets up (`--env setup`), or runs pi subcommands (`--env install npm:x`) in that env. | +The manager lists the install's own home first, as `◆ Gentle Shell base`. It is the `--isolated` home (`GENTLE_SHELL_HOME` or `~/.gentle-shell/agent`), the one Gentle Shell always used before envs, and nothing about it changes. Opening it is `--isolated`, and making it the default persists `"home": "isolated"`, exactly like `gentle-shell home isolated`. Its sessions carry no env label. It cannot be renamed or deleted from the manager, and an env may still be named `base`. The `★` marks what a plain launch opens: the base home when no `home` is persisted (or it is `isolated` or malformed), the env a persisted path names, and nothing in the list for `link`. + Manager keys: `↑`/`↓` or `j`/`k` select. `enter` launches the selected env. `n` creates an env, `r` renames it, and `d` deletes it after you type its name. `s` makes it the default. `p` runs `setup`, `i` installs a package source, and `a` runs `pi auth check` for each signed-in provider. `q` or `esc` quits. The detail pane shows the path, the setup record, the session count and last session, the signed-in provider ids (never credential values), and the declared packages. Inside a session, the header shows `env ` right after the brand, and it stays there as the header narrows. The Status card shows an `Env` group with the name, above `Project`. The launcher passes the name to Pi as `GENTLE_SHELL_ENV`, only for a named env, and clears any inherited value for every other home. The default env is stored as the ordinary persisted `home` path, so a plain `gentle-shell` opens it, and `gentle-shell home link|isolated|` replaces it. Renaming or deleting an env keeps `config.json` consistent: its setup record and default follow it or are removed. Envs are separate homes, not sandboxes. They share the repository you work in, `~/.pi/gentle-ai/persona.json`, and `~/.gentle-ai/state.json`, like every other home. Named envs work the same in a bundled (standalone) install. There, the envs root shares `~/.gentle-shell` with the prefix and launches use the bundled Pi. Both `self-uninstall` variants list the envs root once, as "your named envs", under *Not touched*, and never remove it. They also refuse a plan that would remove anything inside it, for example a `GENTLE_SHELL_HOME` that points into an env. diff --git a/lib/gentle-shell-envs-tui.ts b/lib/gentle-shell-envs-tui.ts index 468f3f207..681c0840c 100644 --- a/lib/gentle-shell-envs-tui.ts +++ b/lib/gentle-shell-envs-tui.ts @@ -15,16 +15,18 @@ export type EnvsKey = | { name: "ctrl-c" } | { name: "char"; char: string }; +// `base: true` targets the install's own home (`--isolated`), never an env: +// an env may even be named like the base entry. export type EnvsEffect = | { kind: "quit" } - | { kind: "launch"; name: string } - | { kind: "set-default"; name: string } - | { kind: "setup"; name: string } - | { kind: "auth-check"; name: string } + | { kind: "launch"; name: string; base?: true } + | { kind: "set-default"; name: string; base?: true } + | { kind: "setup"; name: string; base?: true } + | { kind: "auth-check"; name: string; base?: true } | { kind: "create"; name: string } | { kind: "rename"; from: string; to: string } | { kind: "delete"; name: string } - | { kind: "install"; name: string; source: string }; + | { kind: "install"; name: string; source: string; base?: true }; type PromptKind = "create" | "rename" | "delete" | "install"; @@ -34,6 +36,7 @@ export interface EnvsPrompt { value: string; // The env the prompt acts on (rename/delete/install). target?: string; + targetBase?: boolean; error?: string; } @@ -45,8 +48,12 @@ export interface EnvsState { message?: string; } -export function initialEnvsState(envs: EnvSummary[], message?: string, selectName?: string): EnvsState { - const byName = selectName === undefined ? -1 : envs.findIndex((e) => e.name === selectName); +// `select` names an env, or `{ base: true }` for the base home: an env may +// share the base entry's display name, so the two are never matched by name. +export function initialEnvsState(envs: EnvSummary[], message?: string, select?: string | { base: true }): EnvsState { + const byName = select === undefined ? -1 + : typeof select === "string" ? envs.findIndex((e) => !e.base && e.name === select) + : envs.findIndex((e) => e.base === true); const byDefault = envs.findIndex((e) => e.isDefault); return { envs, selected: byName >= 0 ? byName : Math.max(0, byDefault), message }; } @@ -109,7 +116,7 @@ function reducePrompt(state: EnvsState, prompt: EnvsPrompt, key: EnvsKey): EnvsS if (prompt.kind === "rename" && value === target) return { state: { ...state, prompt: undefined } }; const invalid = validateEnvName(value); if (invalid !== undefined) return fail(invalid); - if (state.envs.some((e) => e.name === value)) return fail(`env "${value}" already exists`); + if (state.envs.some((e) => !e.base && e.name === value)) return fail(`env "${value}" already exists`); return done(prompt.kind === "create" ? { kind: "create", name: value } : { kind: "rename", from: target, to: value }); } if (prompt.kind === "delete") { @@ -117,7 +124,7 @@ function reducePrompt(state: EnvsState, prompt: EnvsPrompt, key: EnvsKey): EnvsS return done({ kind: "delete", name: target }); } if (value.length === 0) return fail("enter a package source, e.g. npm:pi-btw"); - return done({ kind: "install", name: target, source: value }); + return done(prompt.targetBase ? { kind: "install", name: target, source: value, base: true } : { kind: "install", name: target, source: value }); } export function reduceEnvs(state: EnvsState, key: EnvsKey): EnvsStep { @@ -136,12 +143,16 @@ export function reduceEnvs(state: EnvsState, key: EnvsKey): EnvsStep { if (!scoped) return { state }; if (current === undefined) return { state: { ...state, message: EMPTY_HINT } }; const name = current.name; - if (key.name === "enter") return { state, effect: { kind: "launch", name } }; - if (char === "s") return { state, effect: { kind: "set-default", name } }; - if (char === "p") return { state, effect: { kind: "setup", name } }; - if (char === "a") return { state, effect: { kind: "auth-check", name } }; + const base = current.base ? { base: true as const } : {}; + if (key.name === "enter") return { state, effect: { kind: "launch", name, ...base } }; + if (char === "s") return { state, effect: { kind: "set-default", name, ...base } }; + if (char === "p") return { state, effect: { kind: "setup", name, ...base } }; + if (char === "a") return { state, effect: { kind: "auth-check", name, ...base } }; + if (current.base && (char === "r" || char === "d")) { + return { state: { ...state, message: "The base home belongs to the install: it can't be renamed or deleted." } }; + } if (char === "r") return openPrompt(state, { kind: "rename", label: `Rename "${name}" to`, value: name, target: name }); - if (char === "i") return openPrompt(state, { kind: "install", label: `Install into "${name}"`, value: "", target: name }); + if (char === "i") return openPrompt(state, { kind: "install", label: `Install into "${current.base ? "the base home" : name}"`, value: "", target: name, ...(current.base ? { targetBase: true } : {}) }); return openPrompt(state, { kind: "delete", label: `Delete "${name}" and all its data? Type its name`, value: "", target: name }); } @@ -243,6 +254,7 @@ function detailRows(env: EnvSummary | undefined, inner: number, paint: Paint): C return { text, style: (fitted) => `${paint.label(fitted.slice(0, head.length))}${fitted.slice(head.length)}` }; }; return [ + ...(env.base ? [{ text: "The install's own home, as before envs (--isolated).", style: paint.label }] : []), ...(env.isDefault ? [{ text: "★ Default: a plain gentle-shell opens it", style: paint.rose }] : []), field("Path", env.dir), field("Setup", env.provisioned), @@ -327,13 +339,13 @@ export function renderEnvsScreen(state: EnvsState, width: number, height: number const offset = Math.max(0, Math.min(state.selected - visibleRows + 1, state.envs.length - visibleRows)); const listRows: CardRow[] = state.envs.slice(offset, offset + visibleRows).map((env, index) => { const selected = index + offset === state.selected; - const text = `${selected ? "❯" : " "} ${env.isDefault ? "★" : " "} ${env.name}`; + const text = `${selected ? "❯" : " "} ${env.isDefault ? "★" : " "} ${env.base ? `◆ ${BRAND_NAME} base` : env.name}`; return { text, style: selected ? (fitted: string) => paint.rose(paint.bold(fitted)) : undefined }; }); if (state.envs.length === 0) listRows.push({ text: " (none)", style: paint.muted }); const list = card("Envs", listRows, listWidth, bodyHeight, paint); if (sideBySide) { - const detail = card(selectedEnv?.name ?? "Welcome", details, detailWidth, bodyHeight, paint); + const detail = card(selectedEnv === undefined ? "Welcome" : selectedEnv.base ? "Base home" : selectedEnv.name, details, detailWidth, bodyHeight, paint); for (let row = 0; row < bodyHeight; row += 1) body.push(`${lead}${list[row]} ${detail[row]}`); } else { body.push(...list.map((line) => lead + line)); diff --git a/lib/gentle-shell-envs.ts b/lib/gentle-shell-envs.ts index b29e2ac2b..45f5c46d6 100644 --- a/lib/gentle-shell-envs.ts +++ b/lib/gentle-shell-envs.ts @@ -101,6 +101,8 @@ export interface EnvSummaryInput { provisioned?: { gentleAi: string; gentlePi?: string; at: string }; sessionCount: number; lastSessionAt?: string; + // The install's own home (`--isolated`), listed beside the named envs. + base?: boolean; } export interface EnvSummary { @@ -113,6 +115,7 @@ export interface EnvSummary { provisioned: string; sessionCount: number; lastSessionAt?: string; + base?: boolean; } function parseObject(text: string | undefined): Record | undefined { @@ -151,5 +154,6 @@ export function summarizeEnv(input: EnvSummaryInput): EnvSummary { provisioned, sessionCount: input.sessionCount, lastSessionAt: input.lastSessionAt, + ...(input.base ? { base: true } : {}), }; } diff --git a/runtime/gentle-shell-envs-tui.mjs b/runtime/gentle-shell-envs-tui.mjs index 3b0657ef2..5e64cea89 100644 --- a/runtime/gentle-shell-envs-tui.mjs +++ b/runtime/gentle-shell-envs-tui.mjs @@ -16,6 +16,8 @@ import { validateEnvName } from "./gentle-shell-envs.mjs"; +// `base: true` targets the install's own home (`--isolated`), never an env: +// an env may even be named like the base entry. @@ -46,8 +48,13 @@ import { validateEnvName } from "./gentle-shell-envs.mjs"; -export function initialEnvsState(envs , message , selectName ) { - const byName = selectName === undefined ? -1 : envs.findIndex((e) => e.name === selectName); + +// `select` names an env, or `{ base: true }` for the base home: an env may +// share the base entry's display name, so the two are never matched by name. +export function initialEnvsState(envs , message , select ) { + const byName = select === undefined ? -1 + : typeof select === "string" ? envs.findIndex((e) => !e.base && e.name === select) + : envs.findIndex((e) => e.base === true); const byDefault = envs.findIndex((e) => e.isDefault); return { envs, selected: byName >= 0 ? byName : Math.max(0, byDefault), message }; } @@ -110,7 +117,7 @@ function reducePrompt(state , prompt , key ) if (prompt.kind === "rename" && value === target) return { state: { ...state, prompt: undefined } }; const invalid = validateEnvName(value); if (invalid !== undefined) return fail(invalid); - if (state.envs.some((e) => e.name === value)) return fail(`env "${value}" already exists`); + if (state.envs.some((e) => !e.base && e.name === value)) return fail(`env "${value}" already exists`); return done(prompt.kind === "create" ? { kind: "create", name: value } : { kind: "rename", from: target, to: value }); } if (prompt.kind === "delete") { @@ -118,7 +125,7 @@ function reducePrompt(state , prompt , key ) return done({ kind: "delete", name: target }); } if (value.length === 0) return fail("enter a package source, e.g. npm:pi-btw"); - return done({ kind: "install", name: target, source: value }); + return done(prompt.targetBase ? { kind: "install", name: target, source: value, base: true } : { kind: "install", name: target, source: value }); } export function reduceEnvs(state , key ) { @@ -137,12 +144,16 @@ export function reduceEnvs(state , key ) { if (!scoped) return { state }; if (current === undefined) return { state: { ...state, message: EMPTY_HINT } }; const name = current.name; - if (key.name === "enter") return { state, effect: { kind: "launch", name } }; - if (char === "s") return { state, effect: { kind: "set-default", name } }; - if (char === "p") return { state, effect: { kind: "setup", name } }; - if (char === "a") return { state, effect: { kind: "auth-check", name } }; + const base = current.base ? { base: true } : {}; + if (key.name === "enter") return { state, effect: { kind: "launch", name, ...base } }; + if (char === "s") return { state, effect: { kind: "set-default", name, ...base } }; + if (char === "p") return { state, effect: { kind: "setup", name, ...base } }; + if (char === "a") return { state, effect: { kind: "auth-check", name, ...base } }; + if (current.base && (char === "r" || char === "d")) { + return { state: { ...state, message: "The base home belongs to the install: it can't be renamed or deleted." } }; + } if (char === "r") return openPrompt(state, { kind: "rename", label: `Rename "${name}" to`, value: name, target: name }); - if (char === "i") return openPrompt(state, { kind: "install", label: `Install into "${name}"`, value: "", target: name }); + if (char === "i") return openPrompt(state, { kind: "install", label: `Install into "${current.base ? "the base home" : name}"`, value: "", target: name, ...(current.base ? { targetBase: true } : {}) }); return openPrompt(state, { kind: "delete", label: `Delete "${name}" and all its data? Type its name`, value: "", target: name }); } @@ -244,6 +255,7 @@ function detailRows(env , inner , paint ) return { text, style: (fitted) => `${paint.label(fitted.slice(0, head.length))}${fitted.slice(head.length)}` }; }; return [ + ...(env.base ? [{ text: "The install's own home, as before envs (--isolated).", style: paint.label }] : []), ...(env.isDefault ? [{ text: "★ Default: a plain gentle-shell opens it", style: paint.rose }] : []), field("Path", env.dir), field("Setup", env.provisioned), @@ -328,13 +340,13 @@ export function renderEnvsScreen(state , width , height const offset = Math.max(0, Math.min(state.selected - visibleRows + 1, state.envs.length - visibleRows)); const listRows = state.envs.slice(offset, offset + visibleRows).map((env, index) => { const selected = index + offset === state.selected; - const text = `${selected ? "❯" : " "} ${env.isDefault ? "★" : " "} ${env.name}`; + const text = `${selected ? "❯" : " "} ${env.isDefault ? "★" : " "} ${env.base ? `◆ ${BRAND_NAME} base` : env.name}`; return { text, style: selected ? (fitted ) => paint.rose(paint.bold(fitted)) : undefined }; }); if (state.envs.length === 0) listRows.push({ text: " (none)", style: paint.muted }); const list = card("Envs", listRows, listWidth, bodyHeight, paint); if (sideBySide) { - const detail = card(selectedEnv?.name ?? "Welcome", details, detailWidth, bodyHeight, paint); + const detail = card(selectedEnv === undefined ? "Welcome" : selectedEnv.base ? "Base home" : selectedEnv.name, details, detailWidth, bodyHeight, paint); for (let row = 0; row < bodyHeight; row += 1) body.push(`${lead}${list[row]} ${detail[row]}`); } else { body.push(...list.map((line) => lead + line)); diff --git a/runtime/gentle-shell-envs.mjs b/runtime/gentle-shell-envs.mjs index c982de350..c74b3776c 100644 --- a/runtime/gentle-shell-envs.mjs +++ b/runtime/gentle-shell-envs.mjs @@ -112,6 +112,9 @@ export function removeEnvFromConfig(config , aliases + + + @@ -152,5 +155,6 @@ export function summarizeEnv(input ) { provisioned, sessionCount: input.sessionCount, lastSessionAt: input.lastSessionAt, + ...(input.base ? { base: true } : {}), }; } diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index b6e219aec..8b1bb1569 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3558,3 +3558,24 @@ test("envs rejects unknown subcommands with usage", (t) => { assert.equal(result.status, 2); assert.match(result.stderr, /Usage: gentle-shell envs/); }); + +test("envs lists the base home first and stars exactly what a plain launch opens", (t) => { + const f = fixture(t); + assert.equal(run(f.env, ["envs", "new", "work"]).status, 0); + const starred = () => run(f.env, ["envs", "list"]).stdout.split("\n").filter((line) => line.startsWith("*")).map((line) => line.split(/\s+/)[1]); + const opened = () => JSON.parse(run(f.env, ["-p", "hi"]).stdout.trim().split("\n").at(-1) ?? "{}").PI_CODING_AGENT_DIR; + const lines = run(f.env, ["envs", "list"]).stdout.split("\n"); + assert.match(lines[0], new RegExp(`^\\* \\[base\\] +${f.gentleShellHome.replace(/[\\.]/g, "\\$&")} `), "base first, default with no config"); + assert.equal(opened(), f.gentleShellHome); + + assert.equal(run(f.env, ["home", "link"]).status, 0); + assert.deepEqual(starred(), [], "link opens none of the listed homes"); + + assert.equal(run(f.env, ["home", join(f.home, ".gentle-shell", "envs", "work")]).status, 0); + assert.deepEqual(starred(), ["work"]); + assert.equal(opened(), join(f.home, ".gentle-shell", "envs", "work")); + + assert.equal(run(f.env, ["home", "isolated"]).status, 0); + assert.deepEqual(starred(), ["[base]"]); + assert.equal(opened(), f.gentleShellHome); +}); diff --git a/tests/gentle-shell-envs-tui.test.ts b/tests/gentle-shell-envs-tui.test.ts index 458fe098a..33f86ab42 100644 --- a/tests/gentle-shell-envs-tui.test.ts +++ b/tests/gentle-shell-envs-tui.test.ts @@ -223,3 +223,47 @@ test("every layout fills the terminal exactly within its width", () => { } } }); + +// --- the base home (the install's own home, `--isolated`) ---------------------- + +const baseEntry = (extra: Partial = {}): EnvSummary => env("base", { dir: "/h/.gentle-shell/agent", base: true, ...extra }); + +test("base home actions carry base and never a managed env name", () => { + const s0 = initialEnvsState([baseEntry(), env("work")]); + assert.deepEqual(press(s0, { name: "enter" }).effect, { kind: "launch", name: "base", base: true }); + assert.deepEqual(press(s0, char("s")).effect, { kind: "set-default", name: "base", base: true }); + assert.deepEqual(press(s0, char("p")).effect, { kind: "setup", name: "base", base: true }); + assert.deepEqual(press(s0, char("a")).effect, { kind: "auth-check", name: "base", base: true }); + assert.deepEqual(press(s0, char("i"), ...typed("npm:pi-btw"), { name: "enter" }).effect, { kind: "install", name: "base", source: "npm:pi-btw", base: true }); + assert.deepEqual(press(s0, { name: "down" }, { name: "enter" }).effect, { kind: "launch", name: "work" }, "envs keep their exact effects"); +}); + +test("the base home can be neither renamed nor deleted", () => { + const s0 = initialEnvsState([baseEntry(), env("work")]); + for (const key of ["r", "d"]) { + const { state, effect } = press(s0, char(key)); + assert.equal(effect, undefined); + assert.equal(state.prompt, undefined); + assert.match(state.message ?? "", /base home/); + } +}); + +test("an env may be named base: the base home never takes part in name checks", () => { + const s0 = initialEnvsState([baseEntry(), env("work")]); + assert.deepEqual(press(s0, char("n"), ...typed("base"), { name: "enter" }).effect, { kind: "create", name: "base" }); +}); + +test("the base home renders first with its own marker and card, starred when default", () => { + const s0 = initialEnvsState([baseEntry({ isDefault: true }), env("work")]); + const text = renderEnvsScreen(s0, 100, 24).map(stripAnsi).join("\n"); + assert.match(text, /│ ❯ ★ ◆ Gentle Shell base/); + assert.match(text, /╭─ ✿ Base home ─+╮/); + assert.match(text, /The install's own home/); + assert.equal(s0.selected, 0, "initial selection follows the default"); +}); + +test("reselection never confuses the base home with an env of the same name", () => { + const envs = [baseEntry(), env("base"), env("work")]; + assert.equal(initialEnvsState(envs, undefined, "base").selected, 1); + assert.equal(initialEnvsState(envs, undefined, { base: true }).selected, 0); +}); From 6bb583ef3ebe5980adfa95f7308bb775d4a0b0fc Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 13:09:27 -0400 Subject: [PATCH 10/25] fix(launcher): say a home is set up on first open instead of showing no packages --- lib/gentle-shell-envs-tui.ts | 4 ++-- lib/gentle-shell-envs.ts | 3 +++ runtime/gentle-shell-envs-tui.mjs | 4 ++-- runtime/gentle-shell-envs.mjs | 3 +++ tests/gentle-shell-envs-tui.test.ts | 10 ++++++++++ tests/gentle-shell-envs.test.ts | 5 +++++ 6 files changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/gentle-shell-envs-tui.ts b/lib/gentle-shell-envs-tui.ts index 681c0840c..53c10cfeb 100644 --- a/lib/gentle-shell-envs-tui.ts +++ b/lib/gentle-shell-envs-tui.ts @@ -257,10 +257,10 @@ function detailRows(env: EnvSummary | undefined, inner: number, paint: Paint): C ...(env.base ? [{ text: "The install's own home, as before envs (--isolated).", style: paint.label }] : []), ...(env.isDefault ? [{ text: "★ Default: a plain gentle-shell opens it", style: paint.rose }] : []), field("Path", env.dir), - field("Setup", env.provisioned), + field("Setup", env.setUp === false ? "not set up yet" : env.provisioned), field("Sessions", sessions), field("Sign-ins", env.providers.length > 0 ? env.providers.join(", ") : "none yet: /login inside the session"), - field("Packages", env.packages.length > 0 ? `${env.packages.length}` : "none"), + field("Packages", env.packages.length > 0 ? `${env.packages.length}` : env.setUp === false ? "installed when you first open it" : "none"), ...env.packages.map((source) => ({ text: ` · ${source}`, style: paint.muted })), ]; } diff --git a/lib/gentle-shell-envs.ts b/lib/gentle-shell-envs.ts index 45f5c46d6..fb5210096 100644 --- a/lib/gentle-shell-envs.ts +++ b/lib/gentle-shell-envs.ts @@ -113,6 +113,8 @@ export interface EnvSummary { // Provider ids only: credential values never leave auth.json. providers: string[]; provisioned: string; + // False until gentle-shell provisioned this home; its first launch does. + setUp?: boolean; sessionCount: number; lastSessionAt?: string; base?: boolean; @@ -152,6 +154,7 @@ export function summarizeEnv(input: EnvSummaryInput): EnvSummary { packages: packageSources(input.settingsText), providers: Object.keys(parseObject(input.authText) ?? {}).sort(), provisioned, + setUp: p !== undefined, sessionCount: input.sessionCount, lastSessionAt: input.lastSessionAt, ...(input.base ? { base: true } : {}), diff --git a/runtime/gentle-shell-envs-tui.mjs b/runtime/gentle-shell-envs-tui.mjs index 5e64cea89..468057799 100644 --- a/runtime/gentle-shell-envs-tui.mjs +++ b/runtime/gentle-shell-envs-tui.mjs @@ -258,10 +258,10 @@ function detailRows(env , inner , paint ) ...(env.base ? [{ text: "The install's own home, as before envs (--isolated).", style: paint.label }] : []), ...(env.isDefault ? [{ text: "★ Default: a plain gentle-shell opens it", style: paint.rose }] : []), field("Path", env.dir), - field("Setup", env.provisioned), + field("Setup", env.setUp === false ? "not set up yet" : env.provisioned), field("Sessions", sessions), field("Sign-ins", env.providers.length > 0 ? env.providers.join(", ") : "none yet: /login inside the session"), - field("Packages", env.packages.length > 0 ? `${env.packages.length}` : "none"), + field("Packages", env.packages.length > 0 ? `${env.packages.length}` : env.setUp === false ? "installed when you first open it" : "none"), ...env.packages.map((source) => ({ text: ` · ${source}`, style: paint.muted })), ]; } diff --git a/runtime/gentle-shell-envs.mjs b/runtime/gentle-shell-envs.mjs index c74b3776c..9920540e0 100644 --- a/runtime/gentle-shell-envs.mjs +++ b/runtime/gentle-shell-envs.mjs @@ -116,6 +116,8 @@ export function removeEnvFromConfig(config , aliases + + @@ -153,6 +155,7 @@ export function summarizeEnv(input ) { packages: packageSources(input.settingsText), providers: Object.keys(parseObject(input.authText) ?? {}).sort(), provisioned, + setUp: p !== undefined, sessionCount: input.sessionCount, lastSessionAt: input.lastSessionAt, ...(input.base ? { base: true } : {}), diff --git a/tests/gentle-shell-envs-tui.test.ts b/tests/gentle-shell-envs-tui.test.ts index 33f86ab42..127824198 100644 --- a/tests/gentle-shell-envs-tui.test.ts +++ b/tests/gentle-shell-envs-tui.test.ts @@ -267,3 +267,13 @@ test("reselection never confuses the base home with an env of the same name", () assert.equal(initialEnvsState(envs, undefined, "base").selected, 1); assert.equal(initialEnvsState(envs, undefined, { base: true }).selected, 0); }); + +test("a home that was never set up says it is set up on first open, never just 'none'", () => { + const fresh = initialEnvsState([baseEntry({ provisioned: "not provisioned", setUp: false })]); + const text = renderEnvsScreen(fresh, 100, 24).map(stripAnsi).join("\n"); + assert.match(text, /Setup +not set up yet/); + assert.match(text, /Packages +installed when you first open it/); + assert.doesNotMatch(text, /Packages +none/); + const ready = initialEnvsState([env("work", { setUp: true })]); + assert.match(renderEnvsScreen(ready, 100, 24).map(stripAnsi).join("\n"), /Packages +none/); +}); diff --git a/tests/gentle-shell-envs.test.ts b/tests/gentle-shell-envs.test.ts index 98d8bde9a..b2a24ef16 100644 --- a/tests/gentle-shell-envs.test.ts +++ b/tests/gentle-shell-envs.test.ts @@ -172,3 +172,8 @@ test("resolveHome names an explicit --home that is an env in the envs root", () const other = resolveHome({ args: parseLauncherArgs(["--home", "/elsewhere/work"]), env: {}, homedir: "/h", config: undefined }); assert.equal(other.env, undefined); }); + +test("summarizeEnv reports whether the home was ever set up", () => { + assert.equal(summarizeEnv({ name: "x", dir: "/e/x", isDefault: false, sessionCount: 0 }).setUp, false); + assert.equal(summarizeEnv({ name: "x", dir: "/e/x", isDefault: false, sessionCount: 0, provisioned: { gentleAi: "1", at: "2026-01-01" } }).setUp, true); +}); From 7311b660238a3ab32af5446043970e5f2aa7c157 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 13:11:16 -0400 Subject: [PATCH 11/25] fix(launcher): name the base home without a marker that needs a legend --- docs/readme-reference.md | 2 +- lib/gentle-shell-envs-tui.ts | 2 +- runtime/gentle-shell-envs-tui.mjs | 2 +- tests/gentle-shell-envs-tui.test.ts | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/readme-reference.md b/docs/readme-reference.md index c18d15746..e87fae2f0 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -381,7 +381,7 @@ A named env is a separate agent home managed by gentle-shell under `~/.gentle-sh | `gentle-shell envs new ` | Creates the env without launching it. Its first launch installs the companion packages, the same as any new `--home`. | | `gentle-shell --env [...]` | Launches, sets up (`--env setup`), or runs pi subcommands (`--env install npm:x`) in that env. | -The manager lists the install's own home first, as `◆ Gentle Shell base`. It is the `--isolated` home (`GENTLE_SHELL_HOME` or `~/.gentle-shell/agent`), the one Gentle Shell always used before envs, and nothing about it changes. Opening it is `--isolated`, and making it the default persists `"home": "isolated"`, exactly like `gentle-shell home isolated`. Its sessions carry no env label. It cannot be renamed or deleted from the manager, and an env may still be named `base`. The `★` marks what a plain launch opens: the base home when no `home` is persisted (or it is `isolated` or malformed), the env a persisted path names, and nothing in the list for `link`. +The manager lists the install's own home first, as `Gentle Shell base`. It is the `--isolated` home (`GENTLE_SHELL_HOME` or `~/.gentle-shell/agent`), the one Gentle Shell always used before envs, and nothing about it changes. Opening it is `--isolated`, and making it the default persists `"home": "isolated"`, exactly like `gentle-shell home isolated`. Its sessions carry no env label. It cannot be renamed or deleted from the manager, and an env may still be named `base`. The `★` marks what a plain launch opens: the base home when no `home` is persisted (or it is `isolated` or malformed), the env a persisted path names, and nothing in the list for `link`. Manager keys: `↑`/`↓` or `j`/`k` select. `enter` launches the selected env. `n` creates an env, `r` renames it, and `d` deletes it after you type its name. `s` makes it the default. `p` runs `setup`, `i` installs a package source, and `a` runs `pi auth check` for each signed-in provider. `q` or `esc` quits. The detail pane shows the path, the setup record, the session count and last session, the signed-in provider ids (never credential values), and the declared packages. diff --git a/lib/gentle-shell-envs-tui.ts b/lib/gentle-shell-envs-tui.ts index 53c10cfeb..625a4f473 100644 --- a/lib/gentle-shell-envs-tui.ts +++ b/lib/gentle-shell-envs-tui.ts @@ -339,7 +339,7 @@ export function renderEnvsScreen(state: EnvsState, width: number, height: number const offset = Math.max(0, Math.min(state.selected - visibleRows + 1, state.envs.length - visibleRows)); const listRows: CardRow[] = state.envs.slice(offset, offset + visibleRows).map((env, index) => { const selected = index + offset === state.selected; - const text = `${selected ? "❯" : " "} ${env.isDefault ? "★" : " "} ${env.base ? `◆ ${BRAND_NAME} base` : env.name}`; + const text = `${selected ? "❯" : " "} ${env.isDefault ? "★" : " "} ${env.base ? `${BRAND_NAME} base` : env.name}`; return { text, style: selected ? (fitted: string) => paint.rose(paint.bold(fitted)) : undefined }; }); if (state.envs.length === 0) listRows.push({ text: " (none)", style: paint.muted }); diff --git a/runtime/gentle-shell-envs-tui.mjs b/runtime/gentle-shell-envs-tui.mjs index 468057799..44532c56f 100644 --- a/runtime/gentle-shell-envs-tui.mjs +++ b/runtime/gentle-shell-envs-tui.mjs @@ -340,7 +340,7 @@ export function renderEnvsScreen(state , width , height const offset = Math.max(0, Math.min(state.selected - visibleRows + 1, state.envs.length - visibleRows)); const listRows = state.envs.slice(offset, offset + visibleRows).map((env, index) => { const selected = index + offset === state.selected; - const text = `${selected ? "❯" : " "} ${env.isDefault ? "★" : " "} ${env.base ? `◆ ${BRAND_NAME} base` : env.name}`; + const text = `${selected ? "❯" : " "} ${env.isDefault ? "★" : " "} ${env.base ? `${BRAND_NAME} base` : env.name}`; return { text, style: selected ? (fitted ) => paint.rose(paint.bold(fitted)) : undefined }; }); if (state.envs.length === 0) listRows.push({ text: " (none)", style: paint.muted }); diff --git a/tests/gentle-shell-envs-tui.test.ts b/tests/gentle-shell-envs-tui.test.ts index 127824198..d7e75ca98 100644 --- a/tests/gentle-shell-envs-tui.test.ts +++ b/tests/gentle-shell-envs-tui.test.ts @@ -256,7 +256,8 @@ test("an env may be named base: the base home never takes part in name checks", test("the base home renders first with its own marker and card, starred when default", () => { const s0 = initialEnvsState([baseEntry({ isDefault: true }), env("work")]); const text = renderEnvsScreen(s0, 100, 24).map(stripAnsi).join("\n"); - assert.match(text, /│ ❯ ★ ◆ Gentle Shell base/); + assert.match(text, /│ ❯ ★ Gentle Shell base/); + assert.doesNotMatch(text, /◆/, "no marker that would need a legend"); assert.match(text, /╭─ ✿ Base home ─+╮/); assert.match(text, /The install's own home/); assert.equal(s0.selected, 0, "initial selection follows the default"); From 81b0090b34317cca4ba70a648f81538f6d638a22 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 13:14:28 -0400 Subject: [PATCH 12/25] fix(launcher): mark the default env like /gentle:profiles markers, not a star --- docs/readme-reference.md | 2 +- lib/gentle-shell-envs-tui.ts | 11 +++++++---- runtime/gentle-shell-envs-tui.mjs | 11 +++++++---- tests/gentle-shell-envs-tui.test.ts | 9 +++++---- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/docs/readme-reference.md b/docs/readme-reference.md index e87fae2f0..232dd893d 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -381,7 +381,7 @@ A named env is a separate agent home managed by gentle-shell under `~/.gentle-sh | `gentle-shell envs new ` | Creates the env without launching it. Its first launch installs the companion packages, the same as any new `--home`. | | `gentle-shell --env [...]` | Launches, sets up (`--env setup`), or runs pi subcommands (`--env install npm:x`) in that env. | -The manager lists the install's own home first, as `Gentle Shell base`. It is the `--isolated` home (`GENTLE_SHELL_HOME` or `~/.gentle-shell/agent`), the one Gentle Shell always used before envs, and nothing about it changes. Opening it is `--isolated`, and making it the default persists `"home": "isolated"`, exactly like `gentle-shell home isolated`. Its sessions carry no env label. It cannot be renamed or deleted from the manager, and an env may still be named `base`. The `★` marks what a plain launch opens: the base home when no `home` is persisted (or it is `isolated` or malformed), the env a persisted path names, and nothing in the list for `link`. +The manager lists the install's own home first, as `Gentle Shell base`. It is the `--isolated` home (`GENTLE_SHELL_HOME` or `~/.gentle-shell/agent`), the one Gentle Shell always used before envs, and nothing about it changes. Opening it is `--isolated`, and making it the default persists `"home": "isolated"`, exactly like `gentle-shell home isolated`. Its sessions carry no env label. It cannot be renamed or deleted from the manager, and an env may still be named `base`. The `(default)` marker, like the `(active)` marker in `/gentle:profiles`, names what a plain launch opens: the base home when no `home` is persisted (or it is `isolated` or malformed), the env a persisted path names, and nothing in the list for `link`. Manager keys: `↑`/`↓` or `j`/`k` select. `enter` launches the selected env. `n` creates an env, `r` renames it, and `d` deletes it after you type its name. `s` makes it the default. `p` runs `setup`, `i` installs a package source, and `a` runs `pi auth check` for each signed-in provider. `q` or `esc` quits. The detail pane shows the path, the setup record, the session count and last session, the signed-in provider ids (never credential values), and the declared packages. diff --git a/lib/gentle-shell-envs-tui.ts b/lib/gentle-shell-envs-tui.ts index 625a4f473..d13d137f2 100644 --- a/lib/gentle-shell-envs-tui.ts +++ b/lib/gentle-shell-envs-tui.ts @@ -196,7 +196,7 @@ function painter(color: boolean): Paint { } // Visible width: the renderer only emits single-cell characters (ASCII, -// box drawing, braille, ✿ ★ ❯ ·) outside of its own SGR sequences. +// box drawing, braille, ✿ ❯ ·) outside of its own SGR sequences. function visible(text: string): number { return [...text.replace(/\x1b\[[0-9;]*m/g, "")].length; } @@ -255,7 +255,7 @@ function detailRows(env: EnvSummary | undefined, inner: number, paint: Paint): C }; return [ ...(env.base ? [{ text: "The install's own home, as before envs (--isolated).", style: paint.label }] : []), - ...(env.isDefault ? [{ text: "★ Default: a plain gentle-shell opens it", style: paint.rose }] : []), + ...(env.isDefault ? [{ text: "Default: a plain gentle-shell opens it", style: paint.rose }] : []), field("Path", env.dir), field("Setup", env.setUp === false ? "not set up yet" : env.provisioned), field("Sessions", sessions), @@ -326,7 +326,9 @@ export function renderEnvsScreen(state: EnvsState, width: number, height: number const lead = " ".repeat(Math.floor((w - blockWidth) / 2)); const selectedEnv = state.envs[state.selected]; const sideBySide = blockWidth >= 56; - const listWidth = sideBySide ? Math.min(32, Math.max(20, Math.floor(blockWidth * 0.3))) : blockWidth; + // Wide enough for the longest entry (marker and `(default)` included), within bounds. + const longest = Math.max(0, ...state.envs.map((env) => [...(env.base ? `${BRAND_NAME} base` : env.name)].length + (env.isDefault ? " (default)".length : 0))); + const listWidth = sideBySide ? Math.min(Math.max(20, Math.floor(blockWidth * 0.3), longest + 6), 40, blockWidth - 30) : blockWidth; const detailWidth = blockWidth - listWidth - 1; const details = detailRows(selectedEnv, Math.max(1, detailWidth - 4), paint); const contentRows = Math.max(state.envs.length || 1, sideBySide ? details.length : 0) + 2; @@ -339,7 +341,8 @@ export function renderEnvsScreen(state: EnvsState, width: number, height: number const offset = Math.max(0, Math.min(state.selected - visibleRows + 1, state.envs.length - visibleRows)); const listRows: CardRow[] = state.envs.slice(offset, offset + visibleRows).map((env, index) => { const selected = index + offset === state.selected; - const text = `${selected ? "❯" : " "} ${env.isDefault ? "★" : " "} ${env.base ? `${BRAND_NAME} base` : env.name}`; + // The default reads like the /gentle:profiles markers: `name (default)`. + const text = `${selected ? "❯" : " "} ${env.base ? `${BRAND_NAME} base` : env.name}${env.isDefault ? " (default)" : ""}`; return { text, style: selected ? (fitted: string) => paint.rose(paint.bold(fitted)) : undefined }; }); if (state.envs.length === 0) listRows.push({ text: " (none)", style: paint.muted }); diff --git a/runtime/gentle-shell-envs-tui.mjs b/runtime/gentle-shell-envs-tui.mjs index 44532c56f..9a9f85748 100644 --- a/runtime/gentle-shell-envs-tui.mjs +++ b/runtime/gentle-shell-envs-tui.mjs @@ -197,7 +197,7 @@ function painter(color ) { } // Visible width: the renderer only emits single-cell characters (ASCII, -// box drawing, braille, ✿ ★ ❯ ·) outside of its own SGR sequences. +// box drawing, braille, ✿ ❯ ·) outside of its own SGR sequences. function visible(text ) { return [...text.replace(/\x1b\[[0-9;]*m/g, "")].length; } @@ -256,7 +256,7 @@ function detailRows(env , inner , paint ) }; return [ ...(env.base ? [{ text: "The install's own home, as before envs (--isolated).", style: paint.label }] : []), - ...(env.isDefault ? [{ text: "★ Default: a plain gentle-shell opens it", style: paint.rose }] : []), + ...(env.isDefault ? [{ text: "Default: a plain gentle-shell opens it", style: paint.rose }] : []), field("Path", env.dir), field("Setup", env.setUp === false ? "not set up yet" : env.provisioned), field("Sessions", sessions), @@ -327,7 +327,9 @@ export function renderEnvsScreen(state , width , height const lead = " ".repeat(Math.floor((w - blockWidth) / 2)); const selectedEnv = state.envs[state.selected]; const sideBySide = blockWidth >= 56; - const listWidth = sideBySide ? Math.min(32, Math.max(20, Math.floor(blockWidth * 0.3))) : blockWidth; + // Wide enough for the longest entry (marker and `(default)` included), within bounds. + const longest = Math.max(0, ...state.envs.map((env) => [...(env.base ? `${BRAND_NAME} base` : env.name)].length + (env.isDefault ? " (default)".length : 0))); + const listWidth = sideBySide ? Math.min(Math.max(20, Math.floor(blockWidth * 0.3), longest + 6), 40, blockWidth - 30) : blockWidth; const detailWidth = blockWidth - listWidth - 1; const details = detailRows(selectedEnv, Math.max(1, detailWidth - 4), paint); const contentRows = Math.max(state.envs.length || 1, sideBySide ? details.length : 0) + 2; @@ -340,7 +342,8 @@ export function renderEnvsScreen(state , width , height const offset = Math.max(0, Math.min(state.selected - visibleRows + 1, state.envs.length - visibleRows)); const listRows = state.envs.slice(offset, offset + visibleRows).map((env, index) => { const selected = index + offset === state.selected; - const text = `${selected ? "❯" : " "} ${env.isDefault ? "★" : " "} ${env.base ? `${BRAND_NAME} base` : env.name}`; + // The default reads like the /gentle:profiles markers: `name (default)`. + const text = `${selected ? "❯" : " "} ${env.base ? `${BRAND_NAME} base` : env.name}${env.isDefault ? " (default)" : ""}`; return { text, style: selected ? (fitted ) => paint.rose(paint.bold(fitted)) : undefined }; }); if (state.envs.length === 0) listRows.push({ text: " (none)", style: paint.muted }); diff --git a/tests/gentle-shell-envs-tui.test.ts b/tests/gentle-shell-envs-tui.test.ts index d7e75ca98..f101523a1 100644 --- a/tests/gentle-shell-envs-tui.test.ts +++ b/tests/gentle-shell-envs-tui.test.ts @@ -171,10 +171,11 @@ test("envs and details render as rounded Gentle cards with the rose title glyph" assert.ok(lines.some((line) => /^╰─+╯/.test(line))); }); -test("the selected env is marked with the Gentle chevron and the default with a star", () => { +test("the selected env is marked with the Gentle chevron and the default like /gentle:profiles markers", () => { const text = renderEnvsScreen(sample(), 100, 24).map(stripAnsi).join("\n"); - assert.match(text, /│ ❯ ★ work/); - assert.match(text, /│ {5}personal/); + assert.match(text, /│ ❯ work \(default\)/); + assert.doesNotMatch(text, /★/, "the default reads like /gentle:profiles markers, not a star"); + assert.match(text, /│ {3}personal/); }); test("color uses the Gentle rose and never reverse video; monochrome emits no truecolor", () => { @@ -256,7 +257,7 @@ test("an env may be named base: the base home never takes part in name checks", test("the base home renders first with its own marker and card, starred when default", () => { const s0 = initialEnvsState([baseEntry({ isDefault: true }), env("work")]); const text = renderEnvsScreen(s0, 100, 24).map(stripAnsi).join("\n"); - assert.match(text, /│ ❯ ★ Gentle Shell base/); + assert.match(text, /│ ❯ Gentle Shell base \(default\)/); assert.doesNotMatch(text, /◆/, "no marker that would need a legend"); assert.match(text, /╭─ ✿ Base home ─+╮/); assert.match(text, /The install's own home/); From df7580435b5eda076c7261173259ebe7e30e19e2 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 13:34:23 -0400 Subject: [PATCH 13/25] fix(launcher): mark the default in envs list as (default), not a leading star --- bin/gentle-shell.mjs | 7 ++++--- docs/readme-reference.md | 2 +- lib/gentle-shell-launcher.ts | 2 +- runtime/gentle-shell-launcher.mjs | 2 +- tests/gentle-shell-bin.test.ts | 9 +++++---- tests/gentle-shell-bundled.test.ts | 2 +- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index 2ecdbc2b8..c9dbdd43e 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -850,15 +850,16 @@ function runLauncherForEnv(target, extraArgs) { function printEnvList(root) { const entries = loadEnvSummaries(root); - const label = (env) => (env.base ? "[base]" : env.name); + // The default reads like the /gentle:profiles markers: `name (default)`. + const label = (env) => `${env.base ? "[base]" : env.name}${env.isDefault ? " (default)" : ""}`; const width = Math.max(...entries.map((env) => label(env).length)); for (const env of entries) { - process.stdout.write(`${env.isDefault ? "*" : " "} ${label(env).padEnd(width)} ${env.dir} (${env.provisioned})\n`); + process.stdout.write(`${label(env).padEnd(width)} ${env.dir} (${env.provisioned})\n`); } if (entries.length === 1) process.stdout.write(`No named envs in ${root} yet. Create one with 'gentle-shell envs new '.\n`); } -const ENVS_USAGE = "Usage: gentle-shell envs [list | new ]\n envs open the env manager (TTY), or list envs\n envs list print every env; * marks the default\n envs new create an env without launching it"; +const ENVS_USAGE = "Usage: gentle-shell envs [list | new ]\n envs open the env manager (TTY), or list envs\n envs list print the base home and every env; (default) marks what a plain launch opens\n envs new create an env without launching it"; // Returns the launcher selector to launch with, or undefined when nothing should launch. async function handleEnvsCommand(commandArgs) { diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 232dd893d..45d944a6e 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -377,7 +377,7 @@ A named env is a separate agent home managed by gentle-shell under `~/.gentle-sh | Command | Effect | | --- | --- | | `gentle-shell envs` | On a terminal, opens a full-screen env manager. Without a TTY, it behaves like `envs list`. | -| `gentle-shell envs list` | Prints the base home as `[base]`, then every env, each with its setup state; `*` marks what a plain `gentle-shell` opens. | +| `gentle-shell envs list` | Prints the base home as `[base]`, then every env, each with its setup state; `(default)` after a name marks what a plain `gentle-shell` opens. | | `gentle-shell envs new ` | Creates the env without launching it. Its first launch installs the companion packages, the same as any new `--home`. | | `gentle-shell --env [...]` | Launches, sets up (`--env setup`), or runs pi subcommands (`--env install npm:x`) in that env. | diff --git a/lib/gentle-shell-launcher.ts b/lib/gentle-shell-launcher.ts index 0df5a054f..821e718ef 100644 --- a/lib/gentle-shell-launcher.ts +++ b/lib/gentle-shell-launcher.ts @@ -1363,7 +1363,7 @@ export function helpText(): string { " --dry-run only shows the plan. Refuses --link (nothing to import).", " envs Manage named envs: on a terminal, a full-screen manager to create,", " rename, delete, launch, set the default, set up, install into and", - " check sign-ins of each env. 'envs list' prints them (* = default);", + " check sign-ins of each env. 'envs list' prints them, marking (default);", " 'envs new ' creates one. Envs are separate homes, not sandboxes.", " setup Provision the resolved home with the gentle-ai companion packages", " (runs the package-local gentle-ai 'install --agent pi --scope global').", diff --git a/runtime/gentle-shell-launcher.mjs b/runtime/gentle-shell-launcher.mjs index d7db2bfd2..541585a94 100644 --- a/runtime/gentle-shell-launcher.mjs +++ b/runtime/gentle-shell-launcher.mjs @@ -1364,7 +1364,7 @@ export function helpText() { " --dry-run only shows the plan. Refuses --link (nothing to import).", " envs Manage named envs: on a terminal, a full-screen manager to create,", " rename, delete, launch, set the default, set up, install into and", - " check sign-ins of each env. 'envs list' prints them (* = default);", + " check sign-ins of each env. 'envs list' prints them, marking (default);", " 'envs new ' creates one. Envs are separate homes, not sandboxes.", " setup Provision the resolved home with the gentle-ai companion packages", " (runs the package-local gentle-ai 'install --agent pi --scope global').", diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index 8b1bb1569..2f93a4a44 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3546,8 +3546,9 @@ test("envs without a TTY lists envs, marks the default, and honors GENTLE_SHELL_ assert.equal(run(env, ["home", join(envsDir, "b")]).status, 0); const listed = run(env, ["envs"]); assert.equal(listed.status, 0, listed.stderr); - assert.match(listed.stdout, /^ a /m); - assert.match(listed.stdout, /^\* b /m); + assert.match(listed.stdout, /^a +\//m); + assert.match(listed.stdout, /^b \(default\) +\//m); + assert.doesNotMatch(listed.stdout, /^\*/m, "no leading star: the default reads like /gentle:profiles markers"); const version = run(env, ["--version"]); assert.match(version.stdout, new RegExp(`home path ${join(envsDir, "b").replace(/[\\.]/g, "\\$&")}`)); }); @@ -3562,10 +3563,10 @@ test("envs rejects unknown subcommands with usage", (t) => { test("envs lists the base home first and stars exactly what a plain launch opens", (t) => { const f = fixture(t); assert.equal(run(f.env, ["envs", "new", "work"]).status, 0); - const starred = () => run(f.env, ["envs", "list"]).stdout.split("\n").filter((line) => line.startsWith("*")).map((line) => line.split(/\s+/)[1]); + const starred = () => run(f.env, ["envs", "list"]).stdout.split("\n").filter((line) => /^\S+ \(default\)/.test(line)).map((line) => line.split(/\s+/)[0]); const opened = () => JSON.parse(run(f.env, ["-p", "hi"]).stdout.trim().split("\n").at(-1) ?? "{}").PI_CODING_AGENT_DIR; const lines = run(f.env, ["envs", "list"]).stdout.split("\n"); - assert.match(lines[0], new RegExp(`^\\* \\[base\\] +${f.gentleShellHome.replace(/[\\.]/g, "\\$&")} `), "base first, default with no config"); + assert.match(lines[0], new RegExp(`^\\[base\\] \\(default\\) +${f.gentleShellHome.replace(/[\\.]/g, "\\$&")} `), "base first, default with no config"); assert.equal(opened(), f.gentleShellHome); assert.equal(run(f.env, ["home", "link"]).status, 0); diff --git a/tests/gentle-shell-bundled.test.ts b/tests/gentle-shell-bundled.test.ts index 4b645d538..31ec96219 100644 --- a/tests/gentle-shell-bundled.test.ts +++ b/tests/gentle-shell-bundled.test.ts @@ -1207,7 +1207,7 @@ test("named envs work in a bundled install and survive its self-uninstall", { sk assert.equal(created.status, 0, created.stderr); const dir = join(prefix.root, "envs", "work"); assert.ok(existsSync(join(dir, ".gentle-shell-home"))); - assert.match(prefix.run(["envs", "list"]).stdout, /^ {2}work /m); + assert.match(prefix.run(["envs", "list"]).stdout, /^work +\//m); const launched = prefix.run(["--env", "work"], { FIXTURE_NO_PROBE: "0" }); assert.equal(launched.status, 0, launched.stderr); From ace01f1647fdf889b97a3b9954592b0bff302b60 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 18:37:33 -0400 Subject: [PATCH 14/25] fix(launcher): show text from user files in the envs gate without control sequences --- lib/gentle-shell-envs-tui.ts | 18 ++++++++++++++---- runtime/gentle-shell-envs-tui.mjs | 18 ++++++++++++++---- tests/gentle-shell-envs-tui.test.ts | 10 ++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/lib/gentle-shell-envs-tui.ts b/lib/gentle-shell-envs-tui.ts index d13d137f2..3ec1b2585 100644 --- a/lib/gentle-shell-envs-tui.ts +++ b/lib/gentle-shell-envs-tui.ts @@ -201,6 +201,16 @@ function visible(text: string): number { return [...text.replace(/\x1b\[[0-9;]*m/g, "")].length; } +// Text from the user's own files (paths, package sources, provider ids) is +// shown as plain text: escape sequences and other control characters are +// dropped so a file can never move the cursor or retitle the terminal. +function plain(text: string): string { + return text + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g, "") + .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, "") + .replace(/[\x00-\x1f\x7f-\x9f]/g, ""); +} + function fit(text: string, width: number): string { if (width <= 0) return ""; const chars = [...text]; @@ -256,12 +266,12 @@ function detailRows(env: EnvSummary | undefined, inner: number, paint: Paint): C return [ ...(env.base ? [{ text: "The install's own home, as before envs (--isolated).", style: paint.label }] : []), ...(env.isDefault ? [{ text: "Default: a plain gentle-shell opens it", style: paint.rose }] : []), - field("Path", env.dir), - field("Setup", env.setUp === false ? "not set up yet" : env.provisioned), + field("Path", plain(env.dir)), + field("Setup", env.setUp === false ? "not set up yet" : plain(env.provisioned)), field("Sessions", sessions), - field("Sign-ins", env.providers.length > 0 ? env.providers.join(", ") : "none yet: /login inside the session"), + field("Sign-ins", env.providers.length > 0 ? env.providers.map(plain).join(", ") : "none yet: /login inside the session"), field("Packages", env.packages.length > 0 ? `${env.packages.length}` : env.setUp === false ? "installed when you first open it" : "none"), - ...env.packages.map((source) => ({ text: ` · ${source}`, style: paint.muted })), + ...env.packages.map((source) => ({ text: ` · ${plain(source)}`, style: paint.muted })), ]; } diff --git a/runtime/gentle-shell-envs-tui.mjs b/runtime/gentle-shell-envs-tui.mjs index 9a9f85748..5de1eb388 100644 --- a/runtime/gentle-shell-envs-tui.mjs +++ b/runtime/gentle-shell-envs-tui.mjs @@ -202,6 +202,16 @@ function visible(text ) { return [...text.replace(/\x1b\[[0-9;]*m/g, "")].length; } +// Text from the user's own files (paths, package sources, provider ids) is +// shown as plain text: escape sequences and other control characters are +// dropped so a file can never move the cursor or retitle the terminal. +function plain(text ) { + return text + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g, "") + .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, "") + .replace(/[\x00-\x1f\x7f-\x9f]/g, ""); +} + function fit(text , width ) { if (width <= 0) return ""; const chars = [...text]; @@ -257,12 +267,12 @@ function detailRows(env , inner , paint ) return [ ...(env.base ? [{ text: "The install's own home, as before envs (--isolated).", style: paint.label }] : []), ...(env.isDefault ? [{ text: "Default: a plain gentle-shell opens it", style: paint.rose }] : []), - field("Path", env.dir), - field("Setup", env.setUp === false ? "not set up yet" : env.provisioned), + field("Path", plain(env.dir)), + field("Setup", env.setUp === false ? "not set up yet" : plain(env.provisioned)), field("Sessions", sessions), - field("Sign-ins", env.providers.length > 0 ? env.providers.join(", ") : "none yet: /login inside the session"), + field("Sign-ins", env.providers.length > 0 ? env.providers.map(plain).join(", ") : "none yet: /login inside the session"), field("Packages", env.packages.length > 0 ? `${env.packages.length}` : env.setUp === false ? "installed when you first open it" : "none"), - ...env.packages.map((source) => ({ text: ` · ${source}`, style: paint.muted })), + ...env.packages.map((source) => ({ text: ` · ${plain(source)}`, style: paint.muted })), ]; } diff --git a/tests/gentle-shell-envs-tui.test.ts b/tests/gentle-shell-envs-tui.test.ts index f101523a1..51d358918 100644 --- a/tests/gentle-shell-envs-tui.test.ts +++ b/tests/gentle-shell-envs-tui.test.ts @@ -279,3 +279,13 @@ test("a home that was never set up says it is set up on first open, never just ' const ready = initialEnvsState([env("work", { setUp: true })]); assert.match(renderEnvsScreen(ready, 100, 24).map(stripAnsi).join("\n"), /Packages +none/); }); + +test("text from the user's own files never reaches the terminal as control sequences", () => { + const evil = env("work", { dir: "/e/work\x1b]0;x\x07", packages: ["npm:a\x1b[2J"], providers: ["anth\x1b[31mropic"], provisioned: "ok\r" }); + const lines = renderEnvsScreen(initialEnvsState([evil]), 100, 24, { color: false }); + // The renderer's own SGR styling is allowed; nothing else may remain. + for (const line of lines) assert.doesNotMatch(stripAnsi(line), /[\x00-\x1f\x7f]/, JSON.stringify(line)); + const text = lines.join("\n"); + assert.match(text, /npm:a/); + assert.match(text, /anthropic|anth.*ropic/); +}); From 7fc7fcc73c56d053ef42a8c88850bfc2973bc264 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Wed, 14 Oct 2026 10:17:00 -0400 Subject: [PATCH 15/25] test(launcher): drive the envs manager through a simulated TTY to cover rename, default and delete --- tests/gentle-shell-bin.test.ts | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index 2f93a4a44..b5f1b1ef7 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3580,3 +3580,50 @@ test("envs lists the base home first and stars exactly what a plain launch opens assert.deepEqual(starred(), ["[base]"]); assert.equal(opened(), f.gentleShellHome); }); + +// The envs manager needs a TTY. Like the bundled startup-offer test, a +// preload makes stdin/stdout report a TTY with a no-op raw mode, and the +// keys arrive as stdin input; the manager handles them in order. +function runManager(env: NodeJS.ProcessEnv, keys: string) { + const preload = ` + Object.defineProperty(process.stdin, 'isTTY', {value:true}); + Object.defineProperty(process.stdout, 'isTTY', {value:true}); + process.stdin.setRawMode = (raw) => { process.stdin.isRaw = raw; }; + `; + return spawnSync(process.execPath, [binPath, "envs"], { + env: { ...env, NODE_OPTIONS: `${env.NODE_OPTIONS ?? ""} --import=data:text/javascript,${encodeURIComponent(preload)}`.trim() }, + encoding: "utf8", input: keys, timeout: 15000, + }); +} + +test("the envs manager renames, sets the default and deletes envs on disk", (t) => { + const f = fixture(t); + const envs = join(f.home, ".gentle-shell", "envs"); + for (const name of ["a", "b"]) assert.equal(run(f.env, ["envs", "new", name]).status, 0); + // Entries: base (default), a, b. Rename a -> alpha, make it the default, then delete b + // (a wrong confirmation first, which must not delete anything). + const keys = ["j", "r", "\x7f", "alpha", "\r", "s", "j", "d", "x", "\r", "\x7f", "b", "\r", "q"].join(""); + const result = runManager(f.env, keys); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(readdirSync(envs).sort(), ["alpha"]); + const config = JSON.parse(readFileSync(join(f.home, ".gentle-shell", "config.json"), "utf8")); + assert.equal(config.home, join(envs, "alpha"), "the default follows the renamed env"); + // Keys arriving together redraw once at the end, so disk and config are the evidence. + assert.match(result.stdout, /\x1b\[\?1049l/, "the terminal is restored"); +}); + +test("the envs manager never lists or touches a symlinked env", (t) => { + const f = fixture(t); + const envs = join(f.home, ".gentle-shell", "envs"); + assert.equal(run(f.env, ["envs", "new", "real"]).status, 0); + const outside = join(f.root, "outside"); + mkdirSync(outside); + writeFileSync(join(outside, "keep.txt"), "keep"); + symlinkSync(outside, join(envs, "linked")); + // Entries: base, real. Two downs stay on real; delete it, then quit. + const result = runManager(f.env, ["j", "j", "d", "real", "\r", "q"].join("")); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(result.stdout, /\blinked\b/); + assert.equal(readFileSync(join(outside, "keep.txt"), "utf8"), "keep"); + assert.deepEqual(readdirSync(envs), ["linked"]); +}); From 26d2234bd9c74915f46b461323e6fdbe3af26eb2 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Wed, 14 Oct 2026 20:06:07 -0400 Subject: [PATCH 16/25] fix(launcher): clean up a failed envs new, show the env in the regular-mode bar, and drop a rename/delete doc claim A failed bootstrap after mkdir now removes the half-created env, so a retry neither reports "already exists" nor meets an unmarked foreign home. The regular-mode compact bar receives and paints the active env beside the brand, like the header. The reference no longer documents rename/delete, which this PR does not ship. --- bin/gentle-shell.mjs | 12 ++++++++++-- docs/readme-reference.md | 2 +- extensions/gentle-shell.ts | 5 +++-- lib/shell-bar.ts | 3 +++ tests/gentle-shell-bin.test.ts | 19 +++++++++++++++++++ tests/shell-bar.test.ts | 11 +++++++++++ 6 files changed, 47 insertions(+), 5 deletions(-) diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index e77bb40bf..974e791a6 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -781,8 +781,16 @@ async function createEnv(root, name) { if (existsSync(dir)) throw new Error(`env "${name}" already exists`); mkdirSync(root, { recursive: true, mode: 0o700 }); mkdirSync(dir, { mode: 0o700 }); - await installIsolatedTuiModeSetting(dir); - writeHomeOwnershipMarker({ mode: "path", dir }); + try { + await installIsolatedTuiModeSetting(dir); + writeHomeOwnershipMarker({ mode: "path", dir }); + } catch (error) { + // This call created `dir`: a half-bootstrapped env would read as "already + // exists" and, once non-empty and unmarked, as a foreign home --env never + // provisions. Remove it so `envs new` can simply be retried. + rmSync(dir, { recursive: true, force: true }); + throw error; + } return dir; } diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 31ff7fad0..7773b4022 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -381,7 +381,7 @@ A named env is a separate agent home managed by gentle-shell under `~/.gentle-sh | `gentle-shell envs new ` | Creates the env without launching it. Its first launch installs the companion packages, the same as any new `--home`. | | `gentle-shell --env [...]` | Launches, sets up (`--env setup`), or runs pi subcommands (`--env install npm:x`) in that env. | -Inside a session, the header shows `env ` right after the brand, and it stays there as the header narrows. The Status card shows an `Env` group with the name, above `Project`. The launcher passes the name to Pi as `GENTLE_SHELL_ENV`, only for a named env, and clears any inherited value for every other home. The default env is stored as the ordinary persisted `home` path, so a plain `gentle-shell` opens it, and `gentle-shell home link|isolated|` replaces it. Renaming or deleting an env keeps `config.json` consistent: its setup record and default follow it or are removed. Envs are separate homes, not sandboxes. They share the repository you work in, `~/.pi/gentle-ai/persona.json`, and `~/.gentle-ai/state.json`, like every other home. Named envs work the same in a bundled (standalone) install. There, the envs root shares `~/.gentle-shell` with the prefix and launches use the bundled Pi. Both `self-uninstall` variants list the envs root once, as "your named envs", under *Not touched*, and never remove it. They also refuse a plan that would remove anything inside it, for example a `GENTLE_SHELL_HOME` that points into an env. +Inside a session, the header shows `env ` right after the brand, and it stays there as the header narrows. The Status card shows an `Env` group with the name, above `Project`. The launcher passes the name to Pi as `GENTLE_SHELL_ENV`, only for a named env, and clears any inherited value for every other home. The default env is stored as the ordinary persisted `home` path, so a plain `gentle-shell` opens it, and `gentle-shell home link|isolated|` replaces it. Envs are separate homes, not sandboxes. They share the repository you work in, `~/.pi/gentle-ai/persona.json`, and `~/.gentle-ai/state.json`, like every other home. Named envs work the same in a bundled (standalone) install. There, the envs root shares `~/.gentle-shell` with the prefix and launches use the bundled Pi. Both `self-uninstall` variants list the envs root once, as "your named envs", under *Not touched*, and never remove it. They also refuse a plan that would remove anything inside it, for example a `GENTLE_SHELL_HOME` that points into an env. ### Managing packages diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 6066f7327..11975c04a 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -347,6 +347,7 @@ export function createShellBarComponent( dirty: () => number | undefined = () => undefined, usage: () => ProviderUsage | undefined = () => undefined, presentation?: () => ReturnType["settings"], + env: () => string | undefined = () => undefined, ): ShellBarComponent { const unsubscribe = footerData.onBranchChange(() => { host.invalidateSidebar?.(); @@ -354,7 +355,7 @@ export function createShellBarComponent( }); return { render(width: number) { - return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width, presentation?.()); + return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage(), env: env() }), theme, width, presentation?.()); }, invalidate() {}, dispose() { @@ -1914,7 +1915,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p sidebarTui = tui; if (tui.terminal) sidebarState(tui).visibility = { todo: visualSettings.visibility.todo }; renderHost = { requestRender: () => tui.requestRender(), invalidateSidebar: () => invalidateSidebar(tui) }; - const bottom = createShellBarComponent(pi, ctx, renderHost, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? ""), () => visualSettings); + const bottom = createShellBarComponent(pi, ctx, renderHost, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? ""), () => visualSettings, () => activeEnvName(env)); // The Status card paints live session state that no event re-registers a // part for: model, effort, context, cost, session name and extension // statuses. The digest is what keeps the fullscreen memo honest, and it diff --git a/lib/shell-bar.ts b/lib/shell-bar.ts index 3a80130be..af8f65c39 100644 --- a/lib/shell-bar.ts +++ b/lib/shell-bar.ts @@ -181,6 +181,9 @@ function buildSegments(model: ShellBarModel, theme: ShellBarTheme, presentation? const statuses = model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status))); return [ ...(presentation?.density === "minimal" ? [] : [theme.fg(ROLE.BRAND, SHELL_BAR_BRAND)]), + // As in the header: the env sits beside the brand and, being first, is + // never among the trailing segments a narrow line drops. + ...(model.env ? [`${theme.fg(ROLE.LABEL, "env")} ${theme.fg(ROLE.VALUE, theme.bold(sanitizeStatus(model.env)))}`] : []), location, ...(presentation?.visibility.modelDetails === false ? [] : [modelSegment]), ...(presentation?.visibility.usageCost === false ? [] : [context, cost, ...(usage ? [usage] : [])]), diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index b6e219aec..35ce095d2 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3526,6 +3526,25 @@ test("envs new creates a managed, owned env that --env launches into", (t) => { assert.equal(JSON.parse(launched.stdout.trim().split("\n").at(-1) ?? "{}").PI_CODING_AGENT_DIR, dir); }); +test("envs new removes its partial env when bootstrap fails, so a retry succeeds", (t) => { + const f = fixture(t); + const preload = join(f.root, "fail-marker.cjs"); + writeFileSync(preload, [ + "const fs = require('node:fs');", + "const { syncBuiltinESMExports } = require('node:module');", + "const original = fs.writeFileSync;", + "fs.writeFileSync = (path, ...args) => { if (String(path).endsWith('.gentle-shell-home')) throw new Error('marker write failed'); return original(path, ...args); };", + "syncBuiltinESMExports();", + ].join("\n")); + const failed = spawnSync(process.execPath, ["--require", preload, binPath, "envs", "new", "work"], { encoding: "utf8", env: f.env }); + assert.equal(failed.status, 1, failed.stderr); + assert.match(failed.stderr, /marker write failed/); + assert.equal(existsSync(join(f.home, ".gentle-shell", "envs", "work")), false, "no half-created env is left behind"); + const retried = run(f.env, ["envs", "new", "work"]); + assert.equal(retried.status, 0, retried.stderr); + assert.ok(existsSync(join(f.home, ".gentle-shell", "envs", "work", ".gentle-shell-home"))); +}); + test("--env never creates an unknown env and rejects invalid names", (t) => { const f = fixture(t); const unknown = run(f.env, ["--env", "nope"]); diff --git a/tests/shell-bar.test.ts b/tests/shell-bar.test.ts index 97ec82ab4..962eb12f7 100644 --- a/tests/shell-bar.test.ts +++ b/tests/shell-bar.test.ts @@ -887,6 +887,17 @@ test("minimal density hides the brand but still names the env", () => { assert.match(text, /^env work/); }); +test("regular-mode bar names the env right after the brand and keeps it when narrowed", () => { + const [line] = renderShellBar(model({ env: "work" }), plainTheme, 160); + assert.ok(line.startsWith("✿ gentle shell ⟡ env work ⟡ ~/work/gentle-pi main"), line); + const [narrow] = renderShellBar(model({ env: "work" }), plainTheme, 30); + assert.match(narrow, /env work/); + const [plain] = renderShellBar(model(), plainTheme, 160); + assert.doesNotMatch(plain, /env /); + const settings = { ...DEFAULT_VISUAL_SETTINGS, density: "minimal" as const }; + assert.match(renderShellBar(model({ env: "work" }), plainTheme, 160, settings)[0]!, /^env work/); +}); + test("sidebar Status card shows the env as its own group above Project", () => { const lines = renderShellSidebarBar(model({ env: "work" }), plainTheme, 60).map((line) => line.replace(/[│▎]/g, "").trim()); const env = lines.indexOf("Env"); From 96cd7249470d2a2af89e94b429edb7957931f3b3 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Wed, 14 Oct 2026 20:09:38 -0400 Subject: [PATCH 17/25] fix(launcher): roll back a failed env rename and keep '..'-prefixed named envs protected on uninstall A rename whose config.json write fails now moves the env back, so the default home and setup record never point at a missing dir. The uninstall overlap check treats only a whole `..` segment as parent traversal, so a named envs root such as `agent/..envs` is protected in the standard and bundled plans. Adds bundled tests for keeping the named envs in the prefix and refusing an overlapping removal. --- bin/gentle-shell.mjs | 10 +++++++- lib/gentle-shell-uninstall.ts | 3 ++- runtime/gentle-shell-uninstall.mjs | 3 ++- tests/gentle-shell-bin.test.ts | 17 +++++++++++++ tests/gentle-shell-uninstall.test.ts | 38 ++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 3 deletions(-) diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index aba22a723..5d7e388ba 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -832,7 +832,15 @@ function renameEnv(root, from, to) { const aliases = [...new Set([oldDir, safeRealpath(oldDir)])]; renameSync(oldDir, newDir); const configPath = resolveConfigPath(); - writeRawConfig(configPath, renameEnvInConfig(readRawConfig(configPath), aliases, newDir, safeRealpath(newDir))); + try { + writeRawConfig(configPath, renameEnvInConfig(readRawConfig(configPath), aliases, newDir, safeRealpath(newDir))); + } catch (error) { + // config.json still names the old dir (default home, setup record): put + // the env back there rather than leave a launch bootstrapping an empty + // home at the old path and the renamed env provisioning again. + renameSync(newDir, oldDir); + throw error; + } } function deleteEnv(root, name) { diff --git a/lib/gentle-shell-uninstall.ts b/lib/gentle-shell-uninstall.ts index aca2041db..ffc765744 100644 --- a/lib/gentle-shell-uninstall.ts +++ b/lib/gentle-shell-uninstall.ts @@ -142,7 +142,8 @@ function canonical(path: string): string { /** True when `child` is `parent` itself or lies inside it. */ function within(parent: string, child: string, platform: string): boolean { const path = relative(comparable(parent, platform), comparable(child, platform)); - return path === "" || (!path.startsWith("..") && !isAbsolute(path)); + // Only a whole `..` segment is parent traversal; `..envs` is a child name. + return path === "" || (!/^\.\.(?:[\\/]|$)/.test(path) && !isAbsolute(path)); } type Inspection = "ok" | "missing" | "symlink" | "other"; diff --git a/runtime/gentle-shell-uninstall.mjs b/runtime/gentle-shell-uninstall.mjs index d0ad58dc1..5810fba07 100644 --- a/runtime/gentle-shell-uninstall.mjs +++ b/runtime/gentle-shell-uninstall.mjs @@ -143,7 +143,8 @@ function canonical(path ) { /** True when `child` is `parent` itself or lies inside it. */ function within(parent , child , platform ) { const path = relative(comparable(parent, platform), comparable(child, platform)); - return path === "" || (!path.startsWith("..") && !isAbsolute(path)); + // Only a whole `..` segment is parent traversal; `..envs` is a child name. + return path === "" || (!/^\.\.(?:[\\/]|$)/.test(path) && !isAbsolute(path)); } diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index 5653ee86c..a494c85f3 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3631,6 +3631,23 @@ test("the envs manager renames, sets the default and deletes envs on disk", (t) assert.match(result.stdout, /\x1b\[\?1049l/, "the terminal is restored"); }); +test("the envs manager rolls a rename back when config.json cannot be written", { skip: (process.platform === "win32" || process.getuid?.() === 0) && "needs a non-root POSIX read-only dir" }, (t) => { + const f = fixture(t); + const envs = join(f.home, ".gentle-shell", "envs"); + assert.equal(run(f.env, ["envs", "new", "a"]).status, 0); + // A readable config in a read-only folder: the manager loads, every write fails. + const lockedDir = join(f.root, "locked"); + mkdirSync(lockedDir); + writeFileSync(join(lockedDir, "config.json"), "{}\n"); + chmodSync(lockedDir, 0o500); + let result; + // Unlocked before the fixture's own cleanup, which runs first. + try { result = runManager({ ...f.env, GENTLE_SHELL_CONFIG: join(lockedDir, "config.json") }, ["j", "r", "\x7f", "alpha", "\r", "q"].join("")); } + finally { chmodSync(lockedDir, 0o700); } + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(readdirSync(envs), ["a"], "the env keeps its old name, matching config.json"); +}); + test("the envs manager never lists or touches a symlinked env", (t) => { const f = fixture(t); const envs = join(f.home, ".gentle-shell", "envs"); diff --git a/tests/gentle-shell-uninstall.test.ts b/tests/gentle-shell-uninstall.test.ts index 67518b3cf..548c9411b 100644 --- a/tests/gentle-shell-uninstall.test.ts +++ b/tests/gentle-shell-uninstall.test.ts @@ -447,6 +447,17 @@ test("an isolated home inside the named envs is refused, never removed", async ( } finally { w.cleanup(); } }); +test("a named envs root overridden into the isolated home as a '..'-prefixed folder is refused, never removed", async () => { + const w = world(); + try { + // `..envs` is an ordinary child name, not parent traversal. + const envs = join(w.paths.isolated, "..envs"); + write(join(envs, "work", ".gentle-shell-home")); + await refused(w, { GENTLE_SHELL_ENVS_DIR: envs }, /your named envs/); + assert.equal(existsSync(join(envs, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + test("tools the installer may have added are listed with how to remove them, never removed", async () => { for (const platform of ["darwin", "win32"]) { const w = world(); @@ -954,6 +965,33 @@ test("bundled: a Pi home inside the prefix is protected, except the session's ow } finally { w.cleanup(); } }); +test("bundled: named envs in the prefix survive removing every other prefix entry", posixOnly, async () => { + const w = bundledWorld(); + try { + const env = join(w.prefix, "envs", "work", ".gentle-shell-home"); + write(env); + assert.equal(await w.uninstall(["--yes", "--include-shared"]), 0, w.err.join("\n")); + assert.equal(existsSync(env), true); + assert.deepEqual(readdirSync(w.prefix), ["envs"], "only the named envs keep the prefix"); + assert.doesNotMatch(section(w.out, "Kept:").join("\n"), /not written by the bundled install/); + assert.ok(section(w.out, "Not touched:").join("\n").includes(`${join(w.prefix, "envs")} (your named envs)`)); + } finally { w.cleanup(); } +}); + +test("bundled: a removal that overlaps the named envs, even a '..'-prefixed one, refuses the whole run", posixOnly, async () => { + for (const envs of [(w: BundledWorld) => join(w.prefix, "versions", "envs"), (w: BundledWorld) => join(w.shared.agent, "..envs")]) { + const w = bundledWorld(); + try { + const root = envs(w); + write(join(root, "work", ".gentle-shell-home")); + assert.equal(await w.uninstall(["--yes", "--include-shared"], { env: { GENTLE_SHELL_ENVS_DIR: root } }), 1, root); + assert.match(w.err.join("\n"), /your named envs/, root); + bundledUntouched(w); + assert.equal(existsSync(join(root, "work", ".gentle-shell-home")), true, root); + } finally { w.cleanup(); } + } +}); + test("bundled: in a terminal one question confirms, the shared home is asked separately and kept by default; EOF cancels and Ctrl-C aborts", posixOnly, async () => { const cancelled = bundledWorld(); try { From 8220a2aaa77a3713da7da2e06dc4b738a27d5164 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Wed, 14 Oct 2026 20:25:47 -0400 Subject: [PATCH 18/25] fix(shell): keep the env when the bar narrows and guard a symlinked named envs root on bundled uninstall When only the brand and the env fit, the regular-mode bar now drops the brand instead of the env. The bundled uninstall plan guards and lists the real target of a symlinked named envs root, as the standard plan already did, so a removal that holds that target refuses the whole run. --- lib/gentle-shell-uninstall.ts | 11 ++++++++--- lib/shell-bar.ts | 6 +++++- runtime/gentle-shell-uninstall.mjs | 11 ++++++++--- tests/gentle-shell-uninstall.test.ts | 14 ++++++++++++++ tests/shell-bar.test.ts | 2 ++ 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/lib/gentle-shell-uninstall.ts b/lib/gentle-shell-uninstall.ts index aca2041db..b8f747d4f 100644 --- a/lib/gentle-shell-uninstall.ts +++ b/lib/gentle-shell-uninstall.ts @@ -705,7 +705,12 @@ function planBundledSelfUninstall(input: BundledSelfUninstallInput): BundledPlan consider(join(shellHome, "agent"), "your sign-ins, chats, settings and extensions", "dir", plan.shared); consider(join(shellHome, "config.json"), "the launcher's settings", "file", plan.shared); const namedEnvs = envsRoot(env, homedir); - const hasNamedEnvs = inspect(namedEnvs, "dir") === "ok"; + const namedEnvsState = inspect(namedEnvs, "dir"); + const hasNamedEnvs = namedEnvsState === "ok"; + // A symlinked root still holds the user's envs: guard and list its real + // target too. Only a real folder counts as a known prefix entry; a link + // there stays foreign and so keeps the prefix. + const guardsNamedEnvs = hasNamedEnvs || (namedEnvsState === "symlink" && inspect(canonical(namedEnvs), "dir") === "ok"); const known = new Set([...BUNDLED_OWN.map((item) => item.name), ...(windows ? [BUNDLED_WINDOWS_MARKER.name] : []), ...(same(canonical(shellHome), real) ? ["agent", "config.json"] : []), // Named envs are user data that may share the prefix folder: kept, and @@ -718,7 +723,7 @@ function planBundledSelfUninstall(input: BundledSelfUninstallInput): BundledPlan } plan.removeRoot = !foreign; - if (hasNamedEnvs) guards.push({ path: namedEnvs, label: NAMED_ENVS_LABEL, inside: true }); + if (guardsNamedEnvs) guards.push({ path: namedEnvs, label: NAMED_ENVS_LABEL, inside: true }); for (const removal of [...plan.own, ...plan.shared]) { const reason = collision(canonical(removal.path), guards, platform); if (reason) plan.refusals.push(`${removal.path} would be removed, but it ${reason}; refusing to remove anything.`); @@ -726,7 +731,7 @@ function planBundledSelfUninstall(input: BundledSelfUninstallInput): BundledPlan plan.notTouched.push({ path: piHome, label: "your Pi home" }, { path: join(homedir, ".pi", "gentle-ai"), label: "Gentle AI's configuration for Pi" }, { path: join(homedir, ".gentle-ai"), label: "Gentle AI's own state" }, { path: join(homedir, ".engram"), label: "Engram's memory" }); - if (hasNamedEnvs) plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); + if (guardsNamedEnvs) plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); if (env.GENTLE_SHELL_HOME && !same(canonical(env.GENTLE_SHELL_HOME), canonical(join(shellHome, "agent")))) { plan.notTouched.push({ path: env.GENTLE_SHELL_HOME, label: "the Gentle Shell home GENTLE_SHELL_HOME names" }); } diff --git a/lib/shell-bar.ts b/lib/shell-bar.ts index af8f65c39..f0253efdc 100644 --- a/lib/shell-bar.ts +++ b/lib/shell-bar.ts @@ -529,8 +529,12 @@ export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width segments = buildSegments(compactModel(model), theme, presentation); left = joinSegments(segments, theme); } + const brandThenEnv = Boolean(model.env) && presentation?.density !== "minimal"; while (segments.length > 1 && visibleWidth(left) > width) { - segments.pop(); + // Down to brand and env, the brand gives way: the env names the home + // this session writes to. + if (brandThenEnv && segments.length === 2) segments.shift(); + else segments.pop(); left = joinSegments(segments, theme); } return [truncateToWidth(left, width, "…")]; diff --git a/runtime/gentle-shell-uninstall.mjs b/runtime/gentle-shell-uninstall.mjs index d0ad58dc1..7d939044a 100644 --- a/runtime/gentle-shell-uninstall.mjs +++ b/runtime/gentle-shell-uninstall.mjs @@ -706,7 +706,12 @@ function planBundledSelfUninstall(input ) consider(join(shellHome, "agent"), "your sign-ins, chats, settings and extensions", "dir", plan.shared); consider(join(shellHome, "config.json"), "the launcher's settings", "file", plan.shared); const namedEnvs = envsRoot(env, homedir); - const hasNamedEnvs = inspect(namedEnvs, "dir") === "ok"; + const namedEnvsState = inspect(namedEnvs, "dir"); + const hasNamedEnvs = namedEnvsState === "ok"; + // A symlinked root still holds the user's envs: guard and list its real + // target too. Only a real folder counts as a known prefix entry; a link + // there stays foreign and so keeps the prefix. + const guardsNamedEnvs = hasNamedEnvs || (namedEnvsState === "symlink" && inspect(canonical(namedEnvs), "dir") === "ok"); const known = new Set([...BUNDLED_OWN.map((item) => item.name), ...(windows ? [BUNDLED_WINDOWS_MARKER.name] : []), ...(same(canonical(shellHome), real) ? ["agent", "config.json"] : []), // Named envs are user data that may share the prefix folder: kept, and @@ -719,7 +724,7 @@ function planBundledSelfUninstall(input ) } plan.removeRoot = !foreign; - if (hasNamedEnvs) guards.push({ path: namedEnvs, label: NAMED_ENVS_LABEL, inside: true }); + if (guardsNamedEnvs) guards.push({ path: namedEnvs, label: NAMED_ENVS_LABEL, inside: true }); for (const removal of [...plan.own, ...plan.shared]) { const reason = collision(canonical(removal.path), guards, platform); if (reason) plan.refusals.push(`${removal.path} would be removed, but it ${reason}; refusing to remove anything.`); @@ -727,7 +732,7 @@ function planBundledSelfUninstall(input ) plan.notTouched.push({ path: piHome, label: "your Pi home" }, { path: join(homedir, ".pi", "gentle-ai"), label: "Gentle AI's configuration for Pi" }, { path: join(homedir, ".gentle-ai"), label: "Gentle AI's own state" }, { path: join(homedir, ".engram"), label: "Engram's memory" }); - if (hasNamedEnvs) plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); + if (guardsNamedEnvs) plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); if (env.GENTLE_SHELL_HOME && !same(canonical(env.GENTLE_SHELL_HOME), canonical(join(shellHome, "agent")))) { plan.notTouched.push({ path: env.GENTLE_SHELL_HOME, label: "the Gentle Shell home GENTLE_SHELL_HOME names" }); } diff --git a/tests/gentle-shell-uninstall.test.ts b/tests/gentle-shell-uninstall.test.ts index 67518b3cf..37c8a587d 100644 --- a/tests/gentle-shell-uninstall.test.ts +++ b/tests/gentle-shell-uninstall.test.ts @@ -954,6 +954,20 @@ test("bundled: a Pi home inside the prefix is protected, except the session's ow } finally { w.cleanup(); } }); +test("bundled: a symlinked named envs root whose target a removal holds refuses the whole run", posixOnly, async () => { + const w = bundledWorld(); + try { + const target = join(w.prefix, "versions", "envs"); + write(join(target, "work", ".gentle-shell-home")); + const link = join(w.base, "linked-envs"); + symlinkSync(target, link); + assert.equal(await w.uninstall(["--yes", "--include-shared"], { env: { GENTLE_SHELL_ENVS_DIR: link } }), 1); + assert.match(w.err.join("\n"), /your named envs/); + bundledUntouched(w); + assert.equal(existsSync(join(target, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + test("bundled: in a terminal one question confirms, the shared home is asked separately and kept by default; EOF cancels and Ctrl-C aborts", posixOnly, async () => { const cancelled = bundledWorld(); try { diff --git a/tests/shell-bar.test.ts b/tests/shell-bar.test.ts index 962eb12f7..049e9a0db 100644 --- a/tests/shell-bar.test.ts +++ b/tests/shell-bar.test.ts @@ -892,6 +892,8 @@ test("regular-mode bar names the env right after the brand and keeps it when nar assert.ok(line.startsWith("✿ gentle shell ⟡ env work ⟡ ~/work/gentle-pi main"), line); const [narrow] = renderShellBar(model({ env: "work" }), plainTheme, 30); assert.match(narrow, /env work/); + // Narrower than brand plus env: the brand gives way, never the env. + for (const width of [12, 20]) assert.equal(renderShellBar(model({ env: "work" }), plainTheme, width)[0], "env work", `width ${width}`); const [plain] = renderShellBar(model(), plainTheme, 160); assert.doesNotMatch(plain, /env /); const settings = { ...DEFAULT_VISUAL_SETTINGS, density: "minimal" as const }; From 17d905cc41de4416819a28e7b112bae3f63a2d96 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Sun, 18 Oct 2026 17:14:31 -0400 Subject: [PATCH 19/25] fix(launcher): accept --env only for a real env directory A file or a symlink at / passed the existsSync check and reached startup as the Pi home. --env now uses the same rule as the envs list: only a real directory counts as an env. --- bin/gentle-shell.mjs | 12 +++++++++++- tests/gentle-shell-bin.test.ts | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index fdad49820..5ec09049d 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -702,6 +702,14 @@ function currentEnvsRoot() { return envsRoot(process.env, homedir()); } +function isRealDirectory(path) { + try { + return lstatSync(path).isDirectory(); + } catch { + return false; + } +} + function listEnvNames(root) { let entries; try { @@ -2083,7 +2091,9 @@ async function main() { if (home.mode === "path") home = { ...home, dir: resolvePath(home.dir) }; // An explicit --env never creates a home: a typo must not silently become // a new, empty env. A persisted default keeps the ordinary bootstrap. - if (args.env !== undefined && !existsSync(home.dir)) { + // Like the envs list, only a real directory (never a file or a symlink) + // counts as an env. + if (args.env !== undefined && !isRealDirectory(home.dir)) { fail(`gentle-shell: unknown env "${args.env}" (${home.dir}).\nCreate it with 'gentle-shell envs new ${args.env}' or manage envs with 'gentle-shell envs'.`, 2); } diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index 35ce095d2..23a05f671 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3556,6 +3556,23 @@ test("--env never creates an unknown env and rejects invalid names", (t) => { assert.equal(run(f.env, ["envs", "new", "dup"]).status, 1); }); +test("--env accepts only a real env directory, never a file or a symlink", (t) => { + const f = fixture(t); + const root = join(f.home, ".gentle-shell", "envs"); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "file"), "not an env\n"); + const target = join(f.root, "elsewhere"); + mkdirSync(target); + symlinkSync(target, join(root, "link"), "dir"); + for (const name of ["file", "link"]) { + const result = run(f.env, ["--env", name]); + assert.equal(result.status, 2, `${name}: ${result.stderr}`); + assert.match(result.stderr, new RegExp(`unknown env "${name}"`)); + } + assert.equal(readFileSync(join(root, "file"), "utf8"), "not an env\n"); + assert.deepEqual(readdirSync(target), []); +}); + test("envs without a TTY lists envs, marks the default, and honors GENTLE_SHELL_ENVS_DIR", (t) => { const f = fixture(t); const envsDir = join(f.root, "custom-envs"); From 74402df3bd8aacd5187ffb0429f3f084280d1370 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Sun, 18 Oct 2026 17:14:31 -0400 Subject: [PATCH 20/25] fix(uninstall): refuse to remove a folder that holds the named envs symlink When GENTLE_SHELL_ENVS_DIR is a symlink inside a removed folder and its target lies outside, guarding the target was not enough: removal deleted the link and broke the configured path. Both planners now also refuse a removal that contains the symlink entry itself. --- lib/gentle-shell-uninstall.ts | 18 +++++++++++++++++- runtime/gentle-shell-uninstall.mjs | 18 +++++++++++++++++- tests/gentle-shell-uninstall.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/lib/gentle-shell-uninstall.ts b/lib/gentle-shell-uninstall.ts index b8f747d4f..e0ae045e7 100644 --- a/lib/gentle-shell-uninstall.ts +++ b/lib/gentle-shell-uninstall.ts @@ -122,6 +122,15 @@ function comparable(path: string, platform: string): string { return platform === "win32" ? path.toLowerCase() : path; } +/** + * Where a symlinked path's own link entry lives: its parent resolved, its name + * kept. Removing a folder that holds this entry breaks the configured path even + * when the link's target is guarded elsewhere. + */ +function symlinkEntry(path: string, state: Inspection): string | undefined { + return state === "symlink" ? join(canonical(dirname(resolve(path))), basename(path)) : undefined; +} + /** The real path, resolving the nearest existing ancestor when `path` does not exist. */ function canonical(path: string): string { const absolute = resolve(path); @@ -372,8 +381,12 @@ async function planSelfUninstall(input: SelfUninstallInput, configHome: string, // Nothing is both removed and not touched: a removal that is, holds or lies // inside a path listed as not touched refuses the whole run. if (plan.refusals.length === 0) { + const namedEnvsEntry = namedEnvsReal !== undefined ? symlinkEntry(namedEnvs, namedEnvsState) : undefined; for (const removal of [...plan.own, ...plan.shared]) { const real = canonical(removal.path); + if (namedEnvsEntry !== undefined && within(real, namedEnvsEntry, platform)) { + plan.refusals.push(`${removal.path} would be removed, but it overlaps ${namedEnvs} (${NAMED_ENVS_LABEL}), which is not touched; refusing to remove anything.`); + } for (const kept of plan.notTouched) { if (kept.path === undefined) continue; const target = canonical(kept.path); @@ -724,8 +737,11 @@ function planBundledSelfUninstall(input: BundledSelfUninstallInput): BundledPlan plan.removeRoot = !foreign; if (guardsNamedEnvs) guards.push({ path: namedEnvs, label: NAMED_ENVS_LABEL, inside: true }); + const namedEnvsEntry = guardsNamedEnvs ? symlinkEntry(namedEnvs, namedEnvsState) : undefined; for (const removal of [...plan.own, ...plan.shared]) { - const reason = collision(canonical(removal.path), guards, platform); + const real = canonical(removal.path); + const reason = collision(real, guards, platform) + ?? (namedEnvsEntry !== undefined && within(real, namedEnvsEntry, platform) ? `contains ${namedEnvs} (${NAMED_ENVS_LABEL})` : undefined); if (reason) plan.refusals.push(`${removal.path} would be removed, but it ${reason}; refusing to remove anything.`); } diff --git a/runtime/gentle-shell-uninstall.mjs b/runtime/gentle-shell-uninstall.mjs index 7d939044a..0102da6fc 100644 --- a/runtime/gentle-shell-uninstall.mjs +++ b/runtime/gentle-shell-uninstall.mjs @@ -123,6 +123,15 @@ function comparable(path , platform ) { return platform === "win32" ? path.toLowerCase() : path; } +/** + * Where a symlinked path's own link entry lives: its parent resolved, its name + * kept. Removing a folder that holds this entry breaks the configured path even + * when the link's target is guarded elsewhere. + */ +function symlinkEntry(path , state ) { + return state === "symlink" ? join(canonical(dirname(resolve(path))), basename(path)) : undefined; +} + /** The real path, resolving the nearest existing ancestor when `path` does not exist. */ function canonical(path ) { const absolute = resolve(path); @@ -373,8 +382,12 @@ async function planSelfUninstall(input , configHome , // Nothing is both removed and not touched: a removal that is, holds or lies // inside a path listed as not touched refuses the whole run. if (plan.refusals.length === 0) { + const namedEnvsEntry = namedEnvsReal !== undefined ? symlinkEntry(namedEnvs, namedEnvsState) : undefined; for (const removal of [...plan.own, ...plan.shared]) { const real = canonical(removal.path); + if (namedEnvsEntry !== undefined && within(real, namedEnvsEntry, platform)) { + plan.refusals.push(`${removal.path} would be removed, but it overlaps ${namedEnvs} (${NAMED_ENVS_LABEL}), which is not touched; refusing to remove anything.`); + } for (const kept of plan.notTouched) { if (kept.path === undefined) continue; const target = canonical(kept.path); @@ -725,8 +738,11 @@ function planBundledSelfUninstall(input ) plan.removeRoot = !foreign; if (guardsNamedEnvs) guards.push({ path: namedEnvs, label: NAMED_ENVS_LABEL, inside: true }); + const namedEnvsEntry = guardsNamedEnvs ? symlinkEntry(namedEnvs, namedEnvsState) : undefined; for (const removal of [...plan.own, ...plan.shared]) { - const reason = collision(canonical(removal.path), guards, platform); + const real = canonical(removal.path); + const reason = collision(real, guards, platform) + ?? (namedEnvsEntry !== undefined && within(real, namedEnvsEntry, platform) ? `contains ${namedEnvs} (${NAMED_ENVS_LABEL})` : undefined); if (reason) plan.refusals.push(`${removal.path} would be removed, but it ${reason}; refusing to remove anything.`); } diff --git a/tests/gentle-shell-uninstall.test.ts b/tests/gentle-shell-uninstall.test.ts index 37c8a587d..c2e027855 100644 --- a/tests/gentle-shell-uninstall.test.ts +++ b/tests/gentle-shell-uninstall.test.ts @@ -436,6 +436,19 @@ test("a symlinked named envs root is still listed as not touched and kept", posi } finally { w.cleanup(); } }); +test("a named envs root symlink inside a removed home is refused even when its target is outside", posixOnly, async () => { + const w = world(); + try { + const real = join(w.root, "real-envs"); + write(join(real, "work", ".gentle-shell-home")); + const link = join(w.isolated, "envs"); + symlinkSync(real, link); + await refused(w, { GENTLE_SHELL_ENVS_DIR: link }, /your named envs/); + assert.equal(lstatSync(link).isSymbolicLink(), true); + assert.equal(existsSync(join(real, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + test("an isolated home inside the named envs is refused, never removed", async () => { const w = world(); try { @@ -968,6 +981,21 @@ test("bundled: a symlinked named envs root whose target a removal holds refuses } finally { w.cleanup(); } }); +test("bundled: a named envs root symlink inside a removal refuses the whole run even when its target is outside", posixOnly, async () => { + const w = bundledWorld(); + try { + const target = join(w.base, "real-envs"); + write(join(target, "work", ".gentle-shell-home")); + const link = join(w.prefix, "versions", "envs"); + symlinkSync(target, link); + assert.equal(await w.uninstall(["--yes", "--include-shared"], { env: { GENTLE_SHELL_ENVS_DIR: link } }), 1); + assert.match(w.err.join("\n"), /your named envs/); + bundledUntouched(w); + assert.equal(lstatSync(link).isSymbolicLink(), true); + assert.equal(existsSync(join(target, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + test("bundled: in a terminal one question confirms, the shared home is asked separately and kept by default; EOF cancels and Ctrl-C aborts", posixOnly, async () => { const cancelled = bundledWorld(); try { From f72f3b99f59f60a49c1b995c2cae155691e332d6 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Sun, 18 Oct 2026 17:15:58 -0400 Subject: [PATCH 21/25] fix(shell-bar): keep the env over the brand in the narrowest header When the header had room for the env but not for brand plus env, the final fallback showed the brand alone and the active env disappeared. It now prefers the env when it fits, as the compact bar does. --- lib/shell-bar.ts | 7 ++++++- tests/shell-bar.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/shell-bar.ts b/lib/shell-bar.ts index 44aab340e..16cfb35f8 100644 --- a/lib/shell-bar.ts +++ b/lib/shell-bar.ts @@ -424,7 +424,12 @@ function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: num const usageStart = visibleWidth(left) + (targetWidth - visibleWidth(left) - visibleWidth(right)) + visibleWidth(ctxCost) + visibleWidth(` ${SHELL_BAR_SEPARATOR} `); return { text, usageSpan: { start: usageStart, end: usageStart + visibleWidth(usageText) } }; } - const brand = presentation?.density === "minimal" ? "" : theme.fg(ROLE.BRAND, theme.bold(HEADER_BRAND)); + // Narrower than brand plus env: the brand gives way, never the env, as in + // the compact bar. Without an env, or when even it does not fit, the brand + // (hidden in minimal density) is the last thing left. + const envOnly = model.env ? `${theme.fg(ROLE.LABEL, "env")} ${theme.fg(ROLE.VALUE, theme.bold(sanitizeStatus(model.env)))}` : ""; + const brand = envOnly && visibleWidth(envOnly) <= targetWidth ? envOnly + : presentation?.density === "minimal" ? "" : theme.fg(ROLE.BRAND, theme.bold(HEADER_BRAND)); if (jobs && visibleWidth(jobs) <= targetWidth) { const left = visibleWidth(brand) + RIGHT_PADDING + visibleWidth(jobs) <= targetWidth ? brand : ""; return { text: left + " ".repeat(targetWidth - visibleWidth(left) - visibleWidth(jobs)) + jobs }; diff --git a/tests/shell-bar.test.ts b/tests/shell-bar.test.ts index 8f904893f..065d4a62a 100644 --- a/tests/shell-bar.test.ts +++ b/tests/shell-bar.test.ts @@ -887,6 +887,17 @@ test("renderShellHeaderBar keeps the env after profile, effort and location are } }); +test("renderShellHeaderBar keeps the env over the brand when only one of them fits", () => { + const header = buildShellHeaderModel(model({ env: "work", profile: "team" })); + for (const width of [12, 16, 24]) { + const { text } = renderShellHeaderBar(header, plainTheme, width); + assert.ok(visibleWidth(text) <= width, `width ${width}`); + assert.match(text, /^env work/, `width ${width}: ${text}`); + } + // Without an env the narrow header still shows the brand. + assert.match(renderShellHeaderBar(buildShellHeaderModel(model()), plainTheme, 16).text, /^✿ Gentle Shell/); +}); + test("minimal density hides the brand but still names the env", () => { const settings = { ...DEFAULT_VISUAL_SETTINGS, density: "minimal" as const }; const { text } = renderShellHeaderBar(buildShellHeaderModel(model({ env: "work" })), plainTheme, 140, undefined, settings); From 486787b1b9e609a0ec8a02bffb48ed1d795f24e7 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Sun, 18 Oct 2026 17:53:09 -0400 Subject: [PATCH 22/25] fix(uninstall): guard every symlink on the named envs path and '..'-named folders within() read a child folder named like '..envs' as outside its parent, so a removal could hold the named envs unseen. Only a whole '..' segment now leaves the parent. The link guard looked only at the root's last segment, and a trailing slash made lstat follow a symlinked root. The root is now resolved first, and every symlink on its path (the root or any folder above it) is guarded, so removing a folder that holds one is refused. --- lib/gentle-shell-uninstall.ts | 36 ++++++++++------ runtime/gentle-shell-uninstall.mjs | 36 ++++++++++------ tests/gentle-shell-uninstall.test.ts | 61 ++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 24 deletions(-) diff --git a/lib/gentle-shell-uninstall.ts b/lib/gentle-shell-uninstall.ts index e0ae045e7..94d2a274f 100644 --- a/lib/gentle-shell-uninstall.ts +++ b/lib/gentle-shell-uninstall.ts @@ -123,12 +123,20 @@ function comparable(path: string, platform: string): string { } /** - * Where a symlinked path's own link entry lives: its parent resolved, its name - * kept. Removing a folder that holds this entry breaks the configured path even - * when the link's target is guarded elsewhere. + * Where each symlink on the way to `path` lives: the root's own link and any + * symlinked folder above it, each as its resolved parent plus its name. + * Removing a folder that holds one of these breaks the configured path even + * when the real target is guarded elsewhere. */ -function symlinkEntry(path: string, state: Inspection): string | undefined { - return state === "symlink" ? join(canonical(dirname(resolve(path))), basename(path)) : undefined; +function symlinkEntries(path: string): string[] { + const entries: string[] = []; + let current = resolve(path); + for (let parent = dirname(current); parent !== current; current = parent, parent = dirname(current)) { + let link = false; + try { link = lstatSync(current).isSymbolicLink(); } catch { /* missing: nothing to break */ } + if (link) entries.push(join(canonical(parent), basename(current))); + } + return entries; } /** The real path, resolving the nearest existing ancestor when `path` does not exist. */ @@ -151,7 +159,9 @@ function canonical(path: string): string { /** True when `child` is `parent` itself or lies inside it. */ function within(parent: string, child: string, platform: string): boolean { const path = relative(comparable(parent, platform), comparable(child, platform)); - return path === "" || (!path.startsWith("..") && !isAbsolute(path)); + // Only a whole ".." segment leaves the parent: a child folder may itself + // be named "..envs". + return path === "" || (!/^\.\.(?:[\\/]|$)/.test(path) && !isAbsolute(path)); } type Inspection = "ok" | "missing" | "symlink" | "other"; @@ -357,7 +367,8 @@ async function planSelfUninstall(input: SelfUninstallInput, configHome: string, plan.notTouched.push({ path: piHome, label: "your Pi home" }); // Named envs are user data: their root is listed once, never each env as a // custom --home home, and the overlap check below protects all of them. - const namedEnvs = envsRoot(env, homedir); + // Resolved: a trailing slash would make lstat follow a symlinked root. + const namedEnvs = resolve(envsRoot(env, homedir)); // A symlinked root still holds the user's envs: protect its real target too. const namedEnvsState = inspect(namedEnvs, "dir"); const namedEnvsReal = namedEnvsState === "ok" || (namedEnvsState === "symlink" && inspect(canonical(namedEnvs), "dir") === "ok") ? canonical(namedEnvs) : undefined; @@ -381,10 +392,10 @@ async function planSelfUninstall(input: SelfUninstallInput, configHome: string, // Nothing is both removed and not touched: a removal that is, holds or lies // inside a path listed as not touched refuses the whole run. if (plan.refusals.length === 0) { - const namedEnvsEntry = namedEnvsReal !== undefined ? symlinkEntry(namedEnvs, namedEnvsState) : undefined; + const namedEnvsLinks = namedEnvsReal !== undefined ? symlinkEntries(namedEnvs) : []; for (const removal of [...plan.own, ...plan.shared]) { const real = canonical(removal.path); - if (namedEnvsEntry !== undefined && within(real, namedEnvsEntry, platform)) { + if (namedEnvsLinks.some((entry) => within(real, entry, platform))) { plan.refusals.push(`${removal.path} would be removed, but it overlaps ${namedEnvs} (${NAMED_ENVS_LABEL}), which is not touched; refusing to remove anything.`); } for (const kept of plan.notTouched) { @@ -717,7 +728,8 @@ function planBundledSelfUninstall(input: BundledSelfUninstallInput): BundledPlan if (windows) consider(join(root, BUNDLED_WINDOWS_MARKER.name), "the bundled install's folder marker", "file", plan.own); consider(join(shellHome, "agent"), "your sign-ins, chats, settings and extensions", "dir", plan.shared); consider(join(shellHome, "config.json"), "the launcher's settings", "file", plan.shared); - const namedEnvs = envsRoot(env, homedir); + // Resolved: a trailing slash would make lstat follow a symlinked root. + const namedEnvs = resolve(envsRoot(env, homedir)); const namedEnvsState = inspect(namedEnvs, "dir"); const hasNamedEnvs = namedEnvsState === "ok"; // A symlinked root still holds the user's envs: guard and list its real @@ -737,11 +749,11 @@ function planBundledSelfUninstall(input: BundledSelfUninstallInput): BundledPlan plan.removeRoot = !foreign; if (guardsNamedEnvs) guards.push({ path: namedEnvs, label: NAMED_ENVS_LABEL, inside: true }); - const namedEnvsEntry = guardsNamedEnvs ? symlinkEntry(namedEnvs, namedEnvsState) : undefined; + const namedEnvsLinks = guardsNamedEnvs ? symlinkEntries(namedEnvs) : []; for (const removal of [...plan.own, ...plan.shared]) { const real = canonical(removal.path); const reason = collision(real, guards, platform) - ?? (namedEnvsEntry !== undefined && within(real, namedEnvsEntry, platform) ? `contains ${namedEnvs} (${NAMED_ENVS_LABEL})` : undefined); + ?? (namedEnvsLinks.some((entry) => within(real, entry, platform)) ? `contains ${namedEnvs} (${NAMED_ENVS_LABEL})` : undefined); if (reason) plan.refusals.push(`${removal.path} would be removed, but it ${reason}; refusing to remove anything.`); } diff --git a/runtime/gentle-shell-uninstall.mjs b/runtime/gentle-shell-uninstall.mjs index 0102da6fc..83858acf4 100644 --- a/runtime/gentle-shell-uninstall.mjs +++ b/runtime/gentle-shell-uninstall.mjs @@ -124,12 +124,20 @@ function comparable(path , platform ) { } /** - * Where a symlinked path's own link entry lives: its parent resolved, its name - * kept. Removing a folder that holds this entry breaks the configured path even - * when the link's target is guarded elsewhere. + * Where each symlink on the way to `path` lives: the root's own link and any + * symlinked folder above it, each as its resolved parent plus its name. + * Removing a folder that holds one of these breaks the configured path even + * when the real target is guarded elsewhere. */ -function symlinkEntry(path , state ) { - return state === "symlink" ? join(canonical(dirname(resolve(path))), basename(path)) : undefined; +function symlinkEntries(path ) { + const entries = []; + let current = resolve(path); + for (let parent = dirname(current); parent !== current; current = parent, parent = dirname(current)) { + let link = false; + try { link = lstatSync(current).isSymbolicLink(); } catch { /* missing: nothing to break */ } + if (link) entries.push(join(canonical(parent), basename(current))); + } + return entries; } /** The real path, resolving the nearest existing ancestor when `path` does not exist. */ @@ -152,7 +160,9 @@ function canonical(path ) { /** True when `child` is `parent` itself or lies inside it. */ function within(parent , child , platform ) { const path = relative(comparable(parent, platform), comparable(child, platform)); - return path === "" || (!path.startsWith("..") && !isAbsolute(path)); + // Only a whole ".." segment leaves the parent: a child folder may itself + // be named "..envs". + return path === "" || (!/^\.\.(?:[\\/]|$)/.test(path) && !isAbsolute(path)); } @@ -358,7 +368,8 @@ async function planSelfUninstall(input , configHome , plan.notTouched.push({ path: piHome, label: "your Pi home" }); // Named envs are user data: their root is listed once, never each env as a // custom --home home, and the overlap check below protects all of them. - const namedEnvs = envsRoot(env, homedir); + // Resolved: a trailing slash would make lstat follow a symlinked root. + const namedEnvs = resolve(envsRoot(env, homedir)); // A symlinked root still holds the user's envs: protect its real target too. const namedEnvsState = inspect(namedEnvs, "dir"); const namedEnvsReal = namedEnvsState === "ok" || (namedEnvsState === "symlink" && inspect(canonical(namedEnvs), "dir") === "ok") ? canonical(namedEnvs) : undefined; @@ -382,10 +393,10 @@ async function planSelfUninstall(input , configHome , // Nothing is both removed and not touched: a removal that is, holds or lies // inside a path listed as not touched refuses the whole run. if (plan.refusals.length === 0) { - const namedEnvsEntry = namedEnvsReal !== undefined ? symlinkEntry(namedEnvs, namedEnvsState) : undefined; + const namedEnvsLinks = namedEnvsReal !== undefined ? symlinkEntries(namedEnvs) : []; for (const removal of [...plan.own, ...plan.shared]) { const real = canonical(removal.path); - if (namedEnvsEntry !== undefined && within(real, namedEnvsEntry, platform)) { + if (namedEnvsLinks.some((entry) => within(real, entry, platform))) { plan.refusals.push(`${removal.path} would be removed, but it overlaps ${namedEnvs} (${NAMED_ENVS_LABEL}), which is not touched; refusing to remove anything.`); } for (const kept of plan.notTouched) { @@ -718,7 +729,8 @@ function planBundledSelfUninstall(input ) if (windows) consider(join(root, BUNDLED_WINDOWS_MARKER.name), "the bundled install's folder marker", "file", plan.own); consider(join(shellHome, "agent"), "your sign-ins, chats, settings and extensions", "dir", plan.shared); consider(join(shellHome, "config.json"), "the launcher's settings", "file", plan.shared); - const namedEnvs = envsRoot(env, homedir); + // Resolved: a trailing slash would make lstat follow a symlinked root. + const namedEnvs = resolve(envsRoot(env, homedir)); const namedEnvsState = inspect(namedEnvs, "dir"); const hasNamedEnvs = namedEnvsState === "ok"; // A symlinked root still holds the user's envs: guard and list its real @@ -738,11 +750,11 @@ function planBundledSelfUninstall(input ) plan.removeRoot = !foreign; if (guardsNamedEnvs) guards.push({ path: namedEnvs, label: NAMED_ENVS_LABEL, inside: true }); - const namedEnvsEntry = guardsNamedEnvs ? symlinkEntry(namedEnvs, namedEnvsState) : undefined; + const namedEnvsLinks = guardsNamedEnvs ? symlinkEntries(namedEnvs) : []; for (const removal of [...plan.own, ...plan.shared]) { const real = canonical(removal.path); const reason = collision(real, guards, platform) - ?? (namedEnvsEntry !== undefined && within(real, namedEnvsEntry, platform) ? `contains ${namedEnvs} (${NAMED_ENVS_LABEL})` : undefined); + ?? (namedEnvsLinks.some((entry) => within(real, entry, platform)) ? `contains ${namedEnvs} (${NAMED_ENVS_LABEL})` : undefined); if (reason) plan.refusals.push(`${removal.path} would be removed, but it ${reason}; refusing to remove anything.`); } diff --git a/tests/gentle-shell-uninstall.test.ts b/tests/gentle-shell-uninstall.test.ts index c2e027855..853858891 100644 --- a/tests/gentle-shell-uninstall.test.ts +++ b/tests/gentle-shell-uninstall.test.ts @@ -436,6 +436,41 @@ test("a symlinked named envs root is still listed as not touched and kept", posi } finally { w.cleanup(); } }); +test("a named envs root reached through a symlinked folder inside a removed home is refused", posixOnly, async () => { + const w = world(); + try { + const real = join(w.root, "real-parent"); + write(join(real, "envs", "work", ".gentle-shell-home")); + const alias = join(w.isolated, "alias"); + symlinkSync(real, alias); + await refused(w, { GENTLE_SHELL_ENVS_DIR: join(alias, "envs") }, /your named envs/); + assert.equal(lstatSync(alias).isSymbolicLink(), true); + assert.equal(existsSync(join(real, "envs", "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + +test("a named envs root symlink written with a trailing slash is still guarded as a link", posixOnly, async () => { + const w = world(); + try { + const real = join(w.root, "real-envs"); + write(join(real, "work", ".gentle-shell-home")); + const link = join(w.isolated, "envs"); + symlinkSync(real, link); + await refused(w, { GENTLE_SHELL_ENVS_DIR: `${link}/` }, /your named envs/); + assert.equal(lstatSync(link).isSymbolicLink(), true); + } finally { w.cleanup(); } +}); + +test("a named envs root named like '..envs' inside a removed home is still inside it", async () => { + const w = world(); + try { + const envs = join(w.isolated, "..envs"); + write(join(envs, "work", ".gentle-shell-home")); + await refused(w, { GENTLE_SHELL_ENVS_DIR: envs }, /your named envs/); + assert.equal(existsSync(join(envs, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + test("a named envs root symlink inside a removed home is refused even when its target is outside", posixOnly, async () => { const w = world(); try { @@ -981,6 +1016,32 @@ test("bundled: a symlinked named envs root whose target a removal holds refuses } finally { w.cleanup(); } }); +test("bundled: a named envs root reached through a symlinked folder inside a removal refuses the whole run", posixOnly, async () => { + const w = bundledWorld(); + try { + const real = join(w.base, "real-parent"); + write(join(real, "envs", "work", ".gentle-shell-home")); + const alias = join(w.prefix, "versions", "alias"); + symlinkSync(real, alias); + assert.equal(await w.uninstall(["--yes", "--include-shared"], { env: { GENTLE_SHELL_ENVS_DIR: join(alias, "envs") } }), 1); + assert.match(w.err.join("\n"), /your named envs/); + bundledUntouched(w); + assert.equal(lstatSync(alias).isSymbolicLink(), true); + } finally { w.cleanup(); } +}); + +test("bundled: a named envs root named like '..envs' inside a removal refuses the whole run", async () => { + const w = bundledWorld(); + try { + const envs = join(w.prefix, "versions", "..envs"); + write(join(envs, "work", ".gentle-shell-home")); + assert.equal(await w.uninstall(["--yes", "--include-shared"], { env: { GENTLE_SHELL_ENVS_DIR: envs } }), 1); + assert.match(w.err.join("\n"), /your named envs/); + bundledUntouched(w); + assert.equal(existsSync(join(envs, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + test("bundled: a named envs root symlink inside a removal refuses the whole run even when its target is outside", posixOnly, async () => { const w = bundledWorld(); try { From 54bcd4ea6a9c9d708b4d43678e5fcf31f7ba7663 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Sun, 18 Oct 2026 17:54:27 -0400 Subject: [PATCH 23/25] test(shell-bar): cover the narrowest header keeping the env beside the job count --- tests/shell-bar.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/shell-bar.test.ts b/tests/shell-bar.test.ts index 065d4a62a..7f6041a31 100644 --- a/tests/shell-bar.test.ts +++ b/tests/shell-bar.test.ts @@ -895,7 +895,17 @@ test("renderShellHeaderBar keeps the env over the brand when only one of them fi assert.match(text, /^env work/, `width ${width}: ${text}`); } // Without an env the narrow header still shows the brand. - assert.match(renderShellHeaderBar(buildShellHeaderModel(model()), plainTheme, 16).text, /^✿ Gentle Shell/); + assert.match(renderShellHeaderBar(buildShellHeaderModel(model()), plainTheme, 16).text, /^\u273f Gentle Shell/); +}); + +test("the narrowest header keeps the env beside the running job count", () => { + const data = model({ env: "work", jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] }); + for (const width of [20, 24]) { + const text = renderShellHeaderBar(buildShellHeaderModel(data, true), plainTheme, width).text; + assert.ok(visibleWidth(text) <= width, `width ${width}: ${text}`); + assert.match(text, /^env work/, `width ${width}: ${text}`); + assert.match(text, /\u29d7 1 job$/, `width ${width}: ${text}`); + } }); test("minimal density hides the brand but still names the env", () => { From 4addd84ab3726cc652204e57ea92528cfba67fd4 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Sun, 18 Oct 2026 17:54:51 -0400 Subject: [PATCH 24/25] test(launcher): use a junction for the --env link case on Windows --- tests/gentle-shell-bin.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index 23a05f671..fc25eecf9 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3563,7 +3563,8 @@ test("--env accepts only a real env directory, never a file or a symlink", (t) = writeFileSync(join(root, "file"), "not an env\n"); const target = join(f.root, "elsewhere"); mkdirSync(target); - symlinkSync(target, join(root, "link"), "dir"); + // A junction on Windows: it needs no symlink privilege and lstat still reports a link. + symlinkSync(target, join(root, "link"), process.platform === "win32" ? "junction" : "dir"); for (const name of ["file", "link"]) { const result = run(f.env, ["--env", name]); assert.equal(result.status, 2, `${name}: ${result.stderr}`); From 42317f744b5703988de6238cfc42a652a2a6eafa Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Sun, 18 Oct 2026 21:09:30 -0400 Subject: [PATCH 25/25] test(uninstall): run the bundled '..envs' case only where the fixture can create symlinks --- tests/gentle-shell-uninstall.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/gentle-shell-uninstall.test.ts b/tests/gentle-shell-uninstall.test.ts index 853858891..39d319300 100644 --- a/tests/gentle-shell-uninstall.test.ts +++ b/tests/gentle-shell-uninstall.test.ts @@ -1030,7 +1030,7 @@ test("bundled: a named envs root reached through a symlinked folder inside a rem } finally { w.cleanup(); } }); -test("bundled: a named envs root named like '..envs' inside a removal refuses the whole run", async () => { +test("bundled: a named envs root named like '..envs' inside a removal refuses the whole run", posixOnly, async () => { const w = bundledWorld(); try { const envs = join(w.prefix, "versions", "..envs");