@aitherium/shell-cli 1.11.1 → 1.12.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/dist/commands.js +145 -0
- package/dist/doc-view.d.ts +35 -0
- package/dist/doc-view.js +144 -0
- package/dist/image-preview.d.ts +45 -0
- package/dist/image-preview.js +250 -0
- package/dist/main.js +35 -8
- package/dist/renderer.js +1 -1
- package/dist/status-banner.js +22 -2
- package/dist/tui/repl-tui.js +95 -1
- package/dist/tui/screen.d.ts +7 -0
- package/dist/tui/screen.js +310 -1
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -761,6 +761,151 @@ const COMMANDS = {
|
|
|
761
761
|
console.log();
|
|
762
762
|
},
|
|
763
763
|
},
|
|
764
|
+
spec: {
|
|
765
|
+
description: "Spec Ledger — list/inspect specs, ingest a PRD, publish issues, reasoner",
|
|
766
|
+
usage: '/spec [list | status <id> | ingest <path|url> [--pack x] | publish <id> [--repo o/n] [--new "Title"|--board o/n|--no-board] [--go] | reasoner [mode] | settings [--repo o/n] [--board o/n]]',
|
|
767
|
+
handler: async (client, args) => {
|
|
768
|
+
const parts = args.trim().split(/\s+/);
|
|
769
|
+
const sub = (parts[0] || 'list').toLowerCase();
|
|
770
|
+
const flag = (name) => {
|
|
771
|
+
const m = args.match(new RegExp(`--${name}\\s+("[^"]+"|\\S+)`));
|
|
772
|
+
return m ? m[1].replace(/^"|"$/g, '') : '';
|
|
773
|
+
};
|
|
774
|
+
if (sub === 'list' || sub === '') {
|
|
775
|
+
const spinner = ora('Loading specs...').start();
|
|
776
|
+
const r = await client.get('/spec/list?limit=100');
|
|
777
|
+
spinner.stop();
|
|
778
|
+
const specs = r?.specs || [];
|
|
779
|
+
if (!specs.length) {
|
|
780
|
+
console.log(chalk.dim(' No specs ingested yet — /spec ingest <path|url>'));
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
console.log();
|
|
784
|
+
for (const s of specs) {
|
|
785
|
+
console.log(` ${chalk.cyan(s.id)} ${chalk.bold(s.title)}`);
|
|
786
|
+
console.log(chalk.dim(` ${s.requirement_count} reqs · ${s.conformance_pct}% conformant${s.target_pack ? ` · pack ${s.target_pack}` : ''}`));
|
|
787
|
+
}
|
|
788
|
+
console.log();
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (sub === 'status') {
|
|
792
|
+
const id = parts[1];
|
|
793
|
+
if (!id) {
|
|
794
|
+
console.log(chalk.dim(' Usage: /spec status <spec_id>'));
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const spinner = ora('Loading...').start();
|
|
798
|
+
const r = await client.get(`/spec/${encodeURIComponent(id)}/status`);
|
|
799
|
+
spinner.stop();
|
|
800
|
+
if (!r || r.error || r.detail) {
|
|
801
|
+
console.log(chalk.red(` ${r?.error || r?.detail || 'not found'}`));
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const ov = r.coverage?.overall || {};
|
|
805
|
+
console.log();
|
|
806
|
+
console.log(` ${chalk.bold(r.title)} ${chalk.dim(`(${r.target_pack || 'untriaged'})`)}`);
|
|
807
|
+
console.log(` ${ov.pct ?? 0}% conformant — ${ov.satisfied ?? 0}/${ov.total ?? 0} satisfied, ${(r.gaps || []).length} gaps`);
|
|
808
|
+
for (const [cat, c] of Object.entries(r.coverage?.by_category || {})) {
|
|
809
|
+
console.log(chalk.dim(` ${cat}: ${c.satisfied}/${c.total} (${c.pct}%)`));
|
|
810
|
+
}
|
|
811
|
+
console.log();
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (sub === 'ingest') {
|
|
815
|
+
const src = parts[1];
|
|
816
|
+
if (!src) {
|
|
817
|
+
console.log(chalk.dim(' Usage: /spec ingest <path|url> [--pack x]'));
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const body = { background: false };
|
|
821
|
+
body[src.startsWith('http') ? 'url' : 'path'] = src;
|
|
822
|
+
const pack = flag('pack');
|
|
823
|
+
if (pack)
|
|
824
|
+
body.target_pack = pack;
|
|
825
|
+
const spinner = ora('Ingesting + extracting (reasoner)...').start();
|
|
826
|
+
const r = await client.post('/spec/ingest', body);
|
|
827
|
+
spinner.stop();
|
|
828
|
+
if (!r || r.error) {
|
|
829
|
+
console.log(chalk.red(` ${r?.error || 'ingest failed'}`));
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
console.log(chalk.green(` Ingested ${chalk.cyan(r.spec_doc_id)} — ${r.requirement_count ?? 0} requirements`));
|
|
833
|
+
if (r.by_category)
|
|
834
|
+
console.log(chalk.dim(` ${Object.entries(r.by_category).map(([k, v]) => `${k}:${v}`).join(', ')}`));
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (sub === 'reasoner') {
|
|
838
|
+
const mode = parts[1];
|
|
839
|
+
const r = mode
|
|
840
|
+
? await client.post('/spec/reasoner', { mode })
|
|
841
|
+
: await client.get('/spec/reasoner');
|
|
842
|
+
if (!r || r.error) {
|
|
843
|
+
console.log(chalk.red(` ${r?.error || 'reasoner call failed'}`));
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
console.log(` Reasoner: ${chalk.cyan(r.effective)} ${chalk.dim(`(available: ${(r.available_modes || []).join(', ')})`)}`);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (sub === 'settings') {
|
|
850
|
+
const repo = flag('repo'), board = flag('board');
|
|
851
|
+
let r;
|
|
852
|
+
if (repo || board) {
|
|
853
|
+
const body = {};
|
|
854
|
+
if (repo)
|
|
855
|
+
body.publish_repo = repo;
|
|
856
|
+
if (board)
|
|
857
|
+
body.publish_project = board;
|
|
858
|
+
r = await client.post('/spec/settings', body);
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
r = await client.get('/spec/settings');
|
|
862
|
+
}
|
|
863
|
+
if (!r || r.error) {
|
|
864
|
+
console.log(chalk.red(` ${r?.error || 'settings failed'}`));
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
console.log(` Reasoner: ${chalk.cyan(r.reasoner_mode)} · publish_repo: ${chalk.cyan(r.publish_repo || '(unset)')} · board: ${chalk.cyan(r.publish_project || '(unset)')}`);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (sub === 'publish') {
|
|
871
|
+
const id = parts[1];
|
|
872
|
+
if (!id) {
|
|
873
|
+
console.log(chalk.dim(' Usage: /spec publish <id> [--repo o/n] [--new "Title"|--board o/n|--no-board] [--go]'));
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const go = /--go\b/.test(args);
|
|
877
|
+
const body = { dry_run: !go };
|
|
878
|
+
const repo = flag('repo');
|
|
879
|
+
if (repo)
|
|
880
|
+
body.repo = repo; // else server uses spec_settings default
|
|
881
|
+
const newTitle = flag('new');
|
|
882
|
+
const board = flag('board');
|
|
883
|
+
if (newTitle)
|
|
884
|
+
body.create_project_title = newTitle;
|
|
885
|
+
else if (board)
|
|
886
|
+
body.project = board;
|
|
887
|
+
// --no-board: leave both unset → issues only
|
|
888
|
+
const spinner = ora(go ? 'Publishing issues...' : 'Dry-run preview...').start();
|
|
889
|
+
const r = await client.post(`/spec/${encodeURIComponent(id)}/publish-issues`, body);
|
|
890
|
+
spinner.stop();
|
|
891
|
+
if (!r || (r.error && !r.created)) {
|
|
892
|
+
console.log(chalk.red(` ${r?.error || 'publish failed'}`));
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const created = r.created?.length ?? 0, skipped = r.skipped_existing?.length ?? 0, errs = r.errors?.length ?? 0;
|
|
896
|
+
console.log();
|
|
897
|
+
console.log(` ${go ? chalk.green(`Published ${created}`) : chalk.yellow(`Would create ${created}`)} issue(s) on ${chalk.cyan(r.repo)}` +
|
|
898
|
+
(skipped ? `, skipped ${skipped}` : '') + (errs ? chalk.red(`, ${errs} errors`) : '') + (r.project ? ` → board ${chalk.cyan(r.project)}` : ''));
|
|
899
|
+
if (errs)
|
|
900
|
+
console.log(chalk.red(` ${r.errors[0]}`));
|
|
901
|
+
if (!go)
|
|
902
|
+
console.log(chalk.dim(' (dry-run — add --go to actually publish)'));
|
|
903
|
+
console.log();
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
console.log(chalk.dim(' Usage: /spec [list | status <id> | ingest <path|url> | publish <id> | reasoner [mode] | settings]'));
|
|
907
|
+
},
|
|
908
|
+
},
|
|
764
909
|
atlas: {
|
|
765
910
|
description: "Atlas PM board — latest cycle, in-flight items, or run a cycle",
|
|
766
911
|
usage: '/atlas [board|tick] [--items N] [--sync]',
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc-view.ts — Load a file and render it into lines for the in-TUI document
|
|
3
|
+
* viewer (`/view <path>`): markdown gets the full terminal-markdown render,
|
|
4
|
+
* code/text get a line-numbered gutter, and images render inline as half-blocks
|
|
5
|
+
* (see image-preview.ts). The pure load/render logic lives here so it's testable
|
|
6
|
+
* and so both the viewer overlay and a future `/edit` share one classifier.
|
|
7
|
+
*/
|
|
8
|
+
export type DocKind = 'markdown' | 'code' | 'text' | 'image' | 'binary' | 'missing';
|
|
9
|
+
export interface LoadedDoc {
|
|
10
|
+
kind: DocKind;
|
|
11
|
+
absPath: string;
|
|
12
|
+
title: string;
|
|
13
|
+
ext: string;
|
|
14
|
+
sizeBytes: number;
|
|
15
|
+
/** Raw text content (text/code/markdown only). */
|
|
16
|
+
text?: string;
|
|
17
|
+
/** Error/explanatory note for the header line. */
|
|
18
|
+
note?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Resolve a user-supplied path against cwd + the known AitherOS roots. */
|
|
21
|
+
export declare function resolveDocPath(input: string): string;
|
|
22
|
+
/** Load + classify a file. Never throws. */
|
|
23
|
+
export declare function loadDoc(input: string): LoadedDoc;
|
|
24
|
+
export interface ViewRender {
|
|
25
|
+
lines: string[];
|
|
26
|
+
/** When the doc is an image the viewer couldn't render inline (non-PNG), the
|
|
27
|
+
* caller should OS-open this path instead. */
|
|
28
|
+
fallbackOpenPath?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Render a loaded doc into display lines for the viewer overlay, wrapped to
|
|
32
|
+
* `width` columns. Markdown → terminal markdown; code/text → line-numbered;
|
|
33
|
+
* image → inline half-blocks (PNG) or a note + OS-open fallback.
|
|
34
|
+
*/
|
|
35
|
+
export declare function renderDocLines(doc: LoadedDoc, width: number): ViewRender;
|
package/dist/doc-view.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc-view.ts — Load a file and render it into lines for the in-TUI document
|
|
3
|
+
* viewer (`/view <path>`): markdown gets the full terminal-markdown render,
|
|
4
|
+
* code/text get a line-numbered gutter, and images render inline as half-blocks
|
|
5
|
+
* (see image-preview.ts). The pure load/render logic lives here so it's testable
|
|
6
|
+
* and so both the viewer overlay and a future `/edit` share one classifier.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
9
|
+
import { basename, extname, isAbsolute, resolve } from 'node:path';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import { renderMarkdown, stripOsc8, resolveImagePath } from './renderer.js';
|
|
12
|
+
import { imageToAnsi, imageDimensions } from './image-preview.js';
|
|
13
|
+
const MARKDOWN_EXTS = new Set(['.md', '.markdown', '.mdx']);
|
|
14
|
+
const IMAGE_EXTS = new Set(['.png', '.apng', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico']);
|
|
15
|
+
const CODE_EXTS = new Set([
|
|
16
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.rs', '.go', '.java',
|
|
17
|
+
'.c', '.h', '.cpp', '.hpp', '.cs', '.rb', '.php', '.sh', '.ps1', '.psm1',
|
|
18
|
+
'.sql', '.css', '.scss', '.html', '.xml', '.toml', '.ini', '.dockerfile',
|
|
19
|
+
]);
|
|
20
|
+
const DATA_EXTS = new Set(['.json', '.yaml', '.yml', '.csv', '.log', '.txt', '.env', '.conf', '.cfg']);
|
|
21
|
+
/** Map a file extension to a markdown fence language for syntax highlighting. */
|
|
22
|
+
function fenceLang(ext) {
|
|
23
|
+
const e = ext.replace(/^\./, '');
|
|
24
|
+
const map = {
|
|
25
|
+
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', mjs: 'javascript',
|
|
26
|
+
py: 'python', rs: 'rust', go: 'go', rb: 'ruby', ps1: 'powershell', psm1: 'powershell',
|
|
27
|
+
yml: 'yaml', sh: 'bash',
|
|
28
|
+
};
|
|
29
|
+
return map[e] || e || '';
|
|
30
|
+
}
|
|
31
|
+
/** Resolve a user-supplied path against cwd + the known AitherOS roots. */
|
|
32
|
+
export function resolveDocPath(input) {
|
|
33
|
+
const raw = input.replace(/^["']|["']$/g, '').trim();
|
|
34
|
+
if (isAbsolute(raw) && existsSync(raw))
|
|
35
|
+
return raw;
|
|
36
|
+
const cwdTry = resolve(process.cwd(), raw);
|
|
37
|
+
if (existsSync(cwdTry))
|
|
38
|
+
return cwdTry;
|
|
39
|
+
// Reuse the renderer's AitherOS-root resolver (handles Library/ paths etc.).
|
|
40
|
+
try {
|
|
41
|
+
const r = resolveImagePath(raw);
|
|
42
|
+
if (existsSync(r))
|
|
43
|
+
return r;
|
|
44
|
+
}
|
|
45
|
+
catch { /* */ }
|
|
46
|
+
return cwdTry; // best-effort; caller checks existsSync via kind === 'missing'
|
|
47
|
+
}
|
|
48
|
+
/** Load + classify a file. Never throws. */
|
|
49
|
+
export function loadDoc(input) {
|
|
50
|
+
const absPath = resolveDocPath(input);
|
|
51
|
+
const ext = extname(absPath).toLowerCase();
|
|
52
|
+
const title = basename(absPath);
|
|
53
|
+
if (!existsSync(absPath)) {
|
|
54
|
+
return { kind: 'missing', absPath, title, ext, sizeBytes: 0, note: 'file not found' };
|
|
55
|
+
}
|
|
56
|
+
let sizeBytes = 0;
|
|
57
|
+
try {
|
|
58
|
+
const st = statSync(absPath);
|
|
59
|
+
sizeBytes = st.size;
|
|
60
|
+
if (st.isDirectory()) {
|
|
61
|
+
return { kind: 'missing', absPath, title, ext, sizeBytes: 0, note: 'is a directory (pass a file)' };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { /* */ }
|
|
65
|
+
if (IMAGE_EXTS.has(ext)) {
|
|
66
|
+
return { kind: 'image', absPath, title, ext, sizeBytes };
|
|
67
|
+
}
|
|
68
|
+
// Read as text (cap at 2 MB so a huge log doesn't stall the render).
|
|
69
|
+
const CAP = 2 * 1024 * 1024;
|
|
70
|
+
let text;
|
|
71
|
+
try {
|
|
72
|
+
const buf = readFileSync(absPath);
|
|
73
|
+
if (buf.includes(0)) {
|
|
74
|
+
return { kind: 'binary', absPath, title, ext, sizeBytes, note: 'binary file' };
|
|
75
|
+
}
|
|
76
|
+
text = buf.length > CAP
|
|
77
|
+
? buf.subarray(0, CAP).toString('utf-8') + '\n…(truncated)…'
|
|
78
|
+
: buf.toString('utf-8');
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
return { kind: 'binary', absPath, title, ext, sizeBytes, note: e?.message || 'unreadable' };
|
|
82
|
+
}
|
|
83
|
+
if (MARKDOWN_EXTS.has(ext))
|
|
84
|
+
return { kind: 'markdown', absPath, title, ext, sizeBytes, text };
|
|
85
|
+
if (CODE_EXTS.has(ext) || DATA_EXTS.has(ext))
|
|
86
|
+
return { kind: 'code', absPath, title, ext, sizeBytes, text };
|
|
87
|
+
return { kind: 'text', absPath, title, ext, sizeBytes, text };
|
|
88
|
+
}
|
|
89
|
+
function humanSize(n) {
|
|
90
|
+
if (n < 1024)
|
|
91
|
+
return `${n} B`;
|
|
92
|
+
if (n < 1024 * 1024)
|
|
93
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
94
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Render a loaded doc into display lines for the viewer overlay, wrapped to
|
|
98
|
+
* `width` columns. Markdown → terminal markdown; code/text → line-numbered;
|
|
99
|
+
* image → inline half-blocks (PNG) or a note + OS-open fallback.
|
|
100
|
+
*/
|
|
101
|
+
export function renderDocLines(doc, width) {
|
|
102
|
+
const innerW = Math.max(20, width - 2);
|
|
103
|
+
if (doc.kind === 'missing' || doc.kind === 'binary') {
|
|
104
|
+
return { lines: [chalk.red(` ${doc.note || doc.kind}`), chalk.dim(` ${doc.absPath}`)] };
|
|
105
|
+
}
|
|
106
|
+
if (doc.kind === 'image') {
|
|
107
|
+
const dims = imageDimensions(doc.absPath);
|
|
108
|
+
const header = [
|
|
109
|
+
chalk.dim(` ${humanSize(doc.sizeBytes)}${dims ? ` · ${dims.width}×${dims.height}` : ''}`),
|
|
110
|
+
'',
|
|
111
|
+
];
|
|
112
|
+
const ansi = imageToAnsi(doc.absPath, Math.min(innerW, 100), 44);
|
|
113
|
+
if (ansi)
|
|
114
|
+
return { lines: [...header, ...ansi.map(l => ' ' + l)] };
|
|
115
|
+
// Non-PNG (JPEG/WEBP/…): can't decode without a dep — OS-open instead.
|
|
116
|
+
return {
|
|
117
|
+
lines: [
|
|
118
|
+
...header,
|
|
119
|
+
chalk.yellow(` ${doc.ext.replace('.', '').toUpperCase()} preview not supported inline — opening in your OS viewer.`),
|
|
120
|
+
chalk.dim(' (PNG renders inline; other formats open externally.)'),
|
|
121
|
+
],
|
|
122
|
+
fallbackOpenPath: doc.absPath,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (doc.kind === 'markdown' && doc.text != null) {
|
|
126
|
+
const rendered = stripOsc8(renderMarkdown(doc.text));
|
|
127
|
+
return { lines: rendered.replace(/\n$/, '').split('\n') };
|
|
128
|
+
}
|
|
129
|
+
// code / text → line-numbered gutter.
|
|
130
|
+
const text = doc.text ?? '';
|
|
131
|
+
const rawLines = text.split('\n');
|
|
132
|
+
const gutterW = String(rawLines.length).length;
|
|
133
|
+
const lang = doc.kind === 'code' ? fenceLang(doc.ext) : '';
|
|
134
|
+
const out = [];
|
|
135
|
+
rawLines.forEach((ln, i) => {
|
|
136
|
+
const num = chalk.dim(String(i + 1).padStart(gutterW, ' ') + ' │ ');
|
|
137
|
+
// Light, dependency-free emphasis for code: comments dimmed.
|
|
138
|
+
let body = ln;
|
|
139
|
+
if (lang && /^\s*(#|\/\/|--|;)/.test(ln))
|
|
140
|
+
body = chalk.dim(ln);
|
|
141
|
+
out.push(num + body);
|
|
142
|
+
});
|
|
143
|
+
return { lines: out };
|
|
144
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* image-preview.ts — Render raster images INLINE in the terminal as Unicode
|
|
3
|
+
* half-blocks (▀) with 24-bit colour.
|
|
4
|
+
*
|
|
5
|
+
* Why half-blocks instead of sixel / iTerm2 / kitty graphics: those protocols
|
|
6
|
+
* each work in only SOME terminals (sixel needs Windows Terminal ≥1.22, iTerm2's
|
|
7
|
+
* OSC-1337 needs iTerm2/WezTerm, kitty needs kitty) and several need the image
|
|
8
|
+
* re-encoded. The half-block trick — print '▀' with the FOREGROUND set to the top
|
|
9
|
+
* pixel and the BACKGROUND to the bottom pixel, so one character cell shows TWO
|
|
10
|
+
* vertical pixels — works in EVERY 24-bit-colour terminal, including the Windows
|
|
11
|
+
* Terminal the fleet runs on. Decoding uses node's built-in zlib (no new deps),
|
|
12
|
+
* so PNG (the format ComfyUI/AitherCanvas emit) renders for real; other formats
|
|
13
|
+
* fall back to the OS viewer at the call site.
|
|
14
|
+
*/
|
|
15
|
+
export interface DecodedImage {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
/** RGBA, 4 bytes per pixel, row-major. */
|
|
19
|
+
rgba: Uint8Array;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Minimal PNG decoder — enough for the 8-bit images the fleet produces:
|
|
23
|
+
* colour types 0 (grey), 2 (RGB), 3 (palette), 6 (RGBA), and all 5 scanline
|
|
24
|
+
* filters. Returns null for anything it can't handle (interlaced, 16-bit, etc.)
|
|
25
|
+
* so the caller can fall back to the OS viewer.
|
|
26
|
+
*/
|
|
27
|
+
export declare function decodePNG(buf: Buffer): DecodedImage | null;
|
|
28
|
+
/**
|
|
29
|
+
* Render a decoded image as half-block ANSI lines scaled to fit `cols` columns
|
|
30
|
+
* (each line is one terminal row = two image rows). Preserves aspect ratio;
|
|
31
|
+
* terminal cells are ~2× tall as wide, which the half-block already accounts for
|
|
32
|
+
* (2 pixels per cell vertically).
|
|
33
|
+
*/
|
|
34
|
+
export declare function renderHalfBlock(img: DecodedImage, cols: number, maxRows?: number): string[];
|
|
35
|
+
/**
|
|
36
|
+
* Read an image file and render it inline as half-block ANSI lines, scaled to
|
|
37
|
+
* `cols`. Returns null when the format isn't decodable here (JPEG/GIF/WEBP/…),
|
|
38
|
+
* so the caller can fall back to the OS viewer. Never throws.
|
|
39
|
+
*/
|
|
40
|
+
export declare function imageToAnsi(absPath: string, cols: number, maxRows?: number): string[] | null;
|
|
41
|
+
/** Cheap dimensions probe for the metadata header (PNG only; null otherwise). */
|
|
42
|
+
export declare function imageDimensions(absPath: string): {
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
} | null;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* image-preview.ts — Render raster images INLINE in the terminal as Unicode
|
|
3
|
+
* half-blocks (▀) with 24-bit colour.
|
|
4
|
+
*
|
|
5
|
+
* Why half-blocks instead of sixel / iTerm2 / kitty graphics: those protocols
|
|
6
|
+
* each work in only SOME terminals (sixel needs Windows Terminal ≥1.22, iTerm2's
|
|
7
|
+
* OSC-1337 needs iTerm2/WezTerm, kitty needs kitty) and several need the image
|
|
8
|
+
* re-encoded. The half-block trick — print '▀' with the FOREGROUND set to the top
|
|
9
|
+
* pixel and the BACKGROUND to the bottom pixel, so one character cell shows TWO
|
|
10
|
+
* vertical pixels — works in EVERY 24-bit-colour terminal, including the Windows
|
|
11
|
+
* Terminal the fleet runs on. Decoding uses node's built-in zlib (no new deps),
|
|
12
|
+
* so PNG (the format ComfyUI/AitherCanvas emit) renders for real; other formats
|
|
13
|
+
* fall back to the OS viewer at the call site.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { inflateSync } from 'node:zlib';
|
|
17
|
+
/** Paeth predictor (PNG filter type 4). */
|
|
18
|
+
function paeth(a, b, c) {
|
|
19
|
+
const p = a + b - c;
|
|
20
|
+
const pa = Math.abs(p - a);
|
|
21
|
+
const pb = Math.abs(p - b);
|
|
22
|
+
const pc = Math.abs(p - c);
|
|
23
|
+
if (pa <= pb && pa <= pc)
|
|
24
|
+
return a;
|
|
25
|
+
if (pb <= pc)
|
|
26
|
+
return b;
|
|
27
|
+
return c;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Minimal PNG decoder — enough for the 8-bit images the fleet produces:
|
|
31
|
+
* colour types 0 (grey), 2 (RGB), 3 (palette), 6 (RGBA), and all 5 scanline
|
|
32
|
+
* filters. Returns null for anything it can't handle (interlaced, 16-bit, etc.)
|
|
33
|
+
* so the caller can fall back to the OS viewer.
|
|
34
|
+
*/
|
|
35
|
+
export function decodePNG(buf) {
|
|
36
|
+
// Signature.
|
|
37
|
+
const SIG = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
38
|
+
for (let i = 0; i < 8; i++)
|
|
39
|
+
if (buf[i] !== SIG[i])
|
|
40
|
+
return null;
|
|
41
|
+
let pos = 8;
|
|
42
|
+
let width = 0, height = 0, bitDepth = 0, colorType = 0, interlace = 0;
|
|
43
|
+
let palette = null;
|
|
44
|
+
let trns = null;
|
|
45
|
+
const idat = [];
|
|
46
|
+
while (pos < buf.length) {
|
|
47
|
+
const len = buf.readUInt32BE(pos);
|
|
48
|
+
pos += 4;
|
|
49
|
+
const type = buf.toString('ascii', pos, pos + 4);
|
|
50
|
+
pos += 4;
|
|
51
|
+
const data = buf.subarray(pos, pos + len);
|
|
52
|
+
pos += len;
|
|
53
|
+
pos += 4; // CRC — skip.
|
|
54
|
+
if (type === 'IHDR') {
|
|
55
|
+
width = data.readUInt32BE(0);
|
|
56
|
+
height = data.readUInt32BE(4);
|
|
57
|
+
bitDepth = data[8];
|
|
58
|
+
colorType = data[9];
|
|
59
|
+
interlace = data[12];
|
|
60
|
+
}
|
|
61
|
+
else if (type === 'PLTE') {
|
|
62
|
+
palette = new Uint8Array(data);
|
|
63
|
+
}
|
|
64
|
+
else if (type === 'tRNS') {
|
|
65
|
+
trns = new Uint8Array(data);
|
|
66
|
+
}
|
|
67
|
+
else if (type === 'IDAT') {
|
|
68
|
+
idat.push(Buffer.from(data));
|
|
69
|
+
}
|
|
70
|
+
else if (type === 'IEND') {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!width || !height)
|
|
75
|
+
return null;
|
|
76
|
+
if (bitDepth !== 8)
|
|
77
|
+
return null; // 1/2/4/16-bit unsupported (keep it small)
|
|
78
|
+
if (interlace !== 0)
|
|
79
|
+
return null; // Adam7 unsupported
|
|
80
|
+
const channels = colorType === 2 ? 3 : colorType === 6 ? 4 : colorType === 0 ? 1 : colorType === 3 ? 1 : 0;
|
|
81
|
+
if (channels === 0)
|
|
82
|
+
return null;
|
|
83
|
+
let raw;
|
|
84
|
+
try {
|
|
85
|
+
raw = inflateSync(Buffer.concat(idat));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const stride = width * channels;
|
|
91
|
+
const out = new Uint8Array(width * height * 4);
|
|
92
|
+
const line = new Uint8Array(stride);
|
|
93
|
+
const prev = new Uint8Array(stride);
|
|
94
|
+
let rp = 0;
|
|
95
|
+
for (let y = 0; y < height; y++) {
|
|
96
|
+
const filter = raw[rp++];
|
|
97
|
+
for (let x = 0; x < stride; x++) {
|
|
98
|
+
const rawByte = raw[rp++];
|
|
99
|
+
const a = x >= channels ? line[x - channels] : 0;
|
|
100
|
+
const b = prev[x];
|
|
101
|
+
const c = x >= channels ? prev[x - channels] : 0;
|
|
102
|
+
let val;
|
|
103
|
+
switch (filter) {
|
|
104
|
+
case 0:
|
|
105
|
+
val = rawByte;
|
|
106
|
+
break;
|
|
107
|
+
case 1:
|
|
108
|
+
val = rawByte + a;
|
|
109
|
+
break;
|
|
110
|
+
case 2:
|
|
111
|
+
val = rawByte + b;
|
|
112
|
+
break;
|
|
113
|
+
case 3:
|
|
114
|
+
val = rawByte + ((a + b) >> 1);
|
|
115
|
+
break;
|
|
116
|
+
case 4:
|
|
117
|
+
val = rawByte + paeth(a, b, c);
|
|
118
|
+
break;
|
|
119
|
+
default:
|
|
120
|
+
val = rawByte;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
line[x] = val & 0xff;
|
|
124
|
+
}
|
|
125
|
+
// Expand the scanline into RGBA.
|
|
126
|
+
for (let x = 0; x < width; x++) {
|
|
127
|
+
const o = (y * width + x) * 4;
|
|
128
|
+
if (colorType === 2) { // RGB
|
|
129
|
+
out[o] = line[x * 3];
|
|
130
|
+
out[o + 1] = line[x * 3 + 1];
|
|
131
|
+
out[o + 2] = line[x * 3 + 2];
|
|
132
|
+
out[o + 3] = 255;
|
|
133
|
+
}
|
|
134
|
+
else if (colorType === 6) { // RGBA
|
|
135
|
+
out[o] = line[x * 4];
|
|
136
|
+
out[o + 1] = line[x * 4 + 1];
|
|
137
|
+
out[o + 2] = line[x * 4 + 2];
|
|
138
|
+
out[o + 3] = line[x * 4 + 3];
|
|
139
|
+
}
|
|
140
|
+
else if (colorType === 0) { // grey
|
|
141
|
+
const g = line[x];
|
|
142
|
+
out[o] = g;
|
|
143
|
+
out[o + 1] = g;
|
|
144
|
+
out[o + 2] = g;
|
|
145
|
+
out[o + 3] = 255;
|
|
146
|
+
}
|
|
147
|
+
else if (colorType === 3 && palette) { // palette
|
|
148
|
+
const idx = line[x];
|
|
149
|
+
out[o] = palette[idx * 3];
|
|
150
|
+
out[o + 1] = palette[idx * 3 + 1];
|
|
151
|
+
out[o + 2] = palette[idx * 3 + 2];
|
|
152
|
+
out[o + 3] = trns && idx < trns.length ? trns[idx] : 255;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
prev.set(line);
|
|
156
|
+
}
|
|
157
|
+
return { width, height, rgba: out };
|
|
158
|
+
}
|
|
159
|
+
/** Nearest-neighbour sample of the source image at normalised (u,v) → RGBA. */
|
|
160
|
+
function sample(img, u, v) {
|
|
161
|
+
const x = Math.min(img.width - 1, Math.max(0, Math.floor(u * img.width)));
|
|
162
|
+
const y = Math.min(img.height - 1, Math.max(0, Math.floor(v * img.height)));
|
|
163
|
+
const o = (y * img.width + x) * 4;
|
|
164
|
+
return [img.rgba[o], img.rgba[o + 1], img.rgba[o + 2], img.rgba[o + 3]];
|
|
165
|
+
}
|
|
166
|
+
/** Composite a pixel over a dark checkerboard so transparency reads correctly. */
|
|
167
|
+
function overBg(r, g, b, a, dark) {
|
|
168
|
+
if (a >= 255)
|
|
169
|
+
return [r, g, b];
|
|
170
|
+
const bg = dark ? 30 : 45;
|
|
171
|
+
const af = a / 255;
|
|
172
|
+
return [
|
|
173
|
+
Math.round(r * af + bg * (1 - af)),
|
|
174
|
+
Math.round(g * af + bg * (1 - af)),
|
|
175
|
+
Math.round(b * af + bg * (1 - af)),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Render a decoded image as half-block ANSI lines scaled to fit `cols` columns
|
|
180
|
+
* (each line is one terminal row = two image rows). Preserves aspect ratio;
|
|
181
|
+
* terminal cells are ~2× tall as wide, which the half-block already accounts for
|
|
182
|
+
* (2 pixels per cell vertically).
|
|
183
|
+
*/
|
|
184
|
+
export function renderHalfBlock(img, cols, maxRows = 40) {
|
|
185
|
+
const aspect = img.height / img.width;
|
|
186
|
+
let outCols = Math.max(1, Math.min(cols, img.width));
|
|
187
|
+
// rows of CELLS; each cell = 2 vertical pixels, and a cell is ~2× tall as wide.
|
|
188
|
+
let outRows = Math.max(1, Math.round((outCols * aspect) / 2));
|
|
189
|
+
if (outRows > maxRows) {
|
|
190
|
+
outRows = maxRows;
|
|
191
|
+
outCols = Math.max(1, Math.round((outRows * 2) / aspect));
|
|
192
|
+
}
|
|
193
|
+
outCols = Math.min(outCols, cols);
|
|
194
|
+
const lines = [];
|
|
195
|
+
for (let ry = 0; ry < outRows; ry++) {
|
|
196
|
+
let line = '';
|
|
197
|
+
for (let cx = 0; cx < outCols; cx++) {
|
|
198
|
+
const u = (cx + 0.5) / outCols;
|
|
199
|
+
const vTop = (ry * 2 + 0.5) / (outRows * 2);
|
|
200
|
+
const vBot = (ry * 2 + 1.5) / (outRows * 2);
|
|
201
|
+
const [tr, tg, tb, ta] = sample(img, u, vTop);
|
|
202
|
+
const [br, bg, bb, ba] = sample(img, u, vBot);
|
|
203
|
+
const [TR, TG, TB] = overBg(tr, tg, tb, ta, true);
|
|
204
|
+
const [BR, BG, BB] = overBg(br, bg, bb, ba, true);
|
|
205
|
+
// '▀' upper half-block: fg = top pixel, bg = bottom pixel.
|
|
206
|
+
line += `\x1b[38;2;${TR};${TG};${TB}m\x1b[48;2;${BR};${BG};${BB}m▀`;
|
|
207
|
+
}
|
|
208
|
+
line += '\x1b[0m';
|
|
209
|
+
lines.push(line);
|
|
210
|
+
}
|
|
211
|
+
return lines;
|
|
212
|
+
}
|
|
213
|
+
const PNG_EXTS = new Set(['.png', '.apng']);
|
|
214
|
+
/**
|
|
215
|
+
* Read an image file and render it inline as half-block ANSI lines, scaled to
|
|
216
|
+
* `cols`. Returns null when the format isn't decodable here (JPEG/GIF/WEBP/…),
|
|
217
|
+
* so the caller can fall back to the OS viewer. Never throws.
|
|
218
|
+
*/
|
|
219
|
+
export function imageToAnsi(absPath, cols, maxRows = 40) {
|
|
220
|
+
try {
|
|
221
|
+
const dot = absPath.lastIndexOf('.');
|
|
222
|
+
const ext = dot >= 0 ? absPath.slice(dot).toLowerCase() : '';
|
|
223
|
+
if (!PNG_EXTS.has(ext))
|
|
224
|
+
return null; // only PNG decodes without a dep
|
|
225
|
+
const buf = readFileSync(absPath);
|
|
226
|
+
const img = decodePNG(buf);
|
|
227
|
+
if (!img)
|
|
228
|
+
return null;
|
|
229
|
+
return renderHalfBlock(img, cols, maxRows);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Cheap dimensions probe for the metadata header (PNG only; null otherwise). */
|
|
236
|
+
export function imageDimensions(absPath) {
|
|
237
|
+
try {
|
|
238
|
+
const buf = readFileSync(absPath, { flag: 'r' });
|
|
239
|
+
if (buf.length < 24)
|
|
240
|
+
return null;
|
|
241
|
+
// PNG: IHDR width/height at bytes 16..24.
|
|
242
|
+
const isPng = buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47;
|
|
243
|
+
if (isPng)
|
|
244
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Trust AitherOS
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Trust AitherOS internal TLS certs.
|
|
3
|
+
//
|
|
4
|
+
// Reality of the fleet's PKI: each internal service ships a SELF-SIGNED leaf
|
|
5
|
+
// (e.g. Identity at :8115 → subject==issuer CN=aitheros-identity, no SAN for
|
|
6
|
+
// 127.0.0.1). Those certs are NOT issued by ca-chain.pem and carry no loopback
|
|
7
|
+
// SAN, so NODE_EXTRA_CA_CERTS can never validate them — under Node, fetch() to an
|
|
8
|
+
// https-only internal service (Identity) fails cert/hostname verification and the
|
|
9
|
+
// status banner false-flags it DOWN even though it's up. (Bun ignores
|
|
10
|
+
// NODE_EXTRA_CA_CERTS entirely; its per-request `tls` opt is handled in the probe.)
|
|
11
|
+
//
|
|
12
|
+
// So: when the primary endpoint is a LOCAL/loopback fleet (the self-signed trust
|
|
13
|
+
// domain), disable verification. For a PUBLIC endpoint (idp/gateway.aitherium.com,
|
|
14
|
+
// real CA-issued certs) keep strict TLS — those validate normally and must stay
|
|
15
|
+
// protected against MITM. NODE_EXTRA_CA_CERTS is still set when present (harmless,
|
|
16
|
+
// and helps any service that IS chain-issued).
|
|
5
17
|
import { existsSync } from 'fs';
|
|
6
18
|
import { join, basename } from 'path';
|
|
7
19
|
const _caChainPaths = [
|
|
@@ -10,11 +22,26 @@ const _caChainPaths = [
|
|
|
10
22
|
'/app/AitherOS/Library/Data/tls/ca-chain.pem',
|
|
11
23
|
];
|
|
12
24
|
const _caChain = _caChainPaths.find(p => p && existsSync(p));
|
|
13
|
-
if (_caChain)
|
|
25
|
+
if (_caChain)
|
|
14
26
|
process.env.NODE_EXTRA_CA_CERTS = _caChain;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
27
|
+
// Resolve the primary endpoint the same way loadConfig() does (env precedence),
|
|
28
|
+
// then decide if we're in the internal self-signed trust domain.
|
|
29
|
+
const _primaryUrl = process.env.AITHER_API_URL || process.env.AITHER_GENESIS_URL ||
|
|
30
|
+
process.env.AITHER_GATEWAY_URL || 'http://127.0.0.1:8001';
|
|
31
|
+
const _isLoopbackFleet = (() => {
|
|
32
|
+
try {
|
|
33
|
+
const h = new URL(_primaryUrl).hostname;
|
|
34
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '::1' ||
|
|
35
|
+
/^10\./.test(h) || /^192\.168\./.test(h) ||
|
|
36
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
|
|
37
|
+
h.endsWith('.local') || h.endsWith('.internal');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return true;
|
|
41
|
+
} // unparseable → assume local dev
|
|
42
|
+
})();
|
|
43
|
+
// Explicit override always wins; otherwise relax only for the loopback fleet.
|
|
44
|
+
if (process.env.NODE_TLS_REJECT_UNAUTHORIZED == null && _isLoopbackFleet) {
|
|
18
45
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
19
46
|
}
|
|
20
47
|
/**
|
|
@@ -79,7 +106,7 @@ async function resolveResume(client, config, mode, id) {
|
|
|
79
106
|
// Install global crash reporter — catches uncaught exceptions/rejections,
|
|
80
107
|
// prompts user to send error report, creates GitHub issue automatically.
|
|
81
108
|
installCrashReporter();
|
|
82
|
-
const VERSION = '1.
|
|
109
|
+
const VERSION = '1.12.0'; // keep in sync with package.json
|
|
83
110
|
async function main() {
|
|
84
111
|
const args = process.argv.slice(2);
|
|
85
112
|
if (args.includes('--help') || args.includes('-h')) {
|
package/dist/renderer.js
CHANGED
|
@@ -33,7 +33,7 @@ export function osc8Link(url, label) {
|
|
|
33
33
|
}
|
|
34
34
|
/* ── Banner ─────────────────────────────────────────────────── */
|
|
35
35
|
export function renderBanner(info) {
|
|
36
|
-
const version = 'v1.
|
|
36
|
+
const version = 'v1.12.0'; // keep in sync with package.json
|
|
37
37
|
const title = `AitherShell ${version}`;
|
|
38
38
|
let connected;
|
|
39
39
|
if (info.genesisOnline === true) {
|
package/dist/status-banner.js
CHANGED
|
@@ -13,14 +13,34 @@
|
|
|
13
13
|
* connect banner and the TUI share one implementation.
|
|
14
14
|
*/
|
|
15
15
|
import chalk from 'chalk';
|
|
16
|
+
/** Loopback/private hosts serve the internal self-signed CA. Bun's `fetch`
|
|
17
|
+
* ignores NODE_EXTRA_CA_CERTS (set in main.ts), so an https probe to an internal
|
|
18
|
+
* service — notably Identity at :8115, which is https-ONLY (http → 400) — fails
|
|
19
|
+
* cert validation and gets false-flagged DOWN even though it's up. Health probes
|
|
20
|
+
* carry no secrets, so accept the internal cert for private hosts; PUBLIC remote
|
|
21
|
+
* endpoints (idp/gateway, real valid certs) keep strict TLS validation. */
|
|
22
|
+
function insecureTlsFor(url) {
|
|
23
|
+
try {
|
|
24
|
+
const h = new URL(url).hostname;
|
|
25
|
+
const isPrivate = h === 'localhost' || h === '127.0.0.1' || h === '::1' ||
|
|
26
|
+
/^10\./.test(h) || /^192\.168\./.test(h) ||
|
|
27
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
|
|
28
|
+
h.endsWith('.local') || h.endsWith('.internal');
|
|
29
|
+
// `tls` is Bun's per-request option (the shipped binary is Bun-compiled);
|
|
30
|
+
// Node's fetch ignores it harmlessly.
|
|
31
|
+
return isPrivate ? { tls: { rejectUnauthorized: false } } : {};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
16
37
|
/** Probe a health endpoint, tolerating http↔https and a single cold-start miss. */
|
|
17
38
|
export async function probeHealth(url, timeoutMs = 4000) {
|
|
18
39
|
const tryFetch = async (u) => {
|
|
19
40
|
try {
|
|
20
41
|
const r = await fetch(u, {
|
|
21
42
|
signal: AbortSignal.timeout(timeoutMs),
|
|
22
|
-
|
|
23
|
-
...(u.startsWith('https') ? { dispatcher: undefined } : {}),
|
|
43
|
+
...insecureTlsFor(u),
|
|
24
44
|
});
|
|
25
45
|
return r.ok;
|
|
26
46
|
}
|
package/dist/tui/repl-tui.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* for those; everything else runs in-pane.
|
|
11
11
|
*/
|
|
12
12
|
import { execSync } from 'node:child_process';
|
|
13
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { dirname } from 'node:path';
|
|
15
15
|
import chalk from 'chalk';
|
|
16
16
|
import { setActiveConfig } from '../config.js';
|
|
@@ -21,6 +21,9 @@ import { collectArgs } from '../interactive.js';
|
|
|
21
21
|
import { gatherStatus, formatStatusLines } from '../status-banner.js';
|
|
22
22
|
import { setJobNotifier, listJobs, getJob, cancelJob, runningCount, launchChatJob, launchForgeJob, launchSwarmJob, launchCommandJob, formatJobLine, formatJobOutput, } from '../jobs.js';
|
|
23
23
|
import { configureRemoteSync, recordTurn, loadSession, buildContextSummary } from '../session-store.js';
|
|
24
|
+
import { openLocalImage } from '../renderer.js';
|
|
25
|
+
import { loadDoc, renderDocLines, resolveDocPath } from '../doc-view.js';
|
|
26
|
+
import { imageToAnsi, imageDimensions } from '../image-preview.js';
|
|
24
27
|
import { createTuiScreen } from './screen.js';
|
|
25
28
|
import { createTuiRenderer } from './controller.js';
|
|
26
29
|
import { setTuiActive } from '../crash-reporter.js';
|
|
@@ -625,6 +628,89 @@ export async function startTuiRepl(client, config) {
|
|
|
625
628
|
surface.outputLine(chalk.dim(' 📡 Left relay — back to agent chat.'));
|
|
626
629
|
idleStatus();
|
|
627
630
|
}
|
|
631
|
+
/** `/view <path>` — open a file in the in-TUI document viewer. Markdown/code/
|
|
632
|
+
* text render in a full-screen scrollable overlay; PNG images render inline as
|
|
633
|
+
* half-blocks on the real terminal (via runDetached, for true 24-bit colour);
|
|
634
|
+
* other image formats open in the OS viewer. */
|
|
635
|
+
async function handleView(args) {
|
|
636
|
+
const path = args.trim();
|
|
637
|
+
if (!path) {
|
|
638
|
+
surface.outputLine(chalk.yellow('Usage: /view <file>') + chalk.dim(' — markdown, code, text, or a PNG image'));
|
|
639
|
+
surface.focusInput();
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const doc = loadDoc(path);
|
|
643
|
+
if (doc.kind === 'missing' || doc.kind === 'binary') {
|
|
644
|
+
surface.outputLine(chalk.yellow(` ${doc.note || doc.kind}: `) + chalk.dim(doc.absPath));
|
|
645
|
+
surface.focusInput();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (doc.kind === 'image') {
|
|
649
|
+
const cols = Math.max(20, (process.stdout.columns || 100) - 4);
|
|
650
|
+
const ansi = imageToAnsi(doc.absPath, Math.min(cols, 120), 50);
|
|
651
|
+
if (ansi) {
|
|
652
|
+
const dims = imageDimensions(doc.absPath);
|
|
653
|
+
await surface.runDetached(`view ${doc.title}`, () => {
|
|
654
|
+
process.stdout.write(chalk.dim(` ${doc.title}${dims ? ` ${dims.width}×${dims.height}` : ''}\n\n`));
|
|
655
|
+
for (const l of ansi)
|
|
656
|
+
process.stdout.write(' ' + l + '\n');
|
|
657
|
+
});
|
|
658
|
+
idleStatus();
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
openLocalImage(doc.absPath);
|
|
662
|
+
surface.outputLine(chalk.dim(` 🖼 ${doc.ext.replace('.', '').toUpperCase()} opened in OS viewer (only PNG renders inline): `) + chalk.dim(doc.absPath));
|
|
663
|
+
surface.focusInput();
|
|
664
|
+
}
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const width = (process.stdout.columns || 100);
|
|
668
|
+
const { lines, fallbackOpenPath } = renderDocLines(doc, width);
|
|
669
|
+
if (fallbackOpenPath)
|
|
670
|
+
openLocalImage(fallbackOpenPath);
|
|
671
|
+
await surface.showViewer(doc.title, lines);
|
|
672
|
+
idleStatus();
|
|
673
|
+
}
|
|
674
|
+
/** `/edit <path>` — open a file in the in-TUI editor (notepad/vim-lite). Opens
|
|
675
|
+
* an empty buffer for a non-existent path (create-on-save). Refuses image/
|
|
676
|
+
* binary files. Ctrl+S writes the file; Esc/Ctrl+Q closes. */
|
|
677
|
+
async function handleEdit(args) {
|
|
678
|
+
const path = args.trim();
|
|
679
|
+
if (!path) {
|
|
680
|
+
surface.outputLine(chalk.yellow('Usage: /edit <file>') + chalk.dim(' — opens the in-shell editor (^S save · ^Q quit)'));
|
|
681
|
+
surface.focusInput();
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const absPath = resolveDocPath(path);
|
|
685
|
+
let initial = '';
|
|
686
|
+
if (existsSync(absPath)) {
|
|
687
|
+
const doc = loadDoc(path);
|
|
688
|
+
if (doc.kind === 'image' || doc.kind === 'binary') {
|
|
689
|
+
surface.outputLine(chalk.yellow(` cannot edit a ${doc.kind} file: `) + chalk.dim(absPath));
|
|
690
|
+
surface.focusInput();
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
initial = doc.text ?? '';
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
surface.outputLine(chalk.dim(` new file: ${absPath}`));
|
|
697
|
+
}
|
|
698
|
+
const title = absPath.split(/[/\\]/).pop() || path;
|
|
699
|
+
await surface.showEditor(title, initial, async (text) => {
|
|
700
|
+
try {
|
|
701
|
+
const dir = dirname(absPath);
|
|
702
|
+
if (!existsSync(dir))
|
|
703
|
+
mkdirSync(dir, { recursive: true });
|
|
704
|
+
writeFileSync(absPath, text, 'utf-8');
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
catch (e) {
|
|
708
|
+
return e?.message || 'write failed';
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
surface.outputLine(chalk.dim(` edited ${absPath}`));
|
|
712
|
+
idleStatus();
|
|
713
|
+
}
|
|
628
714
|
async function runCommand(input, bg = false) {
|
|
629
715
|
const sp = input.indexOf(' ');
|
|
630
716
|
const name = sp > 0 ? input.slice(1, sp) : input.slice(1);
|
|
@@ -643,6 +729,14 @@ export async function startTuiRepl(client, config) {
|
|
|
643
729
|
showHelp();
|
|
644
730
|
return;
|
|
645
731
|
}
|
|
732
|
+
if (name === 'view' || name === 'open' || name === 'cat') {
|
|
733
|
+
await handleView(args);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (name === 'edit' || name === 'e' || name === 'nano') {
|
|
737
|
+
await handleEdit(args);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
646
740
|
if (name === 'jobs') {
|
|
647
741
|
handleJobs(args);
|
|
648
742
|
return;
|
package/dist/tui/screen.d.ts
CHANGED
|
@@ -43,6 +43,13 @@ export interface TuiSurface {
|
|
|
43
43
|
pickerOpen(): boolean;
|
|
44
44
|
/** Show a filterable command picker; resolves with the chosen value or null. */
|
|
45
45
|
showPicker(title: string, items: PickerItem[], initialFilter?: string): Promise<string | null>;
|
|
46
|
+
/** Show a full-screen scrollable document viewer over pre-rendered lines
|
|
47
|
+
* (markdown/code/text). Resolves when the user closes it (q/Esc). */
|
|
48
|
+
showViewer(title: string, lines: string[]): Promise<void>;
|
|
49
|
+
/** Show a full-screen multi-line text editor seeded with `initialText`.
|
|
50
|
+
* `onSave(text)` persists and returns an error string (or null on success).
|
|
51
|
+
* Resolves when the user closes the editor (Esc/Ctrl+Q). */
|
|
52
|
+
showEditor(title: string, initialText: string, onSave: (text: string) => Promise<string | null>): Promise<void>;
|
|
46
53
|
/**
|
|
47
54
|
* Suspend the blessed screen, run `fn` on the REAL terminal (so command
|
|
48
55
|
* handlers' console.log / ora spinners / @inquirer prompts all work and don't
|
package/dist/tui/screen.js
CHANGED
|
@@ -726,11 +726,320 @@ export function createTuiScreen(opts) {
|
|
|
726
726
|
screen.render();
|
|
727
727
|
});
|
|
728
728
|
}
|
|
729
|
+
// ── Document viewer overlay (full-screen scrollable box) ──────
|
|
730
|
+
// Reuses the _pickerOpen guard so the main keypress handler stands down while
|
|
731
|
+
// the viewer owns the keyboard. Content is pre-rendered (markdown/code/text +
|
|
732
|
+
// chalk ANSI); blessed parses the SGR colour codes itself. Images do NOT come
|
|
733
|
+
// through here — they render via runDetached so 24-bit colour isn't downsampled.
|
|
734
|
+
function showViewer(title, lines) {
|
|
735
|
+
return new Promise((resolve) => {
|
|
736
|
+
_pickerOpen = true;
|
|
737
|
+
const box = blessed.box({
|
|
738
|
+
parent: screen, label: ` ${title} — ↑↓/PgUp/PgDn scroll · g/G top/bottom · q/Esc close `,
|
|
739
|
+
border: 'line', top: 0, left: 0, width: '100%', height: '100%',
|
|
740
|
+
tags: false, scrollable: true, alwaysScroll: true, keys: true, mouse: true,
|
|
741
|
+
scrollbar: { ch: ' ', inverse: true }, wrap: true,
|
|
742
|
+
style: { border: { fg: 'cyan' }, label: { fg: 'cyan' } },
|
|
743
|
+
});
|
|
744
|
+
box.setContent(lines.join('\n'));
|
|
745
|
+
function close() {
|
|
746
|
+
_pickerOpen = false;
|
|
747
|
+
try {
|
|
748
|
+
box.destroy();
|
|
749
|
+
}
|
|
750
|
+
catch { /* */ }
|
|
751
|
+
screen.render();
|
|
752
|
+
focusInput();
|
|
753
|
+
resolve();
|
|
754
|
+
}
|
|
755
|
+
const vpage = () => Math.max(1, box.height - 3);
|
|
756
|
+
box.key(['q', 'escape', 'C-c'], () => close());
|
|
757
|
+
box.key(['up', 'k'], () => { box.scroll(-1); screen.render(); });
|
|
758
|
+
box.key(['down', 'j'], () => { box.scroll(1); screen.render(); });
|
|
759
|
+
box.key(['pageup'], () => { box.scroll(-vpage()); screen.render(); });
|
|
760
|
+
box.key(['pagedown', 'space'], () => { box.scroll(vpage()); screen.render(); });
|
|
761
|
+
box.key(['g', 'home'], () => { box.scrollTo(0); screen.render(); });
|
|
762
|
+
box.key(['G', 'end'], () => { box.setScrollPerc(100); screen.render(); });
|
|
763
|
+
box.on('wheelup', () => { box.scroll(-3); screen.render(); });
|
|
764
|
+
box.on('wheeldown', () => { box.scroll(3); screen.render(); });
|
|
765
|
+
box.focus();
|
|
766
|
+
screen.render();
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
// ── Document editor overlay (notepad/vim-lite, manual keypresses) ─────
|
|
770
|
+
// A real multi-line editor. We drive it from the PROGRAM keypress stream (same
|
|
771
|
+
// reason as the main input box: blessed's textarea double-delivers keys on this
|
|
772
|
+
// terminal). Lines are stored as code-point arrays so emoji/CJK caret maths is
|
|
773
|
+
// correct. While open, _pickerOpen suppresses the main onKey handler and a
|
|
774
|
+
// dedicated keypress listener owns the keyboard until the user quits.
|
|
775
|
+
function showEditor(title, initialText, onSave) {
|
|
776
|
+
return new Promise((resolve) => {
|
|
777
|
+
_pickerOpen = true;
|
|
778
|
+
const lines = (initialText.length ? initialText.replace(/\r\n/g, '\n').split('\n') : [''])
|
|
779
|
+
.map((l) => Array.from(l));
|
|
780
|
+
let cr = 0; // cursor row
|
|
781
|
+
let cc = 0; // cursor col (code-point index)
|
|
782
|
+
let top = 0; // first visible row (vertical scroll)
|
|
783
|
+
let dirty = false;
|
|
784
|
+
let msg = ''; // transient status message
|
|
785
|
+
let confirmingQuit = false;
|
|
786
|
+
const box = blessed.box({
|
|
787
|
+
parent: screen, border: 'line', top: 0, left: 0, width: '100%', height: '100%',
|
|
788
|
+
tags: false, style: { border: { fg: 'yellow' }, label: { fg: 'yellow' } },
|
|
789
|
+
});
|
|
790
|
+
const innerH = () => Math.max(1, box.height - 2);
|
|
791
|
+
const innerW = () => Math.max(20, box.width - 2);
|
|
792
|
+
const gutterW = () => String(lines.length).length;
|
|
793
|
+
function setLabel() {
|
|
794
|
+
const flag = dirty ? chalk.red(' ●') : '';
|
|
795
|
+
const pos = `${cr + 1}:${cc + 1}`;
|
|
796
|
+
const hint = confirmingQuit
|
|
797
|
+
? chalk.red('unsaved — ^Q again to discard, ^S to save')
|
|
798
|
+
: chalk.dim('^S save · ^Q/Esc quit · arrows move · Enter newline');
|
|
799
|
+
const note = msg ? ' ' + chalk.green(msg) : '';
|
|
800
|
+
try {
|
|
801
|
+
box.setLabel(` ${title}${flag} ${chalk.dim(pos)} ${hint}${note} `);
|
|
802
|
+
}
|
|
803
|
+
catch { /* */ }
|
|
804
|
+
}
|
|
805
|
+
function clampView() {
|
|
806
|
+
if (cr < top)
|
|
807
|
+
top = cr;
|
|
808
|
+
if (cr >= top + innerH())
|
|
809
|
+
top = cr - innerH() + 1;
|
|
810
|
+
if (top < 0)
|
|
811
|
+
top = 0;
|
|
812
|
+
}
|
|
813
|
+
function render() {
|
|
814
|
+
clampView();
|
|
815
|
+
const g = gutterW();
|
|
816
|
+
const avail = innerW() - (g + 3); // gutter "NNN │ "
|
|
817
|
+
const out = [];
|
|
818
|
+
for (let r = top; r < Math.min(lines.length, top + innerH()); r++) {
|
|
819
|
+
const isCur = r === cr;
|
|
820
|
+
const cps = lines[r];
|
|
821
|
+
// Horizontal window so the caret stays visible on long lines.
|
|
822
|
+
let start = 0;
|
|
823
|
+
if (isCur && cc > avail - 1)
|
|
824
|
+
start = cc - (avail - 1);
|
|
825
|
+
const slice = cps.slice(start, start + avail);
|
|
826
|
+
const gutter = chalk.dim(String(r + 1).padStart(g, ' ') + ' │ ');
|
|
827
|
+
if (isCur) {
|
|
828
|
+
const rel = cc - start;
|
|
829
|
+
const before = slice.slice(0, rel).join('');
|
|
830
|
+
const atCh = slice[rel] ?? ' ';
|
|
831
|
+
const after = slice.slice(rel + 1).join('');
|
|
832
|
+
const lead = start > 0 ? chalk.dim('‹') : '';
|
|
833
|
+
out.push(gutter + lead + before + chalk.inverse(atCh) + after);
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
out.push(gutter + slice.join(''));
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
try {
|
|
840
|
+
box.setContent(out.join('\n'));
|
|
841
|
+
}
|
|
842
|
+
catch { /* */ }
|
|
843
|
+
setLabel();
|
|
844
|
+
scheduleRender();
|
|
845
|
+
}
|
|
846
|
+
function curLine() { return lines[cr]; }
|
|
847
|
+
async function doSave() {
|
|
848
|
+
const text = lines.map((l) => l.join('')).join('\n');
|
|
849
|
+
const err = await onSave(text);
|
|
850
|
+
if (err) {
|
|
851
|
+
msg = '';
|
|
852
|
+
setLabel();
|
|
853
|
+
try {
|
|
854
|
+
box.setLabel(` ${title} ${chalk.red('save failed: ' + err)} `);
|
|
855
|
+
}
|
|
856
|
+
catch { /* */ }
|
|
857
|
+
scheduleRender();
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
dirty = false;
|
|
861
|
+
confirmingQuit = false;
|
|
862
|
+
msg = 'saved';
|
|
863
|
+
render();
|
|
864
|
+
setTimeout(() => { msg = ''; setLabel(); scheduleRender(); }, 1500);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
function close() {
|
|
868
|
+
screen.program.removeListener('keypress', editorKey);
|
|
869
|
+
_pickerOpen = false;
|
|
870
|
+
try {
|
|
871
|
+
box.destroy();
|
|
872
|
+
}
|
|
873
|
+
catch { /* */ }
|
|
874
|
+
screen.render();
|
|
875
|
+
focusInput();
|
|
876
|
+
resolve();
|
|
877
|
+
}
|
|
878
|
+
function editorKey(ch, key) {
|
|
879
|
+
const name = (key && key.name) || '';
|
|
880
|
+
const ctrl = !!(key && key.ctrl);
|
|
881
|
+
// Save / quit.
|
|
882
|
+
if (ctrl && name === 's') {
|
|
883
|
+
void doSave();
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if ((ctrl && name === 'q') || name === 'escape') {
|
|
887
|
+
if (dirty && !confirmingQuit) {
|
|
888
|
+
confirmingQuit = true;
|
|
889
|
+
setLabel();
|
|
890
|
+
scheduleRender();
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
close();
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
confirmingQuit = false;
|
|
897
|
+
// Navigation.
|
|
898
|
+
if (name === 'up') {
|
|
899
|
+
if (cr > 0) {
|
|
900
|
+
cr--;
|
|
901
|
+
cc = Math.min(cc, curLine().length);
|
|
902
|
+
}
|
|
903
|
+
render();
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (name === 'down') {
|
|
907
|
+
if (cr < lines.length - 1) {
|
|
908
|
+
cr++;
|
|
909
|
+
cc = Math.min(cc, curLine().length);
|
|
910
|
+
}
|
|
911
|
+
render();
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
if (name === 'left') {
|
|
915
|
+
if (cc > 0)
|
|
916
|
+
cc--;
|
|
917
|
+
else if (cr > 0) {
|
|
918
|
+
cr--;
|
|
919
|
+
cc = curLine().length;
|
|
920
|
+
}
|
|
921
|
+
render();
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
if (name === 'right') {
|
|
925
|
+
if (cc < curLine().length)
|
|
926
|
+
cc++;
|
|
927
|
+
else if (cr < lines.length - 1) {
|
|
928
|
+
cr++;
|
|
929
|
+
cc = 0;
|
|
930
|
+
}
|
|
931
|
+
render();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (name === 'home') {
|
|
935
|
+
cc = 0;
|
|
936
|
+
render();
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (name === 'end') {
|
|
940
|
+
cc = curLine().length;
|
|
941
|
+
render();
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (name === 'pageup') {
|
|
945
|
+
cr = Math.max(0, cr - innerH());
|
|
946
|
+
cc = Math.min(cc, curLine().length);
|
|
947
|
+
render();
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
if (name === 'pagedown') {
|
|
951
|
+
cr = Math.min(lines.length - 1, cr + innerH());
|
|
952
|
+
cc = Math.min(cc, curLine().length);
|
|
953
|
+
render();
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
// Editing.
|
|
957
|
+
if (name === 'enter' || name === 'return' || name === 'linefeed') {
|
|
958
|
+
const cur = curLine();
|
|
959
|
+
const tail = cur.slice(cc);
|
|
960
|
+
lines[cr] = cur.slice(0, cc);
|
|
961
|
+
lines.splice(cr + 1, 0, tail);
|
|
962
|
+
cr++;
|
|
963
|
+
cc = 0;
|
|
964
|
+
dirty = true;
|
|
965
|
+
render();
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (name === 'backspace') {
|
|
969
|
+
if (cc > 0) {
|
|
970
|
+
curLine().splice(cc - 1, 1);
|
|
971
|
+
cc--;
|
|
972
|
+
dirty = true;
|
|
973
|
+
}
|
|
974
|
+
else if (cr > 0) {
|
|
975
|
+
const cur = lines.splice(cr, 1)[0];
|
|
976
|
+
cc = lines[cr - 1].length;
|
|
977
|
+
lines[cr - 1] = lines[cr - 1].concat(cur);
|
|
978
|
+
cr--;
|
|
979
|
+
dirty = true;
|
|
980
|
+
}
|
|
981
|
+
render();
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (name === 'delete') {
|
|
985
|
+
if (cc < curLine().length) {
|
|
986
|
+
curLine().splice(cc, 1);
|
|
987
|
+
dirty = true;
|
|
988
|
+
}
|
|
989
|
+
else if (cr < lines.length - 1) {
|
|
990
|
+
const next = lines.splice(cr + 1, 1)[0];
|
|
991
|
+
lines[cr] = curLine().concat(next);
|
|
992
|
+
dirty = true;
|
|
993
|
+
}
|
|
994
|
+
render();
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (name === 'tab') {
|
|
998
|
+
curLine().splice(cc, 0, ' ', ' ');
|
|
999
|
+
cc += 2;
|
|
1000
|
+
dirty = true;
|
|
1001
|
+
render();
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
// Printable insert (paste may deliver several chars; respects newlines).
|
|
1005
|
+
if (ch && typeof ch === 'string' && ch.length >= 1 && ch >= ' ' && !(key && (key.ctrl || key.meta))) {
|
|
1006
|
+
for (const c of Array.from(ch)) {
|
|
1007
|
+
curLine().splice(cc, 0, c);
|
|
1008
|
+
cc++;
|
|
1009
|
+
}
|
|
1010
|
+
dirty = true;
|
|
1011
|
+
render();
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
// Multi-line paste arriving as a chunk with embedded newlines.
|
|
1015
|
+
if (ch && typeof ch === 'string' && ch.includes('\n')) {
|
|
1016
|
+
for (const c of Array.from(ch)) {
|
|
1017
|
+
if (c === '\n') {
|
|
1018
|
+
const tail = curLine().slice(cc);
|
|
1019
|
+
lines[cr] = curLine().slice(0, cc);
|
|
1020
|
+
lines.splice(cr + 1, 0, tail);
|
|
1021
|
+
cr++;
|
|
1022
|
+
cc = 0;
|
|
1023
|
+
}
|
|
1024
|
+
else if (c >= ' ') {
|
|
1025
|
+
curLine().splice(cc, 0, c);
|
|
1026
|
+
cc++;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
dirty = true;
|
|
1030
|
+
render();
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
screen.program.on('keypress', editorKey);
|
|
1035
|
+
render();
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
729
1038
|
renderInput();
|
|
730
1039
|
screen.render();
|
|
731
1040
|
return {
|
|
732
1041
|
screen, input, appendOutput, outputLine, markCheckpoint, replaceOutputFrom, prependOutput,
|
|
733
1042
|
traceLine, startTraceTurn, finishTraceTurn, setStatus, setTracePanel, setOutputLabel, clearPanes, toggleTrace, render, focusInput, destroy,
|
|
734
|
-
pickerOpen, showPicker, runDetached,
|
|
1043
|
+
pickerOpen, showPicker, showViewer, showEditor, runDetached,
|
|
735
1044
|
};
|
|
736
1045
|
}
|