Commit Graph

908 Commits

Author SHA1 Message Date
teknium1 f1d5c99fe5 feat: background-process completions paint a compact title, not the raw notification wall
Subagent completions already got this: the model receives the full
`[ASYNC DELEGATION …]` text while the CLI/TUI/Desktop paint a one-line
"Subagent Task Completed: <goal>" event. Background-process completions
(`terminal(background=True, notify=True)`) still echoed the entire
`[IMPORTANT: Background process proc_… completed normally (exit code 0).
Command: … Output: …]` block as if the user had typed it.

Generalise the delegation mechanism: `TimelineNotification` (formerly
`SubagentNotification`) carries `display_kind` + `display_text`;
`ProcessNotificationBatch` renders a `process_complete` one with a
`process_completion_display_text` title ("Background Process Finished:
<cmd>", "Background Process Failed (exit 1): <cmd>", "N Background
Processes Finished"). The TUI gateway stamps the same kind/metadata on
the synthesized turn and emits the title on `status.update`; Ink and
Desktop project `process_complete` rows as timeline events (Desktop keeps
the raw output behind the existing expandable async-result row). Model
content is byte-identical to before.
2026-09-13 14:35:32 -07:00
hermes-seaeye[bot] 61304d5923 fmt(js): npm run fix on merge (#110132)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-13 17:58:46 +00:00
teknium1 988d471479 style(ts): sort imports/exports the way perfectionist wants after the rebase 2026-09-13 06:50:57 -07:00
teknium1 c764d6d354 fix(shared): ensureContrast keeps the desktop's 0.2-step ladder; TUI chain opts into 0.05
The shared ensureContrast shipped the TUI's fine 0.05×20 ladder, which
changed --dt-primary-solid for 7 of 15 desktop presets (nous #3b6acb →
#3f70d8, cyberpunk #00661a → #008021, slate #505457 → #6f7377) while the PR
body said no preset VALUE changed. The ladder is now the desktop's original
algorithm exactly — pole by luminance < 0.5, accumulating 0.2 steps up to
1.0001, re-mixed from the source colour — with `step` as a parameter. The
only pre-refactor TUI caller (ColorChain.ensureContrast) passes 0.05, so
the terminal palette is byte-identical too.

Test: apps/desktop context.test.tsx iterates every builtin preset × mode,
paints it through ThemeProvider and asserts --dt-primary-solid equals the
value a reference copy of the old desktop algorithm computes. Sabotage
(default step 0.05): 11/30 rows fail. Docs: the SDK table now lists
contrastRatio as `number | null` under sRGB measures, not OKLCH.
2026-09-13 06:50:57 -07:00
teknium1 057c2c85fc refactor(slash): delete the dead web slash re-implementation; one slash parser + command.dispatch narrowing in @hermes/shared
web/src/lib/slashExec.ts and web/src/components/SlashPopover.tsx had zero
importers since the React composer was replaced by the PTY-embedded TUI
(f49afd3122) — exactly what web/AGENTS.md forbids, now orphaned. Their
parseSlash still carried the `(.*)` newline bug and lacked the `prefill`
variant. Desktop and the TUI each hand-rolled the same slash split and the
same command.dispatch narrowing; the multi-line fix (#41323, #55510) had to
be applied to each copy separately.

Sites:
  web/src/lib/slashExec.ts::executeSlash/parseSlash/parseCommandDispatch  -> deleted
  web/src/components/SlashPopover.tsx::SlashPopover                        -> deleted
  apps/desktop/src/lib/chat-runtime.ts::parseSlashCommand                  -> apps/shared/src/slash.ts::parseSlashCommand
  apps/desktop/src/lib/chat-runtime.ts::parseCommandDispatch               -> apps/shared/src/slash.ts::parseCommandDispatch
  apps/desktop/src/lib/chat-runtime.ts::SLASH_COMMAND_RE                   -> apps/shared/src/slash.ts::SLASH_COMMAND_RE
  apps/desktop/src/app/types.ts::*CommandDispatchResponse (5 interfaces)   -> apps/shared/src/slash.ts
  ui-tui/src/domain/slash.ts::parseSlashCommand/looksLikeSlashCommand      -> apps/shared/src/slash.ts
  ui-tui/src/lib/rpc.ts::asCommandDispatch                                 -> apps/shared/src/slash.ts::parseCommandDispatch
  ui-tui/src/gatewayTypes.ts::CommandDispatchResponse                      -> apps/shared/src/slash.ts
  9 desktop importers + 3 TUI importers repointed.

Behavior change: desktop `parseSlashCommand` now lower-cases the command
name like the TUI, backend `resolve_command` and `slash.exec` already do
(`/Help` resolved before via the case-insensitive backend; local desktop
action lookups were case-sensitive). TUI's parsed result no longer carries
the redundant `cmd` echo (no consumer read it).

Tests: apps/shared/src/slash.test.ts (parseSlashCommand multi-line /
newline-boundary / degenerate cases; parseCommandDispatch every variant +
malformed rejection). Sabotage: restoring `(.*)` in SLASH_PARTS_RE fails
2 tests; restored -> 7 pass. Desktop chat-runtime.test.ts and TUI
asCommandDispatch.test.ts cases moved here; slashParity.test.ts repointed.
2026-09-13 06:50:57 -07:00
teknium1 35022e02ed refactor(themes): one sRGB color-math module in @hermes/shared; measured readableOn + fine ensureContrast ladder on both surfaces
ui-tui/src/lib/color.ts called itself "the twin of the desktop app's
src/themes/color.ts" and the two had already drifted: the desktop measured
readableOn but used a coarse 0.2x5 ensureContrast ladder and returned 0 for
unparseable luminance; the TUI had the fine 0.05x20 ladder and null-for-garbage
but a luminance>0.5 threshold readableOn. Both now import the primitives from
apps/shared/src/color.ts (`@hermes/shared/color`, also exported from the root
index); each surface keeps only what is specific to it. No palette / preset /
skin VALUE changes anywhere — only math.

Sites (path::symbol → canonical):
  apps/desktop/src/themes/color.ts::hexToRgb           → @hermes/shared/color::parseColor (deleted)
  apps/desktop/src/themes/color.ts::rgbToHex           → @hermes/shared/color::toHex (deleted)
  apps/desktop/src/themes/color.ts::mix                → @hermes/shared/color::mix
  apps/desktop/src/themes/color.ts::relativeLuminance  → @hermes/shared/color::relativeLuminance
  apps/desktop/src/themes/color.ts::contrastRatio      → @hermes/shared/color::contrastRatio
  apps/desktop/src/themes/color.ts::readableOn         → @hermes/shared/color::readableOn (desktop wrapper readableInk pins ['#161616','#ffffff'])
  apps/desktop/src/themes/color.ts::ensureContrast     → @hermes/shared/color::ensureContrast
  ui-tui/src/lib/color.ts::{Rgb,parseColor,toHex,mix,relativeLuminance,contrastRatio,readableOn,ensureContrast,lighten,darken}
                                                       → @hermes/shared/color (same names)
  Stays desktop-only (apps/desktop/src/themes/color.ts): luminance, normalizeHex, readableInk, OKLCH set
    (hexToOklch, oklchToHex, oklchToSrgb255, maxChroma, hueDelta, harmonize, mixOklab, withHue, ensureContrastOklch).
  Stays TUI-only (ui-tui/src/lib/color.ts): liftForContrast, grayOf, desaturate, toHsl, fromHsl, retone,
    boostSaturation, color()/ColorChain.
  Importers repointed (17): apps/desktop/src/{sdk/index.ts, themes/context.tsx, themes/retint.ts,
    themes/retint.test.ts, themes/skin.ts, themes/vscode.ts, themes/vscode.test.ts};
    ui-tui/src/{theme.ts, sdk/index.ts, sdk/apps/weather.tsx, app/createGatewayEventHandler.ts,
    components/agentsPanel.tsx, components/branding.tsx, components/loaders.tsx,
    components/overlayPrimitives.tsx, lib/color.ts, lib/color.test.ts}.
  Wiring: apps/shared/package.json exports './color'; apps/shared/src/index.ts re-exports;
    apps/desktop/tsconfig.json paths + vite.config.ts alias for '@hermes/shared/color'
    (ui-tui resolves the subpath via the workspace package exports, like './billing').

Behavior change (1): relativeLuminance / contrastRatio return null for unparseable
  input on the desktop too (previously 0, which made garbage measure like pure
  black). Desktop SDK export `contrastRatio` therefore widens to `number | null`.
  Only ensureContrastOklch relied on the number: it now treats null as "already
  passing / can't measure" and returns the input unchanged. Every other desktop
  caller passes 6-digit hex.

Behavior change (2): readableOn MEASURES both candidate inks and returns the one
  with the higher contrast ratio (desktop semantics; the threshold version got
  mid-lightness accents wrong: white on #4f9e5e is 3.29:1 vs near-black 5.50:1).
  Signature is readableOn(bg, inks = ['#000000', '#ffffff']); the desktop passes
  its own pair via `readableInk` so desktop output is byte-identical. The TUI
  switches from the luminance>0.5 threshold to measurement: over the 185 distinct
  hexes in ui-tui/src/theme.ts (DARK/LIGHT seeds + built palettes) and
  hermes_cli/skin_engine.py, 56 flip from '#ffffff' to '#000000' — all
  mid-lightness accents (L 0.18–0.49, e.g. #cd7f32, #4caf50, #ef5350, #ffa726,
  #4dabf7) where black measures 4.6–10.8:1 against white's 1.9–4.5:1. Note the
  TUI never called readableOn directly; it only reaches ensureContrast's pole
  choice (below), and ensureContrast is only reachable via the color() chain and
  the theme.ts re-export (no production caller today).

Behavior change (3): ensureContrast steps 0.05 x 20 from the ORIGINAL color toward
  the measured readableOn pole (TUI semantics). The desktop previously stepped
  0.2 x 5 toward a threshold-chosen pole, so desktop-derived accents that needed a
  lift (skin/VS Code imports whose accent fails 4.5:1 on the sidebar, and
  --dt-primary-solid) may now land up to 0.15 closer to their original hue —
  they stop at the first passing rung. Palette VALUES are unchanged; only
  synthesized colors move.

Also: parseColor accepts #rgb shorthand where desktop hexToRgb rejected it —
  strictly more permissive; the only desktop path fed raw user hex is
  normalizeHex, which already expands shorthand itself.

Tests: apps/shared/src/color.test.ts (moved TUI parse/mix/contrast cases +
  two invariants):
  - "readableOn(%s) returns the ink with the higher measured contrast" — computes
    contrastRatio for each candidate in the test and asserts the returned ink is
    the max (a contract, not a hardcoded hex) over #4f9e5e (both ink pairs),
    #cba6f7, #ffffff, #101014.
    Sabotage: reverted readableOn to the luminance threshold → 3 red
    (#4f9e5e x2, #cba6f7); restored → green.
  - "ensureContrast(%s on %s) clears %s" — 5 failing pairs end ≥ min; plus
    "leaves passing and unparseable colors byte-identical".
    Sabotage: truncated the ladder to 3 rungs → 5 red; restored → green.
  ui-tui/src/lib/color.test.ts keeps only the color() chain case.

Validation:
  apps/shared: npx tsc -p . --noEmit (0) && npx vitest run → 3 files, 30 tests passed; npm run lint clean
  apps/desktop: npx tsc -p . --noEmit (0); npx vitest run --project ui → 798/800 files, 7563/7572 tests;
    the 9 failures (src/app/messaging/index.test.tsx x8 12s-timeouts, src/lib/markdown-blocks.test.ts
    property fuzz 36s) are load-induced flakes under the full parallel run: both files pass in
    isolation on this branch (16/16) and on origin/main; neither imports color math. npm run lint 0 errors
  ui-tui: npm run build:ink; npx tsc -p . --noEmit (0) && npx vitest run → 168 files, 1764 tests passed; npm run lint 0 errors
  git diff --check clean; no new gitignored .d.ts.

Handoff: desktop vs web preset palettes diverge for the four shared ids
  (web presets carry a 3-slot palette {background, midground, foreground(alpha 0)}
  + warmGlow, not the desktop's 24-slot set, so only the comparable slots are
  listed; web `foreground` is #ffffff alpha 0 on all four — a glow/overlay
  slot, not text ink). Design call for Teknium; nothing changed here.

    preset     slot        desktop                     web
    cyberpunk  background  #000a00                     #040608
    cyberpunk  accent      #00ff41 (primary/ring/mid)  #9bffcf (midground)
    cyberpunk  foreground  #00ff41                     #ffffff (alpha 0)
    ember      background  #160800                     #1a0a06
    ember      accent      #d97316 (ring/midground)    #ffd8b0 (midground = desktop fg/primary)
    ember      foreground  #ffd8b0                     #ffffff (alpha 0)
    midnight   background  #08081c                     #0a0a1f
    midnight   accent      #8b80e8 (ring/midground)    #d4c8ff (midground)
    midnight   foreground  #ddd6ff                     #ffffff (alpha 0)
    mono       background  #0e0e0e                     #0e0e0e  (match)
    mono       accent      #9a9a9a (ring/midground)    #eaeaea (midground = desktop fg/primary)
    mono       foreground  #eaeaea                     #ffffff (alpha 0)
2026-09-13 06:50:57 -07:00
teknium1 a3d259019b refactor(ts): one stripAnsi in @hermes/shared (TUI's OSC/DCS/partial-CSI coverage); desktop adopts it
Three TS surfaces each carried their own ANSI stripper with different
coverage. The TUI's (OSC, DCS/SOS/PM/APC strings, complete and truncated
CSI, multi-byte non-CSI ESC sequences, stray ESC, C0 controls) is now the
single implementation at apps/shared/src/ansi.ts, exported from the root
index and the new `@hermes/shared/ansi` subpath (ui-tui has no DOM lib, so
it imports the subpath like it does for billing/skin).

Sites (path::symbol → canonical):
  ui-tui/src/lib/text.ts::stripAnsi, sanitizeAnsiForRender, hasAnsi
      → moved to apps/shared/src/ansi.ts (text.ts now imports stripAnsi
        from '@hermes/shared/ansi' for its own trail helpers)
  ui-tui: 13 importers repointed from '../lib/text.js' to
      '@hermes/shared/ansi' (createGatewayEventHandler.ts,
      components/messageLine.tsx, 11 __tests__ files)
  apps/desktop/src/lib/ansi.ts::stripAnsi (2 regexes) → deleted;
      parseAnsi/ansiColorClass/hasAnsiCodes stay (styled-segment parser)
  apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
      → imports stripAnsi from '@hermes/shared/ansi'
  apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts
      private SGR-only stripAnsi → deleted; imports the shared one

Tests: the TUI 'ANSI sanitizers' cases move from
ui-tui/src/__tests__/text.test.ts to apps/shared/src/ansi.test.ts, plus
one invariant: an OSC-8 hyperlink + DCS string + SGR + partial CSI tail
strips to exactly the visible text with no ESC/BEL left.

Behavior change: desktop chat system messages (use-prompt-actions) and
inline-diff chrome (stripInlineDiffChrome) now also lose OSC hyperlink
payloads, DCS strings, truncated CSI tails and C0 control bytes that the
weaker regexes let through. TUI behavior is unchanged.
2026-09-13 06:50:57 -07:00
teknium1 172b2a722b refactor(ts): one compactNumber and one reasoning-effort value set in @hermes/shared
Three hand-rolled compact-number formatters and two mirrored copies of the
reasoning-effort value set collapse into apps/shared/src/format.ts and
apps/shared/src/reasoning-effort.ts, exported from the package root and as
the subpaths `@hermes/shared/format` / `@hermes/shared/reasoning-effort`
(the TUI compiles with lib ES2023 and imports subpaths only). Surfaces keep
their own label maps and UI helpers. No re-export shims remain.

Convention for compactNumber (desktop's implementation, moved verbatim):
lowercase 'k', uppercase 'M', promotion-guarded thresholds (>= 999.5 -> k,
>= 999_950 -> M) so rounding can never print "1000k", trailing ".0"
stripped, non-finite / <= 0 -> "0".

Sites (path::symbol -> canonical):

  apps/desktop/src/lib/format.ts::compactNumber          -> apps/shared/src/format.ts::compactNumber (moved; file deleted)
  web/src/lib/format.ts::formatTokenCount                -> deleted
  ui-tui/src/lib/text.ts::fmtK                           -> deleted (text.ts's own callers use compactNumber)
  apps/desktop/src/app/agents/index.tsx                  -> @hermes/shared
  apps/desktop/src/app/chat/sidebar/chrome.tsx           -> @hermes/shared
  apps/desktop/src/app/chat/sidebar/session-row.tsx      -> @hermes/shared
  apps/desktop/src/app/command-center/index.tsx          -> @hermes/shared
  apps/desktop/src/app/shell/context-usage-panel.tsx     -> @hermes/shared
  apps/desktop/src/app/shell/titlebar-controls.tsx       -> @hermes/shared
  apps/desktop/src/app/skills/index.tsx                  -> @hermes/shared
  apps/desktop/src/app/skills/mcp-tab.tsx                -> @hermes/shared
  apps/desktop/src/components/ui/tab-dropdown.tsx        -> @hermes/shared
  apps/desktop/src/lib/statusbar.tsx                     -> @hermes/shared
  apps/desktop/src/sdk/index.ts::compactNumber           -> re-exported from @hermes/shared (plugin SDK surface unchanged)
  apps/desktop/src/plugins/kanban/{board,drawer}.tsx     -> unchanged (import via @hermes/plugin-sdk)
  web/src/components/ModelInfoCard.tsx::formatTokenCount -> @hermes/shared::compactNumber
  web/src/pages/ModelsPage.tsx::formatTokenCount         -> @hermes/shared::compactNumber
  ui-tui/src/components/appChrome.tsx::fmtK              -> @hermes/shared/format::compactNumber
  ui-tui/src/components/thinking.tsx::fmtK               -> @hermes/shared/format::compactNumber
  ui-tui/src/app/slash/commands/session.ts::fmtK         -> @hermes/shared/format::compactNumber
  ui-tui/src/__tests__/text.test.ts::fmtK suite          -> apps/shared/src/format.test.ts (table incl. promotion guard)

  apps/desktop/src/lib/reasoning-effort.ts::REASONING_EFFORTS/REASONING_EFFORT_VALUES/
      DEFAULT_REASONING_EFFORT/ReasoningEffort/isReasoningEffort  -> apps/shared/src/reasoning-effort.ts
      (SHORT_LABELS, reasoningEffortLabel, isThinkingEnabled, resolveReasoningEffort stay local)
  apps/desktop/src/app/settings/constants.ts             -> @hermes/shared
  apps/desktop/src/app/settings/model-settings.tsx       -> @hermes/shared
  apps/desktop/src/app/shell/model-catalog-menu.tsx      -> @hermes/shared (+ local reasoningEffortLabel)
  apps/desktop/src/app/shell/model-edit-submenu.tsx      -> @hermes/shared (+ local UI helpers)
  apps/desktop/src/app/shell/model-menu-panel.tsx        -> @hermes/shared
  apps/desktop/src/lib/model-status-label.ts             -> @hermes/shared (+ local reasoningEffortLabel)
  apps/desktop/src/sdk/index.ts                          -> value set re-exported from @hermes/shared; label helper stays from '@/lib/reasoning-effort'
  apps/desktop/src/lib/reasoning-effort.test.ts          -> value-set + isReasoningEffort cases moved to apps/shared/src/reasoning-effort.test.ts
  web/src/lib/reasoning-effort.ts::EFFORT_OPTIONS        -> labels mapped over shared REASONING_EFFORT_VALUES (same order: none, then 7 levels)
  web/src/lib/reasoning-effort.ts::VALID_EFFORTS         -> Set(REASONING_EFFORT_VALUES); normalizeEffort falls back to DEFAULT_REASONING_EFFORT

Semantics kept: web `none` is selectable; desktop `none` resolves to ''
(thinking off); desktop isReasoningEffort still trims + lowercases.

Behavior change:
  - web: token counts on the Models page and ModelInfoCard now print a
    lowercase 'k' and are promotion-guarded: 128_000 "128K" -> "128k",
    999_999 "1000.0K" -> "1M", 1_500 "1.5K" -> "1.5k". 'M' is unchanged.
  - TUI: fmtK used Intl compact notation; compactNumber differs only in
    suffix case and the guard: 1_000_000 "1m" -> "1M", and billions no
    longer get a 'b' suffix (1_000_000_000 "1b" -> "1000M"). Sub-million
    values are identical ("999", "1k", "1.5k"). Non-positive values now
    print "0" instead of "-1k".
  - desktop: none (its formatter moved verbatim).

Tests: apps/shared/src/format.test.ts::"compactNumber" (table incl.
999_999 -> "1M", 999_949 -> "999.9k"; fails when the promotion guard is
removed) and apps/shared/src/reasoning-effort.test.ts::"reasoning-effort"
(no duplicate values, `none` is the only non-level, default is a member;
fails on a duplicated level or a `none`-accepting isReasoningEffort).
2026-09-13 06:50:57 -07:00
teknium1 a2ae8f229d refactor(ts): one fuzzy + model-search-text helper in @hermes/shared; desktop picker ranks with fuzzyRank
Three byte-identical (modulo prettier and a "keep in sync" header comment)
copies of model-search-text.ts and two of fuzzy.ts collapse into one copy
each under apps/shared/src, exported from the package root and as the
subpaths `@hermes/shared/fuzzy` / `@hermes/shared/model-search-text` (the
TUI compiles with lib ES2023 and imports subpaths, never the DOM-typed
root). The vitest suites move with the code; no re-export shims remain.

Sites (path::symbol -> canonical):

  ui-tui/src/lib/fuzzy.ts::fuzzyScore/fuzzyScoreMulti/fuzzyRank   -> apps/shared/src/fuzzy.ts (moved)
  web/src/lib/fuzzy.ts::fuzzyScore/fuzzyScoreMulti/fuzzyRank      -> deleted
  ui-tui/src/lib/model-search-text.ts::modelSearchText            -> apps/shared/src/model-search-text.ts (moved)
  web/src/lib/model-search-text.ts::modelSearchText               -> deleted
  apps/desktop/src/lib/model-search-text.ts::modelSearchText      -> deleted
  ui-tui/src/lib/fuzzy.test.ts                                    -> apps/shared/src/fuzzy.test.ts (moved)
  ui-tui/src/lib/model-search-text.test.ts                        -> apps/shared/src/model-search-text.test.ts (moved)
  ui-tui/src/components/modelPicker.tsx::fuzzyRank, modelSearchText     -> @hermes/shared/fuzzy, @hermes/shared/model-search-text
  web/src/components/ModelPickerDialog.tsx::fuzzyRank, modelSearchText  -> @hermes/shared
  web/src/lib/model-picker-filter.ts::fuzzyScoreMulti                   -> @hermes/shared
  apps/desktop/src/components/model-picker.tsx::modelSearchText         -> @hermes/shared (+ fuzzyRank, see below)

The header comment now names only the cross-language twin
(hermes_cli/model_search.py) as the thing to keep in sync.

Behavior change (desktop only): the desktop model picker used to filter
model rows with `foldIncludes` substring matching and keep the curated
order; it now ranks them with the same `fuzzyRank(models, query,
modelSearchText)` the web and TUI pickers use. What a user sees
differently while typing a query:

  - subsequence queries match: "g4o" now finds "gpt-4o" (previously only
    a literal substring such as "gpt-4" or "4o" matched);
  - the best match floats to the top instead of rows staying in curated
    order (exact > prefix > word-boundary > contiguous > scattered);
  - a query that matches the provider name/slug still shows that
    provider's full curated list in order, exactly as before;
  - an empty query still shows the curated list verbatim.

The in-row highlight is unchanged (substring emphasis via HighlightMatches),
so a fuzzy-only hit renders without emphasis rather than mis-highlighting.

Tests: apps/desktop/src/components/model-picker.test.tsx::"orders model
rows exactly as the shared fuzzyRank does" asserts the rendered row order
equals the shared fuzzyRank order for the same inputs (fails on both the
old substring filter and a reversed ranking).
2026-09-13 06:50:57 -07:00
teknium1 c1e0fd83f9 fix(shared): GatewayEventMap drops phantom keys and types child_session_id
Re-verified against the tui_gateway emitters:
- SubagentEventPayload.cost_usd / .iteration: not in
  tool_progress.py::_SUBAGENT_FIELDS, never emitted → removed; the TUI's
  turnController no longer copies them (its SubagentProgress keeps the
  fields for spawn-history persistence).
- SubagentEventPayload.child_session_id: emitted (in _SUBAGENT_FIELDS, read
  by agent_callbacks.py::_mirror_subagent_to_child) but untyped → added.
- ToolCompletePayload.error: _on_tool_complete never sets it → removed;
  the TUI's completeTool drops its dead `error` parameter and renders the
  trail line as non-error (which is what it always did on the wire).
- ToolStartPayload.todos: not on the wire either, but the TUI handler and
  its fixtures exercise recordTodos from tool.start; kept with a comment
  saying so rather than churning the handler.
- MessageCompletePayload.failure_reason: prompt_turn.py passes
  result.get("failure_reason") through → `string | null`.
2026-09-13 05:42:31 -07:00
teknium1 6b406f1c89 refactor(ts): ui-tui rides apps/shared's JSON-RPC request channel; one pending map, one heartbeat, typed RPC errors
Two independent JSON-RPC client cores existed for one backend: apps/shared's
JsonRpcGatewayClient (desktop, web) and ui-tui/src/gatewayClient.ts, which
re-implemented request ids, the pending map with timeouts, response->error
mapping, event decoding and the gateway.ping heartbeat (~200 LOC, drifted).

Split the transport-agnostic half out of the shared client into
JsonRpcRequestChannel (apps/shared/src/json-rpc-channel.ts): the owner binds a
JsonRpcTransport { send(text) } per connection generation and feeds inbound
text through handleFrame(). JsonRpcGatewayClient keeps only the WebSocket
lifecycle, seq replay and the typed event hub on top of it; the Ink TUI keeps
only its two transports (spawned child stdio, attached socket) and its
mount-order event buffering, and delegates everything else.

Behavior change:
- TUI RPC errors now carry the JSON-RPC `code` / `data` (JsonRpcGatewayError)
  instead of a bare Error(message); the TUI's timeout text is now the shared
  "request timed out after Ns: <method>" (was "timeout: <method>", matched by
  no caller) and callers may pass a per-call timeout.
- TUI heartbeat liveness counts any inbound frame (shared semantics) rather
  than tracking one in-flight ping id; the interval/deadline are unchanged
  and pings no longer carry the unread `last_activity_ms` param.
- Desktop isMissingRpcMethod reads the -32601 code first and only regexes the
  message for code-less (IPC-flattened) errors, so a tool result that merely
  mentions "unknown method" no longer reads as a capability verdict.
- Shared connect() now settles on a `close` during the handshake (auth-gate
  4401/4403) instead of waiting out the 15s connect timeout, and
  invalidate()/close() drop the socket generation before calling close() so a
  synchronous close event cannot run the closed-path twice.
2026-09-13 05:42:31 -07:00
teknium1 36773e0d78 refactor(ts): one GatewayEventMap in apps/shared typed from tui_gateway emitters; drop never-emitted tool.progress
Three TypeScript clients each declared their own copy of the tui_gateway wire
types and had drifted apart: apps/shared had a partial GatewayEventName union
with a `(string & {})` escape hatch, ui-tui/gatewayTypes.ts a 150-line
discriminated union, and apps/desktop an `RpcEvent<T>` that was field-for-field
the shared GatewayEvent with `type: string`. None matched the emitter:
message.complete lacked warning/status/error/recoverable/error_surface,
tool.start/tool.complete lacked args/result, SessionResumeResponse lacked
session_key/messages_omitted/hydrating/auto_continue/todo_state, three
different ModelOptionProvider shapes disagreed on fields, and all three unions
handled a `tool.progress` event that no Python emitter has ever produced.

Now:

* `apps/shared/src/gateway-events.ts` is the single home: payload interfaces
  typed from the Python emitters (file::symbol cited per interface),
  `BackendGatewayEventMap` (89 backend names) + `ClientLocalGatewayEventMap`
  (5 TUI-synthetic transport events, clearly marked, excluded from the
  contract) merged into `GatewayEventMap`; `GatewayEvent<K>` is discriminated
  on `type` with `seq` typed. RPC shapes shared by 2+ surfaces live beside it
  (ModelOptionProvider = union of every field hermes_cli/inventory.py sets,
  incl. pricing_pending/free_tier_pending; SessionResumeResponse<Info>;
  SessionListItem with resolved_id; Usage).
* `JsonRpcGatewayClient.on<K>` is keyed by event name; the gateway.ready
  heartbeat/replay_epoch and per-frame `seq` reads are typed instead of cast.
* ui-tui and apps/desktop import the shared names; their local duplicates are
  deleted (no re-export shims — importers are repointed; the desktop plugin
  SDK barrel keeps its public `RpcEvent` name as an alias of GatewayEvent).
  web/src repoints ModelOptionProvider/ModelOptionsResponse.
* `tool.progress` handling is removed from the TUI handler/turnController,
  desktop event sets/tools handler, shared union, tests, and two docs
  (`grep '"tool.progress"' tui_gateway/` = 0 hits; the `display.tool_progress`
  config mode is unrelated and untouched).
* `message.complete.warning` (history-commit note from
  prompt_turn.py::_complete_turn_payload) is typed and surfaced on both
  surfaces through their existing notice paths (TUI pushActivity 'warn',
  desktop notify kind 'warning').

Contract: `apps/shared/src/gateway-events.json` is the sorted list of
backend-emitted names. `tests/tui_gateway/test_gateway_event_contract.py`
collects names from the Python emitter side (emit-helper literals, the
`.request → .expire` table, change-watcher table, child delta mirror,
subagent relay, desktop_ui tool emitters, gateway.ready/setup.ready/
browser-controller frames) and asserts emitted == JSON in both directions.
`apps/shared/src/gateway-events.test.ts` asserts BACKEND_EVENT_NAMES (which
the map type is `satisfies`-checked against) == JSON. Sabotage-verified: a
fake JSON name fails both tests; a fake TS name fails tsc + vitest; a fake
Python `_emit("...")` fails pytest.
2026-09-13 05:42:31 -07:00
bixycler 70d0f556d7 fix(branding): use the Caduceus ☤ (U+2624), not the Rod of Asclepius ⚕ (U+2625)
Every inline glyph — CLI banner/status bar/response labels/goodbye, setup
and doctor boxes, gateway update prompts, WhatsApp reply prefix, TUI theme,
locale strings and the docs — used ⚕, the staff of Asclepius (medicine).
Hermes carries the Caduceus ☤. The ASCII-art logo was already correct.

Mechanical swap across 60 files (no logic change); both glyphs are
East-Asian-width Neutral so no layout shifts. Skins that set their own
`response_label` / `goodbye` are unaffected.

Direction from PR #7064 (@bixycler), the earliest of #7064 / #9611 / #15574,
redone against current main.

Fixes #9565
2026-09-12 08:25:54 -07:00
hermes-seaeye[bot] 6f8b8e77dd fmt(js): npm run fix on merge (#107545)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-10 17:41:19 +00:00
Teknium b51da65258 fix: adapt execute_code cell authority to the widened prompt-callback table
_callback_api() now yields (getter, setter) pairs for every per-thread prompt
(approval, sudo, vault unlock); the kernel cell captured and restored the old
fixed 4-tuple. Iterate the table so a cell carries every callback and a future
addition needs no change here. Test recorder unpacks the new shape.

Also: perfectionist import order in ui-tui interfaces.ts (CI lint).
2026-09-10 10:35:07 -07:00
Teknium ac33da3c73 fix(tui): hide the composer while the password-manager unlock card is open
Live Ink TUI repro: with the unlock card mounted, keystrokes reached BOTH the
masked prompt and the still-focused composer, so the master password echoed
in clear text in the composer row and was queued as a message. `$isBlocked`
(which unmounts the composer for approval/sudo/secret cards) did not list the
new overlay; the pet's awaiting-input predicate had the same gap. After the
fix the raw PTY stream no longer contains the typed password.

Also tightens the classic-CLI panel copy to fit an 80-column box.
2026-09-10 10:35:07 -07:00
Teknium 92e0de0ac4 feat(vault): Desktop, TUI and CLI surfaces for password-manager unlock
Desktop
- Settings → Credential Vault gains a "Password managers" section: per-manager
  toggle (disabled with a hint when the CLI isn't installed), Locked/Unlocked
  pill, Unlock (masked master-password dialog → vault.unlock) and Lock.
  Items from a manager show a source badge instead of a delete button.
- Mid-turn vault.unlock.request renders a masked card in the chat (same
  contract as the secret/sudo cards: dismiss = keep locked, late answers
  tolerated, blocks the composer, badges background sessions).
- i18n parity en/ar/ja/zh/zh-hant.

Ink TUI (hermes --tui): vault.unlock.request/expire overlay via MaskedPrompt;
Esc keeps the manager locked.

CLI: `hermes vault sources [--enable|--disable NAME]`; `hermes vault list`
shows the source column and names enabled-but-locked managers.

Docs: credential-vault.md covers managers, per-session unlock, and the
headless (cron/webhook/API/-q) no-prompt posture.
2026-09-10 10:35:07 -07:00
Siddharth Balyan ae43fd6df6 fix(tui): bare URLs render verbatim instead of a derived site label (#106843)
The markdown renderer resolved every link to a label — an authored one when
present, otherwise the fetched HTML <title>, otherwise a slug derived from the
last path segment. For a bare URL that meant the target never reached the
screen: `Connect link: https://connect.example.com/link/lk_...` rendered as
`Connect link: <site name>`, so the connect-link handoff could not be read,
copied or retyped from the TUI.

A bare URL is now its own text. Authored markdown labels still win, because
`Link` emits the OSC 8 hyperlink unconditionally and the renderer records it
per cell, so a label never strands its target. Title resolution no longer
drives any render path.
2026-09-10 02:21:54 +05:30
hermes-seaeye[bot] d4d4ecfae0 fmt(js): npm run fix on merge (#105739)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-08 10:47:44 +00:00
Teknium 3863d13440 docs: describe one-line docks and consistent transcript controls 2026-09-08 03:42:08 -07:00
Teknium 0a3b7fdce2 fix: preserve Ink composer cursor across agent monitors 2026-09-08 03:42:08 -07:00
Teknium f7fe32bfde feat: add compact Ink agent dock and Enter live tail 2026-09-08 03:42:08 -07:00
hermes-seaeye[bot] 04b88a4c0a fmt(js): npm run fix on merge (#105732)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-08 10:17:46 +00:00
hermes-seaeye[bot] 7ee52894a7 fmt(js): npm run fix on merge (#105729)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-08 10:11:57 +00:00
Teknium b3e5c437fc test: exercise dock fills with terminal color enabled 2026-09-08 03:06:30 -07:00
Teknium 3669e17a50 fix: keep the compact agent dock passive 2026-09-08 03:06:30 -07:00
Teknium 4aad9b11b1 fix: distinguish the live agent dock with a themed surface 2026-09-08 03:06:30 -07:00
Teknium 924c5ded2e fix: scope subagent stops and publish authoritative live progress 2026-09-08 03:06:30 -07:00
Teknium 8a7b6ffad2 fix: preserve newer Ink controls when status hydration arrives late 2026-09-08 03:06:30 -07:00
Teknium 30be948a0c fix: keep Ink agent progress monotonic across snapshot hydration 2026-09-08 03:06:30 -07:00
Teknium 1c55bc92fa fix(tui): count children rather than async completion units 2026-09-08 03:06:30 -07:00
Teknium 8e4b4e0cc8 fix(tui): follow newest transcript output in tail view 2026-09-08 03:06:30 -07:00
Teknium 1ae7389502 fix(tui): keep agent controls visible on narrow terminals 2026-09-08 03:06:30 -07:00
Teknium f3a9ca089f feat(tui): inspect live tails and steer from full-height agent roster 2026-09-08 03:06:30 -07:00
Teknium 2a908d762b feat(tui): dock live subagent roster above composer
Salvage bounded row projection and status glyphs from PR #70899; hydrate the existing tree from session-scoped snapshots and open with Ctrl+T without disturbing drafts.

Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
2026-09-08 03:06:30 -07:00
Teknium 0da43333db fix: isolate Ink pending queues and reconcile settled snapshots 2026-09-07 22:25:12 -07:00
Ben Barclay 6e2b8e070d fix(tui): match the redo chord case-insensitively so Cmd+Shift+Z works on extended-key terminals (#105493)
Since #90674 hermes-ink restores the shifted letter's case for CSI-u and
modifyOtherKeys input, so Cmd+Shift+Z reaches the composer as inp 'Z' with
key.shift set. The redo branch compared inp === 'z' and missed, falling
through to the printable path and inserting a literal "Z". The legacy
raw-byte path (ESC Z) already delivered 'Z', so the binding was latently
case-sensitive on both paths.

Compare with inp.toLowerCase(), matching the copy/paste chords a few lines
above. Linux Ctrl+Shift+Z is unaffected (ctrl chords keep the lowercase
key name).

The test drives the real TextInput through renderSync with kitty CSI-u
bytes (super+z, then super+shift+z) and asserts the redo lands and no "Z"
is inserted; it fails on the unfixed tree with "aZ".
2026-09-08 12:04:34 +10:00
hermes-seaeye[bot] 966637323e fmt(js): npm run fix on merge (#105451)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-08 00:13:36 +00:00
Hermes Agent ab1a6d016a test(display): encode provenance contract in tilde assertions
Two tests still encoded the pre-PR behaviour that the PR removes:

- tests/test_tui_gateway_server.py: the mirrored fixture carries no
  `context_estimated` flag, so under the PR's rule it is provider-reported
  usage and must render without `~`. The old expectation asserted the
  unconditional tilde main used to emit. Assert the flag-less case is
  unmarked and, in the same test, flip `context_estimated` both ways to
  pin that only the estimate carries `~` in the count and the percent.

- ui-tui appChromeStatusRule.test.tsx: `text.includes('~')` matched the
  `~/repo` cwd label, so the "not estimated" arm was always true. Extract
  the rendered context token and assert the tilde on that token only.
2026-09-07 08:13:01 -07:00
Teknium 04767e7aaa fix(display): distinguish estimated context from provider usage 2026-09-07 08:13:01 -07:00
hermes-seaeye[bot] a51912c25b fmt(js): npm run fix on merge (#105038)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-07 12:03:25 +00:00
Teknium 72719c7c1b fix: show task-first subagent completion notices in CLI and TUI 2026-09-07 01:23:34 -07:00
Teknium 552159d222 feat(cli,tui): collapse bell_on_clarify/approval into display.bell_on_prompt
One key covers every blocking prompt modal: clarify (single + batch),
dangerous-command approval (incl. computer_use), sudo password, and
secret capture. CLI gets a _ring_bell() helper shared with
bell_on_complete; TUI rings on clarify/approval/sudo/secret .request
events (isTTY-gated). 'hermes config' Bell summary shows both flags.
2026-09-02 05:34:35 -07:00
Turgut Kural 3082a34669 feat(cli,tui): add display.bell_on_approval + fix eslint error
- display.bell_on_approval (default false): same BEL mechanism as
  bell_on_complete, rings when a dangerous-command approval prompt
  opens (_approval_callback / approval.request event). Complements
  bell_on_clarify from the previous commit.
- fix(ui-tui): eslint curly error in useConfigSync.applyDisplay
  (if without braces) that failed the CI JS & TS checks job.
2026-09-02 05:34:35 -07:00
Turgut Kural ef6d3367a6 feat(cli,tui): add display.bell_on_clarify — terminal bell on clarify prompts
Same BEL mechanism as display.bell_on_complete (\a / \x07), gated by
display.bell_on_clarify (default false). CLI rings in _clarify_callback
and _clarify_callback_batch before _paint_now(); TUI rings on
clarify.request when bellOnClarify && stdout.isTTY. Docs in
cli-config.yaml.example and website/docs/user-guide/configuration.md.
2026-09-02 05:34:35 -07:00
hermes-seaeye[bot] 6840bb02e8 fmt(js): npm run fix on merge (#101102)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-02 08:42:10 +00:00
Teknium a2600740e8 feat(delegate): tag every subagent progress line with its batch id
Concurrent or nested delegation batches (a parent's 9-way fan-out plus a
child's own 3-way fan-out) printed interleaved `✓ [3/3]` / `✓ [3/9]` lines
with nothing identifying which batch each belongs to.

- CLI: batch header `🔀 [6a66] delegating 9 tasks`; completion lines and
  child tree-view lines become `[6a66 3/9]`; spinner remaining-count tagged.
- Relay: `delegation_id` rides on every `subagent.*` event (TUI gateway
  payload, api_server SSE subagent.start/complete).
- TUI: `[6a66 3/9]` prefix on /agents rows; Desktop Agents pane groups
  workers by exact delegation_id (heuristic shape/time grouping kept for
  older backends) and shows the tag on the group header.
- Tag = last 4 hex of the deleg_xxxxxxxx id (format_batch_tag), same id
  returned by the dispatch and used for cache/delegation/live/<id>/.
2026-09-02 01:06:24 -07:00
Brooklyn Nicholson 00b2e03c80 fix(tui): a collapsed paste resolves before the slash command runs
`/pr-triage [[ … [412 lines] … ]]` dispatched the LABEL: the paste
expansion was computed but only consumed by /queue, so every other
command received "[412 lines]" as its argument and the agent
faithfully reported the paste as truncated.

prepareSlashSubmission names the split the branch actually needs — the
transcript keeps the collapsed label, the dispatch carries the full
text. Image tokens stay as labels, since the gateway already holds
those files in attached_images.
2026-09-01 21:46:46 -05:00
Brooklyn Nicholson 1715d0415e fix(tui): a slash command argument keeps its line breaks
parseSlashCommand split the whole line on `\s+` and rejoined with a
single space, so `/pr-triage <pasted diff>` reached the skill as one
run-on line. Only the separator between the command name and its
argument belongs to the parser; everything after it is the user text
and now survives verbatim.

Every consumer already re-splits the arg it receives, so subcommand
parsing (`/cron add`, `/model x --global`) is unchanged.
2026-09-01 21:46:46 -05:00
fangliquanflq 16ab670a63 fix(tui): preserve edited history input 2026-09-01 14:29:46 +05:30