Selection was falling back to SGR-7 inverse (fg ↔ bg per cell), which
fragments over syntax-highlighted content — each amber/gold/dim/cornsilk
fg turned into a different bg stripe, producing the staircase look.
Now `useMainApp` calls `selection.setSelectionBgColor()` with a muted
navy (`#3a3a55`) on theme change. `setSelectionBg` in screen.ts replaces
just the bg cell-by-cell while preserving fg/bold/dim/italic, so the
highlight is one solid color across the whole drag range and the text
stays readable in its original color.
Skins can override via `selection_bg` in their color map.
Dynamic-importing @hermes/ink + App costs ~170ms on cold start — during
that window the terminal was blank. Now `entry.tsx` writes a raw-ANSI
banner to stdout immediately after the TTY check, using hardcoded
DEFAULT_THEME colors. Ink's `<AlternateScreen>` wipes the normal-screen
buffer when it mounts, so the boot banner is replaced seamlessly by the
real React render a moment later — no double-banner, no flash.
T=2ms banner visible (vs. ~170ms before)
T=~170ms React + Ink mounts
T=~200ms alt screen takes over, Banner component repaints
Palette drift between `bootBanner.ts` and the live theme is harmless —
the live render overrides after ~200ms. Narrow terminals (cols < 98)
fall back to the one-line "⚕ NOUS HERMES" marker.
Post-async-session.create, `session.create` returns in ~1ms with partial
info and the real agent fires `session.info` ~1s later. Previously the
status bar went straight to 'ready' right after the instant RPC return,
which was misleading — `prompt.submit` would block server-side waiting
for the agent to finish building.
Now:
- `newSession`: status = 'starting agent…' when info has no `version`,
else 'ready' (covers the fast resume path too)
- `session.info` event: flips status to 'ready' only if it was
'starting agent…', preserving running/interrupted/error states
Previously `session.create` blocked for ~1.2s on `_make_agent` (mostly
`run_agent` transitive imports + AIAgent constructor). The UI waited
through that whole window before sid became known and the banner/panel
could render.
Now `session.create` returns immediately with `{session_id, info:
{model, cwd, tools:{}, skills:{}}}` and spawns a background thread that
does the real `_make_agent` + `_init_session`. When the agent is live,
the thread emits `session.info` with the full payload.
Python side:
- `_sessions[sid]` gets a placeholder dict with `agent=None` and a
`threading.Event()` named `agent_ready`
- `_wait_agent(session, rid, timeout=30)` blocks until the event is set
(no-op when already set or absent, e.g. for `session.resume`)
- `_sess()` now calls `_wait_agent` — so every handler routed through it
(prompt.submit, session.usage, session.compress, session.branch,
rollback.*, tools.configure, etc.) automatically holds until the agent
is live, but only during the ~1s startup window
- `terminal.resize` and `input.detect_drop` bypass the wait via direct
dict lookup — they don't touch the agent and would otherwise block
the first post-startup RPCs unnecessarily
TS side:
- `session.info` event handler now patches the intro message's `info`
in-place so the seeded banner upgrades to the full session panel when
the agent finishes initializing
- `appLayout` gates `SessionPanel` on `info.version` being present
(only set by `_session_info(agent)`, not by the partial payload from
`session.create`) — so the panel only appears when real data arrives
Net effect on cold start:
T=~400ms banner paints (seeded intro)
T=~245ms ui.sid set (session.create responds in ~1ms after ready)
T=~1400ms session panel fills in (real session.info event)
Pre-session keystrokes queue as before (already handled by the flush
effect); `prompt.submit` will wait on `agent_ready` on the Python side
when the flush tries to send before the agent is live.
Before: entry.tsx imports @hermes/ink (394KB bundle) + App + GatewayClient
in declaration order, then calls `gw.start()` at ~T=220ms. Python fork +
server.py import starts then.
After: only `GatewayClient` is statically imported (5ms, node builtins
only). `gw.start()` fires at ~T=5ms. @hermes/ink + App load in parallel
via `Promise.all(import(...))`. Python gets ~215ms of free runway to do
its own module import before node even finishes loading.
Net: session.info arrives ~150ms earlier in cold start. First React frame
timing is unchanged (still ~240ms — still gated by ink+app imports).
Removed a previously-tried warm-thread in server.py that pre-imported
`run_agent` in the background. Measured variance showed occasional
5-10s outliers (GIL thrashing); median gain was <100ms. Not worth the
non-determinism.
The TUI is fully interactive from the first frame but `session.create`
(agent + tools + MCP) takes ~2s. Plain-text messages typed before the
session is live used to fail with "session not ready yet"; slash and
shell commands worked but agent prompts were dropped.
Now:
- `dispatchSubmission` enqueues plain text when `sid` is null (slash/shell
still short-circuit first)
- `useMainApp` tracks sid transitions and kicks off one `sendQueued()`
when the session first becomes ready; subsequent queued messages drain
on `message.complete` as before
- Fixed pre-existing double-Enter bug that dequeued without sid check
User flow: type `hello` → shows in `queuedDisplay` preview → 2s later
agent wakes → message auto-sends → reply streams. Zero wasted input.
Previously `historyItems` was seeded empty and the intro (with Banner +
SessionPanel) was only pushed after Python's `session.create` returned —
~1.8s of agent + tools + MCP init with nothing on screen. Base CLI feels
instant because it prints the banner as its first action.
Seed `historyItems` with an info-less intro on mount. `appLayout` now
renders the Banner unconditionally for `kind === 'intro'` and gates only
the SessionPanel on `info` being present. Gateway.ready swaps the skin
(~200ms) and session.info fills in the panel when the agent is ready.
Net: first usable frame drops from ~2s to ~300ms (node + module graph +
React mount). No behavior change — intro message is replaced in place
by `introMsg(info)` when `newSession()` / `resumeById()` resolve.
Python's slash worker already prints every echo/panel command through Rich.
TS was reformatting the same data client-side for 23 commands. Delete those
shadows; let the `slash.exec` fallback in `createSlashHandler` route the
worker's text (via `<Ansi>`) and page-wrap long output.
TS registry now contains 23 commands (down from 45) — only those that:
- mutate React-local state (composer, transcript, overlays, uiStore)
- touch the terminal (OSC52 copy, `$EDITOR`, clipboard)
- open pickers (`/model`, `/resume`)
- trigger history surgery (`/undo`, `/retry`, `/compress`, `/personality`)
- need TS-only composition (`/help` merges HOTKEYS + catalog)
Deleted shadows:
session: yolo, skin, verbose, reasoning, provider, stop, reload-mcp,
save, title, insights, debug, fast, platforms, snapshot,
usage, history, profile
ops: plugins, rollback, agents, tasks, cron, config, toolsets,
browser, skills (list/browse only; `/tools configure` kept
for its history-reset side effect)
Side effects:
- Drops `slash/shared.ts` + `SlashShared` + `shared`/`SLASH_OUTPUT_PAGE` —
generic slash.exec fallback handles titled paging via `createSlashHandler`.
- Prunes 17 now-unreferenced `*Response` interfaces from gatewayTypes.ts.
- `createSlashHandler` fallback now pages long output (len>180 || lines>2)
and uses the command name as title.
session.ts: 670 -> 199 (-70%)
ops.ts: 460 -> 52 (-88%)
gatewayTypes.ts: 450 -> 302 (-33%)
Hoist turn state from a 286-line hook into $turnState atom + turnController
singleton. createGatewayEventHandler becomes a typed dispatch over the
controller; its ctx shrinks from 30 fields to 5. Event-handler refs and 16
threaded actions are gone.
Fold three createSlash*Handler factories into a data-driven SlashCommand[]
registry under slash/commands/{core,session,ops}.ts. Aliases are data;
findSlashCommand does name+alias lookup. Shared guarded/guardedErr combinator
in slash/guarded.ts.
Split constants.ts + app/helpers.ts into config/ (timing/limits/env),
content/ (faces/placeholders/hotkeys/verbs/charms/fortunes), domain/ (roles/
details/messages/paths/slash/viewport/usage), protocol/ (interpolation/paste).
Type every RPC response in gatewayTypes.ts (26 new interfaces); drop all
`(r: any)` across slash + main app.
Shrink useMainApp from 1216 -> 646 lines by extracting useSessionLifecycle,
useSubmission, useConfigSync. Add <Fg> themed primitive and strip ~50
`as any` color casts.
Tests: 50 passing. Build + type-check clean.