218d99cddf feat(android): render mermaid fences as offline diagrams (#2161)
* feat(android): render chat markdown math with offline KaTeX

Port the web math pipeline to Android chat markdown. A new
MarkdownMath pre-pass in :core:protocol extracts math with web
semantics (remark-math singleDollarTextMath:false + remark-latex-
bracket-math): $$ display flows, $$ text math only when the closer
run equals the opener and stays inside the paragraph, single $
never triggers math (currency stays prose), \(..\)/\[..\] bracket
pairs, backslash-escaped delimiters stay literal, and code spans,
fences, links and link reference definitions are fully protected.
Matches are lifted behind private-use placeholder tokens and
re-inserted as MathInlineNode/MathBlockNode custom nodes, hoisting
display math out of paragraphs while blockquote/list containers
survive the reparse. 37 JVM tests mirror the web suite's cases.

Rendering is a shared offline WebView (ui/markdown/MathWebView.kt)
fed a self-built HTML document (MathDocument.kt) against bundled
assets/katex/ (KaTeX 0.16.45, same version as web). The view is
network-blocked (blockNetworkLoads + shouldInterceptRequest
refuses http/https/ws/wss) with file/content access disabled;
asset URLs still resolve through the file:///android_asset/katex/
base URL. Theme color and font size are injected per render and
the document reports its height through a JS bridge
(ResizeObserver + onPageFinished re-measure). KaTeX failures
follow the web's three-tier semantics, degrading to monospace
raw TeX.

Inline \(..\) and single-paragraph $$ math degrades to monospace
TeX for v1: AnnotatedString cannot host a WebView and one WebView
per fragment is too costly in chat lists. The parser still emits
MathInlineNode so a future renderer can upgrade inline rendering
without touching the extraction layer.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): convert CSS pixels correctly at the math WebView boundary

The JS bridge reports scrollHeight in CSS pixels, but KaTeXMathBlock
treated the value as Android physical pixels and divided it by the
screen density when sizing the modifier: on a density-3 device a
reported 90 CSS px block collapsed to 30dp instead of ~90dp. The font
conversion had the opposite mismatch — 16sp chat text went through
TextUnit.toPx() (physical pixels) and was injected as `font-size: 48px`
CSS, making formulas render ~3x too large on the same screen.

Define the two boundary conversions explicitly as pure functions next
to the document builder and unit-test them:

- spFontToCssPx: CSS px and sp are both density-independent, so only
  the user's font-scale preference crosses the boundary; density never
  enters (16sp stays 16 CSS px at any density, 21 at fontScale 1.3).
- cssPxToDp: reported CSS px map 1:1 to dp at the default WebView
  scale, multiplied by WebView.scale when the view is zoomed.

The document now also pins the viewport with
`width=device-width, initial-scale=1`, making the 1 CSS px = 1 dp
default scale an explicit property of the document rather than a
WebView default, with a regression test asserting the meta tag.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(android): refresh the math WebView height callback across spec changes

KaTeXMathBlock keyed its height state with remember(spec) but assigned
MathWebView.onHeightChanged inside AndroidView.factory, which runs only
when the Android view is created. The callback therefore captured the
initial spec's state object: when LaTeX, theme color or font size
changed, remember(spec) minted a new state while AndroidView retained
the existing view and its stale callback, so every measurement from the
reloaded document updated the orphaned state and the current block
stayed at its 22dp minimum.

Keep one stable holder (MathViewHeightState) remembered unkeyed for the
mounted view, reset it explicitly in onRender when the spec changes, and
re-point the view's callback plus reset the height inside
AndroidView.update before rendering — the creation-time callback then
always feeds live state. JVM tests drive the holder through spec
changes on one mounted view (the pre-fix wiring cannot compile against
this shape: the factory-captured remember(spec) state is exactly the
orphan the holder removes).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(protocol): bound dollar flow math closing to the enclosing container

findFlowClose kept scanning after the opening line left its container
and accepted an unprefixed closing run, so `> $$\n> x\n\nOrdinary
prose\n\n$$\ny\n$$` paired the quoted opener with the later top-level
`$$`: the prose was swallowed into the formula and the final `$$` lost
its own opener. remark-math (verified against micromark-extension-math,
whose math flow is `concrete` and dies with its container) renders the
quote's math, the prose and a separate top-level equation instead.

Bound matching to the opening container, distinguishing container
shapes: a closer is only accepted from a line that still carries the
container prefix; a blank line ends a blockquote (a following `> $$`
opens a NEW quote, while a `>`-only line is an in-quote blank the
container survives); a list item may resume after blank lines (loose
lists), so blanks are skipped and the next content line decides; an
unrecognized prefix is plain <= 3 space indentation, not a container,
and keeps the unbounded scan (blank lines and fences stay math content).

Regression tests: the quoted example (prose stays prose, the later
equation keeps its own opener), a list-container variant, a
blank-line/new-quote guard, plus two in-container close locks — the
first three fail on the pre-fix scan.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* feat(android): render mermaid fences as offline diagrams

Port the web MermaidDiagram component to Android chat markdown. A fence
whose language is exactly `mermaid` (case-sensitive, like the web's
MARKDOWN_COMPONENTS_BY_LANGUAGE key) now dispatches to MermaidDiagramView
instead of the plain code block; every other fence keeps the CodeBlock
path.

Rendering runs in a hardened offline WebView fed a self-built HTML
document against bundled assets/mermaid/mermaid.min.js (Mermaid 11.14.0,
curl-fetched, sha256-identical to the web app's locked dependency). That
dist file is a self-contained classic script assigning globalThis
mermaid - no imports, no runtime fetches - so no ES-module/CORS
workaround is needed. The view shares the KaTeX skeleton: network stack
blocked, file/content access off, asset URLs through the
file:///android_asset/mermaid/ base URL (OfflineMathWebViewClient
generalized to OfflineMarkdownWebViewClient, no behavior change).

Behavior mirrors the web component line by line: initialize with
startOnLoad:false, securityLevel:'strict', suppressErrorRendering:true
and the web's dark/light themeVariables palettes verbatim (theme flips
re-render); the validate-then-render gate parses with suppressErrors
first and never calls render() after a failed validation, re-parsing
once without suppression only to surface a human-readable reason; svg
element ids are per-instance (`mermaid-<n>`, web useId) so the scoped
styles mermaid emits cannot collide across diagrams in one message;
wide diagrams scroll horizontally inside the document while the svg is
centered at max-width:100% and height:auto.

The code header (language chip + copy) stays above the body like the
web's retained DefaultCodeBlock header; until the diagram renders - and
on failure - the raw source is shown below it (the block's source exit),
with the failure notice appearing only for actual errors so the loading
window never flashes a render-error line. mermaidFallbackUi maps state
to those surfaces as a pure function.

15 JVM tests cover dispatch, offline-ness, palettes, security flags,
the validation gate structure, id uniqueness, generation filtering,
script-breakout safety and the fallback mapping; the driver's runtime
semantics were additionally validated against the real bundle in jsdom
(flowchart/sequence render, invalid source surfaces the parse reason,
hostile labels are rejected/sanitized under strict).

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): convert CSS pixel heights correctly in the mermaid WebView

Both height reporters feeding MermaidDiagramView — the document bridge's
report() (documentElement/body scrollHeight) and
OfflineMarkdownWebViewClient's onPageFinished re-measure — return CSS
pixels, but the rendered branch ran the value through
with(density) { heightPx.toDp() }, which treats it as Android physical
pixels and divides by display density: on a 3x display a diagram
measuring 180 CSS px received a 60dp box and was clipped into an
unnecessarily scrolling WebView.

Fix it exactly like the KaTeX base now does: the document pins
`width=device-width, initial-scale=1` (making 1 CSS px = 1 dp at the
default WebView scale an explicit property of the document rather than a
WebView default), and the reported value crosses the boundary through
the shared density-independent cssPxToDp helper — extracted as the pure
mermaidRenderedHeightDp so the composable no longer holds any density
conversion at all, keeping both markdown renderers on one boundary
contract. (The KaTeXMathBlock repeat of the same bug is fixed by the
rebased base; MathWebView.kt arrives converted already.)

JVM tests: the viewport pin is asserted against the built document
(demonstrated failing on the pre-fix template) and the conversion math
is driven through the pure helper — 180 CSS px stays 180dp because the
boundary carries no density parameter, mirroring the base's
MathDocumentTest contract.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
2026-11-02 18:23:35 +08:00
2026-11-01 20:30:00 +08:00
2026-08-25 13:31:59 +08:00
2026-01-04 20:45:15 +08:00
2026-03-29 09:47:17 +08:00
2025-12-16 15:03:50 +08:00

HAPI

Run official Claude Code / Codex / Cursor Agent / Grok Build / OpenCode / Kimi / Copilot / Antigravity / Pi / DeepSeek Harness sessions and control them remotely through native iOS / Android apps, Web / PWA, or Telegram Mini App.

Why HAPI? HAPI is a local-first alternative to Happy. See Why Not Happy? for the key differences.

Features

  • Seamless Handoff - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart.
  • Shared Codex Sessions - Use Codex from your terminal and phone at the same time. Requires Codex 0.154.0+. Usage and limits.
  • Native First - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory.
  • AFK Without Stopping - Step away from your desk? Approve AI requests from your phone with one tap.
  • Your AI, Your Choice - Claude Code, Codex, Cursor Agent, Grok Build, OpenCode, Kimi, Copilot, Antigravity, Pi, DeepSeek Harness—different agents, one unified workflow.
  • Terminal Anywhere - Run commands from your phone's browser or desktop web app, directly connected to the working machine.
  • Voice Control - Use dictation in native apps, or talk to your AI agent hands-free with the web voice assistant.
  • Workspace Browser - Opt-in via one or more hapi runner start --workspace-root <path> flags: browse scoped file trees from the web and start sessions in allowed subdirectories.

Demo

https://github.com/user-attachments/assets/38230353-94c6-4dbe-9c29-b2a2cc457546

Getting Started

npx @twsxtd/hapi hub --relay     # start hub with E2E encrypted relay
npx @twsxtd/hapi                 # choose an agent and start a session

hapi server remains supported as an alias.

Use hapi <agent> [options] to start an agent directly, for example hapi claude or hapi codex. Scripts must specify the agent explicitly. hapi --help shows HAPI's commands and supported agents.

The hub displays a URL and two QR codes. Then connect a client:

  • Web / PWA (free): open the web URL in a browser, or scan the web QR code. Install it as a PWA for a home-screen app and push notifications.
  • Native apps (paid): install HAPI from the App Store or Google Play, then scan the companion QR. See Native apps for pairing.

The relay uses WireGuard + TLS for end-to-end encryption. Your data is encrypted from your device to your machine.

For self-hosted options (Cloudflare Tunnel, Tailscale), see Installation

Docs

Clients

  • Web / PWA (free) - The full-featured web app, including the remote terminal and voice assistant. Works in any browser and installs as a PWA. See Web / PWA.
  • Native iOS / Android apps (paid) - SwiftUI/UIKit and Kotlin Compose clients with chat, approvals, session creation, files, dictation, and native push notifications. See the native app guide for capabilities and pairing.

Download on the App Store Get it on Google Play

Developers can build the apps from source: iOS, Android. Protocol: client contract.

Build from source

Requires Bun 1.4.4.

bun install
bun run build:single-exe

Credits

HAPI means "哈皮" a Chinese transliteration of Happy. Great credit to the original project.

S
Description
GitHub 镜像: wu736139669/hapi(每日自动同步)
Readme AGPL-3.0
140 MiB
Languages
TypeScript 99.5%
CSS 0.2%
JavaScript 0.2%