Commit graph

177 commits

Author SHA1 Message Date
Brooklyn Nicholson
c730ab8ad7 chore: fmt 2026-04-16 21:09:50 -05:00
Brooklyn Nicholson
c74017f405 fix(tui): sticky prompt correctness + scrollbar re-render thrash
Sticky prompt:
The loop was skipping `first` (the first row in the viewport) when
looking for a user message scrolled above the top edge. If `first`
itself was a user row that had just ticked above the viewport, we'd
fall through the early-return guard (`role === 'user' && !above`),
then walk from `first - 1` backward — never rechecking `first`, never
finding anything, returning '' and leaving the sticky empty. This is
why it felt "stuck" at the start: one-turn sessions with the user row
exactly at/near the top never surfaced the breadcrumb.

Collapsed the two branches into one loop starting at `first`: nearest
user wins — still-on-screen → empty (redundant to echo), already
above → text. Same semantics, covers the gap.

Scrollbar:
`useSyncExternalStore` snapshot was `scrollTop:vp:scrollHeight` —
scrollHeight ticks up by ~1 row on every streamed chunk, forcing a
re-render per chunk. Quantized snapshot to the displayed values
(`thumbTop:thumbSize:vp`) so we only re-render when the bar actually
changes. Drops render count per turn by ~100x during streaming and
stops the "constantly resizes" flicker.
2026-04-16 21:07:19 -05:00
Brooklyn Nicholson
319aabbb80 refactor(tui): wrap progress panel + streaming body in StreamingAssistant
Two improvements:

1. The progress ToolTrail and the streaming MessageLine were two
   sibling JSX blocks in appLayout with hand-rolled margin glue
   between them. Extracted into `<StreamingAssistant>`, a single
   component that owns both the trail and the streaming body plus
   the 1-row gap between them. appLayout just hands it `progress`
   and theme; the layout logic lives in one place, matching the
   mental model that these two pieces are one live assistant turn.

2. Thinking token label was hidden when `reasoningTokens === 0` even
   if the live reasoning text was already populated (the
   scheduleReasoning timer hadn't ticked, or the model sent no
   reasoning but the text was coming in via reasoning.delta).
   Changed the tokenCount fallback from `reasoningTokens !==
   undefined ? reasoningTokens : estimate` to `reasoningTokens > 0 ?
   ... : estimate` so the label appears the moment text exists.
2026-04-16 20:49:41 -05:00
Brooklyn Nicholson
26f3a05c9c fix(tui): don't clobber busy on the progress panel during streaming
`appLayout` was passing `busy={ui.busy && !progress.streaming}` into
ToolTrail, so the moment `message.delta` fired and streaming began,
the panel internally saw `busy=false`. With the prior fix in place
(hasThinking = !!cot || reasoningActive || busy), that flipped
hasThinking to false and the Thinking expander vanished mid-turn —
reappearing only after message.complete when the finalized row
rendered with its own internal expander.

The `!progress.streaming` override was a defensive guard against the
panel implying "still thinking" once the response text was streaming.
But that's already handled inside ToolTrail — `streaming` prop on the
Thinking component uses `busy && reasoningStreaming`, and
reasoningStreaming is already falsey once recordMessageDelta calls
endReasoningPhase.

Pass plain `busy={ui.busy}`. Panel stays up start-to-finish; handoff
to the finalized-message row is continuous.
2026-04-16 20:39:02 -05:00
Brooklyn Nicholson
15096903c7 fix(tui): keep the newline above the streaming assistant text
Finalized assistant messages rendered the thinking/tools trail inside
MessageLine with marginBottom=1 before the response body — giving a
clean blank line above the text. The streaming path rendered the
progress ToolTrail and the streaming MessageLine as two separate
siblings with no margin between, so the in-progress response butted
right up against the thinking panel. That's the "newline appears
after it's done" jank.

Wrap the streaming MessageLine in a Box with marginTop=1 whenever the
progress area is visible above it. Same spacing as the finalized
version, continuous through the handoff.
2026-04-16 20:35:46 -05:00
Brooklyn Nicholson
26859e3fcb fix(tui): keep the Thinking expander visible for the whole turn
Previously `hasThinking = !!cot || reasoningActive || (busy && !hasTools)`
so the moment a tool started streaming (`hasTools` → true) the expander
vanished mid-turn. If the model also produced no `reasoning.delta`
events (reasoning-less models, or reasoning arriving after tools), the
whole turn ran with no Thinking row — then `message.complete`
populated `msg.thinking` from the payload's post-hoc reasoning trace
and the expander suddenly appeared in the transcript AFTER the turn.

Drop the `!hasTools` restriction. The Thinking row now anchors for the
entire `busy` window; tools and thinking coexist as sibling sections
(they already did — the exclusion was a UX mistake). Reasoning-less
models show a dim empty header; streaming models show live content;
tool-interleaved turns keep the anchor visible throughout.
2026-04-16 20:27:06 -05:00
Brooklyn Nicholson
aedc767c66 feat(tui): put the kawaii face+verb ticker in the status bar, not the thinking panel
The status bar was showing stale lifecycle text ("running…") while the
face+verb stream flickered through the thinking panel as Python pushed
thinking.delta events. That's backwards — the face ticker is the
primary "I'm alive" signal, it belongs in the status bar; the thinking
panel is for substantive reasoning and tool activity.

Status bar now reads `ui.busy`: when true, renders a local `<FaceTicker>`
cycling FACES × VERBS on a 2.5s interval, unaffected by server events.
When false, the bar shows the actual status string (ready, starting
agent…, interrupted, etc.).

Side effect: `scheduleThinkingStatus` still patches `ui.status` with
Python's face text, but while busy the bar ignores that string and uses
the ticker instead. No server-side changes needed — Python keeps
emitting thinking.delta as a liveness heartbeat, the TUI just doesn't
let it fight the status bar.
2026-04-16 20:14:25 -05:00
Brooklyn Nicholson
ca30803d89 chore(tui): strip noise comments 2026-04-16 19:14:05 -05:00
Brooklyn Nicholson
727f0eaf74 refactor(tui): clean up touched files — DRY, KISS, functional
Python (tui_gateway/server.py):
- hoist `_wait_agent` next to `_sess` so `_sess` no longer forward-refs
- simplify `_wait_agent`: `ready.wait()` already returns True when set,
  no separate `.is_set()` check, collapse two returns into one expr
- factor `_sess_nowait` for handlers that don't need the agent (currently
  `terminal.resize` + `input.detect_drop`) — DRY up the duplicated
  `_sessions.get` + "session not found" dance
- inline `session = _sessions[sid]` in the session.create build thread so
  agent/worker writes don't re-look-up the dict each time
- rename inline `ready_event` → `ready` (it's never ambiguous)

TS:
- `useSessionLifecycle.newSession`: hoist `r.info ?? null` into `info`
  so it's one lookup, drop ceremonial `{ … }` blocks around single-line
  bodies
- `createGatewayEventHandler.session.info`: wrap the case in a block,
  hoist `ev.payload` into `info`, tighten comments
- `useMainApp` flush effect: collapse two guard returns into one
- `bootBanner.ts`: lift `TAGLINE` + `FALLBACK` to module constants, make
  `GRADIENT` readonly, one-liner return via template literal
- `theme.ts`: group `selectionBg` inside the status* block (it's a UI
  surface bg, same family), trim the comment
2026-04-16 18:07:23 -05:00
Brooklyn Nicholson
275256cdb4 feat(tui): uniform selection background instead of SGR inverse
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.
2026-04-16 15:50:28 -05:00
Brooklyn Nicholson
9503896aa2 perf(tui): paint banner to stdout in ~2ms, before Ink loads
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.
2026-04-16 15:48:41 -05:00
Brooklyn Nicholson
04e36851b7 feat(tui): honest status 'starting agent…' until session.info arrives
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
2026-04-16 15:41:44 -05:00
Brooklyn Nicholson
a8e0a1148f perf(tui): async session.create — sid live in ~250ms instead of ~1350ms
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.
2026-04-16 15:39:19 -05:00
Brooklyn Nicholson
2d693c865c perf(tui): spawn python gateway before loading @hermes/ink
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.
2026-04-16 15:21:49 -05:00
Brooklyn Nicholson
f3920fec0b feat(tui): queue pre-session input, auto-flush when session lands
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.
2026-04-16 15:04:18 -05:00
Brooklyn Nicholson
c6ed61430a perf(tui): paint banner on first frame, don't wait on session.create
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.
2026-04-16 14:58:12 -05:00
Brooklyn Nicholson
18840bcff8 chore: uptick 2026-04-16 14:48:29 -05:00
Brooklyn Nicholson
0478266831 refactor(tui): stop shadowing python — slash fallback inherits worker output
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%)
2026-04-16 14:26:15 -05:00
Brooklyn Nicholson
68ecdb6e26 refactor(tui): store-driven turn state + slash registry + module split
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.
2026-04-16 12:34:45 -05:00
Brooklyn Nicholson
39b1336d1f fix: ctx usage display 2026-04-16 08:27:41 -05:00
Brooklyn Nicholson
8e06db56fd chore: uptick 2026-04-16 01:04:35 -05:00
Brooklyn Nicholson
cb31732c4f chore: uptick 2026-04-15 23:29:00 -05:00
Brooklyn Nicholson
c9f78d110a feat: good vibes indi 2026-04-15 17:43:38 -05:00
Brooklyn Nicholson
57e4b61155 feat: change to $ when in ! mode 2026-04-15 16:34:58 -05:00
Brooklyn Nicholson
cb7b740e32 feat: add subagent details 2026-04-15 14:35:42 -05:00
Brooklyn Nicholson
4b4b4d47bc feat: just more cleaning 2026-04-15 14:14:01 -05:00
Brooklyn Nicholson
9931d1d814 chore: cleanup 2026-04-15 10:35:08 -05:00
Brooklyn Nicholson
33c615504d feat: add inline token count etc and fix venv 2026-04-15 10:20:56 -05:00
Brooklyn Nicholson
99d859ce4a feat: refactor by splitting up app and doing proper state 2026-04-14 22:30:18 -05:00
Brooklyn Nicholson
4cbf54fb33 chore: uptick 2026-04-14 19:38:04 -05:00
Brooklyn Nicholson
3bc661ea29 fix: model et al selection on enter 2026-04-14 18:26:00 -05:00
Brooklyn Nicholson
52c11d172a feat: add scrollbar and fix selection on scroll 2026-04-14 14:34:33 -05:00
Brooklyn Nicholson
9804aa7443 fix: scrolling while selecting 2026-04-14 12:50:22 -05:00
Brooklyn Nicholson
7aed09e1ba fix: ctrlc 2026-04-14 12:07:29 -05:00
Brooklyn Nicholson
dd2b0b4775 chore: uptick 2026-04-14 11:53:55 -05:00
Brooklyn Nicholson
9a3a2925ed feat: scroll aware sticky prompt 2026-04-14 11:49:32 -05:00
Brooklyn Nicholson
c189d5e98b fix: pasting 2026-04-13 22:39:03 -05:00
Brooklyn Nicholson
bbc7316007 feat: add cur cwd 2026-04-13 21:46:08 -05:00
Brooklyn Nicholson
6d6b3b03ac feat: add clicky handles 2026-04-13 21:20:55 -05:00
Brooklyn Nicholson
aeb53131f3 fix(ui-tui): harden TUI error handling, model validation, command UX parity, and gateway lifecycle 2026-04-13 18:29:24 -05:00
Brooklyn Nicholson
783c6b6ed6 chore: uptick 2026-04-13 15:08:06 -05:00
Brooklyn Nicholson
4a260b51fe fix: deep markdown parsing 2026-04-13 15:01:15 -05:00
Brooklyn Nicholson
ebe3270430 fix: fake models 2026-04-13 14:57:42 -05:00
Brooklyn Nicholson
77b97b810a chore: update how txt pasting ux feels 2026-04-13 14:49:10 -05:00
Brooklyn Nicholson
cac1b1b724 fix(ui-tui): surface RPC errors and guard invalid gateway responses 2026-04-13 14:17:52 -05:00
Brooklyn Nicholson
0642b6cc53 fix: clean newline paste thingy 2026-04-13 12:54:48 -05:00
Brooklyn Nicholson
eec1db36f7 chore: preserve commands 2026-04-13 10:43:42 -05:00
Brooklyn Nicholson
713a614ea8 chore: uptick 2026-04-13 10:22:44 -05:00
Brooklyn Nicholson
a27167fb30 chore: fmt 2026-04-13 10:14:05 -05:00
Brooklyn Nicholson
a2c0597ae4 feat: show thinking indicator while inferencing 2026-04-13 10:11:18 -05:00