From bf45aa3a45b467a821cb01809ec32d9d17ef524b Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 15 Jun 2026 15:59:11 +0530 Subject: [PATCH] =?UTF-8?q?opentui(v6):=20WIP=20=E2=80=94=20todo=20panel?= =?UTF-8?q?=20+=20memoryMonitor=20+=20mouse/startup-prompt=20env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preservation snapshot of three in-progress OpenTUI threads before merging main (gate-green together: npm run check 821 tests, 0 lint/type errors): - todo panel (todoPanel/todoTool + App/statusBar/transcript/store wiring, ☑ done/total status chip, latestTodos snapshot) - OpenTUI memoryMonitor (boundary+logic+test; the inverse of the Ink memlog port) - env: resolveMouseEnabled/envToggle/startupPrompt, HERMES_TUI_MOUSE + HERMES_TUI_PROMPT aliases, startup-image attach in main.tsx - termChrome refactor Not yet split per-feature; commit boundary is the pre-merge clean point. --- ui-opentui/src/boundary/memoryMonitor.ts | 46 +++++++ ui-opentui/src/boundary/termChrome.ts | 41 ++++-- ui-opentui/src/entry/main.tsx | 82 +++++++++-- ui-opentui/src/logic/env.ts | 103 ++++++++++++++ ui-opentui/src/logic/memoryMonitor.ts | 88 ++++++++++++ ui-opentui/src/logic/store.ts | 100 +++++++++++++- ui-opentui/src/logic/termChrome.ts | 34 +---- ui-opentui/src/test/env.test.ts | 131 +++++++++++++++++- ui-opentui/src/test/memoryMonitor.test.ts | 80 +++++++++++ ui-opentui/src/test/termChrome.test.tsx | 88 +++++++++--- ui-opentui/src/view/App.tsx | 4 + ui-opentui/src/view/statusBar.tsx | 23 +++- ui-opentui/src/view/todoPanel.tsx | 138 +++++++++++++++++++ ui-opentui/src/view/tools/todoTool.tsx | 161 ++++++++++++++++++++++ ui-opentui/src/view/transcript.tsx | 23 +++- 15 files changed, 1070 insertions(+), 72 deletions(-) create mode 100644 ui-opentui/src/boundary/memoryMonitor.ts create mode 100644 ui-opentui/src/logic/memoryMonitor.ts create mode 100644 ui-opentui/src/test/memoryMonitor.test.ts create mode 100644 ui-opentui/src/view/todoPanel.tsx create mode 100644 ui-opentui/src/view/tools/todoTool.tsx diff --git a/ui-opentui/src/boundary/memoryMonitor.ts b/ui-opentui/src/boundary/memoryMonitor.ts new file mode 100644 index 00000000000..1cac154b6cf --- /dev/null +++ b/ui-opentui/src/boundary/memoryMonitor.ts @@ -0,0 +1,46 @@ +/** + * memoryMonitor — the early-warning BOUNDARY (touches node:process; the pure + * threshold/growth logic lives in logic/memoryMonitor.ts). + * + * Ports the high-value #34095 silent-death early-warning from Ink + * (`ui-tui/src/lib/memoryMonitor.ts`) to the OpenTUI engine, and ONLY that: + * - NO auto heap-snapshot capture (the #41948 disk-fill bug class is not + * re-imported — the always-on memlog NDJSON trace is the diagnosis path, + * and its rss-vs-heap divergence is the better diagnostic for the native + * RSS-leak class a V8 snapshot captures poorly). + * - NO Ink cache eviction (Solid disposes out-of-window rows; windowing + + * proactiveGc already cover memory pressure). + * + * It polls `process.memoryUsage()` on a 10s unref'd interval and, when the + * pure detector fires, surfaces a single transcript system line so the user + * SEES "memory climbing fast" before Node OOMs under the exit threshold. This + * is ON by default (unlike memlog/heapdump): it's a user-facing safety + * heads-up, not a diagnostic dump, and costs one memoryUsage() read per 10s + * with zero disk. Every failure path disables silently (a diagnostic must + * never break the TUI — the one place the "errors propagate" rule is + * intentionally inverted, matching memlog/proactiveGc). + */ +import { createWarnState, evaluateWarn, warnLine } from '../logic/memoryMonitor.ts' + +/** Sample cadence — matches Ink's monitor (10s, unref'd). */ +const SAMPLE_MS = 10_000 + +/** + * Start the early-warning watcher. `emitWarn` receives the ready-to-show system + * line on the (one-shot) tick growth looks abnormal. Returns a stop function. + * The interval is unref'd so it never keeps the process alive. + */ +export function startMemoryMonitor(emitWarn: (line: string) => void): () => void { + const state = createWarnState() + const timer = setInterval(() => { + try { + const { heapUsed, rss } = process.memoryUsage() + const { fire, growthBytes } = evaluateWarn(state, heapUsed) + if (fire) emitWarn(warnLine(heapUsed, rss, growthBytes)) + } catch { + clearInterval(timer) // a failing diagnostic must not retry forever + } + }, SAMPLE_MS) + timer.unref?.() + return () => clearInterval(timer) +} diff --git a/ui-opentui/src/boundary/termChrome.ts b/ui-opentui/src/boundary/termChrome.ts index 8b3246c25da..b533ef82449 100644 --- a/ui-opentui/src/boundary/termChrome.ts +++ b/ui-opentui/src/boundary/termChrome.ts @@ -1,19 +1,28 @@ /** * Terminal chrome seam — window title (OSC 0/2) + desktop notifications - * (OSC 9/99/777) through the renderer's native output path. + * through the renderer's native primitives. * * Why the renderer and not process.stdout: the zig side owns the terminal — - * `setTerminalTitle` is a native FFI call and `writeOut` serializes raw - * control bytes with frame presentation (core itself uses it for OSC 111), - * so chrome writes can never tear a frame. + * `setTerminalTitle` and `triggerNotification` are native FFI calls and + * `writeOut` serializes raw control bytes with frame presentation, so chrome + * writes can never tear a frame. + * + * Notifications go through the native `renderer.triggerNotification(message, + * title)` (zig `lib.triggerNotification`), NOT a hand-rolled OSC 9/99/777 spray. + * The zig side does what raw OSC can't: authoritative protocol detection + * (query > heuristic) so it picks the ONE protocol the terminal speaks, **tmux + * DCS passthrough wrapping** (raw OSC is silently eaten by tmux), and Zellij + * OSC-99 enforcement. It returns `false` when no protocol was detected. * * Focus suppression: core parses mode-1004 focus reports (`ESC[I`/`ESC[O`) * and re-emits them as renderer `focus`/`blur` events — notifications are * skipped while the terminal reports focused (you're already looking at it). - * Terminals that never report focus leave the state at the assumed-focused - * initial value… which would swallow every notification, so the FIRST blur - * is what arms suppression: until a blur arrives we treat focus as unknown - * and notify unconditionally (worst case: a redundant ping while focused). + * Native `triggerNotification` does NOT do focus suppression, so it stays our + * policy here. Terminals that never report focus leave the state at the + * assumed-focused initial value… which would swallow every notification, so + * the FIRST blur is what arms suppression: until a blur arrives we treat focus + * as unknown and notify unconditionally (worst case: a redundant ping while + * focused). * * Everything here is total — chrome must never throw into the render loop * or a teardown path. @@ -23,7 +32,7 @@ import type { CliRenderer } from '@opentui/core' import type { TermNotification } from '../logic/termChrome.ts' import { notifyEnabled, - notifySequences, + sanitizeOscText, TITLE_STACK_RESTORE, TITLE_STACK_SAVE, windowTitleFor @@ -41,6 +50,8 @@ export interface TerminalChromeSeam { /** The renderer surface the seam writes through (runtime-verified shapes). */ interface RendererSeam { setTerminalTitle(title: string): void + /** Native desktop notification (protocol detection + tmux/Zellij wrapping). */ + triggerNotification(message: string, title?: string): boolean writeOut(chunk: string): void on(event: 'focus' | 'blur', listener: () => void): unknown once(event: 'destroy', listener: () => void): unknown @@ -85,7 +96,17 @@ export function installTerminalChrome(renderer: CliRenderer): TerminalChromeSeam }, notify: notification => { if (!notificationsOn || focused === true) return - for (const sequence of notifySequences(notification)) writeRaw(seam, sequence) + // Map our {title:'Hermes', body:'finished — …'} → native (message, title): + // native API takes the BODY as the message and the heading as the title. + const title = sanitizeOscText(notification.title) + const body = sanitizeOscText(notification.body ?? '') + if (!title) return + const message = body || title + try { + if (!seam.isDestroyed) seam.triggerNotification(message, title) + } catch (cause) { + getLog().warn('chrome', 'triggerNotification failed', { cause: String(cause) }) + } } } } diff --git a/ui-opentui/src/entry/main.tsx b/ui-opentui/src/entry/main.tsx index dff357dd53d..d80860c67ce 100644 --- a/ui-opentui/src/entry/main.tsx +++ b/ui-opentui/src/entry/main.tsx @@ -30,12 +30,23 @@ import { GatewayService, type GatewayServiceShape } from '../boundary/gateway/Ga import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts' import { getLog } from '../boundary/log.ts' import { startMemlog } from '../boundary/memlog.ts' +import { startMemoryMonitor } from '../boundary/memoryMonitor.ts' import { startProactiveGc } from '../boundary/proactiveGc.ts' import { registerVendoredParsers } from '../boundary/parsers.ts' import { acquireRenderer } from '../boundary/renderer.ts' import { makeAppLayer } from '../boundary/runtime.ts' import { nthAssistantResponse } from '../logic/copy.ts' -import { envFlag, launchCwd } from '../logic/env.ts' +import { performHeapdump } from '../logic/diagnostics.ts' +import { + envFlag, + heapdumpOnStart, + launchCwd, + noConfirmDestructive, + resolveMouseEnabled, + startupImage, + startupPrompt, + STARTUP_IMAGE_DEFAULT_PROMPT +} from '../logic/env.ts' import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts' import { parseProcessList } from '../logic/backgroundActivity.ts' import { createPasteStore } from '../logic/pastes.ts' @@ -74,6 +85,8 @@ export interface TuiInput { readonly cols: number /** Optional initial prompt submitted once the session is ready — the Phase-1 stand-in for the composer. */ readonly initialPrompt?: string + /** Optional image PATH attached (image.attach) before the initial prompt — `hermes --tui --image `. */ + readonly initialImage?: string /** Resume a session instead of creating one: a session id, 'recent'/'last' * (→ session.most_recent), or 'picker' (bare `--resume` — open the resume * picker BEFORE any session.create; create stays lazy). */ @@ -156,7 +169,13 @@ const resumeInto = (gateway: GatewayServiceShape, store: SessionStore, sid: stri * forked fiber; the promise is STASHED in the slash seam so an early `/model` * awaits THIS request instead of doubling it). */ -const postSessionSetup = (gateway: GatewayServiceShape, store: SessionStore, sid: string, initialPrompt?: string) => +const postSessionSetup = ( + gateway: GatewayServiceShape, + store: SessionStore, + sid: string, + initialPrompt?: string, + initialImage?: string +) => Effect.gen(function* () { const catalog = yield* gateway .request('startup.catalog', { session_id: sid }) @@ -174,7 +193,22 @@ const postSessionSetup = (gateway: GatewayServiceShape, store: SessionStore, sid .pipe(Effect.catchCause(() => Effect.succeed(undefined))) seedLearnedNames(catalogCommandItems(cmdCatalog)) - const prompt = initialPrompt?.trim() + // Seeded image (`hermes --tui --image `): attach BEFORE submitting, so + // the next prompt.submit picks it up — exact Ink parity (createGatewayEventHandler + // scheduleStartupPrompt: image.attach then submit; default prompt when image-only). + const image = initialImage?.trim() + if (image) { + yield* gateway.request('image.attach', { path: image, session_id: sid }).pipe( + Effect.catchCause(cause => + Effect.sync(() => { + getLog().warn('bootstrap', 'startup image attach failed', { cause: String(cause) }) + store.pushSystem(`startup image attach failed: ${String(cause)}`) + }) + ) + ) + } + + const prompt = initialPrompt?.trim() || (image ? STARTUP_IMAGE_DEFAULT_PROMPT : undefined) if (prompt) { store.pushUser(prompt) yield* gateway.request('prompt.submit', { session_id: sid, text: prompt }) @@ -220,7 +254,7 @@ const createFreshSession = (gateway: GatewayServiceShape, store: SessionStore, i writeActiveSession(sid) // record the new session for the launcher's exit epilogue (#5) store.setSessionId(sid) getLog().info('bootstrap', 'session created', { sid }) - yield* postSessionSetup(gateway, store, sid, input.initialPrompt) + yield* postSessionSetup(gateway, store, sid, input.initialPrompt, input.initialImage) }) /** @@ -265,7 +299,7 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp return } yield* resumeInto(gateway, store, sid, input.cols) - yield* postSessionSetup(gateway, store, sid, input.initialPrompt) + yield* postSessionSetup(gateway, store, sid, input.initialPrompt, input.initialImage) return } @@ -426,6 +460,24 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { // collects mid-reply. Scoped release like memlog. const proactiveGc = startProactiveGc(() => store.state.info.running === true) yield* Effect.addFinalizer(() => Effect.sync(proactiveGc.stop)) + // Memory early-warning (#34095 parity) — surfaces a transcript system line + // when heap climbs abnormally fast below the OOM ceiling (the silent-death + // regime). ON by default: a KB user-facing safety heads-up, not a + // diagnostic dump. No auto heap-snapshot (memlog is the diagnosis path). + const stopMemoryMonitor = startMemoryMonitor(line => store.pushSystem(line)) + yield* Effect.addFinalizer(() => Effect.sync(stopMemoryMonitor)) + // HERMES_HEAPDUMP_ON_START (Ink parity): a deliberate baseline snapshot at + // boot. Bypasses the diagnostics master switch (you set it on purpose). + // Best-effort + synchronous (writeHeapSnapshot blocks V8) — a failure must + // never block launch. + if (heapdumpOnStart()) { + try { + const dump = performHeapdump() + store.pushSystem(`heap snapshot written: ${dump.path}`) + } catch (cause) { + getLog().warn('bootstrap', 'heapdump-on-start failed', { cause: String(cause) }) + } + } doQuit = () => { if (!renderer.isDestroyed) renderer.destroy() } @@ -542,7 +594,10 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { return undefined } }, - confirm: (message, onConfirm) => store.setConfirm(message, onConfirm), + // HERMES_TUI_NO_CONFIRM (Ink parity): skip the destructive-action confirm + // step and run the action immediately. Read per call so a wrapper that + // mutates env before launch sees the live value. + confirm: (message, onConfirm) => (noConfirmDestructive() ? onConfirm() : store.setConfirm(message, onConfirm)), copyResponse: n => { const text = nthAssistantResponse(store.state.messages, n) if (!text) return false @@ -684,14 +739,19 @@ function streamHello(controller: FakeGatewayController): void { if (import.meta.main) { const fake = envFlag(process.env.HERMES_TUI_FAKE, false) const cols = process.stdout.columns || 80 - const initialPrompt = process.env.HERMES_TUI_PROMPT?.trim() || process.argv.slice(2).join(' ').trim() + // `hermes --tui "prompt"` / `--image` seed: the launcher sets HERMES_TUI_QUERY + // (+ HERMES_TUI_IMAGE); we also honor HERMES_TUI_PROMPT (OpenTUI alias) and a + // bare argv tail (standalone dev). See logic/env.ts startupPrompt/startupImage. + const initialPrompt = startupPrompt() + const initialImage = startupImage() const resumeId = process.env.HERMES_TUI_RESUME?.trim() - // Mouse on by default (opencode parity: wheel-scroll the transcript, drag the - // scrollbar, click-to-expand tools, text-aware selection). HERMES_TUI_MOUSE=0 opts out. - const mouse = envFlag(process.env.HERMES_TUI_MOUSE, true) + // Mouse on by default. Defers to Ink's env surface (HERMES_TUI_MOUSE_TRACKING > + // HERMES_TUI_DISABLE_MOUSE > HERMES_TUI_MOUSE alias > default on). See env.ts. + const mouse = resolveMouseEnabled() const base = { mouse, fake, cols } const withPrompt = initialPrompt ? { ...base, initialPrompt } : base - const input: TuiInput = resumeId ? { ...withPrompt, resumeId } : withPrompt + const withImage = initialImage ? { ...withPrompt, initialImage } : withPrompt + const input: TuiInput = resumeId ? { ...withImage, resumeId } : withImage const onFatal = (error: unknown) => { getLog().error('entry', 'fatal', { error: String(error) }) diff --git a/ui-opentui/src/logic/env.ts b/ui-opentui/src/logic/env.ts index 274a91738bd..bd79b218f9b 100644 --- a/ui-opentui/src/logic/env.ts +++ b/ui-opentui/src/logic/env.ts @@ -15,6 +15,109 @@ export function envFlag(value: string | undefined, fallback: boolean): boolean { return fallback } +/** + * Tri-state toggle parse: `true`/`false` for a recognized value, `null` when + * unset/unrecognized (so a caller can fall through to the next precedence rung). + * Mirrors Ink's `parseToggle` (`ui-tui/src/config/env.ts`). + */ +export function envToggle(value: string | undefined): boolean | null { + const v = value?.trim() ?? '' + if (TRUE_RE.test(v)) return true + if (FALSE_RE.test(v)) return false + return null +} + +/** + * Resolve whether mouse tracking is ON at boot, deferring to Ink's env surface + * (`ui-tui/src/config/env.ts`) so muscle memory + docs + support scripts carry + * over. Precedence (highest first): + * 1. `HERMES_TUI_MOUSE_TRACKING` (toggle) — the explicit force knob; beats all. + * 2. `HERMES_TUI_DISABLE_MOUSE=1` — the legacy Ink kill switch (off). + * 3. `HERMES_TUI_MOUSE` (toggle) — the OpenTUI-native alias (kept, rule 2); + * it's also what the launcher sets, so it stays a first-class boot knob. + * 4. default ON (opencode parity: wheel-scroll, drag-scrollbar, click-to-expand, + * text-aware selection). + * OpenTUI's renderer mouse is a single boolean, so Ink's granular off|wheel| + * buttons|all collapses to on/off here (any non-off tracking mode → on). + */ +export function resolveMouseEnabled(env: { readonly [k: string]: string | undefined } = process.env): boolean { + const trackingOverride = envToggle(env.HERMES_TUI_MOUSE_TRACKING) + if (trackingOverride !== null) return trackingOverride + if (envFlag(env.HERMES_TUI_DISABLE_MOUSE, false)) return false + const mouseAlias = envToggle(env.HERMES_TUI_MOUSE) + if (mouseAlias !== null) return mouseAlias + return true +} + +/** + * The seeded initial prompt for `hermes --tui "prompt"` / `--image`. + * + * The launcher (`hermes_cli/main.py`) sets `HERMES_TUI_QUERY` (the established + * cross-engine contract Ink reads via `STARTUP_QUERY`); the OpenTUI engine also + * accepts `HERMES_TUI_PROMPT` as its own alias and a bare argv tail for + * standalone dev launches. QUERY wins (it's the launcher contract); PROMPT and + * argv are fallbacks. Empty → undefined. + */ +export function startupPrompt( + env: { readonly [k: string]: string | undefined } = process.env, + argv: readonly string[] = process.argv.slice(2) +): string | undefined { + const query = env.HERMES_TUI_QUERY?.trim() + if (query) return query + const prompt = env.HERMES_TUI_PROMPT?.trim() + if (prompt) return prompt + const tail = argv.join(' ').trim() + return tail || undefined +} + +/** + * The seeded image PATH for `hermes --tui --image `. The launcher sets + * `HERMES_TUI_IMAGE` (Ink reads it as `STARTUP_IMAGE` and `image.attach`es the + * path before submitting the query). Empty → undefined. + */ +export function startupImage(env: { readonly [k: string]: string | undefined } = process.env): string | undefined { + const image = env.HERMES_TUI_IMAGE?.trim() + return image || undefined +} + +/** Ink's default prompt when an image is seeded with no query (`STARTUP_QUERY`). */ +export const STARTUP_IMAGE_DEFAULT_PROMPT = 'What do you see in this image?' + +/** + * `HERMES_TUI_NO_CONFIRM` — skip destructive-action confirm prompts (Ink parity, + * `ui-tui/src/config/env.ts` `NO_CONFIRM_DESTRUCTIVE`). When truthy, the `/clear` + * and `/new` confirm step is bypassed and the action runs immediately. Default + * off (confirm). Same name, same truthy parsing as Ink. + */ +export function noConfirmDestructive(env: { readonly [k: string]: string | undefined } = process.env): boolean { + return envFlag(env.HERMES_TUI_NO_CONFIRM, false) +} + +/** + * `HERMES_HEAPDUMP_ON_START` — write a manual heap snapshot at boot (Ink parity). + * A diagnostic escape hatch that BYPASSES the diagnostics master switch (you set + * it deliberately to capture a baseline). Default off. + */ +export function heapdumpOnStart(env: { readonly [k: string]: string | undefined } = process.env): boolean { + return envFlag(env.HERMES_HEAPDUMP_ON_START, false) +} + +/** + * `HERMES_TUI_SCROLL_SPEED` (or `CLAUDE_CODE_SCROLL_SPEED` for portability) — + * the wheel-scroll speed multiplier (Ink parity, `lib/wheelAccel.ts` + * `readScrollSpeedBase`). Default 1 (the engine's native scroll behavior is + * untouched), clamped to (0, 20]. Returns `null` when UNSET/garbage so the + * caller leaves OpenTUI's native scroll acceleration alone — only an explicit, + * in-range value installs a constant-multiplier override. + */ +export function scrollSpeedMultiplier(env: { readonly [k: string]: string | undefined } = process.env): number | null { + const raw = (env.HERMES_TUI_SCROLL_SPEED ?? env.CLAUDE_CODE_SCROLL_SPEED ?? '').trim() + if (!raw) return null + const n = Number.parseFloat(raw) + if (!Number.isFinite(n) || n <= 0) return null + return Math.min(n, 20) +} + /** * The diagnostics master switch — `HERMES_TUI_DIAGNOSTICS` (default OFF). * diff --git a/ui-opentui/src/logic/memoryMonitor.ts b/ui-opentui/src/logic/memoryMonitor.ts new file mode 100644 index 00000000000..b7a7d97879a --- /dev/null +++ b/ui-opentui/src/logic/memoryMonitor.ts @@ -0,0 +1,88 @@ +/** + * Memory-monitor LOGIC (pure, no node:v8/process/file imports — testable). + * + * Ports the high-value SMART part of Ink's memory monitor + * (`ui-tui/src/lib/memoryMonitor.ts`): the #34095 silent-death EARLY-WARNING. + * It deliberately does NOT port Ink's auto heap-snapshot capture — the OpenTUI + * engine's always-on `memlog` NDJSON trace (boundary/memlog.ts) is the + * diagnosis path, and the rss-vs-heap divergence it records is the better + * diagnostic for the native-RSS leak class (#15141) that a V8 heap snapshot + * captures poorly anyway. So we skip the #41948 disk-fill bug class entirely. + * + * The early-warning regime is BELOW the OOM ceiling: Node can OOM from a render- + * tree / store blowup at a few hundred MB, well under any "critical" exit + * watermark, so a plain level machine never sees it and the death looks silent + * (#34095 showed up only as a bare gateway `stdin EOF`). We fire ONCE when heap + * both crosses a modest absolute floor AND is climbing steeply (≥150MB between + * ticks) — the render-tree-blowup signature — and re-arm only after heap falls + * back below the floor. The boundary turns the fire into a visible transcript + * system line so the user gets a heads-up before the process dies. + */ + +const MB = 1024 ** 2 + +/** Heap floor below which we never warn (a small heap climbing is normal). */ +export const WARN_FLOOR_BYTES = 600 * MB +/** Per-tick growth that, combined with crossing the floor, signals a blowup. */ +export const WARN_GROWTH_STEP_BYTES = 150 * MB + +/** Mutable arm/disarm state for the early-warning detector. */ +export interface WarnState { + /** Previous heapUsed sample; `-1` until the first sample is seen. */ + lastHeap: number + /** Whether we've already fired since the last re-arm (one-shot until reset). */ + warned: boolean +} + +/** A fresh, un-seeded warn state (lastHeap < 0 ⇒ first sample can't "grow"). */ +export function createWarnState(): WarnState { + return { lastHeap: -1, warned: false } +} + +export interface WarnEvaluation { + /** True exactly on the tick the warning should fire (one-shot). */ + readonly fire: boolean + /** The growth since the previous sample (bytes; 0 on the first sample). */ + readonly growthBytes: number +} + +/** + * Advance the early-warning state machine by one sample. MUTATES `state` + * (lastHeap + warned) and returns whether to fire this tick. + * + * Fires once when, while below any OOM ceiling: heap ≥ floor AND grew + * ≥ step since the previous sample AND we haven't already fired. Re-arms + * (warned ← false) once heap drops back below the floor. The first + * (un-seeded) sample only seeds lastHeap and never fires. + */ +export function evaluateWarn( + state: WarnState, + heapUsed: number, + floorBytes: number = WARN_FLOOR_BYTES, + stepBytes: number = WARN_GROWTH_STEP_BYTES +): WarnEvaluation { + const seeded = state.lastHeap >= 0 + const growthBytes = seeded ? heapUsed - state.lastHeap : 0 + let fire = false + + if (seeded) { + if (!state.warned && heapUsed >= floorBytes && growthBytes >= stepBytes) { + state.warned = true + fire = true + } else if (heapUsed < floorBytes) { + state.warned = false + } + } + + state.lastHeap = heapUsed + return { fire, growthBytes } +} + +/** Render the user-facing early-warning line (KB system line, no disk cost). */ +export function warnLine(heapUsed: number, rss: number, growthBytes: number): string { + const mb = (n: number) => Math.round(n / MB) + return ( + `⚠ memory climbing fast — heap ${mb(heapUsed)}MB (+${mb(growthBytes)}MB), rss ${mb(rss)}MB. ` + + `If the TUI dies, this is why; relaunch with HERMES_TUI_DIAGNOSTICS=1 for a trace.` + ) +} diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index 75dd17fcf58..5d86a5be7aa 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -191,6 +191,30 @@ const SUBAGENT_TRACE_LIMIT = 200 * Ctrl-C interrupt (item 11) reads; we also flip it locally on message.start/ * complete so the bar reacts instantly even if a `session.info` lags. */ +/** Todo task states (mirrors tools/todo_tool.py). */ +export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled' + +/** One todo item (content + state); list ORDER is priority — never re-sort. */ +export interface TodoItem { + content: string + status: TodoStatus +} + +/** Counts by state for the panel header + status-bar chip. */ +export interface TodoCounts { + total: number + completed: number + in_progress: number + pending: number + cancelled: number +} + +/** The pinned TodoPanel's live source: the latest todo-tool snapshot. */ +export interface TodoSnapshot { + todos: TodoItem[] + counts: TodoCounts +} + export interface SessionInfo { model?: string effort?: string @@ -243,6 +267,14 @@ export interface StoreState { theme: Theme /** The active blocking prompt (composer is hidden while set); undefined when none. */ prompt: ActivePrompt | undefined + /** The composer's in-progress draft text, persisted so it survives the + * composer unmounting when a blocking prompt (clarify/approval) replaces it + * in the . Restored on the next composer mount; cleared on submit. */ + composerDraft: string + /** The latest todo-tool snapshot, captured from every `todo` tool.complete + * REGARDLESS of HERMES_TUI_TOOL_OUTPUTS (the pinned TodoPanel is a live + * tracker, not a tool body). undefined until the agent first calls `todo`. */ + latestTodos: TodoSnapshot | undefined /** The open pager overlay (replaces the transcript while set); undefined when none. */ pager: PagerState | undefined /** The open resume picker (replaces the composer while set); undefined when none. */ @@ -387,6 +419,32 @@ function onlyStrings(items: ReadonlyArray | undefined): string[] { return (items ?? []).filter((s): s is string => typeof s === 'string') } +function normalizeTodoStatus(s: unknown): TodoStatus { + if (s === 'completed' || s === 'in_progress' || s === 'cancelled') return s + return 'pending' +} + +/** Parse a TodoSnapshot from a `todo` tool.complete payload — result.todos + * first, else args.todos. Returns undefined when there's no usable list (so a + * malformed call never clobbers a good prior snapshot). */ +export function todoSnapshotFrom(result: unknown, args: unknown): TodoSnapshot | undefined { + const fromObj = (o: unknown): unknown => + o && typeof o === 'object' && !Array.isArray(o) ? (o as Record)['todos'] : undefined + const rawList = fromObj(result) ?? fromObj(args) + if (!Array.isArray(rawList)) return undefined + const todos: TodoItem[] = [] + for (const t of rawList) { + if (!t || typeof t !== 'object') continue + const o = t as Record + const content = typeof o['content'] === 'string' ? o['content'] : '' + if (content) todos.push({ content, status: normalizeTodoStatus(o['status']) }) + } + if (todos.length === 0) return undefined + const counts: TodoCounts = { total: todos.length, completed: 0, in_progress: 0, pending: 0, cancelled: 0 } + for (const t of todos) counts[t.status]++ + return { todos, counts } +} + /** Build the typed Catalog from a decoded startup.catalog result (item 9). An * absent `enabled` flag means on; nameless toolsets/categories are dropped and * non-string tool/server names are filtered (defensive — wire arrays are loose). */ @@ -479,6 +537,8 @@ export function createSessionStore(options?: SessionStoreOptions) { dropped: 0, theme: DEFAULT_THEME, prompt: undefined, + composerDraft: '', + latestTodos: undefined, pager: undefined, sessionPicker: undefined, picker: undefined, @@ -713,10 +773,28 @@ export function createSessionStore(options?: SessionStoreOptions) { setState('messages', []) setState('subagents', []) setState('dropped', 0) + // A fresh session has no plan — drop the pinned todo panel's snapshot. + setState('latestTodos', undefined) // Drop the dedup history too — a fresh transcript should re-process any id. applied.clear() // A chrome notice must not survive a transcript reset (new session context). clearNoticeState() + // /new and /clear start a fresh context: the status-bar usage gauges + // (ctx %, tokens, cost, compressions) must zero out. They can't clear via a + // later session.info because infoPatchFrom only MERGES present fields, so a + // fresh session that omits them would leave the stale numbers. Delete them + // here via produce (setState('info', fn) MERGES a returned partial, so an + // omit-by-spread would NOT remove the keys). Keep stable session identity. + setState( + 'info', + produce(info => { + delete info.contextUsed + delete info.contextMax + delete info.contextPercent + delete info.costUsd + delete info.compressions + }) + ) } /** Open / close the agents dashboard overlay (/agents). The optional `agentId` @@ -1089,6 +1167,15 @@ export function createSessionStore(options?: SessionStoreOptions) { } }) ) + // Todo panel (live tracker): capture the latest todo snapshot from EVERY + // `todo` tool.complete, REGARDLESS of HERMES_TUI_TOOL_OUTPUTS (the pinned + // panel is not a tool body). Set OUTSIDE the produce() above — it's a + // separate top-level slice, not part of the message tree. Keeps list + // order (= priority); never re-sorts. + if (name === 'todo') { + const snap = todoSnapshotFrom(event.payload['result'], event.payload['args']) + if (snap) setState('latestTodos', snap) + } break } // ── blocking prompts (spec §8 #6 — unhandled = the agent deadlocks) ── @@ -1218,6 +1305,12 @@ export function createSessionStore(options?: SessionStoreOptions) { setState('prompt', undefined) } + /** Persist the composer's in-progress draft (survives composer unmount when a + * blocking prompt replaces it). Cleared on submit. */ + function setComposerDraft(text: string): void { + setState('composerDraft', text) + } + // ── resume hydrate (opencode sync-v2): buffer live events while the snapshot // loads, then replace history + replay the buffer in order. Split into begin/ // commit so the buffer can span an async `session.resume` RPC. @@ -1234,6 +1327,10 @@ export function createSessionStore(options?: SessionStoreOptions) { // gateway re-emits on the next header parse — acceptable vs. cross-session // bleed + a leaked timer. clearNoticeState() + // …same reasoning for the pinned todo panel: a resumed/different session must + // not inherit the prior session's plan. The resumed session re-emits its own + // `todo` snapshot if it has one (mirrors clearTranscript's reset). + setState('latestTodos', undefined) // Slice to the cap BEFORE the first setState, not after. Yoga (WASM) layout // memory is grow-only, so even a TRANSIENT mount of an over-cap resume // snapshot would permanently ratchet the high-water mark — a set-then-trim @@ -1313,7 +1410,8 @@ export function createSessionStore(options?: SessionStoreOptions) { beginBuffer, commitSnapshot, duplicate, - clearPrompt + clearPrompt, + setComposerDraft } as const } diff --git a/ui-opentui/src/logic/termChrome.ts b/ui-opentui/src/logic/termChrome.ts index 54150581ab1..cb7e97011a6 100644 --- a/ui-opentui/src/logic/termChrome.ts +++ b/ui-opentui/src/logic/termChrome.ts @@ -1,6 +1,6 @@ /** - * Terminal chrome logic — window-title text and desktop-notification OSC - * sequences. Pure string work (no OpenTUI imports); the boundary shim + * Terminal chrome logic — window-title text and notification message shaping. + * Pure string work (no OpenTUI imports); the boundary shim * (`boundary/termChrome.ts`) owns the renderer writes and focus tracking. * * Title: OSC 0/2 content is set natively via `renderer.setTerminalTitle` @@ -8,18 +8,14 @@ * `"{session title} — Hermes"` once the gateway titles the session, * `"Hermes Agent"` until then. * - * Notifications: emitted when the TUI starts waiting on the user (blocking - * prompt, turn complete). Three dialects, terminals ignore what they don't - * speak: - * OSC 9 `ESC ] 9 ; message BEL` (iTerm2 / ConEmu / wezterm) - * OSC 99 `ESC ] 99 ; i=hermes ; title ST` (kitty desktop-notification - * protocol — `p` defaults to title, `d` defaults to done) - * OSC 777 `ESC ] 777 ; notify ; title ; body BEL` (urxvt / foot) + * Notifications: the desktop ping itself is the renderer's native + * `triggerNotification(message, title)` (boundary/termChrome.ts) — protocol + * detection + tmux/Zellij wrapping live in the zig side. This module only + * supplies the message TEXT (promptNotification / TURN_COMPLETE_NOTIFICATION) + * and the sanitizer; it no longer hand-rolls OSC 9/99/777 escape strings. */ const ESC = '\u001b' -const BEL = '\u0007' -const ST = `${ESC}\\` /** Strip control chars (C0/C1, incl. ESC/BEL) so user text can never * terminate or splice an escape sequence; collapse runs of whitespace; @@ -45,22 +41,6 @@ export interface TermNotification { readonly body?: string } -/** The raw escape sequences announcing `n` to the hosting terminal. OSC 9 and - * 777 conventionally terminate with BEL; kitty's OSC 99 spec uses ST. - * Semicolons are swapped out of the OSC 777 fields (its field separator). */ -export function notifySequences(n: TermNotification): string[] { - const title = sanitizeOscText(n.title) - const body = sanitizeOscText(n.body ?? '') - if (!title) return [] - const combined = body ? `${title}: ${body}` : title - const f777 = (s: string) => s.replace(/;/g, ',') - return [ - `${ESC}]9;${combined}${BEL}`, - `${ESC}]99;i=hermes;${combined}${ST}`, - `${ESC}]777;notify;${f777(title)};${f777(body || title)}${BEL}` - ] -} - /** The XTWINOPS title-stack pushes/pops bracketing our title ownership: save * the user's title on install, restore it on teardown (terminals without the * stack ignore these — they just keep our last title, same as today). */ diff --git a/ui-opentui/src/test/env.test.ts b/ui-opentui/src/test/env.test.ts index 4a42cf2d841..6e4ddf8acd8 100644 --- a/ui-opentui/src/test/env.test.ts +++ b/ui-opentui/src/test/env.test.ts @@ -1,6 +1,18 @@ import { describe, expect, test } from 'vitest' -import { envFlag, envOutputLines, envOutputUnlimited, launchCwd } from '../logic/env.ts' +import { + envFlag, + envOutputLines, + envOutputUnlimited, + envToggle, + heapdumpOnStart, + launchCwd, + noConfirmDestructive, + resolveMouseEnabled, + scrollSpeedMultiplier, + startupImage, + startupPrompt +} from '../logic/env.ts' describe('envFlag', () => { test('recognizes truthy values regardless of case/whitespace', () => { @@ -81,3 +93,120 @@ describe('launchCwd (session.create cwd)', () => { expect(launchCwd({})).toBe(process.cwd()) }) }) + +describe('envToggle (tri-state)', () => { + test('true/false for recognized values, null otherwise', () => { + expect(envToggle('on')).toBe(true) + expect(envToggle('0')).toBe(false) + expect(envToggle(undefined)).toBe(null) + expect(envToggle('')).toBe(null) + expect(envToggle('maybe')).toBe(null) + }) +}) + +describe('resolveMouseEnabled (defers to Ink env surface)', () => { + test('default ON when nothing is set', () => { + expect(resolveMouseEnabled({})).toBe(true) + }) + + test('HERMES_TUI_MOUSE_TRACKING is the highest-precedence force knob', () => { + // beats DISABLE_MOUSE and the MOUSE alias either way (toggle values, matching + // Ink's parseToggle — the granular off|wheel|buttons|all lives in config.yaml, + // the env var is on/off only). + expect( + resolveMouseEnabled({ HERMES_TUI_MOUSE_TRACKING: 'off', HERMES_TUI_DISABLE_MOUSE: '0', HERMES_TUI_MOUSE: '1' }) + ).toBe(false) + expect( + resolveMouseEnabled({ HERMES_TUI_MOUSE_TRACKING: 'on', HERMES_TUI_DISABLE_MOUSE: '1', HERMES_TUI_MOUSE: '0' }) + ).toBe(true) + }) + + test('an UNRECOGNIZED tracking value falls through to the next rung (Ink parity)', () => { + // Ink's parseToggle returns null for non-toggle strings like "all", so the + // legacy kill switch / alias / default decide. + expect(resolveMouseEnabled({ HERMES_TUI_MOUSE_TRACKING: 'all' })).toBe(true) + expect(resolveMouseEnabled({ HERMES_TUI_MOUSE_TRACKING: 'all', HERMES_TUI_DISABLE_MOUSE: '1' })).toBe(false) + }) + + test('legacy HERMES_TUI_DISABLE_MOUSE=1 kill switch (below TRACKING)', () => { + expect(resolveMouseEnabled({ HERMES_TUI_DISABLE_MOUSE: '1' })).toBe(false) + // ...but an explicit TRACKING toggle still wins over the legacy kill switch + expect(resolveMouseEnabled({ HERMES_TUI_DISABLE_MOUSE: '1', HERMES_TUI_MOUSE_TRACKING: 'on' })).toBe(true) + }) + + test('HERMES_TUI_MOUSE alias is honored (kept — OpenTUI-native + launcher sets it)', () => { + expect(resolveMouseEnabled({ HERMES_TUI_MOUSE: '0' })).toBe(false) + expect(resolveMouseEnabled({ HERMES_TUI_MOUSE: '1' })).toBe(true) + // alias sits below DISABLE_MOUSE: kill switch wins + expect(resolveMouseEnabled({ HERMES_TUI_DISABLE_MOUSE: '1', HERMES_TUI_MOUSE: '1' })).toBe(false) + }) +}) + +describe('startupPrompt (--tui "prompt" seed)', () => { + test('HERMES_TUI_QUERY wins (the launcher contract Ink also reads)', () => { + expect(startupPrompt({ HERMES_TUI_QUERY: 'hi', HERMES_TUI_PROMPT: 'other' }, ['argv'])).toBe('hi') + }) + + test('HERMES_TUI_PROMPT is the OpenTUI alias fallback', () => { + expect(startupPrompt({ HERMES_TUI_PROMPT: 'from prompt' }, [])).toBe('from prompt') + }) + + test('bare argv tail is the last fallback (standalone dev)', () => { + expect(startupPrompt({}, ['hello', 'world'])).toBe('hello world') + }) + + test('blank/unset → undefined', () => { + expect(startupPrompt({}, [])).toBeUndefined() + expect(startupPrompt({ HERMES_TUI_QUERY: ' ' }, [])).toBeUndefined() + }) +}) + +describe('startupImage (--image seed)', () => { + test('reads HERMES_TUI_IMAGE path (the launcher sets it; was silently dropped)', () => { + expect(startupImage({ HERMES_TUI_IMAGE: '/tmp/a.png' })).toBe('/tmp/a.png') + expect(startupImage({ HERMES_TUI_IMAGE: ' /tmp/b.png ' })).toBe('/tmp/b.png') + }) + + test('blank/unset → undefined', () => { + expect(startupImage({})).toBeUndefined() + expect(startupImage({ HERMES_TUI_IMAGE: ' ' })).toBeUndefined() + }) +}) + +describe('noConfirmDestructive (HERMES_TUI_NO_CONFIRM)', () => { + test('truthy skips the confirm; default off; Ink parity', () => { + expect(noConfirmDestructive({})).toBe(false) + expect(noConfirmDestructive({ HERMES_TUI_NO_CONFIRM: '1' })).toBe(true) + expect(noConfirmDestructive({ HERMES_TUI_NO_CONFIRM: 'true' })).toBe(true) + expect(noConfirmDestructive({ HERMES_TUI_NO_CONFIRM: '0' })).toBe(false) + }) +}) + +describe('heapdumpOnStart (HERMES_HEAPDUMP_ON_START)', () => { + test('truthy enables; default off', () => { + expect(heapdumpOnStart({})).toBe(false) + expect(heapdumpOnStart({ HERMES_HEAPDUMP_ON_START: 'on' })).toBe(true) + expect(heapdumpOnStart({ HERMES_HEAPDUMP_ON_START: 'no' })).toBe(false) + }) +}) + +describe('scrollSpeedMultiplier (HERMES_TUI_SCROLL_SPEED)', () => { + test('null when unset/garbage (keep native scroll behavior)', () => { + expect(scrollSpeedMultiplier({})).toBeNull() + expect(scrollSpeedMultiplier({ HERMES_TUI_SCROLL_SPEED: '' })).toBeNull() + expect(scrollSpeedMultiplier({ HERMES_TUI_SCROLL_SPEED: 'fast' })).toBeNull() + expect(scrollSpeedMultiplier({ HERMES_TUI_SCROLL_SPEED: '0' })).toBeNull() + expect(scrollSpeedMultiplier({ HERMES_TUI_SCROLL_SPEED: '-2' })).toBeNull() + }) + + test('a positive value is honored and clamped to 20', () => { + expect(scrollSpeedMultiplier({ HERMES_TUI_SCROLL_SPEED: '3' })).toBe(3) + expect(scrollSpeedMultiplier({ HERMES_TUI_SCROLL_SPEED: '1.5' })).toBe(1.5) + expect(scrollSpeedMultiplier({ HERMES_TUI_SCROLL_SPEED: '999' })).toBe(20) + }) + + test('CLAUDE_CODE_SCROLL_SPEED is the portability fallback (HERMES wins)', () => { + expect(scrollSpeedMultiplier({ CLAUDE_CODE_SCROLL_SPEED: '4' })).toBe(4) + expect(scrollSpeedMultiplier({ HERMES_TUI_SCROLL_SPEED: '2', CLAUDE_CODE_SCROLL_SPEED: '9' })).toBe(2) + }) +}) diff --git a/ui-opentui/src/test/memoryMonitor.test.ts b/ui-opentui/src/test/memoryMonitor.test.ts new file mode 100644 index 00000000000..c4c8c78fdd9 --- /dev/null +++ b/ui-opentui/src/test/memoryMonitor.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'vitest' + +import { + createWarnState, + evaluateWarn, + warnLine, + WARN_FLOOR_BYTES, + WARN_GROWTH_STEP_BYTES +} from '../logic/memoryMonitor.ts' + +const MB = 1024 ** 2 + +describe('evaluateWarn — #34095 silent-death early-warning', () => { + test('the first (un-seeded) sample only seeds; never fires even if huge', () => { + const s = createWarnState() + const r = evaluateWarn(s, WARN_FLOOR_BYTES + 10 * WARN_GROWTH_STEP_BYTES) + expect(r.fire).toBe(false) + expect(r.growthBytes).toBe(0) + expect(s.lastHeap).toBe(WARN_FLOOR_BYTES + 10 * WARN_GROWTH_STEP_BYTES) + }) + + test('fires once when above the floor AND climbing ≥ the growth step', () => { + const s = createWarnState() + evaluateWarn(s, WARN_FLOOR_BYTES) // seed at the floor + const r = evaluateWarn(s, WARN_FLOOR_BYTES + WARN_GROWTH_STEP_BYTES) + expect(r.fire).toBe(true) + expect(r.growthBytes).toBe(WARN_GROWTH_STEP_BYTES) + }) + + test('does not re-fire while it stays high (one-shot until re-armed)', () => { + const s = createWarnState() + evaluateWarn(s, WARN_FLOOR_BYTES) + expect(evaluateWarn(s, WARN_FLOOR_BYTES + WARN_GROWTH_STEP_BYTES).fire).toBe(true) + // keep climbing — already warned, stays silent + expect(evaluateWarn(s, WARN_FLOOR_BYTES + 3 * WARN_GROWTH_STEP_BYTES).fire).toBe(false) + expect(evaluateWarn(s, WARN_FLOOR_BYTES + 6 * WARN_GROWTH_STEP_BYTES).fire).toBe(false) + }) + + test('does NOT fire on slow growth (below the step) even above the floor', () => { + const s = createWarnState() + evaluateWarn(s, WARN_FLOOR_BYTES) + const r = evaluateWarn(s, WARN_FLOOR_BYTES + 10 * MB) // +10MB << 150MB step + expect(r.fire).toBe(false) + }) + + test('does NOT fire below the floor even on a steep jump (small-heap churn is normal)', () => { + const s = createWarnState() + evaluateWarn(s, 100 * MB) + const r = evaluateWarn(s, 100 * MB + 2 * WARN_GROWTH_STEP_BYTES) // still < 600MB floor + expect(r.fire).toBe(false) + }) + + test('re-arms after heap falls back below the floor, then can fire again', () => { + const s = createWarnState() + evaluateWarn(s, WARN_FLOOR_BYTES) + expect(evaluateWarn(s, WARN_FLOOR_BYTES + WARN_GROWTH_STEP_BYTES).fire).toBe(true) + // drop back below the floor → re-arm (warned cleared) + expect(evaluateWarn(s, 200 * MB).fire).toBe(false) + expect(s.warned).toBe(false) + // climb steeply from below-floor straight past the floor → fires again + // (the jump itself is ≥ step AND lands ≥ floor — exactly the blowup signal) + expect(evaluateWarn(s, WARN_FLOOR_BYTES + 10 * MB).fire).toBe(true) + }) + + test('honors custom floor/step overrides', () => { + const s = createWarnState() + evaluateWarn(s, 50 * MB, 40 * MB, 5 * MB) // floor 40MB, step 5MB + expect(evaluateWarn(s, 60 * MB, 40 * MB, 5 * MB).fire).toBe(true) + }) +}) + +describe('warnLine', () => { + test('reports heap, growth, rss in MB and points at the diagnostics flag', () => { + const line = warnLine(700 * MB, 900 * MB, 160 * MB) + expect(line).toContain('700MB') + expect(line).toContain('+160MB') + expect(line).toContain('900MB') + expect(line).toContain('HERMES_TUI_DIAGNOSTICS=1') + }) +}) diff --git a/ui-opentui/src/test/termChrome.test.tsx b/ui-opentui/src/test/termChrome.test.tsx index 52c765d7646..024e2209c17 100644 --- a/ui-opentui/src/test/termChrome.test.tsx +++ b/ui-opentui/src/test/termChrome.test.tsx @@ -1,21 +1,21 @@ /** - * Terminal chrome: OSC 0/2 window title + OSC 9/99/777 waiting-on-you - * notifications. Layers: - * 1. pure: title shaping, OSC sanitization, the three notification - * dialect sequences, the prompt-kind copy, the env kill-switch - * (logic/termChrome.ts). - * 2. wiring: with an injected fake seam over a real + * Terminal chrome: OSC 0/2 window title + native desktop notifications + * (renderer.triggerNotification). Layers: + * 1. pure: title shaping, OSC sanitization, the prompt-kind copy, the env + * kill-switch (logic/termChrome.ts). + * 2. boundary: installTerminalChrome over a fake renderer — notify routes to + * the native triggerNotification(message, title) with focus suppression. + * 3. wiring: with an injected fake seam over a real * store — the title tracks session.info, prompts and turn-completion * edges notify exactly once, initial state stays silent. */ import { createRoot } from 'solid-js' import { describe, expect, test } from 'vitest' -import type { TerminalChromeSeam } from '../boundary/termChrome.ts' +import { installTerminalChrome, type TerminalChromeSeam } from '../boundary/termChrome.ts' import { createSessionStore } from '../logic/store.ts' import { notifyEnabled, - notifySequences, promptNotification, sanitizeOscText, TURN_COMPLETE_NOTIFICATION, @@ -54,21 +54,71 @@ describe('sanitizeOscText — escape-splice safety', () => { }) }) -describe('notifySequences — the three dialects', () => { - test('emits OSC 9, kitty OSC 99, and OSC 777', () => { - const [osc9, osc99, osc777] = notifySequences({ title: 'Hermes', body: 'finished' }) - expect(osc9).toBe(`${ESC}]9;Hermes: finished${BEL}`) - expect(osc99).toBe(`${ESC}]99;i=hermes;Hermes: finished${ESC}\\`) - expect(osc777).toBe(`${ESC}]777;notify;Hermes;finished${BEL}`) +describe('installTerminalChrome — native triggerNotification transport', () => { + /** A minimal fake renderer matching the RendererSeam the boundary casts to. + * Records triggerNotification calls and lets a test fire focus/blur. */ + function fakeRenderer() { + const calls: Array<{ message: string; title: string | undefined }> = [] + let focusCb: (() => void) | undefined + let blurCb: (() => void) | undefined + const renderer = { + isDestroyed: false, + setTerminalTitle: () => {}, + writeOut: () => {}, + triggerNotification: (message: string, title?: string) => { + calls.push({ message, title }) + return true + }, + on: (event: 'focus' | 'blur', cb: () => void) => { + if (event === 'focus') focusCb = cb + else blurCb = cb + }, + once: () => {} + } + // installTerminalChrome takes a CliRenderer; the boundary only touches the + // shape above, so the cast is safe for this seam test. + return { + calls, + fireBlur: () => blurCb?.(), + fireFocus: () => focusCb?.(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + seam: installTerminalChrome(renderer as any) + } + } + + test('notify calls native triggerNotification(message=body, title=heading)', () => { + const { calls, seam } = fakeRenderer() + seam.notify({ title: 'Hermes', body: 'finished — awaiting your input' }) + expect(calls).toEqual([{ message: 'finished — awaiting your input', title: 'Hermes' }]) }) - test('semicolons cannot splice OSC 777 fields', () => { - const [, , osc777] = notifySequences({ title: 'a;b', body: 'c;d' }) - expect(osc777).toBe(`${ESC}]777;notify;a,b;c,d${BEL}`) + test('body-less notification uses the title as the message', () => { + const { calls, seam } = fakeRenderer() + seam.notify({ title: 'Hermes' }) + expect(calls).toEqual([{ message: 'Hermes', title: 'Hermes' }]) }) - test('an empty title produces nothing', () => { - expect(notifySequences({ title: ' ' })).toEqual([]) + test('an empty title fires nothing', () => { + const { calls, seam } = fakeRenderer() + seam.notify({ title: ' ' }) + expect(calls).toEqual([]) + }) + + test('control chars in the body are sanitized before the native call', () => { + const { calls, seam } = fakeRenderer() + seam.notify({ title: 'Hermes', body: `evil${ESC}]0;pwned${BEL}done` }) + expect(calls).toHaveLength(1) + expect(calls.at(0)?.message).toBe('evil ]0;pwned done') + }) + + test('focus suppression: silent once the terminal reports focus, resumes on blur', () => { + const { calls, fireBlur, fireFocus, seam } = fakeRenderer() + fireFocus() // terminal is focused → suppress + seam.notify({ title: 'Hermes', body: 'a' }) + expect(calls).toHaveLength(0) + fireBlur() // unfocused → notify again + seam.notify({ title: 'Hermes', body: 'b' }) + expect(calls).toEqual([{ message: 'b', title: 'Hermes' }]) }) }) diff --git a/ui-opentui/src/view/App.tsx b/ui-opentui/src/view/App.tsx index 2293b7458ae..cd43788fcba 100644 --- a/ui-opentui/src/view/App.tsx +++ b/ui-opentui/src/view/App.tsx @@ -36,6 +36,7 @@ import { SessionPicker, type SessionPickerOps } from './overlays/sessionPicker.t import { PromptOverlay } from './prompts/promptOverlay.tsx' import { SessionInfoProvider } from './sessionInfo.tsx' import { StatusBar } from './statusBar.tsx' +import { TodoPanel } from './todoPanel.tsx' import { StatusLine } from './statusLine.tsx' import { useTheme } from './theme.tsx' import { Transcript } from './transcript.tsx' @@ -143,6 +144,7 @@ export function App(props: AppProps) { borderColor={theme().color.border} style={{ flexShrink: 0, flexDirection: 'column' }} > + trayApi?.focusTray() ?? false} registerFocus={fn => (focusComposer = fn)} onDoubleEsc={openPromptHistory} + initialDraft={() => props.store.state.composerDraft} + onDraftChange={text => props.store.setComposerDraft(text)} /> } > diff --git a/ui-opentui/src/view/statusBar.tsx b/ui-opentui/src/view/statusBar.tsx index ef33b559895..8912c778589 100644 --- a/ui-opentui/src/view/statusBar.tsx +++ b/ui-opentui/src/view/statusBar.tsx @@ -266,6 +266,16 @@ export function StatusBar(props: { store: SessionStore }) { const n = props.store.state.subagents.filter(isTrayAgent).length return segs().agents && n > 0 ? `⚡ ${n}` : '' }) + // `☑ done/total` — the at-a-glance todo signal. The full live list is the + // pinned TodoPanel above the composer; this chip persists even when the panel + // auto-hides (no active work), so progress is never lost from view. It's tiny + // (~6 cols) so it shows at ANY width (unlike the agents chip) — the chip is + // the narrow-terminal fallback for the panel. Cleared when no snapshot exists. + const todoText = createMemo(() => { + const snap = props.store.state.latestTodos + if (!snap || snap.counts.total === 0) return '' + return `☑ ${snap.counts.completed}/${snap.counts.total}` + }) // The cwd flows LAST on the same line (not right-pinned): its budget is the // row width minus every segment before it; it tail-truncates into that, and @@ -273,7 +283,17 @@ export function StatusBar(props: { store: SessionStore }) { const leftLen = createMemo(() => { let len = 1 // dot if (model()) len += 1 + model().length + effort().length - for (const seg of [agentsText(), ctxText(), costText(), upText(), cmpText(), profileText(), bgText(), mcpText()]) { + for (const seg of [ + agentsText(), + todoText(), + ctxText(), + costText(), + upText(), + cmpText(), + profileText(), + bgText(), + mcpText() + ]) { if (seg) len += SEP.length + seg.length } return len @@ -331,6 +351,7 @@ export function StatusBar(props: { store: SessionStore }) { {effort()} + {SEP} {'ctx: '} diff --git a/ui-opentui/src/view/todoPanel.tsx b/ui-opentui/src/view/todoPanel.tsx new file mode 100644 index 00000000000..b3ac7145d30 --- /dev/null +++ b/ui-opentui/src/view/todoPanel.tsx @@ -0,0 +1,138 @@ +/** + * TodoPanel — the pinned, live-updating task panel above the composer. + * + * Panel-primary todo UX (the in-transcript `todo` tool call collapses to a + * one-line summary; this panel is the live tracker). Pinned in the chrome box + * (App.tsx), so it sits OUTSIDE the windowed transcript scrollbox — always + * mounted, never a spacer. Reads the O(1) `state.latestTodos` snapshot. + * + * Behaviour (locked design): + * - Header: counts only — "10 tasks · 1 done, 1 in progress, 8 open". + * - Rows (A1): in_progress first, then pending, capped at MAX_ROWS; completed + * and cancelled collapse into the header count + an overflow tail. + * - Overflow: "… +4 pending, 2 completed". + * - Auto-hide when there is no ACTIVE work (no pending/in_progress) — and the + * store auto-clears the snapshot once a session resets — so a finished plan + * steals zero screen. A glance-able count lives in the status-bar chip. + * + * Precedent: NoticeBanner / AgentsTray (flexShrink:0, -gated, width-budgeted). + */ +import { createMemo, For, Show } from 'solid-js' + +import type { TodoItem, TodoSnapshot } from '../logic/store.ts' +import { truncRight } from '../logic/truncate.ts' +import { useDimensions } from './dimensions.tsx' +import { useTheme } from './theme.tsx' + +/** Max checklist rows shown before overflow collapse (Claude-Code-ish ~5). */ +const MAX_ROWS = 5 + +function glyph(status: TodoItem['status']): string { + switch (status) { + case 'completed': + return '✓' + case 'in_progress': + return '▣' + case 'cancelled': + return '✗' + default: + return '☐' + } +} + +/** Counts-only header: "N tasks · a done, b in progress, c open" (zero buckets dropped). */ +export function headerText(snap: TodoSnapshot): string { + const c = snap.counts + const bits: string[] = [] + if (c.completed) bits.push(`${c.completed} done`) + if (c.in_progress) bits.push(`${c.in_progress} in progress`) + if (c.pending) bits.push(`${c.pending} open`) + if (c.cancelled) bits.push(`${c.cancelled} cancelled`) + const tail = bits.length ? ` · ${bits.join(', ')}` : '' + return `${c.total} task${c.total === 1 ? '' : 's'}${tail}` +} + +/** Rows to show, A1 order: in_progress first, then pending; completed/cancelled + * are NOT shown as rows (they collapse to the header + overflow). */ +export function visibleRows(snap: TodoSnapshot): TodoItem[] { + const inProg = snap.todos.filter(t => t.status === 'in_progress') + const pending = snap.todos.filter(t => t.status === 'pending') + return [...inProg, ...pending].slice(0, MAX_ROWS) +} + +/** True when there is active work worth pinning (any pending/in_progress). */ +export function hasActiveWork(snap: TodoSnapshot | undefined): snap is TodoSnapshot { + return !!snap && (snap.counts.pending > 0 || snap.counts.in_progress > 0) +} + +/** Overflow tail: "… +N open, M done" for the rows/states not shown above. */ +function overflowText(snap: TodoSnapshot, shownActive: number): string { + const c = snap.counts + const activeTotal = c.pending + c.in_progress + const hiddenActive = Math.max(0, activeTotal - shownActive) + const done = c.completed + const cancelled = c.cancelled + const bits: string[] = [] + if (hiddenActive) bits.push(`${hiddenActive} more open`) + if (done) bits.push(`${done} completed`) + if (cancelled) bits.push(`${cancelled} cancelled`) + return bits.length ? `… +${bits.join(', ')}` : '' +} + +export function TodoPanel(props: { snapshot: TodoSnapshot | undefined }) { + const theme = useTheme() + const dims = useDimensions() + const active = createMemo(() => (hasActiveWork(props.snapshot) ? props.snapshot : undefined)) + const colorFor = (status: TodoItem['status']): string => { + const c = theme().color + switch (status) { + case 'completed': + return c.ok + case 'in_progress': + return c.accent + case 'cancelled': + return c.muted + default: + return c.text + } + } + const budget = () => Math.max(8, dims().width - 4) + return ( + + {snap => { + const rows = createMemo(() => visibleRows(snap())) + const overflow = createMemo(() => overflowText(snap(), rows().length)) + return ( + + {/* counts-only header (chrome, not selectable) */} + + {truncRight(headerText(snap()), budget())} + + {/* active rows: in_progress (bold/accent) first, then pending */} + + {item => ( + + {glyph(item.status)} + + {truncRight(item.content, budget() - 2)} + + + )} + + {/* overflow tail — the rows/states not shown above */} + + + {truncRight(overflow(), budget())} + + + + ) + }} + + ) +} diff --git a/ui-opentui/src/view/tools/todoTool.tsx b/ui-opentui/src/view/tools/todoTool.tsx new file mode 100644 index 00000000000..35824cf2fb4 --- /dev/null +++ b/ui-opentui/src/view/tools/todoTool.tsx @@ -0,0 +1,161 @@ +/** + * TodoTool — renderer for the `todo` task-list tool. + * + * Panel-primary / inline-minimal: the live task list is shown by the pinned + * TodoPanel above the composer (view/todoPanel.tsx), so the in-transcript tool + * call collapses to a one-line SUMMARY ("10 tasks · 1 done, 1 in progress, + * 8 open") instead of dumping the raw JSON. Expanding it shows a clean + * checklist (the historical plan at that point), NEVER the JSON blob. + * + * Wire shape (tools/todo_tool.py result.to_dict / acp_adapter/tools.py): + * { todos: [{ id, content, status }], summary: { completed, in_progress, + * pending, cancelled } } + * status ∈ pending | in_progress | completed | cancelled + * List ORDER is priority (tools/todo_tool.py) — never re-sort; the in_progress + * item is emphasized by glyph + colour, not by reordering. + */ +import { createMemo, For, Show } from 'solid-js' + +import type { ToolPartState } from '../../logic/store.ts' +import { truncate } from '../../logic/toolOutput.ts' +import { useTheme } from '../theme.tsx' +import { defaultSubtitle, structuredResult } from './defaultTool.tsx' +import type { ToolBodyProps, ToolRenderer } from './registry.tsx' + +export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled' + +export interface TodoItem { + content: string + status: TodoStatus +} + +/** Per-state glyph (single-width, in the existing geometric palette). */ +export function todoGlyph(status: TodoStatus): string { + switch (status) { + case 'completed': + return '✓' + case 'in_progress': + return '▣' + case 'cancelled': + return '✗' + default: + return '☐' + } +} + +/** Parse the todos array out of a part's structured result/args. */ +export function todosOf(part: ToolPartState): TodoItem[] { + const r = structuredResult(part) + const raw = r?.['todos'] ?? part.args?.['todos'] + if (!Array.isArray(raw)) return [] + const out: TodoItem[] = [] + for (const t of raw) { + if (!t || typeof t !== 'object') continue + const o = t as Record + const content = typeof o['content'] === 'string' ? o['content'] : '' + const status = normalizeStatus(o['status']) + if (content) out.push({ content, status }) + } + return out +} + +function normalizeStatus(s: unknown): TodoStatus { + if (s === 'completed' || s === 'in_progress' || s === 'cancelled') return s + return 'pending' +} + +export interface TodoCounts { + total: number + completed: number + in_progress: number + pending: number + cancelled: number +} + +/** Counts by state — prefers the gateway summary, else derives from the list. */ +export function todoCounts(part: ToolPartState): TodoCounts { + const todos = todosOf(part) + const s = structuredResult(part)?.['summary'] + if (s && typeof s === 'object' && !Array.isArray(s)) { + const o = s as Record + const num = (k: string): number => { + const v = o[k] + return typeof v === 'number' ? v : 0 + } + const completed = num('completed') + const in_progress = num('in_progress') + const pending = num('pending') + const cancelled = num('cancelled') + const total = completed + in_progress + pending + cancelled || todos.length + return { total, completed, in_progress, pending, cancelled } + } + const counts: TodoCounts = { total: todos.length, completed: 0, in_progress: 0, pending: 0, cancelled: 0 } + for (const t of todos) counts[t.status]++ + return counts +} + +/** One-line summary subtitle: "10 tasks · 1 done, 1 in progress, 8 open" + * (zero-count buckets dropped). */ +export function todoSummary(part: ToolPartState): string { + const c = todoCounts(part) + if (c.total === 0) return '' + const bits: string[] = [] + if (c.completed) bits.push(`${c.completed} done`) + if (c.in_progress) bits.push(`${c.in_progress} in progress`) + if (c.pending) bits.push(`${c.pending} open`) + if (c.cancelled) bits.push(`${c.cancelled} cancelled`) + const tail = bits.length ? ` · ${bits.join(', ')}` : '' + return `${c.total} task${c.total === 1 ? '' : 's'}${tail}` +} + +/** Expanded body: the full checklist (historical plan), in list order. */ +export function TodoToolBody(props: ToolBodyProps) { + const theme = useTheme() + const todos = createMemo(() => todosOf(props.part)) + const colorFor = (status: TodoStatus): string => { + const c = theme().color + switch (status) { + case 'completed': + return c.ok + case 'in_progress': + return c.accent + case 'cancelled': + return c.muted + default: + return c.text + } + } + return ( + 0} fallback={null}> + + + {item => ( + + {todoGlyph(item.status)} + + {truncate(item.content, Math.max(1, props.width - 2))} + + + )} + + + + ) +} + +export const todoRenderer: ToolRenderer = { + Body: TodoToolBody, + // Expandable when there's a real list to show. + expandable: part => todosOf(part).length > 0, + // Honest "(N lines)" = one row per todo. + lines: part => todosOf(part).map(t => `${todoGlyph(t.status)} ${t.content}`), + // Collapsed = the summary line (the live list lives in the pinned panel). + subtitle: part => todoSummary(part) || defaultSubtitle(part) +} diff --git a/ui-opentui/src/view/transcript.tsx b/ui-opentui/src/view/transcript.tsx index 75d571be45a..17e778e5260 100644 --- a/ui-opentui/src/view/transcript.tsx +++ b/ui-opentui/src/view/transcript.tsx @@ -84,11 +84,11 @@ * single user-caused frame absorbs the estimate error (the design's accepted * "remounted for view" path). */ -import type { BoxRenderable, ScrollBoxRenderable } from '@opentui/core' +import type { BoxRenderable, ScrollAcceleration, ScrollBoxRenderable } from '@opentui/core' import { useRenderer } from '@opentui/solid' import { createComputed, createMemo, createSelector, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js' -import { diagnosticsEnabled, envFlag } from '../logic/env.ts' +import { diagnosticsEnabled, envFlag, scrollSpeedMultiplier } from '../logic/env.ts' import type { Message, SessionStore } from '../logic/store.ts' import { computeWindow, @@ -108,6 +108,24 @@ import { MessageLine, turnSpacing } from './messageLine.tsx' import { ScrollAnchorProvider } from './scrollAnchor.tsx' import { useTheme } from './theme.tsx' +/** + * A constant-multiplier scroll acceleration — the OpenTUI port of Ink's + * `HERMES_TUI_SCROLL_SPEED` base. OpenTUI owns the wheel-accel CURVE natively + * (lib/scroll-acceleration.ts MacOSScrollAccel); this just lets a user dial the + * base rows/event up or down by a fixed factor. Installed ONLY when the env var + * is set (scrollSpeedMultiplier returns null otherwise → native default kept). + */ +function constantScrollAccel(multiplier: number): ScrollAcceleration { + return { + tick: () => multiplier, + reset: () => {} + } +} + +/** Module-level: read once at first mount (env is fixed for the process life). */ +const scrollSpeed = scrollSpeedMultiplier() +const scrollAccel: ScrollAcceleration | undefined = scrollSpeed !== null ? constantScrollAccel(scrollSpeed) : undefined + /** * The bottom K rows are ALWAYS mounted (the sticky-bottom region the user * lives in; also the zone where swap turbulence would be most visible). 30 is @@ -487,6 +505,7 @@ export function Transcript(props: { store: SessionStore }) { contentOptions={{ paddingRight: 1 }} stickyScroll stickyStart="bottom" + {...(scrollAccel ? { scrollAcceleration: scrollAccel } : {})} > {/* display flags (/compact, /details — Epic 3) for the rows below */}