@vierratale/ai 0.1.0-beta.1 → 0.1.0-beta.10

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/src/session.js ADDED
@@ -0,0 +1,221 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { Config } from './config.js';
4
+
5
+ const ROOT = join(Config.getConfigDir(), 'sessions');
6
+ const ACTIVE_FILE = join(ROOT, '.active');
7
+ const MIGRATED_FLAG = join(ROOT, '.migrated');
8
+ const MAX_MESSAGES = 20;
9
+
10
+ const sanitize = (name) =>
11
+ String(name || '').replace(/[^A-Za-z0-9._-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'default';
12
+
13
+ const pathFor = (name) => join(ROOT, `${sanitize(name)}.json`);
14
+ const backupPathFor = (name) => join(ROOT, `${sanitize(name)}.bak.json`);
15
+
16
+ const autoName = () => {
17
+ const d = new Date();
18
+ const p = (n) => String(n).padStart(2, '0');
19
+ return `session-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${p(d.getMilliseconds())}`;
20
+ };
21
+
22
+ function ensureDir() {
23
+ if (!existsSync(ROOT)) mkdirSync(ROOT, { recursive: true });
24
+ }
25
+
26
+ function migrateLegacy() {
27
+ // One-time move of the old single history.json into a session named "default".
28
+ // The .migrated flag guarantees the legacy file can never resurrect a deleted
29
+ // "default" session on later runs.
30
+ try {
31
+ const legacy = join(Config.getConfigDir(), 'history.json');
32
+ if (existsSync(legacy) && !existsSync(MIGRATED_FLAG)) {
33
+ if (!existsSync(pathFor('default'))) {
34
+ const data = JSON.parse(readFileSync(legacy, 'utf-8') || '[]');
35
+ if (Array.isArray(data) && data.length) {
36
+ writeFileSync(pathFor('default'), JSON.stringify(data.slice(-MAX_MESSAGES), null, 2));
37
+ }
38
+ }
39
+ writeFileSync(MIGRATED_FLAG, new Date().toISOString());
40
+ }
41
+ } catch {}
42
+ }
43
+
44
+ export const Session = {
45
+ activeName() {
46
+ ensureDir();
47
+ migrateLegacy();
48
+ try {
49
+ if (existsSync(ACTIVE_FILE)) {
50
+ const name = readFileSync(ACTIVE_FILE, 'utf-8').trim();
51
+ if (name && existsSync(pathFor(name))) return name;
52
+ }
53
+ } catch {}
54
+ if (!existsSync(pathFor('default'))) writeFileSync(pathFor('default'), '[]');
55
+ writeFileSync(ACTIVE_FILE, 'default');
56
+ return 'default';
57
+ },
58
+
59
+ list() {
60
+ ensureDir();
61
+ migrateLegacy();
62
+ const out = [];
63
+ const active = this.activeName();
64
+ for (const f of readdirSync(ROOT)) {
65
+ if (!f.endsWith('.json')) continue;
66
+ const name = f.slice(0, -5);
67
+ try {
68
+ const msgs = JSON.parse(readFileSync(join(ROOT, f), 'utf-8') || '[]');
69
+ const first = msgs.find((m) => m.role === 'user');
70
+ out.push({
71
+ name,
72
+ count: msgs.length,
73
+ preview: first ? String(first.content).slice(0, 60) : '(empty)',
74
+ updated: statSync(join(ROOT, f)).mtimeMs,
75
+ active: name === active,
76
+ });
77
+ } catch {}
78
+ }
79
+ out.sort((a, b) => b.updated - a.updated);
80
+ return out;
81
+ },
82
+
83
+ create(name) {
84
+ ensureDir();
85
+ const safe = sanitize(name || autoName());
86
+ writeFileSync(pathFor(safe), '[]');
87
+ writeFileSync(ACTIVE_FILE, safe);
88
+ return safe;
89
+ },
90
+
91
+ open(name) {
92
+ const sessions = this.list();
93
+ let target = name;
94
+ if (/^#\d+$/.test(name)) {
95
+ const idx = parseInt(name.slice(1), 10);
96
+ const s = sessions[idx - 1];
97
+ if (!s) return null;
98
+ target = s.name;
99
+ }
100
+ const safe = sanitize(target);
101
+ if (!existsSync(pathFor(safe))) return null;
102
+ writeFileSync(ACTIVE_FILE, safe);
103
+ return safe;
104
+ },
105
+
106
+ remove(name) {
107
+ const sessions = this.list();
108
+ let target = name;
109
+ if (/^#\d+$/.test(name)) {
110
+ const idx = parseInt(name.slice(1), 10);
111
+ const s = sessions[idx - 1];
112
+ if (!s) return null;
113
+ target = s.name;
114
+ }
115
+ const safe = sanitize(target);
116
+ const file = pathFor(safe);
117
+ if (!existsSync(file)) return null;
118
+ if (safe === this.activeName()) {
119
+ const others = sessions.filter((s) => s.name !== safe);
120
+ if (others.length) {
121
+ writeFileSync(ACTIVE_FILE, others[0].name);
122
+ } else {
123
+ writeFileSync(ACTIVE_FILE, this.create(null));
124
+ }
125
+ }
126
+ try { unlinkSync(file); } catch {}
127
+ return safe;
128
+ },
129
+
130
+ load() {
131
+ return this.loadActive();
132
+ },
133
+
134
+ loadActive() {
135
+ try {
136
+ const path = pathFor(this.activeName());
137
+ if (existsSync(path)) {
138
+ const data = JSON.parse(readFileSync(path, 'utf-8') || '[]');
139
+ if (Array.isArray(data)) return data.slice(-MAX_MESSAGES);
140
+ }
141
+ } catch {}
142
+ return [];
143
+ },
144
+
145
+ save(messages) {
146
+ try {
147
+ ensureDir();
148
+ writeFileSync(pathFor(this.activeName()), JSON.stringify(messages.slice(-MAX_MESSAGES), null, 2));
149
+ } catch {}
150
+ },
151
+
152
+ // Snapshot the active session to a timestamped .bak.json before it is
153
+ // replaced or cleared, so a fresh-start or /new never loses the old talk.
154
+ autoBackup() {
155
+ try {
156
+ ensureDir();
157
+ const name = this.activeName();
158
+ const src = pathFor(name);
159
+ if (!existsSync(src)) return null;
160
+ const msgs = JSON.parse(readFileSync(src, 'utf-8') || '[]');
161
+ if (!Array.isArray(msgs) || msgs.length === 0) return null;
162
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
163
+ const dest = backupPathFor(`${name}.${stamp}`);
164
+ writeFileSync(dest, JSON.stringify(msgs, null, 2));
165
+ return `${name}.${stamp}.bak.json`;
166
+ } catch { return null; }
167
+ },
168
+
169
+ backup() {
170
+ return this.autoBackup();
171
+ },
172
+
173
+ // List .bak.json recovery copies for the sessions UI.
174
+ listBackups() {
175
+ ensureDir();
176
+ const out = [];
177
+ for (const f of readdirSync(ROOT)) {
178
+ if (!f.endsWith('.bak.json')) continue;
179
+ const name = f.slice(0, -'.bak.json'.length);
180
+ try {
181
+ out.push({ name, file: f, updated: statSync(join(ROOT, f)).mtimeMs });
182
+ } catch {}
183
+ }
184
+ out.sort((a, b) => b.updated - a.updated);
185
+ return out;
186
+ },
187
+
188
+ // Restore a backup: copy the .bak.json content back into a normal session file
189
+ // (creating <name>-restored if the original still exists) and switch to it.
190
+ restoreBackup(file) {
191
+ try {
192
+ ensureDir();
193
+ let fname = String(file || '').trim();
194
+ if (!fname.endsWith('.bak.json')) {
195
+ if (!fname.endsWith('.json')) fname += '.bak.json';
196
+ }
197
+ const safe = sanitize(fname.replace(/\.bak\.json$/, ''));
198
+ const src = backupPathFor(safe);
199
+ if (!existsSync(src)) return null;
200
+ const msgs = JSON.parse(readFileSync(src, 'utf-8') || '[]');
201
+ if (!Array.isArray(msgs)) return null;
202
+ const base = sanitize(safe.replace(/\.\d{4}-\d{2}-\d{2}T[\d-]+$/, ''));
203
+ let target = base;
204
+ if (existsSync(pathFor(target))) target = `${base}-restored`;
205
+ writeFileSync(pathFor(target), JSON.stringify(msgs.slice(-MAX_MESSAGES), null, 2));
206
+ writeFileSync(ACTIVE_FILE, target);
207
+ return target;
208
+ } catch { return null; }
209
+ },
210
+
211
+ clear() {
212
+ try {
213
+ ensureDir();
214
+ writeFileSync(pathFor(this.activeName()), '[]');
215
+ } catch {}
216
+ },
217
+
218
+ hasHistory() {
219
+ return this.loadActive().length > 0;
220
+ },
221
+ };
package/src/ui/banner.js CHANGED
@@ -2,10 +2,14 @@ import { readFileSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { Branding } from './branding.js';
5
+ import { Terminal } from './terminal.js';
6
+ import { Config } from '../config.js';
5
7
 
6
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
9
 
8
10
  export function loadSystemPrompt() {
11
+ const custom = Config.get('systemPrompt');
12
+ if (custom) return custom;
9
13
  try {
10
14
  const jsonPath = join(__dirname, '..', 'prompts', 'system.json');
11
15
  const data = JSON.parse(readFileSync(jsonPath, 'utf-8'));
@@ -15,17 +19,24 @@ export function loadSystemPrompt() {
15
19
  }
16
20
  }
17
21
 
18
- export function showBanner(provider, model) {
19
- console.log(Branding.colors.primary + Branding.BANNER + Branding.colors.reset);
20
- console.log(
21
- ` ${Branding.colors.dim}Intelligent Terminal Assistant${Branding.colors.reset}`
22
- );
23
- console.log(
24
- ` ${Branding.colors.accent}Model:${Branding.colors.reset} ${model} ` +
25
- `${Branding.colors.accent}Engine:${Branding.colors.reset} ${provider}`
26
- );
27
- console.log(
28
- ` ${Branding.colors.dim}Type /help for commands, /quit to exit${Branding.colors.reset}`
29
- );
22
+ export function showBanner(providerName, model, providerDisplayName) {
23
+ const C = Branding.colors;
24
+ const width = 44;
25
+ const border = ` ╔${'═'.repeat(width)}╗`;
26
+ const visible = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
27
+ const pad = (s) => s + ' '.repeat(Math.max(1, width - visible(s).length));
28
+
29
+ console.log();
30
+ console.log(`${C.primary}${Branding.BANNER}${C.reset}`);
31
+ console.log(` ${C.bold}${C.accent}▸ Vierratale AI ▸ ${providerDisplayName || 'Cortex'} Engine${C.reset}`);
32
+ console.log();
33
+ console.log(border);
34
+ console.log(` ║${pad(' ' + C.bold + C.primary + 'Model' + C.reset)}║`);
35
+ console.log(` ║${pad(' ' + C.accent + model + C.reset)}║`);
36
+ console.log(` ║${pad(' ' + C.bold + C.primary + 'Engine' + C.reset)}║`);
37
+ console.log(` ║${pad(' ' + C.accent + (providerDisplayName || 'Cortex') + C.reset)}║`);
38
+ console.log(` ╚${'═'.repeat(width)}╝`);
39
+ console.log();
40
+ console.log(` ${C.dim}Type /help for commands · /quit to exit${C.reset}`);
30
41
  console.log();
31
42
  }
@@ -1,6 +1,6 @@
1
1
  export const Branding = {
2
2
  APP_NAME: 'VierrataleAI',
3
- VERSION: '0.1.0-beta.1',
3
+ VERSION: '0.1.0-beta.10',
4
4
 
5
5
  colors: {
6
6
  primary: '\x1b[38;2;124;58;237m',
@@ -14,14 +14,13 @@ export const Branding = {
14
14
  },
15
15
 
16
16
  BANNER: [
17
- '',
18
- ' ██╗ ██╗██╗██████╗ ███████╗',
19
- ' ██║ ██║██║██╔══██╗██╔════╝',
20
- ' ██║ ██║██║██████╔╝█████╗ ',
21
- ' ╚██╗ ██╔╝██║██╔══██╗██╔══╝ ',
22
- ' ╚████╔╝ ██║██████╔╝███████╗',
23
- ' ╚═══╝ ╚═╝╚═════╝ ╚══════╝',
24
- '',
17
+ ' _',
18
+ ' _ _ _ / \\ _______',
19
+ ' | | | | / _ \\\\_ _ /',
20
+ ' | | | |_ ___ _ __ _ __ __ _/ /_\\ \\ | | ',
21
+ ' | | | | |/ _ \\ \'__| \'__/ _` | _ | | | ',
22
+ ' \\ \\_/ / | __/ | | | | (_| | | | |_| |_ ',
23
+ ' \\___/|_|\\___|_| |_| \\__,_|_| |_/\\___/ ',
25
24
  ].join('\n'),
26
25
 
27
26
  USER_PROMPT: 'You',
@@ -0,0 +1,280 @@
1
+ import { Branding } from './branding.js';
2
+
3
+ const C = Branding.colors;
4
+
5
+ const visible = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
6
+
7
+ function padRight(text, width) {
8
+ const t = String(text ?? '');
9
+ return t + ' '.repeat(Math.max(0, width - visible(t)));
10
+ }
11
+
12
+ function wrapLines(text, width) {
13
+ const out = [];
14
+ const src = String(text ?? '');
15
+ for (const raw of src.split('\n')) {
16
+ if (!raw.trim()) {
17
+ out.push('');
18
+ continue;
19
+ }
20
+ const words = raw.split(/\s+/).filter(Boolean);
21
+ let cur = '';
22
+ for (const word of words) {
23
+ if (word.length >= width) {
24
+ if (cur) {
25
+ out.push(cur);
26
+ cur = '';
27
+ }
28
+ for (let i = 0; i < word.length; i += width) out.push(word.slice(i, i + width));
29
+ continue;
30
+ }
31
+ const next = cur ? `${cur} ${word}` : word;
32
+ if (visible(next) > width) {
33
+ if (cur) out.push(cur);
34
+ cur = word;
35
+ } else {
36
+ cur = next;
37
+ }
38
+ }
39
+ if (cur) out.push(cur);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function hardWrap(text, width) {
45
+ const out = [];
46
+ const src = String(text ?? '').replace(/\t/g, ' ');
47
+ for (const raw of src.split('\n')) {
48
+ if (visible(raw) <= width) {
49
+ out.push(raw);
50
+ } else {
51
+ for (let i = 0; i < raw.length; i += width) out.push(raw.slice(i, i + width));
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ // Column geometry: true-full width, then a centered column like opencode.
58
+ export function columnWidth(termWidth) {
59
+ const w = termWidth && termWidth > 44 ? termWidth : 96;
60
+ return Math.max(40, Math.min(100, w - 8));
61
+ }
62
+
63
+ export function centerPad(col, termWidth) {
64
+ const w = termWidth && termWidth > 44 ? termWidth : 96;
65
+ const pad = Math.max(0, Math.floor((w - col) / 2));
66
+ return ' '.repeat(pad);
67
+ }
68
+
69
+ function highlight(text) {
70
+ return String(text)
71
+ .replace(/\*\*([^*]+)\*\*/g, `${C.bold}$1${C.reset}`)
72
+ .replace(/`([^`]+)`/g, `${C.accent}$1${C.reset}`);
73
+ }
74
+
75
+ function splitBlocks(text) {
76
+ const blocks = [];
77
+ const re = /```([\w./+-]*)[^\n]*\n?([\s\S]*?)(?:```|$)/g;
78
+ let last = 0;
79
+ let m;
80
+ while ((m = re.exec(String(text)))) {
81
+ if (m.index > last) blocks.push({ code: false, text: text.slice(last, m.index) });
82
+ blocks.push({ code: true, lang: m[1] || 'code', content: m[2] });
83
+ last = m.index + m[0].length;
84
+ }
85
+ if (last < text.length) blocks.push({ code: false, text: text.slice(last) });
86
+ return blocks;
87
+ }
88
+
89
+ export class FrameThrottle {
90
+ constructor(render, min = 40) {
91
+ this.render = render;
92
+ this.min = min;
93
+ this.last = 0;
94
+ }
95
+
96
+ schedule() {
97
+ const now = performance.now();
98
+ if (now - this.last >= this.min) {
99
+ this.last = now;
100
+ this.render();
101
+ }
102
+ }
103
+
104
+ flush() {
105
+ this.render();
106
+ }
107
+ }
108
+
109
+ export class ChatUI {
110
+ constructor({ model = '', engine = '' } = {}) {
111
+ this.model = model;
112
+ this.engine = engine;
113
+ this.status = '';
114
+ this.notices = [];
115
+ this.streaming = null;
116
+ this.thinking = false;
117
+ this.compositor = null;
118
+ }
119
+
120
+ setCompositor(fn) {
121
+ this.compositor = fn;
122
+ }
123
+
124
+ width() {
125
+ const w = process.stdout.columns;
126
+ return w && w > 44 ? w : 96;
127
+ }
128
+
129
+ setModel(model, engine) {
130
+ this.model = model;
131
+ this.engine = engine;
132
+ }
133
+
134
+ setStatus(text) {
135
+ this.status = String(text ?? '');
136
+ }
137
+
138
+ setThinking(flag) {
139
+ this.thinking = !!flag;
140
+ this.streaming = null;
141
+ }
142
+
143
+ setStreaming(text) {
144
+ this.streaming = String(text ?? '');
145
+ this.thinking = false;
146
+ }
147
+
148
+ notify(text) {
149
+ if (text) this.notices.push(String(text));
150
+ }
151
+
152
+ clearNotices() {
153
+ this.notices = [];
154
+ }
155
+
156
+ _codeBox(lang, content, w) {
157
+ const out = [];
158
+ const c = C.success;
159
+ // The box lives inside the centered column and carries its own 4-space
160
+ // indent, so draw it at (w - 4) to stay inside the column.
161
+ const box = Math.max(8, w - 4);
162
+ const inner = Math.max(1, box - 8);
163
+ const head = ` ${c}╭─${C.reset} ${C.bold}${c}${lang || 'code'}${C.reset}${c} `;
164
+ const fill = Math.max(0, box - visible(head) - 1);
165
+ out.push(`${head}${'─'.repeat(fill)}╮${C.reset}`);
166
+ const lines = hardWrap(content, inner);
167
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
168
+ if (!lines.length) lines.push('');
169
+ for (const line of lines) {
170
+ out.push(` ${c}│${C.reset} ${padRight(line, inner)} ${c}│${C.reset}`);
171
+ }
172
+ out.push(` ${c}╰${'─'.repeat(Math.max(0, box - 6))}╯${C.reset}`);
173
+ return out;
174
+ }
175
+
176
+ _contentRows(content, w) {
177
+ const rows = [];
178
+ const width = Math.max(20, w - 4);
179
+ const blocks = splitBlocks(content);
180
+ for (const b of blocks) {
181
+ if (b.code) {
182
+ rows.push(...this._codeBox(b.lang, b.content, w));
183
+ } else {
184
+ const lines = wrapLines(b.text, width);
185
+ if (lines.length === 0) lines.push('');
186
+ for (const line of lines) {
187
+ rows.push(line.trim() === '' ? '' : ` ${highlight(line)}`);
188
+ }
189
+ }
190
+ }
191
+ return rows;
192
+ }
193
+
194
+ _centered(row) {
195
+ return `${centerPad(columnWidth(process.stdout.columns), process.stdout.columns)}${row}`;
196
+ }
197
+
198
+ // Banner drawn as part of the full clear+draw cycle so it can never be
199
+ // wiped by a later render (the old showBanner() was erased instantly).
200
+ _bannerRows(col) {
201
+ const out = [];
202
+ // Center the art as a BLOCK: pad every line to the widest one, then use a
203
+ // single shared left indent so the llama's rows stay glued together.
204
+ const art = Branding.BANNER.split('\n').map((l) => l.trimEnd());
205
+ const artW = Math.max(...art.map((l) => visible(l)));
206
+ const padL = Math.max(0, Math.floor((col - artW) / 2));
207
+ for (const line of art) {
208
+ out.push(`${' '.repeat(padL)}${C.primary}${line}${C.reset}`);
209
+ }
210
+ const center = (s) => {
211
+ const p = Math.max(0, Math.floor((col - visible(s)) / 2));
212
+ return `${' '.repeat(p)}${s}`;
213
+ };
214
+ out.push(center(`${C.bold}${C.accent}▸ ${Branding.APP_NAME} · ${this.model} · ${this.engine}${C.reset}`));
215
+ out.push('');
216
+ const box = Math.max(24, col - 6);
217
+ const padR = (s) => s + ' '.repeat(Math.max(1, box - visible(s)));
218
+ // Border is ` ┌` + (box+2) dashes + `┐` = box+6 wide, matching every row.
219
+ const borderLine = ` ${C.primary}┌${'─'.repeat(box + 2)}┐${C.reset}`;
220
+ out.push(center(borderLine));
221
+ out.push(center(` ${C.primary}│${C.reset} ${padR(' ' + C.bold + C.primary + 'Model' + C.reset)} ${C.primary}│${C.reset}`));
222
+ out.push(center(` ${C.primary}│${C.reset} ${padR(' ' + C.accent + this.model + C.reset)} ${C.primary}│${C.reset}`));
223
+ out.push(center(` ${C.primary}│${C.reset} ${padR(' ' + C.bold + C.primary + 'Engine' + C.reset)} ${C.primary}│${C.reset}`));
224
+ out.push(center(` ${C.primary}│${C.reset} ${padR(' ' + C.accent + this.engine + C.reset)} ${C.primary}│${C.reset}`));
225
+ out.push(center(` ${C.primary}└${'─'.repeat(box + 2)}┘${C.reset}`));
226
+ out.push('');
227
+ out.push(center(`${C.dim}Type /help for commands · /quit to exit${C.reset}`));
228
+ out.push('');
229
+ return out;
230
+ }
231
+
232
+ render(messages) {
233
+ const w = this.width();
234
+ const col = columnWidth(w);
235
+ const out = [];
236
+ out.push(`\x1b[2J\x1b[H`);
237
+ // Banner (opencode style header) at the top of the centered column.
238
+ out.push(...this._bannerRows(col));
239
+ if (this.status) out.push(this._centered(` ${C.dim}${this.status}${C.reset}`));
240
+ out.push('');
241
+
242
+ for (const m of messages || []) {
243
+ if (!m || m.hidden || !m.content) continue;
244
+ const title = m.role === 'user' ? 'You' : Branding.AI_PROMPT;
245
+ const color = m.role === 'user' ? C.accent : C.primary;
246
+ out.push(this._centered(` ${C.bold}${color}${title}${C.reset}`));
247
+ for (const row of this._contentRows(m.content, col)) {
248
+ out.push(this._centered(row));
249
+ }
250
+ out.push('');
251
+ }
252
+
253
+ if (this.thinking) {
254
+ out.push(this._centered(` ${C.bold}${C.primary}${Branding.AI_PROMPT}${C.reset}`));
255
+ out.push(this._centered(` ${C.dim}···${C.reset}`));
256
+ out.push('');
257
+ } else if (this.streaming !== null) {
258
+ out.push(this._centered(` ${C.bold}${C.primary}${Branding.AI_PROMPT}${C.reset}`));
259
+ const rows = this._contentRows(this.streaming, col);
260
+ for (const r of rows.length ? rows : [' ']) out.push(this._centered(r));
261
+ out.push('');
262
+ }
263
+
264
+ for (const n of this.notices) {
265
+ for (const line of wrapLines(n, col - 4)) out.push(this._centered(` ${C.dim}${line}${C.reset}`));
266
+ }
267
+ if (this.notices.length) out.push('');
268
+
269
+ // Keep the latest content pinned to the bottom of the terminal so streaming
270
+ // never re-scrolls through older messages (which caused a flash).
271
+ const isTty = process.stdout.isTTY && process.stdout.rows > 8;
272
+ const rows = isTty ? process.stdout.rows : 999;
273
+ const reserved = this.compositor ? Math.max(0, this.compositor.reservedRows()) : 0;
274
+ const available = Math.max(3, rows - reserved);
275
+ const lines = out.join('\n').split('\n').slice(1);
276
+ const body = lines.length > available ? lines.slice(lines.length - available) : lines;
277
+ process.stdout.write('\x1b[2J\x1b[H' + body.join('\n') + '\n');
278
+ if (this.compositor) this.compositor.draw();
279
+ }
280
+ }