mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
16e408f3f0
commit
bf45aa3a45
15 changed files with 1070 additions and 72 deletions
46
ui-opentui/src/boundary/memoryMonitor.ts
Normal file
46
ui-opentui/src/boundary/memoryMonitor.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*
|
||||
|
|
|
|||
88
ui-opentui/src/logic/memoryMonitor.ts
Normal file
88
ui-opentui/src/logic/memoryMonitor.ts
Normal 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.`
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
80
ui-opentui/src/test/memoryMonitor.test.ts
Normal file
80
ui-opentui/src/test/memoryMonitor.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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' }])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
138
ui-opentui/src/view/todoPanel.tsx
Normal file
138
ui-opentui/src/view/todoPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
161
ui-opentui/src/view/tools/todoTool.tsx
Normal file
161
ui-opentui/src/view/tools/todoTool.tsx
Normal 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)
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue