229 lines
11 KiB
Markdown
229 lines
11 KiB
Markdown
# Fishbone: black TUI, split starts, leftover OpenCode
|
||
|
||
Fix **bottom to top**. Do not start at “blank screen” or a new `script/dev.ts`. Each layer can look like the same black window.
|
||
|
||
Date: 2026-08-24
|
||
Repo: `neuron`
|
||
Kit: sibling `neuron-tui`
|
||
Symptom: iTerm looks empty after launching the CLI, or the CLI dies before that with a loud error.
|
||
|
||
---
|
||
|
||
## Unproven (do not skip)
|
||
|
||
We have **not** shown that a high-contrast cell landed in **your** iTerm **alternate screen** on the current command.
|
||
|
||
iTerm “session contents” (AppleScript) is usually the **main** buffer. The TUI draws on the **alt** buffer. Empty contents is not proof of a blank app.
|
||
|
||
Until a bright first frame or a dump of the renderer buffer, “always blank” is several failures wearing one coat.
|
||
|
||
---
|
||
|
||
## Core lizard (cyclomatic)
|
||
|
||
Tool: **lizard** (CCN = cyclomatic complexity). Default warn is **15**. Run 2026-08-24 on `@neuron-tui/core` + native Zig.
|
||
|
||
**The JS presentation host is too big for lizard to see.** `renderer.ts` is 5130 lines. Lizard reports avg CCN **1.9** and **0 warnings**. That is a lie: it swallowed most of `CliRenderer` into `resolveModes` (595 lines). `zig.ts` is 6706 lines and lizard found **5 functions**. If a metric cannot split the file, the file is the problem.
|
||
|
||
Hand count of the JS hot path (if/else/switch + `&&`/`||`): `loop` ~32 branches / 122 nloc; `requestRender` ~10; `setupTerminal` ~14; `constructor` ~39. Not 1.9.
|
||
|
||
**Native is where lizard can see, and one function owns present:**
|
||
|
||
| CCN | nloc | fn | file |
|
||
|---|---|---|---|
|
||
| **189** | 394 | `prepareRenderFrameWithWriter` | `native/src/renderer.zig` |
|
||
| **104** | 240 | `checkEnvironmentOverrides` | `native/src/terminal.zig` |
|
||
| 58 | 107 | `processCapabilityResponse` | `terminal.zig` |
|
||
| 57 | 109 | `writeSnapshotCommit` | `renderer.zig` |
|
||
| 47 | 68 | `writeKittyImages` | `renderer.zig` |
|
||
|
||
`prepareRenderFrameWithWriter` is **12×** the warn line. It is the function that turns the JS buffer into TTY bytes. Skip lives next door: `prepareFrame` tryLock miss → `finishSkippedFrame` **clears** the buffer JS just drew. `renderer-output.zig` looks simple (avg CCN 2.6). The skip is small. The present is not.
|
||
|
||
JS outliers that are real parsers, not the blank path: `Markdown.createInitialStyledText` CCN **366** (lizard may have eaten the class), `toNativeAudioStreamFormat` 101, `parseAlign` 89, `parseKeypress` 84, `parsePositiveDecimalPrefix` 77.
|
||
|
||
**Fix from this list first:** split/log `prepareRenderFrameWithWriter`; do not wipe `nextRenderBuffer` on skip; do not run `checkEnvironmentOverrides` + alt-screen + first frame on the same mutex; split `CliRenderer` until lizard can name `loop` / `requestRender` / `setupTerminal`.
|
||
|
||
---
|
||
|
||
## Bottom → top (fix in this order)
|
||
|
||
### 0. Hold one test still
|
||
|
||
This chat mixed runs: Cursor aborted `serve`, AppleScript 0×0 windows, fake `script/dev.ts`, OpenTUI then `neuron-tui`, port 4096 leftovers. “Still blank” compared **different stacks**.
|
||
|
||
**Fix:** one command, real iTerm (not Cursor job), leave the engine alone, do not AppleScript-scrape alt-screen.
|
||
|
||
Current command (from any folder, including `~`):
|
||
|
||
```bash
|
||
bun --cwd /Users/will/Development/neuron-technologies/neuron/packages/cli --conditions=browser src/index.ts
|
||
```
|
||
|
||
`--cwd packages/cli` is required so `packages/cli/bunfig.toml` preloads `@neuron-tui/solid/preload`. That cwd also makes `process.cwd()` = `packages/cli` (the “project” the engine/TUI think you opened). That is a later layer.
|
||
|
||
---
|
||
|
||
### 1. Core: native library (`neuron-tui`)
|
||
|
||
**What:** Zig renderer. On darwin arm64 JS does `import("@neuron-tui/core-darwin-arm64")`.
|
||
**Fact:** that name **404s on npm**. OpenTUI’s `@opentui/core-darwin-arm64` **is** published. Rename did not ship binaries.
|
||
**Error (loud):** `Failed to initialize OpenTUI render library: Cannot find package '@neuron-tui/core-darwin-arm64'` — leftover **OpenTUI** string in **your** core.
|
||
**Done locally:** workspace package `neuron-tui/packages/core-darwin-arm64` copies `packages/native/lib/aarch64-macos/libopentui.dylib` (built ~Aug 22). CLI/TUI depend on it.
|
||
|
||
**Still open:** JS is today; dylib is another day. ABI mismatch → black, **no** JS throw. Rebuild native from current `neuron-tui` when fixing core for real. Other platforms still 404 (linux/win optional deps).
|
||
|
||
---
|
||
|
||
### 2. Core: Mac thread vs Linux, skip vs fail
|
||
|
||
**What:** `CliRenderer` constructor sets `useThread: true` except **Linux** (forced off; comment: thread **crashes**). You are on Mac.
|
||
|
||
**Two output machines:**
|
||
|
||
| Path | What happens on native `render` status `SKIPPED` (1) |
|
||
|---|---|
|
||
| Tests / custom stdout | `NativeSpanFeed` → wait `feed.idle()` → retry |
|
||
| iTerm `process.stdout` + thread | **No feed.** `stdout.write` hijacked to `lib.writeOut`. Skip → `"backpressured"`, **no** `console.error` |
|
||
|
||
**Alt-screen** is `lib.setupTerminal(..., alternate-screen)` — wipe is **native**, before Solid has a tree.
|
||
|
||
**Fix direction:** `useThread: false` on darwin until skip is visible; or log skip; or use feed on TTY; rebuild native; prove `lib.render` returns presented not skipped.
|
||
|
||
---
|
||
|
||
### 3. Core: idle one-shot vs live loop
|
||
|
||
**What:** `@neuron-tui/solid` `render()` does `engine.attach` (timeline) + mount. Tests call `renderer.start()`. The **app does not**.
|
||
|
||
Paint is `requestRender()` → one `activateFrame` if `_isRunning` is false.
|
||
|
||
`activateFrame` **no-ops** if `updateScheduled` is already false.
|
||
|
||
`internalStart()` **clears** `updateScheduled` so queued one-shots die when the live loop starts. Idle shots and the continuous loop **cancel each other by design**.
|
||
|
||
**Fix direction:** `renderer.start()` (or `requestLive`) after mount so skipped frame 0 is not the only chance. Do not “fix” this by adding another wrapper script.
|
||
|
||
---
|
||
|
||
### 4. Core: queries and pixels on the same hijack
|
||
|
||
`setupTerminal` returns immediately. It has already:
|
||
|
||
1. switched alt-screen
|
||
2. turned on capability probes (`privateCapabilityRepliesActive`)
|
||
3. written those probes through **the same** `lib.writeOut` as frames
|
||
|
||
iTerm replies on stdin. Frame 0 is `process.nextTick`. Probes and first paint **contend**. Skip on frame 0 is “queue full,” not “JS forgot to draw.” The 5s timer is **stop asking**, not **don’t draw yet**.
|
||
|
||
Constructor also replaces **global** `requestAnimationFrame`. If the renderer is idle, rAF UI sleeps too.
|
||
|
||
**Fix direction:** don’t probe on the presentation path; or don’t hijack stdout until after first present; or `useThread: false` so `stdout.write` is real TTY again.
|
||
|
||
---
|
||
|
||
### 5. Errors after core owns stdout can vanish
|
||
|
||
Loud errors you pasted (`Failed to start server`, `react`, missing dylib) happened **before** hijack.
|
||
|
||
Bootstrap (`sync.tsx`) uses `throwOnError: true` and `exit(e)` **after** the TUI is up. Those logs can go into Zig. **No `ERROR (#1)` + black ≠ no error.**
|
||
|
||
**Fix direction:** log bootstrap failures to a file, or restore `realStdoutWrite` for `console.error`.
|
||
|
||
---
|
||
|
||
### 6. `--conditions=browser` is a pun
|
||
|
||
**Needed:** `solid-js` client build (`dist/solid.js`), not `dist/server.js`.
|
||
**Also:** Bun treats it as “browser app” → default JSX factory **React**.
|
||
|
||
`--cwd` **repo** skips `packages/cli/bunfig.toml` → no `@neuron-tui/solid/preload` → `Cannot find package 'react'`. Root bunfig preload failed: `@opentui/solid/preload` not installed at root (and that is not your kit).
|
||
|
||
**Fix direction:** keep CLI bunfig preload; do not put kit preload on repo root; or pass `--jsx-import-source @neuron-tui/solid` explicitly; split “solid client resolve” from “browser JSX.”
|
||
|
||
---
|
||
|
||
### 7. One `--cwd`, three jobs
|
||
|
||
| Job | Wants |
|
||
|---|---|
|
||
| bunfig / Solid preload | `packages/cli` |
|
||
| `process.cwd()` = project for engine/TUI | neuron repo (or the user’s real project) |
|
||
| Finding `src/index.ts` | a path that exists from **this** shell (`~` vs repo) |
|
||
|
||
`--cwd packages/cli` from `~` → ENOENT (`packages/cli` is relative).
|
||
`--cwd` repo → JSX React.
|
||
Old `script/dev.ts` climbed **twice** (`URL ..` then `dirname`) → `neuron-technologies/` not `neuron/`. Deleted; do not bring it back.
|
||
|
||
**Fix direction:** absolute CLI path + set process cwd to the repo **after** bun loads bunfig, or `bun --cwd cli` plus `NEURON_CWD` / engine `cwd` override. Never one relative `--cwd` for all three.
|
||
|
||
---
|
||
|
||
### 8. Two bosses for “is the engine up”
|
||
|
||
CLI daemon: alive = `~/.neuron/server.json` **and** `/global/health`.
|
||
A live `bun ./src/serve.ts` on **4096** with **no** file = “no server.” Child spawn uses `stdio: ignore`. Wait → `Failed to start server`.
|
||
|
||
Daemon `start()` now probes default `http://127.0.0.1:4096` if registration is missing.
|
||
|
||
**Fix direction:** one authority (health on the port, or always `--register`). Never hide child stderr. Don’t spawn a second listener on 4096.
|
||
|
||
---
|
||
|
||
### 9. Painted empty ≠ unpainted
|
||
|
||
`packages/tui` is still OpenCode’s **client** (many HTTP routes, plugins, home layout). Neuron answers a lot with **empties** so the client won’t invent data.
|
||
|
||
Home: two `flexGrow={1}` spacers, 7-row logo, prompt. Wordmark PNG is cream on **black**; app clear `#0a0a0a`. `StartupLoading` only appears if `ready()` is false after 500ms; App `ready` starts **true** → **no spinner**.
|
||
|
||
A **successful** frame can look dead.
|
||
|
||
Settings `~/.local/state/opencode/kv.json` used to **hide the whole tree** until a flock lock finished (`createSimpleContext` + `Show when ready`). That gate was removed; file is optional. **Do not treat kv as the remaining blank.**
|
||
|
||
Logo `protocol="auto"` (Kitty/iTerm handshake) could hang with no ASCII fallback. Now `protocol="blocks"` on the same PNG. Contrast still terrible.
|
||
|
||
**Fix direction:** first frame **high contrast** (e.g. yellow “neuron” on black) **before** logo/image; `renderer.start()`; then put the wordmark back. Do not use alt-screen scrape as pass/fail.
|
||
|
||
---
|
||
|
||
### 10. Product vs kit (do not confuse)
|
||
|
||
| Piece | What it is |
|
||
|---|---|
|
||
| `neuron-tui` | Your **kit** (Zig + Solid reconciler). Not the Neuron chat app. |
|
||
| `packages/tui` | OpenCode **screens** now importing `@neuron-tui/*`. |
|
||
| `packages/cli` | Launcher + daemon; still `@opencode-ai/cli`. |
|
||
| `packages/neuron` | Engine HTTP on 4096. |
|
||
| `script/dev.ts` | Fake one-command runner. **Deleted. Do not restore.** |
|
||
|
||
Root `package.json` workspaces include sibling `../neuron-tui/packages/{core,solid,keymap,react,core-darwin-arm64}`. Nested workspace + npm 404s on other `core-*` platforms remain.
|
||
|
||
---
|
||
|
||
## Loud errors already explained (not “blank”)
|
||
|
||
| Paste | Cause |
|
||
|---|---|
|
||
| `Could not change directory to "packages/cli"` | Relative `--cwd` from `~` |
|
||
| `Failed to start server` | Daemon vs leftover 4096 / no `server.json` |
|
||
| `Cannot find package 'react'` | JSX default React; CLI bunfig not loaded |
|
||
| `preload not found "@opentui/solid/preload"` | Root bunfig; not your kit |
|
||
| `Cannot find package '@neuron-tui/core-darwin-arm64'` | Native never published; now local package |
|
||
|
||
---
|
||
|
||
## Not the user’s fault
|
||
|
||
Did not delete `packages/cli`. Did not need React. Did not “fail to use the TS build.” Bun/`package.json` already was that. Did not turn on a blank-screen mode.
|
||
|
||
---
|
||
|
||
## Suggested first patches (still bottom-up)
|
||
|
||
1. Rebuild `neuron-tui` native for this machine; keep `core-darwin-arm64` in workspace.
|
||
2. In `packages/tui` `createCliRenderer`: `useThread: false` until skip is logged.
|
||
3. After Solid `render()`, `renderer.start()`.
|
||
4. First paint: high-contrast text, not the black wordmark.
|
||
5. Then: one cwd story, engine health-only, bootstrap errors to a file.
|
||
|
||
When those five are true in **one** iTerm run, re-judge “blank.” Not before.
|