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' })