Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 11 additions & 21 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, Host>
// 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
Expand Down Expand Up @@ -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: {} }
}

Expand Down Expand Up @@ -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())
}
Expand Down
26 changes: 19 additions & 7 deletions src/ui/SessionsApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -418,20 +426,24 @@ 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])

// ------------------------------- 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 ----
Expand Down Expand Up @@ -670,7 +682,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement {
>
{header}
{main}
{nav}
{!hideNav && nav}
</Box>
)
}
Expand Down
3 changes: 2 additions & 1 deletion src/ui/launch.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -70,6 +70,7 @@ export async function runSessionsUi(options: SessionsUiOptions): Promise<void> {
initialSessionId: options.initialSessionId,
initialConfigName: options.initialConfigName,
initialNotice: options.initialNotice,
hideSessionBar: hideSessionBar(),
buildStartRequest: options.buildStartRequest,
}),
)
Expand Down
80 changes: 0 additions & 80 deletions test/config-legacy.test.ts

This file was deleted.

23 changes: 23 additions & 0 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
clearActiveHostToken,
deleteHost,
getEnrolledRepos,
hideSessionBar,
listHosts,
loadConfig,
requireToken,
Expand Down Expand Up @@ -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' })
Expand Down