opentui(v6): WIP — todo panel + memoryMonitor + mouse/startup-prompt env

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.
This commit is contained in:
alt-glitch 2026-06-15 15:59:11 +05:30
parent 16e408f3f0
commit bf45aa3a45
15 changed files with 1070 additions and 72 deletions

View file

@ -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)
}

View file

@ -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) })
}
}
}
}

View file

@ -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 <path>`. */
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<unknown>('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 <path>`): 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) })

View file

@ -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 <path>`. 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).
*

View file

@ -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.`
)
}

View file

@ -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 <Switch>. 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<unknown> | 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<string, unknown>)['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<string, unknown>
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
}

View file

@ -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). */

View file

@ -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)
})
})

View file

@ -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')
})
})

View file

@ -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: <TerminalChrome> 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: <TerminalChrome> 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' }])
})
})

View file

@ -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' }}
>
<TodoPanel snapshot={props.store.state.latestTodos} />
<NoticeBanner notice={props.store.state.notice} />
<StatusBar store={props.store} />
<Switch
@ -159,6 +161,8 @@ export function App(props: AppProps) {
onFocusDown={() => trayApi?.focusTray() ?? false}
registerFocus={fn => (focusComposer = fn)}
onDoubleEsc={openPromptHistory}
initialDraft={() => props.store.state.composerDraft}
onDraftChange={text => props.store.setComposerDraft(text)}
/>
}
>

View file

@ -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 }) {
<span style={{ fg: theme().color.muted }}>{effort()}</span>
</Show>
<Seg text={agentsText()} fg={theme().color.accent} />
<Seg text={todoText()} fg={theme().color.statusGood} />
<Show when={ctxText()}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
<span style={{ fg: theme().color.muted }}>{'ctx: '}</span>

View file

@ -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, <Show>-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 (
<Show when={active()}>
{snap => {
const rows = createMemo(() => visibleRows(snap()))
const overflow = createMemo(() => overflowText(snap(), rows().length))
return (
<box style={{ flexShrink: 0, flexDirection: 'column', paddingLeft: 1 }}>
{/* counts-only header (chrome, not selectable) */}
<text selectable={false}>
<span style={{ fg: theme().color.label }}>{truncRight(headerText(snap()), budget())}</span>
</text>
{/* active rows: in_progress (bold/accent) first, then pending */}
<For each={rows()}>
{item => (
<text selectable={false}>
<span style={{ fg: colorFor(item.status) }}>{glyph(item.status)} </span>
<span
style={{
fg: item.status === 'in_progress' ? theme().color.text : theme().color.muted,
bold: item.status === 'in_progress'
}}
>
{truncRight(item.content, budget() - 2)}
</span>
</text>
)}
</For>
{/* overflow tail — the rows/states not shown above */}
<Show when={overflow()}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{truncRight(overflow(), budget())}</span>
</text>
</Show>
</box>
)
}}
</Show>
)
}

View file

@ -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<string, unknown>
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<string, unknown>
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 (
<Show when={todos().length > 0} fallback={null}>
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<For each={todos()}>
{item => (
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: colorFor(item.status) }}>{todoGlyph(item.status)} </span>
<span
style={{
fg:
item.status === 'completed' || item.status === 'cancelled'
? theme().color.muted
: theme().color.text
}}
>
{truncate(item.content, Math.max(1, props.width - 2))}
</span>
</text>
)}
</For>
</box>
</Show>
)
}
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)
}

View file

@ -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 } : {})}
>
<ScrollAnchorProvider scroll={scroll}>
{/* display flags (/compact, /details — Epic 3) for the rows below */}