@zhijiewang/openharness 0.12.1 → 1.0.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/README.md +43 -0
- package/dist/commands/index.js +96 -6
- package/dist/components/InitWizard.js +84 -5
- package/dist/renderer/index.d.ts +7 -0
- package/dist/renderer/index.js +33 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -611,6 +611,49 @@ Create `.oh/RULES.md` in any repo (or run `oh init`):
|
|
|
611
611
|
|
|
612
612
|
Rules load automatically into every session.
|
|
613
613
|
|
|
614
|
+
## Skills & Plugins
|
|
615
|
+
|
|
616
|
+
### Skills
|
|
617
|
+
|
|
618
|
+
Skills are markdown files with YAML frontmatter that add reusable behaviors:
|
|
619
|
+
|
|
620
|
+
```markdown
|
|
621
|
+
---
|
|
622
|
+
name: deploy
|
|
623
|
+
description: Deploy the application to production
|
|
624
|
+
trigger: deploy
|
|
625
|
+
tools: [Bash, Read]
|
|
626
|
+
---
|
|
627
|
+
|
|
628
|
+
Run the deploy script with health checks...
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
**Locations** (searched in order):
|
|
632
|
+
1. `.oh/skills/` — project-level skills
|
|
633
|
+
2. `~/.oh/skills/` — global skills (available in all projects)
|
|
634
|
+
|
|
635
|
+
Skills auto-trigger when the user's message contains the trigger keyword, or can be invoked explicitly with `/skill deploy`.
|
|
636
|
+
|
|
637
|
+
### Plugins
|
|
638
|
+
|
|
639
|
+
Plugins are npm packages that bundle skills, hooks, and MCP servers:
|
|
640
|
+
|
|
641
|
+
```json
|
|
642
|
+
{
|
|
643
|
+
"name": "my-openharness-plugin",
|
|
644
|
+
"version": "1.0.0",
|
|
645
|
+
"skills": ["skills/deploy.md", "skills/review.md"],
|
|
646
|
+
"hooks": {
|
|
647
|
+
"sessionStart": "scripts/setup.sh"
|
|
648
|
+
},
|
|
649
|
+
"mcpServers": [
|
|
650
|
+
{ "name": "my-api", "command": "npx", "args": ["-y", "@my-org/mcp-server"] }
|
|
651
|
+
]
|
|
652
|
+
}
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
Save as `openharness-plugin.json` in your npm package root. Install with `npm install`, and openHarness discovers it automatically from `node_modules/`.
|
|
656
|
+
|
|
614
657
|
## How It Works
|
|
615
658
|
|
|
616
659
|
```mermaid
|
package/dist/commands/index.js
CHANGED
|
@@ -473,18 +473,61 @@ register("doctor", "Run diagnostic health checks", (_args, ctx) => {
|
|
|
473
473
|
lines.push(` Session: ${ctx.sessionId}`);
|
|
474
474
|
lines.push(` Messages: ${ctx.messages.length}`);
|
|
475
475
|
lines.push(` Cost: $${ctx.totalCost.toFixed(4)}`);
|
|
476
|
-
// Disk space
|
|
476
|
+
// Disk space & storage
|
|
477
477
|
try {
|
|
478
|
-
const { statSync } = require("node:fs");
|
|
479
478
|
const ohDir = join(homedir(), ".oh");
|
|
480
479
|
if (existsSync(ohDir)) {
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
480
|
+
const sessionsDir = join(ohDir, "sessions");
|
|
481
|
+
const sessCount = existsSync(sessionsDir) ? readdirSync(sessionsDir).filter(f => f.endsWith('.json')).length : 0;
|
|
482
|
+
lines.push(` Sessions: ${sessCount} saved`);
|
|
483
|
+
if (sessCount > 80)
|
|
484
|
+
issues.push(`${sessCount} saved sessions. Consider cleaning old ones.`);
|
|
485
|
+
// Memory stats
|
|
486
|
+
const memDir = join(ohDir, "memory");
|
|
487
|
+
const memCount = existsSync(memDir) ? readdirSync(memDir).filter(f => f.endsWith('.md')).length : 0;
|
|
488
|
+
lines.push(` Memories: ${memCount} global`);
|
|
489
|
+
// Cron stats
|
|
490
|
+
const cronDir = join(ohDir, "crons");
|
|
491
|
+
const cronCount = existsSync(cronDir) ? readdirSync(cronDir).filter(f => f.endsWith('.json')).length : 0;
|
|
492
|
+
lines.push(` Cron tasks: ${cronCount}`);
|
|
485
493
|
}
|
|
486
494
|
}
|
|
487
495
|
catch { /* ignore */ }
|
|
496
|
+
// Project-level stats
|
|
497
|
+
try {
|
|
498
|
+
const projMemDir = join(".oh", "memory");
|
|
499
|
+
const projMemCount = existsSync(projMemDir) ? readdirSync(projMemDir).filter(f => f.endsWith('.md')).length : 0;
|
|
500
|
+
if (projMemCount > 0)
|
|
501
|
+
lines.push(` Project mems: ${projMemCount}`);
|
|
502
|
+
const skillsDir = join(".oh", "skills");
|
|
503
|
+
const skillCount = existsSync(skillsDir) ? readdirSync(skillsDir).filter(f => f.endsWith('.md')).length : 0;
|
|
504
|
+
if (skillCount > 0)
|
|
505
|
+
lines.push(` Skills: ${skillCount}`);
|
|
506
|
+
}
|
|
507
|
+
catch { /* ignore */ }
|
|
508
|
+
// Global config
|
|
509
|
+
const globalCfg = existsSync(join(homedir(), ".oh", "config.yaml"));
|
|
510
|
+
lines.push(` Global config: ${globalCfg ? "~/.oh/config.yaml ✓" : "not set (optional)"}`);
|
|
511
|
+
// Verification config
|
|
512
|
+
try {
|
|
513
|
+
const { getVerificationConfig } = require('../harness/verification.js');
|
|
514
|
+
const vCfg = getVerificationConfig();
|
|
515
|
+
if (vCfg?.enabled) {
|
|
516
|
+
lines.push(` Verification: ✓ (${vCfg.rules.length} rules, mode: ${vCfg.mode})`);
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
lines.push(` Verification: off (no rules detected)`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch { /* ignore */ }
|
|
523
|
+
// Tools
|
|
524
|
+
lines.push("");
|
|
525
|
+
lines.push(` Tools: ${ctx.messages.length > 0 ? 'ready' : 'loaded'}`);
|
|
526
|
+
// Node.js version
|
|
527
|
+
lines.push(` Node.js: ${process.version}`);
|
|
528
|
+
const [major] = process.version.slice(1).split('.').map(Number);
|
|
529
|
+
if (major && major < 18)
|
|
530
|
+
issues.push(`Node.js ${process.version} is below minimum (18+). Upgrade Node.js.`);
|
|
488
531
|
// Issues summary
|
|
489
532
|
if (issues.length > 0) {
|
|
490
533
|
lines.push("");
|
|
@@ -571,6 +614,53 @@ function setPinned(args, ctx, pinned) {
|
|
|
571
614
|
}
|
|
572
615
|
register("pin", "Pin a message (survives /compact)", (args, ctx) => setPinned(args, ctx, true));
|
|
573
616
|
register("unpin", "Unpin a message", (args, ctx) => setPinned(args, ctx, false));
|
|
617
|
+
register("plugins", "List installed plugins and discover new ones", (args) => {
|
|
618
|
+
const { discoverPlugins, discoverSkills } = require('../harness/plugins.js');
|
|
619
|
+
const query = args.trim();
|
|
620
|
+
if (query === 'search' || query.startsWith('search ')) {
|
|
621
|
+
// npm registry search
|
|
622
|
+
const keyword = query.replace(/^search\s*/, '').trim() || 'openharness-plugin';
|
|
623
|
+
return {
|
|
624
|
+
output: `To discover plugins, search npm:\n\n npm search openharness-plugin${keyword !== 'openharness-plugin' ? ' ' + keyword : ''}\n\nInstall with:\n npm install <package-name>\n\nPlugins are auto-discovered from node_modules/ if they contain openharness-plugin.json.`,
|
|
625
|
+
handled: true,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
// List installed
|
|
629
|
+
const plugins = discoverPlugins();
|
|
630
|
+
const skills = discoverSkills();
|
|
631
|
+
const lines = [];
|
|
632
|
+
if (plugins.length > 0) {
|
|
633
|
+
lines.push(`Installed Plugins (${plugins.length}):`);
|
|
634
|
+
for (const p of plugins) {
|
|
635
|
+
lines.push(` ${p.name}@${p.version} — ${p.description || 'no description'}`);
|
|
636
|
+
if (p.skills?.length)
|
|
637
|
+
lines.push(` Skills: ${p.skills.length}`);
|
|
638
|
+
if (p.mcpServers?.length)
|
|
639
|
+
lines.push(` MCP servers: ${p.mcpServers.map((s) => s.name).join(', ')}`);
|
|
640
|
+
}
|
|
641
|
+
lines.push('');
|
|
642
|
+
}
|
|
643
|
+
if (skills.length > 0) {
|
|
644
|
+
lines.push(`Available Skills (${skills.length}):`);
|
|
645
|
+
const bySource = {};
|
|
646
|
+
for (const s of skills) {
|
|
647
|
+
(bySource[s.source] ??= []).push(s);
|
|
648
|
+
}
|
|
649
|
+
for (const [source, sourceSkills] of Object.entries(bySource)) {
|
|
650
|
+
lines.push(` ${source}:`);
|
|
651
|
+
for (const s of sourceSkills) {
|
|
652
|
+
lines.push(` ${s.name} — ${s.description}${s.trigger ? ` (trigger: "${s.trigger}")` : ''}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
else if (plugins.length === 0) {
|
|
657
|
+
lines.push('No plugins or skills installed.');
|
|
658
|
+
lines.push('');
|
|
659
|
+
lines.push('Create skills in .oh/skills/ or ~/.oh/skills/');
|
|
660
|
+
lines.push('Run /plugins search to find npm packages.');
|
|
661
|
+
}
|
|
662
|
+
return { output: lines.join('\n'), handled: true };
|
|
663
|
+
});
|
|
574
664
|
// ── Command Parser ──
|
|
575
665
|
/**
|
|
576
666
|
* Check if input is a slash command. If so, execute it.
|
|
@@ -30,9 +30,30 @@ const PERMISSION_MODES = [
|
|
|
30
30
|
{ key: "trust", label: "trust — auto-approve everything" },
|
|
31
31
|
{ key: "deny", label: "deny — read-only, block write/run tools" },
|
|
32
32
|
];
|
|
33
|
+
/** Auto-detect provider from environment variables */
|
|
34
|
+
function detectProviderFromEnv() {
|
|
35
|
+
if (process.env.ANTHROPIC_API_KEY)
|
|
36
|
+
return PROVIDERS.findIndex(p => p.key === "anthropic");
|
|
37
|
+
if (process.env.OPENAI_API_KEY)
|
|
38
|
+
return PROVIDERS.findIndex(p => p.key === "openai");
|
|
39
|
+
if (process.env.OPENROUTER_API_KEY)
|
|
40
|
+
return PROVIDERS.findIndex(p => p.key === "openrouter");
|
|
41
|
+
return 0; // Default to Ollama
|
|
42
|
+
}
|
|
43
|
+
/** Get the detected API key for a provider */
|
|
44
|
+
function getEnvApiKey(providerKey) {
|
|
45
|
+
const envMap = {
|
|
46
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
47
|
+
openai: 'OPENAI_API_KEY',
|
|
48
|
+
openrouter: 'OPENROUTER_API_KEY',
|
|
49
|
+
};
|
|
50
|
+
const envVar = envMap[providerKey];
|
|
51
|
+
return envVar ? (process.env[envVar] ?? '') : '';
|
|
52
|
+
}
|
|
33
53
|
export default function InitWizard({ onDone }) {
|
|
54
|
+
const detectedIdx = detectProviderFromEnv();
|
|
34
55
|
const [step, setStep] = useState("provider");
|
|
35
|
-
const [providerIdx, setProviderIdx] = useState(
|
|
56
|
+
const [providerIdx, setProviderIdx] = useState(detectedIdx);
|
|
36
57
|
const [apiKey, setApiKey] = useState("");
|
|
37
58
|
const [baseUrl, setBaseUrl] = useState("");
|
|
38
59
|
const [model, setModel] = useState("");
|
|
@@ -43,6 +64,9 @@ export default function InitWizard({ onDone }) {
|
|
|
43
64
|
const [permIdx, setPermIdx] = useState(0);
|
|
44
65
|
const [hatchGotchi, setHatchGotchi] = useState(false);
|
|
45
66
|
const [showSetup, setShowSetup] = useState(false);
|
|
67
|
+
const [suggestedMcp, setSuggestedMcp] = useState([]);
|
|
68
|
+
const [selectedMcp, setSelectedMcp] = useState(new Set());
|
|
69
|
+
const [mcpIdx, setMcpIdx] = useState(0);
|
|
46
70
|
const provider = PROVIDERS[providerIdx];
|
|
47
71
|
// ── Keyboard navigation ──
|
|
48
72
|
useInput(useCallback((input, key) => {
|
|
@@ -54,11 +78,19 @@ export default function InitWizard({ onDone }) {
|
|
|
54
78
|
if (key.return) {
|
|
55
79
|
setBaseUrl(provider.defaultBaseUrl ?? "");
|
|
56
80
|
setModel(provider.defaultModel);
|
|
81
|
+
// Auto-fill API key from environment if available
|
|
82
|
+
const envKey = getEnvApiKey(provider.key);
|
|
83
|
+
if (envKey)
|
|
84
|
+
setApiKey(envKey);
|
|
57
85
|
if (!provider.needsApiKey) {
|
|
58
|
-
// Skip API key, go straight to testing
|
|
59
86
|
runTest(provider, "", provider.defaultBaseUrl ?? "");
|
|
60
87
|
setStep("testing");
|
|
61
88
|
}
|
|
89
|
+
else if (envKey) {
|
|
90
|
+
// Have env API key — skip manual entry, go to testing
|
|
91
|
+
runTest(provider, envKey, provider.defaultBaseUrl ?? "");
|
|
92
|
+
setStep("testing");
|
|
93
|
+
}
|
|
62
94
|
else {
|
|
63
95
|
setStep("apikey");
|
|
64
96
|
}
|
|
@@ -69,8 +101,37 @@ export default function InitWizard({ onDone }) {
|
|
|
69
101
|
setPermIdx(i => Math.max(0, i - 1));
|
|
70
102
|
if (key.downArrow)
|
|
71
103
|
setPermIdx(i => Math.min(PERMISSION_MODES.length - 1, i + 1));
|
|
104
|
+
if (key.return) {
|
|
105
|
+
// Suggest popular MCP servers
|
|
106
|
+
setSuggestedMcp(['github', 'memory', 'fetch', 'sequential-thinking', 'brave-search']);
|
|
107
|
+
setMcpIdx(0);
|
|
108
|
+
setSelectedMcp(new Set());
|
|
109
|
+
setStep("mcp");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (step === "mcp") {
|
|
113
|
+
if (key.upArrow)
|
|
114
|
+
setMcpIdx(i => Math.max(0, i - 1));
|
|
115
|
+
if (key.downArrow)
|
|
116
|
+
setMcpIdx(i => Math.min(suggestedMcp.length - 1, i + 1));
|
|
117
|
+
if (input === " ") {
|
|
118
|
+
// Toggle selection
|
|
119
|
+
const name = suggestedMcp[mcpIdx];
|
|
120
|
+
if (name) {
|
|
121
|
+
setSelectedMcp(prev => {
|
|
122
|
+
const next = new Set(prev);
|
|
123
|
+
if (next.has(name))
|
|
124
|
+
next.delete(name);
|
|
125
|
+
else
|
|
126
|
+
next.add(name);
|
|
127
|
+
return next;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
72
131
|
if (key.return)
|
|
73
132
|
setStep("gotchi");
|
|
133
|
+
if (input === "s" || input === "S")
|
|
134
|
+
setStep("gotchi"); // Skip
|
|
74
135
|
}
|
|
75
136
|
if (step === "model" && availableModels.length > 0) {
|
|
76
137
|
if (key.upArrow)
|
|
@@ -120,16 +181,34 @@ export default function InitWizard({ onDone }) {
|
|
|
120
181
|
// ── Write final config ──
|
|
121
182
|
const writeFinal = useCallback(() => {
|
|
122
183
|
const selectedModel = availableModels.length > 0 ? (availableModels[modelIdx] ?? model) : model;
|
|
184
|
+
// Build MCP server configs from selected registry entries
|
|
185
|
+
let mcpServers;
|
|
186
|
+
if (selectedMcp.size > 0) {
|
|
187
|
+
try {
|
|
188
|
+
const { MCP_REGISTRY } = require('../mcp/registry.js');
|
|
189
|
+
mcpServers = [...selectedMcp]
|
|
190
|
+
.map(name => MCP_REGISTRY.find((e) => e.name === name))
|
|
191
|
+
.filter(Boolean)
|
|
192
|
+
.map((e) => ({
|
|
193
|
+
name: e.name,
|
|
194
|
+
command: 'npx',
|
|
195
|
+
args: ['-y', e.package, ...(e.args ?? [])],
|
|
196
|
+
...(e.envVars?.length ? { env: Object.fromEntries(e.envVars.map((v) => [v, `YOUR_${v}`])) } : {}),
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
catch { /* ignore */ }
|
|
200
|
+
}
|
|
123
201
|
writeOhConfig({
|
|
124
202
|
provider: provider.key,
|
|
125
203
|
model: selectedModel || provider.defaultModel,
|
|
126
204
|
permissionMode: PERMISSION_MODES[permIdx].key,
|
|
127
205
|
...(apiKey ? { apiKey } : {}),
|
|
128
206
|
...(baseUrl ? { baseUrl } : {}),
|
|
207
|
+
...(mcpServers?.length ? { mcpServers } : {}),
|
|
129
208
|
});
|
|
130
209
|
setStep("done");
|
|
131
210
|
setTimeout(() => onDone?.(), 1500);
|
|
132
|
-
}, [provider, model, availableModels, modelIdx, permIdx, apiKey, baseUrl]);
|
|
211
|
+
}, [provider, model, availableModels, modelIdx, permIdx, apiKey, baseUrl, selectedMcp]);
|
|
133
212
|
// ── Render ──
|
|
134
213
|
if (showSetup) {
|
|
135
214
|
return _jsx(CybergotchiSetup, { onComplete: () => { setShowSetup(false); writeFinal(); }, onSkip: () => { setShowSetup(false); writeFinal(); } });
|
|
@@ -137,7 +216,7 @@ export default function InitWizard({ onDone }) {
|
|
|
137
216
|
if (step === "done") {
|
|
138
217
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "green", children: "\u2713 OpenHarness configured!" }), _jsx(Text, { dimColor: true, children: "Config saved to .oh/config.yaml" }), _jsx(Text, { dimColor: true, children: "Run: oh" })] }));
|
|
139
218
|
}
|
|
140
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "OpenHarness Setup" }), _jsx(Text, { children: " " }), step === "provider" && (_jsxs(Box, { flexDirection: "column", children: [
|
|
219
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "OpenHarness Setup" }), _jsx(Text, { children: " " }), step === "provider" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Select provider:", detectedIdx > 0 ? _jsx(Text, { dimColor: true, children: " (auto-detected from env)" }) : ''] }), PROVIDERS.map((p, i) => (_jsxs(Text, { color: i === providerIdx ? "cyan" : undefined, children: [i === providerIdx ? "▶ " : " ", p.label] }, p.key))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate Enter select" })] })), step === "apikey" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["API key for ", _jsx(Text, { color: "cyan", children: provider.label }), ":"] }), _jsx(TextInput, { value: apiKey, onChange: setApiKey, mask: "*", onSubmit: (val) => {
|
|
141
220
|
if (!val.trim())
|
|
142
221
|
return;
|
|
143
222
|
if (provider.key === "custom") {
|
|
@@ -158,6 +237,6 @@ export default function InitWizard({ onDone }) {
|
|
|
158
237
|
const gi = start + vi;
|
|
159
238
|
return (_jsxs(Text, { color: gi === modelIdx ? "cyan" : undefined, children: [gi === modelIdx ? "▶ " : " ", m] }, m));
|
|
160
239
|
}), start + WINDOW < availableModels.length && (_jsxs(Text, { dimColor: true, children: [" \u2193 ", availableModels.length - start - WINDOW, " more"] }))] }));
|
|
161
|
-
})() : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Could not fetch model list. Enter model name:" }), _jsx(TextInput, { value: model, onChange: setModel, onSubmit: () => setStep("permission") })] })), availableModels.length > 0 && _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate Enter select" })] })), step === "permission" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Permission mode:" }), PERMISSION_MODES.map((p, i) => (_jsxs(Text, { color: i === permIdx ? "cyan" : undefined, children: [i === permIdx ? "▶ " : " ", p.label] }, p.key))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate Enter select" })] })), step === "gotchi" && (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: ["Hatch a cybergotchi companion? ", _jsx(Text, { dimColor: true, children: "(Y/n)" })] }) }))] }));
|
|
240
|
+
})() : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Could not fetch model list. Enter model name:" }), _jsx(TextInput, { value: model, onChange: setModel, onSubmit: () => setStep("permission") })] })), availableModels.length > 0 && _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate Enter select" })] })), step === "permission" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Permission mode:" }), PERMISSION_MODES.map((p, i) => (_jsxs(Text, { color: i === permIdx ? "cyan" : undefined, children: [i === permIdx ? "▶ " : " ", p.label] }, p.key))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate Enter select" })] })), step === "mcp" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Add MCP servers? ", _jsx(Text, { dimColor: true, children: "(Space to toggle, Enter to confirm, S to skip)" })] }), _jsx(Text, { children: " " }), suggestedMcp.map((name, i) => (_jsxs(Text, { color: i === mcpIdx ? "cyan" : undefined, children: [i === mcpIdx ? "▶ " : " ", selectedMcp.has(name) ? "[✓] " : "[ ] ", name] }, name))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate Space toggle Enter confirm S skip" })] })), step === "gotchi" && (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: ["Hatch a cybergotchi companion? ", _jsx(Text, { dimColor: true, children: "(Y/n)" })] }) }))] }));
|
|
162
241
|
}
|
|
163
242
|
//# sourceMappingURL=InitWizard.js.map
|
package/dist/renderer/index.d.ts
CHANGED
|
@@ -86,6 +86,13 @@ export declare class TerminalRenderer {
|
|
|
86
86
|
private handlePermissionKey;
|
|
87
87
|
/** Handle question prompt text input. Returns true if key was consumed. */
|
|
88
88
|
private handleQuestionKey;
|
|
89
|
+
private renderTimer;
|
|
90
|
+
private lastRenderTime;
|
|
91
|
+
private static readonly FRAME_MS;
|
|
92
|
+
/**
|
|
93
|
+
* Schedule a render at ~60fps. Multiple calls within a frame are batched.
|
|
94
|
+
* This prevents excessive re-renders during fast token streaming.
|
|
95
|
+
*/
|
|
89
96
|
private scheduleRender;
|
|
90
97
|
/** Apply lightweight markdown styling to a line for scrollback output */
|
|
91
98
|
private styleMarkdownLine;
|
package/dist/renderer/index.js
CHANGED
|
@@ -108,6 +108,10 @@ export class TerminalRenderer {
|
|
|
108
108
|
clearInterval(this.animationTimer);
|
|
109
109
|
this.animationTimer = null;
|
|
110
110
|
}
|
|
111
|
+
if (this.renderTimer) {
|
|
112
|
+
clearTimeout(this.renderTimer);
|
|
113
|
+
this.renderTimer = null;
|
|
114
|
+
}
|
|
111
115
|
if (this.resizeHandler) {
|
|
112
116
|
process.stdout.off('resize', this.resizeHandler);
|
|
113
117
|
this.resizeHandler = null;
|
|
@@ -363,15 +367,39 @@ export class TerminalRenderer {
|
|
|
363
367
|
return true;
|
|
364
368
|
}
|
|
365
369
|
// ── Rendering ──
|
|
370
|
+
renderTimer = null;
|
|
371
|
+
lastRenderTime = 0;
|
|
372
|
+
static FRAME_MS = 16; // ~60fps
|
|
373
|
+
/**
|
|
374
|
+
* Schedule a render at ~60fps. Multiple calls within a frame are batched.
|
|
375
|
+
* This prevents excessive re-renders during fast token streaming.
|
|
376
|
+
*/
|
|
366
377
|
scheduleRender() {
|
|
367
378
|
if (this.renderPending || !this.started)
|
|
368
379
|
return;
|
|
369
380
|
this.renderPending = true;
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
const elapsed = now - this.lastRenderTime;
|
|
383
|
+
if (elapsed >= TerminalRenderer.FRAME_MS) {
|
|
384
|
+
// Enough time has passed — render on next microtask (no delay)
|
|
385
|
+
queueMicrotask(() => {
|
|
386
|
+
this.renderPending = false;
|
|
387
|
+
this.lastRenderTime = Date.now();
|
|
388
|
+
if (this.started)
|
|
389
|
+
this.render();
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
// Too soon — debounce to next frame boundary
|
|
394
|
+
const delay = TerminalRenderer.FRAME_MS - elapsed;
|
|
395
|
+
this.renderTimer = setTimeout(() => {
|
|
396
|
+
this.renderTimer = null;
|
|
397
|
+
this.renderPending = false;
|
|
398
|
+
this.lastRenderTime = Date.now();
|
|
399
|
+
if (this.started)
|
|
400
|
+
this.render();
|
|
401
|
+
}, delay);
|
|
402
|
+
}
|
|
375
403
|
}
|
|
376
404
|
/** Apply lightweight markdown styling to a line for scrollback output */
|
|
377
405
|
styleMarkdownLine(line) {
|