@personaxis/tui 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +13 -0
- package/dist/components.d.ts +47 -0
- package/dist/components.js +65 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +94 -0
- package/dist/ink.d.ts +13 -0
- package/dist/ink.js +12 -0
- package/dist/markdown.d.ts +12 -0
- package/dist/markdown.js +56 -0
- package/dist/screen.d.ts +60 -0
- package/dist/screen.js +243 -0
- package/dist/store.d.ts +26 -0
- package/dist/store.js +79 -0
- package/dist/streaming/commit-queue.d.ts +29 -0
- package/dist/streaming/commit-queue.js +86 -0
- package/dist/visual.d.ts +37 -0
- package/dist/visual.js +236 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Personaxis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Dedicated `personaxis-dash` bin entry. This file is the ONLY place the
|
|
4
|
+
* dashboard auto-runs — the library barrel (index.ts) must never carry a
|
|
5
|
+
* main-module guard: inside a bun-compiled binary every bundled module
|
|
6
|
+
* shares the virtual root, so `import.meta.url === argv[1]` guards fire
|
|
7
|
+
* spuriously on every invocation (found in FR.V verification).
|
|
8
|
+
*/
|
|
9
|
+
import { dashMain } from "./index.js";
|
|
10
|
+
dashMain().catch((err) => {
|
|
11
|
+
console.error("personaxis-dash fatal:", err);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ink 7 components (FR.3). `visual.ts` stays the single source of the brand
|
|
3
|
+
* identity — its functions are pure `(theme, values, frame) → string`, so each
|
|
4
|
+
* component is a thin wrapper: ZERO visual change from the pre-Ink dashboard.
|
|
5
|
+
*/
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { type PersonaTheme } from "@personaxis/core";
|
|
8
|
+
import { envelopeBars } from "./visual.js";
|
|
9
|
+
export declare function Sigil(props: {
|
|
10
|
+
theme: PersonaTheme;
|
|
11
|
+
values: Record<string, number>;
|
|
12
|
+
frame?: number;
|
|
13
|
+
}): React.JSX.Element;
|
|
14
|
+
export declare function AuraBar(props: {
|
|
15
|
+
theme: PersonaTheme;
|
|
16
|
+
values: Record<string, number>;
|
|
17
|
+
frame?: number;
|
|
18
|
+
}): React.JSX.Element;
|
|
19
|
+
export declare function EnvelopeBars(props: {
|
|
20
|
+
theme: PersonaTheme;
|
|
21
|
+
values: Record<string, number>;
|
|
22
|
+
envelopes: Parameters<typeof envelopeBars>[2];
|
|
23
|
+
}): React.JSX.Element;
|
|
24
|
+
export interface TranscriptProps {
|
|
25
|
+
/** Committed lines — rendered ONCE into native scrollback via <Static>. */
|
|
26
|
+
committed: string[];
|
|
27
|
+
/** The bounded live region (in-flight tokens, spinner line, dials). */
|
|
28
|
+
live?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `<Static>` for the terminated transcript (never re-rendered — the Ink-
|
|
32
|
+
* documented mitigation for long histories) + a bounded live region below.
|
|
33
|
+
* The CommitQueue decides WHEN a line moves from live to committed.
|
|
34
|
+
*/
|
|
35
|
+
export declare function Transcript(props: TranscriptProps): React.JSX.Element;
|
|
36
|
+
export interface DashboardProps {
|
|
37
|
+
personaPath: string;
|
|
38
|
+
intervalMs?: number;
|
|
39
|
+
/** Stop after N frames (tests/demos); omit to run until unmount. */
|
|
40
|
+
maxFrames?: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The live dashboard as an Ink app. Reads state.json each frame (same contract
|
|
44
|
+
* as the pre-Ink loop), so evolution in ANOTHER process (REPL, MCP, HTTP)
|
|
45
|
+
* shows up live.
|
|
46
|
+
*/
|
|
47
|
+
export declare function Dashboard(props: DashboardProps): React.JSX.Element;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Ink 7 components (FR.3). `visual.ts` stays the single source of the brand
|
|
4
|
+
* identity — its functions are pure `(theme, values, frame) → string`, so each
|
|
5
|
+
* component is a thin wrapper: ZERO visual change from the pre-Ink dashboard.
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { Box, Text, Static } from "ink";
|
|
9
|
+
import { loadPersona, readState, extractEnvelopes, verifyMemoryChain, readMemory, personaTheme, displayName, } from "@personaxis/core";
|
|
10
|
+
import { sigilLines, auraBar, envelopeBars } from "./visual.js";
|
|
11
|
+
// ── brand components (pure wrappers over visual.ts) ─────────────────────────
|
|
12
|
+
export function Sigil(props) {
|
|
13
|
+
return _jsx(Text, { children: sigilLines(props.theme, props.values, props.frame ?? 0).join("\n") });
|
|
14
|
+
}
|
|
15
|
+
export function AuraBar(props) {
|
|
16
|
+
return _jsx(Text, { children: auraBar(props.theme, props.values, props.frame ?? 0) });
|
|
17
|
+
}
|
|
18
|
+
export function EnvelopeBars(props) {
|
|
19
|
+
return _jsx(Text, { children: envelopeBars(props.theme, props.values, props.envelopes) });
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* `<Static>` for the terminated transcript (never re-rendered — the Ink-
|
|
23
|
+
* documented mitigation for long histories) + a bounded live region below.
|
|
24
|
+
* The CommitQueue decides WHEN a line moves from live to committed.
|
|
25
|
+
*/
|
|
26
|
+
export function Transcript(props) {
|
|
27
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: props.committed, children: (line, i) => _jsx(Text, { children: line }, i) }), props.live ? _jsx(Text, { children: props.live }) : null] }));
|
|
28
|
+
}
|
|
29
|
+
function readFrame(personaPath) {
|
|
30
|
+
const handle = loadPersona(personaPath);
|
|
31
|
+
const state = readState(handle.statePath);
|
|
32
|
+
return {
|
|
33
|
+
name: displayName(handle.frontmatter),
|
|
34
|
+
theme: personaTheme(handle.frontmatter),
|
|
35
|
+
values: state.values,
|
|
36
|
+
envelopes: extractEnvelopes(handle.frontmatter).envelopes,
|
|
37
|
+
mutations: state.mutation_log.length,
|
|
38
|
+
memories: readMemory(handle.personaPath).length,
|
|
39
|
+
chainOk: verifyMemoryChain(handle.personaPath).ok,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The live dashboard as an Ink app. Reads state.json each frame (same contract
|
|
44
|
+
* as the pre-Ink loop), so evolution in ANOTHER process (REPL, MCP, HTTP)
|
|
45
|
+
* shows up live.
|
|
46
|
+
*/
|
|
47
|
+
export function Dashboard(props) {
|
|
48
|
+
const [frame, setFrame] = useState(0);
|
|
49
|
+
const [data, setData] = useState(() => readFrame(props.personaPath));
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (props.maxFrames !== undefined && frame >= props.maxFrames)
|
|
52
|
+
return;
|
|
53
|
+
const t = setTimeout(() => {
|
|
54
|
+
setFrame((f) => f + 1);
|
|
55
|
+
try {
|
|
56
|
+
setData(readFrame(props.personaPath));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* a mid-write read races with another process — keep the last frame */
|
|
60
|
+
}
|
|
61
|
+
}, props.intervalMs ?? 500);
|
|
62
|
+
return () => clearTimeout(t);
|
|
63
|
+
}, [frame, props.personaPath, props.intervalMs, props.maxFrames]);
|
|
64
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, paddingTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyanBright", children: data.name }), _jsxs(Text, { dimColor: true, children: [" · sigil #", data.theme.seed.toString(16), " · "] }), _jsx(AuraBar, { theme: data.theme, values: data.values, frame: frame })] }), _jsx(Text, { children: " " }), _jsx(Sigil, { theme: data.theme, values: data.values, frame: frame }), _jsx(Text, { children: " " }), _jsx(EnvelopeBars, { theme: data.theme, values: data.values, envelopes: data.envelopes }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [`mutations ${data.mutations} · memory ${data.memories} · chain `, data.chainOk ? _jsx(Text, { color: "green", children: "intact" }) : _jsx(Text, { color: "red", children: "BROKEN" }), ` · frame ${frame}`] })] }));
|
|
65
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @personaxis/tui — the living dashboard (`personaxis-dash`).
|
|
3
|
+
*
|
|
4
|
+
* A breathing, per-persona ASCII view: the persona's own sigil (seeded from its
|
|
5
|
+
* personaxis.md identity) animating with its live state, envelope bars, mutation
|
|
6
|
+
* count, and memory-chain integrity. It reads state.json each frame, so it reflects
|
|
7
|
+
* evolution happening in another process (REPL, MCP host, HTTP) in real time.
|
|
8
|
+
*
|
|
9
|
+
* personaxis-dash --persona <path> [--once] [--frames N] [--interval ms]
|
|
10
|
+
*/
|
|
11
|
+
export interface DashOpts {
|
|
12
|
+
persona: string;
|
|
13
|
+
once: boolean;
|
|
14
|
+
frames: number;
|
|
15
|
+
interval: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function renderFrame(personaPath: string, frame: number): string;
|
|
18
|
+
/**
|
|
19
|
+
* Run the live dashboard for a persona. Shared by the `personaxis-dash` bin and the
|
|
20
|
+
* `personaxis dash` CLI subcommand, so both entry points behave identically.
|
|
21
|
+
* Returns when finished (once/non-TTY); in interactive mode it runs until SIGINT.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runDashboard(opts: DashOpts): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* `personaxis-dash` standalone entry — used by bin.ts ONLY. The old
|
|
26
|
+
* `import.meta.url === argv[1]` main-module guard lived here and evaluated
|
|
27
|
+
* TRUE for every module inside a bun-compiled binary (all modules share the
|
|
28
|
+
* virtual root), spuriously launching the dashboard on EVERY CLI invocation.
|
|
29
|
+
* Bun-compile rule: a bin gets a dedicated entry file, never a barrel guard.
|
|
30
|
+
*/
|
|
31
|
+
export declare function dashMain(): Promise<void>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @personaxis/tui — the living dashboard (`personaxis-dash`).
|
|
3
|
+
*
|
|
4
|
+
* A breathing, per-persona ASCII view: the persona's own sigil (seeded from its
|
|
5
|
+
* personaxis.md identity) animating with its live state, envelope bars, mutation
|
|
6
|
+
* count, and memory-chain integrity. It reads state.json each frame, so it reflects
|
|
7
|
+
* evolution happening in another process (REPL, MCP host, HTTP) in real time.
|
|
8
|
+
*
|
|
9
|
+
* personaxis-dash --persona <path> [--once] [--frames N] [--interval ms]
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import chalk from "chalk";
|
|
14
|
+
import { loadPersona, ensureState, readState, extractEnvelopes, verifyMemoryChain, readMemory, personaTheme, displayName, } from "@personaxis/core";
|
|
15
|
+
import { sigilLines, auraBar, envelopeBars } from "./visual.js";
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const o = { persona: ".personaxis/personaxis.md", once: false, frames: 30, interval: 500 };
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (a === "--persona" || a === "-p")
|
|
21
|
+
o.persona = argv[++i];
|
|
22
|
+
else if (a === "--once")
|
|
23
|
+
o.once = true;
|
|
24
|
+
else if (a === "--frames")
|
|
25
|
+
o.frames = Number(argv[++i]) || o.frames;
|
|
26
|
+
else if (a === "--interval")
|
|
27
|
+
o.interval = Number(argv[++i]) || o.interval;
|
|
28
|
+
}
|
|
29
|
+
return o;
|
|
30
|
+
}
|
|
31
|
+
export function renderFrame(personaPath, frame) {
|
|
32
|
+
const handle = loadPersona(personaPath);
|
|
33
|
+
const state = readState(handle.statePath);
|
|
34
|
+
const env = extractEnvelopes(handle.frontmatter);
|
|
35
|
+
const theme = personaTheme(handle.frontmatter);
|
|
36
|
+
const chain = verifyMemoryChain(handle.personaPath);
|
|
37
|
+
const mem = readMemory(handle.personaPath);
|
|
38
|
+
const lines = [];
|
|
39
|
+
lines.push("");
|
|
40
|
+
lines.push(" " + chalk.bold.ansi256(theme.palette.accent)(displayName(handle.frontmatter)) +
|
|
41
|
+
chalk.dim(` · sigil #${theme.seed.toString(16)} · ${auraBar(theme, state.values, frame)}`));
|
|
42
|
+
lines.push("");
|
|
43
|
+
lines.push(...sigilLines(theme, state.values, frame));
|
|
44
|
+
lines.push("");
|
|
45
|
+
lines.push(envelopeBars(theme, state.values, env.envelopes));
|
|
46
|
+
lines.push("");
|
|
47
|
+
lines.push(chalk.dim(` mutations ${state.mutation_log.length} · memory ${mem.length} · chain ` +
|
|
48
|
+
(chain.ok ? chalk.green("intact") : chalk.red("BROKEN")) +
|
|
49
|
+
` · frame ${frame}`));
|
|
50
|
+
lines.push("");
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Run the live dashboard for a persona. Shared by the `personaxis-dash` bin and the
|
|
55
|
+
* `personaxis dash` CLI subcommand, so both entry points behave identically.
|
|
56
|
+
* Returns when finished (once/non-TTY); in interactive mode it runs until SIGINT.
|
|
57
|
+
*/
|
|
58
|
+
export async function runDashboard(opts) {
|
|
59
|
+
const personaPath = resolve(opts.persona);
|
|
60
|
+
if (!existsSync(personaPath)) {
|
|
61
|
+
console.error(chalk.red("Error:"), `persona not found at ${personaPath}`);
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
ensureState(loadPersona(personaPath));
|
|
66
|
+
// Non-interactive (pipe/CI/--once): print static frames, no screen takeover.
|
|
67
|
+
if (opts.once || !process.stdout.isTTY) {
|
|
68
|
+
const n = opts.once ? opts.frames : 1;
|
|
69
|
+
for (let i = 0; i < n; i++)
|
|
70
|
+
process.stdout.write(renderFrame(personaPath, i) + "\n");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// Interactive: Ink 7 render (FR.3). Ink owns the diffing/redraw; the
|
|
74
|
+
// Dashboard component re-reads state.json each frame — same live contract
|
|
75
|
+
// as the pre-Ink loop, same visuals (components wrap visual.ts verbatim).
|
|
76
|
+
const [{ render }, React, { Dashboard }] = await Promise.all([
|
|
77
|
+
import("ink"),
|
|
78
|
+
import("react"),
|
|
79
|
+
import("./components.js"),
|
|
80
|
+
]);
|
|
81
|
+
const app = render(React.createElement(Dashboard, { personaPath, intervalMs: opts.interval }), { exitOnCtrlC: true });
|
|
82
|
+
await app.waitUntilExit();
|
|
83
|
+
console.log(chalk.dim(" dashboard closed.\n"));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* `personaxis-dash` standalone entry — used by bin.ts ONLY. The old
|
|
87
|
+
* `import.meta.url === argv[1]` main-module guard lived here and evaluated
|
|
88
|
+
* TRUE for every module inside a bun-compiled binary (all modules share the
|
|
89
|
+
* virtual root), spuriously launching the dashboard on EVERY CLI invocation.
|
|
90
|
+
* Bun-compile rule: a bin gets a dedicated entry file, never a barrel guard.
|
|
91
|
+
*/
|
|
92
|
+
export async function dashMain() {
|
|
93
|
+
await runDashboard(parseArgs(process.argv.slice(2)));
|
|
94
|
+
}
|
package/dist/ink.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @personaxis/tui/ink — the Ink 7 surface (FR.3).
|
|
3
|
+
*
|
|
4
|
+
* Brand components wrap visual.ts verbatim (zero visual change); Transcript
|
|
5
|
+
* implements the streaming architecture (<Static> + live region + CommitQueue);
|
|
6
|
+
* the store adapts protocol events for React. `screen.ts` (the pre-Ink REPL
|
|
7
|
+
* line editor) remains exported until F3.6 rewires the REPL behind the seam.
|
|
8
|
+
*/
|
|
9
|
+
export { Sigil, AuraBar, EnvelopeBars, Transcript, Dashboard } from "./components.js";
|
|
10
|
+
export type { TranscriptProps, DashboardProps } from "./components.js";
|
|
11
|
+
export { CommitQueue } from "./streaming/commit-queue.js";
|
|
12
|
+
export { renderMarkdown, highlightCode, renderDiff } from "./markdown.js";
|
|
13
|
+
export { createEngineStore, type EngineStore, type EngineUiState } from "./store.js";
|
package/dist/ink.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @personaxis/tui/ink — the Ink 7 surface (FR.3).
|
|
3
|
+
*
|
|
4
|
+
* Brand components wrap visual.ts verbatim (zero visual change); Transcript
|
|
5
|
+
* implements the streaming architecture (<Static> + live region + CommitQueue);
|
|
6
|
+
* the store adapts protocol events for React. `screen.ts` (the pre-Ink REPL
|
|
7
|
+
* line editor) remains exported until F3.6 rewires the REPL behind the seam.
|
|
8
|
+
*/
|
|
9
|
+
export { Sigil, AuraBar, EnvelopeBars, Transcript, Dashboard } from "./components.js";
|
|
10
|
+
export { CommitQueue } from "./streaming/commit-queue.js";
|
|
11
|
+
export { renderMarkdown, highlightCode, renderDiff } from "./markdown.js";
|
|
12
|
+
export { createEngineStore } from "./store.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal markdown / syntax / diff rendering (FR.3 decision table):
|
|
3
|
+
* markdown → marked + marked-terminal
|
|
4
|
+
* syntax → shiki, LAZY-loaded (heavy; first highlight pays the import)
|
|
5
|
+
* diff → jsdiff + chalk
|
|
6
|
+
*/
|
|
7
|
+
/** Render markdown for the terminal (synchronous, no syntax highlight). */
|
|
8
|
+
export declare function renderMarkdown(md: string): string;
|
|
9
|
+
/** Highlight code with shiki when available; plain text otherwise. */
|
|
10
|
+
export declare function highlightCode(code: string, lang: string): Promise<string>;
|
|
11
|
+
/** Line diff, colored for the terminal. */
|
|
12
|
+
export declare function renderDiff(before: string, after: string): string;
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal markdown / syntax / diff rendering (FR.3 decision table):
|
|
3
|
+
* markdown → marked + marked-terminal
|
|
4
|
+
* syntax → shiki, LAZY-loaded (heavy; first highlight pays the import)
|
|
5
|
+
* diff → jsdiff + chalk
|
|
6
|
+
*/
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { Marked } from "marked";
|
|
9
|
+
import { markedTerminal } from "marked-terminal";
|
|
10
|
+
import { diffLines } from "diff";
|
|
11
|
+
const marked = new Marked(markedTerminal({
|
|
12
|
+
reflowText: false,
|
|
13
|
+
tab: 2,
|
|
14
|
+
}));
|
|
15
|
+
/** Render markdown for the terminal (synchronous, no syntax highlight). */
|
|
16
|
+
export function renderMarkdown(md) {
|
|
17
|
+
const out = marked.parse(md, { async: false });
|
|
18
|
+
return out.replace(/\n+$/, "");
|
|
19
|
+
}
|
|
20
|
+
let shikiPromise = null;
|
|
21
|
+
async function shiki() {
|
|
22
|
+
if (!shikiPromise) {
|
|
23
|
+
// Optional at runtime: highlighting degrades to plain text when shiki is
|
|
24
|
+
// not installed (it is a heavy optional enhancement, not a core need).
|
|
25
|
+
shikiPromise = import(/* @vite-ignore */ "shiki")
|
|
26
|
+
.then((m) => m)
|
|
27
|
+
.catch(() => null);
|
|
28
|
+
}
|
|
29
|
+
return shikiPromise;
|
|
30
|
+
}
|
|
31
|
+
/** Highlight code with shiki when available; plain text otherwise. */
|
|
32
|
+
export async function highlightCode(code, lang) {
|
|
33
|
+
const m = (await shiki());
|
|
34
|
+
if (!m?.codeToAnsi)
|
|
35
|
+
return code;
|
|
36
|
+
try {
|
|
37
|
+
return await m.codeToAnsi(code, { lang, theme: "github-dark" });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return code;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// ── diffs ─────────────────────────────────────────────────────────────────────
|
|
44
|
+
/** Line diff, colored for the terminal. */
|
|
45
|
+
export function renderDiff(before, after) {
|
|
46
|
+
return diffLines(before, after)
|
|
47
|
+
.map((part) => {
|
|
48
|
+
const lines = part.value.replace(/\n$/, "").split("\n");
|
|
49
|
+
if (part.added)
|
|
50
|
+
return lines.map((l) => chalk.green(`+ ${l}`)).join("\n");
|
|
51
|
+
if (part.removed)
|
|
52
|
+
return lines.map((l) => chalk.red(`- ${l}`)).join("\n");
|
|
53
|
+
return lines.map((l) => chalk.dim(` ${l}`)).join("\n");
|
|
54
|
+
})
|
|
55
|
+
.join("\n");
|
|
56
|
+
}
|
package/dist/screen.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repl — a NORMAL-buffer, minimalist interactive line editor.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately NOT an alternate-screen app: taking over the screen (and capturing
|
|
5
|
+
* the mouse) breaks the things a terminal already does well — native scrollback,
|
|
6
|
+
* text selection, click. So this stays in the normal buffer: output is printed
|
|
7
|
+
* normally (the terminal owns scrollback / wheel / selection), and only a small
|
|
8
|
+
* prompt block is kept pinned at the bottom, redrawn in place. Raw mode is used
|
|
9
|
+
* for key handling ONLY (no mouse reporting), so wheel/selection still work.
|
|
10
|
+
*
|
|
11
|
+
* Provides: a live `/` command palette navigable with ↑/↓ + Tab, an inline busy
|
|
12
|
+
* spinner, and an approval prompt — all without leaving the normal buffer.
|
|
13
|
+
*/
|
|
14
|
+
export type LineRole = "user" | "persona" | "activity" | "system" | "divider";
|
|
15
|
+
export interface SlashItem {
|
|
16
|
+
name: string;
|
|
17
|
+
desc: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ReplHooks {
|
|
20
|
+
/** The prompt prefix, e.g. "❯ ". */
|
|
21
|
+
prompt(): string;
|
|
22
|
+
/** A status line shown BELOW the input (tokens · time · mode). */
|
|
23
|
+
status(): string;
|
|
24
|
+
commands: SlashItem[];
|
|
25
|
+
onSubmit(line: string): Promise<void> | void;
|
|
26
|
+
onCycleMode?(): void;
|
|
27
|
+
onExit?(): void;
|
|
28
|
+
}
|
|
29
|
+
export declare class Screen {
|
|
30
|
+
private readonly hooks;
|
|
31
|
+
private readonly out;
|
|
32
|
+
private readonly inp;
|
|
33
|
+
private input;
|
|
34
|
+
private menuOpen;
|
|
35
|
+
private menuIndex;
|
|
36
|
+
private busy;
|
|
37
|
+
private phase;
|
|
38
|
+
private closed;
|
|
39
|
+
private renderedRows;
|
|
40
|
+
private spinnerFrame;
|
|
41
|
+
private spinnerTimer;
|
|
42
|
+
private pendingAsk;
|
|
43
|
+
constructor(hooks: ReplHooks);
|
|
44
|
+
start(): void;
|
|
45
|
+
stop(): void;
|
|
46
|
+
/** Print a line of OUTPUT above the prompt (stays in native scrollback). */
|
|
47
|
+
print(text: string, _role?: LineRole): void;
|
|
48
|
+
setBusy(busy: boolean, phase?: string): void;
|
|
49
|
+
setPhase(phase: string): void;
|
|
50
|
+
/** Prompt for a one-line answer (e.g. an approval). The next Enter resolves it. */
|
|
51
|
+
ask(prompt: string): Promise<string>;
|
|
52
|
+
private cols;
|
|
53
|
+
private matches;
|
|
54
|
+
private rule;
|
|
55
|
+
private promptBlock;
|
|
56
|
+
private renderPrompt;
|
|
57
|
+
private clearPrompt;
|
|
58
|
+
private onKey;
|
|
59
|
+
private submit;
|
|
60
|
+
}
|
package/dist/screen.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repl — a NORMAL-buffer, minimalist interactive line editor.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately NOT an alternate-screen app: taking over the screen (and capturing
|
|
5
|
+
* the mouse) breaks the things a terminal already does well — native scrollback,
|
|
6
|
+
* text selection, click. So this stays in the normal buffer: output is printed
|
|
7
|
+
* normally (the terminal owns scrollback / wheel / selection), and only a small
|
|
8
|
+
* prompt block is kept pinned at the bottom, redrawn in place. Raw mode is used
|
|
9
|
+
* for key handling ONLY (no mouse reporting), so wheel/selection still work.
|
|
10
|
+
*
|
|
11
|
+
* Provides: a live `/` command palette navigable with ↑/↓ + Tab, an inline busy
|
|
12
|
+
* spinner, and an approval prompt — all without leaving the normal buffer.
|
|
13
|
+
*/
|
|
14
|
+
import readline from "node:readline";
|
|
15
|
+
import chalk from "chalk";
|
|
16
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
17
|
+
export class Screen {
|
|
18
|
+
hooks;
|
|
19
|
+
out = process.stdout;
|
|
20
|
+
inp = process.stdin;
|
|
21
|
+
input = "";
|
|
22
|
+
menuOpen = false;
|
|
23
|
+
menuIndex = 0;
|
|
24
|
+
busy = false;
|
|
25
|
+
phase = "";
|
|
26
|
+
closed = false;
|
|
27
|
+
renderedRows = 0; // terminal rows the prompt block currently occupies
|
|
28
|
+
spinnerFrame = 0;
|
|
29
|
+
spinnerTimer = null;
|
|
30
|
+
pendingAsk = null;
|
|
31
|
+
constructor(hooks) {
|
|
32
|
+
this.hooks = hooks;
|
|
33
|
+
}
|
|
34
|
+
start() {
|
|
35
|
+
readline.emitKeypressEvents(this.inp);
|
|
36
|
+
if (this.inp.isTTY)
|
|
37
|
+
this.inp.setRawMode(true);
|
|
38
|
+
this.inp.on("keypress", this.onKey);
|
|
39
|
+
this.out.write(chalk.dim("\x1b[?25h")); // ensure cursor visible (no alt-screen, no mouse)
|
|
40
|
+
this.renderPrompt();
|
|
41
|
+
}
|
|
42
|
+
stop() {
|
|
43
|
+
if (this.closed)
|
|
44
|
+
return;
|
|
45
|
+
this.closed = true;
|
|
46
|
+
if (this.spinnerTimer)
|
|
47
|
+
clearInterval(this.spinnerTimer);
|
|
48
|
+
this.clearPrompt();
|
|
49
|
+
this.inp.off("keypress", this.onKey);
|
|
50
|
+
if (this.inp.isTTY)
|
|
51
|
+
this.inp.setRawMode(false);
|
|
52
|
+
this.inp.pause();
|
|
53
|
+
}
|
|
54
|
+
/** Print a line of OUTPUT above the prompt (stays in native scrollback). */
|
|
55
|
+
print(text, _role = "system") {
|
|
56
|
+
this.clearPrompt();
|
|
57
|
+
this.out.write(text + "\n");
|
|
58
|
+
this.renderPrompt();
|
|
59
|
+
}
|
|
60
|
+
setBusy(busy, phase = "") {
|
|
61
|
+
this.busy = busy;
|
|
62
|
+
this.phase = phase;
|
|
63
|
+
if (busy && !this.spinnerTimer) {
|
|
64
|
+
this.spinnerTimer = setInterval(() => {
|
|
65
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER.length;
|
|
66
|
+
this.renderPrompt();
|
|
67
|
+
}, 90);
|
|
68
|
+
}
|
|
69
|
+
else if (!busy && this.spinnerTimer) {
|
|
70
|
+
clearInterval(this.spinnerTimer);
|
|
71
|
+
this.spinnerTimer = null;
|
|
72
|
+
}
|
|
73
|
+
this.renderPrompt();
|
|
74
|
+
}
|
|
75
|
+
setPhase(phase) {
|
|
76
|
+
this.phase = phase;
|
|
77
|
+
if (this.busy)
|
|
78
|
+
this.renderPrompt();
|
|
79
|
+
}
|
|
80
|
+
/** Prompt for a one-line answer (e.g. an approval). The next Enter resolves it. */
|
|
81
|
+
ask(prompt) {
|
|
82
|
+
this.print(chalk.yellow(prompt));
|
|
83
|
+
return new Promise((res) => {
|
|
84
|
+
this.pendingAsk = res;
|
|
85
|
+
this.renderPrompt();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// ── prompt rendering (pinned at the bottom of the normal buffer) ────────────
|
|
89
|
+
cols() {
|
|
90
|
+
return this.out.columns ?? 80;
|
|
91
|
+
}
|
|
92
|
+
matches() {
|
|
93
|
+
const q = this.input.slice(1).toLowerCase();
|
|
94
|
+
return this.hooks.commands.filter((c) => c.name.startsWith(q));
|
|
95
|
+
}
|
|
96
|
+
rule() {
|
|
97
|
+
return chalk.dim("─".repeat(Math.min(this.cols(), 72)));
|
|
98
|
+
}
|
|
99
|
+
promptBlock() {
|
|
100
|
+
if (this.busy) {
|
|
101
|
+
return [this.rule(), `${chalk.cyan(SPINNER[this.spinnerFrame])} ${chalk.dim(this.phase || "working…")}`];
|
|
102
|
+
}
|
|
103
|
+
if (this.pendingAsk)
|
|
104
|
+
return [chalk.yellow("? ") + this.input];
|
|
105
|
+
// The input line: a subtle background marks the area the USER is typing in.
|
|
106
|
+
const typed = this.input.length ? chalk.bgAnsi256(236).whiteBright(` ${this.input} `) : chalk.dim(" type a message… ");
|
|
107
|
+
const promptLine = this.hooks.prompt() + typed;
|
|
108
|
+
if (this.menuOpen) {
|
|
109
|
+
const items = this.matches();
|
|
110
|
+
// Windowed + scrollable: the visible slice follows the cursor, and its height
|
|
111
|
+
// adapts to the terminal so the menu never overruns small windows.
|
|
112
|
+
const rows = this.out.rows ?? 24;
|
|
113
|
+
const maxVisible = Math.max(3, Math.min(items.length, rows - 7));
|
|
114
|
+
let start = 0;
|
|
115
|
+
if (items.length > maxVisible) {
|
|
116
|
+
start = Math.min(Math.max(0, this.menuIndex - Math.floor(maxVisible / 2)), items.length - maxVisible);
|
|
117
|
+
}
|
|
118
|
+
const visible = items.slice(start, start + maxVisible);
|
|
119
|
+
const up = start > 0 ? "↑" : " ";
|
|
120
|
+
const down = start + maxVisible < items.length ? "↓" : " ";
|
|
121
|
+
const head = items.length > maxVisible
|
|
122
|
+
? chalk.dim(` ┄ ${up}${down} ${this.menuIndex + 1}/${items.length} · Tab fill · Enter run · Esc close ┄`)
|
|
123
|
+
: chalk.dim(" ┄ ↑↓ select · Tab fill · Enter run · Esc close ┄");
|
|
124
|
+
const menu = visible.map((c, i) => {
|
|
125
|
+
const idx = start + i;
|
|
126
|
+
const sel = idx === this.menuIndex;
|
|
127
|
+
const label = sel ? chalk.black.bgCyan(` /${c.name} `) : chalk.cyan(` /${c.name} `);
|
|
128
|
+
const pad = " ".repeat(Math.max(0, (sel ? 26 : 20) - (c.name.length + 3)));
|
|
129
|
+
return " " + label + pad + chalk.dim(c.desc.slice(0, Math.max(8, this.cols() - 30)));
|
|
130
|
+
});
|
|
131
|
+
return [promptLine, head, ...menu];
|
|
132
|
+
}
|
|
133
|
+
// Status line BELOW the input, separated by a rule.
|
|
134
|
+
return [promptLine, this.rule(), this.hooks.status()];
|
|
135
|
+
}
|
|
136
|
+
renderPrompt() {
|
|
137
|
+
if (this.closed)
|
|
138
|
+
return;
|
|
139
|
+
this.clearPrompt();
|
|
140
|
+
const block = this.promptBlock();
|
|
141
|
+
this.out.write(block.join("\n"));
|
|
142
|
+
this.renderedRows = block.length;
|
|
143
|
+
}
|
|
144
|
+
clearPrompt() {
|
|
145
|
+
if (this.renderedRows <= 0) {
|
|
146
|
+
this.out.write("\r\x1b[K");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// Move to column 0, up to the first prompt row, then clear everything below.
|
|
150
|
+
this.out.write("\r");
|
|
151
|
+
if (this.renderedRows > 1)
|
|
152
|
+
this.out.write(`\x1b[${this.renderedRows - 1}A`);
|
|
153
|
+
this.out.write("\x1b[J");
|
|
154
|
+
this.renderedRows = 0;
|
|
155
|
+
}
|
|
156
|
+
// ── input ───────────────────────────────────────────────────────────────────
|
|
157
|
+
onKey = (str, key) => {
|
|
158
|
+
if (this.closed)
|
|
159
|
+
return;
|
|
160
|
+
if (key.ctrl && key.name === "c") {
|
|
161
|
+
this.hooks.onExit?.();
|
|
162
|
+
this.stop();
|
|
163
|
+
this.out.write("\n");
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
// Command-palette navigation.
|
|
167
|
+
if (this.menuOpen) {
|
|
168
|
+
const items = this.matches();
|
|
169
|
+
if (key.name === "up") {
|
|
170
|
+
this.menuIndex = (this.menuIndex - 1 + Math.max(1, items.length)) % Math.max(1, items.length);
|
|
171
|
+
return this.renderPrompt();
|
|
172
|
+
}
|
|
173
|
+
if (key.name === "down") {
|
|
174
|
+
this.menuIndex = (this.menuIndex + 1) % Math.max(1, items.length);
|
|
175
|
+
return this.renderPrompt();
|
|
176
|
+
}
|
|
177
|
+
if (key.name === "tab") {
|
|
178
|
+
if (items[this.menuIndex])
|
|
179
|
+
this.input = "/" + items[this.menuIndex].name + " ";
|
|
180
|
+
this.menuOpen = false;
|
|
181
|
+
return this.renderPrompt();
|
|
182
|
+
}
|
|
183
|
+
if (key.name === "escape") {
|
|
184
|
+
this.menuOpen = false;
|
|
185
|
+
return this.renderPrompt();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (key.name === "return") {
|
|
189
|
+
// If the palette is open with a highlighted command and the user only typed
|
|
190
|
+
// the partial, run that command; otherwise submit the typed line.
|
|
191
|
+
if (this.menuOpen) {
|
|
192
|
+
const items = this.matches();
|
|
193
|
+
if (items[this.menuIndex] && this.input.slice(1) !== items[this.menuIndex].name) {
|
|
194
|
+
this.input = "/" + items[this.menuIndex].name;
|
|
195
|
+
}
|
|
196
|
+
this.menuOpen = false;
|
|
197
|
+
}
|
|
198
|
+
if (this.pendingAsk) {
|
|
199
|
+
const ans = this.input;
|
|
200
|
+
this.input = "";
|
|
201
|
+
const r = this.pendingAsk;
|
|
202
|
+
this.pendingAsk = null;
|
|
203
|
+
r(ans);
|
|
204
|
+
return this.renderPrompt();
|
|
205
|
+
}
|
|
206
|
+
void this.submit();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (key.name === "backspace") {
|
|
210
|
+
this.input = this.input.slice(0, -1);
|
|
211
|
+
this.menuOpen = this.input.startsWith("/");
|
|
212
|
+
this.menuIndex = 0;
|
|
213
|
+
return this.renderPrompt();
|
|
214
|
+
}
|
|
215
|
+
if (key.name === "tab" && key.shift) {
|
|
216
|
+
this.hooks.onCycleMode?.();
|
|
217
|
+
return this.renderPrompt();
|
|
218
|
+
}
|
|
219
|
+
if (str && !key.ctrl && !key.meta && str.length === 1 && str >= " ") {
|
|
220
|
+
this.input += str;
|
|
221
|
+
this.menuOpen = this.input.startsWith("/");
|
|
222
|
+
this.menuIndex = 0;
|
|
223
|
+
return this.renderPrompt();
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
async submit() {
|
|
227
|
+
const line = this.input.trim();
|
|
228
|
+
this.input = "";
|
|
229
|
+
this.menuOpen = false;
|
|
230
|
+
this.clearPrompt();
|
|
231
|
+
if (!line) {
|
|
232
|
+
this.renderPrompt();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
await this.hooks.onSubmit(line);
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
this.print(chalk.red(`error: ${e.message}`));
|
|
240
|
+
}
|
|
241
|
+
this.renderPrompt();
|
|
242
|
+
}
|
|
243
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine store (FR.3): the core EventBus — via the protocol seam — stays the
|
|
3
|
+
* SOURCE OF TRUTH; this thin zustand store only adapts events for React
|
|
4
|
+
* consumption (frame-batched token deltas, committed transcript, dials).
|
|
5
|
+
*/
|
|
6
|
+
import type { EventMsg } from "@personaxis/protocol";
|
|
7
|
+
export interface EngineUiState {
|
|
8
|
+
connected: boolean;
|
|
9
|
+
personaName: string;
|
|
10
|
+
mode: string;
|
|
11
|
+
/** Committed transcript lines (render via <Static>). */
|
|
12
|
+
committed: string[];
|
|
13
|
+
/** Live region: pending stream tail. */
|
|
14
|
+
live: string;
|
|
15
|
+
/** Latest state snapshot (dials). */
|
|
16
|
+
values: Record<string, number>;
|
|
17
|
+
mutationCount: number;
|
|
18
|
+
busy: boolean;
|
|
19
|
+
lastError: string | null;
|
|
20
|
+
}
|
|
21
|
+
export declare function createEngineStore(): {
|
|
22
|
+
store: import("zustand/vanilla").StoreApi<EngineUiState>;
|
|
23
|
+
onEvent: (e: EventMsg) => void;
|
|
24
|
+
flushTokens: () => void;
|
|
25
|
+
};
|
|
26
|
+
export type EngineStore = ReturnType<typeof createEngineStore>;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine store (FR.3): the core EventBus — via the protocol seam — stays the
|
|
3
|
+
* SOURCE OF TRUTH; this thin zustand store only adapts events for React
|
|
4
|
+
* consumption (frame-batched token deltas, committed transcript, dials).
|
|
5
|
+
*/
|
|
6
|
+
import { createStore } from "zustand/vanilla";
|
|
7
|
+
import { CommitQueue } from "./streaming/commit-queue.js";
|
|
8
|
+
const initial = {
|
|
9
|
+
connected: false,
|
|
10
|
+
personaName: "",
|
|
11
|
+
mode: "locked",
|
|
12
|
+
committed: [],
|
|
13
|
+
live: "",
|
|
14
|
+
values: {},
|
|
15
|
+
mutationCount: 0,
|
|
16
|
+
busy: false,
|
|
17
|
+
lastError: null,
|
|
18
|
+
};
|
|
19
|
+
export function createEngineStore() {
|
|
20
|
+
const queue = new CommitQueue();
|
|
21
|
+
// Frame-batching: token deltas accumulate here and land in the store at most
|
|
22
|
+
// once per animation frame — a per-token setState would thrash the renderer.
|
|
23
|
+
let pendingTokens = "";
|
|
24
|
+
let scheduled = false;
|
|
25
|
+
const store = createStore(() => ({ ...initial }));
|
|
26
|
+
const flushTokens = () => {
|
|
27
|
+
scheduled = false;
|
|
28
|
+
if (!pendingTokens)
|
|
29
|
+
return;
|
|
30
|
+
const text = pendingTokens;
|
|
31
|
+
pendingTokens = "";
|
|
32
|
+
const newlyCommitted = queue.push(text);
|
|
33
|
+
store.setState((s) => ({
|
|
34
|
+
committed: newlyCommitted.length > 0 ? [...s.committed, ...newlyCommitted] : s.committed,
|
|
35
|
+
live: queue.pending(),
|
|
36
|
+
}));
|
|
37
|
+
};
|
|
38
|
+
const schedule = () => {
|
|
39
|
+
if (scheduled)
|
|
40
|
+
return;
|
|
41
|
+
scheduled = true;
|
|
42
|
+
setTimeout(flushTokens, 16); // ~one frame
|
|
43
|
+
};
|
|
44
|
+
const onEvent = (e) => {
|
|
45
|
+
switch (e.event) {
|
|
46
|
+
case "session.configured":
|
|
47
|
+
store.setState({ connected: true, personaName: e.persona.name, mode: e.mode });
|
|
48
|
+
return;
|
|
49
|
+
case "turn.started":
|
|
50
|
+
store.setState({ busy: true, lastError: null });
|
|
51
|
+
return;
|
|
52
|
+
case "token.delta":
|
|
53
|
+
pendingTokens += e.text;
|
|
54
|
+
schedule();
|
|
55
|
+
return;
|
|
56
|
+
case "turn.completed": {
|
|
57
|
+
flushTokens();
|
|
58
|
+
const rest = queue.flush();
|
|
59
|
+
store.setState((s) => ({
|
|
60
|
+
busy: false,
|
|
61
|
+
committed: rest.length > 0 ? [...s.committed, ...rest] : s.committed,
|
|
62
|
+
live: "",
|
|
63
|
+
}));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
case "state.snapshot":
|
|
67
|
+
store.setState({ values: e.values, mutationCount: e.mutationCount });
|
|
68
|
+
return;
|
|
69
|
+
case "error":
|
|
70
|
+
store.setState({ lastError: e.message, busy: false });
|
|
71
|
+
return;
|
|
72
|
+
case "engine.event":
|
|
73
|
+
case "approval.requested":
|
|
74
|
+
// Rendered by dedicated consumers (activity feed, approval FSM — FR.10).
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
return { store, onEvent, flushTokens };
|
|
79
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newline-gated markdown commit queue (Codex tui/src/streaming pattern).
|
|
3
|
+
*
|
|
4
|
+
* Streaming tokens land in a live region; a line is COMMITTED (moved to the
|
|
5
|
+
* <Static> transcript, i.e. native scrollback, never re-rendered) only when it
|
|
6
|
+
* can no longer change visually:
|
|
7
|
+
* - the line is complete (a newline followed it), AND
|
|
8
|
+
* - it is not inside an open ``` fence (the fence renders as one block), AND
|
|
9
|
+
* - it is not part of a markdown table that may still be growing
|
|
10
|
+
* (TABLE-HOLDBACK: a table is committed only when a non-table line — or
|
|
11
|
+
* flush() — proves it is finished; committing row-by-row breaks alignment).
|
|
12
|
+
*
|
|
13
|
+
* Pure logic, no Ink dependency — unit-testable and reusable by any front-end.
|
|
14
|
+
*/
|
|
15
|
+
export declare class CommitQueue {
|
|
16
|
+
private buffer;
|
|
17
|
+
/** Complete lines that are held back (open fence or growing table). */
|
|
18
|
+
private held;
|
|
19
|
+
private inFence;
|
|
20
|
+
/** Feed streamed text. Returns the lines that became committable NOW. */
|
|
21
|
+
push(text: string): string[];
|
|
22
|
+
/** The not-yet-committed tail (held lines + partial line) for the live region. */
|
|
23
|
+
pending(): string;
|
|
24
|
+
/** End of stream: everything pending commits as-is (fence closed implicitly). */
|
|
25
|
+
flush(): string[];
|
|
26
|
+
private acceptLine;
|
|
27
|
+
private isFenceDelimiter;
|
|
28
|
+
private isTableLine;
|
|
29
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newline-gated markdown commit queue (Codex tui/src/streaming pattern).
|
|
3
|
+
*
|
|
4
|
+
* Streaming tokens land in a live region; a line is COMMITTED (moved to the
|
|
5
|
+
* <Static> transcript, i.e. native scrollback, never re-rendered) only when it
|
|
6
|
+
* can no longer change visually:
|
|
7
|
+
* - the line is complete (a newline followed it), AND
|
|
8
|
+
* - it is not inside an open ``` fence (the fence renders as one block), AND
|
|
9
|
+
* - it is not part of a markdown table that may still be growing
|
|
10
|
+
* (TABLE-HOLDBACK: a table is committed only when a non-table line — or
|
|
11
|
+
* flush() — proves it is finished; committing row-by-row breaks alignment).
|
|
12
|
+
*
|
|
13
|
+
* Pure logic, no Ink dependency — unit-testable and reusable by any front-end.
|
|
14
|
+
*/
|
|
15
|
+
export class CommitQueue {
|
|
16
|
+
buffer = "";
|
|
17
|
+
/** Complete lines that are held back (open fence or growing table). */
|
|
18
|
+
held = [];
|
|
19
|
+
inFence = false;
|
|
20
|
+
/** Feed streamed text. Returns the lines that became committable NOW. */
|
|
21
|
+
push(text) {
|
|
22
|
+
this.buffer += text;
|
|
23
|
+
const committed = [];
|
|
24
|
+
let nl;
|
|
25
|
+
while ((nl = this.buffer.indexOf("\n")) !== -1) {
|
|
26
|
+
const line = this.buffer.slice(0, nl);
|
|
27
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
28
|
+
committed.push(...this.acceptLine(line));
|
|
29
|
+
}
|
|
30
|
+
return committed;
|
|
31
|
+
}
|
|
32
|
+
/** The not-yet-committed tail (held lines + partial line) for the live region. */
|
|
33
|
+
pending() {
|
|
34
|
+
const parts = [...this.held];
|
|
35
|
+
if (this.buffer.length > 0)
|
|
36
|
+
parts.push(this.buffer);
|
|
37
|
+
return parts.join("\n");
|
|
38
|
+
}
|
|
39
|
+
/** End of stream: everything pending commits as-is (fence closed implicitly). */
|
|
40
|
+
flush() {
|
|
41
|
+
const out = [...this.held];
|
|
42
|
+
if (this.buffer.length > 0)
|
|
43
|
+
out.push(this.buffer);
|
|
44
|
+
this.held = [];
|
|
45
|
+
this.buffer = "";
|
|
46
|
+
this.inFence = false;
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
// ── internals ───────────────────────────────────────────────────────────────
|
|
50
|
+
acceptLine(line) {
|
|
51
|
+
if (this.isFenceDelimiter(line)) {
|
|
52
|
+
if (!this.inFence) {
|
|
53
|
+
// Fence OPENS: hold from here — the block renders atomically.
|
|
54
|
+
this.inFence = true;
|
|
55
|
+
this.held.push(line);
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
// Fence CLOSES: the whole block is final.
|
|
59
|
+
this.inFence = false;
|
|
60
|
+
this.held.push(line);
|
|
61
|
+
const block = this.held;
|
|
62
|
+
this.held = [];
|
|
63
|
+
return block;
|
|
64
|
+
}
|
|
65
|
+
if (this.inFence) {
|
|
66
|
+
this.held.push(line);
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
if (this.isTableLine(line)) {
|
|
70
|
+
// Table row: hold — the next line may be another row.
|
|
71
|
+
this.held.push(line);
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
// A non-table, non-fence line: any held table is now provably complete.
|
|
75
|
+
const out = this.held.length > 0 ? [...this.held, line] : [line];
|
|
76
|
+
this.held = [];
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
isFenceDelimiter(line) {
|
|
80
|
+
return /^\s*(```|~~~)/.test(line);
|
|
81
|
+
}
|
|
82
|
+
isTableLine(line) {
|
|
83
|
+
const t = line.trimEnd();
|
|
84
|
+
return t.startsWith("|") && t.endsWith("|") && t.length >= 2;
|
|
85
|
+
}
|
|
86
|
+
}
|
package/dist/visual.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single visual engine for personaxis (plan/09-ascii-ux).
|
|
3
|
+
*
|
|
4
|
+
* One place for ALL terminal visuals — the animated wordmark, a persona's
|
|
5
|
+
* "awakening", its themed + animated sigil, the live aura, per-event flourishes,
|
|
6
|
+
* and voice-styled output. Everything is driven by the persona's PersonaTheme, so
|
|
7
|
+
* each persona looks AND behaves differently in the terminal. Reused by both the
|
|
8
|
+
* REPL (@personaxis/cli) and the dashboard (this package).
|
|
9
|
+
*
|
|
10
|
+
* Animations only play on a real TTY; piped/CI output prints the final frame once.
|
|
11
|
+
*/
|
|
12
|
+
import { type PersonaTheme, type PersonaFrontmatter, type StateFile, type LoopEvent } from "@personaxis/core";
|
|
13
|
+
export declare const supportsAnim: () => boolean;
|
|
14
|
+
/** Compose a word from FONT, row by row. Letters it doesn't know become spaces. */
|
|
15
|
+
export declare function renderWordmark(word: string): string[];
|
|
16
|
+
export declare const LOGO: string[];
|
|
17
|
+
/** A quiet, premium reveal: the emblem settles, the wordmark wipes in once. Monochrome.
|
|
18
|
+
* Responsive: falls back to a one-line mark when the terminal is narrower than the block. */
|
|
19
|
+
export declare function animateLogo(): Promise<void>;
|
|
20
|
+
/** Colored, themed sigil for one frame. */
|
|
21
|
+
export declare function sigilLines(theme: PersonaTheme, values: Record<string, number>, frame?: number): string[];
|
|
22
|
+
/** The persona materializing — sparse → full over a few frames. */
|
|
23
|
+
export declare function awaken(fm: PersonaFrontmatter, state: StateFile): Promise<void>;
|
|
24
|
+
/** A colored aura/mood gauge for the prompt + dashboard. */
|
|
25
|
+
export declare function auraBar(theme: PersonaTheme, values: Record<string, number>, frame?: number): string;
|
|
26
|
+
/** Envelope bars colored in the persona's palette. */
|
|
27
|
+
export declare function envelopeBars(theme: PersonaTheme, values: Record<string, number>, envelopes: Record<string, {
|
|
28
|
+
min: number;
|
|
29
|
+
max: number;
|
|
30
|
+
}>, width?: number): string;
|
|
31
|
+
/** Per-event flourish — themed glyphs + color, distinct per event kind. */
|
|
32
|
+
export declare function eventLine(theme: PersonaTheme, e: LoopEvent): string | null;
|
|
33
|
+
/** Style a line of output to the persona's voice. */
|
|
34
|
+
export declare function voiceWrap(theme: PersonaTheme, text: string): string;
|
|
35
|
+
export declare function farewell(fm: PersonaFrontmatter): Promise<void>;
|
|
36
|
+
/** Loop a renderer for `frames` at `interval`ms (TTY only), clearing each frame. */
|
|
37
|
+
export declare function play(render: (frame: number) => string, frames: number, interval: number): Promise<void>;
|
package/dist/visual.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single visual engine for personaxis (plan/09-ascii-ux).
|
|
3
|
+
*
|
|
4
|
+
* One place for ALL terminal visuals — the animated wordmark, a persona's
|
|
5
|
+
* "awakening", its themed + animated sigil, the live aura, per-event flourishes,
|
|
6
|
+
* and voice-styled output. Everything is driven by the persona's PersonaTheme, so
|
|
7
|
+
* each persona looks AND behaves differently in the terminal. Reused by both the
|
|
8
|
+
* REPL (@personaxis/cli) and the dashboard (this package).
|
|
9
|
+
*
|
|
10
|
+
* Animations only play on a real TTY; piped/CI output prints the final frame once.
|
|
11
|
+
*/
|
|
12
|
+
import chalk from "chalk";
|
|
13
|
+
import { personaTheme, renderThemedSigil, themeIntensity, barIndex, displayName, } from "@personaxis/core";
|
|
14
|
+
export const supportsAnim = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && process.env.PERSONAXIS_NO_ANIM !== "1";
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
const write = (s) => void process.stdout.write(s);
|
|
17
|
+
// A robust 5-row block font (only █ and space → renders identically in every
|
|
18
|
+
// monospace terminal; no slant/underscore tricks that collapsed into "eersonaxis").
|
|
19
|
+
const FONT = {
|
|
20
|
+
p: ["█████", "█ █", "█████", "█ ", "█ "],
|
|
21
|
+
e: ["█████", "█ ", "███ ", "█ ", "█████"],
|
|
22
|
+
r: ["█████", "█ █", "█████", "█ █ ", "█ █"],
|
|
23
|
+
s: ["█████", "█ ", "█████", " █", "█████"],
|
|
24
|
+
o: ["█████", "█ █", "█ █", "█ █", "█████"],
|
|
25
|
+
n: ["█ █", "██ █", "█ █ █", "█ ██", "█ █"],
|
|
26
|
+
a: ["█████", "█ █", "█████", "█ █", "█ █"],
|
|
27
|
+
x: ["█ █", " █ █ ", " █ ", " █ █ ", "█ █"],
|
|
28
|
+
i: ["█████", " █ ", " █ ", " █ ", "█████"],
|
|
29
|
+
};
|
|
30
|
+
/** Compose a word from FONT, row by row. Letters it doesn't know become spaces. */
|
|
31
|
+
export function renderWordmark(word) {
|
|
32
|
+
const rows = ["", "", "", "", ""];
|
|
33
|
+
for (const ch of word.toLowerCase()) {
|
|
34
|
+
const g = FONT[ch] ?? [" ", " ", " ", " ", " "];
|
|
35
|
+
for (let r = 0; r < 5; r++)
|
|
36
|
+
rows[r] += g[r] + " ";
|
|
37
|
+
}
|
|
38
|
+
return rows;
|
|
39
|
+
}
|
|
40
|
+
// The brand mark from logo.svg: a radiating sun/sigil with a bright core.
|
|
41
|
+
const EMBLEM = [
|
|
42
|
+
" · ✶ · ",
|
|
43
|
+
" ╲ ╲│╱ ╱ ",
|
|
44
|
+
" ✶ ── ◉ ── ✶ ",
|
|
45
|
+
" ╱ ╱│╲ ╲ ",
|
|
46
|
+
" · ✶ · ",
|
|
47
|
+
];
|
|
48
|
+
export const LOGO = renderWordmark("personaxis");
|
|
49
|
+
// Monochrome: the terminal's DEFAULT foreground (bold) adapts to light/dark themes
|
|
50
|
+
// automatically — dark on a light terminal, light on a dark one. No color.
|
|
51
|
+
const TAGLINE = chalk.dim(" the home of living, governed AI personas · ") + chalk.bold("/help");
|
|
52
|
+
const word = (l) => chalk.bold(l);
|
|
53
|
+
/** Paint the emblem; `bright` controls the core (used for a single subtle pulse). */
|
|
54
|
+
function paintEmblem(bright) {
|
|
55
|
+
const core = "◉";
|
|
56
|
+
return EMBLEM.map((line) => {
|
|
57
|
+
let out = "";
|
|
58
|
+
for (const ch of line) {
|
|
59
|
+
if (ch === core)
|
|
60
|
+
out += bright ? chalk.bold(ch) : chalk.dim(ch);
|
|
61
|
+
else if (ch === " ")
|
|
62
|
+
out += " ";
|
|
63
|
+
else
|
|
64
|
+
out += chalk.dim(ch);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}).join("\n");
|
|
68
|
+
}
|
|
69
|
+
/** Compact single-line logo for narrow terminals (the block wordmark would wrap + break). */
|
|
70
|
+
function compactLogo() {
|
|
71
|
+
return chalk.bold("◉ personaxis") + chalk.dim(" · living, governed AI personas");
|
|
72
|
+
}
|
|
73
|
+
/** A quiet, premium reveal: the emblem settles, the wordmark wipes in once. Monochrome.
|
|
74
|
+
* Responsive: falls back to a one-line mark when the terminal is narrower than the block. */
|
|
75
|
+
export async function animateLogo() {
|
|
76
|
+
const cols = process.stdout.columns ?? 80;
|
|
77
|
+
if (cols < LOGO[0].length + 2) {
|
|
78
|
+
write("\n" + compactLogo() + "\n\n");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!supportsAnim()) {
|
|
82
|
+
write("\n" + paintEmblem(true) + "\n\n" + LOGO.map(word).join("\n") + "\n" + TAGLINE + "\n\n");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
write("\n");
|
|
86
|
+
// Emblem: a single gentle pulse on the core (dim → bright), not a loop.
|
|
87
|
+
for (const bright of [false, true]) {
|
|
88
|
+
write("\x1b[s" + paintEmblem(bright) + "\x1b[u");
|
|
89
|
+
await sleep(120);
|
|
90
|
+
}
|
|
91
|
+
write(paintEmblem(true) + "\n\n");
|
|
92
|
+
// Wordmark: left-to-right wipe, revealed once, then static.
|
|
93
|
+
const width = LOGO[0].length;
|
|
94
|
+
for (let w = 4; w <= width; w += 4) {
|
|
95
|
+
write(`\x1b[s`);
|
|
96
|
+
for (const line of LOGO)
|
|
97
|
+
write("\x1b[2K" + word(line.slice(0, w)) + "\n");
|
|
98
|
+
write("\x1b[u");
|
|
99
|
+
await sleep(28);
|
|
100
|
+
}
|
|
101
|
+
for (const line of LOGO)
|
|
102
|
+
write("\x1b[2K" + word(line) + "\n");
|
|
103
|
+
write(TAGLINE + "\n\n");
|
|
104
|
+
}
|
|
105
|
+
function paintGlyphRow(theme, row) {
|
|
106
|
+
const { primary, secondary, accent } = theme.palette;
|
|
107
|
+
let out = "";
|
|
108
|
+
for (const ch of row) {
|
|
109
|
+
const idx = theme.glyphs.indexOf(ch);
|
|
110
|
+
if (ch === " " || idx <= 0)
|
|
111
|
+
out += " ";
|
|
112
|
+
else if (idx <= 2)
|
|
113
|
+
out += chalk.ansi256(secondary)(ch);
|
|
114
|
+
else if (idx <= 4)
|
|
115
|
+
out += chalk.ansi256(primary)(ch);
|
|
116
|
+
else
|
|
117
|
+
out += chalk.ansi256(accent).bold(ch);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** Colored, themed sigil for one frame. */
|
|
122
|
+
export function sigilLines(theme, values, frame = 0) {
|
|
123
|
+
const sig = renderThemedSigil(theme, values, frame);
|
|
124
|
+
return sig.grid.map((row) => " " + paintGlyphRow(theme, row));
|
|
125
|
+
}
|
|
126
|
+
/** The persona materializing — sparse → full over a few frames. */
|
|
127
|
+
export async function awaken(fm, state) {
|
|
128
|
+
const theme = personaTheme(fm);
|
|
129
|
+
const name = displayName(fm);
|
|
130
|
+
write(" " + chalk.bold.ansi256(theme.palette.accent)(name) + chalk.dim(` · sigil #${theme.seed.toString(16)}\n\n`));
|
|
131
|
+
const lines = sigilLines(theme, state.values, 0);
|
|
132
|
+
if (!supportsAnim()) {
|
|
133
|
+
write(lines.join("\n") + "\n\n");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// reveal mask: rows appear from the center outward
|
|
137
|
+
const order = [...lines.keys()].sort((a, b) => Math.abs(a - lines.length / 2) - Math.abs(b - lines.length / 2));
|
|
138
|
+
const shown = new Set();
|
|
139
|
+
for (const idx of order) {
|
|
140
|
+
shown.add(idx);
|
|
141
|
+
write("\x1b[s"); // save cursor
|
|
142
|
+
for (let i = 0; i < lines.length; i++)
|
|
143
|
+
write("\x1b[2K" + (shown.has(i) ? lines[i] : "") + "\n");
|
|
144
|
+
write("\x1b[u"); // restore cursor
|
|
145
|
+
await sleep(60);
|
|
146
|
+
}
|
|
147
|
+
write("\x1b[" + lines.length + "B");
|
|
148
|
+
write("\n");
|
|
149
|
+
}
|
|
150
|
+
/** A colored aura/mood gauge for the prompt + dashboard. */
|
|
151
|
+
export function auraBar(theme, values, frame = 0) {
|
|
152
|
+
const intensity = themeIntensity(theme, values, frame);
|
|
153
|
+
const ticks = 11;
|
|
154
|
+
const lit = Math.round(intensity * (ticks - 1));
|
|
155
|
+
let bar = "";
|
|
156
|
+
for (let i = 0; i < ticks; i++) {
|
|
157
|
+
bar += i <= lit ? chalk.ansi256(theme.palette.primary)("◈") : chalk.ansi256(theme.palette.dim)("·");
|
|
158
|
+
}
|
|
159
|
+
return bar;
|
|
160
|
+
}
|
|
161
|
+
/** Envelope bars colored in the persona's palette. */
|
|
162
|
+
export function envelopeBars(theme, values, envelopes, width = 18) {
|
|
163
|
+
const rows = [];
|
|
164
|
+
for (const [k, v] of Object.entries(values)) {
|
|
165
|
+
const e = envelopes[k];
|
|
166
|
+
if (!e)
|
|
167
|
+
continue;
|
|
168
|
+
const pos = barIndex(v, { ...e, mean: 0 }, width);
|
|
169
|
+
let bar = "";
|
|
170
|
+
for (let i = 0; i < width; i++)
|
|
171
|
+
bar += i === pos ? chalk.ansi256(theme.palette.accent)("◉") : chalk.ansi256(theme.palette.dim)("─");
|
|
172
|
+
rows.push(` ${k.padEnd(28)} ${bar} ${chalk.dim(v.toFixed(2))}`);
|
|
173
|
+
}
|
|
174
|
+
return rows.join("\n");
|
|
175
|
+
}
|
|
176
|
+
/** Per-event flourish — themed glyphs + color, distinct per event kind. */
|
|
177
|
+
export function eventLine(theme, e) {
|
|
178
|
+
const p = (n) => chalk.ansi256(n);
|
|
179
|
+
const trunc = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
180
|
+
switch (e.type) {
|
|
181
|
+
case "observe":
|
|
182
|
+
return chalk.dim(` ◌ observe [${e.source}] ${trunc(e.observation, 66)}`);
|
|
183
|
+
case "appraise":
|
|
184
|
+
return p(theme.palette.secondary)(` ◍ appraise `) + chalk.dim(`${trunc(e.signal.appraisal, 60)} (conf ${e.signal.confidence.toFixed(2)})`);
|
|
185
|
+
case "govern": {
|
|
186
|
+
const ok = e.verdicts.filter((v) => v.admitted).length;
|
|
187
|
+
return chalk.dim(` ◇ govern ${ok} admitted, ${e.verdicts.length - ok} rejected`);
|
|
188
|
+
}
|
|
189
|
+
case "mutate": {
|
|
190
|
+
const r = e.result;
|
|
191
|
+
const ripple = p(theme.palette.accent)("◦○◉○◦");
|
|
192
|
+
return ` ${ripple} ${chalk.bold(r.entry.field)} ${r.from.toFixed(3)}→${r.to.toFixed(3)}` +
|
|
193
|
+
(r.clamped ? chalk.yellow(" clamped") : "") + (r.blocked ? chalk.red(" blocked") : "");
|
|
194
|
+
}
|
|
195
|
+
case "memory":
|
|
196
|
+
return p(theme.palette.primary)(` ✶ memory `) + chalk.dim(`[${e.entry.source}] ${trunc(e.entry.content, 52)} #${e.entry.hash.slice(0, 8)}`);
|
|
197
|
+
case "anomaly":
|
|
198
|
+
return chalk.bgRed.whiteBright(` ! ${e.kind} `) + chalk.red(` ${e.detail}`);
|
|
199
|
+
case "recompile":
|
|
200
|
+
return p(theme.palette.secondary)(` ↻ live-sync ${e.reason}`);
|
|
201
|
+
case "abstain":
|
|
202
|
+
return chalk.yellow(` ⊘ abstain ${e.reason}`);
|
|
203
|
+
case "error":
|
|
204
|
+
return chalk.red(` ✗ ${e.message}`);
|
|
205
|
+
case "tick-complete":
|
|
206
|
+
return chalk.dim(` ─ ${e.mutationsApplied} mutation(s), ${e.memoriesWritten} memory write(s)`);
|
|
207
|
+
default:
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Style a line of output to the persona's voice. */
|
|
212
|
+
export function voiceWrap(theme, text) {
|
|
213
|
+
switch (theme.voice.density) {
|
|
214
|
+
case "terse":
|
|
215
|
+
return chalk.ansi256(theme.palette.dim)(text);
|
|
216
|
+
case "expansive":
|
|
217
|
+
return chalk.ansi256(theme.palette.accent)("◇ ") + chalk.ansi256(theme.palette.primary)(text);
|
|
218
|
+
default:
|
|
219
|
+
return chalk.ansi256(theme.palette.primary)(text);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
export async function farewell(fm) {
|
|
223
|
+
const theme = personaTheme(fm);
|
|
224
|
+
write("\n" + chalk.ansi256(theme.palette.dim)(" persona sleeping. state + memory persisted.") + "\n");
|
|
225
|
+
}
|
|
226
|
+
/** Loop a renderer for `frames` at `interval`ms (TTY only), clearing each frame. */
|
|
227
|
+
export async function play(render, frames, interval) {
|
|
228
|
+
if (!supportsAnim()) {
|
|
229
|
+
write(render(0) + "\n");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
for (let f = 0; f < frames; f++) {
|
|
233
|
+
write("\x1b[2J\x1b[H" + render(f));
|
|
234
|
+
await sleep(interval);
|
|
235
|
+
}
|
|
236
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@personaxis/tui",
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "Personaxis TUI — Ink 7 components (living dashboard, streaming transcript with commit queue, brand sigil/aura) + the pre-Ink REPL line editor (until F3.6).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./visual": "./dist/visual.js",
|
|
11
|
+
"./screen": "./dist/screen.js",
|
|
12
|
+
"./ink": "./dist/ink.js"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"personaxis-dash": "dist/bin.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"chalk": "^5.4.1",
|
|
22
|
+
"ink": "^7.1.0",
|
|
23
|
+
"react": "^19.0.0",
|
|
24
|
+
"marked": "^15.0.0",
|
|
25
|
+
"marked-terminal": "^7.3.0",
|
|
26
|
+
"ink-text-input": "^6.0.0",
|
|
27
|
+
"zustand": "^5.0.0",
|
|
28
|
+
"diff": "^7.0.0",
|
|
29
|
+
"@personaxis/core": "0.11.0",
|
|
30
|
+
"@personaxis/protocol": "0.11.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22.10.0",
|
|
34
|
+
"typescript": "^5.8.3",
|
|
35
|
+
"vitest": "^3.0.0",
|
|
36
|
+
"@types/react": "^19.0.0",
|
|
37
|
+
"@types/diff": "^7.0.0",
|
|
38
|
+
"ink-testing-library": "^4.0.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/personaxis/cli.git",
|
|
46
|
+
"directory": "packages/tui"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc",
|
|
50
|
+
"lint": "tsc --noEmit",
|
|
51
|
+
"test": "vitest run"
|
|
52
|
+
}
|
|
53
|
+
}
|