Skip to content

Commit ed02dda

Browse files
feat(ocm-cli): add remote indicator to TUI when attached to Manager (#313)
* feat(ocm-cli): add remote indicator to TUI when attached to Manager * chore(ocm-cli): bump version to 0.2.3 * feat(ocm-cli): show remote indicator on home screen and session prompt * feat(ocm-cli): remove arrow icon from remote indicator * docs: remove arrow icon from remote indicator description
1 parent 7c7e9f3 commit ed02dda

13 files changed

Lines changed: 1437 additions & 39 deletions

File tree

docs/ocm-cli.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,18 @@ ocm --help Show this help
111111
Under the hood, `ocm` execs:
112112

113113
```bash
114-
opencode attach https://manager.example.com/api/opencode-proxy \
114+
OCM_REMOTE_MANAGER_URL=https://manager.example.com \
115+
OCM_REMOTE_REPO_NAME=my-repo \
116+
opencode attach https://manager.example.com/api/opencode-proxy \
115117
--dir /path/to/repo/on/manager \
116118
--password <manager-token> \
117119
--username opencode
118120
```
119121

120122
The child takes over the terminal (`stdio: inherit`); closing the TUI exits `ocm` but leaves the Manager-side session intact.
121123

124+
When the TUI plugin is installed, these internal child-process variables add a `REMOTE <host> · <repo>` indicator to the bottom of Manager-attached TUI windows. Local launches show no indicator.
125+
122126
### Mirror commands
123127

124128
`ocm push` uses a fast git bundle + working-tree patch by default to sync `$PWD` to the matching Manager repo. Pass `--full` to use the legacy tarball mirror (skipping `node_modules`, `dist`, `.next`, `.venv`, `__pycache__`, `.turbo`, and anything matched by `.gitignore`). If the fast path fails, `ocm` prompts before reverting to the tarball mirror (and proceeds automatically when there is no TTY to prompt).
@@ -142,6 +146,8 @@ Both can be used in place of `ocm login`:
142146
| Variable | Description |
143147
|---|---|
144148
| `OPENCODE_MANAGER_URL` | Manager base URL (e.g., `https://manager.example.com`). Not currently consumed by the CLI — use `ocm login`. |
149+
| `OCM_REMOTE_MANAGER_URL` | Internal child-process context set by `ocm attach`; controls the remote TUI indicator. Not a login setting. |
150+
| `OCM_REMOTE_REPO_NAME` | Internal child-process context set by `ocm attach`; labels the remote TUI indicator. Not a login setting. |
145151
| Keychain entry under `https://manager.example.com` | Token used for Bearer auth on Manager API calls and Basic auth on the OpenCode proxy. |
146152

147153
---

ocm-cli/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ refuses to overwrite uncommitted local changes unless `--force` is passed.
6565

6666
The package exposes an OpenCode TUI plugin through its `./tui` package export.
6767
Configure the package name and OpenCode resolves that TUI entrypoint
68-
automatically. It registers `/ocm-move`, which keeps the local session and
68+
automatically. When attached to a Manager via `ocm`, the plugin shows a
69+
`REMOTE <host> · <repo>` indicator at the bottom of the TUI; local launches
70+
show nothing. It registers `/ocm-move`, which keeps the local session and
6971
copies the active session to the Manager after pushing the current repo state.
7072
Use it from inside an OpenCode session after `ocm login` and after the repo
7173
already exists on the Manager (`ocm push --create` if needed).

ocm-cli/bin/ocm.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createProgressReporter } from '../src/progress.js'
99
import { getBranchName, getOriginUrl } from '../src/local-repo.js'
1010
import { resolveOpenCodeProjectId } from '@opencode-manager/shared/project-id'
1111
import { resolveTarget } from '../src/resolve-target.js'
12+
import { buildRemoteAttachEnv } from '../src/remote-context.js'
1213
import { type ManagerRepo, fetchRepos, toRemoteRepoSummaries } from '../src/manager-repos.js'
1314
import packageJson from '../package.json' with { type: 'json' }
1415

@@ -141,7 +142,10 @@ async function attach(managerUrl: string, token: string, repo: ManagerRepo): Pro
141142
'--password', token,
142143
'--username', 'opencode',
143144
]
144-
const child = spawn('opencode', args, { stdio: 'inherit' })
145+
const child = spawn('opencode', args, {
146+
stdio: 'inherit',
147+
env: { ...process.env, ...buildRemoteAttachEnv(managerUrl, repo.name) },
148+
})
145149
child.on('close', (code) => process.exit(code ?? 0))
146150
child.on('error', (err) => die(`failed to spawn opencode: ${err.message}`))
147151
// hand control to child

ocm-cli/eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { defineConfig, globalIgnores } from 'eslint/config'
55
export default defineConfig([
66
globalIgnores(['dist', '.env*', 'coverage']),
77
{
8-
files: ['**/*.ts'],
8+
files: ['**/*.ts', '**/*.tsx'],
99
extends: [
1010
js.configs.recommended,
1111
tseslint.configs.recommended,

ocm-cli/package.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opencode-manager/ocm-cli",
3-
"version": "0.2.2",
3+
"version": "0.2.3",
44
"description": "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.",
55
"license": "MIT",
66
"repository": {
@@ -14,7 +14,7 @@
1414
],
1515
"exports": {
1616
"./tui": {
17-
"import": "./dist/tui.js"
17+
"import": "./dist/tui.tsx"
1818
}
1919
},
2020
"bin": {
@@ -29,18 +29,21 @@
2929
"build": "bun scripts/build.ts",
3030
"postinstall": "node scripts/postinstall.mjs || true",
3131
"typecheck": "tsc --noEmit",
32-
"lint": "eslint . --ext .ts",
33-
"lint:fix": "eslint . --ext .ts --fix",
32+
"lint": "eslint . --ext .ts,.tsx",
33+
"lint:fix": "eslint . --ext .ts,.tsx --fix",
3434
"test": "bun scripts/build.ts && vitest run",
3535
"test:watch": "vitest",
3636
"prepublishOnly": "bun scripts/build.ts"
3737
},
3838
"devDependencies": {
3939
"@eslint/js": "^9.36.0",
4040
"@opencode-manager/shared": "workspace:*",
41+
"@opentui/core": "^0.1.97",
42+
"@opentui/solid": "^0.1.97",
4143
"@types/bun": "^1.3.14",
4244
"@types/node": "^22.0.0",
4345
"eslint": "^9.39.1",
46+
"solid-js": "^1.9.11",
4447
"typescript": "^5.5.0",
4548
"typescript-eslint": "^8.45.0",
4649
"vitest": "^3.1.0"

ocm-cli/scripts/build.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { chmodSync, writeFileSync, rmSync, readFileSync } from 'fs'
1+
import { chmodSync, copyFileSync, writeFileSync, rmSync, readFileSync } from 'fs'
22
import { join } from 'path'
33

44
const root = join(import.meta.dir, '..')
@@ -26,7 +26,9 @@ async function bundleEntry(label: string, entrypoint: string, outputName: string
2626
}
2727

2828
await bundleEntry('ocm CLI', join('bin', 'ocm.ts'), 'ocm.js')
29-
await bundleEntry('TUI plugin entry', join('src', 'tui-plugin.ts'), 'tui.js')
29+
await bundleEntry('TUI plugin impl', join('src', 'tui-plugin.ts'), 'tui-plugin.js')
30+
31+
copyFileSync(join(root, 'src', 'tui.tsx'), join(dist, 'tui.tsx'))
3032

3133
const ocmJsPath = join(dist, 'ocm.js')
3234
const ocmJsContent = readFileSync(ocmJsPath, 'utf-8')

ocm-cli/src/remote-context.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export const REMOTE_MANAGER_URL_ENV = 'OCM_REMOTE_MANAGER_URL'
2+
export const REMOTE_REPO_NAME_ENV = 'OCM_REMOTE_REPO_NAME'
3+
4+
export type RemoteContext = {
5+
managerHost: string
6+
repoName?: string
7+
}
8+
9+
export function buildRemoteAttachEnv(managerUrl: string, repoName: string): Record<string, string> {
10+
return {
11+
[REMOTE_MANAGER_URL_ENV]: managerUrl,
12+
[REMOTE_REPO_NAME_ENV]: repoName,
13+
}
14+
}
15+
16+
export function readRemoteContext(env: NodeJS.ProcessEnv): RemoteContext | undefined {
17+
const urlValue = env[REMOTE_MANAGER_URL_ENV]
18+
if (!urlValue) return undefined
19+
20+
let managerHost: string
21+
try {
22+
managerHost = new URL(urlValue).host
23+
} catch {
24+
managerHost = urlValue.trim()
25+
}
26+
27+
const repoName = env[REMOTE_REPO_NAME_ENV] || undefined
28+
29+
return { managerHost, repoName }
30+
}

ocm-cli/src/tui-plugin.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { TuiPluginApi, TuiPluginModule } from './tui-types.js'
1+
import type { TuiPluginApi } from './tui-types.js'
22
import { readInstallNotice, readState } from './state.js'
33
import { getToken } from './keychain.js'
44
import { fetchRepos, toRemoteRepoSummaries } from './manager-repos.js'
@@ -8,7 +8,7 @@ import { transferSession } from './session-move.js'
88
import { createManagerReplay } from './remote-replay.js'
99
import { readSessionEvents } from './local-history.js'
1010

11-
const tui = async (api: TuiPluginApi): Promise<void> => {
11+
export async function setupOcm(api: TuiPluginApi): Promise<void> {
1212
showInstallNotice(api)
1313
api.keymap.registerLayer({
1414
commands: [
@@ -124,4 +124,4 @@ async function runSessionMove(api: TuiPluginApi): Promise<void> {
124124
}
125125
}
126126

127-
export default { id: 'ocm', tui } satisfies TuiPluginModule
127+
export { readRemoteContext } from './remote-context.js'

ocm-cli/src/tui-types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
import type { RGBA } from '@opentui/core'
2+
3+
export type TuiThemeColors = {
4+
accent: RGBA
5+
text: RGBA
6+
textMuted: RGBA
7+
backgroundElement: RGBA
8+
}
9+
10+
export type TuiSlotContext = { theme: { readonly current: TuiThemeColors } }
11+
12+
export type TuiSlotRegistration = {
13+
order?: number
14+
slots: Record<string, (ctx: TuiSlotContext, props: Record<string, unknown>) => unknown>
15+
}
16+
117
export type TuiRouteCurrent = { name: 'session'; params: { sessionID: string } } | { name: string; params?: Record<string, unknown> }
218
export type TuiSessionInfo = { id: string; directory: string; title?: string }
319
export type TuiToast = { title?: string; message: string; variant?: 'info' | 'success' | 'error' | 'warning'; duration?: number }
@@ -7,5 +23,6 @@ export type TuiPluginApi = {
723
state: { session: { get: (sessionID: string) => TuiSessionInfo | undefined } }
824
ui: { toast: (input: TuiToast) => void }
925
keymap: { registerLayer: (layer: { commands: TuiCommandDef[]; bindings?: Record<string, unknown> }) => unknown }
26+
slots: { register: (registration: TuiSlotRegistration) => string }
1027
}
1128
export type TuiPluginModule = { id?: string; tui: (api: TuiPluginApi, options?: Record<string, unknown>) => Promise<void> }

ocm-cli/src/tui.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/** @jsxImportSource @opentui/solid */
2+
import { setupOcm, readRemoteContext } from './tui-plugin.js'
3+
import type { TuiPluginApi, TuiPluginModule, TuiSlotContext } from './tui-types.js'
4+
5+
const tui = async (api: TuiPluginApi): Promise<void> => {
6+
await setupOcm(api)
7+
8+
const remote = readRemoteContext(process.env)
9+
if (!remote) return
10+
11+
const label = remote.repoName ? `${remote.managerHost} · ${remote.repoName}` : remote.managerHost
12+
const indicator = (ctx: TuiSlotContext) => {
13+
const theme = ctx.theme.current
14+
return (
15+
<box flexDirection="row" flexShrink={0} gap={1}>
16+
<text fg={theme.accent}>
17+
<b>REMOTE</b>
18+
</text>
19+
<text fg={theme.textMuted}>{label}</text>
20+
</box>
21+
)
22+
}
23+
24+
api.slots.register({
25+
order: 300,
26+
slots: {
27+
session_prompt_right: indicator,
28+
home_prompt_right: indicator,
29+
},
30+
})
31+
}
32+
33+
export default { id: 'ocm', tui } satisfies TuiPluginModule

0 commit comments

Comments
 (0)