From 9d9033d3b0a815ccf90759ea6a6ab377d49560a6 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 12:21:37 -0400 Subject: [PATCH 01/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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 d5a32ebaae6e436d4cf92668bd6db5e260be3d72 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 14:50:39 -0400 Subject: [PATCH 16/36] feat(config): resolve Gentle's config home per category for named envs Gentle's own configuration (normally ~/.pi/gentle-ai) is now resolved per category: models and profiles, persona, appearance, and behavior (lib/gentle-config-scope.ts). Models and profiles stay together because a profile is a snapshot of the routing and applying it rewrites models.json. Install-level files (update channel, dev binary) never move. Every config-home read names its category, and a drift-guard test fails when one does not. In a named env that owns a category, the launcher sets GENTLE_SHELL_ENV_CONFIG_HOME (/gentle-ai) and GENTLE_SHELL_ENV_OWN_CONFIG, which win over the bundled prefix and GENTLE_PI_CONFIG_HOME for that category only. Every other home clears both, so an outer session's scope never leaks. Without those variables every read resolves exactly as before, including the banner and the shell's profile reader keeping an empty GENTLE_PI_CONFIG_HOME as-is. Compared with HEAD, these suites show no new failures (18 pre-existing). --- extensions/gentle-agents.ts | 6 +- extensions/gentle-ai.ts | 39 +++++------ extensions/gentle-shell.ts | 24 ++++--- extensions/startup-banner.ts | 13 ++-- lib/agent-home.ts | 10 ++- lib/animation-policy.ts | 4 +- lib/auto-permission-runtime.ts | 2 +- lib/background-subagents-policy.ts | 2 +- lib/builtin-codemode-optout.ts | 6 +- lib/card-content-policy.ts | 4 +- lib/card-style-policy.ts | 4 +- lib/double-esc-cancel-policy.ts | 4 +- lib/gentle-config-scope.ts | 65 ++++++++++++++++++ lib/gentle-shell-launcher.ts | 14 ++++ lib/history-capture-policy.ts | 8 +-- lib/notification-policy.ts | 4 +- lib/permission-mode-policy.ts | 4 +- lib/vim-policy.ts | 4 +- lib/visual-customization-policy.ts | 4 +- lib/visual-profiles.ts | 4 +- lib/yolo-session-policy.ts | 2 +- runtime/gentle-config-scope.mjs | 66 +++++++++++++++++++ runtime/gentle-shell-launcher.mjs | 14 ++++ scripts/build-runtime-modules.mjs | 1 + scripts/verify-package-files.mjs | 2 + tests/gentle-config-scope.test.ts | 102 +++++++++++++++++++++++++++++ tests/gentle-shell-envs.test.ts | 23 +++++++ 27 files changed, 367 insertions(+), 68 deletions(-) create mode 100644 lib/gentle-config-scope.ts create mode 100644 runtime/gentle-config-scope.mjs create mode 100644 tests/gentle-config-scope.test.ts diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index ea0cf5493..d2b09564a 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -537,7 +537,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = let ui: ExtensionContext["ui"] | undefined; let host: { requestRender(): void } | undefined; let sidebarTui: TUI | undefined; - const visualHome = gentlePiConfigHome(env); + const visualHome = gentlePiConfigHome(env, "appearance"); let agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents; let stopVisualUpdates: (() => void) | undefined; let sessions: ExtensionContext["sessionManager"] | undefined; @@ -1614,7 +1614,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // bound session used to kill its own non-git writer mid-preparation because // admission still read only the pin/global layers). Slice 3b-ii: the same // read covers the frozen inherited profile and `follow` mode. - const profileConfigHome = gentlePiConfigHome(deps.env); + const profileConfigHome = gentlePiConfigHome(deps.env, "models"); const sessionProfile = resolveSessionProfile({ sessionId: ctx.sessionManager.getSessionId(), cwd: originalCwd, @@ -2315,7 +2315,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = freezeSessionProfileAtStartup({ sessionId: ctx.sessionManager.getSessionId(), cwd: ctx.sessionManager.getCwd(), - configHome: gentlePiConfigHome(deps.env), + configHome: gentlePiConfigHome(deps.env, "models"), resolveWorktree: deps.resolveWorktree, env: deps.env, }); diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index b17d961df..22b060221 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -42,6 +42,7 @@ import type { } from "@earendil-works/pi-coding-agent"; import { Key, Text, isKeyRelease, matchesKey, truncateToWidth, type KeybindingsManager, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui"; import { resolveGentlePiAgentHome, gentlePiConfigHome } from "../lib/agent-home.ts"; +import type { ConfigCategory } from "../lib/gentle-config-scope.ts"; import { BACKGROUND_SUBAGENTS_FILE, BACKGROUND_SUBAGENTS_SCHEMA, @@ -391,7 +392,7 @@ const DEFAULT_BACKGROUND_SUBAGENTS_RENDERING: BackgroundSubagentsRendering = { /** Write the global policy file, creating the config home when needed. */ function writeGlobalBackgroundSubagentsPolicy( policy: BackgroundSubagentsPolicy, - configHome: string = gentleAiConfigHome(), + configHome: string = gentleAiConfigHome("behavior"), ): string { const path = join(configHome, BACKGROUND_SUBAGENTS_FILE); mkdirSync(configHome, { recursive: true }); @@ -1617,7 +1618,7 @@ function loadRuntimeGuardrailsConfig( return { autonomousMode: true, guardedCommands: {} }; } - const configHome = options.gentlePiConfigHome ?? gentleAiConfigHome(); + const configHome = options.gentlePiConfigHome ?? gentleAiConfigHome("behavior"); const globalConfigPath = join(configHome, "runtime-guardrails.json"); const projectConfigPath = join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json"); @@ -1915,7 +1916,7 @@ function isReversibleYoloCommand(command: string, evaluation: GuardEvaluation): /** Preserve explicitly configured confirmations for the waivable keys present, even when legacy env/autonomy resolution ignores them. */ function yoloConfiguredRestriction(cwd: string, keys: readonly GuardedCommandKey[], options: LoadGuardrailsOptions = {}): GuardAction | undefined { let restriction: GuardAction | undefined; - for (const path of [join(options.gentlePiConfigHome ?? gentleAiConfigHome(), "runtime-guardrails.json"), join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) { + for (const path of [join(options.gentlePiConfigHome ?? gentleAiConfigHome("behavior"), "runtime-guardrails.json"), join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) { try { if (!existsSync(path)) continue; const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); @@ -1969,7 +1970,7 @@ async function confirmCommand( // ask/block rule or the independent data-loss confirmation. if (autoGate && !evaluation.dataLoss) { let explicit = false; - for (const path of [join(gentleAiConfigHome(), "runtime-guardrails.json"), join(ctx.cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) { + for (const path of [join(gentleAiConfigHome("behavior"), "runtime-guardrails.json"), join(ctx.cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) { try { if (!existsSync(path)) continue; const settings: unknown = JSON.parse(readFileSync(path, "utf8")); @@ -2040,16 +2041,16 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function gentleAiConfigHome(): string { - return gentlePiConfigHome(); +function gentleAiConfigHome(category: ConfigCategory): string { + return gentlePiConfigHome(undefined, category); } function modelConfigPath(_cwd: string): string { - return join(gentleAiConfigHome(), "models.json"); + return join(gentleAiConfigHome("models"), "models.json"); } function modelExportPath(_cwd: string): string { - return join(gentleAiConfigHome(), "models.export.json"); + return join(gentleAiConfigHome("models"), "models.export.json"); } const MODEL_EXPORT_KIND = "gentle-pi.agent_model_routing"; @@ -2064,7 +2065,7 @@ function projectPersonaConfigPath(cwd: string): string { } function personaConfigPath(_cwd: string): string { - return join(gentleAiConfigHome(), "persona.json"); + return join(gentleAiConfigHome("persona"), "persona.json"); } function readPersonaFile(path: string): PersonaMode | undefined { @@ -2315,7 +2316,7 @@ async function readMaterializedRoutingEntryAsync( * would use -- there is no second precedence rule here. */ function pinnedEffectiveModelConfig(cwd: string): AgentModelConfig | undefined { - const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() }); + const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome("models") }); return resolution === undefined ? undefined : cloneModelConfig(resolution.modelProfiles); } @@ -3657,9 +3658,9 @@ interface CurrentProfileTarget { */ function resolveCurrentProfileTarget( cwd: string, - store: ProfilesFileReadResult = readProfilesFileResult(profilesFilePath(gentleAiConfigHome())), + store: ProfilesFileReadResult = readProfilesFileResult(profilesFilePath(gentleAiConfigHome("models"))), ): CurrentProfileTarget | undefined { - const pin = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() }); + const pin = resolveProfilePin({ cwd, configHome: gentleAiConfigHome("models") }); if (pin) return { name: pin.profile, source: "pinned" }; if (store.status !== "valid" || store.file.active === undefined) return undefined; if (!hasOwnProfile(store.file.profiles, store.file.active)) return undefined; @@ -3678,7 +3679,7 @@ function describeCurrentProfileTarget(target: CurrentProfileTarget | undefined): * outcome here speaks only about the profile. */ function updateCurrentProfileFromSavedRouting(ctx: ExtensionContext, pi: ExtensionAPI): void { - const path = profilesFilePath(gentleAiConfigHome()); + const path = profilesFilePath(gentleAiConfigHome("models")); const read = readProfilesFileResult(path); if (read.status === "invalid") { ctx.ui.notify( @@ -3861,7 +3862,7 @@ function followRenamedPin( * names exactly what a launch would use. */ function profilePinScopeNote(cwd: string): string | undefined { - const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() }); + const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome("models") }); if (!resolution) return undefined; return sanitizeTerminalText( `This repository pins profile "${resolution.profile}" (${resolution.source} pin at ${resolution.path}), so its subagent launches resolve that profile instead of the global routing. Change or remove the pin with /gentle:profiles (p or P).`, @@ -4441,7 +4442,7 @@ async function runProfilesPanelAction( // pin wins, applying is repo-scoped: re-pin this clone and write no global // routing, no materialized stores, and no orchestrator. The committed // declaration is never rewritten behind a commit. - const pinResolution = resolveProfilePin({ cwd: ctx.cwd, configHome: gentleAiConfigHome() }); + const pinResolution = resolveProfilePin({ cwd: ctx.cwd, configHome: gentleAiConfigHome("models") }); if (pinResolution) { const localPath = pinResolution.status.localPath; let pinNote: string; @@ -4914,7 +4915,7 @@ async function runProfilesPanelAction( } case "export": { if (!hasOwnProfile(file.profiles, result.name)) return file; - const exportPath = profileExportPath(gentleAiConfigHome()); + const exportPath = profileExportPath(gentleAiConfigHome("models")); try { const text = serializeProfileExport(result.name, file.profiles[result.name]); await mkdir(dirname(exportPath), { recursive: true }); @@ -4926,7 +4927,7 @@ async function runProfilesPanelAction( return file; } case "import": { - const importPath = profileExportPath(gentleAiConfigHome()); + const importPath = profileExportPath(gentleAiConfigHome("models")); let text: string; try { text = await readFile(importPath, "utf8"); @@ -4976,7 +4977,7 @@ async function runProfilesPanelAction( /** `/gentle:profiles`: seed or open the store, then loop the panel over one action at a time until it closes. */ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): Promise { - const path = profilesFilePath(gentleAiConfigHome()); + const path = profilesFilePath(gentleAiConfigHome("models")); const read = readProfilesFileResult(path); if (read.status === "invalid") { ctx.ui.notify( @@ -9580,7 +9581,7 @@ function createGentleAiExtensionForTesting( return classifier.run(request); }, cancel: () => classifier?.cancel(), - }, { configHome: gentlePiConfigHome(permissionEnvironment) }); + }, { configHome: gentlePiConfigHome(permissionEnvironment, "behavior") }); const auto = autoPermissions; pi.on("tool_result", (event, ctx) => { if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") auto.toolResult(event, ctx); diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 6066f7327..d7c795cd4 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -183,7 +183,7 @@ export type ActiveProfileReader = (() => string | undefined) & { // Unbound reads retain the global file-identity cache, including atomic replacements. // Bound reads are snapshots: no filesystem or Git work is done during a frame. export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): ActiveProfileReader { - const path = profilesFilePath(gentlePiConfigHome(env)); + const path = profilesFilePath(gentlePiConfigHome(env, "models")); let fingerprint: string | undefined; let name: string | undefined; let bound = false; @@ -218,7 +218,7 @@ export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): const next = cwd === undefined ? global() : sessionProfileLabel(resolveSessionProfile({ sessionId, cwd, - configHome: gentlePiConfigHome(env), + configHome: gentlePiConfigHome(env, "models"), resolveWorktree: () => identity, env, })); @@ -1576,7 +1576,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p }; // One spelling of the config home, so the pin resolver, the global profiles // store and the profile reader cannot drift onto two different stores. - const usageConfigHome = gentlePiConfigHome(env); + const usageConfigHome = gentlePiConfigHome(env, "models"); const usageFetchTimeout = usageFetchTimeoutMs(env); // The subagent routing in force for this session, through the same single // rule launches use (gentle-shell#1064): explicit Enter binding, then the @@ -1778,8 +1778,12 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p // re-syncs this cache from disk first, so status, the no-argument toggle // direction, and the Esc gate always describe the same effective policy // even when another session or a hand edit changed the file mid-session. - const doubleEscCancelConfigHome = gentlePiConfigHome(env); - const animationOptions = { gentlePiConfigHome: doubleEscCancelConfigHome }; + // Two config categories live side by side here (lib/gentle-config-scope.ts): + // behavior (double Esc, history capture) and appearance (animations, Vim, + // cards, visual settings and profiles, banner). A named env may own either. + const doubleEscCancelConfigHome = gentlePiConfigHome(env, "behavior"); + const appearanceConfigHome = gentlePiConfigHome(env, "appearance"); + const animationOptions = { gentlePiConfigHome: appearanceConfigHome }; let animationPolicy = resolveAnimationPolicy(animationOptions).policy; let vimPolicy = resolveVimPolicy(animationOptions).policy; // Conversation cards read the style from a process-wide slot; the saved @@ -2113,9 +2117,9 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p if (closeCustomize === close) closeCustomize = undefined; }; closeCustomize = close; - const home = { gentlePiConfigHome: doubleEscCancelConfigHome }; + const home = { gentlePiConfigHome: appearanceConfigHome }; const rows: CustomizeRow[] = []; - const bannerHome = doubleEscCancelConfigHome; + const bannerHome = appearanceConfigHome; let banner = await readBannerConfig(bannerHome).catch(error => { close(); throw error; }); if (closed) return; let activeTheme = ctx.ui.theme.name; @@ -2191,7 +2195,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p if (current.malformed || current.readError) throw new Error(`Cannot update unreadable or malformed visual settings: ${current.globalFile}`); writeVisualSettings(change(current.settings), home); refreshVisual(); - pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: doubleEscCancelConfigHome }); + pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: appearanceConfigHome }); }; const visual = () => resolveVisualSettings(home).settings; const pending = "Preference saved and applied."; @@ -2379,7 +2383,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p try { writeVisualSettings(structuredClone(DEFAULT_VISUAL_SETTINGS), home); refreshVisual(); - pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: doubleEscCancelConfigHome }); + pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: appearanceConfigHome }); changed.push("visual"); await writeBannerConfig({ ...DEFAULT_BANNER_CONFIG }, bannerHome); banner = { ...DEFAULT_BANNER_CONFIG }; @@ -2426,7 +2430,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p if (current.malformed || current.readError) throw new Error("Visual settings unreadable."); writeVisualSettings(profile.visual, home); refreshVisual(); - pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: doubleEscCancelConfigHome }); + pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: appearanceConfigHome }); }); await step("banner", async () => { await readBannerConfigForEdit(bannerHome); await writeBannerConfig(profile.banner, bannerHome); banner = profile.banner; }); await step("animation", () => { writeAnimationPolicy(profile.animationPolicy, animationOptions); animationPolicy = profile.animationPolicy; prompt?.setAnimationPolicy(animationPolicy); }); diff --git a/extensions/startup-banner.ts b/extensions/startup-banner.ts index 1e6696c68..ecfd3f27a 100644 --- a/extensions/startup-banner.ts +++ b/extensions/startup-banner.ts @@ -8,6 +8,7 @@ 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"; +import type { ConfigCategory } from "../lib/gentle-config-scope.ts"; export type BannerColor = "pink" | "cyan" | "yellow" | "green"; @@ -47,11 +48,11 @@ function rgb(r: number, g: number, b: number, text: string): string { return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`; } -function gentleAiConfigHome(): string { - return gentlePiConfigHome(process.env); +function gentleAiConfigHome(category: ConfigCategory = "appearance"): string { + return gentlePiConfigHome(process.env, category); } -function bannerConfigPath(configHome = gentleAiConfigHome()): string { +function bannerConfigPath(configHome = gentleAiConfigHome("appearance")): string { return join(configHome, "banner.json"); } @@ -67,7 +68,7 @@ function normalizeBannerConfig(value: unknown): BannerConfig { // Modal mutations must not turn an unreadable or malformed existing file into defaults. // Legacy banner commands retain their original tolerant read behavior below. -export async function readBannerConfigForEdit(configHome = gentleAiConfigHome()): Promise { +export async function readBannerConfigForEdit(configHome = gentleAiConfigHome("appearance")): Promise { const path = bannerConfigPath(configHome); let raw: string; try { @@ -86,7 +87,7 @@ export async function readBannerConfigForEdit(configHome = gentleAiConfigHome()) return { showRose: config.showRose, showTextLogo: config.showTextLogo, color: config.color as BannerColor }; } -export async function readBannerConfig(configHome = gentleAiConfigHome()): Promise { +export async function readBannerConfig(configHome = gentleAiConfigHome("appearance")): Promise { try { return normalizeBannerConfig(JSON.parse(await readFile(bannerConfigPath(configHome), "utf8"))); } catch { @@ -94,7 +95,7 @@ export async function readBannerConfig(configHome = gentleAiConfigHome()): Promi } } -export async function writeBannerConfig(config: BannerConfig, configHome = gentleAiConfigHome()): Promise { +export async function writeBannerConfig(config: BannerConfig, configHome = gentleAiConfigHome("appearance")): Promise { const path = bannerConfigPath(configHome); await mkdir(join(path, ".."), { recursive: true }); await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8"); diff --git a/lib/agent-home.ts b/lib/agent-home.ts index d949dd9a9..e3b7fbc9e 100644 --- a/lib/agent-home.ts +++ b/lib/agent-home.ts @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { resolveGentlePiConfigHome, runningBundledPrefix } from "./gentle-shell-bundled.ts"; +import { type ConfigCategory, envConfigHomeFor } from "./gentle-config-scope.ts"; export { GENTLE_SHELL_CONFIG_HOME_ENV } from "./gentle-shell-bundled.ts"; @@ -18,8 +19,13 @@ export function resolveGentlePiAgentHome(env: NodeJS.ProcessEnv = process.env): // the standalone launcher's isolated home next to its agent dir (#2201). The rule // itself lives in resolveGentlePiConfigHome, a generated runtime module, so the // launcher and the Gentle AI process environment share it too. -export function gentlePiConfigHome(env: NodeJS.ProcessEnv = process.env): string { - return resolveGentlePiConfigHome(env, homedir()); +// +// `category` names what the caller reads or writes (lib/gentle-config-scope.ts). +// In a named env that owns that category, it resolves to the env's own config +// home, ahead of that rule. Every other case, and install-level reads that pass +// no category, keep the rule. +export function gentlePiConfigHome(env: NodeJS.ProcessEnv = process.env, category?: ConfigCategory): string { + return envConfigHomeFor(env, category) ?? resolveGentlePiConfigHome(env, homedir()); } // The agent home Pi runs with, only in a bundled Gentle Shell (S9): there the diff --git a/lib/animation-policy.ts b/lib/animation-policy.ts index 4d1eef856..a63b7eceb 100644 --- a/lib/animation-policy.ts +++ b/lib/animation-policy.ts @@ -26,7 +26,7 @@ export function parseAnimationPolicyFile(raw: string): AnimationPolicy | undefin } export function resolveAnimationPolicy(options: AnimationOptions = {}): AnimationResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "animations.json"); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"), "animations.json"); try { const policy = parseAnimationPolicyFile(readFileSync(globalFile, "utf8")); return { policy: policy ?? "quality", source: SOURCE.GLOBAL, malformed: policy === undefined, globalFile }; @@ -38,7 +38,7 @@ export function resolveAnimationPolicy(options: AnimationOptions = {}): Animatio /** Same-directory rename prevents readers from seeing a partially written policy. */ export function writeAnimationPolicy(policy: AnimationPolicy, options: AnimationOptions = {}): string { - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"); const path = join(home, "animations.json"); const temporary = `${path}.${randomUUID()}.tmp`; mkdirSync(home, { recursive: true }); diff --git a/lib/auto-permission-runtime.ts b/lib/auto-permission-runtime.ts index a70539853..dd869d0f9 100644 --- a/lib/auto-permission-runtime.ts +++ b/lib/auto-permission-runtime.ts @@ -234,7 +234,7 @@ export class AutoPermissionRuntime { options: {configHome?: string; retryDelaysMs?: number[]} = {}) { this.pi=pi;this.modes=modes;this.classifier=classifier; this.memory=new AutoPermissionMemory(()=>pi.getAllTools?.() ?? []); - this.configHome = resolve(options.configHome ?? gentlePiConfigHome()); + this.configHome = resolve(options.configHome ?? gentlePiConfigHome(undefined, "behavior")); this.retryDelaysMs = options.retryDelaysMs ?? PROVIDER_RETRY_DELAYS_MS; } private retryDelaysMs: number[]; diff --git a/lib/background-subagents-policy.ts b/lib/background-subagents-policy.ts index 51f7e03d5..881d7ac2b 100644 --- a/lib/background-subagents-policy.ts +++ b/lib/background-subagents-policy.ts @@ -95,7 +95,7 @@ export function resolveBackgroundSubagentsPolicy( let projectFile = ""; let globalFile = ""; try { - const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "behavior"); projectFile = join(cwd, ".pi", "gentle-ai", BACKGROUND_SUBAGENTS_FILE); globalFile = join(configHome, BACKGROUND_SUBAGENTS_FILE); const projectFileExists = existsSync(projectFile); diff --git a/lib/builtin-codemode-optout.ts b/lib/builtin-codemode-optout.ts index 98e6651fb..cdc24bc1c 100644 --- a/lib/builtin-codemode-optout.ts +++ b/lib/builtin-codemode-optout.ts @@ -27,7 +27,7 @@ export type BuiltinCodemodeOptOutOutcome = export interface BuiltinCodemodeOptOutOptions { /** Pi agent dir whose settings.json receives the entry; defaults to getAgentDir(). */ agentDir?: string; - /** gentle-pi config home holding the remembered decline; defaults to gentlePiConfigHome(). */ + /** gentle-pi config home holding the remembered decline; defaults to gentlePiConfigHome(undefined, "behavior"). */ configHome?: string; /** Effective (merged) settings `extensions`, e.g. from pi.getSettings(). */ effectiveExtensions?: unknown; @@ -69,7 +69,7 @@ export function withBuiltinExtensionExcluded(settingsText: string, builtin = BUI return settingsText.endsWith("\n") ? `${serialized}\n` : serialized; } -export function builtinCodemodeOptOutStatePath(configHome = gentlePiConfigHome()): string { +export function builtinCodemodeOptOutStatePath(configHome = gentlePiConfigHome(undefined, "behavior")): string { return join(configHome, "builtin-codemode-optout.json"); } @@ -138,7 +138,7 @@ export async function offerBuiltinCodemodeOptOut(ctx: BuiltinCodemodeOptOutConte if (!ctx.hasUI || ctx.mode !== "tui") return "no-ui"; if (hasExplicitBuiltinEntry(options.effectiveExtensions)) return "explicit-entry"; const settingsPath = resolve(options.agentDir ?? getAgentDir(), "settings.json"); - const configHome = options.configHome ?? gentlePiConfigHome(); + const configHome = options.configHome ?? gentlePiConfigHome(undefined, "behavior"); const currentText = readIfExists(settingsPath); if (currentText === undefined) return "unwritable"; try { diff --git a/lib/card-content-policy.ts b/lib/card-content-policy.ts index ab6c912b9..9d0dd79a4 100644 --- a/lib/card-content-policy.ts +++ b/lib/card-content-policy.ts @@ -42,7 +42,7 @@ export function parseCardContentFile(raw: string): CardContent | undefined { } export function resolveCardContent(options: CardContentOptions = {}): CardContentResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), CARD_CONTENT_FILE); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"), CARD_CONTENT_FILE); try { const content = parseCardContentFile(readFileSync(globalFile, "utf8")); return { content: content ?? CARD_CONTENT.DEFAULT, source: "global_file", malformed: content === undefined, globalFile }; @@ -54,7 +54,7 @@ export function resolveCardContent(options: CardContentOptions = {}): CardConten export function writeCardContent(content: CardContent, options: CardContentOptions = {}): string { if (!isCardContent(content)) throw new TypeError("Invalid card content"); - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"); const current = resolveCardContent({ gentlePiConfigHome: home }); if (current.malformed) throw new Error(`Cannot update malformed or unreadable card content preference: ${current.globalFile}`); const path = current.globalFile; diff --git a/lib/card-style-policy.ts b/lib/card-style-policy.ts index 773f87087..ca58b8d84 100644 --- a/lib/card-style-policy.ts +++ b/lib/card-style-policy.ts @@ -32,7 +32,7 @@ export function parseCardStyleFile(raw: string): CardStyle | undefined { } export function resolveCardStyle(options: CardStyleOptions = {}): CardStyleResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), CARD_STYLE_FILE); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"), CARD_STYLE_FILE); try { const style = parseCardStyleFile(readFileSync(globalFile, "utf8")); return { style: style ?? CARD_STYLE.FLOAT, source: "global_file", malformed: style === undefined, globalFile }; @@ -44,7 +44,7 @@ export function resolveCardStyle(options: CardStyleOptions = {}): CardStyleResol export function writeCardStyle(style: CardStyle, options: CardStyleOptions = {}): string { if (!isCardStyle(style)) throw new TypeError("Invalid card style"); - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"); const current = resolveCardStyle({ gentlePiConfigHome: home }); if (current.malformed) throw new Error(`Cannot update malformed or unreadable card style preference: ${current.globalFile}`); const path = current.globalFile; diff --git a/lib/double-esc-cancel-policy.ts b/lib/double-esc-cancel-policy.ts index 1cb10b8ad..5eb6de518 100644 --- a/lib/double-esc-cancel-policy.ts +++ b/lib/double-esc-cancel-policy.ts @@ -89,7 +89,7 @@ export function resolveDoubleEscCancelPolicy( const envValue = env.GENTLE_PI_DOUBLE_ESC_CANCEL; let globalFile = ""; try { - const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(env); + const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(env, "behavior"); globalFile = join(configHome, DOUBLE_ESC_CANCEL_FILE); const globalFileExists = existsSync(globalFile); if (globalFileExists) { @@ -130,7 +130,7 @@ export function writeDoubleEscCancelPolicy( policy: DoubleEscCancelPolicy, options: { gentlePiConfigHome?: string } = {}, ): string { - const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "behavior"); const path = join(configHome, DOUBLE_ESC_CANCEL_FILE); mkdirSync(configHome, { recursive: true }); writeFileSync(path, `${JSON.stringify({ schema: DOUBLE_ESC_CANCEL_SCHEMA, policy }, null, 2)}\n`); diff --git a/lib/gentle-config-scope.ts b/lib/gentle-config-scope.ts new file mode 100644 index 000000000..9c354f2e2 --- /dev/null +++ b/lib/gentle-config-scope.ts @@ -0,0 +1,65 @@ +import { isAbsolute } from "node:path"; + +// Per-env scope of Gentle's own configuration (the config home, normally +// ~/.pi/gentle-ai). Like VS Code profiles, a named env chooses per category +// whether it shares the base configuration or keeps its own copy. Pure data +// and env parsing only: shared by the launcher and the Pi extensions. + +export const CONFIG_CATEGORIES = Object.freeze(["models", "persona", "appearance", "behavior"] as const); +export type ConfigCategory = (typeof CONFIG_CATEGORIES)[number]; + +export function isConfigCategory(value: string): value is ConfigCategory { + return (CONFIG_CATEGORIES as readonly string[]).includes(value); +} + +// The files each category owns inside a config home. Models and profiles +// travel together: a profile is a snapshot of the routing, and applying one +// rewrites models.json, so splitting them would let an own profile overwrite +// a shared routing. +export const CONFIG_CATEGORY_FILES: Readonly> = Object.freeze({ + models: Object.freeze(["models.json", "models.export.json", "profiles.json", "profiles.export.json"]), + persona: Object.freeze(["persona.json"]), + appearance: Object.freeze([ + "visual-customization.json", + "visual-profiles.json", + "card-style.json", + "card-content.json", + "animations.json", + "banner.json", + "notifications.json", + "vim.json", + ]), + behavior: Object.freeze([ + "permission-mode.json", + "runtime-guardrails.json", + "history-capture.json", + "background-subagents.json", + "double-esc-cancel.json", + "builtin-codemode-optout.json", + ]), +}); + +// Files that belong to the install, never to an env: they always resolve to +// the shared config home. +export const INSTALL_CONFIG_FILES = Object.freeze(["channel.json", "dev-binary.json"]); + +// Set by the launcher on the Pi process of a named env that owns some +// categories, and cleared for every other home. +export const ENV_CONFIG_HOME_ENV = "GENTLE_SHELL_ENV_CONFIG_HOME"; +export const ENV_OWN_CONFIG_ENV = "GENTLE_SHELL_ENV_OWN_CONFIG"; + +// The categories this process keeps in its env config home. Fails closed to +// "nothing own" (the shared configuration, today's behavior) unless the env +// config home is an absolute path and the category is known. +export function ownConfigCategories(env: Record): readonly ConfigCategory[] { + const home = env[ENV_CONFIG_HOME_ENV]; + if (home === undefined || home.length === 0 || !isAbsolute(home)) return []; + const listed = (env[ENV_OWN_CONFIG_ENV] ?? "").split(",").map((value) => value.trim()); + return CONFIG_CATEGORIES.filter((category) => listed.includes(category)); +} + +// The env config home when `category` is own in this process, else undefined. +export function envConfigHomeFor(env: Record, category: ConfigCategory | undefined): string | undefined { + if (category === undefined) return undefined; + return ownConfigCategories(env).includes(category) ? env[ENV_CONFIG_HOME_ENV] : undefined; +} diff --git a/lib/gentle-shell-launcher.ts b/lib/gentle-shell-launcher.ts index 821e718ef..d0dc97db2 100644 --- a/lib/gentle-shell-launcher.ts +++ b/lib/gentle-shell-launcher.ts @@ -2,6 +2,7 @@ import { basename, dirname, isAbsolute, join, resolve as resolvePath } from "nod 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 { ENV_NAME_ENV, envDir, envNameForDir, envsRoot, validateEnvName } from "./gentle-shell-envs.ts"; +import { type ConfigCategory, ENV_CONFIG_HOME_ENV, ENV_OWN_CONFIG_ENV } from "./gentle-config-scope.ts"; export { ENV_NAME_ENV }; @@ -218,6 +219,9 @@ export interface ResolvedHome { source: HomeSource; // Set for a `path` home that is a named env, so hints reproduce `--env`. env?: string; + // The Gentle config categories this env keeps in `/gentle-ai` + // instead of sharing the base configuration (lib/gentle-config-scope.ts). + ownConfig?: readonly ConfigCategory[]; } // A discriminated union instead of a plain `home: string` field: `resolveHome` @@ -1135,6 +1139,16 @@ export function buildPiInvocation(input: BuildPiInvocationInput): PiInvocation { // 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; + // Same rule for the env's config scope: only an env that owns categories + // sets it, so an outer session's scope never reaches another home. + const ownConfig = input.home.env === undefined ? [] : (input.home.ownConfig ?? []); + if (ownConfig.length === 0) { + delete env[ENV_CONFIG_HOME_ENV]; + delete env[ENV_OWN_CONFIG_ENV]; + } else { + env[ENV_CONFIG_HOME_ENV] = join(input.home.dir, "gentle-ai"); + env[ENV_OWN_CONFIG_ENV] = ownConfig.join(","); + } if (childInjection === undefined) delete env[CHILD_PACKAGE_INJECTION_ENV]; else env[CHILD_PACKAGE_INJECTION_ENV] = encodeChildPackageInjection(childInjection); diff --git a/lib/history-capture-policy.ts b/lib/history-capture-policy.ts index 3aa09bb83..74d565f22 100644 --- a/lib/history-capture-policy.ts +++ b/lib/history-capture-policy.ts @@ -46,7 +46,7 @@ export function parseHistoryCaptureFile(raw: string): HistoryCapturePolicy | und } export function resolveHistoryCapturePolicy(options: HistoryCaptureOptions = {}): HistoryCapturePolicyResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "history-capture.json"); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "behavior"), "history-capture.json"); try { const policy = parseHistoryCaptureFile(readFileSync(globalFile, "utf8")); return { policy: policy ?? "off", source: "global_file", malformed: policy === undefined, globalFile }; @@ -59,7 +59,7 @@ export function resolveHistoryCapturePolicy(options: HistoryCaptureOptions = {}) /** Full resolution for status surfaces; always reads the preference. */ export function resolveHistoryCapture(options: { env?: NodeJS.ProcessEnv; gentlePiConfigHome?: string } = {}): HistoryCaptureResolution { const env = options.env ?? process.env; - const saved = resolveHistoryCapturePolicy({ gentlePiConfigHome: options.gentlePiConfigHome ?? gentlePiConfigHome(env) }); + const saved = resolveHistoryCapturePolicy({ gentlePiConfigHome: options.gentlePiConfigHome ?? gentlePiConfigHome(env, "behavior") }); const envOverride = historyCaptureEnvOverride(env); return { enabled: (envOverride ?? saved.policy) === "on", @@ -76,12 +76,12 @@ export function historyCaptureEnabled(options: { env?: NodeJS.ProcessEnv; gentle const env = options.env ?? process.env; const override = historyCaptureEnvOverride(env); if (override) return override === "on"; - return resolveHistoryCapturePolicy({ gentlePiConfigHome: options.gentlePiConfigHome ?? gentlePiConfigHome(env) }).policy === "on"; + return resolveHistoryCapturePolicy({ gentlePiConfigHome: options.gentlePiConfigHome ?? gentlePiConfigHome(env, "behavior") }).policy === "on"; } export function writeHistoryCapturePolicy(policy: HistoryCapturePolicy, options: HistoryCaptureOptions = {}): string { if (policy !== "on" && policy !== "off") throw new TypeError("Invalid history capture policy"); - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "behavior"); const path = join(home, "history-capture.json"); const temporary = `${path}.${randomUUID()}.tmp`; mkdirSync(home, { recursive: true }); diff --git a/lib/notification-policy.ts b/lib/notification-policy.ts index 0cf4f6aec..29ab3c666 100644 --- a/lib/notification-policy.ts +++ b/lib/notification-policy.ts @@ -119,7 +119,7 @@ export function restoreNotificationPreset(settings: NotificationSettings): Notif return { ...structuredClone(DEFAULT_NOTIFICATION_SETTINGS), enabled: settings.enabled }; } export function resolveNotificationSettings(options: NotificationOptions = {}): NotificationResolution { - const globalFile = join(options.gentlePiConfigHome || gentlePiConfigHome(), "notifications.json"); + const globalFile = join(options.gentlePiConfigHome || gentlePiConfigHome(undefined, "appearance"), "notifications.json"); try { const settings = parseNotificationSettingsFile((options.io ?? DEFAULT_IO).readFile(globalFile), options.pathFlavor); return { settings: settings ?? structuredClone(DEFAULT_NOTIFICATION_SETTINGS), source: "global_file", malformed: settings === undefined, readError: false, globalFile }; @@ -132,7 +132,7 @@ export function resolveNotificationSettings(options: NotificationOptions = {}): export function writeNotificationSettings(settings: NotificationSettings, options: NotificationOptions = {}): string { if (!isNotificationSettings(settings, options.pathFlavor)) throw new TypeError("Invalid notification settings"); const io = options.io ?? DEFAULT_IO; - const home = options.gentlePiConfigHome || gentlePiConfigHome(); + const home = options.gentlePiConfigHome || gentlePiConfigHome(undefined, "appearance"); const path = join(home, "notifications.json"); const temporary = `${path}.${randomUUID()}.tmp`; io.mkdir(home); diff --git a/lib/permission-mode-policy.ts b/lib/permission-mode-policy.ts index d432871ba..cdd060ec5 100644 --- a/lib/permission-mode-policy.ts +++ b/lib/permission-mode-policy.ts @@ -17,7 +17,7 @@ export function isPermissionMode(value: unknown): value is PermissionMode { } /** Never consult merged Pi settings or repository configuration. */ export function permissionModePolicyFile(options: PermissionModeOptions = {}): string { - return join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "permission-mode.json"); + return join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "behavior"), "permission-mode.json"); } export const permissionModePolicyPath = permissionModePolicyFile; export function parsePermissionModePolicyFile(raw: string): PermissionMode | undefined { @@ -43,7 +43,7 @@ export function writePermissionModePolicy(mode: PermissionMode, options: Permiss if (!isPermissionMode(mode)) throw new Error("Invalid permission mode"); const resolution = resolvePermissionModePolicy(options); if (resolution.malformed) throw new Error("Permission mode preference is malformed or unreadable; repair it before saving."); - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "behavior"); const path = resolution.globalFile; const temporary = `${path}.${randomUUID()}.tmp`; mkdirSync(home, { recursive: true }); diff --git a/lib/vim-policy.ts b/lib/vim-policy.ts index 264e702e5..a3784adf4 100644 --- a/lib/vim-policy.ts +++ b/lib/vim-policy.ts @@ -24,7 +24,7 @@ export function parseVimPolicyFile(raw: string): VimPolicy | undefined { } export function resolveVimPolicy(options: VimOptions = {}): VimResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "vim.json"); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"), "vim.json"); try { const policy = parseVimPolicyFile(readFileSync(globalFile, "utf8")); return { policy: policy ?? "off", source: "global_file", malformed: policy === undefined, globalFile }; @@ -35,7 +35,7 @@ export function resolveVimPolicy(options: VimOptions = {}): VimResolution { } export function writeVimPolicy(policy: VimPolicy, options: VimOptions = {}): string { - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"); const path = join(home, "vim.json"); const temporary = `${path}.${randomUUID()}.tmp`; mkdirSync(home, { recursive: true }); diff --git a/lib/visual-customization-policy.ts b/lib/visual-customization-policy.ts index c7f7626d7..c1e2243e1 100644 --- a/lib/visual-customization-policy.ts +++ b/lib/visual-customization-policy.ts @@ -93,7 +93,7 @@ export function parseVisualSettingsFile(raw: string): VisualSettings | undefined } catch { return undefined; } } export function resolveVisualSettings(options: VisualOptions = {}): VisualResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "visual-customization.json"); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"), "visual-customization.json"); try { const settings = parseVisualSettingsFile(readFileSync(globalFile, "utf8")); return { settings: settings ?? structuredClone(DEFAULT_VISUAL_SETTINGS), source: "global_file", malformed: settings === undefined, readError: false, globalFile }; @@ -105,7 +105,7 @@ export function resolveVisualSettings(options: VisualOptions = {}): VisualResolu /** Same-directory rename keeps partial JSON invisible to readers. */ export function writeVisualSettings(settings: VisualSettings, options: VisualOptions = {}): string { if (!isVisualSettings(settings)) throw new TypeError("Invalid visual settings"); - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"); const path = join(home, "visual-customization.json"); const temporary = `${path}.${randomUUID()}.tmp`; mkdirSync(home, { recursive: true }); diff --git a/lib/visual-profiles.ts b/lib/visual-profiles.ts index 253dff6a5..4588d9654 100644 --- a/lib/visual-profiles.ts +++ b/lib/visual-profiles.ts @@ -101,7 +101,7 @@ export function parseVisualProfilesFile(raw: string): VisualProfile[] | undefine } export function readVisualProfiles(options: Options = {}): VisualProfilesResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "visual-profiles.json"); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"), "visual-profiles.json"); let fd: number | undefined; try { try { @@ -153,7 +153,7 @@ function existing(options: Options): VisualProfile[] { } function write(profiles: VisualProfile[], options: Options): string { - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"); const path = join(home, "visual-profiles.json"); const data = `${JSON.stringify({ schema: VISUAL_PROFILES_SCHEMA, profiles })}\n`; if (Buffer.byteLength(data) > MAX_BYTES) { diff --git a/lib/yolo-session-policy.ts b/lib/yolo-session-policy.ts index b3dfc32e3..e189511d8 100644 --- a/lib/yolo-session-policy.ts +++ b/lib/yolo-session-policy.ts @@ -102,7 +102,7 @@ interface ModeIdentity { /** UI/slash authoring only. Defaults are read from the dedicated trusted user store. */ export function registerYoloSessionPolicy(pi: ExtensionAPI, env: NodeJS.ProcessEnv, options: { onChange?: () => void } = {}): YoloSessionController { - const store = { gentlePiConfigHome: gentlePiConfigHome(env) }; + const store = { gentlePiConfigHome: gentlePiConfigHome(env, "behavior") }; let generation = 0; let ownerGeneration = 0; let liveMode: PermissionMode = "normal"; diff --git a/runtime/gentle-config-scope.mjs b/runtime/gentle-config-scope.mjs new file mode 100644 index 000000000..c51c03cb1 --- /dev/null +++ b/runtime/gentle-config-scope.mjs @@ -0,0 +1,66 @@ +// Generated by scripts/build-runtime-modules.mjs. Do not edit. +import { isAbsolute } from "node:path"; + +// Per-env scope of Gentle's own configuration (the config home, normally +// ~/.pi/gentle-ai). Like VS Code profiles, a named env chooses per category +// whether it shares the base configuration or keeps its own copy. Pure data +// and env parsing only: shared by the launcher and the Pi extensions. + +export const CONFIG_CATEGORIES = Object.freeze(["models", "persona", "appearance", "behavior"] ); + + +export function isConfigCategory(value ) { + return (CONFIG_CATEGORIES ).includes(value); +} + +// The files each category owns inside a config home. Models and profiles +// travel together: a profile is a snapshot of the routing, and applying one +// rewrites models.json, so splitting them would let an own profile overwrite +// a shared routing. +export const CONFIG_CATEGORY_FILES = Object.freeze({ + models: Object.freeze(["models.json", "models.export.json", "profiles.json", "profiles.export.json"]), + persona: Object.freeze(["persona.json"]), + appearance: Object.freeze([ + "visual-customization.json", + "visual-profiles.json", + "card-style.json", + "card-content.json", + "animations.json", + "banner.json", + "notifications.json", + "vim.json", + ]), + behavior: Object.freeze([ + "permission-mode.json", + "runtime-guardrails.json", + "history-capture.json", + "background-subagents.json", + "double-esc-cancel.json", + "builtin-codemode-optout.json", + ]), +}); + +// Files that belong to the install, never to an env: they always resolve to +// the shared config home. +export const INSTALL_CONFIG_FILES = Object.freeze(["channel.json", "dev-binary.json"]); + +// Set by the launcher on the Pi process of a named env that owns some +// categories, and cleared for every other home. +export const ENV_CONFIG_HOME_ENV = "GENTLE_SHELL_ENV_CONFIG_HOME"; +export const ENV_OWN_CONFIG_ENV = "GENTLE_SHELL_ENV_OWN_CONFIG"; + +// The categories this process keeps in its env config home. Fails closed to +// "nothing own" (the shared configuration, today's behavior) unless the env +// config home is an absolute path and the category is known. +export function ownConfigCategories(env ) { + const home = env[ENV_CONFIG_HOME_ENV]; + if (home === undefined || home.length === 0 || !isAbsolute(home)) return []; + const listed = (env[ENV_OWN_CONFIG_ENV] ?? "").split(",").map((value) => value.trim()); + return CONFIG_CATEGORIES.filter((category) => listed.includes(category)); +} + +// The env config home when `category` is own in this process, else undefined. +export function envConfigHomeFor(env , category ) { + if (category === undefined) return undefined; + return ownConfigCategories(env).includes(category) ? env[ENV_CONFIG_HOME_ENV] : undefined; +} diff --git a/runtime/gentle-shell-launcher.mjs b/runtime/gentle-shell-launcher.mjs index 541585a94..a51edc93c 100644 --- a/runtime/gentle-shell-launcher.mjs +++ b/runtime/gentle-shell-launcher.mjs @@ -3,6 +3,7 @@ import { basename, dirname, isAbsolute, join, resolve as resolvePath } from "nod import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection, } from "./child-package-injection.mjs"; import { GENTLE_SHELL_CONFIG_HOME_ENV } from "./gentle-shell-bundled.mjs"; import { ENV_NAME_ENV, envDir, envNameForDir, envsRoot, validateEnvName } from "./gentle-shell-envs.mjs"; +import { ENV_CONFIG_HOME_ENV, ENV_OWN_CONFIG_ENV } from "./gentle-config-scope.mjs"; export { ENV_NAME_ENV }; @@ -221,6 +222,9 @@ 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 @@ -1136,6 +1140,16 @@ export function buildPiInvocation(input ) { // 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; + // Same rule for the env's config scope: only an env that owns categories + // sets it, so an outer session's scope never reaches another home. + const ownConfig = input.home.env === undefined ? [] : (input.home.ownConfig ?? []); + if (ownConfig.length === 0) { + delete env[ENV_CONFIG_HOME_ENV]; + delete env[ENV_OWN_CONFIG_ENV]; + } else { + env[ENV_CONFIG_HOME_ENV] = join(input.home.dir, "gentle-ai"); + env[ENV_OWN_CONFIG_ENV] = ownConfig.join(","); + } if (childInjection === undefined) delete env[CHILD_PACKAGE_INJECTION_ENV]; else env[CHILD_PACKAGE_INJECTION_ENV] = encodeChildPackageInjection(childInjection); diff --git a/scripts/build-runtime-modules.mjs b/scripts/build-runtime-modules.mjs index f32ed78b4..2462a04dd 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-config-scope", "gentle-shell-envs", "gentle-brand", "gentle-shell-envs-tui", diff --git a/scripts/verify-package-files.mjs b/scripts/verify-package-files.mjs index 862a150f9..fa32317d9 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-config-scope.ts", "lib/gentle-shell-envs.ts", "lib/gentle-brand.ts", "lib/gentle-shell-envs-tui.ts", @@ -104,6 +105,7 @@ export const requiredPaths = [ "lib/telemetry-trigger.ts", "runtime/child-package-injection.mjs", "runtime/gentle-ai-binary.mjs", + "runtime/gentle-config-scope.mjs", "runtime/gentle-shell-envs.mjs", "runtime/gentle-brand.mjs", "runtime/gentle-shell-envs-tui.mjs", diff --git a/tests/gentle-config-scope.test.ts b/tests/gentle-config-scope.test.ts new file mode 100644 index 000000000..adfac311f --- /dev/null +++ b/tests/gentle-config-scope.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { gentlePiConfigHome } from "../lib/agent-home.ts"; +import { + CONFIG_CATEGORIES, + CONFIG_CATEGORY_FILES, + ENV_CONFIG_HOME_ENV, + ENV_OWN_CONFIG_ENV, + INSTALL_CONFIG_FILES, + ownConfigCategories, +} from "../lib/gentle-config-scope.ts"; + +// --- categories --------------------------------------------------------------- + +test("every Gentle config file belongs to exactly one category or is install-level", () => { + assert.deepEqual([...CONFIG_CATEGORIES], ["models", "persona", "appearance", "behavior"]); + const seen = new Map(); + for (const category of CONFIG_CATEGORIES) { + for (const file of CONFIG_CATEGORY_FILES[category]) { + assert.ok(!seen.has(file), `${file} is in ${seen.get(file)} and ${category}`); + seen.set(file, category); + } + } + for (const file of INSTALL_CONFIG_FILES) assert.ok(!seen.has(file), `${file} is install-level`); + for (const file of ["models.json", "profiles.json"]) assert.equal(seen.get(file), "models", "a profile is a snapshot of the routing"); + assert.equal(seen.get("persona.json"), "persona"); + assert.equal(seen.get("card-style.json"), "appearance"); + assert.equal(seen.get("permission-mode.json"), "behavior"); +}); + +test("ownConfigCategories trusts only an absolute env config home and known categories", () => { + const home = join("/", "e", "work", "gentle-ai"); + assert.deepEqual([...ownConfigCategories({ [ENV_CONFIG_HOME_ENV]: home, [ENV_OWN_CONFIG_ENV]: "persona,appearance,bogus" })], ["persona", "appearance"]); + assert.deepEqual([...ownConfigCategories({ [ENV_OWN_CONFIG_ENV]: "persona" })], [], "no env config home, nothing is own"); + assert.deepEqual([...ownConfigCategories({ [ENV_CONFIG_HOME_ENV]: "relative/dir", [ENV_OWN_CONFIG_ENV]: "persona" })], []); + assert.deepEqual([...ownConfigCategories({ [ENV_CONFIG_HOME_ENV]: home })], [], "no list, nothing is own"); +}); + +// --- resolution ----------------------------------------------------------------- + +test("an own category resolves to the env config home; shared ones keep the existing chain", () => { + const envHome = join("/", "e", "work", "gentle-ai"); + const env = { GENTLE_PI_CONFIG_HOME: "/shared/gentle-ai", [ENV_CONFIG_HOME_ENV]: envHome, [ENV_OWN_CONFIG_ENV]: "persona,behavior" }; + assert.equal(gentlePiConfigHome(env, "persona"), envHome); + assert.equal(gentlePiConfigHome(env, "behavior"), envHome); + assert.equal(gentlePiConfigHome(env, "models"), "/shared/gentle-ai"); + assert.equal(gentlePiConfigHome(env, "appearance"), "/shared/gentle-ai"); + assert.equal(gentlePiConfigHome(env), "/shared/gentle-ai", "install-level reads never move"); +}); + +test("without env scope variables every category resolves exactly as before", () => { + for (const category of CONFIG_CATEGORIES) { + assert.equal(gentlePiConfigHome({ GENTLE_PI_CONFIG_HOME: "/custom" }, category), "/custom"); + assert.equal(gentlePiConfigHome({}, category), join(homedir(), ".pi", "gentle-ai")); + } +}); + +// --- drift guard --------------------------------------------------------------- + +// Install-level reads that must ignore env scope: the dev binary registration, +// integrity and the update channel belong to the install, never to an env. +const INSTALL_LEVEL_CALLS = new Set([]); + +test("every config-home read names its category (drift guard)", () => { + const root = new URL("..", import.meta.url); + const offenders: string[] = []; + for (const dir of ["lib", "extensions"]) { + for (const name of readdirSync(new URL(`${dir}/`, root))) { + if (!name.endsWith(".ts")) continue; + const source = readFileSync(new URL(`${dir}/${name}`, root), "utf8"); + source.split("\n").forEach((line, index) => { + if (/export function gentlePiConfigHome/.test(line)) return; + if (/function gentleAiConfigHome\(category/.test(line)) return; + // A hand-written copy of the chain bypasses the category entirely. + if (/bundledGentlePiConfigHome\([^)]*\)\s*\?\?\s*[\w.]*GENTLE_PI_CONFIG_HOME/.test(line) && !/^(gentle-ai-binary|agent-home)\.ts$/.test(name)) { + offenders.push(`${dir}/${name}:${index + 1}: hand-written config chain`); + } + for (const match of line.matchAll(/(? { + const { GENTLE_SHELL_CONFIG_HOME_ENV } = await import("../lib/agent-home.ts"); + const envHome = join("/", "e", "work", "gentle-ai"); + const launcherHome = join("/", "s", ".gentle-shell", "gentle-ai"); + const scoped = { [ENV_CONFIG_HOME_ENV]: envHome, [ENV_OWN_CONFIG_ENV]: "appearance", [GENTLE_SHELL_CONFIG_HOME_ENV]: launcherHome }; + assert.equal(gentlePiConfigHome(scoped, "appearance"), envHome); + assert.equal(gentlePiConfigHome(scoped, "models"), launcherHome); + assert.equal(gentlePiConfigHome({ ...scoped, GENTLE_PI_CONFIG_HOME: "" }, "models"), launcherHome, "an empty override falls through"); + assert.equal(gentlePiConfigHome({ [ENV_CONFIG_HOME_ENV]: envHome, [ENV_OWN_CONFIG_ENV]: "appearance" }, "models"), join(homedir(), ".pi", "gentle-ai")); +}); diff --git a/tests/gentle-shell-envs.test.ts b/tests/gentle-shell-envs.test.ts index b2a24ef16..49fbc9232 100644 --- a/tests/gentle-shell-envs.test.ts +++ b/tests/gentle-shell-envs.test.ts @@ -177,3 +177,26 @@ 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); }); + +test("buildPiInvocation scopes own config categories to the env and never leaks inherited scope", async () => { + const { buildPiInvocation } = 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 dir = join("/h", ".gentle-shell", "envs", "work"); + const own = buildPiInvocation({ ...base, home: { mode: "path", dir, source: "flag", env: "work", ownConfig: ["persona", "appearance"] }, baseEnv: {} }); + assert.equal(own.env.GENTLE_SHELL_ENV_CONFIG_HOME, join(dir, "gentle-ai")); + assert.equal(own.env.GENTLE_SHELL_ENV_OWN_CONFIG, "persona,appearance"); + const inherited = { GENTLE_SHELL_ENV_CONFIG_HOME: "/outer/gentle-ai", GENTLE_SHELL_ENV_OWN_CONFIG: "models" }; + const shared = buildPiInvocation({ ...base, home: { mode: "path", dir, source: "flag", env: "work" }, baseEnv: inherited }); + assert.ok(!("GENTLE_SHELL_ENV_CONFIG_HOME" in shared.env) && !("GENTLE_SHELL_ENV_OWN_CONFIG" in shared.env), "a sharing env clears outer scope"); + const isolated = buildPiInvocation({ ...base, home: { mode: "isolated", dir: "/h/.gentle-shell/agent", source: "default" }, baseEnv: inherited }); + assert.ok(!("GENTLE_SHELL_ENV_CONFIG_HOME" in isolated.env), "the base home never inherits env scope"); +}); From db6c3a27b0314aad476feceb70d6bd6711c8030a Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 14:53:59 -0400 Subject: [PATCH 17/36] feat(launcher): choose per env which Gentle config it owns, like VS Code profiles Each named env stores in `.gentle-shell-env.json` which Gentle config categories it owns (models & profiles, persona, appearance, behavior). Everything else is shared live with the base home. A missing or unreadable file means sharing everything, so existing envs keep today's behavior. `envs new ` and `envs config ` take `--own `, `--share `, and `--from ` or `--empty`. A category that becomes own is seeded once: copied from the base config home (or from another env's effective config, own or shared), or started empty. A category that is already own is never wiped, and sharing it again keeps its files on disk. `envs config ` without flags describes the scope. Launching an env passes its own categories to Pi through the scope variables. --- bin/gentle-shell.mjs | 80 ++++++++++++++++++-- lib/gentle-shell-envs.ts | 127 ++++++++++++++++++++++++++++++++ runtime/gentle-shell-envs.mjs | 127 ++++++++++++++++++++++++++++++++ tests/gentle-shell-bin.test.ts | 65 ++++++++++++++++ tests/gentle-shell-envs.test.ts | 56 ++++++++++++++ 5 files changed, 450 insertions(+), 5 deletions(-) diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index c9dbdd43e..10f21aed7 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -92,6 +92,7 @@ import { bundledUpdateNext, bundledUpdateNotice, bundledUpdatePlan, + bundledPiConfigDir, nodeBundledFs, parseBundledUpgradeArgs, resolveBundledInstall, @@ -107,6 +108,13 @@ import { 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 { + describeEnvScope, + ENV_SCOPE_FILE, + parseEnvScope, + parseScopeFlags, + planScopeChange, + seedOperations, + serializeEnvScope, envDir, envNameForDir, envsRoot, @@ -704,6 +712,46 @@ async function handleImportPiCommand(args) { // --- named envs (`gentle-shell envs`, lib/gentle-shell-envs*.ts) ------------- +// The env's own Gentle config categories (lib/gentle-config-scope.ts); a +// missing or unreadable scope file means it shares everything. +function readEnvScope(dir) { + return parseEnvScope(readTextOrUndefined(join(dir, ENV_SCOPE_FILE))); +} + +// The shared (base) Gentle config home, exactly as a Pi process of this +// install resolves it without env scope: the bundled prefix's config, then +// GENTLE_PI_CONFIG_HOME, then ~/.pi/gentle-ai. +function sharedConfigHome() { + const install = resolveBundledInstall({ packageRoot, platform: process.platform, fs: bundledFs }); + if (install) return bundledPiConfigDir(install.root); + return process.env.GENTLE_PI_CONFIG_HOME || join(homedir(), ".pi", "gentle-ai"); +} + +// Applies --own/--share/--from/--empty to one env: seeds the categories that +// become own (a copy from the base home or another env, or empty), then +// persists the scope. Sharing a category again keeps its own files on disk. +function applyEnvScope(root, name, flags) { + const dir = envDir(root, name); + const current = readEnvScope(dir); + const plan = planScopeChange({ current, own: flags.own, share: flags.share, seed: flags.empty ? { kind: "empty" } : { kind: "copy", from: flags.from } }); + let sourceDirFor = () => sharedConfigHome(); + if (flags.from !== undefined) { + const fromDir = envDir(root, flags.from); + if (!existsSync(fromDir)) throw Object.assign(new Error(`unknown env "${flags.from}"`), { usage: true }); + const fromOwn = readEnvScope(fromDir); + const shared = sharedConfigHome(); + sourceDirFor = (category) => (fromOwn.includes(category) ? join(fromDir, "gentle-ai") : shared); + } + const targetDir = join(dir, "gentle-ai"); + if (plan.seed.length > 0) mkdirSync(targetDir, { recursive: true, mode: 0o700 }); + for (const op of seedOperations({ categories: plan.seed, mode: flags.empty ? "empty" : "copy", sourceDirFor, targetDir, exists: existsSync })) { + if (op.kind === "copy") copyFileSync(op.from, op.to); + else rmSync(op.path, { force: true }); + } + writeFileAtomically(join(dir, ENV_SCOPE_FILE), serializeEnvScope(plan.next), { mode: 0o600 }); + return plan; +} + function currentEnvsRoot() { return envsRoot(process.env, homedir()); } @@ -859,7 +907,7 @@ function printEnvList(root) { 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 the base home and every env; (default) marks what a plain launch opens\n envs new create an env without launching it"; +const ENVS_USAGE = "Usage: gentle-shell envs [list | new [scope] | config [scope]]\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\n envs config show or change which Gentle config an env owns\n scope: --own --share [--from | --empty]\n categories: models, persona, appearance, behavior; new own ones copy\n from the base home (or --from ), or start --empty"; // Returns the launcher selector to launch with, or undefined when nothing should launch. async function handleEnvsCommand(commandArgs) { @@ -869,13 +917,33 @@ async function handleEnvsCommand(commandArgs) { printEnvList(root); return undefined; } - if (sub === "new" && rest.length === 1) { - const invalid = validateEnvName(rest[0]); + if (sub === "new" && rest.length >= 1) { + const [name, ...flagArgs] = rest; + const invalid = validateEnvName(name); + if (invalid !== undefined) fail(`gentle-shell: ${invalid}`, 2); + const flags = parseScopeFlags(flagArgs); + if (flags.error !== undefined) fail(`gentle-shell: ${flags.error}`, 2); + if (flags.from !== undefined && !existsSync(envDir(root, flags.from))) fail(`gentle-shell: unknown env "${flags.from}"`, 2); + try { + process.stdout.write(`Created env ${name} at ${await createEnv(root, name)}\n`); + if (flags.own.length > 0) process.stdout.write(`Config: ${describeEnvScope(applyEnvScope(root, name, flags).next)}\n`); + } catch (error) { + fail(`gentle-shell: ${error.message}`, error.usage ? 2 : 1); + } + return undefined; + } + if (sub === "config" && rest.length >= 1) { + const [name, ...flagArgs] = rest; + const invalid = validateEnvName(name); if (invalid !== undefined) fail(`gentle-shell: ${invalid}`, 2); + if (!existsSync(envDir(root, name))) fail(`gentle-shell: unknown env "${name}"`, 2); + const flags = parseScopeFlags(flagArgs); + if (flags.error !== undefined) fail(`gentle-shell: ${flags.error}`, 2); try { - process.stdout.write(`Created env ${rest[0]} at ${await createEnv(root, rest[0])}\n`); + const own = flagArgs.length === 0 ? readEnvScope(envDir(root, name)) : applyEnvScope(root, name, flags).next; + process.stdout.write(`${name}: ${describeEnvScope(own)}\n`); } catch (error) { - fail(`gentle-shell: ${error.message}`, 1); + fail(`gentle-shell: ${error.message}`, error.usage ? 2 : 1); } return undefined; } @@ -2288,6 +2356,8 @@ async function main() { 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); } + // The env's own Gentle config categories travel with the launch. + if (home.env !== undefined) home = { ...home, ownConfig: readEnvScope(home.dir) }; // 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/lib/gentle-shell-envs.ts b/lib/gentle-shell-envs.ts index fb5210096..9337950fb 100644 --- a/lib/gentle-shell-envs.ts +++ b/lib/gentle-shell-envs.ts @@ -1,4 +1,5 @@ import { basename, dirname, join, resolve as resolvePath } from "node:path"; +import { CONFIG_CATEGORIES, CONFIG_CATEGORY_FILES, type ConfigCategory, isConfigCategory } from "./gentle-config-scope.ts"; // Named environments for the gentle-shell launcher: pure, side-effect-free // helpers over injected env/config/file text. An env is an ordinary managed @@ -160,3 +161,129 @@ export function summarizeEnv(input: EnvSummaryInput): EnvSummary { ...(input.base ? { base: true } : {}), }; } + +// --- per-env config scope (lib/gentle-config-scope.ts) ------------------------ +// Like VS Code profiles: each category of Gentle's config is either shared +// with the base home (live) or owned by the env (its own copy in +// `/gentle-ai`). Only that persistent choice is stored; copying or +// starting empty is a one-time seed when a category becomes own. + +export const ENV_SCOPE_FILE = ".gentle-shell-env.json"; +const ENV_SCOPE_SCHEMA = 1; + +// Tolerant: a missing, malformed or unknown-schema file means "share +// everything", today's behavior for every env created before scopes existed. +export function parseEnvScope(text: string | undefined): ConfigCategory[] { + const own = parseObject(text); + if (own === undefined || own.schema !== ENV_SCOPE_SCHEMA || !Array.isArray(own.own)) return []; + const listed = own.own.filter((value): value is string => typeof value === "string"); + return CONFIG_CATEGORIES.filter((category) => listed.includes(category)); +} + +export function serializeEnvScope(own: readonly ConfigCategory[]): string { + return `${JSON.stringify({ schema: ENV_SCOPE_SCHEMA, own: CONFIG_CATEGORIES.filter((category) => own.includes(category)) }, null, 2)}\n`; +} + +export interface ScopeFlags { + own: ConfigCategory[]; + share: ConfigCategory[]; + // Env to copy newly own categories from; undefined copies from the base home. + from?: string; + empty: boolean; + error?: string; +} + +function categoryList(value: string): { categories: ConfigCategory[]; error?: string } { + if (value === "all") return { categories: [...CONFIG_CATEGORIES] }; + const categories: ConfigCategory[] = []; + for (const raw of value.split(",").map((part) => part.trim()).filter((part) => part.length > 0)) { + if (!isConfigCategory(raw)) return { categories, error: `unknown config category "${raw}" (use ${CONFIG_CATEGORIES.join(", ")} or all)` }; + if (!categories.includes(raw)) categories.push(raw); + } + return { categories }; +} + +// `--own `, `--share `, `--from ` and `--empty`, as +// `--flag value` or `--flag=value`. +export function parseScopeFlags(args: readonly string[]): ScopeFlags { + const result: ScopeFlags = { own: [], share: [], from: undefined, empty: false }; + const fail = (error: string): ScopeFlags => ({ ...result, error }); + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "--empty") { + result.empty = true; + continue; + } + const match = /^--(own|share|from)(?:=(.*))?$/.exec(arg); + if (match === null) return fail(`unknown option ${arg}`); + const [, flag, inline] = match; + const value = inline ?? args[i + 1]; + if (inline === undefined) i += 1; + if (value === undefined || value.length === 0) return fail(`--${flag} needs a value`); + if (flag === "from") { + const invalid = validateEnvName(value); + if (invalid !== undefined) return fail(invalid); + result.from = value; + continue; + } + const list = categoryList(value); + if (list.error !== undefined) return fail(list.error); + result[flag as "own" | "share"] = list.categories; + } + if (result.from !== undefined && result.empty) return fail("--from and --empty cannot be combined"); + const both = result.own.find((category) => result.share.includes(category)); + if (both !== undefined) return fail(`"${both}" cannot be both own and shared`); + return result; +} + +export type ScopeSeed = { kind: "copy"; from?: string } | { kind: "empty" }; + +export interface ScopeChangePlan { + next: ConfigCategory[]; + // Categories that become own now and need their one-time seed. + seed: ConfigCategory[]; +} + +export function planScopeChange(input: { current: readonly ConfigCategory[]; own: readonly ConfigCategory[]; share: readonly ConfigCategory[]; seed: ScopeSeed }): ScopeChangePlan { + const next = CONFIG_CATEGORIES.filter((category) => !input.share.includes(category) && (input.current.includes(category) || input.own.includes(category))); + return { next, seed: next.filter((category) => !input.current.includes(category)) }; +} + +export type SeedOperation = { kind: "copy"; from: string; to: string } | { kind: "remove"; path: string }; + +// The file operations that seed `categories` in `targetDir`: a copy of each +// file the source has, and a removal of each one it lacks (so the target +// starts from the source's defaults), or only removals when starting empty. +// Never touches a file outside these categories. +export function seedOperations(input: { + categories: readonly ConfigCategory[]; + mode: "copy" | "empty"; + sourceDirFor: (category: ConfigCategory) => string; + targetDir: string; + exists: (path: string) => boolean; +}): SeedOperation[] { + const operations: SeedOperation[] = []; + for (const category of input.categories) { + for (const file of CONFIG_CATEGORY_FILES[category]) { + const to = join(input.targetDir, file); + const from = join(input.sourceDirFor(category), file); + if (input.mode === "copy" && input.exists(from)) operations.push({ kind: "copy", from, to }); + else operations.push({ kind: "remove", path: to }); + } + } + return operations; +} + +export const CONFIG_CATEGORY_LABELS: Readonly> = Object.freeze({ + models: "models & profiles", + persona: "persona", + appearance: "appearance", + behavior: "behavior", +}); + +export function describeEnvScope(own: readonly ConfigCategory[]): string { + if (own.length === 0) return "shares everything with the base home"; + if (own.length === CONFIG_CATEGORIES.length) return "fully own (shares nothing)"; + const label = (list: readonly ConfigCategory[]) => list.map((category) => CONFIG_CATEGORY_LABELS[category]).join(", "); + return `own ${label(CONFIG_CATEGORIES.filter((c) => own.includes(c)))} · shares ${label(CONFIG_CATEGORIES.filter((c) => !own.includes(c)))}`; +} diff --git a/runtime/gentle-shell-envs.mjs b/runtime/gentle-shell-envs.mjs index 9920540e0..09a3eeb7e 100644 --- a/runtime/gentle-shell-envs.mjs +++ b/runtime/gentle-shell-envs.mjs @@ -1,5 +1,6 @@ // Generated by scripts/build-runtime-modules.mjs. Do not edit. import { basename, dirname, join, resolve as resolvePath } from "node:path"; +import { CONFIG_CATEGORIES, CONFIG_CATEGORY_FILES, isConfigCategory } from "./gentle-config-scope.mjs"; // Named environments for the gentle-shell launcher: pure, side-effect-free // helpers over injected env/config/file text. An env is an ordinary managed @@ -161,3 +162,129 @@ export function summarizeEnv(input ) { ...(input.base ? { base: true } : {}), }; } + +// --- per-env config scope (lib/gentle-config-scope.ts) ------------------------ +// Like VS Code profiles: each category of Gentle's config is either shared +// with the base home (live) or owned by the env (its own copy in +// `/gentle-ai`). Only that persistent choice is stored; copying or +// starting empty is a one-time seed when a category becomes own. + +export const ENV_SCOPE_FILE = ".gentle-shell-env.json"; +const ENV_SCOPE_SCHEMA = 1; + +// Tolerant: a missing, malformed or unknown-schema file means "share +// everything", today's behavior for every env created before scopes existed. +export function parseEnvScope(text ) { + const own = parseObject(text); + if (own === undefined || own.schema !== ENV_SCOPE_SCHEMA || !Array.isArray(own.own)) return []; + const listed = own.own.filter((value) => typeof value === "string"); + return CONFIG_CATEGORIES.filter((category) => listed.includes(category)); +} + +export function serializeEnvScope(own ) { + return `${JSON.stringify({ schema: ENV_SCOPE_SCHEMA, own: CONFIG_CATEGORIES.filter((category) => own.includes(category)) }, null, 2)}\n`; +} + + + + + + + + + + +function categoryList(value ) { + if (value === "all") return { categories: [...CONFIG_CATEGORIES] }; + const categories = []; + for (const raw of value.split(",").map((part) => part.trim()).filter((part) => part.length > 0)) { + if (!isConfigCategory(raw)) return { categories, error: `unknown config category "${raw}" (use ${CONFIG_CATEGORIES.join(", ")} or all)` }; + if (!categories.includes(raw)) categories.push(raw); + } + return { categories }; +} + +// `--own `, `--share `, `--from ` and `--empty`, as +// `--flag value` or `--flag=value`. +export function parseScopeFlags(args ) { + const result = { own: [], share: [], from: undefined, empty: false }; + const fail = (error ) => ({ ...result, error }); + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "--empty") { + result.empty = true; + continue; + } + const match = /^--(own|share|from)(?:=(.*))?$/.exec(arg); + if (match === null) return fail(`unknown option ${arg}`); + const [, flag, inline] = match; + const value = inline ?? args[i + 1]; + if (inline === undefined) i += 1; + if (value === undefined || value.length === 0) return fail(`--${flag} needs a value`); + if (flag === "from") { + const invalid = validateEnvName(value); + if (invalid !== undefined) return fail(invalid); + result.from = value; + continue; + } + const list = categoryList(value); + if (list.error !== undefined) return fail(list.error); + result[flag ] = list.categories; + } + if (result.from !== undefined && result.empty) return fail("--from and --empty cannot be combined"); + const both = result.own.find((category) => result.share.includes(category)); + if (both !== undefined) return fail(`"${both}" cannot be both own and shared`); + return result; +} + + + + + + + + + +export function planScopeChange(input ) { + const next = CONFIG_CATEGORIES.filter((category) => !input.share.includes(category) && (input.current.includes(category) || input.own.includes(category))); + return { next, seed: next.filter((category) => !input.current.includes(category)) }; +} + + + +// The file operations that seed `categories` in `targetDir`: a copy of each +// file the source has, and a removal of each one it lacks (so the target +// starts from the source's defaults), or only removals when starting empty. +// Never touches a file outside these categories. +export function seedOperations(input + + + + + + ) { + const operations = []; + for (const category of input.categories) { + for (const file of CONFIG_CATEGORY_FILES[category]) { + const to = join(input.targetDir, file); + const from = join(input.sourceDirFor(category), file); + if (input.mode === "copy" && input.exists(from)) operations.push({ kind: "copy", from, to }); + else operations.push({ kind: "remove", path: to }); + } + } + return operations; +} + +export const CONFIG_CATEGORY_LABELS = Object.freeze({ + models: "models & profiles", + persona: "persona", + appearance: "appearance", + behavior: "behavior", +}); + +export function describeEnvScope(own ) { + if (own.length === 0) return "shares everything with the base home"; + if (own.length === CONFIG_CATEGORIES.length) return "fully own (shares nothing)"; + const label = (list ) => list.map((category) => CONFIG_CATEGORY_LABELS[category]).join(", "); + return `own ${label(CONFIG_CATEGORIES.filter((c) => own.includes(c)))} · shares ${label(CONFIG_CATEGORIES.filter((c) => !own.includes(c)))}`; +} diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index b5f1b1ef7..f5ba431a8 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3627,3 +3627,68 @@ test("the envs manager never lists or touches a symlinked env", (t) => { assert.equal(readFileSync(join(outside, "keep.txt"), "utf8"), "keep"); assert.deepEqual(readdirSync(envs), ["linked"]); }); + +// --- per-env config scope (envs new/config, launch scope) ----------------------- + +test("envs new --own seeds own categories from the base config home and the launch scopes them", (t) => { + const f = fixture(t); + const base = join(f.root, "base-config"); + const env = { ...f.env, GENTLE_PI_CONFIG_HOME: base }; + mkdirSync(base, { recursive: true }); + writeFileSync(join(base, "persona.json"), '{"persona":"base"}'); + writeFileSync(join(base, "models.json"), '{"routing":"base"}'); + const created = run(env, ["envs", "new", "work", "--own", "persona"]); + assert.equal(created.status, 0, created.stderr); + const dir = join(f.home, ".gentle-shell", "envs", "work"); + assert.equal(readFileSync(join(dir, "gentle-ai", "persona.json"), "utf8"), '{"persona":"base"}', "copied from the base"); + assert.equal(existsSync(join(dir, "gentle-ai", "models.json")), false, "shared categories are not copied"); + assert.deepEqual(JSON.parse(readFileSync(join(dir, ".gentle-shell-env.json"), "utf8")), { schema: 1, own: ["persona"] }); + const launched = JSON.parse(run(env, ["--env", "work", "-p", "hi"]).stdout.trim().split("\n").at(-1) ?? "{}"); + assert.equal(launched.PI_CODING_AGENT_DIR, dir); + const shown = run(env, ["envs", "config", "work"]); + assert.equal(shown.status, 0, shown.stderr); + assert.match(shown.stdout, /own persona · shares models & profiles, appearance, behavior/); +}); + +test("envs config changes scope with --from another env or --empty, and --share keeps own files", (t) => { + const f = fixture(t); + const base = join(f.root, "base-config"); + const env = { ...f.env, GENTLE_PI_CONFIG_HOME: base }; + mkdirSync(base, { recursive: true }); + writeFileSync(join(base, "persona.json"), '{"persona":"base"}'); + assert.equal(run(env, ["envs", "new", "a", "--own", "persona"]).status, 0); + const envs = join(f.home, ".gentle-shell", "envs"); + writeFileSync(join(envs, "a", "gentle-ai", "persona.json"), '{"persona":"a"}'); + assert.equal(run(env, ["envs", "new", "b"]).status, 0); + assert.equal(run(env, ["envs", "config", "b", "--own", "persona", "--from", "a"]).status, 0); + assert.equal(readFileSync(join(envs, "b", "gentle-ai", "persona.json"), "utf8"), '{"persona":"a"}', "copied from env a's own persona"); + assert.equal(run(env, ["envs", "config", "a", "--share", "persona"]).status, 0); + assert.equal(JSON.parse(readFileSync(join(envs, "a", ".gentle-shell-env.json"), "utf8")).own.length, 0); + assert.equal(existsSync(join(envs, "a", "gentle-ai", "persona.json")), true, "sharing again keeps the own copy on disk"); + assert.equal(run(env, ["envs", "config", "b", "--own", "all", "--empty"]).status, 0); + assert.equal(existsSync(join(envs, "b", "gentle-ai", "persona.json")), true, "already own: --empty never wipes it"); + assert.equal(existsSync(join(envs, "b", "gentle-ai", "models.json")), false); + const bad = run(env, ["envs", "config", "b", "--own", "persona", "--from", "nope"]); + assert.equal(bad.status, 2); + assert.match(bad.stderr, /unknown env "nope"/); + assert.equal(run(env, ["envs", "config", "nope"]).status, 2); +}); + +test("a launched env carries its own config scope to Pi, and a sharing env clears an inherited one", (t) => { + const f = fixture(t); + const pi = join(f.root, "scope-pi.mjs"); + writeFileSync(pi, [ + "#!/usr/bin/env node", + "if (process.argv.includes('--version')) { console.log('1.2.0'); process.exit(0); }", + "console.log(JSON.stringify({ home: process.env.GENTLE_SHELL_ENV_CONFIG_HOME ?? null, own: process.env.GENTLE_SHELL_ENV_OWN_CONFIG ?? null }));", + "", + ].join("\n")); + chmodSync(pi, 0o755); + const env = { ...f.env, GENTLE_SHELL_PI: pi, GENTLE_PI_CONFIG_HOME: join(f.root, "base-config"), GENTLE_SHELL_ENV_CONFIG_HOME: "/outer/gentle-ai", GENTLE_SHELL_ENV_OWN_CONFIG: "models" }; + assert.equal(run(env, ["envs", "new", "own", "--own", "persona,behavior", "--empty"]).status, 0); + assert.equal(run(env, ["envs", "new", "plain"]).status, 0); + const last = (args: string[]) => JSON.parse(run(env, args).stdout.trim().split("\n").at(-1) ?? "{}"); + assert.deepEqual(last(["--env", "own", "-p", "hi"]), { home: join(f.home, ".gentle-shell", "envs", "own", "gentle-ai"), own: "persona,behavior" }); + assert.deepEqual(last(["--env", "plain", "-p", "hi"]), { home: null, own: null }); + assert.deepEqual(last(["-p", "hi"]), { home: null, own: null }, "the base home never inherits env scope"); +}); diff --git a/tests/gentle-shell-envs.test.ts b/tests/gentle-shell-envs.test.ts index 49fbc9232..bd607d07b 100644 --- a/tests/gentle-shell-envs.test.ts +++ b/tests/gentle-shell-envs.test.ts @@ -200,3 +200,59 @@ test("buildPiInvocation scopes own config categories to the env and never leaks const isolated = buildPiInvocation({ ...base, home: { mode: "isolated", dir: "/h/.gentle-shell/agent", source: "default" }, baseEnv: inherited }); assert.ok(!("GENTLE_SHELL_ENV_CONFIG_HOME" in isolated.env), "the base home never inherits env scope"); }); + +// --- per-env config scope (VS Code-like profiles) -------------------------------- + +test("env scope files are tolerant: anything unreadable means sharing everything", async () => { + const { parseEnvScope, serializeEnvScope } = await import("../lib/gentle-shell-envs.ts"); + assert.deepEqual(parseEnvScope(undefined), []); + assert.deepEqual(parseEnvScope("{"), []); + assert.deepEqual(parseEnvScope(JSON.stringify({ schema: 2, own: ["persona"] })), [], "unknown schema"); + assert.deepEqual(parseEnvScope(JSON.stringify({ schema: 1, own: ["appearance", "bogus", "persona", "persona"] })), ["persona", "appearance"]); + assert.deepEqual(parseEnvScope(serializeEnvScope(["behavior", "models"])), ["models", "behavior"]); +}); + +test("parseScopeFlags reads --own/--share/--from/--empty with all and validation", async () => { + const { parseScopeFlags } = await import("../lib/gentle-shell-envs.ts"); + assert.deepEqual(parseScopeFlags([]), { own: [], share: [], from: undefined, empty: false }); + assert.deepEqual(parseScopeFlags(["--own", "persona,appearance", "--from", "work"]), { own: ["persona", "appearance"], share: [], from: "work", empty: false }); + assert.deepEqual(parseScopeFlags(["--own=all", "--empty"]).own, ["models", "persona", "appearance", "behavior"]); + assert.deepEqual(parseScopeFlags(["--share", "all"]).share, ["models", "persona", "appearance", "behavior"]); + assert.match(parseScopeFlags(["--own", "bogus"]).error ?? "", /unknown config category "bogus"/); + assert.match(parseScopeFlags(["--own"]).error ?? "", /--own needs/); + assert.match(parseScopeFlags(["--from", "work", "--empty"]).error ?? "", /--from and --empty/); + assert.match(parseScopeFlags(["--own", "persona", "--share", "persona"]).error ?? "", /both own and shared/); + assert.match(parseScopeFlags(["--bogus"]).error ?? "", /unknown option/); + assert.match(parseScopeFlags(["--from", "Bad"]).error ?? "", /env name/); +}); + +test("planScopeChange seeds only categories that become own, from a source or empty", async () => { + const { planScopeChange } = await import("../lib/gentle-shell-envs.ts"); + const plan = planScopeChange({ current: ["persona"], own: ["persona", "appearance", "models"], share: [], seed: { kind: "copy" } }); + assert.deepEqual(plan.next, ["models", "persona", "appearance"]); + assert.deepEqual(plan.seed, ["models", "appearance"], "persona was already own: its files are kept"); + const back = planScopeChange({ current: ["persona", "models"], own: [], share: ["models"], seed: { kind: "empty" } }); + assert.deepEqual(back.next, ["persona"]); + assert.deepEqual(back.seed, []); +}); + +test("seedOperations copies existing source files and clears the rest, never outside the category", async () => { + const { seedOperations } = await import("../lib/gentle-shell-envs.ts"); + const exists = (path: string) => ["/src/persona.json", "/src/card-style.json"].includes(path); + const ops = seedOperations({ categories: ["persona", "appearance"], mode: "copy", sourceDirFor: () => "/src", targetDir: "/t", exists }); + assert.deepEqual(ops.filter((op) => op.kind === "copy"), [ + { kind: "copy", from: join("/src", "persona.json"), to: join("/t", "persona.json") }, + { kind: "copy", from: join("/src", "card-style.json"), to: join("/t", "card-style.json") }, + ]); + assert.ok(ops.some((op) => op.kind === "remove" && op.path === join("/t", "banner.json")), "a source default resets the target"); + assert.ok(ops.every((op) => !(op.kind === "remove" && op.path.endsWith("models.json"))), "other categories untouched"); + const empty = seedOperations({ categories: ["persona"], mode: "empty", sourceDirFor: () => "/src", targetDir: "/t", exists }); + assert.deepEqual(empty, [{ kind: "remove", path: join("/t", "persona.json") }]); +}); + +test("describeEnvScope reads like a sentence for the gate", async () => { + const { describeEnvScope } = await import("../lib/gentle-shell-envs.ts"); + assert.equal(describeEnvScope([]), "shares everything with the base home"); + assert.equal(describeEnvScope(["models", "persona", "appearance", "behavior"]), "fully own (shares nothing)"); + assert.equal(describeEnvScope(["persona", "appearance"]), "own persona, appearance · shares models & profiles, behavior"); +}); From 56e7114ce17532bfc88e2e08a82c0d99e8b142c7 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 17:14:40 -0400 Subject: [PATCH 18/36] feat(launcher): VS Code-like config form in the envs gate Creating an env with `n` now opens a form, like a new VS Code profile. Each category (models & profiles, persona, appearance, behavior) is either Shared with base or an Own copy, and you pick where new own copies come from: the base home, another env, or empty defaults. Enter with the defaults shares everything, exactly as before. `c` opens the same form for an existing env and applies only the changes; the base home explains it is the shared configuration. The detail card gains a Config row describing each env's scope. Fixes summarizeEnv dropping the env's own categories, which made the gate always report "shares everything". --- bin/gentle-shell.mjs | 13 ++- lib/gentle-shell-envs-tui.ts | 131 ++++++++++++++++++++++++++-- lib/gentle-shell-envs.ts | 5 ++ runtime/gentle-shell-envs-tui.mjs | 129 +++++++++++++++++++++++++-- runtime/gentle-shell-envs.mjs | 5 ++ tests/gentle-shell-envs-tui.test.ts | 85 +++++++++++++++++- tests/gentle-shell-envs.test.ts | 5 ++ 7 files changed, 349 insertions(+), 24 deletions(-) diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index 10f21aed7..59e6ce1d6 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -833,7 +833,7 @@ function loadEnvSummaries(root) { }; 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), {}))]; + return [base, ...listEnvNames(root).map((name) => summarize(name, envDir(root, name), { own: readEnvScope(envDir(root, name)) }))]; } // The launcher selector that reproduces an entry: `--isolated` for the base @@ -1004,7 +1004,7 @@ function envsScreenSession(initial, apply) { state = step.state; if (step.effect === undefined) continue; const kind = step.effect.kind; - if (kind === "create" || kind === "rename" || kind === "delete" || kind === "set-default") { + if (kind === "create" || kind === "rename" || kind === "delete" || kind === "set-default" || kind === "scope") { state = await apply(state, step.effect); continue; } @@ -1044,7 +1044,14 @@ async function runEnvsManager(root) { 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); + const scopeNote = effect.own.length > 0 + ? ` Config: ${describeEnvScope(applyEnvScope(root, effect.name, { own: effect.own, share: [], from: effect.from, empty: effect.empty === true }).next)}.` + : ""; + return reload(`Created ${effect.name}.${scopeNote} Press enter to launch it (first launch installs the companion packages).`, effect.name); + } + if (effect.kind === "scope") { + const plan = applyEnvScope(root, effect.name, { own: effect.own, share: effect.share, from: effect.from, empty: effect.empty === true }); + return reload(`${effect.name}: ${describeEnvScope(plan.next)}. Applies from its next session.`, effect.name); } if (effect.kind === "rename") { renameEnv(root, effect.from, effect.to); diff --git a/lib/gentle-shell-envs-tui.ts b/lib/gentle-shell-envs-tui.ts index 3ec1b2585..079d384d6 100644 --- a/lib/gentle-shell-envs-tui.ts +++ b/lib/gentle-shell-envs-tui.ts @@ -1,5 +1,6 @@ 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"; +import { CONFIG_CATEGORIES, type ConfigCategory } from "./gentle-config-scope.ts"; +import { CONFIG_CATEGORY_LABELS, describeEnvScope, 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 @@ -9,6 +10,8 @@ import { type EnvSummary, validateEnvName } from "./gentle-shell-envs.ts"; export type EnvsKey = | { name: "up" } | { name: "down" } + | { name: "left" } + | { name: "right" } | { name: "enter" } | { name: "backspace" } | { name: "escape" } @@ -23,7 +26,10 @@ export type EnvsEffect = | { kind: "set-default"; name: string; base?: true } | { kind: "setup"; name: string; base?: true } | { kind: "auth-check"; name: string; base?: true } - | { kind: "create"; name: string } + // own: categories the new env owns; from/empty: where their copies come + // from (the base home when neither is set). + | { kind: "create"; name: string; own: ConfigCategory[]; from?: string; empty?: true } + | { kind: "scope"; name: string; own: ConfigCategory[]; share: ConfigCategory[]; from?: string; empty?: true } | { kind: "rename"; from: string; to: string } | { kind: "delete"; name: string } | { kind: "install"; name: string; source: string; base?: true }; @@ -40,10 +46,26 @@ export interface EnvsPrompt { error?: string; } +// The VS Code-like config form: per category, shared with the base home or +// an own copy; plus where newly own copies come from. +export type ScopeSource = { kind: "base" } | { kind: "env"; name: string } | { kind: "empty" }; + +export interface EnvsScopeForm { + mode: "create" | "edit"; + name: string; + own: ConfigCategory[]; + // The scope the env had when the form opened (edit), [] for a new env. + initial: ConfigCategory[]; + source: ScopeSource; + // 0..3 the categories, 4 the source row. + cursor: number; +} + export interface EnvsState { envs: EnvSummary[]; selected: number; prompt?: EnvsPrompt; + scope?: EnvsScopeForm; // One-line status shown above the key hints (results, hints). message?: string; } @@ -74,6 +96,8 @@ export function parseKeys(data: string): EnvsKey[] { const sequence = chars.slice(i + 2, j + 1).join(""); if (sequence === "A") keys.push({ name: "up" }); else if (sequence === "B") keys.push({ name: "down" }); + else if (sequence === "C") keys.push({ name: "right" }); + else if (sequence === "D") keys.push({ name: "left" }); i = j; continue; } @@ -117,7 +141,11 @@ function reducePrompt(state: EnvsState, prompt: EnvsPrompt, key: EnvsKey): EnvsS const invalid = validateEnvName(value); if (invalid !== undefined) return fail(invalid); 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 === "create") { + // Like a new VS Code profile: choose what to share before creating. + return { state: { ...state, prompt: undefined, scope: { mode: "create", name: value, own: [], initial: [], source: { kind: "base" }, cursor: 0 } } }; + } + return done({ kind: "rename", from: target, to: value }); } if (prompt.kind === "delete") { if (value !== target) return fail(`type "${target}" to confirm, or esc to cancel`); @@ -127,8 +155,51 @@ function reducePrompt(state: EnvsState, prompt: EnvsPrompt, key: EnvsKey): EnvsS return done(prompt.targetBase ? { kind: "install", name: target, source: value, base: true } : { kind: "install", name: target, source: value }); } +// Sources a newly own copy can come from: the base home, every other env, or empty. +function scopeSources(state: EnvsState, form: EnvsScopeForm): ScopeSource[] { + const envs = state.envs.filter((env) => !env.base && env.name !== form.name).map((env): ScopeSource => ({ kind: "env", name: env.name })); + return [{ kind: "base" }, ...envs, { kind: "empty" }]; +} + +function sameSource(a: ScopeSource, b: ScopeSource): boolean { + return a.kind === b.kind && (a.kind !== "env" || (b.kind === "env" && a.name === b.name)); +} + +function sourceFields(source: ScopeSource): { from?: string; empty?: true } { + return source.kind === "env" ? { from: source.name } : source.kind === "empty" ? { empty: true } : {}; +} + +function reduceScope(state: EnvsState, form: EnvsScopeForm, key: EnvsKey): EnvsStep { + const set = (next: Partial): EnvsStep => ({ state: { ...state, scope: { ...form, ...next } } }); + if (key.name === "escape" || key.name === "ctrl-c") return { state: { ...state, scope: undefined } }; + if (key.name === "up") return set({ cursor: Math.max(0, form.cursor - 1) }); + if (key.name === "down") return set({ cursor: Math.min(CONFIG_CATEGORIES.length, form.cursor + 1) }); + const char = key.name === "char" ? key.char : undefined; + if (char === "a") return set({ own: form.own.length === CONFIG_CATEGORIES.length ? [] : [...CONFIG_CATEGORIES] }); + const toggle = char === " " || key.name === "left" || key.name === "right"; + if (toggle && form.cursor < CONFIG_CATEGORIES.length) { + const category = CONFIG_CATEGORIES[form.cursor]; + const own = form.own.includes(category) ? form.own.filter((c) => c !== category) : [...form.own, category]; + return set({ own: CONFIG_CATEGORIES.filter((c) => own.includes(c)) }); + } + if (toggle) { + const sources = scopeSources(state, form); + const index = sources.findIndex((source) => sameSource(source, form.source)); + const step = key.name === "left" ? -1 : 1; + return set({ source: sources[(index + step + sources.length) % sources.length] }); + } + if (key.name !== "enter") return { state }; + const close = { ...state, scope: undefined }; + if (form.mode === "create") return { state: close, effect: { kind: "create", name: form.name, own: form.own, ...sourceFields(form.source) } }; + const share = form.initial.filter((c) => !form.own.includes(c)); + const added = form.own.filter((c) => !form.initial.includes(c)); + if (share.length === 0 && added.length === 0) return { state: close }; + return { state: close, effect: { kind: "scope", name: form.name, own: added, share, ...(added.length > 0 ? sourceFields(form.source) : {}) } }; +} + export function reduceEnvs(state: EnvsState, key: EnvsKey): EnvsStep { if (state.prompt !== undefined) return reducePrompt(state, state.prompt, key); + if (state.scope !== undefined) return reduceScope(state, state.scope, key); if (key.name === "ctrl-c" || key.name === "escape") return { state, effect: { kind: "quit" } }; const last = state.envs.length - 1; @@ -139,7 +210,7 @@ export function reduceEnvs(state: EnvsState, key: EnvsKey): EnvsStep { 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)); + const scoped = key.name === "enter" || (char !== undefined && "sparidc".includes(char)); if (!scoped) return { state }; if (current === undefined) return { state: { ...state, message: EMPTY_HINT } }; const name = current.name; @@ -151,6 +222,11 @@ export function reduceEnvs(state: EnvsState, key: EnvsKey): EnvsStep { 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 === "c") { + if (current.base) return { state: { ...state, message: "The base home holds the shared configuration every env can reuse; press c on an env to choose what it owns." } }; + const own = current.own ?? []; + return { state: { ...state, message: undefined, scope: { mode: "edit", name, own, initial: own, source: { kind: "base" }, cursor: 0 } } }; + } 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 "${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 }); @@ -270,22 +346,53 @@ function detailRows(env: EnvSummary | undefined, inner: number, paint: Paint): C field("Setup", env.setUp === false ? "not set up yet" : plain(env.provisioned)), field("Sessions", sessions), field("Sign-ins", env.providers.length > 0 ? env.providers.map(plain).join(", ") : "none yet: /login inside the session"), + field("Config", env.base ? "the shared configuration envs can reuse" : describeEnvScope(env.own ?? [])), field("Packages", env.packages.length > 0 ? `${env.packages.length}` : env.setUp === false ? "installed when you first open it" : "none"), ...env.packages.map((source) => ({ text: ` · ${plain(source)}`, style: paint.muted })), ]; } +// The VS Code-like config form, drawn in place of the detail card. +const CATEGORY_TITLES: Readonly> = Object.freeze({ + models: "Models & profiles", + persona: "Persona", + appearance: "Appearance", + behavior: "Behavior", +}); + +function sourceLabel(source: ScopeSource): string { + return source.kind === "base" ? `${BRAND_NAME} base` : source.kind === "env" ? `env ${source.name}` : "Empty (defaults)"; +} + +function scopeRows(form: EnvsScopeForm, paint: Paint): CardRow[] { + const row = (selected: boolean, label: string, value: string, width = 20): CardRow => { + const text = `${selected ? "❯" : " "} ${label.padEnd(width)}${value}`; + return { text, style: selected ? (fitted: string) => paint.rose(paint.bold(fitted)) : undefined }; + }; + return [ + { text: form.mode === "create" ? "Choose what this env shares with the base home." : "Change what this env owns.", style: paint.label }, + { text: "" }, + ...CONFIG_CATEGORIES.map((category, index) => row(form.cursor === index, CATEGORY_TITLES[category], form.own.includes(category) ? "Own copy" : "Shared with base")), + { text: "" }, + row(form.cursor === CONFIG_CATEGORIES.length, "New own copies from", sourceLabel(form.source), 21), + { text: "" }, + { text: "Shared follows the base home live; an own copy diverges.", style: paint.muted }, + ]; +} + +const SCOPE_HINTS: Array<[string, string]> = [["↑↓", "move"], ["space", "toggle"], ["←→", "change"], ["a", "all"], ["esc", "cancel"]]; + const HINTS: Array<[string, string]> = [ ["enter", "open"], ["n", "new"], ["r", "rename"], ["d", "delete"], ["s", "default"], - ["p", "setup"], ["i", "install"], ["a", "sign-ins"], ["q", "quit"], + ["p", "setup"], ["i", "install"], ["a", "sign-ins"], ["c", "config"], ["q", "quit"], ]; // Wrapped, never truncated: every action stays discoverable at any width. -function hintLines(width: number, paint: Paint): string[] { +function hintLines(width: number, paint: Paint, hints: Array<[string, string]> = HINTS): string[] { const lines: string[] = []; let parts: string[] = []; let used = 0; - for (const [key, action] of HINTS) { + 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) { @@ -312,6 +419,10 @@ export function renderEnvsScreen(state: EnvsState, width: number, height: number const p = state.prompt; 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 if (state.scope !== undefined) { + const hints: Array<[string, string]> = [...SCOPE_HINTS.slice(0, 4), ["enter", state.scope.mode === "create" ? "create" : "apply"], SCOPE_HINTS[4]]; + footer.push(""); + footer.push(...hintLines(Math.min(w, 110), paint, hints).map((line) => center(line, w))); } else { 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))); @@ -340,7 +451,8 @@ export function renderEnvsScreen(state: EnvsState, width: number, height: number 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 form = state.scope; + const details = form !== undefined ? scopeRows(form, paint) : 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)); @@ -358,7 +470,8 @@ export function renderEnvsScreen(state: EnvsState, width: number, height: number 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 === undefined ? "Welcome" : selectedEnv.base ? "Base home" : selectedEnv.name, details, detailWidth, bodyHeight, paint); + const title = form !== undefined ? `${form.mode === "create" ? "New env" : "Config"} · ${form.name}` : selectedEnv === undefined ? "Welcome" : selectedEnv.base ? "Base home" : selectedEnv.name; + const detail = card(title, 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 9337950fb..71b70ce56 100644 --- a/lib/gentle-shell-envs.ts +++ b/lib/gentle-shell-envs.ts @@ -104,6 +104,8 @@ export interface EnvSummaryInput { lastSessionAt?: string; // The install's own home (`--isolated`), listed beside the named envs. base?: boolean; + // Gentle config categories this env owns; omitted means none. + own?: readonly ConfigCategory[]; } export interface EnvSummary { @@ -119,6 +121,8 @@ export interface EnvSummary { sessionCount: number; lastSessionAt?: string; base?: boolean; + // Gentle config categories this env owns (lib/gentle-config-scope.ts). + own?: ConfigCategory[]; } function parseObject(text: string | undefined): Record | undefined { @@ -159,6 +163,7 @@ export function summarizeEnv(input: EnvSummaryInput): EnvSummary { sessionCount: input.sessionCount, lastSessionAt: input.lastSessionAt, ...(input.base ? { base: true } : {}), + own: [...(input.own ?? [])], }; } diff --git a/runtime/gentle-shell-envs-tui.mjs b/runtime/gentle-shell-envs-tui.mjs index 5de1eb388..456b4d67d 100644 --- a/runtime/gentle-shell-envs-tui.mjs +++ b/runtime/gentle-shell-envs-tui.mjs @@ -1,6 +1,7 @@ // 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"; +import { CONFIG_CATEGORIES, } from "./gentle-config-scope.mjs"; +import { CONFIG_CATEGORY_LABELS, describeEnvScope, 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 @@ -16,6 +17,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. @@ -38,6 +41,25 @@ import { validateEnvName } from "./gentle-shell-envs.mjs"; + + + + + + +// The VS Code-like config form: per category, shared with the base home or +// an own copy; plus where newly own copies come from. + + + + + + + + + + + @@ -75,6 +97,8 @@ export function parseKeys(data ) { const sequence = chars.slice(i + 2, j + 1).join(""); if (sequence === "A") keys.push({ name: "up" }); else if (sequence === "B") keys.push({ name: "down" }); + else if (sequence === "C") keys.push({ name: "right" }); + else if (sequence === "D") keys.push({ name: "left" }); i = j; continue; } @@ -118,7 +142,11 @@ function reducePrompt(state , prompt , key ) const invalid = validateEnvName(value); if (invalid !== undefined) return fail(invalid); 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 === "create") { + // Like a new VS Code profile: choose what to share before creating. + return { state: { ...state, prompt: undefined, scope: { mode: "create", name: value, own: [], initial: [], source: { kind: "base" }, cursor: 0 } } }; + } + return done({ kind: "rename", from: target, to: value }); } if (prompt.kind === "delete") { if (value !== target) return fail(`type "${target}" to confirm, or esc to cancel`); @@ -128,8 +156,51 @@ function reducePrompt(state , prompt , key ) return done(prompt.targetBase ? { kind: "install", name: target, source: value, base: true } : { kind: "install", name: target, source: value }); } +// Sources a newly own copy can come from: the base home, every other env, or empty. +function scopeSources(state , form ) { + const envs = state.envs.filter((env) => !env.base && env.name !== form.name).map((env) => ({ kind: "env", name: env.name })); + return [{ kind: "base" }, ...envs, { kind: "empty" }]; +} + +function sameSource(a , b ) { + return a.kind === b.kind && (a.kind !== "env" || (b.kind === "env" && a.name === b.name)); +} + +function sourceFields(source ) { + return source.kind === "env" ? { from: source.name } : source.kind === "empty" ? { empty: true } : {}; +} + +function reduceScope(state , form , key ) { + const set = (next ) => ({ state: { ...state, scope: { ...form, ...next } } }); + if (key.name === "escape" || key.name === "ctrl-c") return { state: { ...state, scope: undefined } }; + if (key.name === "up") return set({ cursor: Math.max(0, form.cursor - 1) }); + if (key.name === "down") return set({ cursor: Math.min(CONFIG_CATEGORIES.length, form.cursor + 1) }); + const char = key.name === "char" ? key.char : undefined; + if (char === "a") return set({ own: form.own.length === CONFIG_CATEGORIES.length ? [] : [...CONFIG_CATEGORIES] }); + const toggle = char === " " || key.name === "left" || key.name === "right"; + if (toggle && form.cursor < CONFIG_CATEGORIES.length) { + const category = CONFIG_CATEGORIES[form.cursor]; + const own = form.own.includes(category) ? form.own.filter((c) => c !== category) : [...form.own, category]; + return set({ own: CONFIG_CATEGORIES.filter((c) => own.includes(c)) }); + } + if (toggle) { + const sources = scopeSources(state, form); + const index = sources.findIndex((source) => sameSource(source, form.source)); + const step = key.name === "left" ? -1 : 1; + return set({ source: sources[(index + step + sources.length) % sources.length] }); + } + if (key.name !== "enter") return { state }; + const close = { ...state, scope: undefined }; + if (form.mode === "create") return { state: close, effect: { kind: "create", name: form.name, own: form.own, ...sourceFields(form.source) } }; + const share = form.initial.filter((c) => !form.own.includes(c)); + const added = form.own.filter((c) => !form.initial.includes(c)); + if (share.length === 0 && added.length === 0) return { state: close }; + return { state: close, effect: { kind: "scope", name: form.name, own: added, share, ...(added.length > 0 ? sourceFields(form.source) : {}) } }; +} + export function reduceEnvs(state , key ) { if (state.prompt !== undefined) return reducePrompt(state, state.prompt, key); + if (state.scope !== undefined) return reduceScope(state, state.scope, key); if (key.name === "ctrl-c" || key.name === "escape") return { state, effect: { kind: "quit" } }; const last = state.envs.length - 1; @@ -140,7 +211,7 @@ export function reduceEnvs(state , key ) { 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)); + const scoped = key.name === "enter" || (char !== undefined && "sparidc".includes(char)); if (!scoped) return { state }; if (current === undefined) return { state: { ...state, message: EMPTY_HINT } }; const name = current.name; @@ -152,6 +223,11 @@ export function reduceEnvs(state , key ) { 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 === "c") { + if (current.base) return { state: { ...state, message: "The base home holds the shared configuration every env can reuse; press c on an env to choose what it owns." } }; + const own = current.own ?? []; + return { state: { ...state, message: undefined, scope: { mode: "edit", name, own, initial: own, source: { kind: "base" }, cursor: 0 } } }; + } 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 "${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 }); @@ -271,22 +347,53 @@ function detailRows(env , inner , paint ) field("Setup", env.setUp === false ? "not set up yet" : plain(env.provisioned)), field("Sessions", sessions), field("Sign-ins", env.providers.length > 0 ? env.providers.map(plain).join(", ") : "none yet: /login inside the session"), + field("Config", env.base ? "the shared configuration envs can reuse" : describeEnvScope(env.own ?? [])), field("Packages", env.packages.length > 0 ? `${env.packages.length}` : env.setUp === false ? "installed when you first open it" : "none"), ...env.packages.map((source) => ({ text: ` · ${plain(source)}`, style: paint.muted })), ]; } +// The VS Code-like config form, drawn in place of the detail card. +const CATEGORY_TITLES = Object.freeze({ + models: "Models & profiles", + persona: "Persona", + appearance: "Appearance", + behavior: "Behavior", +}); + +function sourceLabel(source ) { + return source.kind === "base" ? `${BRAND_NAME} base` : source.kind === "env" ? `env ${source.name}` : "Empty (defaults)"; +} + +function scopeRows(form , paint ) { + const row = (selected , label , value , width = 20) => { + const text = `${selected ? "❯" : " "} ${label.padEnd(width)}${value}`; + return { text, style: selected ? (fitted ) => paint.rose(paint.bold(fitted)) : undefined }; + }; + return [ + { text: form.mode === "create" ? "Choose what this env shares with the base home." : "Change what this env owns.", style: paint.label }, + { text: "" }, + ...CONFIG_CATEGORIES.map((category, index) => row(form.cursor === index, CATEGORY_TITLES[category], form.own.includes(category) ? "Own copy" : "Shared with base")), + { text: "" }, + row(form.cursor === CONFIG_CATEGORIES.length, "New own copies from", sourceLabel(form.source), 21), + { text: "" }, + { text: "Shared follows the base home live; an own copy diverges.", style: paint.muted }, + ]; +} + +const SCOPE_HINTS = [["↑↓", "move"], ["space", "toggle"], ["←→", "change"], ["a", "all"], ["esc", "cancel"]]; + const HINTS = [ ["enter", "open"], ["n", "new"], ["r", "rename"], ["d", "delete"], ["s", "default"], - ["p", "setup"], ["i", "install"], ["a", "sign-ins"], ["q", "quit"], + ["p", "setup"], ["i", "install"], ["a", "sign-ins"], ["c", "config"], ["q", "quit"], ]; // Wrapped, never truncated: every action stays discoverable at any width. -function hintLines(width , paint ) { +function hintLines(width , paint , hints = HINTS) { const lines = []; let parts = []; let used = 0; - for (const [key, action] of HINTS) { + 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) { @@ -313,6 +420,10 @@ export function renderEnvsScreen(state , width , height const p = state.prompt; 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 if (state.scope !== undefined) { + const hints = [...SCOPE_HINTS.slice(0, 4), ["enter", state.scope.mode === "create" ? "create" : "apply"], SCOPE_HINTS[4]]; + footer.push(""); + footer.push(...hintLines(Math.min(w, 110), paint, hints).map((line) => center(line, w))); } else { 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))); @@ -341,7 +452,8 @@ export function renderEnvsScreen(state , width , height 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 form = state.scope; + const details = form !== undefined ? scopeRows(form, paint) : 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)); @@ -359,7 +471,8 @@ export function renderEnvsScreen(state , width , height 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 === undefined ? "Welcome" : selectedEnv.base ? "Base home" : selectedEnv.name, details, detailWidth, bodyHeight, paint); + const title = form !== undefined ? `${form.mode === "create" ? "New env" : "Config"} · ${form.name}` : selectedEnv === undefined ? "Welcome" : selectedEnv.base ? "Base home" : selectedEnv.name; + const detail = card(title, 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 09a3eeb7e..d5f797512 100644 --- a/runtime/gentle-shell-envs.mjs +++ b/runtime/gentle-shell-envs.mjs @@ -117,6 +117,10 @@ export function removeEnvFromConfig(config , aliases + + + + @@ -160,6 +164,7 @@ export function summarizeEnv(input ) { sessionCount: input.sessionCount, lastSessionAt: input.lastSessionAt, ...(input.base ? { base: true } : {}), + own: [...(input.own ?? [])], }; } diff --git a/tests/gentle-shell-envs-tui.test.ts b/tests/gentle-shell-envs-tui.test.ts index 51d358918..c8a13814f 100644 --- a/tests/gentle-shell-envs-tui.test.ts +++ b/tests/gentle-shell-envs-tui.test.ts @@ -34,6 +34,7 @@ test("parseKeys decodes arrows, enter, backspace, escape, ctrl-c and printable t 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"); + assert.deepEqual(parseKeys("\x1b[D\x1b[C "), [{ name: "left" }, { name: "right" }, char(" ")]); }); // --- navigation and direct actions ------------------------------------------- @@ -75,9 +76,13 @@ test("n opens a name prompt that validates and rejects duplicates before creatin 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); + const named = press(s0, char("n"), ...typed("perzonal"), { name: "backspace" }, { name: "backspace" }, ...typed("al"), { name: "enter" }); + assert.equal(named.effect, undefined, "a valid name opens the config form first"); + assert.equal(named.state.prompt, undefined); + assert.equal(named.state.scope?.name, "perzonal"); + const ok = press(named.state, { name: "enter" }); + assert.deepEqual(ok.effect, { kind: "create", name: "perzonal", own: [] }, "enter with defaults shares everything, as before"); + assert.equal(ok.state.scope, undefined); }); test("keys typed into a prompt never trigger list actions", () => { @@ -251,7 +256,7 @@ test("the base home can be neither renamed nor deleted", () => { 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" }); + assert.deepEqual(press(s0, char("n"), ...typed("base"), { name: "enter" }, { name: "enter" }).effect, { kind: "create", name: "base", own: [] }); }); test("the base home renders first with its own marker and card, starred when default", () => { @@ -289,3 +294,75 @@ test("text from the user's own files never reaches the terminal as control seque assert.match(text, /npm:a/); assert.match(text, /anthropic|anth.*ropic/); }); + +// --- config scope form (VS Code-like profiles) ------------------------------------- + +const scoped = (name: string, own: EnvSummary["own"] = [], extra: Partial = {}) => env(name, { own, ...extra }); + +test("the new-env form toggles categories and picks where own copies come from", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work", ["persona"]), scoped("lab")]); + let { state } = press(s0, char("n"), ...typed("client"), { name: "enter" }); + assert.equal(state.scope?.mode, "create"); + state = press(state, { name: "down" }, char(" ")).state; // persona -> own + state = press(state, { name: "down" }, { name: "right" }).state; // appearance -> own + assert.deepEqual(state.scope?.own, ["persona", "appearance"]); + // The source row cycles: base -> each other env -> empty. + state = press(state, { name: "down" }, { name: "down" }, { name: "right" }).state; + assert.deepEqual(state.scope?.source, { kind: "env", name: "work" }); + state = press(state, { name: "right" }, { name: "right" }).state; + assert.deepEqual(state.scope?.source, { kind: "empty" }); + state = press(state, { name: "right" }).state; + assert.deepEqual(state.scope?.source, { kind: "base" }, "wraps around"); + state = press(state, { name: "left" }).state; + assert.deepEqual(state.scope?.source, { kind: "empty" }); + assert.deepEqual(press(state, { name: "enter" }).effect, { kind: "create", name: "client", own: ["persona", "appearance"], empty: true }); + const fromWork = press(state, { name: "right" }, { name: "right" }, { name: "enter" }).effect; + assert.deepEqual(fromWork, { kind: "create", name: "client", own: ["persona", "appearance"], from: "work" }); +}); + +test("a toggles every category own or shared, and esc cancels without creating", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work")]); + const { state } = press(s0, char("n"), ...typed("x"), { name: "enter" }, char("a")); + assert.deepEqual(state.scope?.own, ["models", "persona", "appearance", "behavior"]); + assert.deepEqual(press(state, char("a")).state.scope?.own, []); + const cancelled = press(state, { name: "escape" }); + assert.equal(cancelled.effect, undefined); + assert.equal(cancelled.state.scope, undefined); +}); + +test("c edits an existing env's scope; seeding applies only to newly own categories", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work", ["persona"]), scoped("lab")], undefined, "work"); + const { state } = press(s0, char("c")); + assert.equal(state.scope?.mode, "edit"); + assert.deepEqual(state.scope?.own, ["persona"]); + const edited = press(state, char(" "), { name: "down" }, char(" "), { name: "enter" }); + assert.deepEqual(edited.effect, { kind: "scope", name: "work", own: ["models"], share: ["persona"] }); + assert.deepEqual(press(state, { name: "enter" }).effect, undefined, "no change, nothing to apply"); + assert.equal(press(state, { name: "enter" }).state.scope, undefined); +}); + +test("c on the base home explains it is the shared configuration", () => { + const { state, effect } = press(initialEnvsState([baseEntry(), scoped("work")]), char("c")); + assert.equal(effect, undefined); + assert.equal(state.scope, undefined); + assert.match(state.message ?? "", /shared configuration/); +}); + +test("the form renders as a Gentle card with each category's choice and the source", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work", ["persona"])]); + const { state } = press(s0, char("n"), ...typed("client"), { name: "enter" }, { name: "down" }, char(" ")); + const text = renderEnvsScreen(state, 110, 30).map(stripAnsi).join("\n"); + assert.match(text, /╭─ ✿ New env · client ─+╮/); + assert.match(text, /Models & profiles +Shared with base/); + assert.match(text, /❯ Persona +Own copy/); + assert.match(text, /New own copies from +Gentle Shell base/); + assert.match(text, /space toggle/); + assert.match(text, /enter create/); +}); + +test("the detail card names each env's config scope", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work", ["persona", "appearance"])], undefined, "work"); + assert.match(renderEnvsScreen(s0, 120, 30).map(stripAnsi).join("\n"), /Config +own persona, appearance · shares models & profiles, behavior/); + const base = renderEnvsScreen(initialEnvsState([baseEntry(), scoped("work")], undefined, { base: true }), 120, 30).map(stripAnsi).join("\n"); + assert.match(base, /Config +the shared configuration envs can reuse/); +}); diff --git a/tests/gentle-shell-envs.test.ts b/tests/gentle-shell-envs.test.ts index bd607d07b..ac9cb26e5 100644 --- a/tests/gentle-shell-envs.test.ts +++ b/tests/gentle-shell-envs.test.ts @@ -256,3 +256,8 @@ test("describeEnvScope reads like a sentence for the gate", async () => { assert.equal(describeEnvScope(["models", "persona", "appearance", "behavior"]), "fully own (shares nothing)"); assert.equal(describeEnvScope(["persona", "appearance"]), "own persona, appearance · shares models & profiles, behavior"); }); + +test("summarizeEnv carries the env's own config categories to the gate", () => { + assert.deepEqual(summarizeEnv({ name: "w", dir: "/e/w", isDefault: false, sessionCount: 0, own: ["persona"] }).own, ["persona"]); + assert.deepEqual(summarizeEnv({ name: "w", dir: "/e/w", isDefault: false, sessionCount: 0 }).own, []); +}); From 5836e74ed0d9499180a0767f59184a93e47c4039 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 17:15:52 -0400 Subject: [PATCH 19/36] docs(launcher): document per-env Gentle configuration and envs config --- docs/readme-reference.md | 31 +++++++++++++++++++++++++++++-- lib/gentle-shell-launcher.ts | 4 +++- runtime/gentle-shell-launcher.mjs | 4 +++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 45d944a6e..98304a6fc 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -383,9 +383,36 @@ A named env is a separate agent home managed by gentle-shell under `~/.gentle-sh 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. +Manager keys: `↑`/`↓` or `j`/`k` select. `enter` launches the selected env. `n` creates an env (after its name, a config form opens; see below), `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. `c` opens the config form for the selected env. `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), the config scope, 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. +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. By default they share the repository you work in, Gentle's own configuration (see *Per-env Gentle configuration* below), the Engram memory, 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. + +### Per-env Gentle configuration + +Like VS Code profiles, each named env chooses, per category, whether it shares Gentle's own configuration with the base home or keeps its own copy: + +| Category | What it covers | Files | +| --- | --- | --- | +| `models` | Per-agent model routing and `/gentle:profiles` | `models.json`, `profiles.json` and their exports | +| `persona` | el Gentleman's persona | `persona.json` | +| `appearance` | `/gentle:customize`: visual settings and profiles, cards, animations, banner, notifications, Vim | see `lib/gentle-config-scope.ts` | +| `behavior` | Permission mode, runtime guardrails, history capture, background subagents, double Esc, builtin codemode opt-out | see `lib/gentle-config-scope.ts` | + +Models and profiles stay together because a profile is a snapshot of the routing, and applying one rewrites `models.json`. The update channel and the dev-binary registration belong to the install and are always shared. Repository settings (`/.pi/gentle-ai/`) stay with the repository. + +A shared category follows the base configuration live: `~/.pi/gentle-ai`, `GENTLE_PI_CONFIG_HOME`, or the bundled install's own config, resolved exactly as before. An own category lives in `/gentle-ai/`. When a category becomes own it is seeded once, by copying from the base home or from another env (that env's effective files, own or shared), or by starting empty with the defaults. A category that is already own is never wiped, and sharing it again keeps its files on disk for later. The choice is stored in `/.gentle-shell-env.json`. An env without that file (every env created before this feature) shares everything. + +```bash +gentle-shell envs new client --own persona,appearance # copies them from the base home +gentle-shell envs new lab --own all --empty # owns everything, starting from defaults +gentle-shell envs config client # describes the scope +gentle-shell envs config client --own models --from work # copies work's models & profiles +gentle-shell envs config client --share appearance # shares it again (own files kept) +``` + +In the manager, `n` opens the same choice as a form after the name: `↑`/`↓` move, `space` or `←`/`→` toggle a category or change the source of new own copies, `a` toggles all, `enter` creates (or applies, for `c` on an existing env), and `esc` cancels. Enter with the defaults shares everything, exactly as before. The base home has no scope: it holds the shared configuration. + +When an env owns categories, the launcher passes `GENTLE_SHELL_ENV_CONFIG_HOME` (`/gentle-ai`) and `GENTLE_SHELL_ENV_OWN_CONFIG` to Pi. They win over the bundled config and `GENTLE_PI_CONFIG_HOME` for those categories only. The launcher clears them for every other home, so a nested launch never inherits another env's scope. Scope changes apply from the env's next session. The Engram memory is not part of this yet: all envs still share it. ### Managing packages diff --git a/lib/gentle-shell-launcher.ts b/lib/gentle-shell-launcher.ts index d0dc97db2..04595a875 100644 --- a/lib/gentle-shell-launcher.ts +++ b/lib/gentle-shell-launcher.ts @@ -1378,7 +1378,9 @@ export function helpText(): string { " 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, marking (default);", - " 'envs new ' creates one. Envs are separate homes, not sandboxes.", + " 'envs new ' creates one; 'envs config ' chooses which Gentle", + " config it owns (--own/--share models,persona,appearance,behavior).", + " 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-launcher.mjs b/runtime/gentle-shell-launcher.mjs index a51edc93c..e9b96190f 100644 --- a/runtime/gentle-shell-launcher.mjs +++ b/runtime/gentle-shell-launcher.mjs @@ -1379,7 +1379,9 @@ export function helpText() { " 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, marking (default);", - " 'envs new ' creates one. Envs are separate homes, not sandboxes.", + " 'envs new ' creates one; 'envs config ' chooses which Gentle", + " config it owns (--own/--share models,persona,appearance,behavior).", + " 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", From 00dca1dbb67ab22c81cf826b14077590fd169c21 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 17:49:11 -0400 Subject: [PATCH 20/36] fix(launcher): make the config form say what the copy source needs and when nothing changed --- lib/gentle-shell-envs-tui.ts | 20 +++++++++++++++----- runtime/gentle-shell-envs-tui.mjs | 20 +++++++++++++++----- tests/gentle-shell-envs-tui.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/lib/gentle-shell-envs-tui.ts b/lib/gentle-shell-envs-tui.ts index 079d384d6..384e82a5a 100644 --- a/lib/gentle-shell-envs-tui.ts +++ b/lib/gentle-shell-envs-tui.ts @@ -169,8 +169,10 @@ function sourceFields(source: ScopeSource): { from?: string; empty?: true } { return source.kind === "env" ? { from: source.name } : source.kind === "empty" ? { empty: true } : {}; } +const NOTHING_OWN = "Nothing to copy yet: mark a category as Own copy first (space)."; + function reduceScope(state: EnvsState, form: EnvsScopeForm, key: EnvsKey): EnvsStep { - const set = (next: Partial): EnvsStep => ({ state: { ...state, scope: { ...form, ...next } } }); + const set = (next: Partial): EnvsStep => ({ state: { ...state, message: undefined, scope: { ...form, ...next } } }); if (key.name === "escape" || key.name === "ctrl-c") return { state: { ...state, scope: undefined } }; if (key.name === "up") return set({ cursor: Math.max(0, form.cursor - 1) }); if (key.name === "down") return set({ cursor: Math.min(CONFIG_CATEGORIES.length, form.cursor + 1) }); @@ -183,6 +185,8 @@ function reduceScope(state: EnvsState, form: EnvsScopeForm, key: EnvsKey): EnvsS return set({ own: CONFIG_CATEGORIES.filter((c) => own.includes(c)) }); } if (toggle) { + // The source only matters for own copies: say so instead of cycling it silently. + if (form.own.length === 0) return { state: { ...state, message: NOTHING_OWN } }; const sources = scopeSources(state, form); const index = sources.findIndex((source) => sameSource(source, form.source)); const step = key.name === "left" ? -1 : 1; @@ -193,7 +197,9 @@ function reduceScope(state: EnvsState, form: EnvsScopeForm, key: EnvsKey): EnvsS if (form.mode === "create") return { state: close, effect: { kind: "create", name: form.name, own: form.own, ...sourceFields(form.source) } }; const share = form.initial.filter((c) => !form.own.includes(c)); const added = form.own.filter((c) => !form.initial.includes(c)); - if (share.length === 0 && added.length === 0) return { state: close }; + if (share.length === 0 && added.length === 0) { + return { state: { ...close, message: `No changes for ${form.name}: mark a category as Own copy, or share one again, to change it.` } }; + } return { state: close, effect: { kind: "scope", name: form.name, own: added, share, ...(added.length > 0 ? sourceFields(form.source) : {}) } }; } @@ -370,11 +376,15 @@ function scopeRows(form: EnvsScopeForm, paint: Paint): CardRow[] { return { text, style: selected ? (fitted: string) => paint.rose(paint.bold(fitted)) : undefined }; }; return [ - { text: form.mode === "create" ? "Choose what this env shares with the base home." : "Change what this env owns.", style: paint.label }, + { text: "Mark categories as Own copy (space),", style: paint.label }, + { text: "then choose where their copies come from.", style: paint.label }, { text: "" }, ...CONFIG_CATEGORIES.map((category, index) => row(form.cursor === index, CATEGORY_TITLES[category], form.own.includes(category) ? "Own copy" : "Shared with base")), { text: "" }, - row(form.cursor === CONFIG_CATEGORIES.length, "New own copies from", sourceLabel(form.source), 21), + form.own.length === 0 + // Inactive until something is own: nothing would be copied yet. + ? { ...row(form.cursor === CONFIG_CATEGORIES.length, "New own copies from", "— mark a category as Own copy first", 21), style: paint.muted } + : row(form.cursor === CONFIG_CATEGORIES.length, "New own copies from", sourceLabel(form.source), 21), { text: "" }, { text: "Shared follows the base home live; an own copy diverges.", style: paint.muted }, ]; @@ -421,7 +431,7 @@ export function renderEnvsScreen(state: EnvsState, width: number, height: number footer.push(p.error === undefined ? center(paint.muted(fit("enter confirm · esc cancel", w)), w) : center(paint.error(fit(p.error, w)), w)); } else if (state.scope !== undefined) { const hints: Array<[string, string]> = [...SCOPE_HINTS.slice(0, 4), ["enter", state.scope.mode === "create" ? "create" : "apply"], SCOPE_HINTS[4]]; - footer.push(""); + footer.push(state.message === undefined ? "" : center(paint.value(fit(state.message, w)), w)); footer.push(...hintLines(Math.min(w, 110), paint, hints).map((line) => center(line, w))); } else { footer.push(state.message === undefined ? "" : center(paint.value(fit(state.message, w)), w)); diff --git a/runtime/gentle-shell-envs-tui.mjs b/runtime/gentle-shell-envs-tui.mjs index 456b4d67d..8cad68586 100644 --- a/runtime/gentle-shell-envs-tui.mjs +++ b/runtime/gentle-shell-envs-tui.mjs @@ -170,8 +170,10 @@ function sourceFields(source ) { return source.kind === "env" ? { from: source.name } : source.kind === "empty" ? { empty: true } : {}; } +const NOTHING_OWN = "Nothing to copy yet: mark a category as Own copy first (space)."; + function reduceScope(state , form , key ) { - const set = (next ) => ({ state: { ...state, scope: { ...form, ...next } } }); + const set = (next ) => ({ state: { ...state, message: undefined, scope: { ...form, ...next } } }); if (key.name === "escape" || key.name === "ctrl-c") return { state: { ...state, scope: undefined } }; if (key.name === "up") return set({ cursor: Math.max(0, form.cursor - 1) }); if (key.name === "down") return set({ cursor: Math.min(CONFIG_CATEGORIES.length, form.cursor + 1) }); @@ -184,6 +186,8 @@ function reduceScope(state , form , key ) return set({ own: CONFIG_CATEGORIES.filter((c) => own.includes(c)) }); } if (toggle) { + // The source only matters for own copies: say so instead of cycling it silently. + if (form.own.length === 0) return { state: { ...state, message: NOTHING_OWN } }; const sources = scopeSources(state, form); const index = sources.findIndex((source) => sameSource(source, form.source)); const step = key.name === "left" ? -1 : 1; @@ -194,7 +198,9 @@ function reduceScope(state , form , key ) if (form.mode === "create") return { state: close, effect: { kind: "create", name: form.name, own: form.own, ...sourceFields(form.source) } }; const share = form.initial.filter((c) => !form.own.includes(c)); const added = form.own.filter((c) => !form.initial.includes(c)); - if (share.length === 0 && added.length === 0) return { state: close }; + if (share.length === 0 && added.length === 0) { + return { state: { ...close, message: `No changes for ${form.name}: mark a category as Own copy, or share one again, to change it.` } }; + } return { state: close, effect: { kind: "scope", name: form.name, own: added, share, ...(added.length > 0 ? sourceFields(form.source) : {}) } }; } @@ -371,11 +377,15 @@ function scopeRows(form , paint ) { return { text, style: selected ? (fitted ) => paint.rose(paint.bold(fitted)) : undefined }; }; return [ - { text: form.mode === "create" ? "Choose what this env shares with the base home." : "Change what this env owns.", style: paint.label }, + { text: "Mark categories as Own copy (space),", style: paint.label }, + { text: "then choose where their copies come from.", style: paint.label }, { text: "" }, ...CONFIG_CATEGORIES.map((category, index) => row(form.cursor === index, CATEGORY_TITLES[category], form.own.includes(category) ? "Own copy" : "Shared with base")), { text: "" }, - row(form.cursor === CONFIG_CATEGORIES.length, "New own copies from", sourceLabel(form.source), 21), + form.own.length === 0 + // Inactive until something is own: nothing would be copied yet. + ? { ...row(form.cursor === CONFIG_CATEGORIES.length, "New own copies from", "— mark a category as Own copy first", 21), style: paint.muted } + : row(form.cursor === CONFIG_CATEGORIES.length, "New own copies from", sourceLabel(form.source), 21), { text: "" }, { text: "Shared follows the base home live; an own copy diverges.", style: paint.muted }, ]; @@ -422,7 +432,7 @@ export function renderEnvsScreen(state , width , height footer.push(p.error === undefined ? center(paint.muted(fit("enter confirm · esc cancel", w)), w) : center(paint.error(fit(p.error, w)), w)); } else if (state.scope !== undefined) { const hints = [...SCOPE_HINTS.slice(0, 4), ["enter", state.scope.mode === "create" ? "create" : "apply"], SCOPE_HINTS[4]]; - footer.push(""); + footer.push(state.message === undefined ? "" : center(paint.value(fit(state.message, w)), w)); footer.push(...hintLines(Math.min(w, 110), paint, hints).map((line) => center(line, w))); } else { footer.push(state.message === undefined ? "" : center(paint.value(fit(state.message, w)), w)); diff --git a/tests/gentle-shell-envs-tui.test.ts b/tests/gentle-shell-envs-tui.test.ts index c8a13814f..13ae96bc3 100644 --- a/tests/gentle-shell-envs-tui.test.ts +++ b/tests/gentle-shell-envs-tui.test.ts @@ -366,3 +366,31 @@ test("the detail card names each env's config scope", () => { const base = renderEnvsScreen(initialEnvsState([baseEntry(), scoped("work")], undefined, { base: true }), 120, 30).map(stripAnsi).join("\n"); assert.match(base, /Config +the shared configuration envs can reuse/); }); + +test("the source row reads as inactive until a category is an own copy", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work")]); + const { state } = press(s0, char("n"), ...typed("client"), { name: "enter" }); + const idle = renderEnvsScreen(state, 110, 30).map(stripAnsi).join("\n"); + assert.match(idle, /Mark categories as Own copy \(space\),/); + assert.match(idle, /then choose where their copies come from\./); + assert.match(idle, /New own copies from +— mark a category as Own copy first/); + assert.doesNotMatch(idle, /New own copies from +Gentle Shell base/); + const active = renderEnvsScreen(press(state, char(" ")).state, 110, 30).map(stripAnsi).join("\n"); + assert.match(active, /New own copies from +Gentle Shell base/); +}); + +test("enter on the source row with nothing own explains instead of silently doing nothing", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work")]); + const { state } = press(s0, char("n"), ...typed("client"), { name: "enter" }, { name: "down" }, { name: "down" }, { name: "down" }, { name: "down" }, { name: "right" }); + assert.deepEqual(state.scope?.source, { kind: "base" }, "the source does not change while nothing is own"); + assert.match(state.message ?? "", /mark a category as Own copy first/); +}); + +test("applying an unchanged config form says nothing changed", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work")], undefined, "work"); + const { state, effect } = press(s0, char("c"), { name: "enter" }); + assert.equal(effect, undefined); + assert.equal(state.scope, undefined); + assert.match(state.message ?? "", /No changes/); + assert.match(state.message ?? "", /Own copy/); +}); From 9fefd40cfe7e691a4f1051eb61d0417caacdda61 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Tue, 13 Oct 2026 18:28:58 -0400 Subject: [PATCH 21/36] fix(config): honor env behavior scope in history capture and refuse symlinked env config Found by independent verification of the per-env config branch: - History capture read the shared history-capture.json even in an env that owns `behavior`, so the Customize toggle (which writes the env's own file) had no effect. Both config-home reads in extensions/history now name the `behavior` category. - The drift-guard test only scanned lib/ and extensions/ top-level files, which is how those reads slipped through. It now scans subfolders too. - `envs config` / `envs new --own` seeded without the managed-env guard used by rename/delete, so a symlinked env or `/gentle-ai` could make seeding write or remove files outside the env (for example in the shared config home). Both are now refused before anything changes. --- bin/gentle-shell.mjs | 14 +++++++++++++- extensions/history/index.ts | 4 ++-- tests/gentle-config-scope.test.ts | 11 ++++++++--- tests/gentle-shell-bin.test.ts | 23 +++++++++++++++++++++++ tests/history-env-scope.test.ts | 23 +++++++++++++++++++++++ 5 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 tests/history-env-scope.test.ts diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index 59e6ce1d6..898b4404f 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -731,7 +731,19 @@ function sharedConfigHome() { // become own (a copy from the base home or another env, or empty), then // persists the scope. Sharing a category again keeps its own files on disk. function applyEnvScope(root, name, flags) { - const dir = envDir(root, name); + // Same guard as rename/delete: a managed, real env directory, and an own + // config folder that is never a link (seeding would write through it). + const dir = assertManagedEnvDir(root, name); + const ownDir = join(dir, "gentle-ai"); + let ownStat; + try { + ownStat = lstatSync(ownDir); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + if (ownStat !== undefined && (ownStat.isSymbolicLink() || !ownStat.isDirectory())) { + throw new Error(`refusing to touch ${ownDir}: not a real directory`); + } const current = readEnvScope(dir); const plan = planScopeChange({ current, own: flags.own, share: flags.share, seed: flags.empty ? { kind: "empty" } : { kind: "copy", from: flags.from } }); let sourceDirFor = () => sharedConfigHome(); diff --git a/extensions/history/index.ts b/extensions/history/index.ts index fbe401141..33d972895 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -154,7 +154,7 @@ export interface HistoryDeps { */ export function captureEnabled( env: NodeJS.ProcessEnv = process.env, - configHome: string = gentlePiConfigHome(env), + configHome: string = gentlePiConfigHome(env, "behavior"), ): boolean { return historyCaptureEnabled({ env, gentlePiConfigHome: configHome }); } @@ -1315,7 +1315,7 @@ export default function promptHistoryExtension( deps: HistoryDeps = {}, ): void { const env = deps.env ?? process.env; - const configHome = deps.gentlePiConfigHome ?? gentlePiConfigHome(env); + const configHome = deps.gentlePiConfigHome ?? gentlePiConfigHome(env, "behavior"); // Per-prompt gate: re-read so a Customize toggle applies live. A delegated // child never captures: its prompt is a delegation brief, not user history, // and the parent owns the store's init and GC (gentle-shell#1690). diff --git a/tests/gentle-config-scope.test.ts b/tests/gentle-config-scope.test.ts index adfac311f..5d80c1601 100644 --- a/tests/gentle-config-scope.test.ts +++ b/tests/gentle-config-scope.test.ts @@ -67,9 +67,14 @@ const INSTALL_LEVEL_CALLS = new Set([]); test("every config-home read names its category (drift guard)", () => { const root = new URL("..", import.meta.url); const offenders: string[] = []; - for (const dir of ["lib", "extensions"]) { - for (const name of readdirSync(new URL(`${dir}/`, root))) { - if (!name.endsWith(".ts")) continue; + // Recursive: config-home reads also live in subfolders such as extensions/history. + for (const top of ["lib", "extensions"]) { + for (const entry of readdirSync(new URL(`${top}/`, root), { recursive: true }) as string[]) { + const rel = entry.split("\\").join("/"); + if (!rel.endsWith(".ts")) continue; + const slash = rel.lastIndexOf("/"); + const dir = slash < 0 ? top : `${top}/${rel.slice(0, slash)}`; + const name = slash < 0 ? rel : rel.slice(slash + 1); const source = readFileSync(new URL(`${dir}/${name}`, root), "utf8"); source.split("\n").forEach((line, index) => { if (/export function gentlePiConfigHome/.test(line)) return; diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index f5ba431a8..0b69a6960 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3692,3 +3692,26 @@ test("a launched env carries its own config scope to Pi, and a sharing env clear assert.deepEqual(last(["--env", "plain", "-p", "hi"]), { home: null, own: null }); assert.deepEqual(last(["-p", "hi"]), { home: null, own: null }, "the base home never inherits env scope"); }); + +test("envs config refuses a symlinked env or gentle-ai folder and leaves the target untouched", (t) => { + const f = fixture(t); + const shared = join(f.root, "base-config"); + const env = { ...f.env, GENTLE_PI_CONFIG_HOME: shared }; + mkdirSync(shared, { recursive: true }); + writeFileSync(join(shared, "persona.json"), '{"persona":"shared"}'); + const envs = join(f.home, ".gentle-shell", "envs"); + assert.equal(run(env, ["envs", "new", "linked"]).status, 0); + symlinkSync(shared, join(envs, "linked", "gentle-ai")); + const refused = run(env, ["envs", "config", "linked", "--own", "persona", "--empty"]); + assert.notEqual(refused.status, 0); + assert.match(refused.stderr, /refusing/); + assert.equal(readFileSync(join(shared, "persona.json"), "utf8"), '{"persona":"shared"}', "the shared config is never touched"); + assert.equal(existsSync(join(envs, "linked", ".gentle-shell-env.json")), false); + const outside = join(f.root, "outside-env"); + mkdirSync(outside); + symlinkSync(outside, join(envs, "aliased")); + const aliased = run(env, ["envs", "config", "aliased", "--own", "persona"]); + assert.notEqual(aliased.status, 0); + assert.match(aliased.stderr, /refusing/); + assert.deepEqual(readdirSync(outside), []); +}); diff --git a/tests/history-env-scope.test.ts b/tests/history-env-scope.test.ts new file mode 100644 index 000000000..c41a2334c --- /dev/null +++ b/tests/history-env-scope.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { captureEnabled } from "../extensions/history/index.ts"; + +// An env that owns `behavior` keeps history-capture.json in its own config +// home: the capture gate must read that file, not the shared one. +test("history capture follows the env's own behavior scope", (t) => { + const root = mkdtempSync(join(tmpdir(), "history-env-scope-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const shared = join(root, "shared"); + const own = join(root, "env", "gentle-ai"); + for (const [dir, policy] of [[shared, "off"], [own, "on"]] as const) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "history-capture.json"), JSON.stringify({ schema: "gentle-pi.history-capture/v1", policy })); + } + const base = { GENTLE_PI_CONFIG_HOME: shared }; + assert.equal(captureEnabled(base), false, "no scope: the shared preference"); + assert.equal(captureEnabled({ ...base, GENTLE_SHELL_ENV_CONFIG_HOME: own, GENTLE_SHELL_ENV_OWN_CONFIG: "behavior" }), true); + assert.equal(captureEnabled({ ...base, GENTLE_SHELL_ENV_CONFIG_HOME: own, GENTLE_SHELL_ENV_OWN_CONFIG: "appearance" }), false, "behavior shared"); +}); From 076daadb0cf3f929a621e4df9465f2113d57af6b Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Wed, 14 Oct 2026 10:06:27 -0400 Subject: [PATCH 22/36] fix(launcher): reuse kept own config, validate envs new scope flags, and roll back failed seeds - Re-owning a category whose own files were kept when it was shared again now reuses them instead of seeding over them (copy or empty), so an earlier own copy is never lost silently. - `envs new` rejects --share, --from and --empty without --own instead of accepting and ignoring them. - `envs new --own` and the manager's create form are all or nothing: if applying the scope fails, the newly created env is removed again. --- bin/gentle-shell.mjs | 32 ++++++++++++++++++++++++-------- lib/gentle-shell-envs.ts | 7 +++++++ runtime/gentle-shell-envs.mjs | 7 +++++++ tests/gentle-shell-bin.test.ts | 32 ++++++++++++++++++++++++++++++++ tests/gentle-shell-envs.test.ts | 7 +++++++ 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index 898b4404f..e1d867952 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -108,6 +108,7 @@ import { 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 { + categoriesToSeed, describeEnvScope, ENV_SCOPE_FILE, parseEnvScope, @@ -755,8 +756,9 @@ function applyEnvScope(root, name, flags) { sourceDirFor = (category) => (fromOwn.includes(category) ? join(fromDir, "gentle-ai") : shared); } const targetDir = join(dir, "gentle-ai"); - if (plan.seed.length > 0) mkdirSync(targetDir, { recursive: true, mode: 0o700 }); - for (const op of seedOperations({ categories: plan.seed, mode: flags.empty ? "empty" : "copy", sourceDirFor, targetDir, exists: existsSync })) { + const seed = categoriesToSeed(plan.seed, targetDir, existsSync); + if (seed.length > 0) mkdirSync(targetDir, { recursive: true, mode: 0o700 }); + for (const op of seedOperations({ categories: seed, mode: flags.empty ? "empty" : "copy", sourceDirFor, targetDir, exists: existsSync })) { if (op.kind === "copy") copyFileSync(op.from, op.to); else rmSync(op.path, { force: true }); } @@ -935,11 +937,20 @@ async function handleEnvsCommand(commandArgs) { if (invalid !== undefined) fail(`gentle-shell: ${invalid}`, 2); const flags = parseScopeFlags(flagArgs); if (flags.error !== undefined) fail(`gentle-shell: ${flags.error}`, 2); + // A new env starts sharing everything: only --own makes the others meaningful. + if (flags.own.length === 0 && (flags.share.length > 0 || flags.from !== undefined || flags.empty)) { + fail("gentle-shell: --share, --from and --empty need --own on envs new", 2); + } if (flags.from !== undefined && !existsSync(envDir(root, flags.from))) fail(`gentle-shell: unknown env "${flags.from}"`, 2); + let created; try { - process.stdout.write(`Created env ${name} at ${await createEnv(root, name)}\n`); - if (flags.own.length > 0) process.stdout.write(`Config: ${describeEnvScope(applyEnvScope(root, name, flags).next)}\n`); + created = await createEnv(root, name); + const scope = flags.own.length > 0 ? applyEnvScope(root, name, flags) : undefined; + process.stdout.write(`Created env ${name} at ${created}\n`); + if (scope) process.stdout.write(`Config: ${describeEnvScope(scope.next)}\n`); } catch (error) { + // All or nothing: an env whose scope could not be applied is removed again. + if (created !== undefined) rmSync(created, { recursive: true, force: true }); fail(`gentle-shell: ${error.message}`, error.usage ? 2 : 1); } return undefined; @@ -1055,10 +1066,15 @@ async function runEnvsManager(root) { const apply = async (state, effect) => { try { if (effect.kind === "create") { - await createEnv(root, effect.name); - const scopeNote = effect.own.length > 0 - ? ` Config: ${describeEnvScope(applyEnvScope(root, effect.name, { own: effect.own, share: [], from: effect.from, empty: effect.empty === true }).next)}.` - : ""; + const created = await createEnv(root, effect.name); + let scopeNote = ""; + try { + if (effect.own.length > 0) scopeNote = ` Config: ${describeEnvScope(applyEnvScope(root, effect.name, { own: effect.own, share: [], from: effect.from, empty: effect.empty === true }).next)}.`; + } catch (error) { + // All or nothing, like `envs new`: never leave a half-configured env. + rmSync(created, { recursive: true, force: true }); + throw error; + } return reload(`Created ${effect.name}.${scopeNote} Press enter to launch it (first launch installs the companion packages).`, effect.name); } if (effect.kind === "scope") { diff --git a/lib/gentle-shell-envs.ts b/lib/gentle-shell-envs.ts index 71b70ce56..455a269c6 100644 --- a/lib/gentle-shell-envs.ts +++ b/lib/gentle-shell-envs.ts @@ -279,6 +279,13 @@ export function seedOperations(input: { return operations; } +// Categories that need a seed when they become own: a category whose own +// files were kept on disk when it was shared again is reused, never seeded +// over, so an earlier own copy is not lost. +export function categoriesToSeed(categories: readonly ConfigCategory[], targetDir: string, exists: (path: string) => boolean): ConfigCategory[] { + return categories.filter((category) => !CONFIG_CATEGORY_FILES[category].some((file) => exists(join(targetDir, file)))); +} + export const CONFIG_CATEGORY_LABELS: Readonly> = Object.freeze({ models: "models & profiles", persona: "persona", diff --git a/runtime/gentle-shell-envs.mjs b/runtime/gentle-shell-envs.mjs index d5f797512..832fb69da 100644 --- a/runtime/gentle-shell-envs.mjs +++ b/runtime/gentle-shell-envs.mjs @@ -280,6 +280,13 @@ export function seedOperations(input return operations; } +// Categories that need a seed when they become own: a category whose own +// files were kept on disk when it was shared again is reused, never seeded +// over, so an earlier own copy is not lost. +export function categoriesToSeed(categories , targetDir , exists ) { + return categories.filter((category) => !CONFIG_CATEGORY_FILES[category].some((file) => exists(join(targetDir, file)))); +} + export const CONFIG_CATEGORY_LABELS = Object.freeze({ models: "models & profiles", persona: "persona", diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index 0b69a6960..2b6d56280 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3715,3 +3715,35 @@ test("envs config refuses a symlinked env or gentle-ai folder and leaves the tar assert.match(aliased.stderr, /refusing/); assert.deepEqual(readdirSync(outside), []); }); + +test("re-owning a category reuses its kept own copy instead of seeding over it", (t) => { + const f = fixture(t); + const base = join(f.root, "base-config"); + const env = { ...f.env, GENTLE_PI_CONFIG_HOME: base }; + mkdirSync(base, { recursive: true }); + writeFileSync(join(base, "persona.json"), '{"persona":"base"}'); + assert.equal(run(env, ["envs", "new", "a", "--own", "persona"]).status, 0); + const own = join(f.home, ".gentle-shell", "envs", "a", "gentle-ai", "persona.json"); + writeFileSync(own, '{"persona":"edited"}'); + assert.equal(run(env, ["envs", "config", "a", "--share", "persona"]).status, 0); + assert.equal(run(env, ["envs", "config", "a", "--own", "persona", "--empty"]).status, 0); + assert.equal(readFileSync(own, "utf8"), '{"persona":"edited"}', "the kept own copy is reused"); +}); + +test("envs new rejects scope flags without --own and leaves no half-created env", (t) => { + const f = fixture(t); + const envs = join(f.home, ".gentle-shell", "envs"); + for (const flags of [["--empty"], ["--share", "persona"], ["--from", "x"]]) { + const result = run(f.env, ["envs", "new", "lonely", ...flags]); + assert.equal(result.status, 2, flags.join(" ")); + assert.match(result.stderr, /--own/); + assert.equal(existsSync(join(envs, "lonely")), false, flags.join(" ")); + } + // A seed that fails (the source file is a directory, so the copy throws) rolls the new env back. + assert.equal(run(f.env, ["envs", "new", "src"]).status, 0); + mkdirSync(join(envs, "src", "gentle-ai", "persona.json"), { recursive: true }); + writeFileSync(join(envs, "src", ".gentle-shell-env.json"), '{"schema":1,"own":["persona"]}'); + const failed = run(f.env, ["envs", "new", "rolled", "--own", "persona", "--from", "src"]); + assert.notEqual(failed.status, 0); + assert.equal(existsSync(join(envs, "rolled")), false, "a failed seed leaves no env behind"); +}); diff --git a/tests/gentle-shell-envs.test.ts b/tests/gentle-shell-envs.test.ts index ac9cb26e5..ac1961e90 100644 --- a/tests/gentle-shell-envs.test.ts +++ b/tests/gentle-shell-envs.test.ts @@ -261,3 +261,10 @@ test("summarizeEnv carries the env's own config categories to the gate", () => { assert.deepEqual(summarizeEnv({ name: "w", dir: "/e/w", isDefault: false, sessionCount: 0, own: ["persona"] }).own, ["persona"]); assert.deepEqual(summarizeEnv({ name: "w", dir: "/e/w", isDefault: false, sessionCount: 0 }).own, []); }); + +test("categoriesToSeed skips categories that already have own files kept on disk", async () => { + const { categoriesToSeed } = await import("../lib/gentle-shell-envs.ts"); + const kept = (path: string) => path === join("/t", "persona.json"); + assert.deepEqual(categoriesToSeed(["persona", "appearance"], "/t", kept), ["appearance"]); + assert.deepEqual(categoriesToSeed(["persona"], "/t", () => false), ["persona"]); +}); From 26d2234bd9c74915f46b461323e6fdbe3af26eb2 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Wed, 14 Oct 2026 20:06:07 -0400 Subject: [PATCH 23/36] 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 24/36] 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 25/36] 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 fecd68f31d36f72fa842a3100206baeea282ccb0 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Wed, 14 Oct 2026 20:32:25 -0400 Subject: [PATCH 26/36] fix(launcher): share named envs with the base home's real config, roll back a failed envs config seed, and scope the background policy to its env Since #2201 the base --isolated home keeps its config in ~/.gentle-shell/gentle-ai, but named envs shared and seeded from ~/.pi/gentle-ai, so "shared with the base home" was not true. Env launches, setup, the shared seed source and the one-time migration now use the base home's own folder (launchConfigHome/baseConfigHome). GENTLE_PI_CONFIG_HOME and the bundled prefix still win, and a base home without its own folder keeps ~/.pi/gentle-ai. A failed seed in `envs config` now removes what it copied, so a partial category is never mistaken for a kept own copy. The background-subagents resolver reads the behavior home from the env it was given. --- bin/gentle-shell.mjs | 51 +++++++++++++++++++--------- docs/readme-reference.md | 2 +- lib/background-subagents-policy.ts | 2 +- lib/gentle-config-scope.ts | 4 +-- lib/gentle-shell-launcher.ts | 23 ++++++++++--- runtime/gentle-config-scope.mjs | 4 +-- runtime/gentle-shell-launcher.mjs | 23 ++++++++++--- tests/background-subagents.test.ts | 12 +++++++ tests/gentle-shell-bin.test.ts | 20 +++++++++++ tests/standalone-config-home.test.ts | 32 +++++++++++++++++ 10 files changed, 143 insertions(+), 30 deletions(-) diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index b18612fd9..5e58098b3 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -63,6 +63,8 @@ import { separateHomeHint, shellQuote, standaloneConfigHome, + launchConfigHome, + baseConfigHome, userPiHome, } from "../runtime/gentle-shell-launcher.mjs"; import { formatImportPiReport, importPi } from "../runtime/gentle-shell-import-pi.mjs"; @@ -719,13 +721,15 @@ function readEnvScope(dir) { return parseEnvScope(readTextOrUndefined(join(dir, ENV_SCOPE_FILE))); } -// The shared (base) Gentle config home, exactly as a Pi process of this -// install resolves it without env scope: the bundled prefix's config, then -// GENTLE_PI_CONFIG_HOME, then ~/.pi/gentle-ai. +// The shared (base) Gentle config home, exactly as a Pi process launched in +// a named env resolves it without env scope: the bundled prefix's config, +// then GENTLE_PI_CONFIG_HOME, then the base isolated home's own folder +// (~/.gentle-shell/gentle-ai, #2201), then ~/.pi/gentle-ai. function sharedConfigHome() { const install = resolveBundledInstall({ packageRoot, platform: process.platform, fs: bundledFs }); if (install) return bundledPiConfigDir(install.root); - return process.env.GENTLE_PI_CONFIG_HOME || join(homedir(), ".pi", "gentle-ai"); + if (process.env.GENTLE_PI_CONFIG_HOME) return process.env.GENTLE_PI_CONFIG_HOME; + return baseConfigHome(process.env, homedir()) ?? join(homedir(), ".pi", "gentle-ai"); } // Applies --own/--share/--from/--empty to one env: seeds the categories that @@ -757,12 +761,25 @@ function applyEnvScope(root, name, flags) { } const targetDir = join(dir, "gentle-ai"); const seed = categoriesToSeed(plan.seed, targetDir, existsSync); - if (seed.length > 0) mkdirSync(targetDir, { recursive: true, mode: 0o700 }); - for (const op of seedOperations({ categories: seed, mode: flags.empty ? "empty" : "copy", sourceDirFor, targetDir, exists: existsSync })) { - if (op.kind === "copy") copyFileSync(op.from, op.to); - else rmSync(op.path, { force: true }); + const createdTargetDir = seed.length > 0 && !existsSync(targetDir); + const copied = []; + try { + if (seed.length > 0) mkdirSync(targetDir, { recursive: true, mode: 0o700 }); + for (const op of seedOperations({ categories: seed, mode: flags.empty ? "empty" : "copy", sourceDirFor, targetDir, exists: existsSync })) { + if (op.kind === "copy") { + copyFileSync(op.from, op.to); + copied.push(op.to); + } else rmSync(op.path, { force: true }); + } + writeFileAtomically(join(dir, ENV_SCOPE_FILE), serializeEnvScope(plan.next), { mode: 0o600 }); + } catch (error) { + // A seeded category had no own files before this call (categoriesToSeed), + // so what it copied is all there is: a partial copy left behind would + // read as a kept own copy next time and never be seeded again. + for (const path of copied) rmSync(path, { force: true }); + if (createdTargetDir) rmSync(targetDir, { recursive: true, force: true }); + throw error; } - writeFileAtomically(join(dir, ENV_SCOPE_FILE), serializeEnvScope(plan.next), { mode: 0o600 }); return plan; } @@ -1329,9 +1346,10 @@ function buildSetupEnv(home, runtime, piLauncherDir) { GENTLE_PI_AGENT_HOME: home.dir, PATH: [...(piLauncherDir ? [piLauncherDir] : []), dirname(runtime.command), process.env.PATH ?? ""].join(delimiter), }; - // #2201: the isolated home's own config folder, given to Gentle AI the - // bundled way (gentleAiIsolationEnv); never an outer session's marker. - const configHome = standaloneConfigHome(home); + // #2201: the isolated home's own config folder (shared by named envs), + // given to Gentle AI the bundled way (gentleAiIsolationEnv); never an + // outer session's marker. + const configHome = launchConfigHome(home, process.env, homedir()); if (configHome === undefined) delete env[GENTLE_SHELL_CONFIG_HOME_ENV]; else env[GENTLE_SHELL_CONFIG_HOME_ENV] = configHome; return gentleAiIsolationEnv(env, { packageRoot }); @@ -1384,7 +1402,7 @@ function sharedPersonaPath() { // does nothing. function setupPersonaPaths(home) { if (bundledInstall) return [join(bundledGentleAiPaths(bundledInstall.root, process.platform).piConfig, "persona.json"), sharedPersonaPath()]; - const configHome = standaloneConfigHome(home); + const configHome = launchConfigHome(home, process.env, homedir()); return configHome === undefined ? [sharedPersonaPath()] : [join(configHome, "persona.json"), sharedPersonaPath()]; } @@ -2500,9 +2518,10 @@ async function main() { } // #2201: the standalone isolated home's own config folder, seeded once from - // the shared ~/.pi/gentle-ai. Not when an explicit GENTLE_PI_CONFIG_HOME - // decides the config home; never blocks the launch. - const configHome = bundled || process.env.GENTLE_PI_CONFIG_HOME ? undefined : standaloneConfigHome(home); + // the shared ~/.pi/gentle-ai, also when a named env sharing it launches + // first. Not when an explicit GENTLE_PI_CONFIG_HOME decides the config + // home; never blocks the launch. + const configHome = bundled || process.env.GENTLE_PI_CONFIG_HOME ? undefined : launchConfigHome(home, process.env, homedir()); if (configHome !== undefined) { try { const migration = migrateStandaloneConfig({ source: join(homedir(), ".pi", "gentle-ai"), target: configHome, fs: configMigrationFs }); diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 98304a6fc..244e2ce05 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -400,7 +400,7 @@ Like VS Code profiles, each named env chooses, per category, whether it shares G Models and profiles stay together because a profile is a snapshot of the routing, and applying one rewrites `models.json`. The update channel and the dev-binary registration belong to the install and are always shared. Repository settings (`/.pi/gentle-ai/`) stay with the repository. -A shared category follows the base configuration live: `~/.pi/gentle-ai`, `GENTLE_PI_CONFIG_HOME`, or the bundled install's own config, resolved exactly as before. An own category lives in `/gentle-ai/`. When a category becomes own it is seeded once, by copying from the base home or from another env (that env's effective files, own or shared), or by starting empty with the defaults. A category that is already own is never wiped, and sharing it again keeps its files on disk for later. The choice is stored in `/.gentle-shell-env.json`. An env without that file (every env created before this feature) shares everything. +A shared category follows the base home's configuration live: the bundled install's own config, else `GENTLE_PI_CONFIG_HOME`, else the base home's `~/.gentle-shell/gentle-ai` (the same folder `--isolated` uses since #2201), else `~/.pi/gentle-ai` when the base home has no folder of its own (a custom `GENTLE_SHELL_HOME`). An env launched before the base home ever was still gets that folder seeded once from `~/.pi/gentle-ai`. An own category lives in `/gentle-ai/`. When a category becomes own it is seeded once, by copying from the base home or from another env (that env's effective files, own or shared), or by starting empty with the defaults. A category that is already own is never wiped, and sharing it again keeps its files on disk for later. The choice is stored in `/.gentle-shell-env.json`. An env without that file (every env created before this feature) shares everything. ```bash gentle-shell envs new client --own persona,appearance # copies them from the base home diff --git a/lib/background-subagents-policy.ts b/lib/background-subagents-policy.ts index 881d7ac2b..664c10540 100644 --- a/lib/background-subagents-policy.ts +++ b/lib/background-subagents-policy.ts @@ -95,7 +95,7 @@ export function resolveBackgroundSubagentsPolicy( let projectFile = ""; let globalFile = ""; try { - const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "behavior"); + const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(env, "behavior"); projectFile = join(cwd, ".pi", "gentle-ai", BACKGROUND_SUBAGENTS_FILE); globalFile = join(configHome, BACKGROUND_SUBAGENTS_FILE); const projectFileExists = existsSync(projectFile); diff --git a/lib/gentle-config-scope.ts b/lib/gentle-config-scope.ts index 9c354f2e2..b23efc0d0 100644 --- a/lib/gentle-config-scope.ts +++ b/lib/gentle-config-scope.ts @@ -1,7 +1,7 @@ import { isAbsolute } from "node:path"; -// Per-env scope of Gentle's own configuration (the config home, normally -// ~/.pi/gentle-ai). Like VS Code profiles, a named env chooses per category +// Per-env scope of Gentle's own configuration (the base home's config home, +// ~/.gentle-shell/gentle-ai or ~/.pi/gentle-ai). Like VS Code profiles, a named env chooses per category // whether it shares the base configuration or keeps its own copy. Pure data // and env parsing only: shared by the launcher and the Pi extensions. diff --git a/lib/gentle-shell-launcher.ts b/lib/gentle-shell-launcher.ts index 04595a875..24ae47286 100644 --- a/lib/gentle-shell-launcher.ts +++ b/lib/gentle-shell-launcher.ts @@ -272,6 +272,20 @@ export function standaloneConfigHome(home: ResolvedHome): string | undefined { return basename(dir) === "agent" && basename(parent) === ".gentle-shell" ? join(parent, "gentle-ai") : undefined; } +// The launcher config folder a launch of `home` uses (#2201): the isolated +// home's own, and for a named env the base isolated home's, since "shared" +// means shared with the base home. Undefined keeps ~/.pi/gentle-ai (--link, +// a plain --home, or a base home without its own folder). +export function launchConfigHome(home: ResolvedHome, env: Record, homedir: string): string | undefined { + if (home.mode === "isolated") return standaloneConfigHome(home); + return home.env === undefined ? undefined : baseConfigHome(env, homedir); +} + +// The base (isolated) home's own config folder, or undefined when it has none. +export function baseConfigHome(env: Record, homedir: string): string | undefined { + return standaloneConfigHome({ mode: "isolated", dir: isolatedDir(env, homedir), source: "default" }); +} + // The filesystem behind migrateStandaloneConfig; bin/gentle-shell.mjs wires the real one. export interface ConfigMigrationFs { // lstat: a symbolic link is "other", never followed. @@ -1128,10 +1142,11 @@ export function buildPiInvocation(input: BuildPiInvocationInput): PiInvocation { GENTLE_PI_AGENT_HOME: input.home.dir, [USER_PI_HOME_ENV]: userPiHome(input.baseEnv, input.homedir), }; - // #2201: only the standalone isolated home gets its own config folder; any - // other launch removes an inherited marker, so a nested `--link` launch from - // an isolated session reads ~/.pi/gentle-ai again. - const configHome = input.bundled === true ? undefined : standaloneConfigHome(input.home); + // #2201: only the standalone isolated home, and the named envs that share + // it, get its own config folder; any other launch removes an inherited + // marker, so a nested `--link` launch from an isolated session reads + // ~/.pi/gentle-ai again. + const configHome = input.bundled === true ? undefined : launchConfigHome(input.home, input.baseEnv, input.homedir); if (configHome === undefined) delete env[GENTLE_SHELL_CONFIG_HOME_ENV]; else env[GENTLE_SHELL_CONFIG_HOME_ENV] = configHome; diff --git a/runtime/gentle-config-scope.mjs b/runtime/gentle-config-scope.mjs index c51c03cb1..0f49da077 100644 --- a/runtime/gentle-config-scope.mjs +++ b/runtime/gentle-config-scope.mjs @@ -1,8 +1,8 @@ // Generated by scripts/build-runtime-modules.mjs. Do not edit. import { isAbsolute } from "node:path"; -// Per-env scope of Gentle's own configuration (the config home, normally -// ~/.pi/gentle-ai). Like VS Code profiles, a named env chooses per category +// Per-env scope of Gentle's own configuration (the base home's config home, +// ~/.gentle-shell/gentle-ai or ~/.pi/gentle-ai). Like VS Code profiles, a named env chooses per category // whether it shares the base configuration or keeps its own copy. Pure data // and env parsing only: shared by the launcher and the Pi extensions. diff --git a/runtime/gentle-shell-launcher.mjs b/runtime/gentle-shell-launcher.mjs index e9b96190f..b0abcc9ea 100644 --- a/runtime/gentle-shell-launcher.mjs +++ b/runtime/gentle-shell-launcher.mjs @@ -273,6 +273,20 @@ export function standaloneConfigHome(home ) { return basename(dir) === "agent" && basename(parent) === ".gentle-shell" ? join(parent, "gentle-ai") : undefined; } +// The launcher config folder a launch of `home` uses (#2201): the isolated +// home's own, and for a named env the base isolated home's, since "shared" +// means shared with the base home. Undefined keeps ~/.pi/gentle-ai (--link, +// a plain --home, or a base home without its own folder). +export function launchConfigHome(home , env , homedir ) { + if (home.mode === "isolated") return standaloneConfigHome(home); + return home.env === undefined ? undefined : baseConfigHome(env, homedir); +} + +// The base (isolated) home's own config folder, or undefined when it has none. +export function baseConfigHome(env , homedir ) { + return standaloneConfigHome({ mode: "isolated", dir: isolatedDir(env, homedir), source: "default" }); +} + // The filesystem behind migrateStandaloneConfig; bin/gentle-shell.mjs wires the real one. @@ -1129,10 +1143,11 @@ export function buildPiInvocation(input ) { GENTLE_PI_AGENT_HOME: input.home.dir, [USER_PI_HOME_ENV]: userPiHome(input.baseEnv, input.homedir), }; - // #2201: only the standalone isolated home gets its own config folder; any - // other launch removes an inherited marker, so a nested `--link` launch from - // an isolated session reads ~/.pi/gentle-ai again. - const configHome = input.bundled === true ? undefined : standaloneConfigHome(input.home); + // #2201: only the standalone isolated home, and the named envs that share + // it, get its own config folder; any other launch removes an inherited + // marker, so a nested `--link` launch from an isolated session reads + // ~/.pi/gentle-ai again. + const configHome = input.bundled === true ? undefined : launchConfigHome(input.home, input.baseEnv, input.homedir); if (configHome === undefined) delete env[GENTLE_SHELL_CONFIG_HOME_ENV]; else env[GENTLE_SHELL_CONFIG_HOME_ENV] = configHome; diff --git a/tests/background-subagents.test.ts b/tests/background-subagents.test.ts index 7b79f4e68..ef2930b03 100644 --- a/tests/background-subagents.test.ts +++ b/tests/background-subagents.test.ts @@ -399,6 +399,18 @@ test("the resolver attributes the project file, with its path", () => { assert.equal(resolution.envValue, "off"); }); +test("the resolver reads the behavior home from the env it was given, not the process env", () => { + const cwd = makeScratch("gp-bg-src-scope-"); + const ownHome = join(makeScratch("gp-bg-own-"), "gentle-ai"); + writePolicyFile(ownHome, "on"); + const resolution = resolveBackgroundSubagentsPolicy(cwd, { + env: { GENTLE_SHELL_ENV_CONFIG_HOME: ownHome, GENTLE_SHELL_ENV_OWN_CONFIG: "behavior" }, + }); + assert.equal(resolution.globalFile, join(ownHome, "background-subagents.json")); + assert.equal(resolution.policy, "on"); + assert.equal(resolution.source, "global_file"); +}); + test("the resolver attributes the global file when no project file exists", () => { const cwd = makeScratch("gp-bg-src-global-"); const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai"); diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index b911d1532..7b73f83a4 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3766,6 +3766,26 @@ test("re-owning a category reuses its kept own copy instead of seeding over it", assert.equal(readFileSync(own, "utf8"), '{"persona":"edited"}', "the kept own copy is reused"); }); +test("envs config removes a partial seed when a copy fails, so a retry seeds the whole category", (t) => { + const f = fixture(t); + const base = join(f.root, "base-config"); + const env = { ...f.env, GENTLE_PI_CONFIG_HOME: base }; + mkdirSync(base, { recursive: true }); + writeFileSync(join(base, "models.json"), '{"models":"base"}'); + // profiles.json is a directory, so its copy throws after models.json was copied. + mkdirSync(join(base, "profiles.json")); + assert.equal(run(env, ["envs", "new", "a"]).status, 0); + const own = join(f.home, ".gentle-shell", "envs", "a", "gentle-ai"); + const failed = run(env, ["envs", "config", "a", "--own", "models"]); + assert.equal(failed.status, 1, failed.stderr); + assert.equal(existsSync(join(own, "models.json")), false, "no partial copy is left to read as a kept own copy"); + assert.equal(existsSync(join(f.home, ".gentle-shell", "envs", "a", ".gentle-shell-env.json")), false); + rmSync(join(base, "profiles.json"), { recursive: true }); + writeFileSync(join(base, "profiles.json"), '{"profiles":"base"}'); + assert.equal(run(env, ["envs", "config", "a", "--own", "models"]).status, 0); + assert.equal(readFileSync(join(own, "profiles.json"), "utf8"), '{"profiles":"base"}', "the retry seeds the whole category"); +}); + test("envs new rejects scope flags without --own and leaves no half-created env", (t) => { const f = fixture(t); const envs = join(f.home, ".gentle-shell", "envs"); diff --git a/tests/standalone-config-home.test.ts b/tests/standalone-config-home.test.ts index 57a36e945..1d79e5704 100644 --- a/tests/standalone-config-home.test.ts +++ b/tests/standalone-config-home.test.ts @@ -322,3 +322,35 @@ test("standalone setup gives Gentle AI the config pair and keeps both persona fi assert.equal(readFileSync(join(w.home, ".pi", "gentle-ai", "persona.json"), "utf8"), "{\"mode\":\"neutral\"}"); assert.equal(readFileSync(join(w.shellConfig, "persona.json"), "utf8"), "{\"mode\":\"neutral\"}", "the migrated copy is restored too"); }); + +// --- named envs share the base home's config (#2259) ----------------------------- + +test("a named env launch shares the base isolated home's config folder, and only that", () => { + const env: ResolvedHome = { mode: "path", dir: "/os-home/.gentle-shell/envs/work", source: "flag", env: "work" }; + assert.equal(launch(env).env[MARKER], "/os-home/.gentle-shell/gentle-ai", "the base home's own config, not ~/.pi/gentle-ai"); + assert.equal(MARKER in launch(env, { GENTLE_SHELL_HOME: "/custom/shell/agent" }).env, false, "a base without its own folder keeps the shared one"); + assert.equal(MARKER in launch({ mode: "path", dir: "/custom/home", source: "flag" }).env, false, "a plain --home is not an env"); + assert.equal(MARKER in launch(env, {}, true).env, false, "bundled resolves its own prefix config"); +}); + +test("named envs read, seed from and migrate the base home's config, not ~/.pi/gentle-ai", posixOnly, () => { + const w = launcherWorld(); + mkdirSync(w.shellConfig, { recursive: true }); + writeFileSync(join(w.shellConfig, "persona.json"), "{\"persona\":\"base\"}\n"); + writeFileSync(join(w.home, ".pi", "gentle-ai", "persona.json"), "{\"persona\":\"pi\"}\n"); + assert.equal(w.run(["envs", "new", "work"]).status, 0); + const launched = w.run(["--env", "work", "list"]); + assert.equal(launched.status, 0, launched.stderr); + assert.deepEqual(JSON.parse(launched.stdout.trim().split("\n").at(-1)!), { marker: w.shellConfig, explicit: null }); + const created = w.run(["envs", "new", "own", "--own", "persona"]); + assert.equal(created.status, 0, created.stderr); + assert.equal(readFileSync(join(w.home, ".gentle-shell", "envs", "own", "gentle-ai", "persona.json"), "utf8"), "{\"persona\":\"base\"}\n", "seeded from the base home"); +}); + +test("an env launched before the base home ever was still gets the base config migrated", posixOnly, () => { + const w = launcherWorld(); + assert.equal(w.run(["envs", "new", "work"]).status, 0); + const launched = w.run(["--env", "work", "list"]); + assert.equal(launched.status, 0, launched.stderr); + assert.equal(readFileSync(join(w.shellConfig, "banner.json"), "utf8"), "{\"showTextLogo\":false}\n"); +}); From 4014dce8c02948b133a0a093115d245da5c06324 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Sun, 18 Oct 2026 13:02:42 -0400 Subject: [PATCH 27/36] test(config): keep session scratch ledgers in the shared config home main added extensions/session-scratch.ts, whose scratch ledgers are runtime state rather than configuration. List its config-home read as install-level so the drift guard accepts it without a category. --- tests/gentle-config-scope.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/gentle-config-scope.test.ts b/tests/gentle-config-scope.test.ts index 5d80c1601..1ba7827a8 100644 --- a/tests/gentle-config-scope.test.ts +++ b/tests/gentle-config-scope.test.ts @@ -62,7 +62,9 @@ test("without env scope variables every category resolves exactly as before", () // Install-level reads that must ignore env scope: the dev binary registration, // integrity and the update channel belong to the install, never to an env. -const INSTALL_LEVEL_CALLS = new Set([]); +// Session scratch ledgers are runtime state, not configuration, so they stay +// in the shared config home whatever categories an env owns. +const INSTALL_LEVEL_CALLS = new Set(["extensions/session-scratch.ts:93"]); test("every config-home read names its category (drift guard)", () => { const root = new URL("..", import.meta.url); From 17d905cc41de4416819a28e7b112bae3f63a2d96 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Sun, 18 Oct 2026 17:14:31 -0400 Subject: [PATCH 28/36] 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 29/36] 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 30/36] 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 b714df02be7ba793776f5dcaf9d5c355b6e6fb0f Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Sun, 18 Oct 2026 17:17:14 -0400 Subject: [PATCH 31/36] fix(launcher): drop a deleted env from config.json before removing it deleteEnv removed the folder first; a failed config write then left config.json naming a missing default, and the next launch recreated the env empty. The config is now written first, so a failure keeps the env. --- bin/gentle-shell.mjs | 5 ++++- tests/gentle-shell-bin.test.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index a910e1e70..766212e64 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -933,9 +933,12 @@ function renameEnv(root, from, to) { function deleteEnv(root, name) { const dir = assertManagedEnvDir(root, name); const aliases = [...new Set([dir, safeRealpath(dir)])]; - rmSync(dir, { recursive: true, force: true }); + // config.json first: removing the folder cannot be undone, so a failed + // config write must leave the env on disk, never a default that names a + // missing folder (the next launch would recreate it empty). const configPath = resolveConfigPath(); writeRawConfig(configPath, removeEnvFromConfig(readRawConfig(configPath), aliases)); + rmSync(dir, { recursive: true, force: true }); } function setDefaultEnv(root, name) { diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index 932a4bc73..7f521878c 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3665,6 +3665,23 @@ test("the envs manager rolls a rename back when config.json cannot be written", assert.deepEqual(readdirSync(envs), ["a"], "the env keeps its old name, matching config.json"); }); +test("the envs manager keeps an env it cannot drop from config.json", { 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); + // The env is the default: losing its folder while config.json still names + // it would make the next plain launch recreate it empty. + const lockedDir = join(f.root, "locked"); + mkdirSync(lockedDir); + writeFileSync(join(lockedDir, "config.json"), `${JSON.stringify({ home: join(envs, "a") })}\n`); + chmodSync(lockedDir, 0o500); + let result; + try { result = runManager({ ...f.env, GENTLE_SHELL_CONFIG: join(lockedDir, "config.json") }, ["j", "d", "a", "\r", "q"].join("")); } + finally { chmodSync(lockedDir, 0o700); } + assert.equal(result.status, 0, result.stderr); + assert.ok(existsSync(join(envs, "a", ".gentle-shell-home")), "the env stays on disk, 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"); From 681feb02783f4928b2d2f0edf62af049e5f1541b Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Sun, 18 Oct 2026 17:17:14 -0400 Subject: [PATCH 32/36] docs(launcher): list envs config and scope flags in the envs synopsis --- docs/readme-reference.md | 2 +- lib/gentle-shell-launcher.ts | 2 +- runtime/gentle-shell-launcher.mjs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 17330e2bb..5d7d94628 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -301,7 +301,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 envs [list | new [scope] | config [scope]] gentle-shell [home selectors] setup [--dry-run] gentle-shell upgrade [--channel release|main] gentle-shell upgrade --rollback diff --git a/lib/gentle-shell-launcher.ts b/lib/gentle-shell-launcher.ts index 24ae47286..5a6806128 100644 --- a/lib/gentle-shell-launcher.ts +++ b/lib/gentle-shell-launcher.ts @@ -1362,7 +1362,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 envs [list | new [scope] | config [scope]]", " gentle-shell [home selectors] setup [--dry-run]", " gentle-shell upgrade [--channel release|main]", " gentle-shell upgrade --rollback", diff --git a/runtime/gentle-shell-launcher.mjs b/runtime/gentle-shell-launcher.mjs index b0abcc9ea..7f411b524 100644 --- a/runtime/gentle-shell-launcher.mjs +++ b/runtime/gentle-shell-launcher.mjs @@ -1363,7 +1363,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 envs [list | new [scope] | config [scope]]", " gentle-shell [home selectors] setup [--dry-run]", " gentle-shell upgrade [--channel release|main]", " gentle-shell upgrade --rollback", From 486787b1b9e609a0ec8a02bffb48ed1d795f24e7 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Sun, 18 Oct 2026 17:53:09 -0400 Subject: [PATCH 33/36] 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 34/36] 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 35/36] 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 36/36] 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");