Skip to content
Open
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
27 changes: 27 additions & 0 deletions apps/tui/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@bazilion/tui",
"version": "0.0.0",
"private": true,
"description": "Terminal UI for the Bazilion daemon — Ink + React, packaged via `bun build --compile`. See docs/backlog/draft/BAZ-004-tui-client.md.",
"type": "module",
"scripts": {
"dev": "tsx src/index.tsx",
"typecheck": "tsc --noEmit",
"compile:darwin-arm64": "bun build src/index.tsx --compile --target=bun-darwin-arm64 --outfile dist/bazi-darwin-arm64",
"compile:darwin-x64": "bun build src/index.tsx --compile --target=bun-darwin-x64 --outfile dist/bazi-darwin-x64",
"compile:linux-x64": "bun build src/index.tsx --compile --target=bun-linux-x64 --outfile dist/bazi-linux-x64",
"compile:linux-arm64": "bun build src/index.tsx --compile --target=bun-linux-arm64 --outfile dist/bazi-linux-arm64",
"compile:windows-x64": "bun build src/index.tsx --compile --target=bun-windows-x64 --outfile dist/bazi-windows-x64.exe"
},
"dependencies": {
"@bazilion/api-types": "workspace:*",
"@bazilion/client": "workspace:*",
"cli-highlight": "^2.1.11",
"ink": "^7.0.4",
"marked": "^14.1.4",
"react": "^19.2.6"
},
"devDependencies": {
"@types/react": "^19.2.15"
}
}
31 changes: 31 additions & 0 deletions apps/tui/src/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Box, Text, useApp, useInput } from 'ink'
import { type ClientConfig, createClient } from './client.ts'
import { AgentsList } from './screens/agents-list.tsx'

interface AppProps {
config: ClientConfig
}

export function App({ config }: AppProps) {
const client = createClient(config)
const { exit } = useApp()

useInput((input, key) => {
if (input === 'q' || (key.ctrl && input === 'c')) exit()
})

return (
<Box flexDirection="column" paddingX={1} paddingY={1}>
<Box marginBottom={1}>
<Text bold color="cyan">
bazi
</Text>
<Text dimColor> · {config.serverUrl}</Text>
</Box>
<AgentsList client={client} />
<Box marginTop={1}>
<Text dimColor>q · quit</Text>
</Box>
</Box>
)
}
18 changes: 18 additions & 0 deletions apps/tui/src/auth-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { existsSync, readFileSync } from 'node:fs'

export interface AuthFile {
token: string
remote?: { server: string; token: string } | null
}

export function readAuthFile(authFile: string): AuthFile {
if (!existsSync(authFile)) {
throw new Error(`${authFile} not found. Start the daemon with \`bazilion serve\` first.`)
}
const raw = readFileSync(authFile, 'utf8')
const parsed = JSON.parse(raw) as Partial<AuthFile>
if (typeof parsed.token !== 'string' || !parsed.token) {
throw new Error(`${authFile} is missing the "token" field`)
}
return { token: parsed.token, remote: parsed.remote ?? null }
}
38 changes: 38 additions & 0 deletions apps/tui/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { type BazilionClient, createClient as createPackageClient } from '@bazilion/client'
import { readAuthFile } from './auth-file.ts'
import { resolveTuiPaths } from './paths.ts'

export type { BazilionClient } from '@bazilion/client'
export { ApiClientError } from '@bazilion/client'

export interface ClientConfig {
serverUrl: string
token: string
}

// Mirrors `apps/cli/src/client.ts:loadClientConfig` deliberately — the TUI and
// CLI share the same fallback chain (env → auth.remote → loopback) so a user
// who paired their CLI against a remote daemon gets the TUI pointed at the
// same place. We don't import from apps/cli to keep app-to-app coupling out.
export function loadClientConfig(): ClientConfig {
const envUrl = process.env.BAZILION_SERVER
const envToken = process.env.BAZILION_TOKEN
if (envUrl && envToken) return { serverUrl: envUrl, token: envToken }

const paths = resolveTuiPaths()
const auth = readAuthFile(paths.authFile)
if (auth.remote?.server && auth.remote.token) {
return {
serverUrl: envUrl ?? auth.remote.server,
token: auth.remote.token,
}
}
return {
serverUrl: envUrl ?? 'http://127.0.0.1:4321',
token: auth.token,
}
}

export function createClient(cfg: ClientConfig = loadClientConfig()): BazilionClient {
return createPackageClient({ serverUrl: cfg.serverUrl, token: cfg.token })
}
14 changes: 14 additions & 0 deletions apps/tui/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { render } from 'ink'
import { App } from './app.tsx'
import { type ClientConfig, loadClientConfig } from './client.ts'

let config: ClientConfig
try {
config = loadClientConfig()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
process.stderr.write(`bazi: ${msg}\n`)
process.exit(1)
}

render(<App config={config} />)
12 changes: 12 additions & 0 deletions apps/tui/src/paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { homedir } from 'node:os'
import { join } from 'node:path'

export interface TuiPaths {
home: string
authFile: string
}

export function resolveTuiPaths(home?: string): TuiPaths {
const root = home ?? process.env.BAZILION_HOME ?? join(homedir(), '.bazilion')
return { home: root, authFile: join(root, 'auth.json') }
}
97 changes: 97 additions & 0 deletions apps/tui/src/screens/agents-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { Agent } from '@bazilion/api-types'
import { Box, Text } from 'ink'
import { useEffect, useState } from 'react'
import type { BazilionClient } from '../client.ts'
import { ApiClientError } from '../client.ts'

interface AgentsListProps {
client: BazilionClient
}

type FetchState =
| { kind: 'loading' }
| { kind: 'ok'; agents: Agent[] }
| { kind: 'setup-required' }
| { kind: 'error'; message: string }

export function AgentsList({ client }: AgentsListProps) {
const [state, setState] = useState<FetchState>({ kind: 'loading' })

useEffect(() => {
let cancelled = false
client
.get<Agent[]>('/api/agents')
.then((agents) => {
if (!cancelled) setState({ kind: 'ok', agents })
})
.catch((err: unknown) => {
if (cancelled) return
// 409 from any data endpoint means the first-run setup gate hasn't
// cleared yet — finish provider + model curation in the web UI or via
// the CLI, then relaunch. Handled distinctly from generic errors so
// the user gets actionable next steps.
if (err instanceof ApiClientError && err.status === 409) {
setState({ kind: 'setup-required' })
return
}
setState({ kind: 'error', message: err instanceof Error ? err.message : String(err) })
})
return () => {
cancelled = true
}
}, [client])

if (state.kind === 'loading') return <Text dimColor>Loading agents…</Text>

if (state.kind === 'setup-required') {
return (
<Box flexDirection="column">
<Text color="yellow">Setup not complete.</Text>
<Text dimColor>
Enable a provider and curate at least one model via the web UI (http://127.0.0.1:4322) or
the CLI, then relaunch.
</Text>
</Box>
)
}

if (state.kind === 'error') {
return (
<Box flexDirection="column">
<Text color="red">Failed to load agents:</Text>
<Text>{state.message}</Text>
</Box>
)
}

if (state.agents.length === 0) {
return <Text dimColor>No agents yet. Spawn one with `bazilion agent spawn`.</Text>
}

return (
<Box flexDirection="column">
<Text bold>Agents ({state.agents.length})</Text>
{state.agents.map((agent) => (
<Box key={agent.id}>
<Text color={statusColor(agent.status)}>● </Text>
<Text>{agent.name}</Text>
<Text dimColor>
{' '}
{agent.modelOverride ?? 'profile-default'} · {agent.id.slice(0, 8)}
</Text>
</Box>
))}
</Box>
)
}

function statusColor(status: Agent['status']): string {
switch (status) {
case 'running':
return 'green'
case 'archived':
return 'gray'
default:
return 'cyan'
}
}
8 changes: 8 additions & 0 deletions apps/tui/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
},
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"]
}
6 changes: 4 additions & 2 deletions docs/backlog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ docs/backlog/
| [BAZ-001](draft/BAZ-001-a2a-federation-spike.md) | Spike — federated multi-employee Bazilion via A2A | S | Investigation only; output is a follow-up implementation BAZ |
| [BAZ-003](draft/BAZ-003-hermes-self-learning.md) | Hermes-style self-learning loop — background reviewer + skill self-editing | L | MVP = reviewer + human-approval gate; curator / FTS5 / runtime skill authoring deferred to v2 BAZs |

## Todo (0)
## Todo (1)

_None right now._
| ID | Title | Size | Notes |
|----|-------|------|-------|
| [BAZ-004](todo/BAZ-004-tui-client.md) | TUI client (apps/tui) — feature-parity terminal UI for the daemon | L | TS + Ink + React, packaged via `bun build --compile`; reuses `@bazilion/client` + `@bazilion/api-types`. v1 = scaffold + read-mostly screens. Scaffold landed; v1 screens to follow |

## Done (1)

Expand Down
Loading
Loading