@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 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
- // Intermediate warning if no data for 60s
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
- warnTimer = setTimeout(() => {
562
- warningSent = true;
563
- }, WARN_TIMEOUT_MS);
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([readPromise, timeoutPromise]);
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;
@@ -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;