@aitherium/shell-cli 1.11.1 → 1.12.1
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/client.d.ts +10 -0
- package/dist/client.js +62 -6
- package/dist/commands.js +153 -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/install-wizard.d.ts +94 -0
- package/dist/install-wizard.js +397 -0
- package/dist/main.js +36 -9
- package/dist/renderer.js +52 -4
- package/dist/repl.js +204 -10
- package/dist/status-banner.js +22 -2
- package/dist/tui/controller.js +37 -0
- package/dist/tui/repl-tui.js +222 -10
- package/dist/tui/screen.d.ts +12 -0
- package/dist/tui/screen.js +326 -1
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -96,6 +96,16 @@ export declare class GenesisClient {
|
|
|
96
96
|
effort?: number;
|
|
97
97
|
}): Promise<any>;
|
|
98
98
|
getLogs(limit?: number, level?: string, service?: string): Promise<any>;
|
|
99
|
+
/** List all expeditions (unified chat jobs, promoted jobs, real expeditions). */
|
|
100
|
+
listExpeditions(): Promise<any>;
|
|
101
|
+
/** Get expedition status summary. */
|
|
102
|
+
getExpeditionStatus(id: string): Promise<any>;
|
|
103
|
+
/** Get expedition tasks. */
|
|
104
|
+
getExpeditionTasks(id: string): Promise<any>;
|
|
105
|
+
/** Steer an expedition with a message (append or hint). */
|
|
106
|
+
steerExpedition(id: string, message: string, action?: 'append' | 'hint'): Promise<any>;
|
|
107
|
+
/** Stream SSE events from an expedition. */
|
|
108
|
+
streamExpeditionEvents(id: string): AsyncGenerator<SSEEvent>;
|
|
99
109
|
getLLMStatus(): Promise<any>;
|
|
100
110
|
/** Resolve the MicroScheduler base URL the same way getLLMStatus does. */
|
|
101
111
|
private _msCandidates;
|
package/dist/client.js
CHANGED
|
@@ -305,6 +305,49 @@ export class GenesisClient {
|
|
|
305
305
|
params.set('service', service);
|
|
306
306
|
return this.get(`/chronicle/logs?${params}`);
|
|
307
307
|
}
|
|
308
|
+
/* ── Cloud Expeditions (durable jobs) ─────────────────────── */
|
|
309
|
+
// NOTE: use getDetailed (NOT get) — get() collapses any error to `null`, which
|
|
310
|
+
// is indistinguishable from "no expeditions". getDetailed preserves {error} so
|
|
311
|
+
// the REPL can show a real diagnostic (genesis down / 404) vs an empty list.
|
|
312
|
+
/** List all expeditions (unified chat jobs, promoted jobs, real expeditions). */
|
|
313
|
+
async listExpeditions() {
|
|
314
|
+
return this.getDetailed('/expedition/list');
|
|
315
|
+
}
|
|
316
|
+
/** Get expedition status summary. */
|
|
317
|
+
async getExpeditionStatus(id) {
|
|
318
|
+
return this.getDetailed(`/expedition/${id}/status`);
|
|
319
|
+
}
|
|
320
|
+
/** Get expedition tasks. */
|
|
321
|
+
async getExpeditionTasks(id) {
|
|
322
|
+
return this.getDetailed(`/expedition/${id}/tasks`);
|
|
323
|
+
}
|
|
324
|
+
/** Steer an expedition with a message (append or hint). */
|
|
325
|
+
async steerExpedition(id, message, action = 'append') {
|
|
326
|
+
return this.post(`/expedition/${id}/steer`, { message, action });
|
|
327
|
+
}
|
|
328
|
+
/** Stream SSE events from an expedition. */
|
|
329
|
+
async *streamExpeditionEvents(id) {
|
|
330
|
+
try {
|
|
331
|
+
const r = await fetch(`${this.baseUrl}/expedition/${id}/stream`, {
|
|
332
|
+
headers: { 'X-Caller-Type': 'PLATFORM', ...this.authHeaders() },
|
|
333
|
+
signal: AbortSignal.timeout(600_000), // 10 min timeout for watching
|
|
334
|
+
});
|
|
335
|
+
if (!r.ok) {
|
|
336
|
+
yield {
|
|
337
|
+
type: 'error',
|
|
338
|
+
data: { type: 'error', error: `HTTP ${r.status}` },
|
|
339
|
+
};
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
yield* this.readSSE(r);
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
yield {
|
|
346
|
+
type: 'error',
|
|
347
|
+
data: { type: 'error', error: err?.message || 'Stream failed' },
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
308
351
|
async getLLMStatus() {
|
|
309
352
|
try {
|
|
310
353
|
const candidates = [process.env.AITHER_LLM_URL, 'https://localhost:8150', 'http://localhost:8150'].filter(Boolean);
|
|
@@ -541,11 +584,15 @@ export class GenesisClient {
|
|
|
541
584
|
const WARN_TIMEOUT_MS = 60_000; // warn after 60s of silence
|
|
542
585
|
let lastChunkAt = Date.now();
|
|
543
586
|
let warningSent = false;
|
|
587
|
+
// Retain the in-flight read across a warning-yield so reader.read() is
|
|
588
|
+
// never called twice concurrently (that throws on a locked reader).
|
|
589
|
+
let pendingRead = null;
|
|
544
590
|
try {
|
|
545
591
|
while (true) {
|
|
546
592
|
// Race reader.read() against a timeout so the client never hangs
|
|
547
593
|
// if the backend stalls (e.g. reasoning model cold-start, 404 retry loop).
|
|
548
|
-
const readPromise = reader.read();
|
|
594
|
+
const readPromise = pendingRead ?? reader.read();
|
|
595
|
+
pendingRead = readPromise;
|
|
549
596
|
// IMPORTANT: keep a handle to the timeout timer and clear it once the
|
|
550
597
|
// chunk arrives. Otherwise every chunk (hundreds per streamed turn) leaks
|
|
551
598
|
// a live 120s timer; the swarm of late, post-settle rejections keeps the
|
|
@@ -555,16 +602,18 @@ export class GenesisClient {
|
|
|
555
602
|
const timeoutPromise = new Promise((_, reject) => {
|
|
556
603
|
chunkTimer = setTimeout(() => reject(new Error('SSE chunk timeout')), CHUNK_TIMEOUT_MS);
|
|
557
604
|
});
|
|
558
|
-
//
|
|
605
|
+
// Warn after 60s of silence WITHOUT consuming the read: race a sentinel
|
|
606
|
+
// alongside it so we can surface "still working" and keep waiting.
|
|
559
607
|
let warnTimer = null;
|
|
608
|
+
const racers = [readPromise, timeoutPromise];
|
|
560
609
|
if (!warningSent) {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
}
|
|
610
|
+
racers.push(new Promise((res) => {
|
|
611
|
+
warnTimer = setTimeout(() => res({ __warn: true }), WARN_TIMEOUT_MS);
|
|
612
|
+
}));
|
|
564
613
|
}
|
|
565
614
|
let result;
|
|
566
615
|
try {
|
|
567
|
-
result = await Promise.race(
|
|
616
|
+
result = await Promise.race(racers);
|
|
568
617
|
if (chunkTimer)
|
|
569
618
|
clearTimeout(chunkTimer);
|
|
570
619
|
if (warnTimer)
|
|
@@ -601,6 +650,13 @@ export class GenesisClient {
|
|
|
601
650
|
}
|
|
602
651
|
break;
|
|
603
652
|
}
|
|
653
|
+
// Silence warning fired — surface it and keep waiting on the SAME read.
|
|
654
|
+
if (result.__warn) {
|
|
655
|
+
warningSent = true;
|
|
656
|
+
yield { type: 'stream_warning', data: { type: 'stream_warning', silent_ms: Date.now() - lastChunkAt, message: 'Still working — model may be warming up…' } };
|
|
657
|
+
continue; // pendingRead retained → no second reader.read()
|
|
658
|
+
}
|
|
659
|
+
pendingRead = null; // real chunk consumed
|
|
604
660
|
lastChunkAt = Date.now();
|
|
605
661
|
const { done, value } = result;
|
|
606
662
|
if (done)
|
package/dist/commands.js
CHANGED
|
@@ -14,6 +14,7 @@ import { getRemoteMcpClient } from './mcp-client.js';
|
|
|
14
14
|
import { formatTable, getSessionArtifacts, clearSessionArtifacts, resolveImagePath, osc8Link, addSessionArtifact } from './renderer.js';
|
|
15
15
|
import { loadSession, saveSession, listSessions, deleteSession, transcriptMarkdown, sessionTokens, } from './session-store.js';
|
|
16
16
|
import { loginWithPassword, verify2FA, register, logoutSession, validateToken, buildProfile, setProfile, clearProfile, getActiveProfile, getActiveUser, getActiveToken, requestEmailOTP, verifyEmailOTP, requestDeviceCode, pollDeviceToken, } from './auth.js';
|
|
17
|
+
import { runWizard } from './install-wizard.js';
|
|
17
18
|
const ONBOARD_CODE_EXTENSIONS = new Set([
|
|
18
19
|
'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.java', '.go', '.rs', '.cs',
|
|
19
20
|
'.cpp', '.cc', '.c', '.h', '.hpp', '.rb', '.php', '.swift', '.kt', '.kts', '.scala',
|
|
@@ -279,6 +280,13 @@ const COMMANDS = {
|
|
|
279
280
|
console.log();
|
|
280
281
|
},
|
|
281
282
|
},
|
|
283
|
+
// ── Installation & onboarding ──
|
|
284
|
+
install: {
|
|
285
|
+
description: 'One-click installer wizard (non-technical setup)',
|
|
286
|
+
handler: async (client, _args, config) => {
|
|
287
|
+
await runWizard(client, config);
|
|
288
|
+
},
|
|
289
|
+
},
|
|
282
290
|
// ── Conversation QoL (transcript-backed) ──
|
|
283
291
|
chats: {
|
|
284
292
|
description: 'List, resume, or delete saved chat conversations',
|
|
@@ -761,6 +769,151 @@ const COMMANDS = {
|
|
|
761
769
|
console.log();
|
|
762
770
|
},
|
|
763
771
|
},
|
|
772
|
+
spec: {
|
|
773
|
+
description: "Spec Ledger — list/inspect specs, ingest a PRD, publish issues, reasoner",
|
|
774
|
+
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]]',
|
|
775
|
+
handler: async (client, args) => {
|
|
776
|
+
const parts = args.trim().split(/\s+/);
|
|
777
|
+
const sub = (parts[0] || 'list').toLowerCase();
|
|
778
|
+
const flag = (name) => {
|
|
779
|
+
const m = args.match(new RegExp(`--${name}\\s+("[^"]+"|\\S+)`));
|
|
780
|
+
return m ? m[1].replace(/^"|"$/g, '') : '';
|
|
781
|
+
};
|
|
782
|
+
if (sub === 'list' || sub === '') {
|
|
783
|
+
const spinner = ora('Loading specs...').start();
|
|
784
|
+
const r = await client.get('/spec/list?limit=100');
|
|
785
|
+
spinner.stop();
|
|
786
|
+
const specs = r?.specs || [];
|
|
787
|
+
if (!specs.length) {
|
|
788
|
+
console.log(chalk.dim(' No specs ingested yet — /spec ingest <path|url>'));
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
console.log();
|
|
792
|
+
for (const s of specs) {
|
|
793
|
+
console.log(` ${chalk.cyan(s.id)} ${chalk.bold(s.title)}`);
|
|
794
|
+
console.log(chalk.dim(` ${s.requirement_count} reqs · ${s.conformance_pct}% conformant${s.target_pack ? ` · pack ${s.target_pack}` : ''}`));
|
|
795
|
+
}
|
|
796
|
+
console.log();
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (sub === 'status') {
|
|
800
|
+
const id = parts[1];
|
|
801
|
+
if (!id) {
|
|
802
|
+
console.log(chalk.dim(' Usage: /spec status <spec_id>'));
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
const spinner = ora('Loading...').start();
|
|
806
|
+
const r = await client.get(`/spec/${encodeURIComponent(id)}/status`);
|
|
807
|
+
spinner.stop();
|
|
808
|
+
if (!r || r.error || r.detail) {
|
|
809
|
+
console.log(chalk.red(` ${r?.error || r?.detail || 'not found'}`));
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const ov = r.coverage?.overall || {};
|
|
813
|
+
console.log();
|
|
814
|
+
console.log(` ${chalk.bold(r.title)} ${chalk.dim(`(${r.target_pack || 'untriaged'})`)}`);
|
|
815
|
+
console.log(` ${ov.pct ?? 0}% conformant — ${ov.satisfied ?? 0}/${ov.total ?? 0} satisfied, ${(r.gaps || []).length} gaps`);
|
|
816
|
+
for (const [cat, c] of Object.entries(r.coverage?.by_category || {})) {
|
|
817
|
+
console.log(chalk.dim(` ${cat}: ${c.satisfied}/${c.total} (${c.pct}%)`));
|
|
818
|
+
}
|
|
819
|
+
console.log();
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (sub === 'ingest') {
|
|
823
|
+
const src = parts[1];
|
|
824
|
+
if (!src) {
|
|
825
|
+
console.log(chalk.dim(' Usage: /spec ingest <path|url> [--pack x]'));
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const body = { background: false };
|
|
829
|
+
body[src.startsWith('http') ? 'url' : 'path'] = src;
|
|
830
|
+
const pack = flag('pack');
|
|
831
|
+
if (pack)
|
|
832
|
+
body.target_pack = pack;
|
|
833
|
+
const spinner = ora('Ingesting + extracting (reasoner)...').start();
|
|
834
|
+
const r = await client.post('/spec/ingest', body);
|
|
835
|
+
spinner.stop();
|
|
836
|
+
if (!r || r.error) {
|
|
837
|
+
console.log(chalk.red(` ${r?.error || 'ingest failed'}`));
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
console.log(chalk.green(` Ingested ${chalk.cyan(r.spec_doc_id)} — ${r.requirement_count ?? 0} requirements`));
|
|
841
|
+
if (r.by_category)
|
|
842
|
+
console.log(chalk.dim(` ${Object.entries(r.by_category).map(([k, v]) => `${k}:${v}`).join(', ')}`));
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (sub === 'reasoner') {
|
|
846
|
+
const mode = parts[1];
|
|
847
|
+
const r = mode
|
|
848
|
+
? await client.post('/spec/reasoner', { mode })
|
|
849
|
+
: await client.get('/spec/reasoner');
|
|
850
|
+
if (!r || r.error) {
|
|
851
|
+
console.log(chalk.red(` ${r?.error || 'reasoner call failed'}`));
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
console.log(` Reasoner: ${chalk.cyan(r.effective)} ${chalk.dim(`(available: ${(r.available_modes || []).join(', ')})`)}`);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (sub === 'settings') {
|
|
858
|
+
const repo = flag('repo'), board = flag('board');
|
|
859
|
+
let r;
|
|
860
|
+
if (repo || board) {
|
|
861
|
+
const body = {};
|
|
862
|
+
if (repo)
|
|
863
|
+
body.publish_repo = repo;
|
|
864
|
+
if (board)
|
|
865
|
+
body.publish_project = board;
|
|
866
|
+
r = await client.post('/spec/settings', body);
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
r = await client.get('/spec/settings');
|
|
870
|
+
}
|
|
871
|
+
if (!r || r.error) {
|
|
872
|
+
console.log(chalk.red(` ${r?.error || 'settings failed'}`));
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
console.log(` Reasoner: ${chalk.cyan(r.reasoner_mode)} · publish_repo: ${chalk.cyan(r.publish_repo || '(unset)')} · board: ${chalk.cyan(r.publish_project || '(unset)')}`);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (sub === 'publish') {
|
|
879
|
+
const id = parts[1];
|
|
880
|
+
if (!id) {
|
|
881
|
+
console.log(chalk.dim(' Usage: /spec publish <id> [--repo o/n] [--new "Title"|--board o/n|--no-board] [--go]'));
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const go = /--go\b/.test(args);
|
|
885
|
+
const body = { dry_run: !go };
|
|
886
|
+
const repo = flag('repo');
|
|
887
|
+
if (repo)
|
|
888
|
+
body.repo = repo; // else server uses spec_settings default
|
|
889
|
+
const newTitle = flag('new');
|
|
890
|
+
const board = flag('board');
|
|
891
|
+
if (newTitle)
|
|
892
|
+
body.create_project_title = newTitle;
|
|
893
|
+
else if (board)
|
|
894
|
+
body.project = board;
|
|
895
|
+
// --no-board: leave both unset → issues only
|
|
896
|
+
const spinner = ora(go ? 'Publishing issues...' : 'Dry-run preview...').start();
|
|
897
|
+
const r = await client.post(`/spec/${encodeURIComponent(id)}/publish-issues`, body);
|
|
898
|
+
spinner.stop();
|
|
899
|
+
if (!r || (r.error && !r.created)) {
|
|
900
|
+
console.log(chalk.red(` ${r?.error || 'publish failed'}`));
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const created = r.created?.length ?? 0, skipped = r.skipped_existing?.length ?? 0, errs = r.errors?.length ?? 0;
|
|
904
|
+
console.log();
|
|
905
|
+
console.log(` ${go ? chalk.green(`Published ${created}`) : chalk.yellow(`Would create ${created}`)} issue(s) on ${chalk.cyan(r.repo)}` +
|
|
906
|
+
(skipped ? `, skipped ${skipped}` : '') + (errs ? chalk.red(`, ${errs} errors`) : '') + (r.project ? ` → board ${chalk.cyan(r.project)}` : ''));
|
|
907
|
+
if (errs)
|
|
908
|
+
console.log(chalk.red(` ${r.errors[0]}`));
|
|
909
|
+
if (!go)
|
|
910
|
+
console.log(chalk.dim(' (dry-run — add --go to actually publish)'));
|
|
911
|
+
console.log();
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
console.log(chalk.dim(' Usage: /spec [list | status <id> | ingest <path|url> | publish <id> | reasoner [mode] | settings]'));
|
|
915
|
+
},
|
|
916
|
+
},
|
|
764
917
|
atlas: {
|
|
765
918
|
description: "Atlas PM board — latest cycle, in-flight items, or run a cycle",
|
|
766
919
|
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;
|