From bd6f614c3c8956a492a59d0e0cd45f32db08c172 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Mon, 10 Aug 2026 18:30:10 -0400 Subject: [PATCH] config: add hideSessionBar, drop the legacy config fallback Setting "hideSessionBar": true in ~/.ellipsis/config.json removes the session nav under the text input. The chat window gets its rows, focus never leaves the chat, and the sidebar poll that only fed the nav is skipped. Config file only, no env var and no flag. Also removes the pre-0.17 read-only fallback to ~/.config/ellipsis: we are pre customer, so we move straight to the ideal end state instead of carrying migration paths. --- README.md | 7 +++- src/lib/config.ts | 32 ++++++--------- src/ui/SessionsApp.tsx | 26 +++++++++---- src/ui/launch.tsx | 3 +- test/config-legacy.test.ts | 80 -------------------------------------- test/config.test.ts | 23 +++++++++++ 6 files changed, 60 insertions(+), 111 deletions(-) delete mode 100644 test/config-legacy.test.ts diff --git a/README.md b/README.md index da5b700..ac92c91 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,11 @@ at that host's dashboard. Hosts and tokens live in `~/.ellipsis/config.json` (mode 0600); set `ELLIPSIS_CONFIG_DIR` to relocate it. A config file from before hosts existed is migrated on first use — your existing login becomes a host named for its API -base — and a config left at the old `~/.config/ellipsis` location is still read -until the next write lands it in `~/.ellipsis`, so nothing needs re-doing. +base. + +The config file also carries UI preferences. Set `"hideSessionBar": true` at +the top level to drop the session list from the bottom of the interactive UI +and give its rows to the chat window. ## Develop diff --git a/src/lib/config.ts b/src/lib/config.ts index d6ebe32..f527a91 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -18,13 +18,6 @@ function configFile(): string { return join(configDir(), 'config.json') } -// Where pre-0.17 installs kept the config. Read-only fallback so the move to -// ~/.ellipsis doesn't log anyone out; the first saveConfig writes the new -// location and it wins from then on. -function legacyConfigFile(): string { - return join(homedir(), '.config', 'ellipsis', 'config.json') -} - // One Ellipsis instance the CLI can target (prod, beta, or a self-hosted // deployment). `apiBase` is the API host; `appBase` is the dashboard host used // to build clickable links and the login verification URL — derived from @@ -41,10 +34,15 @@ export interface Host { // The config file (v2): a named set of hosts plus which one is active. Commands // resolve against the active host unless an env var / explicit arg overrides. +// UI preferences live at the top level, not per host: they describe the +// terminal in front of the user, which is the same whichever host is active. export interface CliConfig { version: 2 activeHost?: string hosts: Record + // Hide the session bar (the nav under the text input) in the multi-session + // UI, giving its rows to the chat window. + hideSessionBar?: boolean } // The pre-hosts (v1) file shape — a single flat instance. Kept only so @@ -100,20 +98,6 @@ function migrate(raw: unknown): CliConfig { export function loadConfig(): CliConfig { const file = configFile() if (existsSync(file)) return migrate(JSON.parse(readFileSync(file, 'utf8'))) - // No config at the current path: fall back to the legacy XDG location, but - // only for the default dir — an explicit ELLIPSIS_CONFIG_DIR must resolve - // exactly (tests and sandboxes rely on that isolation). - if (!process.env.ELLIPSIS_CONFIG_DIR) { - const legacy = legacyConfigFile() - if (existsSync(legacy)) { - try { - return migrate(JSON.parse(readFileSync(legacy, 'utf8'))) - } catch { - // Unreadable legacy file (often a root-owned ~/.config from an old - // sudo run — the problem ~/.ellipsis exists to avoid). Start fresh. - } - } - } return { version: 2, hosts: {} } } @@ -232,6 +216,12 @@ export function clearAllTokens(): void { saveConfig(cfg) } +// Whether the multi-session UI should hide the session bar. Read from the +// config file only — set `"hideSessionBar": true` in ~/.ellipsis/config.json. +export function hideSessionBar(): boolean { + return loadConfig().hideSessionBar === true +} + export function getEnrolledRepos(): string[] { return (activeHost()?.enrolledRepos ?? []).map((r) => r.toLowerCase()) } diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index 59391f4..5b9175a 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -113,6 +113,10 @@ export interface SessionsAppProps { // caveat to show in its chat (watch-only reasons ride connectability). initialConfigName?: string initialNotice?: string + // Drop the session nav (band 4) entirely, giving its rows to the chat. + // Focus never leaves the chat: esc and ↓ at the bottom edge do nothing. + // Set via "hideSessionBar": true in the config file. + hideSessionBar?: boolean // Builds the start request for a composer-spawned session (the entry point // owns repository detection and defaults). buildStartRequest: (prompt: string) => StartAgentSessionRequest @@ -132,6 +136,7 @@ type MainPane = { type: 'new' } | { type: 'chat'; sessionId: string } export function SessionsApp(props: SessionsAppProps): React.ReactElement { const { api, openSocket, appBase, customerLogin, authorId } = props + const hideNav = props.hideSessionBar === true const { exit } = useApp() const { isRawModeSupported } = useStdin() const { stdout } = useStdout() @@ -191,11 +196,14 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { } }, [api, authorId]) + // The poll only feeds the nav's rows and attention dots; with the bar + // hidden there is nothing on screen it could update. useEffect(() => { + if (hideNav) return void poll() const t = setInterval(() => void poll(), SIDEBAR_POLL_MS) return () => clearInterval(t) - }, [poll]) + }, [poll, hideNav]) // The age lines tick on their own clock (nothing else re-renders idle rows). const [, setAgeTick] = useState(0) @@ -418,7 +426,11 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { { isActive: focus === 'nav' && isRawModeSupported }, ) - const focusNav = useCallback((): void => setFocus('nav'), []) + // With the session bar hidden there is nothing to hand focus to: esc and ↓ + // at the chat's bottom edge land where they started. + const focusNav = useCallback((): void => { + if (!hideNav) setFocus('nav') + }, [hideNav]) const refreshOnDone = useCallback((): void => { void poll() }, [poll]) @@ -426,12 +438,12 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // ------------------------------- rendering -------------------------------- // Band heights: header = blank + title line + rule (3); nav = rule + the - // new-session row + five session rows + hint (8). The chat band gets the - // rest, and a terminal too short for both drops session rows rather than - // growing the frame past the screen. + // new-session row + five session rows + hint (8), or nothing when hidden. + // The chat band gets the rest, and a terminal too short for both drops + // session rows rather than growing the frame past the screen. const headerRows = 3 const navSessionRows = Math.max(1, Math.min(NAV_SESSION_ROWS, contentRows - 10)) - const navRows = 3 + navSessionRows + const navRows = hideNav ? 0 : 3 + navSessionRows const chatRows = Math.max(4, contentRows - headerRows - navRows) // ---- band 1: the header ---- @@ -670,7 +682,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { > {header} {main} - {nav} + {!hideNav && nav} ) } diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx index a579d92..6fa1951 100644 --- a/src/ui/launch.tsx +++ b/src/ui/launch.tsx @@ -1,7 +1,7 @@ import React from 'react' import { render } from 'ink' import { ApiClient } from '../lib/api' -import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' +import { hideSessionBar, requireToken, resolveApiBase, resolveAppBase } from '../lib/config' import { repoFromCwd } from '../lib/laptop' import { makeOpenSocket, resolveWsBase } from '../lib/stream' import type { StartAgentSessionRequest } from '../lib/types' @@ -70,6 +70,7 @@ export async function runSessionsUi(options: SessionsUiOptions): Promise { initialSessionId: options.initialSessionId, initialConfigName: options.initialConfigName, initialNotice: options.initialNotice, + hideSessionBar: hideSessionBar(), buildStartRequest: options.buildStartRequest, }), ) diff --git a/test/config-legacy.test.ts b/test/config-legacy.test.ts deleted file mode 100644 index a64bc43..0000000 --- a/test/config-legacy.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -// The ~/.config/ellipsis fallback only engages when ELLIPSIS_CONFIG_DIR is -// unset, so unlike config.test.ts these tests can't isolate via that env var. -// Instead homedir() is mocked into a throwaway directory per test. -let home: string -vi.mock('node:os', async (importOriginal) => { - const os = await importOriginal() - return { ...os, homedir: () => home } -}) - -import { loadConfig, resolveToken, setActiveHostToken } from '../src/lib/config' - -function writeLegacyConfig(contents: string): void { - const dir = join(home, '.config', 'ellipsis') - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'config.json'), contents) -} - -function newConfigFile(): string { - return join(home, '.ellipsis', 'config.json') -} - -beforeEach(() => { - home = mkdtempSync(join(tmpdir(), 'ellipsis-home-')) - delete process.env.ELLIPSIS_CONFIG_DIR - delete process.env.ELLIPSIS_API_TOKEN -}) - -afterEach(() => { - rmSync(home, { recursive: true, force: true }) -}) - -describe('legacy ~/.config/ellipsis fallback', () => { - it('reads a legacy config when ~/.ellipsis has none', () => { - writeLegacyConfig(JSON.stringify({ token: 'legacy_tok' })) - expect(resolveToken()).toBe('legacy_tok') - }) - - it('prefers ~/.ellipsis over the legacy location once it exists', () => { - writeLegacyConfig(JSON.stringify({ token: 'legacy_tok' })) - mkdirSync(join(home, '.ellipsis'), { recursive: true }) - writeFileSync(newConfigFile(), JSON.stringify({ token: 'new_tok' })) - expect(resolveToken()).toBe('new_tok') - }) - - it('migrates to ~/.ellipsis on the first write, keeping legacy state', () => { - writeLegacyConfig( - JSON.stringify({ token: 'legacy_tok', enrolledRepos: ['acme/api'] }), - ) - setActiveHostToken('fresh_tok') - expect(existsSync(newConfigFile())).toBe(true) - const saved = JSON.parse(readFileSync(newConfigFile(), 'utf8')) - expect(saved.hosts.prod).toMatchObject({ - token: 'fresh_tok', - enrolledRepos: ['acme/api'], - }) - expect(resolveToken()).toBe('fresh_tok') - }) - - it('starts fresh when the legacy file is unreadable garbage', () => { - writeLegacyConfig('not json{{') - expect(loadConfig()).toEqual({ version: 2, hosts: {} }) - }) - - it('ELLIPSIS_CONFIG_DIR disables the fallback entirely', () => { - writeLegacyConfig(JSON.stringify({ token: 'legacy_tok' })) - const isolated = mkdtempSync(join(tmpdir(), 'ellipsis-cfg-')) - process.env.ELLIPSIS_CONFIG_DIR = isolated - try { - expect(resolveToken()).toBeUndefined() - } finally { - delete process.env.ELLIPSIS_CONFIG_DIR - rmSync(isolated, { recursive: true, force: true }) - } - }) -}) diff --git a/test/config.test.ts b/test/config.test.ts index 955b9ec..4be127d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -9,6 +9,7 @@ import { clearActiveHostToken, deleteHost, getEnrolledRepos, + hideSessionBar, listHosts, loadConfig, requireToken, @@ -219,6 +220,28 @@ describe('host management', () => { }) }) +describe('hideSessionBar', () => { + it('defaults to false with no config file', () => { + expect(hideSessionBar()).toBe(false) + }) + + it('reads true from the config file', () => { + writeConfig({ version: 2, hosts: {}, hideSessionBar: true }) + expect(hideSessionBar()).toBe(true) + }) + + it('treats a non-boolean value as false', () => { + writeConfig({ version: 2, hosts: {}, hideSessionBar: 'yes' }) + expect(hideSessionBar()).toBe(false) + }) + + it('survives a host write', () => { + writeConfig({ version: 2, hosts: {}, hideSessionBar: true }) + addHost('beta', 'https://beta-api.ellipsis.dev') + expect(hideSessionBar()).toBe(true) + }) +}) + describe('v1 -> v2 config migration', () => { it('folds a flat {token, apiBase} into one active host', () => { writeConfig({ token: 'file_tok', apiBase: 'https://beta-api.ellipsis.dev' })