opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine

hermes --tui launches the native OpenTUI engine (Bun) when
HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config);
Ink stays the default and the shipping path is untouched.

- _resolve_tui_engine() (env > config > ink); refuses opentui on
  Windows/Termux (no Bun) -> falls back to ink with a notice.
- _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step).
- _bun_bin() with HERMES_BUN override.
- Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host
  must not bootstrap Node).
- Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun
  is JSC; the V8 flag errors/ignores).

Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI ->
real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
alt-glitch 2026-06-08 11:11:54 +00:00
parent 24f74eb888
commit 2bd9c9b881
741 changed files with 17733 additions and 79889 deletions

View file

@ -1,115 +0,0 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import { api, setManagementProfile } from "@/lib/api";
import { ProfileContext } from "@/contexts/profile-context";
/**
* Machine-level management-profile scope.
*
* One switcher (rendered in the sidebar) decides which profile every
* management page reads/writes. React STATE is the source of truth; the
* URL (`?profile=<name>`) is a synchronized projection of it so deep links
* land scoped and refresh survives. The selection is mirrored into the api
* module so `fetchJSON` transparently appends it to the profile-scoped
* endpoint families. "" = the dashboard's own profile.
*
* Why state-first instead of URL-first: sidebar nav links are bare paths
* (`/config`, `/skills`). A URL-derived scope would silently reset to the
* dashboard's own profile on every nav click the switcher would LOOK
* global while normal navigation dropped the write target. With state as
* truth, the effect below re-asserts `?profile=` onto the new location
* after each navigation, so the scope survives nav and stays deep-linkable.
*
* This exists because "Set as active" on the Profiles page only flips the
* sticky active_profile file (future CLI/gateway runs) it cannot retarget
* the running dashboard. The switcher is the dashboard's own, visible,
* write-target selector.
*/
export function ProfileProvider({ children }: { children: ReactNode }) {
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [profiles, setProfiles] = useState<string[]>([]);
const [currentProfile, setCurrentProfile] = useState("default");
// Initial value comes from the URL (deep link / refresh / unified-launch
// preselect); afterwards state leads and the URL follows.
const [profile, setProfileState] = useState(
() => searchParams.get("profile") ?? "",
);
// Mirror into the api module synchronously on every render where it
// changed, so fetches fired by child effects in the same commit see it.
setManagementProfile(profile);
// A profile param arriving via in-app navigation (e.g. the Profiles
// page's "Manage skills & tools" linking to /skills?profile=X) must win
// over current state — it's an explicit scope request.
const urlProfile = searchParams.get("profile");
useEffect(() => {
if (urlProfile !== null && urlProfile !== profile) {
setManagementProfile(urlProfile);
setProfileState(urlProfile);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlProfile]);
// Re-assert ?profile= after navigations that dropped it (bare nav links).
// Runs on every pathname/profile change; no-ops when already in sync.
useEffect(() => {
const inUrl = searchParams.get("profile") ?? "";
if ((profile || "") === inUrl) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (profile) next.set("profile", profile);
else next.delete("profile");
return next;
},
{ replace: true },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname, profile]);
useEffect(() => {
api
.getProfiles()
.then((res) => setProfiles(res.profiles.map((p) => p.name)))
.catch(() => {});
api
.getActiveProfile()
.then((info) => setCurrentProfile(info.current || "default"))
.catch(() => {});
}, []);
const setProfile = useCallback(
(name: string) => {
setManagementProfile(name);
setProfileState(name);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (name) next.set("profile", name);
else next.delete("profile");
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const value = useMemo(
() => ({ profile, currentProfile, profiles, setProfile }),
[profile, currentProfile, profiles, setProfile],
);
return (
<ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>
);
}

View file

@ -1,19 +0,0 @@
import { createContext } from "react";
export interface ProfileContextValue {
/** Profile every management surface reads/writes ("" = the dashboard
* process's own profile). */
profile: string;
/** The profile the dashboard process itself runs under. */
currentProfile: string;
/** Known profile names (includes "default"). */
profiles: string[];
setProfile: (name: string) => void;
}
export const ProfileContext = createContext<ProfileContextValue>({
profile: "",
currentProfile: "default",
profiles: [],
setProfile: () => {},
});

View file

@ -1,6 +0,0 @@
import { useContext } from "react";
import { ProfileContext } from "@/contexts/profile-context";
export function useProfileScope() {
return useContext(ProfileContext);
}